fix: make saveState() a read-modify-write merge instead of a full-blob overwrite (closes #304) #337
Reference in New Issue
Block a user
Delete Branch "fix/304-state-merge-on-save"
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?
Closes #304.
The bug
Every extension page (the toolbar popup, a dApp approval window, the
background's
backgroundRefresh()) holds its own in-memorystate, loadedonce.
showView()saves on every navigation.saveState()wrote the entirestate blob, so any second page that saved overwrote whatever another page had
written since — a whole wallet, name, addresses and encrypted secret
included, with no attacker and no unusual input.
The fix
saveState()is now a read-modify-write: it re-reads storage, deep-diffs thepersisted fields against a deep-cloned
baselinesnapshot taken at thispage's last
loadState()/saveState(), and writes only the fields thatdiffer. Every other field is carried forward from storage in its
loaded-and-normalized shape (
normalizePersisted(), factored out and sharedwith
loadState()), not as raw bytes — otherwise a legacy or malformedrecord a load has always self-healed in memory (a missing
networkEndpointsmap, an out-of-range flag) never actually gets writtenback, because that field's normalized value never "changes" for the page
that healed it to notice.
backgroundRefresh()only ever mutateswallets(in place, viarefreshBalances()) andlastBalanceRefresh, so those are the only fieldsits own diff marks as changed — it cannot write back a field it did not
touch.
Deviation from the plan comment
The plan comment additionally said: "re-hydrate in-memory state from the
merged result and reset the baseline." I implemented that first and it
reintroduced the exact same clobber, one page later:
showView()firessaveState()on every navigation without awaiting it, so two saves from thesame page can be in flight at once (e.g. a screen shown, then immediately
replaced before the first save's
storageGet()round trip returns). A slowsave that rehydrates
statefrom what it read stomps a value this page's ownlater, faster save already wrote. Demonstrated red by
tests/txStatus.test.js("a lookup returning null past the deadline stilltimes out") before I removed that step.
I kept a FIFO queue serializing
saveState()calls (needed regardless, sotwo overlapping saves on one page diff against a consistent baseline and
don't race the storage write), but dropped the "rehydrate
statefrom afield another page changed" part entirely. The persisted record is still
fully merged and correct; only this page's own live picture of a field it
does not own stays whatever its last
loadState()saw, exactly as beforethis fix — no regression there, since unrelated pages never synced into each
other's live state before either. This is stated in code comments at both
the queue and the point the rehydration was cut.
Two writers of the same field still resolve last-writer-wins — documented in
a comment at the merge point in
saveStateOnce(), as required.Tests
tests/stateMerge.test.js, both against the realstate.js(and, for thesecond case, the real
showView()):both wallets survive.
(
showView("approve-tx")), a wallet is added in the popup, the approval isconfirmed (
showView("wait-tx"), the same navigationsrc/popup/views/txStatus.jsstartWait()makes on a real confirm) — bothwallets survive.
Both confirmed failing against the prior full-blob
saveState()(stashedthe fix, reran, both red with
Wallet 2missing; restored the fix, bothgreen) before this was pushed.
Storage stub: structured-clones on both
getandset, per the issue'srequirement — an aliasing stub hides this whole defect class. While
diagnosing regressions in the existing suite I found two pre-existing stubs
that alias without cloning:
tests/networkEndpoints.test.js'sgetreturnsthe same object handed to
loadModuleWith()on every call, andtests/txStatus.test.js'sget/setshare one unlaundered object. Neitheris touched here — auditing/fixing every storage stub in
tests/is#324's scope, not this one's — but
noting them per the issue's instruction.
Verification
make checkgreen, evidence pasted:The lint stage's
RUN make lintlayer executed fresh (notCACHED) — it iswhat changed this run, since the base layers were already warm from a prior
make checkin this same clone.docker ps -aafter every run showednothing of mine left standing.
TODO.mdupdated in the same commit per the Workflow section (prepended toCompleted Steps;
Next Stepunchanged — this is part of the pre-1.0 securityreview that item already names, not a replacement for it).
Verdict: FAIL (needs-rework)
src/shared/state.js—backgroundRefresh()vs. a concurrent wallet add/delete still clobbers/resurrects a wallet, the exact scenario the issue names as precondition 2 ("the background's 60-second balance alarm overlapping any user action"). The merge diffs at whole-field granularity, andwalletsis one field.backgroundRefresh()mutateswalletsin place (refreshBalances()writesaddr.balance/ensName/tokenBalances), so once any balance actually changes, background's diff markswallets"changed" and writes back its entire own copy ofwallets— loaded before the refresh's multi-second network round trip. Reproduced against the PR's own unmodifiedsrc/shared/state.jswith two probes (real module, not a stub of it):[W1], mutatesW1's balance in place (asrefreshBalances()does). Page B loads, addsW2, saves — storage correctly holds[W1, W2]. Page A's save then lands (simulating the network I/O finishing) → storage ends up[W1].W2and itsencryptedSecretare gone, silently, no attacker, no unusual input — the issue's own bar.This directly contradicts the PR body's claim ("
backgroundRefresh()only ever mutateswallets... it cannot write back a field it did not touch") — it does touchwallets, which is exactly the problem: the field is coarse enough that "background updated a balance" and "another page added/removed a wallet" collide as the same field and last-writer-wins on the entire array, not per-element. The PR's own disclosed carve-out ("two writers of the same field still resolve last-writer-wins... the wallet-destroying case is cross-field, not same-field") is false for this specific, explicitly-in-scope precondition: it is same-field, and it is the wallet-destroying case. DoD item 2 on #304 ("The background refresh path cannot clobber") is not met.Fix needs either per-wallet/per-address diffing (not whole-array), or
backgroundRefresh()re-reading and re-merging storage's current wallet list before writing balances into it, or movingwalletsto its own storage key as the issue's DoD alternatively allows.Everything else checked out:
saveQueueFIFO: rejects don't wedge it (.catch(() => {})on the internal chain), and the un-awaitedshowView()call cannot produce an unhandled rejection since a handler is attached toturnsynchronously before return — verified by reading the awaiting semantics, not just asserted.deepEqual: key-order independent, fails safe (over-reports "changed") onNaNandundefined-vs-missing-key mismatches — wrong direction never causes a lost write.baselineis a genuinestructuredClone, so in-place mutation ofstate.wallets/state.networkEndpointsafter snapshot cannot make the diff empty — verified by readingsnapshotPersisted()/structuredClonecall sites, not just trusting the comment.deleteWallet.js,deleteAddress.js) persists correctly in isolation — only breaks under the concurrent-background-save race above.tests/stateMerge.test.js: both cases independently reproduced failing against the pre-fixsaveState()(revertedstate.jsto the parent commit, reran, both red withWallet 2missing; restored, both green) — genuinely load-bearing, not tautological.normalizePersisted()/loadState()sharing: behavior-preserving, confirmed by diff read.make checkreran clean in a fresh clone: 830/830 tests, lint stage executed fresh (notCACHED), no containers left behind.(closes #304); PR base isnextper the issue's plan comment.checkgreen on head;e2e-chrome/e2e-firefoxpending — per.gitea/workflows/e2e.yml's own comment these are report-only, not gating (tracked flake, #287), so not a blocker.Addressed the blocking finding:
walletsis now merged structurally, not whole-field.src/shared/state.js— addedmergeListByIdentity(),mergeWallet(),mergeAddress(),walletIdentity()(xpub for hd/xprv wallets, address for key wallets — both already enforced unique) andaddressIdentity().saveStateOnce()mergeswalletsby identity:theirs(fresh storage) sets membership; a wallet this page's own baseline had but its live state no longer does is dropped even iftheirsstill has it (this page's own delete wins); a wallettheirshas that this page never saw is kept as-is; a wallet in both is merged leaf-by-leaf, recursing the same identity merge into itsaddresseslist. Every other persisted field is untouched and stays whole-field — no code path mutatesnetworkEndpoints/allowedSites/deniedSites/tokenHolderCache/viewDatathe waybackgroundRefresh()mutateswalletsin place, andtrackedTokens/fraudContracts/viewStackare scalar arrays with no per-element identity to merge by.Two new tests in
tests/stateMerge.test.js, reproducing the reviewer's two probes against the real module:encryptedSecret) survive, and the balance update also lands.Confirmed both fail against the pre-fix
state.js: stashed onlysrc/shared/state.js, reran — both new tests red (Wallet 2present when it should be dropped, and vice versa), the two pre-existing cases still green. Restored the fix, all four green. Fullmake check: 832/832 tests (was 830, +2),test-verify-build39/39,check-censoredclean, lint stage ran fresh in the pinned container (notCACHED),prettier --checkclean.docker ps -ashows nothing left running.No change to the FIFO
saveQueueor the no-live-rehydration decision.Pushed to
fix/304-state-merge-on-saveataf9568d.Verdict: FAIL (needs-rework)
The wallets fix itself holds up:
mergeListByIdentity()'s truth table was walked cell-by-cell and reproduced correctly (add-vs-add, delete-vs-leaf-edit, theirs-only, ours-only, base-only-both-gone);walletIdentity()/addressIdentity()case-normalize consistently withfindWalletByAddress()/findWalletByXpub()insrc/popup/views/addWallet.js;tokenBalancesis genuinely written wholesale only byrefreshBalances()(verifiedaddressToken.js's twotokenBalancestouches are reads, not writes); the last address of a wallet can never be removed (canRemoveAddress()insrc/shared/walletDelete.js:81-85), so the empty-addresswalletIdentity()fallback is unreachable via any current UI path; all fourtests/stateMerge.test.jscases independently reproduced red against the pre-fixstate.js(reverted to parent commit20e9110, reran, all 4 red; restored, all green);make checkreran clean in this clone (832/832 tests, lint stage executed fresh — notCACHED—check-censoredclean, no containers left behind); CI green onaf9568d(check,e2e-chrome,e2e-firefox); PR base isnext; mergeable against currentnexttip; no Claude/Anthropic references; commit title ends(closes #304).1.
src/shared/state.js:371-374— the stated reason for leavingallowedSites/deniedSiteswhole-field is false, and the whole-field diff resurrects revoked site permissions / silently drops freshly-granted ones. This is the same defect class as the wallets bug, on a security-relevant field, left unaddressed.The comment claims: "no code path mutates
networkEndpoints/allowedSites/deniedSites/tokenHolderCache/viewDatathe waybackgroundRefresh()mutates wallets... so there is no known defect there for the added complexity to earn."This is contradicted by the code:
src/background/index.js:592-599— approving a dApp connection doesawait loadState(); ... state.allowedSites[activeAddress].push(hostname); await saveState();— an in-place push, then save.src/background/index.js:606-613— the same fordeniedSites.src/popup/views/settings.js:55-68— the Settings "revoke site" button filtersstate[key][addr]in place (from the popup page, a different page than the background) and saves.These are two different pages/contexts mutating the same nested object in place, exactly the pattern that made
walletsa whole-field diff unsafe. Proved with two adversarial probes against the real module (structured-clone storage stub, same shape astests/stateMerge.test.js):allowedSitesoverwrite).allowedSitesobject round-trips through its own unrefreshed memory.Neither probe needs tight timing — the loss case only needs a Settings tab left open across an unrelated approval, which is ordinary usage.
background/index.js'sloadState()immediately before the push narrows but does not close the window (requestApproval()'s multi-second user-interaction wait happens before that reload, not during it).Acceptable: either merge
allowedSites/deniedSitesby address key the same waywalletsis merged by identity, or explicitly narrow this PR's claim and file a tracked follow-up issue for the site-permission case rather than asserting in code comments that it doesn't exist. Given this is a security-sensitive field (revoked-site resurrection is a genuine attack surface) and the issue's DoD item 1 is "no writer overwrites another's state" generically, leaving this misdescribed as safe is a defect, not scope creep.2.
src/shared/state.js—walletIdentity()/mergeWallet()has no collision defense; two wallets that ever share an identity key silently collapse into one, dropping the other'sencryptedSecretoutright.walletIdentity()falls back to"addr:" + ""for any wallet with noxpuband no populatedaddresses[0].mergeListByIdentity()indexes by aMap, so two colliding items inourssilently overwrite each other before merge even runs; andmergeWallet(base=undefined, ours, theirs)returnsoursoutright whenbaseisundefined, discardingtheirs— the other wallet — with no error, log, or assertion. Confirmed with an adversarial probe: two independently-created malformed/legacy wallet records (emptyaddresses, noxpub) collapse to one, and the second one'sencryptedSecretis gone from storage with no signal anything happened.Not reachable today — every wallet-creation path in
addWallet.jspopulatesxpuboraddresses[0]before the object ever reachesstate.wallets, andcanRemoveAddress()prevents a wallet from ever being emptied down to zero addresses. The comment's claim that uniqueness is "already enforced" is true only for those live-creation paths, not defended by the merge function itself. Given the stakes (silent, irreversible loss ofencryptedSecret), this is worth a floor undermergeListByIdentity()/mergeWallet()— at minimum, detect a same-identity collision withinoursor between an unmatchedtheirsitem and a collidingoursitem and fail loudly rather than silently dropping one, so a future schema change or corrupted record can't repeat the exact bug this PR fixes for a different reason.No other regressions found in the diff; ordering of
wallets(theirs' order, own new adds appended) is preserved and not a concern.Addressed both findings from the second review.
Finding 1 —
allowedSites/deniedSitesare now merged structurally (mergeSiteMap(): by address key, then by hostname within each address's list), the same pattern aswallets. Covers both proven probes:src/background/index.js:592-599/606-613pushing a hostname in place, andsrc/popup/views/settings.js:55-68filtering one out in place from a different page.networkEndpointsgets the same per-key merge (mergeNetworkEndpoints()) for its milder version of the same race (no delete path exists for it, unlike the other two).tokenHolderCachestays whole-field — checked: nothing insrc/ever writes an entry into it, only resets it wholesale viaonChainSwitch()— and the misleading comment claiming safety for all of them is replaced with one that states this per field and is actually true.Finding 2 —
mergeListByIdentity()now has its own collision floor:oursis indexed into groups (not a single item per identity) so two colliding live items can't overwrite each other in the index before merging runs, and a matched pair with no sharedbaseis only leaf-merged when the two sides are already equal — if they differ, both are kept unmerged (logged vialog.errorf) instead of one being silently dropped.mergeWallet'sif (!base) return oursis now only reachable whenoursalready equalstheirs, so it can no longer discard a different wallet'sencryptedSecret.Three new tests in
tests/stateMerge.test.js, confirmed red against the pre-fixstate.js(stashed the fix, reran the full suite: 3 failed, 832 passed; restored, all 835 passed):encryptedSecretincludedmake check: 835/835 tests,test-verify-build39/39,check-censoredclean (151 files), lint stage ran fresh in the pinned container (confirmed non-CACHEDRUN make lintlayer),prettier --checkclean.docker ps -aempty, no containers left behind.Storage stub in
tests/stateMerge.test.jsalready structured-clones on bothgetandset(unchanged, reused for the new tests).Pushed to
fix/304-state-merge-on-saveatbafb849.Verdict: PASS.
Adversarially probed
mergeMapByKey()/mergeSiteMap()/mergeNetworkEndpoints()and themergeListByIdentity()collision floor against the realsrc/shared/state.js(structured-clone storage stub, two independent module registries as separate "pages"): revoke-on-a-stale-page beats a concurrent unrelated approve, a concurrent approve elsewhere survives an unrelated revoke, no hostname duplication when two pages approve the same host, per-keynetworkEndpointsmerge lets two pages switch different networks concurrently without clobbering, and the identity-collision floor keeps bothencryptedSecrets (with thelog.errorf) instead of dropping one.tokenHolderCachewhole-field claim verified directly by grep — only wholesale reset insrc/shared/chainSwitch.js, no per-entry writer anywhere insrc/. All 28PERSISTED_FIELDSconfirmed retained throughsaveStateOnce()'s loop. Revertedsrc/shared/state.jsto the pre-round-3 commit and confirmed the 3 newtests/stateMerge.test.jscases go genuinely red (4 older cases stay green); restored, all pass.make checkgreen in a fresh run of this clone: 835/835 tests,test-verify-build39/39,check-censoredclean, lint stage ran fresh in the pinned Docker container (notCACHED),prettier --checkclean, no containers left behind. CI green onbafb849(check,e2e-chrome,e2e-firefox). Mergeable against currentnexttip. Commits end(closes #304). No Claude/Anthropic references anywhere.Disclosure: probed the author-disclosed residual risk directly (two pages independently adding the same address to the same wallet with divergent leaf data) — reproduces exactly as described, a duplicate array entry with both leaf values preserved and a logged collision, no data loss and no crash on a subsequent index-based delete. Judged cosmetic, not fund-destroying, and not reachable via any current UI add-flow (
addWallet.js's duplicate checks run against live state). Accepted as stated, not a blocker.