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

View File

@@ -194,6 +194,23 @@ function storageSet(items) {
return Promise.resolve(storage.set(items));
}
/**
* Erase stored keys. The one caller is the destructive reset on the recovery
* screen (src/popup/views/stateRecovery.js), which is the only way out of a
* profile no build can read; it rejects rather than defaulting for the same
* reason the two above do — a reset that silently did nothing would leave the
* user in the dead end they were promised an exit from.
*
* @param {string|string[]} keys
* @returns {Promise<void>}
* @throws rejects where `storage.local` is absent.
*/
function storageRemove(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("remove");
return Promise.resolve(storage.remove(keys));
}
/**
* @param {Object} queryInfo
* @returns {Promise<Array>} the matching tabs.
@@ -251,6 +268,7 @@ module.exports = {
sendMessage,
storageGet,
storageLocal,
storageRemove,
storageSet,
tabsApi,
tabsQuery,