All checks were successful
check / check (push) Successful in 30s
The block explorer's holders_count is optional. Reading it as `holders_count || "0"` recorded a token the explorer said nothing about as a token with no holders at all, which is the strongest spam signal the wallet has: the low-holder rule then hid a legitimate transfer from the history and withheld a token the user actually holds from the Send selector. It also made the `tx.holders !== null` guard in filterTransactions unreachable for token transfers, since the coercion guaranteed a number. The null-versus-zero rule and the 1,000-holder threshold now live in one place, src/shared/holders.js, because the rule was open-coded at three call sites and got it wrong at all three. An unknown count is shown rather than hidden in both user-facing filters: hiding an asset the user owns costs more than showing a spam row they can see is unusual, and both filters have a setting behind them. The balance-list spam gate in fetchTokenBalances keeps its strict behaviour — it has no off switch and governs the whole balance list, so an unreported count is no evidence for admission — but it now records the unknown as null, so a token that reaches the list by being known or tracked is no longer hidden downstream by a zero it never reported. A reported count of zero still parses to 0 and is still filtered everywhere; that is covered by tests alongside the unknown-count ones.
1553 lines
57 KiB
JavaScript
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")).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([]);
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
});
|