// 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). 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). // // 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. The last block // covers what the background tells open tabs when the user switches the // network in Settings. const { networkById } = require("../src/shared/networks"); const { makeStorageStub } = require("./support/storageStub"); const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; // The site the persisted state has connected, and one it has never heard of. 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: "1.5", tokenBalances: [] }, ], }, ]; } // Let the handler's promise chain run to the next suspension point. The gate // reads storage before it answers, so the response is several awaits deep. async function settle() { for (let i = 0; i < 50; i++) await Promise.resolve(); } afterEach(() => { delete global.chrome; }); // 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. function loadBackground() { jest.resetModules(); jest.doMock("../src/shared/balances", () => ({ getProvider: () => ({}), refreshBalances: jest.fn(async () => {}), })); jest.doMock("../src/shared/phishingDomains", () => ({ isPhishingDomain: () => false, })); jest.doMock("../src/shared/alarms", () => ({ BALANCE_REFRESH_ALARM: "balance", BALANCE_REFRESH_PERIOD_MINUTES: 1, ensureRecurringAlarms: jest.fn(async () => {}), registerAlarmHandlers: jest.fn(), })); // Storage is the only wallet state there is. The background reads and // writes it per call — it holds no in-memory copy and cannot reach the // shared singleton — so a switch that happened is visible here as a // written record, and one that did not is visible as its absence. const persisted = { networkId: "mainnet", rpcUrl: CUSTOM_RPC, blockscoutUrl: MAINNET.defaultBlockscoutUrl, networkEndpoints: {}, wallets: walletFixture(), lastBalanceRefresh: 1, tokenHolderCache: {}, fraudContracts: [], activeAddress: ADDRESS, allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] }, deniedSites: {}, }; 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 a switch is visible here as // well as in the state. const toTabs = []; global.chrome = { storage, runtime: { getURL: (path) => EXT_URL + path, onMessage: { addListener: (fn) => { messageListener = fn; }, }, onConnect: { addListener: (fn) => { connectListener = fn; }, }, lastError: null, }, windows: { getLastFocused: (cb) => cb(null), create: (options, cb) => { opened.push(options.url); cb({ id: opened.length }); }, remove: (id, cb) => { if (cb) cb(); }, onRemoved: { addListener: (fn) => { windowRemovedListener = fn; }, }, }, tabs: { query: (queryInfo, cb) => cb([{ id: 1 }]), sendMessage: (tabId, message, cb) => { toTabs.push(message); if (cb) cb(); }, }, action: { setPopup: () => {} }, }; 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( { type: "AUTISTMASK_RPC", method: "wallet_switchEthereumChain", params: [{ chainId }], }, { origin }, (r) => { result = r; }, ); await settle(); 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, // A message to the background from `sender`, and its answer. send: (msg, sender) => { let reply = null; messageListener(msg, sender, (r) => { reply = r; }); return reply; }, 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 request = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN); 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. 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 request = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN); 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 request = await bg.switchChain("0x89", STRANGER_ORIGIN); 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("approving the prompt switches the network", async () => { const bg = loadBackground(); const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN); await bg.answerPrompt(true); 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", eventName: "chainChanged", data: SEPOLIA.chainId, }, ]); }); 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 first = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN); const second = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN); 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("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); }); }); // Switching the network in Settings tells open pages, as an approved site // request does (https://git.eeqj.de/sneak/AutistMask/issues/500). These drive // the real Settings view over the background's storage, with what it sends // delivered to the background from the extension's own page. describe("switching the network in Settings tells every open tab", () => { const POPUP = { url: EXT_URL + "src/popup/index.html" }; // A stand-in for one DOM node: enough of an element for init() to set // properties on it and hang listeners off it. function fakeElement() { return { value: "", checked: false, textContent: "", style: {}, dataset: {}, classList: { add() {}, remove() {} }, listeners: {}, addEventListener(event, handler) { this.listeners[event] = handler; }, querySelectorAll: () => [], }; } // Settings, opened the way the popup opens it. Returns its network // selector. async function openSettings(bg) { const elements = {}; const element = (id) => (elements[id] ||= fakeElement()); jest.doMock("../src/popup/views/helpers", () => ({ $: element, showView: () => {}, updateDebugBanner: () => {}, showFlash: () => {}, escapeHtml: (s) => s, flashCopyFeedback: () => {}, goBack: () => {}, pushCurrentView: () => {}, onViewLeave: () => {}, VIEWS: [], })); global.chrome.runtime.sendMessage = (msg) => { bg.send(msg, POPUP); }; await require("../src/shared/state").loadState(); require("../src/popup/views/settings").init({ pageClosed: new AbortController().signal, }); const select = element("settings-network"); select.value = "mainnet"; return select; } // The user picks `networkId` in the selector. async function choose(select, networkId) { select.value = networkId; await select.listeners.change(); await settle(); } afterEach(() => { jest.dontMock("../src/popup/views/helpers"); }); test("switching to the other network sends chainChanged once, with its chain id", async () => { const bg = loadBackground(); const select = await openSettings(bg); await choose(select, "sepolia"); expect(bg.walletState().networkId).toBe("sepolia"); expect(bg.chainChangedEvents()).toEqual([ { type: "AUTISTMASK_EVENT", eventName: "chainChanged", data: SEPOLIA.chainId, }, ]); }); test("choosing the network already active changes nothing and sends nothing", async () => { const bg = loadBackground(); const select = await openSettings(bg); const before = bg.walletState(); await choose(select, "mainnet"); expect(bg.walletState()).toEqual(before); expect(bg.chainChangedEvents()).toEqual([]); }); test("a page cannot make the background send chainChanged", async () => { const bg = loadBackground(); const reply = bg.send( { type: "AUTISTMASK_NETWORK_CHANGED", chainId: SEPOLIA.chainId }, { url: CONNECTED_ORIGIN + "/" }, ); await settle(); expect(reply).toEqual({ error: "Unauthorized sender" }); expect(bg.chainChangedEvents()).toEqual([]); }); });