// A nonce the page supplies is not used // (https://git.eeqj.de/sneak/AutistMask/issues/404). With it a page could // replace one of the user's pending transactions (the same nonce at a higher // fee) or leave the new one stuck behind a gap, so the transaction is always // given the account's next nonce from the network. // // Driven through the preparation the background runs on a page's // eth_sendTransaction (src/shared/approvalTx.js) and the real approval screen, // password and Confirm included, against a minimal DOM stub in the shape // tests/approvalOrigin.test.js uses. The vault is mocked so that no password // has to be hashed. jest.mock("../src/shared/vault", () => ({ decryptWithPassword: jest.fn(), })); globalThis.chrome = { storage: { local: { get: async () => ({}), set: async () => {} } }, }; const { Network, Transaction } = require("ethers"); const { state } = require("../src/shared/state"); const { decryptWithPassword } = require("../src/shared/vault"); const { prepareApprovalTx } = require("../src/shared/approvalTx"); const approval = require("../src/popup/views/approval"); // A well-known test phrase, and its first address. const PHRASE = "test test test test test test test test test test test junk"; const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; // The account's next nonce, as the network reports it. const NETWORK_NONCE = 7; const network = { getNetwork: async () => Network.from(1), getTransactionCount: async () => NETWORK_NONCE, estimateGas: async () => 21000n, getFeeData: async () => ({ gasPrice: 2000000000n, maxFeePerGas: 2000000000n, maxPriorityFeePerGas: 1000000000n, }), }; function makeElement(id) { const classes = new Set(); const el = { id, textContent: "", value: "", innerHTML: "", disabled: false, style: {}, dataset: {}, listeners: {}, classList: { add: (...names) => names.forEach((n) => classes.add(n)), remove: (...names) => names.forEach((n) => classes.delete(n)), contains: (n) => classes.has(n), toggle: (n, force) => { const on = force === undefined ? !classes.has(n) : force; if (on) classes.add(n); else classes.delete(n); return on; }, }, addEventListener: (name, fn) => { el.listeners[name] = el.listeners[name] || []; el.listeners[name].push(fn); }, querySelectorAll: () => [], appendChild: () => {}, }; // Views reach for .parentElement to hide whole sections. Object.defineProperty(el, "parentElement", { get: () => node(id + "-parent"), }); return el; } function makeDocument() { const els = new Map(); return { getElementById(id) { // The debug banner is created on demand by helpers.js; absent // is the state a non-debug, non-testnet popup is in. if (id === "debug-banner") return null; if (!els.has(id)) els.set(id, makeElement(id)); return els.get(id); }, createElement: () => makeElement("created"), body: { prepend: () => {} }, }; } function node(id) { return globalThis.document.getElementById(id); } function click(id) { return Promise.all((node(id).listeners.click || []).map((fn) => fn())); } // Open the transaction approval screen the way the popup does: the background // hands over the populated transaction and show() draws it. Returns every // message the screen sends to the background. The background's answer to the // signed transaction does not matter here; a retryable refusal keeps the // screen where it is. async function openTxApproval(approvedTx) { const sent = []; globalThis.document = makeDocument(); globalThis.window = { location: { search: "" }, close: () => {} }; globalThis.chrome.runtime = { connect: () => ({ postMessage: () => {} }), sendMessage: (msg, reply) => { sent.push(msg); if (!reply) return; if (msg.type !== "AUTISTMASK_GET_APPROVAL") { return reply({ error: "Not sent.", retryable: true }); } reply({ type: "tx", origin: "https://dapp.example", isPhishingDomain: false, approvedFrom: FROM, approvedTx, }); }, }; state.activeAddress = FROM; state.wallets = [ { type: "hd", name: "Wallet 1", xpub: "xpub-wallet-1", encryptedSecret: "encrypted-secret-1", nextIndex: 1, addresses: [{ address: FROM, balance: "0", tokenBalances: [] }], }, ]; approval.init({}); await approval.show(1); return sent; } test("a page's nonce is replaced by the network's, on screen and in the signed transaction", async () => { // What the background does with the page's request before it opens the // approval window. const approvedTx = await prepareApprovalTx(network, FROM, { from: FROM, to: RECIPIENT, value: "0x0", data: "0x", nonce: "0x2", }); const sent = await openTxApproval(approvedTx); expect(node("approve-tx-nonce").textContent).toBe("7"); decryptWithPassword.mockResolvedValue(PHRASE); node("approve-tx-password").value = "any password"; await click("btn-approve-tx"); const response = sent.find((msg) => msg.type === "AUTISTMASK_TX_RESPONSE"); expect(Transaction.from(response.rawSignedTx).nonce).toBe(NETWORK_NONCE); });