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
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);
|
|
});
|
|
});
|