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
+14 -6
View File
@@ -551,12 +551,20 @@
class="text-xs text-muted"
></span>
</div>
<input
type="text"
id="send-amount"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0.0"
/>
<div class="flex gap-1">
<input
type="text"
id="send-amount"
class="border border-border p-1 flex-1 min-w-0 font-mono text-sm bg-bg text-fg"
placeholder="0.0"
/>
<button
id="btn-send-max"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Max
</button>
</div>
</div>
<button
id="btn-send-review"
+59 -18
View File
@@ -43,6 +43,7 @@ const {
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
maxEthAmount,
validateTransfer,
} = require("../../shared/txValidation");
const { log } = require("../../shared/log");
@@ -54,6 +55,10 @@ let pendingTx = null;
// filled in by estimateGas() when the estimate resolves or fails.
let feeStatus = FEE_PENDING;
let feeWei = null;
// The fee fields a max ETH send is signed with: those of the estimate its
// amount was derived from. Null for any other send, which ethers prices from
// the node at signing time.
let maxSendFees = null;
function restore() {
const d = state.viewData;
@@ -82,10 +87,24 @@ function valueWithUsd(text, usdAmount) {
return text;
}
// The Amount line, with its USD value. A max ETH send's line is drawn again
// once its amount is re-derived from the fee estimate.
function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + displaySymbol(rawSymbol),
amountUsd,
);
}
function show(txInfo) {
pendingTx = txInfo;
feeStatus = FEE_PENDING;
feeWei = null;
maxSendFees = null;
const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the
@@ -132,18 +151,11 @@ function show(txInfo) {
);
$("confirm-to-ens").classList.add("hidden");
// Amount (with inline USD)
const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol);
const amountNum = parseFloat(txInfo.amount);
const price = isErc20 ? tokenPrice : ethPrice;
const amountUsd = price ? amountNum * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + symbol,
amountUsd,
);
renderAmount(txInfo);
// Balance (with inline USD)
const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol);
if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
@@ -380,8 +392,8 @@ async function estimateGas(txInfo) {
}
// What the node will require to be reserved, which is what the gate
// must be: the send pins no fee fields, so it is broadcast as a
// type-2 transaction priced at maxFeePerGas.
// must be: the send is broadcast as a type-2 transaction priced at
// maxFeePerGas, which only a max ETH send pins (see below).
const gasCostWei = feeReserveWei(gasLimit, feeData);
if (gasCostWei === null) {
throw new Error("no usable gas price from the provider");
@@ -419,6 +431,30 @@ async function estimateGas(txInfo) {
}
feeStatus = FEE_KNOWN;
feeWei = gasCostWei;
// A max ETH send is the balance minus this estimate's reserve, not the
// Send screen's, and is signed with this estimate's fee fields: fees
// fetched again at signing could exceed the reserve it leaves, and the
// node would refuse it for want of funds. Where the balance no longer
// covers the fee, the amount is left as it is and the balance check
// below says so.
if (txInfo.max && txInfo.token === "ETH") {
const amount = maxEthAmount(txInfo.balance, gasCostWei);
if (amount !== null) {
txInfo.amount = amount;
renderAmount(txInfo);
// Priced as feeReserveWei() priced the reserve: maxFeePerGas,
// or gasPrice on a network with no type-2 pricing.
if (feeData.maxFeePerGas != null) {
maxSendFees = {
gasLimit,
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
};
} else {
maxSendFees = { gasLimit, gasPrice: feeData.gasPrice };
}
}
}
renderValidation(txInfo);
} catch (e) {
log.errorf("gas estimation failed:", e.shortMessage || e.message);
@@ -432,18 +468,19 @@ async function estimateGas(txInfo) {
}
// Populate the transaction this send describes, enforce the fee bound against
// the fees that were actually filled in, then sign and broadcast it. The send
// pins no fee fields, so ethers fills maxFeePerGas and the gas limit from what
// the configured RPC node answers, with nothing otherwise bounding what a
// the fees that were actually filled in, then sign and broadcast it. Apart
// from a max ETH send, which passes its estimate's fee fields as `fees`, the
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
// what the configured RPC node answers, with nothing otherwise bounding what a
// hostile node can set — the dApp path's ceilings never reached this one.
// Populating before the check is what makes assertWithinCeilings() see the
// same numbers that would be signed; it throws an ApprovalMismatchError when
// the product gasLimit × maxFeePerGas is over the bound, which the caller
// shows in the reserved error area rather than sending.
async function populateVerifyAndSend(connectedSigner, tx) {
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
let request;
if (tx.token === "ETH") {
request = { to: tx.to, value: parseEther(tx.amount) };
request = { to: tx.to, value: parseEther(tx.amount), ...fees };
} else {
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
// The contract's decimals() is read to be COMPARED with the scale the
@@ -548,7 +585,11 @@ function init(_ctx) {
const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider);
tx = await populateVerifyAndSend(connectedSigner, pendingTx);
tx = await populateVerifyAndSend(
connectedSigner,
pendingTx,
maxSendFees,
);
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
+114 -2
View File
@@ -22,10 +22,25 @@ const {
truncateAmountNeverZero,
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { getAddress } = require("ethers");
const {
feeReserveWei,
maxEthAmount,
maxTokenAmount,
} = require("../../shared/txValidation");
const { log } = require("../../shared/log");
const { getAddress, parseEther } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
// Whether the amount field holds what Max filled in. The confirmation screen
// re-derives a max ETH amount from its own fee estimate; typing in the field
// makes it an ordinary amount again.
let amountIsMax = false;
// Counts the times the Send screen has opened, so a Max fee estimate started
// before it was last opened fills nothing in.
let sendScreenOpenings = 0;
/**
* Validate a destination address string.
* Returns { valid: true } or { valid: false, error: "..." }.
@@ -229,9 +244,102 @@ function updateSendBalance() {
}
}
// Fill the amount field with the most the selected holding can send: a
// token's whole balance (cut to 18 decimal places), or for ETH the exact
// balance minus the fee reserve the confirmation screen checks against, never
// the rounded balance the screen shows. Where there is nothing to fill in, a
// flash message says why.
async function fillMaxAmount() {
const addr = currentAddress();
if (!addr) return;
const token = state.selectedToken || $("send-token").value;
if (token !== "ETH") {
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
if (bal == null) {
showFlash("This token's balance is unknown.");
return;
}
const amount = maxTokenAmount(bal);
if (!(parseFloat(amount) > 0)) {
showFlash("This token's balance is zero.");
return;
}
$("send-amount").value = amount;
amountIsMax = true;
return;
}
// The fee is estimated for this recipient, as the confirmation screen
// estimates it: sending to a contract can cost more gas.
const to = $("send-to").value.trim();
if (!validateToAddress(to).valid) {
showFlash("Please enter a recipient address first.");
return;
}
const typed = $("send-amount").value;
const opening = sendScreenOpenings;
let feeWei = null;
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const [feeData, gasLimit] = await Promise.all([
provider.getFeeData(),
provider.estimateGas({
from: addr.address,
to,
value: parseEther(addr.balance || "0"),
}),
]);
feeWei = feeReserveWei(gasLimit, feeData);
} catch (e) {
log.errorf(
"max amount fee estimate failed:",
e.shortMessage || e.message,
);
}
// While the estimate was in flight the user left the screen (and perhaps
// opened it again), typed an amount, or changed the address, the holding
// or the recipient: what they did wins.
if (
state.currentView !== "send" ||
sendScreenOpenings !== opening ||
currentAddress()?.address !== addr.address ||
(state.selectedToken || $("send-token").value) !== token ||
$("send-to").value.trim() !== to ||
$("send-amount").value !== typed
) {
return;
}
if (feeWei === null) {
showFlash("The network fee could not be estimated.");
return;
}
const amount = maxEthAmount(addr.balance, feeWei);
if (amount === null) {
showFlash("Your balance does not cover the network fee.");
return;
}
$("send-amount").value = amount;
amountIsMax = true;
}
function init(_ctx) {
ctx = _ctx;
$("send-token").addEventListener("change", updateSendBalance);
$("send-token").addEventListener("change", () => {
// A filled-in maximum is the maximum of the holding it was filled in
// for.
if (amountIsMax) {
$("send-amount").value = "";
amountIsMax = false;
}
updateSendBalance();
});
$("btn-send-max").addEventListener("click", fillMaxAmount);
$("send-amount").addEventListener("input", () => {
amountIsMax = false;
});
// Initial state: disable review button until address is entered
$("btn-send-review").disabled = true;
@@ -308,6 +416,7 @@ function init(_ctx) {
tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals,
max: amountIsMax,
});
});
@@ -318,7 +427,10 @@ function init(_ctx) {
});
}
// Called each time the Send screen opens, with its fields cleared.
function resetSendValidation() {
sendScreenOpenings++;
amountIsMax = false;
const errorEl = $("send-to-error");
const btn = $("btn-send-review");
if (errorEl) errorEl.textContent = "";
+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,
};