fix: version stored state, validate its shape, and give a corrupt blob a way out (closes #311)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m46s
e2e / e2e-firefox (push) Successful in 34s

Stored state had no version and no structural validation, so a corrupt blob produced a completely blank popup with no message and no recovery control, and made every dApp RPC call from every page answer a generic -32603. There was no reset or wipe control anywhere in the UI.

saveState() now stamps a schema version and loadState() validates the shape. A version it does not understand, or a wallets array it cannot parse, lands on a recovery screen that names the problem, offers the stored record verbatim for export, and offers a destructive reset behind a typed confirmation. Unversioned but valid state -- which every existing install has -- migrates in place and keeps working; it is never shown a wipe prompt. A dApp call against unusable state answers -32007, which EIP-1474 leaves unassigned, rather than -32603. networkById() refuses an unknown id loudly instead of returning mainnet, and networkId is validated so a corrupt value cannot be used as an object key.

Fields the gate does not refuse are floored by type, container and entries both: a malformed trackedTokens or tokenBalances entry is dropped rather than dereferenced. Verified by an independent sweep of 1152 corrupt blobs producing no blank popup, with the same harness showing 9 blanks against the previous revision.
This commit was merged in pull request #360.
This commit is contained in:
2026-08-23 20:04:00 +02:00
parent 28a527295a
commit ad6aa7b20d
25 changed files with 2270 additions and 50 deletions

40
TODO.md
View File

@@ -57,6 +57,46 @@ but the review is broader than any of them.
`Token Out` now reads `Unknown (not named in the calldata)` and
`Min. received` falls to the base-unit refusal from
[#340](https://git.eeqj.de/sneak/AutistMask/issues/340).
- 2026-08-23: The stored profile carries a schema version, and a record the
wallet cannot read produces a screen instead of a blank popup
([#311](https://git.eeqj.de/sneak/AutistMask/issues/311)). `saveState()` and
`updateState()` both stamp `STATE_SCHEMA_VERSION`
(`src/shared/stateSchema.js`), and every read goes through
`assertStateUsable()` on the raw bytes before normalization gets a chance to
paper over them. Version 1 is the shape that shipped unversioned, so the
profile every existing install holds loads normally and is migrated in place
by being stamped on the first write — an upgrade shows nobody a wipe prompt
for a wallet that is fine. A record this build cannot vouch for is refused
instead: not normalized, not written back, not half-loaded. The popup shows
the new StateRecovery screen, which names the problem, exports the raw record
verbatim into the page (and downloads it where the browser allows), and offers
an erase behind a typed `ERASE MY WALLET` — both controls, because an export
with no reset leaves the user stuck and a reset with no export destroys the
only copy of possibly recoverable key material. The background refuses the
same record and answers dApps `-32007` — a code EIP-1474 leaves unassigned,
unlike `-32000`..`-32006` — with a message saying the saved data cannot be
read and that nothing was signed or sent, rather than the generic `-32603`
that every request used to get. Fields the gate deliberately does not check
produced the same blank popup on their own: `trackedTokens` and
`activeAddress` were floored on truthiness rather than on type, and
`trackedTokens`' ENTRIES and each address's `tokenBalances` were not floored
at all — `[1, 2]` is a list, and the dereference is `t.address.toLowerCase()`
one level below the container. All of them are type-checked now, entries
included, and the header of `src/shared/stateSchema.js` lists which fields of
the record get a type check and which get a `saved.x || default` or a verbatim
passthrough, rather than asserting a rule the module does not follow.
`networkById()` now throws on an id it does not know instead of quietly
answering mainnet, and the gate's key tests are all own-property tests:
`networkId` is an object key into `networkEndpoints`, so an unvalidated
`"__proto__"` used to set that map's prototype and drop the user's endpoint
silently. The three corrupt blobs from the issue drive the real popup entry
point in `tests/stateRecovery.test.js` and the real worker in
`tests/stateUnusableRpc.test.js`; each rendered nothing at all and answered
`-32603` before this. `src/popup/restorableViews.js` moved to
`src/shared/restorableViews.js`, since `persistedState.js` requires it and
that module is in the background bundle.
- 2026-08-23: The background no longer reads or writes the shared `state`
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
also closes the cold-worker wrong-chain send