Files
AutistMask/tests/transactions.test.js
clawbot c8e193335d
All checks were successful
check / check (push) Successful in 33s
fix: a shared ticker no longer hides one of its two real tokens (closes #276)
KNOWN_SYMBOLS maps a symbol to the set of contract addresses that bear
it, instead of to one of them.

A ticker is not unique, and the bundled list proves it: seven of its 512
tokens -- FRAX, REUSD, TON, EURE, MSUSD, MUSD and JPYC -- share a symbol
with another bundled entry at a different real contract.  The table is
built from that list first-wins, so it kept the earlier entry of each
pair and the later one was judged a spoof of its own symbol at its own
address.  A user holding any of the seven saw it filtered out of the
balance list, the transaction history and the send token selector, and
so could not spend it through the UI.

Both contracts of every pair come from the same source fetch (CoinGecko,
2026-02-27, decimals verified on-chain), so neither is stale relative to
the other and there is nothing to prefer between them.  The fix is
therefore in the shape of the table rather than in its contents: no
address was picked and none was dropped.  isSpoofedSymbol() asks set
membership where it asked equality, which does not loosen the rule --
every address in a set is one the wallet ships as a real token, and a
contract outside the set is still a spoof.  The native-asset entry stays
null and still means no contract may bear the symbol.

The suite walked KNOWN_SYMBOLS, which is derived from TOKENS, so it
could only assert that the table agreed with itself.  It now also walks
TOKENS asserting that no bundled token is filtered at its own address --
the walk that would have caught this -- pins both contracts of each of
the seven by address, asserts a third contract bearing a shared ticker
is still filtered, and asserts every address the table vouches for is a
bundled token reporting that symbol.
2026-08-12 11:14:29 +00:00

1553 lines
57 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 = {
hideSpoofedSymbols: true,
hideLowHolderTokens: true,
hideFraudContracts: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
fraudContracts: [],
};
function filters(overrides) {
return { ...DEFAULT_FILTERS, ...overrides };
}
// ---------------------------------------------------------------------------
// Fixture builders producing objects shaped exactly like the parsed entries
// that fetchRecentTransactions hands to filterTransactions.
// ---------------------------------------------------------------------------
// A native (non-token) transaction. valueGwei is set, contractAddress and
// holders are null, as parseTx produces.
function nativeTx(overrides = {}) {
return {
hash: "0x" + "a".repeat(64),
blockNumber: 21000000,
timestamp: 1740657600,
from: ORDINARY_PEER,
to: VICTIM,
value: "0.0500",
exactValue: "0.05",
rawAmount: "50000000000000000",
rawUnit: "wei",
valueGwei: 50000000,
symbol: "ETH",
direction: "received",
directionLabel: "Received",
isError: false,
contractAddress: null,
holders: null,
isContractCall: false,
method: null,
...overrides,
};
}
// An ERC-20 transfer entry. contractAddress is lowercased and holders is a
// number, as parseTokenTransfer produces.
function tokenTx(overrides = {}) {
return {
hash: "0x" + "b".repeat(64),
blockNumber: 21000000,
timestamp: 1740657600,
from: ORDINARY_PEER,
to: VICTIM,
value: "1500.5000",
exactValue: "1500.5",
rawAmount: "1500500000",
rawUnit: "USDC base units (10^-6)",
valueGwei: null,
symbol: "USDC",
direction: "received",
directionLabel: "Received",
isError: false,
contractAddress: USDC_CONTRACT,
holders: 3500000,
...overrides,
};
}
// Attack 1: the fake "Ethereum"/"ETH" token transfer claiming the victim sent
// 0.005 "ETH" to the look-alike scam address.
function fakeEthTokenTransfer() {
return tokenTx({
hash: FAKE_ETH_TRANSFER_HASH,
from: VICTIM,
to: TOKEN_SCAM_LOOKALIKE,
value: "0.0050",
exactValue: "0.005",
rawAmount: "5000000000000000",
rawUnit: "ETH base units (10^-18)",
symbol: "ETH",
direction: "sent",
directionLabel: "Sent",
contractAddress: FAKE_ETH_CONTRACT,
holders: 0,
});
}
// Attack 2: the real 1 gwei native transfer from the look-alike address.
function nativeDustTransfer() {
return nativeTx({
hash: DUST_TX_HASH,
from: DUST_SENDER_LOOKALIKE,
to: VICTIM,
value: "0.0000",
exactValue: "0.000000001",
rawAmount: "1000000000",
valueGwei: 1,
direction: "received",
directionLabel: "Received",
});
}
// The legitimate 0.005 ETH send that the attacks piggybacked on.
function legitimateEthSend() {
return nativeTx({
hash: "0x" + "c".repeat(64),
from: VICTIM,
to: LEGIT_RECIPIENT,
value: "0.0050",
exactValue: "0.005",
rawAmount: "5000000000000000",
valueGwei: 5000000,
direction: "sent",
directionLabel: "Sent",
});
}
function hashesOf(result) {
return result.transactions.map((tx) => tx.hash);
}
// ---------------------------------------------------------------------------
describe("token list assumptions the fixtures rely on", () => {
test('"ETH" is a known symbol with no legitimate ERC-20 contract', () => {
expect(KNOWN_SYMBOLS.has("ETH")).toBe(true);
expect(KNOWN_SYMBOLS.get("ETH")).toBeNull();
});
test("USDC and WETH map to their genuine lowercased contracts", () => {
expect([...KNOWN_SYMBOLS.get("USDC")]).toEqual([USDC_CONTRACT]);
expect([...KNOWN_SYMBOLS.get("WETH")]).toEqual([WETH_CONTRACT]);
});
test("the spam fixture symbol is not in the known token list", () => {
expect(KNOWN_SYMBOLS.has(NOVEL_SPAM_SYMBOL)).toBe(false);
});
});
describe("the real attacks documented in README.md:730-814", () => {
test('the fake "Ethereum"/"ETH" token transfer is filtered by default', () => {
const attack = fakeEthTokenTransfer();
const result = filterTransactions([attack], filters());
expect(result.transactions).toEqual([]);
});
test("the fake token contract is reported as a newly found fraud contract", () => {
const result = filterTransactions([fakeEthTokenTransfer()], filters());
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
test("the 1 gwei native dust transfer is filtered by default", () => {
const result = filterTransactions([nativeDustTransfer()], filters());
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([]);
});
test("both attacks are removed while the genuine send survives", () => {
const legit = legitimateEthSend();
const result = filterTransactions(
[fakeEthTokenTransfer(), legit, nativeDustTransfer()],
filters(),
);
expect(hashesOf(result)).toEqual([legit.hash]);
});
});
describe("known-symbol spoof verification", () => {
test("a known symbol from a non-matching contract is spoofed", () => {
const spoof = tokenTx({
symbol: "USDC",
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 5000000,
});
const result = filterTransactions([spoof], filters());
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([NOVEL_SPAM_CONTRACT]);
});
test("the genuine contract for that symbol is not spoofed", () => {
const genuine = tokenTx();
const result = filterTransactions([genuine], filters());
expect(result.transactions).toEqual([genuine]);
expect(result.newFraudContracts).toEqual([]);
});
test("an unknown symbol from an unknown contract is not flagged by this check", () => {
const unknown = tokenTx({
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 25000,
});
const result = filterTransactions([unknown], filters());
expect(result.transactions).toEqual([unknown]);
expect(result.newFraudContracts).toEqual([]);
});
test('"ETH" as an ERC-20 is spoofed no matter which contract emits it', () => {
// KNOWN_SYMBOLS maps "ETH" to null: there is no legitimate ERC-20
// "ETH", so even the real WETH contract claiming it is a spoof.
const fromWeth = tokenTx({
symbol: "ETH",
contractAddress: WETH_CONTRACT,
holders: 900000,
});
const result = filterTransactions([fromWeth], filters());
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([WETH_CONTRACT]);
});
test("symbol comparison is case-insensitive", () => {
const spoof = tokenTx({
symbol: "usdc",
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 5000000,
});
expect(filterTransactions([spoof], filters()).transactions).toEqual([]);
});
test("a native transaction has no contract and is never spoof-filtered", () => {
const native = nativeTx({ symbol: "ETH" });
const result = filterTransactions([native], filters());
expect(result.transactions).toEqual([native]);
expect(result.newFraudContracts).toEqual([]);
});
test("a repeated fraud contract is reported only once", () => {
const result = filterTransactions(
[fakeEthTokenTransfer(), fakeEthTokenTransfer()],
filters(),
);
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
test("an already-known fraud contract is not reported as new", () => {
const result = filterTransactions(
[fakeEthTokenTransfer()],
filters({ fraudContracts: [FAKE_ETH_CONTRACT] }),
);
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([]);
});
test("an already-known fraud contract given in checksummed form is not reported as new", () => {
const result = filterTransactions(
[fakeEthTokenTransfer()],
filters({
fraudContracts: ["0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82"],
}),
);
expect(result.newFraudContracts).toEqual([]);
});
// Regression guard (#179): EIP-55 mixed case is a checksum over the
// address, not part of its identity, so the contract comparison must be
// case-insensitive in both directions — a genuine token in any casing is
// genuine, and a spoof cannot escape detection by changing its casing.
test("a genuine contract in all-lowercase form is not a spoof", () => {
const tx = tokenTx({ contractAddress: USDC_CONTRACT });
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
test("a genuine contract in EIP-55 checksummed form is not a spoof", () => {
const tx = tokenTx({
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
});
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([tx]);
expect(result.newFraudContracts).toEqual([]);
});
test("a genuine contract in all-uppercase form is not a spoof", () => {
const tx = tokenTx({
contractAddress: "0X" + USDC_CONTRACT.slice(2).toUpperCase(),
});
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([tx]);
expect(result.newFraudContracts).toEqual([]);
});
test("a genuinely different contract claiming USDC is still a spoof in any casing", () => {
const tx = tokenTx({
contractAddress: "0xD05339F9EA5AB9D9F03B9D57F671D2ABD1F55C82",
});
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([]);
// The recorded fraud contract is normalised, so the persisted
// blocklist matches later transfers whatever casing they arrive in.
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
// Turning the other three filters off must not turn this one off: each
// filter is independent, and this is the one the README calls out as the
// defense against the fake "ETH" attack.
test("the check still runs when the other three filters are off", () => {
const result = filterTransactions(
[fakeEthTokenTransfer()],
filters({
hideLowHolderTokens: false,
hideFraudContracts: false,
hideDustTransactions: false,
dustThresholdGwei: 1,
}),
);
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
test("spoof filtering also applies with no filters argument", () => {
const result = filterTransactions([fakeEthTokenTransfer()]);
expect(result.transactions).toEqual([]);
});
// Fail-safe: unlike the other three flags, an absent hideSpoofedSymbols
// leaves the check ON. A caller that forgets the key keeps the wallet's
// headline protection; only a user who deliberately switched the setting
// off sends an explicit false.
test("an absent hideSpoofedSymbols leaves the check on", () => {
const result = filterTransactions([fakeEthTokenTransfer()], {
fraudContracts: [],
});
expect(result.transactions).toEqual([]);
});
test("a truthy-but-not-true hideSpoofedSymbols leaves the check on", () => {
const result = filterTransactions(
[fakeEthTokenTransfer()],
filters({ hideSpoofedSymbols: undefined }),
);
expect(result.transactions).toEqual([]);
});
});
describe("disabling known-symbol spoof verification", () => {
test("the spoofed transfer is shown when hideSpoofedSymbols is false", () => {
const attack = fakeEthTokenTransfer();
const result = filterTransactions(
[attack],
filters({
hideSpoofedSymbols: false,
// The blocklist rule would otherwise hide the same row via a
// contract this pass had already learned.
hideFraudContracts: false,
hideLowHolderTokens: false,
}),
);
expect(result.transactions).toEqual([attack]);
});
// The blocklist is populated only by this check, so switching the check
// off stops the learning too. Leaving learning on would make the setting
// a no-op: the contract it recorded would immediately hide the same row
// through the fraud-contract rule, which is on by default.
test("no fraud contract is learned when hideSpoofedSymbols is false", () => {
const result = filterTransactions(
[fakeEthTokenTransfer()],
filters({ hideSpoofedSymbols: false }),
);
expect(result.newFraudContracts).toEqual([]);
});
test("the setting off does not stop the other three rules", () => {
const dust = nativeDustTransfer();
const lowHolder = tokenTx({
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 0,
});
const result = filterTransactions(
[dust, lowHolder],
filters({ hideSpoofedSymbols: false }),
);
expect(result.transactions).toEqual([]);
});
// An already-persisted fraud contract keeps being filtered: the blocklist
// rule is a separate setting and is unaffected by this one.
test("an already-blocklisted contract is still hidden with the check off", () => {
const result = filterTransactions(
[fakeEthTokenTransfer()],
filters({
hideSpoofedSymbols: false,
fraudContracts: [FAKE_ETH_CONTRACT],
}),
);
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([]);
});
test("a genuine transfer is unaffected by the setting either way", () => {
const tx = tokenTx();
expect(
filterTransactions([tx], filters({ hideSpoofedSymbols: false }))
.transactions,
).toEqual([tx]);
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
});
describe("low-holder token filtering (the 1,000-holder rule)", () => {
function spamWithHolders(holders) {
return tokenTx({
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: NOVEL_SPAM_CONTRACT,
holders: holders,
});
}
test("a zero-holder poisoning token is filtered", () => {
const result = filterTransactions([spamWithHolders(0)], filters());
expect(result.transactions).toEqual([]);
});
test("boundary: 999 holders is filtered", () => {
const result = filterTransactions([spamWithHolders(999)], filters());
expect(result.transactions).toEqual([]);
});
test("boundary: exactly 1000 holders is kept", () => {
const tx = spamWithHolders(1000);
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
test("boundary: 1001 holders is kept", () => {
const tx = spamWithHolders(1001);
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
test("the rule is bypassed when hideLowHolderTokens is off", () => {
const tx = spamWithHolders(0);
const result = filterTransactions(
[tx],
filters({ hideLowHolderTokens: false }),
);
expect(result.transactions).toEqual([tx]);
});
test("native transactions have no holder count and are unaffected", () => {
const tx = legitimateEthSend();
expect(tx.holders).toBeNull();
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
// Regression guard (#179): an unknown holder count on a real token — the
// explorer rate-limited the call, or a self-hosted instance omits the
// field — must not be read as zero holders. Reading it that way hides a
// legitimate transfer from the user's history, the same over-filtering
// harm as the zero-threshold bug. This pins the `tx.holders !== null`
// guard, which no fixture previously reached.
test("a token whose holder count is unknown is not filtered", () => {
const tx = tokenTx({
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: NOVEL_SPAM_CONTRACT,
holders: null,
});
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
});
describe("fraud contract blocklist", () => {
function blocklistedTx() {
return tokenTx({
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 50000,
});
}
test("a transfer from a blocklisted contract is filtered", () => {
const result = filterTransactions(
[blocklistedTx()],
filters({ fraudContracts: [NOVEL_SPAM_CONTRACT] }),
);
expect(result.transactions).toEqual([]);
});
test("the blocklist is matched case-insensitively", () => {
const result = filterTransactions(
[blocklistedTx()],
filters({
fraudContracts: [NOVEL_SPAM_CONTRACT.toUpperCase()],
}),
);
expect(result.transactions).toEqual([]);
});
test("the blocklist is bypassed when hideFraudContracts is off", () => {
const tx = blocklistedTx();
const result = filterTransactions(
[tx],
filters({
hideFraudContracts: false,
fraudContracts: [NOVEL_SPAM_CONTRACT],
}),
);
expect(result.transactions).toEqual([tx]);
});
test("a contract not on the blocklist is unaffected", () => {
const tx = blocklistedTx();
const result = filterTransactions(
[tx],
filters({ fraudContracts: [FAKE_ETH_CONTRACT] }),
);
expect(result.transactions).toEqual([tx]);
});
test("native transactions are never blocklist-filtered", () => {
const tx = legitimateEthSend();
const result = filterTransactions(
[tx],
filters({ fraudContracts: [FAKE_ETH_CONTRACT] }),
);
expect(result.transactions).toEqual([tx]);
});
test("a contract caught spoofing earlier in the batch blocks its later transfers", () => {
// The fake "Ethereum" contract is detected as a spoof, added to the
// working blocklist, and its later transfer under a novel symbol is
// then filtered by the blocklist rule rather than the spoof rule.
const later = tokenTx({
hash: "0x" + "d".repeat(64),
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: FAKE_ETH_CONTRACT,
holders: 50000,
});
const result = filterTransactions(
[fakeEthTokenTransfer(), later],
filters(),
);
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
test("that later transfer survives when hideFraudContracts is off", () => {
const later = tokenTx({
hash: "0x" + "d".repeat(64),
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: FAKE_ETH_CONTRACT,
holders: 50000,
});
const result = filterTransactions(
[fakeEthTokenTransfer(), later],
filters({ hideFraudContracts: false }),
);
expect(hashesOf(result)).toEqual([later.hash]);
});
});
describe("dust threshold filtering", () => {
function dustOf(gwei) {
return nativeTx({ valueGwei: gwei });
}
test("boundary: 99,999 gwei is filtered at the 100,000 gwei default", () => {
const result = filterTransactions([dustOf(99999)], filters());
expect(result.transactions).toEqual([]);
});
test("boundary: exactly 100,000 gwei is kept", () => {
const tx = dustOf(100000);
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
test("boundary: 100,001 gwei is kept", () => {
const tx = dustOf(100001);
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
test("the 100,000 gwei default applies when no threshold is supplied", () => {
const below = dustOf(99999);
const at = dustOf(100000);
const opts = {
hideLowHolderTokens: true,
hideFraudContracts: true,
hideDustTransactions: true,
fraudContracts: [],
};
expect(filterTransactions([below], opts).transactions).toEqual([]);
expect(filterTransactions([at], opts).transactions).toEqual([at]);
});
test("a user-raised threshold is honoured on both sides", () => {
const opts = filters({ dustThresholdGwei: 5000000 });
const below = dustOf(4999999);
const at = dustOf(5000000);
expect(filterTransactions([below], opts).transactions).toEqual([]);
expect(filterTransactions([at], opts).transactions).toEqual([at]);
});
test("dust filtering is bypassed when hideDustTransactions is off", () => {
const tx = nativeDustTransfer();
const result = filterTransactions(
[tx],
filters({ hideDustTransactions: false }),
);
expect(result.transactions).toEqual([tx]);
});
test("zero-value contract calls are never treated as dust", () => {
const approve = nativeTx({
hash: "0x" + "e".repeat(64),
valueGwei: 0,
value: "",
exactValue: "",
direction: "contract",
directionLabel: "Approve",
isContractCall: true,
method: "approve",
});
expect(filterTransactions([approve], filters()).transactions).toEqual([
approve,
]);
});
test("token transfers carry no gwei value and are never dust-filtered", () => {
const tx = tokenTx({ valueGwei: null });
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
// Regression guard (#179): 0 is a real threshold meaning "hide nothing",
// not an absent one. It used to be swallowed by `|| 100000`, so the one
// value a user would pick to see everything was the one that did not
// work.
test("a threshold of 0 hides nothing, leaving the toggle on", () => {
const dust = dustOf(50);
const zero = dustOf(0);
const opts = filters({ dustThresholdGwei: 0 });
expect(filterTransactions([dust], opts).transactions).toEqual([dust]);
expect(filterTransactions([zero], opts).transactions).toEqual([zero]);
});
test("a threshold of 0 agrees with clearing the hide-dust checkbox", () => {
const tx = nativeDustTransfer();
const thresholdZero = filterTransactions(
[tx],
filters({ dustThresholdGwei: 0 }),
);
const toggleOff = filterTransactions(
[tx],
filters({ hideDustTransactions: false }),
);
expect(thresholdZero.transactions).toEqual([tx]);
expect(toggleOff.transactions).toEqual([tx]);
});
test("0, unset and a set threshold are three distinct behaviours", () => {
const tx = dustOf(50);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: 0 }))
.transactions,
).toEqual([tx]);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: undefined }))
.transactions,
).toEqual([]);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: 40 }))
.transactions,
).toEqual([tx]);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: 60 }))
.transactions,
).toEqual([]);
});
});
describe("filter defaults promised by the README and Settings", () => {
test("all four toggles default to on and the threshold to 100,000 gwei", () => {
expect(state.hideSpoofedSymbols).toBe(true);
expect(state.hideLowHolderTokens).toBe(true);
expect(state.hideFraudContracts).toBe(true);
expect(state.hideDustTransactions).toBe(true);
expect(state.dustThresholdGwei).toBe(100000);
});
test("the fraud contract list starts empty", () => {
expect(state.fraudContracts).toEqual([]);
});
// Documents current behaviour: filterTransactions defaults the other three
// optional filters to off. Their "default to on" promise is satisfied by
// the state defaults above, which every caller passes in. Spoof
// verification is the exception and stays on unless explicitly disabled.
test("current behaviour: with no filters argument only spoof filtering runs", () => {
const dust = nativeDustTransfer();
const lowHolder = tokenTx({
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 0,
});
const result = filterTransactions([dust, lowHolder]);
expect(hashesOf(result)).toEqual([dust.hash, lowHolder.hash]);
});
});
describe("legitimate transactions are never filtered", () => {
test("a plain ETH transfer of an ordinary amount survives all four rules", () => {
const tx = nativeTx();
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([tx]);
expect(result.newFraudContracts).toEqual([]);
});
test("a genuine high-holder USDC transfer survives all four rules", () => {
const tx = tokenTx();
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([tx]);
expect(result.newFraudContracts).toEqual([]);
});
test("a genuine WETH transfer survives all four rules", () => {
const tx = tokenTx({
hash: "0x" + "f".repeat(64),
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
value: "1.2500",
exactValue: "1.25",
});
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([tx]);
});
test("a mixed history keeps exactly the legitimate entries, in order", () => {
const ethIn = nativeTx();
const usdcIn = tokenTx();
const ethOut = legitimateEthSend();
const result = filterTransactions(
[
fakeEthTokenTransfer(),
ethIn,
nativeDustTransfer(),
usdcIn,
tokenTx({
hash: "0x" + "9".repeat(64),
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 0,
}),
ethOut,
],
filters(),
);
expect(hashesOf(result)).toEqual([
ethIn.hash,
usdcIn.hash,
ethOut.hash,
]);
});
test("surviving entries are the same objects, unmodified", () => {
const tx = tokenTx();
const before = JSON.stringify(tx);
const result = filterTransactions([tx], filters());
expect(result.transactions[0]).toBe(tx);
expect(JSON.stringify(tx)).toBe(before);
});
test("an empty history yields empty results", () => {
const result = filterTransactions([], filters());
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// mergeTransactions is the pure core of the merge: it takes parsed native
// entries and parsed token transfers and decides how many rows one on-chain
// transaction becomes. One transaction is one row per distinct value
// movement, so the native side of a plain ERC-20 transfer must not survive
// next to its token row (the duplicate-row bug), while a hash that really
// did move several things must keep a row for each.
// ---------------------------------------------------------------------------
// A native entry as parseTx produces it for a decoded contract call: the
// amount fields are blanked and direction is "contract".
function contractCallTx(overrides = {}) {
return nativeTx({
from: VICTIM,
to: USDC_CONTRACT,
value: "",
exactValue: "",
rawAmount: "",
rawUnit: "",
valueGwei: 0,
direction: "contract",
directionLabel: "Approve",
isContractCall: true,
method: "approve",
...overrides,
});
}
// The native entry parseTx produces for a plain ERC-20 transfer: sent to the
// token contract, no ETH, and method "transfer", which is exactly why it is
// not marked as a display-level contract call.
function erc20CallTx(overrides = {}) {
return nativeTx({
from: VICTIM,
to: USDC_CONTRACT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
method: "transfer",
...overrides,
});
}
describe("mergeTransactions: one row per value movement", () => {
const HASH = "0x" + "d".repeat(64);
const OTHER_HASH = "0x" + "e".repeat(64);
const ROUTER = "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad";
test("a plain ERC-20 transfer yields one row, the token row", () => {
const native = erc20CallTx({ hash: HASH });
const token = tokenTx({
hash: HASH,
from: VICTIM,
to: ORDINARY_PEER,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
});
test("an ETH-only transfer keeps its row unchanged", () => {
const merged = mergeTransactions([legitimateEthSend()], []);
expect(merged).toHaveLength(1);
expect(merged[0]).toEqual(legitimateEthSend());
});
test("a genuine zero-value native transaction is still displayed", () => {
const zero = nativeTx({
hash: HASH,
from: VICTIM,
to: ORDINARY_PEER,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([zero], []);
expect(merged).toEqual([zero]);
});
test("a zero-value native row is only absorbed by a transfer sharing its hash", () => {
const zero = erc20CallTx({ hash: HASH });
const unrelated = tokenTx({ hash: OTHER_HASH });
const merged = mergeTransactions([zero], [unrelated]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.hash).sort()).toEqual(
[HASH, OTHER_HASH].sort(),
);
});
test("a native transaction that moved ETH keeps its row beside the token row", () => {
// An undecoded call (no method name) carrying ETH that also emitted
// a token transfer: two real movements, so two rows.
const native = nativeTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
valueGwei: 250000000,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
});
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
});
test("a sub-gwei ETH movement keeps its row beside the token row", () => {
// 500000000 wei is 0.5 gwei, so parseTx's valueGwei floors to 0 while
// rawAmount stays nonzero. Deciding "moved no ETH" on valueGwei would
// delete this row and lose a real ETH movement, so the decision is made
// on rawAmount as a BigInt.
const native = nativeTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.0000",
exactValue: "0.0000000005",
rawAmount: "500000000",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
});
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
expect(merged.find((t) => t.symbol === "ETH").rawAmount).toBe(
"500000000",
);
});
test("a swap consolidates every token leg into one row, preferring the received leg", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "execute",
});
const sentLeg = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
direction: "sent",
directionLabel: "Sent",
});
const receivedLeg = tokenTx({
hash: HASH,
from: ROUTER,
to: VICTIM,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
rawUnit: "WETH base units (10^-18)",
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
});
const merged = mergeTransactions([native], [sentLeg, receivedLeg]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("WETH");
expect(merged[0].exactValue).toBe("0.25");
// The user's own address and the contract called are preserved.
expect(merged[0].from).toBe(VICTIM);
expect(merged[0].to).toBe(ROUTER);
expect(merged[0].directionLabel).toBe("Swap");
});
test("a swap whose legs are all sent takes its amount from the first sent leg", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "execute",
});
const firstSent = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
direction: "sent",
directionLabel: "Sent",
});
const secondSent = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
rawUnit: "WETH base units (10^-18)",
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [firstSent, secondSent]);
expect(merged).toHaveLength(1);
// With no received leg the display amount comes from the first sent
// leg, and a later sent leg does not overwrite it.
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
expect(merged[0].holders).toBe(3500000);
});
test("a contract call carrying ETH plus a token transfer stays one row", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "swapExactETHForTokens",
valueGwei: 250000000,
});
const received = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [received]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
// The ETH leg is still visible as the row's native quantity.
expect(merged[0].valueGwei).toBe(250000000);
});
test("an approve keeps its row and survives the filters", () => {
const approve = contractCallTx({ hash: HASH });
const merged = mergeTransactions([approve], []);
expect(merged).toEqual([approve]);
expect(filterTransactions(merged, filters()).transactions).toEqual([
approve,
]);
});
test("a contract creation keeps its row", () => {
const creation = nativeTx({
hash: HASH,
from: VICTIM,
to: "",
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
});
expect(mergeTransactions([creation], [])).toEqual([creation]);
});
test("a native self-send keeps its single row", () => {
const selfSend = nativeTx({
hash: HASH,
from: VICTIM,
to: VICTIM,
direction: "sent",
directionLabel: "Sent",
});
expect(mergeTransactions([selfSend], [])).toEqual([selfSend]);
});
test("a token self-send yields one row", () => {
const native = erc20CallTx({ hash: HASH });
const token = tokenTx({
hash: HASH,
from: VICTIM,
to: VICTIM,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].from).toBe(VICTIM);
expect(merged[0].to).toBe(VICTIM);
});
test("several distinct tokens moved by one ERC-20 call keep a row each", () => {
const native = erc20CallTx({ hash: HASH });
const usdc = tokenTx({ hash: HASH });
const weth = tokenTx({
hash: HASH,
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
});
const merged = mergeTransactions([native], [usdc, weth]);
expect(merged.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
test("rows are sorted by block number, newest first", () => {
const older = nativeTx({ hash: HASH, blockNumber: 21000000 });
const newer = nativeTx({ hash: OTHER_HASH, blockNumber: 21000010 });
const merged = mergeTransactions([older, newer], []);
expect(merged.map((t) => t.blockNumber)).toEqual([21000010, 21000000]);
});
test("the entries handed in are never mutated", () => {
const native = contractCallTx({ hash: HASH, method: "execute" });
const token = tokenTx({ hash: HASH });
const before = JSON.stringify([native, token]);
mergeTransactions([native], [token]);
expect(JSON.stringify([native, token])).toBe(before);
});
});
// ---------------------------------------------------------------------------
// fetchRecentTransactions owns the per-address merge of normal transactions
// with ERC-20 transfers. (The cross-address merge Home performs lives in
// src/popup/views/home.js.) debugFetch is mocked, so these tests exercise the
// merge logic against static fixture payloads with no network involved.
// ---------------------------------------------------------------------------
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const TS = "2026-02-27T12:00:00.000Z";
const TS_EPOCH = Math.floor(Date.parse(TS) / 1000);
function respondWith(txItems, tokenTransferItems) {
debugFetch.mockImplementation(async (url) => ({
ok: true,
status: 200,
statusText: "OK",
json: async () =>
url.includes("/token-transfers")
? { items: tokenTransferItems }
: { items: txItems },
}));
}
describe("fetchRecentTransactions merge and dedup", () => {
beforeEach(() => {
debugFetch.mockReset();
});
test("queries only the two Blockscout endpoints for the address", async () => {
respondWith([], []);
await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(debugFetch).toHaveBeenCalledTimes(2);
const urls = debugFetch.mock.calls.map((c) => c[0]);
expect(urls).toContain(
BLOCKSCOUT + "/addresses/" + VICTIM + "/transactions",
);
expect(urls).toContain(
BLOCKSCOUT +
"/addresses/" +
VICTIM +
"/token-transfers?type=ERC-20",
);
});
test("a swap consolidates its token transfers into the single tx entry", async () => {
const hash = "0x" + "1".repeat(64);
respondWith(
[
{
hash: hash,
block_number: 21000010,
timestamp: TS,
from: { hash: VICTIM },
to: {
hash: "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad",
is_contract: true,
},
value: "0",
method: "execute",
status: "ok",
},
],
[
{
transaction_hash: hash,
block_number: 21000010,
timestamp: TS,
from: { hash: VICTIM },
to: { hash: "0x66a9893cc07d91d95644aedd05d03f95e1dba8af" },
total: { value: "1500500000", decimals: "6" },
token: {
symbol: "USDC",
address_hash: USDC_CONTRACT,
holders_count: "3500000",
},
},
{
transaction_hash: hash,
block_number: 21000010,
timestamp: TS,
from: {
hash: "0x66a9893cc07d91d95644aedd05d03f95e1dba8af",
},
to: { hash: VICTIM },
total: { value: "250000000000000000", decimals: "18" },
token: {
symbol: "WETH",
address_hash: WETH_CONTRACT,
holders_count: "850000",
},
},
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1);
const merged = txs[0];
// The received leg (the swap output) supplies the display amount.
expect(merged.symbol).toBe("WETH");
expect(merged.value).toBe("0.2500");
expect(merged.contractAddress).toBe(WETH_CONTRACT);
expect(merged.holders).toBe(850000);
// The user's own address and the contract they called are preserved,
// not the router addresses from the token transfer legs.
expect(merged.from).toBe(VICTIM);
expect(merged.to).toBe("0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad");
expect(merged.direction).toBe("contract");
expect(merged.directionLabel).toBe("Swap");
expect(merged.timestamp).toBe(TS_EPOCH);
});
test("a token transfer with no matching transaction gets its own entry", async () => {
respondWith(
[],
[
{
transaction_hash: "0x" + "2".repeat(64),
block_number: 21000020,
timestamp: TS,
from: { hash: ORDINARY_PEER },
to: { hash: VICTIM },
total: { value: "1500500000", decimals: "6" },
token: {
symbol: "USDC",
address_hash: USDC_CONTRACT,
holders_count: "3500000",
},
},
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC");
expect(txs[0].value).toBe("1500.5000");
expect(txs[0].direction).toBe("received");
expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
expect(txs[0].holders).toBe(3500000);
});
test("two transfers of the same token in one transaction collapse to one entry", async () => {
const hash = "0x" + "3".repeat(64);
const leg = (value) => ({
transaction_hash: hash,
block_number: 21000030,
timestamp: TS,
from: { hash: ORDINARY_PEER },
to: { hash: VICTIM },
total: { value: value, decimals: "6" },
token: {
symbol: "USDC",
address_hash: USDC_CONTRACT,
holders_count: "3500000",
},
});
respondWith([], [leg("1000000"), leg("2000000")]);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1);
// Keyed by hash plus contract, so the later leg wins.
expect(txs[0].exactValue).toBe("2.0");
});
test("two different tokens in one transaction stay as separate entries", async () => {
const hash = "0x" + "4".repeat(64);
respondWith(
[],
[
{
transaction_hash: hash,
block_number: 21000040,
timestamp: TS,
from: { hash: ORDINARY_PEER },
to: { hash: VICTIM },
total: { value: "1000000", decimals: "6" },
token: {
symbol: "USDC",
address_hash: USDC_CONTRACT,
holders_count: "3500000",
},
},
{
transaction_hash: hash,
block_number: 21000040,
timestamp: TS,
from: { hash: ORDINARY_PEER },
to: { hash: VICTIM },
total: { value: "1000000000000000000", decimals: "18" },
token: {
symbol: "WETH",
address_hash: WETH_CONTRACT,
holders_count: "850000",
},
},
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
// Regression guard for the duplicate-row bug: for a plain ERC-20
// transfer the method is "transfer", so parseTx does not mark the entry
// as a contract call in the display sense. The native side of that
// transaction moved no ETH and is represented by the token row, so it
// must not survive the merge as a second, zero-value row.
test("a plain ERC-20 transfer produces exactly one entry", async () => {
const hash = "0x" + "5".repeat(64);
respondWith(
[
{
hash: hash,
block_number: 21000050,
timestamp: TS,
from: { hash: VICTIM },
to: { hash: USDC_CONTRACT, is_contract: true },
value: "0",
method: "transfer",
status: "ok",
},
],
[
{
transaction_hash: hash,
block_number: 21000050,
timestamp: TS,
from: { hash: VICTIM },
to: { hash: ORDINARY_PEER },
total: { value: "1000000", decimals: "6" },
token: {
symbol: "USDC",
address_hash: USDC_CONTRACT,
holders_count: "3500000",
},
},
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC");
expect(txs[0].exactValue).toBe("1.0");
expect(txs[0].direction).toBe("sent");
expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
// The surviving row is the token row, and the filters keep it.
const kept = filterTransactions(txs, filters()).transactions;
expect(kept).toHaveLength(1);
expect(kept[0].symbol).toBe("USDC");
});
test("entries are sorted by block number descending and capped at count", async () => {
const item = (n) => ({
hash: "0x" + String(n).repeat(64),
block_number: 21000000 + n,
timestamp: TS,
from: { hash: ORDINARY_PEER },
to: { hash: VICTIM, is_contract: false },
value: "1000000000000000000",
method: null,
status: "ok",
});
respondWith([item(6), item(8), item(7)], []);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, 2);
expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]);
});
test("the fake token transfer survives fetching and is then filtered", async () => {
respondWith(
[],
[
{
transaction_hash: FAKE_ETH_TRANSFER_HASH,
block_number: 21000060,
timestamp: TS,
from: { hash: VICTIM },
to: { hash: TOKEN_SCAM_LOOKALIKE },
total: { value: "5000000000000000", decimals: "18" },
token: {
symbol: "ETH",
address_hash: FAKE_ETH_CONTRACT,
holders_count: "0",
},
},
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1);
expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT);
expect(txs[0].holders).toBe(0);
const result = filterTransactions(txs, filters());
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
// Regression guards (#230): the explorer's holders_count is optional. A
// missing field means the count is unknown; it does not mean the token
// has no holders. Recording the two as the same number both hides a
// legitimate token and makes the `holders !== null` guard in
// filterTransactions unreachable for token transfers.
describe("an unreported holders_count is unknown, not zero", () => {
function spamTransferWithToken(token) {
return [
{
transaction_hash: "0x" + "9".repeat(64),
block_number: 21000070,
timestamp: TS,
from: { hash: ORDINARY_PEER },
to: { hash: VICTIM },
total: { value: "1500500000", decimals: "6" },
token: token,
},
];
}
const OMITTED = {
symbol: NOVEL_SPAM_SYMBOL,
address_hash: NOVEL_SPAM_CONTRACT,
};
const NULLED = { ...OMITTED, holders_count: null };
const ZERO = { ...OMITTED, holders_count: "0" };
test("an omitted holders_count parses to null", async () => {
respondWith([], spamTransferWithToken(OMITTED));
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs[0].holders).toBeNull();
});
test("a null holders_count parses to null", async () => {
respondWith([], spamTransferWithToken(NULLED));
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs[0].holders).toBeNull();
});
test("the transfer survives the low-holder filter", async () => {
respondWith([], spamTransferWithToken(OMITTED));
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(filterTransactions(txs, filters()).transactions).toEqual(
txs,
);
});
// The regression this fix could cause: a token that genuinely
// reports zero holders must keep being filtered. Unlike the fake
// "ETH" fixture above, this symbol is not in the token list, so the
// holder count is the only rule that can catch it.
test('a reported holders_count of "0" still parses to 0 and is filtered', async () => {
respondWith([], spamTransferWithToken(ZERO));
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs[0].holders).toBe(0);
expect(filterTransactions(txs, filters()).transactions).toEqual([]);
});
});
test("failed responses yield an empty list rather than throwing", async () => {
debugFetch.mockImplementation(async () => ({
ok: false,
status: 502,
statusText: "Bad Gateway",
json: async () => {
throw new Error("body must not be read on a failed response");
},
}));
await expect(
fetchRecentTransactions(VICTIM, BLOCKSCOUT),
).resolves.toEqual([]);
});
test("no test in this file performed a network request", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
});