From f1b02f77ebaafb30141849be4603c89ce8226cd5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 08:10:04 -0400 Subject: [PATCH 1/2] 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) + } } From 241131c0cc74e6b2e0fb7a816804751d46521b44 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 13:27:54 -0400 Subject: [PATCH 2/2] fix(tokens): disable Convert on Dollars when nothing else is held A Dollars-only user could open Convert on the Dollars screen, but the flow had no destination. With #1677 the destination stays on Dollars, and tapping confirm on the receipt left the spinner on forever: ProceedWithConversion returned early without resetting loading. The Convert tile on the Dollars screen is now disabled unless the user holds another currency with a displayable balance. TokenInfoViewModel.State.canConvert reads fundableBalanceMints, which uses the same hasDisplayableValue filter as the swap screen's default destination. The set is empty until balances hydrate, so Convert starts disabled. Other token screens always have Dollars as a destination and are unchanged. OnConvert checks canConvert too. ProceedWithConversion now clears loading on every early return, so a destination that disappears mid-flow can't strand the spinner. Bugsnag 6ac36f88991833fd09434aad --- .../components/info/CurrencyInfoContentV2.kt | 9 +- .../flipcash/app/tokens/ui/SwapViewModel.kt | 13 +- .../app/tokens/ui/TokenInfoViewModel.kt | 14 +- .../app/tokens/ui/SwapViewModelErrorTest.kt | 13 ++ .../ui/TokenInfoViewModelConvertTest.kt | 165 ++++++++++++++++++ 5 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt index eebf78ffa6..a22a5527c8 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt @@ -225,6 +225,7 @@ internal fun CurrencyInfoContentV2( isHeld = isHeld, tokenMint = token.address, shortfall = shortfall, + convertEnabled = state.canConvert, dispatch = dispatch, ) } @@ -349,6 +350,7 @@ private fun CurrencyActionTiles( isHeld: Boolean, tokenMint: Mint, shortfall: Fiat?, + convertEnabled: Boolean, dispatch: (TokenInfoViewModel.Event) -> Unit, modifier: Modifier = Modifier, ) { @@ -387,6 +389,7 @@ private fun CurrencyActionTiles( ) }, onClick = { dispatch(TokenInfoViewModel.Event.OnConvert) }, + enabled = convertEnabled, ) } @@ -462,19 +465,23 @@ private fun CurrencyActionTiles( } } +private const val DisabledActionTileAlpha = 0.38f + @Composable private fun ActionTile( label: String, icon: @Composable () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Column( modifier = modifier + .alpha(if (enabled) 1f else DisabledActionTileAlpha) .height(CodeTheme.dimens.staticGrid.x18) .clip(CodeTheme.shapes.extraSmall) .background(Color.White.copy(alpha = 0.1f)) - .clickable(onClick = onClick), + .clickable(enabled = enabled, onClick = onClick), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { 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 b3995cb7f6..cbaa12dd2c 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 @@ -1191,11 +1191,16 @@ class SwapViewModel @Inject constructor( .filterIsInstance() .onEach { dispatchEvent(Event.UpdateSellState(loading = true)) } .mapNotNull { event -> - val owner = userManager.accountCluster ?: return@mapNotNull null - stateFlow.value.purpose as? SwapPurpose.Convert ?: return@mapNotNull null - val source = stateFlow.value.tokenWithBalance?.token ?: return@mapNotNull null + val owner = userManager.accountCluster + val source = stateFlow.value.tokenWithBalance?.token val destination = stateFlow.value.destinationTokenWithBalance?.token - ?: return@mapNotNull null + if (owner == null || stateFlow.value.purpose !is SwapPurpose.Convert || + source == null || destination == null + ) { + // Nothing to submit; drop the spinner the confirm tap turned on. + dispatchEvent(Event.UpdateSellState(loading = false)) + return@mapNotNull null + } Triple(owner, source to destination, event.amount) } .onEach { (owner, tokens, amount) -> diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index 7f2fca304d..36346d02d4 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -78,6 +78,15 @@ class TokenInfoViewModel @Inject constructor( val isCashReserve: Boolean get() = token.dataOrNull?.address == Mint.usdf + + /** + * Whether Convert has somewhere to go. Out of any other currency, Dollars is always a valid + * destination. Out of Dollars, the user needs another currency with a displayable balance: + * the same `hasDisplayableValue` filter the swap screen uses to pick a default destination. + * [fundableBalanceMints] is empty until balances load, so Convert from Dollars starts off. + */ + val canConvert: Boolean + get() = mint != Mint.usdf || hasFundableBalance } sealed interface Event { @@ -341,8 +350,9 @@ class TokenInfoViewModel @Inject constructor( .filterIsInstance() .onEach { val mint = stateFlow.value.mint ?: return@onEach - // No gate, matching iOS: the tile only shows for a held currency, and the - // destination needs no balance of its own. + // The tile is disabled when this is false; checked again here so nothing else can + // open a Convert out of Dollars with no destination. + if (!stateFlow.value.canConvert) return@onEach // Dollars is the default landing spot; when Dollars *is* the source the swap // view model substitutes the user's largest other holding. 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 3ebd41cc0d..ff827ec4c5 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 @@ -568,4 +568,17 @@ class SwapViewModelErrorTest { val purpose = assertIs(vm.stateFlow.value.purpose) assertEquals(Mint.usdf, purpose.destinationMint) } + + @Test + fun `confirming a conversion without a destination clears the loading state`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = convertFromDollars(listOf(held(Mint.usdf, 5.0))) + advanceUntilIdle() + + vm.dispatchEvent(SwapViewModel.Event.OnConvertConfirmed) + advanceUntilIdle() + + assertFalse(vm.stateFlow.value.sellProgress.loading) + } } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt new file mode 100644 index 0000000000..ce80126f59 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt @@ -0,0 +1,165 @@ +package com.flipcash.app.tokens.ui + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.shareable.ShareSheetController +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.tokens.SwapPurpose +import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.AccountController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.DataSource +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenResult +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Convert on the Dollars screen is enabled only when the user holds another currency with a + * displayable balance. Bugsnag 6ac36f88991833fd09434aad: a Dollars-only user reached the convert + * flow with a leftover zero-balance USDC row as the only "other" holding. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TokenInfoViewModelConvertTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val tokenCoordinator = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val accountController = mockk(relaxed = true) + private val balances = MutableStateFlow>(emptyList()) + + private val other = Mint(ByteArray(32) { 2 }.toList()) + + 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) + } + } + + @Before + fun setUp() { + BottomBarManager.clear() + every { exchange.observePreferredRate() } returns + MutableStateFlow(Rate(fx = 1.0, currency = CurrencyCode.USD)) + every { accountController.observeHasAccountFor(any()) } returns MutableStateFlow(false) + every { tokenCoordinator.tokenBalances } returns balances + every { tokenCoordinator.balanceForToken(any()) } returns MutableStateFlow(Fiat.Zero) + every { tokenCoordinator.appreciationForToken(any()) } returns MutableStateFlow(Fiat.Zero) + coEvery { tokenCoordinator.getHistoricalMarketCapData(any(), any(), any()) } returns + Result.success(emptyList()) + listOf(Mint.usdf, other).forEach { mint -> + val token = mockk(relaxed = true) { every { address } returns mint } + every { tokenCoordinator.cachedToken(mint) } returns token + coEvery { tokenCoordinator.getTokenMetadata(mint) } returns + Result.success(TokenResult(token, DataSource.Cache)) + } + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun TestScope.openToken(mint: Mint): TokenInfoViewModel { + val vm = TokenInfoViewModel( + accountController = accountController, + tokenCoordinator = tokenCoordinator, + exchange = exchange, + shareController = mockk(relaxed = true), + resources = FakeResourceHelper(), + purchaseMethodController = mockk(relaxed = true), + feedCoordinator = mockk(relaxed = true), + dispatchers = TestDispatchers(testScheduler), + ) + vm.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint)) + advanceUntilIdle() + return vm + } + + private fun TestScope.openedRoutes(vm: TokenInfoViewModel): List { + val routes = mutableListOf() + vm.eventFlow.filterIsInstance() + .onEach { routes += it.screen } + .launchIn(backgroundScope) + return routes + } + + @Test + fun `Convert from Dollars is disabled while balances load`() = runTest(mainCoroutineRule.dispatcher) { + val vm = openToken(Mint.usdf) + assertFalse(vm.stateFlow.value.canConvert) + } + + @Test + fun `Convert from Dollars is disabled when only Dollars is held`() = runTest(mainCoroutineRule.dispatcher) { + balances.value = listOf(held(Mint.usdf, 5.0)) + val vm = openToken(Mint.usdf) + val routes = openedRoutes(vm) + + assertFalse(vm.stateFlow.value.canConvert) + vm.dispatchEvent(TokenInfoViewModel.Event.OnConvert) + advanceUntilIdle() + assertTrue(routes.isEmpty(), routes.toString()) + } + + @Test + fun `Convert from Dollars is disabled when the only other holding is empty`() = + runTest(mainCoroutineRule.dispatcher) { + balances.value = listOf(held(Mint.usdf, 5.0), held(other, 0.0)) + val vm = openToken(Mint.usdf) + assertFalse(vm.stateFlow.value.canConvert) + } + + @Test + fun `Convert from Dollars is enabled when another currency has a balance`() = + runTest(mainCoroutineRule.dispatcher) { + balances.value = listOf(held(Mint.usdf, 5.0), held(other, 2.0)) + val vm = openToken(Mint.usdf) + val routes = openedRoutes(vm) + + assertTrue(vm.stateFlow.value.canConvert) + vm.dispatchEvent(TokenInfoViewModel.Event.OnConvert) + advanceUntilIdle() + val route = assertIs(routes.single()) + assertEquals(SwapPurpose.Convert(mint = Mint.usdf, destinationMint = Mint.usdf), route.purpose) + } + + @Test + fun `Convert from another currency stays enabled with only Dollars held`() = + runTest(mainCoroutineRule.dispatcher) { + balances.value = listOf(held(Mint.usdf, 5.0)) + val vm = openToken(other) + assertTrue(vm.stateFlow.value.canConvert) + } +}