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
This commit was merged in pull request #501.
This commit is contained in:
+72
-19
@@ -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),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+49
-14
@@ -14,6 +14,7 @@ const {
|
||||
} = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const {
|
||||
networkById,
|
||||
networkByChainId,
|
||||
nativeCurrencyByChainId,
|
||||
} = require("../../shared/networks");
|
||||
@@ -328,9 +329,9 @@ function showTxApproval(details) {
|
||||
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
|
||||
const usdStr = formatUsd(ethUsd);
|
||||
// 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
|
||||
// active network after this transaction is prepared and back before it is
|
||||
// signed.
|
||||
// Network line names, not the active network's: the active network can
|
||||
// change, in Settings or when the user approves a site's request, after
|
||||
// this transaction is prepared and change back before it is signed.
|
||||
$("approve-tx-value").textContent =
|
||||
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
|
||||
// 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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,8 +84,9 @@ function show(tx) {
|
||||
contractAddress: tx.contractAddress || null,
|
||||
// The network the history entry was read from. The type line and
|
||||
// the fee are in its native currency, not the active network's:
|
||||
// a site can switch the active network before a later popup
|
||||
// shows this screen again.
|
||||
// the active network can change, in Settings or when the user
|
||||
// approves a site's request, before a later popup shows this
|
||||
// screen again.
|
||||
chainId: tx.chainId,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -89,8 +89,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
|
||||
// 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
|
||||
// on, not the active network's: a site can switch the active network
|
||||
// while this screen is open or before a later popup resumes it.
|
||||
// on, not the active network's: the active network can change, in
|
||||
// Settings or when the user approves a site's request, while this screen
|
||||
// is open or before a later popup resumes it.
|
||||
const symbol =
|
||||
txInfo.token === "ETH"
|
||||
? nativeCurrencyByChainId(txInfo.chainId)
|
||||
|
||||
Reference in New Issue
Block a user