Five defects, one of which destroyed every wallet, came from src/background reading and writing the module-level state singleton the MV3 worker never populates, which silently served DEFAULT_STATE. Each point fix created the next defect. The background now has its own per-call getState() and a queued read-modify-write updateState(); the singleton is unreachable from it, and an unpopulated read throws instead of serving defaults. The prohibition is enforced by the build, not by review: build.js asserts over esbuild's own metafile that no forbidden module is an input of a background bundle, so every specifier syntax esbuild resolves is covered, and both halves of the table are checked for rot -- a stale key, a stale module, an empty list, or an unlisted entry point under src/background/ all fail the build. The ESLint rule remains as fast local feedback and reads the same shared table. Known bounds are documented where the table lives. Also closes #320: getProvider() now requires a validated network id, so a cold worker no longer prepares a non-mainnet dApp transaction for mainnet and gets refused by the wallet's own verifier. backgroundRefresh() no longer mutates address objects across a network round trip, the broadcast path takes its endpoint and chain id from one snapshot, and eight test storage stubs now structured-clone on get as the real chrome.storage.local does. closes #320
399 lines
14 KiB
JavaScript
399 lines
14 KiB
JavaScript
// 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);
|
|
});
|
|
});
|