From 8dd056bc083e3941b6b734ef2de3a71937a924ba Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 9 Aug 2026 05:05:15 +0000 Subject: [PATCH] test: cover the address-poisoning filters in transactions.js (closes #160) src/shared/transactions.js had zero test coverage despite owning the four anti-poisoning filters that README.md:730-814 documents as a core security property: known token symbol verification, the 1,000-holder rule, the fraud contract blocklist, and the dust threshold. A regression in any of them does not crash, it just silently stops filtering, so the behaviour needs pinning down in both directions. Adds tests/transactions.test.js with fixtures built from the two real attacks cited in the README: the fake "Ethereum"/"ETH" token at 0xD05339f9 with zero holders, and the 1 gwei native dust transfer 0x2708ebdd from the look-alike sender 0xC3c6B3b4. Every filter is proven to work when on and to be bypassed when off, both thresholds are asserted at, just below, and just above their boundary, and legitimate traffic (a plain ETH transfer and genuine USDC and WETH transfers) is proven to survive all four rules. Also covers the per-address merge and dedup that fetchRecentTransactions owns, with debugFetch mocked at the module boundary. No test performs a network request: global.fetch is a throwing stub and is asserted never to have been called. Several tests are named as documenting current behaviour where it diverges from the README; no source file is modified here. --- TODO.md | 2 + tests/transactions.test.js | 1002 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1004 insertions(+) create mode 100644 tests/transactions.test.js diff --git a/TODO.md b/TODO.md index 0f0f652..3f1366f 100644 --- a/TODO.md +++ b/TODO.md @@ -37,6 +37,8 @@ review. launch-time canary aborts the suite if worker traffic starts escaping. - 2026-08-09: Reviewed the repo end to end and filed the 1.0.0 backlog (#149-#168). +- 2026-08-09: Test coverage for the address-poisoning defense in + `src/shared/transactions.js` (#160) - 2026-07-26: About well in settings with build info, repo link and the version click easter egg (#145); proper view navigation stack (#146). - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile 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(); + }); +});