fix: only the user switches the wallet's network (closes #408)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A connected site's wallet_switchEthereumChain request for the other
supported network now opens a prompt in its own window, through the
existing approval machinery, naming the site and both networks. The
network, endpoints, balances and caches change, and chainChanged is
sent, only when the user approves it; rejecting or closing the prompt
answers 4001. One such prompt per site at a time; a request for the
active network needs none. The approval window no longer shows the
connection prompt while it waits for the approval's description,
since both prompts answer on the same port.

Model: opus-5-5
This commit is contained in:
2026-10-07 21:33:11 +00:00
parent bb60b399ec
commit 8b68b3e681
13 changed files with 841 additions and 111 deletions
+24 -2
View File
@@ -1,5 +1,5 @@
// The connection, transaction and signature prompts name the site by its full
// origin, scheme and port included, not by its bare hostname
// The connection, transaction, signature and network switch prompts name the
// site by its full origin, scheme and port included, not by its bare hostname
// (https://git.eeqj.de/sneak/AutistMask/issues/402). A page served over http,
// or on another port, of a host the user trusts over https must not raise a
// prompt that reads as that trusted site.
@@ -104,6 +104,7 @@ beforeEach(() => {
test("the connection prompt shows the origin", async () => {
await openApproval({});
expect(node("approve-origin").textContent).toBe(ORIGIN);
expect(node("view-approve-site").classList.contains("hidden")).toBe(false);
});
test("the transaction prompt shows the origin", async () => {
@@ -138,3 +139,24 @@ test("the signature prompt shows the origin", async () => {
});
expect(node("approve-sign-origin").textContent).toBe(ORIGIN);
});
test("the network switch prompt shows the origin and both networks", async () => {
await openApproval({
type: "network",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
});
expect(node("approve-network-origin").textContent).toBe(ORIGIN);
expect(node("approve-network-current").textContent).toBe(
"Ethereum Mainnet",
);
expect(node("approve-network-requested").textContent).toBe(
"Sepolia Testnet",
);
// Its own screen, and not the connection prompt, whose "Allow" answers
// on the same port.
expect(node("view-approve-network").classList.contains("hidden")).toBe(
false,
);
expect(node("view-approve-site").classList.contains("hidden")).toBe(true);
});
+14 -14
View File
@@ -25,6 +25,7 @@
const { Wallet } = require("ethers");
const { networkById } = require("../src/shared/networks");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY =
@@ -240,8 +241,8 @@ describe("a chain switch under a transaction already committed to a chain", () =
// The artifact is verified against the chain read at the top of the
// attempt. Whatever endpoint it is then broadcast to has to be that same
// chain's — otherwise the wallet checks a transaction against Sepolia and
// sends it to a mainnet node. A connected site can switch the chain at any
// moment, including this one.
// sends it to a mainnet node. The user can switch the network in Settings
// at any moment, including this one.
test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => {
const bg = loadWorker("sepolia");
@@ -264,9 +265,9 @@ describe("a chain switch under a transaction already committed to a chain", () =
populated(Number(SEPOLIA.networkVersion)),
);
// A connected site switches the chain while the attempt is running,
// and the switch is committed to storage in full before the attempt
// goes any further.
// The user switches the network in Settings while the attempt is
// running: the popup writes the switched record to storage in full
// before the attempt goes any further.
//
// It is fired from inside the attempt's SECOND state read, because
// that is where the window used to be: the chain id was captured at
@@ -277,18 +278,18 @@ describe("a chain switch under a transaction already committed to a chain", () =
// this to fire on, and the switch below runs after the attempt is
// done instead — which is the point.
let reads = 0;
let switched = null;
const doSwitch = async () => {
switched = bg.rpc("wallet_switchEthereumChain", [
{ chainId: MAINNET.chainId },
]);
await settle();
let switched = false;
const doSwitch = () => {
const record = bg.persisted();
applyChainSwitchFields(record, MAINNET.id);
global.chrome.storage.write("autistmask", record);
switched = true;
};
bg.setGetHook(async () => {
reads++;
if (reads !== 2) return;
bg.setGetHook(null);
await doSwitch();
doSwitch();
});
const attempt = bg.send(
@@ -303,8 +304,7 @@ describe("a chain switch under a transaction already committed to a chain", () =
await settle();
bg.setGetHook(null);
if (!switched) await doSwitch();
expect(switched.result()).toEqual({ result: null });
if (!switched) doSwitch();
expect(bg.persisted().networkId).toBe("mainnet");
await settle();
+208 -38
View File
@@ -1,16 +1,22 @@
// Who may move the active chain.
// Who may move the active chain, and when.
//
// wallet_switchEthereumChain used to be answered for any origin at all, with
// no connection check and no prompt, so a page the user had never connected
// to could clear the [TESTNET] banner under someone who believed they were
// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). The refusal
// is asserted as a refusal to ACT — the state unmoved and no chainChanged
// broadcast — because an error code alone would not distinguish a gate from
// a switch that happened and then reported a failure.
// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). Gated on the
// connection, a connected site could still move the wallet between mainnet and
// Sepolia without asking. Only the user switches the network: a connected
// site's request opens a prompt, and nothing changes unless the user approves
// it there (https://git.eeqj.de/sneak/AutistMask/issues/408).
//
// The endpoint half of that issue lives in tests/networkEndpoints.test.js,
// which covers the popup's chain switch; this file covers the background's,
// which goes through storage rather than the shared state singleton.
// Every refusal is asserted as a refusal to ACT — the stored record unmoved
// and no chainChanged broadcast — because an error code alone would not
// distinguish a refusal from a switch that happened and then reported a
// failure.
//
// The endpoint half of #308 lives in tests/networkEndpoints.test.js, which
// covers the popup's chain switch; this file covers the background's, which
// goes through storage rather than the shared state singleton.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
@@ -21,18 +27,23 @@ const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const STRANGER_ORIGIN = "https://stranger.example";
const EXT_URL = "chrome-extension://autistmask/";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
// The user's own node, so a switch that happens is visible as the loss of it.
const CUSTOM_RPC = "http://127.0.0.1:8545";
// A balance a switch would clear, so a switch that happens is visible here too.
function walletFixture() {
return [
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
addresses: [
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
],
},
];
}
@@ -47,10 +58,6 @@ afterEach(() => {
delete global.chrome;
});
// ---------------------------------------------------------------------------
// The gate: which origins the background will switch the chain for.
// ---------------------------------------------------------------------------
// Load the background worker against stubbed browser APIs, with the real
// chain-switch and persistence modules behind it, and return the handles to
// drive it.
@@ -91,30 +98,46 @@ function loadBackground() {
const storage = makeStorageStub({ autistmask: persisted });
let messageListener = null;
let connectListener = null;
let windowRemovedListener = null;
// The URL of every approval window the background opened. The approval id
// is in it, and that is how the popup learns which approval it answers.
const opened = [];
// Every message the background pushed at a content script. chainChanged
// is what tells a page the wallet moved, so an ungated switch is visible
// here as well as in the state.
// is what tells a page the wallet moved, so a switch is visible here as
// well as in the state.
const toTabs = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }),
create: (options, cb) => {
opened.push(options.url);
cb({ id: opened.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
onRemoved: {
addListener: (fn) => {
windowRemovedListener = fn;
},
},
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
@@ -128,6 +151,8 @@ function loadBackground() {
require("../src/background/index");
// A page's request. Its answer is read with result(), which is null for as
// long as the request is waiting on the user.
async function switchChain(chainId, origin) {
let result = null;
messageListener(
@@ -142,56 +167,147 @@ function loadBackground() {
},
);
await settle();
return result;
return { result: () => result };
}
function promptId() {
return new URL(opened[opened.length - 1]).searchParams.get("approval");
}
// What the popup is told to show for the prompt.
function describePrompt() {
let reply = null;
messageListener(
{ type: "AUTISTMASK_GET_APPROVAL", id: promptId() },
{ url: EXT_URL + "src/popup/index.html" },
(r) => {
reply = r;
},
);
return reply;
}
// The user's answer, as the popup sends it: on the port named for the
// approval, from the extension's own page, and then the window closes.
async function answerPrompt(approved) {
const onMessage = [];
const onDisconnect = [];
const port = {
name: "approval:" + promptId(),
sender: { url: EXT_URL + "src/popup/index.html" },
onMessage: { addListener: (fn) => onMessage.push(fn) },
onDisconnect: { addListener: (fn) => onDisconnect.push(fn) },
};
connectListener(port);
for (const fn of onMessage) {
fn(
{
type: "AUTISTMASK_APPROVAL_DECISION",
approved,
remember: false,
},
port,
);
}
for (const fn of onDisconnect) fn(port);
await settle();
}
// The user closes the prompt window without answering it.
async function closePrompt() {
windowRemovedListener(opened.length);
await settle();
}
return {
switchChain,
describePrompt,
answerPrompt,
closePrompt,
opened,
walletState: () => storage.read("autistmask"),
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};
}
const USER_REJECTED = { code: 4001, message: "User rejected the request." };
describe("wallet_switchEthereumChain is gated on the connection", () => {
test("an origin the wallet was never connected to is refused with 4100", async () => {
const bg = loadBackground();
const before = bg.walletState();
const result = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN);
const request = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN);
expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
expect(result.result).toBeUndefined();
expect(request.result().error).toEqual({
code: 4100,
message: "Unauthorized",
});
expect(request.result().result).toBeUndefined();
// The refusal has to be a refusal to ACT, not just an error string:
// the wallet is still on mainnet, still on the user's own node, and
// no page was told the chain moved.
expect(bg.walletState().networkId).toBe("mainnet");
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
// no page was told the chain moved. Nor was the user asked.
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
expect(bg.opened).toEqual([]);
});
test("an unconnected origin is refused even for the chain already active", async () => {
const bg = loadBackground();
const result = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN);
const request = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN);
expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
expect(request.result().error).toEqual({
code: 4100,
message: "Unauthorized",
});
});
test("an unconnected origin is refused before the unsupported-chain answer", async () => {
const bg = loadBackground();
const result = await bg.switchChain("0x89", STRANGER_ORIGIN);
const request = await bg.switchChain("0x89", STRANGER_ORIGIN);
expect(result.error.code).toBe(4100);
expect(request.result().error.code).toBe(4100);
});
});
describe("only the user switches the network", () => {
test("a connected site's request changes nothing while the prompt is open", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
// Waiting on the user, with one prompt on screen naming the site and
// both networks.
expect(request.result()).toBeNull();
expect(bg.opened).toHaveLength(1);
expect(bg.describePrompt()).toMatchObject({
origin: CONNECTED_ORIGIN,
type: "network",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
});
// The network, the endpoints and the balances are as they were, and
// no page was told otherwise.
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("a connected origin switches the chain", async () => {
test("approving the prompt switches the network", async () => {
const bg = loadBackground();
const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(result).toEqual({ result: null });
expect(bg.walletState().networkId).toBe("sepolia");
expect(request.result()).toEqual({ result: null });
const after = bg.walletState();
expect(after.networkId).toBe("sepolia");
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
@@ -201,22 +317,76 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
]);
});
test("a connected origin asking for an unsupported chain still gets 4902", async () => {
test("rejecting the prompt changes nothing and answers 4001", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(false);
expect(request.result()).toEqual({ error: USER_REJECTED });
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("closing the prompt without answering changes nothing and answers 4001", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.closePrompt();
expect(request.result()).toEqual({ error: USER_REJECTED });
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("a request for the chain already active opens no prompt", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
expect(request.result()).toEqual({ result: null });
expect(bg.opened).toEqual([]);
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("a second request while the prompt is open is refused with -32002", async () => {
const bg = loadBackground();
const result = await bg.switchChain("0x89", CONNECTED_ORIGIN);
const first = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
const second = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(result.error.code).toBe(4902);
expect(second.result().error.code).toBe(-32002);
expect(bg.opened).toHaveLength(1);
// The first prompt still decides.
await bg.answerPrompt(true);
expect(first.result()).toEqual({ result: null });
expect(bg.walletState().networkId).toBe("sepolia");
});
test("a request for an unsupported chain still gets 4902 and no prompt", async () => {
const bg = loadBackground();
const request = await bg.switchChain("0x89", CONNECTED_ORIGIN);
expect(request.result().error.code).toBe(4902);
expect(bg.opened).toEqual([]);
expect(bg.walletState().networkId).toBe("mainnet");
});
test("a switch by a connected origin keeps the user's endpoint", async () => {
test("an approved switch keeps the user's endpoint", async () => {
const bg = loadBackground();
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
});
});
+44 -2
View File
@@ -14,6 +14,10 @@
// itself: the handler has to do it. tests/chainSwitchGate.test.js mocks the
// state module wholesale and tests/networkEndpoints.test.js always loads
// first, so neither can see this.
//
// A site's switch happens only once the user approves it on a prompt
// (https://git.eeqj.de/sneak/AutistMask/issues/408), so every switch here is
// approved the way the popup approves one.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
@@ -90,6 +94,9 @@ function loadColdWorker(networkId) {
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
let messageListener = null;
let connectListener = null;
// The URL of every approval window opened; the approval id is in it.
const opened = [];
const toTabs = [];
global.chrome = {
@@ -101,12 +108,19 @@ function loadColdWorker(networkId) {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }),
create: (options, cb) => {
opened.push(options.url);
cb({ id: opened.length });
},
remove: (id, cb) => {
if (cb) cb();
},
@@ -124,6 +138,32 @@ function loadColdWorker(networkId) {
require("../src/background/index");
// The user approves the prompt the request opened, as the popup does: a
// decision on the port named for the approval, from the extension's own
// page.
function approvePrompt() {
const id = new URL(opened[opened.length - 1]).searchParams.get(
"approval",
);
let onDecision = null;
const port = {
name: "approval:" + id,
sender: { url: "chrome-extension://autistmask/src/popup/" },
onMessage: {
addListener: (fn) => {
onDecision = fn;
},
},
onDisconnect: { addListener: () => {} },
};
connectListener(port);
onDecision(
{ type: "AUTISTMASK_APPROVAL_DECISION", approved: true },
port,
);
}
// A connected site asks for `chainId`, and the user approves it.
async function switchChain(chainId) {
let result = null;
messageListener(
@@ -138,6 +178,8 @@ function loadColdWorker(networkId) {
},
);
await settle();
approvePrompt();
await settle();
return result;
}
+154
View File
@@ -63,6 +63,7 @@ const {
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp");
const { STUB_COUNTERPARTY } = require("../network");
const { NETWORKS } = require("../../../src/shared/networks");
const {
STATE_SCHEMA_VERSION,
stateProblem,
@@ -684,6 +685,159 @@ step(
},
);
// The network fields of the stored record, read on the popup page, the one
// moz-extension:// document the suite has open.
async function storedNetwork(env) {
const d = env.driver;
await d.switchToWindow(env.popupWindow);
const stored = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const api = typeof browser !== "undefined" ? browser : chrome;
Promise.resolve(api.storage.local.get("autistmask")).then(
(r) => done({
networkId: r.autistmask.networkId,
rpcUrl: r.autistmask.rpcUrl,
blockscoutUrl: r.autistmask.blockscoutUrl,
}),
(e) => done({ error: String((e && e.message) || e) }),
);`,
);
assert(
stored && !stored.error,
"could not read the stored network: " + (stored && stored.error),
);
return stored;
}
// Every chainChanged event the test page has been sent, waiting up to
// `timeout` for there to be `count` of them: the background sends the event
// alongside its answer to the request, so it can arrive just after it. Leaves
// the driver on the page.
async function chainChangedEvents(env, count = 0, timeout = 5000) {
const d = env.driver;
await d.switchToWindow(env.dappWindow);
const deadline = Date.now() + timeout;
for (;;) {
const events = (await dappMessages(d, "AUTISTMASK_EVENT")).filter(
(m) => m.eventName === "chainChanged",
);
if (events.length >= count || Date.now() > deadline) return events;
await sleep(100);
}
}
// Ask, from the page, to switch from network `from` to network `to`, and
// return the prompt that opens, checked to name the site and both networks.
// Leaves the driver on the prompt.
async function openNetworkPrompt(env, key, from, to) {
const d = env.driver;
await d.switchToWindow(env.dappWindow);
await startRequest(d, key, "wallet_switchEthereumChain", [
{ chainId: to.chainId },
]);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-network");
const screen = {
origin: await d.text("#approve-network-origin"),
current: await d.text("#approve-network-current"),
requested: await d.text("#approve-network-requested"),
};
assert(
isDeepStrictEqual(screen, {
origin: env.server.origin,
current: from.name,
requested: to.name,
}),
"the network switch prompt shows " + JSON.stringify(screen),
);
return popup;
}
// Only the user switches the network. A connected site's request opens a
// prompt, and until the user approves it the stored network does not move and
// no page is told it did (https://git.eeqj.de/sneak/AutistMask/issues/408).
// The wallet goes back to mainnet the same way at the end, for the transaction
// step after this one.
step(
"a site's network switch changes nothing until the user approves it",
async (env) => {
const d = env.driver;
const { mainnet, sepolia } = NETWORKS;
const before = await storedNetwork(env);
assert(
before.networkId === "mainnet",
"this step starts on mainnet, not on " + before.networkId,
);
const eventsBefore = (await chainChangedEvents(env)).length;
const rejected = await openNetworkPrompt(
env,
"switch-reject",
mainnet,
sepolia,
);
assert(
isDeepStrictEqual(await storedNetwork(env), before),
"the network moved while its prompt was still open",
);
// Nothing else closes this window, so a click that did not land
// leaves the request unanswered and the assertion below fails.
await d.switchToWindow(rejected);
await d.click("#btn-reject-network");
await d.switchToWindow(env.dappWindow);
await assertUserRejection(
d,
"switch-reject",
"the network switch rejection",
);
assert(
isDeepStrictEqual(await storedNetwork(env), before),
"a rejected network switch moved the network",
);
assert(
(await chainChangedEvents(env)).length === eventsBefore,
"a rejected network switch told the page the chain changed",
);
await openNetworkPrompt(env, "switch-approve", mainnet, sepolia);
await d.click("#btn-approve-network");
await d.switchToWindow(env.dappWindow);
let outcome = await settleRequest(d, "switch-approve");
assert(
outcome.settled === "resolved" && outcome.result === null,
"the approved network switch did not resolve: " +
JSON.stringify(outcome),
);
assert(
(await storedNetwork(env)).networkId === "sepolia",
"the approved network switch did not move the network",
);
const events = await chainChangedEvents(env, eventsBefore + 1);
assert(
events.length === eventsBefore + 1 &&
events[events.length - 1].data === sepolia.chainId,
"the page was not told of the approved switch: " +
JSON.stringify(events),
);
await openNetworkPrompt(env, "switch-restore", sepolia, mainnet);
await d.click("#btn-approve-network");
await d.switchToWindow(env.dappWindow);
outcome = await settleRequest(d, "switch-restore");
assert(
outcome.settled === "resolved",
"switching back to mainnet did not resolve: " +
JSON.stringify(outcome),
);
assert(
isDeepStrictEqual(await storedNetwork(env), before),
"switching back did not restore the mainnet network and endpoints",
);
await d.switchToWindow(env.dappWindow);
},
);
step(
"eth_sendTransaction shows the transaction and returns its hash",
async (env) => {
+156 -7
View File
@@ -3499,7 +3499,7 @@ async function closeApprovalPages(ctx) {
}
// Click a button whose own handler closes the window it lives in — every
// Reject, and Allow on the site prompt.
// Reject, Allow on the site prompt, and Switch on the network switch prompt.
//
// page.click() dispatches the click and then waits for the renderer to
// acknowledge it, and a page torn down by the handler never gets to. The
@@ -3514,12 +3514,13 @@ async function closeApprovalPages(ctx) {
// #btn-reject-sign, #btn-reject-tx — their disconnect leaves the approval
// pending, so a click that never landed leaves the dApp promise unsettled
// and the assertion after the call fails on its own.
// #btn-approve — only a decision resolves the promise, and a swallowed click
// cannot produce settled === "resolved".
// #btn-reject on the site prompt — NOT self-proving. A page that went away
// without the click landing disconnects the approval port, the background
// settles that as 4001, and 4001 is exactly what assertUserRejection
// accepts. Both call sites arm the click trace below and assert it.
// #btn-approve, #btn-approve-network — only a decision resolves the promise,
// and a swallowed click cannot produce settled === "resolved".
// #btn-reject on the site prompt, #btn-reject-network — NOT self-proving. A
// page that went away without the click landing disconnects the approval
// port, the background settles that as 4001, and 4001 is exactly what
// assertUserRejection accepts. Every call site arms the click trace below
// and asserts it.
//
// A button that is missing or unclickable raises a different error, which is
// rethrown.
@@ -4389,6 +4390,154 @@ test("a prompt raised while another approval window has focus opens its own (#29
await assertUserRejection(env.dapp, "focus-sign", "the sign prompt");
});
// The network fields of the stored record.
async function storedNetwork(page) {
const s = await storedRecord(page);
return {
networkId: s.networkId,
rpcUrl: s.rpcUrl,
blockscoutUrl: s.blockscoutUrl,
};
}
// Every chainChanged event the test page has been sent, waiting up to
// `timeout` for there to be `count` of them: the background sends the event
// alongside its answer to the request, so it can arrive just after it.
async function chainChangedEvents(page, count = 0, timeout = 5000) {
const deadline = Date.now() + timeout;
for (;;) {
const events = (await dappMessages(page, "AUTISTMASK_EVENT")).filter(
(m) => m.eventName === "chainChanged",
);
if (events.length >= count || Date.now() > deadline) return events;
await sleep(50);
}
}
// Ask, from the test page, to switch from network `from` to network `to`, and
// return the prompt that opens, checked to name the site and both networks.
async function openNetworkPrompt(env, key, from, to) {
await startRequest(env.dapp, key, "wallet_switchEthereumChain", [
{ chainId: to.chainId },
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-network");
const screen = await popup.evaluate(() => ({
origin: document.getElementById("approve-network-origin").textContent,
current: document.getElementById("approve-network-current").textContent,
requested: document.getElementById("approve-network-requested")
.textContent,
}));
assert(
isDeepStrictEqual(screen, {
origin: DAPP_ORIGIN,
current: from.name,
requested: to.name,
}),
"the network switch prompt shows " + JSON.stringify(screen),
);
return popup;
}
// Only the user switches the network. A connected site's request opens a
// prompt, and until the user approves it the stored network does not move and
// no page is told it did (https://git.eeqj.de/sneak/AutistMask/issues/408).
// The wallet goes back to mainnet the same way at the end, for the tests after
// this one.
test("a site's network switch changes nothing until the user approves it (#408)", async (env) => {
const { mainnet, sepolia } = NETWORKS;
const before = await storedNetwork(env.page);
assert(
before.networkId === "mainnet",
"this test starts on mainnet, not on " + before.networkId,
);
const eventsBefore = (await chainChangedEvents(env.dapp)).length;
const rejected = await openNetworkPrompt(
env,
"switch-reject",
mainnet,
sepolia,
);
try {
assert(
isDeepStrictEqual(await storedNetwork(env.page), before),
"the network moved while its prompt was still open",
);
// Closing the prompt unanswered is also a rejection, so the click
// itself is witnessed.
await armClickTrace(env, rejected, "#btn-reject-network");
await clickAndClose(rejected, "#btn-reject-network");
await assertClickLanded(env, "#btn-reject-network");
await assertUserRejection(
env.dapp,
"switch-reject",
"the network switch rejection",
);
} finally {
await closeApprovalPages(env.ctx);
}
assert(
isDeepStrictEqual(await storedNetwork(env.page), before),
"a rejected network switch moved the network",
);
assert(
(await chainChangedEvents(env.dapp)).length === eventsBefore,
"a rejected network switch told the page the chain changed",
);
const approved = await openNetworkPrompt(
env,
"switch-approve",
mainnet,
sepolia,
);
let outcome;
try {
await clickAndClose(approved, "#btn-approve-network");
outcome = await settleRequest(env.dapp, "switch-approve");
} finally {
await closeApprovalPages(env.ctx);
}
assert(
outcome.settled === "resolved" && outcome.result === null,
"the approved network switch did not resolve: " +
JSON.stringify(outcome),
);
assert(
(await storedNetwork(env.page)).networkId === "sepolia",
"the approved network switch did not move the network",
);
const events = await chainChangedEvents(env.dapp, eventsBefore + 1);
assert(
events.length === eventsBefore + 1 &&
events[events.length - 1].data === sepolia.chainId,
"the page was not told of the approved switch: " +
JSON.stringify(events),
);
const restored = await openNetworkPrompt(
env,
"switch-restore",
sepolia,
mainnet,
);
try {
await clickAndClose(restored, "#btn-approve-network");
outcome = await settleRequest(env.dapp, "switch-restore");
} finally {
await closeApprovalPages(env.ctx);
}
assert(
outcome.settled === "resolved",
"switching back to mainnet did not resolve: " + JSON.stringify(outcome),
);
assert(
isDeepStrictEqual(await storedNetwork(env.page), before),
"switching back did not restore the mainnet network and endpoints",
);
});
// 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