Files
AutistMask/src/shared/balances.js
clawbot bd0a626e7b
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s
harden: stop the background reading the shared state singleton, and enforce it at build time (closes #324)
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
2026-08-23 17:57:30 +02:00

328 lines
12 KiB
JavaScript

// Balance fetching: ETH balances via RPC, ERC-20 token balances via
// Blockscout, ENS reverse lookup via RPC.
const {
JsonRpcProvider,
Network,
Contract,
formatEther,
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");
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
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).
//
// `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(".");
if (parts.length === 1) return eth + ".0";
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
return parts[0] + "." + dec;
}
function formatTokenBalance(raw, decimals) {
const val = formatUnits(raw, decimals);
const parts = val.split(".");
if (parts.length === 1) return val + ".0";
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
return parts[0] + "." + dec;
}
// Fetch token balances for a single address from Blockscout.
// Returns [{ address, symbol, decimals, balance }].
// Filters out spam: only shows tokens that are in the known token list,
// explicitly tracked by the user, or have >= 1000 holders.
async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
try {
const resp = await debugFetch(
blockscoutUrl + "/addresses/" + address + "/token-balances",
);
if (!resp.ok) {
log.errorf("blockscout token-balances:", resp.status);
return null;
}
const items = await resp.json();
if (!Array.isArray(items)) return null;
const trackedSet = new Set(
(trackedTokens || []).map((t) => t.address.toLowerCase()),
);
const balances = [];
for (const item of items) {
// Case-insensitive: the token type is an explorer's label, not a
// protocol value, and an exact comparison silently drops a real
// holding if one ever writes "erc-20". Which types are admitted
// is unchanged.
const type = String(item.token?.type || "").toUpperCase();
if (type !== "ERC-20") continue;
const decimals = parseInt(item.token.decimals || "18", 10);
const bal = formatTokenBalance(item.value || "0", decimals);
if (bal === "0.0") continue;
const tokenAddr = (item.token.address_hash || "").toLowerCase();
// null means the explorer reported no count, which is not the
// same as a count of zero. This gate is not the low-holder
// display filter: it has no user-facing off switch and governs
// the whole balance list, so it stays strict and admits a token
// only on a reported count — an unreported one is no evidence.
// A legitimate token still reaches the list through the known
// token list or by the user tracking it, and the null is carried
// through to the views, where the two low-holder filters treat
// an unknown count as "do not judge" rather than as zero.
const holders = parseHoldersCount(item.token.holders_count);
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
const isTracked = trackedSet.has(tokenAddr);
const hasEnoughHolders =
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
// Skip spam tokens the user never asked to see
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
// Skip tokens spoofing a known symbol from a different address.
// Every row here is an ERC-20 the explorer reported, so it has a
// contract address; the native ETH balance is fetched over RPC in
// refreshBalances and never passes through this loop.
if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue;
balances.push({
address: item.token.address_hash,
name: item.token.name || "",
symbol: item.token.symbol || "???",
decimals: decimals,
balance: bal,
holders: holders,
});
}
return balances;
} catch (e) {
log.errorf("fetchTokenBalances failed:", e.message);
return null;
}
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
async function refreshBalances(
wallets,
rpcUrl,
blockscoutUrl,
trackedTokens,
networkId,
) {
log.debugf("refreshBalances start, rpc:", rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const updates = [];
for (const wallet of wallets) {
for (const addr of wallet.addresses) {
// ETH balance
updates.push(
provider
.getBalance(addr.address)
.then((bal) => {
addr.balance = formatBalance(bal);
log.debugf("ETH balance", addr.address, addr.balance);
})
.catch((e) => {
log.errorf(
"ETH balance failed",
addr.address,
e.shortMessage || e.message,
);
}),
);
// ENS reverse lookup — only overwrite on success so that
// transient RPC errors don't wipe a previously resolved name.
updates.push(
provider
.lookupAddress(addr.address)
.then((name) => {
addr.ensName = name || null;
log.debugf(
"ENS reverse",
addr.address,
"->",
addr.ensName,
);
})
.catch((e) => {
log.errorf(
"ENS reverse failed",
addr.address,
e.message,
);
// Keep existing addr.ensName if we had one
}),
);
// ERC-20 token balances via Blockscout
updates.push(
fetchTokenBalances(
addr.address,
blockscoutUrl,
trackedTokens,
).then((balances) => {
if (balances !== null) {
addr.tokenBalances = balances;
log.debugf(
"Token balances",
addr.address,
balances.length,
"tokens",
);
}
}),
);
}
}
await Promise.all(updates);
log.debugf("refreshBalances done");
}
// Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const contract = new Contract(contractAddress, ERC20_ABI, provider);
let name, symbol, decimals;
try {
symbol = await contract.symbol();
log.debugf("symbol() =", symbol);
} catch (e) {
log.errorf("symbol() failed:", e.shortMessage || e.message);
throw new Error("Not a valid ERC-20 token (symbol() failed).");
}
try {
decimals = await contract.decimals();
log.debugf("decimals() =", decimals);
} catch (e) {
log.errorf("decimals() failed:", e.shortMessage || e.message);
throw new Error("Not a valid ERC-20 token (decimals() failed).");
}
try {
name = await contract.name();
log.debugf("name() =", name);
} catch (e) {
log.warnf("name() failed, using symbol as name:", e.message);
name = symbol;
}
// Truncate to prevent storage of excessively long values from RPC
name = String(name).slice(0, 64);
symbol = String(symbol).slice(0, 12);
log.infof("Token resolved:", symbol, "decimals", Number(decimals));
return { name, symbol, decimals: Number(decimals) };
}
// Derive HD addresses starting from index 0 and check for on-chain activity.
// 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, networkId, gapLimit = 5) {
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
const provider = getProvider(rpcUrl, networkId);
const used = [];
let checked = 0;
let checkUpTo = gapLimit;
while (checked < checkUpTo) {
const batch = [];
for (let i = checked; i < checkUpTo; i++) {
const addr = deriveAddressFromXpub(xpub, i);
batch.push({ addr, index: i });
}
const results = await Promise.all(
batch.map(async ({ addr, index }) => {
try {
const [balance, txCount] = await Promise.all([
provider.getBalance(addr),
provider.getTransactionCount(addr),
]);
return { addr, index, isUsed: balance > 0n || txCount > 0 };
} catch (e) {
log.errorf(
"scanForAddresses check failed",
addr,
e.shortMessage || e.message,
);
return { addr, index, isUsed: false };
}
}),
);
checked = checkUpTo;
for (const r of results) {
if (r.isUsed) {
used.push({ address: r.addr, index: r.index });
log.debugf("scanForAddresses used", r.addr, "index:", r.index);
checkUpTo = Math.max(checkUpTo, r.index + 1 + gapLimit);
}
}
}
used.sort((a, b) => a.index - b.index);
const nextIndex = used.length > 0 ? used[used.length - 1].index + 1 : 1;
log.infof(
"scanForAddresses done, found:",
used.length,
"nextIndex:",
nextIndex,
);
return { addresses: used, nextIndex };
}
module.exports = {
fetchTokenBalances,
refreshBalances,
lookupTokenInfo,
getProvider,
scanForAddresses,
};