Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -130,6 +131,19 @@ class TransactionController @Inject constructor(
fee: Fiat?,
scope: CoroutineScope,
): Result<IntentType> {
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")))

Expand All @@ -156,6 +170,18 @@ class TransactionController @Inject constructor(
destinationOwner: PublicKey,
fee: LocalFiat,
): Result<SwapId> {
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")))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading