From c316d9ee782d1694d784ad9c7130dd0c7793587c Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 14:16:26 +0200 Subject: [PATCH] fix: tracked tokens' balances are read through the RPC endpoint (closes #669) refreshBalances() now reads the balance of every token tracked on the active network, at every address, with balanceOf through the same provider as the ETH balance. The entry takes its holder count and decimals from Blockscout's entry for the token in the same refresh, or keeps them from before while Blockscout fails. A failed balanceOf keeps the token's previous entry, or takes Blockscout's entry when there is none, and is logged. Blockscout's answer still adds the tokens the user has not tracked, as before. The browser test for a hostile symbol now untracks the stub token first, because a tracked token is listed under the symbol it was added with. Model: opus-5-5 --- README.md | 46 ++-- src/shared/balances.js | 150 +++++++++++-- tests/balanceRefreshCancelled.test.js | 14 +- tests/e2e/run.js | 18 ++ tests/trackedTokenBalances.test.js | 303 ++++++++++++++++++++++++++ 5 files changed, 489 insertions(+), 42 deletions(-) create mode 100644 tests/trackedTokenBalances.test.js diff --git a/README.md b/README.md index a393165..f4e8ae7 100644 --- a/README.md +++ b/README.md @@ -845,7 +845,7 @@ src/ views/ — one JS module per screen (home, send, approval, etc.) shared/ — modules used by both popup and background alarms.js — recurring background jobs (extension alarms API) - balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout + balances.js — ETH and tracked token balances via RPC, others via Blockscout constants.js — chain IDs, default RPC endpoint, ERC-20 ABI ens.js — ENS forward/reverse resolution (popup only) holders.js — holder-count parsing and the low-holder rule @@ -1379,23 +1379,30 @@ The core hierarchy is **Wallets → Addresses**: - The user can have multiple wallets, each with multiple addresses (HD) or a single address (key). -Which tokens an address shows is decided by `fetchTokenBalances()` in -`src/shared/balances.js`, from the Blockscout `token-balances` response, so -tokens do appear without the user adding them. An ERC-20 is shown when its -balance is nonzero and it is in the bundled known-token list, is tracked by the -user, or has 1,000 or more holders; a token claiming a symbol from the bundled -list from any other contract address is always dropped, and so is any token -claiming a symbol that belongs to the native asset and therefore has no -legitimate contract at all (`"ETH"`, and every network's `nativeCurrency`, such -as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide -tokens with fewer than 1,000 holders" setting governs the transaction history -and the send-screen token selector, not this list. A token's holder count is -unknown when the explorer reports none, or reports anything other than a whole -number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()` -in `src/shared/holders.js`). This list does not take an unknown count as 1,000 -or more, so such a token is shown only when it is on the bundled list or -tracked. `fetchTokenBalances()` stores every nonzero holding of a token it -admits, however small, but a holding below 0.000001 is left out of the balance +Which tokens an address shows is decided on every refresh by `refreshBalances()` +in `src/shared/balances.js`. The balance of each token tracked on the active +network is read with `balanceOf` through the RPC endpoint, for every address. +Its holder count and `decimals` are those of Blockscout's entry for it in the +same refresh, kept from before while Blockscout fails. A read that fails keeps +that token's previous entry, as a failed ETH balance keeps its figure, or takes +Blockscout's entry from the same refresh when there is none. Every other token +comes from the Blockscout `token-balances` response (`fetchTokenBalances()`), so +tokens do appear without the user adding them, and they keep their previous +figures while Blockscout fails. A tracked token is shown when its balance is +nonzero, and any other ERC-20 when its balance is nonzero and it is in the +bundled known-token list or has 1,000 or more holders. A token claiming a symbol +from the bundled list from any other contract address is always dropped, tracked +or not, and so is any token claiming a symbol that belongs to the native asset +and therefore has no legitimate contract at all (`"ETH"`, and every network's +`nativeCurrency`, such as `"SepoliaETH"`, on every network). That filter is +unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs +the transaction history and the send-screen token selector, not this list. A +token's holder count is unknown when the explorer reports none, or reports +anything other than a whole number written in digits alone, such as `1,000` or +`1e3` (`parseHoldersCount()` in `src/shared/holders.js`). This list does not +take an unknown count as 1,000 or more, so such a token is shown only when it is +on the bundled list or tracked. Every nonzero holding of a token shown is stored +exactly, however small, but a holding below 0.000001 is left out of the balance lists, the send-screen token selector, the address total and the remove-address warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and confirmation screens show it when its token is the one being sent. Tracked @@ -2662,7 +2669,8 @@ AutistMask is not a fully self-contained offline tool. It necessarily communicates with three external services to function as a wallet: - **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query - balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`), + balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`), among + them the balance of every tracked token at every address (`balanceOf`), estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`), broadcast transactions (`eth_sendRawTransaction`), and check transaction receipts. The default endpoint is a public RPC (configurable by the user to diff --git a/src/shared/balances.js b/src/shared/balances.js index 7c16604..c459f85 100644 --- a/src/shared/balances.js +++ b/src/shared/balances.js @@ -1,4 +1,5 @@ -// Balance fetching: ETH balances via RPC, ERC-20 token balances via +// Balance fetching: ETH balances and the balances of the tokens the user +// tracks via RPC, the other ERC-20 token balances and the holder counts via // Blockscout, ENS reverse lookup via RPC. const { @@ -81,7 +82,9 @@ function rawUnits(value) { // Fetch token balances for a single address from Blockscout. // Returns [{ address, name, symbol, decimals, balance, holders }]. // Filters out spam: only shows tokens that are in the known token list, -// explicitly tracked by the user, or have >= 1000 holders. +// explicitly tracked by the user, or have >= 1000 holders. A tracked token's +// balance is read through the RPC endpoint instead, and its row here gives that +// entry its holder count and `decimals` (fetchTrackedTokenBalances()). // // `decimals` and `balance` are each null when the answer is unknown, the same // way `holders` already is. Absence is never filled in here: this is the @@ -205,8 +208,96 @@ async function fetchTokenBalances( } } -// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. +// The entries at `address` of the tokens the user tracks, shaped as +// fetchTokenBalances() returns them, for the tokens the address holds. +// +// The balance is read with balanceOf through the RPC endpoint. The name and +// symbol are the tracked token's. `decimals` and `holders` are those of +// Blockscout's entry for the token in `explorerRequest`, this refresh's +// fetchTokenBalances() for the address; while Blockscout fails they are kept +// from the token's entry in `previous`, the address's entries before this +// refresh. The balance is stated at the scale the screens read the token at +// (resolveTokenDecimals()): the bundled list's, then the one the tracked token +// records, then Blockscout's, as in fetchTokenBalances(). +// +// A token whose balanceOf fails keeps its entry in `previous`, as a failed ETH +// balance keeps its figure, or takes Blockscout's entry from this refresh when +// it has none. The failure is logged. A balance of zero gets no entry, as a +// zero Blockscout reports gets none: the balance lists take a tracked token's +// zero from the tracked list. A token whose symbol the spoof rule refuses gets +// no entry either, as in fetchTokenBalances(). +// // `signal` is as for fetchTokenBalances(). +async function fetchTrackedTokenBalances( + provider, + address, + trackedTokens, + previous, + explorerRequest, + signal, +) { + const listed = trackedTokens.filter( + (token) => !isSpoofedSymbol(token.symbol, token.address), + ); + // Each token's balanceOf answer, or null where it failed. + const balances = await Promise.all( + listed.map(async (token) => { + try { + const contract = new Contract( + token.address, + ERC20_ABI, + provider, + ); + return await contract.balanceOf(address); + } catch (e) { + if (!signal?.aborted) { + log.errorf( + "token balance failed", + address, + token.address, + e.shortMessage || e.message, + ); + } + return null; + } + }), + ); + const explorerRows = await explorerRequest; + const reported = explorerRows ?? previous; + + const rows = []; + listed.forEach((token, i) => { + const lower = token.address.toLowerCase(); + const entryIn = (list) => + list.find((row) => String(row.address).toLowerCase() === lower); + const raw = balances[i]; + if (raw === null) { + const kept = entryIn(previous) || entryIn(explorerRows || []); + if (kept) rows.push(kept); + return; + } + if (raw === 0n) return; + const entry = entryIn(reported); + const decimals = entry ? entry.decimals : null; + const known = resolveTokenDecimals(lower, { trackedTokens }); + const scale = known !== null ? known : decimals; + rows.push({ + address: token.address, + name: token.name || "", + symbol: token.symbol || "???", + decimals: decimals, + // null when nothing knows the scale: a holding whose amount + // cannot be stated, never a zero. + balance: scale === null ? null : formatTokenBalance(raw, scale), + holders: entry ? entry.holders : null, + }); + }); + return rows; +} + +// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. +// `trackedTokens` are the tokens tracked on the active network. `signal` is as +// for fetchTokenBalances(). async function refreshBalances( wallets, rpcUrl, @@ -217,6 +308,8 @@ async function refreshBalances( ) { log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl)); const provider = getProvider(rpcUrl, networkId); + const tracked = trackedTokens || []; + const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase())); const updates = []; for (const wallet of wallets) { @@ -266,23 +359,42 @@ async function refreshBalances( }), ); - // ERC-20 token balances via Blockscout + // ERC-20 token balances: the tracked tokens' through the RPC + // endpoint, and from Blockscout the tokens the user has not + // tracked. When Blockscout fails, the untracked rows from before + // this refresh stay. The Blockscout request is started first and + // handed to fetchTrackedTokenBalances() too, which waits for it + // after sending its balanceOf reads, so the two run side by side. + const previous = addr.tokenBalances || []; + const explorerRequest = fetchTokenBalances( + addr.address, + blockscoutUrl, + tracked, + signal, + ); updates.push( - fetchTokenBalances( - addr.address, - blockscoutUrl, - trackedTokens, - signal, - ).then((balances) => { - if (balances !== null) { - addr.tokenBalances = balances; - log.debugf( - "Token balances", - addr.address, - balances.length, - "tokens", - ); - } + Promise.all([ + fetchTrackedTokenBalances( + provider, + addr.address, + tracked, + previous, + explorerRequest, + signal, + ), + explorerRequest, + ]).then(([trackedRows, explorerRows]) => { + const untrackedRows = (explorerRows ?? previous).filter( + (row) => + !trackedSet.has(String(row.address).toLowerCase()), + ); + addr.tokenBalances = trackedRows.concat(untrackedRows); + log.debugf( + "Token balances", + addr.address, + addr.tokenBalances.length, + "tokens", + ); }), ); } diff --git a/tests/balanceRefreshCancelled.test.js b/tests/balanceRefreshCancelled.test.js index 5852484..9686fa8 100644 --- a/tests/balanceRefreshCancelled.test.js +++ b/tests/balanceRefreshCancelled.test.js @@ -18,6 +18,11 @@ const { const RPC_URL = "https://rpc.example.invalid"; const EXPLORER_URL = "https://explorer.example.invalid/api/v2"; const ADDRESS = "0x1111111111111111111111111111111111111111"; +const TRACKED = { + address: "0x7777777777777777777777777777777777777777", + symbol: "TRK", + decimals: 6, +}; const realFetch = globalThis.fetch; let logged; @@ -30,9 +35,9 @@ beforeEach(() => { const failedToFetch = async () => { throw new TypeError("Failed to fetch"); }; - // The RPC calls (ETH balance, ENS name, and the scan's balance and - // transaction count) and the explorer request (token balances) all fail - // the same way. + // The RPC calls (ETH balance, ENS name, the tracked token's balance, and + // the scan's balance and transaction count) and the explorer request (the + // other token balances) all fail the same way. FetchRequest.registerGetUrl(failedToFetch); globalThis.fetch = jest.fn(failedToFetch); }); @@ -49,7 +54,7 @@ function refresh(signal) { wallets, RPC_URL, EXPLORER_URL, - [], + [TRACKED], "mainnet", signal, ); @@ -60,6 +65,7 @@ test("a failure while the popup is open is reported", async () => { for (const label of [ "ETH balance failed", "ENS reverse failed", + "token balance failed", "fetchTokenBalances failed: Failed to fetch", ]) { expect(logged.some((line) => line.includes(label))).toBe(true); diff --git a/tests/e2e/run.js b/tests/e2e/run.js index 2d95c8f..651bea5 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -3480,6 +3480,24 @@ function hostileSymbolState(page, tokenAddress) { } test("a token whose symbol() returns markup renders as text (#307)", async (env) => { + // The stub token is no longer tracked from here on, so the explorer is + // what lists it, as it lists a token airdropped to the victim. A tracked + // token is listed from the RPC endpoint under the symbol it was added + // with, which the hostile symbol below never reaches + // (https://git.eeqj.de/sneak/AutistMask/issues/669). + await env.page.click("#btn-settings"); + await visible(env.page, "#view-settings"); + await env.page.click("#settings-tracked-tokens .btn-remove-token"); + // The list is drawn again once the removal is saved. + await env.page.waitForFunction( + () => + document + .getElementById("settings-tracked-tokens") + .innerText.trim() === "None", + ); + await env.page.click("#btn-settings-back"); + await visible(env.page, "#view-address"); + env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI); env.routeOpts.seedTokenBalance = true; env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL; diff --git a/tests/trackedTokenBalances.test.js b/tests/trackedTokenBalances.test.js new file mode 100644 index 0000000..7dbfa38 --- /dev/null +++ b/tests/trackedTokenBalances.test.js @@ -0,0 +1,303 @@ +// The balance of each token the user tracks is read with balanceOf through the +// RPC endpoint, for every address. Blockscout's answer gives a tracked token its +// holder count and decimals, and adds the tokens the user has not tracked +// (https://git.eeqj.de/sneak/AutistMask/issues/669). +// +// The real refreshBalances() runs against a fake JSON-RPC node, answered +// through ethers' own request hook, and a fake Blockscout, answered through +// fetch(). + +const { AbiCoder, FetchRequest } = require("ethers"); +const { refreshBalances } = require("../src/shared/balances"); + +const RPC_URL = "https://rpc.example.invalid"; +const EXPLORER_URL = "https://explorer.example.invalid/api/v2"; +const FIRST = "0x1111111111111111111111111111111111111111"; +const SECOND = "0x2222222222222222222222222222222222222222"; + +// A token the user tracks, outside the bundled list, at 6 decimals. +const TOKEN = "0x7777777777777777777777777777777777777777"; +const TRACKED = { + address: TOKEN, + symbol: "TRK", + name: "Tracked Token", + decimals: 6, +}; + +// USDC, from the bundled list, which the user does not track. +const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; +// Neither on the bundled list nor tracked. +const OTHER = "0x8888888888888888888888888888888888888888"; + +const ZERO_WORD = "0x" + "0".repeat(64); +const BALANCE_OF = "0x70a08231"; + +// What balanceOf answers, by token contract and then by holder. A pair that is +// not here makes balanceOf fail. +let holdings; +// What Blockscout answers for every address; null makes the request fail. +let explorerRows; +let logged; + +// One JSON-RPC answer. A call that is not balanceOf is the ENS reverse lookup, +// answered with no resolver, so no name. +function reply(req) { + const envelope = { jsonrpc: "2.0", id: req.id }; + if (req.method === "eth_getBalance") { + return { ...envelope, result: "0x0" }; + } + if (req.method !== "eth_call") { + return { + ...envelope, + error: { code: -32601, message: "method not found" }, + }; + } + const { to, data } = req.params[0]; + if (!data.startsWith(BALANCE_OF)) return { ...envelope, result: ZERO_WORD }; + const holder = "0x" + data.slice(-40); + const amount = (holdings[to.toLowerCase()] || {})[holder]; + if (amount === undefined) { + return { + ...envelope, + error: { code: -32000, message: "fixture: balanceOf failed" }, + }; + } + return { + ...envelope, + result: AbiCoder.defaultAbiCoder().encode(["uint256"], [amount]), + }; +} + +const realFetch = globalThis.fetch; + +beforeEach(() => { + holdings = {}; + explorerRows = null; + logged = []; + jest.spyOn(console, "error").mockImplementation((...args) => { + logged.push(args.map(String).join(" ")); + }); + FetchRequest.registerGetUrl(async (req) => { + const body = JSON.parse(Buffer.from(req.body).toString("utf8")); + const answer = Array.isArray(body) ? body.map(reply) : reply(body); + return { + statusCode: 200, + statusMessage: "OK", + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify(answer)), + }; + }); + globalThis.fetch = jest.fn(async () => { + if (explorerRows === null) throw new TypeError("Failed to fetch"); + return { ok: true, status: 200, json: async () => explorerRows }; + }); +}); + +afterEach(() => { + FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc()); + globalThis.fetch = realFetch; + jest.restoreAllMocks(); +}); + +// One Blockscout token-balances row. +function explorerRow(address, symbol, value, holders = "50000") { + return { + value: String(value), + token: { + type: "ERC-20", + address_hash: address, + symbol: symbol, + name: symbol, + decimals: "6", + holders_count: holders, + }, + }; +} + +// The row refreshBalances() writes for TOKEN from a balanceOf answer, with the +// decimals and holder count Blockscout reported for it, if any. +function trackedRow(balance, decimals = null, holders = null) { + return { + address: TOKEN, + name: "Tracked Token", + symbol: "TRK", + decimals: decimals, + balance: balance, + holders: holders, + }; +} + +async function refresh(addresses, trackedTokens) { + await refreshBalances( + [{ addresses }], + RPC_URL, + EXPLORER_URL, + trackedTokens, + "mainnet", + new AbortController().signal, + ); + return addresses; +} + +test("with Blockscout failing, a tracked token's balance is read through the RPC endpoint for every address", async () => { + holdings[TOKEN] = { [FIRST]: 1234500000n, [SECOND]: 1n }; + const [first, second] = await refresh( + [ + { address: FIRST, tokenBalances: [] }, + { address: SECOND, tokenBalances: [] }, + ], + [TRACKED], + ); + expect(first.tokenBalances).toEqual([trackedRow("1234.5")]); + // Exact, never cut: one base unit is not zero. + expect(second.tokenBalances).toEqual([trackedRow("0.000001")]); +}); + +test("with Blockscout failing, the untracked rows from before stay", async () => { + holdings[TOKEN] = { [FIRST]: 2000000n }; + const usdc = { + address: USDC, + name: "USD Coin", + symbol: "USDC", + decimals: 6, + balance: "5.0", + holders: 50000, + }; + const [first] = await refresh( + [{ address: FIRST, tokenBalances: [usdc] }], + [TRACKED], + ); + expect(first.tokenBalances).toEqual([trackedRow("2.0"), usdc]); +}); + +test("a tracked token Blockscout also reports shows the RPC figure, and an untracked one is admitted as before", async () => { + holdings[TOKEN] = { [FIRST]: 2000000n }; + explorerRows = [ + explorerRow(TOKEN, "TRK", 9000000), + explorerRow(USDC, "USDC", 5000000), + // Too few holders, and neither on the bundled list nor tracked. + explorerRow(OTHER, "SPAM", 5000000, "10"), + // A known symbol from a contract it does not belong to. + explorerRow(OTHER, "USDC", 5000000), + ]; + const [first] = await refresh( + [{ address: FIRST, tokenBalances: [] }], + [TRACKED], + ); + expect(first.tokenBalances).toEqual([ + trackedRow("2.0", 6, 50000), + { + address: USDC, + name: "USDC", + symbol: "USDC", + decimals: 6, + balance: "5.0", + holders: 50000, + }, + ]); +}); + +test("a tracked token's entry carries the holder count and decimals Blockscout reports for it", async () => { + holdings[TOKEN] = { [FIRST]: 2000000n }; + // Fewer holders than "Hide tokens with fewer than 1,000 holders" lets + // through to Send's token list. + explorerRows = [explorerRow(TOKEN, "TRK", 9000000, "10")]; + const [first] = await refresh( + [{ address: FIRST, tokenBalances: [] }], + [TRACKED], + ); + expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 10)]); +}); + +test("while Blockscout fails, a tracked token keeps the holder count and decimals of its entry from before", async () => { + holdings[TOKEN] = { [FIRST]: 2000000n }; + const [first] = await refresh( + [{ address: FIRST, tokenBalances: [trackedRow("7.0", 6, 10)] }], + [TRACKED], + ); + expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 10)]); +}); + +test("a failed balanceOf keeps the token's entry from before, and is logged", async () => { + explorerRows = [explorerRow(TOKEN, "TRK", 9000000)]; + const before = trackedRow("7.0", 6, 50000); + const [first] = await refresh( + [{ address: FIRST, tokenBalances: [before] }], + [TRACKED], + ); + expect(first.tokenBalances).toEqual([before]); + expect(logged.some((line) => line.includes("token balance failed"))).toBe( + true, + ); +}); + +test("a failed balanceOf with no entry from before takes Blockscout's entry from this refresh", async () => { + // balanceOf fails at FIRST and answers at SECOND. + holdings[TOKEN] = { [SECOND]: 2000000n }; + explorerRows = [explorerRow(TOKEN, "TRK", 9000000)]; + const [first, second] = await refresh( + [ + { address: FIRST, tokenBalances: [] }, + { address: SECOND, tokenBalances: [] }, + ], + [TRACKED], + ); + expect(first.tokenBalances).toEqual([ + { + address: TOKEN, + name: "TRK", + symbol: "TRK", + decimals: 6, + balance: "9.0", + holders: 50000, + }, + ]); + // Where balanceOf answers, its figure stands. + expect(second.tokenBalances).toEqual([trackedRow("2.0", 6, 50000)]); +}); + +test("a balance of zero read through the RPC endpoint replaces the previous figure", async () => { + holdings[TOKEN] = { [FIRST]: 0n }; + const [first] = await refresh( + [{ address: FIRST, tokenBalances: [trackedRow("7.0")] }], + [TRACKED], + ); + expect(first.tokenBalances).toEqual([]); +}); + +test("a tracked token whose scale nothing knows gets no quantity, not a zero", async () => { + holdings[TOKEN] = { [FIRST]: 5n }; + const [first] = await refresh( + [{ address: FIRST, tokenBalances: [] }], + [{ ...TRACKED, decimals: null }], + ); + expect(first.tokenBalances).toEqual([trackedRow(null)]); +}); + +test("a tracked token whose scale only Blockscout reports is stated at that scale", async () => { + holdings[TOKEN] = { [FIRST]: 2000000n }; + explorerRows = [explorerRow(TOKEN, "TRK", 9000000)]; + const [first] = await refresh( + [{ address: FIRST, tokenBalances: [] }], + [{ ...TRACKED, decimals: null }], + ); + expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 50000)]); +}); + +test("a tracked token wearing a known symbol from another contract is not listed", async () => { + holdings[OTHER] = { [FIRST]: 5000000n }; + // Its entry from before goes too, although Blockscout fails. + const before = { + address: OTHER, + name: "Fake", + symbol: "USDC", + decimals: 6, + balance: "5.0", + holders: 50000, + }; + const [first] = await refresh( + [{ address: FIRST, tokenBalances: [before] }], + [{ address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 }], + ); + expect(first.tokenBalances).toEqual([]); +});