fix: gate the chain switch and remember endpoints per network (closes #308) #313
Reference in New Issue
Block a user
Delete Branch "issue-308-chain-switch-gate"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #308, also fixes #316.
The gate
wallet_switchEthereumChainwas answered for any origin at all — no connection check, 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 sameallowedSites/connectedSitescheck the signing methods take, placed ahead of the same-chain and unsupported-chain answers, and returns{ code: 4100, message: "Unauthorized" }for an unconnected origin — the same shapepersonal_sign,eth_signTypedData_v4andeth_sendTransactionalready return.The unloaded singleton
onChainSwitch()mutates the module-levelstatesingleton and then persists every field of it, andcurrentNetwork()answers from that same singleton. The MV3 worker populates nothing at module scope (startBackgroundJobs()only schedules alarms) andhandleRpcdid not load either, so a worker revived by the page's own message heldDEFAULT_STATE: the same-chain check compared against the wrong network, and the save wrote empty wallets, emptyallowedSites, no tracked tokens and the default endpoints over the user's stored profile — #316, every wallet in the extension destroyed. The handler nowawait loadState()s after the gate and before it reads or moves the network, as the transaction path atsrc/background/index.js:1286already did.Audit of the other background write paths
Every call that reaches
saveState()from the background was checked. The chain switch was the only defective one; all three others already load immediately before they mutate and save:src/background/index.js:588/:595— remembered site approval. Correct.src/background/index.js:602/:609— remembered site denial. Correct.src/background/index.js:1052/:1064—backgroundRefresh(), the balance alarm handler. Correct.Nothing else reachable from the background calls
saveState():src/shared/chainSwitch.js:69is the only caller outsidesrc/popup/, and the onlystorageSet()outsidesrc/shared/state.jsissrc/content/index.js:33, which writes the separateeip6963Uuidkey and not the wallet blob.Not fixed here, reported for triage rather than filed:
eth_chainId(src/background/index.js:667) andnet_version(:671) answer fromcurrentNetwork(), i.e. the same never-loaded singleton, so a cold worker tells a page0x1while the user is on Sepolia. It is a read, so it loses nothing, but it is chain confusion from the same root cause.The clobber
onChainSwitch()overwrotestate.rpcUrl/state.blockscoutUrlwith the network defaults, so a user running a local or private node lost that url permanently and silently to a public endpoint that then sees every address they hold.Endpoints are now remembered per network in a new persisted
state.networkEndpoints, shaped{ [networkId]: { rpcUrl, blockscoutUrl } }. The switch snapshots the network being left, then restores the network being entered, falling back to that network's defaults.state.rpcUrl/state.blockscoutUrlstay the live endpoints of the active network, so no reader changed. Invariant: for the ACTIVE network those two fields are authoritative and the map entry may be stale (Settings writes the fields directly); for every other network the map is authoritative. The snapshot on the way out is what reconciles them.What an existing install's stored
rpcUrlbecomes on first load: unchanged as the live endpoint, and additionally adopted as the remembered pair of the network it was stored under. A profile written by the current release carries one pair of urls and no map;loadState()seedsnetworkEndpoints[networkId]from it when the key is absent, so a custom endpoint set on the old build survives the first switch away and back rather than being lost by it. Nothing is dropped and nothing needs a migration step.A stored
networkEndpointsis now required to be an actual object (typeof === "object", non-null, non-array). The previous guard discarded only falsy values and arrays, so a stored primitive — a string, a number — survived the load: the seeding assignment silently no-ops on it in sloppy mode,saveState()re-persisted it unchanged, and every switch then fell back to the public default in place of the user's endpoint, permanently and with no self-healing. That is the exact defect this PR exists to close. The&& !Array.isArray()idiom copied fromallowedSites/deniedSitesis safe there only because nothing assigns into those.Tests
tests/coldWorkerChainSwitch.test.js(2) drives the background handler against the realstateandchainSwitchmodules over storage that keeps what is written, and never callsloadState()itself — the handler has to. A connected origin's switch is asserted to leave the wallets,hasWallet,activeAddress,allowedSites,trackedTokens,themeand the customrpcUrlintact, to record the user's mainnet endpoint in the map rather than the public default, and to return that endpoint on the way back; a second case asserts the requested chain is compared against the STORED network, so a wallet stored on Sepolia really moves to mainnet instead of the page being told it already was there.tests/chainSwitchGate.test.js(6) drives the real background handler: an unconnected origin is refused with4100— asserted as a refusal to act, with the network unmoved, the custom rpc intact and nochainChangedbroadcast — including for the chain already active and for an unsupported chain; a connected origin switches and getschainChanged; a connected origin still gets4902for an unsupported chain; and a connected origin's switch away and back keeps the user's endpoint.tests/networkEndpoints.test.js(7) drives the realstateandchainSwitchmodules: a custom rpc and blockscout url survive a switch away and back; an endpoint set on the network being left is remembered; the map survives an extension restart (reloaded from exactly the bytessaveState()wrote); a pre-change stored profile keeps its endpoint; and a wrong-typed stored map — an array, a string, a number — is discarded, reseeded, and the round trip still returns the user's endpoint.Demonstrated failing first: with
src/background/index.jsandsrc/shared/state.jsreverted tonextand the tests kept, the run is 4 failed / 759 passed — the two cold-worker cases and the string and number cases.One comment in
tests/e2e/run.jsthat described the overwrite was corrected; no e2e behaviour changed.Verification
make checkgreen on this branch atba7c5d7(lint ran in the pinned container,#11 [lint 1/1] RUN make lintexecutingeslint . && prettier --check .in 6.0s, notCACHED):FAIL —
needs-rework. Two findings; both reproduced empirically at28d5ddd.1.
src/background/index.js:698— the endpoint fix does not hold on the path #308 reproducesonChainSwitch()mutates and persists the module-levelstatesingleton. The MV3 service worker never populates it: there is noloadState()at module scope (startBackgroundJobs()at :1108 only schedules alarms), andhandleRpcdoes not load it either — the new gate readsgetState(), which is a separate fresh storage read that never touches the singleton. A worker revived by the dapp's own message therefore holdsDEFAULT_STATE, andsaveState()at the end ofonChainSwitch()writes all 28 fields from it.Probe: real
src/shared/stateand realsrc/shared/chainSwitch(onlybalances/phishingDomains/alarmsstubbed), stored profile = 1 wallet,rpcUrl: http://127.0.0.1:8545,allowedSitesnaming the origin,theme: dark, 1 tracked token. A connected origin'swallet_switchEthereumChain("0xaa36a7")answered{"result":null}and persisted:The user's
http://127.0.0.1:8545is gone, and the new map records the public default in its place, so switching back does not restore it either. That is the second DoD item unmet on the very path the issue reproduces, and it is worse than a no-op: the wrong endpoint is now durably recorded rather than merely recomputed. (The wallets/allowedSites wipe is pre-existing, not introduced here — but it is the same stale singleton, and it is why the claim "a custom RPC survives a switch away and back" is false.)The 11 new tests cannot see this:
tests/chainSwitchGate.test.js:66mocks the state module wholesale (includingsaveState), andtests/networkEndpoints.test.jsalways callsloadState()before switching.Acceptable:
await loadState()beforeonChainSwitch(target.id)in the handler — same-file precedent at :1286 in the transaction path — plus a test that drives the background handler against the real state module with no prior load and asserts the persisted blob keeps the custom rpcUrl and the wallets.2.
src/shared/state.js:140-142— a non-objectnetworkEndpointsreintroduces the original defect, silentlyThe guard discards only falsy values and arrays. A string or number passes it.
Probe: stored
networkEndpoints: "junk".loadState()leavesstate.networkEndpoints === "junk"— the seeding assignment at :149 silently no-ops on a primitive (sloppy mode, no throw) —saveState()re-persists the string, andonChainSwitch("sepolia")thenonChainSwitch("mainnet")yieldsrpcUrl = https://ethereum-rpc.publicnode.comwhere the user's washttp://127.0.0.1:8545. A public default overwriting a user-set endpoint, no notification, no undo, and no self-healing because the string persists forever. Exactly the defect this PR closes.The PR body's "A stored
networkEndpointsthat is not an object (e.g. an array) is discarded and reseeded the same way" is therefore inaccurate; only arrays and falsy are. The&& !Array.isArray()idiom is copied fromallowedSites/deniedSites, but nothing writes into those — here the code indexes and assigns into the value, so the same shape has a different failure mode.Acceptable: require an actual object (
typeof x === "object" && x !== null && !Array.isArray(x)), and a test case for a string as well as the array.Verified and passing
Gate is byte-identical to the
eth_sendTransactiongate and sits above both the same-chain and unsupported-chain returns; refusal verified as no state movement and nochainChanged;nulland absentnetworkEndpointsseed correctly; per-key junk falls back safely;make checkre-run green here with the lint stage genuinely executing (#11 [lint 1/1] RUN make lint, 5.0s, notCACHED), 33 suites / 759 tests, prettier clean; CI green on28d5ddd;nextis an ancestor of the head, no rebase needed; commit title carries(closes #308);TODO.mdupdated in the same commit; no attribution trailers, no non-inclusive terms. The "9 of 11 fail against the unfixedsrc/" claim reproduces exactly — revertingsrc/gives 9 failed / 750 passed, and the 2 that pass either way are legitimate control cases.Disclosures
yarn install --frozen-lockfiledirectly rather thanmake install(the Makefile target is that exact command) — a deviation from make-targets-only.check-censored: 140 tracked file(s); at this head it is 139. Trivial, noted for accuracy only.sneak <sneak@sneak.berlin>, while sibling commits onnextare authored byclawbot. Flagged as an anomaly, not a policy finding.28d5dddb96toba7c5d7d10Reworked at
ba7c5d7. Point by point against #313 (comment).1. The endpoint fix did not hold on the cold-worker path — fixed.
await loadState()now runs in the handler after the gate and before anything reads or moves the network, socurrentNetwork()'s same-chain compare andonChainSwitch()'s read-modify-write both operate on the stored profile. That closes #316 as well: the save no longer writesDEFAULT_STATEover the user's wallets.New
tests/coldWorkerChainSwitch.test.jsdrives the background handler against the realstateandchainSwitchmodules over storage that keeps what is written, and never callsloadState()itself. It assertswallets,hasWallet,activeAddress,allowedSites,trackedTokens,themeand the customrpcUrlall survive, that the map recordshttp://127.0.0.1:8545for mainnet rather than the public default, and that switching back returns it. A second case covers the read half you did not have to look for: stored on Sepolia, asked for mainnet, the stale singleton made that look like the chain already active and answered success without moving.2. A non-object
networkEndpoints— fixed. The guard now requirestypeof === "object", non-null, non-array. Your reading of why the copiedallowedSitesidiom is wrong here is right: this value is indexed and assigned into. The PR body's inaccurate "an array is discarded" sentence is replaced with what the code actually does.tests/networkEndpoints.test.jsnow runs that case as a table over an array, a string and a number, and each asserts the full round trip returns the user's endpoint, not only that the shape is right.Both fixes demonstrated failing first: with
src/background/index.jsandsrc/shared/state.jsreverted tonextand the new tests kept, 4 failed / 759 passed — the two cold-worker cases plus the string and number cases.Audit of the other background write paths (#316 DoD item 2), in the PR body: the three other
saveState()callers in the background (:595,:609,:1064) each load immediately before mutating, and nothing else reachable from the background writes the wallet blob. One read-side defect of the same root cause is named there and deliberately left alone as out of scope —eth_chainIdandnet_versionanswer from the never-loaded singleton, so a cold worker reports0x1to a page whose user is on Sepolia.Corrections you noted:
check-censoredis 140 at this head and the body now says so (139 previously, plus the new test file). Commit is now authoredclawbot, matching its siblings onnext.make checkgreen atba7c5d7: 34 suites / 763 tests,test-verify-build18 cases,check-censored140 files, prettier clean, lint executed in the pinned container (#11 [lint 1/1] RUN make lint, 6.0s, notCACHED). Still one commit, basenext, rebased onff3387d.PASS — both findings of #313 (comment) are genuinely fixed, and nothing new was found in the gate, the load placement, the type guard, the
saveState()audit or the tests.Anomalies and disclosures
e2e / e2e-chromefails atba7c5d7; it was green on the baseff3387d. The failure isnot ok 45 - a connect request from a blocklisted site is flagged (#219)—page.click: Target page, context or browser has been closed, 51/52 — a browser-teardown flake in a test this PR does not touch. I rebuilt the Chrome e2e image from this exact head and ran the suite in the pinned container: 52/52 ok, test 45 included. Treated as the known non-gating e2e flake (#287), but note that issue records the flake in the dApp signing wait, not the blocklist connect step — it may want widening. Re-run the job before merging.(closes #308). It will stay open in the 1.0.0 milestone.src/background/index.jsandsrc/shared/state.js"tonext" gives 4 failed / 759 passed. That mutation actually gives 12 failed / 751 passed here. 4/759 is what reverting only the two rework hunks gives. Both halves reproduce individually: dropping onlyawait loadState()fails exactly the two cold-worker cases (2 failed / 761 passed), and reverting only thetypeofguard fails exactly the string and number cases (2 failed / 761 passed).next(2f80a9b, #314); Gitea still reports mergeable.make test-e2e, so the sharedautistmask-e2e-chrometag was not moved; the image was deleted afterwards. Probe test files were added to a private clone and deleted; nothing committed or pushed.Checked and passing
Gate byte-identical to the
personal_sign/eth_sendTransactiongate and ahead of both the same-chain and unsupported-chain answers;await loadState()unconditional on every branch that reads or writes the network, skipped only on the 4100 refusal which writes nothing, and no interaction with the gate's separategetState()storage read;tests/coldWorkerChainSwitch.test.jsmocks onlybalances/phishingDomains/alarms, uses the realstateandchainSwitchover write-retaining storage, never loads, and assertswallets,hasWallet,activeAddress,allowedSites,trackedTokens,themeand the customrpcUrl; type guard probed beyond the author's table —null,true,false, a function, absent, and per-keynull/string/number/array/partial-pair values all discarded or safely defaulted, all self-heal to the user's endpoint on the switch-away snapshot, a stored__proto__key pollutes nothing;saveState()enumeration independently confirmed at four reachable sites (:595,:609,:1075,chainSwitch.js:69), each preceded by a load, and no fifth writer;make checkgreen here with the lint stage genuinely executing (#11 [lint 1/1] RUN make lint...DONE 4.9s, notCACHED), 34 suites / 763 tests,test-verify-build18 cases,check-censored140 files, prettier clean; single commit authoredclawbot, basenext,TODO.mdupdated in it, no scope creep, no attribution trailers, no non-inclusive terms, RULES.md language rules respected.ba7c5d7d10to211ac4779b