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 && ( -
- {t('amountInput.balance')} {secondaryDenomination ? 'USD ' : '$ '} - {walletBalance} -
- )} + {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 ( +
+ {`${t('amountInput.balance')} ${balanceAmount}`} +
+ ) + } + // 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 +}