// A loopback dApp origin and stub Ethereum node for the Firefox suite. // // The Firefox container runs with --network none, and the harness note in // driver.js records the consequence: with no http:// origin in reach, no // content script was ever injected, so content-script behaviour was // UNVERIFIED and the dApp flows could not be driven at all. // // --network none removes every interface except loopback, and loopback is // enough. This serves the page and the JSON-RPC endpoint from 127.0.0.1 // inside the same container Firefox runs in, so the dApp round trips execute // against a real http:// origin and the run stays as offline as it was: the // only reachable peer is this process. // // The page itself is not written twice. DAPP_HTML comes from the Chrome // suite's fixture, so both harnesses drive the same __dapp API and the same // message log. // // Unlike driver.js this file does use ethers, and it has to: the node has to // answer eth_sendRawTransaction with the hash ethers computes for the // artifact it was handed, or provider.broadcastTransaction() refuses the // answer, and the suite recovers signatures itself rather than believing the // extension's own verdict. "use strict"; const http = require("http"); const { Transaction } = require("ethers"); const { DAPP_HTML } = require("../network"); // The same fee shape the Chrome suite uses, for the same reason: it has to // pass the ceilings in src/shared/approvalVerify.js and it has to leave the // reserve and the estimate distinguishable. 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; const STUB_BLOCK_NUMBER = 21000000; // A 32-byte zero word, returned for every eth_call. It is what makes ethers' // ENS reverse lookup resolve to "no resolver set" instead of throwing, and a // throw there reaches the console through src/shared/log.js, which fails the // run on its own. const ZERO_WORD = "0x" + "0".repeat(64); // One ETH, so the popup's balance lines render something and the wallet does // not look empty on the approval screen. const STUB_BALANCE_WEI = 10n ** 18n; function hex(value) { return "0x" + BigInt(value).toString(16); } 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: "0xc0ffee0000000000000000000000000000c0ffee", extraData: "0x", baseFeePerGas: hex(BASE_FEE_WEI), transactions: [], }; } const RPC_RESULTS = { eth_chainId: "0x1", net_version: "1", eth_blockNumber: hex(STUB_BLOCK_NUMBER), eth_getBalance: hex(STUB_BALANCE_WEI), 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), // "accepted but not mined", which is what a node says about a transaction // it has only just taken. The wait screen the approval hands off to polls // this for the rest of the run. eth_getTransactionReceipt: null, web3_clientVersion: "autistmask-e2e-firefox/0", }; // Answer one JSON-RPC call. `broadcast` collects every raw transaction that // reached this node, which is what the transaction assertions are made // against — the artifact as the node saw it, never as the extension described // it. function rpcResult(req, state) { const method = req.method; if (method === "eth_sendRawTransaction") { const raw = req.params && req.params[0]; state.broadcast.push(raw); // ethers checks the hash it is given against the hash it computes for // the artifact it sent, so this cannot be a fixed string. return Transaction.from(raw).hash; } if (method === "eth_getBlockByNumber" || method === "eth_getBlockByHash") { return latestBlock(); } if (Object.prototype.hasOwnProperty.call(RPC_RESULTS, method)) { return RPC_RESULTS[method]; } // Never a silent default. An unstubbed method answered with null looks // like a working node returning nothing, and the assertion downstream // fails somewhere unrelated. state.unstubbed.push(method); throw new Error("no fixture for JSON-RPC method " + method); } function readBody(req) { return new Promise((resolve, reject) => { let body = ""; req.on("data", (chunk) => { body += chunk; }); req.on("end", () => resolve(body)); req.on("error", reject); }); } function handleRpcBody(body, state) { const parsed = JSON.parse(body); const answer = (req) => { try { return { jsonrpc: "2.0", id: req.id, result: rpcResult(req, state), }; } catch (e) { return { jsonrpc: "2.0", id: req.id, error: { code: -32601, message: e.message }, }; } }; return Array.isArray(parsed) ? parsed.map(answer) : answer(parsed); } /** * Serve the dApp page and the stub node on loopback. * * @returns {Promise} the running fixture: `url` and `origin` of the * page, `rpcUrl` for the extension's rpcUrl setting, `broadcast` (the raw * transactions the node received, in order), `unstubbed` (JSON-RPC methods * nothing answered) and `close()`. */ async function startDappServer() { const state = { broadcast: [], unstubbed: [], requests: [] }; const server = http.createServer((req, res) => { const url = new URL(req.url, "http://127.0.0.1"); state.requests.push(req.method + " " + url.pathname); if (url.pathname === "/rpc" && req.method === "POST") { readBody(req) .then((body) => { const payload = JSON.stringify(handleRpcBody(body, state)); res.writeHead(200, { "Content-Type": "application/json", // The extension fetches this from its background // page, whose origin is moz-extension://. Without CORS // the fetch fails and every transaction assertion // fails for a reason that has nothing to do with the // wallet. "Access-Control-Allow-Origin": "*", }); res.end(payload); }) .catch((e) => { res.writeHead(500, { "Content-Type": "text/plain" }); res.end(String(e && e.message)); }); return; } if (url.pathname === "/") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(DAPP_HTML); return; } // An empty favicon rather than a 404: a 404 is a page error in // Firefox's console under some settings, and the suite fails the run // on those. if (url.pathname === "/favicon.ico") { res.writeHead(200, { "Content-Type": "image/x-icon" }); res.end(""); return; } res.writeHead(404, { "Content-Type": "text/plain" }); res.end("not found"); }); await new Promise((resolve, reject) => { server.on("error", reject); // Port 0: this host runs many sessions at once, and a fixed port is a // guaranteed collision rather than a possible one. server.listen(0, "127.0.0.1", resolve); }); const { port } = server.address(); const origin = "http://127.0.0.1:" + port; return { origin, url: origin + "/", rpcUrl: origin + "/rpc", broadcast: state.broadcast, unstubbed: state.unstubbed, requests: state.requests, close: () => new Promise((resolve) => { server.closeAllConnections(); server.close(() => resolve()); }), }; } module.exports = { GAS_LIMIT, GAS_PRICE_WEI, STUB_BALANCE_WEI, startDappServer, };