From dae958bec3899ac9ca7964e14f5ee5073218de01 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 7 Oct 2026 21:33:11 +0000 Subject: [PATCH] fix: only the user switches the wallet's network (closes #408) 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 --- README.md | 75 ++++++-- TODO.md | 13 ++ src/background/index.js | 91 +++++++-- src/popup/index.html | 48 +++++ src/popup/index.js | 4 +- src/popup/views/approval.js | 57 ++++-- src/popup/views/helpers.js | 1 + tests/approvalOrigin.test.js | 26 ++- tests/backgroundStateIsolation.test.js | 28 +-- tests/chainSwitchGate.test.js | 246 +++++++++++++++++++++---- tests/coldWorkerChainSwitch.test.js | 46 ++++- tests/e2e/firefox/run.js | 154 ++++++++++++++++ tests/e2e/run.js | 163 +++++++++++++++- 13 files changed, 841 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index 13e3a7b..c1da95e 100644 --- a/README.md +++ b/README.md @@ -414,16 +414,18 @@ 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, a prompt -raised while another approval window has focus is required to open a window of -its own, 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 +`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4`, +`eth_sendTransaction` and `wallet_switchEthereumChain` 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, a network switch request is required to leave the +stored network unchanged and send no `chainChanged` until it is approved, a +prompt raised while another approval window has focus is required to open a +window of its own, 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). @@ -525,10 +527,11 @@ Chrome that ever changes this fails the run instead of passing it. `make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver. It covers popup load, the StateRecovery screen (the same cases as the Chrome -suite), wallet creation through the UI, the Add Token screen, and the four dApp -round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and -a closed approval window rejecting with EIP-1193 4001 — driven through the real -content script, background page and approval windows. +suite), wallet creation through the UI, the Add Token screen, and the dApp round +trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain` +rejected and then approved, `eth_sendTransaction`, and a closed approval window +rejecting with EIP-1193 4001 — driven through the real content script, +background page and approval windows. The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has **no npm dependencies at all**: it is built on global `fetch` and @@ -1783,7 +1786,9 @@ view would leave a wallet one click from deletion. - Display: "Show tracked tokens with zero balance" checkbox, "UTC Timestamps" checkbox, and a Theme selector (System / Light / Dark) - Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching - resets the RPC and Blockscout endpoints to that network's defaults + resets the RPC and Blockscout endpoints to that network's defaults. The + network changes only here, or when the user approves a site's request on + **NetworkApproval** - Ethereum RPC: endpoint URL input + "Save" button (validated against `eth_chainId` before being saved) - Blockscout API: endpoint URL input + "Save" button (validated against @@ -2071,7 +2076,10 @@ view would leave a wallet one click from deletion. no window and takes no nonce, and the site can send it again once the pending one is answered. The window is centred on the browser window the user was last in; if that was another approval window, or the browser refuses the centred - position, the browser picks the position. + position, the browser picks the position. The network shown is the one the + transaction was populated on, and a site cannot switch it without the user: + its `wallet_switchEthereumChain` request changes the network only when the + user approves it on **NetworkApproval**. - **Elements**: - "Transaction Request" heading - Phishing warning banner (shown when the hostname is on the phishing @@ -2162,6 +2170,39 @@ view would leave a wallet one click from deletion. - Popup window closed without answering → the request is rejected with EIP-1193 code 4001 +#### NetworkApproval (`approve-network`) + +- **When**: A connected website asks to switch the network with + `wallet_switchEthereumChain`, naming a supported network other than the active + one. Only the user switches the network: until the user approves the request + here, it changes nothing — not the network, the RPC and Blockscout endpoints, + the balances or the cached token data — and no page is sent `chainChanged`. + Opened the same way as TxApproval, in a separate popup window. Only one exists + per site at a time: a further switch request from a site whose switch request + is still unanswered is refused with EIP-1193 code `-32002` and opens no + window. A request naming the network already active is answered at once and + opens nothing; one naming an unsupported chain is refused with code `4902`, + and one from a site that is not connected with code `4100`. + `wallet_addEthereumChain` never switches the network. +- **Elements**: + - "Network Switch Request" heading + - Phishing warning banner (shown when the hostname is on the phishing + blocklist) + - Site origin (bold, scheme and port included) + "wants to switch the + wallet's network." + - Current network: its name + - Requested network: its name + - A line saying that switching changes the network for the whole wallet and + for every site + - "Switch" / "Reject" buttons +- **Transitions**: + - "Switch" → closes popup; the background switches the network as + **Settings** does, sends `chainChanged` to every open tab, and answers the + site with success + - "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and + nothing changes + - Popup window closed without answering → the same as "Reject" + #### StateRecovery (`state-recovery`) - **When**: the stored profile fails `assertStateUsable()`. At open, that is @@ -2456,6 +2497,8 @@ logged. - Sign transactions requested by connected sites (`eth_sendTransaction`) - Sign messages (`personal_sign`, `eth_sign`) - Sign typed data (`eth_signTypedData_v4`, `eth_signTypedData`) +- Switch network at a connected site's request, once the user approves it + (`wallet_switchEthereumChain`) - Human-readable transaction decoding (ERC-20, Uniswap Universal Router) - ETH/USD and token/USD price display - Configurable RPC endpoint and Blockscout API diff --git a/TODO.md b/TODO.md index 618163a..f7ed4ce 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,19 @@ then continue tagging as milestones land. # Completed Steps +- 2026-10-07: A site can no longer switch the wallet's network by itself + ([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)). A connected site's + `wallet_switchEthereumChain` request for the other supported network opens a + prompt in its own window, through the same approval machinery as the + transaction and signature prompts, naming the site, the current network and + the requested one. The network, its endpoints, the balances and the 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. The + approval window no longer shows the connection prompt while it waits for the + background to describe the approval, because that prompt's "Allow" answers on + the same port as the new one. `tests/chainSwitchGate.test.js` and both browser + suites drive the prompt. + - 2026-10-07: Two contradictions between the documents and the code are resolved as ruled on [#165](https://git.eeqj.de/sneak/AutistMask/issues/165). The README's 1.0 non-goal now puts Ethereum mainnet and the Sepolia testnet in diff --git a/src/background/index.js b/src/background/index.js index bbeb4c9..609deea 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -137,11 +137,12 @@ function releaseTxApprovalSlotFor(approvalId) { } } -// One site-connection approval and one sign approval per site at a time: a -// page that asks again before the user has answered is refused with the code -// above instead of opening another window, so it cannot bury the user in -// prompts. The pending approval itself holds the place, so a caller must test -// this and raise its approval with nothing awaited in between. +// One site-connection approval, one sign approval and one network-switch +// approval per site at a time: a page that asks again before the user has +// answered is refused with the code above instead of opening another window, +// so it cannot bury the user in prompts. The pending approval itself holds the +// place, so a caller must test this and raise its approval with nothing awaited +// in between. function findPendingApproval(origin, type) { return Object.values(pendingApprovals).find( (approval) => approval.origin === origin && approval.type === type, @@ -348,8 +349,9 @@ function settleApproval(id, result, options) { // What a pending approval resolves to when it is given up on rather than // answered: the window was closed, or could not be opened at all. A tx or sign -// approval answers the requesting page in EIP-1193 shape; a site-connection -// approval answers the connection handler in its own. +// approval answers the requesting page in EIP-1193 shape; a site-connection or +// network-switch approval answers its handler in the shape the popup's +// decision has, as a refusal. function abandonedResult(approval, code, message) { if (approval.type === "tx" || approval.type === "sign") { return { error: { code, message } }; @@ -588,6 +590,26 @@ function requestSignApproval(origin, signParams, approvedFrom) { }); } +// Open a network-switch approval popup and return a promise that resolves with +// { approved }. Opened in a window, as tx and sign approvals are, because a +// site's request is not a user gesture. The popup answers on the approval port, +// as a site-connection approval does. +function requestNetworkApproval(origin, currentNetworkId, requestedNetworkId) { + return new Promise((resolve) => { + const id = crypto.randomUUID(); + pendingApprovals[id] = { + id, + origin, + currentNetworkId, + requestedNetworkId, + resolve, + type: "network", + }; + + openApprovalWindow(id); + }); +} + // Anything only the extension's own pages may say. A content script speaks // with the page's URL, so this is what separates the popup from the site the // popup is being asked about. @@ -597,8 +619,8 @@ function isExtensionSender(sender) { } // The approval popup's port: it carries the user's decision on a -// site-connection approval, and its disconnect is how that approval learns the -// popup closed without one. +// site-connection or network-switch approval, and its disconnect is how that +// approval learns the popup closed without one. // // The decision travels this port rather than a one-off runtime.sendMessage() // for exactly one reason: the port is also what the popup's window.close() @@ -806,6 +828,10 @@ async function handleRpc(method, params, origin) { // tab is served from, so a page the user never connected to must // not be able to do it. Ungated, any page could clear the // [TESTNET] banner under a user who believed they were on Sepolia. + // + // A connected site does not switch it either: only the user does, + // by approving the request on the prompt below + // (https://git.eeqj.de/sneak/AutistMask/issues/408). const s = await getState(); const activeAddress = activeAddressOf(s); const allowed = s.allowedSites[activeAddress] || []; @@ -827,6 +853,27 @@ async function handleRpc(method, params, origin) { } if (SUPPORTED_CHAIN_IDS.has(chainId)) { const target = networkByChainId(chainId); + if (findPendingApproval(origin, "network")) { + return { + error: { + code: APPROVAL_PENDING_CODE, + message: APPROVAL_PENDING_MESSAGE, + }, + }; + } + const decision = await requestNetworkApproval( + origin, + s.networkId, + target.id, + ); + if (!decision.approved) { + return { + error: { + code: APPROVAL_REJECTED_CODE, + message: APPROVAL_REJECTED_MESSAGE, + }, + }; + } // Read-modify-write against storage. The old path went through // onChainSwitch(), which mutates the singleton and then persists // every field of it — on an unloaded worker that wrote empty @@ -1345,20 +1392,21 @@ startBackgroundJobs(); // which then fails retryably settles instead of waiting in a window that no // longer exists. // -// A site-connection approval whose popup connected its port is not decided -// here. That popup approves and closes in the same breath, and this event -// races the decision on a channel of its own — the same race the port exists -// to end. Its port disconnect says the same thing this event does, in an order -// that is defined, so the disconnect is left to say it. The window closing -// before any port connected is the one case with nothing else to speak for it, -// and is rejected here so the dApp is not left waiting on a window that is -// gone. +// A site-connection or network-switch approval whose popup connected its port +// is not decided here. That popup approves and closes in the same breath, and +// this event races the decision on a channel of its own — the same race the +// port exists to end. Its port disconnect says the same thing this event does, +// in an order that is defined, so the disconnect is left to say it. The window +// closing before any port connected is the one case with nothing else to speak +// for it, and is rejected here so the dApp is not left waiting on a window that +// is gone. if (windowsNs && windowsNs.onRemoved) { windowsNs.onRemoved.addListener((windowId) => { for (const [id, approval] of Object.entries(pendingApprovals)) { if (approval.windowId !== windowId) continue; - const isSite = approval.type !== "tx" && approval.type !== "sign"; - if (isSite && approval.portConnected) continue; + const decidedOnPort = + approval.type !== "tx" && approval.type !== "sign"; + if (decidedOnPort && approval.portConnected) continue; const rejection = abandonedResult( approval, APPROVAL_REJECTED_CODE, @@ -1446,6 +1494,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { resp.signParams = approval.signParams; resp.approvedFrom = approval.approvedFrom; } + if (approval.type === "network") { + resp.type = "network"; + resp.currentNetworkId = approval.currentNetworkId; + resp.requestedNetworkId = approval.requestedNetworkId; + } // Flag if the requesting domain is on the phishing blocklist. resp.isPhishingDomain = isPhishingDomain( extractHostname(approval.origin), diff --git a/src/popup/index.html b/src/popup/index.html index 3b853cc..1dd52bc 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -1741,6 +1741,54 @@ + + +