fix: version stored state, validate its shape, and give a corrupt blob a way out (closes #311)
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:
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user