Files
AutistMask/tests/transactions.test.js
clawbot f271bcd7b4
Some checks failed
check / check (push) Has been cancelled
fix: one transaction history row per value movement (closes #177)
2026-08-11 14:56:30 +02:00

1337 lines
48 KiB
JavaScript

// 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();
});
});