From 9722998872c95d007c3efca2473a2b8db861b520 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 16 Jul 2026 17:48:51 +0200 Subject: [PATCH 1/2] fix: exempt crypto withdrawals from the fiat $1 minimum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared withdraw amount step applied getMinimumAmount() to every method; crypto has no country so it fell into the $1 default — blocking sub-$1 on-chain sends that send-via-link already allows. The minimum was introduced for bank offramps (295c8ec11, Bridge's $1 floor) and crypto only inherited it by sharing the page. Bank rails keep their country minimums. --- .../__tests__/withdraw-states.test.tsx | 33 +++++++++++++++++++ src/app/(mobile-ui)/withdraw/page.tsx | 6 +++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index bb51cd3c80..5d9f60c6ef 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -394,6 +394,39 @@ describe('GROUP 3: Amount Validation', () => { expect(screen.queryByTestId('error-alert')).not.toBeInTheDocument() expect(screen.getByTestId('limits-warning-card')).toBeInTheDocument() }) + + test('Crypto withdrawal allows sub-$1 amounts (no fiat-rail minimum)', () => { + // Regression: the shared amount step applied the bank $1 minimum to + // crypto (getMinimumAmount('') → 1), blocking sub-$1 on-chain sends + // that send-via-link already allows. + mockWithdrawFlow.selectedMethod = { type: 'crypto' } + mockWithdrawFlow.amountToWithdraw = '0.5' + + renderWithdraw() + + const continueBtn = screen.getByText('Continue') + expect(continueBtn).not.toBeDisabled() + + fireEvent.click(continueBtn) + expect(mockRouterPush).toHaveBeenCalledWith('/withdraw/crypto') + }) + + test('Bank withdrawal keeps the $1 minimum for sub-$1 amounts', async () => { + mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } + mockWithdrawFlow.amountToWithdraw = '0.5' + + renderWithdraw() + + expect(screen.getByText('Continue')).toBeDisabled() + // validation is debounced 300ms behind typing + await waitFor(() => + expect(mockSetError).toHaveBeenCalledWith({ + showError: true, + errorMessage: 'Minimum withdrawal is $1.', + }) + ) + expect(mockRouterPush).not.toHaveBeenCalled() + }) }) // ============================================================ diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 6eb1a7f91c..5555657b8c 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -120,8 +120,12 @@ export default function WithdrawPage() { enabled: rateAccountType !== AccountType.US && countryIso2 !== '', }) + // crypto withdrawals are plain on-chain transfers — fiat-rail minimums don't apply + const isCryptoWithdraw = selectedMethod?.type === 'crypto' || isCryptoFromSend + // compute minimum withdrawal in USD using the exchange rate const minUsdAmount = useMemo(() => { + if (isCryptoWithdraw) return 0 // any amount > 0 is valid, same as send-via-link const localMin = getMinimumAmount(countryIso2) // for US or unknown, minimum is already in USD if (!countryIso2 || countryIso2 === 'US') return localMin @@ -131,7 +135,7 @@ export default function WithdrawPage() { const rate = parseFloat(exchangeRate || '0') if (rate <= 0) return 1 // fallback while rate is loading return Math.ceil(localMin / rate) - }, [countryIso2, exchangeRate]) + }, [isCryptoWithdraw, countryIso2, exchangeRate]) // validate against user's limits for bank withdrawals // note: crypto withdrawals don't have fiat limits From 7edcf0919a247e8ecf0e554f5deaa1a62f66b461 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 16 Jul 2026 18:06:02 +0200 Subject: [PATCH 2/2] fix: follow selectedMethod for the crypto exemption + gate sub-minimum Rhino deposits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the initial cut: - The exemption keyed off the URL param independently of routing: a stale bank selectedMethod (app-wide context) entering via /withdraw?method=crypto got min=$0 while Continue still routed to the bank flow. The predicate now follows selectedMethod when set; the URL only covers the pre-effect frame. - Removing the blanket $1 floor exposed the cross-chain path to sub-minimum Rhino SDA deposits, which are accepted on-chain but never bridged (stranded, uncredited — known incident class). The confirm CTA now blocks below the route's minDepositLimitUsd, which the page previously discarded. - Consolidated the two other inline is-crypto checks onto the same predicate, stopped the exchange-rate poll when crypto is selected, dropped a vacuous test assertion. --- .../__tests__/withdraw-states.test.tsx | 14 ++++++++++- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 14 +++++++++++ src/app/(mobile-ui)/withdraw/page.tsx | 18 ++++++++------- .../Withdraw/views/Confirm.withdraw.view.tsx | 12 +++++++++- src/utils/__tests__/withdraw.utils.test.ts | 23 +++++++++++++++++++ src/utils/withdraw.utils.ts | 17 ++++++++++++++ 6 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index 5d9f60c6ef..cb09a95891 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -425,7 +425,19 @@ describe('GROUP 3: Amount Validation', () => { errorMessage: 'Minimum withdrawal is $1.', }) ) - expect(mockRouterPush).not.toHaveBeenCalled() + }) + + test('Stale bank method entering via ?method=crypto keeps the bank minimum', () => { + // Regression: the crypto exemption must follow selectedMethod (the + // routing source of truth), not the URL param. A leftover bank method + // from an abandoned withdraw survives in the app-wide context and + // still routes Continue to the bank flow — so sub-$1 must stay blocked. + mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } + mockWithdrawFlow.amountToWithdraw = '0.5' + + renderWithdraw({ method: 'crypto' }) + + expect(screen.getByText('Continue')).toBeDisabled() }) }) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 07b8d9ee51..8da2a90967 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -19,6 +19,7 @@ import type { import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils' import { isWithdrawFeeDisproportionate } from '@/utils/cross-chain-fee.utils' import { isAmountWithinBalance } from '@/utils/balance.utils' +import { isBelowRhinoMinDeposit } from '@/utils/withdraw.utils' import * as peanutInterfaces from '@/interfaces/peanut-sdk-types' import { useRouter } from 'next/navigation' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' @@ -72,6 +73,7 @@ export default function WithdrawCryptoPage() { receiveAmount, payAmount, feeUsd, + minDepositLimitUsd, isCalculating, isXChain, isDiffToken, @@ -465,6 +467,17 @@ export default function WithdrawCryptoPage() { [isCrossChainWithdrawal, payAmount, spendableBalance] ) + // Rhino accepts SDA deposits below the route minimum on-chain but never + // bridges them — funds strand at the SDA, uncredited. Block the CTA before + // the user signs. Same-chain USDC transfers have no minimum. + const belowMinimumMessage = useMemo( + () => + isCrossChainWithdrawal && isBelowRhinoMinDeposit(payAmount, minDepositLimitUsd) + ? `The minimum withdrawal to this network is $${minDepositLimitUsd}. Enter a larger amount.` + : null, + [isCrossChainWithdrawal, payAmount, minDepositLimitUsd] + ) + if (!amountToWithdraw && currentView !== 'STATUS') { // Redirect to main withdraw page for amount input // Guard against STATUS view: resetWithdrawFlow() clears amountToWithdraw, @@ -501,6 +514,7 @@ export default function WithdrawCryptoPage() { payAmount={payAmount} showHighFeeWarning={showHighFeeWarning} insufficientBalance={insufficientForFee} + belowMinimumMessage={belowMinimumMessage} /> )} diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 5555657b8c..59b1bf14b1 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -114,15 +114,19 @@ export default function WithdrawPage() { return { countryIso2: '', rateAccountType: AccountType.US } }, [selectedBankAccount, selectedMethod]) + // crypto withdrawals are plain on-chain transfers — fiat-rail minimums don't + // apply. selectedMethod is the routing source of truth (a stale bank method + // from an abandoned withdraw still routes to the bank flow, so it must keep + // its minimum); the URL param only covers the first render before the mount + // effect commits the crypto method. + const isCryptoWithdraw = selectedMethod ? selectedMethod.type === 'crypto' : isCryptoFromSend + // fetch exchange rate for non-USD countries to convert local minimum to USD const { exchangeRate } = useGetExchangeRate({ accountType: rateAccountType, - enabled: rateAccountType !== AccountType.US && countryIso2 !== '', + enabled: !isCryptoWithdraw && rateAccountType !== AccountType.US && countryIso2 !== '', }) - // crypto withdrawals are plain on-chain transfers — fiat-rail minimums don't apply - const isCryptoWithdraw = selectedMethod?.type === 'crypto' || isCryptoFromSend - // compute minimum withdrawal in USD using the exchange rate const minUsdAmount = useMemo(() => { if (isCryptoWithdraw) return 0 // any amount > 0 is valid, same as send-via-link @@ -379,8 +383,7 @@ export default function WithdrawPage() { if (step === 'inputAmount') { // only show limits card for bank/manteca withdrawals, not crypto - const showLimitsCard = - selectedMethod?.type !== 'crypto' && (limitsValidation.isBlocking || limitsValidation.isWarning) + const showLimitsCard = !isCryptoWithdraw && (limitsValidation.isBlocking || limitsValidation.isWarning) return (
@@ -437,8 +440,7 @@ export default function WithdrawPage() { onClick={handleAmountContinue} disabled={ isContinueDisabled || - (selectedMethod?.type !== 'crypto' && - (limitsValidation.isLoading || limitsValidation.isBlocking)) + (!isCryptoWithdraw && (limitsValidation.isLoading || limitsValidation.isBlocking)) } className="w-full" > diff --git a/src/components/Withdraw/views/Confirm.withdraw.view.tsx b/src/components/Withdraw/views/Confirm.withdraw.view.tsx index 4c2bc0e21a..a411f9340c 100644 --- a/src/components/Withdraw/views/Confirm.withdraw.view.tsx +++ b/src/components/Withdraw/views/Confirm.withdraw.view.tsx @@ -56,6 +56,12 @@ interface WithdrawConfirmViewProps { * sign into the misleading "balance still settling" send error. */ insufficientBalance?: boolean + /** + * Set when the cross-chain deposit falls below Rhino's route minimum — the + * bridge would accept the funds on-chain but never deliver them. Blocks the + * CTA and shows the message. + */ + belowMinimumMessage?: string | null } export default function ConfirmWithdrawView({ @@ -75,6 +81,7 @@ export default function ConfirmWithdrawView({ payAmount, showHighFeeWarning = false, insufficientBalance = false, + belowMinimumMessage = null, }: WithdrawConfirmViewProps) { const { tokenIconUrl, chainIconUrl, resolvedChainName, resolvedTokenSymbol } = useTokenChainIcons({ chainId: chain.chainId, @@ -210,7 +217,7 @@ export default function ConfirmWithdrawView({ variant="purple" shadowSize="4" onClick={onConfirm} - disabled={isProcessing || isCalculating || insufficientBalance} + disabled={isProcessing || isCalculating || insufficientBalance || !!belowMinimumMessage} loading={isProcessing} className="w-full" > @@ -219,6 +226,9 @@ export default function ConfirmWithdrawView({ )} {insufficientBalance && !error && } + {belowMinimumMessage && !insufficientBalance && !error && ( + + )} {error && }
diff --git a/src/utils/__tests__/withdraw.utils.test.ts b/src/utils/__tests__/withdraw.utils.test.ts index 6512014b7d..8a07550696 100644 --- a/src/utils/__tests__/withdraw.utils.test.ts +++ b/src/utils/__tests__/withdraw.utils.test.ts @@ -8,6 +8,7 @@ import { validatePixKey, getCountryCodeForWithdraw, getCountryFromIban, + isBelowRhinoMinDeposit, } from '@/utils/withdraw.utils' jest.mock('@/assets', () => ({})) @@ -392,4 +393,26 @@ describe('Withdraw Utilities', () => { }) }) }) + + // Guards the confirm CTA on cross-chain withdrawals: a sub-minimum SDA + // deposit is accepted on-chain but never bridged (funds strand, uncredited), + // reachable since the amount step dropped its blanket $1 crypto minimum. + describe('isBelowRhinoMinDeposit', () => { + test('blocks when the deposit is below the route minimum', () => { + expect(isBelowRhinoMinDeposit('0.50', 5)).toBe(true) + }) + + test('allows at or above the minimum', () => { + expect(isBelowRhinoMinDeposit('5.00', 5)).toBe(false) + expect(isBelowRhinoMinDeposit('12.34', 5)).toBe(false) + }) + + test('stays permissive while values are unknown (CTA already gated by isCalculating)', () => { + expect(isBelowRhinoMinDeposit(null, 5)).toBe(false) + expect(isBelowRhinoMinDeposit(undefined, 5)).toBe(false) + expect(isBelowRhinoMinDeposit('0.50', null)).toBe(false) + expect(isBelowRhinoMinDeposit('0.50', undefined)).toBe(false) + expect(isBelowRhinoMinDeposit('not-a-number', 5)).toBe(false) + }) + }) }) diff --git a/src/utils/withdraw.utils.ts b/src/utils/withdraw.utils.ts index 14a264a690..af4f68d07f 100644 --- a/src/utils/withdraw.utils.ts +++ b/src/utils/withdraw.utils.ts @@ -330,3 +330,20 @@ export const validatePixKey = (pixKey: string): { valid: boolean; message?: stri message: 'Invalid PIX key format. Must be phone, CPF, CNPJ, email, random key, or QR code', } } + +/** + * True when a cross-chain (Rhino SDA) withdrawal's on-chain deposit would fall + * below the route's minimum. Rhino accepts sub-minimum SDA deposits on-chain + * but never bridges them — the funds strand at the SDA and the user is never + * credited — so the confirm CTA must block. `payAmount` is the actual SDA + * deposit (principal + fee, USD-stable). Unknown values (quote still loading, + * no limit reported) return false — the CTA is already gated by isCalculating. + */ +export const isBelowRhinoMinDeposit = ( + payAmount: string | null | undefined, + minDepositLimitUsd: number | null | undefined +): boolean => { + if (payAmount == null || minDepositLimitUsd == null) return false + const pay = parseFloat(payAmount) + return Number.isFinite(pay) && pay < minDepositLimitUsd +}