fix: gate the chain switch and remember endpoints per network (closes #308)
All checks were successful
check / check (push) Successful in 29s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 22s

wallet_switchEthereumChain was answered for any origin at all, with no
connection check and no prompt, so any page could move the active chain and
clear the [TESTNET] banner under a user who believed they were on Sepolia. It
now takes the same allowedSites check the signing methods take and returns 4100
for an unconnected origin.

The handler also awaits loadState() before it reads or moves the network. The
MV3 worker populates nothing at module scope, so a worker revived by the page's
own message held DEFAULT_STATE: the same-chain check compared against the wrong
network, and the save wrote empty wallets, empty allowedSites and default
endpoints over the user's stored profile, destroying every wallet in the
extension. Also fixes #316.

Endpoints are now remembered per network in a persisted networkEndpoints map,
so a user running a local or private node no longer loses that url permanently
to a public endpoint on every switch. A stored map must be an actual object; a
primitive previously survived the load and made every switch fall back to the
public default with no self-healing.

Verified failing first: dropping only the added loadState() fails exactly the
two cold-worker cases; reverting only the type guard fails exactly the string
and number cases. Reverting both source files to next gives 12 failed / 751
passed.
This commit was merged in pull request #313.
This commit is contained in:
2026-08-20 12:42:01 +02:00
parent 2f80a9bdb4
commit 6350aad591
8 changed files with 741 additions and 4 deletions

View File

@@ -14,6 +14,11 @@ const DEFAULT_STATE = {
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
// Endpoints remembered per network: { [networkId]: { rpcUrl,
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
// of the active network; this is what the others are restored from
// when the active network changes. See onChainSwitch().
networkEndpoints: {},
lastBalanceRefresh: 0,
activeAddress: null,
allowedSites: {},
@@ -34,6 +39,9 @@ const DEFAULT_STATE = {
const state = {
...DEFAULT_STATE,
// Its own object, not the one DEFAULT_STATE holds: onChainSwitch()
// mutates this map in place, and a spread copies the reference.
networkEndpoints: {},
currentView: null,
selectedWallet: null,
selectedAddress: null,
@@ -88,6 +96,7 @@ async function saveState() {
networkId: state.networkId,
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
networkEndpoints: state.networkEndpoints,
lastBalanceRefresh: state.lastBalanceRefresh,
activeAddress: state.activeAddress,
allowedSites: state.allowedSites,
@@ -128,6 +137,30 @@ async function loadState() {
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
state.blockscoutUrl =
saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// An actual object is required, not merely a truthy non-array: the
// code below and onChainSwitch() index and ASSIGN INTO this value,
// and assigning a property to a string or a number is a silent no-op
// in sloppy mode. A stored primitive would therefore be re-persisted
// unchanged forever, and every switch would fall back to the network
// default — the endpoint loss this map exists to prevent, with no
// self-healing. The allowedSites/deniedSites guards below are only
// read from, which is why they can be looser.
state.networkEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
// A profile written before this map existed carries exactly one pair
// of endpoints, belonging to whatever network it was last on. Adopt
// it as that network's remembered pair, so a custom endpoint set on
// the old build is not lost by the first switch away and back.
if (!state.networkEndpoints[state.networkId]) {
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
}
state.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
state.activeAddress = saved.activeAddress || null;
state.allowedSites =