diff --git a/README.md b/README.md
index 6b1c28b..1d19c85 100644
--- a/README.md
+++ b/README.md
@@ -1467,6 +1467,17 @@ view would leave a wallet one click from deletion.
- Amount input with current balance display, which reads
`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 a
+ token's 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
- "Review" button, disabled until the recipient validates
- **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
@@ -1483,7 +1494,14 @@ view would leave a wallet one click from deletion.
- Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name
- - Amount: value + symbol (USD in parentheses)
+ - Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
+ filled in is worked out again from this screen's own fee estimate when it
+ arrives, as the balance minus the reserve, and the transaction is signed
+ with that estimate's fee fields, so a fee fetched again at signing cannot
+ exceed what the amount leaves behind. The address keeps whatever part of
+ the reserve the transaction does not use. If the balance no longer covers
+ the fee, the amount is left as it was and the amount-plus-fee error below
+ blocks the send
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
for a token whose scale is unknown
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
diff --git a/TODO.md b/TODO.md
index e34755e..e354a4a 100644
--- a/TODO.md
+++ b/TODO.md
@@ -45,6 +45,20 @@ but the review is broader than any of them.
# Completed Steps
+- 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.
+
- 2026-10-05: A token scale of zero decimals is tested
([#325](https://git.eeqj.de/sneak/AutistMask/issues/325)).
`resolveTokenDecimals()` already used a scale of 0 from the bundled list or
diff --git a/docs/README.md b/docs/README.md
index 032fca3..6e4cf94 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -240,7 +240,9 @@ screen. Tokens can also be added from Settings, under "Tracked Tokens".
2. Select what to send (ETH, or any ERC-20 token with a balance on this address
that survives the spam filters).
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
-4. Enter the amount.
+4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18
+ decimal places, or your ETH balance minus the amount reserved for the network
+ fee.
5. Click "Review" to see the confirmation screen.
The confirmation screen shows:
diff --git a/src/popup/index.html b/src/popup/index.html
index 83635ee..d318b67 100644
--- a/src/popup/index.html
+++ b/src/popup/index.html
@@ -551,12 +551,20 @@
class="text-xs text-muted"
>
-
+
+
+
+ Max
+
+
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 = "";
diff --git a/src/shared/txValidation.js b/src/shared/txValidation.js
index 49e6314..6948692 100644
--- a/src/shared/txValidation.js
+++ b/src/shared/txValidation.js
@@ -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,
};
diff --git a/tests/sendMax.test.js b/tests/sendMax.test.js
new file mode 100644
index 0000000..8c71bfc
--- /dev/null
+++ b/tests/sendMax.test.js
@@ -0,0 +1,530 @@
+// The Send screen's "Max" control
+// (https://git.eeqj.de/sneak/AutistMask/issues/198).
+//
+// For ETH it fills in the exact balance minus the fee reserve the
+// confirmation screen's balance check gates on, never the four-decimal balance
+// the Send screen shows; the confirmation screen re-derives that amount from
+// its own estimate, and signs with that estimate's fee fields. For a token it
+// fills in the whole balance, and the check that ETH covers the fee still
+// applies.
+//
+// Driven through the real refreshBalances(), Send screen and confirmation
+// screen, Sign & Send included, with only the node, the explorer and the DOM
+// stubbed.
+
+"use strict";
+
+// What the stub node answers, and the signed transactions it was handed.
+const mockNode = {
+ balanceWei: 0n,
+ feeData: null,
+ broadcast: [],
+};
+
+// The token rows the stub explorer reports for the address.
+const mockExplorer = { items: [] };
+
+jest.mock("ethers", () => {
+ const actual = jest.requireActual("ethers");
+ class StubProvider {
+ async getBalance() {
+ return mockNode.balanceWei;
+ }
+ async lookupAddress() {
+ return null;
+ }
+ async getFeeData() {
+ return mockNode.feeData;
+ }
+ async estimateGas() {
+ return 21000n;
+ }
+ async getCode() {
+ return "0x";
+ }
+ async getTransactionCount() {
+ return 1;
+ }
+ async getNetwork() {
+ return { chainId: 1n };
+ }
+ async broadcastTransaction(signed) {
+ mockNode.broadcast.push(signed);
+ return { hash: "0x" + "ab".repeat(32) };
+ }
+ }
+ return {
+ ...actual,
+ JsonRpcProvider: StubProvider,
+ Network: { from: () => ({}) },
+ };
+});
+
+jest.mock("../src/shared/log", () => ({
+ log: {
+ debugf: () => {},
+ infof: () => {},
+ warnf: () => {},
+ errorf: () => {},
+ },
+ // The explorer's token list, which refreshBalances() also fetches.
+ debugFetch: jest.fn(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => mockExplorer.items,
+ })),
+ urlOrigin: () => "",
+ setRuntimeDebug: () => {},
+ isDebug: () => false,
+}));
+
+// The wait screen polls for a receipt; these tests stop at the broadcast.
+jest.mock("../src/popup/views/txStatus", () => ({
+ showWait: jest.fn(),
+ showError: jest.fn(),
+}));
+
+// The confirmation screen's Etherscan label lookup is the only fetch() these
+// screens make; it fails, as it does offline.
+global.fetch = jest.fn(() => {
+ throw new Error("tests must not perform network requests");
+});
+
+const { makeStorageStub } = require("./support/storageStub");
+global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
+
+// A stub DOM: every id resolves to a recording element.
+const elements = new Map();
+
+function makeEl(id) {
+ const handlers = new Map();
+ return {
+ id,
+ textContent: "",
+ innerHTML: "",
+ value: "",
+ disabled: false,
+ style: {},
+ dataset: {},
+ classList: {
+ add() {},
+ remove() {},
+ toggle() {},
+ contains: () => false,
+ },
+ handlers,
+ children: [],
+ addEventListener(name, fn) {
+ handlers.set(name, fn);
+ },
+ appendChild(child) {
+ this.children.push(child);
+ return child;
+ },
+ querySelectorAll: () => [],
+ querySelector: () => null,
+ remove() {},
+ focus() {},
+ };
+}
+
+global.document = {
+ getElementById(id) {
+ if (!elements.has(id)) elements.set(id, makeEl(id));
+ return elements.get(id);
+ },
+ createElement: (tag) => makeEl(tag),
+ body: { prepend() {}, appendChild() {} },
+ addEventListener() {},
+};
+global.navigator = { clipboard: { writeText() {} } };
+
+const { Transaction, Wallet, formatEther } = require("ethers");
+const { refreshBalances } = require("../src/shared/balances");
+const { encryptWithPassword } = require("../src/shared/vault");
+const { state } = require("../src/shared/state");
+const send = require("../src/popup/views/send");
+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;
+const GAS = 21000n;
+
+// The Send screen shows this balance as 1.2345 ETH.
+const BALANCE_WEI = 1234567890123456789n;
+
+// A token the bundled list does not know, with enough holders to be listed.
+const TOKEN = "0x" + "d".repeat(40);
+
+// Fee data whose reserve is 21000 gas at `maxFeePerGas`. The expected cost,
+// at gasPrice, is lower, as it is on mainnet.
+function fees(maxFeePerGas) {
+ return {
+ maxFeePerGas,
+ maxPriorityFeePerGas: GWEI,
+ gasPrice: maxFeePerGas / 2n,
+ };
+}
+
+// The balance minus a reserve of 21000 gas at `maxFeePerGas`.
+function maxAfter(maxFeePerGas) {
+ return formatEther(BALANCE_WEI - GAS * maxFeePerGas);
+}
+
+function el(id) {
+ return global.document.getElementById(id);
+}
+
+function text(id) {
+ return el(id).textContent;
+}
+
+// The ETH balance the node reports and the token rows the explorer reports,
+// fetched and stored exactly where the popup stores them.
+async function refreshWith(balanceWei, tokenItems = []) {
+ mockNode.balanceWei = balanceWei;
+ mockExplorer.items = tokenItems;
+ state.wallets = [
+ {
+ type: "key",
+ name: "Wallet 1",
+ encryptedSecret: await encryptWithPassword(PRIVATE_KEY, PASSWORD),
+ addresses: [{ address: HOLDER }],
+ },
+ ];
+ state.selectedWallet = 0;
+ state.selectedAddress = 0;
+ await refreshBalances(
+ state.wallets,
+ "https://rpc.example.invalid",
+ "https://blockscout.example/api/v2",
+ state.trackedTokens,
+ "mainnet",
+ );
+}
+
+function tokenRow(value, decimals = "18") {
+ return {
+ value: String(value),
+ token: {
+ type: "ERC-20",
+ address_hash: TOKEN,
+ symbol: "TOK",
+ name: "Token",
+ decimals,
+ holders_count: "50000",
+ },
+ };
+}
+
+// The confirmation screen Review leads to, once shown.
+let confirmed = null;
+
+// Open the Send screen for `token` ("ETH" or a token address), with the
+// recipient entered.
+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 = "";
+}
+
+async function pressMax() {
+ await el("btn-send-max").handlers.get("click")();
+}
+
+// Press Review and show the confirmation screen with its fee estimate settled.
+async function review() {
+ await el("btn-send-review").handlers.get("click")();
+ confirmTx.show(confirmed);
+ await settle();
+}
+
+// show() starts the fee estimate without awaiting it; this lets it settle.
+async function settle() {
+ for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
+}
+
+function canSend() {
+ return !el("btn-confirm-send").disabled;
+}
+
+beforeEach(() => {
+ elements.clear();
+ confirmed = null;
+ state.selectedToken = null;
+ state.trackedTokens = [];
+ state.fraudContracts = [];
+ state.currentView = null;
+ mockNode.feeData = fees(20n * GWEI);
+ mockNode.broadcast = [];
+});
+
+describe("Max on an ETH send", () => {
+ test("fills in the exact balance minus the fee reserve", async () => {
+ await refreshWith(BALANCE_WEI);
+ openSend();
+ send.updateSendBalance();
+ expect(text("send-balance")).toBe("Current balance: 1.2345 ETH");
+ await pressMax();
+ // 1.234567890123456789 - 21000 * 20 gwei.
+ expect(el("send-amount").value).toBe("1.234147890123456789");
+ });
+
+ test("leaves exactly the fee reserve behind, and the confirmation screen enables Send", async () => {
+ await refreshWith(BALANCE_WEI);
+ openSend();
+ await pressMax();
+ await review();
+ expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
+ expect(el("confirm-errors").innerHTML).toBe("");
+ expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
+ expect(canSend()).toBe(true);
+ });
+
+ test("re-derives the amount when the fee estimate changes", async () => {
+ await refreshWith(BALANCE_WEI);
+ openSend();
+ await pressMax();
+ expect(el("send-amount").value).toBe(maxAfter(20n * GWEI));
+
+ // The fee rises between Max and the confirmation screen's estimate.
+ // Kept, the Send screen's amount would be refused for want of funds.
+ mockNode.feeData = fees(30n * GWEI);
+ await review();
+ expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
+ expect(canSend()).toBe(true);
+
+ // And falls when the screen is shown again, as on reopening the popup.
+ mockNode.feeData = fees(10n * GWEI);
+ confirmTx.restore();
+ await settle();
+ expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
+ expect(canSend()).toBe(true);
+ });
+
+ test("is signed with the fee its amount leaves behind", async () => {
+ await refreshWith(BALANCE_WEI);
+ openSend();
+ await pressMax();
+ await review();
+ // The fee the node quotes rises after the estimate. Fetched afresh
+ // at signing, it would make amount plus fee more than the balance.
+ mockNode.feeData = fees(25n * GWEI);
+ el("confirm-tx-password").value = PASSWORD;
+ await el("btn-confirm-send").handlers.get("click")();
+
+ expect(mockNode.broadcast).toHaveLength(1);
+ const tx = Transaction.from(mockNode.broadcast[0]);
+ expect(tx.to).toBe(RECIPIENT);
+ expect(tx.maxFeePerGas).toBe(20n * GWEI);
+ expect(tx.value + tx.gasLimit * tx.maxFeePerGas).toBe(BALANCE_WEI);
+ });
+
+ test("says so instead of filling in an amount when the balance does not cover the fee", async () => {
+ // 0.0001 ETH against a reserve of 0.00042 ETH.
+ await refreshWith(100000000000000n);
+ openSend();
+ await pressMax();
+ expect(el("send-amount").value).toBe("");
+ expect(text("flash-msg")).toBe(
+ "Your balance does not cover the network fee.",
+ );
+ });
+
+ test("asks for the recipient first, since the fee depends on it", async () => {
+ await refreshWith(BALANCE_WEI);
+ openSend();
+ el("send-to").value = "";
+ await pressMax();
+ expect(el("send-amount").value).toBe("");
+ expect(text("flash-msg")).toBe(
+ "Please enter a recipient address first.",
+ );
+ });
+
+ // 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 nothing in when the holding was changed while the fee was estimated", async () => {
+ await refreshWith(BALANCE_WEI, [tokenRow(10n ** 18n)]);
+ // Opened from the home screen, where the dropdown picks the holding.
+ openSend(null);
+ el("send-token").value = "ETH";
+ const release = holdFeeEstimate();
+ const pressed = pressMax();
+ el("send-token").value = TOKEN;
+ el("send-token").handlers.get("change")();
+ release();
+ await pressed;
+ expect(el("send-amount").value).toBe("");
+ expect(text("flash-msg")).toBe("");
+ });
+
+ test("keeps an amount typed while the fee was estimated", async () => {
+ await refreshWith(BALANCE_WEI);
+ openSend();
+ const release = holdFeeEstimate();
+ const pressed = pressMax();
+ el("send-amount").value = "0.5";
+ el("send-amount").handlers.get("input")();
+ release();
+ await pressed;
+ expect(el("send-amount").value).toBe("0.5");
+ 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();
+ await pressMax();
+ el("send-amount").value = "0.5";
+ el("send-amount").handlers.get("input")();
+ mockNode.feeData = fees(30n * GWEI);
+ await review();
+ expect(text("confirm-amount")).toBe("0.5 ETH");
+ });
+});
+
+describe("Max on a token send", () => {
+ // 1234.567890123456789012 TOK; the Send screen shows 1234.5678.
+ const TOKEN_UNITS = 1234567890123456789012n;
+
+ test("fills in the whole token balance", async () => {
+ await refreshWith(BALANCE_WEI, [tokenRow(TOKEN_UNITS)]);
+ 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("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);
+ await pressMax();
+ expect(el("send-amount").value).toBe("1234.567890123456789012");
+ await review();
+ expect(el("confirm-gas-error").style.visibility).toBe("visible");
+ expect(canSend()).toBe(false);
+ });
+
+ test("says so when the token balance is unknown", async () => {
+ // No scale from the explorer, the bundled list or a tracked token.
+ const row = tokenRow(TOKEN_UNITS);
+ delete row.token.decimals;
+ await refreshWith(BALANCE_WEI, [row]);
+ openSend(TOKEN);
+ await pressMax();
+ expect(el("send-amount").value).toBe("");
+ expect(text("flash-msg")).toBe("This token's balance is unknown.");
+ });
+
+ test("says so when the token balance is zero", async () => {
+ state.trackedTokens = [
+ { address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
+ ];
+ await refreshWith(BALANCE_WEI, [tokenRow(0n)]);
+ openSend(TOKEN);
+ await pressMax();
+ expect(el("send-amount").value).toBe("");
+ expect(text("flash-msg")).toBe("This token's balance is zero.");
+ });
+});
diff --git a/tests/txValidation.test.js b/tests/txValidation.test.js
index c5d5e6c..f08039a 100644
--- a/tests/txValidation.test.js
+++ b/tests/txValidation.test.js
@@ -6,6 +6,8 @@ const {
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
+ maxEthAmount,
+ maxTokenAmount,
toFixedPoint,
validateTransfer,
} = require("../src/shared/txValidation");
@@ -306,6 +308,72 @@ describe("feeEstimateWei", () => {
});
});
+// The amount the Send screen's Max fills in for ETH: the exact balance, as
+// balances.js stores it, minus the fee reserve. Never the four-decimal balance
+// the Send screen shows.
+describe("maxEthAmount", () => {
+ // The Send screen shows this balance as 1.2345.
+ const BALANCE = "1.234567890123456789";
+
+ test("is the exact balance minus the fee, to the wei", () => {
+ expect(maxEthAmount(BALANCE, FEE)).toBe("1.234147890123456789");
+ expect(
+ parseEther(BALANCE) - parseEther(maxEthAmount(BALANCE, FEE)),
+ ).toBe(FEE);
+ });
+
+ test("passes validateTransfer with exactly the fee left behind", () => {
+ const r = validateTransfer({
+ isErc20: false,
+ amount: maxEthAmount(BALANCE, FEE),
+ ethBalance: BALANCE,
+ feeStatus: FEE_KNOWN,
+ feeWei: FEE,
+ });
+ expect(r).toEqual({ canSend: true, codes: [] });
+ });
+
+ test("is one wei when the balance is one wei more than the fee", () => {
+ expect(maxEthAmount("0.000420000000000001", FEE)).toBe(
+ "0.000000000000000001",
+ );
+ });
+
+ test("is null when the balance does not cover the fee", () => {
+ expect(maxEthAmount("0.0001", FEE)).toBe(null);
+ expect(maxEthAmount("0.0", FEE)).toBe(null);
+ });
+
+ test("is null when the balance covers the fee and nothing more", () => {
+ expect(maxEthAmount("0.00042", FEE)).toBe(null);
+ });
+
+ test("is null on a balance or fee it cannot do exact arithmetic on", () => {
+ expect(maxEthAmount(undefined, FEE)).toBe(null);
+ expect(maxEthAmount("not a number", FEE)).toBe(null);
+ expect(maxEthAmount(BALANCE, null)).toBe(null);
+ expect(maxEthAmount(BALANCE, -1n)).toBe(null);
+ expect(maxEthAmount(BALANCE, 420000000000000)).toBe(null);
+ });
+});
+
+// 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.