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 c4e2a933b..4b131ab8c 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 @@ -50,6 +50,7 @@ import com.getcode.opencode.model.core.errors.SwapError import com.getcode.opencode.model.financial.Currency import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HOUSE_SELL_FEE_BPS import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Rate @@ -63,6 +64,7 @@ import com.getcode.opencode.model.financial.max import com.getcode.opencode.model.financial.min import com.getcode.opencode.model.financial.minus import com.getcode.opencode.model.financial.plus +import com.getcode.opencode.model.financial.sellFeeBpsOrHouseRate import com.getcode.opencode.model.financial.toFiat import com.getcode.opencode.model.financial.usdf import com.getcode.opencode.model.transactions.SwapState @@ -483,8 +485,8 @@ class SwapViewModel @Inject constructor( get() { val purpose = stateFlow.value.purpose as? SwapPurpose.Convert ?: return 0 if (purpose.mint == Mint.usdf) return DEFAULT_CONVERT_FEE_BPS - return stateFlow.value.tokenWithBalance?.token?.launchpadMetadata?.sellFeeBps - ?: DEFAULT_CONVERT_FEE_BPS + return stateFlow.value.tokenWithBalance?.token?.launchpadMetadata + .sellFeeBpsOrHouseRate } private val feeAmount: Fiat @@ -559,12 +561,13 @@ class SwapViewModel @Inject constructor( /** * 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 + * every other currency has its pool's sell fee grossed up into the debit (falling back to the + * house rate when the pool doesn't declare one, as [convertFeeBps] does); v1's reserves buy is * free. */ private fun buyFeeFor(fundingToken: Token): Pair = when { fundingToken.address != Mint.usdf -> - (fundingToken.launchpadMetadata?.sellFeeBps ?: 0) to false + fundingToken.launchpadMetadata.sellFeeBpsOrHouseRate to false stateFlow.value.isGet -> DEFAULT_CONVERT_FEE_BPS to true else -> 0 to false } @@ -1315,7 +1318,7 @@ class SwapViewModel @Inject constructor( } } else { enteredFiat.grossingUpLaunchpadSellFee( - bps = fundingToken.launchpadMetadata?.sellFeeBps ?: 0, + bps = fundingToken.launchpadMetadata.sellFeeBpsOrHouseRate, ) }, token = fundingToken, @@ -1891,7 +1894,7 @@ class SwapViewModel @Inject constructor( * House rate for a conversion when the source pool doesn't charge its own sell fee — 1%, * matching the launchpad default. */ - private const val DEFAULT_CONVERT_FEE_BPS = 100 + private const val DEFAULT_CONVERT_FEE_BPS = HOUSE_SELL_FEE_BPS val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { 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 83a1c7796..aa2770ba5 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 @@ -58,3 +58,18 @@ fun Fiat.spendableUnderSellFeeOnTop(bps: Int): Fiat { val cappedBps = bps.coerceIn(0, MAX_FEE_BPS) return this / (1.0 + cappedBps / MAX_FEE_BPS.toDouble()) } + +/** + * The flat house sell fee, in basis points, charged wherever a pool doesn't declare one of its own. + */ +const val HOUSE_SELL_FEE_BPS = 100 + +/** + * This pool's declared sell fee, or the house rate when there is no pool to declare one. + * + * Erring high is the safe direction: the fee is grossed up into the debit, so falling back to zero + * prices a debit the funding balance can't actually cover. iOS assumes the house rate on every leg + * for the same reason. + */ +val LaunchpadMetadata?.sellFeeBpsOrHouseRate: Int + get() = this?.sellFeeBps ?: HOUSE_SELL_FEE_BPS 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 1efdb4dcc..d75dd0daa 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 @@ -2,6 +2,7 @@ package com.getcode.opencode.model.financial import kotlin.test.Test import kotlin.test.assertEquals +import com.getcode.opencode.tests.generateRandomPublicKeyForTest import kotlin.test.assertFalse /** @@ -110,4 +111,37 @@ class LaunchpadSellFeeTest { // Clamped to 10_000 (100%): half the balance covers a fee equal to the entry. assertEquals("$10.00", balance.spendableUnderSellFeeOnTop(bps = 12_000).formatted()) } + + // region House-rate fallback + + @Test + fun `a pool that declares its own sell fee uses it`() { + assertEquals(250, pool(sellFeeBps = 250).sellFeeBpsOrHouseRate) + } + + @Test + fun `a token with no pool falls back to the house rate`() { + // Erring high is the safe direction — the fee is grossed up into the debit, so a zero + // fallback prices a debit the funding balance can't actually cover. iOS assumes the house + // rate on every leg for the same reason. + assertEquals(HOUSE_SELL_FEE_BPS, null.sellFeeBpsOrHouseRate) + } + + private fun pool(sellFeeBps: Int): LaunchpadMetadata { + val key = generateRandomPublicKeyForTest() + return LaunchpadMetadata( + currencyConfig = key, + liquidityPool = key, + seed = key, + authority = key, + mintVault = key, + coreMintVault = key, + currentCirculatingSupplyQuarks = 1_000_000L, + sellFeeBps = sellFeeBps, + price = Fiat(fiat = 1.0), + marketCap = Fiat(fiat = 1000.0), + ) + } + + // endregion }