diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 37a603b5d1..ece2644316 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -458,10 +458,6 @@ You Pay You Receive - Insufficient Balance After Fees - Switch to maximum amount, or go back and enter a smaller amount - Buy Maximum Amount - Give Send Transaction History diff --git a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt index cf2a5cd820..4e47c3bc23 100644 --- a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt +++ b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt @@ -182,6 +182,18 @@ class AmountEntryDelegate( updateAnimatedModel(backspace = false) } + /** + * Replaces whatever is currently entered with [amount], as opposed to [prefill], which types + * on top of the existing entry. For corrections the flow makes on the user's behalf — dropping + * an entry to the maximum a balance can actually fund, say — so the amount screen keeps + * agreeing with what the rest of the flow priced. + */ + fun setAmount(amount: Double) { + numberInputHelper.fractionUnits = _state.value.currency.fractionUnits + reset() + prefill(amount) + } + fun reset() { numberInputHelper.reset() _state.update { it.copy(amountAnimatedModel = AmountAnimatedInputUiModel()) } diff --git a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt index 807d5846ac..edb48e489c 100644 --- a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt +++ b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt @@ -120,6 +120,18 @@ class AmountEntryDelegateTest { assertEquals(0.0, delegate.state.value.enteredAmount) } + @Test + fun `setAmount replaces the current entry rather than appending to it`() = runTest { + val delegate = createDelegate() + delegate.onCurrencyChanged(usd) + delegate.onNumber(1) + delegate.onNumber(2) + + delegate.setAmount(9.90) + + assertEquals(9.90, delegate.state.value.enteredAmount) + } + @Test fun `reset clears amount`() = runTest { val delegate = createDelegate() diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/FeeAffordableEntry.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/FeeAffordableEntry.kt new file mode 100644 index 0000000000..ff7481e37a --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/FeeAffordableEntry.kt @@ -0,0 +1,44 @@ +package com.flipcash.app.tokens + +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.grossingUpLaunchpadSellFee +import com.getcode.opencode.model.financial.launchpadSellFee +import com.getcode.opencode.model.financial.plus +import com.getcode.opencode.model.financial.spendableUnderGrossedUpSellFee +import com.getcode.opencode.model.financial.spendableUnderSellFeeOnTop + +/** + * Pure logic for trimming an entered amount down to what its funding [balance] can actually cover + * once the fee is applied. + * + * Amount entry is capped at the raw balance, so the only thing that can push the debit past it is + * the fee: charged on top, the debit is `entered × (1 + f)`; grossed up out of a launchpad sale, it + * is `entered / (1 - f)`. Entering the maximum therefore always overruns — by the fee, and never by + * more — which is why the correction is applied silently instead of being put to the user. + * + * Returns the corrected entry, floored to the currency's smallest unit so re-deriving the debit + * from it never lands back over the balance, or `null` when the entry already fits and should stay + * exactly as typed. [entered] is interpreted in [balance]'s currency. + */ +internal fun entryAffordableAfterFee( + entered: Double, + balance: Fiat, + feeBps: Int, + feeChargedOnTop: Boolean, +): Fiat? { + if (feeBps <= 0) return null + + val enteredFiat = Fiat(entered, balance.currencyCode) + val debit = if (feeChargedOnTop) { + enteredFiat + enteredFiat.launchpadSellFee(feeBps) + } else { + enteredFiat.grossingUpLaunchpadSellFee(feeBps) + } + if (debit <= balance) return null + + return if (feeChargedOnTop) { + balance.spendableUnderSellFeeOnTop(feeBps) + } else { + balance.spendableUnderGrossedUpSellFee(feeBps) + }.flooredToSmallestUnit() +} 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 7c3e2a5aa7..c4e2a933be 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 @@ -30,6 +30,7 @@ import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.funding.PurchaseMethodMetadata import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.UsdcDepositSweep +import com.flipcash.app.tokens.entryAffordableAfterFee import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.internal.model.thirdparty.OnRampProvider @@ -56,7 +57,6 @@ import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.opencode.model.financial.TokenWithLocalizedBalance -import com.getcode.opencode.model.financial.div import com.getcode.opencode.model.financial.grossingUpLaunchpadSellFee import com.getcode.opencode.model.financial.launchpadSellFee import com.getcode.opencode.model.financial.max @@ -530,70 +530,43 @@ class SwapViewModel @Inject constructor( } /** - * Cross-currency buys pay the funding pool's sell fee on top of the entered amount, so - * "You Pay" = amount + fee. If that total exceeds the funding token's wallet balance, the user - * can't cover it — surface a modal (automatically, on landing the receipt) offering to drop to - * the maximum affordable amount rather than letting the buy fail. + * Drops the entry to the most the funding balance can cover once its fee is applied, in place. * - * No-op when the funding side charges nothing — v1's USDF buy — and sub-cent rounding is - * tolerated so applying the max doesn't immediately re-prompt. All comparisons are in USD - * ([Fiat.convertingToUsdIfNeeded]), the common denominator across native currencies. + * Entry is capped at the raw balance, so entering the maximum always overruns by exactly the + * fee — whether it rides on top of the amount (Dollars, which has no pool to skim) or is + * grossed up out of a launchpad sale. Rather than pricing a number the balance can't fund and + * asking the user to confirm a correction, the entry itself is set to the true maximum, so the + * amount screen and everything priced from it agree. Entries with room to spare are untouched. + * + * [balance] must be in the same currency the amount is entered in — the localized balance from + * [State.tokenWithBalance], or a USD wallet balance converted through the preferred rate. */ - private fun maybePromptInsufficientBalanceAfterFees( - fundingToken: Token, - payTotal: Fiat, - rate: Rate, + private fun correctEntryToAffordable( + balance: Fiat, + feeBps: Int, + feeChargedOnTop: Boolean, ) { - // A v2 Get from Dollars charges the house rate on top instead of skimming a pool, so it - // can overrun the balance just like a cross-currency buy. v1's USDF buy is still free. - val feeChargedOnTop = fundingToken.address == Mint.usdf && stateFlow.value.isGet - if (fundingToken.address == Mint.usdf && !feeChargedOnTop) return - - val balanceUsd = tokenCoordinator.balanceForToken(fundingToken).convertingToUsdIfNeeded(rate) - val payUsd = payTotal.convertingToUsdIfNeeded(rate) - if ((payUsd - balanceUsd) <= balanceUsd.smallestUnit) return - - BottomBarManager.showInfo( - title = resources.getString(R.string.title_insufficientBalanceAfterFees), - message = resources.getString(R.string.description_insufficientBalanceAfterFees), - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_buyMaximumAmount), - style = BottomBarManager.BottomBarButtonStyle.Filled, - ) { - // The most we can pay is the full balance, so the most we can receive (net of - // the fee) is balance - fee(balance). This only corrects the displayed receipt - // (via OnAmountAccepted, a pure state update): the buy already caps the pay amount - // to the balance on-chain, and the entered amount is left untouched so returning - // to amount entry preserves what the user typed. selectedAmount is passed through - // unchanged. - val balanceNative = balanceUsd.convertingTo(rate) - val maxReceive = if (feeChargedOnTop) { - // Fee on top: pay = receive × (1 + f), so the balance affords receive = - // balance / (1 + f). - balanceNative / (1.0 + DEFAULT_CONVERT_FEE_BPS / 10_000.0) - } else { - // Fee grossed up: pay = receive / (1 - f), so the balance affords receive = - // balance × (1 - f). - balanceNative - - balanceNative.launchpadSellFee(fundingToken.launchpadMetadata?.sellFeeBps ?: 0) - } - val maxFee = balanceNative - maxReceive - dispatchEvent( - Event.OnAmountAccepted( - amount = stateFlow.value.amountEntryState.selectedAmount, - netTransferAmount = maxReceive, - enteredAmount = maxReceive, - feeAmount = maxFee, - ) - ) - }, - BottomBarAction( - text = resources.getString(R.string.action_dismiss), - style = BottomBarManager.BottomBarButtonStyle.Text, - ), - ), - ) + val corrected = entryAffordableAfterFee( + entered = amountDelegate.state.value.enteredAmount, + balance = balance, + feeBps = feeBps, + feeChargedOnTop = feeChargedOnTop, + ) ?: return + + amountDelegate.setAmount(corrected.decimalValue) + } + + /** + * The fee a buy funded by [fundingToken] charges, as bps and whether it rides on top of the + * entered amount. A v2 Get from Dollars pays the flat house rate on top (no pool to skim); + * every other currency has its pool's sell fee grossed up into the debit; v1's reserves buy is + * free. + */ + private fun buyFeeFor(fundingToken: Token): Pair = when { + fundingToken.address != Mint.usdf -> + (fundingToken.launchpadMetadata?.sellFeeBps ?: 0) to false + stateFlow.value.isGet -> DEFAULT_CONVERT_FEE_BPS to true + else -> 0 to false } private suspend fun transactionLimit(): Fiat { @@ -971,6 +944,17 @@ class SwapViewModel @Inject constructor( is SwapPurpose.Convert -> { val rate = exchange.preferredRate val sourceWithBalance = stateFlow.value.tokenWithBalance ?: return@onEach + // Converting out of Dollars charges the fee on top, so the whole balance + // can't be converted — trim the entry to the real maximum instead of + // pricing a debit the balance can't cover. Every other direction has its + // fee skimmed out of the entry, which can't overrun. + if (stateFlow.value.isConvertingFromDollars) { + correctEntryToAffordable( + balance = sourceWithBalance.balance, + feeBps = convertFeeBps, + feeChargedOnTop = true, + ) + } // The pin is taken against the total debited, which is the entered amount // plus the fee when converting out of Dollars (see [totalDebitAmount]). val amountFiat = verifiedFiatCalculator.compute( @@ -1238,8 +1222,19 @@ class SwapViewModel @Inject constructor( .map { it.mint } .mapNotNull { val token = tokenCoordinator.getTokenMetadata(it).getOrNull()?.token ?: return@mapNotNull null - val delegateState = amountDelegate.state.value val rate = exchange.preferredRate + + // The fee comes out of the same balance that funds the buy, so entering the whole + // balance can never cover both. Trim the entry to the real maximum before pricing + // anything, so the receipt and the amount screen show the same number. + val (feeBps, feeChargedOnTop) = buyFeeFor(token) + correctEntryToAffordable( + balance = tokenCoordinator.balanceForToken(token).convertingTo(rate), + feeBps = feeBps, + feeChargedOnTop = feeChargedOnTop, + ) + + val delegateState = amountDelegate.state.value val amountFiat = verifiedFiatCalculator.compute( amount = Fiat(delegateState.enteredAmount, rate.currency), token = token, @@ -1260,22 +1255,17 @@ class SwapViewModel @Inject constructor( balance = nativeAmount ) - val exchangeFee = if (token.address == Mint.usdf) { + val exchangeFee = if (feeChargedOnTop) { // Dollars has no launchpad sale to skim, so a v2 Get pays the flat house rate // *on top* of the entered amount — the same convention Convert-from-Dollars - // uses. The v1 reserves buy stays free. - if (stateFlow.value.isGet) { - nativeAmount.launchpadSellFee(DEFAULT_CONVERT_FEE_BPS) - } else { - 0.toFiat((rate.currency)) - } + // uses. + nativeAmount.launchpadSellFee(feeBps) } else { // The pool's sell fee is grossed up on top of the entered amount, so the // fee is (amount / (1 - fee)) - amount. Uses the funding pool's own bps, - // matching the gross-up applied at buy time in OnBuyConfirmed. - nativeAmount.grossingUpLaunchpadSellFee( - token.launchpadMetadata?.sellFeeBps ?: 0, - ) - nativeAmount + // matching the gross-up applied at buy time in OnBuyConfirmed. v1's reserves + // buy charges nothing, so its zero bps nets out to zero here. + nativeAmount.grossingUpLaunchpadSellFee(feeBps) - nativeAmount } dispatchEvent( @@ -1291,14 +1281,6 @@ class SwapViewModel @Inject constructor( ) ) dispatchEvent(Event.OnFundingTokenResolved(tokenWithBalance)) - - // Landing the receipt: if the fee pushes "You Pay" past the funding token's - // wallet balance, auto-offer to drop to the maximum affordable amount. - maybePromptInsufficientBalanceAfterFees( - fundingToken = token, - payTotal = nativeAmount + exchangeFee, - rate = rate, - ) }.onEach { }.launchIn(viewModelScope) diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/FeeAffordableEntryTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/FeeAffordableEntryTest.kt new file mode 100644 index 0000000000..7fb00401e6 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/FeeAffordableEntryTest.kt @@ -0,0 +1,130 @@ +package com.flipcash.app.tokens + +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.grossingUpLaunchpadSellFee +import com.getcode.opencode.model.financial.launchpadSellFee +import com.getcode.opencode.model.financial.plus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FeeAffordableEntryTest { + + private fun usd(value: Double) = Fiat(value, CurrencyCode.USD) + + // --- Entries that already fit --- + + @Test + fun `an entry with room for its fee is left exactly as typed`() { + assertNull( + entryAffordableAfterFee( + entered = 5.0, + balance = usd(10.0), + feeBps = 100, + feeChargedOnTop = true, + ) + ) + } + + @Test + fun `a free conversion never corrects the entry`() { + assertNull( + entryAffordableAfterFee( + entered = 10.0, + balance = usd(10.0), + feeBps = 0, + feeChargedOnTop = true, + ) + ) + } + + // --- Entering the maximum --- + + @Test + fun `the whole balance drops to what a fee charged on top leaves`() { + val corrected = entryAffordableAfterFee( + entered = 10.0, + balance = usd(10.0), + feeBps = 100, + feeChargedOnTop = true, + ) + + assertEquals("$9.90", corrected?.formatted()) + assertTrue(corrected!! + corrected.launchpadSellFee(100) <= usd(10.0)) + } + + @Test + fun `the whole balance drops to what a grossed-up fee leaves`() { + val corrected = entryAffordableAfterFee( + entered = 10.0, + balance = usd(10.0), + feeBps = 100, + feeChargedOnTop = false, + ) + + assertEquals("$9.90", corrected?.formatted()) + assertTrue(corrected!!.grossingUpLaunchpadSellFee(100) <= usd(10.0)) + } + + @Test + fun `the correction is floored so the fee still fits when rounding would not`() { + // $10.11 / 1.01 = $10.0099, which rounds up to $10.01 — and $10.01 plus its own 1% fee is + // $10.1101, back over the balance. Flooring to $10.00 keeps the debit inside it. + val corrected = entryAffordableAfterFee( + entered = 10.11, + balance = usd(10.11), + feeBps = 100, + feeChargedOnTop = true, + ) + + assertEquals("$10.00", corrected?.formatted()) + assertTrue(corrected!! + corrected.launchpadSellFee(100) <= usd(10.11)) + } + + @Test + fun `re-entering the corrected amount does not correct it again`() { + val corrected = entryAffordableAfterFee( + entered = 10.0, + balance = usd(10.0), + feeBps = 100, + feeChargedOnTop = true, + ) + + assertNull( + entryAffordableAfterFee( + entered = corrected!!.decimalValue, + balance = usd(10.0), + feeBps = 100, + feeChargedOnTop = true, + ) + ) + } + + @Test + fun `an entry beyond the balance is corrected down to the maximum too`() { + val corrected = entryAffordableAfterFee( + entered = 50.0, + balance = usd(10.0), + feeBps = 100, + feeChargedOnTop = true, + ) + + assertEquals("$9.90", corrected?.formatted()) + } + + @Test + fun `the correction is denominated in the balance's currency`() { + val corrected = entryAffordableAfterFee( + entered = 1000.0, + balance = Fiat(1000.0, CurrencyCode.JPY), + feeBps = 100, + feeChargedOnTop = true, + ) + + // ¥ has no fractional unit, so the correction floors to whole yen. + assertEquals(CurrencyCode.JPY, corrected?.currencyCode) + assertEquals(Fiat(990.0, CurrencyCode.JPY).quarks, corrected?.quarks) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt index d4c58c9cb4..3127616f8e 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt @@ -162,6 +162,17 @@ data class Fiat( return Fiat(fiat = step, currencyCode = currencyCode) } + /** + * This value truncated down to its currency's smallest displayable unit (e.g. $9.90099 → + * $9.90). Used where rounding up would break the invariant that produced the value, such as a + * spend ceiling derived from a balance. + */ + fun flooredToSmallestUnit(): Fiat { + val step = smallestUnit.quarks + if (step <= 1L) return this + return Fiat(quarks = Math.floorDiv(quarks, step) * step, currencyCode = currencyCode) + } + /** Whether this value would format as non-zero in its currency. */ val hasDisplayableValue: Boolean get() = rounded(currencyCode.fractionDigits) >= smallestUnit diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt index fbc304cf47..83a1c77962 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt @@ -38,3 +38,23 @@ fun Fiat.grossingUpLaunchpadSellFee(bps: Int): Fiat { val feeFraction = cappedBps / MAX_FEE_BPS.toDouble() return this / (1.0 - feeFraction) } + +/** + * The most of this balance that can be spent when the pool's sell fee is *grossed up* out of the + * spend: `balance × (1 − bps/10_000)`, the inverse of [grossingUpLaunchpadSellFee]. + * + * Grossing the result back up lands exactly on this balance, which makes it the largest entry the + * balance can actually fund. + */ +fun Fiat.spendableUnderGrossedUpSellFee(bps: Int): Fiat = this - launchpadSellFee(bps) + +/** + * The most of this balance that can be spent when the fee is charged *on top* of the spend rather + * than skimmed out of it: `balance / (1 + bps/10_000)`. + * + * Adding that entry's own fee back lands exactly on this balance. + */ +fun Fiat.spendableUnderSellFeeOnTop(bps: Int): Fiat { + val cappedBps = bps.coerceIn(0, MAX_FEE_BPS) + return this / (1.0 + cappedBps / MAX_FEE_BPS.toDouble()) +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt index 8df1c3f6a1..56c21c9607 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt @@ -307,4 +307,25 @@ class FiatTest { assertEquals(0L, Fiat.Zero.quarks) assertEquals(CurrencyCode.USD, Fiat.Zero.currencyCode) } + + // --- Flooring to the smallest unit --- + + @Test + fun `flooring truncates sub-unit precision instead of rounding it up`() { + // Leaving 1% headroom on a $10.00 balance gives $9.90099 — rounding up would put the + // entry right back over budget. + assertEquals(Fiat(9.90).quarks, Fiat(9.90099).flooredToSmallestUnit().quarks) + } + + @Test + fun `flooring leaves a value already on the smallest unit untouched`() { + val exact = Fiat(9.90) + assertEquals(exact, exact.flooredToSmallestUnit()) + } + + @Test + fun `flooring a zero-decimal currency truncates to whole units`() { + val yen = Fiat(1234.9, CurrencyCode.JPY) + assertEquals(Fiat(1234.0, CurrencyCode.JPY).quarks, yen.flooredToSmallestUnit().quarks) + } } diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt index 6d12bf00f6..1efdb4dccd 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt @@ -74,4 +74,40 @@ class LaunchpadSellFeeTest { assertEquals(Fiat.Zero.quarks, net.launchpadSellFee(bps = 0).quarks) assertEquals(net, net.grossingUpLaunchpadSellFee(bps = 0)) } + + @Test + fun `spendable under a grossed-up fee grosses back up to the balance`() { + val balance = Fiat(20.0) + + val spendable = balance.spendableUnderGrossedUpSellFee(bps = 100) + + assertEquals("$19.80", spendable.formatted()) + assertEquals(balance.quarks, spendable.grossingUpLaunchpadSellFee(bps = 100).quarks) + } + + @Test + fun `spendable under a fee charged on top leaves exactly enough for the fee`() { + val balance = Fiat(10.10) + + val spendable = balance.spendableUnderSellFeeOnTop(bps = 100) + + assertEquals("$10.00", spendable.formatted()) + assertEquals(balance.quarks, (spendable + spendable.launchpadSellFee(bps = 100)).quarks) + } + + @Test + fun `spending the whole balance is only possible with a zero fee`() { + val balance = Fiat(20.0) + + assertEquals(balance, balance.spendableUnderSellFeeOnTop(bps = 0)) + assertEquals(balance, balance.spendableUnderGrossedUpSellFee(bps = 0)) + } + + @Test + fun `an on-top fee above 100 percent is clamped rather than over-shrinking the entry`() { + val balance = Fiat(20.0) + + // Clamped to 10_000 (100%): half the balance covers a fee equal to the entry. + assertEquals("$10.00", balance.spendableUnderSellFeeOnTop(bps = 12_000).formatted()) + } }