Compare commits

..
1 Commits
Author SHA1 Message Date
sneak b60eec9197 feat: a "Max" button on the Send screen (closes #198)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
Max fills in a token's whole 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 or
recipient 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
2026-10-05 04:49:38 +00:00
6 changed files with 182 additions and 36 deletions
+11 -8
View File
@@ -1468,14 +1468,17 @@ view would leave a wallet one click from deletion.
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as `Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
ConfirmTx's balance line does (see Unknown token scale) ConfirmTx's balance line does (see Unknown token scale)
- "Max" button beside the amount input, always in place. It fills in the - "Max" button beside the amount input, always in place. It fills in the
most the selected holding can send: a token's whole balance, or for ETH most the selected holding can send: a token's whole balance, cut down to
the exact balance minus the network fee reserve that ConfirmTx's balance the 18 decimal places ConfirmTx accepts for a token that has more, or for
check gates on, never the rounded balance shown above it. The ETH fee is ETH the exact balance minus the network fee reserve that ConfirmTx's
estimated for the recipient entered, so it asks for a recipient first. balance check gates on, never the rounded balance shown above it. The ETH
Where there is nothing to fill in, a flash message says why: the balance fee is estimated for the recipient entered, so it asks for a recipient
does not cover the fee, the fee could not be estimated, or the token's first; an estimate that finishes after the screen was left or the address,
balance is unknown or zero. Typing in the amount makes it an ordinary holding or recipient changed fills nothing in. Where there is nothing to
amount; changing what to send clears an amount Max filled in fill in, a flash message says why: the balance does not cover the fee, the
fee could not be estimated, or the token's balance is unknown or zero.
Typing in the amount makes it an ordinary amount; changing what to send
clears an amount Max filled in
- "Review" button, disabled until the recipient validates - "Review" button, disabled until the recipient validates
- **Transitions**: - **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx** - "Review" (valid inputs, ENS resolved) → **ConfirmTx**
+10 -8
View File
@@ -48,14 +48,16 @@ but the review is broader than any of them.
- 2026-10-05: The Send screen has a "Max" button - 2026-10-05: The Send screen has a "Max" button
([#198](https://git.eeqj.de/sneak/AutistMask/issues/198)). Emptying an ETH ([#198](https://git.eeqj.de/sneak/AutistMask/issues/198)). Emptying an ETH
address took guessing an amount and being refused by the confirmation screen's address took guessing an amount and being refused by the confirmation screen's
balance check. Max fills in a token's whole balance, or for ETH the exact balance check. Max fills in a token's whole balance, cut to the 18 decimal
balance minus the fee reserve that check gates on, never the four-decimal places the confirmation screen accepts, or for ETH the exact balance minus the
balance shown. The confirmation screen works a max ETH amount out again from fee reserve that check gates on, never the four-decimal balance shown; a fee
its own fee estimate and signs it with that estimate's fee fields: fetched estimate that finishes after the Send screen was left, or its address, holding
again at signing, a fee that had risen since would leave amount plus fee above or recipient changed, fills nothing in. The confirmation screen works a max
the balance, and the node would refuse the send. A token's maximum is still ETH amount out again from its own fee estimate and signs it with that
refused when ETH cannot pay the fee. Where there is nothing to fill in, a estimate's fee fields: fetched again at signing, a fee that had risen since
flash message says why. would leave amount plus fee above the balance, and the node would refuse the
send. A token's maximum is still refused when ETH cannot pay the fee. Where
there is nothing to fill in, a flash message says why.
- 2026-10-05: A token scale of zero decimals is tested - 2026-10-05: A token scale of zero decimals is tested
([#325](https://git.eeqj.de/sneak/AutistMask/issues/325)). ([#325](https://git.eeqj.de/sneak/AutistMask/issues/325)).
+36 -13
View File
@@ -22,7 +22,11 @@ const {
truncateAmountNeverZero, truncateAmountNeverZero,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { feeReserveWei, maxEthAmount } = require("../../shared/txValidation"); const {
feeReserveWei,
maxEthAmount,
maxTokenAmount,
} = require("../../shared/txValidation");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const { getAddress, parseEther } = require("ethers"); const { getAddress, parseEther } = require("ethers");
@@ -33,6 +37,10 @@ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
// makes it an ordinary amount again. // makes it an ordinary amount again.
let amountIsMax = false; 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. * Validate a destination address string.
* Returns { valid: true } or { valid: false, error: "..." }. * Returns { valid: true } or { valid: false, error: "..." }.
@@ -237,9 +245,10 @@ function updateSendBalance() {
} }
// Fill the amount field with the most the selected holding can send: a // Fill the amount field with the most the selected holding can send: a
// token's whole balance, or for ETH the exact balance minus the fee reserve // token's whole balance (cut to 18 decimal places), or for ETH the exact
// the confirmation screen checks against, never the rounded balance the screen // balance minus the fee reserve the confirmation screen checks against, never
// shows. Where there is nothing to fill in, a flash message says why. // the rounded balance the screen shows. Where there is nothing to fill in, a
// flash message says why.
async function fillMaxAmount() { async function fillMaxAmount() {
const addr = currentAddress(); const addr = currentAddress();
if (!addr) return; if (!addr) return;
@@ -249,12 +258,15 @@ async function fillMaxAmount() {
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance; const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
if (bal == null) { if (bal == null) {
showFlash("This token's balance is unknown."); showFlash("This token's balance is unknown.");
} else if (!(parseFloat(bal) > 0)) { return;
showFlash("This token's balance is zero.");
} else {
$("send-amount").value = bal;
amountIsMax = true;
} }
const amount = maxTokenAmount(bal);
if (!(parseFloat(amount) > 0)) {
showFlash("This token's balance is zero.");
return;
}
$("send-amount").value = amount;
amountIsMax = true;
return; return;
} }
@@ -266,6 +278,7 @@ async function fillMaxAmount() {
return; return;
} }
const typed = $("send-amount").value; const typed = $("send-amount").value;
const opening = sendScreenOpenings;
let feeWei = null; let feeWei = null;
try { try {
const provider = getProvider(state.rpcUrl, state.networkId); const provider = getProvider(state.rpcUrl, state.networkId);
@@ -284,10 +297,19 @@ async function fillMaxAmount() {
e.shortMessage || e.message, e.shortMessage || e.message,
); );
} }
// The user typed an amount or chose another holding while the estimate // While the estimate was in flight the user left the screen (and perhaps
// was in flight: what they did wins. // opened it again), typed an amount, or changed the address, the holding
if ($("send-amount").value !== typed) return; // or the recipient: what they did wins.
if ((state.selectedToken || $("send-token").value) !== token) return; 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) { if (feeWei === null) {
showFlash("The network fee could not be estimated."); showFlash("The network fee could not be estimated.");
@@ -407,6 +429,7 @@ function init(_ctx) {
// Called each time the Send screen opens, with its fields cleared. // Called each time the Send screen opens, with its fields cleared.
function resetSendValidation() { function resetSendValidation() {
sendScreenOpenings++;
amountIsMax = false; amountIsMax = false;
const errorEl = $("send-to-error"); const errorEl = $("send-to-error");
const btn = $("btn-send-review"); const btn = $("btn-send-review");
+12 -5
View File
@@ -102,6 +102,13 @@ function maxEthAmount(ethBalance, feeWei) {
return formatEther(amountWei); 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. // Validate a pending transfer against the balances that must cover it.
// //
// isErc20 — token transfer rather than a native ETH transfer // isErc20 — token transfer rather than a native ETH transfer
@@ -154,13 +161,12 @@ function validateTransfer({
const feeFp = known ? feeWei : null; const feeFp = known ? feeWei : null;
if (isErc20) { if (isErc20) {
// A token can declare more than 18 decimals, and its balance is // Only the first 18 places of the balance are read: an amount with
// stored with all of them. Only the first 18 places (SCALE_DECIMALS) // more was refused above, so the places after them cannot decide
// are read: an amount with more was refused above, so the places // whether the amount fits.
// after them cannot decide whether the amount fits.
const tokenText = const tokenText =
typeof tokenBalance === "string" typeof tokenBalance === "string"
? tokenBalance.replace(/(\.\d{18})\d+$/, "$1") ? maxTokenAmount(tokenBalance)
: tokenBalance; : tokenBalance;
const tokenFp = toFixedPoint(tokenText) ?? 0n; const tokenFp = toFixedPoint(tokenText) ?? 0n;
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN); if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
@@ -190,6 +196,7 @@ module.exports = {
feeReserveWei, feeReserveWei,
feeEstimateWei, feeEstimateWei,
maxEthAmount, maxEthAmount,
maxTokenAmount,
toFixedPoint, toFixedPoint,
validateTransfer, validateTransfer,
}; };
+95 -2
View File
@@ -149,6 +149,8 @@ const confirmTx = require("../src/popup/views/confirmTx");
const PRIVATE_KEY = "0x" + "11".repeat(32); const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address; const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// A second address of the wallet, and a second recipient.
const OTHER = "0x" + "e".repeat(40);
const PASSWORD = "correct horse battery staple"; const PASSWORD = "correct horse battery staple";
const GWEI = 1000000000n; const GWEI = 1000000000n;
@@ -207,7 +209,7 @@ async function refreshWith(balanceWei, tokenItems = []) {
); );
} }
function tokenRow(value) { function tokenRow(value, decimals = "18") {
return { return {
value: String(value), value: String(value),
token: { token: {
@@ -215,7 +217,7 @@ function tokenRow(value) {
address_hash: TOKEN, address_hash: TOKEN,
symbol: "TOK", symbol: "TOK",
name: "Token", name: "Token",
decimals: "18", decimals,
holders_count: "50000", holders_count: "50000",
}, },
}; };
@@ -230,6 +232,7 @@ function openSend(token = "ETH") {
send.init({ showConfirmTx: (info) => (confirmed = info) }); send.init({ showConfirmTx: (info) => (confirmed = info) });
confirmTx.init({}); confirmTx.init({});
send.resetSendValidation(); send.resetSendValidation();
state.currentView = "send";
state.selectedToken = token; state.selectedToken = token;
el("send-to").value = RECIPIENT; el("send-to").value = RECIPIENT;
el("send-amount").value = ""; el("send-amount").value = "";
@@ -349,6 +352,83 @@ describe("Max on an ETH send", () => {
); );
}); });
// Holds the node's fee answer, so Max's estimate is still running, until
// the returned function is called.
function holdFeeEstimate() {
let release;
mockNode.feeData = new Promise((resolve) => {
release = () => resolve(fees(20n * GWEI));
});
return release;
}
test.each([
["the same address", 0],
["another address", 1],
])(
"fills nothing in once Send was left and opened again for %s while the fee was estimated",
async (_, addressIndex) => {
await refreshWith(BALANCE_WEI);
state.wallets[0].addresses.push({
address: OTHER,
balance: "2.0",
tokenBalances: [],
});
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
// Back, then Send again as home.js opens it, with the same
// recipient typed in again.
state.selectedAddress = addressIndex;
el("send-to").value = "";
el("send-amount").value = "";
send.resetSendValidation();
el("send-to").value = RECIPIENT;
release();
await pressed;
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("");
},
);
test("fills nothing in and says nothing once Send was left while the fee was estimated", async () => {
// 0.0001 ETH, which does not cover the fee: a result that landed
// would say so on whichever screen is shown.
await refreshWith(100000000000000n);
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
state.currentView = "home";
release();
await pressed;
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("");
});
test("fills nothing in when the recipient changed while the fee was estimated", async () => {
await refreshWith(BALANCE_WEI);
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
el("send-to").value = OTHER;
release();
await pressed;
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("");
});
test("fills in once the held fee estimate arrives with nothing changed", async () => {
await refreshWith(BALANCE_WEI);
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
release();
await pressed;
expect(el("send-amount").value).toBe(maxAfter(20n * GWEI));
});
test("typed over, is an ordinary amount the confirmation screen keeps", async () => { test("typed over, is an ordinary amount the confirmation screen keeps", async () => {
await refreshWith(BALANCE_WEI); await refreshWith(BALANCE_WEI);
openSend(); openSend();
@@ -375,6 +455,19 @@ describe("Max on a token send", () => {
expect(canSend()).toBe(true); expect(canSend()).toBe(true);
}); });
test("of a token with more than 18 decimal places, fills in the balance cut down to the 18 the confirmation screen accepts", async () => {
// 1234.567890123456789012999999 TOK at 24 decimal places.
await refreshWith(BALANCE_WEI, [
tokenRow(1234567890123456789012999999n, "24"),
]);
openSend(TOKEN);
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(canSend()).toBe(true);
});
test("is still refused when ETH cannot cover the fee", async () => { test("is still refused when ETH cannot cover the fee", async () => {
await refreshWith(0n, [tokenRow(TOKEN_UNITS)]); await refreshWith(0n, [tokenRow(TOKEN_UNITS)]);
openSend(TOKEN); openSend(TOKEN);
+18
View File
@@ -7,6 +7,7 @@ const {
feeReserveWei, feeReserveWei,
feeEstimateWei, feeEstimateWei,
maxEthAmount, maxEthAmount,
maxTokenAmount,
toFixedPoint, toFixedPoint,
validateTransfer, validateTransfer,
} = require("../src/shared/txValidation"); } = require("../src/shared/txValidation");
@@ -356,6 +357,23 @@ describe("maxEthAmount", () => {
}); });
}); });
// The amount the Send screen's Max fills in for a token.
describe("maxTokenAmount", () => {
test("cuts a balance with more than 18 places down, never up", () => {
const amount = maxTokenAmount("1234.567890123456789012999999");
expect(amount).toBe("1234.567890123456789012");
expect(toFixedPoint(amount)).not.toBe(null);
});
test("leaves a balance with 18 places or fewer as it is", () => {
expect(maxTokenAmount("0.123456789012345678")).toBe(
"0.123456789012345678",
);
expect(maxTokenAmount("1.5")).toBe("1.5");
expect(maxTokenAmount("100")).toBe("100");
});
});
// Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does. // Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does.
// Each of these previously returned { canSend: true, codes: [] } — counting no // Each of these previously returned { canSend: true, codes: [] } — counting no
// fee at all, on a full-balance send, in the direction that lets money out. // fee at all, on a full-balance send, in the direction that lets money out.