Compare commits
1 Commits
main
...
a10a98438f
| Author | SHA1 | Date | |
|---|---|---|---|
| a10a98438f |
90
README.md
90
README.md
@@ -985,6 +985,48 @@ tokens with fewer than 1,000 holders" setting governs the transaction history
|
|||||||
and the send-screen token selector, not this list. Tracked tokens with a zero
|
and the send-screen token selector, not this list. Tracked tokens with a zero
|
||||||
balance are listed as well while "Show tracked tokens with zero balance" is on.
|
balance are listed as well while "Show tracked tokens with zero balance" is on.
|
||||||
|
|
||||||
|
#### Stored state and its version
|
||||||
|
|
||||||
|
The whole profile lives under a single extension-storage key, `autistmask`, and
|
||||||
|
carries a `schemaVersion` — `STATE_SCHEMA_VERSION` in
|
||||||
|
`src/shared/stateSchema.js`, currently `1`. Every write stamps it: the popup's
|
||||||
|
`saveState()` and the background's `updateState()` both do, so whichever context
|
||||||
|
wrote last, the record says which build's shape it is in.
|
||||||
|
|
||||||
|
Version 1 is the shape that shipped before versions existed, so a stored record
|
||||||
|
with no `schemaVersion` is version 1 rather than a defect: it loads normally and
|
||||||
|
is migrated in place by being stamped on the first write. An upgrade never shows
|
||||||
|
an existing user a warning about a profile that is perfectly good. The version
|
||||||
|
is bumped only when the MEANING of a stored field changes — a new field with a
|
||||||
|
sensible absent value is handled by `normalizePersisted()` and is not a bump,
|
||||||
|
because bumping for one would send every older install to StateRecovery for
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
Every read of the record goes through `assertStateUsable()` first, on the raw
|
||||||
|
bytes, before normalization: `loadState()` for the popup and `getState()` for
|
||||||
|
the background. It refuses a record that is not an object, a `schemaVersion`
|
||||||
|
this build does not understand (a newer one included), a `wallets` that is not a
|
||||||
|
list of wallet records with address records in them, and a `networkId` that is
|
||||||
|
not a network in `src/shared/networks.js`. Refusing is the whole point — a
|
||||||
|
record the wallet cannot vouch for is never normalized, never written back, and
|
||||||
|
never half-loaded. The popup shows StateRecovery; a dApp gets a specific error
|
||||||
|
(`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the
|
||||||
|
saved data cannot be read and that nothing was signed or sent, rather than the
|
||||||
|
generic `-32603` every request used to answer.
|
||||||
|
|
||||||
|
Every other field of the record is floored in `normalizePersisted()` rather than
|
||||||
|
gated, and that floor is a type check: a truthy value of the wrong type walks
|
||||||
|
through a `saved.x || default` and throws on the first dereference, which is the
|
||||||
|
blank popup again by a longer route. Adding a field means giving it a floor
|
||||||
|
there or a check in the gate.
|
||||||
|
|
||||||
|
The `networkId` check is not cosmetic: that value is an object KEY into
|
||||||
|
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
|
||||||
|
prototype instead of an own key and the user's endpoint would silently not be
|
||||||
|
recorded. Every key test in the gate is an own-property test for that reason,
|
||||||
|
and `networkById()` throws on an id it does not know rather than quietly
|
||||||
|
answering mainnet.
|
||||||
|
|
||||||
#### Navigation
|
#### Navigation
|
||||||
|
|
||||||
The main view shows all addresses grouped by wallet, with ETH balances inline.
|
The main view shows all addresses grouped by wallet, with ETH balances inline.
|
||||||
@@ -1011,10 +1053,12 @@ Three elements sit outside the screens and are present on all of them: the title
|
|||||||
bar ("AutistMask by @sneak" plus the Settings gear), the flash message line
|
bar ("AutistMask by @sneak" plus the Settings gear), the flash message line
|
||||||
under it, and the red banner at the very top that appears on a debug build, when
|
under it, and the red banner at the very top that appears on a debug build, when
|
||||||
runtime debug mode is on, or when the active network is a testnet. They are not
|
runtime debug mode is on, or when the active network is a testnet. They are not
|
||||||
repeated in the element lists below.
|
repeated in the element lists below. StateRecovery is the one screen they are
|
||||||
|
not all present on: it hides the Settings gear, because it is shown precisely
|
||||||
|
when there is no profile for the screens behind that gear to render from.
|
||||||
|
|
||||||
Closing and reopening the popup returns to the screen the user was last on only
|
Closing and reopening the popup returns to the screen the user was last on only
|
||||||
for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`).
|
for the views listed in `RESTORABLE_VIEWS` (`src/shared/restorableViews.js`).
|
||||||
Every other screen falls back to Home. The screens that display a secret —
|
Every other screen falls back to Home. The screens that display a secret —
|
||||||
ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
|
ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
|
||||||
so the popup can never reopen onto one of them with no password prompt in front
|
so the popup can never reopen onto one of them with no password prompt in front
|
||||||
@@ -1684,6 +1728,48 @@ view would leave a wallet one click from deletion.
|
|||||||
- Popup window closed without answering → the request is rejected with
|
- Popup window closed without answering → the request is rejected with
|
||||||
EIP-1193 code 4001
|
EIP-1193 code 4001
|
||||||
|
|
||||||
|
#### StateRecovery (`state-recovery`)
|
||||||
|
|
||||||
|
- **When**: `loadState()` refused the stored profile, so the popup has no
|
||||||
|
profile at all. It is the only screen reached without one, and the only one
|
||||||
|
that never appears during ordinary use.
|
||||||
|
- **Why it exists**: a record the wallet cannot read used to render nothing — no
|
||||||
|
view, no message, no control — while every dApp call answered a generic
|
||||||
|
internal error, and no reset or wipe control existed anywhere in the product.
|
||||||
|
The only escape was clearing extension storage through browser internals
|
||||||
|
([#311](https://git.eeqj.de/sneak/AutistMask/issues/311)).
|
||||||
|
- **Elements**:
|
||||||
|
- "Saved Data Cannot Be Read" heading, and a statement that nothing has been
|
||||||
|
changed or erased and nothing can be signed or sent
|
||||||
|
- The problem, in one sentence naming what is wrong with the record
|
||||||
|
- "Export Saved Data" button, and the read-only text box it fills
|
||||||
|
- What erasing does and does not do, in bold: every wallet stored in this
|
||||||
|
browser is deleted; nothing on chain changes and no money is moved
|
||||||
|
- A text input asking for `ERASE MY WALLET` to be typed back
|
||||||
|
- Error line
|
||||||
|
- "Erase Saved Data" button
|
||||||
|
- **Transitions**:
|
||||||
|
- "Export Saved Data" → the raw stored record, verbatim, in the text box on
|
||||||
|
the screen, and a downloaded `autistmask-saved-data.json` where the
|
||||||
|
browser allows one. The box is filled first and never depends on the
|
||||||
|
download: an export that can fail is not an export.
|
||||||
|
- "Erase Saved Data" (phrase typed) → the stored record is removed and the
|
||||||
|
popup reloads into **Welcome**
|
||||||
|
- "Erase Saved Data" (phrase not typed) → "Type ERASE MY WALLET to confirm.
|
||||||
|
Nothing was erased." on the error line
|
||||||
|
- **No other control is reachable.** The Settings gear is hidden while this
|
||||||
|
screen is up, because every screen behind it renders from the profile that
|
||||||
|
could not be read, and `showView()` is not used to raise it for the same
|
||||||
|
reason — it reads and writes the state singleton.
|
||||||
|
- **Both controls are required.** An export with no reset leaves the user
|
||||||
|
looking at a broken profile with no way to use the wallet again; a reset with
|
||||||
|
no export destroys the only copy of a record that may hold recoverable key
|
||||||
|
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
|
||||||
|
and for the same reason: there is no password to gate this with, since there
|
||||||
|
is no profile to check one against.
|
||||||
|
- Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because
|
||||||
|
nothing on this path writes state at all.
|
||||||
|
|
||||||
### External Services
|
### External Services
|
||||||
|
|
||||||
AutistMask is not a fully self-contained offline tool. It necessarily
|
AutistMask is not a fully self-contained offline tool. It necessarily
|
||||||
|
|||||||
34
TODO.md
34
TODO.md
@@ -57,6 +57,40 @@ but the review is broader than any of them.
|
|||||||
`Token Out` now reads `Unknown (not named in the calldata)` and
|
`Token Out` now reads `Unknown (not named in the calldata)` and
|
||||||
`Min. received` falls to the base-unit refusal from
|
`Min. received` falls to the base-unit refusal from
|
||||||
[#340](https://git.eeqj.de/sneak/AutistMask/issues/340).
|
[#340](https://git.eeqj.de/sneak/AutistMask/issues/340).
|
||||||
|
|
||||||
|
- 2026-08-23: The stored profile carries a schema version, and a record the
|
||||||
|
wallet cannot read produces a screen instead of a blank popup
|
||||||
|
([#311](https://git.eeqj.de/sneak/AutistMask/issues/311)). `saveState()` and
|
||||||
|
`updateState()` both stamp `STATE_SCHEMA_VERSION`
|
||||||
|
(`src/shared/stateSchema.js`), and every read goes through
|
||||||
|
`assertStateUsable()` on the raw bytes before normalization gets a chance to
|
||||||
|
paper over them. Version 1 is the shape that shipped unversioned, so the
|
||||||
|
profile every existing install holds loads normally and is migrated in place
|
||||||
|
by being stamped on the first write — an upgrade shows nobody a wipe prompt
|
||||||
|
for a wallet that is fine. A record this build cannot vouch for is refused
|
||||||
|
instead: not normalized, not written back, not half-loaded. The popup shows
|
||||||
|
the new StateRecovery screen, which names the problem, exports the raw record
|
||||||
|
verbatim into the page (and downloads it where the browser allows), and offers
|
||||||
|
an erase behind a typed `ERASE MY WALLET` — both controls, because an export
|
||||||
|
with no reset leaves the user stuck and a reset with no export destroys the
|
||||||
|
only copy of possibly recoverable key material. The background refuses the
|
||||||
|
same record and answers dApps `-32007` — a code EIP-1474 leaves unassigned,
|
||||||
|
unlike `-32000`..`-32006` — with a message saying the saved data cannot be
|
||||||
|
read and that nothing was signed or sent, rather than the generic `-32603`
|
||||||
|
that every request used to get. Two fields the gate deliberately does not
|
||||||
|
check, `trackedTokens` and `activeAddress`, were floored on truthiness rather
|
||||||
|
than on type and so produced the same blank popup for a truthy value of the
|
||||||
|
wrong type; both are type-checked now. `networkById()` now throws on an id it
|
||||||
|
does not know instead of quietly answering mainnet, and the gate's key tests
|
||||||
|
are all own-property tests: `networkId` is an object key into
|
||||||
|
`networkEndpoints`, so an unvalidated `"__proto__"` used to set that map's
|
||||||
|
prototype and drop the user's endpoint silently. The three corrupt blobs from
|
||||||
|
the issue drive the real popup entry point in `tests/stateRecovery.test.js`
|
||||||
|
and the real worker in `tests/stateUnusableRpc.test.js`; each rendered nothing
|
||||||
|
at all and answered `-32603` before this. `src/popup/restorableViews.js` moved
|
||||||
|
to `src/shared/restorableViews.js`, since `persistedState.js` requires it and
|
||||||
|
that module is in the background bundle.
|
||||||
|
|
||||||
- 2026-08-23: The background no longer reads or writes the shared `state`
|
- 2026-08-23: The background no longer reads or writes the shared `state`
|
||||||
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
||||||
also closes the cold-worker wrong-chain send
|
also closes the cold-worker wrong-chain send
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
|
|||||||
// script/lib/forbiddenBundleInputs.js). The ESLint rule of the same name is
|
// script/lib/forbiddenBundleInputs.js). The ESLint rule of the same name is
|
||||||
// the same prohibition reported early, not the guarantee.
|
// the same prohibition reported early, not the guarantee.
|
||||||
const { getState, updateState } = require("./state");
|
const { getState, updateState } = require("./state");
|
||||||
|
const { StateUnusableError } = require("../shared/stateSchema");
|
||||||
const { refreshBalances, getProvider } = require("../shared/balances");
|
const { refreshBalances, getProvider } = require("../shared/balances");
|
||||||
const { debugFetch, log } = require("../shared/log");
|
const { debugFetch, log } = require("../shared/log");
|
||||||
const {
|
const {
|
||||||
@@ -179,6 +180,45 @@ const INTERNAL_ERROR_CODE = -32603;
|
|||||||
const INTERNAL_ERROR_MESSAGE =
|
const INTERNAL_ERROR_MESSAGE =
|
||||||
"AutistMask could not complete this request because of an internal error.";
|
"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
|
// 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
|
// 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
|
// 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);
|
const result = await proxyRpc(method, params);
|
||||||
return { result };
|
return { result };
|
||||||
} catch (e) {
|
} 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 } };
|
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
|
// module-level state does not outlive it. Alarms are held by the browser and
|
||||||
// wake the worker to deliver them.
|
// wake the worker to deliver them.
|
||||||
registerAlarmHandlers({
|
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
|
// 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
|
// "it does not throw today" is not a property anyone is
|
||||||
// maintaining.
|
// maintaining.
|
||||||
log.errorf("RPC request failed:", msg.method, err);
|
log.errorf("RPC request failed:", msg.method, err);
|
||||||
sendResponse({
|
sendResponse({ error: failureError(err) });
|
||||||
error: {
|
|
||||||
code: INTERNAL_ERROR_CODE,
|
|
||||||
message: INTERNAL_ERROR_MESSAGE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1481,18 +1528,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
// the popup nor the page is ever answered. Settle both, through
|
// the popup nor the page is ever answered. Settle both, through
|
||||||
// the same chokepoint as every other retirement.
|
// the same chokepoint as every other retirement.
|
||||||
log.errorf("transaction approval response failed:", e);
|
log.errorf("transaction approval response failed:", e);
|
||||||
settleApproval(
|
const failure = failureError(e);
|
||||||
msg.id,
|
settleApproval(msg.id, { error: failure }, { holdsClaim: true });
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: INTERNAL_ERROR_CODE,
|
|
||||||
message: INTERNAL_ERROR_MESSAGE,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ holdsClaim: true },
|
|
||||||
);
|
|
||||||
sendResponse({
|
sendResponse({
|
||||||
error: INTERNAL_ERROR_MESSAGE,
|
error: failure.message,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
stage: lastResortStage,
|
stage: lastResortStage,
|
||||||
});
|
});
|
||||||
@@ -1584,18 +1623,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
// Same shape as the transaction path: a throw out of the catch
|
// Same shape as the transaction path: a throw out of the catch
|
||||||
// block above would leave the popup and the page both waiting.
|
// block above would leave the popup and the page both waiting.
|
||||||
log.errorf("sign approval response failed:", e);
|
log.errorf("sign approval response failed:", e);
|
||||||
settleApproval(
|
const failure = failureError(e);
|
||||||
msg.id,
|
settleApproval(msg.id, { error: failure }, { holdsClaim: true });
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: INTERNAL_ERROR_CODE,
|
|
||||||
message: INTERNAL_ERROR_MESSAGE,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ holdsClaim: true },
|
|
||||||
);
|
|
||||||
sendResponse({
|
sendResponse({
|
||||||
error: INTERNAL_ERROR_MESSAGE,
|
error: failure.message,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,14 +28,26 @@
|
|||||||
|
|
||||||
const { storageGet, storageSet } = require("../shared/browserApi");
|
const { storageGet, storageSet } = require("../shared/browserApi");
|
||||||
const { normalizePersisted } = require("../shared/persistedState");
|
const { normalizePersisted } = require("../shared/persistedState");
|
||||||
|
const {
|
||||||
|
STATE_SCHEMA_VERSION,
|
||||||
|
assertStateUsable,
|
||||||
|
} = require("../shared/stateSchema");
|
||||||
|
|
||||||
// A fresh, fully-normalized, detached copy of the persisted profile.
|
// A fresh, fully-normalized, detached copy of the persisted profile.
|
||||||
//
|
//
|
||||||
// Normalized rather than raw: a legacy or malformed record is self-healed the
|
// Normalized rather than raw: a legacy or malformed record is self-healed the
|
||||||
// same way loadState() heals it for the popup, so the background is never the
|
// same way loadState() heals it for the popup, so the background is never the
|
||||||
// one context reasoning about a shape the rest of the extension repairs.
|
// one context reasoning about a shape the rest of the extension repairs.
|
||||||
|
//
|
||||||
|
// Throws StateUnusableError for a record this build cannot make sense of,
|
||||||
|
// before normalization gets a chance to paper over it — the same gate, in the
|
||||||
|
// same place, as the popup's loadState(). Every handler that consults the
|
||||||
|
// profile comes through here, so a dApp call against such a record is answered
|
||||||
|
// with the specific error the dispatcher maps that to (src/background/index.js)
|
||||||
|
// rather than dereferencing its way into a generic -32603.
|
||||||
async function getState() {
|
async function getState() {
|
||||||
const result = await storageGet("autistmask");
|
const result = await storageGet("autistmask");
|
||||||
|
assertStateUsable(result.autistmask);
|
||||||
return normalizePersisted(result.autistmask);
|
return normalizePersisted(result.autistmask);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +60,10 @@ async function updateStateOnce(mutate) {
|
|||||||
const s = await getState();
|
const s = await getState();
|
||||||
await mutate(s);
|
await mutate(s);
|
||||||
s.hasWallet = Boolean(s.wallets && s.wallets.length > 0);
|
s.hasWallet = Boolean(s.wallets && s.wallets.length > 0);
|
||||||
|
// Stamped on every write, exactly as the popup's saveState() stamps it:
|
||||||
|
// whichever context writes last, the record in storage is in this build's
|
||||||
|
// shape and says so.
|
||||||
|
s.schemaVersion = STATE_SCHEMA_VERSION;
|
||||||
await storageSet({ autistmask: s });
|
await storageSet({ autistmask: s });
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1761,6 +1761,75 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ STATE RECOVERY ============ -->
|
||||||
|
<!--
|
||||||
|
Shown when the stored profile cannot be read at all. Every
|
||||||
|
other screen renders from that profile, so this one is reached
|
||||||
|
without one and is the only way out of a wallet that would
|
||||||
|
otherwise be a blank popup.
|
||||||
|
-->
|
||||||
|
<div id="view-state-recovery" class="view hidden">
|
||||||
|
<h2 class="font-bold mb-2">Saved Data Cannot Be Read</h2>
|
||||||
|
<p class="text-xs mb-2">
|
||||||
|
AutistMask stopped rather than guessing. Nothing has been
|
||||||
|
changed or erased, and nothing can be signed or sent until
|
||||||
|
this is resolved.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
id="state-recovery-problem"
|
||||||
|
class="text-xs font-bold mb-3 break-words"
|
||||||
|
></div>
|
||||||
|
<p class="text-xs mb-2">
|
||||||
|
Export the saved data first and keep it. It may hold the
|
||||||
|
encrypted keys for your wallets, and it is the only copy.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
id="btn-state-recovery-export"
|
||||||
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
|
>
|
||||||
|
Export Saved Data
|
||||||
|
</button>
|
||||||
|
<textarea
|
||||||
|
id="state-recovery-blob"
|
||||||
|
readonly
|
||||||
|
class="hidden border border-border p-1 w-full h-32 font-mono text-xs bg-bg text-fg mt-2"
|
||||||
|
></textarea>
|
||||||
|
<p class="text-xs mt-3 mb-2">
|
||||||
|
<strong
|
||||||
|
>Erasing the saved data deletes every wallet stored in
|
||||||
|
this browser.</strong
|
||||||
|
>
|
||||||
|
Nothing on the blockchain changes and no money is moved, but
|
||||||
|
without the exported copy above, or the recovery phrase for
|
||||||
|
each wallet written down, everything they hold is gone
|
||||||
|
forever.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs mb-1">
|
||||||
|
To confirm, type
|
||||||
|
<strong>ERASE MY WALLET</strong>
|
||||||
|
below.
|
||||||
|
</p>
|
||||||
|
<div class="mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="state-recovery-reset-input"
|
||||||
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
|
placeholder="Type ERASE MY WALLET"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="state-recovery-flash"
|
||||||
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
|
></div>
|
||||||
|
<button
|
||||||
|
id="btn-state-recovery-reset"
|
||||||
|
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
|
>
|
||||||
|
Erase Saved Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="index.js"></script>
|
<script src="index.js"></script>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Loads state, initializes views, triggers first render.
|
// Loads state, initializes views, triggers first render.
|
||||||
|
|
||||||
const { state, saveState, loadState } = require("../shared/state");
|
const { state, saveState, loadState } = require("../shared/state");
|
||||||
|
const { StateUnusableError } = require("../shared/stateSchema");
|
||||||
const { setRuntimeDebug } = require("../shared/log");
|
const { setRuntimeDebug } = require("../shared/log");
|
||||||
const { refreshPrices } = require("../shared/prices");
|
const { refreshPrices } = require("../shared/prices");
|
||||||
const { refreshBalances } = require("../shared/balances");
|
const { refreshBalances } = require("../shared/balances");
|
||||||
@@ -16,7 +17,7 @@ const {
|
|||||||
const { applyTheme } = require("./theme");
|
const { applyTheme } = require("./theme");
|
||||||
// Renders a view the popup lands on without having navigated to it forward:
|
// Renders a view the popup lands on without having navigated to it forward:
|
||||||
// on restore here, and on Back. Only the views that can be fully re-rendered
|
// on restore here, and on Back. Only the views that can be fully re-rendered
|
||||||
// from persisted state (RESTORABLE_VIEWS, src/popup/restorableViews.js) go
|
// from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go
|
||||||
// through it; anything else falls back to the nearest restorable parent.
|
// through it; anything else falls back to the nearest restorable parent.
|
||||||
const { renderView, makeBackRenderer } = require("./viewRouter");
|
const { renderView, makeBackRenderer } = require("./viewRouter");
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ const settings = require("./views/settings");
|
|||||||
const settingsAddToken = require("./views/settingsAddToken");
|
const settingsAddToken = require("./views/settingsAddToken");
|
||||||
const deleteAddress = require("./views/deleteAddress");
|
const deleteAddress = require("./views/deleteAddress");
|
||||||
const approval = require("./views/approval");
|
const approval = require("./views/approval");
|
||||||
|
const stateRecovery = require("./views/stateRecovery");
|
||||||
|
|
||||||
function renderWalletList() {
|
function renderWalletList() {
|
||||||
home.render(ctx);
|
home.render(ctx);
|
||||||
@@ -134,7 +136,22 @@ function fallbackView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
await loadState();
|
try {
|
||||||
|
await loadState();
|
||||||
|
} catch (e) {
|
||||||
|
// A profile this build cannot read is the one failure that must not
|
||||||
|
// fall through to the rest of init(). It used to: the load "succeeded"
|
||||||
|
// on a record nothing had validated, and the first dereference below
|
||||||
|
// threw, leaving a popup with no view, no message and no control on
|
||||||
|
// it, and no way out of the wallet from inside the product
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/311). Now the load
|
||||||
|
// refuses, and this is the screen that says so.
|
||||||
|
if (e instanceof StateUnusableError) {
|
||||||
|
stateRecovery.show(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
applyTheme(state.theme);
|
applyTheme(state.theme);
|
||||||
|
|
||||||
// Sync runtime debug flag from persisted state before first render
|
// Sync runtime debug flag from persisted state before first render
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// dispatch and its data guards can be tested directly; src/popup/index.js
|
// dispatch and its data guards can be tested directly; src/popup/index.js
|
||||||
// cannot be required outside a browser.
|
// cannot be required outside a browser.
|
||||||
|
|
||||||
const { RESTORABLE_VIEWS } = require("./restorableViews");
|
const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
|
||||||
|
|
||||||
// The views this page load has rendered.
|
// The views this page load has rendered.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ const VIEWS = [
|
|||||||
"approve-sign",
|
"approve-sign",
|
||||||
"export-privkey",
|
"export-privkey",
|
||||||
"show-phrase",
|
"show-phrase",
|
||||||
|
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
||||||
|
// cannot be read. It is never reached through showView() — by then the
|
||||||
|
// state singleton this file writes on every navigation refuses to be read
|
||||||
|
// — but it is listed so that every view-hiding loop covers it.
|
||||||
|
"state-recovery",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Cleanup callbacks for views that hold a secret in the DOM. The view
|
// Cleanup callbacks for views that hold a secret in the DOM. The view
|
||||||
|
|||||||
190
src/popup/views/stateRecovery.js
Normal file
190
src/popup/views/stateRecovery.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
// The screen the popup shows when it cannot read the stored profile.
|
||||||
|
//
|
||||||
|
// Everything else in the popup assumes a loaded profile: showView() reads and
|
||||||
|
// writes the state singleton, every view renders from it, and the Settings
|
||||||
|
// gear leads to a screen that does both. None of that is available here — by
|
||||||
|
// the time this runs, loadState() has REFUSED, deliberately, and reading the
|
||||||
|
// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||||
|
//
|
||||||
|
// So this module talks to the DOM directly and touches no state at all. It is
|
||||||
|
// the one screen that must work when nothing else can, which is also why it
|
||||||
|
// takes no ctx and needs no init(): whatever the rest of the popup did or did
|
||||||
|
// not manage to wire up, this shows.
|
||||||
|
//
|
||||||
|
// Two controls, and both are required. An export with no reset leaves the user
|
||||||
|
// looking at their broken profile with no way to use the wallet again; a reset
|
||||||
|
// with no export destroys the only copy of a record that may hold key material
|
||||||
|
// a later build could read. So the export is offered first, in the page where
|
||||||
|
// it cannot fail, and the reset is behind a typed confirmation.
|
||||||
|
|
||||||
|
// $ and VIEWS only: nothing else in helpers is safe here, since showView() and
|
||||||
|
// everything under it read the state singleton. $ is taken from there rather
|
||||||
|
// than written again locally so that tests/popupElementIds.test.js sees these
|
||||||
|
// lookups and holds every id below against the markup.
|
||||||
|
const { $, VIEWS } = require("./helpers");
|
||||||
|
const { storageGet, storageRemove } = require("../../shared/browserApi");
|
||||||
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
|
// Typed in full before anything is erased, in the same spirit as the wallet
|
||||||
|
// name on DeleteWalletLostPassword: this button destroys key material and
|
||||||
|
// there is no password in front of it, because there is no profile to check a
|
||||||
|
// password against. Compared case-insensitively — the phrase is the barrier,
|
||||||
|
// not the shift key.
|
||||||
|
const RESET_PHRASE = "ERASE MY WALLET";
|
||||||
|
|
||||||
|
let wired = false;
|
||||||
|
|
||||||
|
function setFlash(message) {
|
||||||
|
const node = $("state-recovery-flash");
|
||||||
|
node.textContent = message;
|
||||||
|
node.style.visibility = message ? "visible" : "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored record exactly as storage hands it back, however malformed, with
|
||||||
|
// no normalization, no defaulting and no repair on it: this is evidence, and
|
||||||
|
// the point of the export is that a later build (or a human) sees what is
|
||||||
|
// actually there. It is not the raw bytes — storage deserializes, and
|
||||||
|
// exportRecord() re-serializes with JSON.stringify — so a value JSON cannot
|
||||||
|
// represent is the one thing that does not survive the trip. See there.
|
||||||
|
async function rawRecord() {
|
||||||
|
const result = await storageGet("autistmask");
|
||||||
|
return result.autistmask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best effort, and never the only route. A download from an extension popup
|
||||||
|
// depends on the browser, the popup staying open long enough, and the
|
||||||
|
// extension's content security policy; the textarea below depends on none of
|
||||||
|
// those, and is filled first.
|
||||||
|
function offerDownload(text) {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
typeof Blob !== "function" ||
|
||||||
|
typeof URL === "undefined" ||
|
||||||
|
typeof URL.createObjectURL !== "function"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([text], { type: "application/json" }),
|
||||||
|
);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "autistmask-saved-data.json";
|
||||||
|
link.click();
|
||||||
|
// Revoked in a later task, not in this one: the download is started
|
||||||
|
// from the click and revoking the URL in the same turn can cancel it
|
||||||
|
// before it has been read. If the popup closes first the URL dies with
|
||||||
|
// the document anyway.
|
||||||
|
if (typeof URL.revokeObjectURL === "function") {
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
log.errorf("state recovery: download failed:", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Residual, stated rather than left to be discovered: JSON.stringify THROWS on
|
||||||
|
// a reference cycle or a BigInt, and Firefox's structured-clone storage can
|
||||||
|
// hold both even though no build here writes one. That lands in the catch
|
||||||
|
// below, so the export fails entirely and erase is the only control left on
|
||||||
|
// the screen. Nothing here can serialize such a record; recovering one needs
|
||||||
|
// the browser's own storage inspector.
|
||||||
|
async function exportRecord() {
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
const record = await rawRecord();
|
||||||
|
text = JSON.stringify(record === undefined ? null : record, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
log.errorf("state recovery: export failed:", e);
|
||||||
|
setFlash(
|
||||||
|
"The saved data could not be read out of storage. Nothing has" +
|
||||||
|
" been changed.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// JSON.stringify answers undefined for a value it cannot represent, and
|
||||||
|
// an empty box would read as "there was nothing there".
|
||||||
|
if (typeof text !== "string") text = String(text);
|
||||||
|
|
||||||
|
const box = $("state-recovery-blob");
|
||||||
|
box.value = text;
|
||||||
|
box.classList.remove("hidden");
|
||||||
|
const downloaded = offerDownload(text);
|
||||||
|
setFlash(
|
||||||
|
downloaded
|
||||||
|
? "Saved data downloaded, and shown below. Keep a copy before" +
|
||||||
|
" erasing anything."
|
||||||
|
: "Saved data shown below. Copy it and keep it before erasing" +
|
||||||
|
" anything.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetProfile() {
|
||||||
|
const typed = $("state-recovery-reset-input").value || "";
|
||||||
|
if (typed.trim().toUpperCase() !== RESET_PHRASE) {
|
||||||
|
setFlash("Type " + RESET_PHRASE + " to confirm. Nothing was erased.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await storageRemove("autistmask");
|
||||||
|
} catch (e) {
|
||||||
|
log.errorf("state recovery: reset failed:", e);
|
||||||
|
setFlash("The saved data could not be erased. Nothing was changed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFlash("Saved data erased. AutistMask is starting fresh.");
|
||||||
|
// Back to a first run, which is what the wallet now is. A popup that
|
||||||
|
// cannot reload says so rather than sitting on a screen describing a
|
||||||
|
// profile that no longer exists.
|
||||||
|
if (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.location &&
|
||||||
|
typeof window.location.reload === "function"
|
||||||
|
) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFlash("Saved data erased. Close and reopen AutistMask.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire() {
|
||||||
|
if (wired) return;
|
||||||
|
wired = true;
|
||||||
|
$("btn-state-recovery-export").addEventListener("click", exportRecord);
|
||||||
|
$("btn-state-recovery-reset").addEventListener("click", resetProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the recovery screen, naming `problem`.
|
||||||
|
*
|
||||||
|
* @param {Error|string} problem the StateUnusableError from the read that
|
||||||
|
* refused, or its sentence.
|
||||||
|
*/
|
||||||
|
function show(problem) {
|
||||||
|
const sentence =
|
||||||
|
(problem && (problem.problem || problem.message)) || String(problem);
|
||||||
|
|
||||||
|
// Not showView(): that reads and writes the singleton this screen exists
|
||||||
|
// because nothing could load.
|
||||||
|
for (const view of VIEWS) {
|
||||||
|
const node = document.getElementById("view-" + view);
|
||||||
|
if (node) node.classList.add("hidden");
|
||||||
|
}
|
||||||
|
// The one global control, and it leads to a screen that renders from the
|
||||||
|
// profile. There is nowhere to go from here but out.
|
||||||
|
const gear = $("btn-settings");
|
||||||
|
if (gear) gear.classList.add("hidden");
|
||||||
|
|
||||||
|
$("state-recovery-problem").textContent = sentence;
|
||||||
|
$("state-recovery-blob").value = "";
|
||||||
|
$("state-recovery-blob").classList.add("hidden");
|
||||||
|
$("state-recovery-reset-input").value = "";
|
||||||
|
setFlash("");
|
||||||
|
wire();
|
||||||
|
$("view-state-recovery").classList.remove("hidden");
|
||||||
|
log.errorf("state is unusable, showing the recovery screen:", sentence);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { show, RESET_PHRASE };
|
||||||
@@ -194,6 +194,23 @@ function storageSet(items) {
|
|||||||
return Promise.resolve(storage.set(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
|
* @param {Object} queryInfo
|
||||||
* @returns {Promise<Array>} the matching tabs.
|
* @returns {Promise<Array>} the matching tabs.
|
||||||
@@ -251,6 +268,7 @@ module.exports = {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
storageGet,
|
storageGet,
|
||||||
storageLocal,
|
storageLocal,
|
||||||
|
storageRemove,
|
||||||
storageSet,
|
storageSet,
|
||||||
tabsApi,
|
tabsApi,
|
||||||
tabsQuery,
|
tabsQuery,
|
||||||
|
|||||||
@@ -31,8 +31,42 @@ const SUPPORTED_CHAIN_IDS = new Set(
|
|||||||
Object.values(NETWORKS).map((n) => n.chainId),
|
Object.values(NETWORKS).map((n) => n.chainId),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Thrown rather than defaulted. An id this build does not know used to answer
|
||||||
|
// with MAINNET, so a stored `{networkId:"base"}` rendered the selector as
|
||||||
|
// Ethereum Mainnet with no banner and answered eth_chainId 0x1, while rpcUrl
|
||||||
|
// still pointed at Base — the wallet telling the user and the page one chain
|
||||||
|
// while transacting on another. Nothing in this codebase has an unknown id to
|
||||||
|
// offer: stored state is validated against this table before it is loaded
|
||||||
|
// (src/shared/stateSchema.js), and every other caller passes an id it took
|
||||||
|
// from here. So an unknown id is a defect, and it says so, the same way
|
||||||
|
// getProvider() (src/shared/balances.js) already refuses one.
|
||||||
|
class UnknownNetworkError extends Error {
|
||||||
|
constructor(id) {
|
||||||
|
super(
|
||||||
|
"AutistMask does not know the network " +
|
||||||
|
JSON.stringify(id) +
|
||||||
|
"; it supports " +
|
||||||
|
Object.keys(NETWORKS).join(", "),
|
||||||
|
);
|
||||||
|
this.name = "UnknownNetworkError";
|
||||||
|
this.networkId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Own properties only: NETWORKS inherits from Object.prototype, so
|
||||||
|
// NETWORKS["constructor"] and NETWORKS["__proto__"] both answer with something
|
||||||
|
// truthy that is not a network. A stored id is untrusted input, and this is
|
||||||
|
// the test the validator uses to decide whether it may be adopted at all.
|
||||||
|
function isKnownNetworkId(id) {
|
||||||
|
return (
|
||||||
|
typeof id === "string" &&
|
||||||
|
Object.prototype.hasOwnProperty.call(NETWORKS, id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function networkById(id) {
|
function networkById(id) {
|
||||||
return NETWORKS[id] || NETWORKS.mainnet;
|
if (!isKnownNetworkId(id)) throw new UnknownNetworkError(id);
|
||||||
|
return NETWORKS[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
function networkByChainId(chainId) {
|
function networkByChainId(chainId) {
|
||||||
@@ -51,6 +85,8 @@ function explorerLink(network, type, value) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
NETWORKS,
|
NETWORKS,
|
||||||
SUPPORTED_CHAIN_IDS,
|
SUPPORTED_CHAIN_IDS,
|
||||||
|
UnknownNetworkError,
|
||||||
|
isKnownNetworkId,
|
||||||
networkById,
|
networkById,
|
||||||
networkByChainId,
|
networkByChainId,
|
||||||
explorerLink,
|
explorerLink,
|
||||||
|
|||||||
@@ -10,8 +10,14 @@
|
|||||||
// reaching it anyway and being served DEFAULT_STATE.
|
// reaching it anyway and being served DEFAULT_STATE.
|
||||||
|
|
||||||
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
|
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
|
||||||
// Dependency-free constant module; safe to pull into a background bundle.
|
const { isKnownNetworkId } = require("./networks");
|
||||||
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
|
const { STATE_SCHEMA_VERSION } = require("./stateSchema");
|
||||||
|
// Dependency-free constant module. It lives under src/shared/ rather than
|
||||||
|
// src/popup/ precisely because this module is in the background bundle: a
|
||||||
|
// popup-path module reached from the worker is the shape the prohibition in
|
||||||
|
// script/lib/forbiddenBundleInputs.js exists to keep out, even when the
|
||||||
|
// particular module is harmless.
|
||||||
|
const { RESTORABLE_VIEWS } = require("./restorableViews");
|
||||||
|
|
||||||
const DEFAULT_STATE = {
|
const DEFAULT_STATE = {
|
||||||
hasWallet: false,
|
hasWallet: false,
|
||||||
@@ -46,7 +52,10 @@ const DEFAULT_STATE = {
|
|||||||
// Every field written to and read from the single "autistmask" storage key.
|
// Every field written to and read from the single "autistmask" storage key.
|
||||||
// hasWallet is deliberately excluded from the diffing/merge logic in
|
// hasWallet is deliberately excluded from the diffing/merge logic in
|
||||||
// state.js — like loadState() does, it is always derived from `wallets`,
|
// state.js — like loadState() does, it is always derived from `wallets`,
|
||||||
// never carried as an independent value.
|
// never carried as an independent value. schemaVersion is excluded for the
|
||||||
|
// same reason and is absent from DEFAULT_STATE for it: it describes the
|
||||||
|
// record rather than being part of it, and every write stamps the current
|
||||||
|
// value rather than diffing whatever was read.
|
||||||
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
|
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
|
||||||
.filter((key) => key !== "hasWallet")
|
.filter((key) => key !== "hasWallet")
|
||||||
.concat([
|
.concat([
|
||||||
@@ -107,11 +116,32 @@ function restorableStack(stored, currentView) {
|
|||||||
function normalizePersisted(saved) {
|
function normalizePersisted(saved) {
|
||||||
saved = saved || {};
|
saved = saved || {};
|
||||||
const out = {};
|
const out = {};
|
||||||
|
// Every write goes out at the current version. That IS the migration for
|
||||||
|
// the unversioned records every install in the field holds: version 1 is
|
||||||
|
// the shape that shipped unversioned, so a record that validated is
|
||||||
|
// carried forward simply by being stamped. A record this build does NOT
|
||||||
|
// understand never reaches here — assertStateUsable() refuses it on the
|
||||||
|
// read path first (src/shared/stateSchema.js).
|
||||||
|
out.schemaVersion = STATE_SCHEMA_VERSION;
|
||||||
out.wallets = structuredClone(saved.wallets || []);
|
out.wallets = structuredClone(saved.wallets || []);
|
||||||
// Derived, never trusted verbatim off storage — see loadState().
|
// Derived, never trusted verbatim off storage — see loadState().
|
||||||
out.hasWallet = out.wallets.length > 0;
|
out.hasWallet = out.wallets.length > 0;
|
||||||
out.trackedTokens = structuredClone(saved.trackedTokens || []);
|
// An actual list is required, not merely a truthy value: everything
|
||||||
out.networkId = saved.networkId || DEFAULT_STATE.networkId;
|
// downstream iterates this and dereferences `token.address`, so a stored
|
||||||
|
// string or object walks through a `|| []` and throws on the first read
|
||||||
|
// — the blank popup from the issue, for a profile whose wallets are
|
||||||
|
// perfectly fine. An empty list is a legitimate value and survives.
|
||||||
|
out.trackedTokens = Array.isArray(saved.trackedTokens)
|
||||||
|
? structuredClone(saved.trackedTokens)
|
||||||
|
: [];
|
||||||
|
// The loud refusal for an unknown id is assertStateUsable(); this is the
|
||||||
|
// floor under it. networkId is an object KEY into networkEndpoints below,
|
||||||
|
// so a value that is not a network in networks.js must never get that far
|
||||||
|
// — "__proto__" would set the map's prototype instead of an own key, and
|
||||||
|
// the user's endpoint would silently not be recorded.
|
||||||
|
out.networkId = isKnownNetworkId(saved.networkId)
|
||||||
|
? saved.networkId
|
||||||
|
: DEFAULT_STATE.networkId;
|
||||||
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
|
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
|
||||||
out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
|
out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
|
||||||
// An actual object is required, not merely a truthy non-array: the code
|
// An actual object is required, not merely a truthy non-array: the code
|
||||||
@@ -127,7 +157,19 @@ function normalizePersisted(saved) {
|
|||||||
: {};
|
: {};
|
||||||
out.networkEndpoints = {};
|
out.networkEndpoints = {};
|
||||||
for (const netId of Object.keys(rawEndpoints)) {
|
for (const netId of Object.keys(rawEndpoints)) {
|
||||||
out.networkEndpoints[netId] = { ...rawEndpoints[netId] };
|
// defineProperty, not assignment: a stored map with an own
|
||||||
|
// "__proto__" key — which JSON can carry and assignment treats as the
|
||||||
|
// prototype setter — would otherwise replace this object's prototype
|
||||||
|
// and record no entry at all. Keys other than the known network ids
|
||||||
|
// are kept rather than dropped, so a profile that has been on a build
|
||||||
|
// with more networks does not lose their endpoints by passing through
|
||||||
|
// this one.
|
||||||
|
Object.defineProperty(out.networkEndpoints, netId, {
|
||||||
|
value: { ...rawEndpoints[netId] },
|
||||||
|
writable: true,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// A profile written before this map existed carries exactly one pair of
|
// A profile written before this map existed carries exactly one pair of
|
||||||
// endpoints, belonging to whatever network it was last on. Adopt it as
|
// endpoints, belonging to whatever network it was last on. Adopt it as
|
||||||
@@ -140,7 +182,13 @@ function normalizePersisted(saved) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
|
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
|
||||||
out.activeAddress = saved.activeAddress || null;
|
// Text or null, never anything else: this is passed to address.slice()
|
||||||
|
// and compared against stored addresses, so a stored number or object
|
||||||
|
// walks through a `|| null` and throws on the first render. The empty
|
||||||
|
// string is text and is kept as stored; every reader treats it as "none
|
||||||
|
// selected", which is what it is.
|
||||||
|
out.activeAddress =
|
||||||
|
typeof saved.activeAddress === "string" ? saved.activeAddress : null;
|
||||||
out.allowedSites =
|
out.allowedSites =
|
||||||
saved.allowedSites && !Array.isArray(saved.allowedSites)
|
saved.allowedSites && !Array.isArray(saved.allowedSites)
|
||||||
? structuredClone(saved.allowedSites)
|
? structuredClone(saved.allowedSites)
|
||||||
|
|||||||
@@ -17,7 +17,13 @@
|
|||||||
//
|
//
|
||||||
// Kept in its own module, with no dependencies, so tests can assert the
|
// Kept in its own module, with no dependencies, so tests can assert the
|
||||||
// exclusion directly rather than trusting a reading of the popup entry
|
// exclusion directly rather than trusting a reading of the popup entry
|
||||||
// point, which cannot be required outside a browser.
|
// point.
|
||||||
|
//
|
||||||
|
// It sits under src/shared/ rather than src/popup/ because
|
||||||
|
// src/shared/persistedState.js needs it and that module is in the BACKGROUND
|
||||||
|
// bundle: a popup-path module reached from the worker is the shape
|
||||||
|
// script/lib/forbiddenBundleInputs.js exists to keep out, whether or not the
|
||||||
|
// particular module is harmless.
|
||||||
const RESTORABLE_VIEWS = new Set([
|
const RESTORABLE_VIEWS = new Set([
|
||||||
"main",
|
"main",
|
||||||
"address",
|
"address",
|
||||||
@@ -29,6 +29,12 @@ const {
|
|||||||
normalizePersisted,
|
normalizePersisted,
|
||||||
} = require("./persistedState");
|
} = require("./persistedState");
|
||||||
|
|
||||||
|
const {
|
||||||
|
STATE_SCHEMA_VERSION,
|
||||||
|
assertStateUsable,
|
||||||
|
migrationNeeded,
|
||||||
|
} = require("./stateSchema");
|
||||||
|
|
||||||
const { storageGet, storageSet } = require("./browserApi");
|
const { storageGet, storageSet } = require("./browserApi");
|
||||||
const { log } = require("./log");
|
const { log } = require("./log");
|
||||||
|
|
||||||
@@ -446,6 +452,14 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
async function saveStateOnce() {
|
async function saveStateOnce() {
|
||||||
const current = snapshotPersisted();
|
const current = snapshotPersisted();
|
||||||
const result = await storageGet("autistmask");
|
const result = await storageGet("autistmask");
|
||||||
|
// The record in storage right now is about to be merged into and written
|
||||||
|
// back, so it is validated exactly like a load validates it. Without this,
|
||||||
|
// a page whose own load succeeded would normalize a record it does not
|
||||||
|
// understand — one a NEWER build wrote in the meantime, say — and write
|
||||||
|
// the result back over it, destroying the only copy of whatever that
|
||||||
|
// record held. Refusing is louder than that and loses nothing: the live
|
||||||
|
// state is untouched and the next save retries.
|
||||||
|
assertStateUsable(result.autistmask);
|
||||||
// Normalized, not raw: a field this page did not change still has to
|
// Normalized, not raw: a field this page did not change still has to
|
||||||
// come from storage in its loaded (self-healed) shape. See
|
// come from storage in its loaded (self-healed) shape. See
|
||||||
// normalizePersisted() in persistedState.js.
|
// normalizePersisted() in persistedState.js.
|
||||||
@@ -481,6 +495,10 @@ async function saveStateOnce() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
|
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
|
||||||
|
// Stamped on every write, never merged or diffed: the record that goes to
|
||||||
|
// storage is in THIS build's shape whatever shape it was read in, which is
|
||||||
|
// what migrates the unversioned records every install in the field holds.
|
||||||
|
merged.schemaVersion = STATE_SCHEMA_VERSION;
|
||||||
|
|
||||||
await storageSet({ autistmask: merged });
|
await storageSet({ autistmask: merged });
|
||||||
|
|
||||||
@@ -512,8 +530,24 @@ function saveState() {
|
|||||||
return turn;
|
return turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rejects with StateUnusableError for a stored record this build cannot make
|
||||||
|
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
|
||||||
|
// caller that ignores the rejection gets StateNotLoadedError on the first
|
||||||
|
// read rather than a half-populated profile. The caller that does NOT ignore
|
||||||
|
// it is the popup entry point, which shows the recovery screen
|
||||||
|
// (src/popup/views/stateRecovery.js) instead of proceeding.
|
||||||
async function loadState() {
|
async function loadState() {
|
||||||
const result = await storageGet("autistmask");
|
const result = await storageGet("autistmask");
|
||||||
|
// Before normalization, on the raw bytes: normalizing first would paper
|
||||||
|
// over the very shapes this refuses, which is how a corrupt record used to
|
||||||
|
// reach the popup and blank it (issue #311).
|
||||||
|
assertStateUsable(result.autistmask);
|
||||||
|
if (migrationNeeded(result.autistmask)) {
|
||||||
|
log.infof(
|
||||||
|
"state: migrating an unversioned profile to schema version",
|
||||||
|
STATE_SCHEMA_VERSION,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (result.autistmask) {
|
if (result.autistmask) {
|
||||||
Object.assign(rawState, normalizePersisted(result.autistmask));
|
Object.assign(rawState, normalizePersisted(result.autistmask));
|
||||||
}
|
}
|
||||||
|
|||||||
247
src/shared/stateSchema.js
Normal file
247
src/shared/stateSchema.js
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// The version stamped on the stored profile, and the shape check every read
|
||||||
|
// of one goes through.
|
||||||
|
//
|
||||||
|
// Storage is the one input to this extension that nobody validated. A profile
|
||||||
|
// carried no version at all, so there was no way to tell a record this build
|
||||||
|
// understands from one a later build wrote, and loadState() coerced scalars
|
||||||
|
// while trusting the structure — so a `wallets` that was a string, or an array
|
||||||
|
// of nulls, or a later schema's wallet records, reached the popup and threw on
|
||||||
|
// the first dereference. The popup rendered NOTHING: no view, no message, no
|
||||||
|
// control, and no way out from inside the product
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||||
|
//
|
||||||
|
// Two separate jobs, deliberately not merged:
|
||||||
|
//
|
||||||
|
// stateProblem() / assertStateUsable() refuse a record this build cannot
|
||||||
|
// safely reason about, loudly, naming the problem in a
|
||||||
|
// sentence that goes on screen. This is the gate.
|
||||||
|
// normalizePersisted() (persistedState.js) self-heal a record that IS
|
||||||
|
// usable: absent fields, legacy shapes, out-of-range flags.
|
||||||
|
//
|
||||||
|
// The gate runs FIRST, on the raw stored bytes, before normalization has a
|
||||||
|
// chance to paper over a record whose meaning nobody can vouch for. A blob
|
||||||
|
// that fails it is left in storage untouched — it is the user's only copy of
|
||||||
|
// whatever it holds, and the recovery screen exports it before offering to
|
||||||
|
// erase it.
|
||||||
|
//
|
||||||
|
// What is checked HERE is what nothing downstream can floor: the wallet list,
|
||||||
|
// the version, and the network id that keys an object. Every other field is
|
||||||
|
// normalizePersisted()'s to make safe, and that obligation is a TYPE CHECK,
|
||||||
|
// not a `saved.x || default` — a truthy value of the wrong type walks through
|
||||||
|
// truthiness and throws on the first dereference, which is the same blank
|
||||||
|
// popup, reached the long way round. Adding a field to the record means giving
|
||||||
|
// it a floor there or a check here; do not assume a default covers it.
|
||||||
|
|
||||||
|
const { isKnownNetworkId } = require("./networks");
|
||||||
|
|
||||||
|
// Bump this when the MEANING of a stored field changes, and add the migration
|
||||||
|
// that carries the older version forward. Adding a field with a defaulted
|
||||||
|
// absent value is not a bump: normalizePersisted() already handles that, and
|
||||||
|
// bumping for it would send every older install to the recovery screen for no
|
||||||
|
// reason.
|
||||||
|
//
|
||||||
|
// Version 1 is the shape that shipped unversioned. An unversioned record is
|
||||||
|
// therefore version 1, not a defect — see migrationNeeded() below.
|
||||||
|
const STATE_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
// Thrown by every read path that finds a record it cannot use. `problem` is
|
||||||
|
// the sentence shown to the user; `message` carries the same text so a log
|
||||||
|
// line or a rethrow is not empty.
|
||||||
|
class StateUnusableError extends Error {
|
||||||
|
constructor(problem) {
|
||||||
|
super(problem);
|
||||||
|
this.name = "StateUnusableError";
|
||||||
|
this.problem = problem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Own properties only, everywhere in this file. `saved` comes from storage as
|
||||||
|
// parsed JSON, so `saved.constructor` and `saved.__proto__` answer from the
|
||||||
|
// prototype chain for a record that carries neither — a check written as a
|
||||||
|
// plain truthiness test can be satisfied by Object.prototype rather than by
|
||||||
|
// anything the user's profile actually contains.
|
||||||
|
function has(obj, key) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinal(index) {
|
||||||
|
return String(index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeType(value) {
|
||||||
|
if (value === null) return "null";
|
||||||
|
if (Array.isArray(value)) return "a list";
|
||||||
|
return "a " + typeof value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One address record, as every screen dereferences it.
|
||||||
|
function addressProblem(addr, walletIndex, addrIndex) {
|
||||||
|
const where =
|
||||||
|
"address " +
|
||||||
|
ordinal(addrIndex) +
|
||||||
|
" of wallet " +
|
||||||
|
ordinal(walletIndex) +
|
||||||
|
" in the saved data";
|
||||||
|
if (!isPlainObject(addr)) {
|
||||||
|
return "The " + where + " is " + describeType(addr) + ", not a record.";
|
||||||
|
}
|
||||||
|
if (typeof addr.address !== "string" || addr.address === "") {
|
||||||
|
return "The " + where + " has no address.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walletProblem(wallet, index) {
|
||||||
|
const where = "Wallet " + ordinal(index) + " in the saved data";
|
||||||
|
if (!isPlainObject(wallet)) {
|
||||||
|
return where + " is " + describeType(wallet) + ", not a wallet record.";
|
||||||
|
}
|
||||||
|
if (!Array.isArray(wallet.addresses)) {
|
||||||
|
return where + " has no list of addresses.";
|
||||||
|
}
|
||||||
|
if (has(wallet, "name") && typeof wallet.name !== "string") {
|
||||||
|
return where + " has a name that is not text.";
|
||||||
|
}
|
||||||
|
for (let i = 0; i < wallet.addresses.length; i++) {
|
||||||
|
const problem = addressProblem(wallet.addresses[i], index, i);
|
||||||
|
if (problem) return problem;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionProblem(saved) {
|
||||||
|
// No version field at all is the shape every install in the field has:
|
||||||
|
// no build ever wrote one. It is version 1, and it is migrated in place.
|
||||||
|
if (!has(saved, "schemaVersion")) return null;
|
||||||
|
const version = saved.schemaVersion;
|
||||||
|
if (
|
||||||
|
typeof version !== "number" ||
|
||||||
|
!Number.isInteger(version) ||
|
||||||
|
version < 1
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
"The saved data carries a schema version AutistMask does not" +
|
||||||
|
" recognize (" +
|
||||||
|
JSON.stringify(version) +
|
||||||
|
")."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (version > STATE_SCHEMA_VERSION) {
|
||||||
|
return (
|
||||||
|
"The saved data was written by a newer version of AutistMask" +
|
||||||
|
" (schema version " +
|
||||||
|
version +
|
||||||
|
"; this build understands version " +
|
||||||
|
STATE_SCHEMA_VERSION +
|
||||||
|
")."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reason this build cannot use `saved`, as a sentence for the user, or
|
||||||
|
* null when it can.
|
||||||
|
*
|
||||||
|
* @param {*} saved the raw record from storage, or undefined for a fresh
|
||||||
|
* install.
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function stateProblem(saved) {
|
||||||
|
// Nothing stored is a first run, not a defect.
|
||||||
|
if (saved === undefined || saved === null) return null;
|
||||||
|
if (!isPlainObject(saved)) {
|
||||||
|
return (
|
||||||
|
"The saved data is " +
|
||||||
|
describeType(saved) +
|
||||||
|
", not the record AutistMask stores."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = versionProblem(saved);
|
||||||
|
if (version) return version;
|
||||||
|
|
||||||
|
// Read once, from an OWN property or not at all, so that a polluted
|
||||||
|
// prototype cannot decide whether a profile is refused. Note that
|
||||||
|
// normalizePersisted() reads the same field plainly, and so WOULD consult
|
||||||
|
// the prototype chain: the two halves agree only because a record arriving
|
||||||
|
// from storage has been through structuredClone and always carries
|
||||||
|
// Object.prototype. Nothing reachable from storage can put them at odds,
|
||||||
|
// but a caller that hands either one a hand-built object with an unusual
|
||||||
|
// prototype is not covered by that.
|
||||||
|
const wallets =
|
||||||
|
has(saved, "wallets") && saved.wallets !== undefined
|
||||||
|
? saved.wallets
|
||||||
|
: [];
|
||||||
|
if (!Array.isArray(wallets)) {
|
||||||
|
return (
|
||||||
|
"The list of wallets in the saved data is " +
|
||||||
|
describeType(wallets) +
|
||||||
|
", not a list."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < wallets.length; i++) {
|
||||||
|
const problem = walletProblem(wallets[i], i);
|
||||||
|
if (problem) return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkId is not merely displayed: it is an object KEY into
|
||||||
|
// state.networkEndpoints. A corrupt "__proto__" would set that map's
|
||||||
|
// prototype instead of an own key, so the user's endpoint would silently
|
||||||
|
// not be recorded and a switch away and back would return the public
|
||||||
|
// default. isKnownNetworkId() is an own-property test against the network
|
||||||
|
// table for exactly that reason.
|
||||||
|
if (
|
||||||
|
has(saved, "networkId") &&
|
||||||
|
saved.networkId !== undefined &&
|
||||||
|
!isKnownNetworkId(saved.networkId)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
"The saved data selects a network AutistMask does not know (" +
|
||||||
|
JSON.stringify(saved.networkId) +
|
||||||
|
")."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse a record this build cannot use.
|
||||||
|
*
|
||||||
|
* @param {*} saved the raw record from storage.
|
||||||
|
* @throws {StateUnusableError}
|
||||||
|
*/
|
||||||
|
function assertStateUsable(saved) {
|
||||||
|
const problem = stateProblem(saved);
|
||||||
|
if (problem) throw new StateUnusableError(problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `saved` is a usable record written before versions existed, and so
|
||||||
|
* gets the current version stamped on it the next time anything writes. Purely
|
||||||
|
* informational — the migration itself is that stamp, since version 1 IS the
|
||||||
|
* unversioned shape.
|
||||||
|
*
|
||||||
|
* @param {*} saved
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function migrationNeeded(saved) {
|
||||||
|
return (
|
||||||
|
isPlainObject(saved) &&
|
||||||
|
!has(saved, "schemaVersion") &&
|
||||||
|
stateProblem(saved) === null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
STATE_SCHEMA_VERSION,
|
||||||
|
StateUnusableError,
|
||||||
|
assertStateUsable,
|
||||||
|
migrationNeeded,
|
||||||
|
stateProblem,
|
||||||
|
};
|
||||||
@@ -397,8 +397,23 @@ describe("balance refresh steady-state cadence", () => {
|
|||||||
return {
|
return {
|
||||||
autistmask: {
|
autistmask: {
|
||||||
hasWallet: true,
|
hasWallet: true,
|
||||||
|
// A whole wallet record, not a bare address: a stored profile
|
||||||
|
// is validated against the schema on every read now
|
||||||
|
// (src/shared/stateSchema.js), and a wallet with no address
|
||||||
|
// list is one of the shapes that refuses to load.
|
||||||
wallets: [
|
wallets: [
|
||||||
{ address: "0x0000000000000000000000000000000000000001" },
|
{
|
||||||
|
name: "Wallet 1",
|
||||||
|
type: "hd",
|
||||||
|
addresses: [
|
||||||
|
{
|
||||||
|
address:
|
||||||
|
"0x0000000000000000000000000000000000000001",
|
||||||
|
balance: "0",
|
||||||
|
tokenBalances: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
lastBalanceRefresh: 0,
|
lastBalanceRefresh: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -175,8 +175,22 @@ function loadBackground(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const persisted = {
|
const persisted = {
|
||||||
|
// Address RECORDS, not bare address strings: a stored profile is
|
||||||
|
// validated against the schema on every read now
|
||||||
|
// (src/shared/stateSchema.js), and a bare string where a record
|
||||||
|
// belongs is one of the shapes that refuses to load.
|
||||||
wallets: [
|
wallets: [
|
||||||
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
|
{
|
||||||
|
name: "Wallet 1",
|
||||||
|
type: "hd",
|
||||||
|
addresses: [
|
||||||
|
{
|
||||||
|
address: signer.address,
|
||||||
|
balance: "0",
|
||||||
|
tokenBalances: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
networkId: "mainnet",
|
networkId: "mainnet",
|
||||||
rpcUrl: "https://rpc.invalid",
|
rpcUrl: "https://rpc.invalid",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ jest.mock("../src/shared/vault", () => ({
|
|||||||
decryptWithPassword: jest.fn(),
|
decryptWithPassword: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
|
|
||||||
const VIEW = "delete-wallet-lost-password";
|
const VIEW = "delete-wallet-lost-password";
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jest.mock("../src/shared/wallet", () => ({
|
|||||||
getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
|
getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||||
|
|
||||||
const VIEW = "export-privkey";
|
const VIEW = "export-privkey";
|
||||||
const PASSWORD = "correct horse battery";
|
const PASSWORD = "correct horse battery";
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const { walletHasRecoveryPhrase } = require("../src/shared/wallet");
|
const { walletHasRecoveryPhrase } = require("../src/shared/wallet");
|
||||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||||
|
|
||||||
const SHOW_PHRASE_VIEW = "show-phrase";
|
const SHOW_PHRASE_VIEW = "show-phrase";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
|
// Address RECORDS, not bare address strings. A stored profile is validated
|
||||||
|
// against the schema on every read now (src/shared/stateSchema.js), and a bare
|
||||||
|
// string where an address record belongs is one of the shapes that refuses to
|
||||||
|
// load — as it should, since every screen dereferences addr.address.
|
||||||
function oneWallet() {
|
function oneWallet() {
|
||||||
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
|
return [
|
||||||
|
{
|
||||||
|
name: "Wallet 1",
|
||||||
|
type: "hd",
|
||||||
|
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
|
|||||||
501
tests/stateRecovery.test.js
Normal file
501
tests/stateRecovery.test.js
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
// A stored profile the popup cannot read must produce a SCREEN, not a blank
|
||||||
|
// popup (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||||
|
//
|
||||||
|
// The three corrupt blobs below are the ones the pre-1.0 audit wrote into
|
||||||
|
// storage. Against the build this file was added to, each one rendered nothing
|
||||||
|
// at all — no view, no message, no control — because init() dereferenced
|
||||||
|
// `state.wallets[0].addresses` on a record nothing had validated and threw
|
||||||
|
// before the first showView().
|
||||||
|
//
|
||||||
|
// So the assertions here are deliberately made through the REAL popup entry
|
||||||
|
// point rather than against the recovery view module directly. A recovery
|
||||||
|
// screen that renders perfectly when something calls it, and that nothing
|
||||||
|
// calls, is exactly the defect: what has to be true is that BOOTING the popup
|
||||||
|
// on a bad blob lands on it.
|
||||||
|
//
|
||||||
|
// The DOM stub is built FROM src/popup/index.html — every id in the markup,
|
||||||
|
// with the classes the markup gives it — so "which views are visible" is
|
||||||
|
// answered against the real element set, and a recovery screen with no markup
|
||||||
|
// behind it cannot pass.
|
||||||
|
//
|
||||||
|
// The fourth case is the upgrade one, and it is the case that must NOT reach
|
||||||
|
// the recovery screen: every install in the field has a valid profile with no
|
||||||
|
// version field, and showing those users a wipe prompt would be a worse defect
|
||||||
|
// than the one being fixed. It is migrated in place and keeps working.
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
|
|
||||||
|
const POPUP_HTML = fs.readFileSync(
|
||||||
|
path.join(__dirname, "..", "src", "popup", "index.html"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fixed address, never used for anything but these tests.
|
||||||
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- fixtures
|
||||||
|
|
||||||
|
// A profile in the shape every install in the field has it: complete, valid,
|
||||||
|
// and carrying no version field, because no build ever wrote one.
|
||||||
|
function unversionedValidProfile() {
|
||||||
|
return {
|
||||||
|
hasWallet: true,
|
||||||
|
wallets: [
|
||||||
|
{
|
||||||
|
type: "hd",
|
||||||
|
name: "Wallet 1",
|
||||||
|
xpub: "xpub-wallet-1",
|
||||||
|
encryptedSecret: "encrypted-secret-1",
|
||||||
|
nextIndex: 1,
|
||||||
|
addresses: [
|
||||||
|
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeAddress: ADDRESS,
|
||||||
|
networkId: "mainnet",
|
||||||
|
rpcUrl: "https://ethereum-rpc.publicnode.com",
|
||||||
|
blockscoutUrl: "https://eth.blockscout.com/api/v2",
|
||||||
|
allowedSites: { [ADDRESS]: ["dapp.example"] },
|
||||||
|
deniedSites: {},
|
||||||
|
trackedTokens: [],
|
||||||
|
theme: "system",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three blobs from the issue, each with the error it produced.
|
||||||
|
const CORRUPT_BLOBS = [
|
||||||
|
{
|
||||||
|
name: "wallets is a string",
|
||||||
|
blob: { hasWallet: true, wallets: ADDRESS, activeAddress: ADDRESS },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wallets is an array of garbage",
|
||||||
|
blob: {
|
||||||
|
hasWallet: true,
|
||||||
|
wallets: [null, 42, "wallet"],
|
||||||
|
activeAddress: ADDRESS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "future-schema blob (unknown fields, no version)",
|
||||||
|
blob: {
|
||||||
|
hasWallet: true,
|
||||||
|
// A later schema that renamed the field and moved the key
|
||||||
|
// material, written by a build this one knows nothing about, and
|
||||||
|
// stamped with no version because this build never wrote one.
|
||||||
|
wallets: [
|
||||||
|
{
|
||||||
|
id: "wallet-1",
|
||||||
|
label: "Wallet 1",
|
||||||
|
accounts: [{ addr: ADDRESS, wei: "0x0" }],
|
||||||
|
keyring: { kind: "hd", vault: "…" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
profileFormat: "am-2",
|
||||||
|
activeAccount: ADDRESS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- DOM stub
|
||||||
|
|
||||||
|
function makeElement(id, className) {
|
||||||
|
const classes = new Set(
|
||||||
|
(className || "").split(/\s+/).filter((name) => name !== ""),
|
||||||
|
);
|
||||||
|
const el = {
|
||||||
|
id,
|
||||||
|
tagName: "DIV",
|
||||||
|
textContent: "",
|
||||||
|
value: "",
|
||||||
|
innerHTML: "",
|
||||||
|
href: "",
|
||||||
|
download: "",
|
||||||
|
disabled: false,
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
listeners: {},
|
||||||
|
clicked: 0,
|
||||||
|
classList: {
|
||||||
|
add: (...names) => names.forEach((n) => classes.add(n)),
|
||||||
|
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
||||||
|
contains: (n) => classes.has(n),
|
||||||
|
toggle: (n, force) => {
|
||||||
|
const on = force === undefined ? !classes.has(n) : force;
|
||||||
|
if (on) classes.add(n);
|
||||||
|
else classes.delete(n);
|
||||||
|
return on;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addEventListener: (name, fn) => {
|
||||||
|
el.listeners[name] = el.listeners[name] || [];
|
||||||
|
el.listeners[name].push(fn);
|
||||||
|
},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
appendChild: () => {},
|
||||||
|
remove: () => {},
|
||||||
|
focus: () => {},
|
||||||
|
select: () => {},
|
||||||
|
setAttribute: (name, value) => {
|
||||||
|
el[name] = value;
|
||||||
|
},
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
click: () => {
|
||||||
|
el.clicked += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every id in the markup, with the classes the markup gives it. A view the
|
||||||
|
// popup is supposed to reveal has to exist here, which means it has to exist
|
||||||
|
// in src/popup/index.html.
|
||||||
|
function idsFromHtml(html) {
|
||||||
|
const out = new Map();
|
||||||
|
const tags = html.match(/<[a-zA-Z][^>]*>/g) || [];
|
||||||
|
for (const tag of tags) {
|
||||||
|
const id = /\bid="([^"]+)"/.exec(tag);
|
||||||
|
if (!id) continue;
|
||||||
|
const cls = /\bclass="([^"]*)"/.exec(tag);
|
||||||
|
out.set(id[1], cls ? cls[1] : "");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDocument(html) {
|
||||||
|
const authored = idsFromHtml(html);
|
||||||
|
const els = new Map();
|
||||||
|
for (const [id, className] of authored) {
|
||||||
|
els.set(id, makeElement(id, className));
|
||||||
|
}
|
||||||
|
const created = [];
|
||||||
|
const doc = {
|
||||||
|
listeners: {},
|
||||||
|
getElementById(id) {
|
||||||
|
// Created on demand by updateDebugBanner(); absent is the state a
|
||||||
|
// non-debug, non-testnet popup is in.
|
||||||
|
if (id === "debug-banner") return null;
|
||||||
|
if (!els.has(id)) els.set(id, makeElement(id, ""));
|
||||||
|
return els.get(id);
|
||||||
|
},
|
||||||
|
createElement(tag) {
|
||||||
|
const el = makeElement("created-" + tag, "");
|
||||||
|
el.tagName = String(tag).toUpperCase();
|
||||||
|
created.push(el);
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
addEventListener(name, fn) {
|
||||||
|
doc.listeners[name] = doc.listeners[name] || [];
|
||||||
|
doc.listeners[name].push(fn);
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
documentElement: makeElement("html", ""),
|
||||||
|
body: {
|
||||||
|
prepend: () => {},
|
||||||
|
appendChild: () => {},
|
||||||
|
removeChild: () => {},
|
||||||
|
},
|
||||||
|
elements: els,
|
||||||
|
authoredIds: authored,
|
||||||
|
created,
|
||||||
|
};
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- harness
|
||||||
|
|
||||||
|
// Boot the real popup entry point over `stored`, exactly as the browser does:
|
||||||
|
// storage already holds the record, the page loads, DOMContentLoaded fires.
|
||||||
|
async function bootPopup(stored) {
|
||||||
|
jest.resetModules();
|
||||||
|
|
||||||
|
// The two modules that reach the network. Neither is on the path under
|
||||||
|
// test; both would make this suite hit the internet.
|
||||||
|
jest.doMock("../src/shared/prices", () => ({
|
||||||
|
prices: {},
|
||||||
|
refreshPrices: jest.fn(async () => {}),
|
||||||
|
clearPrices: jest.fn(),
|
||||||
|
getPrice: () => null,
|
||||||
|
formatUsd: () => "",
|
||||||
|
formatAddressTotal: () => "",
|
||||||
|
getAddressValue: () => ({ usd: null, partial: false }),
|
||||||
|
getWalletValue: () => ({ usd: null, partial: false }),
|
||||||
|
getTotalValue: () => ({ usd: null, partial: false }),
|
||||||
|
}));
|
||||||
|
jest.doMock("../src/shared/balances", () => ({
|
||||||
|
fetchTokenBalances: jest.fn(async () => []),
|
||||||
|
refreshBalances: jest.fn(async () => {}),
|
||||||
|
lookupTokenInfo: jest.fn(async () => null),
|
||||||
|
getProvider: () => ({}),
|
||||||
|
scanForAddresses: jest.fn(async () => []),
|
||||||
|
}));
|
||||||
|
jest.doMock("../src/shared/transactions", () => ({
|
||||||
|
fetchRecentTransactions: jest.fn(async () => []),
|
||||||
|
filterTransactions: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const storage = makeStorageStub(
|
||||||
|
stored === undefined ? {} : { autistmask: stored },
|
||||||
|
);
|
||||||
|
const document = makeDocument(POPUP_HTML);
|
||||||
|
const reloads = [];
|
||||||
|
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: storage.local },
|
||||||
|
runtime: {
|
||||||
|
sendMessage: jest.fn(async () => ({})),
|
||||||
|
getURL: (p) => "chrome-extension://autistmask/" + p,
|
||||||
|
onMessage: { addListener: () => {} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.document = document;
|
||||||
|
globalThis.window = {
|
||||||
|
location: {
|
||||||
|
search: "",
|
||||||
|
href: "chrome-extension://autistmask/src/popup/index.html",
|
||||||
|
reload: () => reloads.push(Date.now()),
|
||||||
|
},
|
||||||
|
matchMedia: () => ({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
}),
|
||||||
|
addEventListener: () => {},
|
||||||
|
};
|
||||||
|
// The 10s refresh loop init() starts would outlive the test.
|
||||||
|
const realSetInterval = globalThis.setInterval;
|
||||||
|
globalThis.setInterval = () => 0;
|
||||||
|
|
||||||
|
require("../src/popup/index");
|
||||||
|
|
||||||
|
const booted = [];
|
||||||
|
for (const fn of document.listeners.DOMContentLoaded || []) {
|
||||||
|
booted.push(fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the browser console would have shown. A throw out of init() is the
|
||||||
|
// blank popup this issue is about, so it is captured rather than thrown:
|
||||||
|
// the assertion that matters is what ended up on screen.
|
||||||
|
const pageErrors = [];
|
||||||
|
for (const p of booted) {
|
||||||
|
try {
|
||||||
|
await p;
|
||||||
|
} catch (e) {
|
||||||
|
pageErrors.push(String((e && e.message) || e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
globalThis.setInterval = realSetInterval;
|
||||||
|
|
||||||
|
return {
|
||||||
|
storage,
|
||||||
|
document,
|
||||||
|
pageErrors,
|
||||||
|
reloaded: () => reloads.length,
|
||||||
|
node: (id) => document.getElementById(id),
|
||||||
|
text: (id) => document.getElementById(id).textContent,
|
||||||
|
value: (id) => document.getElementById(id).value,
|
||||||
|
hidden: (id) =>
|
||||||
|
document.getElementById(id).classList.contains("hidden"),
|
||||||
|
click: async (id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const fns = el.listeners.click || [];
|
||||||
|
for (const fn of fns) await fn();
|
||||||
|
await settle();
|
||||||
|
},
|
||||||
|
// The view ids whose section is not hidden, as the audit measured them.
|
||||||
|
visibleViews: () => {
|
||||||
|
const out = [];
|
||||||
|
for (const [id, el] of document.elements) {
|
||||||
|
if (!id.startsWith("view-")) continue;
|
||||||
|
if (!el.classList.contains("hidden")) out.push(id.slice(5));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete globalThis.chrome;
|
||||||
|
delete globalThis.document;
|
||||||
|
delete globalThis.window;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- tests
|
||||||
|
|
||||||
|
describe("a stored profile the popup cannot read", () => {
|
||||||
|
for (const { name, blob } of CORRUPT_BLOBS) {
|
||||||
|
test(`${name}: the recovery screen, not a blank popup`, async () => {
|
||||||
|
const env = await bootPopup(blob);
|
||||||
|
|
||||||
|
// Asserted together, and in the audit's own shape: a failure here
|
||||||
|
// prints both what was on screen and what the console said, which
|
||||||
|
// is the pair that identifies this defect.
|
||||||
|
expect({
|
||||||
|
visibleViews: env.visibleViews(),
|
||||||
|
errors: env.pageErrors,
|
||||||
|
}).toEqual({ visibleViews: ["state-recovery"], errors: [] });
|
||||||
|
|
||||||
|
// The screen has to NAME the problem. A blank recovery screen is
|
||||||
|
// the same dead end with a border around it.
|
||||||
|
expect(env.text("state-recovery-problem").length).toBeGreaterThan(
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the Settings gear is hidden, since every screen behind it reads the profile", async () => {
|
||||||
|
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
|
||||||
|
|
||||||
|
expect(env.hidden("btn-settings")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it does not write over the record it could not read", async () => {
|
||||||
|
// The blob is evidence, and possibly the only copy of key material in
|
||||||
|
// a shape a later build could recover. A boot that normalized it back
|
||||||
|
// into storage would destroy exactly that.
|
||||||
|
const env = await bootPopup(CORRUPT_BLOBS[2].blob);
|
||||||
|
|
||||||
|
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[2].blob);
|
||||||
|
expect(env.storage.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the export on the recovery screen", () => {
|
||||||
|
test("hands back the raw stored record verbatim", async () => {
|
||||||
|
const env = await bootPopup(CORRUPT_BLOBS[2].blob);
|
||||||
|
|
||||||
|
await env.click("btn-state-recovery-export");
|
||||||
|
|
||||||
|
// Shown in the page, which always works, whatever the browser does
|
||||||
|
// with a download from an extension popup.
|
||||||
|
expect(env.hidden("state-recovery-blob")).toBe(false);
|
||||||
|
expect(JSON.parse(env.value("state-recovery-blob"))).toEqual(
|
||||||
|
CORRUPT_BLOBS[2].blob,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the destructive reset on the recovery screen", () => {
|
||||||
|
test("erases nothing without the typed confirmation", async () => {
|
||||||
|
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
|
||||||
|
|
||||||
|
env.node("state-recovery-reset-input").value = "yes";
|
||||||
|
await env.click("btn-state-recovery-reset");
|
||||||
|
|
||||||
|
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
|
||||||
|
expect(env.text("state-recovery-flash").length).toBeGreaterThan(10);
|
||||||
|
expect(env.reloaded()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("erases the stored profile once the phrase is typed", async () => {
|
||||||
|
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
|
||||||
|
|
||||||
|
env.node("state-recovery-reset-input").value = "erase my wallet";
|
||||||
|
await env.click("btn-state-recovery-reset");
|
||||||
|
|
||||||
|
expect(env.storage.read("autistmask")).toBeUndefined();
|
||||||
|
expect(env.reloaded()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("an unversioned profile that is perfectly valid", () => {
|
||||||
|
// The upgrade case. Every install in the field is in this state, and the
|
||||||
|
// popup must load it, not offer to wipe it.
|
||||||
|
test("boots to the wallet list, not the recovery screen", async () => {
|
||||||
|
const env = await bootPopup(unversionedValidProfile());
|
||||||
|
|
||||||
|
expect(env.visibleViews()).toEqual(["main"]);
|
||||||
|
expect(env.pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is migrated in place: the version is stamped, the wallet survives", async () => {
|
||||||
|
const env = await bootPopup(unversionedValidProfile());
|
||||||
|
|
||||||
|
const stored = env.storage.read("autistmask");
|
||||||
|
expect(stored.schemaVersion).toBe(1);
|
||||||
|
expect(stored.wallets).toHaveLength(1);
|
||||||
|
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
||||||
|
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
|
||||||
|
expect(stored.activeAddress).toBe(ADDRESS);
|
||||||
|
expect(stored.allowedSites).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a first run with nothing in storage", () => {
|
||||||
|
test("boots to Welcome", async () => {
|
||||||
|
const env = await bootPopup(undefined);
|
||||||
|
|
||||||
|
expect(env.visibleViews()).toEqual(["welcome"]);
|
||||||
|
expect(env.pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a garbage value in a field the gate does not check", () => {
|
||||||
|
// The gate refuses only what nothing can floor: the wallet list, the
|
||||||
|
// version, the network key. Everything else is normalizePersisted()'s job,
|
||||||
|
// and where that job was written as `saved.x || default` rather than a
|
||||||
|
// type check, a TRUTHY value of the wrong type walked straight through and
|
||||||
|
// threw on the first dereference — the same blank popup this issue is
|
||||||
|
// about, measured the same way. Both of these did, at 2e2ecf9:
|
||||||
|
//
|
||||||
|
// trackedTokens: "nope" -> views=[] "Cannot read properties of
|
||||||
|
// undefined (reading 'toLowerCase')"
|
||||||
|
// activeAddress: 42 -> views=[] "address.slice is not a function"
|
||||||
|
//
|
||||||
|
// These belong on the floor rather than in the gate: neither value carries
|
||||||
|
// key material, both have a sane default, and sending a user whose wallets
|
||||||
|
// are perfectly readable to an export-or-erase screen over a broken token
|
||||||
|
// list would destroy more than it saves.
|
||||||
|
const CORRUPT_FIELDS = [
|
||||||
|
{ name: "trackedTokens is a string", patch: { trackedTokens: "nope" } },
|
||||||
|
{ name: "trackedTokens is a number", patch: { trackedTokens: 42 } },
|
||||||
|
{
|
||||||
|
name: "trackedTokens is an object",
|
||||||
|
patch: { trackedTokens: { a: 1 } },
|
||||||
|
},
|
||||||
|
{ name: "activeAddress is a number", patch: { activeAddress: 42 } },
|
||||||
|
{
|
||||||
|
name: "activeAddress is an object",
|
||||||
|
patch: { activeAddress: { a: 1 } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, patch } of CORRUPT_FIELDS) {
|
||||||
|
test(`${name}: a working popup, not a blank one`, async () => {
|
||||||
|
const env = await bootPopup(
|
||||||
|
Object.assign(unversionedValidProfile(), patch),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect({
|
||||||
|
visibleViews: env.visibleViews(),
|
||||||
|
errors: env.pageErrors,
|
||||||
|
}).toEqual({ visibleViews: ["main"], errors: [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the wallet is intact afterwards, and the bad value is gone", async () => {
|
||||||
|
const env = await bootPopup(
|
||||||
|
Object.assign(unversionedValidProfile(), {
|
||||||
|
trackedTokens: "nope",
|
||||||
|
activeAddress: 42,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const stored = env.storage.read("autistmask");
|
||||||
|
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
||||||
|
expect(stored.trackedTokens).toEqual([]);
|
||||||
|
// Floored to null, then filled in by init()'s auto-default.
|
||||||
|
expect(stored.activeAddress).toBe(ADDRESS);
|
||||||
|
});
|
||||||
|
});
|
||||||
360
tests/stateSchema.test.js
Normal file
360
tests/stateSchema.test.js
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
// The stored-profile version stamp and the shape gate in front of it
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||||
|
//
|
||||||
|
// tests/stateRecovery.test.js covers what the USER sees when a record is
|
||||||
|
// refused. This file covers what is refused and what is not, which is the
|
||||||
|
// half that decides whether an upgrade brick or a false alarm ever happens:
|
||||||
|
// a gate that refuses too much sends a perfectly good wallet to a wipe prompt,
|
||||||
|
// and one that refuses too little is the blank popup again.
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const {
|
||||||
|
STATE_SCHEMA_VERSION,
|
||||||
|
StateUnusableError,
|
||||||
|
assertStateUsable,
|
||||||
|
migrationNeeded,
|
||||||
|
stateProblem,
|
||||||
|
} = require("../src/shared/stateSchema");
|
||||||
|
const {
|
||||||
|
NETWORKS,
|
||||||
|
UnknownNetworkError,
|
||||||
|
isKnownNetworkId,
|
||||||
|
networkById,
|
||||||
|
} = require("../src/shared/networks");
|
||||||
|
const { normalizePersisted } = require("../src/shared/persistedState");
|
||||||
|
const { RESET_PHRASE } = require("../src/popup/views/stateRecovery");
|
||||||
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
|
|
||||||
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
|
function validProfile(extra) {
|
||||||
|
return {
|
||||||
|
hasWallet: true,
|
||||||
|
wallets: [
|
||||||
|
{
|
||||||
|
type: "hd",
|
||||||
|
name: "Wallet 1",
|
||||||
|
xpub: "xpub-wallet-1",
|
||||||
|
encryptedSecret: "encrypted-secret-1",
|
||||||
|
nextIndex: 1,
|
||||||
|
addresses: [
|
||||||
|
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeAddress: ADDRESS,
|
||||||
|
networkId: "mainnet",
|
||||||
|
...(extra || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.chrome;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what the gate accepts", () => {
|
||||||
|
test("nothing stored at all is a first run, not a defect", () => {
|
||||||
|
expect(stateProblem(undefined)).toBeNull();
|
||||||
|
expect(stateProblem(null)).toBeNull();
|
||||||
|
expect(stateProblem({})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a valid profile with no version field is accepted and migrated", () => {
|
||||||
|
const saved = validProfile();
|
||||||
|
|
||||||
|
expect(stateProblem(saved)).toBeNull();
|
||||||
|
expect(migrationNeeded(saved)).toBe(true);
|
||||||
|
// The migration IS the stamp: version 1 is the shape that shipped
|
||||||
|
// unversioned, so nothing about the record has to change.
|
||||||
|
expect(normalizePersisted(saved).schemaVersion).toBe(
|
||||||
|
STATE_SCHEMA_VERSION,
|
||||||
|
);
|
||||||
|
expect(normalizePersisted(saved).wallets).toEqual(saved.wallets);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a profile already at the current version needs no migration", () => {
|
||||||
|
const saved = validProfile({ schemaVersion: STATE_SCHEMA_VERSION });
|
||||||
|
|
||||||
|
expect(stateProblem(saved)).toBeNull();
|
||||||
|
expect(migrationNeeded(saved)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty wallet list is fine", () => {
|
||||||
|
expect(stateProblem({ hasWallet: false, wallets: [] })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown extra fields alone are not a defect", () => {
|
||||||
|
// Only a change in the MEANING of a stored field is a version bump, so
|
||||||
|
// a field this build does not know must not be a refusal on its own —
|
||||||
|
// otherwise a downgrade would wipe a working wallet.
|
||||||
|
expect(stateProblem(validProfile({ somethingNew: 42 }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what the gate refuses", () => {
|
||||||
|
test("a record that is not the record AutistMask stores", () => {
|
||||||
|
expect(stateProblem("wallet")).toMatch(/not the record/);
|
||||||
|
expect(stateProblem([1, 2])).toMatch(/not the record/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a version from a newer build, naming both versions", () => {
|
||||||
|
const problem = stateProblem(
|
||||||
|
validProfile({ schemaVersion: STATE_SCHEMA_VERSION + 1 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(problem).toMatch(/newer version/);
|
||||||
|
expect(problem).toContain(String(STATE_SCHEMA_VERSION + 1));
|
||||||
|
expect(problem).toContain(String(STATE_SCHEMA_VERSION));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a version that is not a version at all", () => {
|
||||||
|
for (const version of ["1", 1.5, 0, -1, null, {}]) {
|
||||||
|
expect(
|
||||||
|
stateProblem(validProfile({ schemaVersion: version })),
|
||||||
|
).toMatch(/schema version/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wallets that is not a list", () => {
|
||||||
|
expect(stateProblem({ wallets: ADDRESS })).toMatch(/not a list/);
|
||||||
|
expect(stateProblem({ wallets: { 0: {} } })).toMatch(/not a list/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a wallet that is not a wallet record", () => {
|
||||||
|
expect(stateProblem({ wallets: [null] })).toMatch(/wallet record/);
|
||||||
|
expect(stateProblem({ wallets: [42] })).toMatch(/wallet record/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a wallet whose addresses are missing or not records", () => {
|
||||||
|
expect(stateProblem({ wallets: [{ name: "Wallet 1" }] })).toMatch(
|
||||||
|
/no list of addresses/,
|
||||||
|
);
|
||||||
|
expect(stateProblem({ wallets: [{ addresses: [ADDRESS] }] })).toMatch(
|
||||||
|
/not a record/,
|
||||||
|
);
|
||||||
|
expect(stateProblem({ wallets: [{ addresses: [{}] }] })).toMatch(
|
||||||
|
/no address/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the problem names WHICH wallet, counting from one", () => {
|
||||||
|
const problem = stateProblem({
|
||||||
|
wallets: [validProfile().wallets[0], { name: "Wallet 2" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(problem).toContain("Wallet 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assertStateUsable throws the sentence, not a generic error", () => {
|
||||||
|
let thrown = null;
|
||||||
|
try {
|
||||||
|
assertStateUsable({ wallets: ADDRESS });
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toBeInstanceOf(StateUnusableError);
|
||||||
|
expect(thrown.problem).toMatch(/not a list/);
|
||||||
|
expect(thrown.message).toBe(thrown.problem);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("networkId, which is used as an object key", () => {
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/311#issuecomment-67478:
|
||||||
|
// state.networkId keys state.networkEndpoints, so a corrupt "__proto__"
|
||||||
|
// sets that map's prototype instead of an own key and the user's endpoint
|
||||||
|
// is silently not recorded.
|
||||||
|
test("a network this build does not know is refused", () => {
|
||||||
|
expect(stateProblem(validProfile({ networkId: "base" }))).toMatch(
|
||||||
|
/does not know/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"__proto__" and "constructor" are refused, not resolved', () => {
|
||||||
|
for (const id of ["__proto__", "constructor", "toString"]) {
|
||||||
|
expect(stateProblem(validProfile({ networkId: id }))).toMatch(
|
||||||
|
/does not know/,
|
||||||
|
);
|
||||||
|
expect(isKnownNetworkId(id)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the gate reads own properties only", () => {
|
||||||
|
// A record whose PROTOTYPE carries the fields must not be read as
|
||||||
|
// though it carried them itself: that is how a polluted prototype
|
||||||
|
// would decide whether a profile is refused.
|
||||||
|
const inherited = Object.create({
|
||||||
|
schemaVersion: STATE_SCHEMA_VERSION + 99,
|
||||||
|
networkId: "base",
|
||||||
|
wallets: "not a list",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stateProblem(inherited)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizing never turns a stored key into a prototype", () => {
|
||||||
|
// JSON can carry an own "__proto__" key, and plain assignment would
|
||||||
|
// treat it as the prototype setter rather than storing an entry.
|
||||||
|
const saved = JSON.parse(
|
||||||
|
'{"networkId":"mainnet","networkEndpoints":' +
|
||||||
|
'{"__proto__":{"rpcUrl":"https://evil.invalid"}}}',
|
||||||
|
);
|
||||||
|
|
||||||
|
const out = normalizePersisted(saved);
|
||||||
|
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(out, "rpcUrl")).toBe(true);
|
||||||
|
expect(out.rpcUrl).not.toBe("https://evil.invalid");
|
||||||
|
expect(Object.getPrototypeOf(out.networkEndpoints)).toBe(
|
||||||
|
Object.prototype,
|
||||||
|
);
|
||||||
|
expect({}.rpcUrl).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown stored networkId never reaches the endpoint map", () => {
|
||||||
|
// The floor under the gate: normalization alone must not adopt it.
|
||||||
|
const out = normalizePersisted({ networkId: "__proto__" });
|
||||||
|
|
||||||
|
expect(out.networkId).toBe("mainnet");
|
||||||
|
expect(Object.keys(out.networkEndpoints)).toEqual(["mainnet"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the floors under the gate, for fields the gate does not check", () => {
|
||||||
|
// The gate's scope is what nothing can floor. Everything it lets through
|
||||||
|
// is normalizePersisted()'s to make safe, and a floor written as
|
||||||
|
// `saved.x || default` is not one: a truthy value of the wrong type walks
|
||||||
|
// through it and throws on the first dereference. These two did, and
|
||||||
|
// produced the blank popup from the issue. Type checks, not truthiness —
|
||||||
|
// an empty list and an empty string are legitimate values and survive.
|
||||||
|
test("trackedTokens that is not a list becomes an empty list", () => {
|
||||||
|
for (const bad of ["nope", 42, true, { a: 1 }]) {
|
||||||
|
expect(
|
||||||
|
normalizePersisted({ trackedTokens: bad }).trackedTokens,
|
||||||
|
).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a real trackedTokens list survives, copied not shared", () => {
|
||||||
|
const saved = { trackedTokens: [{ address: ADDRESS, symbol: "AM" }] };
|
||||||
|
|
||||||
|
const out = normalizePersisted(saved);
|
||||||
|
|
||||||
|
expect(out.trackedTokens).toEqual(saved.trackedTokens);
|
||||||
|
expect(out.trackedTokens).not.toBe(saved.trackedTokens);
|
||||||
|
expect(normalizePersisted({ trackedTokens: [] }).trackedTokens).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("activeAddress that is not text becomes null", () => {
|
||||||
|
for (const bad of [42, true, { a: 1 }, [ADDRESS]]) {
|
||||||
|
expect(
|
||||||
|
normalizePersisted({ activeAddress: bad }).activeAddress,
|
||||||
|
).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a real activeAddress survives, including an empty string", () => {
|
||||||
|
expect(
|
||||||
|
normalizePersisted({ activeAddress: ADDRESS }).activeAddress,
|
||||||
|
).toBe(ADDRESS);
|
||||||
|
// Not a useful address, but it is text and it is what was stored;
|
||||||
|
// rewriting it to null would be normalization inventing a change.
|
||||||
|
expect(normalizePersisted({ activeAddress: "" }).activeAddress).toBe(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("networkById on an unknown id", () => {
|
||||||
|
test("throws instead of quietly answering mainnet", () => {
|
||||||
|
expect(() => networkById("base")).toThrow(UnknownNetworkError);
|
||||||
|
expect(() => networkById(undefined)).toThrow(UnknownNetworkError);
|
||||||
|
// Both of these used to answer with something truthy off the
|
||||||
|
// prototype chain rather than with a network.
|
||||||
|
expect(() => networkById("constructor")).toThrow(UnknownNetworkError);
|
||||||
|
expect(() => networkById("__proto__")).toThrow(UnknownNetworkError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still answers every network it does know", () => {
|
||||||
|
for (const id of Object.keys(NETWORKS)) {
|
||||||
|
expect(networkById(id).id).toBe(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the version stamp on the way out", () => {
|
||||||
|
function loadStateModule(persisted) {
|
||||||
|
jest.resetModules();
|
||||||
|
const storage = makeStorageStub(
|
||||||
|
persisted ? { autistmask: persisted } : {},
|
||||||
|
);
|
||||||
|
global.chrome = { storage };
|
||||||
|
return { storage, mod: require("../src/shared/state") };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the popup stamps it on a profile that had none", async () => {
|
||||||
|
const { storage, mod } = loadStateModule(validProfile());
|
||||||
|
|
||||||
|
await mod.loadState();
|
||||||
|
await mod.saveState();
|
||||||
|
|
||||||
|
expect(storage.read("autistmask").schemaVersion).toBe(
|
||||||
|
STATE_SCHEMA_VERSION,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the background stamps it on a profile that had none", async () => {
|
||||||
|
jest.resetModules();
|
||||||
|
const storage = makeStorageStub({ autistmask: validProfile() });
|
||||||
|
global.chrome = { storage };
|
||||||
|
const { updateState } = require("../src/background/state");
|
||||||
|
|
||||||
|
await updateState((s) => {
|
||||||
|
s.lastBalanceRefresh = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(storage.read("autistmask").schemaVersion).toBe(
|
||||||
|
STATE_SCHEMA_VERSION,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a save refuses to write over a record it cannot read", async () => {
|
||||||
|
// Another context — a newer build, or something else entirely — wrote
|
||||||
|
// a record this one does not understand while this page was open. That
|
||||||
|
// record is the only copy of whatever it holds, and normalizing it
|
||||||
|
// back into storage would destroy it.
|
||||||
|
const { storage, mod } = loadStateModule(validProfile());
|
||||||
|
await mod.loadState();
|
||||||
|
|
||||||
|
const hostile = { schemaVersion: STATE_SCHEMA_VERSION + 1 };
|
||||||
|
storage.write("autistmask", hostile);
|
||||||
|
|
||||||
|
// By name rather than by constructor: jest.resetModules() above gives
|
||||||
|
// the module under test its own copy of the error class, so instanceof
|
||||||
|
// across that boundary would be comparing two identical classes.
|
||||||
|
const thrown = await mod.saveState().then(
|
||||||
|
() => null,
|
||||||
|
(e) => e,
|
||||||
|
);
|
||||||
|
expect(thrown && thrown.name).toBe("StateUnusableError");
|
||||||
|
expect(thrown.problem).toMatch(/newer version/);
|
||||||
|
expect(storage.read("autistmask")).toEqual(hostile);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the typed confirmation phrase", () => {
|
||||||
|
test("the markup asks for the phrase the code checks", () => {
|
||||||
|
// The button is behind a phrase typed by hand. A screen that asks for
|
||||||
|
// one phrase while the code compares another is an exit that cannot be
|
||||||
|
// taken, on the one screen that exists to be an exit.
|
||||||
|
const html = fs.readFileSync(
|
||||||
|
path.join(__dirname, "..", "src", "popup", "index.html"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain(RESET_PHRASE);
|
||||||
|
});
|
||||||
|
});
|
||||||
203
tests/stateUnusableRpc.test.js
Normal file
203
tests/stateUnusableRpc.test.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
// What a dApp is told when the wallet's stored profile cannot be read
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||||
|
//
|
||||||
|
// The popup is not the only casualty of a bad blob. getActiveAddress()
|
||||||
|
// dereferences the stored wallet list on nearly every method, so against the
|
||||||
|
// build this file was added to, EVERY request from EVERY page came back as
|
||||||
|
// -32603 "AutistMask could not complete this request because of an internal
|
||||||
|
// error" — the code the wallet also answers when a signing attempt blows up,
|
||||||
|
// with nothing in it to tell the page or the user what is actually wrong or
|
||||||
|
// what to do about it.
|
||||||
|
//
|
||||||
|
// So what is pinned here is that the answer is SPECIFIC: its own code, and a
|
||||||
|
// message that says the saved data cannot be read, that nothing was signed or
|
||||||
|
// sent, and where to go to fix it.
|
||||||
|
//
|
||||||
|
// Same cold-worker shape as tests/coldWorkerChainId.test.js: the real state
|
||||||
|
// modules, over a storage stub, with no loadState() of the test's own — the
|
||||||
|
// handler has to reach storage by itself, as a worker revived by the page's
|
||||||
|
// own message does.
|
||||||
|
|
||||||
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
|
// The generic answer, quoted rather than imported: this file's whole point is
|
||||||
|
// that the state-unusable path stopped using it.
|
||||||
|
const GENERIC_INTERNAL_ERROR_CODE = -32603;
|
||||||
|
|
||||||
|
// EIP-1474's assigned non-standard codes, verbatim. The spec sets aside
|
||||||
|
// -32000..-32099 for implementation-defined server errors but hands out
|
||||||
|
// meanings for the first seven, so those are exactly the codes this condition
|
||||||
|
// may NOT take: a page reading -32001 is entitled to read "Resource not
|
||||||
|
// found". -32002 is in this table AND in use here, for a pending approval.
|
||||||
|
const EIP_1474_ASSIGNED = {
|
||||||
|
"-32000": "Invalid input",
|
||||||
|
"-32001": "Resource not found",
|
||||||
|
"-32002": "Resource unavailable",
|
||||||
|
"-32003": "Transaction rejected",
|
||||||
|
"-32004": "Method not supported",
|
||||||
|
"-32005": "Limit exceeded",
|
||||||
|
"-32006": "JSON-RPC version not supported",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The three blobs from the issue.
|
||||||
|
const CORRUPT_BLOBS = [
|
||||||
|
{
|
||||||
|
name: "wallets is a string",
|
||||||
|
blob: { hasWallet: true, wallets: ADDRESS },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wallets is an array of garbage",
|
||||||
|
blob: { hasWallet: true, wallets: [null, 42, "wallet"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "future-schema blob (unknown fields, no version)",
|
||||||
|
blob: {
|
||||||
|
hasWallet: true,
|
||||||
|
wallets: [{ id: "wallet-1", accounts: [{ addr: ADDRESS }] }],
|
||||||
|
profileFormat: "am-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.chrome;
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadColdWorker(stored) {
|
||||||
|
jest.resetModules();
|
||||||
|
|
||||||
|
jest.doMock("../src/shared/balances", () => ({
|
||||||
|
getProvider: () => ({}),
|
||||||
|
refreshBalances: jest.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||||
|
isPhishingDomain: () => false,
|
||||||
|
}));
|
||||||
|
jest.doMock("../src/shared/alarms", () => ({
|
||||||
|
BALANCE_REFRESH_ALARM: "balance",
|
||||||
|
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
||||||
|
ensureRecurringAlarms: jest.fn(async () => {}),
|
||||||
|
registerAlarmHandlers: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const store = { autistmask: structuredClone(stored) };
|
||||||
|
let messageListener = null;
|
||||||
|
const set = jest.fn(async (items) => {
|
||||||
|
store.autistmask = structuredClone(items.autistmask);
|
||||||
|
});
|
||||||
|
|
||||||
|
global.chrome = {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: jest.fn(async () => structuredClone(store)),
|
||||||
|
set,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
getURL: (p) => "chrome-extension://autistmask/" + p,
|
||||||
|
onMessage: {
|
||||||
|
addListener: (fn) => {
|
||||||
|
messageListener = fn;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onConnect: { addListener: () => {} },
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
windows: {
|
||||||
|
getLastFocused: (cb) => cb(null),
|
||||||
|
create: (options, cb) => cb({ id: 1 }),
|
||||||
|
remove: (id, cb) => {
|
||||||
|
if (cb) cb();
|
||||||
|
},
|
||||||
|
onRemoved: { addListener: () => {} },
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
query: (queryInfo, cb) => cb([{ id: 1 }]),
|
||||||
|
sendMessage: (tabId, message, cb) => {
|
||||||
|
if (cb) cb();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action: { setPopup: () => {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
require("../src/background/index");
|
||||||
|
|
||||||
|
async function rpc(method, params) {
|
||||||
|
let result = null;
|
||||||
|
messageListener(
|
||||||
|
{ type: "AUTISTMASK_RPC", method, params: params || [] },
|
||||||
|
{ origin: CONNECTED_ORIGIN },
|
||||||
|
(r) => {
|
||||||
|
result = r;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rpc, persisted: () => store.autistmask, storageSet: set };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every method a page can reach that has to consult the profile.
|
||||||
|
const METHODS = [
|
||||||
|
"eth_accounts",
|
||||||
|
"eth_requestAccounts",
|
||||||
|
"eth_chainId",
|
||||||
|
"personal_sign",
|
||||||
|
"eth_sendTransaction",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("a dApp call against a profile the wallet cannot read", () => {
|
||||||
|
for (const { name, blob } of CORRUPT_BLOBS) {
|
||||||
|
test(`${name}: a specific error, not the generic internal one`, async () => {
|
||||||
|
const bg = loadColdWorker(blob);
|
||||||
|
|
||||||
|
const answer = await bg.rpc("eth_accounts");
|
||||||
|
|
||||||
|
expect(answer.error).toBeDefined();
|
||||||
|
expect(answer.error.code).not.toBe(GENERIC_INTERNAL_ERROR_CODE);
|
||||||
|
// The message has to say what is wrong, that nothing was sent,
|
||||||
|
// and where to go. "Internal error" says none of the three.
|
||||||
|
expect(answer.error.message).toMatch(/saved data/i);
|
||||||
|
expect(answer.error.message).toMatch(/nothing was/i);
|
||||||
|
expect(answer.error.message).toMatch(/AutistMask/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("every method that consults the profile answers the same way", async () => {
|
||||||
|
const bg = loadColdWorker(CORRUPT_BLOBS[0].blob);
|
||||||
|
|
||||||
|
const codes = new Set();
|
||||||
|
for (const method of METHODS) {
|
||||||
|
const answer = await bg.rpc(method, ["0x00", ADDRESS]);
|
||||||
|
expect(answer.error).toBeDefined();
|
||||||
|
codes.add(answer.error.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One code for the condition, whatever the method was.
|
||||||
|
expect(codes.size).toBe(1);
|
||||||
|
expect(codes.has(GENERIC_INTERNAL_ERROR_CODE)).toBe(false);
|
||||||
|
|
||||||
|
// And it is a code EIP-1474 has not already given a meaning to, so a
|
||||||
|
// page reading it by the spec's table is not told something false.
|
||||||
|
const code = [...codes][0];
|
||||||
|
expect(EIP_1474_ASSIGNED[String(code)]).toBeUndefined();
|
||||||
|
expect(code).toBeLessThanOrEqual(-32007);
|
||||||
|
expect(code).toBeGreaterThanOrEqual(-32099);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it does not write over the record it could not read", async () => {
|
||||||
|
const bg = loadColdWorker(CORRUPT_BLOBS[1].blob);
|
||||||
|
|
||||||
|
await bg.rpc("eth_accounts");
|
||||||
|
await bg.rpc("eth_chainId");
|
||||||
|
|
||||||
|
expect(bg.storageSet).not.toHaveBeenCalled();
|
||||||
|
expect(bg.persisted()).toEqual(CORRUPT_BLOBS[1].blob);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -107,7 +107,7 @@ global.chrome = { storage };
|
|||||||
|
|
||||||
const txStatus = require("../src/popup/views/txStatus");
|
const txStatus = require("../src/popup/views/txStatus");
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||||
|
|
||||||
const TX_HASH =
|
const TX_HASH =
|
||||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
||||||
|
|||||||
Reference in New Issue
Block a user