// 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 real // DOM path around it — reading the fee error into the reserved errors box — is // covered by the Chrome e2e suite. globalThis.chrome = { storage: { local: { get: async () => ({}), set: async () => {} } }, }; global.fetch = jest.fn(() => { throw new Error("tests must not perform network requests"); }); const { populateVerifyAndSend } = require("../src/popup/views/confirmTx"); const { 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); }); });