Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
22 changes: 21 additions & 1 deletion src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 15 additions & 12 deletions src/app/(mobile-ui)/withdraw/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down
13 changes: 13 additions & 0 deletions src/components/Withdraw/views/Initial.withdraw.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
44 changes: 43 additions & 1 deletion src/utils/cross-chain-fee.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<string, unknown>)['1']).toBeDefined()
expect(getMinWithdrawUsdForChain('1')).toBe(ETHEREUM_MIN_WITHDRAW_USD)
})
})
30 changes: 30 additions & 0 deletions src/utils/cross-chain-fee.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
'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
}
Loading