harden: make the background physically unable to read the shared state singleton (closes #324)
Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.
So the reachability goes rather than a sixth call site.
The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile.
- Every handler takes one snapshot and answers from it, including the address
it names: activeAddressOf(s) replaced a second, later storage read that
could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
chainSwitch.js, which keeps the singleton path for the popup) inside
updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
balances that came back by address, so it never publishes an object other
in-flight work holds, and a wallet added or deleted during the round trip
survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
snapshot. They used to come from different moments, so a chain switch
committed in between moved the endpoint under an artifact already verified
against the old chain.
getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.
The prohibition is enforced mechanically, not by review, and it is enforced by
the bundler rather than by a guess at what the bundler does. build.js keeps a
FORBIDDEN_INPUTS table of modules an entry point's bundle may not contain, and
assertNoForbiddenInputs() fails the build when esbuild's metafile reports
src/shared/state.js as an input of a background bundle, naming the import chain
from the metafile's own graph. That is the resolution the shipped bundle was
built from, so no specifier syntax, no hop and no resolution rule can slip past
it; Dockerfile:42 runs make build, so it holds in CI. A FORBIDDEN_INPUTS key
that matches no bundled entry point also fails, so the table cannot rot into a
vacuous pass.
A custom ESLint rule walks the CommonJS require graph from every src/background/
file and reports the same thing in the editor, before a full bundle. It matches
specifiers textually, so it is best-effort fast feedback and not the guarantee —
two earlier revisions of it shipped holes (a template literal, a dynamic
import(), a comment inside the call, a directory resolved through package.json
main). Those are covered now and pinned by
tests/backgroundStateLintRule.test.js, and the next divergence between a
hand-rolled matcher and a real bundler is caught by the build instead. A
computed specifier (require("../shared/" + "state")) is deliberately not
matched: esbuild cannot resolve it either, so it never reaches the bundle.
Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.
Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.
closes #320
This commit is contained in:
31
TODO.md
31
TODO.md
@@ -45,6 +45,37 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 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
|
||||
([#320](https://git.eeqj.de/sneak/AutistMask/issues/320)). One in-memory copy
|
||||
loaded once is the popup's lifetime, not the MV3 worker's: the worker is
|
||||
killed when idle, nothing loaded state at module scope, and an unpopulated
|
||||
read was answered out of `DEFAULT_STATE` in silence. Five defects traced to
|
||||
that, and every point fix added a `loadState()` that created the next one — a
|
||||
load detaches the objects an in-flight handler is holding. The background now
|
||||
has its own storage layer (`src/background/state.js`): `getState()` for a
|
||||
detached per-call read, `updateState()` for a queued read-modify-write.
|
||||
`backgroundRefresh()` refreshes a private copy and applies the balances that
|
||||
came back by address, so a wallet added, renamed or deleted during the round
|
||||
trip survives. The transaction attempt takes its chain id and its endpoint
|
||||
from one snapshot, so a committed chain switch can no longer move the endpoint
|
||||
under an artifact already verified against the old chain. `getProvider()` now
|
||||
REQUIRES the network id, which is what closes
|
||||
[#320](https://git.eeqj.de/sneak/AutistMask/issues/320) at the shape rather
|
||||
than at the call site. The prohibition is enforced by `build.js`, which fails
|
||||
the build when esbuild's own metafile reports `src/shared/state.js` as an
|
||||
input of either background bundle — the resolution the shipped bundle was
|
||||
actually built from, so no specifier syntax and no resolution rule can slip
|
||||
past it, and `make build` runs in CI. An ESLint rule that walks the require
|
||||
graph textually gives the same answer in the editor, before a full bundle; it
|
||||
is fast feedback rather than the guarantee, and the shapes it is known to
|
||||
catch are pinned by `tests/backgroundStateLintRule.test.js`. Reading an
|
||||
unloaded singleton now throws `StateNotLoadedError` instead of serving
|
||||
defaults. The `chrome.storage.local` stubs in eight test files aliased instead
|
||||
of structured-cloning, which could let an assertion pass on a build that never
|
||||
wrote anything; every test that drives real persistence now goes through
|
||||
`tests/support/storageStub.js`.
|
||||
- 2026-08-23: A swap always names its output token
|
||||
([#346](https://git.eeqj.de/sneak/AutistMask/issues/346)). The `Token Out`
|
||||
detail line in `src/shared/uniswap.js` was pushed only when a symbol was
|
||||
|
||||
97
build.js
97
build.js
@@ -16,6 +16,26 @@ const SRC = path.join(__dirname, "src");
|
||||
// rotting with it.
|
||||
const AUDITED_MODULE = "src/shared/constants.js";
|
||||
|
||||
// Modules a given entry point's bundle may not contain, keyed by the
|
||||
// repo-relative entry point.
|
||||
//
|
||||
// src/shared/state.js is a module-level object loaded once by loadState() and
|
||||
// mutated in place from then on. That is the popup's model. The MV3 service
|
||||
// worker has no "once" — it is killed when idle and revived by the next
|
||||
// message — so a background read of it is answered out of DEFAULT_STATE. Five
|
||||
// defects came from that, one of which destroyed a wallet
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/324); the background has its own
|
||||
// per-call storage layer in src/background/state.js instead.
|
||||
//
|
||||
// This is the authoritative check, and it is here rather than in the linter
|
||||
// because it consults the resolution esbuild actually performed. Any specifier
|
||||
// syntax, any hop, any resolution rule that puts the module in the bundle fails
|
||||
// the build, whether or not a text matcher would have recognized it.
|
||||
// Dockerfile:42 runs `make build`, so it is enforced in CI.
|
||||
const FORBIDDEN_INPUTS = {
|
||||
"src/background/index.js": ["src/shared/state.js"],
|
||||
};
|
||||
|
||||
// The build receipt: every file this build emits, with its sha256 and whether
|
||||
// it is one of the audited bundles. script/verify-build is handed this and
|
||||
// checks dist/ against it, so the file list comes from the build that just ran
|
||||
@@ -65,6 +85,71 @@ function outputsContainingAuditedModule(metafile) {
|
||||
.map(([outFile]) => repoRelative(outFile));
|
||||
}
|
||||
|
||||
// Shortest import chain from `entryInput` to `target` through the metafile's
|
||||
// own input graph, or null when there is none. The message this feeds is the
|
||||
// point of the check: "state.js is in the worker bundle" is not actionable on
|
||||
// its own, "index.js -> chainSwitchFields.js -> state.js" is.
|
||||
function importChain(metafile, entryInput, target) {
|
||||
const graph = new Map(
|
||||
Object.entries(metafile.inputs).map(([input, info]) => [
|
||||
repoRelative(input),
|
||||
(info.imports || []).map((i) => repoRelative(i.path)),
|
||||
]),
|
||||
);
|
||||
const start = repoRelative(entryInput);
|
||||
const seen = new Set([start]);
|
||||
const queue = [[start]];
|
||||
while (queue.length > 0) {
|
||||
const chain = queue.shift();
|
||||
for (const next of graph.get(chain[chain.length - 1]) || []) {
|
||||
if (next === target) return chain.concat([next]);
|
||||
if (seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
queue.push(chain.concat([next]));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Entry points from FORBIDDEN_INPUTS that this build actually bundled. A key
|
||||
// that matches nothing means the table has rotted away from the entry point
|
||||
// list — the prohibition would then be silently unenforced, so the build fails
|
||||
// on it rather than passing vacuously.
|
||||
const forbiddenEntriesSeen = new Set();
|
||||
|
||||
// Fail the build when an entry point's bundle contains a module it is
|
||||
// prohibited from reaching. The inputs come from esbuild's metafile, so this is
|
||||
// the resolution the shipped bundle was built from and not a guess at it.
|
||||
function assertNoForbiddenInputs(entryPoint, outfile, metafile) {
|
||||
const entry = repoRelative(entryPoint);
|
||||
const forbidden = FORBIDDEN_INPUTS[entry];
|
||||
if (!forbidden) return;
|
||||
forbiddenEntriesSeen.add(entry);
|
||||
|
||||
const out = repoRelative(outfile);
|
||||
const entryOutput = Object.entries(metafile.outputs).find(
|
||||
([outFile]) => repoRelative(outFile) === out,
|
||||
);
|
||||
if (!entryOutput) {
|
||||
throw new Error(`esbuild reported no metafile output for ${out}`);
|
||||
}
|
||||
const inputs = new Set(
|
||||
Object.keys(entryOutput[1].inputs).map(repoRelative),
|
||||
);
|
||||
|
||||
for (const module of forbidden) {
|
||||
if (!inputs.has(module)) continue;
|
||||
const chain = importChain(metafile, entryPoint, module);
|
||||
throw new Error(
|
||||
`${out} bundles ${module}, which ${entry} must not reach` +
|
||||
`${chain ? `: ${chain.join(" -> ")}` : ""}. The MV3 worker ` +
|
||||
`never populates the shared state singleton, so reading it ` +
|
||||
`serves DEFAULT_STATE. Use getState()/updateState() from ` +
|
||||
`src/background/state.js instead.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Every file this build writes under dist/, recorded as it is written. This is
|
||||
// the build's own account of what it emitted; it is never recovered by
|
||||
// listing dist/, because a file that is in dist/ without this build having put
|
||||
@@ -293,6 +378,9 @@ async function build() {
|
||||
metafile: true,
|
||||
define,
|
||||
});
|
||||
// Before the output is recorded as emitted: a bundle that violates a
|
||||
// prohibition must abort the build, not be written into a receipt.
|
||||
assertNoForbiddenInputs(entryPoint, outfile, result.metafile);
|
||||
recordEmitted(outfile);
|
||||
auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
|
||||
}
|
||||
@@ -349,6 +437,15 @@ async function build() {
|
||||
path.join(DIST_FIREFOX, "manifest.json"),
|
||||
);
|
||||
|
||||
for (const entry of Object.keys(FORBIDDEN_INPUTS)) {
|
||||
if (!forbiddenEntriesSeen.has(entry)) {
|
||||
throw new Error(
|
||||
`${entry} is listed in FORBIDDEN_INPUTS but was not bundled, ` +
|
||||
`so nothing checked it`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Written last so a build that died partway through leaves no receipt at
|
||||
// all, which script/verify-build treats as a hard failure rather than as
|
||||
// "nothing to check".
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
const js = require("@eslint/js");
|
||||
const globals = require("globals");
|
||||
const backgroundState = require("./script/lib/eslint/noStateSingletonInBackground");
|
||||
|
||||
// The extension APIs. MV3 Chrome exposes `chrome`; Firefox exposes both, and
|
||||
// the code feature-detects between them.
|
||||
@@ -78,12 +79,28 @@ module.exports = [
|
||||
},
|
||||
|
||||
// MV3 background: a service worker, with no window and no document.
|
||||
//
|
||||
// It also may not reach src/shared/state.js. That module's `state` export
|
||||
// is a per-bundle singleton loaded once and mutated in place, which is the
|
||||
// popup's lifetime and not the worker's: the worker is killed when idle,
|
||||
// nothing loads state at module scope, and an unpopulated read used to be
|
||||
// served DEFAULT_STATE silently. Five defects came from background code
|
||||
// reading or writing it (https://git.eeqj.de/sneak/AutistMask/issues/324),
|
||||
// and each point fix added a loadState() that created the next one. The
|
||||
// rule below checks reachability through the whole require graph, not just
|
||||
// the direct require, because a re-export from any shared module the
|
||||
// background already pulls in would put the singleton back in the bundle
|
||||
// with no background file naming it.
|
||||
{
|
||||
files: ["src/background/**/*.js"],
|
||||
plugins: { background: backgroundState },
|
||||
languageOptions: {
|
||||
...commonjs,
|
||||
globals: { ...globals.serviceworker, ...extensionGlobals },
|
||||
},
|
||||
rules: {
|
||||
"background/no-state-singleton-in-background": "error",
|
||||
},
|
||||
},
|
||||
|
||||
// src/shared is bundled into both, so it may only use what both provide:
|
||||
@@ -107,9 +124,9 @@ module.exports = [
|
||||
},
|
||||
},
|
||||
|
||||
// Unit tests: jest on node.
|
||||
// Unit tests, and the helpers they require: jest on node.
|
||||
{
|
||||
files: ["tests/**/*.test.js"],
|
||||
files: ["tests/**/*.test.js", "tests/support/**/*.js"],
|
||||
languageOptions: {
|
||||
...commonjs,
|
||||
globals: { ...globals.node, ...globals.jest },
|
||||
|
||||
184
script/lib/eslint/noStateSingletonInBackground.js
Normal file
184
script/lib/eslint/noStateSingletonInBackground.js
Normal file
@@ -0,0 +1,184 @@
|
||||
// ESLint rule: the background bundle may not reach the shared state singleton.
|
||||
//
|
||||
// src/shared/state.js holds a module-level `state` object, loaded once by
|
||||
// loadState() and mutated in place from then on. That is the popup's model. In
|
||||
// the MV3 service worker there is no "once": the worker is terminated when
|
||||
// idle and revived by the next message, nothing loads state at module scope,
|
||||
// and an unpopulated read used to be served DEFAULT_STATE without complaint —
|
||||
// five defects, one cause
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The background has its
|
||||
// own per-call storage layer in src/background/state.js instead.
|
||||
//
|
||||
// THIS RULE IS NOT THE GUARANTEE, and must not be described as one. The
|
||||
// guarantee is in build.js: FORBIDDEN_INPUTS / assertNoForbiddenInputs() fails
|
||||
// the build when esbuild's own metafile reports src/shared/state.js as an input
|
||||
// of a background bundle. That consults the resolution esbuild actually
|
||||
// performed, so no specifier syntax and no resolution rule can slip past it,
|
||||
// and Dockerfile:42 runs `make build` in CI.
|
||||
//
|
||||
// What this rule is: fast local feedback, in the editor and in `make lint`,
|
||||
// before a full bundle. It reads sources from disk and matches import
|
||||
// specifiers TEXTUALLY, so it is a best-effort approximation of module
|
||||
// resolution — a hand-rolled matcher will diverge from a real bundler, and two
|
||||
// earlier revisions of this file proved it by shipping holes (a template
|
||||
// literal, a dynamic `import()`, a comment inside the call, a directory
|
||||
// resolved through `package.json` `main`). Those are all covered now, and the
|
||||
// next divergence is caught by the build rather than by widening this again.
|
||||
//
|
||||
// It checks REACHABILITY, not just the direct require: the singleton is one
|
||||
// `require()` away from any shared module the background pulls in, and a
|
||||
// re-export would put it back in the bundle without any background file naming
|
||||
// it. So each background file is the root of a walk over the CommonJS require
|
||||
// graph, and the error names the whole chain that brought the singleton in.
|
||||
//
|
||||
// Matching textually over-approximates — a specifier inside a comment or a
|
||||
// string counts — which is the safe direction here: the failure mode is a
|
||||
// spurious error naming an exact file and line, not a silent hole.
|
||||
//
|
||||
// Deliberately not matched: a computed specifier, `require("../shared/" +
|
||||
// "state")`. esbuild cannot resolve that statically either, so it never
|
||||
// reaches the bundle.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// The module this rule exists to keep out, relative to the repo root.
|
||||
const FORBIDDEN = path.join("src", "shared", "state.js");
|
||||
|
||||
// Whatever may sit between a keyword, a paren and a specifier: whitespace and
|
||||
// comments. `import(/* webpackChunkName: "x" */ "./x")` is a standard bundler
|
||||
// idiom, and an inline `/* eslint-… */` is just as ordinary, so a matcher that
|
||||
// allows only \s there is not strict, it is broken. Each alternative starts
|
||||
// with a distinct character, so this cannot backtrack quadratically.
|
||||
const GAP = "(?:\\s|/\\*[^]*?\\*/|//[^\\n]*)";
|
||||
const SPECIFIER = "[\"'`]([^\"'`]+)[\"'`]";
|
||||
|
||||
// Both alternatives capture the specifier: call form first
|
||||
// (`require(...)`/`import(...)`), then clause form (`from "x"`, and the bare
|
||||
// side-effect `import "x"`). Nothing after the specifier is matched, so a
|
||||
// trailing comment or a trailing comma cannot break the match either.
|
||||
const SPECIFIER_RE = new RegExp(
|
||||
`\\b(?:require|import)${GAP}*\\(${GAP}*${SPECIFIER}` +
|
||||
`|\\b(?:from|import)${GAP}+${SPECIFIER}`,
|
||||
"g",
|
||||
);
|
||||
|
||||
// The `main` of a directory's package.json, as a specifier relative to that
|
||||
// directory, or null. esbuild resolves a directory through it, so a walk that
|
||||
// stops at `<dir>/index.js` reports a specifier it matched perfectly well as
|
||||
// unresolvable.
|
||||
function packageMain(dir) {
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
|
||||
);
|
||||
return typeof pkg.main === "string" && pkg.main ? pkg.main : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a relative require to a file path, trying what node and esbuild would
|
||||
// in the order they would: the path itself, then extensions, then the directory
|
||||
// (its package.json `main`, then its index.js).
|
||||
function resolveRelative(fromFile, spec) {
|
||||
if (!spec.startsWith(".")) return null; // a package, not our tree
|
||||
const base = path.resolve(path.dirname(fromFile), spec);
|
||||
const main = packageMain(base);
|
||||
for (const candidate of [
|
||||
base,
|
||||
base + ".js",
|
||||
base + ".json",
|
||||
...(main
|
||||
? [path.resolve(base, main), path.resolve(base, main) + ".js"]
|
||||
: []),
|
||||
path.join(base, "index.js"),
|
||||
]) {
|
||||
try {
|
||||
if (fs.statSync(candidate).isFile()) return candidate;
|
||||
} catch {
|
||||
// Not this candidate.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function requiresOf(file) {
|
||||
let source;
|
||||
try {
|
||||
source = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
for (const match of source.matchAll(SPECIFIER_RE)) {
|
||||
const resolved = resolveRelative(file, match[1] ?? match[2]);
|
||||
if (resolved) out.push(resolved);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Breadth-first from `entry`, returning the shortest chain of files that ends
|
||||
// at the forbidden module, or null when it is not reachable.
|
||||
function chainToForbidden(entry, forbidden) {
|
||||
const seen = new Set([entry]);
|
||||
const queue = [[entry]];
|
||||
while (queue.length > 0) {
|
||||
const chain = queue.shift();
|
||||
for (const next of requiresOf(chain[chain.length - 1])) {
|
||||
if (next === forbidden) return chain.concat([next]);
|
||||
if (seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
queue.push(chain.concat([next]));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"the background bundle must not be able to reach the" +
|
||||
" module-level state singleton in src/shared/state.js",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
reachable:
|
||||
"The background must not reach the shared state singleton:" +
|
||||
" {{chain}}. The MV3 worker never populates it, so reading it" +
|
||||
" serves DEFAULT_STATE. Use getState()/updateState() from" +
|
||||
" src/background/state.js instead.",
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
"Program:exit"(node) {
|
||||
const filename = context.filename;
|
||||
// ESLint lints from the repo root, which is also where the
|
||||
// forbidden path is anchored.
|
||||
const forbidden = path.resolve(context.cwd, FORBIDDEN);
|
||||
const chain = chainToForbidden(
|
||||
path.resolve(filename),
|
||||
forbidden,
|
||||
);
|
||||
if (!chain) return;
|
||||
context.report({
|
||||
node,
|
||||
messageId: "reachable",
|
||||
data: {
|
||||
chain: chain
|
||||
.map((file) => path.relative(context.cwd, file))
|
||||
.join(" -> "),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
rules: { "no-state-singleton-in-background": rule },
|
||||
};
|
||||
@@ -2,19 +2,17 @@
|
||||
// Handles EIP-1193 RPC requests from content scripts and proxies
|
||||
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint.
|
||||
|
||||
const { DEFAULT_RPC_URL } = require("../shared/constants");
|
||||
const {
|
||||
SUPPORTED_CHAIN_IDS,
|
||||
networkById,
|
||||
networkByChainId,
|
||||
} = require("../shared/networks");
|
||||
const { onChainSwitch } = require("../shared/chainSwitch");
|
||||
const {
|
||||
state,
|
||||
loadState,
|
||||
saveState,
|
||||
currentNetwork,
|
||||
} = require("../shared/state");
|
||||
const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
|
||||
// The background's own storage layer. src/shared/state.js — the module-level
|
||||
// `state` singleton, loadState() and saveState() — is deliberately NOT
|
||||
// imported here and must never be: see the header of src/background/state.js,
|
||||
// and the lint rule that enforces it in eslint.config.js.
|
||||
const { getState, updateState } = require("./state");
|
||||
const { refreshBalances, getProvider } = require("../shared/balances");
|
||||
const { debugFetch, log } = require("../shared/log");
|
||||
const {
|
||||
@@ -42,7 +40,6 @@ const {
|
||||
const {
|
||||
actionApi,
|
||||
runtimeApi,
|
||||
storageGet,
|
||||
tabsQuery,
|
||||
tabsSendMessage,
|
||||
windowsApi,
|
||||
@@ -179,21 +176,12 @@ const INTERNAL_ERROR_CODE = -32603;
|
||||
const INTERNAL_ERROR_MESSAGE =
|
||||
"AutistMask could not complete this request because of an internal error.";
|
||||
|
||||
async function getState() {
|
||||
const result = await storageGet("autistmask");
|
||||
return (
|
||||
result.autistmask || {
|
||||
wallets: [],
|
||||
rpcUrl: DEFAULT_RPC_URL,
|
||||
activeAddress: null,
|
||||
allowedSites: {},
|
||||
deniedSites: {},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getActiveAddress() {
|
||||
const s = await getState();
|
||||
// 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
|
||||
// — the two can disagree, and the checks that compare them would then be
|
||||
// comparing two different moments.
|
||||
function activeAddressOf(s) {
|
||||
if (s.activeAddress) return s.activeAddress;
|
||||
// Fall back to first address
|
||||
if (s.wallets.length > 0 && s.wallets[0].addresses.length > 0) {
|
||||
@@ -202,6 +190,11 @@ async function getActiveAddress() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For the few call sites that need only the address and hold no snapshot.
|
||||
async function getActiveAddress() {
|
||||
return activeAddressOf(await getState());
|
||||
}
|
||||
|
||||
// Whether a request names a signing address other than the active one. Such a
|
||||
// request is refused rather than quietly signed as whichever address happens
|
||||
// to be active: the page asked for account A and would otherwise be handed
|
||||
@@ -210,9 +203,14 @@ function namesAnotherAddress(requested, activeAddress) {
|
||||
return !!requested && !sameAddress(requested, activeAddress);
|
||||
}
|
||||
|
||||
// The endpoint alone, for the one caller that needs nothing else. Anything
|
||||
// that also needs the network the endpoint belongs to must take both from ONE
|
||||
// snapshot — see handleSendTransaction() — because a chain switch moves them
|
||||
// together and a provider built from two different reads can end up pointed at
|
||||
// one chain and told it is on another
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
|
||||
async function getRpcUrl() {
|
||||
const s = await getState();
|
||||
return s.rpcUrl || DEFAULT_RPC_URL;
|
||||
return (await getState()).rpcUrl;
|
||||
}
|
||||
|
||||
function extractHostname(origin) {
|
||||
@@ -553,10 +551,26 @@ runtime.onConnect.addListener((port) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Record a remembered site decision under one address.
|
||||
//
|
||||
// A read-modify-write against storage, not a load-mutate-save of a shared
|
||||
// singleton: the user takes seconds to answer the prompt, and everything else
|
||||
// in the worker — a balance refresh in flight, another site's approval — has
|
||||
// gone on running the whole time. Loading here used to replace the very
|
||||
// objects that work was holding.
|
||||
async function rememberSiteChoice(field, address, hostname) {
|
||||
await updateState((s) => {
|
||||
if (!s[field][address]) s[field][address] = [];
|
||||
if (!s[field][address].includes(hostname)) {
|
||||
s[field][address].push(hostname);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle connection requests (eth_requestAccounts, wallet_requestPermissions)
|
||||
async function handleConnectionRequest(origin) {
|
||||
const s = await getState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
if (!activeAddress) {
|
||||
return { error: { message: "No accounts available" } };
|
||||
}
|
||||
@@ -588,29 +602,14 @@ async function handleConnectionRequest(origin) {
|
||||
|
||||
if (decision.approved) {
|
||||
if (decision.remember) {
|
||||
// Reload state to get latest, add to allowed, persist
|
||||
await loadState();
|
||||
if (!state.allowedSites[activeAddress]) {
|
||||
state.allowedSites[activeAddress] = [];
|
||||
}
|
||||
if (!state.allowedSites[activeAddress].includes(hostname)) {
|
||||
state.allowedSites[activeAddress].push(hostname);
|
||||
}
|
||||
await saveState();
|
||||
await rememberSiteChoice("allowedSites", activeAddress, hostname);
|
||||
} else {
|
||||
connectedSites[origin + ":" + activeAddress] = true;
|
||||
}
|
||||
return { result: [activeAddress] };
|
||||
} else {
|
||||
if (decision.remember) {
|
||||
await loadState();
|
||||
if (!state.deniedSites[activeAddress]) {
|
||||
state.deniedSites[activeAddress] = [];
|
||||
}
|
||||
if (!state.deniedSites[activeAddress].includes(hostname)) {
|
||||
state.deniedSites[activeAddress].push(hostname);
|
||||
}
|
||||
await saveState();
|
||||
await rememberSiteChoice("deniedSites", activeAddress, hostname);
|
||||
}
|
||||
return {
|
||||
error: {
|
||||
@@ -654,7 +653,7 @@ async function handleRpc(method, params, origin) {
|
||||
|
||||
if (method === "eth_accounts") {
|
||||
const s = await getState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
if (!activeAddress) return { result: [] };
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
@@ -667,22 +666,11 @@ async function handleRpc(method, params, origin) {
|
||||
return { result: [] };
|
||||
}
|
||||
|
||||
// Both answered from currentNetwork(), which reads the module-level state
|
||||
// singleton, and nothing populates that at module scope. A worker revived
|
||||
// by the page's own message therefore held DEFAULT_STATE and told a page
|
||||
// it was on mainnet while the user was on Sepolia
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/317).
|
||||
//
|
||||
// Answered from getState() rather than by loading the singleton. Any page
|
||||
// reaches these two — neither is gated on a connection, and the injected
|
||||
// provider sends eth_chainId on every page load — and loadState() replaces
|
||||
// state.wallets wholesale, which would detach the address objects an
|
||||
// in-flight backgroundRefresh() is mutating across its network round trip,
|
||||
// so its saveState() would persist the pre-refresh balances while still
|
||||
// stamping lastBalanceRefresh. getState() is the detached per-call storage
|
||||
// read the other read handlers here already use.
|
||||
// networkById(undefined) falls back to mainnet, matching the default for a
|
||||
// profile with no stored networkId.
|
||||
// Both used to be answered from currentNetwork(), which reads the
|
||||
// module-level state singleton, and nothing populates that at module
|
||||
// scope. A worker revived by the page's own message therefore held
|
||||
// DEFAULT_STATE and told a page it was on mainnet while the user was on
|
||||
// Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/317).
|
||||
if (method === "eth_chainId" || method === "net_version") {
|
||||
const s = await getState();
|
||||
const net = networkById(s.networkId);
|
||||
@@ -699,7 +687,7 @@ async function handleRpc(method, params, origin) {
|
||||
// not be able to do it. Ungated, any page could clear the
|
||||
// [TESTNET] banner under a user who believed they were on Sepolia.
|
||||
const s = await getState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
if (
|
||||
@@ -709,24 +697,25 @@ async function handleRpc(method, params, origin) {
|
||||
return { error: { code: 4100, message: "Unauthorized" } };
|
||||
}
|
||||
|
||||
// onChainSwitch() mutates the module-level state singleton and then
|
||||
// saves every field of it, and currentNetwork() reads the same
|
||||
// singleton. This worker may have been started by this very message:
|
||||
// nothing loads state at module scope, so without this the singleton
|
||||
// is DEFAULT_STATE, the same-chain check compares against the wrong
|
||||
// network, and the save writes empty wallets, empty allowedSites and
|
||||
// the default endpoints over the user's stored profile
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/316). Same precedent
|
||||
// as the transaction path below.
|
||||
await loadState();
|
||||
|
||||
// The chain in force is read from the snapshot above, not from the
|
||||
// singleton: this worker may have been started by this very message,
|
||||
// and the singleton would then be DEFAULT_STATE, so the same-chain
|
||||
// check compared against mainnet whatever the user was on
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/316).
|
||||
const chainId = params?.[0]?.chainId;
|
||||
if (chainId === currentNetwork().chainId) {
|
||||
if (chainId === networkById(s.networkId).chainId) {
|
||||
return { result: null };
|
||||
}
|
||||
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
|
||||
const target = networkByChainId(chainId);
|
||||
await onChainSwitch(target.id);
|
||||
// Read-modify-write against storage. The old path went through
|
||||
// onChainSwitch(), which mutates the singleton and then persists
|
||||
// every field of it — on an unloaded worker that wrote empty
|
||||
// wallets, empty allowedSites and the default endpoints over the
|
||||
// user's stored profile, encrypted secrets included.
|
||||
await updateState((fresh) =>
|
||||
applyChainSwitchFields(fresh, target.id),
|
||||
);
|
||||
broadcastChainChanged(target.chainId);
|
||||
return { result: null };
|
||||
}
|
||||
@@ -773,7 +762,7 @@ async function handleRpc(method, params, origin) {
|
||||
|
||||
if (method === "wallet_getPermissions") {
|
||||
const s = await getState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
const isConnected =
|
||||
@@ -799,7 +788,7 @@ async function handleRpc(method, params, origin) {
|
||||
|
||||
if (method === "personal_sign" || method === "eth_sign") {
|
||||
const s = await getState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
if (!activeAddress)
|
||||
return { error: { message: "No accounts available" } };
|
||||
|
||||
@@ -848,7 +837,7 @@ async function handleRpc(method, params, origin) {
|
||||
|
||||
if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") {
|
||||
const s = await getState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
if (!activeAddress)
|
||||
return { error: { message: "No accounts available" } };
|
||||
|
||||
@@ -904,7 +893,7 @@ async function handleRpc(method, params, origin) {
|
||||
// page has its answer.
|
||||
async function handleSendTransaction(params, origin) {
|
||||
const s = await getState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
if (!activeAddress) return { error: { message: "No accounts available" } };
|
||||
|
||||
const hostname = extractHostname(origin);
|
||||
@@ -948,10 +937,19 @@ async function handleSendTransaction(params, origin) {
|
||||
// user is shown is a complete one and is the same object the signed
|
||||
// artifact is checked against. A failure raises no approval at all and
|
||||
// is reported to the requesting page; see approvalTx.js.
|
||||
//
|
||||
// The provider is built from ONE snapshot — the endpoint and the
|
||||
// network name both come from `s`. It used to be
|
||||
// getProvider(await getRpcUrl()) with no network name at all, so
|
||||
// getProvider fell back to the unpopulated singleton's mainnet: the
|
||||
// endpoint was the user's chain and the static hint was 0x1, ethers
|
||||
// fixed chainId at 0x1, and the wallet's own verifySignedTx then
|
||||
// refused every non-mainnet dApp send
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
|
||||
let approvedTx;
|
||||
try {
|
||||
approvedTx = await prepareApprovalTx(
|
||||
getProvider(await getRpcUrl()),
|
||||
getProvider(s.rpcUrl, s.networkId),
|
||||
activeAddress,
|
||||
txParams,
|
||||
);
|
||||
@@ -1039,7 +1037,7 @@ async function broadcastAccountsChanged() {
|
||||
}
|
||||
resetPopupUrl();
|
||||
const s = await getState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
|
||||
let tabs;
|
||||
try {
|
||||
@@ -1079,20 +1077,60 @@ async function broadcastAccountsChanged() {
|
||||
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
|
||||
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
|
||||
|
||||
// The wallets this refresh works on are its OWN, and nothing else in the
|
||||
// worker can reach them.
|
||||
//
|
||||
// refreshBalances() mutates address objects in place across a multi-second
|
||||
// network round trip. It used to be handed the module-level singleton's
|
||||
// wallets, which meant any concurrent handler that called loadState() replaced
|
||||
// state.wallets underneath it: the refreshed balances landed on detached
|
||||
// objects, and the save that followed persisted the PRE-refresh values while
|
||||
// still stamping lastBalanceRefresh, suppressing the redo. Every point fix for
|
||||
// the singleton added such a loadState(), so the next one would have done it
|
||||
// again (https://git.eeqj.de/sneak/AutistMask/issues/324).
|
||||
//
|
||||
// So: read a snapshot, refresh a private copy of its wallets, then apply the
|
||||
// balances that came back — by address, onto whatever storage holds NOW.
|
||||
// Applying by address rather than writing the array back is what keeps a
|
||||
// wallet or address added, renamed or deleted during the round trip.
|
||||
async function backgroundRefresh() {
|
||||
await loadState();
|
||||
const s = await getState();
|
||||
const now = Date.now();
|
||||
if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS)
|
||||
return;
|
||||
if (state.wallets.length === 0) return;
|
||||
if (now - (s.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS) return;
|
||||
if (s.wallets.length === 0) return;
|
||||
|
||||
const wallets = s.wallets;
|
||||
await refreshBalances(
|
||||
state.wallets,
|
||||
state.rpcUrl,
|
||||
state.blockscoutUrl,
|
||||
state.trackedTokens,
|
||||
wallets,
|
||||
s.rpcUrl,
|
||||
s.blockscoutUrl,
|
||||
s.trackedTokens,
|
||||
s.networkId,
|
||||
);
|
||||
state.lastBalanceRefresh = now;
|
||||
await saveState();
|
||||
|
||||
const refreshed = new Map();
|
||||
for (const wallet of wallets) {
|
||||
for (const addr of wallet.addresses || []) {
|
||||
refreshed.set(String(addr.address).toLowerCase(), addr);
|
||||
}
|
||||
}
|
||||
|
||||
await updateState((fresh) => {
|
||||
for (const wallet of fresh.wallets) {
|
||||
for (const addr of wallet.addresses || []) {
|
||||
const got = refreshed.get(String(addr.address).toLowerCase());
|
||||
if (!got) continue;
|
||||
// Only fields the refresh actually produced. refreshBalances()
|
||||
// leaves a field untouched when its lookup failed, so an
|
||||
// undefined here means "no answer", not "the answer is empty",
|
||||
// and must not overwrite what is stored.
|
||||
for (const key of ["balance", "ensName", "tokenBalances"]) {
|
||||
if (got[key] !== undefined) addr[key] = got[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
fresh.lastBalanceRefresh = now;
|
||||
});
|
||||
}
|
||||
|
||||
// The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is
|
||||
@@ -1307,16 +1345,29 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
// so an escape from there must not tell the user it might have.
|
||||
let lastResortStage = TX_STAGE_VERIFY;
|
||||
(async () => {
|
||||
// The chain this attempt is on, read once. Verification below
|
||||
// refuses an artifact signed for any other chain, and the nonce
|
||||
// record is both consulted and written under this one, so a
|
||||
// network switch part-way through cannot make the check and the
|
||||
// record disagree about which chain the nonce was spent on.
|
||||
// The chain this attempt is on, read once — and the endpoint it
|
||||
// will be broadcast to comes from the SAME read.
|
||||
//
|
||||
// Verification below refuses an artifact signed for any other
|
||||
// chain, and the nonce record is both consulted and written under
|
||||
// this one, so a network switch part-way through cannot make the
|
||||
// check and the record disagree about which chain the nonce was
|
||||
// spent on. The endpoint used to be read separately, several
|
||||
// awaits later (`state.rpcUrl` off the singleton), so a chain
|
||||
// switch committed in that window moved the endpoint out from
|
||||
// under a transaction already verified against the old chain: the
|
||||
// artifact would be sent to the new chain's node, which is
|
||||
// precisely the "signed for a different network" case the
|
||||
// verification exists to prevent.
|
||||
let chainId;
|
||||
let rpcUrl;
|
||||
let networkId;
|
||||
try {
|
||||
await loadState();
|
||||
chainId = currentNetwork().chainId;
|
||||
const activeAddress = await getActiveAddress();
|
||||
const s = await getState();
|
||||
networkId = s.networkId;
|
||||
chainId = networkById(networkId).chainId;
|
||||
rpcUrl = s.rpcUrl;
|
||||
const activeAddress = activeAddressOf(s);
|
||||
// An address switch between approval and signing refuses. The
|
||||
// approval named one account; signing from whichever account
|
||||
// is active now would send funds from an account this screen
|
||||
@@ -1388,7 +1439,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
lastResortStage = TX_STAGE_BROADCAST;
|
||||
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
||||
if (nonce !== null) spent.add(nonce);
|
||||
|
||||
76
src/background/state.js
Normal file
76
src/background/state.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// The background's access to the persisted profile.
|
||||
//
|
||||
// There is no in-memory copy here, and that is the whole design. The MV3
|
||||
// service worker is terminated when idle and revived by the next message, so
|
||||
// anything held at module scope is either absent or arbitrarily stale, and
|
||||
// src/shared/state.js's module-level `state` singleton — which nothing in the
|
||||
// worker ever populates — silently served DEFAULT_STATE to whoever read it.
|
||||
// Five defects came out of that (https://git.eeqj.de/sneak/AutistMask/issues/324),
|
||||
// and every point fix for one of them added a loadState() that created the
|
||||
// next: loading detaches the objects an in-flight handler is holding.
|
||||
//
|
||||
// So the background reads per call and writes read-modify-write:
|
||||
//
|
||||
// getState() one storage read, normalized, detached. Nothing else
|
||||
// holds the object it returns, so a handler may keep it
|
||||
// across any number of awaits and no concurrent work can
|
||||
// move it.
|
||||
// updateState(fn) read fresh, apply fn to that fresh record, write it
|
||||
// back — all inside a queue, so two background writes
|
||||
// never interleave, and the read is one storage round trip
|
||||
// ahead of the write rather than a page lifetime ahead of
|
||||
// it (which is what made the popup's saveState() need a
|
||||
// per-field merge against a baseline at all).
|
||||
//
|
||||
// A handler that must both read and write therefore does its network work
|
||||
// against a snapshot it owns, and applies the RESULT inside updateState().
|
||||
// It never publishes an object other in-flight work is holding.
|
||||
|
||||
const { storageGet, storageSet } = require("../shared/browserApi");
|
||||
const { normalizePersisted } = require("../shared/persistedState");
|
||||
|
||||
// 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.
|
||||
async function getState() {
|
||||
const result = await storageGet("autistmask");
|
||||
return normalizePersisted(result.autistmask);
|
||||
}
|
||||
|
||||
// Serializes the read-modify-write turns below. Two of them interleaved would
|
||||
// each read before the other wrote, and the second write would carry the first
|
||||
// one's fields back to their pre-turn values.
|
||||
let updateQueue = Promise.resolve();
|
||||
|
||||
async function updateStateOnce(mutate) {
|
||||
const s = await getState();
|
||||
await mutate(s);
|
||||
s.hasWallet = Boolean(s.wallets && s.wallets.length > 0);
|
||||
await storageSet({ autistmask: s });
|
||||
return s;
|
||||
}
|
||||
|
||||
// Apply `mutate` to a record read fresh from storage and write the result
|
||||
// back. `mutate` receives a detached, normalized profile and mutates it in
|
||||
// place; it may be async, but it must not do anything slow — the window
|
||||
// between the read and the write is the window in which another context's
|
||||
// write is lost, and keeping it to one storage round trip is what makes a
|
||||
// whole-record write safe here.
|
||||
//
|
||||
// `mutate` must also not call updateState() itself, directly or through
|
||||
// anything it awaits: the queue is strictly serial, so the inner turn waits on
|
||||
// the outer one, which is waiting on the inner one. That deadlocks the whole
|
||||
// background, not just the caller. Mutate the record you were handed.
|
||||
//
|
||||
// Resolves with the record that was written.
|
||||
function updateState(mutate) {
|
||||
const turn = updateQueue.then(() => updateStateOnce(mutate));
|
||||
// The queue must advance even when a turn rejects, or every update after
|
||||
// it queues behind a promise that never settles.
|
||||
updateQueue = turn.catch(() => {});
|
||||
return turn;
|
||||
}
|
||||
|
||||
module.exports = { getState, updateState };
|
||||
@@ -53,6 +53,7 @@ async function doRefreshAndRender() {
|
||||
state.rpcUrl,
|
||||
state.blockscoutUrl,
|
||||
state.trackedTokens,
|
||||
state.networkId,
|
||||
),
|
||||
]);
|
||||
state.lastBalanceRefresh = Date.now();
|
||||
|
||||
@@ -49,7 +49,11 @@ function init(ctx) {
|
||||
infoEl.style.visibility = "visible";
|
||||
log.debugf("Looking up token contract", contractAddr);
|
||||
try {
|
||||
const info = await lookupTokenInfo(contractAddr, state.rpcUrl);
|
||||
const info = await lookupTokenInfo(
|
||||
contractAddr,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
);
|
||||
log.infof("Adding token", info.symbol, contractAddr);
|
||||
state.trackedTokens.push({
|
||||
address: contractAddr,
|
||||
|
||||
@@ -179,7 +179,7 @@ async function importMnemonic(ctx) {
|
||||
|
||||
// Scan for used HD addresses beyond index 0.
|
||||
showFlash("Scanning for addresses...", 30000);
|
||||
const scan = await scanForAddresses(xpub, state.rpcUrl);
|
||||
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
|
||||
if (scan.addresses.length > 1) {
|
||||
wallet.addresses = scan.addresses.map((a) => ({
|
||||
address: a.address,
|
||||
@@ -298,7 +298,7 @@ async function importXprvKey(ctx) {
|
||||
|
||||
// Scan for used HD addresses beyond index 0.
|
||||
showFlash("Scanning for addresses...", 30000);
|
||||
const scan = await scanForAddresses(xpub, state.rpcUrl);
|
||||
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
|
||||
if (scan.addresses.length > 1) {
|
||||
wallet.addresses = scan.addresses.map((a) => ({
|
||||
address: a.address,
|
||||
|
||||
@@ -188,6 +188,7 @@ async function loadTransactions(address) {
|
||||
ensNameMap = await resolveEnsNames(
|
||||
counterparties,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
);
|
||||
} catch {
|
||||
ensNameMap = new Map();
|
||||
|
||||
@@ -268,6 +268,7 @@ async function loadTransactions(address, tokenId) {
|
||||
ensNameMap = await resolveEnsNames(
|
||||
counterparties,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
);
|
||||
} catch {
|
||||
ensNameMap = new Map();
|
||||
|
||||
@@ -304,7 +304,7 @@ function formatFeeEth(wei) {
|
||||
|
||||
async function estimateGas(txInfo) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const feeData = await provider.getFeeData();
|
||||
let gasLimit;
|
||||
|
||||
@@ -386,7 +386,7 @@ async function estimateGas(txInfo) {
|
||||
|
||||
async function checkRecipientHistory(txInfo) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
|
||||
fromAddress: txInfo.from,
|
||||
});
|
||||
@@ -454,7 +454,7 @@ function init(_ctx) {
|
||||
state.selectedAddress,
|
||||
decryptedSecret,
|
||||
);
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const connectedSigner = signer.connect(provider);
|
||||
|
||||
if (pendingTx.token === "ETH") {
|
||||
|
||||
@@ -202,7 +202,7 @@ function init(_ctx) {
|
||||
let ensName = null;
|
||||
if (to.includes(".") && !to.startsWith("0x")) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const resolved = await provider.resolveName(to);
|
||||
if (!resolved) {
|
||||
showFlash("Could not resolve " + to);
|
||||
|
||||
@@ -133,7 +133,11 @@ function init(_ctx) {
|
||||
infoEl.style.visibility = "visible";
|
||||
log.debugf("Looking up token contract", addr);
|
||||
try {
|
||||
const info = await lookupTokenInfo(addr, state.rpcUrl);
|
||||
const info = await lookupTokenInfo(
|
||||
addr,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
);
|
||||
log.infof("Adding token", info.symbol, addr);
|
||||
state.trackedTokens.push({
|
||||
address: addr,
|
||||
|
||||
@@ -113,7 +113,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
renderElapsed();
|
||||
}, 1000);
|
||||
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
async function poll() {
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
formatUnits,
|
||||
} = require("ethers");
|
||||
const { ERC20_ABI } = require("./constants");
|
||||
const { NETWORKS } = require("./networks");
|
||||
const { log, debugFetch } = require("./log");
|
||||
const { deriveAddressFromXpub } = require("./wallet");
|
||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
@@ -17,17 +18,38 @@ const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||
|
||||
// Use a static network to skip auto-detection (which can fail and cause
|
||||
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
||||
// Accepts an optional networkName ("mainnet" or "sepolia") for the static
|
||||
// network hint so ethers picks the right chain parameters. When omitted,
|
||||
// reads the currently selected network from extension state.
|
||||
function getProvider(rpcUrl, networkName) {
|
||||
// Lazy require to avoid circular dependency issues at module scope.
|
||||
const { currentNetwork } = require("./state");
|
||||
const name = networkName || currentNetwork().id;
|
||||
const net = Network.from(name);
|
||||
//
|
||||
// `networkId` is REQUIRED, and is one of the ids in networks.js. It used to be
|
||||
// optional, falling back to currentNetwork() — the module-level `state`
|
||||
// singleton, which the MV3 service worker never populates. The endpoint then
|
||||
// came out right and the static hint came out mainnet, so ethers fixed
|
||||
// `chainId` at 0x1 and every non-mainnet dApp send was prepared for the wrong
|
||||
// chain and then refused by the wallet's own verifier
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/320). Requiring it is what
|
||||
// stops that from coming back: a caller that has no network to name has no
|
||||
// business constructing a provider, and there is no longer a default for it
|
||||
// to get silently wrong.
|
||||
//
|
||||
// Validated against NETWORKS rather than passed straight to Network.from():
|
||||
// ethers knows chains this wallet does not, so an id that is not one of ours
|
||||
// is a caller bug and must not resolve to a working provider for some other
|
||||
// chain.
|
||||
function getProvider(rpcUrl, networkId) {
|
||||
const net = Network.from(requireNetworkId(networkId).id);
|
||||
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
|
||||
}
|
||||
|
||||
function requireNetworkId(networkId) {
|
||||
const net = NETWORKS[networkId];
|
||||
if (!net) {
|
||||
throw new Error(
|
||||
"getProvider requires the id of a supported network; got " +
|
||||
JSON.stringify(networkId),
|
||||
);
|
||||
}
|
||||
return net;
|
||||
}
|
||||
|
||||
function formatBalance(wei) {
|
||||
const eth = formatEther(wei);
|
||||
const parts = eth.split(".");
|
||||
@@ -118,9 +140,15 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
}
|
||||
|
||||
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
|
||||
async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
|
||||
async function refreshBalances(
|
||||
wallets,
|
||||
rpcUrl,
|
||||
blockscoutUrl,
|
||||
trackedTokens,
|
||||
networkId,
|
||||
) {
|
||||
log.debugf("refreshBalances start, rpc:", rpcUrl);
|
||||
const provider = getProvider(rpcUrl);
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const updates = [];
|
||||
|
||||
for (const wallet of wallets) {
|
||||
@@ -193,9 +221,9 @@ async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
|
||||
|
||||
// Look up token metadata from its contract.
|
||||
// Calls symbol() and decimals() to verify it implements ERC-20.
|
||||
async function lookupTokenInfo(contractAddress, rpcUrl) {
|
||||
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
||||
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
|
||||
const provider = getProvider(rpcUrl);
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
||||
|
||||
let name, symbol, decimals;
|
||||
@@ -235,9 +263,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl) {
|
||||
// Checks gapLimit addresses in parallel per batch. Stops when an entire
|
||||
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
|
||||
// Returns { addresses: [{ address, index }], nextIndex }.
|
||||
async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
|
||||
async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
|
||||
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
|
||||
const provider = getProvider(rpcUrl);
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const used = [];
|
||||
let checked = 0;
|
||||
let checkUpTo = gapLimit;
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
// Consolidated chain-switch handler.
|
||||
// Consolidated chain-switch handler for the popup.
|
||||
//
|
||||
// Every state change required when the active network changes is
|
||||
// performed here so that callers (settings UI, background
|
||||
// wallet_switchEthereumChain, future chain additions) all go
|
||||
// performed here so that callers (settings UI, future chain additions) all go
|
||||
// through a single code path.
|
||||
//
|
||||
// Adding a new chain (e.g. ETC) requires only a new entry in
|
||||
// networks.js — no per-caller wiring is needed.
|
||||
//
|
||||
// The background does NOT come through here: this function mutates the
|
||||
// module-level `state` singleton, which the MV3 service worker never
|
||||
// populates, and a background switch performed on it wrote DEFAULT_STATE over
|
||||
// the user's whole profile
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/316). The field mutations
|
||||
// themselves live in chainSwitchFields.js, which takes the record to mutate as
|
||||
// an argument; src/background/state.js applies them inside a read-modify-write
|
||||
// against storage, and the singleton is not reachable from the background
|
||||
// bundle at all (enforced by the ESLint rule in eslint.config.js).
|
||||
|
||||
const { networkById } = require("./networks");
|
||||
const { applyChainSwitchFields } = require("./chainSwitchFields");
|
||||
const { clearPrices } = require("./prices");
|
||||
|
||||
// Switch the active chain and reset all chain-specific cached state.
|
||||
@@ -16,56 +25,14 @@ const { clearPrices } = require("./prices");
|
||||
async function onChainSwitch(newNetworkId) {
|
||||
const { state, saveState } = require("./state");
|
||||
|
||||
const net = networkById(newNetworkId);
|
||||
|
||||
// --- core identity ---
|
||||
// Endpoints are remembered per network rather than reset to the
|
||||
// defaults, because a user who points the wallet at their own node has
|
||||
// no way to get that URL back once it is gone: overwriting it moved
|
||||
// every address and every transaction onto a third-party endpoint
|
||||
// silently and permanently.
|
||||
//
|
||||
// state.rpcUrl / state.blockscoutUrl stay the live endpoints of the
|
||||
// active network, so nothing that reads them changes. The invariant is
|
||||
// that for the ACTIVE network those two fields are authoritative and
|
||||
// the map entry may be stale (Settings writes the fields directly);
|
||||
// for every other network the map is authoritative. Snapshotting the
|
||||
// outgoing network here, before the switch, is what reconciles them.
|
||||
state.networkEndpoints[state.networkId] = {
|
||||
rpcUrl: state.rpcUrl,
|
||||
blockscoutUrl: state.blockscoutUrl,
|
||||
};
|
||||
const remembered = state.networkEndpoints[net.id] || {};
|
||||
state.networkId = net.id;
|
||||
state.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
|
||||
state.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
|
||||
const net = applyChainSwitchFields(state, newNetworkId);
|
||||
|
||||
// --- price cache ---
|
||||
// Prices are chain-specific (testnet tokens are worthless,
|
||||
// ETC has different pricing, etc.).
|
||||
// ETC has different pricing, etc.). In-memory and per bundle, so this is
|
||||
// the popup's own cache — the only context that ever fills it.
|
||||
clearPrices();
|
||||
|
||||
// --- balance / refresh state ---
|
||||
// Reset last-refresh timestamp so the next polling cycle
|
||||
// triggers an immediate balance refresh on the new chain.
|
||||
state.lastBalanceRefresh = 0;
|
||||
|
||||
// Clear per-address balances and token balances so stale data
|
||||
// from the previous chain is never displayed while the first
|
||||
// refresh on the new chain is in flight.
|
||||
for (const wallet of state.wallets) {
|
||||
for (const addr of wallet.addresses) {
|
||||
addr.balance = "0";
|
||||
addr.tokenBalances = [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- chain-specific caches ---
|
||||
// Token holder counts and fraud contract lists are
|
||||
// chain-specific and must not carry over.
|
||||
state.tokenHolderCache = {};
|
||||
state.fraudContracts = [];
|
||||
|
||||
await saveState();
|
||||
|
||||
return net;
|
||||
|
||||
69
src/shared/chainSwitchFields.js
Normal file
69
src/shared/chainSwitchFields.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// The field mutations a chain switch performs, applied to a state record
|
||||
// handed in rather than to the module-level `state` singleton.
|
||||
//
|
||||
// Split out of chainSwitch.js so the background can perform a chain switch
|
||||
// without the singleton being reachable from its bundle at all. The popup
|
||||
// still goes through onChainSwitch() (chainSwitch.js), which applies this to
|
||||
// the singleton and saves; the background applies it to the detached record of
|
||||
// its own read-modify-write (src/background/state.js).
|
||||
//
|
||||
// Everything here is synchronous and touches nothing but the object it is
|
||||
// given: no storage, no caches, no imports beyond the network table. That is
|
||||
// what makes it usable on a record that has been read fresh from storage
|
||||
// microseconds earlier and is about to be written back.
|
||||
|
||||
const { networkById } = require("./networks");
|
||||
|
||||
// Switch `s` to `newNetworkId` and reset every piece of chain-specific state
|
||||
// it carries. Returns the network configuration object for the new chain.
|
||||
function applyChainSwitchFields(s, newNetworkId) {
|
||||
const net = networkById(newNetworkId);
|
||||
|
||||
// --- core identity ---
|
||||
// Endpoints are remembered per network rather than reset to the
|
||||
// defaults, because a user who points the wallet at their own node has
|
||||
// no way to get that URL back once it is gone: overwriting it moved
|
||||
// every address and every transaction onto a third-party endpoint
|
||||
// silently and permanently.
|
||||
//
|
||||
// s.rpcUrl / s.blockscoutUrl stay the live endpoints of the active
|
||||
// network, so nothing that reads them changes. The invariant is that for
|
||||
// the ACTIVE network those two fields are authoritative and the map entry
|
||||
// may be stale (Settings writes the fields directly); for every other
|
||||
// network the map is authoritative. Snapshotting the outgoing network
|
||||
// here, before the switch, is what reconciles them.
|
||||
if (!s.networkEndpoints) s.networkEndpoints = {};
|
||||
s.networkEndpoints[s.networkId] = {
|
||||
rpcUrl: s.rpcUrl,
|
||||
blockscoutUrl: s.blockscoutUrl,
|
||||
};
|
||||
const remembered = s.networkEndpoints[net.id] || {};
|
||||
s.networkId = net.id;
|
||||
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
|
||||
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
|
||||
|
||||
// --- balance / refresh state ---
|
||||
// Reset last-refresh timestamp so the next polling cycle
|
||||
// triggers an immediate balance refresh on the new chain.
|
||||
s.lastBalanceRefresh = 0;
|
||||
|
||||
// Clear per-address balances and token balances so stale data
|
||||
// from the previous chain is never displayed while the first
|
||||
// refresh on the new chain is in flight.
|
||||
for (const wallet of s.wallets || []) {
|
||||
for (const addr of wallet.addresses || []) {
|
||||
addr.balance = "0";
|
||||
addr.tokenBalances = [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- chain-specific caches ---
|
||||
// Token holder counts and fraud contract lists are
|
||||
// chain-specific and must not carry over.
|
||||
s.tokenHolderCache = {};
|
||||
s.fraudContracts = [];
|
||||
|
||||
return net;
|
||||
}
|
||||
|
||||
module.exports = { applyChainSwitchFields };
|
||||
@@ -32,11 +32,11 @@ function setCache(address, name) {
|
||||
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
|
||||
}
|
||||
|
||||
async function resolveEnsName(address, rpcUrl) {
|
||||
async function resolveEnsName(address, rpcUrl, networkId) {
|
||||
const cached = getCached(address);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const provider = getProvider(rpcUrl);
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
try {
|
||||
const name = (await provider.lookupAddress(address)) || null;
|
||||
setCache(address, name);
|
||||
@@ -48,11 +48,11 @@ async function resolveEnsName(address, rpcUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveEnsNames(addresses, rpcUrl) {
|
||||
async function resolveEnsNames(addresses, rpcUrl, networkId) {
|
||||
const results = new Map();
|
||||
await Promise.all(
|
||||
addresses.map(async (addr) => {
|
||||
results.set(addr, await resolveEnsName(addr, rpcUrl));
|
||||
results.set(addr, await resolveEnsName(addr, rpcUrl, networkId));
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
|
||||
204
src/shared/persistedState.js
Normal file
204
src/shared/persistedState.js
Normal file
@@ -0,0 +1,204 @@
|
||||
// The shape of the persisted profile, and the normalization every read of it
|
||||
// goes through. No singleton, no storage access, no browser API: just the
|
||||
// record definition and pure functions over it.
|
||||
//
|
||||
// Split out of state.js so that a context which must never touch the
|
||||
// module-level `state` singleton can still speak the same record format.
|
||||
// src/background/state.js is that context — the MV3 service worker never
|
||||
// populates the singleton, and every defect in
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/324 came from background code
|
||||
// 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 DEFAULT_STATE = {
|
||||
hasWallet: false,
|
||||
wallets: [],
|
||||
trackedTokens: [],
|
||||
networkId: "mainnet",
|
||||
rpcUrl: DEFAULT_RPC_URL,
|
||||
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
|
||||
// Endpoints remembered per network: { [networkId]: { rpcUrl,
|
||||
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
|
||||
// of the active network; this is what the others are restored from
|
||||
// when the active network changes. See applyChainSwitchFields().
|
||||
networkEndpoints: {},
|
||||
lastBalanceRefresh: 0,
|
||||
activeAddress: null,
|
||||
allowedSites: {},
|
||||
deniedSites: {},
|
||||
rememberSiteChoice: true,
|
||||
showZeroBalanceTokens: true,
|
||||
hideSpoofedSymbols: true,
|
||||
hideLowHolderTokens: true,
|
||||
hideFraudContracts: true,
|
||||
hideDustTransactions: true,
|
||||
dustThresholdGwei: 100000,
|
||||
utcTimestamps: false,
|
||||
fraudContracts: [],
|
||||
tokenHolderCache: {},
|
||||
theme: "system",
|
||||
debugMode: false,
|
||||
};
|
||||
|
||||
// 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.
|
||||
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
|
||||
.filter((key) => key !== "hasWallet")
|
||||
.concat([
|
||||
"currentView",
|
||||
"selectedWallet",
|
||||
"selectedAddress",
|
||||
"selectedToken",
|
||||
"viewData",
|
||||
"viewStack",
|
||||
]);
|
||||
|
||||
// Keep only the leading run of stored views the popup is willing to render.
|
||||
//
|
||||
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
||||
// behind it used to be restored verbatim, so Back could walk onto a screen
|
||||
// whose content is deliberately never re-rendered — and "show-phrase" has no
|
||||
// Back control to leave by. Truncating at the first such entry instead of
|
||||
// splicing it out keeps the result a prefix of the stored stack, so every
|
||||
// surviving entry's Back target is exactly the one it had; splicing would
|
||||
// silently re-point the entry above the hole at a different screen.
|
||||
//
|
||||
// Filtering happens here on load rather than in saveState(): the live
|
||||
// in-session stack is legitimate (the screen really is rendered while the
|
||||
// popup is open), and only a load-side filter also repairs the stacks
|
||||
// already in storage, including ones written before a view left the set.
|
||||
function restorableStack(stored, currentView) {
|
||||
// A stored stack that is missing or not an array keeps nothing, but it
|
||||
// still goes through the never-empty rule below rather than returning
|
||||
// early: otherwise a corrupt stack would depend on exactly the goBack()
|
||||
// fallback that the explicit ["main"] exists in order not to depend on.
|
||||
const source = Array.isArray(stored) ? stored : [];
|
||||
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
|
||||
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
|
||||
// A view restored below the root still needs somewhere for Back to go.
|
||||
if (
|
||||
kept.length === 0 &&
|
||||
currentView !== "main" &&
|
||||
RESTORABLE_VIEWS.has(currentView)
|
||||
) {
|
||||
return ["main"];
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
// Turn a raw stored (or missing) record into the full, defaulted shape
|
||||
// loadState() used to assign directly onto `state`. A pure function so that
|
||||
// saveState() can apply it too: the fields THIS page did not change still have
|
||||
// to come from storage in their loaded-and-normalized form, not as the raw
|
||||
// bytes another page (or an old release) left there — otherwise a legacy shape
|
||||
// a load has always self-healed in memory (a missing networkEndpoints map, an
|
||||
// out-of-range flag) is dropped right back into storage unfixed every time the
|
||||
// page that DID normalize it saves something unrelated, because that field's
|
||||
// value never "changed" for that page to notice.
|
||||
//
|
||||
// The result never shares structure with `saved`, so a caller may mutate it
|
||||
// freely: it is the detached record every per-call read in the background is
|
||||
// built on.
|
||||
function normalizePersisted(saved) {
|
||||
saved = saved || {};
|
||||
const out = {};
|
||||
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;
|
||||
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
|
||||
// below and applyChainSwitchFields() index and ASSIGN INTO this value, and
|
||||
// assigning a property to a string or a number is a silent no-op in
|
||||
// sloppy mode. Copied rather than referenced, nested pairs included, so
|
||||
// normalizing never mutates the object a caller handed in.
|
||||
const rawEndpoints =
|
||||
typeof saved.networkEndpoints === "object" &&
|
||||
saved.networkEndpoints !== null &&
|
||||
!Array.isArray(saved.networkEndpoints)
|
||||
? saved.networkEndpoints
|
||||
: {};
|
||||
out.networkEndpoints = {};
|
||||
for (const netId of Object.keys(rawEndpoints)) {
|
||||
out.networkEndpoints[netId] = { ...rawEndpoints[netId] };
|
||||
}
|
||||
// A profile written before this map existed carries exactly one pair of
|
||||
// endpoints, belonging to whatever network it was last on. Adopt it as
|
||||
// that network's remembered pair, so a custom endpoint set on the old
|
||||
// build is not lost by the first switch away and back.
|
||||
if (!out.networkEndpoints[out.networkId]) {
|
||||
out.networkEndpoints[out.networkId] = {
|
||||
rpcUrl: out.rpcUrl,
|
||||
blockscoutUrl: out.blockscoutUrl,
|
||||
};
|
||||
}
|
||||
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
|
||||
out.activeAddress = saved.activeAddress || null;
|
||||
out.allowedSites =
|
||||
saved.allowedSites && !Array.isArray(saved.allowedSites)
|
||||
? structuredClone(saved.allowedSites)
|
||||
: {};
|
||||
out.deniedSites =
|
||||
saved.deniedSites && !Array.isArray(saved.deniedSites)
|
||||
? structuredClone(saved.deniedSites)
|
||||
: {};
|
||||
out.rememberSiteChoice =
|
||||
saved.rememberSiteChoice !== undefined
|
||||
? saved.rememberSiteChoice
|
||||
: true;
|
||||
out.showZeroBalanceTokens =
|
||||
saved.showZeroBalanceTokens !== undefined
|
||||
? saved.showZeroBalanceTokens
|
||||
: true;
|
||||
// A profile written before this setting existed has no key for it. It
|
||||
// is a safety filter, so absent must load as on, not as undefined.
|
||||
out.hideSpoofedSymbols =
|
||||
saved.hideSpoofedSymbols !== undefined
|
||||
? saved.hideSpoofedSymbols
|
||||
: true;
|
||||
out.hideLowHolderTokens =
|
||||
saved.hideLowHolderTokens !== undefined
|
||||
? saved.hideLowHolderTokens
|
||||
: true;
|
||||
out.hideFraudContracts =
|
||||
saved.hideFraudContracts !== undefined
|
||||
? saved.hideFraudContracts
|
||||
: true;
|
||||
out.hideDustTransactions =
|
||||
saved.hideDustTransactions !== undefined
|
||||
? saved.hideDustTransactions
|
||||
: true;
|
||||
out.dustThresholdGwei =
|
||||
saved.dustThresholdGwei !== undefined
|
||||
? saved.dustThresholdGwei
|
||||
: 100000;
|
||||
out.utcTimestamps =
|
||||
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
|
||||
out.fraudContracts = structuredClone(saved.fraudContracts || []);
|
||||
out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
|
||||
out.theme = saved.theme || "system";
|
||||
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
|
||||
out.currentView = saved.currentView || null;
|
||||
out.selectedWallet =
|
||||
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
|
||||
out.selectedAddress =
|
||||
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
|
||||
out.selectedToken = saved.selectedToken || null;
|
||||
out.viewData = structuredClone(saved.viewData || {});
|
||||
out.viewStack = restorableStack(saved.viewStack, out.currentView);
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_STATE,
|
||||
PERSISTED_FIELDS,
|
||||
normalizePersisted,
|
||||
restorableStack,
|
||||
};
|
||||
@@ -1,46 +1,36 @@
|
||||
// State management and extension storage persistence.
|
||||
//
|
||||
// The `state` export is a module-level singleton: ONE in-memory copy of the
|
||||
// profile per bundle, loaded once by loadState() and mutated in place from
|
||||
// then on. That is the popup's model — one page, one load at boot, one
|
||||
// lifetime.
|
||||
//
|
||||
// It is NOT the background's model, and the background must not reach it. The
|
||||
// MV3 service worker is torn down when idle and revived by the next message,
|
||||
// nothing loads state at module scope, and an unpopulated read used to hand
|
||||
// back DEFAULT_STATE with no complaint — five defects came out of that one
|
||||
// fact (https://git.eeqj.de/sneak/AutistMask/issues/324). Two things close it:
|
||||
// this module is unreachable from the background bundle (enforced by the
|
||||
// ESLint rule in eslint.config.js, and by the background having its own
|
||||
// per-call storage layer in src/background/state.js), and reading a persisted
|
||||
// field of the singleton before a load now THROWS instead of quietly serving
|
||||
// a default.
|
||||
|
||||
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
|
||||
const { networkById } = require("./networks");
|
||||
// Dependency-free constant module; safe to pull into a background bundle.
|
||||
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
|
||||
const {
|
||||
DEFAULT_STATE,
|
||||
PERSISTED_FIELDS,
|
||||
normalizePersisted,
|
||||
} = require("./persistedState");
|
||||
|
||||
const { storageGet, storageSet } = require("./browserApi");
|
||||
const { log } = require("./log");
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
hasWallet: false,
|
||||
wallets: [],
|
||||
trackedTokens: [],
|
||||
networkId: "mainnet",
|
||||
rpcUrl: DEFAULT_RPC_URL,
|
||||
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
|
||||
// Endpoints remembered per network: { [networkId]: { rpcUrl,
|
||||
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
|
||||
// of the active network; this is what the others are restored from
|
||||
// when the active network changes. See onChainSwitch().
|
||||
networkEndpoints: {},
|
||||
lastBalanceRefresh: 0,
|
||||
activeAddress: null,
|
||||
allowedSites: {},
|
||||
deniedSites: {},
|
||||
rememberSiteChoice: true,
|
||||
showZeroBalanceTokens: true,
|
||||
hideSpoofedSymbols: true,
|
||||
hideLowHolderTokens: true,
|
||||
hideFraudContracts: true,
|
||||
hideDustTransactions: true,
|
||||
dustThresholdGwei: 100000,
|
||||
utcTimestamps: false,
|
||||
fraudContracts: [],
|
||||
tokenHolderCache: {},
|
||||
theme: "system",
|
||||
debugMode: false,
|
||||
};
|
||||
|
||||
const state = {
|
||||
// The live record the proxy below guards. Everything inside this module reads
|
||||
// and writes THIS object, never the proxy: the guard is for callers.
|
||||
const rawState = {
|
||||
...DEFAULT_STATE,
|
||||
// Its own object, not the one DEFAULT_STATE holds: onChainSwitch()
|
||||
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
|
||||
// mutates this map in place, and a spread copies the reference.
|
||||
networkEndpoints: {},
|
||||
currentView: null,
|
||||
@@ -51,161 +41,72 @@ const state = {
|
||||
viewStack: [],
|
||||
};
|
||||
|
||||
// Keep only the leading run of stored views the popup is willing to render.
|
||||
// False until loadState() has completed in this bundle. Until then, a
|
||||
// persisted field that has not been assigned in this context cannot be READ:
|
||||
// see StateNotLoadedError.
|
||||
let loaded = false;
|
||||
|
||||
// True once this context has assigned anything into the singleton.
|
||||
//
|
||||
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
||||
// behind it used to be restored verbatim, so Back could walk onto a screen
|
||||
// whose content is deliberately never re-rendered — and "show-phrase" has no
|
||||
// Back control to leave by. Truncating at the first such entry instead of
|
||||
// splicing it out keeps the result a prefix of the stored stack, so every
|
||||
// surviving entry's Back target is exactly the one it had; splicing would
|
||||
// silently re-point the entry above the hole at a different screen.
|
||||
// What the guard is for is a context that READS a profile nobody put there —
|
||||
// every one of the five defects was a pure read of an untouched singleton,
|
||||
// answered out of DEFAULT_STATE. A context that has written into it is
|
||||
// managing it deliberately (the popup does, via loadState() at boot and by
|
||||
// hand thereafter), and reading back what you yourself put there is not the
|
||||
// mistake being caught.
|
||||
//
|
||||
// Filtering happens here on load rather than in saveState(): the live
|
||||
// in-session stack is legitimate (the screen really is rendered while the
|
||||
// popup is open), and only a load-side filter also repairs the stacks
|
||||
// already in storage, including ones written before a view left the set.
|
||||
function restorableStack(stored, currentView) {
|
||||
// A stored stack that is missing or not an array keeps nothing, but it
|
||||
// still goes through the never-empty rule below rather than returning
|
||||
// early: otherwise a corrupt stack would depend on exactly the goBack()
|
||||
// fallback that the explicit ["main"] exists in order not to depend on.
|
||||
const source = Array.isArray(stored) ? stored : [];
|
||||
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
|
||||
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
|
||||
// A view restored below the root still needs somewhere for Back to go.
|
||||
if (
|
||||
kept.length === 0 &&
|
||||
currentView !== "main" &&
|
||||
RESTORABLE_VIEWS.has(currentView)
|
||||
) {
|
||||
return ["main"];
|
||||
// The cost of that is honest and worth naming: a context that writes one field
|
||||
// and then reads a different, untouched one is still served that field's
|
||||
// default. Nothing closes that here — what closes it for the background is
|
||||
// that the background cannot reach this module at all (eslint.config.js).
|
||||
let adopted = false;
|
||||
|
||||
// Every field whose pre-load value would be a plausible-looking default rather
|
||||
// than the user's data. The view scratch fields are guarded too: currentView
|
||||
// and viewStack are persisted, and a save that carried their pre-load values
|
||||
// would overwrite a real stored stack with an empty one.
|
||||
const GUARDED_FIELDS = new Set(PERSISTED_FIELDS.concat(["hasWallet"]));
|
||||
|
||||
class StateNotLoadedError extends Error {
|
||||
constructor(field) {
|
||||
super(
|
||||
"state." +
|
||||
field +
|
||||
" was read before loadState(); this context has no profile" +
|
||||
" loaded and must not be served DEFAULT_STATE",
|
||||
);
|
||||
this.name = "StateNotLoadedError";
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
// Loud, not defaulted. The whole defect class this guard closes looks exactly
|
||||
// like working code at the call site: the read succeeds, the value is
|
||||
// well-formed, and it describes a wallet that is not the user's.
|
||||
const state = new Proxy(rawState, {
|
||||
get(target, prop, receiver) {
|
||||
if (
|
||||
!loaded &&
|
||||
!adopted &&
|
||||
typeof prop === "string" &&
|
||||
GUARDED_FIELDS.has(prop)
|
||||
) {
|
||||
throw new StateNotLoadedError(prop);
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
set(target, prop, value, receiver) {
|
||||
if (typeof prop === "string" && GUARDED_FIELDS.has(prop)) {
|
||||
adopted = true;
|
||||
}
|
||||
return Reflect.set(target, prop, value, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
// Return the network configuration for the currently selected network.
|
||||
function currentNetwork() {
|
||||
return networkById(state.networkId);
|
||||
}
|
||||
|
||||
// Every field written to and read from the single "autistmask" storage key.
|
||||
// hasWallet is deliberately excluded from the diffing/merge logic below —
|
||||
// like loadState() does, it is always derived from `wallets`, never carried
|
||||
// as an independent value.
|
||||
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
|
||||
.filter((key) => key !== "hasWallet")
|
||||
.concat([
|
||||
"currentView",
|
||||
"selectedWallet",
|
||||
"selectedAddress",
|
||||
"selectedToken",
|
||||
"viewData",
|
||||
"viewStack",
|
||||
]);
|
||||
|
||||
// Turn a raw stored (or missing) record into the full, defaulted shape
|
||||
// loadState() used to assign directly onto `state`. Pulled out as a pure
|
||||
// function so saveState() can apply it too: the fields THIS page did not
|
||||
// change still have to come from storage in their loaded-and-normalized
|
||||
// form, not as the raw bytes another page (or an old release) left there —
|
||||
// otherwise a legacy shape a load has always self-healed in memory (a
|
||||
// missing networkEndpoints map, an out-of-range flag) is dropped right back
|
||||
// into storage unfixed every time the page that DID normalize it saves
|
||||
// something unrelated, because that field's value never "changed" for that
|
||||
// page to notice.
|
||||
function normalizePersisted(saved) {
|
||||
saved = saved || {};
|
||||
const out = {};
|
||||
out.wallets = saved.wallets || [];
|
||||
// Derived, never trusted verbatim off storage — see loadState().
|
||||
out.hasWallet = out.wallets.length > 0;
|
||||
out.trackedTokens = saved.trackedTokens || [];
|
||||
out.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
|
||||
// below and onChainSwitch() index and ASSIGN INTO this value, and
|
||||
// assigning a property to a string or a number is a silent no-op in
|
||||
// sloppy mode. Copied rather than referenced, nested pairs included, so
|
||||
// normalizing never mutates the object a caller handed in.
|
||||
const rawEndpoints =
|
||||
typeof saved.networkEndpoints === "object" &&
|
||||
saved.networkEndpoints !== null &&
|
||||
!Array.isArray(saved.networkEndpoints)
|
||||
? saved.networkEndpoints
|
||||
: {};
|
||||
out.networkEndpoints = {};
|
||||
for (const netId of Object.keys(rawEndpoints)) {
|
||||
out.networkEndpoints[netId] = { ...rawEndpoints[netId] };
|
||||
}
|
||||
// A profile written before this map existed carries exactly one pair of
|
||||
// endpoints, belonging to whatever network it was last on. Adopt it as
|
||||
// that network's remembered pair, so a custom endpoint set on the old
|
||||
// build is not lost by the first switch away and back.
|
||||
if (!out.networkEndpoints[out.networkId]) {
|
||||
out.networkEndpoints[out.networkId] = {
|
||||
rpcUrl: out.rpcUrl,
|
||||
blockscoutUrl: out.blockscoutUrl,
|
||||
};
|
||||
}
|
||||
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
|
||||
out.activeAddress = saved.activeAddress || null;
|
||||
out.allowedSites =
|
||||
saved.allowedSites && !Array.isArray(saved.allowedSites)
|
||||
? saved.allowedSites
|
||||
: {};
|
||||
out.deniedSites =
|
||||
saved.deniedSites && !Array.isArray(saved.deniedSites)
|
||||
? saved.deniedSites
|
||||
: {};
|
||||
out.rememberSiteChoice =
|
||||
saved.rememberSiteChoice !== undefined
|
||||
? saved.rememberSiteChoice
|
||||
: true;
|
||||
out.showZeroBalanceTokens =
|
||||
saved.showZeroBalanceTokens !== undefined
|
||||
? saved.showZeroBalanceTokens
|
||||
: true;
|
||||
// A profile written before this setting existed has no key for it. It
|
||||
// is a safety filter, so absent must load as on, not as undefined.
|
||||
out.hideSpoofedSymbols =
|
||||
saved.hideSpoofedSymbols !== undefined
|
||||
? saved.hideSpoofedSymbols
|
||||
: true;
|
||||
out.hideLowHolderTokens =
|
||||
saved.hideLowHolderTokens !== undefined
|
||||
? saved.hideLowHolderTokens
|
||||
: true;
|
||||
out.hideFraudContracts =
|
||||
saved.hideFraudContracts !== undefined
|
||||
? saved.hideFraudContracts
|
||||
: true;
|
||||
out.hideDustTransactions =
|
||||
saved.hideDustTransactions !== undefined
|
||||
? saved.hideDustTransactions
|
||||
: true;
|
||||
out.dustThresholdGwei =
|
||||
saved.dustThresholdGwei !== undefined
|
||||
? saved.dustThresholdGwei
|
||||
: 100000;
|
||||
out.utcTimestamps =
|
||||
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
|
||||
out.fraudContracts = saved.fraudContracts || [];
|
||||
out.tokenHolderCache = saved.tokenHolderCache || {};
|
||||
out.theme = saved.theme || "system";
|
||||
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
|
||||
out.currentView = saved.currentView || null;
|
||||
out.selectedWallet =
|
||||
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
|
||||
out.selectedAddress =
|
||||
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
|
||||
out.selectedToken = saved.selectedToken || null;
|
||||
out.viewData = saved.viewData || {};
|
||||
out.viewStack = restorableStack(saved.viewStack, out.currentView);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The persisted fields as they stood at the end of this page's last
|
||||
// loadState() or saveState(). saveState() diffs the live state against this
|
||||
// to find only the fields THIS page actually changed.
|
||||
@@ -217,7 +118,7 @@ let baseline = null;
|
||||
|
||||
function snapshotPersisted() {
|
||||
const out = {};
|
||||
for (const key of PERSISTED_FIELDS) out[key] = state[key];
|
||||
for (const key of PERSISTED_FIELDS) out[key] = rawState[key];
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -374,11 +275,10 @@ function mergeWallet(base, ours, theirs) {
|
||||
}
|
||||
|
||||
// Merge one address's leaf fields (balance, ensName, tokenBalances, ...).
|
||||
// tokenBalances is itself an array, but only backgroundRefresh() ever
|
||||
// writes it and always wholesale (refreshBalances() in
|
||||
// src/shared/balances.js), so there is no membership to reconcile within
|
||||
// it — it is a leaf like balance or ensName, not a list with its own
|
||||
// identity.
|
||||
// tokenBalances is itself an array, but only a balance refresh ever writes
|
||||
// it and always wholesale (refreshBalances() in src/shared/balances.js), so
|
||||
// there is no membership to reconcile within it — it is a leaf like balance
|
||||
// or ensName, not a list with its own identity.
|
||||
function mergeAddress(base, ours, theirs) {
|
||||
if (!base) return ours;
|
||||
const merged = { ...theirs };
|
||||
@@ -425,12 +325,12 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
|
||||
}
|
||||
|
||||
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
|
||||
// list is itself membership, not a leaf — src/background/index.js pushes a
|
||||
// newly approved/denied hostname onto it in place, and the Settings "revoke"
|
||||
// button (src/popup/views/settings.js) filters a hostname out of it in
|
||||
// place, from a different page. Merge it the same way wallets are merged:
|
||||
// identity is the hostname itself, so a merged pair is always equal and
|
||||
// mergeItem is a no-op pick.
|
||||
// list is itself membership, not a leaf — the background appends a newly
|
||||
// approved/denied hostname to it, and the Settings "revoke" button
|
||||
// (src/popup/views/settings.js) filters a hostname out of it in place, from a
|
||||
// different page. Merge it the same way wallets are merged: identity is the
|
||||
// hostname itself, so a merged pair is always equal and mergeItem is a no-op
|
||||
// pick.
|
||||
function mergeHostnameList(base, ours, theirs) {
|
||||
return mergeListByIdentity(
|
||||
base,
|
||||
@@ -445,14 +345,15 @@ function mergeSiteMap(base, ours, theirs) {
|
||||
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
|
||||
}
|
||||
|
||||
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }. onChainSwitch()
|
||||
// (src/shared/chainSwitch.js) writes state.networkEndpoints[networkId] in
|
||||
// place before saving. No code path ever removes a key from this map, so the
|
||||
// membership collision that matters for allowedSites/wallets (an add on one
|
||||
// page racing a delete on another) can't happen here — but two pages
|
||||
// switching to two different networks concurrently still race a whole-field
|
||||
// diff the same way, so it gets the same per-key merge for the leaf edit
|
||||
// case (e.g. Settings saving a custom RPC URL for the active network).
|
||||
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
|
||||
// applyChainSwitchFields() (src/shared/chainSwitchFields.js) writes
|
||||
// networkEndpoints[networkId] in place before saving. No code path ever
|
||||
// removes a key from this map, so the membership collision that matters for
|
||||
// allowedSites/wallets (an add on one page racing a delete on another) can't
|
||||
// happen here — but two pages switching to two different networks
|
||||
// concurrently still race a whole-field diff the same way, so it gets the same
|
||||
// per-key merge for the leaf edit case (e.g. Settings saving a custom RPC URL
|
||||
// for the active network).
|
||||
function mergeEndpointEntry(base, ours, theirs) {
|
||||
if (!base) return ours;
|
||||
const merged = { ...theirs };
|
||||
@@ -468,12 +369,12 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
|
||||
// Read-modify-write, merged per field, rather than one full-blob write.
|
||||
//
|
||||
// Every extension page (the toolbar popup, a dApp approval window, the
|
||||
// background's backgroundRefresh()) holds its own in-memory `state`, loaded
|
||||
// once, and showView() saves on every navigation. A full-blob write here
|
||||
// clobbers whatever a second page had written since — including, in the
|
||||
// worst case, an entire wallet and its encrypted secret with no attacker
|
||||
// and no unusual input (see the issue this fixes).
|
||||
// Every extension page (the toolbar popup, a dApp approval window) holds its
|
||||
// own in-memory `state`, loaded once, and showView() saves on every
|
||||
// navigation. A full-blob write here clobbers whatever a second page had
|
||||
// written since — including, in the worst case, an entire wallet and its
|
||||
// encrypted secret with no attacker and no unusual input (see the issue this
|
||||
// fixes).
|
||||
//
|
||||
// Only the fields this page actually changed — those that differ from
|
||||
// `baseline`, captured at the last loadState()/saveState() on this page —
|
||||
@@ -482,28 +383,27 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
//
|
||||
// `wallets` is merged structurally (mergeListByIdentity(), by wallet
|
||||
// identity and then by address identity within each wallet), not as one
|
||||
// whole field: backgroundRefresh() mutates wallets IN PLACE (addr.balance /
|
||||
// whole field: a balance refresh mutates wallets IN PLACE (addr.balance /
|
||||
// ensName / tokenBalances, via refreshBalances()), so a whole-field diff
|
||||
// would mark all of `wallets` "changed" the moment any balance moved and
|
||||
// write back background's own copy — loaded before its multi-second network
|
||||
// write back that page's own copy — loaded before its multi-second network
|
||||
// round trip — clobbering a wallet another page added, or resurrecting one
|
||||
// another page deleted, in that window. Merging by identity lets
|
||||
// background's leaf changes and another page's membership changes
|
||||
// (add/delete a wallet or an address) apply independently instead of
|
||||
// colliding as the same field.
|
||||
// another page deleted, in that window. Merging by identity lets the leaf
|
||||
// changes and another page's membership changes (add/delete a wallet or an
|
||||
// address) apply independently instead of colliding as the same field.
|
||||
//
|
||||
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
|
||||
// by address key and then by hostname within each address's list), for the
|
||||
// identical reason: src/background/index.js pushes a newly
|
||||
// approved/denied hostname onto them in place, and the Settings "revoke"
|
||||
// button (src/popup/views/settings.js) filters one out in place, from a
|
||||
// different page. A whole-field diff here doesn't just lose data, it is a
|
||||
// security defect — a stale page's save can resurrect a just-revoked site
|
||||
// permission, or silently wipe a permission just granted elsewhere.
|
||||
// identical reason: the background appends a newly approved/denied hostname
|
||||
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
|
||||
// filters one out in place, from a different page. A whole-field diff here
|
||||
// doesn't just lose data, it is a security defect — a stale page's save can
|
||||
// resurrect a just-revoked site permission, or silently wipe a permission just
|
||||
// granted elsewhere.
|
||||
//
|
||||
// `networkEndpoints` gets the same treatment too (mergeNetworkEndpoints(),
|
||||
// by network id), since onChainSwitch() writes into it in place; the value
|
||||
// per key is a small leaf object with no membership of its own; see the
|
||||
// by network id), since applyChainSwitchFields() writes into it in place; the
|
||||
// value per key is a small leaf object with no membership of its own; see the
|
||||
// comment at mergeEndpointEntry() for why the collision this closes is
|
||||
// milder than the other two.
|
||||
//
|
||||
@@ -511,8 +411,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
||||
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
|
||||
// the ones above, but nothing in src/ ever writes an entry into it — it is
|
||||
// only ever reset wholesale to `{}` (onChainSwitch()) — so there is no
|
||||
// in-place mutation for a whole-field diff to collide with; `viewData` is
|
||||
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is
|
||||
// no in-place mutation for a whole-field diff to collide with; `viewData` is
|
||||
// this page's own UI scratch space, not data another page has any reason to
|
||||
// share membership of.
|
||||
//
|
||||
@@ -539,7 +439,7 @@ async function saveStateOnce() {
|
||||
const result = await storageGet("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() above.
|
||||
// normalizePersisted() in persistedState.js.
|
||||
const fresh = normalizePersisted(result.autistmask);
|
||||
|
||||
const merged = { ...fresh };
|
||||
@@ -578,7 +478,7 @@ async function saveStateOnce() {
|
||||
// Derived from this page's own wallets, never adopted off the wire —
|
||||
// see loadState(). Everything else this page did not change is left
|
||||
// exactly as it stood; see the note above.
|
||||
state.hasWallet = state.wallets.length > 0;
|
||||
rawState.hasWallet = rawState.wallets.length > 0;
|
||||
|
||||
baseline = structuredClone(snapshotPersisted());
|
||||
}
|
||||
@@ -606,14 +506,21 @@ function saveState() {
|
||||
async function loadState() {
|
||||
const result = await storageGet("autistmask");
|
||||
if (result.autistmask) {
|
||||
Object.assign(state, normalizePersisted(result.autistmask));
|
||||
Object.assign(rawState, normalizePersisted(result.autistmask));
|
||||
}
|
||||
// The point of comparison every saveState() on this page diffs against,
|
||||
// whether storage had a profile or was empty. See PERSISTED_FIELDS above
|
||||
// saveState() for why a reference here would be wrong.
|
||||
// Whether storage had a profile or was empty, this context has now read
|
||||
// it, and the defaults standing in for an empty profile are the right
|
||||
// answer rather than a stand-in for one nobody looked for.
|
||||
loaded = true;
|
||||
// The point of comparison every saveState() on this page diffs against.
|
||||
// See PERSISTED_FIELDS in persistedState.js for why a reference here
|
||||
// would be wrong.
|
||||
baseline = structuredClone(snapshotPersisted());
|
||||
}
|
||||
|
||||
// Through the guarded proxy, not rawState: a caller asking which address is
|
||||
// selected before anything was loaded gets the same loud failure it would get
|
||||
// reading the fields itself.
|
||||
function currentAddress() {
|
||||
if (state.selectedWallet === null || state.selectedAddress === null) {
|
||||
return null;
|
||||
@@ -627,4 +534,5 @@ module.exports = {
|
||||
loadState,
|
||||
currentAddress,
|
||||
currentNetwork,
|
||||
StateNotLoadedError,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
// A controllable clock plus a stubbed balance refresh, so a cadence test can
|
||||
// measure the interval between refreshes that actually happened rather than
|
||||
// asserting the interval someone intended.
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
let mockNow = 0;
|
||||
const mockBalanceRefreshAt = [];
|
||||
|
||||
@@ -247,32 +249,17 @@ describe("alarms module", () => {
|
||||
// Loads the background worker against stubbed browser APIs. The returned
|
||||
// store is the extension storage the worker sees, so a test can seed wallet
|
||||
// state and read back what the worker persisted.
|
||||
// The stub clones in both directions, as the real chrome.storage.local does,
|
||||
// and carries the latency simulation above on every operation. It used to
|
||||
// alias, which for this file meant the worker's in-memory wallets and the
|
||||
// "stored" ones were one object — see tests/support/storageStub.js.
|
||||
function loadBackground(initialStore = {}) {
|
||||
const storageStore = initialStore;
|
||||
const storage = makeStorageStub(initialStore, mockStorageTick);
|
||||
const alarmsStub = makeAlarmsStub();
|
||||
const listeners = { onInstalled: [], onStartup: [] };
|
||||
global.chrome = {
|
||||
alarms: alarmsStub,
|
||||
storage: {
|
||||
local: {
|
||||
get: async (key) => {
|
||||
mockStorageTick();
|
||||
return Object.prototype.hasOwnProperty.call(
|
||||
storageStore,
|
||||
key,
|
||||
)
|
||||
? { [key]: storageStore[key] }
|
||||
: {};
|
||||
},
|
||||
set: async (items) => {
|
||||
mockStorageTick();
|
||||
Object.assign(storageStore, items);
|
||||
},
|
||||
remove: async (key) => {
|
||||
delete storageStore[key];
|
||||
},
|
||||
},
|
||||
},
|
||||
storage,
|
||||
runtime: {
|
||||
onMessage: { addListener: jest.fn() },
|
||||
onConnect: { addListener: jest.fn() },
|
||||
@@ -301,7 +288,7 @@ function loadBackground(initialStore = {}) {
|
||||
}));
|
||||
jest.resetModules();
|
||||
require("../src/background/index");
|
||||
return { alarmsStub, listeners, store: storageStore };
|
||||
return { alarmsStub, listeners, storage };
|
||||
}
|
||||
|
||||
// Flush the promise chains the startup path and the alarm handlers run on.
|
||||
@@ -476,12 +463,16 @@ describe("balance refresh steady-state cadence", () => {
|
||||
// The guard's actual job, and the reason it is shortened rather than
|
||||
// removed: while the popup is open it refreshes every 10 seconds and
|
||||
// stamps the same field, and the background job has nothing to add.
|
||||
const store = seededStore();
|
||||
const { alarmsStub } = loadBackground(store);
|
||||
const { alarmsStub, storage } = loadBackground(seededStore());
|
||||
await settle();
|
||||
|
||||
mockNow += PERIOD_MS;
|
||||
store.autistmask.lastBalanceRefresh = mockNow - 10 * 1000;
|
||||
// As the open popup's own refresh would leave it: written to storage,
|
||||
// not poked into an object the worker happens to share.
|
||||
storage.write("autistmask", {
|
||||
...storage.read("autistmask"),
|
||||
lastBalanceRefresh: mockNow - 10 * 1000,
|
||||
});
|
||||
alarmsStub.fire(BALANCE_REFRESH_ALARM);
|
||||
await settle();
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const { Network, Wallet } = require("ethers");
|
||||
// before any jest.doMock() of the module, so the copy assertions below check
|
||||
// what the user is actually shown.
|
||||
const { describeSigningFailure } = require("../src/shared/approvalVerify");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const SIGNER_KEY =
|
||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
||||
@@ -137,22 +138,22 @@ function loadBackground(options) {
|
||||
jest.resetModules();
|
||||
|
||||
const broadcastTransaction = jest.fn();
|
||||
const loadState = jest.fn(opts.loadState || (async () => {}));
|
||||
|
||||
// The network the wallet is on, which the tests switch under a pending
|
||||
// approval. The node the transaction is populated against is on the same
|
||||
// one, as it would be: switching networks switches the RPC endpoint too.
|
||||
let chain = MAINNET;
|
||||
// The node the transaction is populated against is on whatever chain the
|
||||
// stored profile says, as it would be: switching networks switches the RPC
|
||||
// endpoint too. The background takes the network from storage per call —
|
||||
// it holds no in-memory copy — so this reads the record rather than a
|
||||
// variable the test keeps alongside it.
|
||||
const chainOf = (networkId) =>
|
||||
networkId === "sepolia" ? SEPOLIA : MAINNET;
|
||||
|
||||
jest.doMock("../src/shared/state", () => ({
|
||||
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
|
||||
loadState,
|
||||
saveState: jest.fn(async () => {}),
|
||||
currentNetwork: () => ({ chainId: chain.hex }),
|
||||
}));
|
||||
jest.doMock("../src/shared/balances", () => ({
|
||||
getProvider: () =>
|
||||
fakeProvider(broadcastTransaction, opts.provider, chain.num),
|
||||
getProvider: (rpcUrl, networkId) =>
|
||||
fakeProvider(
|
||||
broadcastTransaction,
|
||||
opts.provider,
|
||||
chainOf(networkId).num,
|
||||
),
|
||||
refreshBalances: jest.fn(async () => {}),
|
||||
}));
|
||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||
@@ -177,12 +178,31 @@ function loadBackground(options) {
|
||||
wallets: [
|
||||
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
|
||||
],
|
||||
networkId: "mainnet",
|
||||
rpcUrl: "https://rpc.invalid",
|
||||
activeAddress: signer.address,
|
||||
allowedSites: { [signer.address]: [HOSTNAME] },
|
||||
deniedSites: {},
|
||||
};
|
||||
|
||||
// The one wallet state there is. The background reads it per call and
|
||||
// writes it read-modify-write; it holds no in-memory copy and cannot reach
|
||||
// the shared singleton. Clones in both directions, as the real API does —
|
||||
// the stub here used to hand back the live record and drop every write on
|
||||
// the floor, so a test could neither see what was persisted nor be sure
|
||||
// what it read had crossed the boundary
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
|
||||
const storage = makeStorageStub({ autistmask: persisted });
|
||||
|
||||
// A test that needs the state read itself to misbehave installs a hook —
|
||||
// a stall, a throw — in place of the next reads. Armed after setup so
|
||||
// that raising the approval is not what fails.
|
||||
let storageGetHook = opts.storageGet || null;
|
||||
const realGet = storage.local.get;
|
||||
storage.local.get = jest.fn(async (key) =>
|
||||
storageGetHook ? storageGetHook(key) : realGet(key),
|
||||
);
|
||||
|
||||
let messageListener = null;
|
||||
let windowRemovedListener = null;
|
||||
let connectListener = null;
|
||||
@@ -194,15 +214,7 @@ function loadBackground(options) {
|
||||
const actionPopups = [];
|
||||
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(
|
||||
opts.storageGet ||
|
||||
(async () => ({ autistmask: persisted })),
|
||||
),
|
||||
set: jest.fn(async () => {}),
|
||||
},
|
||||
},
|
||||
storage,
|
||||
runtime: {
|
||||
getURL: (path) => EXT_URL + path,
|
||||
onMessage: {
|
||||
@@ -401,18 +413,32 @@ function loadBackground(options) {
|
||||
connectApproval,
|
||||
closeWindow,
|
||||
broadcastTransaction,
|
||||
loadState,
|
||||
created,
|
||||
removed,
|
||||
storage,
|
||||
// The user switching account in the toolbar popup, as the background
|
||||
// sees it: the persisted active address changes underneath a pending
|
||||
// approval.
|
||||
setActiveAddress: (address) => {
|
||||
persisted.activeAddress = address;
|
||||
storage.write("autistmask", {
|
||||
...storage.read("autistmask"),
|
||||
activeAddress: address,
|
||||
});
|
||||
},
|
||||
// The user switching network in the toolbar popup.
|
||||
// The user switching network in the toolbar popup. It moves the stored
|
||||
// network and the endpoint together, as a real switch does.
|
||||
setNetwork: (network) => {
|
||||
chain = network;
|
||||
const networkId = network === SEPOLIA ? "sepolia" : "mainnet";
|
||||
storage.write("autistmask", {
|
||||
...storage.read("autistmask"),
|
||||
networkId,
|
||||
rpcUrl: "https://rpc-" + networkId + ".invalid",
|
||||
});
|
||||
},
|
||||
// Make the next state reads misbehave — stall, throw — without
|
||||
// touching the reads that raised the approval. Pass null to restore.
|
||||
setStateReadHook: (hook) => {
|
||||
storageGetHook = hook;
|
||||
},
|
||||
fromPopup: { url: EXT_URL + "src/popup/index.html" },
|
||||
};
|
||||
@@ -677,15 +703,16 @@ describe("one transaction approval at a time", () => {
|
||||
// page never — and holds the slot for the life of the worker with it.
|
||||
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
|
||||
const stalled = deferred();
|
||||
const bg = loadBackground({
|
||||
loadState: async () => {
|
||||
await stalled.promise;
|
||||
throw new Error("The wallet data could not be read.");
|
||||
},
|
||||
});
|
||||
const bg = loadBackground();
|
||||
|
||||
const first = bg.requestTx();
|
||||
await settle();
|
||||
// Armed only now: the approval was raised against a working state
|
||||
// read, and it is the ATTEMPT's read that hangs and then fails.
|
||||
bg.setStateReadHook(async () => {
|
||||
await stalled.promise;
|
||||
throw new Error("The wallet data could not be read.");
|
||||
});
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
@@ -709,6 +736,7 @@ describe("one transaction approval at a time", () => {
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
|
||||
bg.setStateReadHook(null);
|
||||
const second = bg.requestTx();
|
||||
await settle();
|
||||
expect(second.result()).toBeNull();
|
||||
@@ -1226,19 +1254,18 @@ describe("what the approval is verified against", () => {
|
||||
// The interlock must not cost the retry the approval exists to allow.
|
||||
describe("the interlock releases a failed attempt", () => {
|
||||
test("a retryable failure before the broadcast leaves the approval usable", async () => {
|
||||
let failNext = true;
|
||||
const bg = loadBackground({
|
||||
loadState: async () => {
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
throw new Error("storage unavailable");
|
||||
}
|
||||
},
|
||||
});
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
// The attempt's state read fails once, then works: nothing was
|
||||
// broadcast, so the approval must survive for the retry.
|
||||
bg.setStateReadHook(() => {
|
||||
bg.setStateReadHook(null);
|
||||
throw new Error("storage unavailable");
|
||||
});
|
||||
|
||||
const first = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
|
||||
398
tests/backgroundStateIsolation.test.js
Normal file
398
tests/backgroundStateIsolation.test.js
Normal file
@@ -0,0 +1,398 @@
|
||||
// What one background handler's state can do to another's while both are in
|
||||
// flight.
|
||||
//
|
||||
// The background used to read and write the module-level `state` singleton in
|
||||
// src/shared/state.js — one object, shared by every handler in the worker,
|
||||
// replaced wholesale by any loadState(). Two consequences, both covered here
|
||||
// and both from https://git.eeqj.de/sneak/AutistMask/issues/324:
|
||||
//
|
||||
// - A transaction attempt captured the chain id at its loadState() and then
|
||||
// read the ENDPOINT off the singleton several awaits later. A chain switch
|
||||
// committed in that window moved the endpoint under an artifact already
|
||||
// verified against the old chain, so it would have gone to the new chain's
|
||||
// node — the very thing the verification exists to prevent.
|
||||
//
|
||||
// - backgroundRefresh() handed the singleton's wallets to refreshBalances(),
|
||||
// which mutates address objects in place across a multi-second network
|
||||
// round trip. Any concurrent handler that loaded state replaced those
|
||||
// objects, so the refreshed balances landed on detached ones and the save
|
||||
// that followed persisted the pre-refresh values — while still stamping
|
||||
// lastBalanceRefresh, suppressing the redo.
|
||||
//
|
||||
// Both use the real persistence path over a cloning storage stub. Nothing here
|
||||
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it
|
||||
// holds against any implementation that gets the outcome right.
|
||||
|
||||
const { Wallet } = require("ethers");
|
||||
const { networkById } = require("../src/shared/networks");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const SIGNER_KEY =
|
||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
||||
const signer = new Wallet(SIGNER_KEY);
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||
const CONNECTED_HOSTNAME = "dapp.example";
|
||||
const EXT_URL = "chrome-extension://autistmask/";
|
||||
|
||||
const MAINNET = networkById("mainnet");
|
||||
const SEPOLIA = networkById("sepolia");
|
||||
|
||||
const NONCE = 7;
|
||||
const REFRESHED_BALANCE = "1.5";
|
||||
|
||||
// The transaction the background populates, and the artifact signed from it.
|
||||
// Its chain is a parameter because the whole subject here is a chain moving
|
||||
// under work already committed to one.
|
||||
function populated(chainId) {
|
||||
return {
|
||||
type: 2,
|
||||
chainId,
|
||||
nonce: NONCE,
|
||||
gasLimit: 100000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
to: RECIPIENT,
|
||||
value: 10000000000000000n,
|
||||
data: "0x",
|
||||
};
|
||||
}
|
||||
|
||||
function storedProfile(networkId) {
|
||||
const net = networkById(networkId);
|
||||
return {
|
||||
hasWallet: true,
|
||||
wallets: [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
type: "hd",
|
||||
xpub: "xpub-1",
|
||||
addresses: [
|
||||
{
|
||||
address: signer.address,
|
||||
balance: "0.0",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
activeAddress: signer.address,
|
||||
networkId,
|
||||
rpcUrl: net.defaultRpcUrl,
|
||||
blockscoutUrl: net.defaultBlockscoutUrl,
|
||||
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
|
||||
deniedSites: {},
|
||||
trackedTokens: [],
|
||||
lastBalanceRefresh: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 60; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
// The background worker over a cloning storage stub, with the network and the
|
||||
// clock stubbed out. `opts.refreshBalances` replaces the balance refresh so a
|
||||
// test can hold one open across another handler's whole turn.
|
||||
function loadWorker(networkId, opts) {
|
||||
const options = opts || {};
|
||||
jest.resetModules();
|
||||
|
||||
// Every provider this worker constructs, in order, with the endpoint and
|
||||
// the network id it was given. The subject of the first test is which pair
|
||||
// reaches the broadcast.
|
||||
const providers = [];
|
||||
const broadcastTransaction = jest.fn(async () => ({ hash: "0xfeed" }));
|
||||
|
||||
jest.doMock("../src/shared/balances", () => ({
|
||||
getProvider: (rpcUrl, networkId2) => {
|
||||
const provider = {
|
||||
rpcUrl,
|
||||
networkId: networkId2,
|
||||
broadcastTransaction,
|
||||
getNetwork: async () => ({
|
||||
chainId: BigInt(networkById(networkId2).networkVersion),
|
||||
}),
|
||||
getTransactionCount: async () => NONCE,
|
||||
estimateGas: async () => 100000n,
|
||||
getFeeData: async () => ({
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
}),
|
||||
};
|
||||
providers.push(provider);
|
||||
return provider;
|
||||
},
|
||||
refreshBalances:
|
||||
options.refreshBalances || jest.fn(async () => undefined),
|
||||
}));
|
||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||
isPhishingDomain: () => false,
|
||||
}));
|
||||
let alarmHandlers = {};
|
||||
jest.doMock("../src/shared/alarms", () => ({
|
||||
BALANCE_REFRESH_ALARM: "balance",
|
||||
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
||||
ensureRecurringAlarms: jest.fn(async () => {}),
|
||||
registerAlarmHandlers: jest.fn((handlers) => {
|
||||
alarmHandlers = handlers;
|
||||
}),
|
||||
}));
|
||||
|
||||
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
|
||||
// A hook the tests use to suspend one handler mid-flight, so the other one
|
||||
// runs entirely inside its window.
|
||||
let getHook = null;
|
||||
const realGet = storage.local.get;
|
||||
storage.local.get = jest.fn(async (key) => {
|
||||
if (getHook) await getHook();
|
||||
return realGet(key);
|
||||
});
|
||||
|
||||
let messageListener = null;
|
||||
// Every popup URL the background opened. The approval id is in it, and
|
||||
// that is how the popup learns which approval it is answering.
|
||||
const createdUrls = [];
|
||||
global.chrome = {
|
||||
storage,
|
||||
runtime: {
|
||||
getURL: (path) => EXT_URL + path,
|
||||
onMessage: {
|
||||
addListener: (fn) => {
|
||||
messageListener = fn;
|
||||
},
|
||||
},
|
||||
onConnect: { addListener: () => {} },
|
||||
lastError: null,
|
||||
},
|
||||
windows: {
|
||||
getLastFocused: (cb) => cb(null),
|
||||
create: (createOpts, cb) => {
|
||||
createdUrls.push(createOpts.url);
|
||||
cb({ id: createdUrls.length });
|
||||
},
|
||||
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");
|
||||
|
||||
function send(msg, sender) {
|
||||
let result = null;
|
||||
const kept = messageListener(msg, sender, (r) => {
|
||||
result = r;
|
||||
});
|
||||
return { kept, result: () => result };
|
||||
}
|
||||
|
||||
function rpc(method, params, origin) {
|
||||
return send(
|
||||
{ type: "AUTISTMASK_RPC", method, params },
|
||||
{ origin: origin || CONNECTED_ORIGIN },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
rpc,
|
||||
send,
|
||||
providers,
|
||||
broadcastTransaction,
|
||||
persisted: () => storage.read("autistmask"),
|
||||
setGetHook: (hook) => {
|
||||
getHook = hook;
|
||||
},
|
||||
fromPopup: { url: EXT_URL + "src/popup/index.html" },
|
||||
fireBalanceAlarm: () => alarmHandlers.balance(),
|
||||
lastApprovalId: () => {
|
||||
const url = createdUrls[createdUrls.length - 1];
|
||||
if (!url) return null;
|
||||
return new URL(url, EXT_URL).searchParams.get("approval");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("a chain switch under a transaction already committed to a chain", () => {
|
||||
// Item 4 of https://git.eeqj.de/sneak/AutistMask/issues/324.
|
||||
//
|
||||
// The artifact is verified against the chain read at the top of the
|
||||
// attempt. Whatever endpoint it is then broadcast to has to be that same
|
||||
// chain's — otherwise the wallet checks a transaction against Sepolia and
|
||||
// sends it to a mainnet node. A connected site can switch the chain at any
|
||||
// moment, including this one.
|
||||
test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => {
|
||||
const bg = loadWorker("sepolia");
|
||||
|
||||
// Raise the approval, then find its id from the popup's own fetch.
|
||||
bg.rpc("eth_sendTransaction", [
|
||||
{
|
||||
from: signer.address,
|
||||
to: RECIPIENT,
|
||||
value: "0x2386f26fc10000",
|
||||
data: "0x",
|
||||
},
|
||||
]);
|
||||
await settle();
|
||||
|
||||
const id = bg.lastApprovalId();
|
||||
expect(id).toBeTruthy();
|
||||
|
||||
// The popup signs what it was shown: Sepolia.
|
||||
const rawSignedTx = await signer.signTransaction(
|
||||
populated(Number(SEPOLIA.networkVersion)),
|
||||
);
|
||||
|
||||
// A connected site switches the chain while the attempt is running,
|
||||
// and the switch is committed to storage in full before the attempt
|
||||
// goes any further.
|
||||
//
|
||||
// It is fired from inside the attempt's SECOND state read, because
|
||||
// that is where the window used to be: the chain id was captured at
|
||||
// the first read and the endpoint was taken off the singleton several
|
||||
// awaits later, so a switch landing between them moved the endpoint
|
||||
// under an artifact already verified against the old chain. An
|
||||
// implementation that takes both from one read has no second read for
|
||||
// this to fire on, and the switch below runs after the attempt is
|
||||
// done instead — which is the point.
|
||||
let reads = 0;
|
||||
let switched = null;
|
||||
const doSwitch = async () => {
|
||||
switched = bg.rpc("wallet_switchEthereumChain", [
|
||||
{ chainId: MAINNET.chainId },
|
||||
]);
|
||||
await settle();
|
||||
};
|
||||
bg.setGetHook(async () => {
|
||||
reads++;
|
||||
if (reads !== 2) return;
|
||||
bg.setGetHook(null);
|
||||
await doSwitch();
|
||||
});
|
||||
|
||||
const attempt = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx,
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
bg.setGetHook(null);
|
||||
if (!switched) await doSwitch();
|
||||
expect(switched.result()).toEqual({ result: null });
|
||||
expect(bg.persisted().networkId).toBe("mainnet");
|
||||
await settle();
|
||||
|
||||
// It went out, and it went out to Sepolia's node — the chain the
|
||||
// artifact was verified against. Reading the endpoint separately from
|
||||
// the chain id put mainnet's here.
|
||||
expect(attempt.result()).toEqual({ txHash: "0xfeed" });
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
const used = bg.providers[bg.providers.length - 1];
|
||||
expect(used.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
||||
expect(used.networkId).toBe("sepolia");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a balance refresh under another handler's state read", () => {
|
||||
// Item 5 of https://git.eeqj.de/sneak/AutistMask/issues/324.
|
||||
//
|
||||
// The trigger is a same-chain wallet_switchEthereumChain from a connected
|
||||
// site: it answers { result: null } and changes nothing, so the ONLY thing
|
||||
// it can do to the refresh is what its state read does. On the singleton
|
||||
// that read replaced state.wallets, detaching the objects the refresh was
|
||||
// mutating.
|
||||
test("a chain read arriving mid-refresh does not discard the refresh", async () => {
|
||||
const roundTrip = deferred();
|
||||
const reachedNetwork = deferred();
|
||||
|
||||
const bg = loadWorker("sepolia", {
|
||||
refreshBalances: async (wallets) => {
|
||||
reachedNetwork.resolve();
|
||||
await roundTrip.promise;
|
||||
// In place, on the objects handed in — as balances.js does.
|
||||
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = bg.fireBalanceAlarm();
|
||||
await reachedNetwork.promise;
|
||||
|
||||
const answered = bg.rpc("wallet_switchEthereumChain", [
|
||||
{ chainId: SEPOLIA.chainId },
|
||||
]);
|
||||
await settle();
|
||||
expect(answered.result()).toEqual({ result: null });
|
||||
|
||||
roundTrip.resolve();
|
||||
await refresh;
|
||||
|
||||
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
|
||||
REFRESHED_BALANCE,
|
||||
);
|
||||
expect(bg.persisted().lastBalanceRefresh).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The other half of "does not publish a shared object": a wallet added
|
||||
// while the refresh was in flight must survive the refresh's own write.
|
||||
test("a wallet added mid-refresh survives the refresh's write", async () => {
|
||||
const roundTrip = deferred();
|
||||
const reachedNetwork = deferred();
|
||||
|
||||
const bg = loadWorker("sepolia", {
|
||||
refreshBalances: async (wallets) => {
|
||||
reachedNetwork.resolve();
|
||||
await roundTrip.promise;
|
||||
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = bg.fireBalanceAlarm();
|
||||
await reachedNetwork.promise;
|
||||
|
||||
// Another extension page adds a wallet while the round trip is out.
|
||||
const during = bg.persisted();
|
||||
during.wallets.push({
|
||||
name: "Wallet 2",
|
||||
type: "hd",
|
||||
xpub: "xpub-2",
|
||||
addresses: [
|
||||
{ address: RECIPIENT, balance: "0.0", tokenBalances: [] },
|
||||
],
|
||||
});
|
||||
global.chrome.storage.write("autistmask", during);
|
||||
|
||||
roundTrip.resolve();
|
||||
await refresh;
|
||||
|
||||
const after = bg.persisted();
|
||||
expect(after.wallets).toHaveLength(2);
|
||||
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
|
||||
});
|
||||
});
|
||||
235
tests/backgroundStateLintRule.test.js
Normal file
235
tests/backgroundStateLintRule.test.js
Normal file
@@ -0,0 +1,235 @@
|
||||
// The lint rule that keeps src/shared/state.js out of the background bundle
|
||||
// (script/lib/eslint/noStateSingletonInBackground.js).
|
||||
//
|
||||
// Five defects, one of which destroyed a wallet, came from background code
|
||||
// reaching that singleton, and each point fix created the next site
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
|
||||
//
|
||||
// What this file does NOT do is establish that the singleton cannot reach the
|
||||
// background bundle. That is build.js's FORBIDDEN_INPUTS assertion, which reads
|
||||
// esbuild's metafile and so cannot be evaded by a syntax a matcher does not
|
||||
// know. The rule under test here is fast local feedback in front of that, and
|
||||
// these cases pin the shapes it is known to catch, so a regression in the
|
||||
// matcher is a failing test rather than a quietly narrower rule.
|
||||
//
|
||||
// Every shape below was measured against a real `make build`: each one puts
|
||||
// state.js in the shipped background bundles, and each one was invisible to
|
||||
// some earlier revision of the matcher — the quoted-only regex missed the
|
||||
// backtick, the dynamic import and the `from` clause; its successor missed a
|
||||
// comment inside the call and a directory resolved through package.json `main`.
|
||||
//
|
||||
// NOT covered, deliberately: a computed specifier such as
|
||||
// `require("../shared/" + "state")`. esbuild cannot resolve that statically
|
||||
// either, so it never reaches the bundle — there is nothing to block.
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { Linter } = require("eslint");
|
||||
|
||||
const plugin = require("../script/lib/eslint/noStateSingletonInBackground");
|
||||
|
||||
const RULE = "background/no-state-singleton-in-background";
|
||||
|
||||
// The three files a fixture tree always has. `src/background/index.js` is
|
||||
// supplied per case; the other two stand in for the real modules.
|
||||
const SHARED_STATE = "const state = {};\nmodule.exports = { state };\n";
|
||||
const SHARED_HOP =
|
||||
"// A shared module the background legitimately imports.\n" +
|
||||
"module.exports = { applyChainSwitchFields() {} };\n";
|
||||
|
||||
let roots = [];
|
||||
|
||||
function fixture(files) {
|
||||
const root = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-state-rule-")),
|
||||
);
|
||||
roots.push(root);
|
||||
const tree = {
|
||||
"src/shared/state.js": SHARED_STATE,
|
||||
"src/shared/chainSwitchFields.js": SHARED_HOP,
|
||||
...files,
|
||||
};
|
||||
for (const [rel, source] of Object.entries(tree)) {
|
||||
const abs = path.join(root, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, source);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
// Run the rule exactly as eslint.config.js runs it, over a real tree: the walk
|
||||
// reads its sources from disk, so a virtual RuleTester would not exercise it.
|
||||
// `sourceType` is the fixture's own, not the rule's business: the walk is
|
||||
// textual and never parses the files it follows. The two ESM cases below pass
|
||||
// "module" only so espree can parse the fixture at all — in this repo those
|
||||
// shapes are also a parse error under the commonjs config, but the rule must
|
||||
// not be left depending on that.
|
||||
function lintBackground(root, { sourceType = "commonjs" } = {}) {
|
||||
const file = path.join(root, "src/background/index.js");
|
||||
const linter = new Linter({ cwd: root });
|
||||
return linter.verify(
|
||||
fs.readFileSync(file, "utf8"),
|
||||
{
|
||||
plugins: { background: plugin },
|
||||
languageOptions: { ecmaVersion: 2024, sourceType },
|
||||
rules: { [RULE]: "error" },
|
||||
},
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
function chainOf(messages) {
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].ruleId).toBe(RULE);
|
||||
// "...singleton: <chain>. The MV3 worker..." — the chain is what the
|
||||
// message exists to hand the reader, so assert on it rather than on the
|
||||
// fact that something was reported.
|
||||
return messages[0].message.split("singleton: ")[1].split(". The MV3")[0];
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
|
||||
roots = [];
|
||||
});
|
||||
|
||||
describe("the specifier syntaxes the matcher is known to catch", () => {
|
||||
test("a quoted require", () => {
|
||||
const root = fixture({
|
||||
"src/background/index.js":
|
||||
'const { state } = require("../shared/state");\n' +
|
||||
"module.exports = { state };\n",
|
||||
});
|
||||
expect(chainOf(lintBackground(root))).toBe(
|
||||
"src/background/index.js -> src/shared/state.js",
|
||||
);
|
||||
});
|
||||
|
||||
test("a backtick require", () => {
|
||||
const root = fixture({
|
||||
"src/background/index.js":
|
||||
"const { state } = require(`../shared/state`);\n" +
|
||||
"module.exports = { state };\n",
|
||||
});
|
||||
expect(chainOf(lintBackground(root))).toBe(
|
||||
"src/background/index.js -> src/shared/state.js",
|
||||
);
|
||||
});
|
||||
|
||||
test("a dynamic import inside an async function", () => {
|
||||
const root = fixture({
|
||||
"src/background/index.js":
|
||||
"async function readState() {\n" +
|
||||
' const m = await import("../shared/state");\n' +
|
||||
" return m.state;\n" +
|
||||
"}\n" +
|
||||
"module.exports = { readState };\n",
|
||||
});
|
||||
expect(chainOf(lintBackground(root))).toBe(
|
||||
"src/background/index.js -> src/shared/state.js",
|
||||
);
|
||||
});
|
||||
|
||||
test("a static import from-clause", () => {
|
||||
const root = fixture({
|
||||
"src/background/index.js":
|
||||
'import { state } from "../shared/state";\n' +
|
||||
"export { state };\n",
|
||||
});
|
||||
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
|
||||
"src/background/index.js -> src/shared/state.js",
|
||||
);
|
||||
});
|
||||
|
||||
// `import(/* webpackChunkName: "x" */ "./x")` is the standard bundler
|
||||
// annotation idiom, and prettier leaves both of these exactly as written,
|
||||
// so nothing else in the repo would object to them either.
|
||||
test("a comment between the paren and the specifier", () => {
|
||||
const root = fixture({
|
||||
"src/background/index.js":
|
||||
'globalThis.__probe = require(/* probe */ "../shared/state").state;\n',
|
||||
});
|
||||
expect(chainOf(lintBackground(root))).toBe(
|
||||
"src/background/index.js -> src/shared/state.js",
|
||||
);
|
||||
});
|
||||
|
||||
test("a comment between the specifier and the closing paren", () => {
|
||||
const root = fixture({
|
||||
"src/background/index.js":
|
||||
'globalThis.__probe = require("../shared/state" /* probe */).state;\n',
|
||||
});
|
||||
expect(chainOf(lintBackground(root))).toBe(
|
||||
"src/background/index.js -> src/shared/state.js",
|
||||
);
|
||||
});
|
||||
|
||||
test("a bare side-effect import", () => {
|
||||
const root = fixture({
|
||||
"src/background/index.js": 'import "../shared/state";\n',
|
||||
});
|
||||
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
|
||||
"src/background/index.js -> src/shared/state.js",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reachability, not just the direct specifier", () => {
|
||||
// The shape a no-restricted-imports could never see: no background file
|
||||
// names state.js, and the singleton is in the bundle anyway. In a backtick
|
||||
// require, so this fails on the specifier widening as well as on the walk.
|
||||
test("a two-hop re-export through a shared module", () => {
|
||||
const root = fixture({
|
||||
"src/background/index.js":
|
||||
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
|
||||
"module.exports = { applyChainSwitchFields };\n",
|
||||
"src/shared/chainSwitchFields.js":
|
||||
SHARED_HOP +
|
||||
"module.exports.state = require(`./state`).state;\n",
|
||||
});
|
||||
expect(chainOf(lintBackground(root))).toBe(
|
||||
"src/background/index.js -> src/shared/chainSwitchFields.js" +
|
||||
" -> src/shared/state.js",
|
||||
);
|
||||
});
|
||||
|
||||
// Resolution, not syntax: the specifier names a directory, and the file it
|
||||
// resolves to is chosen by that directory's package.json `main`. A walk
|
||||
// that only tries `<dir>/index.js` stops on a specifier it matched.
|
||||
test("a directory resolved through its package.json main", () => {
|
||||
const root = fixture({
|
||||
"src/background/index.js":
|
||||
'globalThis.__probe = require("../shared/probepkg").state;\n',
|
||||
"src/shared/probepkg/package.json": '{"main": "./bridge.js"}\n',
|
||||
"src/shared/probepkg/bridge.js":
|
||||
'const { state } = require("../state");\n' +
|
||||
"module.exports = { state };\n",
|
||||
});
|
||||
expect(chainOf(lintBackground(root))).toBe(
|
||||
"src/background/index.js -> src/shared/probepkg/bridge.js" +
|
||||
" -> src/shared/state.js",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the rule must not report", () => {
|
||||
test("a background file that reaches only its own state layer", () => {
|
||||
const root = fixture({
|
||||
"src/background/index.js":
|
||||
'const { getState } = require("./state");\n' +
|
||||
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
|
||||
"module.exports = { getState, applyChainSwitchFields };\n",
|
||||
"src/background/state.js":
|
||||
"async function getState() {}\nmodule.exports = { getState };\n",
|
||||
});
|
||||
expect(lintBackground(root)).toEqual([]);
|
||||
});
|
||||
|
||||
// The tree as it actually stands. This is the assertion that would catch a
|
||||
// widened matcher that resolves something it should not: it runs the rule
|
||||
// over the real background entrypoint, from the real repo root.
|
||||
test("the repository's own background entrypoint", () => {
|
||||
const root = path.resolve(__dirname, "..");
|
||||
expect(lintBackground(root)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,12 @@
|
||||
// broadcast — because an error code alone would not distinguish a gate from
|
||||
// a switch that happened and then reported a failure.
|
||||
//
|
||||
// The endpoint half of that issue lives in tests/networkEndpoints.test.js;
|
||||
// this file mocks the state module, which that one exercises for real.
|
||||
// The endpoint half of that issue lives in tests/networkEndpoints.test.js,
|
||||
// which covers the popup's chain switch; this file covers the background's,
|
||||
// which goes through storage rather than the shared state singleton.
|
||||
|
||||
const { networkById } = require("../src/shared/networks");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
@@ -51,29 +53,11 @@ afterEach(() => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Load the background worker against stubbed browser APIs, with the real
|
||||
// chain-switch module behind it, and return the handles to drive it. The
|
||||
// wallet state is a plain object so that a switch that DID happen is visible
|
||||
// as a mutation of it, and one that did not is visible as its absence.
|
||||
// chain-switch and persistence modules behind it, and return the handles to
|
||||
// drive it.
|
||||
function loadBackground() {
|
||||
jest.resetModules();
|
||||
|
||||
const walletState = {
|
||||
networkId: "mainnet",
|
||||
rpcUrl: CUSTOM_RPC,
|
||||
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
|
||||
networkEndpoints: {},
|
||||
wallets: walletFixture(),
|
||||
lastBalanceRefresh: 1,
|
||||
tokenHolderCache: {},
|
||||
fraudContracts: [],
|
||||
};
|
||||
|
||||
jest.doMock("../src/shared/state", () => ({
|
||||
state: walletState,
|
||||
loadState: jest.fn(async () => {}),
|
||||
saveState: jest.fn(async () => {}),
|
||||
currentNetwork: () => networkById(walletState.networkId),
|
||||
}));
|
||||
jest.doMock("../src/shared/balances", () => ({
|
||||
getProvider: () => ({}),
|
||||
refreshBalances: jest.fn(async () => {}),
|
||||
@@ -88,12 +72,24 @@ function loadBackground() {
|
||||
registerAlarmHandlers: jest.fn(),
|
||||
}));
|
||||
|
||||
// Storage is the only wallet state there is. The background reads and
|
||||
// writes it per call — it holds no in-memory copy and cannot reach the
|
||||
// shared singleton — so a switch that happened is visible here as a
|
||||
// written record, and one that did not is visible as its absence.
|
||||
const persisted = {
|
||||
networkId: "mainnet",
|
||||
rpcUrl: CUSTOM_RPC,
|
||||
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
|
||||
networkEndpoints: {},
|
||||
wallets: walletFixture(),
|
||||
lastBalanceRefresh: 1,
|
||||
tokenHolderCache: {},
|
||||
fraudContracts: [],
|
||||
activeAddress: ADDRESS,
|
||||
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||
deniedSites: {},
|
||||
};
|
||||
const storage = makeStorageStub({ autistmask: persisted });
|
||||
|
||||
let messageListener = null;
|
||||
// Every message the background pushed at a content script. chainChanged
|
||||
@@ -102,12 +98,7 @@ function loadBackground() {
|
||||
const toTabs = [];
|
||||
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(async () => ({ autistmask: persisted })),
|
||||
set: jest.fn(async () => {}),
|
||||
},
|
||||
},
|
||||
storage,
|
||||
runtime: {
|
||||
getURL: (path) => "chrome-extension://autistmask/" + path,
|
||||
onMessage: {
|
||||
@@ -157,7 +148,7 @@ function loadBackground() {
|
||||
|
||||
return {
|
||||
switchChain,
|
||||
walletState,
|
||||
walletState: () => storage.read("autistmask"),
|
||||
chainChangedEvents: () =>
|
||||
toTabs.filter((m) => m.eventName === "chainChanged"),
|
||||
};
|
||||
@@ -174,8 +165,8 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
|
||||
// The refusal has to be a refusal to ACT, not just an error string:
|
||||
// the wallet is still on mainnet, still on the user's own node, and
|
||||
// no page was told the chain moved.
|
||||
expect(bg.walletState.networkId).toBe("mainnet");
|
||||
expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC);
|
||||
expect(bg.walletState().networkId).toBe("mainnet");
|
||||
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
|
||||
expect(bg.chainChangedEvents()).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -201,7 +192,7 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
|
||||
const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
||||
|
||||
expect(result).toEqual({ result: null });
|
||||
expect(bg.walletState.networkId).toBe("sepolia");
|
||||
expect(bg.walletState().networkId).toBe("sepolia");
|
||||
expect(bg.chainChangedEvents()).toEqual([
|
||||
{
|
||||
type: "AUTISTMASK_EVENT",
|
||||
@@ -217,16 +208,16 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
|
||||
const result = await bg.switchChain("0x89", CONNECTED_ORIGIN);
|
||||
|
||||
expect(result.error.code).toBe(4902);
|
||||
expect(bg.walletState.networkId).toBe("mainnet");
|
||||
expect(bg.walletState().networkId).toBe("mainnet");
|
||||
});
|
||||
|
||||
test("a switch by a connected origin keeps the user's endpoint", async () => {
|
||||
const bg = loadBackground();
|
||||
|
||||
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
||||
expect(bg.walletState.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
||||
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
||||
|
||||
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
|
||||
expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC);
|
||||
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// first, so neither can see this.
|
||||
|
||||
const { networkById } = require("../src/shared/networks");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
@@ -64,9 +65,12 @@ afterEach(() => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
// Load the background worker with the real state and chain-switch modules
|
||||
// behind it, over a storage stub that actually keeps what is written — a
|
||||
// wipe is only observable against storage that remembers.
|
||||
// Load the background worker with the real chain-switch and persistence
|
||||
// modules behind it, over a storage stub that actually keeps what is written —
|
||||
// a wipe is only observable against storage that remembers — and that clones
|
||||
// in both directions, as the real API does. It used to alias, so the record
|
||||
// the worker held and the "stored" one were a single object; see
|
||||
// tests/support/storageStub.js.
|
||||
function loadColdWorker(networkId) {
|
||||
jest.resetModules();
|
||||
|
||||
@@ -84,20 +88,13 @@ function loadColdWorker(networkId) {
|
||||
registerAlarmHandlers: jest.fn(),
|
||||
}));
|
||||
|
||||
const store = { autistmask: storedProfile(networkId) };
|
||||
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
|
||||
|
||||
let messageListener = null;
|
||||
const toTabs = [];
|
||||
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(async () => ({ autistmask: store.autistmask })),
|
||||
set: jest.fn(async (items) => {
|
||||
store.autistmask = items.autistmask;
|
||||
}),
|
||||
},
|
||||
},
|
||||
storage,
|
||||
runtime: {
|
||||
getURL: (path) => "chrome-extension://autistmask/" + path,
|
||||
onMessage: {
|
||||
@@ -147,7 +144,7 @@ function loadColdWorker(networkId) {
|
||||
|
||||
return {
|
||||
switchChain,
|
||||
persisted: () => store.autistmask,
|
||||
persisted: () => storage.read("autistmask"),
|
||||
chainChangedEvents: () =>
|
||||
toTabs.filter((m) => m.eventName === "chainChanged"),
|
||||
};
|
||||
|
||||
279
tests/coldWorkerSendTransaction.test.js
Normal file
279
tests/coldWorkerSendTransaction.test.js
Normal file
@@ -0,0 +1,279 @@
|
||||
// Which chain a dApp transaction is PREPARED for on a worker that has not
|
||||
// loaded state.
|
||||
//
|
||||
// The MV3 service worker is terminated when idle — roughly 30 seconds, which
|
||||
// is its normal condition — and revived by the page's own message. Nothing
|
||||
// loads state at module scope, so handleSendTransaction() used to build its
|
||||
// provider with `getProvider(await getRpcUrl())`: the endpoint came from
|
||||
// storage and was right, and the static network hint was omitted, so
|
||||
// src/shared/balances.js fell back to currentNetwork() — the unpopulated
|
||||
// singleton — and answered mainnet. ethers then fixed `chainId` at 0x1.
|
||||
//
|
||||
// The transaction was not sent on the wrong chain: verifySignedTx() compares
|
||||
// the artifact against the selected chain and refused it. So the guard held
|
||||
// and the feature did not — a user on any non-mainnet network could not send
|
||||
// from a dApp at all, and the error described the symptom
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
|
||||
//
|
||||
// This drives the real balances module and the real approval preparation and
|
||||
// verification. Only ethers' JsonRpcProvider is replaced, so the static
|
||||
// network hint getProvider() computes is the hint the population sees.
|
||||
|
||||
const { Network, Wallet, Transaction } = require("ethers");
|
||||
const { networkById } = require("../src/shared/networks");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const SIGNER_KEY =
|
||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
||||
const signer = new Wallet(SIGNER_KEY);
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||
const CONNECTED_HOSTNAME = "dapp.example";
|
||||
const EXT_URL = "chrome-extension://autistmask/";
|
||||
|
||||
const SEPOLIA = networkById("sepolia");
|
||||
const MAINNET = networkById("mainnet");
|
||||
|
||||
const NONCE = 7;
|
||||
const TX_HASH = "0xfeed";
|
||||
|
||||
const TX_PARAMS = {
|
||||
from: signer.address,
|
||||
to: RECIPIENT,
|
||||
value: "0x2386f26fc10000",
|
||||
data: "0x",
|
||||
};
|
||||
|
||||
function storedProfile(networkId) {
|
||||
const net = networkById(networkId);
|
||||
return {
|
||||
hasWallet: true,
|
||||
wallets: [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
type: "hd",
|
||||
xpub: "xpub-1",
|
||||
addresses: [
|
||||
{
|
||||
address: signer.address,
|
||||
balance: "0.0",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
activeAddress: signer.address,
|
||||
networkId,
|
||||
rpcUrl: net.defaultRpcUrl,
|
||||
blockscoutUrl: net.defaultBlockscoutUrl,
|
||||
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
|
||||
deniedSites: {},
|
||||
trackedTokens: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 60; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
// A worker whose only wallet state is what is in storage, with ethers'
|
||||
// JsonRpcProvider replaced by a stub that answers out of the static network it
|
||||
// was constructed with — which is exactly what a real staticNetwork provider
|
||||
// does, and what makes the chain id on the approval screen observable here.
|
||||
function loadColdWorker(networkId) {
|
||||
jest.resetModules();
|
||||
|
||||
const constructed = [];
|
||||
const broadcast = [];
|
||||
|
||||
jest.doMock("ethers", () => {
|
||||
const actual = jest.requireActual("ethers");
|
||||
class StubJsonRpcProvider {
|
||||
constructor(url, network) {
|
||||
this._network = network;
|
||||
constructed.push({ url, network });
|
||||
}
|
||||
async getNetwork() {
|
||||
return this._network;
|
||||
}
|
||||
async getTransactionCount() {
|
||||
return NONCE;
|
||||
}
|
||||
async estimateGas() {
|
||||
return 100000n;
|
||||
}
|
||||
async getFeeData() {
|
||||
return {
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
};
|
||||
}
|
||||
async broadcastTransaction(raw) {
|
||||
broadcast.push(raw);
|
||||
return { hash: TX_HASH };
|
||||
}
|
||||
}
|
||||
return { ...actual, JsonRpcProvider: StubJsonRpcProvider };
|
||||
});
|
||||
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 storage = makeStorageStub({ autistmask: storedProfile(networkId) });
|
||||
|
||||
let messageListener = null;
|
||||
const createdUrls = [];
|
||||
|
||||
global.chrome = {
|
||||
storage,
|
||||
runtime: {
|
||||
getURL: (path) => EXT_URL + path,
|
||||
onMessage: {
|
||||
addListener: (fn) => {
|
||||
messageListener = fn;
|
||||
},
|
||||
},
|
||||
onConnect: { addListener: () => {} },
|
||||
lastError: null,
|
||||
},
|
||||
windows: {
|
||||
getLastFocused: (cb) => cb(null),
|
||||
create: (opts, cb) => {
|
||||
createdUrls.push(opts.url);
|
||||
cb({ id: createdUrls.length });
|
||||
},
|
||||
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");
|
||||
|
||||
function send(msg, sender) {
|
||||
let result = null;
|
||||
messageListener(msg, sender, (r) => {
|
||||
result = r;
|
||||
});
|
||||
return () => result;
|
||||
}
|
||||
|
||||
return {
|
||||
send,
|
||||
constructed,
|
||||
broadcast,
|
||||
fromPopup: { url: EXT_URL + "src/popup/index.html" },
|
||||
// The first message this worker ever sees, as the injected provider
|
||||
// sends it.
|
||||
sendTransaction: () =>
|
||||
send(
|
||||
{
|
||||
type: "AUTISTMASK_RPC",
|
||||
method: "eth_sendTransaction",
|
||||
params: [TX_PARAMS],
|
||||
},
|
||||
{ origin: CONNECTED_ORIGIN },
|
||||
),
|
||||
approvalId: () => {
|
||||
const url = createdUrls[createdUrls.length - 1];
|
||||
return url
|
||||
? new URL(url, EXT_URL).searchParams.get("approval")
|
||||
: null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// What the approval window does: fetch the approval and sign the transaction
|
||||
// it was handed, exactly as given.
|
||||
function signApproved(approvedTx) {
|
||||
const tx = {};
|
||||
for (const [key, value] of Object.entries(approvedTx)) {
|
||||
if (key === "from") continue;
|
||||
tx[key] = value;
|
||||
}
|
||||
return signer.signTransaction(tx);
|
||||
}
|
||||
|
||||
describe("a dApp transaction prepared by a worker that never loaded state", () => {
|
||||
test("a cold send on Sepolia reaches the approval screen and goes out", async () => {
|
||||
const bg = loadColdWorker("sepolia");
|
||||
|
||||
const answer = bg.sendTransaction();
|
||||
await settle();
|
||||
|
||||
// The provider was built for Sepolia, endpoint and static hint
|
||||
// together. Omitting the hint made this mainnet.
|
||||
expect(bg.constructed).toHaveLength(1);
|
||||
expect(bg.constructed[0].url).toBe(SEPOLIA.defaultRpcUrl);
|
||||
expect(bg.constructed[0].network.chainId).toBe(
|
||||
Network.from("sepolia").chainId,
|
||||
);
|
||||
|
||||
// So the approval the user is shown is a Sepolia transaction.
|
||||
const id = bg.approvalId();
|
||||
expect(id).toBeTruthy();
|
||||
const approval = bg.send(
|
||||
{ type: "AUTISTMASK_GET_APPROVAL", id },
|
||||
{ url: bg.fromPopup.url },
|
||||
)();
|
||||
expect(approval.type).toBe("tx");
|
||||
expect(approval.approvedTx.chainId).toBe(SEPOLIA.chainId);
|
||||
|
||||
// And it survives the wallet's own verification, which is where a
|
||||
// 0x1-stamped artifact was refused as "for a different network".
|
||||
const rawSignedTx = await signApproved(approval.approvedTx);
|
||||
const response = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx,
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(response()).toEqual({ txHash: TX_HASH });
|
||||
expect(bg.broadcast).toEqual([rawSignedTx]);
|
||||
expect(Number(Transaction.from(rawSignedTx).chainId)).toBe(
|
||||
Number(SEPOLIA.networkVersion),
|
||||
);
|
||||
expect(answer()).toEqual({ result: TX_HASH });
|
||||
});
|
||||
|
||||
test("a cold send on mainnet is prepared for mainnet", async () => {
|
||||
// The stored value and the old fallback agree here, so this case
|
||||
// cannot catch the defect; it is what keeps the fix from being a swap.
|
||||
const bg = loadColdWorker("mainnet");
|
||||
|
||||
bg.sendTransaction();
|
||||
await settle();
|
||||
|
||||
expect(bg.constructed[0].url).toBe(MAINNET.defaultRpcUrl);
|
||||
const approval = bg.send(
|
||||
{ type: "AUTISTMASK_GET_APPROVAL", id: bg.approvalId() },
|
||||
{ url: bg.fromPopup.url },
|
||||
)();
|
||||
expect(approval.approvedTx.chainId).toBe(MAINNET.chainId);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,14 @@ const {
|
||||
balanceWarningHtml,
|
||||
} = require("../src/popup/views/deleteAddress");
|
||||
const { prices, clearPrices } = require("../src/shared/prices");
|
||||
const { state } = require("../src/shared/state");
|
||||
|
||||
// The screen prices holdings, and pricing asks which chain it is on. Reading
|
||||
// the singleton's network before anything loaded it now throws rather than
|
||||
// answering mainnet by default
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/324), so the network this
|
||||
// fixture is on is stated instead of assumed.
|
||||
state.networkId = "mainnet";
|
||||
|
||||
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
||||
|
||||
|
||||
@@ -13,13 +13,15 @@
|
||||
// never persisting it looks identical from `state`, and a build that never
|
||||
// wrote at all would pass a check that only reads `state` back.
|
||||
//
|
||||
// That makes the storage stub load-bearing, so it is a real store that
|
||||
// structured-clones on both `set` and `get`. A stub whose `get` hands back
|
||||
// the same object its `set` was given aliases the caller's own array: the
|
||||
// test then reads its own in-memory mutation and calls it persistence, and
|
||||
// passes against a build that persists nothing (see issue #324). The
|
||||
// aliasing is closed off explicitly by the first test below rather than
|
||||
// left as an assumption about `structuredClone`.
|
||||
// That makes the storage stub load-bearing, so it is the shared one from
|
||||
// tests/support/storageStub.js, a real store that structured-clones on both
|
||||
// `set` and `get`. A stub whose `get` hands back the same object its `set`
|
||||
// was given aliases the caller's own array: the test then reads its own
|
||||
// in-memory mutation and calls it persistence, and passes against a build
|
||||
// that persists nothing
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The aliasing is closed
|
||||
// off explicitly by the first test below rather than left as an assumption
|
||||
// about `structuredClone`.
|
||||
//
|
||||
// The view is driven against a minimal DOM stub, in the same shape as
|
||||
// tests/exportPrivkey.test.js: the module reads and writes named nodes and
|
||||
@@ -34,6 +36,7 @@ jest.mock("../src/shared/vault", () => ({
|
||||
}));
|
||||
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const VIEW = "delete-wallet-lost-password";
|
||||
|
||||
@@ -94,35 +97,6 @@ function makeDocument() {
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- storage stub
|
||||
|
||||
// A store that behaves the way `chrome.storage.local` does: what goes in is
|
||||
// serialized, so the caller keeps no handle on what came to rest there, and
|
||||
// what comes out is a fresh object the caller may mutate freely.
|
||||
function makeStorage() {
|
||||
let store = {};
|
||||
return {
|
||||
get: async (keys) => {
|
||||
const wanted =
|
||||
keys === undefined || keys === null
|
||||
? Object.keys(store)
|
||||
: [].concat(keys);
|
||||
const out = {};
|
||||
for (const key of wanted) {
|
||||
if (key in store) out[key] = structuredClone(store[key]);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
set: async (items) => {
|
||||
for (const [key, value] of Object.entries(items)) {
|
||||
store[key] = structuredClone(value);
|
||||
}
|
||||
},
|
||||
// Test-only: what the extension would find on a cold start.
|
||||
_raw: () => structuredClone(store),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ harness
|
||||
|
||||
function wallet(name, secret, addresses) {
|
||||
@@ -144,10 +118,10 @@ function load() {
|
||||
jest.resetModules();
|
||||
mockSettingsShow.mockClear();
|
||||
|
||||
const storage = makeStorage();
|
||||
const storage = makeStorageStub();
|
||||
const sent = [];
|
||||
globalThis.chrome = {
|
||||
storage: { local: storage },
|
||||
storage: { local: storage.local },
|
||||
runtime: { sendMessage: (msg) => sent.push(msg) },
|
||||
};
|
||||
globalThis.document = makeDocument();
|
||||
@@ -205,7 +179,7 @@ async function openLostPassword(deleteWallet, walletIdx) {
|
||||
// every other test in this file against a build that never writes.
|
||||
describe("the storage stub", () => {
|
||||
test("does not hand back the object it was given", async () => {
|
||||
const storage = makeStorage();
|
||||
const storage = makeStorageStub();
|
||||
const written = { wallets: [{ name: "Wallet 1" }] };
|
||||
|
||||
await storage.set({ autistmask: written });
|
||||
@@ -393,8 +367,8 @@ describe("deleting without the password", () => {
|
||||
|
||||
// The deleted wallet's secret is gone from storage entirely, not
|
||||
// merely unreferenced by the wallet list.
|
||||
expect(JSON.stringify(storage._raw())).not.toContain("secret-two");
|
||||
expect(JSON.stringify(storage._raw())).not.toContain("xpub-Wallet 2");
|
||||
expect(JSON.stringify(storage.read())).not.toContain("secret-two");
|
||||
expect(JSON.stringify(storage.read())).not.toContain("xpub-Wallet 2");
|
||||
});
|
||||
|
||||
test("only the deleted wallet's site permissions are dropped", async () => {
|
||||
@@ -462,7 +436,7 @@ describe("deleting without the password", () => {
|
||||
expect(saved.activeAddress).toBeNull();
|
||||
expect(saved.allowedSites).toEqual({});
|
||||
expect(state.currentView).toBe("welcome");
|
||||
expect(JSON.stringify(storage._raw())).not.toContain("secret-one");
|
||||
expect(JSON.stringify(storage.read())).not.toContain("secret-one");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// happened.
|
||||
|
||||
const { networkById } = require("../src/shared/networks");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
@@ -34,25 +35,19 @@ function walletFixture() {
|
||||
// saveState() wrote — so a case can reload a fresh module from the bytes an
|
||||
// earlier one persisted, which is what an extension restart does. `state` is
|
||||
// a module-level singleton, so the registry has to be reset per load.
|
||||
// The stub clones in both directions, as the real chrome.storage.local does.
|
||||
// It used to alias, and written() then handed the NEXT module load the live
|
||||
// in-memory object of the previous one as its "persisted bytes" — an extension
|
||||
// restart that never crossed a serialization boundary. See
|
||||
// tests/support/storageStub.js.
|
||||
function loadModuleWith(persisted) {
|
||||
jest.resetModules();
|
||||
let written = null;
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(async () =>
|
||||
persisted ? { autistmask: persisted } : {},
|
||||
),
|
||||
set: jest.fn(async (items) => {
|
||||
written = items.autistmask;
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
|
||||
global.chrome = { storage };
|
||||
return {
|
||||
mod: require("../src/shared/state"),
|
||||
chainSwitch: require("../src/shared/chainSwitch"),
|
||||
written: () => written,
|
||||
written: () => storage.read("autistmask"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
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",
|
||||
@@ -51,19 +53,17 @@ describe("the UTC Timestamps checkbox placement", () => {
|
||||
});
|
||||
|
||||
describe("the UTC Timestamps setting round-trips through storage", () => {
|
||||
let store;
|
||||
let storage;
|
||||
|
||||
// The stub clones in both directions, as the real chrome.storage.local
|
||||
// does. It used to alias, which is fatal to a round-trip test in
|
||||
// particular: the object the module holds and the object "storage" holds
|
||||
// are then the same object, so the setting appears to have been persisted
|
||||
// and read back on a build where neither happened. See
|
||||
// tests/support/storageStub.js.
|
||||
function loadStateModule() {
|
||||
store = {};
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: async (key) =>
|
||||
key in store ? { [key]: store[key] } : {},
|
||||
set: async (obj) => Object.assign(store, obj),
|
||||
},
|
||||
},
|
||||
};
|
||||
storage = makeStorageStub();
|
||||
global.chrome = { storage };
|
||||
jest.resetModules();
|
||||
return require("../src/shared/state");
|
||||
}
|
||||
@@ -86,12 +86,18 @@ describe("the UTC Timestamps setting round-trips through storage", () => {
|
||||
// What the change handler in views/settings.js does.
|
||||
first.state.utcTimestamps = true;
|
||||
await first.saveState();
|
||||
expect(store.autistmask.utcTimestamps).toBe(true);
|
||||
expect(storage.read("autistmask").utcTimestamps).toBe(true);
|
||||
|
||||
// A fresh popup load sees it.
|
||||
// A fresh popup load sees it — and, before that load, refuses to
|
||||
// answer at all rather than reporting the default. That refusal is
|
||||
// what makes the assertion below evidence of a read from storage
|
||||
// instead of a value that was already sitting in memory
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
|
||||
jest.resetModules();
|
||||
const second = require("../src/shared/state");
|
||||
expect(second.state.utcTimestamps).toBe(false);
|
||||
expect(() => second.state.utcTimestamps).toThrow(
|
||||
second.StateNotLoadedError,
|
||||
);
|
||||
await second.loadState();
|
||||
expect(second.state.utcTimestamps).toBe(true);
|
||||
});
|
||||
|
||||
@@ -4,23 +4,24 @@ function oneWallet() {
|
||||
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
|
||||
}
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
// state.js resolves the storage API at require time, so the stub has to exist
|
||||
// before the module is loaded, and the module registry has to be reset between
|
||||
// cases because `state` is a module-level singleton.
|
||||
//
|
||||
// The stub clones in both directions, as the real chrome.storage.local does —
|
||||
// see tests/support/storageStub.js for why an aliasing one made this file
|
||||
// assert less than it appears to.
|
||||
function loadModuleWith(persisted) {
|
||||
jest.resetModules();
|
||||
const set = jest.fn(async () => {});
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(async () =>
|
||||
persisted ? { autistmask: persisted } : {},
|
||||
),
|
||||
set,
|
||||
},
|
||||
},
|
||||
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
|
||||
global.chrome = { storage };
|
||||
return {
|
||||
mod: require("../src/shared/state"),
|
||||
set: storage.set,
|
||||
stored: () => storage.read("autistmask"),
|
||||
};
|
||||
return { mod: require("../src/shared/state"), set };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -10,32 +10,12 @@
|
||||
//
|
||||
// Both cases below drive the real state.js module through two independent
|
||||
// module registries sharing one storage backend, the way two real extension
|
||||
// pages share one chrome.storage.local. The storage stub structured-clones
|
||||
// on both get and set — a stub that hands back the object it was given
|
||||
// aliases the caller's own mutation and would make this entire defect class
|
||||
// invisible (see https://git.eeqj.de/sneak/AutistMask/issues/324).
|
||||
// pages share one chrome.storage.local. The shared stub structured-clones on
|
||||
// both get and set — a stub that hands back the object it was given aliases
|
||||
// the caller's own mutation and would make this entire defect class invisible
|
||||
// (see https://git.eeqj.de/sneak/AutistMask/issues/324).
|
||||
|
||||
function makeStorage() {
|
||||
let store = {};
|
||||
return {
|
||||
get: async (keys) => {
|
||||
const wanted =
|
||||
keys === undefined || keys === null
|
||||
? Object.keys(store)
|
||||
: [].concat(keys);
|
||||
const out = {};
|
||||
for (const key of wanted) {
|
||||
if (key in store) out[key] = structuredClone(store[key]);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
set: async (items) => {
|
||||
for (const [key, value] of Object.entries(items)) {
|
||||
store[key] = structuredClone(value);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
// One extension page: a fresh module registry over the shared storage.
|
||||
// state.js resolves the storage API at require time, so the stub has to be
|
||||
@@ -43,7 +23,7 @@ function makeStorage() {
|
||||
// singleton, so each page needs its own registry to hold its own copy.
|
||||
function loadPage(storage) {
|
||||
jest.resetModules();
|
||||
globalThis.chrome = { storage: { local: storage } };
|
||||
globalThis.chrome = { storage: { local: storage.local } };
|
||||
return {
|
||||
state: require("../src/shared/state"),
|
||||
helpers: require("../src/popup/views/helpers"),
|
||||
@@ -118,7 +98,7 @@ describe("a save from a page that never saw a wallet another page added", () =>
|
||||
// a save from a second page loaded before that wallet existed. Both
|
||||
// wallets must survive.
|
||||
test("both wallets are in storage afterwards", async () => {
|
||||
const storage = makeStorage();
|
||||
const storage = makeStorageStub();
|
||||
await storage.set({ autistmask: { wallets: [W1] } });
|
||||
|
||||
// Loaded while storage held only Wallet 1, and never reloads —
|
||||
@@ -171,7 +151,7 @@ describe("the approval-window reproduction", () => {
|
||||
test("the wallet added in the popup survives confirming the approval", async () => {
|
||||
globalThis.document = makeDocument();
|
||||
|
||||
const storage = makeStorage();
|
||||
const storage = makeStorageStub();
|
||||
await storage.set({ autistmask: { wallets: [W1] } });
|
||||
|
||||
// The background opens the approval window on the approve-tx
|
||||
@@ -223,7 +203,7 @@ describe("the approval-window reproduction", () => {
|
||||
// membership" collided as the same field.
|
||||
describe("background refresh racing a wallet added on another page", () => {
|
||||
test("the wallet added elsewhere survives background's stale balance save", async () => {
|
||||
const storage = makeStorage();
|
||||
const storage = makeStorageStub();
|
||||
await storage.set({ autistmask: { wallets: [W1] } });
|
||||
|
||||
// "background": loads first, and its save is the one that lands
|
||||
@@ -263,7 +243,7 @@ describe("background refresh racing a wallet added on another page", () => {
|
||||
|
||||
describe("background refresh racing a wallet deleted on another page", () => {
|
||||
test("the wallet deleted elsewhere stays deleted after background's stale balance save", async () => {
|
||||
const storage = makeStorage();
|
||||
const storage = makeStorageStub();
|
||||
await storage.set({ autistmask: { wallets: [W1, W2] } });
|
||||
|
||||
const background = loadPage(storage);
|
||||
@@ -324,7 +304,7 @@ function revokeSite(pageState, hostname) {
|
||||
|
||||
describe("a dApp approval racing a stale Settings page's later save", () => {
|
||||
test("the fresh approval survives Settings revoking an unrelated site", async () => {
|
||||
const storage = makeStorage();
|
||||
const storage = makeStorageStub();
|
||||
await storage.set({
|
||||
autistmask: {
|
||||
wallets: [W1],
|
||||
@@ -362,7 +342,7 @@ describe("a dApp approval racing a stale Settings page's later save", () => {
|
||||
|
||||
describe("a revoked site permission against a stale page's later save", () => {
|
||||
test("the revocation holds even when the stale page approves something else", async () => {
|
||||
const storage = makeStorage();
|
||||
const storage = makeStorageStub();
|
||||
await storage.set({
|
||||
autistmask: {
|
||||
wallets: [W1],
|
||||
@@ -413,7 +393,7 @@ function legacyWallet(name, secret) {
|
||||
|
||||
describe("two wallets independently created with a colliding identity", () => {
|
||||
test("both survive, encryptedSecret included, instead of one silently replacing the other", async () => {
|
||||
const storage = makeStorage();
|
||||
const storage = makeStorageStub();
|
||||
await storage.set({ autistmask: { wallets: [W1] } });
|
||||
|
||||
// Both pages load before either has created their malformed wallet,
|
||||
|
||||
73
tests/support/storageStub.js
Normal file
73
tests/support/storageStub.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// A chrome.storage.local stub that behaves like the real one.
|
||||
//
|
||||
// The real extension storage API is a serialization boundary: `set` writes a
|
||||
// structured clone of what it is given, and `get` hands back a structured
|
||||
// clone of what is stored. Nothing an extension page holds is ever the object
|
||||
// storage holds.
|
||||
//
|
||||
// A stub that skips the clone aliases them together, and that hides an entire
|
||||
// class of defect rather than merely being imprecise. loadState() assigns
|
||||
// nested references straight out of the get result, so over an aliasing stub a
|
||||
// test can assert "the endpoint was persisted" and pass on a build that never
|
||||
// called saveState() at all: the in-memory mutation IS the stored record.
|
||||
// Measured, not theorised — with an aliasing `get` restored over the handler
|
||||
// fixed in https://git.eeqj.de/sneak/AutistMask/pulls/319, the whole suite
|
||||
// passed 794/794 (https://git.eeqj.de/sneak/AutistMask/issues/324).
|
||||
//
|
||||
// So every test that drives real persistence uses this, and nothing rebuilds
|
||||
// a storage stub by hand.
|
||||
|
||||
// `initial` is the starting contents, keyed as storage is: { autistmask: {...} }.
|
||||
// `onOp` runs before each operation, for a test that needs to advance a clock
|
||||
// or count round trips.
|
||||
function makeStorageStub(initial, onOp) {
|
||||
const store = initial ? structuredClone(initial) : {};
|
||||
const tick = onOp || (() => {});
|
||||
|
||||
const get = jest.fn(async (key) => {
|
||||
tick();
|
||||
if (key === undefined || key === null) return structuredClone(store);
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
const out = {};
|
||||
for (const k of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(store, k)) {
|
||||
out[k] = structuredClone(store[k]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const set = jest.fn(async (items) => {
|
||||
tick();
|
||||
for (const k of Object.keys(items)) {
|
||||
store[k] = structuredClone(items[k]);
|
||||
}
|
||||
});
|
||||
|
||||
const remove = jest.fn(async (key) => {
|
||||
tick();
|
||||
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
|
||||
});
|
||||
|
||||
return {
|
||||
// Drop this straight in as chrome.storage.
|
||||
local: { get, set, remove },
|
||||
get,
|
||||
set,
|
||||
remove,
|
||||
// What is stored, cloned on the way out: a test can neither observe a
|
||||
// later write through an object it read nor reach into the store by
|
||||
// mutating one.
|
||||
read: (key) =>
|
||||
key === undefined
|
||||
? structuredClone(store)
|
||||
: structuredClone(store[key]),
|
||||
// Seed or replace a record without going through the module under
|
||||
// test — for standing in as "another page wrote this".
|
||||
write: (key, value) => {
|
||||
store[key] = structuredClone(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { makeStorageStub };
|
||||
@@ -682,6 +682,7 @@ describe("surface 3: the balance list", () => {
|
||||
"https://rpc.example.invalid",
|
||||
BLOCKSCOUT,
|
||||
[],
|
||||
"mainnet",
|
||||
);
|
||||
expect(addr.balance).toBe("1.2345");
|
||||
expect(addr.tokenBalances).toEqual([]);
|
||||
|
||||
@@ -30,8 +30,11 @@ global.fetch = jest.fn(() => {
|
||||
});
|
||||
|
||||
// state.js reads chrome.storage.local at module load; stub it so the
|
||||
// default settings can be asserted against what the README promises.
|
||||
global.chrome = { storage: { local: {} } };
|
||||
// default settings can be asserted against what the README promises. Empty
|
||||
// storage, so a load produces exactly the defaults.
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
global.chrome = { storage: makeStorageStub() };
|
||||
|
||||
const {
|
||||
fetchRecentTransactions,
|
||||
@@ -745,6 +748,16 @@ describe("dust threshold filtering", () => {
|
||||
});
|
||||
|
||||
describe("filter defaults promised by the README and Settings", () => {
|
||||
// The defaults are what a load of empty storage produces, so the load is
|
||||
// part of the assertion rather than an incantation before it: reading the
|
||||
// singleton before any load now throws (StateNotLoadedError), because a
|
||||
// context served DEFAULT_STATE without asking for it is the whole subject
|
||||
// of https://git.eeqj.de/sneak/AutistMask/issues/324. The stub at the top
|
||||
// of this file has storage empty.
|
||||
beforeAll(async () => {
|
||||
await require("../src/shared/state").loadState();
|
||||
});
|
||||
|
||||
test("all four toggles default to on and the threshold to 100,000 gwei", () => {
|
||||
expect(state.hideSpoofedSymbols).toBe(true);
|
||||
expect(state.hideLowHolderTokens).toBe(true);
|
||||
|
||||
@@ -96,18 +96,14 @@ global.document = {
|
||||
|
||||
global.window = { location: { search: "" } };
|
||||
|
||||
const stored = {};
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
set: (obj) => {
|
||||
Object.assign(stored, obj);
|
||||
return Promise.resolve();
|
||||
},
|
||||
get: () => Promise.resolve(stored),
|
||||
},
|
||||
},
|
||||
};
|
||||
// Clones in both directions, as the real chrome.storage.local does; the stub
|
||||
// here used to hand back the live stored object, so an in-memory mutation
|
||||
// looked like a write that had reached storage. See
|
||||
// tests/support/storageStub.js.
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const storage = makeStorageStub();
|
||||
global.chrome = { storage };
|
||||
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
const { state } = require("../src/shared/state");
|
||||
|
||||
Reference in New Issue
Block a user