// 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."); }); });