// 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); }); }); // Issue #260: the symbol is whatever the ERC-20 contract returns, and HTML // collapses leading and trailing whitespace, so a token calling itself // `" ETH "` reaches the user's eye as `ETH` while missing a raw // KNOWN_SYMBOLS lookup. Normalizing inside the shared rule fixes all three // surfaces at once, which is what consolidating the rule bought. // // Every character under test here is built from its code point rather than // pasted in: most of them are invisible, and an invisible character in a // test file is unreviewable. const cp = (...codes) => String.fromCodePoint(...codes); const NBSP = cp(0x00a0); // no-break space const FIGURE_SPACE = cp(0x2007); const IDEOGRAPHIC_SPACE = cp(0x3000); const ZWSP = cp(0x200b); // zero-width space const BOM = cp(0xfeff); // zero-width no-break space const WORD_JOINER = cp(0x2060); const SOFT_HYPHEN = cp(0x00ad); const LRM = cp(0x200e); // left-to-right mark const RLO = cp(0x202e); // right-to-left override const HANGUL_FILLER = cp(0x3164); const CHOSEONG_FILLER = cp(0x115f); const VS16 = cp(0xfe0f); // variation selector-16 const VS1 = cp(0xfe00); // variation selector-1 const NEL = cp(0x0085); // next line, a C1 control const DEL = cp(0x007f); const FULLWIDTH_ETH = cp(0xff25, 0xff34, 0xff28); const FULLWIDTH_USDC = cp(0xff55, 0xff53, 0xff44, 0xff43); // lowercase const CYRILLIC_CAPITAL_IE = cp(0x0415); describe("the shared rule: symbols that render as a known symbol", () => { test("ASCII padding does not buy a pass", () => { expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true); expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true); expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT)).toBe(true); }); test("non-breaking and other Unicode spaces do not either", () => { expect(isSpoofedSymbol(NBSP + "ETH" + NBSP, FAKE_ETH_CONTRACT)).toBe( true, ); expect( isSpoofedSymbol( FIGURE_SPACE + "ETH" + IDEOGRAPHIC_SPACE, FAKE_ETH_CONTRACT, ), ).toBe(true); }); // These render as nothing at all, in any position, so they are removed // wherever they sit rather than only at the ends. test("zero-width characters are stripped wherever they sit", () => { expect(isSpoofedSymbol("E" + ZWSP + "TH", FAKE_ETH_CONTRACT)).toBe( true, ); expect(isSpoofedSymbol(BOM + "ETH", FAKE_ETH_CONTRACT)).toBe(true); expect( isSpoofedSymbol("ET" + WORD_JOINER + "H", FAKE_ETH_CONTRACT), ).toBe(true); expect( isSpoofedSymbol("E" + SOFT_HYPHEN + "TH", FAKE_ETH_CONTRACT), ).toBe(true); }); // An LRM is invisible and, in all-Latin text, moves nothing: dropping it // leaves exactly the string the user saw. test("an invisible bidi mark does not hide a known symbol", () => { expect(isSpoofedSymbol(LRM + "ETH", FAKE_ETH_CONTRACT)).toBe(true); }); // Invisibility is not confined to \p{Cf}. A Hangul filler is Lo and a // variation selector is Mn, yet each of these four measures 32.00px in // the repo's pinned e2e Chromium at 16px sans-serif — exactly the width // of a plain `ETH` — so each reaches the user's eye as `ETH`. They are // caught by \p{Default_Ignorable_Code_Point}, not by \p{Cf}. test("invisible non-format characters are stripped too", () => { expect(isSpoofedSymbol(HANGUL_FILLER + "ETH", FAKE_ETH_CONTRACT)).toBe( true, ); expect( isSpoofedSymbol(CHOSEONG_FILLER + "ETH", FAKE_ETH_CONTRACT), ).toBe(true); expect(isSpoofedSymbol("ETH" + VS16, FAKE_ETH_CONTRACT)).toBe(true); expect(isSpoofedSymbol("E" + VS1 + "TH", FAKE_ETH_CONTRACT)).toBe(true); }); // Nor is it confined to the Unicode classes. U+007F is a control (Cc) // and is not default-ignorable, so neither class reaches it, but it // measures 32.00px in the same browser — it paints nothing, so a // symbol carrying it reaches the eye as `ETH`. It is named on its own // in the strip for exactly that reason. test("U+007F paints nothing and is stripped", () => { expect(isSpoofedSymbol(DEL + "ETH", FAKE_ETH_CONTRACT)).toBe(true); }); // The other side of the boundary, which is not the class boundary but // the visibility one: the remaining C0 and C1 controls render as a // visible 48.00px box in the same browser, so a symbol carrying one // does not look like `ETH` and must not be judged a spoof. Widening // the strip to \p{Cc} — the obvious over-correction once U+007F is in // it — fails this test. test("visible control characters do not make a symbol a spoof", () => { expect(isSpoofedSymbol(NEL + "ETH", FAKE_ETH_CONTRACT)).toBe(false); expect(isSpoofedSymbol(cp(0x0001) + "ETH", FAKE_ETH_CONTRACT)).toBe( false, ); expect(isSpoofedSymbol(cp(0x0090) + "ETH", FAKE_ETH_CONTRACT)).toBe( false, ); }); test("compatibility forms fold onto the symbol they imitate", () => { expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true); expect(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true); }); // The two knowingly open classes, asserted here so that the boundary is // a fact in the suite and not a claim in a PR body. A Cyrillic capital // Ie is a distinct letter rather than a compatibility variant, so NFKC // leaves it alone; and a right-to-left override reverses the rendering // of what follows it, which dropping the control character does not // undo. Closing either needs a confusables table or a bidi resolver, // and both are a separate change from this one. test("a Cyrillic homoglyph is knowingly still not caught", () => { expect( isSpoofedSymbol(CYRILLIC_CAPITAL_IE + "TH", FAKE_ETH_CONTRACT), ).toBe(false); }); test("a bidi-reordered symbol is knowingly still not caught", () => { expect(isSpoofedSymbol(RLO + "HTE", FAKE_ETH_CONTRACT)).toBe(false); }); // Normalization does not reach the native-asset exemption, which turns // on the absence of a contract address and never on the symbol. test("a padded symbol with no contract is still not a spoof", () => { expect(isSpoofedSymbol(" ETH ", null)).toBe(false); expect(isSpoofedSymbol(NBSP + "ETH", "")).toBe(false); }); test("a genuine contract still bears its own padded symbol", () => { expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false); expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false); }); // Normalization must not invent a match. Interior ASCII whitespace is // left alone: `E T H` renders as `E T H`, not as `ETH`, so folding it // would filter a token no user could confuse with the native asset. test("a symbol that renders differently is not judged a spoof", () => { expect(isSpoofedSymbol("E T H", FAKE_ETH_CONTRACT)).toBe(false); expect(isSpoofedSymbol("ETH2", FAKE_ETH_CONTRACT)).toBe(false); expect(isSpoofedSymbol("MY ETH", FAKE_ETH_CONTRACT)).toBe(false); }); // The false-positive question, answered against the shipped data rather // than by assertion: no bundled symbol carries whitespace or a // non-ASCII character, so the normalization cannot newly filter one. // The character class starts at `!` rather than at the space so that it // asserts the claim it stands for — `[ -~]` would admit an interior // space and let a whitespace-bearing entry through the guard. test("no bundled symbol is touched by the normalization", () => { for (const [symbol, address] of KNOWN_SYMBOLS) { expect(symbol).toBe(symbol.trim()); expect(symbol).toMatch(/^[!-~]+$/); if (address === null) continue; expect(isSpoofedSymbol(symbol, address)).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([]); }); // Issue #260 on this surface: the same transfer with a padded symbol. test("a padded fake ETH token transfer is filtered too", () => { const padded = { ...fakeEthTransfer(), symbol: " ETH " }; const result = filterTransactions([padded], { hideSpoofedSymbols: true, hideFraudContracts: true, hideLowHolderTokens: true, hideDustTransactions: true, dustThresholdGwei: 100000, }); expect(result.transactions).toEqual([]); // The contract is learned as fraudulent, exactly as for the // unpadded symbol: the padding must not cost the blocklist entry. expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]); }); 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([]); }); // Issue #260 on this surface: the option text is rendered into HTML, // which collapses the padding, so an unfiltered padded token would sit // in the selector reading exactly `ETH`. test("a padded fake ETH token is not selectable either", () => { render([ { address: FAKE_ETH_CONTRACT, symbol: " ETH ", decimals: 18, balance: "0.005", holders: 900000, }, ]); expect(select.children).toEqual([]); }); test("a genuine token with a padded symbol stays selectable", () => { render([ { address: USDC_CONTRACT, symbol: " USDC ", decimals: 6, balance: "12.5", holders: 900000, }, ]); expect(select.children).toHaveLength(1); expect(select.children[0].value).toBe(USDC_CONTRACT); }); 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([]); }); // Issue #260 on this surface: the balance list is where the user forms // their belief about what they own, and it renders the symbol into HTML. test("a padded fake ETH token is filtered too", async () => { respondWith([fakeEthItem({ symbol: " ETH " })]); expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]); }); test("a fake ETH token padded with a no-break space is filtered", async () => { respondWith([fakeEthItem({ symbol: NBSP + "ETH" + NBSP })]); expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]); }); // The false-positive direction on the surface that matters most: a real // holding whose symbol happens to carry padding is still listed, and the // list still shows the symbol the token actually reports. test("a genuine token with a padded symbol is not newly filtered", 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 "); }); 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 adjacent finding from the same review as issue #260: the type gate // compared exactly, so an explorer that ever varied the casing would // silently drop a real holding before any filter ran. The comparison is // now case-insensitive, which changes nothing about which types are // admitted. test("a differently-cased ERC-20 type still lists a real holding", async () => { respondWith([ fakeEthItem({ type: "erc-20", 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"); }); test("case insensitivity does not admit another token type", async () => { respondWith([fakeEthItem({ type: "erc-721" })]); expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]); respondWith([fakeEthItem({ type: "ERC-20-EXTRA" })]); 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(); }); });