From 0c2858cf85a2deb1a394e83e7b9b9a1771083fcd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 28 Jul 2026 19:59:42 -0400 Subject: [PATCH] fix(tokens): allow buying a currency with a non-USDF balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buy gate on the token info screen checked only the USDF reserve balance (hasReserves), so a user holding another Flipcash currency but no USDF was routed to the Add Money flow instead of the swap screen — even though the swap flow already supports funding a buy with any held currency. Gate on whether the user holds a displayable balance in some token other than the one being bought (hasFundableBalance), mirroring the swap token picker's own eligibility rule. Replaces the USDF-only reserves observation with an all-token balances observation. --- .../app/tokens/ui/TokenInfoViewModel.kt | 40 +++++++------ .../tokens/ui/TokenInfoViewModelStateTest.kt | 56 +++++++++++++++++++ 2 files changed, 75 insertions(+), 21 deletions(-) create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelStateTest.kt 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 5df23ab7f..fb82c0628 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 @@ -69,13 +69,14 @@ class TokenInfoViewModel @Inject constructor( val historicalMarketCapData: Map>> = emptyMap(), val selectedPeriod: Period = Period.All, val canGiveUsdf: Boolean = false, - val reservesBalance: LocalFiat = LocalFiat.Zero, + val fundableBalanceMints: Set = emptySet(), ) { val canSell: Boolean get() = balance.underlyingTokenAmount.valueNonZero() - val hasReserves: Boolean - get() = reservesBalance.underlyingTokenAmount.valueNonZero() + /** True when the user holds a displayable balance in some token other than [mint], which could fund a buy of it. */ + val hasFundableBalance: Boolean + get() = fundableBalanceMints.any { it != mint } val isCashReserve: Boolean get() = token.dataOrNull?.address == Mint.usdf @@ -96,7 +97,7 @@ class TokenInfoViewModel @Inject constructor( data class OnMarketCapPeriodSelected(val period: Period) : Event data class OnBalanceUpdated(val balance: LocalFiat) : Event - data class OnReservesBalanceUpdated(val balance: LocalFiat): Event + data class OnFundableBalancesUpdated(val mints: Set): Event data class OnAppreciatedEnabled(val enabled: Boolean) : Event data class OnTransactionHistoryEnabled(val enabled: Boolean): Event data class OnAppreciationUpdated(val amount: LocalFiat?) : Event @@ -181,18 +182,15 @@ class TokenInfoViewModel @Inject constructor( } .launchIn(viewModelScope) - combine( - tokenCoordinator.observeReservesBalance(), - exchange.observePreferredRate(), - ) { balance, rate -> - LocalFiat( - usdf = balance, - nativeAmount = balance.convertingTo(rate), - mint = Mint.usdf, - ) - }.onEach { - dispatchEvent(Event.OnReservesBalanceUpdated(it)) - }.launchIn(viewModelScope) + tokenCoordinator.tokenBalances + .map { balances -> + balances.filter { it.balance.hasDisplayableValue } + .map { it.token.address } + .toSet() + } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnFundableBalancesUpdated(it)) } + .launchIn(viewModelScope) eventFlow .filterIsInstance() @@ -289,11 +287,11 @@ class TokenInfoViewModel @Inject constructor( .filterIsInstance() .onEach { event -> val mint = stateFlow.value.mint ?: return@onEach - // Buying requires cash reserves to fund the swap; if there are none, - // send the user to deposit options first instead of the swap screen. - // This check is only done if AddMoneyUx FF is enabled. + // A buy can be funded by USDF reserves or any other currency the user + // holds; only send them to deposit options first when they have nothing + // to fund the swap with. This check is only done if AddMoneyUX is enabled. val addMoney = features.get(FeatureFlag.AddMoneyUX) - if (!stateFlow.value.hasReserves && addMoney) { + if (!stateFlow.value.hasFundableBalance && addMoney) { BottomBarManager.showInfo( title = resources.getString(R.string.title_noBalanceYet), message = resources.getString(R.string.description_noBalanceYetToBuy), @@ -345,7 +343,7 @@ class TokenInfoViewModel @Inject constructor( is Event.OnTokenChanged -> { state -> state.copy(token = event.token) } is Event.OnMarketCapChanged -> { state -> state.copy(marketCap = event.mcap) } is Event.OnBalanceUpdated -> { state -> state.copy(balance = event.balance) } - is Event.OnReservesBalanceUpdated -> { state -> state.copy(reservesBalance = event.balance) } + is Event.OnFundableBalancesUpdated -> { state -> state.copy(fundableBalanceMints = event.mints) } is Event.OnAppreciationUpdated -> { state -> state.copy(appreciation = event.amount) } is Event.ExpandDescription -> { state -> state.copy(descriptionExpanded = event.expand) } is Event.PresentDepositOptions -> { state -> state } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelStateTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelStateTest.kt new file mode 100644 index 000000000..b77907d13 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelStateTest.kt @@ -0,0 +1,56 @@ +package com.flipcash.app.tokens.ui + +import com.getcode.solana.keys.Mint +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Covers the buy-gate predicate [TokenInfoViewModel.State.hasFundableBalance]. + * + * Regression: buying a currency was gated on USDF reserves only, so a user + * holding another Flipcash currency (but no USDF) was wrongly sent to the + * Add Money flow instead of the swap screen, where that currency can fund + * the purchase. + */ +class TokenInfoViewModelStateTest { + + private val reduce = TokenInfoViewModel.Companion.updateStateForEvent + + private val target = Mint(ByteArray(32) { 1 }.toList()) + private val other = Mint(ByteArray(32) { 2 }.toList()) + + @Test + fun `hasFundableBalance is false when no balances`() { + val state = TokenInfoViewModel.State(mint = target) + assertFalse(state.hasFundableBalance) + } + + @Test + fun `hasFundableBalance is false when only the target token has a balance`() { + val state = TokenInfoViewModel.State(mint = target, fundableBalanceMints = setOf(target)) + assertFalse(state.hasFundableBalance) + } + + @Test + fun `hasFundableBalance is true when another currency has a balance`() { + // The regression: no USDF, but a non-target currency can fund the buy. + val state = TokenInfoViewModel.State(mint = target, fundableBalanceMints = setOf(other)) + assertTrue(state.hasFundableBalance) + } + + @Test + fun `hasFundableBalance is true when USDF reserves are present`() { + val state = TokenInfoViewModel.State(mint = target, fundableBalanceMints = setOf(Mint.usdf)) + assertTrue(state.hasFundableBalance) + } + + @Test + fun `OnFundableBalancesUpdated stores the fundable mints`() { + val state = reduce( + TokenInfoViewModel.Event.OnFundableBalancesUpdated(setOf(other)) + )(TokenInfoViewModel.State()) + assertEquals(setOf(other), state.fundableBalanceMints) + } +}