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 65124ae7d3..f74cd0ac0e 100644
--- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx
+++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx
@@ -61,10 +61,13 @@ const mockSetUsdAmount = jest.fn()
const mockSetSelectedBankAccount = jest.fn()
const mockSetSelectedMethod = jest.fn()
const mockSetShowAllWithdrawMethods = jest.fn()
+const mockSetIsMaxWithdrawal = jest.fn()
const mockWithdrawFlow = {
amountToWithdraw: '',
setAmountToWithdraw: mockSetAmountToWithdraw,
+ isMaxWithdrawal: false,
+ setIsMaxWithdrawal: mockSetIsMaxWithdrawal,
setError: mockSetError,
error: { showError: false, errorMessage: '' },
setUsdAmount: mockSetUsdAmount,
@@ -155,6 +158,20 @@ jest.mock('@/components/Global/AmountInput', () => ({
disabled={props.disabled}
/>
{props.walletBalance && {props.walletBalance}}
+ {!!props.balanceFillAmount && (
+
+ )}
),
}))
@@ -472,6 +489,89 @@ describe('GROUP 3: Amount Validation', () => {
)
})
+ test('Marks the amount as a max withdrawal, and unmarks it on any edit', () => {
+ // The flag is what lets the crypto path settle the sub-cent remainder
+ // the displayed 2 decimals leave behind (TASK-21899).
+ mockWithdrawFlow.selectedMethod = { type: 'crypto' }
+ mockUseWallet.mockReturnValue({
+ spendableBalance: parseUnits('12.345678', 6),
+ formattedSpendableBalance: '12.34',
+ hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 12.345678,
+ })
+
+ renderWithdraw()
+
+ fireEvent.click(screen.getByTestId('use-full-balance'))
+ expect(mockSetIsMaxWithdrawal).toHaveBeenLastCalledWith(true)
+
+ fireEvent.change(screen.getByTestId('amount-field'), { target: { value: '5' } })
+ expect(mockSetIsMaxWithdrawal).toHaveBeenLastCalledWith(false)
+ })
+
+ test('Hands down the full-precision balance while the field shows cents', () => {
+ // The page passes the number its own validation compares against, not
+ // the rounded label; the input is what floors it for display, and the
+ // crypto path recovers the remainder from the flag (TASK-21899).
+ mockWithdrawFlow.selectedMethod = { type: 'crypto' }
+ mockUseWallet.mockReturnValue({
+ spendableBalance: parseUnits('12.345678', 6),
+ formattedSpendableBalance: '12.34',
+ hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 12.345678,
+ })
+
+ renderWithdraw()
+ expect(screen.getByTestId('use-full-balance')).toHaveAttribute('data-fill', '12.345678')
+
+ fireEvent.click(screen.getByTestId('use-full-balance'))
+
+ expect(screen.getByTestId('amount-field')).toHaveValue('12.34')
+ expect(screen.getByText('Continue')).not.toBeDisabled()
+ })
+
+ test('Full balance passes validation and continues with that amount', () => {
+ mockWithdrawFlow.selectedMethod = { type: 'crypto' }
+
+ renderWithdraw()
+ fireEvent.click(screen.getByTestId('use-full-balance'))
+
+ const continueBtn = screen.getByText('Continue')
+ expect(continueBtn).not.toBeDisabled()
+
+ fireEvent.click(continueBtn)
+ expect(mockSetAmountToWithdraw).toHaveBeenCalledWith('100')
+ })
+
+ test('Full balance below the method minimum keeps Continue disabled', async () => {
+ mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' }
+ mockUseWallet.mockReturnValue({
+ spendableBalance: parseUnits('0.5', 6),
+ formattedSpendableBalance: '0.50',
+ hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 0.5,
+ })
+
+ renderWithdraw()
+ fireEvent.click(screen.getByTestId('use-full-balance'))
+
+ expect(screen.getByText('Continue')).toBeDisabled()
+ // Same channel as a typed sub-minimum amount: the field's own error,
+ // never the flow-level setError.
+ await waitFor(() => expect(screen.getByTestId('error-alert')).toHaveTextContent('Minimum withdrawal is $1.'))
+ })
+
+ test('No fill action while the balance is still loading', () => {
+ mockWithdrawFlow.selectedMethod = { type: 'crypto' }
+ mockUseWallet.mockReturnValue({
+ spendableBalance: undefined,
+ formattedSpendableBalance: '0.00',
+ hasSufficientSpendableBalance: () => false,
+ })
+
+ renderWithdraw()
+
+ expect(screen.queryByTestId('use-full-balance')).not.toBeInTheDocument()
+ expect(screen.getByText('Continue')).toBeDisabled()
+ })
+
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
diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx
index 5c564c3249..028a5b6b17 100644
--- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx
+++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx
@@ -78,12 +78,17 @@ jest.mock('@/utils/cross-chain-fee.utils', () => ({
isWithdrawFeeDisproportionate: () => false,
}))
-jest.mock('@/utils/balance.utils', () => ({
- isAmountWithinBalance: () => true,
-}))
+// NOT stubbed: `isAmountWithinBalance` is the pre-sign affordability gate, and
+// a stub that always says "affordable" cannot fail the way the real comparison
+// does. The fixtures below fund the wallet well above the amount, so the
+// existing cases are unaffected; the balance-gate suite drives it to the edge.
+jest.mock('@/utils/balance.utils', () => jest.requireActual('@/utils/balance.utils'))
jest.mock('@/utils/withdraw.utils', () => ({
isBelowRhinoMinDeposit: () => false,
+ // real behaviour, covered by src/utils/__tests__/withdraw.utils.test.ts —
+ // these suites drive the non-max path, where it returns the amount as-is
+ resolveWithdrawAmount: jest.requireActual('@/utils/withdraw.utils').resolveWithdrawAmount,
}))
jest.mock('@/utils/general.utils', () => ({
@@ -119,11 +124,20 @@ jest.mock('@/services/requests', () => ({
jest.mock('@/components/Withdraw/views/Confirm.withdraw.view', () => ({
__esModule: true,
- default: (props: { onConfirm: () => void }) => (
-
- ),
+ default: (props: { onConfirm: () => void; insufficientBalance?: boolean; error?: string | null }) =>
+ // Mirrors the real view: the normal CTA honours insufficientBalance, but
+ // the error-state Retry renders with disabled={false}. Do NOT "fix" that
+ // here — a mock that disables Retry too would hide the gap between the
+ // gate and the retry path.
+ props.error ? (
+
+ ) : (
+
+ ),
}))
jest.mock('@/components/Withdraw/views/Initial.withdraw.view', () => ({
@@ -190,8 +204,13 @@ const withdrawData = {
amount: '50',
}
+const mockSetPreparedAmount = jest.fn()
const mockWithdrawFlow = {
amountToWithdraw: '50',
+ isMaxWithdrawal: false,
+ setIsMaxWithdrawal: jest.fn(),
+ preparedAmount: null as string | null,
+ setPreparedAmount: mockSetPreparedAmount,
usdAmount: '50',
setAmountToWithdraw: jest.fn(),
currentView: 'CONFIRM',
@@ -219,13 +238,15 @@ jest.mock('@/context/WithdrawFlowContext', () => ({
const mockSendMoney = jest.fn()
const mockSendTransactions = jest.fn()
+/** Mutable so the balance-gate suite can drive the comparison to the edge. */
+const mockWallet = { spendableBalance: 100n * 10n ** 6n }
jest.mock('@/hooks/wallet/useWallet', () => ({
useWallet: () => ({
isConnected: true,
address: USER_ADDRESS,
sendMoney: mockSendMoney,
sendTransactions: mockSendTransactions,
- spendableBalance: 100n * 10n ** 6n,
+ spendableBalance: mockWallet.spendableBalance,
}),
}))
@@ -628,3 +649,131 @@ describe('crypto withdraw retry — record-only replay (TASK-19581 double-spend)
expect(mockSendMoney).toHaveBeenCalledTimes(2)
})
})
+
+describe('crypto withdraw confirm — pre-sign balance gate (real balance math)', () => {
+ // The gate now covers the same-chain path too — the highest-volume route,
+ // and the one "use full balance" is built for. Nothing stubs the comparison
+ // in this suite, so these run the real bigint math.
+ const BALANCE = 50n * 10n ** 6n
+
+ afterEach(() => {
+ mockWallet.spendableBalance = 100n * 10n ** 6n
+ Object.assign(mockWithdrawFlow, { isMaxWithdrawal: false })
+ Object.assign(mockCrossChainTransfer, { payAmount: '50' })
+ })
+
+ it('a full-balance same-chain withdraw at exact equality keeps the CTA enabled', () => {
+ mockWallet.spendableBalance = BALANCE
+ Object.assign(mockWithdrawFlow, { isMaxWithdrawal: true })
+ // What the CHARGE records: usdValue / token.price, and a USDC price of
+ // 0.9999 is routine from a feed. The kernel still sends effectiveAmount,
+ // so gating on this number would refuse a withdrawal that fits.
+ Object.assign(mockCrossChainTransfer, { payAmount: '50.005001' })
+
+ render()
+
+ expect(screen.getByTestId('confirm-withdraw')).toBeEnabled()
+ })
+
+ it('one base unit short disables the CTA', () => {
+ mockWallet.spendableBalance = BALANCE - 1n
+ Object.assign(mockWithdrawFlow, { isMaxWithdrawal: true })
+
+ render()
+
+ expect(screen.getByTestId('confirm-withdraw')).toBeDisabled()
+ })
+
+ it('cross-chain still gates on the quote pay side, which is what the kernel sends', () => {
+ mockWallet.spendableBalance = BALANCE
+ Object.assign(mockCrossChainTransfer, { isXChain: true, payAmount: '50.01' })
+ try {
+ render()
+ expect(screen.getByTestId('confirm-withdraw')).toBeDisabled()
+ } finally {
+ Object.assign(mockCrossChainTransfer, { isXChain: false })
+ }
+ })
+})
+
+describe('crypto withdraw — the spend is frozen with the charge', () => {
+ afterEach(() => {
+ mockWallet.spendableBalance = 100n * 10n ** 6n
+ Object.assign(mockWithdrawFlow, { amountToWithdraw: '50', isMaxWithdrawal: false, preparedAmount: null })
+ })
+
+ // The feature exists to drain the dust. Nothing asserted the amount that
+ // actually leaves the wallet, so reverting page.tsx's sendMoney argument to
+ // `amountToWithdraw` left the suite green while the remainder stayed
+ // stranded — displaying as $0.00 and never withdrawable.
+ it('a max withdrawal sends the sub-cent remainder, not the displayed cents', async () => {
+ Object.assign(mockWithdrawFlow, { isMaxWithdrawal: true, preparedAmount: '50.006123' })
+ mockSendMoney.mockResolvedValue({
+ txHash: '0xsent',
+ userOpHash: undefined,
+ receipt: { transactionHash: '0xsent', status: 'success' },
+ strategy: 'smart-only',
+ intentId: undefined,
+ })
+
+ render()
+ fireEvent.click(screen.getByTestId('confirm-withdraw'))
+
+ await waitFor(() => expect(mockSendMoney).toHaveBeenCalled())
+ expect(mockSendMoney).toHaveBeenCalledWith(RECIPIENT, '50.006123', expect.anything())
+ })
+
+ // The charge records one number and the API validator settles against it.
+ // Deriving the spend live let the balance move underneath while both values
+ // still floored to the displayed cents, so the wallet would underpay its
+ // own charge.
+ it('a balance drop after the charge is prepared does not change what is sent', async () => {
+ // The displayed cents (10.12) are what the old resolver compared against,
+ // so a drop to 10.121111 still "matched" and was silently adopted — an
+ // underpayment of the 10.126123 charge the validator settles against.
+ Object.assign(mockWithdrawFlow, {
+ amountToWithdraw: '10.12',
+ isMaxWithdrawal: true,
+ preparedAmount: '10.126123',
+ })
+ mockWallet.spendableBalance = 10_121111n
+ mockSendMoney.mockResolvedValue({
+ txHash: '0xsent',
+ userOpHash: undefined,
+ receipt: { transactionHash: '0xsent', status: 'success' },
+ strategy: 'smart-only',
+ intentId: undefined,
+ })
+
+ render()
+ fireEvent.click(screen.getByTestId('confirm-withdraw'))
+
+ // The gate refuses it — the frozen spend no longer fits the balance — and
+ // above all the drifted 10.121111 is never what gets signed.
+ expect(screen.getByTestId('confirm-withdraw')).toBeDisabled()
+ expect(mockSendMoney).not.toHaveBeenCalledWith(RECIPIENT, '10.121111', expect.anything())
+ expect(mockSendMoney).not.toHaveBeenCalled()
+ })
+
+ it('a balance rise after the charge is prepared does not enlarge what is sent', async () => {
+ Object.assign(mockWithdrawFlow, {
+ amountToWithdraw: '10.12',
+ isMaxWithdrawal: true,
+ preparedAmount: '10.126123',
+ })
+ mockWallet.spendableBalance = 10_129999n
+ mockSendMoney.mockResolvedValue({
+ txHash: '0xsent',
+ userOpHash: undefined,
+ receipt: { transactionHash: '0xsent', status: 'success' },
+ strategy: 'smart-only',
+ intentId: undefined,
+ })
+
+ render()
+ fireEvent.click(screen.getByTestId('confirm-withdraw'))
+
+ await waitFor(() => expect(mockSendMoney).toHaveBeenCalled())
+ expect(mockSendMoney).toHaveBeenCalledWith(RECIPIENT, '10.126123', expect.anything())
+ })
+})
diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx
index 3e185ebe7e..e850b2778a 100644
--- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx
@@ -20,7 +20,7 @@ import type {
import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils'
import { isWithdrawFeeDisproportionate, getMinWithdrawUsdForChain } from '@/utils/cross-chain-fee.utils'
import { isAmountWithinBalance } from '@/utils/balance.utils'
-import { isBelowRhinoMinDeposit } from '@/utils/withdraw.utils'
+import { isBelowRhinoMinDeposit, resolveWithdrawAmount } from '@/utils/withdraw.utils'
import * as peanutInterfaces from '@/interfaces/peanut-sdk-types'
import { useRouter } from 'next/navigation'
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
@@ -65,6 +65,9 @@ export default function WithdrawCryptoPage() {
const { resetTokenContextProvider } = useContext(tokenSelectorContext)
const {
amountToWithdraw,
+ isMaxWithdrawal,
+ preparedAmount,
+ setPreparedAmount,
usdAmount,
currentView,
setCurrentView,
@@ -151,13 +154,31 @@ export default function WithdrawCryptoPage() {
resetPaymentRecorder()
}, [setChargeDetails, setTransactionHash, setPaymentDetails, resetRouteCalculation, resetPaymentRecorder])
+ // What a withdrawal prepared RIGHT NOW would move: the amount on screen,
+ // plus the sub-cent remainder when the user tapped "use full balance" and
+ // did not edit it. Tracks the live balance, so it is only read at the
+ // moment the charge is built. See resolveWithdrawAmount for the guard
+ // rails (TASK-21899).
+ const liveResolvedAmount = useMemo(
+ () => resolveWithdrawAmount(amountToWithdraw, spendableBalance, isMaxWithdrawal, PEANUT_WALLET_TOKEN_DECIMALS),
+ [amountToWithdraw, spendableBalance, isMaxWithdrawal]
+ )
+
+ // What THIS withdrawal moves. Once a charge exists, the amount it was built
+ // from is the only one that may be quoted, gated or sent — the charge is
+ // what the API validator settles against, and the live figure keeps moving
+ // under it. See WithdrawFlowContext.preparedAmount.
+ const effectiveAmount = preparedAmount ?? liveResolvedAmount
+
// clear errors when amount changes
useEffect(() => {
if (amountToWithdraw) {
clearErrors()
setChargeDetails(null)
+ // The charge is gone, so the amount frozen against it is too.
+ setPreparedAmount(null)
}
- }, [amountToWithdraw, clearErrors, setChargeDetails])
+ }, [amountToWithdraw, clearErrors, setChargeDetails, setPreparedAmount])
// propagate route/record errors
useEffect(() => {
@@ -177,9 +198,9 @@ export default function WithdrawCryptoPage() {
address: address as Address,
tokenAddress: PEANUT_WALLET_TOKEN as Address,
chainId: PEANUT_WALLET_CHAIN.id.toString(),
- // amountToWithdraw is USD-denominated; source token is USDC (1:1).
- // Required for the bridge path's 'pay' mode (cross-chain ETH/etc).
- tokenAmount: amountToWithdraw,
+ // effectiveAmount is USD-denominated; source token is USDC (1:1).
+ // It sizes the pay-mode quote on every cross-chain path.
+ tokenAmount: effectiveAmount,
},
destination: {
recipientAddress: chargeDetails.requestLink.recipientAddress as Address,
@@ -194,7 +215,7 @@ export default function WithdrawCryptoPage() {
senderPeanutWalletAddress: address as Address,
skipGasEstimate: true, // peanut wallet handles gas
})
- }, [chargeDetails, withdrawData, calculateRoute, address, amountToWithdraw])
+ }, [chargeDetails, withdrawData, calculateRoute, address, effectiveAmount])
// prepare transaction when entering confirm view
useEffect(() => {
@@ -209,6 +230,11 @@ export default function WithdrawCryptoPage() {
return
}
+ // Resolve ONCE, here. Everything this function decides — the minimum
+ // check, the destination token amount, the charge — must come from
+ // the same number, and that number is what gets frozen below.
+ const spendAmount = liveResolvedAmount
+
// 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
@@ -218,7 +244,7 @@ export default function WithdrawCryptoPage() {
data.chain.chainId.toString() === PEANUT_WALLET_CHAIN.id.toString() &&
data.token.address.toLowerCase() === PEANUT_WALLET_TOKEN.toLowerCase()
if (!isSameChainUsdc) {
- const usdToWithdraw = parseFloat(amountToWithdraw)
+ const usdToWithdraw = parseFloat(spendAmount)
const minUsd = getMinWithdrawUsdForChain(data.chain.chainId)
if (!Number.isFinite(usdToWithdraw) || usdToWithdraw < minUsd) {
const minDisplay = minUsd % 1 === 0 ? `$${minUsd}` : `$${minUsd.toFixed(2)}`
@@ -231,6 +257,9 @@ export default function WithdrawCryptoPage() {
clearErrors()
setChargeDetails(null)
+ // Re-arm: this preparation decides the amount afresh from the live
+ // balance, then freezes it below.
+ setPreparedAmount(null)
setIsPreparingReview(true)
try {
@@ -239,10 +268,10 @@ export default function WithdrawCryptoPage() {
// units before persisting the request/charge — otherwise meta
// ends up with `tokenAmount: "1"` + `tokenSymbol: "ETH"` and
// history renders "1 ETH" for what was actually a $1 withdraw.
- const usdValue = parseFloat(amountToWithdraw)
+ const usdValue = parseFloat(spendAmount)
const tokenPrice = data.token.price ?? 0
const destinationTokenAmount =
- tokenPrice > 0 ? (usdValue / tokenPrice).toFixed(Number(data.token.decimals)) : amountToWithdraw
+ tokenPrice > 0 ? (usdValue / tokenPrice).toFixed(Number(data.token.decimals)) : spendAmount
const completeWithdrawData = { ...data, amount: destinationTokenAmount }
setWithdrawData(completeWithdrawData)
@@ -296,6 +325,10 @@ export default function WithdrawCryptoPage() {
const fullChargeDetails = await chargesApi.get(createdCharge.data.id)
+ // Frozen with the charge, not before it: a failure above leaves
+ // the flow re-armed rather than pinned to an amount that never
+ // reached the backend.
+ setPreparedAmount(spendAmount)
setChargeDetails(fullChargeDetails)
setShowCompatibilityModal(true)
} catch (err) {
@@ -308,6 +341,8 @@ export default function WithdrawCryptoPage() {
},
[
amountToWithdraw,
+ liveResolvedAmount,
+ setPreparedAmount,
clearErrors,
setChargeDetails,
setIsPreparingReview,
@@ -414,7 +449,7 @@ export default function WithdrawCryptoPage() {
txHash,
receipt: r,
strategy: s,
- } = await sendMoney(withdrawData.address as Address, amountToWithdraw, {
+ } = await sendMoney(withdrawData.address as Address, effectiveAmount, {
kind: 'CRYPTO_WITHDRAW',
// Lets the backend settle the charge directly when the spend
// routes through Rain card collateral (collateral-only): the
@@ -556,6 +591,7 @@ export default function WithdrawCryptoPage() {
chargeDetails,
withdrawData,
amountToWithdraw,
+ effectiveAmount,
address,
transactions,
payAmount,
@@ -611,21 +647,32 @@ export default function WithdrawCryptoPage() {
[isCrossChainWithdrawal, networkFee, usdAmount]
)
- // Pre-sign affordability gate for cross-chain. The input-time gate only
- // checked the principal, but the kernel must spend principal + bridge fee
- // (`payAmount`), so a withdraw that fit the balance at input can fall short
- // here once the fee is known — and the send would surface the misleading
- // "balance isn't fully available yet" (settling) error instead of an honest
- // "not enough balance". Block it here with the right message. Only once the
- // quote has resolved `payAmount` (skipped while calculating; CTA is disabled
- // by isCalculating anyway).
- const insufficientForFee = useMemo(
+ // Pre-sign affordability gate on every path: what the kernel spends must fit
+ // the LIVE balance. The input-time gate saw the balance at input; a card
+ // spend settling, another withdrawal landing first, or a quoted fee can
+ // leave it short here — and the send would surface the misleading "balance
+ // isn't fully available yet" (settling) error instead of an honest "not
+ // enough balance".
+ //
+ // The spend is not the same number on both paths. Cross-chain the kernel
+ // sends the quote's pay side (`payAmount`, via requiredUsdcAmount); it is
+ // null until the route resolves, so the gate simply doesn't fire while
+ // calculating — the CTA is disabled by isCalculating anyway. Same-chain the
+ // kernel sends `effectiveAmount` (frozen with the charge), and `payAmount`
+ // there is the CHARGE's
+ // destination amount (`usdValue / token.price`) — so a routine USDC price of
+ // 0.9999 makes it a few base units more than the balance on a full-balance
+ // withdrawal, which would disable the CTA on a send that would have
+ // succeeded. Gating on the frozen amount is also what makes the gate honest:
+ // it is the number that will actually leave the wallet.
+ const kernelSpend = isCrossChainWithdrawal ? payAmount : effectiveAmount
+ const insufficientBalance = useMemo(
() =>
- isCrossChainWithdrawal &&
- payAmount != null &&
+ kernelSpend != null &&
+ kernelSpend !== '' &&
spendableBalance !== undefined &&
- !isAmountWithinBalance(payAmount, spendableBalance),
- [isCrossChainWithdrawal, payAmount, spendableBalance]
+ !isAmountWithinBalance(kernelSpend, spendableBalance),
+ [kernelSpend, spendableBalance]
)
// Rhino accepts SDA deposits below the route minimum on-chain but never
@@ -683,7 +730,7 @@ export default function WithdrawCryptoPage() {
receiveAmount={receiveAmount}
payAmount={payAmount}
showHighFeeWarning={showHighFeeWarning}
- insufficientBalance={insufficientForFee}
+ insufficientBalance={insufficientBalance}
belowMinimumMessage={belowMinimumMessage}
isFromSendFlow={isFromSendFlow}
/>
diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx
index 4ca3b8c0d9..57a47df713 100644
--- a/src/app/(mobile-ui)/withdraw/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/page.tsx
@@ -59,6 +59,7 @@ export default function WithdrawPage() {
const {
amountToWithdraw: amountFromContext,
setAmountToWithdraw,
+ setIsMaxWithdrawal,
setError,
error,
setUsdAmount,
@@ -253,6 +254,18 @@ export default function WithdrawPage() {
[balance, maxDecimalAmount, selectedTokenData?.price, isFromSendFlow, minUsdAmount, t, tErrors]
)
+ // The exact string the balance tap last filled. Any other value reaching
+ // handleTokenAmountChange is the user typing, which retires the max intent.
+ const filledFromBalanceRef = useRef(null)
+
+ const handleBalanceFilled = useCallback(
+ (value: string) => {
+ filledFromBalanceRef.current = value
+ setIsMaxWithdrawal(true)
+ },
+ [setIsMaxWithdrawal]
+ )
+
const handleTokenAmountChange = useCallback(
(value: string | undefined) => {
let newValue = value || ''
@@ -262,6 +275,11 @@ export default function WithdrawPage() {
}
setRawTokenAmount(newValue)
+ if (newValue !== filledFromBalanceRef.current) {
+ filledFromBalanceRef.current = null
+ setIsMaxWithdrawal(false)
+ }
+
// ignore programmatically injected tiny residual amounts (<1) before user interaction
const numericVal = parseFloat(newValue)
if (!userTypedRef.current && numericVal > 0 && numericVal < 1) {
@@ -451,6 +469,8 @@ export default function WithdrawPage() {
decimals: 6, // we want USDC decimals to be able to pay exactly
}}
walletBalance={peanutWalletBalance}
+ balanceFillAmount={maxDecimalAmount}
+ onBalanceFilled={handleBalanceFilled}
hideCurrencyToggle
/>
diff --git a/src/components/Global/AmountInput/__tests__/balance-fill.test.tsx b/src/components/Global/AmountInput/__tests__/balance-fill.test.tsx
new file mode 100644
index 0000000000..9ea9d8c636
--- /dev/null
+++ b/src/components/Global/AmountInput/__tests__/balance-fill.test.tsx
@@ -0,0 +1,170 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { renderWithIntl } from '@/test-utils/intl'
+import AmountInput from '@/components/Global/AmountInput'
+
+/**
+ * Tapping the balance amount fills the whole spendable amount (TASK-21899).
+ * The point of these tests is that the fill is floored to cents and can never
+ * exceed the balance — the user is never told they can withdraw more than
+ * they hold, and the fill matches the label, which truncates the same way.
+ */
+
+// USDC, as the withdraw amount screen configures it
+const USDC = { symbol: '$', price: 1, decimals: 6 }
+
+function setup(props: Partial> = {}) {
+ const setPrimaryAmount = jest.fn()
+ renderWithIntl(
+
+ )
+ const field = screen.getByRole('textbox') as HTMLInputElement
+ return {
+ setPrimaryAmount,
+ field,
+ useFullBalance: () => screen.queryByRole('button', { name: /use full balance/i }),
+ lastReported: () => setPrimaryAmount.mock.lastCall?.[0],
+ }
+}
+
+describe('AmountInput full-balance fill', () => {
+ it('fills the balance floored to cents', () => {
+ const { field, useFullBalance, lastReported } = setup()
+
+ fireEvent.click(useFullBalance()!)
+
+ expect(field.value).toBe('12.34')
+ expect(lastReported()).toBe('12.34')
+ })
+
+ it('rounds down, never up, so the fill cannot exceed the balance', () => {
+ // 10.126123 must become 10.12, not 10.13 — the 0.006123 stays behind.
+ const { field, useFullBalance } = setup({ walletBalance: '10.12', balanceFillAmount: 10.126123 })
+
+ fireEvent.click(useFullBalance()!)
+
+ expect(field.value).toBe('10.12')
+ expect(Number(field.value)).toBeLessThanOrEqual(10.126123)
+ })
+
+ it('stays at cents even when the field accepts more decimals', () => {
+ // The withdraw screen runs this input at 6 decimals so a user CAN type
+ // them; the fill still stops at the two the balance label shows.
+ const { field, useFullBalance } = setup({
+ primaryDenomination: { symbol: '$', price: 1, decimals: 6 },
+ balanceFillAmount: 12.345678,
+ })
+
+ fireEvent.click(useFullBalance()!)
+
+ expect(field.value).toBe('12.34')
+ })
+
+ it('does not fill more decimals than a coarse denomination holds', () => {
+ const { field, useFullBalance } = setup({
+ primaryDenomination: { symbol: '$', price: 1, decimals: 0 },
+ balanceFillAmount: 12.345678,
+ })
+
+ fireEvent.click(useFullBalance()!)
+
+ expect(field.value).toBe('12')
+ })
+
+ it('makes only the amount tappable, not the word Balance', () => {
+ const { useFullBalance } = setup()
+
+ expect(useFullBalance()).toHaveTextContent('$12.34')
+ expect(useFullBalance()).not.toHaveTextContent(/Balance/)
+ expect(screen.getByText('Balance:')).toBeInTheDocument()
+ })
+
+ it('writes the symbol against the number, and an ISO code apart from it', () => {
+ const { unmount } = renderWithIntl(
+
+ )
+ expect(screen.getByText('Balance: $12.34')).toBeInTheDocument()
+ unmount()
+
+ renderWithIntl(
+
+ )
+ expect(screen.getByText('Balance: USD 12.34')).toBeInTheDocument()
+ })
+
+ it('keeps the balance plain text when there is nothing to withdraw', () => {
+ const { field, useFullBalance, setPrimaryAmount } = setup({
+ walletBalance: '0.00',
+ balanceFillAmount: 0,
+ })
+
+ expect(useFullBalance()).toBeNull()
+ expect(screen.getByText(/Balance:/)).toBeInTheDocument()
+ expect(field.value).toBe('')
+ expect(setPrimaryAmount).not.toHaveBeenCalledWith(expect.stringMatching(/[1-9]/))
+ })
+
+ it('keeps the balance plain text when it is smaller than a cent', () => {
+ const { field, useFullBalance } = setup({
+ walletBalance: '0.00',
+ balanceFillAmount: 0.004,
+ })
+
+ expect(useFullBalance()).toBeNull()
+ expect(field.value).toBe('')
+ })
+
+ it('restores the full balance after a manual edit', () => {
+ const { field, useFullBalance, lastReported } = setup()
+
+ fireEvent.click(useFullBalance()!)
+ fireEvent.change(field, { target: { value: '5' } })
+ expect(lastReported()).toBe('5')
+
+ fireEvent.click(useFullBalance()!)
+
+ expect(field.value).toBe('12.34')
+ expect(lastReported()).toBe('12.34')
+ })
+
+ it('does not open the keyboard over the CTA when filling', () => {
+ // The form wrapper focuses the field on any click inside it; the fill
+ // button must not ride that path.
+ const { field, useFullBalance } = setup()
+ field.blur()
+
+ fireEvent.click(useFullBalance()!)
+
+ expect(document.activeElement).not.toBe(field)
+ expect(field.value).toBe('12.34')
+ })
+
+ it('reports the fill separately, so the parent can tell it from typing', () => {
+ const onBalanceFilled = jest.fn()
+ const { field, useFullBalance } = setup({ onBalanceFilled })
+
+ fireEvent.click(useFullBalance()!)
+ expect(onBalanceFilled).toHaveBeenCalledWith('12.34')
+
+ onBalanceFilled.mockClear()
+ fireEvent.change(field, { target: { value: '5' } })
+ expect(onBalanceFilled).not.toHaveBeenCalled()
+ })
+
+ it('does not offer the fill while the input is disabled', () => {
+ const { useFullBalance } = setup({ disabled: true })
+
+ expect(useFullBalance()).toBeNull()
+ })
+})
diff --git a/src/components/Global/AmountInput/index.tsx b/src/components/Global/AmountInput/index.tsx
index 30d14961cf..4241a9e9c0 100644
--- a/src/components/Global/AmountInput/index.tsx
+++ b/src/components/Global/AmountInput/index.tsx
@@ -24,6 +24,13 @@ interface AmountInputProps {
secondaryDenomination?: { symbol: string; price: number; decimals: number }
setCurrentDenomination?: (denomination: string) => void
walletBalance?: string
+ /**
+ * Exact amount, in the primary denomination, that tapping the balance row
+ * fills in. Omit to keep the balance row plain text.
+ */
+ balanceFillAmount?: number
+ /** Called with the amount actually filled when the balance row is tapped. */
+ onBalanceFilled?: (value: string) => void
hideCurrencyToggle?: boolean
hideBalance?: boolean
infoContent?: React.ReactNode
@@ -49,6 +56,8 @@ const AmountInput = ({
secondaryDenomination,
setCurrentDenomination,
walletBalance,
+ balanceFillAmount,
+ onBalanceFilled,
hideCurrencyToggle,
hideBalance,
infoContent,
@@ -239,6 +248,40 @@ const AmountInput = ({
}
}, [defaultSliderSuggestedAmount])
+ // What tapping the balance row fills in, or undefined when the row stays
+ // plain text. Computed from the number the parent validates against, never
+ // parsed back out of the label. Floored to the 2 decimals the balance label
+ // shows — that label truncates too (formatNumberForDisplay, roundingMode
+ // 'trunc'), so the filled amount and the number under the user's thumb
+ // always agree, and neither can claim more than the wallet holds. Anything
+ // finer than a cent stays behind on purpose (TASK-21899).
+ const fillValue = useMemo(() => {
+ if (disabled || !balanceFillAmount || balanceFillAmount <= 0) return undefined
+ // The amount is denominated in the primary unit, so it must not be
+ // filled into a field the user toggled to the secondary one.
+ if (displaySymbol !== primaryDenomination.symbol) return undefined
+ // A denomination coarser than cents still wins — filling 10.12 into a
+ // whole-number field would show an amount it can't hold.
+ const decimals = Math.min(2, denominations[displaySymbol]?.decimals ?? 2)
+ // forInput slices the fraction instead of rounding it, so this floors.
+ const formatted = formatTokenAmount(String(balanceFillAmount), decimals, true)
+ // Anything the field can't express — a balance under a cent, or a
+ // magnitude String() writes in exponential notation — formats to "0"/"".
+ // Leave the row inert rather than offering an amount that can't be used.
+ return formatted && Number(formatted) ? formatted : undefined
+ }, [disabled, balanceFillAmount, displaySymbol, primaryDenomination.symbol, denominations])
+
+ const fillBalance = useCallback(() => {
+ if (!fillValue) return
+ isEditingRef.current = true
+ setDisplayValue(fillValue)
+ setExactValue(Number(fillValue) * 10 ** DECIMAL_SCALE)
+ // Reported separately from setPrimaryAmount, which cannot tell a filled
+ // amount from a typed one — the withdraw screen needs that distinction
+ // to know the user asked for "everything".
+ onBalanceFilled?.(fillValue)
+ }, [fillValue, onBalanceFilled])
+
const inputRef = useRef(null)
// set input width based on display value length
// add extra space for decimal numbers to prevent cutoff
@@ -333,12 +376,42 @@ const AmountInput = ({
)}
{/* Balance */}
- {walletBalance && !hideBalance && (
-
- )}
+ {walletBalance &&
+ !hideBalance &&
+ (() => {
+ // A symbol sits against the number ($10.12), an ISO code
+ // takes a space (USD 10.12) — the CLDR rule for en-US,
+ // which is how the amount itself is formatted.
+ const balanceAmount = `${secondaryDenomination ? 'USD ' : '$'}${walletBalance}`
+ if (!fillValue) {
+ return (
+
+ )
+ }
+ // Only the amount is the action — "Balance:" stays a label,
+ // so the underline marks exactly what the tap fills in.
+ return (
+
+ {t('amountInput.balance')}
+
+
+ )
+ })()}
{/* Conversion toggle */}
{showConversion && (
diff --git a/src/context/WithdrawFlowContext.tsx b/src/context/WithdrawFlowContext.tsx
index 44d564b8d2..0ba1ed707a 100644
--- a/src/context/WithdrawFlowContext.tsx
+++ b/src/context/WithdrawFlowContext.tsx
@@ -36,6 +36,30 @@ export interface RecipientState {
interface WithdrawFlowContextType {
amountToWithdraw: string
setAmountToWithdraw: (amount: string) => void
+ /**
+ * The user filled the amount by tapping their balance and has not edited it
+ * since, so they asked for "everything" rather than for the rounded number
+ * on screen. `amountToWithdraw` stays at the 2 decimals they saw; the crypto
+ * path reads this to settle the sub-cent remainder too (TASK-21899).
+ */
+ isMaxWithdrawal: boolean
+ setIsMaxWithdrawal: (isMax: boolean) => void
+ /**
+ * The exact amount the withdrawal will move, frozen at the moment the
+ * request/charge is created from it.
+ *
+ * A max withdrawal resolves to the live balance, which keeps moving. The
+ * charge does not: it records one number, and the API validator settles
+ * against that number. Deriving the spend live through the confirm screen
+ * let the two drift apart while both still floored to the same displayed
+ * cents — the wallet would send less than the charge required, and the
+ * validator would reject the underpayment (or, on the trusted collateral
+ * path, complete and book the stale requested amount). Whatever the charge
+ * was built from is what gets sent. Null until a charge is prepared, and
+ * cleared whenever the amount is edited (TASK-21899).
+ */
+ preparedAmount: string | null
+ setPreparedAmount: (amount: string | null) => void
usdAmount: string
setUsdAmount: (amount: string) => void
currentView: WithdrawView
@@ -76,6 +100,8 @@ const WithdrawFlowContext = createContext(u
export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [amountToWithdraw, setAmountToWithdraw] = useState('')
+ const [isMaxWithdrawal, setIsMaxWithdrawal] = useState(false)
+ const [preparedAmount, setPreparedAmount] = useState(null)
const [usdAmount, setUsdAmount] = useState('')
const [currentView, setCurrentView] = useState('INITIAL')
const [withdrawData, setWithdrawData] = useState(null)
@@ -101,6 +127,8 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({
const resetWithdrawFlow = useCallback(() => {
setAmountToWithdraw('')
+ setIsMaxWithdrawal(false)
+ setPreparedAmount(null)
// browser-back with the compatibility modal open leaves it armed for the
// next /withdraw/crypto entry — reset must close it like everything else
setShowCompatibilityModal(false)
@@ -123,6 +151,10 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({
() => ({
amountToWithdraw,
setAmountToWithdraw,
+ isMaxWithdrawal,
+ setIsMaxWithdrawal,
+ preparedAmount,
+ setPreparedAmount,
usdAmount,
setUsdAmount,
currentView,
@@ -159,6 +191,8 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({
}),
[
amountToWithdraw,
+ isMaxWithdrawal,
+ preparedAmount,
currentView,
withdrawData,
showCompatibilityModal,
diff --git a/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts b/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts
index 99c1df1363..e73b67796d 100644
--- a/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts
+++ b/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts
@@ -83,7 +83,7 @@ describe('useCrossChainTransfer — feeUsd is the quote, verbatim', () => {
})
})
- it('SDA path: sends depositor/recipient to the preview and exposes feeUsd and payAmount as quoted', async () => {
+ it('SDA withdraw: quotes pay mode by the source amount, so a full-balance withdraw never needs more than the balance', async () => {
mockPreviewSdaTransfer.mockResolvedValue(quote(0))
const { result } = renderHook(() => useCrossChainTransfer())
@@ -105,7 +105,7 @@ describe('useCrossChainTransfer — feeUsd is the quote, verbatim', () => {
})
expect(mockPreviewSdaTransfer).toHaveBeenCalledWith(
- expect.objectContaining({ depositor: KERNEL, recipient: RECIPIENT, mode: 'receive', amount: '10' })
+ expect.objectContaining({ depositor: KERNEL, recipient: RECIPIENT, mode: 'pay', amount: '10' })
)
expect(result.current.path).toBe('sda')
expect(result.current.feeUsd).toBe(0)
@@ -115,6 +115,31 @@ describe('useCrossChainTransfer — feeUsd is the quote, verbatim', () => {
expect(result.current.error).toBeNull()
})
+ it('SDA pay-request: quotes receive mode by the destination amount (the payer covers any fee)', async () => {
+ mockPreviewSdaTransfer.mockResolvedValue(quote(0))
+ const { result } = renderHook(() => useCrossChainTransfer())
+
+ await act(async () => {
+ await result.current.calculate({
+ source: { ...source, tokenAmount: undefined },
+ destination: {
+ recipientAddress: RECIPIENT,
+ tokenAddress: USDC_ARB,
+ tokenAmount: '10',
+ tokenDecimals: 6,
+ tokenType: 1,
+ chainId: '8453',
+ tokenSymbol: 'USDC',
+ },
+ context: 'pay-request',
+ contextId: 'charge-3',
+ })
+ })
+
+ expect(mockPreviewSdaTransfer).toHaveBeenCalledWith(expect.objectContaining({ mode: 'receive', amount: '10' }))
+ expect(result.current.error).toBeNull()
+ })
+
it('bridge path: feeUsd is the quote total, not feeUsd plus a gas component', async () => {
mockGetBridgeQuote.mockResolvedValue({ ...quote(1.51), isSwap: true })
const { result } = renderHook(() => useCrossChainTransfer())
@@ -143,6 +168,45 @@ describe('useCrossChainTransfer — feeUsd is the quote, verbatim', () => {
expect(result.current.error).toBeNull()
})
+ // A max withdrawal re-quotes on every sub-cent balance change, so two
+ // calculates are routinely in flight. If the older one lands last it used to
+ // overwrite the newer numbers — and the affordability gate would then be
+ // checking a payAmount the user is not about to send.
+ it('a superseded quote landing last does not overwrite the newer one', async () => {
+ let releaseFirst: (v: unknown) => void = () => {}
+ const first = new Promise((res) => {
+ releaseFirst = res
+ })
+ mockGetBridgeQuote
+ .mockImplementationOnce(async () => {
+ await first
+ return { ...quote(1.51), payAmount: '10.126123', isSwap: true }
+ })
+ .mockImplementationOnce(async () => ({ ...quote(1.51), payAmount: '10.129999', isSwap: true }))
+
+ const { result } = renderHook(() => useCrossChainTransfer())
+ const destination = {
+ recipientAddress: RECIPIENT,
+ tokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' as `0x${string}`,
+ tokenAmount: '0.004',
+ tokenDecimals: 18,
+ tokenType: 0,
+ chainId: '1',
+ tokenSymbol: 'ETH',
+ }
+
+ await act(async () => {
+ // A is in flight and stuck; B starts and finishes.
+ const a = result.current.calculate({ source, destination, context: 'withdraw', contextId: 'charge-A' })
+ await result.current.calculate({ source, destination, context: 'withdraw', contextId: 'charge-B' })
+ releaseFirst(undefined)
+ await a
+ })
+
+ // B is newer, so B's numbers stand even though A resolved last.
+ expect(result.current.payAmount).toBe('10.129999')
+ })
+
// Deploy order: this build must also work against an API that predates the
// payAmount/receiveAmount rename and still sends amountIn/amountOut.
it('bridge path: reads the pre-rename amountIn/amountOut when that is all the API sends', async () => {
diff --git a/src/features/payments/shared/hooks/useCrossChainTransfer.ts b/src/features/payments/shared/hooks/useCrossChainTransfer.ts
index da568c3c26..672c75816c 100644
--- a/src/features/payments/shared/hooks/useCrossChainTransfer.ts
+++ b/src/features/payments/shared/hooks/useCrossChainTransfer.ts
@@ -21,7 +21,7 @@
* await calculate({ source, destination, context: 'withdraw', contextId: chargeUuid })
*/
-import { useCallback, useState } from 'react'
+import { useCallback, useRef, useState } from 'react'
import { captureException } from '@sentry/nextjs'
import { encodeFunctionData, erc20Abi, parseUnits, type Address, type Hex } from 'viem'
import * as peanutInterfaces from '@/interfaces/peanut-sdk-types'
@@ -187,6 +187,14 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn {
const [isXChain, setIsXChain] = useState(false)
const [isDiffToken, setIsDiffToken] = useState(false)
const [isCalculating, setIsCalculating] = useState(false)
+ /**
+ * Each `calculate` gets a generation. A max withdrawal re-quotes on every
+ * sub-cent balance change, so two are routinely in flight; without this an
+ * older one finishing last would overwrite the newer numbers — and the
+ * affordability gate would then be checking a `payAmount` the user is not
+ * about to send. Latest wins, the same way the claim flow does it.
+ */
+ const quoteGenerationRef = useRef(0)
const [isFeeEstimationError, setIsFeeEstimationError] = useState(false)
const [error, setError] = useState(null)
const [path, setPath] = useState(null)
@@ -244,6 +252,15 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn {
senderPeanutWalletAddress,
skipGasEstimate,
}: CalculateInput) => {
+ const generation = ++quoteGenerationRef.current
+ const isCurrent = () => generation === quoteGenerationRef.current
+ /** Wrap a setter so a superseded quote cannot write through it. */
+ const live =
+ (set: (v: T) => void) =>
+ (v: T) => {
+ if (isCurrent()) set(v)
+ }
+
setIsCalculating(true)
setError(null)
setIsFeeEstimationError(false)
@@ -268,14 +285,14 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn {
// request-link fulfillment (existing behavior — unchanged).
await buildSameChainTx({
destination,
- setTransactions,
- setEstimatedGasCostUsd,
- setIsFeeEstimationError,
- setReceiveAmount,
- setPayAmount,
+ setTransactions: live(setTransactions),
+ setEstimatedGasCostUsd: live(setEstimatedGasCostUsd),
+ setIsFeeEstimationError: live(setIsFeeEstimationError),
+ setReceiveAmount: live(setReceiveAmount),
+ setPayAmount: live(setPayAmount),
skipGasEstimate,
})
- setPath('same-chain')
+ if (isCurrent()) setPath('same-chain')
return
}
@@ -308,16 +325,16 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn {
sourceRhinoChain,
destRhinoChain,
tokenSymbol,
- setTransactions,
- setReceiveAmount,
- setPayAmount,
- setFeeUsd,
- setEstimatedGasCostUsd,
- setIsFeeEstimationError,
- setQuoteExpiresAt,
- setCommitmentId,
+ setTransactions: live(setTransactions),
+ setReceiveAmount: live(setReceiveAmount),
+ setPayAmount: live(setPayAmount),
+ setFeeUsd: live(setFeeUsd),
+ setEstimatedGasCostUsd: live(setEstimatedGasCostUsd),
+ setIsFeeEstimationError: live(setIsFeeEstimationError),
+ setQuoteExpiresAt: live(setQuoteExpiresAt),
+ setCommitmentId: live(setCommitmentId),
})
- setPath('bridge')
+ if (isCurrent()) setPath('bridge')
return
}
@@ -326,12 +343,22 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn {
// persist them onto the charge for audit (the FEE ledger entry is
// booked from Rhino's executed actuals, not from this quote).
// Sequential because provision depends on preview's numbers.
+ // A withdraw is sized by what the user spends (pay mode, the
+ // source amount): whatever Rhino quotes as a fee comes out of
+ // the delivery, never on top, so a full-balance withdraw always
+ // fits the balance. A pay-request / claim is sized by what the
+ // recipient must get (receive mode): the payer covers any fee.
+ // Under the 1:1 account config both give the same numbers.
+ const withdraw = context === 'withdraw'
+ if (withdraw && !source.tokenAmount) {
+ throw new Error('Withdraw requires source.tokenAmount (the USDC amount the user is spending)')
+ }
const preview = await previewSdaTransfer({
chainIn: sourceRhinoChain,
chainOut: destRhinoChain,
token: tokenSymbol,
- amount: destination.tokenAmount,
- mode: 'receive', // UI always asks "merchant gets X" — user pays X + quoted fee
+ amount: withdraw ? source.tokenAmount! : destination.tokenAmount,
+ mode: withdraw ? 'pay' : 'receive',
depositor: source.address,
recipient: destination.recipientAddress,
})
@@ -352,25 +379,29 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn {
preview,
sda,
source,
- setTransactions,
- setSdaAddress,
- setReceiveAmount,
- setPayAmount,
- setFeeUsd,
- setMinDepositLimitUsd,
- setMaxDepositLimitUsd,
- setEstimatedGasCostUsd,
- setIsFeeEstimationError,
- setQuoteExpiresAt,
+ setTransactions: live(setTransactions),
+ setSdaAddress: live(setSdaAddress),
+ setReceiveAmount: live(setReceiveAmount),
+ setPayAmount: live(setPayAmount),
+ setFeeUsd: live(setFeeUsd),
+ setMinDepositLimitUsd: live(setMinDepositLimitUsd),
+ setMaxDepositLimitUsd: live(setMaxDepositLimitUsd),
+ setEstimatedGasCostUsd: live(setEstimatedGasCostUsd),
+ setIsFeeEstimationError: live(setIsFeeEstimationError),
+ setQuoteExpiresAt: live(setQuoteExpiresAt),
})
- setPath('sda')
+ if (isCurrent()) setPath('sda')
} catch (err) {
const message = err instanceof Error ? err.message : 'failed to calculate cross-chain transfer'
- setError(message)
- setIsFeeEstimationError(true)
+ // A superseded quote's failure is not the user's problem — the
+ // newer one owns the screen, including whether it errored.
+ live(setError)(message)
+ live(setIsFeeEstimationError)(true)
captureException(err)
} finally {
- setIsCalculating(false)
+ // Only the newest quote clears the spinner; a stale one finishing
+ // first would otherwise say "done" while the real one still runs.
+ if (isCurrent()) setIsCalculating(false)
}
},
[]
@@ -643,12 +674,13 @@ function applyRhinoResult({
])
setSdaAddress(sda.sdaAddress)
setReceiveAmount(preview.receiveAmount)
- // SDA path uses mode='receive' — any fee Rhino quotes is taken at source, so
- // `payAmount` IS `principal + quoted fee` (== principal under the current
- // 1:1 account config) and matches the on-chain transfer amount we just
- // encoded above. Callers routing through sendTransactions({ requiredUsdcAmount })
- // MUST pass this — not the principal — or the kernel's collateral-sweep
- // under-funds and the transfer reverts with `ERC20: transfer amount exceeds balance`.
+ // `payAmount` is the quote's pay side and matches the on-chain transfer
+ // amount we just encoded above: the source amount on a withdraw (pay
+ // mode), principal + quoted fee on a pay-request (receive mode) — the same
+ // number under the current 1:1 account config. Callers routing through
+ // sendTransactions({ requiredUsdcAmount }) MUST pass this — not the
+ // principal — or the kernel's collateral-sweep under-funds and the
+ // transfer reverts with `ERC20: transfer amount exceeds balance`.
setPayAmount(preview.payAmount)
setFeeUsd(preview.feeUsd)
setMinDepositLimitUsd(sda.minDepositLimitUsd)
diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json
index 24056834f9..49357f3b53 100644
--- a/src/i18n/app/messages/en.json
+++ b/src/i18n/app/messages/en.json
@@ -3088,6 +3088,7 @@
"global": {
"amountInput": {
"balance": "Balance:",
+ "useFullBalance": "Use full balance: {balance}",
"switchCurrency": "Switch currency"
},
"tokenSelector": {
diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json
index c3f1d4aa77..472f1b014e 100644
--- a/src/i18n/app/messages/es-419.json
+++ b/src/i18n/app/messages/es-419.json
@@ -3088,6 +3088,7 @@
"global": {
"amountInput": {
"balance": "Saldo:",
+ "useFullBalance": "Usar saldo completo: {balance}",
"switchCurrency": "Cambiar moneda"
},
"tokenSelector": {
diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json
index 3c405892b5..7513db1b0f 100644
--- a/src/i18n/app/messages/pt-BR.json
+++ b/src/i18n/app/messages/pt-BR.json
@@ -3088,6 +3088,7 @@
"global": {
"amountInput": {
"balance": "Saldo:",
+ "useFullBalance": "Usar saldo total: {balance}",
"switchCurrency": "Trocar moeda"
},
"tokenSelector": {
diff --git a/src/utils/__tests__/withdraw.utils.test.ts b/src/utils/__tests__/withdraw.utils.test.ts
index 8abb3f0153..48b1fd71ec 100644
--- a/src/utils/__tests__/withdraw.utils.test.ts
+++ b/src/utils/__tests__/withdraw.utils.test.ts
@@ -9,7 +9,9 @@ import {
getCountryCodeForWithdraw,
getCountryFromIban,
isBelowRhinoMinDeposit,
+ resolveWithdrawAmount,
} from '@/utils/withdraw.utils'
+import { parseUnits } from 'viem'
jest.mock('@/assets', () => ({}))
@@ -434,3 +436,51 @@ describe('Withdraw Utilities', () => {
})
})
})
+
+describe('resolveWithdrawAmount', () => {
+ const USDC = 6
+ const balance = (v: string) => parseUnits(v, USDC)
+
+ it('returns the typed amount untouched when the user did not tap the balance', () => {
+ expect(resolveWithdrawAmount('10.12', balance('10.126123'), false, USDC)).toBe('10.12')
+ })
+
+ it('settles the sub-cent remainder after a full-balance tap', () => {
+ // the point of the flag: the wallet reaches a true zero instead of
+ // stranding 0.006123 that displays as $0.00 and can never be withdrawn
+ expect(resolveWithdrawAmount('10.12', balance('10.126123'), true, USDC)).toBe('10.126123')
+ })
+
+ it('ignores a deposit that lands between the tap and the confirm', () => {
+ // the user agreed to withdraw 10.12, not the 50 that just arrived
+ expect(resolveWithdrawAmount('10.12', balance('50.00'), true, USDC)).toBe('10.12')
+ })
+
+ it('returns the amount on screen when the balance dropped — it does not clamp', () => {
+ // Not an overdraw guard: shrinking the amount under the user would send
+ // less than they confirmed. The shortfall is caught downstream instead
+ // (cross-chain blocks the CTA, a same-chain send fails), exactly as it
+ // always has for a typed amount.
+ expect(resolveWithdrawAmount('10.12', balance('3.00'), true, USDC)).toBe('10.12')
+ })
+
+ it('holds the line when the balance moved by a sub-cent amount', () => {
+ // still floors to 10.12, so the remainder is the user's to take
+ expect(resolveWithdrawAmount('10.12', balance('10.129999'), true, USDC)).toBe('10.129999')
+ // dropped below the cent the user saw — return what they saw, unchanged
+ expect(resolveWithdrawAmount('10.12', balance('10.119999'), true, USDC)).toBe('10.12')
+ })
+
+ it('is a no-op on an exact-cent balance', () => {
+ expect(resolveWithdrawAmount('10.12', balance('10.12'), true, USDC)).toBe('10.12')
+ })
+
+ it('falls back while the balance is still loading', () => {
+ expect(resolveWithdrawAmount('10.12', undefined, true, USDC)).toBe('10.12')
+ })
+
+ it('falls back on an empty or unparseable amount', () => {
+ expect(resolveWithdrawAmount('', balance('10.126123'), true, USDC)).toBe('')
+ expect(resolveWithdrawAmount('abc', balance('10.126123'), true, USDC)).toBe('abc')
+ })
+})
diff --git a/src/utils/withdraw.utils.ts b/src/utils/withdraw.utils.ts
index 6a3aca65b6..a5d5b12f48 100644
--- a/src/utils/withdraw.utils.ts
+++ b/src/utils/withdraw.utils.ts
@@ -1,5 +1,6 @@
import { countryData, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts'
import { isValidEmail } from '@/utils/format.utils'
+import { formatUnits } from 'viem'
/**
* Extracts the country name from an IBAN by parsing the first 2 characters (country code)
@@ -357,3 +358,46 @@ export const isBelowRhinoMinDeposit = (
const pay = parseFloat(payAmount)
return Number.isFinite(pay) && pay < minDepositLimitUsd
}
+
+/**
+ * The amount a crypto withdrawal should actually move.
+ *
+ * "Use full balance" fills the balance rounded down to cents, and that rounded
+ * number is what the user reads on every screen of the flow. When they have not
+ * edited it since, they asked for everything — so the withdrawal settles the
+ * sub-cent remainder too and the wallet reaches a true zero, rather than
+ * stranding dust that displays as $0.00 and can never be withdrawn.
+ *
+ * The live balance is only used while it still floors to the amount on screen.
+ * Otherwise — a deposit landed, or the balance dropped — this returns the amount
+ * the user saw, unchanged.
+ *
+ * It deliberately does NOT clamp to the live balance. This function's only job is
+ * to decide whether the sub-cent remainder rides along; it never enlarges or
+ * shrinks what the user agreed to withdraw. Silently sending less than the
+ * confirmed amount would be worse than failing, and an amount that now exceeds
+ * the balance is caught downstream by the pre-sign gate on the confirm screen,
+ * which compares what the kernel will actually send against the live balance.
+ * That is the same outcome a typed amount has always had when the balance moves.
+ *
+ * Called once, when the charge is prepared — the result is then frozen for that
+ * withdrawal (WithdrawFlowContext.preparedAmount). Re-resolving it later would
+ * let the spend drift away from the charge the API settles against.
+ *
+ * @param amount the amount on screen, as filled or typed (USD)
+ * @param spendableBalance live spendable balance in token units
+ * @param isMaxWithdrawal the amount came from the balance tap, unedited
+ */
+export const resolveWithdrawAmount = (
+ amount: string,
+ spendableBalance: bigint | undefined,
+ isMaxWithdrawal: boolean,
+ decimals: number
+): string => {
+ if (!isMaxWithdrawal || spendableBalance === undefined || !amount) return amount
+ const live = formatUnits(spendableBalance, decimals)
+ const liveNum = Number(live)
+ const amountNum = Number(amount)
+ if (!Number.isFinite(liveNum) || !Number.isFinite(amountNum)) return amount
+ return Math.floor(liveNum * 100) / 100 === amountNum ? live : amount
+}