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.
A rejected password was reported three different ways depending on which screen
you were on, including the fragment "Wrong password." which is not a sentence.
All six decryptWithPassword call sites now show the same full sentence.
Strings only -- a wrong password still fails closed on every screen and still
resolves no pending approval.
A test pins the invariant per call site: each decryptWithPassword call is walked
out to its enclosing try and forward to that block's catch, and the prose shown
there must equal the canonical sentence. Per-file matching was not enough, since
a file with two call sites kept passing while one of them diverged.