From fdd6d6e2eb7edcf0fababe087f765732938918ae Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 29 Jul 2026 15:20:54 -0400 Subject: [PATCH] fix(tokens): correct INR Phantom add-money flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Add money via Phantom" flow was broken for non-USD (e.g. INR) users and mis-navigated on the buy-shortfall path. - Interpret the Phantom add-money amount in the user's selected currency. The confirm path forced exchange.rateForUsd(), labelling e.g. ₹500 as $500, so compute() skipped the FX conversion and produced an underlyingTokenAmount ~83x too large — checkBalances then rejected the deposit as InsufficientUsdc. Now uses exchange.preferredRate. - Localize the "Enter up to X" max-balance hint. The AddMoney max came from USD-denominated token balances; convert it to the preferred currency so the hint (and the over-max comparison) match what the user is typing. - Guard purchaseMethodController.selections against the amount-less deposit-sheet selection (the CoinbaseOnRamp branch already did this). Without it, tapping Phantom in the buy-shortfall sheet double-navigated the buy flow to PhantomConnect, stranding the user on the connect prompt after the add-money flow finished. - Replace the buy flow with the add-money route so finishing the top-up returns to the token screen (origin) instead of the abandoned amount-to-buy step. - Make the Phantom Processing step terminal via replaceStack, with InnerFlowNavigator unit tests covering the back-stack behavior. --- .../app/tokens/PhantomWalletScreens.kt | 5 ++- .../flipcash/app/tokens/SwapEntryScreen.kt | 11 +++++- .../flipcash/app/tokens/ui/SwapViewModel.kt | 35 +++++++++++++----- .../kotlin/com/getcode/navigation/TestNav.kt | 1 + .../navigation/flow/InnerFlowNavigatorTest.kt | 37 +++++++++++++++++++ 5 files changed, 76 insertions(+), 13 deletions(-) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt index 7b27d8e1b..8a23ae83b 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt @@ -163,7 +163,10 @@ internal fun PhantomTransactionConfirmationScreen() { viewModel.eventFlow .filterIsInstance() .onEach { - flowNavigator.navigateTo(SwapStep.Processing) + // Replace the whole inner stack so Processing is terminal: the connect prompt + // and confirm steps are cleared, so leaving Processing exits the flow back to + // the origin (e.g. token info) instead of the buried Phantom connect prompt. + flowNavigator.replaceStack(listOf(SwapStep.Processing)) }.launchIn(this) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt index 9058ba646..d85aa98ed 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt @@ -138,11 +138,14 @@ internal fun SwapEntryScreen( // Deposit-first "Add Money" via Phantom enters the amount here after connecting; // confirming it signs the transaction and advances straight to processing. + // Replace the whole inner stack so Processing is terminal: the connect prompt and + // amount-entry steps are cleared, so leaving Processing exits the flow back to the + // origin (e.g. token info) instead of surfacing the buried Phantom connect prompt. LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() .onEach { - flowNavigator.navigateTo(SwapStep.Processing) + flowNavigator.replaceStack(listOf(SwapStep.Processing)) }.launchIn(this) } @@ -157,7 +160,11 @@ internal fun SwapEntryScreen( LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .onEach { navigator.push(it.screen) } + // OpenScreen here is only ever the buy-shortfall add-money route. Replace the buy flow + // with it (rather than stacking on top) so that finishing the add-money flow returns + // the user to the token screen (the origin) instead of the abandoned amount-to-buy step + // — matching how add-money launched directly from the token screen already behaves. + .onEach { navigator.replace(it.screen) } .launchIn(this) } 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 718e8d742..6158f1bcc 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 @@ -166,13 +166,19 @@ class SwapViewModel @Inject constructor( stateFlow.map { it.amountEntryState.maxToAdd }, stateFlow.map { it.tokenBalance }, tokenCoordinator.tokenBalances.distinctUntilChanged(), - ) { purpose, maxToAdd, tokenBalance, tokenBalances -> + exchange.observePreferredRate(), + ) { purpose, maxToAdd, tokenBalance, tokenBalances, rate -> when (purpose) { is SwapPurpose.Buy -> { val limit = maxToAdd?.let { Fiat(it.first, it.second) } val mustAddMoney = featureFlags.get(FeatureFlag.AddMoneyUX) if (mustAddMoney && !stateFlow.value.isAddingMoney) { - val maxTokenBalance = tokenBalances.maxOf { it.balance } + // tokenBalances are USD-denominated; convert to the user's preferred + // currency so the "Enter up to X" hint is localized and the over-max + // comparison (which relabels the entered amount with max.currencyCode) + // happens in the same currency the user is typing in. `limit` is already + // in the selected currency, so both sides of min() now agree. + val maxTokenBalance = tokenBalances.maxOf { it.balance }.convertingTo(rate) return@combine limit?.let { min(maxTokenBalance, it) } ?: maxTokenBalance } @@ -743,7 +749,12 @@ class SwapViewModel @Inject constructor( .onEach { try { val delegateState = amountDelegate.state.value - val rate = exchange.rateForUsd() + // Use the user's preferred (selected) currency rate so the entered amount is + // interpreted in the currency shown in the UI. Forcing rateForUsd() here labelled + // a non-USD amount (e.g. ₹500) as USD, so compute() skipped the FX conversion and + // produced an underlyingTokenAmount ~83× too large — which made checkBalances + // reject the deposit as InsufficientUsdc for INR (and any non-USD) users. + val rate = exchange.preferredRate val amountFiat = verifiedFiatCalculator.compute( amount = Fiat(delegateState.enteredAmount, rate.currency), token = Token.usdf, @@ -1137,14 +1148,16 @@ class SwapViewModel @Inject constructor( purchaseMethodController.selections .onEach { (method, metadata) -> + // The add-money deposit sheet (presentDepositOptions) emits an amount-less + // selection and owns its own navigation (it returns the route to open). Only react + // to an actual purchase, which carries a purchaseAmount. Reacting to the amount-less + // deposit selection would double-navigate: Coinbase would push verification twice, + // and Phantom would send this (buy) flow to PhantomConnect *underneath* the pushed + // add-money flow — so finishing the add-money flow strands the user on the connect + // prompt instead of returning to the token screen. + val amount = metadata.purchaseAmount ?: return@onEach when (method) { PurchaseMethod.CoinbaseOnRamp -> { - // The add-money deposit sheet (presentDepositOptions) emits a Plain - // selection with no amount and owns its own email/phone gate + navigation. - // Only react to an actual purchase (which carries an amount) here — reacting - // to the deposit selection would push the verification screen a second time. - val amount = metadata.purchaseAmount ?: return@onEach - analytics.buttonTapped(Button.TokenBuyWithCoinbase) dispatchEvent(Event.CoinbaseSelected) @@ -1228,7 +1241,9 @@ class SwapViewModel @Inject constructor( return@mapNotNull AppRoute.Transfers.Deposit(showOtherOptions = false) } // present the add-money/deposit sheet; navigate to whatever the user picks. - // popToRoot = false so the user returns to the in-progress buy screen. + // popToRoot = false: the chosen add-money route replaces this buy flow (see the + // OpenScreen handler in SwapEntryScreen), so finishing it pops a single level back + // to the token screen — no full pop-to-root needed. purchaseMethodController.presentDepositOptions(popToRoot = false) } .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } diff --git a/ui/navigation/src/test/kotlin/com/getcode/navigation/TestNav.kt b/ui/navigation/src/test/kotlin/com/getcode/navigation/TestNav.kt index 7c9712b77..0a2110fc6 100644 --- a/ui/navigation/src/test/kotlin/com/getcode/navigation/TestNav.kt +++ b/ui/navigation/src/test/kotlin/com/getcode/navigation/TestNav.kt @@ -21,6 +21,7 @@ data object DemoSheet : Sheet // --- Flow steps --- data object StepOne : FlowStep data object StepTwo : FlowStep +data object StepThree : FlowStep // --- A flow-host route that returns DemoResult --- @Parcelize diff --git a/ui/navigation/src/test/kotlin/com/getcode/navigation/flow/InnerFlowNavigatorTest.kt b/ui/navigation/src/test/kotlin/com/getcode/navigation/flow/InnerFlowNavigatorTest.kt index fdbb1c3e8..01f86a2d5 100644 --- a/ui/navigation/src/test/kotlin/com/getcode/navigation/flow/InnerFlowNavigatorTest.kt +++ b/ui/navigation/src/test/kotlin/com/getcode/navigation/flow/InnerFlowNavigatorTest.kt @@ -5,6 +5,7 @@ import com.getcode.navigation.AppRegion import com.getcode.navigation.DemoResult import com.getcode.navigation.RecordingFlowScope import com.getcode.navigation.StepOne +import com.getcode.navigation.StepThree import com.getcode.navigation.StepTwo import com.getcode.navigation.testNavigator import com.getcode.navigation.core.CodeNavigator @@ -86,6 +87,42 @@ class InnerFlowNavigatorTest { assertEquals(listOf(AppHome), h.root.backStack.toList()) } + // Reproduces the Phantom "Add money" swap flow. The inner stack has the Phantom connect + // prompt (StepOne) buried beneath the amount-entry step (StepTwo). Advancing to Processing + // (StepThree) via replaceStack must collapse the buried steps so Processing becomes the flow + // root — backing out of it then exits the flow (the host pops to the origin, e.g. token info) + // rather than surfacing the buried connect prompt. + @Test + fun `replaceStack to a single step makes it terminal so back exits the flow to origin`() { + val h = Harness(StepOne, StepTwo) // StepOne = PhantomConnect, StepTwo = Entry + + h.nav.replaceStack(listOf(StepThree)) // StepThree = Processing + + assertEquals(listOf(StepThree), h.flow.backStack.toList()) + assertFalse(h.nav.canGoBack) + + val couldGoBack = h.nav.back() + assertFalse(couldGoBack) + assertEquals(listOf>(FlowExitReason.BackedOutOfRoot), h.exits) + } + + // Documents the pre-fix behavior: a plain navigateTo(Processing) leaves the connect prompt + // buried, so backing out of Processing returns into the flow (Entry, then Connect) instead of + // exiting to the origin. This is the bug the replaceStack fix resolves. + @Test + fun `plain navigateTo leaves earlier steps buried so back does not exit the flow`() { + val h = Harness(StepOne, StepTwo) + + h.nav.navigateTo(StepThree) + + assertEquals(listOf(StepOne, StepTwo, StepThree), h.flow.backStack.toList()) + + val couldGoBack = h.nav.back() + assertTrue(couldGoBack) + assertEquals(listOf(StepOne, StepTwo), h.flow.backStack.toList()) + assertEquals(emptyList(), h.exits) // flow not exited; origin not reached + } + @Test fun `navigate with a FlowStep lands on the flow stack (callers must pass app routes)`() { val h = Harness(StepOne)