Skip to content

Commit d69acb6

Browse files
authored
fix(withdrawal): normalize balance to USD before capping in VerifiedFiatCalculator (#763)
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 <git@bmcreations.dev>
1 parent 7c30deb commit d69acb6

3 files changed

Lines changed: 88 additions & 1 deletion

File tree

services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.getcode.opencode.solana.intents.IntentType
3333
import com.getcode.opencode.utils.flowInterval
3434
import com.getcode.solana.keys.Mint
3535
import com.getcode.solana.keys.PublicKey
36+
import com.getcode.solana.keys.base58
3637
import com.getcode.utils.TraceType
3738
import com.getcode.utils.base64
3839
import com.getcode.utils.trace
@@ -130,6 +131,19 @@ class TransactionController @Inject constructor(
130131
fee: Fiat?,
131132
scope: CoroutineScope,
132133
): Result<IntentType> {
134+
trace(
135+
tag = "TransactionController",
136+
message = "Starting withdrawal",
137+
type = TraceType.Process,
138+
metadata = {
139+
"amount (native)" to amount.localFiat.nativeAmount.formatted()
140+
"amount (underlying)" to amount.localFiat.underlyingTokenAmount.formatted()
141+
"amount currency" to amount.localFiat.nativeAmount.currencyCode.name
142+
"fee" to (fee?.formatted() ?: "none")
143+
"mint" to mint.base58()
144+
}
145+
)
146+
133147
val verifiedState = amount.verifiedState
134148
?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found")))
135149

@@ -156,6 +170,18 @@ class TransactionController @Inject constructor(
156170
destinationOwner: PublicKey,
157171
fee: LocalFiat,
158172
): Result<SwapId> {
173+
trace(
174+
tag = "TransactionController",
175+
message = "Starting USDF->USDC withdrawal",
176+
type = TraceType.Process,
177+
metadata = {
178+
"amount (native)" to "${amount.localFiat.nativeAmount.formatted()} (${amount.localFiat.nativeAmount.currencyCode})"
179+
"amount (underlying)" to "${amount.localFiat.underlyingTokenAmount.formatted()} (${amount.localFiat.underlyingTokenAmount.currencyCode})"
180+
"fee (native)" to "${fee.nativeAmount.formatted()} (${fee.nativeAmount.currencyCode})"
181+
"fee (underlying)" to "${fee.underlyingTokenAmount.formatted()} (${fee.underlyingTokenAmount.currencyCode})"
182+
}
183+
)
184+
159185
val verifiedState = amount.verifiedState
160186
?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found")))
161187

services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ internal class RealVerifiedFiatCalculator @Inject constructor(
7373
val usdValue = amount.convertingToUsdIfNeeded(rate)
7474
// cap the entered amount as well, since our display rounds HALF_UP
7575
// e,g entered 0.02 USD, but balance is 0.016 USD
76-
val cappedValue = balance?.let { min(it, usdValue) } ?: usdValue
76+
// Balance may arrive in the user's local currency; normalize to USD
77+
// before comparing so the capped value preserves the USD currency code.
78+
val balanceInUsd = balance?.convertingToUsdIfNeeded(rate)
79+
val cappedValue = balanceInUsd?.let { min(it, usdValue) } ?: usdValue
7780

7881
val verifiedState = resolveVerifiedState(rate.currency, token.address)
7982

services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import com.getcode.opencode.model.financial.CurrencyCode
1313
import com.getcode.opencode.model.financial.Fiat
1414
import com.getcode.opencode.model.financial.HolderMetrics
1515
import com.getcode.opencode.model.financial.LaunchpadMetadata
16+
import com.getcode.opencode.model.financial.LocalFiat
1617
import com.getcode.opencode.model.financial.MintMetadata
1718
import com.getcode.opencode.model.financial.Rate
19+
import com.getcode.opencode.model.financial.minus
1820
import com.getcode.opencode.model.financial.Token
1921
import com.getcode.opencode.model.financial.VmMetadata
2022
import com.getcode.solana.keys.Mint
@@ -225,6 +227,62 @@ class RealVerifiedFiatCalculatorTest {
225227
)
226228
}
227229

230+
@Test
231+
fun `USDF caps correctly when balance is in non-USD currency`() = runTest {
232+
// Regression: when the user's local currency is stronger than USD (e.g. GBP
233+
// at fx=0.79), the balance in GBP quarks is smaller than the USD equivalent.
234+
// A naive min() would pick the GBP Fiat, corrupting underlyingTokenAmount
235+
// with a non-USD currency code and causing "Cannot subtract different currencies".
236+
val token = usdfToken()
237+
val gbpRate = Rate(fx = 0.79, currency = CurrencyCode.GBP)
238+
239+
// User has $100 USD on-chain → £79 GBP display balance
240+
val balanceGbp = Fiat(fiat = 79.0, currencyCode = CurrencyCode.GBP)
241+
// User enters £79 GBP (full balance)
242+
val amount = Fiat(fiat = 79.0, currencyCode = CurrencyCode.GBP)
243+
244+
val result = calculator.compute(
245+
amount = amount,
246+
token = token,
247+
balance = balanceGbp,
248+
rate = gbpRate,
249+
trace = false,
250+
).getOrThrow()
251+
252+
// underlyingTokenAmount must always be USD-denominated
253+
assertEquals(CurrencyCode.USD, result.localFiat.underlyingTokenAmount.currencyCode)
254+
assertEquals(CurrencyCode.GBP, result.localFiat.nativeAmount.currencyCode)
255+
}
256+
257+
@Test
258+
fun `USDF withdrawal with non-USD balance does not crash on fee subtraction`() = runTest {
259+
// End-to-end regression: reproduces the exact crash path in
260+
// TransactionService.withdrawUsdf where amount - fee threw
261+
// "Cannot subtract different currencies".
262+
val token = usdfToken()
263+
val gbpRate = Rate(fx = 0.79, currency = CurrencyCode.GBP)
264+
val balanceGbp = Fiat(fiat = 79.0, currencyCode = CurrencyCode.GBP)
265+
val amount = Fiat(fiat = 79.0, currencyCode = CurrencyCode.GBP)
266+
267+
val verifiedAmount = calculator.compute(
268+
amount = amount,
269+
token = token,
270+
balance = balanceGbp,
271+
rate = gbpRate,
272+
trace = false,
273+
).getOrThrow().localFiat
274+
275+
val fee = LocalFiat.fromUsd(
276+
usdf = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD),
277+
rate = verifiedAmount.rate,
278+
)
279+
280+
// This is the exact line that crashed in TransactionService.withdrawUsdf
281+
val netAmount = verifiedAmount - fee
282+
assertEquals(CurrencyCode.USD, netAmount.underlyingTokenAmount.currencyCode)
283+
assertEquals(CurrencyCode.GBP, netAmount.nativeAmount.currencyCode)
284+
}
285+
228286
@Test
229287
fun `does not cap when balance is larger than amount`() = runTest {
230288
val supply = 1_000_000_000_000L

0 commit comments

Comments
 (0)