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

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

Model: opus-5-5
This commit is contained in:
2026-10-07 22:02:28 +00:00
parent ca18beb97f
commit dae958bec3
13 changed files with 841 additions and 111 deletions
+48
View File
@@ -1741,6 +1741,54 @@
</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 ============ -->
<!--
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
// the run on both harnesses (Playwright `pageerror`, and the Firefox
// 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);
showView("approve-site");
return;
}
+46 -11
View File
@@ -14,6 +14,7 @@ const {
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const {
networkById,
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
@@ -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
// 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.
//
// 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) {
approvalId = id;
approvalPort = runtimeApi().connect({ name: "approval:" + id });
@@ -778,6 +799,10 @@ async function show(id) {
showSignApproval(details);
return;
}
if (details.type === "network") {
showNetworkApproval(details);
return;
}
// Site connection approval
showPhishingWarning(
"approve-site-phishing-warning",
@@ -787,6 +812,7 @@ async function show(id) {
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice;
showView("approve-site");
}
let approvalId = null;
@@ -866,20 +892,21 @@ function clearSignPassword() {
hideError("approve-sign-error");
}
// Answer a site-connection approval and close. The decision goes out on the
// approval port — see approvalPort above for why — and carries no approval id,
// because the port name already names the approval the background will settle.
// The post is guarded because a throw must not cost the close: posting on a
// port whose background worker has been torn down throws, and the approval it
// would have settled died with that worker, so the only thing left to do is
// what the user asked for — go away.
function decideSite(approved) {
// Answer a site-connection or network-switch approval and close. The decision
// goes out on the approval port — see approvalPort above for why — and carries
// no approval id, because the port name already names the approval the
// background will settle. `remember` means something only for a site
// connection. The post is guarded because a throw must not cost the close:
// posting on a port whose background worker has been torn down throws, and the
// approval it would have settled died with that worker, so the only thing left
// to do is what the user asked for — go away.
function decide(approved, remember) {
if (approvalPort) {
try {
approvalPort.postMessage({
type: "AUTISTMASK_APPROVAL_DECISION",
approved,
remember: $("approve-remember").checked,
remember,
});
} catch {
// Nothing to report it to; the window closes either way.
@@ -898,11 +925,19 @@ function init(_ctx) {
});
$("btn-approve").addEventListener("click", () => {
decideSite(true);
decide(true, $("approve-remember").checked);
});
$("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 () => {
+1
View File
@@ -51,6 +51,7 @@ const VIEWS = [
"approve-site",
"approve-tx",
"approve-sign",
"approve-network",
"export-privkey",
"show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile