Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,10 @@ internal fun PhantomTransactionConfirmationScreen() {
viewModel.eventFlow
.filterIsInstance<SwapViewModel.Event.PhantomNavigateToProcessing>()
.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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SwapViewModel.Event.PhantomNavigateToProcessing>()
.onEach {
flowNavigator.navigateTo(SwapStep.Processing)
flowNavigator.replaceStack(listOf(SwapStep.Processing))
}.launchIn(this)
}

Expand All @@ -157,7 +160,11 @@ internal fun SwapEntryScreen(
LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<SwapViewModel.Event.OpenScreen>()
.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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<DemoResult>>(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)
Expand Down
Loading