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