fix: a cold worker answers eth_chainId and net_version from never-loaded state, reporting mainnet to a page whose user is on Sepolia #317

Closed
opened 2026-08-20 12:26:57 +02:00 by clawbot · 2 comments
Collaborator

Found while reworking #313. The read-side twin of #316, same root cause, no data loss.

eth_chainId (src/background/index.js:667) and net_version (:671) answer from currentNetwork(), which reads the module-level state singleton. The MV3 service worker never populates that singleton, so a worker revived by the page's own message holds DEFAULT_STATE and reports mainnet0x1 / 1 — to a page whose user is actually on Sepolia.

Consequence

Chain confusion in the page's direction: a dApp asks what chain the wallet is on, is told mainnet, and builds its interaction accordingly while the user's wallet is on a testnet. The user's own signing path is not directly at risk — chainId is verified against local state before signing, and the transaction path already calls loadState() at src/background/index.js:1286 — so this misleads the page rather than mis-signing the transaction. It is filed against 1.0.0 because chain confusion is the class this milestone is closing, and the fix is one line.

The write-side of the same defect destroys all wallets and is tracked separately as #316, fixed in #313.

Audit context from that rework, worth keeping: only four background call sites reach saveState(), and the chain switch was the only defective one — the remembered-approval (:588/:595), remembered-denial (:602/:609) and backgroundRefresh (:1052/:1064) paths each already loadState() before mutating. So the write side is now believed complete; this is the remaining read side.

Definition of done

  • eth_chainId and net_version answer from loaded state, not from the unpopulated singleton.
  • Any other background read path answering from currentNetwork() or the singleton is audited and named in the PR body.
  • Test: a cold worker with no prior loadState() reports the stored chain, not the default. Must fail against current head and pass after.
  • make check green.
Found while reworking https://git.eeqj.de/sneak/AutistMask/pulls/313. The read-side twin of https://git.eeqj.de/sneak/AutistMask/issues/316, same root cause, no data loss. `eth_chainId` (`src/background/index.js:667`) and `net_version` (`:671`) answer from `currentNetwork()`, which reads the module-level `state` singleton. The MV3 service worker never populates that singleton, so a worker revived by the page's own message holds `DEFAULT_STATE` and reports **mainnet** — `0x1` / `1` — to a page whose user is actually on Sepolia. ## Consequence Chain confusion in the page's direction: a dApp asks what chain the wallet is on, is told mainnet, and builds its interaction accordingly while the user's wallet is on a testnet. The user's own signing path is not directly at risk — `chainId` is verified against local state before signing, and the transaction path already calls `loadState()` at `src/background/index.js:1286` — so this misleads the page rather than mis-signing the transaction. It is filed against 1.0.0 because chain confusion is the class this milestone is closing, and the fix is one line. The write-side of the same defect destroys all wallets and is tracked separately as https://git.eeqj.de/sneak/AutistMask/issues/316, fixed in https://git.eeqj.de/sneak/AutistMask/pulls/313. Audit context from that rework, worth keeping: only four background call sites reach `saveState()`, and the chain switch was the only defective one — the remembered-approval (`:588`/`:595`), remembered-denial (`:602`/`:609`) and `backgroundRefresh` (`:1052`/`:1064`) paths each already `loadState()` before mutating. So the write side is now believed complete; this is the remaining read side. ## Definition of done - [ ] `eth_chainId` and `net_version` answer from loaded state, not from the unpopulated singleton. - [ ] Any other background **read** path answering from `currentNetwork()` or the singleton is audited and named in the PR body. - [ ] Test: a cold worker with no prior `loadState()` reports the **stored** chain, not the default. Must fail against current head and pass after. - [ ] `make check` green.
clawbot added this to the 1.0.0 milestone 2026-08-20 12:26:57 +02:00
Author
Collaborator

Fixed in #319 (base next).

eth_chainId and net_version are now one branch behind a single await loadState(), rather than two loads for the same read of the same value. Same placement idiom as the chain-switch handler and the transaction path.

Read-side audit: the other singleton reads in the background are wallet_switchEthereumChain, the transaction verify/broadcast path and backgroundRefresh, and all three already load first; every other handler answers from storage per call through getState(). One stale read is named but not fixed, being a different handler rather than the same one-line shape — handleSendTransaction calls getProvider() with no network name (src/background/index.js:934), so src/shared/balances.js:26 falls back to the unloaded singleton for ethers' static network hint and a cold-worker send on Sepolia is prepared with a mainnet chainId. It is caught before broadcast by the verify step against loaded state, so it fails the send rather than sending on the wrong chain. Full table in the PR body.

Verified: tests/coldWorkerChainId.test.js drives the real state module over write-retaining storage and never loads state itself. Reverting only src/background/index.js to next gives 3 failed / 775 passed — exactly the three cases that read the chain on a cold worker, each answering 0x1/1 instead of 0xaa36a7/11155111. With the fix make check is green: 36 suites / 778 tests, test-verify-build 18 cases, check-censored 143 files, and lint executed uncached in the pinned container.

Fixed in [#319](https://git.eeqj.de/sneak/AutistMask/pulls/319) (base `next`). `eth_chainId` and `net_version` are now one branch behind a single `await loadState()`, rather than two loads for the same read of the same value. Same placement idiom as the chain-switch handler and the transaction path. Read-side audit: the other singleton reads in the background are `wallet_switchEthereumChain`, the transaction verify/broadcast path and `backgroundRefresh`, and all three already load first; every other handler answers from storage per call through `getState()`. One stale read is named but not fixed, being a different handler rather than the same one-line shape — `handleSendTransaction` calls `getProvider()` with no network name (`src/background/index.js:934`), so `src/shared/balances.js:26` falls back to the unloaded singleton for ethers' static network hint and a cold-worker send on Sepolia is prepared with a mainnet `chainId`. It is caught before broadcast by the verify step against loaded state, so it fails the send rather than sending on the wrong chain. Full table in the PR body. Verified: `tests/coldWorkerChainId.test.js` drives the real state module over write-retaining storage and never loads state itself. Reverting only `src/background/index.js` to `next` gives 3 failed / 775 passed — exactly the three cases that read the chain on a cold worker, each answering `0x1`/`1` instead of `0xaa36a7`/`11155111`. With the fix `make check` is green: 36 suites / 778 tests, `test-verify-build` 18 cases, `check-censored` 143 files, and lint executed uncached in the pinned container.
Author
Collaborator

Correction to the comment above: the await loadState() approach it describes was rejected in review and is not what #319 now does.

Loading the singleton here introduced a new defect. These two methods are ungated and the injected provider sends eth_chainId on every page load, so any page could land a loadState() inside an in-flight backgroundRefresh() — which detaches the address objects refreshBalances() is mutating in place, persisting the pre-refresh balances while still stamping lastBalanceRefresh.

Both now answer from getState() instead — the per-call detached storage read the other read handlers use — so the read touches no shared state at all. The audit and the verification are unchanged in substance; details in the PR body.

Correction to the comment above: the `await loadState()` approach it describes was rejected in review and is not what [#319](https://git.eeqj.de/sneak/AutistMask/pulls/319) now does. Loading the singleton here introduced a new defect. These two methods are ungated and the injected provider sends `eth_chainId` on every page load, so any page could land a `loadState()` inside an in-flight `backgroundRefresh()` — which detaches the address objects `refreshBalances()` is mutating in place, persisting the pre-refresh balances while still stamping `lastBalanceRefresh`. Both now answer from `getState()` instead — the per-call detached storage read the other read handlers use — so the read touches no shared state at all. The audit and the verification are unchanged in substance; details in the PR body.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#317