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. The table of
modules an entry point's bundle may not contain lives in
script/lib/forbiddenBundleInputs.js — one copy, read by both layers that act on
it — and build.js's 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.
Both halves of the table are checked for rot, because either one turns the
prohibition into a pass that checks nothing: a key no bundled entry point
matched fails, and so does a listed module this build bundled nowhere. The
second is what makes a rename of src/shared/state.js loud instead of silently
disarming the check, and it is stronger than an existsSync() because it also
fails when the module is still there but has dropped out of every bundle. What
the assertion does NOT cover is a COPY of the singleton at another path: it is
keyed by path, so a copy builds and lints clean. That is stated where the table
lives, with the reason it is accepted — a copy carries the singleton's own
guard, so a background read of it throws rather than being served DEFAULT_STATE.
make check does not run make build, so the assertion is unit tested against
synthetic metafiles in tests/buildForbiddenInputs.test.js: build.js runs its
build() only as a program now and exports the checks. Executing a check in CI
is not testing it — without that file, inverting the condition leaves every
check in this repo green with the singleton back in the worker.
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 reads
the same table, and 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. Two shapes it does not report are
recorded in its header as known divergences the build catches, both measured: a
computed specifier (require("../shared/" + "state"), which esbuild
constant-folds into the bundle) and a symlink to the module (esbuild reports the
real path). Each is make lint exit 0 and make build exit 2.
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
273 lines
11 KiB
JavaScript
273 lines
11 KiB
JavaScript
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
|
|
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 storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
|
|
global.chrome = { storage };
|
|
return {
|
|
mod: require("../src/shared/state"),
|
|
set: storage.set,
|
|
stored: () => storage.read("autistmask"),
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
delete global.chrome;
|
|
});
|
|
|
|
describe("loadState hasWallet reconciliation", () => {
|
|
// A profile that deleted its last wallet on a build predating the write
|
|
// path fix keeps hasWallet: true forever. It must load as no wallet, which
|
|
// is what sends the popup to the welcome view.
|
|
test("stored hasWallet true with zero wallets loads as no wallet", async () => {
|
|
const { mod } = loadModuleWith({ hasWallet: true, wallets: [] });
|
|
await mod.loadState();
|
|
expect(mod.state.hasWallet).toBe(false);
|
|
});
|
|
|
|
test("stored hasWallet true with a missing wallets key loads as no wallet", async () => {
|
|
const { mod } = loadModuleWith({ hasWallet: true });
|
|
await mod.loadState();
|
|
expect(mod.state.wallets).toEqual([]);
|
|
expect(mod.state.hasWallet).toBe(false);
|
|
});
|
|
|
|
test("stored hasWallet false with one wallet loads as having a wallet", async () => {
|
|
const { mod } = loadModuleWith({
|
|
hasWallet: false,
|
|
wallets: oneWallet(),
|
|
});
|
|
await mod.loadState();
|
|
expect(mod.state.hasWallet).toBe(true);
|
|
});
|
|
|
|
test("absent hasWallet with wallets present loads as having a wallet", async () => {
|
|
const { mod } = loadModuleWith({ wallets: oneWallet() });
|
|
await mod.loadState();
|
|
expect(mod.state.hasWallet).toBe(true);
|
|
});
|
|
|
|
test("consistent stored states are preserved", async () => {
|
|
const withWallet = loadModuleWith({
|
|
hasWallet: true,
|
|
wallets: oneWallet(),
|
|
});
|
|
await withWallet.mod.loadState();
|
|
expect(withWallet.mod.state.hasWallet).toBe(true);
|
|
|
|
const without = loadModuleWith({ hasWallet: false, wallets: [] });
|
|
await without.mod.loadState();
|
|
expect(without.mod.state.hasWallet).toBe(false);
|
|
});
|
|
|
|
test("empty storage leaves the default no-wallet state", async () => {
|
|
const { mod } = loadModuleWith(null);
|
|
await mod.loadState();
|
|
expect(mod.state.hasWallet).toBe(false);
|
|
expect(mod.state.wallets).toEqual([]);
|
|
});
|
|
|
|
// The correction is derived on every load rather than written back, so a
|
|
// load never has a storage side effect.
|
|
test("loadState does not write to storage", async () => {
|
|
const { mod, set } = loadModuleWith({ hasWallet: true, wallets: [] });
|
|
await mod.loadState();
|
|
expect(set).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Deriving must not disturb the rest of the load.
|
|
test("other persisted fields still load", async () => {
|
|
const { mod } = loadModuleWith({
|
|
hasWallet: false,
|
|
wallets: oneWallet(),
|
|
networkId: "sepolia",
|
|
theme: "dark",
|
|
activeAddress: ADDRESS,
|
|
});
|
|
await mod.loadState();
|
|
expect(mod.state.networkId).toBe("sepolia");
|
|
expect(mod.state.theme).toBe("dark");
|
|
expect(mod.state.activeAddress).toBe(ADDRESS);
|
|
});
|
|
});
|
|
|
|
// The known-symbol spoof filter is a safety filter, so an existing profile
|
|
// stored before the setting existed must load with it on rather than with
|
|
// undefined, which would read as off.
|
|
describe("hideSpoofedSymbols persistence", () => {
|
|
test("defaults to on with empty storage", async () => {
|
|
const { mod } = loadModuleWith(null);
|
|
await mod.loadState();
|
|
expect(mod.state.hideSpoofedSymbols).toBe(true);
|
|
});
|
|
|
|
test("a profile stored without the key loads with it on", async () => {
|
|
const { mod } = loadModuleWith({ wallets: oneWallet() });
|
|
await mod.loadState();
|
|
expect(mod.state.hideSpoofedSymbols).toBe(true);
|
|
});
|
|
|
|
test("an explicit false survives the load", async () => {
|
|
const { mod } = loadModuleWith({
|
|
wallets: oneWallet(),
|
|
hideSpoofedSymbols: false,
|
|
});
|
|
await mod.loadState();
|
|
expect(mod.state.hideSpoofedSymbols).toBe(false);
|
|
});
|
|
|
|
test("saveState persists the flag", async () => {
|
|
const { mod, set } = loadModuleWith(null);
|
|
mod.state.hideSpoofedSymbols = false;
|
|
await mod.saveState();
|
|
expect(set).toHaveBeenCalledWith({
|
|
autistmask: expect.objectContaining({ hideSpoofedSymbols: false }),
|
|
});
|
|
});
|
|
|
|
test("the flag round-trips off through save and load", async () => {
|
|
const first = loadModuleWith(null);
|
|
first.mod.state.hideSpoofedSymbols = false;
|
|
await first.mod.saveState();
|
|
const persisted = first.set.mock.calls[0][0].autistmask;
|
|
|
|
const second = loadModuleWith(persisted);
|
|
await second.mod.loadState();
|
|
expect(second.mod.state.hideSpoofedSymbols).toBe(false);
|
|
});
|
|
|
|
test("the flag round-trips back on through save and load", async () => {
|
|
const first = loadModuleWith(null);
|
|
first.mod.state.hideSpoofedSymbols = true;
|
|
await first.mod.saveState();
|
|
const persisted = first.set.mock.calls[0][0].autistmask;
|
|
|
|
const second = loadModuleWith(persisted);
|
|
await second.mod.loadState();
|
|
expect(second.mod.state.hideSpoofedSymbols).toBe(true);
|
|
});
|
|
});
|
|
|
|
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
|
// behind it was restored verbatim, so Back could still walk onto a screen
|
|
// whose content is deliberately never re-rendered — and "show-phrase" has no
|
|
// Back control of its own to leave by. The stack is filtered on load, at the
|
|
// first entry the popup would not render, and everything above it goes too:
|
|
// those entries were reached THROUGH the dropped one.
|
|
describe("restored viewStack is filtered against RESTORABLE_VIEWS", () => {
|
|
const NON_RESTORABLE = ["export-privkey", "show-phrase"];
|
|
|
|
function restoredStack(viewStack, currentView = "settings") {
|
|
return loadModuleWith({
|
|
wallets: oneWallet(),
|
|
currentView,
|
|
viewStack,
|
|
});
|
|
}
|
|
|
|
test("a non-restorable view at the top of the stack is dropped", async () => {
|
|
const { mod } = restoredStack(["main", "address", "export-privkey"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main", "address"]);
|
|
});
|
|
|
|
test("a non-restorable view in the middle truncates the stack there", async () => {
|
|
const { mod } = restoredStack(["main", "show-phrase", "address"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main"]);
|
|
});
|
|
|
|
// Truncating a stack rooted at a non-restorable view leaves nothing, and
|
|
// the restored view still needs somewhere for Back to go.
|
|
test("a non-restorable view at the bottom leaves main to go back to", async () => {
|
|
const { mod } = restoredStack(["export-privkey", "address", "receive"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main"]);
|
|
});
|
|
|
|
test("no restored stack retains a secret-bearing view", async () => {
|
|
for (const view of NON_RESTORABLE) {
|
|
const { mod } = restoredStack(["main", "address", view, "receive"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).not.toContain(view);
|
|
}
|
|
});
|
|
|
|
// The rule is "views the popup will render", not a blocklist of the two
|
|
// secret screens: a name no longer in the set (or never a view at all)
|
|
// has to go the same way.
|
|
test("a name that is not a restorable view at all is dropped", async () => {
|
|
const { mod } = restoredStack(["main", "welcome", "address"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main"]);
|
|
});
|
|
|
|
// Restorable entries are kept verbatim. That they are then unhidden
|
|
// without being re-rendered is a separate defect, tracked in #268; this
|
|
// filter is only about views the popup declined to restore.
|
|
test("an ordinary restorable stack is restored unchanged", async () => {
|
|
const stack = ["main", "address", "address-token"];
|
|
const { mod } = restoredStack(stack);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(stack);
|
|
});
|
|
|
|
test("restoring onto main keeps the stack empty", async () => {
|
|
const { mod } = restoredStack(["show-phrase"], "main");
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual([]);
|
|
});
|
|
|
|
// main is not the only view that gets no ["main"] beneath it: restoreView()
|
|
// will not reopen onto a non-restorable view either, so nothing is left for
|
|
// Back to sit under and the stack stays empty.
|
|
test("restoring onto a view the popup will not reopen keeps the stack empty", async () => {
|
|
const { mod } = restoredStack(["export-privkey"], "show-phrase");
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual([]);
|
|
});
|
|
|
|
// Not an array means nothing survives, but the never-empty rule still
|
|
// applies: a corrupt stack must not leave a restored view with no Back
|
|
// target of its own.
|
|
test("a stack that is not an array still gets main beneath a restored view", async () => {
|
|
const { mod } = restoredStack("main");
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main"]);
|
|
});
|
|
|
|
test("a stack that is not an array loads as empty under main", async () => {
|
|
const { mod } = restoredStack({ 0: "main" }, "main");
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual([]);
|
|
});
|
|
|
|
// Filtering belongs on load, not on save: the live in-session stack is
|
|
// legitimate — the user really is one Back away from a screen that is
|
|
// rendered right now — and only a load-side filter also cleans the
|
|
// stacks already sitting in storage.
|
|
test("saveState persists the live stack verbatim", async () => {
|
|
const { mod, set } = loadModuleWith(null);
|
|
mod.state.viewStack = ["main", "address", "export-privkey"];
|
|
await mod.saveState();
|
|
expect(set).toHaveBeenCalledWith({
|
|
autistmask: expect.objectContaining({
|
|
viewStack: ["main", "address", "export-privkey"],
|
|
}),
|
|
});
|
|
});
|
|
});
|