Skip to content

feat(send): max amount button on the amount screen - #689

Open
islandbitcoin wants to merge 5 commits into
mainfrom
feat/max-amount-button
Open

feat(send): max amount button on the amount screen#689
islandbitcoin wants to merge 5 commits into
mainfrom
feat/max-amount-button

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Summary

Implements the approved design for a MAX button on Lightning sends: a compact chip in the amount screen's balance header, immediately right of the balance value.

Fixes #512

UX

  • Chip states: outlined green (available) → solid green while the entered amount equals the computed max → back to outlined the moment the user edits the amount. Greyed out and non-interactive when the balance is zero. All colors come from the theme (colors.primary) — no hardcoded hex.
  • Info row: while the chip is solid, a note under the amount explains the computation (fee reserved / no fee / recipient cap). An error message always takes precedence over the note.
  • Currency toggle: toggling display currency keeps the same underlying amount, so the chip stays solid.

Max rules

Destination Max Note shown
Intraledger / Flash-to-Flash Full wallet balance, no API call "Full balance — no fee between Flash accounts"
External invoice / LNURL (BTC wallet) Balance − fee estimate via fetchBreezFee (reuses the resolved payRequest from #680, no second LNURL round-trip) "~$0.26 reserved for the network fee — final fee shown on confirm"
External invoice (USD wallet) Balance − fee via the existing fee probes (useIbexFee), probed at the full balance same as above
LNURL destinations Clamped to min(computed max, maxSendable) — reuses the LUD-06 bounds validation shipped in #680; applies to BTC-wallet intraledger sends too since they settle via LNURL-pay "Capped at … — the most this recipient can receive per payment"
Fee estimate fails / times out Fills the full balance anyway; the existing pre-validation from #680 surfaces the typed fee error. The tap never blocks and never spinner-traps.

Architecture

amount-input-screen stays a generic component with zero payment knowledge. It gains an optional maxAmountButton?: { disabled?: boolean; compute: () => Promise<{ amount, note? } | null> } prop — when absent (all other usages of the amount screen), no chip renders and nothing changes.

The computation lives in the send flow:

  • app/screens/send-bitcoin-screen/max-send-amount.ts — pure, dependency-free max rules (computeMaxSendAmount), unit-testable under plain jest (same pattern as fee-errors.ts from fix(send): surface real BTC fee errors and validate LNURL receive limits #680).
  • send-bitcoin-details-screen.tsx builds the config from the payment detail (intraledger vs external vs LNURL, balances, resolved receiverPayRequest/receiverLimits) and threads it: DetailAmountNoteAmountInputAmountInputModalAmountInputScreenAmountInputScreenUI.

The filled amount is floored in the primary display currency so it never converts back to more than the computed max in wallet units.

i18n

Four new AmountInputScreen keys in app/i18n/en/index.ts (max, maxNoteIntraledger, maxNoteFeeReserved, maxNoteRecipientCap). English placeholders appended to all 23 locale files under raw-i18n/translations/ for the next translation batch; generated i18n-types.ts and raw-i18n/source/en.json committed. No literal $ before interpolations — currency symbols arrive in the interpolated values.

Tests

  • __tests__/utils/max-send-amount.spec.ts — 12 unit cases: intraledger = balance; external = balance − fee; LNURL clamp (binding, non-binding, on intraledger, on the fallback); fee null / throw / timeout fallbacks; zero balance; fee ≥ balance; negative/NaN fee.
  • __tests__/components/amount-input-screen-max-chip.spec.tsx — 6 component cases in the house style of the fix(send): surface real BTC fee errors and validate LNURL receive limits #680 DetailAmountNote tests: no chip without the prop; outlined → tap fills the display-currency amount, goes solid, shows the note; editing returns it to outlined and hides the note; solid across currency toggle; greyed + inert at zero balance.

Checks run locally: yarn test 57 suites / 332 tests green, yarn tsc:check clean, eslint clean on changed lines, yarn check:translation-drift green, yarn update-translations idempotent (generated files committed).

🤖 Generated with Claude Code

https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH

Dread and others added 4 commits August 13, 2026 14:23
Adds a MAX chip beside the balance in the amount input screen's header
(issue #512). Outlined green when available, solid green while the
entered amount is the computed max, back to outlined the moment the
user edits, greyed out on a zero balance. Theme colors only.

Max rules, computed in the send flow (the amount screen stays free of
payment knowledge via an optional maxAmountButton prop):
- intraledger / Flash-to-Flash: full wallet balance, no API call; note
  says there is no fee between Flash accounts
- external invoice / LNURL: balance minus the fee estimate (BTC wallet
  via fetchBreezFee, USD wallet via the existing fee probes); note
  states the reserved fee
- LNURL destinations clamp to the receiver's maxSendable (reuses the
  payRequest bounds from #680)
- fee estimate failure or timeout fills the full balance and lets the
  existing pre-validation surface the typed fee error — the tap never
  blocks

The currency toggle keeps the same underlying amount, so the chip
stays solid across toggles.

i18n: new AmountInputScreen keys in en, English placeholders appended
to all 23 locale files for the next translation batch.

Tests: computeMaxSendAmount unit suite (12 cases) and an
AmountInputScreen component suite for the chip states (6 cases).

Fixes #512

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
- BTC-wallet intraledger sends settle via LNURL-pay with a real Breez
  fee: route them through the fee path (effectiveMaxPaymentType) instead
  of filling the full balance with a false no-fee note, which dead-ended
  every BTC-wallet MAX-to-Flash send at confirm time
- exclude onchain from the chip: its fee estimate needs the not-yet-
  selected fee speed and the flow already has its own send-all Max
- guard NaN balances: disable the chip while wallets are still loading
  and treat non-finite balances as zero in computeMaxSendAmount, so the
  number pad can never fill the literal string NaN
- drop stale in-flight max computations: any edit (key press, clear,
  currency toggle) invalidates the pending fee fetch so a slow resolve
  can no longer overwrite the user's typed amount or revert a currency
  toggle; the chip now shows a computing state while the fetch runs
- show no note when the reserved fee is zero (a $0.00 reservation note
  is nonsense)
- extract the decision logic into pure functions (maxChipSupports-
  PaymentType, effectiveMaxPaymentType, resolveRecipientCap,
  noteForResult) and unit-test them, plus component tests for the
  computing state and stale-resolve races

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
fetchBreezFee bounds-validates the probe amount against the receiver's
LUD-06 limits before preparing, so probing at the full balance was
guaranteed to fail whenever the recipient cap binds — the fee came back
null and MAX filled the cap-clamped amount with no fee headroom. In the
band where balance - cap < fee, confirm-time validation then dead-ended
with "amount exceeds balance (amount + fee)".

computeMaxSendAmount now derives the probe amount (balance clamped to
the recipient cap) and passes it to fetchFee; the details screen probes
Breez/IBEX at that amount. Both regimes now yield a confirm-safe MAX:
min(balance - fee, cap). Adds unit tests for the probe amount and the
cap-within-fee-distance-of-balance band.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
…overdraw

On-device repro (iPhone, USD cash wallet, zero-amount invoice): the API
reports USD balances in FRACTIONAL cents (109.9346 = $1.099346 at
IBEX); MAX offered it un-floored, downstream rounding produced a
$1.10 invoice, and IBEX rejected the send — 'insufficient balance.
Current Balance: 1.099346 ... invoice amount: 1.100000'.

computeMaxSendAmount now floors the spendable balance, the fee-adjusted
result, and the recipient cap to whole minor units (no-op for integer
sat balances), and a sub-cent balance counts as zero. Four regression
tests pin the repro numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
@islandbitcoin

Copy link
Copy Markdown
Contributor Author

Device-testing follow-up: MAX on a fractional-cent USD balance ($1.099346) produced a $1.10 invoice and an IBEX insufficient-balance rejection. Fixed in the latest commit — the max computation now floors balance, fee-adjusted result, and recipient cap to whole minor units (sats unaffected), with regression tests pinning the exact repro numbers. Full suite 360/360 green.

The pad can hold a different currency than the wallet (JMD display over
a USD wallet). The fill converted the wallet-unit max into the pad
currency and floored there — but the send path converts the pad amount
BACK to wallet units and rounds, so a forward-rounding conversion (or a
rate tick) can still round-trip above the computed max and overdraw.

The fill now steps the pad amount down until its round-trip back to
wallet units stays within the computed max. Component test pins the
class with a coarse rounding converter (109-sat max, 10-sats-per-cent:
naive fill 11 cents → 110 sats overdraw; guarded fill 10 cents).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Enhancement] - Create Max amount button for Lightning Transactions

1 participant