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..f68601910a 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -395,12 +395,14 @@ 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 has no amount-step minimum (parity with send-via-link)', () => { // 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. Same-chain Arbitrum withdrawals + // have no minimum at all; Rhino's per-network bridge minimums are + // enforced at review time, once the destination is known. mockWithdrawFlow.selectedMethod = { type: 'crypto' } - mockWithdrawFlow.amountToWithdraw = '0.5' + mockWithdrawFlow.amountToWithdraw = '0.4' renderWithdraw() diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 685153e04e..61aad6eb70 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,26 @@ export default function WithdrawCryptoPage() { return } + // Same-chain USDC is a direct transfer — no Rhino, no minimum + // (parity with send-via-link). Every other destination/token rides + // Rhino, which parks (doesn't auto-refund) deposits below the route + // minimum — block those before any request/charge is created. + // amountToWithdraw is USD. + const isSameChainUsdc = + data.chain.chainId.toString() === PEANUT_WALLET_CHAIN.id.toString() && + data.token.address.toLowerCase() === PEANUT_WALLET_TOKEN.toLowerCase() + if (!isSameChainUsdc) { + 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..7768fcbba0 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -8,13 +8,12 @@ import AmountInput from '@/components/Global/AmountInput' import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' 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 useGetExchangeRate from '@/hooks/useGetExchangeRate' import { AccountType } from '@/interfaces' import { useRouter, useSearchParams } from 'next/navigation' -import React, { useCallback, useEffect, useMemo, useState, useRef, useContext } from 'react' +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react' import { formatUnits } from 'viem' import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' @@ -28,7 +27,6 @@ type WithdrawStep = 'inputAmount' | 'selectMethod' export default function WithdrawPage() { const router = useRouter() const searchParams = useSearchParams() - const { selectedTokenData } = useContext(tokenSelectorContext) // check if coming from send flow based on method query param const methodParam = searchParams.get('method') @@ -129,7 +127,12 @@ 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 + // no amount-step minimum for crypto: same-chain (Arbitrum) withdrawals + // are direct transfers with no floor, matching send-via-link. Rhino's + // per-network bridge minimums ($0.50, ETH $5, Tron $10) are enforced + // chain-aware at review time (see withdraw/crypto), once the + // destination is known. + if (isCryptoWithdraw) return 0 const localMin = getMinimumAmount(countryIso2) // for US or unknown, minimum is already in USD if (!countryIso2 || countryIso2 === 'US') return localMin @@ -195,9 +198,10 @@ export default function WithdrawPage() { return false } - // convert the entered token amount to USD - const price = selectedTokenData?.price ?? 0 // 0 for safety; will fail below - const usdEquivalent = price ? amount * price : amount // if no price assume token pegged 1 USD + // AmountInput is USD-pinned on this page (price: 1), so the typed + // value IS the USD value — scaling by the app-wide token price let + // a stale non-USD price loosen or false-trip the minimums. + const usdEquivalent = amount // While the balance is still loading, maxDecimalAmount is 0 — skip the // balance check so a pre-filled amount isn't false-blocked; the effect @@ -223,7 +227,7 @@ export default function WithdrawPage() { setError({ showError: true, errorMessage: message }) return false }, - [balance, maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] + [balance, maxDecimalAmount, setError, isFromSendFlow, minUsdAmount] ) const handleTokenAmountChange = useCallback( @@ -274,7 +278,7 @@ export default function WithdrawPage() { const handleAmountContinue = () => { if (validateAmount(rawTokenAmount) && selectedMethod) { setAmountToWithdraw(rawTokenAmount) - const usdVal = (selectedTokenData?.price ?? 1) * parseFloat(rawTokenAmount) + const usdVal = parseFloat(rawTokenAmount) setUsdAmount(usdVal.toString()) posthog.capture(ANALYTICS_EVENTS.WITHDRAW_AMOUNT_ENTERED, { amount_usd: usdVal, @@ -352,13 +356,12 @@ export default function WithdrawPage() { const numericAmount = parseFloat(rawTokenAmount) if (!Number.isFinite(numericAmount) || numericAmount <= 0) return true - const usdEq = (selectedTokenData?.price ?? 1) * numericAmount - if (usdEq < minUsdAmount) return true // below country-specific minimum + if (numericAmount < minUsdAmount) return true // below the method's USD minimum // only apply the balance ceiling once it has loaded (maxDecimalAmount is 0 // while spendableBalance is undefined) — else Continue is disabled during load return (balance !== undefined && numericAmount > maxDecimalAmount) || error.showError - }, [rawTokenAmount, balance, maxDecimalAmount, error.showError, selectedTokenData?.price, minUsdAmount]) + }, [rawTokenAmount, balance, maxDecimalAmount, error.showError, minUsdAmount]) // native app: render country-specific views when ?country= is present const viewFromQuery = searchParams.get('view') diff --git a/src/components/Withdraw/views/Initial.withdraw.view.tsx b/src/components/Withdraw/views/Initial.withdraw.view.tsx index 2231f1df46..fc4ff47e82 100644 --- a/src/components/Withdraw/views/Initial.withdraw.view.tsx +++ b/src/components/Withdraw/views/Initial.withdraw.view.tsx @@ -57,6 +57,19 @@ export default function InitialWithdrawView({ amount, onReview, onBack, isProces } }, [addressFamily, setRecipient, setIsValidRecipient]) + // Changing the destination chain invalidates chain-scoped errors (e.g. the + // per-network minimum block from review, which says "pick a different + // network") — without this the stale error keeps Review disabled after the + // user follows that instruction. Safe for address errors too: Review stays + // gated by isValidRecipient regardless of the error banner. + const prevChainRef = useRef(selectedChainID) + useEffect(() => { + if (prevChainRef.current !== selectedChainID) { + prevChainRef.current = selectedChainID + if (error.showError) setError({ showError: false, errorMessage: '' }) + } + }, [selectedChainID, error.showError, setError]) + const handleReview = () => { // Context record already includes the synthetic non-EVM withdraw // destinations (merged once in tokenSelector.context). diff --git a/src/utils/cross-chain-fee.utils.test.ts b/src/utils/cross-chain-fee.utils.test.ts index f2359c1081..f588b0d4ff 100644 --- a/src/utils/cross-chain-fee.utils.test.ts +++ b/src/utils/cross-chain-fee.utils.test.ts @@ -1,4 +1,12 @@ -import { isWithdrawFeeDisproportionate, HIGH_WITHDRAW_FEE_RATIO } from './cross-chain-fee.utils' +import { + isWithdrawFeeDisproportionate, + getMinWithdrawUsdForChain, + HIGH_WITHDRAW_FEE_RATIO, + MIN_CRYPTO_WITHDRAW_USD, + ETHEREUM_MIN_WITHDRAW_USD, +} from './cross-chain-fee.utils' +import { NON_EVM_WITHDRAW_CHAINS } from '@/constants/chainRegistry.consts' +import chainDetails from '@/constants/chain-details.json' describe('isWithdrawFeeDisproportionate', () => { test('no heads-up for a tiny L2 fee on a normal amount', () => { @@ -41,3 +49,37 @@ describe('isWithdrawFeeDisproportionate', () => { expect(HIGH_WITHDRAW_FEE_RATIO).toBe(0.05) }) }) + +describe('getMinWithdrawUsdForChain', () => { + test('Ethereum mainnet needs $5, string or numeric chainId', () => { + expect(getMinWithdrawUsdForChain('1')).toBe(5) + expect(getMinWithdrawUsdForChain(1)).toBe(5) + expect(getMinWithdrawUsdForChain('1')).toBe(ETHEREUM_MIN_WITHDRAW_USD) + }) + + test('Tron needs $10 — both the picker slug and the numeric id', () => { + // the withdraw picker supplies 'tron' (NON_EVM_WITHDRAW_CHAINS slug) + expect(getMinWithdrawUsdForChain('tron')).toBe(10) + expect(getMinWithdrawUsdForChain('728126428')).toBe(10) + }) + + test('every other network floors at $0.50', () => { + expect(getMinWithdrawUsdForChain('42161')).toBe(0.5) // Arbitrum (same-chain) + expect(getMinWithdrawUsdForChain('8453')).toBe(0.5) // Base + expect(getMinWithdrawUsdForChain('10')).toBe(0.5) // Optimism — NOT Ethereum's '1' + expect(getMinWithdrawUsdForChain('unknown-chain')).toBe(MIN_CRYPTO_WITHDRAW_USD) + }) + + test('the REAL chain records the withdraw flow supplies hit the intended minimums', () => { + // Anti-drift: the original version of this map keyed Tron by its + // numeric chain id, which no picker record ever carries — the $10 + // floor was dead code and the hand-typed-id test passed vacuously. + // Assert against the records the flow actually passes to the guard. + const tron = NON_EVM_WITHDRAW_CHAINS['tron'] + expect(tron).toBeDefined() + expect(getMinWithdrawUsdForChain(tron.chainId)).toBe(10) + + expect((chainDetails as Record)['1']).toBeDefined() + expect(getMinWithdrawUsdForChain('1')).toBe(ETHEREUM_MIN_WITHDRAW_USD) + }) +}) diff --git a/src/utils/cross-chain-fee.utils.ts b/src/utils/cross-chain-fee.utils.ts index f617cb46dc..9b4fa421fa 100644 --- a/src/utils/cross-chain-fee.utils.ts +++ b/src/utils/cross-chain-fee.utils.ts @@ -27,3 +27,33 @@ export function isWithdrawFeeDisproportionate( if (!Number.isFinite(amountUsd) || amountUsd <= 0) return false return feeUsd / amountUsd > threshold } + +/** + * Rhino per-network withdrawal minimums. + * + * Rhino REJECTS a bridge deposit below the route minimum (`UNDER_MIN` webhook) + * and parks the funds at the deposit address — no auto-refund, recovery is a + * manual Rhino support action (2026-07-15 incident: $2.50 → Ethereum stuck). + * So sub-minimum withdrawals must be blocked before funds move. Minimums are + * USD, uniform across tokens on a chain, and driven by the expensive side of + * the route: $0.50 everywhere except Ethereum mainnet ($5) and Tron ($10). + * They apply to RHINO-ROUTED withdrawals only — same-chain (Arbitrum) USDC is + * a direct transfer with no minimum; callers exempt it before consulting this. + * Verified against Rhino's getSupportedTokens API on 2026-07-21. + */ +export const MIN_CRYPTO_WITHDRAW_USD = 0.5 +export const ETHEREUM_MIN_WITHDRAW_USD = 5 + +const CHAIN_MIN_WITHDRAW_USD: Record = { + '1': ETHEREUM_MIN_WITHDRAW_USD, // Ethereum mainnet + // Tron: the withdraw picker's NON_EVM_WITHDRAW_CHAINS entry uses the + // 'tron' slug (chainRegistry.consts.ts), not the numeric chain id — key + // both so neither representation slips past the $10 floor. + tron: 10, + '728126428': 10, +} + +/** Minimum USD amount for a crypto withdrawal to the given destination chain. */ +export function getMinWithdrawUsdForChain(chainId: string | number): number { + return CHAIN_MIN_WITHDRAW_USD[String(chainId)] ?? MIN_CRYPTO_WITHDRAW_USD +}