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
280 lines
9.2 KiB
JavaScript
280 lines
9.2 KiB
JavaScript
// 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);
|
|
});
|
|
});
|