Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
72 changes: 72 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,70 @@ describe('GROUP 3: Amount Validation', () => {
)
})

test('Tapping the balance fills the exact spendable amount, not the rounded label', () => {
// The label rounds to two decimals; filling from it would strand dust
// the user asked to withdraw in full (TASK-21899).
mockWithdrawFlow.selectedMethod = { type: 'crypto' }
mockUseWallet.mockReturnValue({
spendableBalance: parseUnits('12.345678', 6),
formattedSpendableBalance: '12.35',
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
118 changes: 118 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,118 @@
import { fireEvent, screen } from '@testing-library/react'
import { renderWithIntl } from '@/test-utils/intl'
import AmountInput from '@/components/Global/AmountInput'

/**
* Tapping the balance row fills the whole spendable amount (TASK-21899).
* The point of these tests is that the filled amount comes from the exact
* number the parent validates against, not from the rounded label next to it.
*/

// 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.35"
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 exact balance rather than the rounded label', () => {
const { field, useFullBalance, lastReported } = setup()

fireEvent.click(useFullBalance()!)

expect(field.value).toBe('12.345678')
expect(lastReported()).toBe('12.345678')
})

it('truncates to the denomination precision instead of rounding above the balance', () => {
const { field, useFullBalance } = setup({ balanceFillAmount: 12.3456789 })

fireEvent.click(useFullBalance()!)

expect(Number(field.value)).toBeLessThanOrEqual(12.3456789)
expect(field.value).toBe('12.345678')
})

it('honours a two-decimal denomination', () => {
const { field, useFullBalance } = setup({
primaryDenomination: { symbol: '$', price: 1, decimals: 2 },
balanceFillAmount: 12.345678,
})

fireEvent.click(useFullBalance()!)

expect(field.value).toBe('12.34')
})

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 the input can express', () => {
const { field, useFullBalance } = setup({
primaryDenomination: { symbol: '$', price: 1, decimals: 2 },
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.345678')
expect(lastReported()).toBe('12.345678')
})

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.345678')
})

it('does not offer the fill while the input is disabled', () => {
const { useFullBalance } = setup({ disabled: true })

expect(useFullBalance()).toBeNull()
})
})
60 changes: 54 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,31 @@ const AmountInput = ({
}
}, [defaultSliderSuggestedAmount])

// What tapping the balance row fills in, or undefined when the row stays
// plain text. The value comes from the number the parent validates against,
// never from the formatted label — that label rounds to two decimals, so
// parsing it would either leave dust behind or ask for more than the wallet
// holds (TASK-21899). forInput truncates to the denomination's decimals, so
// the filled amount never rounds up past the balance.
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
const formatted = formatTokenAmount(String(balanceFillAmount), denominations[displaySymbol]?.decimals, true)
// Anything the field can't express — a balance below its precision, 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 +350,29 @@ 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}`
const label = `${t('amountInput.balance')} ${balanceAmount}`
if (!fillValue) return <div className="text-center text-grey-1">{label}</div>
return (
<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-2 text-center text-grey-1 underline underline-offset-4"
>
{label}
</button>
)
})()}
</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