fix: stored state has no version and no migration, and a corrupt blob bricks the popup and every dApp call #311
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Found by the pre-1.0 deployability audit (#303). Blocker: no upgrade story, and no in-product way out of a bad blob.
saveState()(src/shared/state.js:83-113) enumerates 28 fields. Read back from a live install, there is noversionorschemaVersionamong them:loadState()coerces scalars but validates nothing structural.Reproduction
Hostile blobs written to storage, then the real popup opened:
A completely blank popup: no view visible, no message, no recovery control.
It is not only the UI. With such a blob in storage,
getActiveAddress()(src/background/index.js:191-199) dereferencess.wallets[0].addresses[0]and throws, so every dApp RPC call from every page answers-32603 AutistMask could not complete this request because of an internal error. And there is no reset or wipe control anywhere in the UI —src/popup/andsrc/background/were grepped for one.Consequence
An unversioned blob plus any future schema change is a bricked wallet on auto-update, with no way out from inside the product. Today the same state is reachable from a partial write or a downgrade. The version field is the cheap insurance that is missing.
Note the adjacent latent case:
networkById()(src/shared/networks.js:34-36) returns mainnet for any unknown id — a stored{networkId:"base"}renders the selector asmainnetwith no banner andeth_chainId0x1, whilerpcUrlstill points at Base. Unreachable through today's UI (two networks, no free-form field), but it is exactly the failure mode a downgrade would produce.Definition of done
saveState()stamps a schema version.loadState()validates the shape. On a version it does not understand, or awalletsarray it cannot parse, it refuses to run and shows an explicit screen naming the problem, rather than rendering blank.-32603.networkById()on an unknown id fails loudly rather than silently returning mainnet.make checkgreen.Trap for whoever implements this, introduced by #313:
state.networkIdis loaded unvalidated (src/shared/state.js:136) and is now used as an object key into the newstate.networkEndpointsmap. A corruptnetworkIdof"__proto__"sets the map's prototype instead of an own key, so the user's endpoint is silently not recorded and a switch away and back returns the public default.Not reachable today — only this codebase writes that field, and only with validated network ids — which is why it is a note here rather than its own issue. It becomes reachable the moment stored state is treated as untrusted, which is exactly what this issue asks for. Validate
networkIdagainstsrc/shared/networks.jsas part of the shape validation.Implementation plan, before code.
Version and migration.
STATE_SCHEMA_VERSION = 1in a newsrc/shared/stateSchema.js. Every write path stamps it: the popup'ssaveState()and the background'supdateState(). An UNVERSIONED but structurally valid blob — which is what every install in the field has — is treated as version 1 and migrated in place on the next write. It never reaches the recovery screen; that case gets its own test, because a wipe prompt for a perfectly good wallet on upgrade is the worse failure.Validation.
assertStateUsable(raw)runs on the raw record before normalization, on both read paths (loadState()insrc/shared/state.js,getState()insrc/background/state.js), and throwsStateUnusableErrorcarrying a user-facing sentence naming the problem. It refuses: a record that is not an object; aschemaVersionthat is not an integer this build understands (newer blob included);walletsthat is not an array, or whose entries are not wallet records with anaddressesarray of address records; and anetworkIdthat is not a network insrc/shared/networks.js. Every key test isObject.prototype.hasOwnProperty, so"__proto__"and"constructor"are refused rather than resolving through the prototype chain — the trap in the comment above.Popup.
init()catchesStateUnusableErrorand shows a newstate-recoveryscreen instead of proceeding; nothing else runs, and the Settings gear is hidden, because every other screen reads the profile. The screen names the problem, offers an export of the raw blob (revealed in a selectable textarea, which always works, plus a best-effort file download), and a destructive reset behind a typed confirmation.showView()is not used on this path: it reads and writes the state singleton, which by then refuses to be read.Background.
getState()throws the same error, and the RPC dispatcher answers a specific code and message — the wallet cannot read its saved data, nothing was signed or sent, open the extension — instead of the generic-32603.networkById. Throws
UnknownNetworkErroron an unknown id instead of returning mainnet, matchinggetProvider(). Also fixesnetworkById("constructor")currently answering withObject.Tests. The three corrupt blobs from the issue drive the real popup entry point over a DOM stub and must land on the recovery screen; the unversioned-but-valid blob must load, keep its wallet, and gain the version stamp. The dApp path gets the specific error over the existing cold-worker harness. Fail-first output for all four goes in the PR body.