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 1ef2eb8a3..b3995cb7f 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 c21e85138..3ebd41cc0 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) + } }