Skip to content

Commit ff05a2f

Browse files
authored
fix(convert): auto-correct the entry to the affordable max instead of prompting (#1302)
Amount entry is capped at the raw balance, but the fee comes out of that same balance — charged on top when the funding side is Dollars (no launchpad sale to skim), grossed up out of the sale otherwise. Entering the maximum therefore always overruns the balance by exactly the fee, and never by more. Both flows handled that badly. A Get funded by a token the fee pushed over balance priced the typed number and then popped an "Insufficient Balance After Fees" modal asking the user to confirm a correction. Converting the whole Dollars balance hard-failed at submit with "Insufficient Funds". Trim the entry itself to what the balance can actually fund, before anything is priced. The correction band is only ever the fee percentage, so there is nothing to confirm. Writing through the delegate (rather than only the receipt, as the modal did) keeps the amount screen agreeing with what the flow priced when the user navigates back. The maximum is floored to the currency's smallest unit — rounding it up would put the debit straight back over the balance ($10.11 at 1% corrects to $10.00, not $10.01). Buy now derives its fee shape once, feeding both the correction and the receipt's fee, so the two can't disagree.
1 parent c86d81d commit ff05a2f

10 files changed

Lines changed: 350 additions & 86 deletions

File tree

‎apps/flipcash/core/src/main/res/values/strings.xml‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -458,10 +458,6 @@
458458
<string name="subtitle_youPay">You Pay</string>
459459
<string name="subtitle_youReceive">You Receive</string>
460460

461-
<string name="title_insufficientBalanceAfterFees">Insufficient Balance After Fees</string>
462-
<string name="description_insufficientBalanceAfterFees">Switch to maximum amount, or go back and enter a smaller amount</string>
463-
<string name="action_buyMaximumAmount">Buy Maximum Amount</string>
464-
465461
<string name="action_give">Give</string>
466462
<string name="action_send">Send</string>
467463
<string name="title_transactionHistory">Transaction History</string>

‎apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,18 @@ class AmountEntryDelegate(
182182
updateAnimatedModel(backspace = false)
183183
}
184184

185+
/**
186+
* Replaces whatever is currently entered with [amount], as opposed to [prefill], which types
187+
* on top of the existing entry. For corrections the flow makes on the user's behalf — dropping
188+
* an entry to the maximum a balance can actually fund, say — so the amount screen keeps
189+
* agreeing with what the rest of the flow priced.
190+
*/
191+
fun setAmount(amount: Double) {
192+
numberInputHelper.fractionUnits = _state.value.currency.fractionUnits
193+
reset()
194+
prefill(amount)
195+
}
196+
185197
fun reset() {
186198
numberInputHelper.reset()
187199
_state.update { it.copy(amountAnimatedModel = AmountAnimatedInputUiModel()) }

‎apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ class AmountEntryDelegateTest {
120120
assertEquals(0.0, delegate.state.value.enteredAmount)
121121
}
122122

123+
@Test
124+
fun `setAmount replaces the current entry rather than appending to it`() = runTest {
125+
val delegate = createDelegate()
126+
delegate.onCurrencyChanged(usd)
127+
delegate.onNumber(1)
128+
delegate.onNumber(2)
129+
130+
delegate.setAmount(9.90)
131+
132+
assertEquals(9.90, delegate.state.value.enteredAmount)
133+
}
134+
123135
@Test
124136
fun `reset clears amount`() = runTest {
125137
val delegate = createDelegate()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.flipcash.app.tokens
2+
3+
import com.getcode.opencode.model.financial.Fiat
4+
import com.getcode.opencode.model.financial.grossingUpLaunchpadSellFee
5+
import com.getcode.opencode.model.financial.launchpadSellFee
6+
import com.getcode.opencode.model.financial.plus
7+
import com.getcode.opencode.model.financial.spendableUnderGrossedUpSellFee
8+
import com.getcode.opencode.model.financial.spendableUnderSellFeeOnTop
9+
10+
/**
11+
* Pure logic for trimming an entered amount down to what its funding [balance] can actually cover
12+
* once the fee is applied.
13+
*
14+
* Amount entry is capped at the raw balance, so the only thing that can push the debit past it is
15+
* the fee: charged on top, the debit is `entered × (1 + f)`; grossed up out of a launchpad sale, it
16+
* is `entered / (1 - f)`. Entering the maximum therefore always overruns — by the fee, and never by
17+
* more — which is why the correction is applied silently instead of being put to the user.
18+
*
19+
* Returns the corrected entry, floored to the currency's smallest unit so re-deriving the debit
20+
* from it never lands back over the balance, or `null` when the entry already fits and should stay
21+
* exactly as typed. [entered] is interpreted in [balance]'s currency.
22+
*/
23+
internal fun entryAffordableAfterFee(
24+
entered: Double,
25+
balance: Fiat,
26+
feeBps: Int,
27+
feeChargedOnTop: Boolean,
28+
): Fiat? {
29+
if (feeBps <= 0) return null
30+
31+
val enteredFiat = Fiat(entered, balance.currencyCode)
32+
val debit = if (feeChargedOnTop) {
33+
enteredFiat + enteredFiat.launchpadSellFee(feeBps)
34+
} else {
35+
enteredFiat.grossingUpLaunchpadSellFee(feeBps)
36+
}
37+
if (debit <= balance) return null
38+
39+
return if (feeChargedOnTop) {
40+
balance.spendableUnderSellFeeOnTop(feeBps)
41+
} else {
42+
balance.spendableUnderGrossedUpSellFee(feeBps)
43+
}.flooredToSmallestUnit()
44+
}

‎apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt‎

Lines changed: 64 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import com.flipcash.app.funding.PurchaseMethodController
3030
import com.flipcash.app.funding.PurchaseMethodMetadata
3131
import com.flipcash.app.tokens.TokenCoordinator
3232
import com.flipcash.app.tokens.UsdcDepositSweep
33+
import com.flipcash.app.tokens.entryAffordableAfterFee
3334
import com.flipcash.app.userflags.UserFlagsCoordinator
3435
import com.flipcash.libs.coroutines.DispatcherProvider
3536
import com.flipcash.services.internal.model.thirdparty.OnRampProvider
@@ -56,7 +57,6 @@ import com.getcode.opencode.model.financial.SendLimit
5657
import com.getcode.opencode.model.financial.Token
5758
import com.getcode.opencode.model.financial.TokenWithBalance
5859
import com.getcode.opencode.model.financial.TokenWithLocalizedBalance
59-
import com.getcode.opencode.model.financial.div
6060
import com.getcode.opencode.model.financial.grossingUpLaunchpadSellFee
6161
import com.getcode.opencode.model.financial.launchpadSellFee
6262
import com.getcode.opencode.model.financial.max
@@ -530,70 +530,43 @@ class SwapViewModel @Inject constructor(
530530
}
531531

532532
/**
533-
* Cross-currency buys pay the funding pool's sell fee on top of the entered amount, so
534-
* "You Pay" = amount + fee. If that total exceeds the funding token's wallet balance, the user
535-
* can't cover it — surface a modal (automatically, on landing the receipt) offering to drop to
536-
* the maximum affordable amount rather than letting the buy fail.
533+
* Drops the entry to the most the funding balance can cover once its fee is applied, in place.
537534
*
538-
* No-op when the funding side charges nothing — v1's USDF buy — and sub-cent rounding is
539-
* tolerated so applying the max doesn't immediately re-prompt. All comparisons are in USD
540-
* ([Fiat.convertingToUsdIfNeeded]), the common denominator across native currencies.
535+
* Entry is capped at the raw balance, so entering the maximum always overruns by exactly the
536+
* fee — whether it rides on top of the amount (Dollars, which has no pool to skim) or is
537+
* grossed up out of a launchpad sale. Rather than pricing a number the balance can't fund and
538+
* asking the user to confirm a correction, the entry itself is set to the true maximum, so the
539+
* amount screen and everything priced from it agree. Entries with room to spare are untouched.
540+
*
541+
* [balance] must be in the same currency the amount is entered in — the localized balance from
542+
* [State.tokenWithBalance], or a USD wallet balance converted through the preferred rate.
541543
*/
542-
private fun maybePromptInsufficientBalanceAfterFees(
543-
fundingToken: Token,
544-
payTotal: Fiat,
545-
rate: Rate,
544+
private fun correctEntryToAffordable(
545+
balance: Fiat,
546+
feeBps: Int,
547+
feeChargedOnTop: Boolean,
546548
) {
547-
// A v2 Get from Dollars charges the house rate on top instead of skimming a pool, so it
548-
// can overrun the balance just like a cross-currency buy. v1's USDF buy is still free.
549-
val feeChargedOnTop = fundingToken.address == Mint.usdf && stateFlow.value.isGet
550-
if (fundingToken.address == Mint.usdf && !feeChargedOnTop) return
551-
552-
val balanceUsd = tokenCoordinator.balanceForToken(fundingToken).convertingToUsdIfNeeded(rate)
553-
val payUsd = payTotal.convertingToUsdIfNeeded(rate)
554-
if ((payUsd - balanceUsd) <= balanceUsd.smallestUnit) return
555-
556-
BottomBarManager.showInfo(
557-
title = resources.getString(R.string.title_insufficientBalanceAfterFees),
558-
message = resources.getString(R.string.description_insufficientBalanceAfterFees),
559-
actions = listOf(
560-
BottomBarAction(
561-
text = resources.getString(R.string.action_buyMaximumAmount),
562-
style = BottomBarManager.BottomBarButtonStyle.Filled,
563-
) {
564-
// The most we can pay is the full balance, so the most we can receive (net of
565-
// the fee) is balance - fee(balance). This only corrects the displayed receipt
566-
// (via OnAmountAccepted, a pure state update): the buy already caps the pay amount
567-
// to the balance on-chain, and the entered amount is left untouched so returning
568-
// to amount entry preserves what the user typed. selectedAmount is passed through
569-
// unchanged.
570-
val balanceNative = balanceUsd.convertingTo(rate)
571-
val maxReceive = if (feeChargedOnTop) {
572-
// Fee on top: pay = receive × (1 + f), so the balance affords receive =
573-
// balance / (1 + f).
574-
balanceNative / (1.0 + DEFAULT_CONVERT_FEE_BPS / 10_000.0)
575-
} else {
576-
// Fee grossed up: pay = receive / (1 - f), so the balance affords receive =
577-
// balance × (1 - f).
578-
balanceNative -
579-
balanceNative.launchpadSellFee(fundingToken.launchpadMetadata?.sellFeeBps ?: 0)
580-
}
581-
val maxFee = balanceNative - maxReceive
582-
dispatchEvent(
583-
Event.OnAmountAccepted(
584-
amount = stateFlow.value.amountEntryState.selectedAmount,
585-
netTransferAmount = maxReceive,
586-
enteredAmount = maxReceive,
587-
feeAmount = maxFee,
588-
)
589-
)
590-
},
591-
BottomBarAction(
592-
text = resources.getString(R.string.action_dismiss),
593-
style = BottomBarManager.BottomBarButtonStyle.Text,
594-
),
595-
),
596-
)
549+
val corrected = entryAffordableAfterFee(
550+
entered = amountDelegate.state.value.enteredAmount,
551+
balance = balance,
552+
feeBps = feeBps,
553+
feeChargedOnTop = feeChargedOnTop,
554+
) ?: return
555+
556+
amountDelegate.setAmount(corrected.decimalValue)
557+
}
558+
559+
/**
560+
* The fee a buy funded by [fundingToken] charges, as bps and whether it rides on top of the
561+
* entered amount. A v2 Get from Dollars pays the flat house rate on top (no pool to skim);
562+
* every other currency has its pool's sell fee grossed up into the debit; v1's reserves buy is
563+
* free.
564+
*/
565+
private fun buyFeeFor(fundingToken: Token): Pair<Int, Boolean> = when {
566+
fundingToken.address != Mint.usdf ->
567+
(fundingToken.launchpadMetadata?.sellFeeBps ?: 0) to false
568+
stateFlow.value.isGet -> DEFAULT_CONVERT_FEE_BPS to true
569+
else -> 0 to false
597570
}
598571

599572
private suspend fun transactionLimit(): Fiat {
@@ -971,6 +944,17 @@ class SwapViewModel @Inject constructor(
971944
is SwapPurpose.Convert -> {
972945
val rate = exchange.preferredRate
973946
val sourceWithBalance = stateFlow.value.tokenWithBalance ?: return@onEach
947+
// Converting out of Dollars charges the fee on top, so the whole balance
948+
// can't be converted — trim the entry to the real maximum instead of
949+
// pricing a debit the balance can't cover. Every other direction has its
950+
// fee skimmed out of the entry, which can't overrun.
951+
if (stateFlow.value.isConvertingFromDollars) {
952+
correctEntryToAffordable(
953+
balance = sourceWithBalance.balance,
954+
feeBps = convertFeeBps,
955+
feeChargedOnTop = true,
956+
)
957+
}
974958
// The pin is taken against the total debited, which is the entered amount
975959
// plus the fee when converting out of Dollars (see [totalDebitAmount]).
976960
val amountFiat = verifiedFiatCalculator.compute(
@@ -1238,8 +1222,19 @@ class SwapViewModel @Inject constructor(
12381222
.map { it.mint }
12391223
.mapNotNull {
12401224
val token = tokenCoordinator.getTokenMetadata(it).getOrNull()?.token ?: return@mapNotNull null
1241-
val delegateState = amountDelegate.state.value
12421225
val rate = exchange.preferredRate
1226+
1227+
// The fee comes out of the same balance that funds the buy, so entering the whole
1228+
// balance can never cover both. Trim the entry to the real maximum before pricing
1229+
// anything, so the receipt and the amount screen show the same number.
1230+
val (feeBps, feeChargedOnTop) = buyFeeFor(token)
1231+
correctEntryToAffordable(
1232+
balance = tokenCoordinator.balanceForToken(token).convertingTo(rate),
1233+
feeBps = feeBps,
1234+
feeChargedOnTop = feeChargedOnTop,
1235+
)
1236+
1237+
val delegateState = amountDelegate.state.value
12431238
val amountFiat = verifiedFiatCalculator.compute(
12441239
amount = Fiat(delegateState.enteredAmount, rate.currency),
12451240
token = token,
@@ -1260,22 +1255,17 @@ class SwapViewModel @Inject constructor(
12601255
balance = nativeAmount
12611256
)
12621257

1263-
val exchangeFee = if (token.address == Mint.usdf) {
1258+
val exchangeFee = if (feeChargedOnTop) {
12641259
// Dollars has no launchpad sale to skim, so a v2 Get pays the flat house rate
12651260
// *on top* of the entered amount — the same convention Convert-from-Dollars
1266-
// uses. The v1 reserves buy stays free.
1267-
if (stateFlow.value.isGet) {
1268-
nativeAmount.launchpadSellFee(DEFAULT_CONVERT_FEE_BPS)
1269-
} else {
1270-
0.toFiat((rate.currency))
1271-
}
1261+
// uses.
1262+
nativeAmount.launchpadSellFee(feeBps)
12721263
} else {
12731264
// The pool's sell fee is grossed up on top of the entered amount, so the
12741265
// fee is (amount / (1 - fee)) - amount. Uses the funding pool's own bps,
1275-
// matching the gross-up applied at buy time in OnBuyConfirmed.
1276-
nativeAmount.grossingUpLaunchpadSellFee(
1277-
token.launchpadMetadata?.sellFeeBps ?: 0,
1278-
) - nativeAmount
1266+
// matching the gross-up applied at buy time in OnBuyConfirmed. v1's reserves
1267+
// buy charges nothing, so its zero bps nets out to zero here.
1268+
nativeAmount.grossingUpLaunchpadSellFee(feeBps) - nativeAmount
12791269
}
12801270

12811271
dispatchEvent(
@@ -1291,14 +1281,6 @@ class SwapViewModel @Inject constructor(
12911281
)
12921282
)
12931283
dispatchEvent(Event.OnFundingTokenResolved(tokenWithBalance))
1294-
1295-
// Landing the receipt: if the fee pushes "You Pay" past the funding token's
1296-
// wallet balance, auto-offer to drop to the maximum affordable amount.
1297-
maybePromptInsufficientBalanceAfterFees(
1298-
fundingToken = token,
1299-
payTotal = nativeAmount + exchangeFee,
1300-
rate = rate,
1301-
)
13021284
}.onEach {
13031285

13041286
}.launchIn(viewModelScope)

0 commit comments

Comments
 (0)