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

@@ -16,6 +16,7 @@ const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
// script/lib/forbiddenBundleInputs.js). The ESLint rule of the same name is
// the same prohibition reported early, not the guarantee.
const { getState, updateState } = require("./state");
const { StateUnusableError } = require("../shared/stateSchema");
const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch, log } = require("../shared/log");
const {
@@ -179,6 +180,45 @@ const INTERNAL_ERROR_CODE = -32603;
const INTERNAL_ERROR_MESSAGE =
"AutistMask could not complete this request because of an internal error.";
// What the page is told when the wallet's own stored profile cannot be read.
//
// This used to be the generic answer above: getActiveAddress() dereferenced
// the stored wallet list on nearly every method, so a corrupt or
// newer-than-this-build record turned EVERY request from EVERY page into
// "internal error", which is also what a failed signing attempt answers. The
// page cannot tell those apart, and the user is told nothing about the one
// thing that is actually wrong or where to fix it
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// Its own code rather than -32603, because the condition is specific,
// diagnosable and has a user action attached — none of which "internal error"
// conveys.
//
// -32007 specifically: EIP-1474 sets aside -32000..-32099 for
// implementation-defined server errors, but it ASSIGNS meanings to -32000
// through -32006 (Invalid input, Resource not found, Resource unavailable,
// Transaction rejected, Method not supported, Limit exceeded, JSON-RPC version
// not supported). -32007..-32099 are the unassigned ones, and this condition
// is not any of the seven. Nothing above it is free to be overloaded either:
// this wallet already answers EIP-1474's -32002 "Resource unavailable" for a
// pending approval, the conventional way, so a page is entitled to read these
// codes by that table.
const STATE_UNUSABLE_CODE = -32007;
const STATE_UNUSABLE_MESSAGE =
"AutistMask cannot read its saved data, so nothing was signed or sent." +
" Open the AutistMask extension to export or reset it.";
// The EIP-1193 error for a handler that threw, by cause. Everything that
// consults the profile goes through getState(), so this one mapping covers
// every method rather than each one having to know about the condition.
function failureError(err) {
if (err instanceof StateUnusableError) {
log.errorf("state is unusable:", err.problem);
return { code: STATE_UNUSABLE_CODE, message: STATE_UNUSABLE_MESSAGE };
}
return { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
}
// The active address of a profile snapshot. Pure, and taking the snapshot as
// an argument rather than reading storage itself: a handler that has already
// read state must not answer "which account is this" from a SECOND, later read
@@ -883,6 +923,11 @@ async function handleRpc(method, params, origin) {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
// A node that answered with an error is reported as itself. The
// wallet being unable to read its own profile is not that, and it
// must not be flattened into a message with no code: it goes back
// to the dispatcher, which has the one answer for it.
if (e instanceof StateUnusableError) throw e;
return { error: { message: e.message } };
}
}
@@ -1142,7 +1187,14 @@ async function backgroundRefresh() {
// module-level state does not outlive it. Alarms are held by the browser and
// wake the worker to deliver them.
registerAlarmHandlers({
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
// Caught here rather than left to the alarm dispatcher, which does not
// await what it calls: a profile this build cannot read makes every
// refresh throw, and an unhandled rejection per alarm tick says less than
// one logged line per tick does.
[BALANCE_REFRESH_ALARM]: () =>
backgroundRefresh().catch((e) => {
log.errorf("background balance refresh failed:", e);
}),
});
// Everything the background context needs re-established on start. This runs
@@ -1241,12 +1293,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// "it does not throw today" is not a property anyone is
// maintaining.
log.errorf("RPC request failed:", msg.method, err);
sendResponse({
error: {
code: INTERNAL_ERROR_CODE,
message: INTERNAL_ERROR_MESSAGE,
},
});
sendResponse({ error: failureError(err) });
});
return true;
}
@@ -1481,18 +1528,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// the popup nor the page is ever answered. Settle both, through
// the same chokepoint as every other retirement.
log.errorf("transaction approval response failed:", e);
settleApproval(
msg.id,
{
error: {
code: INTERNAL_ERROR_CODE,
message: INTERNAL_ERROR_MESSAGE,
},
},
{ holdsClaim: true },
);
const failure = failureError(e);
settleApproval(msg.id, { error: failure }, { holdsClaim: true });
sendResponse({
error: INTERNAL_ERROR_MESSAGE,
error: failure.message,
retryable: false,
stage: lastResortStage,
});
@@ -1584,18 +1623,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// Same shape as the transaction path: a throw out of the catch
// block above would leave the popup and the page both waiting.
log.errorf("sign approval response failed:", e);
settleApproval(
msg.id,
{
error: {
code: INTERNAL_ERROR_CODE,
message: INTERNAL_ERROR_MESSAGE,
},
},
{ holdsClaim: true },
);
const failure = failureError(e);
settleApproval(msg.id, { error: failure }, { holdsClaim: true });
sendResponse({
error: INTERNAL_ERROR_MESSAGE,
error: failure.message,
retryable: false,
});
});