All checks were successful
check / check (push) Successful in 28s
Every call site that touched `browser.*` or `chrome.*` now goes through `src/shared/browserApi.js`, the only file in the tree that names either. It exposes lazily-resolved namespace handles for events and synchronous methods, and promise-returning wrappers for everything that is callback-shaped on Chrome. Callers await; `runtime.lastError` is gone, folded into the rejection the wrapper produces on the Chrome path. The Firefox suite gains the four dApp round trips the issue's definition of done asks for — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001 — driven through the real content script, background page and approval windows. `--network none` was thought to rule that out because it leaves no `http://` origin to inject into; loopback survives it, so the page and a JSON-RPC node are served from 127.0.0.1 inside the container and the run still reaches nothing but itself. That harness refutes the premise it was built to verify. On Firefox 153.0.3, `browser.*` honours a trailing Chrome-style callback and does populate `runtime.lastError`, both measured directly, and all four flows pass against the unconverted code. So this is a uniformity and coverage change, not a repair of a broken target; the PR records the measurement in full. One real defect is fixed on the way past: the window id written back into a pending approval after `windows.create()` was unguarded, so an approval settled during the open — an address switch will do it — dereferenced a deleted entry.
239 lines
8.2 KiB
JavaScript
239 lines
8.2 KiB
JavaScript
// 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<Object>} 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,
|
|
};
|