// Which chain a dApp transaction is PREPARED for on a worker that has not // loaded state. // // The MV3 service worker is terminated when idle — roughly 30 seconds, which // is its normal condition — and revived by the page's own message. Nothing // loads state at module scope, so handleSendTransaction() used to build its // provider with `getProvider(await getRpcUrl())`: the endpoint came from // storage and was right, and the static network hint was omitted, so // src/shared/balances.js fell back to currentNetwork() — the unpopulated // singleton — and answered mainnet. ethers then fixed `chainId` at 0x1. // // The transaction was not sent on the wrong chain: verifySignedTx() compares // the artifact against the selected chain and refused it. So the guard held // and the feature did not — a user on any non-mainnet network could not send // from a dApp at all, and the error described the symptom // (https://git.eeqj.de/sneak/AutistMask/issues/320). // // This drives the real balances module and the real approval preparation and // verification. Only ethers' JsonRpcProvider is replaced, so the static // network hint getProvider() computes is the hint the population sees. const { Network, Wallet, Transaction } = require("ethers"); const { networkById } = require("../src/shared/networks"); const { makeStorageStub } = require("./support/storageStub"); const SIGNER_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; const signer = new Wallet(SIGNER_KEY); const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const CONNECTED_ORIGIN = "https://dapp.example"; const CONNECTED_HOSTNAME = "dapp.example"; const EXT_URL = "chrome-extension://autistmask/"; const SEPOLIA = networkById("sepolia"); const MAINNET = networkById("mainnet"); const NONCE = 7; const TX_HASH = "0xfeed"; const TX_PARAMS = { from: signer.address, to: RECIPIENT, value: "0x2386f26fc10000", data: "0x", }; function storedProfile(networkId) { const net = networkById(networkId); return { hasWallet: true, wallets: [ { name: "Wallet 1", type: "hd", xpub: "xpub-1", addresses: [ { address: signer.address, balance: "0.0", tokenBalances: [], }, ], }, ], activeAddress: signer.address, networkId, rpcUrl: net.defaultRpcUrl, blockscoutUrl: net.defaultBlockscoutUrl, allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] }, deniedSites: {}, trackedTokens: [], }; } async function settle() { for (let i = 0; i < 60; i++) await Promise.resolve(); } afterEach(() => { delete global.chrome; }); // A worker whose only wallet state is what is in storage, with ethers' // JsonRpcProvider replaced by a stub that answers out of the static network it // was constructed with — which is exactly what a real staticNetwork provider // does, and what makes the chain id on the approval screen observable here. function loadColdWorker(networkId) { jest.resetModules(); const constructed = []; const broadcast = []; jest.doMock("ethers", () => { const actual = jest.requireActual("ethers"); class StubJsonRpcProvider { constructor(url, network) { this._network = network; constructed.push({ url, network }); } async getNetwork() { return this._network; } async getTransactionCount() { return NONCE; } async estimateGas() { return 100000n; } async getFeeData() { return { gasPrice: 2000000000n, maxFeePerGas: 2000000000n, maxPriorityFeePerGas: 1000000000n, }; } async broadcastTransaction(raw) { broadcast.push(raw); return { hash: TX_HASH }; } } return { ...actual, JsonRpcProvider: StubJsonRpcProvider }; }); jest.doMock("../src/shared/phishingDomains", () => ({ isPhishingDomain: () => false, })); jest.doMock("../src/shared/alarms", () => ({ BALANCE_REFRESH_ALARM: "balance", BALANCE_REFRESH_PERIOD_MINUTES: 1, ensureRecurringAlarms: jest.fn(async () => {}), registerAlarmHandlers: jest.fn(), })); const storage = makeStorageStub({ autistmask: storedProfile(networkId) }); let messageListener = null; const createdUrls = []; global.chrome = { storage, runtime: { getURL: (path) => EXT_URL + path, onMessage: { addListener: (fn) => { messageListener = fn; }, }, onConnect: { addListener: () => {} }, lastError: null, }, windows: { getLastFocused: (cb) => cb(null), create: (opts, cb) => { createdUrls.push(opts.url); cb({ id: createdUrls.length }); }, remove: (id, cb) => { if (cb) cb(); }, onRemoved: { addListener: () => {} }, }, tabs: { query: (queryInfo, cb) => cb([{ id: 1 }]), sendMessage: (tabId, message, cb) => { if (cb) cb(); }, }, action: { setPopup: () => {} }, }; require("../src/background/index"); function send(msg, sender) { let result = null; messageListener(msg, sender, (r) => { result = r; }); return () => result; } return { send, constructed, broadcast, fromPopup: { url: EXT_URL + "src/popup/index.html" }, // The first message this worker ever sees, as the injected provider // sends it. sendTransaction: () => send( { type: "AUTISTMASK_RPC", method: "eth_sendTransaction", params: [TX_PARAMS], }, { origin: CONNECTED_ORIGIN }, ), approvalId: () => { const url = createdUrls[createdUrls.length - 1]; return url ? new URL(url, EXT_URL).searchParams.get("approval") : null; }, }; } // What the approval window does: fetch the approval and sign the transaction // it was handed, exactly as given. function signApproved(approvedTx) { const tx = {}; for (const [key, value] of Object.entries(approvedTx)) { if (key === "from") continue; tx[key] = value; } return signer.signTransaction(tx); } describe("a dApp transaction prepared by a worker that never loaded state", () => { test("a cold send on Sepolia reaches the approval screen and goes out", async () => { const bg = loadColdWorker("sepolia"); const answer = bg.sendTransaction(); await settle(); // The provider was built for Sepolia, endpoint and static hint // together. Omitting the hint made this mainnet. expect(bg.constructed).toHaveLength(1); expect(bg.constructed[0].url).toBe(SEPOLIA.defaultRpcUrl); expect(bg.constructed[0].network.chainId).toBe( Network.from("sepolia").chainId, ); // So the approval the user is shown is a Sepolia transaction. const id = bg.approvalId(); expect(id).toBeTruthy(); const approval = bg.send( { type: "AUTISTMASK_GET_APPROVAL", id }, { url: bg.fromPopup.url }, )(); expect(approval.type).toBe("tx"); expect(approval.approvedTx.chainId).toBe(SEPOLIA.chainId); // And it survives the wallet's own verification, which is where a // 0x1-stamped artifact was refused as "for a different network". const rawSignedTx = await signApproved(approval.approvedTx); const response = bg.send( { type: "AUTISTMASK_TX_RESPONSE", id, approved: true, rawSignedTx, }, { url: bg.fromPopup.url }, ); await settle(); expect(response()).toEqual({ txHash: TX_HASH }); expect(bg.broadcast).toEqual([rawSignedTx]); expect(Number(Transaction.from(rawSignedTx).chainId)).toBe( Number(SEPOLIA.networkVersion), ); expect(answer()).toEqual({ result: TX_HASH }); }); test("a cold send on mainnet is prepared for mainnet", async () => { // The stored value and the old fallback agree here, so this case // cannot catch the defect; it is what keeps the fix from being a swap. const bg = loadColdWorker("mainnet"); bg.sendTransaction(); await settle(); expect(bg.constructed[0].url).toBe(MAINNET.defaultRpcUrl); const approval = bg.send( { type: "AUTISTMASK_GET_APPROVAL", id: bg.approvalId() }, { url: bg.fromPopup.url }, )(); expect(approval.approvedTx.chainId).toBe(MAINNET.chainId); }); });