Files
AutistMask/tests/networkEndpoints.test.js
sneak 277ec8c8f8
All checks were successful
check / check (push) Successful in 50s
e2e / e2e-chrome (push) Successful in 1m25s
e2e / e2e-firefox (push) Successful in 38s
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: a custom ESLint rule
walks the CommonJS require graph from every src/background/ file and fails the
lint when src/shared/state.js is reachable, naming the chain. A re-export from
any shared module cannot put the singleton back in the bundle unnoticed.

The rule's matcher covers every specifier syntax esbuild resolves statically —
quoted require, backtick require, dynamic import(), and a static import/export
`from` clause — because a narrower match is not a matter of tidiness but a sixth
site the build cannot see: each of those shapes was measured to put state.js in
dist/chrome/src/background/index.js while the lint stayed clean.
tests/backgroundStateLintRule.test.js pins all of them, plus the two-hop
re-export, against a real fixture tree. 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
2026-08-23 14:08:14 +00:00

188 lines
7.5 KiB
JavaScript

// What a chain switch is allowed to do to the endpoints the user configured.
//
// A switch used to overwrite state.rpcUrl and state.blockscoutUrl with the
// network defaults, so a user pointing the wallet at their own node lost that
// url the first time anything switched chains — with no notification and no
// way to recover it, having been moved onto a public endpoint that then sees
// every address they hold (https://git.eeqj.de/sneak/AutistMask/issues/308).
// Endpoints are now remembered per network, which is why the round trips
// below assert the ORIGINAL url comes back rather than only that the switch
// happened.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
// The user's own node: the pair the switch used to throw away.
const CUSTOM_RPC = "http://127.0.0.1:8545";
const CUSTOM_BLOCKSCOUT = "http://127.0.0.1:4000/api/v2";
function walletFixture() {
return [
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
}
// The real state module against stubbed storage, plus whatever the last
// 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();
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
global.chrome = { storage };
return {
mod: require("../src/shared/state"),
chainSwitch: require("../src/shared/chainSwitch"),
written: () => storage.read("autistmask"),
};
}
afterEach(() => {
delete global.chrome;
});
describe("a custom endpoint survives a chain switch", () => {
test("switching away and back restores the user's rpc and blockscout urls", async () => {
const { mod, chainSwitch } = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
networkEndpoints: {
mainnet: {
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
},
},
});
await mod.loadState();
await chainSwitch.onChainSwitch("sepolia");
// The new chain gets its own endpoints, not the ones belonging to the
// chain just left: a mainnet node cannot answer for Sepolia.
expect(mod.state.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(mod.state.blockscoutUrl).toBe(SEPOLIA.defaultBlockscoutUrl);
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
test("an endpoint set on the network being left is remembered, not lost", async () => {
const { mod, chainSwitch } = loadModuleWith({
wallets: walletFixture(),
networkId: "sepolia",
rpcUrl: SEPOLIA.defaultRpcUrl,
blockscoutUrl: SEPOLIA.defaultBlockscoutUrl,
networkEndpoints: {},
});
await mod.loadState();
// What the Settings screen does: write the live field, then save. The
// map entry for the active network is stale until the switch, which
// is what snapshotting the outgoing network exists to reconcile.
mod.state.rpcUrl = CUSTOM_RPC;
await mod.saveState();
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(MAINNET.defaultRpcUrl);
await chainSwitch.onChainSwitch("sepolia");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
});
test("the remembered endpoints survive an extension restart", async () => {
const first = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
});
await first.mod.loadState();
await first.chainSwitch.onChainSwitch("sepolia");
// Reload from exactly the bytes the switch persisted.
const second = loadModuleWith(first.written());
await second.mod.loadState();
expect(second.mod.state.networkId).toBe("sepolia");
expect(second.mod.state.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await second.chainSwitch.onChainSwitch("mainnet");
expect(second.mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(second.mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
test("a profile written before networkEndpoints existed keeps its endpoint", async () => {
// Exactly the stored shape the current release writes: one pair of
// urls and no map. It is adopted as the remembered pair of the
// network it was stored under.
const { mod, chainSwitch } = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
});
await mod.loadState();
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(mod.state.networkEndpoints).toEqual({
mainnet: { rpcUrl: CUSTOM_RPC, blockscoutUrl: CUSTOM_BLOCKSCOUT },
});
await chainSwitch.onChainSwitch("sepolia");
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
// A primitive is the dangerous case, not the array: assigning a property
// to a string throws nothing and stores nothing, so a stored string would
// be carried through loadState() and re-persisted by every save, and each
// switch would fall back to the public default in place of the user's
// endpoint, permanently.
test.each([
["an array", ["not", "a", "map"]],
["a string", "junk"],
["a number", 7],
])("a stored networkEndpoints that is %s is discarded", async (_, bad) => {
const { mod, chainSwitch } = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
networkEndpoints: bad,
});
await mod.loadState();
// Discarded, then seeded from the live endpoints the same way an old
// profile is — never left as something onChainSwitch() would index.
expect(mod.state.networkEndpoints).toEqual({
mainnet: {
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
},
});
// And the endpoint really survives the round trip, which is the point
// of discarding it rather than only of the shape being right.
await chainSwitch.onChainSwitch("sepolia");
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
});