diff --git a/README.md b/README.md
index ee2bf5a..edeae29 100644
--- a/README.md
+++ b/README.md
@@ -169,6 +169,34 @@ reserve while sitting on the same side of the estimate, so swapping the two in
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only.
+It also covers the **dApp approval round trips** — the one place where the
+content script, the inpage provider, the background worker and the approval
+popup all have to work together. A local test page is served by the route
+handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
+`MAIN`-world content script like any other page, and drives
+`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
+`eth_sendTransaction` through the real prompts. Every signature is recovered in
+the runner and compared against the active address, the transaction assertions
+run against the raw signed transaction captured at `eth_sendRawTransaction`
+rather than against anything the extension reported, rejecting each prompt is
+required to return a rejection to the page rather than hang or resolve, and the
+password is required to be absent from every message the approval window sends
+to the background — with the message that would carry it required to be present,
+so that check cannot pass by observing nothing. That last one is the standing
+floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
+
+Three limits of that coverage, none of them papered over. The RPC is stubbed
+throughout, so this is **not** a real dApp against a real network with real
+funds; that remains a human pass before 1.0.0. The site-connection prompt is
+raised through `chrome.action.openPopup()`, and headless Chromium's
+browser-action popup is not a page Playwright can see or click, so that one
+prompt is driven at the URL the extension itself puts on the action — the same
+page and the same approval id, but whether a real toolbar click shows it is not
+observable here. And the EIP-1193 error code does not survive the last hop: the
+rejection that crosses the boundary carries code 4001 and is asserted to, but
+`src/content/inpage.js` rebuilds it as `new Error(message)`, so the calling page
+catches an error with no `code` property.
+
Any test that drives a failure path on purpose declares the `console.error` it
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
consumes exactly one matching record, and a declaration nothing matched fails
diff --git a/TODO.md b/TODO.md
index ab9ae00..755c79f 100644
--- a/TODO.md
+++ b/TODO.md
@@ -45,6 +45,17 @@ undefined identifiers, which is how
# Completed Steps
+- 2026-08-12: The dApp approval round trips are driven end to end in the
+ browser. A test page served by the harness speaks EIP-1193 to the real inpage
+ provider through the real content script, background worker and approval popup
+ for `eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
+ `eth_sendTransaction`. Every signature is recovered and compared against the
+ active address, the transaction is checked against the bytes handed to the
+ stubbed RPC, each rejection must reach the page as a rejection, and the
+ password must appear in no message the approval window sends — the assertion
+ that gives [#157](https://git.eeqj.de/sneak/AutistMask/issues/157) a permanent
+ floor. This does not discharge a real dApp with real funds against mainnet
+ ([#183](https://git.eeqj.de/sneak/AutistMask/issues/183)).
- 2026-08-12: A containerized Firefox end-to-end harness
(`make test-e2e-firefox`) drives the real popup in a real Firefox with the MV2
build installed as a temporary add-on. Zero npm dependencies — a WebDriver
diff --git a/tests/e2e/network.js b/tests/e2e/network.js
index c134ee7..260b9d7 100644
--- a/tests/e2e/network.js
+++ b/tests/e2e/network.js
@@ -23,6 +23,8 @@
"use strict";
+const { Transaction } = require("ethers");
+
// 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
@@ -62,6 +64,84 @@ function word(value) {
return "0x" + BigInt(value).toString(16).padStart(64, "0");
}
+// -------------------------------------------------------- dApp fixture
+//
+// The origin the EIP-1193 test page is served from, and the page itself.
+//
+// It is a fixture like every other one in this file: the route handler
+// fulfils the navigation from the string below, so the page never comes
+// from a remote origin and nothing about the dApp round trips leaves the
+// container. `.test` is reserved by RFC 6761 and has no owner to reach in
+// the first place; the launch arguments map every host to NOTFOUND anyway.
+//
+// What the page deliberately does NOT do is load a provider. window.ethereum
+// is put there by the shipped manifest's MAIN-world content script, exactly
+// as it is on any http(s) page a user visits, so what these tests speak to
+// is the real inpage provider and not a copy the harness wired up.
+const DAPP_ORIGIN = "https://dapp.e2e.test";
+const DAPP_URL = DAPP_ORIGIN + "/";
+
+// Requests are parked rather than awaited. An approval prompt only exists
+// while its call is in flight, so a test that awaited the promise could
+// never drive the popup that has to settle it; start() files the promise
+// under a key and settle() collects it once the prompt has been dealt with.
+//
+// The rejection branch records `code` as it arrives. EIP-1193 says a user
+// rejection is a ProviderRpcError carrying code 4001; what the page can
+// actually see is recorded here rather than assumed, and asserted in run.js.
+//
+// The message log is the page's half of the boundary observation: every
+// AUTISTMASK_* message that crosses between this page and the content
+// script, in both directions, verbatim.
+const DAPP_HTML = [
+ "",
+ '',
+ "
",
+ '',
+ "AutistMask e2e dApp",
+ // Inline and empty: without it Chromium asks for /favicon.ico, which
+ // the unstubbed-request guard would report as escaping traffic.
+ '',
+ "",
+ "",
+ "
AutistMask e2e dApp
",
+ "",
+ "",
+ "",
+].join("\n");
+
// ------------------------------------------------------------ fee fixture
//
// The confirmation screen carries two different numbers for the same
@@ -101,6 +181,11 @@ const RPC_RESULTS = {
eth_estimateGas: hex(GAS_LIMIT),
eth_getTransactionCount: "0x0",
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
+ // "not mined yet", which is what a node answers for a transaction it has
+ // only just accepted. The wait screen the dApp transaction approval hands
+ // off to polls this every 10 seconds; leaving it unstubbed would report
+ // the poll as escaping traffic the moment a test outlived one tick.
+ eth_getTransactionReceipt: null,
};
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
@@ -264,6 +349,31 @@ function rpcReply(req, opts, report) {
if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock() });
}
+ // The end of the dApp transaction round trip: the raw signed transaction
+ // the background hands to the node. It is recorded verbatim so a test can
+ // recover the signer from the exact bytes that were broadcast, rather than
+ // from anything the extension reported about them.
+ //
+ // The reply must be the transaction's real hash. ethers compares the hash
+ // the node returns against the one it computes itself and throws on a
+ // mismatch, so a constant here would fail the broadcast for a reason that
+ // has nothing to do with what is being tested.
+ if (req.method === "eth_sendRawTransaction") {
+ const raw = Array.isArray(req.params) ? req.params[0] : null;
+ let parsed;
+ try {
+ parsed = Transaction.from(raw);
+ } catch {
+ report("eth_sendRawTransaction with an undecodable transaction");
+ return Object.assign(envelope, {
+ error: { code: -32000, message: "undecodable transaction" },
+ });
+ }
+ if (Array.isArray(opts.broadcastTransactions)) {
+ opts.broadcastTransactions.push(raw);
+ }
+ return Object.assign(envelope, { result: parsed.hash });
+ }
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
@@ -375,6 +485,8 @@ function traceEnabled(raw) {
* node-side refusal.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again.
+ * @param {string[]} [opts.broadcastTransactions] every raw signed
+ * transaction handed to eth_sendRawTransaction, appended in order.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise}>}
*/
@@ -420,6 +532,18 @@ async function installNetworkStubs(ctx, opts) {
return handleRpc(route, req.postData(), opts, report);
}
+ // The local EIP-1193 test page. Served from here so the dApp round
+ // trips run against a real http(s) origin — which is what makes the
+ // shipped content scripts inject at all — without any remote origin
+ // being involved.
+ if (url.origin === DAPP_ORIGIN && p === "/") {
+ return route.fulfill({
+ status: 200,
+ contentType: "text/html; charset=utf-8",
+ body: DAPP_HTML,
+ });
+ }
+
// Blockscout v2
if (p.includes("/api/v2/")) {
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
@@ -508,6 +632,8 @@ async function installNetworkStubs(ctx, opts) {
module.exports = {
installNetworkStubs,
+ DAPP_ORIGIN,
+ DAPP_URL,
FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI,
STUB_COUNTERPARTY,
diff --git a/tests/e2e/run.js b/tests/e2e/run.js
index 918dbac..c02604f 100644
--- a/tests/e2e/run.js
+++ b/tests/e2e/run.js
@@ -9,7 +9,18 @@
"use strict";
-const { formatEther } = require("ethers");
+const {
+ Transaction,
+ formatEther,
+ getAddress,
+ getBytes,
+ hexlify,
+ parseEther,
+ toQuantity,
+ toUtf8Bytes,
+ verifyMessage,
+ verifyTypedData,
+} = require("ethers");
const {
PASSWORD,
createWallet,
@@ -20,6 +31,8 @@ const {
visible,
} = require("./harness");
const {
+ DAPP_ORIGIN,
+ DAPP_URL,
FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI,
STUB_COUNTERPARTY,
@@ -1197,6 +1210,966 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble
);
});
+// ------------------------------------------- dApp round trips (#183)
+//
+// The seam. Everything above drives the popup on its own; this section is
+// the only place where a page, the content script, the inpage provider, the
+// background worker and the approval popup all have to work together, and
+// nothing else in the repo covers it — the unit suite covers each side in
+// isolation.
+//
+// Three things make these tests worth more than "a call came back":
+//
+// - every signature is recovered here, in the runner, from the exact
+// artifact the extension produced, and compared against the address read
+// out of extension storage. The background verifies too (see
+// src/shared/approvalVerify.js) but these assertions do not lean on it:
+// a test that trusted the extension's own verdict would pass against a
+// wallet that verified nothing.
+// - the transaction assertions run against the raw signed transaction the
+// background handed to eth_sendRawTransaction, captured by the route
+// handler, not against anything the extension reported about it.
+// - the password is required to be absent from the popup-to-background
+// channel, observed directly, with the message that would carry it
+// required to be present. That is the standing floor under the fix in
+// https://git.eeqj.de/sneak/AutistMask/issues/157.
+//
+// What this does NOT cover, and must not be presented as covering: a real
+// dApp, with real funds, against a real network. The RPC is stubbed
+// throughout. That pass stays on the human list before 1.0.0.
+
+const DAPP_HOSTNAME = new URL(DAPP_URL).hostname;
+
+// The personal_sign payload. Sent as hex, which is what dApps send and what
+// the popup requires — it calls getBytes() on the message — and displayed on
+// the approval screen as the decoded text, which is what the user is agreeing
+// to and therefore what the screen assertion checks.
+const SIGN_TEXT = "AutistMask e2e round trip: personal_sign";
+const SIGN_HEX = hexlify(toUtf8Bytes(SIGN_TEXT));
+
+const TYPED_DOMAIN = {
+ name: "AutistMask e2e",
+ version: "1",
+ chainId: 1,
+ verifyingContract: STUB_COUNTERPARTY,
+};
+const TYPED_TYPES = {
+ Mail: [
+ { name: "contents", type: "string" },
+ { name: "amount", type: "uint256" },
+ ],
+};
+const TYPED_MESSAGE = {
+ contents: "AutistMask e2e round trip: typed data",
+ amount: "1234",
+};
+
+// The wire form: EIP-712 payloads reach the wallet as a JSON string that
+// carries EIP712Domain in `types`. ethers derives that entry itself and
+// rejects it as an input, which is why the recovery below uses TYPED_TYPES
+// and the payload here does not.
+const TYPED_DATA_JSON = JSON.stringify({
+ domain: TYPED_DOMAIN,
+ types: Object.assign(
+ {
+ EIP712Domain: [
+ { name: "name", type: "string" },
+ { name: "version", type: "string" },
+ { name: "chainId", type: "uint256" },
+ { name: "verifyingContract", type: "address" },
+ ],
+ },
+ TYPED_TYPES,
+ ),
+ primaryType: "Mail",
+ message: TYPED_MESSAGE,
+});
+
+// The transaction. Call data that decodes as nothing keeps the screen
+// assertion honest: the raw data section is shown verbatim, so what is
+// compared is the calldata itself rather than a decoder's summary of it.
+const TX_VALUE_ETH = "0.0123";
+const TX_VALUE_WEI = parseEther(TX_VALUE_ETH);
+const TX_DATA = "0xdeadbeef" + "01".repeat(28);
+
+const USER_REJECTION_MESSAGE = "User rejected the request.";
+
+// The active address, read from extension storage rather than from any
+// screen: it is the address the background will sign with, and it is what
+// every recovery below is compared against.
+async function extensionActiveAddress(page) {
+ const s = await page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ chrome.storage.local.get("autistmask", (r) =>
+ resolve(r.autistmask || null),
+ );
+ }),
+ );
+ assert(s !== null, "the extension has no persisted state to read");
+ const first =
+ s.wallets && s.wallets[0] && s.wallets[0].addresses[0]
+ ? s.wallets[0].addresses[0].address
+ : null;
+ const address = s.activeAddress || first;
+ assert(address, "the extension holds no active address");
+ return getAddress(address);
+}
+
+async function openDapp(ctx) {
+ const page = await ctx.newPage();
+ await page.goto(DAPP_URL);
+ // window.ethereum is not the fixture's doing — it is the shipped
+ // MAIN-world content script. Waiting for it is waiting for the real
+ // provider to have injected itself into a real http(s) origin.
+ await page.waitForFunction(
+ () => !!window.ethereum && !!window.__dapp,
+ null,
+ { timeout: 30000 },
+ );
+ return page;
+}
+
+function startRequest(page, key, method, params) {
+ return page.evaluate(
+ (a) => window.__dapp.start(a.key, a.method, a.params),
+ { key, method, params },
+ );
+}
+
+// The settled outcome of a parked request, or {settled:"pending"} if it is
+// still outstanding after `timeout`. A bounded wait rather than a bare await
+// on purpose: "returns a rejection rather than hanging" is one of the things
+// under test, and an await would report a hang as a suite timeout with no
+// indication of which call never settled.
+function settleRequest(page, key, timeout = 60000) {
+ return page.evaluate(
+ (a) =>
+ Promise.race([
+ window.__dapp.settle(a.key),
+ new Promise((resolve) => {
+ setTimeout(
+ () => resolve({ settled: "pending" }),
+ a.timeout,
+ );
+ }),
+ ]),
+ { key, timeout },
+ );
+}
+
+// Every AUTISTMASK_* message that has crossed between the test page and the
+// content script so far, in both directions.
+function dappMessages(page, type) {
+ return page.evaluate(
+ (want) =>
+ window.__dapp.messages.filter((m) => !want || m.type === want),
+ type || null,
+ );
+}
+
+// The approval window the background opened. Approvals are raised from an
+// RPC call rather than from a user gesture, so the extension opens a real
+// popup window for them; it is an ordinary page in this context.
+async function waitForApprovalWindow(ctx, timeout = 30000) {
+ const deadline = Date.now() + timeout;
+ for (;;) {
+ const page = ctx
+ .pages()
+ .find((p) => !p.isClosed() && p.url().includes("?approval="));
+ if (page) return page;
+ if (Date.now() > deadline) {
+ throw new Error(
+ "the extension opened no approval window within " +
+ timeout +
+ "ms",
+ );
+ }
+ await sleep(50);
+ }
+}
+
+// A tab held ready for the site-connection prompt, reserved BEFORE the
+// request that raises it and navigated — never replaced — once it has.
+//
+// The site connection is the one approval src/background/index.js raises
+// through the toolbar-anchored popup: chrome.action.setPopup() followed by
+// chrome.action.openPopup(), with a real window only as the fallback for an
+// openPopup() that throws or rejects. Headless Chromium does open that popup,
+// but Playwright cannot see it — it is not a page in ctx.pages() and never
+// becomes one — so the prompt has to be driven at the URL the extension put
+// on the action, which is the same page, the same approval id and the same
+// code path the toolbar button shows, and the route README.md documents for
+// reopening a pending approval.
+//
+// What must not happen is creating a page while that approval is pending.
+// A new page dismisses the browser-action popup; the popup's approval port
+// disconnects; src/background/index.js settles the approval as a rejection;
+// and the prompt is gone before anything can be clicked. Measured directly:
+// AUTISTMASK_GET_APPROVAL answers with the approval immediately before the
+// tab is created and with null immediately after. Navigating a tab that
+// already exists does not disturb it, which is the whole reason this
+// reservation exists.
+// How long the tab creation above is given to take effect before the request
+// that raises the next prompt is issued. Dismissing a browser-action popup
+// disconnects its port, and src/background/index.js calls resetPopupUrl() on
+// that disconnect; issued too early, the next request's setPopup() is undone
+// by the previous popup's teardown, chrome.action.openPopup() then rejects,
+// and the fallback window it opens dismisses the popup that was just raised —
+// which settles the fresh approval as a rejection before it can be seen.
+// Every one of those steps was observed. 1.5s is comfortably past it.
+const APPROVAL_TAB_SETTLE_MS = 1500;
+
+async function reserveApprovalTab(env) {
+ if (env.approvalTab && !env.approvalTab.isClosed()) {
+ await env.approvalTab.close();
+ }
+ // Always a fresh page, and always before the request: creating it is what
+ // dismisses the stale browser-action popup left over from the previous
+ // approval, and doing that after the next one exists would take the next
+ // one down with it.
+ env.approvalTab = await env.ctx.newPage();
+
+ // The one accommodation this section makes to the shipped code, and the
+ // reason for it.
+ //
+ // Both approval buttons call runtime.sendMessage() and then window.close()
+ // on the next line. Closing this page disconnects the approval port, and
+ // the disconnect handler in src/background/index.js settles a pending
+ // site approval as a rejection. In a tab those two race and the teardown
+ // wins: the approve message is never acted on, and the page is told the
+ // user rejected. Measured — with the close left in place the approval
+ // resolves as a rejection every time; with it deferred it resolves as an
+ // approval every time.
+ //
+ // It is deferred, not removed: the harness closes the page itself once
+ // the outcome has been observed, which is what window.close() would have
+ // done, only after the message it was racing has been processed.
+ //
+ // This affects the site-connection prompt only. The sign and transaction
+ // prompts run in windows the extension opens itself, with window.close()
+ // untouched, and their disconnect handler deliberately keeps a tx or sign
+ // approval pending rather than rejecting it — so there is no race there
+ // to accommodate. Whether the same ordering holds in a real toolbar popup
+ // is not observable from a headless harness and is reported rather than
+ // assumed either way.
+ await env.approvalTab.addInitScript(() => {
+ window.close = function () {};
+ });
+ await env.approvalTab.goto("about:blank");
+ await sleep(APPROVAL_TAB_SETTLE_MS);
+ return env.approvalTab;
+}
+
+// The site-connection prompt: the window the extension opened if it managed
+// to open one, and the reserved tab at the action's approval URL otherwise.
+async function openSiteApprovalPopup(env, timeout = 30000) {
+ const deadline = Date.now() + timeout;
+ for (;;) {
+ const existing = env.ctx
+ .pages()
+ .find((p) => !p.isClosed() && p.url().includes("?approval="));
+ if (existing) return existing;
+
+ const url = await env.page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ chrome.action.getPopup({}, (u) => resolve(String(u)));
+ }),
+ );
+ if (url.includes("?approval=")) {
+ await env.approvalTab.goto(url);
+ return env.approvalTab;
+ }
+ if (Date.now() > deadline) {
+ throw new Error(
+ "no site approval prompt appeared within " +
+ timeout +
+ "ms; the browser action carried " +
+ JSON.stringify(url),
+ );
+ }
+ await sleep(50);
+ }
+}
+
+// Retire every approval page still open. A settled approval whose page is
+// left behind would be found by the next waitForApprovalWindow() and driven
+// as if it were the next approval.
+async function closeApprovalPages(ctx) {
+ for (const page of ctx.pages()) {
+ if (!page.isClosed() && page.url().includes("?approval=")) {
+ await page.close();
+ }
+ }
+}
+
+// Record every message the approval window sends to the background worker.
+//
+// This is the direct observation the password check needs. It is installed
+// after the approval screen has rendered and before Approve is clicked,
+// which is the whole window in which a password could be put on the wire:
+// the popup takes the password, derives the key, signs, and only then sends.
+// The earlier AUTISTMASK_GET_APPROVAL exchange happens before the password
+// field has been touched and carries nothing to leak.
+//
+// It wraps the property rather than the captured reference, which is what
+// makes it see src/popup/views/approval.js's own `runtime.sendMessage` calls:
+// that module captures the chrome.runtime object at load, not the function.
+async function watchApprovalBoundary(page, env) {
+ const records = [];
+ await page.exposeFunction("__amRecordBoundary", (json) => {
+ records.push(JSON.parse(json));
+ env.boundaryRecords.push(JSON.parse(json));
+ });
+ const wrapped = await page.evaluate(() => {
+ const rt = chrome.runtime;
+ const original = rt.sendMessage.bind(rt);
+ rt.sendMessage = function (...args) {
+ let json;
+ try {
+ json = JSON.stringify(args[0]);
+ } catch (e) {
+ // A payload that will not serialize is still a payload, and
+ // recording nothing for it would be exactly the blind spot
+ // this observation exists to close.
+ json = JSON.stringify({ unserializable: String(e) });
+ }
+ const sent = window.__amRecordBoundary(json);
+ if (sent && typeof sent.catch === "function") {
+ // The window closes on a successful signature; a binding
+ // call still in flight then must not become a page error.
+ sent.catch(() => {});
+ }
+ return original.apply(rt, args);
+ };
+ return chrome.runtime.sendMessage !== original;
+ });
+ assert(
+ wrapped,
+ "could not wrap chrome.runtime.sendMessage in the approval window, so " +
+ "nothing was observed and the password assertion would be vacuous",
+ );
+ return records;
+}
+
+async function waitForBoundaryRecords(records, type, timeout = 30000) {
+ const deadline = Date.now() + timeout;
+ for (;;) {
+ const found = records.filter((r) => r && r.type === type);
+ if (found.length > 0) return found;
+ if (Date.now() > deadline) {
+ throw new Error(
+ "no " +
+ type +
+ " message was observed crossing the popup-to-background " +
+ "boundary, so the password assertion has nothing to assert on",
+ );
+ }
+ await sleep(25);
+ }
+}
+
+function assertNoPassword(records, where) {
+ const leaked = records.filter((r) => JSON.stringify(r).includes(PASSWORD));
+ assert(
+ leaked.length === 0,
+ "the password crossed the extension messaging boundary " +
+ where +
+ ": " +
+ JSON.stringify(leaked),
+ );
+}
+
+// The most recent AUTISTMASK_RESPONSE the page received, which is where the
+// EIP-1193 error code lives on the wire.
+async function lastResponseError(page) {
+ const responses = await dappMessages(page, "AUTISTMASK_RESPONSE");
+ const last = responses[responses.length - 1];
+ assert(last, "the page received no AUTISTMASK_RESPONSE at all");
+ return last.error || null;
+}
+
+// A rejected prompt, asserted at both ends: the page's promise rejected
+// rather than hanging or resolving, and the response that crossed the
+// boundary carried EIP-1193 code 4001.
+//
+// The code is asserted on the wire because that is the only place it
+// survives. src/content/inpage.js rebuilds the rejection as `new
+// Error(error.message)`, so the Error the calling page catches carries the
+// message and no code. That is reported rather than asserted either way —
+// locking in the current behaviour would make the gap permanent, and
+// asserting the code on the Error would fail today.
+async function assertUserRejection(page, key, label) {
+ const outcome = await settleRequest(page, key);
+ assert(
+ outcome.settled !== "pending",
+ label + " never settled: the rejected prompt left the page hanging",
+ );
+ assert(
+ outcome.settled === "rejected",
+ label + " resolved instead of rejecting: " + JSON.stringify(outcome),
+ );
+ assert(
+ outcome.message === USER_REJECTION_MESSAGE,
+ label + " rejected with the wrong message: " + outcome.message,
+ );
+ const error = await lastResponseError(page);
+ assert(
+ error && error.code === 4001,
+ label +
+ " did not carry EIP-1193 code 4001 across the boundary: " +
+ JSON.stringify(error),
+ );
+ console.log(
+ "# " +
+ label +
+ ": boundary code=" +
+ error.code +
+ " page Error.code=" +
+ JSON.stringify(outcome.code) +
+ " page Error carries a code=" +
+ outcome.hasCode,
+ );
+ return outcome;
+}
+
+test("the harness serves a page that gets the real inpage provider (#183)", async (env) => {
+ env.dapp = await openDapp(env.ctx);
+ env.expectedAddress = await extensionActiveAddress(env.page);
+
+ // EIP-6963, asked of the provider itself: the announcement has to name
+ // this extension and hand back the very object on window.ethereum. An
+ // identity check rather than a shape check, so nothing the fixture could
+ // have installed itself would satisfy it.
+ const announced = await env.dapp.evaluate(
+ () =>
+ new Promise((resolve) => {
+ const onAnnounce = (e) => {
+ window.removeEventListener(
+ "eip6963:announceProvider",
+ onAnnounce,
+ );
+ resolve({
+ rdns: e.detail.info.rdns,
+ name: e.detail.info.name,
+ isWindowEthereum: e.detail.provider === window.ethereum,
+ });
+ };
+ window.addEventListener("eip6963:announceProvider", onAnnounce);
+ window.dispatchEvent(new Event("eip6963:requestProvider"));
+ setTimeout(() => resolve(null), 10000);
+ }),
+ );
+ assert(announced, "the provider announced itself to no EIP-6963 request");
+ assert(
+ announced.rdns === "berlin.sneak.autistmask",
+ "the announced provider is not this extension: " +
+ JSON.stringify(announced),
+ );
+ assert(
+ announced.isWindowEthereum,
+ "the announced provider is not the object on window.ethereum",
+ );
+
+ // A full page -> content script -> background round trip that needs no
+ // approval, so the transport is proven before any prompt is driven.
+ const chainId = await env.dapp.evaluate(() =>
+ window.ethereum.request({ method: "eth_chainId" }),
+ );
+ assert(
+ chainId === "0x1",
+ "eth_chainId did not round trip through the extension: " +
+ JSON.stringify(chainId),
+ );
+ console.log(
+ "# dapp origin " +
+ DAPP_ORIGIN +
+ " active address " +
+ env.expectedAddress,
+ );
+});
+
+test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", async (env) => {
+ await reserveApprovalTab(env);
+ await startRequest(env.dapp, "accounts-reject", "eth_requestAccounts", []);
+ const popup = await openSiteApprovalPopup(env);
+ try {
+ await visible(popup, "#view-approve-site");
+
+ const hostname = await popup.locator("#approve-hostname").innerText();
+ assert(
+ hostname === DAPP_HOSTNAME,
+ "the site prompt names the wrong origin: " +
+ JSON.stringify(hostname),
+ );
+
+ // Deliberately not remembered: a remembered rejection lands the
+ // origin in deniedSites and every later test in this section is
+ // auto-rejected with no prompt at all, which would look like a pass.
+ await popup.uncheck("#approve-remember");
+ await popup.click("#btn-reject");
+
+ await assertUserRejection(
+ env.dapp,
+ "accounts-reject",
+ "eth_requestAccounts rejection",
+ );
+ } finally {
+ await closeApprovalPages(env.ctx);
+ }
+});
+
+test("eth_requestAccounts approved returns the selected address (#183)", async (env) => {
+ await reserveApprovalTab(env);
+ await startRequest(env.dapp, "accounts", "eth_requestAccounts", []);
+ const popup = await openSiteApprovalPopup(env);
+ let outcome;
+ try {
+ await visible(popup, "#view-approve-site");
+
+ const shown = await popup.locator("#approve-address").innerText();
+ assert(
+ shown.toLowerCase().includes(env.expectedAddress.toLowerCase()),
+ "the site prompt shows the wrong address: " + JSON.stringify(shown),
+ );
+
+ // Remembered, so the connection survives a background worker that MV3
+ // terminates after 30 seconds idle. The in-memory connectedSites map
+ // does not, and the sign and transaction tests below all require the
+ // origin to still be authorized.
+ await popup.check("#approve-remember");
+ await popup.click("#btn-approve");
+
+ outcome = await settleRequest(env.dapp, "accounts");
+ } finally {
+ await closeApprovalPages(env.ctx);
+ }
+ assert(
+ outcome.settled === "resolved",
+ "eth_requestAccounts did not resolve: " + JSON.stringify(outcome),
+ );
+ assert(
+ Array.isArray(outcome.result) && outcome.result.length === 1,
+ "eth_requestAccounts returned no single account: " +
+ JSON.stringify(outcome.result),
+ );
+ assert(
+ getAddress(outcome.result[0]) === env.expectedAddress,
+ "eth_requestAccounts returned " +
+ outcome.result[0] +
+ ", not the selected address " +
+ env.expectedAddress,
+ );
+});
+
+test("personal_sign signs, and the signature recovers to the address (#183)", async (env) => {
+ await startRequest(env.dapp, "sign", "personal_sign", [
+ SIGN_HEX,
+ env.expectedAddress,
+ ]);
+ const popup = await waitForApprovalWindow(env.ctx);
+ await visible(popup, "#view-approve-sign");
+ const boundary = await watchApprovalBoundary(popup, env);
+
+ const screen = await popup.evaluate(() => ({
+ hostname: document.getElementById("approve-sign-hostname").textContent,
+ type: document.getElementById("approve-sign-type").textContent,
+ message: document.getElementById("approve-sign-message").textContent,
+ from: document.getElementById("approve-sign-from").textContent,
+ }));
+ assert(
+ screen.hostname === DAPP_HOSTNAME,
+ "the sign prompt names the wrong origin: " +
+ JSON.stringify(screen.hostname),
+ );
+ assert(
+ screen.type === "Personal message",
+ "the sign prompt reports the wrong type: " +
+ JSON.stringify(screen.type),
+ );
+ assert(
+ screen.message === SIGN_TEXT,
+ "the sign prompt shows the wrong message: " +
+ JSON.stringify(screen.message),
+ );
+ assert(
+ screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()),
+ "the sign prompt shows the wrong signing address: " +
+ JSON.stringify(screen.from),
+ );
+
+ await popup.fill("#approve-sign-password", PASSWORD);
+ await popup.click("#btn-approve-sign");
+
+ const outcome = await settleRequest(env.dapp, "sign");
+ assert(
+ outcome.settled === "resolved",
+ "personal_sign did not resolve: " + JSON.stringify(outcome),
+ );
+ const recovered = getAddress(
+ verifyMessage(getBytes(SIGN_HEX), outcome.result),
+ );
+ console.log(
+ "# personal_sign: recovered=" +
+ recovered +
+ " expected=" +
+ env.expectedAddress,
+ );
+ assert(
+ recovered === env.expectedAddress,
+ "the personal_sign signature recovers to " +
+ recovered +
+ ", not to the approved address " +
+ env.expectedAddress,
+ );
+
+ const sent = await waitForBoundaryRecords(
+ boundary,
+ "AUTISTMASK_SIGN_RESPONSE",
+ );
+ assert(
+ sent.length === 1 && sent[0].approved === true,
+ "the popup did not send exactly one approved sign response: " +
+ JSON.stringify(sent),
+ );
+ assert(
+ sent[0].signature === outcome.result,
+ "the signature the page received is not the one the popup produced",
+ );
+ assertNoPassword(boundary, "on the personal_sign approval");
+});
+
+test("personal_sign rejected returns a rejection to the page (#183)", async (env) => {
+ await startRequest(env.dapp, "sign-reject", "personal_sign", [
+ SIGN_HEX,
+ env.expectedAddress,
+ ]);
+ const popup = await waitForApprovalWindow(env.ctx);
+ await visible(popup, "#view-approve-sign");
+ await popup.click("#btn-reject-sign");
+
+ await assertUserRejection(
+ env.dapp,
+ "sign-reject",
+ "personal_sign rejection",
+ );
+});
+
+test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => {
+ await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [
+ env.expectedAddress,
+ TYPED_DATA_JSON,
+ ]);
+ const popup = await waitForApprovalWindow(env.ctx);
+ await visible(popup, "#view-approve-sign");
+ const boundary = await watchApprovalBoundary(popup, env);
+
+ const screen = await popup.evaluate(() => ({
+ hostname: document.getElementById("approve-sign-hostname").textContent,
+ type: document.getElementById("approve-sign-type").textContent,
+ message: document.getElementById("approve-sign-message").innerText,
+ from: document.getElementById("approve-sign-from").textContent,
+ }));
+ assert(
+ screen.hostname === DAPP_HOSTNAME,
+ "the typed data prompt names the wrong origin: " +
+ JSON.stringify(screen.hostname),
+ );
+ assert(
+ screen.type === "Typed data (EIP-712)",
+ "the typed data prompt reports the wrong type: " +
+ JSON.stringify(screen.type),
+ );
+ for (const want of [
+ TYPED_DOMAIN.name,
+ "Mail",
+ TYPED_MESSAGE.contents,
+ TYPED_MESSAGE.amount,
+ ]) {
+ assert(
+ screen.message.includes(want),
+ "the typed data prompt does not show " +
+ JSON.stringify(want) +
+ ", got: " +
+ JSON.stringify(screen.message),
+ );
+ }
+ assert(
+ screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()),
+ "the typed data prompt shows the wrong signing address: " +
+ JSON.stringify(screen.from),
+ );
+
+ await popup.fill("#approve-sign-password", PASSWORD);
+ await popup.click("#btn-approve-sign");
+
+ const outcome = await settleRequest(env.dapp, "typed");
+ assert(
+ outcome.settled === "resolved",
+ "eth_signTypedData_v4 did not resolve: " + JSON.stringify(outcome),
+ );
+ const recovered = getAddress(
+ verifyTypedData(
+ TYPED_DOMAIN,
+ TYPED_TYPES,
+ TYPED_MESSAGE,
+ outcome.result,
+ ),
+ );
+ console.log(
+ "# eth_signTypedData_v4: recovered=" +
+ recovered +
+ " expected=" +
+ env.expectedAddress,
+ );
+ assert(
+ recovered === env.expectedAddress,
+ "the typed data signature recovers to " +
+ recovered +
+ ", not to the approved address " +
+ env.expectedAddress,
+ );
+
+ const sent = await waitForBoundaryRecords(
+ boundary,
+ "AUTISTMASK_SIGN_RESPONSE",
+ );
+ assert(
+ sent.length === 1 && sent[0].signature === outcome.result,
+ "the popup did not send exactly one sign response carrying this signature: " +
+ JSON.stringify(sent),
+ );
+ assertNoPassword(boundary, "on the eth_signTypedData_v4 approval");
+});
+
+test("eth_signTypedData_v4 rejected returns a rejection to the page (#183)", async (env) => {
+ await startRequest(env.dapp, "typed-reject", "eth_signTypedData_v4", [
+ env.expectedAddress,
+ TYPED_DATA_JSON,
+ ]);
+ const popup = await waitForApprovalWindow(env.ctx);
+ await visible(popup, "#view-approve-sign");
+ await popup.click("#btn-reject-sign");
+
+ await assertUserRejection(
+ env.dapp,
+ "typed-reject",
+ "eth_signTypedData_v4 rejection",
+ );
+});
+
+test("eth_sendTransaction signs the approved transaction and broadcasts it (#183)", async (env) => {
+ const txParams = {
+ from: env.expectedAddress,
+ to: STUB_COUNTERPARTY,
+ value: toQuantity(TX_VALUE_WEI),
+ data: TX_DATA,
+ };
+ await startRequest(env.dapp, "tx", "eth_sendTransaction", [txParams]);
+ const popup = await waitForApprovalWindow(env.ctx);
+ await visible(popup, "#view-approve-tx");
+ const boundary = await watchApprovalBoundary(popup, env);
+
+ const screen = await popup.evaluate(() => ({
+ hostname: document.getElementById("approve-tx-hostname").textContent,
+ from: document.getElementById("approve-tx-from").textContent,
+ to: document.getElementById("approve-tx-to").textContent,
+ value: document.getElementById("approve-tx-value").textContent,
+ data: document.getElementById("approve-tx-data").textContent,
+ dataShown: !document
+ .getElementById("approve-tx-data-section")
+ .classList.contains("hidden"),
+ }));
+ assert(
+ screen.hostname === DAPP_HOSTNAME,
+ "the transaction prompt names the wrong origin: " +
+ JSON.stringify(screen.hostname),
+ );
+ assert(
+ screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()),
+ "the transaction prompt shows the wrong sender: " +
+ JSON.stringify(screen.from),
+ );
+ assert(
+ screen.to.toLowerCase().includes(STUB_COUNTERPARTY.toLowerCase()),
+ "the transaction prompt shows the wrong recipient: " +
+ JSON.stringify(screen.to),
+ );
+ assert(
+ screen.value.startsWith(TX_VALUE_ETH + " ETH"),
+ "the transaction prompt shows the wrong value: " +
+ JSON.stringify(screen.value),
+ );
+ assert(
+ screen.dataShown && screen.data === TX_DATA,
+ "the transaction prompt does not show the approved call data: " +
+ JSON.stringify(screen.data),
+ );
+
+ const broadcastBefore = env.routeOpts.broadcastTransactions.length;
+ await popup.fill("#approve-tx-password", PASSWORD);
+ await popup.click("#btn-approve-tx");
+
+ const outcome = await settleRequest(env.dapp, "tx");
+ assert(
+ outcome.settled === "resolved",
+ "eth_sendTransaction did not resolve: " + JSON.stringify(outcome),
+ );
+
+ // The artifact as the node saw it, not as the extension described it.
+ const broadcast = env.routeOpts.broadcastTransactions;
+ assert(
+ broadcast.length === broadcastBefore + 1,
+ "expected exactly one raw transaction to reach the RPC, got " +
+ (broadcast.length - broadcastBefore),
+ );
+ const signed = Transaction.from(broadcast[broadcast.length - 1]);
+ console.log(
+ "# eth_sendTransaction: signer=" +
+ getAddress(signed.from) +
+ " expected=" +
+ env.expectedAddress +
+ " to=" +
+ getAddress(signed.to) +
+ " value=" +
+ formatEther(signed.value) +
+ " chainId=" +
+ signed.chainId,
+ );
+ assert(
+ getAddress(signed.from) === env.expectedAddress,
+ "the broadcast transaction was signed by " +
+ getAddress(signed.from) +
+ ", not by the approved address " +
+ env.expectedAddress,
+ );
+ assert(
+ getAddress(signed.to) === getAddress(STUB_COUNTERPARTY),
+ "the broadcast transaction goes to " + signed.to,
+ );
+ assert(
+ signed.value === TX_VALUE_WEI,
+ "the broadcast transaction carries " +
+ formatEther(signed.value) +
+ " ETH, not the approved " +
+ TX_VALUE_ETH,
+ );
+ assert(
+ signed.data === TX_DATA,
+ "the broadcast transaction carries different call data: " + signed.data,
+ );
+ assert(
+ signed.chainId === 1n,
+ "the broadcast transaction is for chain " + signed.chainId,
+ );
+ assert(
+ outcome.result === signed.hash,
+ "the page received " +
+ outcome.result +
+ ", not the hash of the broadcast transaction " +
+ signed.hash,
+ );
+
+ const sent = await waitForBoundaryRecords(
+ boundary,
+ "AUTISTMASK_TX_RESPONSE",
+ );
+ assert(
+ sent.length === 1 &&
+ sent[0].approved === true &&
+ typeof sent[0].rawSignedTx === "string",
+ "the popup did not send exactly one approved transaction response: " +
+ JSON.stringify(sent),
+ );
+ assertNoPassword(boundary, "on the eth_sendTransaction approval");
+
+ // The approval window hands off to the wait screen rather than closing,
+ // and it is this run's job to close it: left open it keeps polling for a
+ // receipt for the rest of the suite.
+ await visible(popup, "#view-wait-tx");
+ const waitHash = await popup.locator("#wait-tx-hash").innerText();
+ assert(
+ waitHash.includes(signed.hash),
+ "the wait screen shows a different hash: " + JSON.stringify(waitHash),
+ );
+ await popup.close();
+});
+
+test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
+ const before = env.routeOpts.broadcastTransactions.length;
+ await startRequest(env.dapp, "tx-reject", "eth_sendTransaction", [
+ {
+ from: env.expectedAddress,
+ to: STUB_COUNTERPARTY,
+ value: toQuantity(TX_VALUE_WEI),
+ data: TX_DATA,
+ },
+ ]);
+ const popup = await waitForApprovalWindow(env.ctx);
+ await visible(popup, "#view-approve-tx");
+ await popup.click("#btn-reject-tx");
+
+ await assertUserRejection(
+ env.dapp,
+ "tx-reject",
+ "eth_sendTransaction rejection",
+ );
+ assert(
+ env.routeOpts.broadcastTransactions.length === before,
+ "a rejected transaction still reached the RPC",
+ );
+});
+
+// The closing pass over both boundaries at once. Every message the section
+// put on either channel is re-read here and required to be free of the
+// password — and required to be there at all, method by method, so the
+// assertion cannot pass by having observed nothing.
+test("the password never crossed either boundary in this section (#183)", async (env) => {
+ const messages = await dappMessages(env.dapp);
+ const requested = messages
+ .filter((m) => m.type === "AUTISTMASK_REQUEST")
+ .map((m) => m.method);
+ for (const method of [
+ "eth_requestAccounts",
+ "personal_sign",
+ "eth_signTypedData_v4",
+ "eth_sendTransaction",
+ ]) {
+ assert(
+ requested.includes(method),
+ "no " +
+ method +
+ " was observed crossing the page boundary, so this check " +
+ "is asserting on an incomplete record: " +
+ JSON.stringify(requested),
+ );
+ }
+ assert(
+ env.boundaryRecords.length >= 3,
+ "fewer popup-to-background messages were observed than the three " +
+ "approvals that were signed: " +
+ env.boundaryRecords.length,
+ );
+
+ console.log(
+ "# boundary observation: " +
+ messages.length +
+ " page/content-script messages, " +
+ env.boundaryRecords.length +
+ " popup/background messages, " +
+ requested.length +
+ " requests",
+ );
+ assertNoPassword(messages, "between the page and the content script");
+ assertNoPassword(
+ env.boundaryRecords,
+ "between the popup and the background",
+ );
+
+ await env.dapp.close();
+});
+
// ---------------------------------------------------------------- runner
async function main() {
@@ -1220,6 +2193,10 @@ async function main() {
ethBalanceWei: null,
failGasEstimate: false,
holdGasEstimate: false,
+ // Every raw signed transaction handed to eth_sendRawTransaction, in
+ // order. The dApp transaction round trip asserts against these bytes
+ // rather than against anything the extension reported about them.
+ broadcastTransactions: [],
};
let session;
@@ -1252,6 +2229,13 @@ async function main() {
// compared against every later state of the same screen.
ethPendingHeight: null,
erc20PendingHeight: null,
+ // The dApp round trips: the test page, the address every signature
+ // must recover to, and every message observed leaving an approval
+ // window for the background worker.
+ dapp: null,
+ approvalTab: null,
+ expectedAddress: null,
+ boundaryRecords: [],
};
// Attribution of collected errors is total. session.errors has no