harden: stop the background reading the shared state singleton, and enforce it at build time (closes #324)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s

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
This commit was merged in pull request #344.
This commit is contained in:
2026-08-23 17:57:30 +02:00
parent 36bc6bee0e
commit bd0a626e7b
40 changed files with 2959 additions and 653 deletions

View File

@@ -9,6 +9,7 @@ const {
formatUnits,
} = require("ethers");
const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks");
const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
@@ -17,17 +18,38 @@ const { isSpoofedSymbol } = require("./symbolSpoof");
// Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare).
// Accepts an optional networkName ("mainnet" or "sepolia") for the static
// network hint so ethers picks the right chain parameters. When omitted,
// reads the currently selected network from extension state.
function getProvider(rpcUrl, networkName) {
// Lazy require to avoid circular dependency issues at module scope.
const { currentNetwork } = require("./state");
const name = networkName || currentNetwork().id;
const net = Network.from(name);
//
// `networkId` is REQUIRED, and is one of the ids in networks.js. It used to be
// optional, falling back to currentNetwork() — the module-level `state`
// singleton, which the MV3 service worker never populates. The endpoint then
// came out right and the static hint came out mainnet, so ethers fixed
// `chainId` at 0x1 and every non-mainnet dApp send was prepared for the wrong
// chain and then refused by the wallet's own verifier
// (https://git.eeqj.de/sneak/AutistMask/issues/320). Requiring it is what
// stops that from coming back: a caller that has no network to name has no
// business constructing a provider, and there is no longer a default for it
// to get silently wrong.
//
// Validated against NETWORKS rather than passed straight to Network.from():
// ethers knows chains this wallet does not, so an id that is not one of ours
// is a caller bug and must not resolve to a working provider for some other
// chain.
function getProvider(rpcUrl, networkId) {
const net = Network.from(requireNetworkId(networkId).id);
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
}
function requireNetworkId(networkId) {
const net = NETWORKS[networkId];
if (!net) {
throw new Error(
"getProvider requires the id of a supported network; got " +
JSON.stringify(networkId),
);
}
return net;
}
function formatBalance(wei) {
const eth = formatEther(wei);
const parts = eth.split(".");
@@ -118,9 +140,15 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
async function refreshBalances(
wallets,
rpcUrl,
blockscoutUrl,
trackedTokens,
networkId,
) {
log.debugf("refreshBalances start, rpc:", rpcUrl);
const provider = getProvider(rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const updates = [];
for (const wallet of wallets) {
@@ -193,9 +221,9 @@ async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
// Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl) {
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
const provider = getProvider(rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const contract = new Contract(contractAddress, ERC20_ABI, provider);
let name, symbol, decimals;
@@ -235,9 +263,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl) {
// Checks gapLimit addresses in parallel per batch. Stops when an entire
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
// Returns { addresses: [{ address, index }], nextIndex }.
async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
const provider = getProvider(rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const used = [];
let checked = 0;
let checkUpTo = gapLimit;