@@ -30,6 +30,7 @@ import com.flipcash.app.funding.PurchaseMethodController
3030import com.flipcash.app.funding.PurchaseMethodMetadata
3131import com.flipcash.app.tokens.TokenCoordinator
3232import com.flipcash.app.tokens.UsdcDepositSweep
33+ import com.flipcash.app.tokens.entryAffordableAfterFee
3334import com.flipcash.app.userflags.UserFlagsCoordinator
3435import com.flipcash.libs.coroutines.DispatcherProvider
3536import com.flipcash.services.internal.model.thirdparty.OnRampProvider
@@ -56,7 +57,6 @@ import com.getcode.opencode.model.financial.SendLimit
5657import com.getcode.opencode.model.financial.Token
5758import com.getcode.opencode.model.financial.TokenWithBalance
5859import com.getcode.opencode.model.financial.TokenWithLocalizedBalance
59- import com.getcode.opencode.model.financial.div
6060import com.getcode.opencode.model.financial.grossingUpLaunchpadSellFee
6161import com.getcode.opencode.model.financial.launchpadSellFee
6262import 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