fix: the background worker saves state it never loaded, so a chain switch wipes every wallet in storage #316
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Found during the independent review of #313 (#313 (comment)). Blocker: destroys every wallet in the extension. Pre-existing on
next, not introduced by that PR.src/background/index.js:698callsonChainSwitch(), which mutates andsaveState()s the module-levelstatesingleton insrc/shared/state.js. The MV3 service worker never populates that singleton — there is noloadState()at module scope (startBackgroundJobs()at:1108only schedules alarms), andhandleRpcdoes not load it either. A worker revived by a page's own message therefore holdsDEFAULT_STATE, and the save writes those defaults over the user's real storage.Reproduction
Probe against the real
stateandchainSwitchmodules (onlybalances,phishingDomainsandalarmsstubbed), stored profile carrying 1 wallet,rpcUrl: http://127.0.0.1:8545,allowedSitesnaming the origin.wallet_switchEthereumChain("0xaa36a7")answered{"result":null}and persisted:Consequence
Every wallet is gone from storage — names, addresses and encrypted secrets — along with every site approval, every tracked token and every setting. Funds are unrecoverable unless the user wrote down each recovery phrase. Nothing reports it; the page's switch call answers success.
On current
nextthe trigger has no precondition at all:wallet_switchEthereumChainis ungated (#308), so any page the user visits can do this to a cold worker. Once #313 lands the gate, the precondition becomes a connected site — still reachable, still total.Same root cause family as #304 (a blind full-blob write from a writer holding stale state), but a distinct path with a distinct fix and a far larger blast radius: #304 loses one wallet, this loses all of them. Fixing #304's read-modify-write would also close this; fixing this alone would not close #304.
Definition of done
await loadState()beforeonChainSwitch(); precedent exists atsrc/background/index.js:1286in the transaction path.wallets,allowedSites,trackedTokensand a customrpcUrlall survive. Both existing test files are blind to this —chainSwitchGate.test.jsmocks the state module wholesale includingsaveState, andnetworkEndpoints.test.jsalways callsloadState()first — so a test that loads state first does not count.make checkgreen.Fixed on
nextby the squash-merge of #313, which carried a closing keyword for #308 only. Closing by hand.Both DoD items are met and were verified by an independent reviewer:
await loadState()runs in thewallet_switchEthereumChainhandler after the gate and before anything reads or moves the network. The only branch that skips it is the4100refusal, which writes nothing.saveState()sites are reachable from the background (src/background/index.js:595,:609,:1075, andsrc/shared/chainSwitch.js:69), each now preceded by a load. No fifth exists; the only non-statestorageSet()issrc/content/index.js:33writing the separateeip6963Uuidkey.tests/coldWorkerChainSwitch.test.jsdrives the background handler against the realstateandchainSwitchmodules over write-retaining storage and never callsloadState()itself, assertingwallets,hasWallet,activeAddress,allowedSites,trackedTokens,themeand the customrpcUrlall survive. Dropping only the addedloadState()fails exactly the two cold-worker cases.The read-side twin of this defect is not fixed and remains open as #317.