// Browser-level network interception for the end-to-end suite. // // Every http(s) request the extension makes — from the popup page AND // from the MV3 background service worker — is fulfilled from these // fixtures, so the suite is deterministic and runs entirely offline. The // probe that motivated this harness (see issue #181) observed live calls // to Blockscout returning 401 inside the container, which would make any // assertion about rendered transaction data worthless. // // Service-worker coverage is not free: ctx.route() only sees worker // traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in // the environment, which script/test-e2e does. Without it the phishing // blocklist fetch that src/background/index.js issues at worker startup // silently reaches raw.githubusercontent.com on the open internet, and // src/shared/phishingDomains.js swallows the failure so nothing surfaces // it. That is not left to trust: waitForServiceWorkerTraffic() below // backs the launch-time canary in harness.js, which fails the entire // suite if worker requests stop being visible here. // // Anything not explicitly stubbed here is aborted AND reported to the // error collector, so a newly added outbound call shows up as a test // failure rather than as intermittent flakiness. "use strict"; // Fictional ERC-20 used to seed the transaction-detail test. The symbol // must not collide with any entry in src/shared/tokenList.js, or // isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a // symbol-spoofing attempt; holders_count must be >= 1000 or the default // hideLowHolderTokens filter drops it. Either would make the test pass // vacuously by never rendering a row at all. const STUB_TOKEN = { address: "0xe2e0000000000000000000000000000000000e2e", symbol: "E2E", name: "End To End Test Token", decimals: "6", holders: "12345", }; const STUB_COUNTERPARTY = "0xc0ffee0000000000000000000000000000c0ffee"; const STUB_TX_HASH = "0xe2e0000000000000000000000000000000000000000000000000000000000e2e"; const STUB_BLOCK_NUMBER = 21000000; // Fixed instant so timeAgo() output is stable across runs. const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z"; // A 32-byte zero word. Returned for every eth_call, which is what makes // ethers' ENS reverse lookup resolve to "no resolver set" and return null // instead of throwing. A throw would be logged by src/shared/ens.js via // log.errorf(), i.e. console.error, which fails the run on its own. const ZERO_WORD = "0x" + "0".repeat(64); function hex(value) { return "0x" + BigInt(value).toString(16); } // A bigint as a 32-byte ABI word. function word(value) { return "0x" + BigInt(value).toString(16).padStart(64, "0"); } // ------------------------------------------------------------ fee fixture // // The confirmation screen carries two different numbers for the same // transaction and may gate on only one of them: // // reserve = gasLimit * maxFeePerGas — what a node requires to be // available for a type-2 transaction, and what the spend gate // must use. // estimate = gasLimit * gasPrice — what the transfer is expected to // actually cost. Display only. // // Issue #154 was the gate reading the smaller of the two. ethers derives // maxFeePerGas as baseFeePerGas * 2 + maxPriorityFeePerGas, so the numbers // below put the reserve at very nearly twice the estimate. That gap is the // entire point of these values: it leaves room for a send that an // estimate-based gate accepts and a reserve-based gate refuses, which is // what lets the ConfirmTx tests tell the two apart at all. Collapse the gap // — by dropping baseFeePerGas from the block below, say — and those tests // go on passing while asserting nothing. const GAS_LIMIT = 21000n; const BASE_FEE_WEI = 100000000000n; // 100 gwei const PRIORITY_FEE_WEI = 1000000000n; // 1 gwei const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI; // 101 gwei const MAX_FEE_WEI = BASE_FEE_WEI * 2n + PRIORITY_FEE_WEI; // 201 gwei const FEE_ESTIMATE_WEI = GAS_LIMIT * GAS_PRICE_WEI; // 0.002121 ETH const FEE_RESERVE_WEI = GAS_LIMIT * MAX_FEE_WEI; // 0.004221 ETH const RPC_RESULTS = { eth_chainId: "0x1", net_version: "1", eth_blockNumber: "0x1406f40", eth_getBalance: "0x0", eth_call: ZERO_WORD, eth_getCode: "0x", eth_gasPrice: hex(GAS_PRICE_WEI), eth_estimateGas: hex(GAS_LIMIT), eth_getTransactionCount: "0x0", eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI), }; // The "latest" block, which ethers' getFeeData() reads baseFeePerGas from // to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the // reserve and the estimate collapse to the same number, and the gate tests // stop being able to distinguish them. function latestBlock() { return { hash: "0x" + "11".repeat(32), parentHash: "0x" + "22".repeat(32), number: hex(STUB_BLOCK_NUMBER), timestamp: hex(1767326645), nonce: "0x0000000000000000", difficulty: "0x0", gasLimit: "0x1c9c380", gasUsed: "0xf4240", miner: STUB_COUNTERPARTY, extraData: "0x", baseFeePerGas: hex(BASE_FEE_WEI), transactions: [], }; } // keccak("decimals()")[0:4]. const SELECTOR_DECIMALS = "0x313ce567"; // Every eth_call still answers with a zero word except decimals() on the // stub token. ethers reads that before it can encode an ERC-20 transfer, // and a zero there makes parseUnits() reject any fractional amount — so the // ERC-20 confirmation path would fail its gas estimate for a reason that // has nothing to do with what is being tested. function ethCallResult(req) { const call = Array.isArray(req.params) ? req.params[0] : null; if (!call || typeof call !== "object") return ZERO_WORD; const data = String(call.data || call.input || "").toLowerCase(); const to = String(call.to || "").toLowerCase(); if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) { return word(STUB_TOKEN.decimals); } return ZERO_WORD; } function tokenObject() { return { address_hash: STUB_TOKEN.address, address: STUB_TOKEN.address, symbol: STUB_TOKEN.symbol, name: STUB_TOKEN.name, decimals: STUB_TOKEN.decimals, holders_count: STUB_TOKEN.holders, type: "ERC-20", }; } // One received ERC-20 transfer of 1.5 E2E to the address under test. function tokenTransferItems(address) { return [ { transaction_hash: STUB_TX_HASH, block_number: STUB_BLOCK_NUMBER, timestamp: STUB_TX_TIMESTAMP, from: { hash: STUB_COUNTERPARTY }, to: { hash: address }, total: { decimals: STUB_TOKEN.decimals, value: "1500000" }, token: tokenObject(), }, ]; } // A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving // this is what puts an ERC-20 in the send screen's token dropdown, which is // the only way the confirmation screen's ERC-20 path can be reached. function tokenBalanceItems() { return [ { value: "1500000", token: tokenObject(), }, ]; } // Full details for STUB_TX_HASH. raw_input is "0x" so the calldata // decoder short-circuits; the on-chain detail fields still populate. function transactionDetails() { return { hash: STUB_TX_HASH, block_number: STUB_BLOCK_NUMBER, nonce: 7, gas_used: "51000", gas_price: "1000000000", fee: { value: "51000000000000" }, raw_input: "0x", status: "ok", }; } function jsonResponse(route, body) { return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body), }); } // Extract the address from a Blockscout /addresses//... path. function blockscoutAddress(pathname) { const m = pathname.match(/\/addresses\/(0x[0-9a-fA-F]{40})\//); return m ? m[1] : null; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // How long a deliberately held reply is allowed to stay held, and how often // the release flag is re-read while it is. const HOLD_POLL_MS = 25; const HOLD_MAX_MS = 30000; // Hold a gas estimate open for as long as the test asks. // // opts.holdGasEstimate is read here rather than captured, so a test flips it // on the same options object the route was registered with — the same // pattern as seedTokenTransfer. This is the only way to observe the // confirmation screen while its estimate is genuinely in flight; sampling // the screen and hoping to win a race against the network would assert // nothing on a slow machine. // // It never gives up quietly. A hold that outlives the bound is reported like // any other harness fault, because a "pending" state that stopped being // pending on its own is a green assertion about the wrong screen. async function awaitRelease(opts, report) { const started = Date.now(); while (opts.holdGasEstimate) { if (Date.now() - started > HOLD_MAX_MS) { report( "held gas estimate was never released after " + HOLD_MAX_MS + "ms", ); return; } await sleep(HOLD_POLL_MS); } } // One JSON-RPC reply. Methods whose answer depends on a fixture a test has // set, or on the call itself, are resolved here; every other method is a // constant in RPC_RESULTS. function rpcReply(req, opts, report) { const envelope = { jsonrpc: "2.0", id: req.id }; if (req.method === "eth_getBalance") { return Object.assign(envelope, { result: opts.ethBalanceWei || RPC_RESULTS.eth_getBalance, }); } if (req.method === "eth_call") { return Object.assign(envelope, { result: ethCallResult(req) }); } if (req.method === "eth_getBlockByNumber") { return Object.assign(envelope, { result: latestBlock() }); } if (req.method === "eth_estimateGas" && opts.failGasEstimate) { // A refusal the node itself would produce, not a transport error: // this is the shape the confirmation screen has to turn into // "Unable to estimate" rather than into a fee of zero. return Object.assign(envelope, { error: { code: -32000, message: "e2e fixture: gas required exceeds allowance", }, }); } const result = RPC_RESULTS[req.method]; if (result === undefined) { report("unstubbed RPC method: " + req.method); return Object.assign(envelope, { error: { code: -32601, message: "unstubbed in e2e harness" }, }); } return Object.assign(envelope, { result }); } async function handleRpc(route, postData, opts, report) { let payload; try { payload = JSON.parse(postData || "null"); } catch { report("unstubbed RPC: unparseable body " + String(postData)); return route.abort(); } // ethers batches by default, so the body may be an array. const batch = Array.isArray(payload) ? payload : [payload]; // Anything that is not a JSON-RPC object, or a batch of them, is not // RPC at all and must be reported like any other unrecognised // outbound traffic rather than dereferenced. request.postData() // returns null both for a bodyless POST and for a body Playwright // cannot decode as UTF-8 (sendBeacon with a Blob, or any binary // payload), so this is not an empty-string special case: it rejects // every non-object payload, exactly as the catch above rejects every // unparseable one. if ( payload === null || typeof payload !== "object" || !batch.every((req) => req !== null && typeof req === "object") ) { report("unstubbed request: POST " + route.request().url()); return route.abort(); } if (batch.some((req) => req.method === "eth_estimateGas")) { await awaitRelease(opts, report); } const replies = batch.map((req) => rpcReply(req, opts, report)); return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]); } const TRACE_TRUE = ["1", "true", "yes", "on"]; const TRACE_FALSE = ["", "0", "false", "no", "off"]; // Whether E2E_TRACE_NETWORK asks for the request trace. // // A set-but-unrecognised value is a hard error rather than a quiet // "off": E2E_TRACE_NETWORK=true asking for a trace and getting silence // is the operator being lied to about what the harness is doing, which // is the whole failure mode this suite exists to eliminate. Refusing to // guess costs one line and one obvious error message. function traceEnabled(raw) { if (raw === undefined || raw === null) return false; const v = String(raw).trim().toLowerCase(); if (TRACE_TRUE.includes(v)) return true; if (TRACE_FALSE.includes(v)) return false; throw new Error( "E2E_TRACE_NETWORK is set to " + JSON.stringify(String(raw)) + ", which is not a recognised on/off value. Use one of " + TRACE_TRUE.join(", ") + " to enable the request trace, or one of " + TRACE_FALSE.slice(1).join(", ") + " to disable it. Refusing to guess: a diagnostic that silently " + "does nothing is worse than one that is not there", ); } /** * Route every http(s) request through local fixtures. * * @param {import("playwright-core").BrowserContext} ctx * @param {object} opts * @param {(text: string) => void} opts.report called for unstubbed traffic * @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20 * transfer. Read at request time, so a test can flip it on the same * options object without re-registering the route. * @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20 * holding, which is what makes the token reachable from the send screen. * @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance; * defaults to zero, which is what every test that predates the funded * fixture expects. * @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a * node-side refusal. * @param {boolean} [opts.holdGasEstimate] hold every batch containing an * eth_estimateGas until this is cleared again. * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * Promise}>} */ async function installNetworkStubs(ctx, opts) { const report = opts.report; // First request seen that originated in a service worker, and the // resolver waiting for it. This is what proves worker interception is // actually in force; see waitForServiceWorkerTraffic below. let firstWorkerRequest = null; let announceWorkerRequest = null; // E2E_TRACE_NETWORK=1 prints every request that reaches this handler, // tagged [sw] when it originated in the background service worker. // It exists so the isolation claim above can be re-checked by anyone // in one command, without editing files: the phishing blocklist fetch // showing up with an [sw] tag is the proof that the worker really is // intercepted and that the raw.githubusercontent.com stub below is // live code rather than decoration. const trace = traceEnabled(process.env.E2E_TRACE_NETWORK); // Regex rather than a glob so chrome-extension:// resource loads are // never touched — routing those would break the popup itself. await ctx.route(/^https?:\/\//, async (route) => { const req = route.request(); const url = new URL(req.url()); const p = url.pathname; const fromWorker = !!req.serviceWorker(); if (fromWorker && !firstWorkerRequest) { firstWorkerRequest = req.method() + " " + req.url(); if (announceWorkerRequest) announceWorkerRequest(firstWorkerRequest); } if (trace) { const origin = fromWorker ? "[sw] " : "[page] "; console.log("# routed " + origin + req.method() + " " + req.url()); } // JSON-RPC endpoint (any host): a POST with a JSON-RPC body. if (req.method() === "POST") { return handleRpc(route, req.postData(), opts, report); } // Blockscout v2 if (p.includes("/api/v2/")) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) { return jsonResponse(route, { items: [] }); } if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) { const addr = blockscoutAddress(p); return jsonResponse(route, { items: opts.seedTokenTransfer && addr ? tokenTransferItems(addr) : [], }); } if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) { return jsonResponse( route, opts.seedTokenBalance ? tokenBalanceItems() : [], ); } if (p.endsWith("/transactions/" + STUB_TX_HASH)) { return jsonResponse(route, transactionDetails()); } } // CoinDesk price tick if (url.hostname.endsWith("coindesk.com")) { return jsonResponse(route, { Data: {} }); } // MetaMask phishing blocklist if ( url.hostname === "raw.githubusercontent.com" || p.endsWith("/eth-phishing-detect/main/src/config.json") ) { return jsonResponse(route, { version: 2, tolerance: 2, fuzzylist: [], whitelist: [], blacklist: [], }); } // Best-effort Etherscan address labels: served as an empty page. if (url.hostname.endsWith("etherscan.io")) { return route.fulfill({ status: 200, contentType: "text/html", body: "", }); } report("unstubbed request: " + req.method() + " " + req.url()); return route.abort(); }); return { /** * Resolve with the first service-worker-originated request this * handler saw, or null if none arrives within `ms`. * * The background worker fetches the phishing blocklist at * startup, unconditionally, within about a second of the context * coming up — so under working interception this resolves almost * immediately. Nothing arriving means worker traffic is bypassing * the handler entirely and going to the real internet, which the * caller turns into a hard failure of the whole suite. */ waitForServiceWorkerTraffic(ms) { if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest); return new Promise((resolve) => { const timer = setTimeout(() => { announceWorkerRequest = null; resolve(null); }, ms); announceWorkerRequest = (req) => { clearTimeout(timer); announceWorkerRequest = null; resolve(req); }; }); }, }; } module.exports = { installNetworkStubs, FEE_ESTIMATE_WEI, FEE_RESERVE_WEI, STUB_COUNTERPARTY, STUB_TOKEN, STUB_TX_HASH, };