Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b984cc9
feat(withdraw): tap the balance to withdraw everything
abalinda Aug 27, 2026
09187b2
comment: name exponential notation as a case the inert balance row co…
abalinda Aug 27, 2026
ca67be6
fix(withdraw): keep the balance tap from opening the keyboard over th…
abalinda Aug 27, 2026
e959d37
feat(withdraw): underline only the amount, and fill it floored to cents
abalinda Aug 27, 2026
29cd908
fix(ui): write the dollar symbol against the amount
abalinda Aug 27, 2026
b2cc434
a11y: floor the balance action's tap target width too
abalinda Aug 27, 2026
149bae3
feat(withdraw): withdraw everything on crypto, still show two decimals
abalinda Aug 27, 2026
6de65ff
docs: resolveWithdrawAmount does not clamp — say so
abalinda Aug 27, 2026
b2409d1
Merge origin/dev — resolve the balance row onto the DS-migrated Amoun…
abalinda Aug 31, 2026
80c0b05
a11y(ds): keyboard focus ring on the balance action (law 8)
abalinda Aug 31, 2026
acc2b39
Merge remote-tracking branch 'origin/fix/21991-network-fee-one-source…
abalinda Sep 1, 2026
c92839b
fix(withdraw): quote a withdraw by what the user spends; gate the spe…
abalinda Sep 1, 2026
75de393
Merge remote-tracking branch 'origin/fix/21991-network-fee-one-source…
abalinda Sep 1, 2026
db33891
Merge remote-tracking branch 'origin/fix/21991-network-fee-one-source…
abalinda Sep 1, 2026
271f163
Merge remote-tracking branch 'origin/fix/21991-network-fee-one-source…
abalinda Sep 1, 2026
d7c82ce
Merge remote-tracking branch 'origin/fix/21991-network-fee-one-source…
abalinda Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -155,6 +158,20 @@ jest.mock('@/components/Global/AmountInput', () => ({
disabled={props.disabled}
/>
{props.walletBalance && <span data-testid="wallet-balance">{props.walletBalance}</span>}
{!!props.balanceFillAmount && (
<button
data-testid="use-full-balance"
data-fill={String(props.balanceFillAmount)}
onClick={() => {
// real component floors to cents, then reports both ways
const filled = (Math.floor(props.balanceFillAmount * 100) / 100).toString()
props.onBalanceFilled?.(filled)
props.setPrimaryAmount?.(filled)
}}
>
Use full balance
</button>
)}
</div>
),
}))
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
55 changes: 32 additions & 23 deletions src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -64,6 +64,7 @@ export default function WithdrawCryptoPage() {
const { resetTokenContextProvider } = useContext(tokenSelectorContext)
const {
amountToWithdraw,
isMaxWithdrawal,
usdAmount,
currentView,
setCurrentView,
Expand Down Expand Up @@ -148,6 +149,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) {
Expand All @@ -172,9 +181,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).
// effectiveAmount is USD-denominated; source token is USDC (1:1).
// Required for the bridge path's 'pay' mode (cross-chain ETH/etc).
tokenAmount: amountToWithdraw,
tokenAmount: effectiveAmount,
Comment thread
abalinda marked this conversation as resolved.
Outdated
},
destination: {
recipientAddress: chargeDetails.requestLink.recipientAddress as Address,
Expand All @@ -190,7 +199,7 @@ export default function WithdrawCryptoPage() {
skipGasEstimate: true, // peanut wallet handles gas
})
}
}, [currentView, chargeDetails, withdrawData, calculateRoute, address, amountToWithdraw])
}, [currentView, chargeDetails, withdrawData, calculateRoute, address, effectiveAmount])

const handleSetupReview = useCallback(
async (data: Omit<WithdrawData, 'amount'>) => {
Expand All @@ -209,7 +218,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)}`
Expand All @@ -230,10 +239,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)
Comment thread
abalinda marked this conversation as resolved.
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)
Expand Down Expand Up @@ -299,6 +308,7 @@ export default function WithdrawCryptoPage() {
},
[
amountToWithdraw,
effectiveAmount,
clearErrors,
setChargeDetails,
setIsPreparingReview,
Expand Down Expand Up @@ -385,7 +395,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
Expand Down Expand Up @@ -527,6 +537,7 @@ export default function WithdrawCryptoPage() {
chargeDetails,
withdrawData,
amountToWithdraw,
effectiveAmount,
address,
transactions,
payAmount,
Expand Down Expand Up @@ -580,21 +591,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<boolean>(
// 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<boolean>(
() =>
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
Expand Down Expand Up @@ -651,7 +660,7 @@ export default function WithdrawCryptoPage() {
receiveAmount={receiveAmount}
payAmount={payAmount}
showHighFeeWarning={showHighFeeWarning}
insufficientBalance={insufficientForFee}
insufficientBalance={insufficientBalance}
belowMinimumMessage={belowMinimumMessage}
isFromSendFlow={isFromSendFlow}
/>
Expand Down
20 changes: 20 additions & 0 deletions src/app/(mobile-ui)/withdraw/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
const {
amountToWithdraw: amountFromContext,
setAmountToWithdraw,
setIsMaxWithdrawal,
setError,
error,
setUsdAmount,
Expand Down Expand Up @@ -201,7 +202,7 @@
if (amountFromContext) {
setShowAllWithdrawMethods(true)
}
}, [])

Check warning on line 205 in src/app/(mobile-ui)/withdraw/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has missing dependencies: 'amountFromContext' and 'setShowAllWithdrawMethods'. Either include them or remove the dependency array

const validateAmount = useCallback(
(amountStr: string): boolean => {
Expand Down Expand Up @@ -245,9 +246,21 @@
setError({ showError: true, errorMessage: message })
return false
},
[balance, maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount, t, tErrors]

Check warning on line 249 in src/app/(mobile-ui)/withdraw/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useCallback has an unnecessary dependency: 'selectedTokenData.price'. Either exclude it or remove the dependency array
)

// 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<string | null>(null)

const handleBalanceFilled = useCallback(
(value: string) => {
filledFromBalanceRef.current = value
setIsMaxWithdrawal(true)
},
[setIsMaxWithdrawal]
)

const handleTokenAmountChange = useCallback(
(value: string | undefined) => {
let newValue = value || ''
Expand All @@ -257,6 +270,11 @@
}
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) {
Expand All @@ -273,7 +291,7 @@
setError({ showError: false, errorMessage: '' })
}
},
[setRawTokenAmount, error.showError, setError]

Check warning on line 294 in src/app/(mobile-ui)/withdraw/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useCallback has a missing dependency: 'setIsMaxWithdrawal'. Either include it or remove the dependency array
)

// only validate when rawTokenAmount changes and we're in inputAmount step
Expand Down Expand Up @@ -439,6 +457,8 @@
decimals: 6, // we want USDC decimals to be able to pay exactly
}}
walletBalance={peanutWalletBalance}
balanceFillAmount={maxDecimalAmount}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.
onBalanceFilled={handleBalanceFilled}
hideCurrencyToggle
/>

Expand Down
Loading
Loading