From f1b02f77ebaafb30141849be4603c89ce8226cd5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 08:10:04 -0400 Subject: [PATCH] fix(swap): don't default a Convert from Dollars to an empty holding Converting from Dollars opens with Dollars as the destination, and SwapViewModel swaps in the largest other entry in tokenBalances. That list can carry zero-balance rows, because TokenState.withAccounts only adds holdings. Bugsnag 6ac36f88991833fd09434aad: a user holding only Dollars got a leftover USDC row as the destination, and all six attempts failed with INVALID_SWAP "mint not found". Filter the fallback to held currencies with a displayable balance, which is what the destination picker (SelectTokenViewModel) and iOS (Session.balances(for:)) already do. With no candidate the destination stays on Dollars. --- .../flipcash/app/tokens/ui/SwapViewModel.kt | 7 ++- .../app/tokens/ui/SwapViewModelErrorTest.kt | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 1ef2eb8a3d..b3995cb7f6 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -852,7 +852,12 @@ class SwapViewModel @Inject constructor( ) { balances, rate -> if (destination == source) { val fallback = if (source == Mint.usdf) { - balances.filter { it.token.address != source } + // Same candidates as the destination picker: held currencies with a + // displayable balance. The coordinator keeps zero-balance rows for + // accounts it no longer sees, and the server rejects a buy into one. + balances.filter { + it.token.address != source && it.balance.hasDisplayableValue + } .maxByOrNull { it.balance }?.token?.address } else { Mint.usdf diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt index c21e851380..3ebd41cc0d 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt @@ -518,4 +518,54 @@ class SwapViewModelErrorTest { // Rate.ignore would convert this to ~0 and clear every ceiling. Fail closed instead. assertTrue(vm.checkFundingAmount()) } + + private fun held(mint: Mint, usd: Double): TokenWithBalance { + val token = mockk(relaxed = true) { every { address } returns mint } + return mockk(relaxed = true) { + every { this@mockk.token } returns token + every { this@mockk.balance } returns Fiat(usd) + } + } + + private fun convertFromDollars(balances: List): SwapViewModel { + every { tokenCoordinator.tokenBalances } returns MutableStateFlow(balances) + // The destination resolver waits on a rate; a relaxed mock's flow never emits. + every { exchange.observePreferredRate() } returns + MutableStateFlow(Rate(fx = 1.0, currency = CurrencyCode.USD)) + val vm = createViewModel() + vm.dispatchEvent( + SwapViewModel.Event.OnPurposeChanged( + SwapPurpose.Convert(mint = Mint.usdf, destinationMint = Mint.usdf) + ) + ) + return vm + } + + @Test + fun `converting from Dollars defaults to the largest other holding`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val small = Mint(ByteArray(32) { 2 }.toList()) + val large = Mint(ByteArray(32) { 3 }.toList()) + val vm = convertFromDollars( + listOf(held(Mint.usdf, 5.0), held(small, 1.0), held(large, 3.0)) + ) + advanceUntilIdle() + + val purpose = assertIs(vm.stateFlow.value.purpose) + assertEquals(large, purpose.destinationMint) + } + + @Test + fun `converting from Dollars never defaults to an empty holding`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + // A zero-balance row the coordinator kept after the account left the server's list. + val stale = Mint(ByteArray(32) { 4 }.toList()) + val vm = convertFromDollars(listOf(held(Mint.usdf, 5.0), held(stale, 0.0))) + advanceUntilIdle() + + val purpose = assertIs(vm.stateFlow.value.purpose) + assertEquals(Mint.usdf, purpose.destinationMint) + } }