From 5f282e8e2ad0b48f07738dcc8653b18d6c50ba6a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 21 Aug 2026 21:11:17 -0400 Subject: [PATCH] fix(tokens): fall back to the house sell fee on the buy leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A launchpad token whose metadata omits `sellFeeBps` was treated as fee-free when it funded a buy — both when trimming the entry to what the balance can afford and when grossing the fee up into the debit at confirm time. The convert leg already falls back to the house rate for the same case, so the two legs disagreed about the same token, and iOS assumes the house rate on every leg. Erring high is the safe direction here: the fee is grossed up into the debit, so a zero fallback prices a debit the funding balance can't actually cover, and the swap fails downstream rather than being trimmed up front. Names the fallback once as `HOUSE_SELL_FEE_BPS` next to the rest of the fee math, with `sellFeeBpsOrHouseRate` reading it off a pool, so the buy leg, the convert leg, and the on-top Dollars rate can't drift apart again. --- .../flipcash/app/tokens/ui/SwapViewModel.kt | 15 ++++---- .../model/financial/LaunchpadSellFee.kt | 15 ++++++++ .../model/financial/LaunchpadSellFeeTest.kt | 34 +++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) 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 }