fix: answer eth_chainId and net_version from loaded state (closes #317)
All checks were successful
check / check (push) Successful in 28s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 22s

Both methods answered from currentNetwork(), which reads the module-level state
singleton, and nothing populates that at module scope. A service worker revived
by the page's own message therefore held DEFAULT_STATE and reported mainnet
0x1 / 1 to a page whose user was on Sepolia, so a dApp asking which chain the
wallet is on built its interaction for the wrong one. Neither method is gated on
a connection, so any page got the stale answer.

Both now answer from getState() — the per-call storage read that returns a
detached object, which every other read handler in this file already uses —
rather than by loading the singleton. Loading it would fix the stale answer but
introduce a worse defect on the same path: loadState() replaces state.wallets
wholesale, and backgroundRefresh() hands the singleton's wallets to
refreshBalances(), which mutates those address objects in place across a network
round trip before stamping lastBalanceRefresh and saving. A load landing inside
that round trip detaches the objects being mutated, so the save persists the
pre-refresh balances while still marking the refresh done, and the freshness
guard then suppresses the redo for half the alarm period. These two methods are
reachable by any page, and the injected provider sends eth_chainId on every page
load, so an ordinary page load would be enough to drop a refresh and a polling
page could keep any refresh from ever persisting. getState() reads storage once
per call and mutates nothing shared. networkById(undefined) already falls back
to mainnet, which is the answer a profile with no stored networkId had before.

Read-side audit of the background, which the fix was the occasion for: the other
singleton reads 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 remains
and is deliberately not fixed here, being a different handler rather than the
same one-line shape: handleSendTransaction calls getProvider() with no network
name, so balances.js falls back to the same unloaded singleton for ethers'
static network hint, and a cold-worker send on Sepolia is prepared with a
mainnet hint. It is caught later — the artifact is verified against the loaded
chain before broadcast — so it fails the send rather than sending on the wrong
chain.

The test's storage stub structured-clones in both directions, as the real
chrome.storage.local does. A stub that hands back the live stored object aliases
it into whatever reads it, which makes an in-place mutation of a detached copy
look as though it reached storage and hides this entire class of defect: with an
aliasing get, the whole suite passes against the loadState() version above.

Verified failing first, two mutations, each with the rest of the tree untouched.
Reverting the handler to the singleton read gives 3 failed / 791 passed: exactly
the three cases that read the chain on a cold worker, each answering 0x1 / 1
instead of 0xaa36a7 / 11155111. Replacing getState() with await loadState() plus
currentNetwork() gives 1 failed / 793 passed: the new mid-refresh case, with the
persisted balance "0" where the refresh wrote "1.5". With the fix, 794 passed /
37 suites, and lint ran uncached in the pinned container.
This commit is contained in:
2026-08-20 10:47:18 +00:00
parent 50078b3566
commit 5c4a671d4a
3 changed files with 303 additions and 7 deletions

18
TODO.md
View File

@@ -44,6 +44,24 @@ but the review is broader than any of them.
# Completed Steps
- 2026-08-20: A page asking which chain the wallet is on is told the chain the
user is actually on ([#317](https://git.eeqj.de/sneak/AutistMask/issues/317)).
`eth_chainId` and `net_version` answered from `currentNetwork()`, which reads
the module-level `state` singleton that nothing populates at module scope, so
a service worker revived by the page's own message answered out of
`DEFAULT_STATE` and reported mainnet `0x1`/`1` to a user on Sepolia — a dApp
building its interaction for the wrong chain. Both now answer from
`getState()`, the per-call detached storage read the other read handlers use,
rather than from the singleton: these two are reachable by any page on every
provider init, and mutating the shared singleton on that path would detach the
wallet objects an in-flight `backgroundRefresh()` is mutating. The read side
of the background was audited with it: the remaining singleton reads are the
chain switch, the transaction verification path and `backgroundRefresh`, which
each already load, and everything else answers from storage per call through
`getState()`. One stale read is left named but unfixed, outside this issue's
scope: `handleSendTransaction` builds its provider with no network name, so
`getProvider()` falls back to the same unloaded singleton for ethers' static
network hint.
- 2026-08-20: The dApp approval screen no longer shows a token transfer it
cannot scale as `0.0000`
([#306](https://git.eeqj.de/sneak/AutistMask/issues/306)). `decodeCalldata`