Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
773b4ca
Merge branch 'dev' into feat/withdraw-use-full-balance
innolope-dev Sep 6, 2026
d828f61
Merge remote-tracking branch 'origin/fix/21991-network-fee-one-source…
innolope-dev Sep 6, 2026
c275e19
test(withdraw): assert the sub-minimum full-balance error on the fiel…
innolope-dev Sep 6, 2026
6dd1ba4
Merge remote-tracking branch 'origin/fix/21991-network-fee-one-source…
innolope-dev Sep 6, 2026
7361786
Merge remote-tracking branch 'origin/fix/21991-network-fee-one-source…
innolope-dev Sep 6, 2026
280fe99
Merge remote-tracking branch 'origin/dev' into feat/withdraw-use-full…
innolope-dev Sep 6, 2026
c786437
fix(withdraw): latest-wins quoting, and gate on what the kernel actua…
innolope-dev Sep 6, 2026
24518c7
fix(withdraw): freeze the spend with the charge instead of deriving i…
innolope-dev Sep 6, 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
100 changes: 100 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,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -119,11 +124,20 @@ jest.mock('@/services/requests', () => ({

jest.mock('@/components/Withdraw/views/Confirm.withdraw.view', () => ({
__esModule: true,
default: (props: { onConfirm: () => void }) => (
<button data-testid="confirm-withdraw" onClick={props.onConfirm}>
Confirm
</button>
),
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 ? (
<button data-testid="confirm-withdraw" onClick={props.onConfirm}>
Retry
</button>
) : (
<button data-testid="confirm-withdraw" onClick={props.onConfirm} disabled={props.insufficientBalance}>
Confirm
</button>
),
}))

jest.mock('@/components/Withdraw/views/Initial.withdraw.view', () => ({
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
}),
}))

Expand Down Expand Up @@ -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(<WithdrawCryptoPage />)

expect(screen.getByTestId('confirm-withdraw')).toBeEnabled()
})

it('one base unit short disables the CTA', () => {
mockWallet.spendableBalance = BALANCE - 1n
Object.assign(mockWithdrawFlow, { isMaxWithdrawal: true })

render(<WithdrawCryptoPage />)

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(<WithdrawCryptoPage />)
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(<WithdrawCryptoPage />)
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(<WithdrawCryptoPage />)
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(<WithdrawCryptoPage />)
fireEvent.click(screen.getByTestId('confirm-withdraw'))

await waitFor(() => expect(mockSendMoney).toHaveBeenCalled())
expect(mockSendMoney).toHaveBeenCalledWith(RECIPIENT, '10.126123', expect.anything())
})
})
Loading
Loading