From bc0b6c6a2740e9e359959bb4fcfa1cb74737fcca Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Tue, 21 Jul 2026 13:37:52 +0200 Subject: [PATCH 1/6] fix: block crypto withdrawals below Rhino route minimums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rhino rejects bridge deposits under the route minimum (UNDER_MIN) and parks the funds at the SDA with no auto-refund — a $2.50 withdrawal to Ethereum (min $5) sat stranded since 07-15 with the intent COMPLETED. Floor every crypto withdrawal at $0.50 on the amount step (red, blocking), warn $0.50–$5 amounts that Ethereum needs $5, and hard-block chain-aware minimums (ETH $5 / Tron $10) at review time before any charge exists. --- .../__tests__/withdraw-states.test.tsx | 51 ++++++++++++++++++- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 15 +++++- src/app/(mobile-ui)/withdraw/page.tsx | 25 ++++++++- src/utils/cross-chain-fee.utils.test.ts | 27 +++++++++- src/utils/cross-chain-fee.utils.ts | 24 +++++++++ 5 files changed, 137 insertions(+), 5 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 cb09a95891..9387d3df9e 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -395,10 +395,11 @@ describe('GROUP 3: Amount Validation', () => { expect(screen.getByTestId('limits-warning-card')).toBeInTheDocument() }) - test('Crypto withdrawal allows sub-$1 amounts (no fiat-rail minimum)', () => { + test('Crypto withdrawal allows sub-$1 amounts down to the $0.50 bridge floor', () => { // 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. + // that send-via-link already allows. Crypto's own floor is Rhino's + // $0.50 route minimum — $0.50 exactly must still pass. mockWithdrawFlow.selectedMethod = { type: 'crypto' } mockWithdrawFlow.amountToWithdraw = '0.5' @@ -411,6 +412,52 @@ describe('GROUP 3: Amount Validation', () => { expect(mockRouterPush).toHaveBeenCalledWith('/withdraw/crypto') }) + test('Crypto withdrawal blocks below the $0.50 bridge floor', async () => { + // Rhino parks (doesn't refund) bridge deposits under the route minimum, + // so amounts under $0.50 never leave the amount step. + mockWithdrawFlow.selectedMethod = { type: 'crypto' } + mockWithdrawFlow.amountToWithdraw = '0.4' + + renderWithdraw() + + expect(screen.getByText('Continue')).toBeDisabled() + // validation is debounced 300ms behind typing + await waitFor(() => + expect(mockSetError).toHaveBeenCalledWith({ + showError: true, + errorMessage: 'Minimum withdrawal is $0.50.', + }) + ) + }) + + test('Crypto amount under $5 shows the Ethereum minimum notice, non-blocking', () => { + mockWithdrawFlow.selectedMethod = { type: 'crypto' } + mockWithdrawFlow.amountToWithdraw = '2' + + renderWithdraw() + + expect(screen.getByText(/Ethereum mainnet withdrawals need at least \$5/)).toBeInTheDocument() + expect(screen.getByText('Continue')).not.toBeDisabled() + }) + + test('Crypto amount at $5+ shows no Ethereum notice', () => { + mockWithdrawFlow.selectedMethod = { type: 'crypto' } + mockWithdrawFlow.amountToWithdraw = '5' + + renderWithdraw() + + expect(screen.queryByText(/Ethereum mainnet withdrawals need at least \$5/)).not.toBeInTheDocument() + }) + + test('Bank withdrawal shows no Ethereum notice', () => { + mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } + mockWithdrawFlow.amountToWithdraw = '2' + + renderWithdraw() + + expect(screen.queryByText(/Ethereum mainnet withdrawals need at least \$5/)).not.toBeInTheDocument() + }) + test('Bank withdrawal keeps the $1 minimum for sub-$1 amounts', async () => { mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } mockWithdrawFlow.amountToWithdraw = '0.5' diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 685153e04e..cbd4bc6fe9 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -17,7 +17,7 @@ import type { TRequestResponse, } from '@/services/services.types' import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils' -import { isWithdrawFeeDisproportionate } from '@/utils/cross-chain-fee.utils' +import { isWithdrawFeeDisproportionate, getMinWithdrawUsdForChain } 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' @@ -171,6 +171,19 @@ export default function WithdrawCryptoPage() { return } + // Rhino parks (doesn't auto-refund) bridge deposits below the route + // minimum, so block sub-minimum withdrawals for the chosen network + // before any request/charge is created. amountToWithdraw is USD. + const usdToWithdraw = parseFloat(amountToWithdraw) + const minUsd = getMinWithdrawUsdForChain(data.chain.chainId) + if (!Number.isFinite(usdToWithdraw) || usdToWithdraw < minUsd) { + const minDisplay = minUsd % 1 === 0 ? `$${minUsd}` : `$${minUsd.toFixed(2)}` + setError( + `Withdrawals to ${data.chain.networkName} need at least ${minDisplay}. Increase the amount or pick a different network.` + ) + return + } + clearErrors() setChargeDetails(null) setIsPreparingReview(true) diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 59b1bf14b1..c07a35f211 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -11,6 +11,8 @@ import { useWallet } from '@/hooks/wallet/useWallet' import { tokenSelectorContext } from '@/context/tokenSelector.context' import { INSUFFICIENT_BALANCE_MESSAGE } from '@/utils/balance.utils' import { getCountryFromAccount, getCountryFromPath, getMinimumAmount } from '@/utils/bridge.utils' +import { MIN_CRYPTO_WITHDRAW_USD, ETHEREUM_MIN_WITHDRAW_USD } from '@/utils/cross-chain-fee.utils' +import InfoCard from '@/components/Global/InfoCard' import useGetExchangeRate from '@/hooks/useGetExchangeRate' import { AccountType } from '@/interfaces' import { useRouter, useSearchParams } from 'next/navigation' @@ -129,7 +131,10 @@ export default function WithdrawPage() { // 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 + // crypto rides the Rhino bridge, which parks (doesn't refund) deposits + // below the route minimum — $0.50 floors every network; Ethereum's $5 + // is enforced chain-aware at review time (see withdraw/crypto). + if (isCryptoWithdraw) return MIN_CRYPTO_WITHDRAW_USD const localMin = getMinimumAmount(countryIso2) // for US or unknown, minimum is already in USD if (!countryIso2 || countryIso2 === 'US') return localMin @@ -385,6 +390,13 @@ export default function WithdrawPage() { // only show limits card for bank/manteca withdrawals, not crypto const showLimitsCard = !isCryptoWithdraw && (limitsValidation.isBlocking || limitsValidation.isWarning) + // crypto amounts that clear the $0.50 floor but not Ethereum's $5 get a + // non-blocking heads-up — the destination chain isn't chosen yet, so we + // warn here and hard-block at review time if Ethereum is picked. + const typedUsd = (selectedTokenData?.price ?? 1) * parseFloat(rawTokenAmount || '0') + const showEthereumMinNotice = + isCryptoWithdraw && typedUsd >= MIN_CRYPTO_WITHDRAW_USD && typedUsd < ETHEREUM_MIN_WITHDRAW_USD + return (
: null })()} + {/* heads-up for crypto amounts below Ethereum's bridge minimum */} + {showEthereumMinNotice && ( + + )} +