Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.
So the reachability goes rather than a sixth call site.
The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile.
- Every handler takes one snapshot and answers from it, including the address
it names: activeAddressOf(s) replaced a second, later storage read that
could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
chainSwitch.js, which keeps the singleton path for the popup) inside
updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
balances that came back by address, so it never publishes an object other
in-flight work holds, and a wallet added or deleted during the round trip
survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
snapshot. They used to come from different moments, so a chain switch
committed in between moved the endpoint under an artifact already verified
against the old chain.
getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.
The prohibition is enforced mechanically, not by review: a custom ESLint rule
walks the CommonJS require graph from every src/background/ file and fails the
lint when src/shared/state.js is reachable, naming the chain. A re-export from
any shared module cannot put the singleton back in the bundle unnoticed.
Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.
Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. They all go through tests/support/storageStub.js now, which
structured-clones in both directions.
closes#320
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.