feat: a "Max" button on the Send screen (closes #198)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 3s

Max fills in a token's balance, cut down to the 18 decimal places the
confirmation screen accepts, or for ETH the exact balance minus the fee
reserve the confirmation screen's balance check gates on. An ETH fee estimate
that finishes after the Send screen was left, or its address, holding,
recipient or amount changed, fills nothing in. The confirmation screen works a
max ETH amount out again from its own fee estimate and signs it with that
estimate's fee fields, so a fee that rose before signing cannot push amount
plus fee above the balance. validateTransfer() still gates every send, the
check that ETH covers a token send's fee included. Where there is nothing to
fill in, a flash message says why.

Model: opus-5-5
This commit was merged in pull request #452.
This commit is contained in:
2026-10-05 07:43:06 +02:00
parent cf7ca99215
commit a207ac70bd
9 changed files with 851 additions and 35 deletions
+30 -7
View File
@@ -1,4 +1,4 @@
// Balance arithmetic for the transaction confirmation screen.
// Balance arithmetic for the Send and transaction confirmation screens.
//
// Pure: no DOM, no network, no state. Everything is exact integer math on
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
@@ -10,7 +10,7 @@
// the token balance arrive as human decimal strings, so comparing them at a
// common scale is exact.
const { parseUnits } = require("ethers");
const { parseUnits, formatEther } = require("ethers");
const SCALE_DECIMALS = 18;
@@ -87,6 +87,28 @@ function toFixedPoint(value) {
}
}
// The most ETH a send can carry: the exact balance minus the fee reserve from
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
// exactly that reserve left behind. `ethBalance` is the exact decimal string
// balances.js stores, never a rounded one. Null when the balance does not
// leave anything to send once the fee is paid, or when either input is
// unusable.
function maxEthAmount(ethBalance, feeWei) {
const balanceWei = toFixedPoint(ethBalance);
if (balanceWei === null) return null;
if (typeof feeWei !== "bigint" || feeWei < 0n) return null;
const amountWei = balanceWei - feeWei;
if (amountWei <= 0n) return null;
return formatEther(amountWei);
}
// The most of a token a send can carry: its balance cut down, never rounded
// up, to the 18 places (SCALE_DECIMALS) an amount may have. A token can
// declare more than 18 decimals, and its balance is stored with all of them.
function maxTokenAmount(tokenBalance) {
return tokenBalance.replace(/(\.\d{18})\d+$/, "$1");
}
// Validate a pending transfer against the balances that must cover it.
//
// isErc20 — token transfer rather than a native ETH transfer
@@ -139,13 +161,12 @@ function validateTransfer({
const feeFp = known ? feeWei : null;
if (isErc20) {
// A token can declare more than 18 decimals, and its balance is
// stored with all of them. Only the first 18 places (SCALE_DECIMALS)
// are read: an amount with more was refused above, so the places
// after them cannot decide whether the amount fits.
// Only the first 18 places of the balance are read: an amount with
// more was refused above, so the places after them cannot decide
// whether the amount fits.
const tokenText =
typeof tokenBalance === "string"
? tokenBalance.replace(/(\.\d{18})\d+$/, "$1")
? maxTokenAmount(tokenBalance)
: tokenBalance;
const tokenFp = toFixedPoint(tokenText) ?? 0n;
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
@@ -174,6 +195,8 @@ module.exports = {
SCALE_DECIMALS,
feeReserveWei,
feeEstimateWei,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
validateTransfer,
};