From f086666262af0fce9fbb75ca3675202730c419ea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 22 May 2026 11:54:57 -0400 Subject: [PATCH] fix(withdrawal): normalize balance to USD before capping in VerifiedFiatCalculator When a user local currency is stronger than USD (e.g. GBP at fx=0.79), the balance in local currency quarks is smaller than the USD equivalent. The min() comparison picked the local-currency Fiat, corrupting underlyingTokenAmount with a non-USD currency code. This caused TransactionService.withdrawUsdf to throw "Cannot subtract different currencies" when subtracting the fee (correctly in USD) from the corrupted amount. Convert balance to USD via convertingToUsdIfNeeded before the min() comparison so cappedValue always preserves the USD currency code. Also adds withdrawal breadcrumb traces in TransactionController for both withdraw and withdrawUsdf paths, and two regression tests covering the cap fix and the full subtraction path. Bugsnag: 6a0f534a8c3285d1a5b1aea3 Signed-off-by: Brandon McAnsh --- .../controllers/TransactionController.kt | 26 +++++++++ .../exchange/RealVerifiedFiatCalculator.kt | 5 +- .../RealVerifiedFiatCalculatorTest.kt | 58 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt index 1fd7bbdd7..870edced7 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt @@ -33,6 +33,7 @@ import com.getcode.opencode.solana.intents.IntentType import com.getcode.opencode.utils.flowInterval import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.base58 import com.getcode.utils.TraceType import com.getcode.utils.base64 import com.getcode.utils.trace @@ -130,6 +131,19 @@ class TransactionController @Inject constructor( fee: Fiat?, scope: CoroutineScope, ): Result { + trace( + tag = "TransactionController", + message = "Starting withdrawal", + type = TraceType.Process, + metadata = { + "amount (native)" to amount.localFiat.nativeAmount.formatted() + "amount (underlying)" to amount.localFiat.underlyingTokenAmount.formatted() + "amount currency" to amount.localFiat.nativeAmount.currencyCode.name + "fee" to (fee?.formatted() ?: "none") + "mint" to mint.base58() + } + ) + val verifiedState = amount.verifiedState ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found"))) @@ -156,6 +170,18 @@ class TransactionController @Inject constructor( destinationOwner: PublicKey, fee: LocalFiat, ): Result { + trace( + tag = "TransactionController", + message = "Starting USDF->USDC withdrawal", + type = TraceType.Process, + metadata = { + "amount (native)" to "${amount.localFiat.nativeAmount.formatted()} (${amount.localFiat.nativeAmount.currencyCode})" + "amount (underlying)" to "${amount.localFiat.underlyingTokenAmount.formatted()} (${amount.localFiat.underlyingTokenAmount.currencyCode})" + "fee (native)" to "${fee.nativeAmount.formatted()} (${fee.nativeAmount.currencyCode})" + "fee (underlying)" to "${fee.underlyingTokenAmount.formatted()} (${fee.underlyingTokenAmount.currencyCode})" + } + ) + val verifiedState = amount.verifiedState ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found"))) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt index 0169b219e..d8f93955a 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt @@ -73,7 +73,10 @@ internal class RealVerifiedFiatCalculator @Inject constructor( val usdValue = amount.convertingToUsdIfNeeded(rate) // cap the entered amount as well, since our display rounds HALF_UP // e,g entered 0.02 USD, but balance is 0.016 USD - val cappedValue = balance?.let { min(it, usdValue) } ?: usdValue + // Balance may arrive in the user's local currency; normalize to USD + // before comparing so the capped value preserves the USD currency code. + val balanceInUsd = balance?.convertingToUsdIfNeeded(rate) + val cappedValue = balanceInUsd?.let { min(it, usdValue) } ?: usdValue val verifiedState = resolveVerifiedState(rate.currency, token.address) diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt index 64d7240e4..6c977efee 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt @@ -13,8 +13,10 @@ import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.HolderMetrics import com.getcode.opencode.model.financial.LaunchpadMetadata +import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.MintMetadata import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.minus import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.VmMetadata import com.getcode.solana.keys.Mint @@ -225,6 +227,62 @@ class RealVerifiedFiatCalculatorTest { ) } + @Test + fun `USDF caps correctly when balance is in non-USD currency`() = runTest { + // Regression: when the user's local currency is stronger than USD (e.g. GBP + // at fx=0.79), the balance in GBP quarks is smaller than the USD equivalent. + // A naive min() would pick the GBP Fiat, corrupting underlyingTokenAmount + // with a non-USD currency code and causing "Cannot subtract different currencies". + val token = usdfToken() + val gbpRate = Rate(fx = 0.79, currency = CurrencyCode.GBP) + + // User has $100 USD on-chain → £79 GBP display balance + val balanceGbp = Fiat(fiat = 79.0, currencyCode = CurrencyCode.GBP) + // User enters £79 GBP (full balance) + val amount = Fiat(fiat = 79.0, currencyCode = CurrencyCode.GBP) + + val result = calculator.compute( + amount = amount, + token = token, + balance = balanceGbp, + rate = gbpRate, + trace = false, + ).getOrThrow() + + // underlyingTokenAmount must always be USD-denominated + assertEquals(CurrencyCode.USD, result.localFiat.underlyingTokenAmount.currencyCode) + assertEquals(CurrencyCode.GBP, result.localFiat.nativeAmount.currencyCode) + } + + @Test + fun `USDF withdrawal with non-USD balance does not crash on fee subtraction`() = runTest { + // End-to-end regression: reproduces the exact crash path in + // TransactionService.withdrawUsdf where amount - fee threw + // "Cannot subtract different currencies". + val token = usdfToken() + val gbpRate = Rate(fx = 0.79, currency = CurrencyCode.GBP) + val balanceGbp = Fiat(fiat = 79.0, currencyCode = CurrencyCode.GBP) + val amount = Fiat(fiat = 79.0, currencyCode = CurrencyCode.GBP) + + val verifiedAmount = calculator.compute( + amount = amount, + token = token, + balance = balanceGbp, + rate = gbpRate, + trace = false, + ).getOrThrow().localFiat + + val fee = LocalFiat.fromUsd( + usdf = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD), + rate = verifiedAmount.rate, + ) + + // This is the exact line that crashed in TransactionService.withdrawUsdf + val netAmount = verifiedAmount - fee + assertEquals(CurrencyCode.USD, netAmount.underlyingTokenAmount.currencyCode) + assertEquals(CurrencyCode.GBP, netAmount.nativeAmount.currencyCode) + } + @Test fun `does not cap when balance is larger than amount`() = runTest { val supply = 1_000_000_000_000L