Files
AutistMask/tests/e2e/dapp.js
T
sneak 1466e73332
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: read USD prices from Chainlink price feeds through the RPC endpoint (closes #525)
CoinDesk now refuses every request without an API key, so mainnet showed no
USD prices. On mainnet the wallet now reads latestRoundData() from Chainlink's
USD price feeds through the configured RPC endpoint. ETH and the held or
tracked tokens with a feed are read as one batched request, at most every 5
minutes. src/shared/tokenList.js lists the feeds of 53 bundled tokens, keyed by
contract address, and the ETH/USD feed. A reading older than its feed's
heartbeat plus 30 minutes, or not positive, is no price. A failed read leaves
no price and is logged. getPrice() takes a contract address instead of a
symbol. The CoinDesk request is removed.

Model: opus-5-5
2026-10-27 01:45:01 +00:00

255 lines
9.0 KiB
JavaScript

// 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 but a price feed's
// latestRoundData() (see rpcResult() below). 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);
const SELECTOR_LATEST_ROUND_DATA = "0xfeaf968c";
// 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();
}
// A price feed's latestRoundData() gets five zero words, as in network.js:
// a price of zero, which the wallet counts as no price.
const data = String(
(req.params && req.params[0] && req.params[0].data) || "",
);
if (method === "eth_call" && data.startsWith(SELECTOR_LATEST_ROUND_DATA)) {
return "0x" + "0".repeat(64 * 5);
}
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,
};