Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
73 changes: 73 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 @@ -155,6 +155,14 @@ 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"
onClick={() => props.setPrimaryAmount?.(String(props.balanceFillAmount))}
>
Use full balance
</button>
)}
</div>
),
}))
Expand Down Expand Up @@ -481,6 +489,71 @@ describe('GROUP 3: Amount Validation', () => {
)
})

test('Hands the full-precision spendable amount to the input, not the label', () => {
// The page passes the number its own validation compares against; the
// input is what floors it to cents before filling (TASK-21899). Passing
// the label instead would tie the fill to display formatting.
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(screen.getByTestId('amount-field')).toHaveValue('12.345678')
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
1 change: 1 addition & 0 deletions src/app/(mobile-ui)/withdraw/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
if (amountFromContext) {
setShowAllWithdrawMethods(true)
}
}, [])

Check warning on line 204 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,7 +245,7 @@
setError({ showError: true, errorMessage: message })
return false
},
[balance, maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount, t, tErrors]

Check warning on line 248 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
)

const handleTokenAmountChange = useCallback(
Expand Down Expand Up @@ -439,6 +439,7 @@
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.
hideCurrencyToggle
/>

Expand Down
140 changes: 140 additions & 0 deletions src/components/Global/AmountInput/__tests__/balance-fill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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<React.ComponentProps<typeof AmountInput>> = {}) {
const setPrimaryAmount = jest.fn()
renderWithIntl(
<AmountInput
setPrimaryAmount={setPrimaryAmount}
primaryDenomination={USDC}
hideCurrencyToggle
walletBalance="12.34"
balanceFillAmount={12.345678}
{...props}
/>
)
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('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('does not offer the fill while the input is disabled', () => {
const { useFullBalance } = setup({ disabled: true })

expect(useFullBalance()).toBeNull()
})
})
75 changes: 69 additions & 6 deletions src/components/Global/AmountInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ 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
hideCurrencyToggle?: boolean
hideBalance?: boolean
infoContent?: React.ReactNode
Expand All @@ -50,6 +55,7 @@ const AmountInput = ({
secondaryDenomination,
setCurrentDenomination,
walletBalance,
balanceFillAmount,
hideCurrencyToggle,
hideBalance,
infoContent,
Expand Down Expand Up @@ -240,6 +246,36 @@ 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)
Comment thread
abalinda marked this conversation as resolved.
// 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)
}, [fillValue])

const inputRef = useRef<HTMLInputElement>(null)
// set input width based on display value length
// add extra space for decimal numbers to prevent cutoff
Expand Down Expand Up @@ -319,12 +355,39 @@ const AmountInput = ({
)}

{/* Balance */}
{walletBalance && !hideBalance && (
<div className="text-center text-grey-1">
{t('amountInput.balance')} {secondaryDenomination ? 'USD ' : '$ '}
{walletBalance}
</div>
)}
{walletBalance &&
!hideBalance &&
(() => {
const balanceAmount = `${secondaryDenomination ? 'USD ' : '$ '}${walletBalance}`
if (!fillValue) {
return (
<div className="text-center text-grey-1">
{`${t('amountInput.balance')} ${balanceAmount}`}
</div>
)
}
// Only the amount is the action — "Balance:" stays a label,
// so the underline marks exactly what the tap fills in.
return (
<div className="flex items-center justify-center gap-1 text-grey-1">
<span>{t('amountInput.balance')}</span>
<button
type="button"
// The form wrapper focuses the amount field on any
// click inside it. Let this one bubble and the mobile
// keyboard opens over the CTA the user is heading for.
onClick={(e) => {
e.stopPropagation()
fillBalance()
}}
aria-label={t('amountInput.useFullBalance', { balance: balanceAmount })}
className="min-h-11 px-1 underline underline-offset-4"
>
{balanceAmount}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</button>
</div>
)
})()}
</div>
{/* Conversion toggle */}
{showConversion && (
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/app/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2707,7 +2707,8 @@
},
"global": {
"amountInput": {
"balance": "Balance:"
"balance": "Balance:",
"useFullBalance": "Use full balance: {balance}"
},
"tokenSelector": {
"selectAToken": "Select a token",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/app/messages/es-419.json
Original file line number Diff line number Diff line change
Expand Up @@ -2707,7 +2707,8 @@
},
"global": {
"amountInput": {
"balance": "Saldo:"
"balance": "Saldo:",
"useFullBalance": "Usar saldo completo: {balance}"
},
"tokenSelector": {
"selectAToken": "Elige un token",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/app/messages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -2707,7 +2707,8 @@
},
"global": {
"amountInput": {
"balance": "Saldo:"
"balance": "Saldo:",
"useFullBalance": "Usar saldo total: {balance}"
},
"tokenSelector": {
"selectAToken": "Escolha um token",
Expand Down
Loading