fix: a slow answer to an approval still reaches the site (closes #540)
Chrome stopped the background worker about six minutes after a site's request even with the approval window open, and the approval and the site's waiting request went with it. Every approval is now added through addPendingApproval(), which starts a timer in the worker that calls runtime.getPlatformInfo() every 20 seconds while any approval is pending; Chrome counts each call as activity. tests/e2e/slowApproval.js, run by make test-e2e, leaves a transaction and a signature approval open for seven minutes in minimized windows and requires the site to receive both results. It drives Chromium over the DevTools pipe because Playwright keeps DevTools attached to the worker. Its loopback dApp server, shared with the Firefox suite, moves to tests/e2e/dapp.js. Model: opus-5-5
This commit was merged in pull request #553.
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
// A loopback dApp origin and stub Ethereum node, used by the two browser
|
||||
// programs that have no Playwright to answer the browser's requests for them:
|
||||
// the Firefox suite (tests/e2e/firefox/run.js) and the Chrome program that
|
||||
// leaves approvals open for seven minutes (tests/e2e/slowApproval.js). The
|
||||
// Chrome suite in run.js answers its requests through network.js instead.
|
||||
//
|
||||
// The Firefox container runs with --network none, and the harness note in
|
||||
// firefox/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 the browser 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 every browser program drives the same __dapp API and
|
||||
// the same message log.
|
||||
//
|
||||
// Unlike firefox/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 both programs recover signatures themselves 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",
|
||||
// In Firefox 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 Firefox 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,
|
||||
};
|
||||
Reference in New Issue
Block a user