diff --git a/TODO.md b/TODO.md index 5677a1b..6801fb7 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,8 @@ get review, merge to main. # Completed Steps +- 2026-08-09: Test coverage for the address-poisoning defense in + `src/shared/transactions.js` (#160) - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile shims, README Entrypoints section - 2026-03-01: About well in settings with build info and easter egg (in flight diff --git a/tests/transactions.test.js b/tests/transactions.test.js new file mode 100644 index 0000000..73bf7b7 --- /dev/null +++ b/tests/transactions.test.js @@ -0,0 +1,1002 @@ +// 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. +global.chrome = { storage: { local: {} } }; + +const { + fetchRecentTransactions, + filterTransactions, +} = 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 = { + 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")).toBe(USDC_CONTRACT); + expect(KNOWN_SYMBOLS.get("WETH")).toBe(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([]); + }); + + // Documents current behaviour, not desired behaviour: the spoof check + // compares tx.contractAddress against a lowercased known address with + // ===, so a caller passing a checksummed address for a genuine token has + // it treated as a spoof. In the app this cannot happen because + // parseTokenTransfer lowercases, but the exported function is not + // defensive about it the way the blocklist check is. + test("current behaviour: a checksummed genuine contract is treated as a spoof", () => { + const genuineButChecksummed = tokenTx({ + contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }); + const result = filterTransactions([genuineButChecksummed], filters()); + expect(result.transactions).toEqual([]); + }); + + // Documents current behaviour: README.md:810-814 says all four filters + // "default to on but can be individually disabled". There is no setting + // for known-symbol verification, and filterTransactions applies it + // unconditionally, so it cannot be turned off. + test("current behaviour: spoof filtering cannot be disabled by any setting", () => { + const allFiltersOff = { + hideLowHolderTokens: false, + hideFraudContracts: false, + hideDustTransactions: false, + dustThresholdGwei: 1, + fraudContracts: [], + }; + const result = filterTransactions( + [fakeEthTokenTransfer()], + allFiltersOff, + ); + expect(result.transactions).toEqual([]); + expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]); + }); + + test("current behaviour: spoof filtering also applies with no filters argument", () => { + const result = filterTransactions([fakeEthTokenTransfer()]); + expect(result.transactions).toEqual([]); + }); +}); + +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]); + }); +}); + +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]); + }); + + // Documents current behaviour: the threshold is read as + // `filters.dustThresholdGwei || 100000`, so a user who sets the threshold + // to 0 (the natural way to ask for no dust filtering while leaving the + // toggle on) silently gets the 100,000 gwei default instead. + test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => { + const result = filterTransactions( + [dustOf(50)], + filters({ dustThresholdGwei: 0 }), + ); + expect(result.transactions).toEqual([]); + }); +}); + +describe("filter defaults promised by the README and Settings", () => { + test("all three toggles default to on and the threshold to 100,000 gwei", () => { + 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 itself defaults every + // optional filter to off. The "default to on" promise is satisfied by + // the state defaults above, which every caller passes in; the pure + // function makes no assumption of its own. + 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([]); + }); +}); + +// --------------------------------------------------------------------------- +// 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"]); + }); + + // Documents current behaviour: 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 and the merge loop does not consolidate the token + // transfer into it. The result is two entries for one transaction: a + // zero-value native row and the real token row. The zero-value row also + // escapes dust filtering because isContractCall is true. + test("current behaviour: a plain ERC-20 transfer produces two entries", 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(2); + expect(txs.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]); + const nativeRow = txs.find((t) => t.symbol === "ETH"); + expect(nativeRow.exactValue).toBe("0.0"); + expect(nativeRow.isContractCall).toBe(true); + // And the zero-value row is not removed by the dust filter. + const kept = filterTransactions(txs, filters()).transactions; + expect(kept).toHaveLength(2); + }); + + 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]); + }); + + 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(); + }); +});