Compare commits
1 Commits
next
...
2e2ecf9f78
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e2ecf9f78 |
83
README.md
83
README.md
@@ -985,6 +985,41 @@ 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
|
||||
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
|
||||
(`-32001`) saying the saved data cannot be read and that nothing was signed or
|
||||
sent, rather than the generic `-32603` every request used to answer.
|
||||
|
||||
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
|
||||
|
||||
The main view shows all addresses grouped by wallet, with ETH balances inline.
|
||||
@@ -1011,10 +1046,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
|
||||
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
|
||||
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
|
||||
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 —
|
||||
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
|
||||
@@ -1684,6 +1721,48 @@ view would leave a wallet one click from deletion.
|
||||
- Popup window closed without answering → the request is rejected with
|
||||
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
|
||||
|
||||
AutistMask is not a fully self-contained offline tool. It necessarily
|
||||
|
||||
30
TODO.md
30
TODO.md
@@ -57,6 +57,36 @@ but the review is broader than any of them.
|
||||
`Token Out` now reads `Unknown (not named in the calldata)` and
|
||||
`Min. received` falls to the base-unit refusal from
|
||||
[#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 `-32001` 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. `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`
|
||||
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
||||
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
|
||||
// the same prohibition reported early, not the guarantee.
|
||||
const { getState, updateState } = require("./state");
|
||||
const { StateUnusableError } = require("../shared/stateSchema");
|
||||
const { refreshBalances, getProvider } = require("../shared/balances");
|
||||
const { debugFetch, log } = require("../shared/log");
|
||||
const {
|
||||
@@ -179,6 +180,36 @@ const INTERNAL_ERROR_CODE = -32603;
|
||||
const INTERNAL_ERROR_MESSAGE =
|
||||
"AutistMask could not complete this request because of an internal error.";
|
||||
|
||||
// What the page is told when the wallet's own stored profile cannot be read.
|
||||
//
|
||||
// This used to be the generic answer above: getActiveAddress() dereferenced
|
||||
// the stored wallet list on nearly every method, so a corrupt or
|
||||
// newer-than-this-build record turned EVERY request from EVERY page into
|
||||
// "internal error", which is also what a failed signing attempt answers. The
|
||||
// page cannot tell those apart, and the user is told nothing about the one
|
||||
// thing that is actually wrong or where to fix it
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||
//
|
||||
// -32001 rather than -32603: EIP-1474 reserves -32000..-32099 for
|
||||
// implementation-defined server errors, this wallet uses no other code in that
|
||||
// range except -32002 for a pending approval, and the condition is specific,
|
||||
// diagnosable and has a user action attached — none of which -32603 conveys.
|
||||
const STATE_UNUSABLE_CODE = -32001;
|
||||
const STATE_UNUSABLE_MESSAGE =
|
||||
"AutistMask cannot read its saved data, so nothing was signed or sent." +
|
||||
" Open the AutistMask extension to export or reset it.";
|
||||
|
||||
// The EIP-1193 error for a handler that threw, by cause. Everything that
|
||||
// consults the profile goes through getState(), so this one mapping covers
|
||||
// every method rather than each one having to know about the condition.
|
||||
function failureError(err) {
|
||||
if (err instanceof StateUnusableError) {
|
||||
log.errorf("state is unusable:", err.problem);
|
||||
return { code: STATE_UNUSABLE_CODE, message: STATE_UNUSABLE_MESSAGE };
|
||||
}
|
||||
return { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
|
||||
}
|
||||
|
||||
// The active address of a profile snapshot. Pure, and taking the snapshot as
|
||||
// an argument rather than reading storage itself: a handler that has already
|
||||
// read state must not answer "which account is this" from a SECOND, later read
|
||||
@@ -883,6 +914,11 @@ async function handleRpc(method, params, origin) {
|
||||
const result = await proxyRpc(method, params);
|
||||
return { result };
|
||||
} catch (e) {
|
||||
// A node that answered with an error is reported as itself. The
|
||||
// wallet being unable to read its own profile is not that, and it
|
||||
// must not be flattened into a message with no code: it goes back
|
||||
// to the dispatcher, which has the one answer for it.
|
||||
if (e instanceof StateUnusableError) throw e;
|
||||
return { error: { message: e.message } };
|
||||
}
|
||||
}
|
||||
@@ -1142,7 +1178,14 @@ async function backgroundRefresh() {
|
||||
// module-level state does not outlive it. Alarms are held by the browser and
|
||||
// wake the worker to deliver them.
|
||||
registerAlarmHandlers({
|
||||
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
|
||||
// Caught here rather than left to the alarm dispatcher, which does not
|
||||
// await what it calls: a profile this build cannot read makes every
|
||||
// refresh throw, and an unhandled rejection per alarm tick says less than
|
||||
// one logged line per tick does.
|
||||
[BALANCE_REFRESH_ALARM]: () =>
|
||||
backgroundRefresh().catch((e) => {
|
||||
log.errorf("background balance refresh failed:", e);
|
||||
}),
|
||||
});
|
||||
|
||||
// Everything the background context needs re-established on start. This runs
|
||||
@@ -1241,12 +1284,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
// "it does not throw today" is not a property anyone is
|
||||
// maintaining.
|
||||
log.errorf("RPC request failed:", msg.method, err);
|
||||
sendResponse({
|
||||
error: {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: INTERNAL_ERROR_MESSAGE,
|
||||
},
|
||||
});
|
||||
sendResponse({ error: failureError(err) });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1481,18 +1519,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
// the popup nor the page is ever answered. Settle both, through
|
||||
// the same chokepoint as every other retirement.
|
||||
log.errorf("transaction approval response failed:", e);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{
|
||||
error: {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: INTERNAL_ERROR_MESSAGE,
|
||||
},
|
||||
},
|
||||
{ holdsClaim: true },
|
||||
);
|
||||
const failure = failureError(e);
|
||||
settleApproval(msg.id, { error: failure }, { holdsClaim: true });
|
||||
sendResponse({
|
||||
error: INTERNAL_ERROR_MESSAGE,
|
||||
error: failure.message,
|
||||
retryable: false,
|
||||
stage: lastResortStage,
|
||||
});
|
||||
@@ -1584,18 +1614,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
// Same shape as the transaction path: a throw out of the catch
|
||||
// block above would leave the popup and the page both waiting.
|
||||
log.errorf("sign approval response failed:", e);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{
|
||||
error: {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: INTERNAL_ERROR_MESSAGE,
|
||||
},
|
||||
},
|
||||
{ holdsClaim: true },
|
||||
);
|
||||
const failure = failureError(e);
|
||||
settleApproval(msg.id, { error: failure }, { holdsClaim: true });
|
||||
sendResponse({
|
||||
error: INTERNAL_ERROR_MESSAGE,
|
||||
error: failure.message,
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,14 +28,26 @@
|
||||
|
||||
const { storageGet, storageSet } = require("../shared/browserApi");
|
||||
const { normalizePersisted } = require("../shared/persistedState");
|
||||
const {
|
||||
STATE_SCHEMA_VERSION,
|
||||
assertStateUsable,
|
||||
} = require("../shared/stateSchema");
|
||||
|
||||
// A fresh, fully-normalized, detached copy of the persisted profile.
|
||||
//
|
||||
// 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
|
||||
// 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() {
|
||||
const result = await storageGet("autistmask");
|
||||
assertStateUsable(result.autistmask);
|
||||
return normalizePersisted(result.autistmask);
|
||||
}
|
||||
|
||||
@@ -48,6 +60,10 @@ async function updateStateOnce(mutate) {
|
||||
const s = await getState();
|
||||
await mutate(s);
|
||||
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 });
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -1761,6 +1761,75 @@
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<script src="index.js"></script>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Loads state, initializes views, triggers first render.
|
||||
|
||||
const { state, saveState, loadState } = require("../shared/state");
|
||||
const { StateUnusableError } = require("../shared/stateSchema");
|
||||
const { setRuntimeDebug } = require("../shared/log");
|
||||
const { refreshPrices } = require("../shared/prices");
|
||||
const { refreshBalances } = require("../shared/balances");
|
||||
@@ -16,7 +17,7 @@ const {
|
||||
const { applyTheme } = require("./theme");
|
||||
// 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
|
||||
// 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.
|
||||
const { renderView, makeBackRenderer } = require("./viewRouter");
|
||||
|
||||
@@ -35,6 +36,7 @@ const settings = require("./views/settings");
|
||||
const settingsAddToken = require("./views/settingsAddToken");
|
||||
const deleteAddress = require("./views/deleteAddress");
|
||||
const approval = require("./views/approval");
|
||||
const stateRecovery = require("./views/stateRecovery");
|
||||
|
||||
function renderWalletList() {
|
||||
home.render(ctx);
|
||||
@@ -134,7 +136,22 @@ function fallbackView() {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
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);
|
||||
|
||||
// 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
|
||||
// cannot be required outside a browser.
|
||||
|
||||
const { RESTORABLE_VIEWS } = require("./restorableViews");
|
||||
const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
|
||||
|
||||
// The views this page load has rendered.
|
||||
//
|
||||
|
||||
@@ -45,6 +45,11 @@ const VIEWS = [
|
||||
"approve-sign",
|
||||
"export-privkey",
|
||||
"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
|
||||
|
||||
181
src/popup/views/stateRecovery.js
Normal file
181
src/popup/views/stateRecovery.js
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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 raw record, as bytes, however malformed. Never normalized and never
|
||||
// re-serialized from a parsed copy of itself: this is evidence, and the point
|
||||
// of the export is that a later build (or a human) sees exactly what is 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;
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Erase stored keys. The one caller is the destructive reset on the recovery
|
||||
* screen (src/popup/views/stateRecovery.js), which is the only way out of a
|
||||
* profile no build can read; it rejects rather than defaulting for the same
|
||||
* reason the two above do — a reset that silently did nothing would leave the
|
||||
* user in the dead end they were promised an exit from.
|
||||
*
|
||||
* @param {string|string[]} keys
|
||||
* @returns {Promise<void>}
|
||||
* @throws rejects where `storage.local` is absent.
|
||||
*/
|
||||
function storageRemove(keys) {
|
||||
const storage = storageLocal();
|
||||
if (!storage) return storageUnavailable("remove");
|
||||
return Promise.resolve(storage.remove(keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} queryInfo
|
||||
* @returns {Promise<Array>} the matching tabs.
|
||||
@@ -251,6 +268,7 @@ module.exports = {
|
||||
sendMessage,
|
||||
storageGet,
|
||||
storageLocal,
|
||||
storageRemove,
|
||||
storageSet,
|
||||
tabsApi,
|
||||
tabsQuery,
|
||||
|
||||
@@ -31,8 +31,42 @@ const SUPPORTED_CHAIN_IDS = new Set(
|
||||
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) {
|
||||
return NETWORKS[id] || NETWORKS.mainnet;
|
||||
if (!isKnownNetworkId(id)) throw new UnknownNetworkError(id);
|
||||
return NETWORKS[id];
|
||||
}
|
||||
|
||||
function networkByChainId(chainId) {
|
||||
@@ -51,6 +85,8 @@ function explorerLink(network, type, value) {
|
||||
module.exports = {
|
||||
NETWORKS,
|
||||
SUPPORTED_CHAIN_IDS,
|
||||
UnknownNetworkError,
|
||||
isKnownNetworkId,
|
||||
networkById,
|
||||
networkByChainId,
|
||||
explorerLink,
|
||||
|
||||
@@ -10,8 +10,14 @@
|
||||
// reaching it anyway and being served DEFAULT_STATE.
|
||||
|
||||
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
|
||||
// Dependency-free constant module; safe to pull into a background bundle.
|
||||
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
|
||||
const { isKnownNetworkId } = require("./networks");
|
||||
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 = {
|
||||
hasWallet: false,
|
||||
@@ -46,7 +52,10 @@ const DEFAULT_STATE = {
|
||||
// Every field written to and read from the single "autistmask" storage key.
|
||||
// hasWallet is deliberately excluded from the diffing/merge logic in
|
||||
// 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)
|
||||
.filter((key) => key !== "hasWallet")
|
||||
.concat([
|
||||
@@ -107,11 +116,25 @@ function restorableStack(stored, currentView) {
|
||||
function normalizePersisted(saved) {
|
||||
saved = saved || {};
|
||||
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 || []);
|
||||
// Derived, never trusted verbatim off storage — see loadState().
|
||||
out.hasWallet = out.wallets.length > 0;
|
||||
out.trackedTokens = structuredClone(saved.trackedTokens || []);
|
||||
out.networkId = saved.networkId || DEFAULT_STATE.networkId;
|
||||
// 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.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
|
||||
// An actual object is required, not merely a truthy non-array: the code
|
||||
@@ -127,7 +150,19 @@ function normalizePersisted(saved) {
|
||||
: {};
|
||||
out.networkEndpoints = {};
|
||||
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
|
||||
// endpoints, belonging to whatever network it was last on. Adopt it as
|
||||
|
||||
@@ -17,7 +17,13 @@
|
||||
//
|
||||
// Kept in its own module, with no dependencies, so tests can assert the
|
||||
// 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([
|
||||
"main",
|
||||
"address",
|
||||
@@ -29,6 +29,12 @@ const {
|
||||
normalizePersisted,
|
||||
} = require("./persistedState");
|
||||
|
||||
const {
|
||||
STATE_SCHEMA_VERSION,
|
||||
assertStateUsable,
|
||||
migrationNeeded,
|
||||
} = require("./stateSchema");
|
||||
|
||||
const { storageGet, storageSet } = require("./browserApi");
|
||||
const { log } = require("./log");
|
||||
|
||||
@@ -446,6 +452,14 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
async function saveStateOnce() {
|
||||
const current = snapshotPersisted();
|
||||
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
|
||||
// come from storage in its loaded (self-healed) shape. See
|
||||
// normalizePersisted() in persistedState.js.
|
||||
@@ -481,6 +495,10 @@ async function saveStateOnce() {
|
||||
}
|
||||
}
|
||||
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 });
|
||||
|
||||
@@ -512,8 +530,24 @@ function saveState() {
|
||||
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() {
|
||||
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) {
|
||||
Object.assign(rawState, normalizePersisted(result.autistmask));
|
||||
}
|
||||
|
||||
239
src/shared/stateSchema.js
Normal file
239
src/shared/stateSchema.js
Normal file
@@ -0,0 +1,239 @@
|
||||
// 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 the rest of the code dereferences without a
|
||||
// floor of its own. Everything else has one in normalizePersisted() and does
|
||||
// not need a second.
|
||||
|
||||
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. Reading `saved.wallets`
|
||||
// again below would consult the prototype chain for a record that carries
|
||||
// no wallets of its own, so what gets validated would not be what gets
|
||||
// loaded.
|
||||
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 {
|
||||
autistmask: {
|
||||
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: [
|
||||
{ address: "0x0000000000000000000000000000000000000001" },
|
||||
{
|
||||
name: "Wallet 1",
|
||||
type: "hd",
|
||||
addresses: [
|
||||
{
|
||||
address:
|
||||
"0x0000000000000000000000000000000000000001",
|
||||
balance: "0",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
lastBalanceRefresh: 0,
|
||||
},
|
||||
|
||||
@@ -175,8 +175,22 @@ function loadBackground(options) {
|
||||
}
|
||||
|
||||
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: [
|
||||
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
|
||||
{
|
||||
name: "Wallet 1",
|
||||
type: "hd",
|
||||
addresses: [
|
||||
{
|
||||
address: signer.address,
|
||||
balance: "0",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
networkId: "mainnet",
|
||||
rpcUrl: "https://rpc.invalid",
|
||||
|
||||
@@ -35,7 +35,7 @@ jest.mock("../src/shared/vault", () => ({
|
||||
decryptWithPassword: jest.fn(),
|
||||
}));
|
||||
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const VIEW = "delete-wallet-lost-password";
|
||||
|
||||
@@ -21,7 +21,7 @@ jest.mock("../src/shared/wallet", () => ({
|
||||
getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
|
||||
}));
|
||||
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
|
||||
const VIEW = "export-privkey";
|
||||
const PASSWORD = "correct horse battery";
|
||||
|
||||
@@ -12,7 +12,7 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
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";
|
||||
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
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() {
|
||||
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");
|
||||
|
||||
442
tests/stateRecovery.test.js
Normal file
442
tests/stateRecovery.test.js
Normal file
@@ -0,0 +1,442 @@
|
||||
// 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([]);
|
||||
});
|
||||
});
|
||||
313
tests/stateSchema.test.js
Normal file
313
tests/stateSchema.test.js
Normal file
@@ -0,0 +1,313 @@
|
||||
// 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("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);
|
||||
});
|
||||
});
|
||||
181
tests/stateUnusableRpc.test.js
Normal file
181
tests/stateUnusableRpc.test.js
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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;
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
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 { state } = require("../src/shared/state");
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
|
||||
const TX_HASH =
|
||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
||||
|
||||
Reference in New Issue
Block a user