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 was merged in pull request #501.
This commit is contained in:
2026-10-08 07:30:16 +02:00
parent ca18beb97f
commit ff05bd50f7
18 changed files with 911 additions and 124 deletions
+62 -17
View File
@@ -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 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 handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
`MAIN`-world content script like any other page, and drives `MAIN`-world content script like any other page, and drives
`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and `eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4`,
`eth_sendTransaction` through the real prompts. Every signature is recovered in `eth_sendTransaction` and `wallet_switchEthereumChain` through the real prompts.
the runner and compared against the active address, the transaction assertions Every signature is recovered in the runner and compared against the active
run against the raw signed transaction captured at `eth_sendRawTransaction` address, the transaction assertions run against the raw signed transaction
rather than against anything the extension reported, rejecting each prompt is captured at `eth_sendRawTransaction` rather than against anything the extension
required to return a rejection to the page rather than hang or resolve, a prompt reported, rejecting each prompt is required to return a rejection to the page
raised while another approval window has focus is required to open a window of rather than hang or resolve, a network switch request is required to leave the
its own, and the password is required to be absent from every message the stored network unchanged and send no `chainChanged` until it is approved, a
approval window sends to the background — with the message that would carry it prompt raised while another approval window has focus is required to open a
required to be present, so that check cannot pass by observing nothing. That 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 last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157). [#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 `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. 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 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 suite), wallet creation through the UI, the Add Token screen, and the dApp round
round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain`
a closed approval window rejecting with EIP-1193 4001 — driven through the real rejected and then approved, `eth_sendTransaction`, and a closed approval window
content script, background page and approval windows. 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 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 **no npm dependencies at all**: it is built on global `fetch` and
@@ -1782,8 +1785,11 @@ view would leave a wallet one click from deletion.
plus a "+ Add token" button plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox, "UTC - Display: "Show tracked tokens with zero balance" checkbox, "UTC
Timestamps" checkbox, and a Theme selector (System / Light / Dark) Timestamps" checkbox, and a Theme selector (System / Light / Dark)
- Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching - Network: network selector (Ethereum Mainnet / Sepolia Testnet). The
resets the RPC and Blockscout endpoints to that network's defaults network changes only here, or when the user approves a site's request on
**NetworkApproval**; either way, switching restores the RPC and Blockscout
endpoints last used on that network, or that network's defaults if it has
none
- Ethereum RPC: endpoint URL input + "Save" button (validated against - Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved) `eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against - Blockscout API: endpoint URL input + "Save" button (validated against
@@ -2071,7 +2077,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 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 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 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**: - **Elements**:
- "Transaction Request" heading - "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing - Phishing warning banner (shown when the hostname is on the phishing
@@ -2162,6 +2171,40 @@ view would leave a wallet one click from deletion.
- Popup window closed without answering → the request is rejected with - Popup window closed without answering → the request is rejected with
EIP-1193 code 4001 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, restoring the RPC and Blockscout endpoints last used on
that network (or that network's defaults if it has none), 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`) #### StateRecovery (`state-recovery`)
- **When**: the stored profile fails `assertStateUsable()`. At open, that is - **When**: the stored profile fails `assertStateUsable()`. At open, that is
@@ -2456,6 +2499,8 @@ logged.
- Sign transactions requested by connected sites (`eth_sendTransaction`) - Sign transactions requested by connected sites (`eth_sendTransaction`)
- Sign messages (`personal_sign`, `eth_sign`) - Sign messages (`personal_sign`, `eth_sign`)
- Sign typed data (`eth_signTypedData_v4`, `eth_signTypedData`) - 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) - Human-readable transaction decoding (ERC-20, Uniswap Universal Router)
- ETH/USD and token/USD price display - ETH/USD and token/USD price display
- Configurable RPC endpoint and Blockscout API - Configurable RPC endpoint and Blockscout API
+14
View File
@@ -44,6 +44,20 @@ then continue tagging as milestones land.
# Completed Steps # 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/approvalWindow.test.js` checks that it
shows no screen until then. `tests/chainSwitchGate.test.js` and both browser
suites drive the prompt.
- 2026-10-07: Two contradictions between the documents and the code are resolved - 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 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 README's 1.0 non-goal now puts Ethereum mainnet and the Sepolia testnet in
+72 -19
View File
@@ -137,11 +137,12 @@ function releaseTxApprovalSlotFor(approvalId) {
} }
} }
// One site-connection approval and one sign approval per site at a time: a // One site-connection approval, one sign approval and one network-switch
// page that asks again before the user has answered is refused with the code // approval per site at a time: a page that asks again before the user has
// above instead of opening another window, so it cannot bury the user in // answered is refused with the code above instead of opening another window,
// prompts. The pending approval itself holds the place, so a caller must test // so it cannot bury the user in prompts. The pending approval itself holds the
// this and raise its approval with nothing awaited in between. // place, so a caller must test this and raise its approval with nothing awaited
// in between.
function findPendingApproval(origin, type) { function findPendingApproval(origin, type) {
return Object.values(pendingApprovals).find( return Object.values(pendingApprovals).find(
(approval) => approval.origin === origin && approval.type === type, (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 // 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 // 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 requesting page in EIP-1193 shape; a site-connection or
// approval answers the connection handler in its own. // network-switch approval answers its handler in the shape the popup's
// decision has, as a refusal.
function abandonedResult(approval, code, message) { function abandonedResult(approval, code, message) {
if (approval.type === "tx" || approval.type === "sign") { if (approval.type === "tx" || approval.type === "sign") {
return { error: { code, message } }; 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 // 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 // with the page's URL, so this is what separates the popup from the site the
// popup is being asked about. // 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 // 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 // site-connection or network-switch approval, and its disconnect is how that
// popup closed without one. // approval learns the popup closed without one.
// //
// The decision travels this port rather than a one-off runtime.sendMessage() // 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() // 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 // 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 // not be able to do it. Ungated, any page could clear the
// [TESTNET] banner under a user who believed they were on Sepolia. // [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 s = await getState();
const activeAddress = activeAddressOf(s); const activeAddress = activeAddressOf(s);
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
@@ -827,6 +853,27 @@ async function handleRpc(method, params, origin) {
} }
if (SUPPORTED_CHAIN_IDS.has(chainId)) { if (SUPPORTED_CHAIN_IDS.has(chainId)) {
const target = networkByChainId(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 // Read-modify-write against storage. The old path went through
// onChainSwitch(), which mutates the singleton and then persists // onChainSwitch(), which mutates the singleton and then persists
// every field of it — on an unloaded worker that wrote empty // 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 // which then fails retryably settles instead of waiting in a window that no
// longer exists. // longer exists.
// //
// A site-connection approval whose popup connected its port is not decided // A site-connection or network-switch approval whose popup connected its port
// here. That popup approves and closes in the same breath, and this event // is not decided here. That popup approves and closes in the same breath, and
// races the decision on a channel of its own — the same race the port exists // this event races the decision on a channel of its own — the same race the
// to end. Its port disconnect says the same thing this event does, in an order // port exists to end. Its port disconnect says the same thing this event does,
// that is defined, so the disconnect is left to say it. The window closing // in an order that is defined, so the disconnect is left to say it. The window
// before any port connected is the one case with nothing else to speak for it, // closing before any port connected is the one case with nothing else to speak
// and is rejected here so the dApp is not left waiting on a window that is // for it, and is rejected here so the dApp is not left waiting on a window that
// gone. // is gone.
if (windowsNs && windowsNs.onRemoved) { if (windowsNs && windowsNs.onRemoved) {
windowsNs.onRemoved.addListener((windowId) => { windowsNs.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) { for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue; if (approval.windowId !== windowId) continue;
const isSite = approval.type !== "tx" && approval.type !== "sign"; const decidedOnPort =
if (isSite && approval.portConnected) continue; approval.type !== "tx" && approval.type !== "sign";
if (decidedOnPort && approval.portConnected) continue;
const rejection = abandonedResult( const rejection = abandonedResult(
approval, approval,
APPROVAL_REJECTED_CODE, APPROVAL_REJECTED_CODE,
@@ -1446,6 +1494,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
resp.signParams = approval.signParams; resp.signParams = approval.signParams;
resp.approvedFrom = approval.approvedFrom; 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. // Flag if the requesting domain is on the phishing blocklist.
resp.isPhishingDomain = isPhishingDomain( resp.isPhishingDomain = isPhishingDomain(
extractHostname(approval.origin), extractHostname(approval.origin),
+48
View File
@@ -1741,6 +1741,54 @@
</div> </div>
</div> </div>
<!-- ============ NETWORK SWITCH APPROVAL ============ -->
<div id="view-approve-network" class="view hidden">
<h2 class="font-bold mb-2">Network Switch Request</h2>
<div
id="approve-network-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md"
>
⚠️ PHISHING WARNING: This site is on a known phishing
blocklist. Proceed with extreme caution.
</div>
<p class="mb-2">
<span id="approve-network-origin" class="font-bold"></span>
wants to switch the wallet's network.
</p>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Current network</div>
<div
id="approve-network-current"
class="text-xs font-bold"
></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Requested network</div>
<div
id="approve-network-requested"
class="text-xs font-bold"
></div>
</div>
<p class="mb-3 text-xs">
Switching changes the network for the whole wallet and for
every site, not only for this one.
</p>
<div class="flex justify-between">
<button
id="btn-approve-network"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Switch
</button>
<button
id="btn-reject-network"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Reject
</button>
</div>
</div>
<!-- ============ STATE RECOVERY ============ --> <!-- ============ STATE RECOVERY ============ -->
<!-- <!--
Shown when the stored profile cannot be read at all. Every Shown when the stored profile cannot be read at all. Every
+2 -2
View File
@@ -238,9 +238,9 @@ async function init() {
// rejection rather than an uncaught error — measured as still failing // rejection rather than an uncaught error — measured as still failing
// the run on both harnesses (Playwright `pageerror`, and the Firefox // the run on both harnesses (Playwright `pageerror`, and the Firefox
// driver's console-service drain), so nothing is lost by leaving it // driver's console-service drain), so nothing is lost by leaving it
// on that path. // on that path. show() puts the approval's own screen up once the
// background has described it.
approval.show(approvalId); approval.show(approvalId);
showView("approve-site");
return; return;
} }
+49 -14
View File
@@ -14,6 +14,7 @@ const {
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { const {
networkById,
networkByChainId, networkByChainId,
nativeCurrencyByChainId, nativeCurrencyByChainId,
} = require("../../shared/networks"); } = require("../../shared/networks");
@@ -328,9 +329,9 @@ function showTxApproval(details) {
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null; const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd); const usdStr = formatUsd(ethUsd);
// In the native currency of the network the transaction is for, which the // In the native currency of the network the transaction is for, which the
// Network line names, not the active network's: a site can switch the // Network line names, not the active network's: the active network can
// active network after this transaction is prepared and back before it is // change, in Settings or when the user approves a site's request, after
// signed. // this transaction is prepared and change back before it is signed.
$("approve-tx-value").textContent = $("approve-tx-value").textContent =
ethValueFormatted + ethValueFormatted +
" " + " " +
@@ -752,9 +753,29 @@ function showSignApproval(details) {
); );
} }
function showNetworkApproval(details) {
showPhishingWarning(
"approve-network-phishing-warning",
details.isPhishingDomain,
);
$("approve-network-origin").textContent = details.origin;
$("approve-network-current").textContent = networkById(
details.currentNetworkId,
).name;
$("approve-network-requested").textContent = networkById(
details.requestedNetworkId,
).name;
showView("approve-network");
}
// Awaited by nobody: the popup entry point calls this and moves on. It // Awaited by nobody: the popup entry point calls this and moves on. It
// therefore has to absorb its own failure, and a background that cannot // therefore has to absorb its own failure, and a background that cannot
// describe the approval is the same outcome as an approval that is gone. // describe the approval is the same outcome as an approval that is gone.
//
// Nothing is on screen until the background has described the approval, so
// the screen shown is always the one for the approval this window answers:
// the connection prompt's "Allow" and the network switch prompt's "Switch"
// answer on the same port, and either would approve the other.
async function show(id) { async function show(id) {
approvalId = id; approvalId = id;
approvalPort = runtimeApi().connect({ name: "approval:" + id }); approvalPort = runtimeApi().connect({ name: "approval:" + id });
@@ -778,6 +799,10 @@ async function show(id) {
showSignApproval(details); showSignApproval(details);
return; return;
} }
if (details.type === "network") {
showNetworkApproval(details);
return;
}
// Site connection approval // Site connection approval
showPhishingWarning( showPhishingWarning(
"approve-site-phishing-warning", "approve-site-phishing-warning",
@@ -787,6 +812,7 @@ async function show(id) {
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress); $("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
attachCopyHandlers("view-approve-site"); attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice; $("approve-remember").checked = state.rememberSiteChoice;
showView("approve-site");
} }
let approvalId = null; let approvalId = null;
@@ -866,20 +892,21 @@ function clearSignPassword() {
hideError("approve-sign-error"); hideError("approve-sign-error");
} }
// Answer a site-connection approval and close. The decision goes out on the // Answer a site-connection or network-switch approval and close. The decision
// approval port — see approvalPort above for why — and carries no approval id, // goes out on the approval port — see approvalPort above for why — and carries
// because the port name already names the approval the background will settle. // no approval id, because the port name already names the approval the
// The post is guarded because a throw must not cost the close: posting on a // background will settle. `remember` means something only for a site
// port whose background worker has been torn down throws, and the approval it // connection. The post is guarded because a throw must not cost the close:
// would have settled died with that worker, so the only thing left to do is // posting on a port whose background worker has been torn down throws, and the
// what the user asked for — go away. // approval it would have settled died with that worker, so the only thing left
function decideSite(approved) { // to do is what the user asked for — go away.
function decide(approved, remember) {
if (approvalPort) { if (approvalPort) {
try { try {
approvalPort.postMessage({ approvalPort.postMessage({
type: "AUTISTMASK_APPROVAL_DECISION", type: "AUTISTMASK_APPROVAL_DECISION",
approved, approved,
remember: $("approve-remember").checked, remember,
}); });
} catch { } catch {
// Nothing to report it to; the window closes either way. // Nothing to report it to; the window closes either way.
@@ -898,11 +925,19 @@ function init(_ctx) {
}); });
$("btn-approve").addEventListener("click", () => { $("btn-approve").addEventListener("click", () => {
decideSite(true); decide(true, $("approve-remember").checked);
}); });
$("btn-reject").addEventListener("click", () => { $("btn-reject").addEventListener("click", () => {
decideSite(false); decide(false, $("approve-remember").checked);
});
$("btn-approve-network").addEventListener("click", () => {
decide(true, false);
});
$("btn-reject-network").addEventListener("click", () => {
decide(false, false);
}); });
$("btn-approve-tx").addEventListener("click", async () => { $("btn-approve-tx").addEventListener("click", async () => {
+1
View File
@@ -51,6 +51,7 @@ const VIEWS = [
"approve-site", "approve-site",
"approve-tx", "approve-tx",
"approve-sign", "approve-sign",
"approve-network",
"export-privkey", "export-privkey",
"show-phrase", "show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile // Shown by src/popup/views/stateRecovery.js when the stored profile
+3 -2
View File
@@ -84,8 +84,9 @@ function show(tx) {
contractAddress: tx.contractAddress || null, contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and // The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's: // the fee are in its native currency, not the active network's:
// a site can switch the active network before a later popup // the active network can change, in Settings or when the user
// shows this screen again. // approves a site's request, before a later popup shows this
// screen again.
chainId: tx.chainId, chainId: tx.chainId,
}, },
}; };
+3 -2
View File
@@ -89,8 +89,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// A native amount, here and on the success and error screens, is in the // A native amount, here and on the success and error screens, is in the
// native currency of txInfo.chainId, the network the transaction was sent // native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: a site can switch the active network // on, not the active network's: the active network can change, in
// while this screen is open or before a later popup resumes it. // Settings or when the user approves a site's request, while this screen
// is open or before a later popup resumes it.
const symbol = const symbol =
txInfo.token === "ETH" txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId) ? nativeCurrencyByChainId(txInfo.chainId)
+24 -2
View File
@@ -1,5 +1,5 @@
// The connection, transaction and signature prompts name the site by its full // The connection, transaction, signature and network switch prompts name the
// origin, scheme and port included, not by its bare hostname // 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, // (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 // or on another port, of a host the user trusts over https must not raise a
// prompt that reads as that trusted site. // prompt that reads as that trusted site.
@@ -104,6 +104,7 @@ beforeEach(() => {
test("the connection prompt shows the origin", async () => { test("the connection prompt shows the origin", async () => {
await openApproval({}); await openApproval({});
expect(node("approve-origin").textContent).toBe(ORIGIN); 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 () => { 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); 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);
});
+47
View File
@@ -0,0 +1,47 @@
// The approval window shows no screen until the background has described the
// approval it answers (https://git.eeqj.de/sneak/AutistMask/issues/408). The
// connection prompt's "Allow" and the network switch prompt's "Switch" answer
// on the same port, so a window that showed the connection prompt while it
// waited could approve a network switch.
//
// Booted through the real popup entry point, which is where the connection
// prompt used to be put up before the background had answered.
const {
bootPopup,
cleanupPopup,
settle,
unversionedValidProfile,
} = require("./support/popupBoot");
afterEach(cleanupPopup);
test("the approval window shows no screen until the background describes the approval", async () => {
// The background's answer, held until the test gives it.
let answer = null;
const env = await bootPopup(unversionedValidProfile(), {
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") answer = reply;
},
},
});
expect(answer).not.toBeNull();
// No screen at all, the connection prompt included.
expect(env.visibleViews()).toEqual([]);
answer({
type: "network",
origin: "https://dapp.example",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
isPhishingDomain: false,
});
await settle();
expect(env.visibleViews()).toEqual(["approve-network"]);
expect(env.pageErrors).toEqual([]);
});
+14 -14
View File
@@ -25,6 +25,7 @@
const { Wallet } = require("ethers"); const { Wallet } = require("ethers");
const { networkById } = require("../src/shared/networks"); const { networkById } = require("../src/shared/networks");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY = 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 // 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 // attempt. Whatever endpoint it is then broadcast to has to be that same
// chain's — otherwise the wallet checks a transaction against Sepolia and // 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 // sends it to a mainnet node. The user can switch the network in Settings
// moment, including this one. // at any moment, including this one.
test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => { test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => {
const bg = loadWorker("sepolia"); const bg = loadWorker("sepolia");
@@ -264,9 +265,9 @@ describe("a chain switch under a transaction already committed to a chain", () =
populated(Number(SEPOLIA.networkVersion)), populated(Number(SEPOLIA.networkVersion)),
); );
// A connected site switches the chain while the attempt is running, // The user switches the network in Settings while the attempt is
// and the switch is committed to storage in full before the attempt // running: the popup writes the switched record to storage in full
// goes any further. // before the attempt goes any further.
// //
// It is fired from inside the attempt's SECOND state read, because // 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 // 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 // this to fire on, and the switch below runs after the attempt is
// done instead — which is the point. // done instead — which is the point.
let reads = 0; let reads = 0;
let switched = null; let switched = false;
const doSwitch = async () => { const doSwitch = () => {
switched = bg.rpc("wallet_switchEthereumChain", [ const record = bg.persisted();
{ chainId: MAINNET.chainId }, applyChainSwitchFields(record, MAINNET.id);
]); global.chrome.storage.write("autistmask", record);
await settle(); switched = true;
}; };
bg.setGetHook(async () => { bg.setGetHook(async () => {
reads++; reads++;
if (reads !== 2) return; if (reads !== 2) return;
bg.setGetHook(null); bg.setGetHook(null);
await doSwitch(); doSwitch();
}); });
const attempt = bg.send( const attempt = bg.send(
@@ -303,8 +304,7 @@ describe("a chain switch under a transaction already committed to a chain", () =
await settle(); await settle();
bg.setGetHook(null); bg.setGetHook(null);
if (!switched) await doSwitch(); if (!switched) doSwitch();
expect(switched.result()).toEqual({ result: null });
expect(bg.persisted().networkId).toBe("mainnet"); expect(bg.persisted().networkId).toBe("mainnet");
await settle(); 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 // 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 // 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 // to could clear the [TESTNET] banner under someone who believed they were
// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). The refusal // on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). Gated on the
// is asserted as a refusal to ACT — the state unmoved and no chainChanged // connection, a connected site could still move the wallet between mainnet and
// broadcast — because an error code alone would not distinguish a gate from // Sepolia without asking. Only the user switches the network: a connected
// a switch that happened and then reported a failure. // 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, // Every refusal is asserted as a refusal to ACT — the stored record unmoved
// which covers the popup's chain switch; this file covers the background's, // and no chainChanged broadcast — because an error code alone would not
// which goes through storage rather than the shared state singleton. // 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 { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
@@ -21,18 +27,23 @@ const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example"; const CONNECTED_ORIGIN = "https://dapp.example";
const STRANGER_ORIGIN = "https://stranger.example"; const STRANGER_ORIGIN = "https://stranger.example";
const EXT_URL = "chrome-extension://autistmask/";
const MAINNET = networkById("mainnet"); const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia"); const SEPOLIA = networkById("sepolia");
// The user's own node, so a switch that happens is visible as the loss of it. // 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"; 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() { function walletFixture() {
return [ return [
{ {
name: "Wallet 1", name: "Wallet 1",
type: "hd", type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }], addresses: [
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
],
}, },
]; ];
} }
@@ -47,10 +58,6 @@ afterEach(() => {
delete global.chrome; 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 // Load the background worker against stubbed browser APIs, with the real
// chain-switch and persistence modules behind it, and return the handles to // chain-switch and persistence modules behind it, and return the handles to
// drive it. // drive it.
@@ -91,30 +98,46 @@ function loadBackground() {
const storage = makeStorageStub({ autistmask: persisted }); const storage = makeStorageStub({ autistmask: persisted });
let messageListener = null; 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 // Every message the background pushed at a content script. chainChanged
// is what tells a page the wallet moved, so an ungated switch is visible // is what tells a page the wallet moved, so a switch is visible here as
// here as well as in the state. // well as in the state.
const toTabs = []; const toTabs = [];
global.chrome = { global.chrome = {
storage, storage,
runtime: { runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path, getURL: (path) => EXT_URL + path,
onMessage: { onMessage: {
addListener: (fn) => { addListener: (fn) => {
messageListener = fn; messageListener = fn;
}, },
}, },
onConnect: { addListener: () => {} }, onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
lastError: null, lastError: null,
}, },
windows: { windows: {
getLastFocused: (cb) => cb(null), getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }), create: (options, cb) => {
opened.push(options.url);
cb({ id: opened.length });
},
remove: (id, cb) => { remove: (id, cb) => {
if (cb) cb(); if (cb) cb();
}, },
onRemoved: { addListener: () => {} }, onRemoved: {
addListener: (fn) => {
windowRemovedListener = fn;
},
},
}, },
tabs: { tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]), query: (queryInfo, cb) => cb([{ id: 1 }]),
@@ -128,6 +151,8 @@ function loadBackground() {
require("../src/background/index"); 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) { async function switchChain(chainId, origin) {
let result = null; let result = null;
messageListener( messageListener(
@@ -142,56 +167,147 @@ function loadBackground() {
}, },
); );
await settle(); 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 { return {
switchChain, switchChain,
describePrompt,
answerPrompt,
closePrompt,
opened,
walletState: () => storage.read("autistmask"), walletState: () => storage.read("autistmask"),
chainChangedEvents: () => chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"), toTabs.filter((m) => m.eventName === "chainChanged"),
}; };
} }
const USER_REJECTED = { code: 4001, message: "User rejected the request." };
describe("wallet_switchEthereumChain is gated on the connection", () => { describe("wallet_switchEthereumChain is gated on the connection", () => {
test("an origin the wallet was never connected to is refused with 4100", async () => { test("an origin the wallet was never connected to is refused with 4100", async () => {
const bg = loadBackground(); 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(request.result().error).toEqual({
expect(result.result).toBeUndefined(); code: 4100,
message: "Unauthorized",
});
expect(request.result().result).toBeUndefined();
// The refusal has to be a refusal to ACT, not just an error string: // 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 // the wallet is still on mainnet, still on the user's own node, and
// no page was told the chain moved. // no page was told the chain moved. Nor was the user asked.
expect(bg.walletState().networkId).toBe("mainnet"); expect(bg.walletState()).toEqual(before);
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.chainChangedEvents()).toEqual([]); expect(bg.chainChangedEvents()).toEqual([]);
expect(bg.opened).toEqual([]);
}); });
test("an unconnected origin is refused even for the chain already active", async () => { test("an unconnected origin is refused even for the chain already active", async () => {
const bg = loadBackground(); 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 () => { test("an unconnected origin is refused before the unsupported-chain answer", async () => {
const bg = loadBackground(); 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);
});
}); });
test("a connected origin switches the chain", async () => { 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 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(request.result()).toEqual({ result: null });
expect(bg.walletState().networkId).toBe("sepolia"); 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([ expect(bg.chainChangedEvents()).toEqual([
{ {
type: "AUTISTMASK_EVENT", 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 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"); 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(); const bg = loadBackground();
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN); await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl); expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN); await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC); 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 // itself: the handler has to do it. tests/chainSwitchGate.test.js mocks the
// state module wholesale and tests/networkEndpoints.test.js always loads // state module wholesale and tests/networkEndpoints.test.js always loads
// first, so neither can see this. // 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 { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
@@ -90,6 +94,9 @@ function loadColdWorker(networkId) {
const storage = makeStorageStub({ autistmask: storedProfile(networkId) }); const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
let messageListener = null; let messageListener = null;
let connectListener = null;
// The URL of every approval window opened; the approval id is in it.
const opened = [];
const toTabs = []; const toTabs = [];
global.chrome = { global.chrome = {
@@ -101,12 +108,19 @@ function loadColdWorker(networkId) {
messageListener = fn; messageListener = fn;
}, },
}, },
onConnect: { addListener: () => {} }, onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
lastError: null, lastError: null,
}, },
windows: { windows: {
getLastFocused: (cb) => cb(null), getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }), create: (options, cb) => {
opened.push(options.url);
cb({ id: opened.length });
},
remove: (id, cb) => { remove: (id, cb) => {
if (cb) cb(); if (cb) cb();
}, },
@@ -124,6 +138,32 @@ function loadColdWorker(networkId) {
require("../src/background/index"); 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) { async function switchChain(chainId) {
let result = null; let result = null;
messageListener( messageListener(
@@ -138,6 +178,8 @@ function loadColdWorker(networkId) {
}, },
); );
await settle(); await settle();
approvePrompt();
await settle();
return result; return result;
} }
+154
View File
@@ -63,6 +63,7 @@ const {
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver"); const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp"); const { startDappServer } = require("./dapp");
const { STUB_COUNTERPARTY } = require("../network"); const { STUB_COUNTERPARTY } = require("../network");
const { NETWORKS } = require("../../../src/shared/networks");
const { const {
STATE_SCHEMA_VERSION, STATE_SCHEMA_VERSION,
stateProblem, 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( step(
"eth_sendTransaction shows the transaction and returns its hash", "eth_sendTransaction shows the transaction and returns its hash",
async (env) => { 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 // 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 // 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 // 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 // #btn-reject-sign, #btn-reject-tx — their disconnect leaves the approval
// pending, so a click that never landed leaves the dApp promise unsettled // pending, so a click that never landed leaves the dApp promise unsettled
// and the assertion after the call fails on its own. // and the assertion after the call fails on its own.
// #btn-approve — only a decision resolves the promise, and a swallowed click // #btn-approve, #btn-approve-network — only a decision resolves the promise,
// cannot produce settled === "resolved". // and a swallowed click cannot produce settled === "resolved".
// #btn-reject on the site prompt — NOT self-proving. A page that went away // #btn-reject on the site prompt, #btn-reject-network — NOT self-proving. A
// without the click landing disconnects the approval port, the background // page that went away without the click landing disconnects the approval
// settles that as 4001, and 4001 is exactly what assertUserRejection // port, the background settles that as 4001, and 4001 is exactly what
// accepts. Both call sites arm the click trace below and assert it. // 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 // A button that is missing or unclickable raises a different error, which is
// rethrown. // 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"); 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 // 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 // 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 // password — and required to be there at all, method by method, so the
+4 -4
View File
@@ -345,10 +345,10 @@ describe.each([
}); });
// A transaction's value and fee are in the native currency of the network the // A transaction's value and fee are in the native currency of the network the
// transaction is on, which need not be the active one. A site can switch the // transaction is on, which need not be the active one. The active network can
// active network after its transaction is prepared and back before it is // change, in Settings or when the user approves a site's request, after a
// signed, and a popup opened after a switch shows a sent or listed transaction // transaction is prepared and change back before it is signed, and a popup
// again. The wallet's balances follow the active network; these do not. // opened after a switch shows a sent or listed transaction again. The wallet's balances follow the active network; these do not.
describe.each([ describe.each([
["mainnet", "sepolia", "ETH"], ["mainnet", "sepolia", "ETH"],
["sepolia", "mainnet", "SepoliaETH"], ["sepolia", "mainnet", "SepoliaETH"],
+6 -1
View File
@@ -238,6 +238,10 @@ async function settle() {
* @param {object} [options] * @param {object} [options]
* @param {object} [options.storage] a storage stub from makeStorageStub(), for * @param {object} [options.storage] a storage stub from makeStorageStub(), for
* a test that needs to make writes fail or to watch the round trips. * a test that needs to make writes fail or to watch the round trips.
* @param {string} [options.search] the page URL's query string, such as
* "?approval=" and an id for the popup opened as an approval window.
* @param {object} [options.runtime] members of chrome.runtime that replace the
* stub's own, for a test that has to answer the background's messages.
* @returns {Promise<object>} handles onto the booted page. * @returns {Promise<object>} handles onto the booted page.
*/ */
async function bootPopup(stored, options) { async function bootPopup(stored, options) {
@@ -281,12 +285,13 @@ async function bootPopup(stored, options) {
sendMessage: jest.fn(async () => ({})), sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p, getURL: (p) => "chrome-extension://autistmask/" + p,
onMessage: { addListener: () => {} }, onMessage: { addListener: () => {} },
...(options && options.runtime),
}, },
}; };
globalThis.document = document; globalThis.document = document;
globalThis.window = { globalThis.window = {
location: { location: {
search: "", search: (options && options.search) || "",
href: "chrome-extension://autistmask/src/popup/index.html", href: "chrome-extension://autistmask/src/popup/index.html",
reload: () => reloads.push(Date.now()), reload: () => reloads.push(Date.now()),
}, },