Files
AutistMask/src/shared/balances.js
clawbot b75bd197b2
All checks were successful
check / check (push) Successful in 27s
fix: filter a fake ETH token from the balance list too (closes #235)
KNOWN_SYMBOLS maps "ETH" to null, and the three surfaces that show tokens
disagreed about what that means. The transaction history and the Send
token selector read it as "no contract may bear this symbol" and filtered
a fake ETH ERC-20; the balance list's guard required a non-null mapping,
so the same token was listed as a holding named ETH next to the user's
real ETH. That is the surface where the user forms their belief about
what they own.

The rule now lives in src/shared/symbolSpoof.js and all three sites call
it, so a fourth reading is not available to a future call site. A symbol
mapped to null belongs to the native asset and may be borne by no
contract at all; the native exemption is "has no contract address", not
"the symbol is ETH", so a second null-mapped entry needs no call-site
change.

The user's real ETH balance is untouched: it is read over RPC in
refreshBalances and never enters fetchTokenBalances, whose loop only
considers explorer rows of type ERC-20.

tests/symbolSpoof.test.js drives the same fake ETH token through all
three surfaces plus refreshBalances, which reports the native balance
unchanged while the fake token is gone. Its two balance-list cases were
watched failing against the unmodified call sites first.
2026-08-12 09:06:59 +00:00

295 lines
11 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 { 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).
// 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);
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: 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) {
if (item.token?.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) {
log.debugf("refreshBalances start, rpc:", rpcUrl);
const provider = getProvider(rpcUrl);
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) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
const provider = getProvider(rpcUrl);
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, gapLimit = 5) {
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
const provider = getProvider(rpcUrl);
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,
};