fix: a second extension page silently deletes a wallet — saveState() is a last-writer-wins full-blob overwrite #304
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 by the pre-1.0 deployability audit (#303). Blocker: destroys a wallet with no attacker and no unusual input.
src/shared/state.js:83-115—saveState()writes the entire state blob.src/popup/views/helpers.js:80— everyshowView()saves.src/popup/index.js:136—loadState()runs exactly once per page. There is nochrome.storage.onChangedlistener anywhere insrc/, so each extension page holds a private in-memorystateand every write is a full-blob overwrite of whatever another page wrote.Reproduction
Driven through the real UI in the Chrome harness; the approval window was opened by the background, not by the test:
Also reproduced with two plain popup pages:
RESULT A: 2 wallets before B navigated, 1 after. *** WALLET LOST ***.Answering an approval was the trigger here, but any navigation in the stale page does it, because
showView()saves unconditionally. The background is a third writer:backgroundRefresh()atsrc/background/index.js:1034-1048doesloadState()→ seconds of network I/O →saveState(), so the same clobber happens with no second window open at all.Consequence
The user loses the entire wallet — name, addresses, and the encrypted secret. Funds at those addresses are unrecoverable unless the recovery phrase was written down during the seconds it was on screen. Nothing reports it, and the dApp transaction succeeds normally so nothing looks wrong.
Preconditions: one extension page open while another writes. That is a dApp approval plus the toolbar popup, or the background's 60-second balance alarm overlapping any user action.
Definition of done
storage.onChangedre-hydrates every page, orsaveState()becomes a read-modify-write with per-field merge, orwalletsmoves to its own storage key written only by the paths that own it.make checkgreen.Implementation plan
Scope is this issue's DoD only. The root-cause refactor is #324 and stays out of this unit; this fix must not preclude it.
Approach:
saveState()becomes a read-modify-write that merges only the fields this page actually changed, computed by diff against a per-page baseline.All 43
saveState()call sites take no arguments. Requiring each caller to declare which fields it touched would be a 40-site mechanical change and would fail open — a caller that forgets a field silently stops persisting it. Diffing against a baseline needs no call-site changes and cannot fail open in that direction.loadState()and at the end of everysaveState(), store a deep clone of the persisted fields asbaseline. A clone is required, not a reference: callers mutate in place (state.wallets.push(...)), which would otherwise mutate the baseline too and make the diff always empty.saveState()re-reads storage, deep-compares each persisted field againstbaseline, and writes{...fresh, ...onlyChangedFields}. Then it re-hydrates in-memorystatefrom the merged result and resetsbaseline.backgroundRefresh()did not changewallets, sowalletsis not in its diff and cannot be written back stale. Same for a stale page whose only change iscurrentView/viewStack.Take a different approach if this one proves unsound, but say why on the PR.
Required:
chrome.storage.localtest stub this unit relies on must structured-clone onget, as the real API does. An aliasing stub makes this entire defect class invisible (see #324, DoD item 5).next, PR based onnext.make checkgreen, linting in Docker.