Commit Graph

2 Commits

Author SHA1 Message Date
af9568db90 fix: merge wallets by identity, not whole-field, in saveState() (closes #304)
All checks were successful
check / check (push) Successful in 31s
e2e / e2e-chrome (push) Successful in 1m12s
e2e / e2e-firefox (push) Successful in 24s
backgroundRefresh() mutates state.wallets in place (addr.balance/ensName/
tokenBalances via refreshBalances()), so a whole-field diff on `wallets`
marked the entire array "changed" the moment any balance moved and wrote
back background's own copy -- loaded before its multi-second network
round trip -- clobbering a wallet another page added, or resurrecting one
another page deleted, in that window. That is DoD item 2 on the issue,
still unmet by the prior whole-field merge.

`wallets` is now merged structurally: by wallet identity (xpub for
hd/xprv wallets, address for key wallets, both already enforced unique),
then by address identity within each wallet. A leaf background actually
changed applies on top of storage's current copy; membership added or
removed by another page applies independently, since it no longer
collides with `wallets` as a single field. Every other persisted field
stays a whole-field diff -- no code path mutates them the way
backgroundRefresh() mutates wallets, so there is no matching defect to
fix there.
2026-08-20 14:14:29 +00:00
31b2aa2d8a fix: make saveState() a read-modify-write merge instead of a full-blob overwrite (closes #304)
All checks were successful
check / check (push) Successful in 30s
e2e / e2e-chrome (push) Successful in 1m12s
e2e / e2e-firefox (push) Successful in 24s
Every extension page (the toolbar popup, a dApp approval window, the
background's backgroundRefresh()) holds its own in-memory `state`, loaded
once, and showView() saves on every navigation. saveState() wrote the
entire state 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.

saveState() now re-reads storage, diffs the persisted fields against a
deep-cloned baseline snapshot taken at this page's last
loadState()/saveState(), and writes only the fields that differ. Every
other field is carried forward from storage in its loaded-and-normalized
shape (normalizePersisted(), shared with loadState()), so a legacy or
malformed record a load has always self-healed in memory keeps getting
written back even on a save that touched something unrelated.
showView() fires saveState() without awaiting it, so two saves from the
SAME page can be in flight at once; a FIFO queue serializes them.

Deliberately not done, a documented deviation from the plan on the
issue: the live `state` of a field this page does not own is not
rehydrated from what another page wrote, only the persisted record is.
Adopting a concurrently-written value into `state` reintroduced the same
clobber one page later, under the fire-and-forget saveState() calling
convention every view uses -- caught red by tests/txStatus.test.js.
Two writers of the same field still resolve last-writer-wins, documented
at the merge point.

tests/stateMerge.test.js covers both required cases against the real
state.js and showView(): a save from a page loaded before a wallet was
added elsewhere, and the approval-window reproduction from the issue.
Both were confirmed failing against the prior full-blob write before
this fix landed.
2026-08-20 14:00:00 +00:00