diff --git a/README.md b/README.md
index 6b1c28b..5c848fe 100644
--- a/README.md
+++ b/README.md
@@ -1467,6 +1467,15 @@ 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 the
+ 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**
@@ -1483,7 +1492,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..6935435 100644
--- a/TODO.md
+++ b/TODO.md
@@ -45,6 +45,18 @@ 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, 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)).
`resolveTokenDecimals()` already used a scale of 0 from the bundled list or
diff --git a/docs/README.md b/docs/README.md
index 032fca3..eab4a76 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -240,7 +240,8 @@ 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 in everything you can send: a
+ token's whole balance, or your ETH balance minus 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.");
+ } else {
+ $("send-amount").value = bal;
+ 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;
+ 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,
+ );
+ }
+ // 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.");
+ 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 +394,7 @@ function init(_ctx) {
tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals,
+ max: amountIsMax,
});
});
@@ -318,7 +405,9 @@ function init(_ctx) {
});
}
+// Called each time the Send screen opens, with its fields cleared.
function resetSendValidation() {
+ 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..24b0812 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,21 @@ 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);
+}
+
// Validate a pending transfer against the balances that must cover it.
//
// isErc20 — token transfer rather than a native ETH transfer
@@ -174,6 +189,7 @@ module.exports = {
SCALE_DECIMALS,
feeReserveWei,
feeEstimateWei,
+ maxEthAmount,
toFixedPoint,
validateTransfer,
};
diff --git a/tests/sendMax.test.js b/tests/sendMax.test.js
new file mode 100644
index 0000000..c40f704
--- /dev/null
+++ b/tests/sendMax.test.js
@@ -0,0 +1,409 @@
+// 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";
+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) {
+ return {
+ value: String(value),
+ token: {
+ type: "ERC-20",
+ address_hash: TOKEN,
+ symbol: "TOK",
+ name: "Token",
+ decimals: "18",
+ 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.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.",
+ );
+ });
+
+ 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("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..d3303cf 100644
--- a/tests/txValidation.test.js
+++ b/tests/txValidation.test.js
@@ -6,6 +6,7 @@ const {
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
+ maxEthAmount,
toFixedPoint,
validateTransfer,
} = require("../src/shared/txValidation");
@@ -306,6 +307,55 @@ 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);
+ });
+});
+
// 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.