Files
AutistMask/tests/chainSwitchGate.test.js
sneak 4343d8fc77
All checks were successful
check / check (push) Successful in 49s
e2e / e2e-chrome (push) Successful in 1m27s
e2e / e2e-firefox (push) Successful in 39s
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.
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. They all go through tests/support/storageStub.js now, which
structured-clones in both directions.

closes #320
2026-08-23 13:44:19 +00:00

224 lines
7.8 KiB
JavaScript

// Who may move the active chain.
//
// wallet_switchEthereumChain used to be answered for any origin at all, with
// no connection check and no prompt, so a page the user had never connected
// to could clear the [TESTNET] banner under someone who believed they were
// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). The refusal
// is asserted as a refusal to ACT — the state unmoved and no chainChanged
// 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,
// 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";
// The site the persisted state has connected, and one it has never heard of.
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const STRANGER_ORIGIN = "https://stranger.example";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
// The user's own node, so a switch that happens is visible as the loss of it.
const CUSTOM_RPC = "http://127.0.0.1:8545";
function walletFixture() {
return [
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
}
// Let the handler's promise chain run to the next suspension point. The gate
// reads storage before it answers, so the response is several awaits deep.
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
// ---------------------------------------------------------------------------
// The gate: which origins the background will switch the chain for.
// ---------------------------------------------------------------------------
// Load the background worker against stubbed browser APIs, with the real
// chain-switch and persistence modules behind it, and return the handles to
// drive it.
function loadBackground() {
jest.resetModules();
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
// 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
// is what tells a page the wallet moved, so an ungated switch is visible
// here as well as in the state.
const toTabs = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }),
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
toTabs.push(message);
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
async function switchChain(chainId, origin) {
let result = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "wallet_switchEthereumChain",
params: [{ chainId }],
},
{ origin },
(r) => {
result = r;
},
);
await settle();
return result;
}
return {
switchChain,
walletState: () => storage.read("autistmask"),
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};
}
describe("wallet_switchEthereumChain is gated on the connection", () => {
test("an origin the wallet was never connected to is refused with 4100", async () => {
const bg = loadBackground();
const result = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN);
expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
expect(result.result).toBeUndefined();
// 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.chainChangedEvents()).toEqual([]);
});
test("an unconnected origin is refused even for the chain already active", async () => {
const bg = loadBackground();
const result = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN);
expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
});
test("an unconnected origin is refused before the unsupported-chain answer", async () => {
const bg = loadBackground();
const result = await bg.switchChain("0x89", STRANGER_ORIGIN);
expect(result.error.code).toBe(4100);
});
test("a connected origin switches the chain", async () => {
const bg = loadBackground();
const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(result).toEqual({ result: null });
expect(bg.walletState().networkId).toBe("sepolia");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: SEPOLIA.chainId,
},
]);
});
test("a connected origin asking for an unsupported chain still gets 4902", async () => {
const bg = loadBackground();
const result = await bg.switchChain("0x89", CONNECTED_ORIGIN);
expect(result.error.code).toBe(4902);
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);
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
});
});