fix: version the stored profile, and give a record that cannot be read a way out (closes #311)
All checks were successful
check / check (push) Successful in 32s
e2e / e2e-chrome (push) Successful in 1m44s
e2e / e2e-firefox (push) Successful in 29s

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:
2026-08-23 16:23:58 +00:00
parent 28a527295a
commit a10a98438f
25 changed files with 1990 additions and 50 deletions

View File

@@ -985,6 +985,48 @@ tokens with fewer than 1,000 holders" setting governs the transaction history
and the send-screen token selector, not this list. Tracked tokens with a zero
balance are listed as well while "Show tracked tokens with zero balance" is on.
#### Stored state and its version
The whole profile lives under a single extension-storage key, `autistmask`, and
carries a `schemaVersion``STATE_SCHEMA_VERSION` in
`src/shared/stateSchema.js`, currently `1`. Every write stamps it: the popup's
`saveState()` and the background's `updateState()` both do, so whichever context
wrote last, the record says which build's shape it is in.
Version 1 is the shape that shipped before versions existed, so a stored record
with no `schemaVersion` is version 1 rather than a defect: it loads normally and
is migrated in place by being stamped on the first write. An upgrade never shows
an existing user a warning about a profile that is perfectly good. The version
is bumped only when the MEANING of a stored field changes — a new field with a
sensible absent value is handled by `normalizePersisted()` and is not a bump,
because bumping for one would send every older install to StateRecovery for
nothing.
Every read of the record goes through `assertStateUsable()` first, on the raw
bytes, before normalization: `loadState()` for the popup and `getState()` for
the background. It refuses a record that is not an object, a `schemaVersion`
this build does not understand (a newer one included), a `wallets` that is not a
list of wallet records with address records in them, and a `networkId` that is
not a network in `src/shared/networks.js`. Refusing is the whole point — a
record the wallet cannot vouch for is never normalized, never written back, and
never half-loaded. The popup shows StateRecovery; a dApp gets a specific error
(`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the
saved data cannot be read and that nothing was signed or sent, rather than the
generic `-32603` every request used to answer.
Every other field of the record is floored in `normalizePersisted()` rather than
gated, and that floor is a type check: a truthy value of the wrong type walks
through a `saved.x || default` and throws on the first dereference, which is the
blank popup again by a longer route. Adding a field means giving it a floor
there or a check in the gate.
The `networkId` check is not cosmetic: that value is an object KEY into
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
prototype instead of an own key and the user's endpoint would silently not be
recorded. Every key test in the gate is an own-property test for that reason,
and `networkById()` throws on an id it does not know rather than quietly
answering mainnet.
#### Navigation
The main view shows all addresses grouped by wallet, with ETH balances inline.
@@ -1011,10 +1053,12 @@ Three elements sit outside the screens and are present on all of them: the title
bar ("AutistMask by @sneak" plus the Settings gear), the flash message line
under it, and the red banner at the very top that appears on a debug build, when
runtime debug mode is on, or when the active network is a testnet. They are not
repeated in the element lists below.
repeated in the element lists below. StateRecovery is the one screen they are
not all present on: it hides the Settings gear, because it is shown precisely
when there is no profile for the screens behind that gear to render from.
Closing and reopening the popup returns to the screen the user was last on only
for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`).
for the views listed in `RESTORABLE_VIEWS` (`src/shared/restorableViews.js`).
Every other screen falls back to Home. The screens that display a secret —
ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
so the popup can never reopen onto one of them with no password prompt in front
@@ -1684,6 +1728,48 @@ view would leave a wallet one click from deletion.
- Popup window closed without answering → the request is rejected with
EIP-1193 code 4001
#### StateRecovery (`state-recovery`)
- **When**: `loadState()` refused the stored profile, so the popup has no
profile at all. It is the only screen reached without one, and the only one
that never appears during ordinary use.
- **Why it exists**: a record the wallet cannot read used to render nothing — no
view, no message, no control — while every dApp call answered a generic
internal error, and no reset or wipe control existed anywhere in the product.
The only escape was clearing extension storage through browser internals
([#311](https://git.eeqj.de/sneak/AutistMask/issues/311)).
- **Elements**:
- "Saved Data Cannot Be Read" heading, and a statement that nothing has been
changed or erased and nothing can be signed or sent
- The problem, in one sentence naming what is wrong with the record
- "Export Saved Data" button, and the read-only text box it fills
- What erasing does and does not do, in bold: every wallet stored in this
browser is deleted; nothing on chain changes and no money is moved
- A text input asking for `ERASE MY WALLET` to be typed back
- Error line
- "Erase Saved Data" button
- **Transitions**:
- "Export Saved Data" → the raw stored record, verbatim, in the text box on
the screen, and a downloaded `autistmask-saved-data.json` where the
browser allows one. The box is filled first and never depends on the
download: an export that can fail is not an export.
- "Erase Saved Data" (phrase typed) → the stored record is removed and the
popup reloads into **Welcome**
- "Erase Saved Data" (phrase not typed) → "Type ERASE MY WALLET to confirm.
Nothing was erased." on the error line
- **No other control is reachable.** The Settings gear is hidden while this
screen is up, because every screen behind it renders from the profile that
could not be read, and `showView()` is not used to raise it for the same
reason — it reads and writes the state singleton.
- **Both controls are required.** An export with no reset leaves the user
looking at a broken profile with no way to use the wallet again; a reset with
no export destroys the only copy of a record that may hold recoverable key
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
and for the same reason: there is no password to gate this with, since there
is no profile to check one against.
- Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because
nothing on this path writes state at all.
### External Services
AutistMask is not a fully self-contained offline tool. It necessarily