fix: version the stored profile, and give a record that cannot be read a way out (closes #311)
The stored profile carried no version, so nothing could tell a record this build wrote from one a later build did, and loadState() coerced scalars while trusting the structure. A wallets that was a string, an array of nulls, or a later schema's wallet records reached the popup and threw on the first dereference: no view, no message, no control, and every dApp call answering a generic -32603 because getActiveAddress() dereferenced the same record. There was no reset or wipe control anywhere in the product, so the only escape was clearing extension storage through browser internals. saveState() and updateState() now both stamp STATE_SCHEMA_VERSION, and every read goes through assertStateUsable() on the raw bytes before normalization can 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, and refused all the way: not normalized, not written back, not half-loaded, and not overwritten by a save either. The gate covers what nothing downstream can floor. Everything else is normalizePersisted()'s job, and two of those floors were written on truthiness rather than on type, so a truthy value of the wrong type walked straight through and threw on the first dereference — the same blank popup, by a longer route. trackedTokens: "nope" rendered nothing with "Cannot read properties of undefined (reading 'toLowerCase')", and activeAddress: 42 rendered nothing with "address.slice is not a function", for profiles whose wallets were perfectly readable. Both are type checks now, matching what networkEndpoints and viewStack already did in the same file, and an empty list or an empty string still survives. The popup shows a new StateRecovery screen. It names the problem in a sentence, exports the stored record into a text box on the page with no normalization or repair on it (and downloads it where the browser allows), and offers an erase behind a typed ERASE MY WALLET. Both controls are required: 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 Settings gear is hidden while it is up, and showView() is not used to raise it, because both read the state singleton that by then refuses to be read. The export is JSON.stringify of the deserialized record, so a value JSON cannot represent — a cycle, or a BigInt, which Firefox's storage can hold — fails the export entirely and leaves erase as the only control; that is now stated where the export is written. The background refuses the same record and answers dApps -32007 with a message saying the saved data cannot be read and that nothing was signed or sent, rather than the -32603 it also answers when a signing attempt breaks. EIP-1474 sets aside -32000..-32099 for implementation-defined server errors but assigns meanings to -32000 through -32006, including -32001 "Resource not found" and the -32002 "Resource unavailable" this wallet already uses for a pending approval; -32007..-32099 are the unassigned ones, and a test pins the code against that table. networkById() now throws on an id it does not know instead of quietly answering mainnet, which also stops NETWORKS["constructor"] resolving off the prototype chain. Every key test in the gate is an own-property test, because networkId is an object key into networkEndpoints and an unvalidated "__proto__" set that map's prototype instead of an own key, dropping the user's endpoint silently; normalizePersisted() copies endpoint entries with defineProperty for the same reason. That own-property discipline is the gate's alone — normalizePersisted() reads the same fields plainly, and the two agree only because a record from storage has been through structuredClone and carries Object.prototype. The three corrupt blobs from the issue drive the real popup entry point and the real worker in tests; each rendered nothing at all and answered -32603 before this, and the unversioned-but-valid case is tested too. Three test files used fixture wallets the product cannot produce (a bare address string where an address record belongs, a wallet with no address list) and now use whole records. src/popup/restorableViews.js moved to src/shared/restorableViews.js, since persistedState.js requires it and that module is in the background bundle.
This commit is contained in:
34
TODO.md
34
TODO.md
@@ -57,6 +57,40 @@ 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. Two fields the gate deliberately does not
|
||||
check, `trackedTokens` and `activeAddress`, were floored on truthiness rather
|
||||
than on type and so produced the same blank popup for a truthy value of the
|
||||
wrong type; both are type-checked now. `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
|
||||
|
||||
Reference in New Issue
Block a user