// Tests for the address-poisoning defense in src/shared/transactions.js.
//
// README.md:730-814 describes four filters as a core security property of
// the wallet: known token symbol verification, the low-holder threshold, the
// fraud contract blocklist, and the dust threshold.  A regression in any of
// them does not crash — it silently stops filtering, and poisoned look-alike
// addresses reappear in the user's transaction history.  These tests pin the
// behaviour down in both directions: the documented attacks must be filtered,
// and legitimate transactions must survive untouched.
//
// Fixtures are static local objects modelled on the two real attacks cited in
// the README.  Nothing here touches the network: global.fetch is replaced
// with a throwing stub and the only fetch path in the module under test
// (debugFetch, from src/shared/log) is mocked at the module boundary.

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");
});

// state.js reads chrome.storage.local at module load; stub it so the
// default settings can be asserted against what the README promises. Empty
// storage, so a load produces exactly the defaults.
const { makeStorageStub } = require("./support/storageStub");

global.chrome = { storage: makeStorageStub() };

const {
    fetchRecentTransactions,
    filterTransactions,
    mergeTransactions,
} = require("../src/shared/transactions");
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state");

// ---------------------------------------------------------------------------
// Addresses and hashes from the two attacks documented in README.md:730-814.
// ---------------------------------------------------------------------------

// The fake "Ethereum" token with symbol "ETH" and zero holders
// (README.md:735-750).
const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
// The fraudulent Transfer event it emitted.
const FAKE_ETH_TRANSFER_HASH =
    "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
// Our test address, the claimed sender of the fake transfer.
const VICTIM = "0x66133e8ea0f5d1d612d2502a968757d1048c214a";
// The legitimate recipient of the real 0.005 ETH send.
const LEGIT_RECIPIENT = "0xc3c693ae04bad5f13c45885c1e85a9557798f37e";
// The scam address the fake token transfer pointed at ("0xC3C0" vs "0xC3c6").
const TOKEN_SCAM_LOOKALIKE = "0xc3c0aea127c575b9ffd03bf11c6a878e8979c37f";

// The second wave: a real 1 gwei native transfer (README.md:800-808).
const DUST_TX_HASH =
    "0x2708ebddfb9b5fa3f7a89d3ea398ef9fd8771b83ed861ecb7c21cd55d18edc74";
const DUST_SENDER_LOOKALIKE = "0xc3c6b3b4402bd78a9582ab6b00e747769344f37e";

// Genuine contracts from the shipped token list.
const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const WETH_CONTRACT = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";

// A poisoning contract that uses a symbol not present in the token list, so
// only the holder-count and blocklist rules can catch it.
const NOVEL_SPAM_CONTRACT = "0x1111111111111111111111111111111111111111";
const NOVEL_SPAM_SYMBOL = "SPAMTKN";

// An ordinary counterparty for legitimate-transaction fixtures.
const ORDINARY_PEER = "0x5aa0f9f1e0a1d0e0e5c1e7ce3b7dbbe9c19f0a11";

// The documented default settings (README.md:810-814, state.js:24-27).
const DEFAULT_FILTERS = {
    hideSpoofedSymbols: true,
    hideLowHolderTokens: true,
    hideFraudContracts: true,
    hideDustTransactions: true,
    dustThresholdGwei: 100000,
    fraudContracts: [],
};

function filters(overrides) {
    return { ...DEFAULT_FILTERS, ...overrides };
}

// ---------------------------------------------------------------------------
// Fixture builders producing objects shaped exactly like the parsed entries
// that fetchRecentTransactions hands to filterTransactions.
// ---------------------------------------------------------------------------

// A native (non-token) transaction.  valueGwei is set, contractAddress and
// holders are null, as parseTx produces.
function nativeTx(overrides = {}) {
    return {
        hash: "0x" + "a".repeat(64),
        blockNumber: 21000000,
        timestamp: 1740657600,
        from: ORDINARY_PEER,
        to: VICTIM,
        value: "0.0500",
        exactValue: "0.05",
        rawAmount: "50000000000000000",
        rawUnit: "wei",
        valueGwei: 50000000,
        symbol: "ETH",
        direction: "received",
        directionLabel: "Received",
        isError: false,
        contractAddress: null,
        holders: null,
        isContractCall: false,
        method: null,
        ...overrides,
    };
}

// An ERC-20 transfer entry.  contractAddress is lowercased and holders is a
// number, as parseTokenTransfer produces.
function tokenTx(overrides = {}) {
    return {
        hash: "0x" + "b".repeat(64),
        blockNumber: 21000000,
        timestamp: 1740657600,
        from: ORDINARY_PEER,
        to: VICTIM,
        value: "1500.5000",
        exactValue: "1500.5",
        rawAmount: "1500500000",
        rawUnit: "USDC base units (10^-6)",
        valueGwei: null,
        symbol: "USDC",
        direction: "received",
        directionLabel: "Received",
        isError: false,
        contractAddress: USDC_CONTRACT,
        holders: 3500000,
        ...overrides,
    };
}

// Attack 1: the fake "Ethereum"/"ETH" token transfer claiming the victim sent
// 0.005 "ETH" to the look-alike scam address.
function fakeEthTokenTransfer() {
    return tokenTx({
        hash: FAKE_ETH_TRANSFER_HASH,
        from: VICTIM,
        to: TOKEN_SCAM_LOOKALIKE,
        value: "0.0050",
        exactValue: "0.005",
        rawAmount: "5000000000000000",
        rawUnit: "ETH base units (10^-18)",
        symbol: "ETH",
        direction: "sent",
        directionLabel: "Sent",
        contractAddress: FAKE_ETH_CONTRACT,
        holders: 0,
    });
}

// Attack 2: the real 1 gwei native transfer from the look-alike address.
function nativeDustTransfer() {
    return nativeTx({
        hash: DUST_TX_HASH,
        from: DUST_SENDER_LOOKALIKE,
        to: VICTIM,
        value: "0.0000",
        exactValue: "0.000000001",
        rawAmount: "1000000000",
        valueGwei: 1,
        direction: "received",
        directionLabel: "Received",
    });
}

// The legitimate 0.005 ETH send that the attacks piggybacked on.
function legitimateEthSend() {
    return nativeTx({
        hash: "0x" + "c".repeat(64),
        from: VICTIM,
        to: LEGIT_RECIPIENT,
        value: "0.0050",
        exactValue: "0.005",
        rawAmount: "5000000000000000",
        valueGwei: 5000000,
        direction: "sent",
        directionLabel: "Sent",
    });
}

function hashesOf(result) {
    return result.transactions.map((tx) => tx.hash);
}

// ---------------------------------------------------------------------------

describe("token list assumptions the fixtures rely on", () => {
    test('"ETH" is a known symbol with no legitimate ERC-20 contract', () => {
        expect(KNOWN_SYMBOLS.has("ETH")).toBe(true);
        expect(KNOWN_SYMBOLS.get("ETH")).toBeNull();
    });

    test("USDC and WETH map to their genuine lowercased contracts", () => {
        expect([...KNOWN_SYMBOLS.get("USDC")]).toEqual([USDC_CONTRACT]);
        expect([...KNOWN_SYMBOLS.get("WETH")]).toEqual([WETH_CONTRACT]);
    });

    test("the spam fixture symbol is not in the known token list", () => {
        expect(KNOWN_SYMBOLS.has(NOVEL_SPAM_SYMBOL)).toBe(false);
    });
});

describe("the real attacks documented in README.md:730-814", () => {
    test('the fake "Ethereum"/"ETH" token transfer is filtered by default', () => {
        const attack = fakeEthTokenTransfer();
        const result = filterTransactions([attack], filters());
        expect(result.transactions).toEqual([]);
    });

    test("the fake token contract is reported as a newly found fraud contract", () => {
        const result = filterTransactions([fakeEthTokenTransfer()], filters());
        expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
    });

    test("the 1 gwei native dust transfer is filtered by default", () => {
        const result = filterTransactions([nativeDustTransfer()], filters());
        expect(result.transactions).toEqual([]);
        expect(result.newFraudContracts).toEqual([]);
    });

    test("both attacks are removed while the genuine send survives", () => {
        const legit = legitimateEthSend();
        const result = filterTransactions(
            [fakeEthTokenTransfer(), legit, nativeDustTransfer()],
            filters(),
        );
        expect(hashesOf(result)).toEqual([legit.hash]);
    });
});

describe("known-symbol spoof verification", () => {
    test("a known symbol from a non-matching contract is spoofed", () => {
        const spoof = tokenTx({
            symbol: "USDC",
            contractAddress: NOVEL_SPAM_CONTRACT,
            holders: 5000000,
        });
        const result = filterTransactions([spoof], filters());
        expect(result.transactions).toEqual([]);
        expect(result.newFraudContracts).toEqual([NOVEL_SPAM_CONTRACT]);
    });

    test("the genuine contract for that symbol is not spoofed", () => {
        const genuine = tokenTx();
        const result = filterTransactions([genuine], filters());
        expect(result.transactions).toEqual([genuine]);
        expect(result.newFraudContracts).toEqual([]);
    });

    test("an unknown symbol from an unknown contract is not flagged by this check", () => {
        const unknown = tokenTx({
            symbol: NOVEL_SPAM_SYMBOL,
            contractAddress: NOVEL_SPAM_CONTRACT,
            holders: 25000,
        });
        const result = filterTransactions([unknown], filters());
        expect(result.transactions).toEqual([unknown]);
        expect(result.newFraudContracts).toEqual([]);
    });

    test('"ETH" as an ERC-20 is spoofed no matter which contract emits it', () => {
        // KNOWN_SYMBOLS maps "ETH" to null: there is no legitimate ERC-20
        // "ETH", so even the real WETH contract claiming it is a spoof.
        const fromWeth = tokenTx({
            symbol: "ETH",
            contractAddress: WETH_CONTRACT,
            holders: 900000,
        });
        const result = filterTransactions([fromWeth], filters());
        expect(result.transactions).toEqual([]);
        expect(result.newFraudContracts).toEqual([WETH_CONTRACT]);
    });

    test("symbol comparison is case-insensitive", () => {
        const spoof = tokenTx({
            symbol: "usdc",
            contractAddress: NOVEL_SPAM_CONTRACT,
            holders: 5000000,
        });
        expect(filterTransactions([spoof], filters()).transactions).toEqual([]);
    });

    test("a native transaction has no contract and is never spoof-filtered", () => {
        const native = nativeTx({ symbol: "ETH" });
        const result = filterTransactions([native], filters());
        expect(result.transactions).toEqual([native]);
        expect(result.newFraudContracts).toEqual([]);
    });

    test("a repeated fraud contract is reported only once", () => {
        const result = filterTransactions(
            [fakeEthTokenTransfer(), fakeEthTokenTransfer()],
            filters(),
        );
        expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
    });

    test("an already-known fraud contract is not reported as new", () => {
        const result = filterTransactions(
            [fakeEthTokenTransfer()],
            filters({ fraudContracts: [FAKE_ETH_CONTRACT] }),
        );
        expect(result.transactions).toEqual([]);
        expect(result.newFraudContracts).toEqual([]);
    });

    test("an already-known fraud contract given in checksummed form is not reported as new", () => {
        const result = filterTransactions(
            [fakeEthTokenTransfer()],
            filters({
                fraudContracts: ["0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82"],
            }),
        );
        expect(result.newFraudContracts).toEqual([]);
    });

    // Regression guard (#179): EIP-55 mixed case is a checksum over the
    // address, not part of its identity, so the contract comparison must be
    // case-insensitive in both directions — a genuine token in any casing is
    // genuine, and a spoof cannot escape detection by changing its casing.
    test("a genuine contract in all-lowercase form is not a spoof", () => {
        const tx = tokenTx({ contractAddress: USDC_CONTRACT });
        expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
    });

    test("a genuine contract in EIP-55 checksummed form is not a spoof", () => {
        const tx = tokenTx({
            contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
        });
        const result = filterTransactions([tx], filters());
        expect(result.transactions).toEqual([tx]);
        expect(result.newFraudContracts).toEqual([]);
    });

    test("a genuine contract in all-uppercase form is not a spoof", () => {
        const tx = tokenTx({
            contractAddress: "0X" + USDC_CONTRACT.slice(2).toUpperCase(),
        });
        const result = filterTransactions([tx], filters());
        expect(result.transactions).toEqual([tx]);
        expect(result.newFraudContracts).toEqual([]);
    });

    test("a genuinely different contract claiming USDC is still a spoof in any casing", () => {
        const tx = tokenTx({
            contractAddress: "0xD05339F9EA5AB9D9F03B9D57F671D2ABD1F55C82",
        });
        const result = filterTransactions([tx], filters());
        expect(result.transactions).toEqual([]);
        // The recorded fraud contract is normalised, so the persisted
        // blocklist matches later transfers whatever casing they arrive in.
        expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
    });

    // Turning the other three filters off must not turn this one off: each
    // filter is independent, and this is the one the README calls out as the
    // defense against the fake "ETH" attack.
    test("the check still runs when the other three filters are off", () => {
        const result = filterTransactions(
            [fakeEthTokenTransfer()],
            filters({
                hideLowHolderTokens: false,
                hideFraudContracts: false,
                hideDustTransactions: false,
                dustThresholdGwei: 1,
            }),
        );
        expect(result.transactions).toEqual([]);
        expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
    });

    test("spoof filtering also applies with no filters argument", () => {
        const result = filterTransactions([fakeEthTokenTransfer()]);
        expect(result.transactions).toEqual([]);
    });

    // Fail-safe: unlike the other three flags, an absent hideSpoofedSymbols
    // leaves the check ON.  A caller that forgets the key keeps the wallet's
    // headline protection; only a user who deliberately switched the setting
    // off sends an explicit false.
    test("an absent hideSpoofedSymbols leaves the check on", () => {
        const result = filterTransactions([fakeEthTokenTransfer()], {
            fraudContracts: [],
        });
        expect(result.transactions).toEqual([]);
    });

    test("a truthy-but-not-true hideSpoofedSymbols leaves the check on", () => {
        const result = filterTransactions(
            [fakeEthTokenTransfer()],
            filters({ hideSpoofedSymbols: undefined }),
        );
        expect(result.transactions).toEqual([]);
    });
});

describe("disabling known-symbol spoof verification", () => {
    test("the spoofed transfer is shown when hideSpoofedSymbols is false", () => {
        const attack = fakeEthTokenTransfer();
        const result = filterTransactions(
            [attack],
            filters({
                hideSpoofedSymbols: false,
                // The blocklist rule would otherwise hide the same row via a
                // contract this pass had already learned.
                hideFraudContracts: false,
                hideLowHolderTokens: false,
            }),
        );
        expect(result.transactions).toEqual([attack]);
    });

    // The blocklist is populated only by this check, so switching the check
    // off stops the learning too.  Leaving learning on would make the setting
    // a no-op: the contract it recorded would immediately hide the same row
    // through the fraud-contract rule, which is on by default.
    test("no fraud contract is learned when hideSpoofedSymbols is false", () => {
        const result = filterTransactions(
            [fakeEthTokenTransfer()],
            filters({ hideSpoofedSymbols: false }),
        );
        expect(result.newFraudContracts).toEqual([]);
    });

    test("the setting off does not stop the other three rules", () => {
        const dust = nativeDustTransfer();
        const lowHolder = tokenTx({
            symbol: NOVEL_SPAM_SYMBOL,
            contractAddress: NOVEL_SPAM_CONTRACT,
            holders: 0,
        });
        const result = filterTransactions(
            [dust, lowHolder],
            filters({ hideSpoofedSymbols: false }),
        );
        expect(result.transactions).toEqual([]);
    });

    // An already-persisted fraud contract keeps being filtered: the blocklist
    // rule is a separate setting and is unaffected by this one.
    test("an already-blocklisted contract is still hidden with the check off", () => {
        const result = filterTransactions(
            [fakeEthTokenTransfer()],
            filters({
                hideSpoofedSymbols: false,
                fraudContracts: [FAKE_ETH_CONTRACT],
            }),
        );
        expect(result.transactions).toEqual([]);
        expect(result.newFraudContracts).toEqual([]);
    });

    test("a genuine transfer is unaffected by the setting either way", () => {
        const tx = tokenTx();
        expect(
            filterTransactions([tx], filters({ hideSpoofedSymbols: false }))
                .transactions,
        ).toEqual([tx]);
        expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
    });
});

describe("low-holder token filtering (the 1,000-holder rule)", () => {
    function spamWithHolders(holders) {
        return tokenTx({
            symbol: NOVEL_SPAM_SYMBOL,
            contractAddress: NOVEL_SPAM_CONTRACT,
            holders: holders,
        });
    }

    test("a zero-holder poisoning token is filtered", () => {
        const result = filterTransactions([spamWithHolders(0)], filters());
        expect(result.transactions).toEqual([]);
    });

    test("boundary: 999 holders is filtered", () => {
        const result = filterTransactions([spamWithHolders(999)], filters());
        expect(result.transactions).toEqual([]);
    });

    test("boundary: exactly 1000 holders is kept", () => {
        const tx = spamWithHolders(1000);
        expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
    });

    test("boundary: 1001 holders is kept", () => {
        const tx = spamWithHolders(1001);
        expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
    });

    test("the rule is bypassed when hideLowHolderTokens is off", () => {
        const tx = spamWithHolders(0);
        const result = filterTransactions(
            [tx],
            filters({ hideLowHolderTokens: false }),
        );
        expect(result.transactions).toEqual([tx]);
    });

    test("native transactions have no holder count and are unaffected", () => {
        const tx = legitimateEthSend();
        expect(tx.holders).toBeNull();
        expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
    });

    // Regression guard (#179): an unknown holder count on a real token — the
    // explorer rate-limited the call, or a self-hosted instance omits the
    // field — must not be read as zero holders.  Reading it that way hides a
    // legitimate transfer from the user's history, the same over-filtering
    // harm as the zero-threshold bug.  This pins the `tx.holders !== null`
    // guard, which no fixture previously reached.
    test("a token whose holder count is unknown is not filtered", () => {
        const tx = tokenTx({
            symbol: NOVEL_SPAM_SYMBOL,
            contractAddress: NOVEL_SPAM_CONTRACT,
            holders: null,
        });
        expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
    });
});

describe("fraud contract blocklist", () => {
    function blocklistedTx() {
        return tokenTx({
            symbol: NOVEL_SPAM_SYMBOL,
            contractAddress: NOVEL_SPAM_CONTRACT,
            holders: 50000,
        });
    }

    test("a transfer from a blocklisted contract is filtered", () => {
        const result = filterTransactions(
            [blocklistedTx()],
            filters({ fraudContracts: [NOVEL_SPAM_CONTRACT] }),
        );
        expect(result.transactions).toEqual([]);
    });

    test("the blocklist is matched case-insensitively", () => {
        const result = filterTransactions(
            [blocklistedTx()],
            filters({
                fraudContracts: [NOVEL_SPAM_CONTRACT.toUpperCase()],
            }),
        );
        expect(result.transactions).toEqual([]);
    });

    test("the blocklist is bypassed when hideFraudContracts is off", () => {
        const tx = blocklistedTx();
        const result = filterTransactions(
            [tx],
            filters({
                hideFraudContracts: false,
                fraudContracts: [NOVEL_SPAM_CONTRACT],
            }),
        );
        expect(result.transactions).toEqual([tx]);
    });

    test("a contract not on the blocklist is unaffected", () => {
        const tx = blocklistedTx();
        const result = filterTransactions(
            [tx],
            filters({ fraudContracts: [FAKE_ETH_CONTRACT] }),
        );
        expect(result.transactions).toEqual([tx]);
    });

    test("native transactions are never blocklist-filtered", () => {
        const tx = legitimateEthSend();
        const result = filterTransactions(
            [tx],
            filters({ fraudContracts: [FAKE_ETH_CONTRACT] }),
        );
        expect(result.transactions).toEqual([tx]);
    });

    test("a contract caught spoofing earlier in the batch blocks its later transfers", () => {
        // The fake "Ethereum" contract is detected as a spoof, added to the
        // working blocklist, and its later transfer under a novel symbol is
        // then filtered by the blocklist rule rather than the spoof rule.
        const later = tokenTx({
            hash: "0x" + "d".repeat(64),
            symbol: NOVEL_SPAM_SYMBOL,
            contractAddress: FAKE_ETH_CONTRACT,
            holders: 50000,
        });
        const result = filterTransactions(
            [fakeEthTokenTransfer(), later],
            filters(),
        );
        expect(result.transactions).toEqual([]);
        expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
    });

    test("that later transfer survives when hideFraudContracts is off", () => {
        const later = tokenTx({
            hash: "0x" + "d".repeat(64),
            symbol: NOVEL_SPAM_SYMBOL,
            contractAddress: FAKE_ETH_CONTRACT,
            holders: 50000,
        });
        const result = filterTransactions(
            [fakeEthTokenTransfer(), later],
            filters({ hideFraudContracts: false }),
        );
        expect(hashesOf(result)).toEqual([later.hash]);
    });
});

describe("dust threshold filtering", () => {
    function dustOf(gwei) {
        return nativeTx({ valueGwei: gwei });
    }

    test("boundary: 99,999 gwei is filtered at the 100,000 gwei default", () => {
        const result = filterTransactions([dustOf(99999)], filters());
        expect(result.transactions).toEqual([]);
    });

    test("boundary: exactly 100,000 gwei is kept", () => {
        const tx = dustOf(100000);
        expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
    });

    test("boundary: 100,001 gwei is kept", () => {
        const tx = dustOf(100001);
        expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
    });

    test("the 100,000 gwei default applies when no threshold is supplied", () => {
        const below = dustOf(99999);
        const at = dustOf(100000);
        const opts = {
            hideLowHolderTokens: true,
            hideFraudContracts: true,
            hideDustTransactions: true,
            fraudContracts: [],
        };
        expect(filterTransactions([below], opts).transactions).toEqual([]);
        expect(filterTransactions([at], opts).transactions).toEqual([at]);
    });

    test("a user-raised threshold is honoured on both sides", () => {
        const opts = filters({ dustThresholdGwei: 5000000 });
        const below = dustOf(4999999);
        const at = dustOf(5000000);
        expect(filterTransactions([below], opts).transactions).toEqual([]);
        expect(filterTransactions([at], opts).transactions).toEqual([at]);
    });

    test("dust filtering is bypassed when hideDustTransactions is off", () => {
        const tx = nativeDustTransfer();
        const result = filterTransactions(
            [tx],
            filters({ hideDustTransactions: false }),
        );
        expect(result.transactions).toEqual([tx]);
    });

    test("zero-value contract calls are never treated as dust", () => {
        const approve = nativeTx({
            hash: "0x" + "e".repeat(64),
            valueGwei: 0,
            value: "",
            exactValue: "",
            direction: "contract",
            directionLabel: "Approve",
            isContractCall: true,
            method: "approve",
        });
        expect(filterTransactions([approve], filters()).transactions).toEqual([
            approve,
        ]);
    });

    test("token transfers carry no gwei value and are never dust-filtered", () => {
        const tx = tokenTx({ valueGwei: null });
        expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
    });

    // Regression guard (#179): 0 is a real threshold meaning "hide nothing",
    // not an absent one.  It used to be swallowed by `|| 100000`, so the one
    // value a user would pick to see everything was the one that did not
    // work.
    test("a threshold of 0 hides nothing, leaving the toggle on", () => {
        const dust = dustOf(50);
        const zero = dustOf(0);
        const opts = filters({ dustThresholdGwei: 0 });
        expect(filterTransactions([dust], opts).transactions).toEqual([dust]);
        expect(filterTransactions([zero], opts).transactions).toEqual([zero]);
    });

    test("a threshold of 0 agrees with clearing the hide-dust checkbox", () => {
        const tx = nativeDustTransfer();
        const thresholdZero = filterTransactions(
            [tx],
            filters({ dustThresholdGwei: 0 }),
        );
        const toggleOff = filterTransactions(
            [tx],
            filters({ hideDustTransactions: false }),
        );
        expect(thresholdZero.transactions).toEqual([tx]);
        expect(toggleOff.transactions).toEqual([tx]);
    });

    test("0, unset and a set threshold are three distinct behaviours", () => {
        const tx = dustOf(50);
        expect(
            filterTransactions([tx], filters({ dustThresholdGwei: 0 }))
                .transactions,
        ).toEqual([tx]);
        expect(
            filterTransactions([tx], filters({ dustThresholdGwei: undefined }))
                .transactions,
        ).toEqual([]);
        expect(
            filterTransactions([tx], filters({ dustThresholdGwei: 40 }))
                .transactions,
        ).toEqual([tx]);
        expect(
            filterTransactions([tx], filters({ dustThresholdGwei: 60 }))
                .transactions,
        ).toEqual([]);
    });
});

describe("filter defaults promised by the README and Settings", () => {
    // The defaults are what a load of empty storage produces, so the load is
    // part of the assertion rather than an incantation before it: reading the
    // singleton before any load now throws (StateNotLoadedError), because a
    // context served DEFAULT_STATE without asking for it is the whole subject
    // of https://git.eeqj.de/sneak/AutistMask/issues/324. The stub at the top
    // of this file has storage empty.
    beforeAll(async () => {
        await require("../src/shared/state").loadState();
    });

    test("all four toggles default to on and the threshold to 100,000 gwei", () => {
        expect(state.hideSpoofedSymbols).toBe(true);
        expect(state.hideLowHolderTokens).toBe(true);
        expect(state.hideFraudContracts).toBe(true);
        expect(state.hideDustTransactions).toBe(true);
        expect(state.dustThresholdGwei).toBe(100000);
    });

    test("the fraud contract list starts empty", () => {
        expect(state.fraudContracts).toEqual([]);
    });

    // Documents current behaviour: filterTransactions defaults the other three
    // optional filters to off.  Their "default to on" promise is satisfied by
    // the state defaults above, which every caller passes in.  Spoof
    // verification is the exception and stays on unless explicitly disabled.
    test("current behaviour: with no filters argument only spoof filtering runs", () => {
        const dust = nativeDustTransfer();
        const lowHolder = tokenTx({
            symbol: NOVEL_SPAM_SYMBOL,
            contractAddress: NOVEL_SPAM_CONTRACT,
            holders: 0,
        });
        const result = filterTransactions([dust, lowHolder]);
        expect(hashesOf(result)).toEqual([dust.hash, lowHolder.hash]);
    });
});

describe("legitimate transactions are never filtered", () => {
    test("a plain ETH transfer of an ordinary amount survives all four rules", () => {
        const tx = nativeTx();
        const result = filterTransactions([tx], filters());
        expect(result.transactions).toEqual([tx]);
        expect(result.newFraudContracts).toEqual([]);
    });

    test("a genuine high-holder USDC transfer survives all four rules", () => {
        const tx = tokenTx();
        const result = filterTransactions([tx], filters());
        expect(result.transactions).toEqual([tx]);
        expect(result.newFraudContracts).toEqual([]);
    });

    test("a genuine WETH transfer survives all four rules", () => {
        const tx = tokenTx({
            hash: "0x" + "f".repeat(64),
            symbol: "WETH",
            contractAddress: WETH_CONTRACT,
            holders: 850000,
            value: "1.2500",
            exactValue: "1.25",
        });
        const result = filterTransactions([tx], filters());
        expect(result.transactions).toEqual([tx]);
    });

    test("a mixed history keeps exactly the legitimate entries, in order", () => {
        const ethIn = nativeTx();
        const usdcIn = tokenTx();
        const ethOut = legitimateEthSend();
        const result = filterTransactions(
            [
                fakeEthTokenTransfer(),
                ethIn,
                nativeDustTransfer(),
                usdcIn,
                tokenTx({
                    hash: "0x" + "9".repeat(64),
                    symbol: NOVEL_SPAM_SYMBOL,
                    contractAddress: NOVEL_SPAM_CONTRACT,
                    holders: 0,
                }),
                ethOut,
            ],
            filters(),
        );
        expect(hashesOf(result)).toEqual([
            ethIn.hash,
            usdcIn.hash,
            ethOut.hash,
        ]);
    });

    test("surviving entries are the same objects, unmodified", () => {
        const tx = tokenTx();
        const before = JSON.stringify(tx);
        const result = filterTransactions([tx], filters());
        expect(result.transactions[0]).toBe(tx);
        expect(JSON.stringify(tx)).toBe(before);
    });

    test("an empty history yields empty results", () => {
        const result = filterTransactions([], filters());
        expect(result.transactions).toEqual([]);
        expect(result.newFraudContracts).toEqual([]);
    });
});

// ---------------------------------------------------------------------------
// mergeTransactions is the pure core of the merge: it takes parsed native
// entries and parsed token transfers and decides how many rows one on-chain
// transaction becomes.  One transaction is one row per distinct value
// movement, so the native side of a plain ERC-20 transfer must not survive
// next to its token row (the duplicate-row bug), while a hash that really
// did move several things must keep a row for each.
// ---------------------------------------------------------------------------

// A native entry as parseTx produces it for a decoded contract call: the
// amount fields are blanked and direction is "contract".
function contractCallTx(overrides = {}) {
    return nativeTx({
        from: VICTIM,
        to: USDC_CONTRACT,
        value: "",
        exactValue: "",
        rawAmount: "",
        rawUnit: "",
        valueGwei: 0,
        direction: "contract",
        directionLabel: "Approve",
        isContractCall: true,
        method: "approve",
        ...overrides,
    });
}

// The native entry parseTx produces for a plain ERC-20 transfer: sent to the
// token contract, no ETH, and method "transfer", which is exactly why it is
// not marked as a display-level contract call.
function erc20CallTx(overrides = {}) {
    return nativeTx({
        from: VICTIM,
        to: USDC_CONTRACT,
        value: "0.0000",
        exactValue: "0.0",
        rawAmount: "0",
        valueGwei: 0,
        direction: "sent",
        directionLabel: "Sent",
        isContractCall: true,
        method: "transfer",
        ...overrides,
    });
}

describe("mergeTransactions: one row per value movement", () => {
    const HASH = "0x" + "d".repeat(64);
    const OTHER_HASH = "0x" + "e".repeat(64);
    const ROUTER = "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad";

    test("a plain ERC-20 transfer yields one row, the token row", () => {
        const native = erc20CallTx({ hash: HASH });
        const token = tokenTx({
            hash: HASH,
            from: VICTIM,
            to: ORDINARY_PEER,
            direction: "sent",
            directionLabel: "Sent",
        });

        const merged = mergeTransactions([native], [token]);
        expect(merged).toHaveLength(1);
        expect(merged[0].symbol).toBe("USDC");
        expect(merged[0].exactValue).toBe("1500.5");
        expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
    });

    test("an ETH-only transfer keeps its row unchanged", () => {
        const merged = mergeTransactions([legitimateEthSend()], []);
        expect(merged).toHaveLength(1);
        expect(merged[0]).toEqual(legitimateEthSend());
    });

    test("a genuine zero-value native transaction is still displayed", () => {
        const zero = nativeTx({
            hash: HASH,
            from: VICTIM,
            to: ORDINARY_PEER,
            value: "0.0000",
            exactValue: "0.0",
            rawAmount: "0",
            valueGwei: 0,
            direction: "sent",
            directionLabel: "Sent",
        });

        const merged = mergeTransactions([zero], []);
        expect(merged).toEqual([zero]);
    });

    test("a zero-value native row is only absorbed by a transfer sharing its hash", () => {
        const zero = erc20CallTx({ hash: HASH });
        const unrelated = tokenTx({ hash: OTHER_HASH });

        const merged = mergeTransactions([zero], [unrelated]);
        expect(merged).toHaveLength(2);
        expect(merged.map((t) => t.hash).sort()).toEqual(
            [HASH, OTHER_HASH].sort(),
        );
    });

    test("a native transaction that moved ETH keeps its row beside the token row", () => {
        // An undecoded call (no method name) carrying ETH that also emitted
        // a token transfer: two real movements, so two rows.
        const native = nativeTx({
            hash: HASH,
            from: VICTIM,
            to: ROUTER,
            value: "0.2500",
            exactValue: "0.25",
            rawAmount: "250000000000000000",
            valueGwei: 250000000,
            direction: "sent",
            directionLabel: "Sent",
            isContractCall: true,
        });
        const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });

        const merged = mergeTransactions([native], [token]);
        expect(merged).toHaveLength(2);
        expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
    });

    test("a sub-gwei ETH movement keeps its row beside the token row", () => {
        // 500000000 wei is 0.5 gwei, so parseTx's valueGwei floors to 0 while
        // rawAmount stays nonzero.  Deciding "moved no ETH" on valueGwei would
        // delete this row and lose a real ETH movement, so the decision is made
        // on rawAmount as a BigInt.
        const native = nativeTx({
            hash: HASH,
            from: VICTIM,
            to: ROUTER,
            value: "0.0000",
            exactValue: "0.0000000005",
            rawAmount: "500000000",
            valueGwei: 0,
            direction: "sent",
            directionLabel: "Sent",
            isContractCall: true,
        });
        const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });

        const merged = mergeTransactions([native], [token]);
        expect(merged).toHaveLength(2);
        expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
        expect(merged.find((t) => t.symbol === "ETH").rawAmount).toBe(
            "500000000",
        );
    });

    test("a swap consolidates every token leg into one row, preferring the received leg", () => {
        const native = contractCallTx({
            hash: HASH,
            to: ROUTER,
            directionLabel: "Swap",
            method: "execute",
        });
        const sentLeg = tokenTx({
            hash: HASH,
            from: VICTIM,
            to: ROUTER,
            direction: "sent",
            directionLabel: "Sent",
        });
        const receivedLeg = tokenTx({
            hash: HASH,
            from: ROUTER,
            to: VICTIM,
            value: "0.2500",
            exactValue: "0.25",
            rawAmount: "250000000000000000",
            rawUnit: "WETH base units (10^-18)",
            symbol: "WETH",
            contractAddress: WETH_CONTRACT,
            holders: 850000,
        });

        const merged = mergeTransactions([native], [sentLeg, receivedLeg]);
        expect(merged).toHaveLength(1);
        expect(merged[0].symbol).toBe("WETH");
        expect(merged[0].exactValue).toBe("0.25");
        // The user's own address and the contract called are preserved.
        expect(merged[0].from).toBe(VICTIM);
        expect(merged[0].to).toBe(ROUTER);
        expect(merged[0].directionLabel).toBe("Swap");
    });

    test("a swap whose legs are all sent takes its amount from the first sent leg", () => {
        const native = contractCallTx({
            hash: HASH,
            to: ROUTER,
            directionLabel: "Swap",
            method: "execute",
        });
        const firstSent = tokenTx({
            hash: HASH,
            from: VICTIM,
            to: ROUTER,
            direction: "sent",
            directionLabel: "Sent",
        });
        const secondSent = tokenTx({
            hash: HASH,
            from: VICTIM,
            to: ROUTER,
            value: "0.2500",
            exactValue: "0.25",
            rawAmount: "250000000000000000",
            rawUnit: "WETH base units (10^-18)",
            symbol: "WETH",
            contractAddress: WETH_CONTRACT,
            holders: 850000,
            direction: "sent",
            directionLabel: "Sent",
        });

        const merged = mergeTransactions([native], [firstSent, secondSent]);
        expect(merged).toHaveLength(1);
        // With no received leg the display amount comes from the first sent
        // leg, and a later sent leg does not overwrite it.
        expect(merged[0].symbol).toBe("USDC");
        expect(merged[0].exactValue).toBe("1500.5");
        expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
        expect(merged[0].holders).toBe(3500000);
    });

    test("a contract call carrying ETH plus a token transfer stays one row", () => {
        const native = contractCallTx({
            hash: HASH,
            to: ROUTER,
            directionLabel: "Swap",
            method: "swapExactETHForTokens",
            valueGwei: 250000000,
        });
        const received = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });

        const merged = mergeTransactions([native], [received]);
        expect(merged).toHaveLength(1);
        expect(merged[0].symbol).toBe("USDC");
        expect(merged[0].exactValue).toBe("1500.5");
        // The ETH leg is still visible as the row's native quantity.
        expect(merged[0].valueGwei).toBe(250000000);
    });

    test("an approve keeps its row and survives the filters", () => {
        const approve = contractCallTx({ hash: HASH });

        const merged = mergeTransactions([approve], []);
        expect(merged).toEqual([approve]);
        expect(filterTransactions(merged, filters()).transactions).toEqual([
            approve,
        ]);
    });

    test("a contract creation keeps its row", () => {
        const creation = nativeTx({
            hash: HASH,
            from: VICTIM,
            to: "",
            value: "0.0000",
            exactValue: "0.0",
            rawAmount: "0",
            valueGwei: 0,
            direction: "sent",
            directionLabel: "Sent",
        });

        expect(mergeTransactions([creation], [])).toEqual([creation]);
    });

    test("a native self-send keeps its single row", () => {
        const selfSend = nativeTx({
            hash: HASH,
            from: VICTIM,
            to: VICTIM,
            direction: "sent",
            directionLabel: "Sent",
        });

        expect(mergeTransactions([selfSend], [])).toEqual([selfSend]);
    });

    test("a token self-send yields one row", () => {
        const native = erc20CallTx({ hash: HASH });
        const token = tokenTx({
            hash: HASH,
            from: VICTIM,
            to: VICTIM,
            direction: "sent",
            directionLabel: "Sent",
        });

        const merged = mergeTransactions([native], [token]);
        expect(merged).toHaveLength(1);
        expect(merged[0].symbol).toBe("USDC");
        expect(merged[0].from).toBe(VICTIM);
        expect(merged[0].to).toBe(VICTIM);
    });

    test("several distinct tokens moved by one ERC-20 call keep a row each", () => {
        const native = erc20CallTx({ hash: HASH });
        const usdc = tokenTx({ hash: HASH });
        const weth = tokenTx({
            hash: HASH,
            symbol: "WETH",
            contractAddress: WETH_CONTRACT,
            holders: 850000,
        });

        const merged = mergeTransactions([native], [usdc, weth]);
        expect(merged.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
    });

    test("rows are sorted by block number, newest first", () => {
        const older = nativeTx({ hash: HASH, blockNumber: 21000000 });
        const newer = nativeTx({ hash: OTHER_HASH, blockNumber: 21000010 });

        const merged = mergeTransactions([older, newer], []);
        expect(merged.map((t) => t.blockNumber)).toEqual([21000010, 21000000]);
    });

    test("the entries handed in are never mutated", () => {
        const native = contractCallTx({ hash: HASH, method: "execute" });
        const token = tokenTx({ hash: HASH });
        const before = JSON.stringify([native, token]);

        mergeTransactions([native], [token]);
        expect(JSON.stringify([native, token])).toBe(before);
    });
});

// ---------------------------------------------------------------------------
// fetchRecentTransactions owns the per-address merge of normal transactions
// with ERC-20 transfers.  (The cross-address merge Home performs lives in
// src/popup/views/home.js.)  debugFetch is mocked, so these tests exercise the
// merge logic against static fixture payloads with no network involved.
// ---------------------------------------------------------------------------

const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const TS = "2026-02-27T12:00:00.000Z";
const TS_EPOCH = Math.floor(Date.parse(TS) / 1000);

function respondWith(txItems, tokenTransferItems) {
    debugFetch.mockImplementation(async (url) => ({
        ok: true,
        status: 200,
        statusText: "OK",
        json: async () =>
            url.includes("/token-transfers")
                ? { items: tokenTransferItems }
                : { items: txItems },
    }));
}

describe("fetchRecentTransactions merge and dedup", () => {
    beforeEach(() => {
        debugFetch.mockReset();
    });

    test("queries only the two Blockscout endpoints for the address", async () => {
        respondWith([], []);
        await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
        expect(debugFetch).toHaveBeenCalledTimes(2);
        const urls = debugFetch.mock.calls.map((c) => c[0]);
        expect(urls).toContain(
            BLOCKSCOUT + "/addresses/" + VICTIM + "/transactions",
        );
        expect(urls).toContain(
            BLOCKSCOUT +
                "/addresses/" +
                VICTIM +
                "/token-transfers?type=ERC-20",
        );
    });

    test("a swap consolidates its token transfers into the single tx entry", async () => {
        const hash = "0x" + "1".repeat(64);
        respondWith(
            [
                {
                    hash: hash,
                    block_number: 21000010,
                    timestamp: TS,
                    from: { hash: VICTIM },
                    to: {
                        hash: "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad",
                        is_contract: true,
                    },
                    value: "0",
                    method: "execute",
                    status: "ok",
                },
            ],
            [
                {
                    transaction_hash: hash,
                    block_number: 21000010,
                    timestamp: TS,
                    from: { hash: VICTIM },
                    to: { hash: "0x66a9893cc07d91d95644aedd05d03f95e1dba8af" },
                    total: { value: "1500500000", decimals: "6" },
                    token: {
                        symbol: "USDC",
                        address_hash: USDC_CONTRACT,
                        holders_count: "3500000",
                    },
                },
                {
                    transaction_hash: hash,
                    block_number: 21000010,
                    timestamp: TS,
                    from: {
                        hash: "0x66a9893cc07d91d95644aedd05d03f95e1dba8af",
                    },
                    to: { hash: VICTIM },
                    total: { value: "250000000000000000", decimals: "18" },
                    token: {
                        symbol: "WETH",
                        address_hash: WETH_CONTRACT,
                        holders_count: "850000",
                    },
                },
            ],
        );

        const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
        expect(txs).toHaveLength(1);
        const merged = txs[0];
        // The received leg (the swap output) supplies the display amount.
        expect(merged.symbol).toBe("WETH");
        expect(merged.value).toBe("0.2500");
        expect(merged.contractAddress).toBe(WETH_CONTRACT);
        expect(merged.holders).toBe(850000);
        // The user's own address and the contract they called are preserved,
        // not the router addresses from the token transfer legs.
        expect(merged.from).toBe(VICTIM);
        expect(merged.to).toBe("0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad");
        expect(merged.direction).toBe("contract");
        expect(merged.directionLabel).toBe("Swap");
        expect(merged.timestamp).toBe(TS_EPOCH);
    });

    test("a token transfer with no matching transaction gets its own entry", async () => {
        respondWith(
            [],
            [
                {
                    transaction_hash: "0x" + "2".repeat(64),
                    block_number: 21000020,
                    timestamp: TS,
                    from: { hash: ORDINARY_PEER },
                    to: { hash: VICTIM },
                    total: { value: "1500500000", decimals: "6" },
                    token: {
                        symbol: "USDC",
                        address_hash: USDC_CONTRACT,
                        holders_count: "3500000",
                    },
                },
            ],
        );

        const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
        expect(txs).toHaveLength(1);
        expect(txs[0].symbol).toBe("USDC");
        expect(txs[0].value).toBe("1500.5000");
        expect(txs[0].direction).toBe("received");
        expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
        expect(txs[0].holders).toBe(3500000);
    });

    test("two transfers of the same token in one transaction collapse to one entry", async () => {
        const hash = "0x" + "3".repeat(64);
        const leg = (value) => ({
            transaction_hash: hash,
            block_number: 21000030,
            timestamp: TS,
            from: { hash: ORDINARY_PEER },
            to: { hash: VICTIM },
            total: { value: value, decimals: "6" },
            token: {
                symbol: "USDC",
                address_hash: USDC_CONTRACT,
                holders_count: "3500000",
            },
        });
        respondWith([], [leg("1000000"), leg("2000000")]);

        const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
        expect(txs).toHaveLength(1);
        // Keyed by hash plus contract, so the later leg wins.
        expect(txs[0].exactValue).toBe("2.0");
    });

    test("two different tokens in one transaction stay as separate entries", async () => {
        const hash = "0x" + "4".repeat(64);
        respondWith(
            [],
            [
                {
                    transaction_hash: hash,
                    block_number: 21000040,
                    timestamp: TS,
                    from: { hash: ORDINARY_PEER },
                    to: { hash: VICTIM },
                    total: { value: "1000000", decimals: "6" },
                    token: {
                        symbol: "USDC",
                        address_hash: USDC_CONTRACT,
                        holders_count: "3500000",
                    },
                },
                {
                    transaction_hash: hash,
                    block_number: 21000040,
                    timestamp: TS,
                    from: { hash: ORDINARY_PEER },
                    to: { hash: VICTIM },
                    total: { value: "1000000000000000000", decimals: "18" },
                    token: {
                        symbol: "WETH",
                        address_hash: WETH_CONTRACT,
                        holders_count: "850000",
                    },
                },
            ],
        );

        const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
        expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
    });

    // Regression guard for the duplicate-row bug: for a plain ERC-20
    // transfer the method is "transfer", so parseTx does not mark the entry
    // as a contract call in the display sense.  The native side of that
    // transaction moved no ETH and is represented by the token row, so it
    // must not survive the merge as a second, zero-value row.
    test("a plain ERC-20 transfer produces exactly one entry", async () => {
        const hash = "0x" + "5".repeat(64);
        respondWith(
            [
                {
                    hash: hash,
                    block_number: 21000050,
                    timestamp: TS,
                    from: { hash: VICTIM },
                    to: { hash: USDC_CONTRACT, is_contract: true },
                    value: "0",
                    method: "transfer",
                    status: "ok",
                },
            ],
            [
                {
                    transaction_hash: hash,
                    block_number: 21000050,
                    timestamp: TS,
                    from: { hash: VICTIM },
                    to: { hash: ORDINARY_PEER },
                    total: { value: "1000000", decimals: "6" },
                    token: {
                        symbol: "USDC",
                        address_hash: USDC_CONTRACT,
                        holders_count: "3500000",
                    },
                },
            ],
        );

        const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
        expect(txs).toHaveLength(1);
        expect(txs[0].symbol).toBe("USDC");
        expect(txs[0].exactValue).toBe("1.0");
        expect(txs[0].direction).toBe("sent");
        expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
        // The surviving row is the token row, and the filters keep it.
        const kept = filterTransactions(txs, filters()).transactions;
        expect(kept).toHaveLength(1);
        expect(kept[0].symbol).toBe("USDC");
    });

    test("entries are sorted by block number descending and capped at count", async () => {
        const item = (n) => ({
            hash: "0x" + String(n).repeat(64),
            block_number: 21000000 + n,
            timestamp: TS,
            from: { hash: ORDINARY_PEER },
            to: { hash: VICTIM, is_contract: false },
            value: "1000000000000000000",
            method: null,
            status: "ok",
        });
        respondWith([item(6), item(8), item(7)], []);

        const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, 2);
        expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]);
    });

    test("the fake token transfer survives fetching and is then filtered", async () => {
        respondWith(
            [],
            [
                {
                    transaction_hash: FAKE_ETH_TRANSFER_HASH,
                    block_number: 21000060,
                    timestamp: TS,
                    from: { hash: VICTIM },
                    to: { hash: TOKEN_SCAM_LOOKALIKE },
                    total: { value: "5000000000000000", decimals: "18" },
                    token: {
                        symbol: "ETH",
                        address_hash: FAKE_ETH_CONTRACT,
                        holders_count: "0",
                    },
                },
            ],
        );

        const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
        expect(txs).toHaveLength(1);
        expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT);
        expect(txs[0].holders).toBe(0);

        const result = filterTransactions(txs, filters());
        expect(result.transactions).toEqual([]);
        expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
    });

    // Regression guards (#230): the explorer's holders_count is optional.  A
    // missing field means the count is unknown; it does not mean the token
    // has no holders.  Recording the two as the same number both hides a
    // legitimate token and makes the `holders !== null` guard in
    // filterTransactions unreachable for token transfers.
    describe("an unreported holders_count is unknown, not zero", () => {
        function spamTransferWithToken(token) {
            return [
                {
                    transaction_hash: "0x" + "9".repeat(64),
                    block_number: 21000070,
                    timestamp: TS,
                    from: { hash: ORDINARY_PEER },
                    to: { hash: VICTIM },
                    total: { value: "1500500000", decimals: "6" },
                    token: token,
                },
            ];
        }

        const OMITTED = {
            symbol: NOVEL_SPAM_SYMBOL,
            address_hash: NOVEL_SPAM_CONTRACT,
        };
        const NULLED = { ...OMITTED, holders_count: null };
        const ZERO = { ...OMITTED, holders_count: "0" };

        test("an omitted holders_count parses to null", async () => {
            respondWith([], spamTransferWithToken(OMITTED));
            const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
            expect(txs[0].holders).toBeNull();
        });

        test("a null holders_count parses to null", async () => {
            respondWith([], spamTransferWithToken(NULLED));
            const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
            expect(txs[0].holders).toBeNull();
        });

        test("the transfer survives the low-holder filter", async () => {
            respondWith([], spamTransferWithToken(OMITTED));
            const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
            expect(filterTransactions(txs, filters()).transactions).toEqual(
                txs,
            );
        });

        // The regression this fix could cause: a token that genuinely
        // reports zero holders must keep being filtered.  Unlike the fake
        // "ETH" fixture above, this symbol is not in the token list, so the
        // holder count is the only rule that can catch it.
        test('a reported holders_count of "0" still parses to 0 and is filtered', async () => {
            respondWith([], spamTransferWithToken(ZERO));
            const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
            expect(txs[0].holders).toBe(0);
            expect(filterTransactions(txs, filters()).transactions).toEqual([]);
        });
    });

    test("failed responses yield an empty list rather than throwing", async () => {
        debugFetch.mockImplementation(async () => ({
            ok: false,
            status: 502,
            statusText: "Bad Gateway",
            json: async () => {
                throw new Error("body must not be read on a failed response");
            },
        }));
        await expect(
            fetchRecentTransactions(VICTIM, BLOCKSCOUT),
        ).resolves.toEqual([]);
    });

    test("no test in this file performed a network request", () => {
        expect(global.fetch).not.toHaveBeenCalled();
    });
});
