From 9c1fbc498bd1459d8d4fb578fdd21c6bdfee3023 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 31 May 2026 19:42:07 -0400 Subject: [PATCH] fix(exchange): reject sub-minimum native amounts in VerifiedFiatCalculator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiny bonding-curve sell estimates (e.g. ₦0.003 for a low-supply token) pass the existing zero guard but round to ₦0.00 at display precision, causing the server to reject with "native amount is less than minimum transfer value." Validate at the source so all callers surface a proper error instead of silently dropping the bill. --- .../core/src/main/res/values/strings.xml | 3 +++ .../app/cash/internal/CashScreenViewModel.kt | 14 +++++++--- .../exchange/RealVerifiedFiatCalculator.kt | 5 ++++ .../opencode/model/core/errors/Errors.kt | 1 + .../RealVerifiedFiatCalculatorTest.kt | 26 +++++++++++++++++++ 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 8599d00d3..57ee06a11 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -679,6 +679,9 @@ Rate Unavailable Couldn\'t get a fresh rate. Please try again. + Amount Too Small + The amount you entered is too small to transfer\nPlease enter a larger amount + Settings Withdraw as USDC diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt index 8c7e7b8ba..508626051 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt @@ -316,11 +316,19 @@ internal class CashScreenViewModel @Inject constructor( token = token, balance = balance.underlyingTokenAmount, rate = rate, - ).getOrElse { + ).getOrElse { error -> dispatchEvent(Event.UpdateLoadingState(loading = false)) + val (title, message) = when (error) { + is ComputeVerifiedFiatError.AmountBelowMinimum -> { + R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall + } + else -> { + R.string.error_title_staleRates to R.string.error_description_staleRates + } + } BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_staleRates), - message = resources.getString(R.string.error_description_staleRates), + title = resources.getString(title), + message = resources.getString(message), ) return@onEach } 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 d8f93955a..3227bef7d 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 @@ -114,6 +114,11 @@ internal class RealVerifiedFiatCalculator @Inject constructor( val underlyingTokenAmount = Fiat(quarks = quarks.toLong(), currencyCode = CurrencyCode.USD) val sellEstimate = Fiat.tokenBalance(quarks.toLong(), token, supply).convertingTo(rate) + + if (!sellEstimate.hasDisplayableValue) { + return Result.failure(ComputeVerifiedFiatError.AmountBelowMinimum()) + } + val fx = sellEstimate.decimalValue.toBigDecimal().divideWithHighPrecision(units).toDouble() if (trace) { diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt index 358f44ff6..7abef714a 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt @@ -382,4 +382,5 @@ sealed class ComputeVerifiedFiatError( class StaleRate : ComputeVerifiedFiatError("Reserve state unavailable or stale") data class ComputationFailed(override val cause: Throwable? = null) : ComputeVerifiedFiatError(message = cause?.message, cause = cause) + class AmountBelowMinimum : ComputeVerifiedFiatError("Amount is too small to transfer") } 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 6c977efee..5abf381bf 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 @@ -396,6 +396,32 @@ class RealVerifiedFiatCalculatorTest { // endregion + // region sub-minimum amount + + @Test + fun `returns AmountBelowMinimum when sell estimate has no displayable value`() = runTest { + // Simulates the Bugsnag scenario: a low-supply custom token where the + // bonding curve produces a native amount below the currency's smallest + // displayable unit (e.g. ₦0.003 rounds to ₦0.00 for NGN). + val supply = 1_000_000_000_000L + val token = bondingCurveToken(supply = supply) + stubVerifiedState(CurrencyCode.USD, testMint, supply) + + // Use a sub-cent amount ($0.000001 = 1 quark) so the curve succeeds + // but the sell estimate is below the smallest displayable USD unit ($0.01). + val result = calculator.compute( + amount = Fiat(quarks = 1, currencyCode = CurrencyCode.USD), + token = token, + rate = Rate.oneToOne, + trace = false, + ) + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + // endregion + // region helpers private fun usdfToken(): Token = MintMetadata(