From 13af62a9775030b0d29a3fa53e4b1452383976eb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 13:33:53 -0400 Subject: [PATCH] fix(convert): disable Convert on Dollars when there is nothing to convert into Converting out of Dollars needs another holding to buy into. With none, ConvertAmountViewModel's "largest other holding" fallback lands back on Dollars, so the flow would start a Dollars-for-Dollars convert. Android hit the same gap (Bugsnag 6ac36f88991833fd09434aad). The Dollars screen's Convert tile is now disabled unless the destination picker would list at least one entry. Both read the new Session.convertDestinations(from:rate:), which is balances(for:) minus the source, so a zero or non-displayable holding counts as nothing. Convert on other currencies' screens is unchanged: Dollars is always a destination there. --- .../ConvertAmountViewModel.swift | 13 ++- .../Currency Info/CurrencyInfoContentV2.swift | 21 +++- .../Currency Info/CurrencyInfoViewModel.swift | 11 ++ Flipcash/Core/Session/Session.swift | 6 ++ .../ConvertDestinationAvailabilityTests.swift | 101 ++++++++++++++++++ 5 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 FlipcashTests/Convert/ConvertDestinationAvailabilityTests.swift diff --git a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift index 1e2cb0623..f757cd122 100644 --- a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift @@ -46,9 +46,10 @@ final class ConvertAmountViewModel { /// Destinations offered in the picker: Dollars plus every other held /// balance, excluding the source. Read by the destination picker sheet. var destinationOptions: [ExchangedBalance] { - let rate = ratesController.rateForBalanceCurrency() - return session.balances(for: rate) - .filter { $0.stored.mint != sourceBalance.mint } + session.convertDestinations( + from: sourceBalance.mint, + rate: ratesController.rateForBalanceCurrency() + ) } /// Display name of the current destination — "Dollars" for USDF. @@ -88,8 +89,10 @@ final class ConvertAmountViewModel { // Default to Dollars, except when converting *from* Dollars — then pick // the largest other holding so the destination is never the source. if sourceBalance.mint == .usdf { - let rate = ratesController.rateForBalanceCurrency() - let options = session.balances(for: rate).filter { $0.stored.mint != .usdf } + let options = session.convertDestinations( + from: .usdf, + rate: ratesController.rateForBalanceCurrency() + ) self.destinationMint = options .max(by: { $0.exchangedFiat.nativeAmount.value < $1.exchangedFiat.nativeAmount.value })? .stored.mint ?? .usdf diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index 7574439d6..01adb2d98 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -38,7 +38,7 @@ struct CurrencyInfoContentV2: View { /// Give — owned community tokens only. let onGive: () -> Void - /// Convert — sells this (non-USDF) currency into a chosen destination. + /// Convert — sells this currency into a chosen destination. let onConvert: () -> Void /// Buy — routes to the buy flow, whether or not the currency is held. let onBuy: () -> Void @@ -302,8 +302,14 @@ struct CurrencyInfoContentV2: View { if isUSDF { // Dollars is what the other currencies are bought with, so // there is nothing to buy more of — it withdraws instead. - // Its Convert goes out via a reserves buy. - actionTile("Convert", icon: .asset("IconArrowBottomTop"), action: onConvert) + // Its Convert goes out via a reserves buy, so it needs + // another holding to buy into. + actionTile( + "Convert", + icon: .asset("IconArrowBottomTop"), + isEnabled: viewModel.canConvert, + action: onConvert + ) actionTile("Withdraw", icon: .asset("IconArrowUp"), action: onWithdraw) } else { actionTile("Buy More", icon: .asset("IconArrowDown"), action: onBuy) @@ -315,7 +321,12 @@ struct CurrencyInfoContentV2: View { } } - private func actionTile(_ title: String, icon: TileIcon, action: @escaping () -> Void) -> some View { + private func actionTile( + _ title: String, + icon: TileIcon, + isEnabled: Bool = true, + action: @escaping () -> Void + ) -> some View { Button(action: action) { VStack(spacing: 12) { tileIcon(icon) @@ -330,6 +341,8 @@ struct CurrencyInfoContentV2: View { .clipShape(RoundedRectangle(cornerRadius: Metrics.buttonRadius, style: .continuous)) } .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.4) } @ViewBuilder private func tileIcon(_ icon: TileIcon) -> some View { diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift index 3e757abe4..71328bc26 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift @@ -84,6 +84,17 @@ class CurrencyInfoViewModel { ).nativeAmount } + /// Whether Convert has somewhere to go. Converting into Dollars is always + /// possible, so only the Dollars screen depends on other holdings — the + /// same list the convert destination picker shows. + var canConvert: Bool { + guard mint == .usdf else { return true } + return !session.convertDestinations( + from: .usdf, + rate: ratesController.rateForBalanceCurrency() + ).isEmpty + } + /// The absolute appreciation (or depreciation) of this currency's balance /// relative to its cost basis, converted to the display currency. /// Returns zero with `isPositive: true` when diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 1b51c5831..f18b2f837 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -157,6 +157,12 @@ class Session { } } + /// The holdings a conversion out of `sourceMint` can go into: every balance + /// ``balances(for:)`` shows, minus the source itself. + func convertDestinations(from sourceMint: PublicKey, rate: Rate) -> [ExchangedBalance] { + balances(for: rate).filter { $0.stored.mint != sourceMint } + } + /// The balances the wallet shows a card for: every token it holds, and /// Dollars only once they are worth showing. static func walletCardBalances(from balances: [ExchangedBalance]) -> [ExchangedBalance] { diff --git a/FlipcashTests/Convert/ConvertDestinationAvailabilityTests.swift b/FlipcashTests/Convert/ConvertDestinationAvailabilityTests.swift new file mode 100644 index 000000000..a4f746710 --- /dev/null +++ b/FlipcashTests/Convert/ConvertDestinationAvailabilityTests.swift @@ -0,0 +1,101 @@ +// +// ConvertDestinationAvailabilityTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@Suite("Convert availability on the currency info screen") +@MainActor +struct ConvertDestinationAvailabilityTests { + + private static let jeffySupply: UInt64 = 50_000 * 10_000_000_000 + private static let jeffyQuarks: UInt64 = 2_000 * 10_000_000_000 // ≈ $20 of curve value + + /// Reserves are seeded in every case so a disabled result comes from the + /// holdings, not from a curve that can't be priced. + private static func makeContainer(holdings: [SessionContainer.Holding]) async throws -> SessionContainer { + let container = try SessionContainer.makeTest(holdings: holdings) + + container.ratesController.configureTestRates( + balanceCurrency: .usd, + rates: [Rate(fx: 1, currency: .usd)] + ) + await container.ratesController.verifiedProtoService.saveRates([ + .freshRate(currencyCode: "USD", rate: 1) + ]) + await container.ratesController.verifiedProtoService.saveReserveStates([ + .freshReserve(mint: .jeffy, supplyFromBonding: jeffySupply) + ]) + + return container + } + + private static func canConvert(_ mint: PublicKey, in container: SessionContainer) -> Bool { + CurrencyInfoViewModel( + mint: mint, + session: container.session, + database: container.database, + ratesController: container.ratesController + ).canConvert + } + + private static let jeffy = MintMetadata.makeLaunchpad(address: .jeffy, supplyFromBonding: jeffySupply) + + @Test("Dollars only: Convert on the Dollars screen is disabled") + func dollarsOnly_isDisabled() async throws { + let container = try await Self.makeContainer(holdings: [ + SessionContainer.Holding(mint: .usdf, quarks: 10_000_000), + ]) + + #expect(Self.canConvert(.usdf, in: container) == false) + } + + @Test("Dollars plus a held currency: Convert is enabled and opens on that currency") + func dollarsAndHeldCurrency_isEnabled() async throws { + let container = try await Self.makeContainer(holdings: [ + SessionContainer.Holding(mint: .usdf, quarks: 10_000_000), + SessionContainer.Holding(mint: Self.jeffy, quarks: Self.jeffyQuarks), + ]) + + #expect(Self.canConvert(.usdf, in: container)) + + let usdf = try #require(container.session.balance(for: .usdf)) + let convert = ConvertAmountViewModel( + sourceBalance: usdf, + session: container.session, + ratesController: container.ratesController + ) + #expect(convert.destinationMint == .jeffy) + } + + @Test("Only other holding is a zero balance: Convert is disabled") + func zeroBalanceOtherHolding_isDisabled() async throws { + let container = try await Self.makeContainer(holdings: [ + SessionContainer.Holding(mint: .usdf, quarks: 10_000_000), + SessionContainer.Holding(mint: Self.jeffy, quarks: 0), + ]) + + #expect(Self.canConvert(.usdf, in: container) == false) + } + + @Test("No balances yet: Convert on the Dollars screen is disabled") + func noBalances_isDisabled() async throws { + let container = try await Self.makeContainer(holdings: []) + + #expect(Self.canConvert(.usdf, in: container) == false) + } + + @Test("Another currency's screen keeps Convert enabled with nothing else held") + func nonDollarsScreen_isEnabled() async throws { + let container = try await Self.makeContainer(holdings: [ + SessionContainer.Holding(mint: Self.jeffy, quarks: Self.jeffyQuarks), + ]) + + #expect(Self.canConvert(.jeffy, in: container)) + } +}