Compare commits

..
1 Commits
Author SHA1 Message Date
sneak a0f1e98323 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, or for ETH the exact balance minus
the fee reserve the confirmation screen's balance check gates on. 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:05:20 +00:00
6 changed files with 35 additions and 181 deletions
+8 -11
View File
@@ -1468,17 +1468,14 @@ view would leave a wallet one click from deletion.
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
ConfirmTx's balance line does (see Unknown token scale)
- "Max" button beside the amount input, always in place. It fills in the
most the selected holding can send: a token's whole balance, cut down to
the 18 decimal places ConfirmTx accepts for a token that has more, or for
ETH the exact balance minus the network fee reserve that ConfirmTx's
balance check gates on, never the rounded balance shown above it. The ETH
fee is estimated for the recipient entered, so it asks for a recipient
first; an estimate that finishes after the screen was left or the address,
holding or recipient changed fills nothing in. Where there is nothing to
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
most the selected holding can send: a token's whole balance, or for ETH
the exact balance minus the network fee reserve that ConfirmTx's balance
check gates on, never the rounded balance shown above it. The ETH fee is
estimated for the recipient entered, so it asks for a recipient first.
Where there is nothing to 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
- **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
+8 -10
View File
@@ -48,16 +48,14 @@ but the review is broader than any of them.
- 2026-10-05: The Send screen has a "Max" button
([#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
balance check. Max fills in a token's whole balance, cut to the 18 decimal
places the confirmation screen accepts, or for ETH the exact balance minus the
fee reserve that check gates on, never the four-decimal balance shown; a 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: fetched again at signing, a fee that had risen since
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.
balance check. Max fills in a token's whole balance, or for ETH the exact
balance minus the fee reserve that check gates on, never the four-decimal
balance shown. The confirmation screen works a max ETH amount out again from
its own fee estimate and signs it with that estimate's fee fields: fetched
again at signing, a fee that had risen since 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
([#325](https://git.eeqj.de/sneak/AutistMask/issues/325)).
+12 -35
View File
@@ -22,11 +22,7 @@ const {
truncateAmountNeverZero,
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const {
feeReserveWei,
maxEthAmount,
maxTokenAmount,
} = require("../../shared/txValidation");
const { feeReserveWei, maxEthAmount } = require("../../shared/txValidation");
const { log } = require("../../shared/log");
const { getAddress, parseEther } = require("ethers");
@@ -37,10 +33,6 @@ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
// 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: "..." }.
@@ -245,10 +237,9 @@ 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.
// token's whole balance, 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;
@@ -258,15 +249,12 @@ async function fillMaxAmount() {
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)) {
} else if (!(parseFloat(bal) > 0)) {
showFlash("This token's balance is zero.");
return;
} else {
$("send-amount").value = bal;
amountIsMax = true;
}
$("send-amount").value = amount;
amountIsMax = true;
return;
}
@@ -278,7 +266,6 @@ async function fillMaxAmount() {
return;
}
const typed = $("send-amount").value;
const opening = sendScreenOpenings;
let feeWei = null;
try {
const provider = getProvider(state.rpcUrl, state.networkId);
@@ -297,19 +284,10 @@ async function fillMaxAmount() {
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;
}
// The user typed an amount or chose another holding while the estimate
// was in flight: what they did wins.
if ($("send-amount").value !== typed) return;
if ((state.selectedToken || $("send-token").value) !== token) return;
if (feeWei === null) {
showFlash("The network fee could not be estimated.");
@@ -429,7 +407,6 @@ 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");
+5 -12
View File
@@ -102,13 +102,6 @@ function maxEthAmount(ethBalance, feeWei) {
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
@@ -161,12 +154,13 @@ function validateTransfer({
const feeFp = known ? feeWei : null;
if (isErc20) {
// 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.
// 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.
const tokenText =
typeof tokenBalance === "string"
? maxTokenAmount(tokenBalance)
? tokenBalance.replace(/(\.\d{18})\d+$/, "$1")
: tokenBalance;
const tokenFp = toFixedPoint(tokenText) ?? 0n;
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
@@ -196,7 +190,6 @@ module.exports = {
feeReserveWei,
feeEstimateWei,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
validateTransfer,
};
+2 -95
View File
@@ -149,8 +149,6 @@ const confirmTx = require("../src/popup/views/confirmTx");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
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 GWEI = 1000000000n;
@@ -209,7 +207,7 @@ async function refreshWith(balanceWei, tokenItems = []) {
);
}
function tokenRow(value, decimals = "18") {
function tokenRow(value) {
return {
value: String(value),
token: {
@@ -217,7 +215,7 @@ function tokenRow(value, decimals = "18") {
address_hash: TOKEN,
symbol: "TOK",
name: "Token",
decimals,
decimals: "18",
holders_count: "50000",
},
};
@@ -232,7 +230,6 @@ function openSend(token = "ETH") {
send.init({ showConfirmTx: (info) => (confirmed = info) });
confirmTx.init({});
send.resetSendValidation();
state.currentView = "send";
state.selectedToken = token;
el("send-to").value = RECIPIENT;
el("send-amount").value = "";
@@ -352,83 +349,6 @@ 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 () => {
await refreshWith(BALANCE_WEI);
openSend();
@@ -455,19 +375,6 @@ describe("Max on a token send", () => {
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 () => {
await refreshWith(0n, [tokenRow(TOKEN_UNITS)]);
openSend(TOKEN);
-18
View File
@@ -7,7 +7,6 @@ const {
feeReserveWei,
feeEstimateWei,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
validateTransfer,
} = require("../src/shared/txValidation");
@@ -357,23 +356,6 @@ 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.
// 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.