// 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, 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 = { 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([]); }); }); // --------------------------------------------------------------------------- // 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]); }); 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(); }); });