// 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 { TOKENS, 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, addresses] of KNOWN_SYMBOLS) {
            expect(symbol).toBe(symbol.trim());
            expect(symbol).toMatch(/^[!-~]+$/);
            if (addresses === null) continue;
            for (const address of addresses) {
                expect(isSpoofedSymbol(symbol, address)).toBe(false);
            }
        }
    });
});

// Issue #276: the guard that was missing.  The suite walked KNOWN_SYMBOLS,
// which is built from TOKENS, so it could only ever assert that the table
// agrees with itself.  Seven symbols appear twice in the bundled list at two
// different real contracts, and the table kept whichever came first, so the
// other seven contracts — tokens in our own shipped list, at their own
// addresses — were judged spoofs and hidden from the balance list, the
// history and the send selector.  That is the over-filtering direction: it
// hides a holding the user cannot then spend.
//
// This walk is over TOKENS, the data the wallet actually ships, so it fails
// whenever a bundled token would be filtered at its own address no matter
// which side of the table the mistake is on.
describe("the shipped token list", () => {
    test("no bundled token is filtered at its own address", () => {
        const filtered = TOKENS.filter((t) =>
            isSpoofedSymbol(t.symbol, t.address),
        ).map((t) => t.symbol + " @ " + t.address);
        expect(filtered).toEqual([]);
    });

    // The third failure mode the issue asks about: a symbol whose table entry
    // names an address that is in neither the table nor the list would be a
    // contract we vouch for and do not ship.  There is none, and the table is
    // built from the list, so this asserts the derivation has not acquired a
    // hand-written entry.
    test("every address the table vouches for is a bundled token", () => {
        const bundled = new Set(TOKENS.map((t) => t.address.toLowerCase()));
        for (const [symbol, addresses] of KNOWN_SYMBOLS) {
            if (addresses === null) continue;
            expect(addresses.size).toBeGreaterThan(0);
            for (const address of addresses) {
                expect(address).toBe(address.toLowerCase());
                expect(bundled.has(address)).toBe(true);
                // And it is the token that actually reports that symbol.
                const token = TOKENS.find(
                    (t) => t.address.toLowerCase() === address,
                );
                expect(token.symbol.toUpperCase()).toBe(symbol);
            }
        }
    });

    // Both contracts behind a shared ticker must pass, from either side: a
    // rule that admits only the one the table happens to visit first is the
    // bug, not the fix.
    test("both contracts behind a shared ticker are admitted", () => {
        const bySymbol = new Map();
        for (const t of TOKENS) {
            const upper = t.symbol.toUpperCase();
            if (!bySymbol.has(upper)) bySymbol.set(upper, []);
            bySymbol.get(upper).push(t);
        }
        const shared = [...bySymbol].filter(([, list]) => list.length > 1);
        // The shared tickers are a fact about the shipped data; if a future
        // list has none, this test would silently assert nothing.
        expect(shared.length).toBeGreaterThan(0);
        for (const [, list] of shared) {
            for (const t of list) {
                expect(isSpoofedSymbol(t.symbol, t.address)).toBe(false);
            }
        }
    });

    // The seven from issue #276, named so that the reconciliation is a fact
    // in the suite: each is two real contracts from the same source fetch,
    // and the table now holds both rather than the one that came first.
    test("the seven shared tickers each name both bundled contracts", () => {
        const expected = {
            TON: [
                "0x582d872a1b094fc48f5de31d3b73f2d9be47def1", // Toncoin
                "0x2be5e8c109e2197d077d13a82daead6a9b3433c5", // Tokamak Network
            ],
            FRAX: [
                "0x853d955acef822db058eb8505911ed77f175b99e", // Legacy Frax Dollar
                "0x3432b6a60d23ca0dfca7761b7ab56459d9c964d0", // Frax (prev. FXS)
            ],
            REUSD: [
                "0x5086bf358635b81d8c47c66d1c8b9e567db70c72", // Re Protocol reUSD
                "0x57ab1e0003f623289cd798b1824be09a793e4bec", // Resupply USD
            ],
            EURE: [
                "0x39b8b6385416f4ca36a20319f70d28621895279d", // Monerium EUR emoney
                "0x3231cb76718cdef2155fc47b5286d82e6eda273f", // Monerium EUR emoney [OLD]
            ],
            MSUSD: [
                "0x4ba01f22827018b4772cd326c7627fb4956a7c00", // Main Street USD
                "0xab5eb14c09d416f0ac63661e57edb7aecdb9befa", // Metronome Synth USD
            ],
            MUSD: [
                "0xaca92e438df0b2401ff60da7e4337b687a2435da",
                "0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD
            ],
            JPYC: [
                "0x431d5dff03120afa4bdf332c61a6e1766ef37bdb", // JPY Coin
                "0x2370f9d504c7a6e775bf6e14b3f12846b594cd53", // JPY Coin v1
            ],
        };
        for (const [symbol, addresses] of Object.entries(expected)) {
            expect([...KNOWN_SYMBOLS.get(symbol)].sort()).toEqual(
                [...addresses].sort(),
            );
            for (const address of addresses) {
                expect(isSpoofedSymbol(symbol, address)).toBe(false);
            }
        }
    });

    // The other direction, on the same symbols: widening the table to hold
    // every bundled address for a ticker must not turn it into a pass for
    // any other contract.
    test("a shared ticker from a third contract is still a spoof", () => {
        const bySymbol = new Map();
        for (const t of TOKENS) {
            const upper = t.symbol.toUpperCase();
            if (!bySymbol.has(upper)) bySymbol.set(upper, []);
            bySymbol.get(upper).push(t);
        }
        for (const [symbol, list] of bySymbol) {
            if (list.length < 2) continue;
            expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT)).toBe(true);
        }
    });
});

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('<option value="ETH">ETH</option>');
    });
});

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,
            [],
            "mainnet",
        );
        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();
    });
});
