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 16d4a1f40f..2b9d2cfed2 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,92 @@ 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() + await waitFor(() => + expect(mockSetError).toHaveBeenCalledWith({ + showError: true, + errorMessage: '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 3caa0c30fb..0035db7ce7 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 @@ -84,6 +84,9 @@ jest.mock('@/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', () => ({ diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 70b2d32d67..a80413ba14 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,7 @@ export default function WithdrawCryptoPage() { const { resetTokenContextProvider } = useContext(tokenSelectorContext) const { amountToWithdraw, + isMaxWithdrawal, usdAmount, currentView, setCurrentView, @@ -151,6 +152,14 @@ export default function WithdrawCryptoPage() { resetPaymentRecorder() }, [setChargeDetails, setTransactionHash, setPaymentDetails, resetRouteCalculation, resetPaymentRecorder]) + // What the withdrawal actually moves: the amount on screen, plus the + // sub-cent remainder when the user tapped "use full balance" and did not + // edit it. See resolveWithdrawAmount for the guard rails (TASK-21899). + const effectiveAmount = useMemo( + () => resolveWithdrawAmount(amountToWithdraw, spendableBalance, isMaxWithdrawal, PEANUT_WALLET_TOKEN_DECIMALS), + [amountToWithdraw, spendableBalance, isMaxWithdrawal] + ) + // clear errors when amount changes useEffect(() => { if (amountToWithdraw) { @@ -177,9 +186,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 +203,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(() => { @@ -218,7 +227,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(effectiveAmount) const minUsd = getMinWithdrawUsdForChain(data.chain.chainId) if (!Number.isFinite(usdToWithdraw) || usdToWithdraw < minUsd) { const minDisplay = minUsd % 1 === 0 ? `$${minUsd}` : `$${minUsd.toFixed(2)}` @@ -239,10 +248,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(effectiveAmount) 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)) : effectiveAmount const completeWithdrawData = { ...data, amount: destinationTokenAmount } setWithdrawData(completeWithdrawData) @@ -308,6 +317,7 @@ export default function WithdrawCryptoPage() { }, [ amountToWithdraw, + effectiveAmount, clearErrors, setChargeDetails, setIsPreparingReview, @@ -409,7 +419,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 @@ -551,6 +561,7 @@ export default function WithdrawCryptoPage() { chargeDetails, withdrawData, amountToWithdraw, + effectiveAmount, address, transactions, payAmount, @@ -606,21 +617,19 @@ 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: the kernel spend (`payAmount` + // — the quote's pay side cross-chain, the principal same-chain) 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". Only once the route has resolved + // `payAmount` (skipped while calculating; CTA is disabled by isCalculating + // anyway). + const insufficientBalance = useMemo( () => - isCrossChainWithdrawal && - payAmount != null && - spendableBalance !== undefined && - !isAmountWithinBalance(payAmount, spendableBalance), - [isCrossChainWithdrawal, payAmount, spendableBalance] + payAmount != null && spendableBalance !== undefined && !isAmountWithinBalance(payAmount, spendableBalance), + [payAmount, spendableBalance] ) // Rhino accepts SDA deposits below the route minimum on-chain but never @@ -678,7 +687,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 39959504ab..6071a7b095 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -58,6 +58,7 @@ export default function WithdrawPage() { const { amountToWithdraw: amountFromContext, setAmountToWithdraw, + setIsMaxWithdrawal, setError, error, setUsdAmount, @@ -248,6 +249,18 @@ export default function WithdrawPage() { [balance, maxDecimalAmount, setError, 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 || '' @@ -257,6 +270,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) { @@ -439,6 +457,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 887062c307..ec10e177d1 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 @@ -330,12 +373,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..abcb1c030b 100644 --- a/src/context/WithdrawFlowContext.tsx +++ b/src/context/WithdrawFlowContext.tsx @@ -36,6 +36,14 @@ 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 usdAmount: string setUsdAmount: (amount: string) => void currentView: WithdrawView @@ -76,6 +84,7 @@ const WithdrawFlowContext = createContext(u export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [amountToWithdraw, setAmountToWithdraw] = useState('') + const [isMaxWithdrawal, setIsMaxWithdrawal] = useState(false) const [usdAmount, setUsdAmount] = useState('') const [currentView, setCurrentView] = useState('INITIAL') const [withdrawData, setWithdrawData] = useState(null) @@ -101,6 +110,7 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({ const resetWithdrawFlow = useCallback(() => { setAmountToWithdraw('') + setIsMaxWithdrawal(false) // 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 +133,8 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({ () => ({ amountToWithdraw, setAmountToWithdraw, + isMaxWithdrawal, + setIsMaxWithdrawal, usdAmount, setUsdAmount, currentView, @@ -159,6 +171,7 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({ }), [ amountToWithdraw, + isMaxWithdrawal, 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 7a7e4bc0c7..8b413d7fae 100644 --- a/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts +++ b/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts @@ -79,7 +79,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()) @@ -101,7 +101,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) @@ -111,6 +111,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()) diff --git a/src/features/payments/shared/hooks/useCrossChainTransfer.ts b/src/features/payments/shared/hooks/useCrossChainTransfer.ts index 4f387b6f5d..1730ae8459 100644 --- a/src/features/payments/shared/hooks/useCrossChainTransfer.ts +++ b/src/features/payments/shared/hooks/useCrossChainTransfer.ts @@ -325,12 +325,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, }) @@ -634,12 +644,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 f58c0f71da..7392d38675 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -2971,7 +2971,8 @@ }, "global": { "amountInput": { - "balance": "Balance:" + "balance": "Balance:", + "useFullBalance": "Use full balance: {balance}" }, "tokenSelector": { "selectAToken": "Select a token", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 2248ebd98d..ded23179c8 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -2971,7 +2971,8 @@ }, "global": { "amountInput": { - "balance": "Saldo:" + "balance": "Saldo:", + "useFullBalance": "Usar saldo completo: {balance}" }, "tokenSelector": { "selectAToken": "Elige un token", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index a01529b31b..5f82196557 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -2971,7 +2971,8 @@ }, "global": { "amountInput": { - "balance": "Saldo:" + "balance": "Saldo:", + "useFullBalance": "Usar saldo total: {balance}" }, "tokenSelector": { "selectAToken": "Escolha um token", 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..6ba24f6297 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,42 @@ 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: cross-chain withdrawals block the confirm CTA + * on `insufficientForFee`, and a same-chain send fails rather than overdrawing. + * That is the same outcome a typed amount has always had when the balance moves. + * + * @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 +}