From 1f41a07df255f07e6371fd43ea00f20a1b22714a Mon Sep 17 00:00:00 2001 From: clawbot Date: Wed, 12 Aug 2026 11:10:38 +0200 Subject: [PATCH] fix: filter a fake ETH token from the balance list too (closes #235) --- README.md | 42 +++--- TODO.md | 9 ++ src/popup/views/send.js | 13 +- src/shared/balances.js | 17 +-- src/shared/symbolSpoof.js | 43 ++++++ src/shared/transactions.js | 17 +-- tests/symbolSpoof.test.js | 296 +++++++++++++++++++++++++++++++++++++ 7 files changed, 383 insertions(+), 54 deletions(-) create mode 100644 src/shared/symbolSpoof.js create mode 100644 tests/symbolSpoof.test.js diff --git a/README.md b/README.md index 7fe4c4f..73737b0 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ src/ prices.js — ETH/USD and token/USD via CoinDesk API scamlist.js — known fraud contract addresses state.js — persisted state (extension storage) + symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces tokenList.js — top ERC-20 tokens by market cap (hardcoded) transactions.js — tx history fetching + anti-poisoning filters uniswap.js — Uniswap Universal Router calldata decoder @@ -458,11 +459,12 @@ Which tokens an address shows is decided by `fetchTokenBalances()` in 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. 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. -Tracked tokens with a zero balance are listed as well while "Show tracked tokens -with zero balance" is on. +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"`). 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. Tracked tokens with a zero +balance are listed as well while "Show tracked tokens with zero balance" is on. #### Navigation @@ -1280,14 +1282,15 @@ indexes it as a real token transfer. that is the only thing that populates it. In the transaction history the check is the "Hide fake tokens impersonating a known symbol" setting, on by default; with it off, spoofed transfers are shown and no new blocklist entries are - learned from them. The send-screen token selector applies the same check - unconditionally, because it decides which tokens the user can act on rather - than what the history displays. The balance list applies it unconditionally - too, but not identically: it exempts symbols that `KNOWN_SYMBOLS` maps to - `null`, and `"ETH"` is the only one. So the fake "Ethereum" token above is - filtered from the transaction history and from the send selector, but a - fake-`ETH` ERC-20 that clears the balance list's own 1,000-holder floor — or - that the user tracked manually — is still shown in the balance list. + learned from them. The send-screen token selector and the balance list apply + the same check unconditionally, because they decide which tokens the user can + act on and what the user believes they own rather than what the history + displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`, + so they cannot answer the question differently. A symbol the list maps to no + contract at all — `"ETH"`, the native asset, is the only one — may be borne by + no contract, so every ERC-20 claiming it is a spoof on all three. The user's + real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees + it. - **Low-holder token filtering**: Token transfers from ERC-20 contracts with fewer than 1,000 holders are hidden from transaction history by default. @@ -1323,13 +1326,12 @@ indexes it as a real token transfer. a sharp tool — users who understand the risks can configure the wallet to show everything unfiltered, unix-style. All four settings govern the transaction history; what else each one reaches varies. The known-symbol check also runs - unconditionally on the send-screen token selector, and on the balance list - except for symbols mapped to `null` (`"ETH"` alone), which the balance list - does not filter. The fraud contract blocklist is applied unconditionally on - that selector and is not consulted by the balance list at all. The low-holder - setting also gates the send selector, while the balance list's own - 1,000-holder floor is unconditional (see Data Model). The dust threshold - applies to the transaction history alone. + unconditionally on the send-screen token selector and on the balance list, in + both cases identically to the history. The fraud contract blocklist is applied + unconditionally on that selector and is not consulted by the balance list at + all. The low-holder setting also gates the send selector, while the balance + list's own 1,000-holder floor is unconditional (see Data Model). The dust + threshold applies to the transaction history alone. #### Phishing Domain Protection diff --git a/TODO.md b/TODO.md index 9cc3b4f..a811321 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,15 @@ undefined identifiers, which is how # Completed Steps +- 2026-08-12: The known-symbol spoof rule moved into `src/shared/symbolSpoof.js` + and is now the only copy. The balance list had exempted symbols the token list + maps to `null` — `"ETH"` alone — so a fake ETH ERC-20 was hidden from the + transaction history and the Send selector but listed as a holding named ETH. A + symbol with no legitimate contract may now be borne by no contract on any of + the three surfaces, and the native exemption is "has no contract address", so + a second null-mapped symbol needs no call-site change. The user's real ETH + balance is read over RPC and never passes through the rule + ([#235](https://git.eeqj.de/sneak/AutistMask/issues/235)). - 2026-08-12: `script/verify-build`'s failure modes are now a committed target, `script/test-verify-build`, run by `make check`. It asserts the exit status and the message of every case against a fixture tree in a temp dir, and drops diff --git a/src/popup/views/send.js b/src/popup/views/send.js index 6884764..85cadbf 100644 --- a/src/popup/views/send.js +++ b/src/popup/views/send.js @@ -12,8 +12,9 @@ const { const { state, currentAddress } = require("../../shared/state"); let ctx; const { getProvider } = require("../../shared/balances"); -const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList"); +const { resolveSymbol } = require("../../shared/tokenList"); const { isLowHolderCount } = require("../../shared/holders"); +const { isSpoofedSymbol } = require("../../shared/symbolSpoof"); const { getAddress } = require("ethers"); const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; @@ -116,14 +117,6 @@ function updateToValidation() { } } -function isSpoofedToken(t) { - const upper = (t.symbol || "").toUpperCase(); - if (!KNOWN_SYMBOLS.has(upper)) return false; - const legit = KNOWN_SYMBOLS.get(upper); - if (legit === null) return true; - return t.address.toLowerCase() !== legit; -} - function renderSendTokenSelect(addr) { const sel = $("send-token"); sel.innerHTML = ''; @@ -131,7 +124,7 @@ function renderSendTokenSelect(addr) { (state.fraudContracts || []).map((a) => a.toLowerCase()), ); for (const t of addr.tokenBalances || []) { - if (isSpoofedToken(t)) continue; + if (isSpoofedSymbol(t.symbol, t.address)) continue; if (fraudSet.has(t.address.toLowerCase())) continue; // An unknown holder count does not withhold a token the user holds: // only a count the explorer actually reported as below the threshold diff --git a/src/shared/balances.js b/src/shared/balances.js index 1ca5be5..a66bc5c 100644 --- a/src/shared/balances.js +++ b/src/shared/balances.js @@ -11,8 +11,9 @@ const { const { ERC20_ABI } = require("./constants"); const { log, debugFetch } = require("./log"); const { deriveAddressFromXpub } = require("./wallet"); -const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList"); +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). @@ -89,15 +90,11 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) { // Skip spam tokens the user never asked to see if (!isKnown && !isTracked && !hasEnoughHolders) continue; - // Skip tokens spoofing a known symbol from a different address - const sym = (item.token.symbol || "").toUpperCase(); - const legitAddr = KNOWN_SYMBOLS.get(sym); - if ( - legitAddr !== undefined && - legitAddr !== null && - tokenAddr !== legitAddr - ) - 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, diff --git a/src/shared/symbolSpoof.js b/src/shared/symbolSpoof.js new file mode 100644 index 0000000..b106528 --- /dev/null +++ b/src/shared/symbolSpoof.js @@ -0,0 +1,43 @@ +// The known-symbol spoof rule, in one place. +// +// A token that borrows a known symbol from a contract that is not the one +// that symbol belongs to is a spoof, and the wallet hides it. Three surfaces +// ask that question — the transaction history, the Send token selector and +// the balance list — and they must answer it identically: a token the history +// calls fake while the balance list lists it as a holding is worse than +// either verdict alone, because the balance list is where the user forms +// their belief about what they own (issue #235). +// +// KNOWN_SYMBOLS maps a symbol to the lowercased contract address that may +// bear it, or to null. Null means the symbol belongs to the native asset, +// which has no contract at all, so no contract may bear it and every one +// that does is a spoof. "ETH" is the only such entry today; the rule is +// written so that a second one needs no change here or at any call site. + +const { KNOWN_SYMBOLS } = require("./tokenList"); + +// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum +// over the address, not part of its identity. +function normalizeAddress(addr) { + return (addr || "").toLowerCase(); +} + +// True when a token bearing `symbol` from contract `contractAddress` is +// impersonating a known symbol. +// +// An empty contract address is the native asset, which is never a spoof: +// this is what keeps the user's real ETH out of the rule, and it holds for +// any symbol that becomes null-mapped later, not just for ETH. +function isSpoofedSymbol(symbol, contractAddress) { + const contract = normalizeAddress(contractAddress); + if (!contract) return false; + const sym = (symbol || "").toUpperCase(); + if (!KNOWN_SYMBOLS.has(sym)) return false; + const legit = KNOWN_SYMBOLS.get(sym); + if (legit === null) return true; + return contract !== normalizeAddress(legit); +} + +module.exports = { + isSpoofedSymbol, +}; diff --git a/src/shared/transactions.js b/src/shared/transactions.js index b4c9638..0a87103 100644 --- a/src/shared/transactions.js +++ b/src/shared/transactions.js @@ -8,8 +8,9 @@ const { formatEther, formatUnits } = require("ethers"); const { log, debugFetch } = require("./log"); -const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList"); +const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { parseHoldersCount, isLowHolderCount } = require("./holders"); +const { isSpoofedSymbol } = require("./symbolSpoof"); // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum // over the address, not part of its identity. Every address comparison in @@ -245,18 +246,6 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { return result; } -// Check if a token transfer is spoofing a known symbol. -// Returns true if the symbol matches a known token but the contract -// address doesn't match the legitimate one. -function isSpoofedSymbol(tx) { - if (!tx.contractAddress) return false; - const symbol = (tx.symbol || "").toUpperCase(); - if (!KNOWN_SYMBOLS.has(symbol)) return false; - const legit = KNOWN_SYMBOLS.get(symbol); - if (legit === null) return true; // "ETH" as ERC-20 is always fake - return normalizeAddress(tx.contractAddress) !== normalizeAddress(legit); -} - // Pure filter function. Takes raw transactions and filter settings, // returns { transactions, newFraudContracts }. function filterTransactions(txs, filters = {}) { @@ -283,7 +272,7 @@ function filterTransactions(txs, filters = {}) { const contract = normalizeAddress(tx.contractAddress); // Filter spoofed known symbols and record the fraud contract - if (hideSpoofed && isSpoofedSymbol(tx)) { + if (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) { if (contract && !fraudSet.has(contract)) { fraudSet.add(contract); newFraud.push(contract); diff --git a/tests/symbolSpoof.test.js b/tests/symbolSpoof.test.js new file mode 100644 index 0000000..b0e612a --- /dev/null +++ b/tests/symbolSpoof.test.js @@ -0,0 +1,296 @@ +// Tests for the known-symbol spoof rule (src/shared/symbolSpoof.js) and for +// its application on all three surfaces that show tokens: the transaction +// history, the Send token selector, and the balance list. +// +// Issue #235: the three surfaces disagreed about what a `null` entry in +// KNOWN_SYMBOLS means. The history and the selector read it as "no contract +// may bear this symbol" and filtered a fake `ETH` ERC-20; the balance list +// read it as "no comparison is possible" and listed the fake token next to +// the user's real ETH, which is where a user forms their belief about what +// they own. The rule now lives in one module, so a fourth surface cannot +// reintroduce a fourth reading, and these tests assert the same attack on +// each surface. +// +// Nothing here touches the network: global.fetch is a throwing stub and the +// only fetch path in the modules under test (debugFetch, from +// src/shared/log) is mocked at the module boundary. + +// The RPC provider is replaced so that refreshBalances can be driven end to +// end: the native balance it reports must survive a balance list in which +// every ERC-20 row is a fake ETH. Everything else in ethers is the real +// module, including the formatters the assertions depend on. +jest.mock("ethers", () => { + const actual = jest.requireActual("ethers"); + class StubProvider { + async getBalance() { + return 1234500000000000000n; + } + async lookupAddress() { + return null; + } + } + return { + ...actual, + JsonRpcProvider: StubProvider, + Network: { from: () => ({}) }, + }; +}); + +jest.mock("../src/shared/log", () => ({ + log: { + debugf: () => {}, + infof: () => {}, + warnf: () => {}, + errorf: () => {}, + }, + debugFetch: jest.fn(), + setRuntimeDebug: () => {}, + isDebug: () => false, +})); + +global.fetch = jest.fn(() => { + throw new Error("tests must not perform network requests"); +}); +global.chrome = { + storage: { local: { get: async () => ({}), set: async () => {} } }, +}; + +const { isSpoofedSymbol } = require("../src/shared/symbolSpoof"); +const { KNOWN_SYMBOLS } = require("../src/shared/tokenList"); +const { filterTransactions } = require("../src/shared/transactions"); +const { + fetchTokenBalances, + refreshBalances, +} = require("../src/shared/balances"); +const { renderSendTokenSelect } = require("../src/popup/views/send"); +const { state } = require("../src/shared/state"); +const { debugFetch } = require("../src/shared/log"); + +// The fake "Ethereum" token with symbol "ETH" from the attack documented in +// README.md, given a holder count high enough to clear every other filter so +// that only the known-symbol rule can catch it. +const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82"; +const HOLDER = "0x66133e8ea0f5d1d612d2502a968757d1048c214a"; +const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; +const WETH_CONTRACT = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"; +const BLOCKSCOUT = "https://eth.blockscout.com/api/v2"; + +describe("the shared rule", () => { + test('"ETH" is still the null-mapped symbol these tests assume', () => { + expect(KNOWN_SYMBOLS.get("ETH")).toBeNull(); + }); + + test("a contract bearing a null-mapped symbol is a spoof", () => { + expect(isSpoofedSymbol("ETH", FAKE_ETH_CONTRACT)).toBe(true); + }); + + test("even a genuine contract may not bear a null-mapped symbol", () => { + expect(isSpoofedSymbol("ETH", WETH_CONTRACT)).toBe(true); + }); + + test("the native asset carries no contract and is never a spoof", () => { + expect(isSpoofedSymbol("ETH", null)).toBe(false); + expect(isSpoofedSymbol("ETH", undefined)).toBe(false); + expect(isSpoofedSymbol("ETH", "")).toBe(false); + }); + + // The native exemption is "has no contract address", not "the symbol is + // ETH". A second null-mapped symbol added to the table later inherits + // both halves of the rule without any call site being revisited. + test("a newly null-mapped symbol behaves the same way", () => { + const added = !KNOWN_SYMBOLS.has("XTZTEST"); + KNOWN_SYMBOLS.set("XTZTEST", null); + try { + expect(isSpoofedSymbol("XTZTEST", FAKE_ETH_CONTRACT)).toBe(true); + expect(isSpoofedSymbol("XTZTEST", null)).toBe(false); + } finally { + if (added) KNOWN_SYMBOLS.delete("XTZTEST"); + } + }); + + test("a known symbol from its own contract is not a spoof", () => { + expect(isSpoofedSymbol("USDC", USDC_CONTRACT)).toBe(false); + expect(isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase())).toBe( + false, + ); + }); + + test("a known symbol from another contract is a spoof", () => { + expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT)).toBe(true); + }); + + test("a symbol that is not in the table is not judged here", () => { + expect(isSpoofedSymbol("SPAMTKN", FAKE_ETH_CONTRACT)).toBe(false); + }); +}); + +describe("surface 1: the transaction history", () => { + function fakeEthTransfer() { + return { + hash: "0x" + "1".repeat(64), + symbol: "ETH", + contractAddress: FAKE_ETH_CONTRACT, + holders: 900000, + valueGwei: null, + isContractCall: false, + }; + } + + test("a fake ETH token transfer is filtered", () => { + const result = filterTransactions([fakeEthTransfer()], { + hideSpoofedSymbols: true, + hideFraudContracts: true, + hideLowHolderTokens: true, + hideDustTransactions: true, + dustThresholdGwei: 100000, + }); + expect(result.transactions).toEqual([]); + }); + + test("a real native ETH transfer survives", () => { + const native = { + hash: "0x" + "2".repeat(64), + symbol: "ETH", + contractAddress: null, + holders: null, + valueGwei: 5000000, + isContractCall: false, + }; + const result = filterTransactions([native], { + hideSpoofedSymbols: true, + hideFraudContracts: true, + hideLowHolderTokens: true, + hideDustTransactions: true, + dustThresholdGwei: 100000, + }); + expect(result.transactions).toEqual([native]); + }); +}); + +describe("surface 2: the Send token selector", () => { + let select; + + function render(tokenBalances) { + select = { innerHTML: "", children: [] }; + select.appendChild = (child) => select.children.push(child); + globalThis.document = { + getElementById: (id) => (id === "send-token" ? select : null), + createElement: () => ({ value: "", textContent: "" }), + }; + renderSendTokenSelect({ + address: "0x" + "a".repeat(40), + tokenBalances, + }); + } + + beforeEach(() => { + state.fraudContracts = []; + state.hideLowHolderTokens = true; + }); + + test("a fake ETH token is not selectable", () => { + render([ + { + address: FAKE_ETH_CONTRACT, + symbol: "ETH", + decimals: 18, + balance: "0.005", + holders: 900000, + }, + ]); + expect(select.children).toEqual([]); + }); + + test("native ETH remains the always-present option", () => { + render([]); + expect(select.innerHTML).toBe(''); + }); +}); + +describe("surface 3: the balance list", () => { + function respondWith(items) { + debugFetch.mockImplementation(async () => ({ + ok: true, + status: 200, + statusText: "OK", + json: async () => items, + })); + } + + function fakeEthItem(overrides = {}) { + return { + value: "5000000000000000", + token: { + type: "ERC-20", + address_hash: FAKE_ETH_CONTRACT, + symbol: "ETH", + name: "Ethereum", + decimals: "18", + holders_count: "900000", + ...overrides, + }, + }; + } + + beforeEach(() => { + debugFetch.mockReset(); + }); + + // The bug in issue #235: this token cleared the balance list's own + // 1,000-holder floor and was listed as a holding named ETH. + test("a fake ETH token clearing the holder floor is filtered", async () => { + respondWith([fakeEthItem()]); + expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]); + }); + + test("tracking the fake token manually does not admit it either", async () => { + respondWith([fakeEthItem({ holders_count: "0" })]); + const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [ + { address: FAKE_ETH_CONTRACT }, + ]); + expect(balances).toEqual([]); + }); + + test("a genuine token keeps its place in the list", async () => { + respondWith([ + fakeEthItem({ + address_hash: USDC_CONTRACT, + symbol: "USDC", + name: "USD Coin", + decimals: "6", + }), + ]); + const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []); + expect(balances).toHaveLength(1); + expect(balances[0].symbol).toBe("USDC"); + }); + + // The trap in this change: the user's real ETH balance is not an ERC-20 + // and is fetched over RPC in refreshBalances, so it never passes through + // this loop at all. An explorer row that is not an ERC-20 is dropped + // before the symbol rule is consulted. + test("a non-ERC-20 row claiming ETH never reaches the symbol rule", async () => { + respondWith([fakeEthItem({ type: "ERC-721" })]); + expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]); + }); + + // The money test: the user holds real ETH and has been airdropped a fake + // ETH ERC-20. The fake is gone from the list of tokens; the real balance + // is exactly what the node reported. + test("the real native ETH balance survives a fake ETH airdrop", async () => { + respondWith([fakeEthItem()]); + const addr = { address: HOLDER }; + await refreshBalances( + [{ addresses: [addr] }], + "https://rpc.example.invalid", + BLOCKSCOUT, + [], + ); + expect(addr.balance).toBe("1.2345"); + expect(addr.tokenBalances).toEqual([]); + }); + + test("no test in this file performed a network request", () => { + expect(global.fetch).not.toHaveBeenCalled(); + }); +});