// The wallet's OWN send path enforces the same combined fee bound the dApp // path does (https://git.eeqj.de/sneak/AutistMask/issues/399). // // The send in src/popup/views/confirmTx.js pins no fee fields, so ethers fills // maxFeePerGas and the gas limit from whatever the configured RPC node // answers. Nothing bounded that: a hostile node could report a fee whose // product with the gas limit is thousands of ETH, and it would be both // displayed and signed. populateVerifyAndSend() populates the transaction and // runs assertWithinCeilings() on the populated fees before signing, so an // over-bound send is refused before anything is broadcast. // // The check is driven here with a fake connected signer rather than a real // one: populateTransaction() returns the fees the node would have produced, // and sendTransaction() records whether the send actually happened. // // The last test drives the real confirmation screen instead, with only the // node and the DOM stubbed: a gas limit over its own bound at an ordinary fee // is refused in the same box as a fee over the bound // (https://git.eeqj.de/sneak/AutistMask/issues/583). That the box moves // nothing on the screen is checked by the Chrome e2e suite. // What the stub node quotes for that test: a gas limit of 150,000,000 at a // maximum fee of 2 gwei per gas, 0.3 ETH in all. And the signed transactions // it was handed. const mockNode = { gasLimit: 150000000n, maxFeePerGas: 2000000000n, broadcast: [], }; jest.mock("ethers", () => { const actual = jest.requireActual("ethers"); class StubProvider { constructor(url, network) { this.network = network; } async getNetwork() { return this.network; } async getBalance() { return actual.parseEther("5"); } async getCode() { return "0x"; } async getTransactionCount() { return 1; } async getFeeData() { return { maxFeePerGas: mockNode.maxFeePerGas, maxPriorityFeePerGas: 1000000000n, gasPrice: 1000000000n, }; } async estimateGas() { return mockNode.gasLimit; } async broadcastTransaction(signed) { mockNode.broadcast.push(signed); return { hash: "0x" + "ab".repeat(32) }; } } return { ...actual, JsonRpcProvider: StubProvider }; }); // The screens a send that was not refused goes on to. jest.mock("../src/popup/views/txStatus", () => ({ showWait: jest.fn(), showError: jest.fn(), })); const { makeStorageStub } = require("./support/storageStub"); global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } }; global.fetch = jest.fn(() => { throw new Error("tests must not perform network requests"); }); // 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, addEventListener(name, fn) { handlers.set(name, fn); }, querySelectorAll: () => [], remove() {}, }; } global.document = { getElementById(id) { if (!elements.has(id)) elements.set(id, makeEl(id)); return elements.get(id); }, }; const { Wallet } = require("ethers"); const { encryptWithPassword } = require("../src/shared/vault"); const { state, loadState } = require("../src/shared/state"); const txStatus = require("../src/popup/views/txStatus"); const confirmTx = require("../src/popup/views/confirmTx"); const { populateVerifyAndSend } = confirmTx; const { MAX_GAS_LIMIT, MAX_FEE_PER_GAS, MAX_TOTAL_FEE, } = require("../src/shared/approvalVerify"); const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; // A signer whose populateTransaction() fills in the fees a node quoted and // whose sendTransaction() records the call, so a test can assert whether the // send was reached at all. function fakeSigner(fees) { const sent = []; return { sent, populateTransaction: async (request) => ({ ...request, from: RECIPIENT, nonce: 0, type: 2, chainId: 1n, gasLimit: fees.gasLimit, maxFeePerGas: fees.maxFeePerGas, maxPriorityFeePerGas: 1000000000n, }), sendTransaction: async (tx) => { sent.push(tx); return { hash: "0xabc" }; }, }; } const ETH_SEND = { token: "ETH", to: RECIPIENT, amount: "1.0" }; describe("populateVerifyAndSend enforces the combined fee bound", () => { // A gas limit and a fee that are each under their own field ceiling, but // multiply to about 3,000 ETH — the combination the per-field ceilings // cannot see. const OVER = { gasLimit: 30000000n, maxFeePerGas: MAX_FEE_PER_GAS }; test("each field is under its ceiling but the product is over the bound", () => { expect(OVER.maxFeePerGas).toBeLessThanOrEqual(MAX_FEE_PER_GAS); expect(OVER.gasLimit * OVER.maxFeePerGas).toBeGreaterThan( MAX_TOTAL_FEE, ); }); test("refuses an over-bound send without broadcasting it", async () => { const signer = fakeSigner(OVER); let thrown; try { await populateVerifyAndSend(signer, ETH_SEND); } catch (e) { thrown = e; } expect(thrown).toBeDefined(); expect(thrown.approvalMismatch).toBe(true); expect(thrown.message).toMatch(/^[A-Z].*\.$/); expect(thrown.message).toContain("3000.0 ETH"); expect(thrown.message).toContain("1.0 ETH"); // The one guarantee that matters: nothing was signed or sent. expect(signer.sent).toHaveLength(0); }); test("broadcasts a send whose product is just under the bound", async () => { // 21,000 gas at 40 gwei is 0.00084 ETH — an ordinary send. const under = { gasLimit: 21000n, maxFeePerGas: 40000000000n }; expect(under.gasLimit * under.maxFeePerGas).toBeLessThan(MAX_TOTAL_FEE); const signer = fakeSigner(under); const tx = await populateVerifyAndSend(signer, ETH_SEND); expect(tx.hash).toBe("0xabc"); expect(signer.sent).toHaveLength(1); expect(signer.sent[0].gasLimit).toBe(under.gasLimit); }); }); describe("Sign & Send on the confirmation screen", () => { const PRIVATE_KEY = "0x" + "11".repeat(32); const PASSWORD = "correct horse battery staple"; let encryptedSecret; beforeAll(async () => { await loadState(); encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD); }); function el(id) { return global.document.getElementById(id); } function shown(id) { return el(id).style.visibility === "visible"; } // show() reads the balance and estimates the fee without awaiting // either; this lets both finish. async function settle() { for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); } test("refuses a gas limit over its bound at an ordinary fee in the box for a refused gas limit or fee, and sends nothing", async () => { expect(mockNode.gasLimit).toBeGreaterThan(MAX_GAS_LIMIT); expect(mockNode.gasLimit * mockNode.maxFeePerGas).toBeLessThan( MAX_TOTAL_FEE, ); const from = new Wallet(PRIVATE_KEY).address; state.wallets = [ { type: "key", name: "Wallet 1", encryptedSecret, addresses: [{ address: from }], }, ]; state.selectedWallet = 0; state.selectedAddress = 0; confirmTx.init({}); confirmTx.show({ from, to: RECIPIENT, ensName: null, amount: "1", token: "ETH", tokenSymbol: null, tokenDecimals: null, max: false, }); await settle(); expect(el("btn-confirm-send").disabled).toBe(false); expect(shown("confirm-fee-bound-error")).toBe(false); el("confirm-tx-password").value = PASSWORD; await el("btn-confirm-send").handlers.get("click")(); expect(shown("confirm-fee-bound-error")).toBe(true); expect(mockNode.broadcast).toEqual([]); expect(txStatus.showWait).not.toHaveBeenCalled(); expect(txStatus.showError).not.toHaveBeenCalled(); }); });