Some checks failed
check / check (push) Has been cancelled
47 tests over src/shared/transactions.js: both real address-poisoning attacks as fixtures, each of the four filters on and off, threshold boundaries, no false positives, and the per-address merge/dedup path. No source file changed.
1003 lines
37 KiB
JavaScript
1003 lines
37 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,
|
|
} = require("../src/shared/transactions");
|
|
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
|
|
const { debugFetch } = require("../src/shared/log");
|
|
const { state } = require("../src/shared/state");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Addresses and hashes from the two attacks documented in README.md:730-814.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The fake "Ethereum" token with symbol "ETH" and zero holders
|
|
// (README.md:735-750).
|
|
const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
|
|
// The fraudulent Transfer event it emitted.
|
|
const FAKE_ETH_TRANSFER_HASH =
|
|
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
|
// Our test address, the claimed sender of the fake transfer.
|
|
const VICTIM = "0x66133e8ea0f5d1d612d2502a968757d1048c214a";
|
|
// The legitimate recipient of the real 0.005 ETH send.
|
|
const LEGIT_RECIPIENT = "0xc3c693ae04bad5f13c45885c1e85a9557798f37e";
|
|
// The scam address the fake token transfer pointed at ("0xC3C0" vs "0xC3c6").
|
|
const TOKEN_SCAM_LOOKALIKE = "0xc3c0aea127c575b9ffd03bf11c6a878e8979c37f";
|
|
|
|
// The second wave: a real 1 gwei native transfer (README.md:800-808).
|
|
const DUST_TX_HASH =
|
|
"0x2708ebddfb9b5fa3f7a89d3ea398ef9fd8771b83ed861ecb7c21cd55d18edc74";
|
|
const DUST_SENDER_LOOKALIKE = "0xc3c6b3b4402bd78a9582ab6b00e747769344f37e";
|
|
|
|
// Genuine contracts from the shipped token list.
|
|
const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
|
const WETH_CONTRACT = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";
|
|
|
|
// A poisoning contract that uses a symbol not present in the token list, so
|
|
// only the holder-count and blocklist rules can catch it.
|
|
const NOVEL_SPAM_CONTRACT = "0x1111111111111111111111111111111111111111";
|
|
const NOVEL_SPAM_SYMBOL = "SPAMTKN";
|
|
|
|
// An ordinary counterparty for legitimate-transaction fixtures.
|
|
const ORDINARY_PEER = "0x5aa0f9f1e0a1d0e0e5c1e7ce3b7dbbe9c19f0a11";
|
|
|
|
// The documented default settings (README.md:810-814, state.js:24-27).
|
|
const DEFAULT_FILTERS = {
|
|
hideLowHolderTokens: true,
|
|
hideFraudContracts: true,
|
|
hideDustTransactions: true,
|
|
dustThresholdGwei: 100000,
|
|
fraudContracts: [],
|
|
};
|
|
|
|
function filters(overrides) {
|
|
return { ...DEFAULT_FILTERS, ...overrides };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture builders producing objects shaped exactly like the parsed entries
|
|
// that fetchRecentTransactions hands to filterTransactions.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// A native (non-token) transaction. valueGwei is set, contractAddress and
|
|
// holders are null, as parseTx produces.
|
|
function nativeTx(overrides = {}) {
|
|
return {
|
|
hash: "0x" + "a".repeat(64),
|
|
blockNumber: 21000000,
|
|
timestamp: 1740657600,
|
|
from: ORDINARY_PEER,
|
|
to: VICTIM,
|
|
value: "0.0500",
|
|
exactValue: "0.05",
|
|
rawAmount: "50000000000000000",
|
|
rawUnit: "wei",
|
|
valueGwei: 50000000,
|
|
symbol: "ETH",
|
|
direction: "received",
|
|
directionLabel: "Received",
|
|
isError: false,
|
|
contractAddress: null,
|
|
holders: null,
|
|
isContractCall: false,
|
|
method: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// An ERC-20 transfer entry. contractAddress is lowercased and holders is a
|
|
// number, as parseTokenTransfer produces.
|
|
function tokenTx(overrides = {}) {
|
|
return {
|
|
hash: "0x" + "b".repeat(64),
|
|
blockNumber: 21000000,
|
|
timestamp: 1740657600,
|
|
from: ORDINARY_PEER,
|
|
to: VICTIM,
|
|
value: "1500.5000",
|
|
exactValue: "1500.5",
|
|
rawAmount: "1500500000",
|
|
rawUnit: "USDC base units (10^-6)",
|
|
valueGwei: null,
|
|
symbol: "USDC",
|
|
direction: "received",
|
|
directionLabel: "Received",
|
|
isError: false,
|
|
contractAddress: USDC_CONTRACT,
|
|
holders: 3500000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// Attack 1: the fake "Ethereum"/"ETH" token transfer claiming the victim sent
|
|
// 0.005 "ETH" to the look-alike scam address.
|
|
function fakeEthTokenTransfer() {
|
|
return tokenTx({
|
|
hash: FAKE_ETH_TRANSFER_HASH,
|
|
from: VICTIM,
|
|
to: TOKEN_SCAM_LOOKALIKE,
|
|
value: "0.0050",
|
|
exactValue: "0.005",
|
|
rawAmount: "5000000000000000",
|
|
rawUnit: "ETH base units (10^-18)",
|
|
symbol: "ETH",
|
|
direction: "sent",
|
|
directionLabel: "Sent",
|
|
contractAddress: FAKE_ETH_CONTRACT,
|
|
holders: 0,
|
|
});
|
|
}
|
|
|
|
// Attack 2: the real 1 gwei native transfer from the look-alike address.
|
|
function nativeDustTransfer() {
|
|
return nativeTx({
|
|
hash: DUST_TX_HASH,
|
|
from: DUST_SENDER_LOOKALIKE,
|
|
to: VICTIM,
|
|
value: "0.0000",
|
|
exactValue: "0.000000001",
|
|
rawAmount: "1000000000",
|
|
valueGwei: 1,
|
|
direction: "received",
|
|
directionLabel: "Received",
|
|
});
|
|
}
|
|
|
|
// The legitimate 0.005 ETH send that the attacks piggybacked on.
|
|
function legitimateEthSend() {
|
|
return nativeTx({
|
|
hash: "0x" + "c".repeat(64),
|
|
from: VICTIM,
|
|
to: LEGIT_RECIPIENT,
|
|
value: "0.0050",
|
|
exactValue: "0.005",
|
|
rawAmount: "5000000000000000",
|
|
valueGwei: 5000000,
|
|
direction: "sent",
|
|
directionLabel: "Sent",
|
|
});
|
|
}
|
|
|
|
function hashesOf(result) {
|
|
return result.transactions.map((tx) => tx.hash);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("token list assumptions the fixtures rely on", () => {
|
|
test('"ETH" is a known symbol with no legitimate ERC-20 contract', () => {
|
|
expect(KNOWN_SYMBOLS.has("ETH")).toBe(true);
|
|
expect(KNOWN_SYMBOLS.get("ETH")).toBeNull();
|
|
});
|
|
|
|
test("USDC and WETH map to their genuine lowercased contracts", () => {
|
|
expect(KNOWN_SYMBOLS.get("USDC")).toBe(USDC_CONTRACT);
|
|
expect(KNOWN_SYMBOLS.get("WETH")).toBe(WETH_CONTRACT);
|
|
});
|
|
|
|
test("the spam fixture symbol is not in the known token list", () => {
|
|
expect(KNOWN_SYMBOLS.has(NOVEL_SPAM_SYMBOL)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("the real attacks documented in README.md:730-814", () => {
|
|
test('the fake "Ethereum"/"ETH" token transfer is filtered by default', () => {
|
|
const attack = fakeEthTokenTransfer();
|
|
const result = filterTransactions([attack], filters());
|
|
expect(result.transactions).toEqual([]);
|
|
});
|
|
|
|
test("the fake token contract is reported as a newly found fraud contract", () => {
|
|
const result = filterTransactions([fakeEthTokenTransfer()], filters());
|
|
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
|
});
|
|
|
|
test("the 1 gwei native dust transfer is filtered by default", () => {
|
|
const result = filterTransactions([nativeDustTransfer()], filters());
|
|
expect(result.transactions).toEqual([]);
|
|
expect(result.newFraudContracts).toEqual([]);
|
|
});
|
|
|
|
test("both attacks are removed while the genuine send survives", () => {
|
|
const legit = legitimateEthSend();
|
|
const result = filterTransactions(
|
|
[fakeEthTokenTransfer(), legit, nativeDustTransfer()],
|
|
filters(),
|
|
);
|
|
expect(hashesOf(result)).toEqual([legit.hash]);
|
|
});
|
|
});
|
|
|
|
describe("known-symbol spoof verification", () => {
|
|
test("a known symbol from a non-matching contract is spoofed", () => {
|
|
const spoof = tokenTx({
|
|
symbol: "USDC",
|
|
contractAddress: NOVEL_SPAM_CONTRACT,
|
|
holders: 5000000,
|
|
});
|
|
const result = filterTransactions([spoof], filters());
|
|
expect(result.transactions).toEqual([]);
|
|
expect(result.newFraudContracts).toEqual([NOVEL_SPAM_CONTRACT]);
|
|
});
|
|
|
|
test("the genuine contract for that symbol is not spoofed", () => {
|
|
const genuine = tokenTx();
|
|
const result = filterTransactions([genuine], filters());
|
|
expect(result.transactions).toEqual([genuine]);
|
|
expect(result.newFraudContracts).toEqual([]);
|
|
});
|
|
|
|
test("an unknown symbol from an unknown contract is not flagged by this check", () => {
|
|
const unknown = tokenTx({
|
|
symbol: NOVEL_SPAM_SYMBOL,
|
|
contractAddress: NOVEL_SPAM_CONTRACT,
|
|
holders: 25000,
|
|
});
|
|
const result = filterTransactions([unknown], filters());
|
|
expect(result.transactions).toEqual([unknown]);
|
|
expect(result.newFraudContracts).toEqual([]);
|
|
});
|
|
|
|
test('"ETH" as an ERC-20 is spoofed no matter which contract emits it', () => {
|
|
// KNOWN_SYMBOLS maps "ETH" to null: there is no legitimate ERC-20
|
|
// "ETH", so even the real WETH contract claiming it is a spoof.
|
|
const fromWeth = tokenTx({
|
|
symbol: "ETH",
|
|
contractAddress: WETH_CONTRACT,
|
|
holders: 900000,
|
|
});
|
|
const result = filterTransactions([fromWeth], filters());
|
|
expect(result.transactions).toEqual([]);
|
|
expect(result.newFraudContracts).toEqual([WETH_CONTRACT]);
|
|
});
|
|
|
|
test("symbol comparison is case-insensitive", () => {
|
|
const spoof = tokenTx({
|
|
symbol: "usdc",
|
|
contractAddress: NOVEL_SPAM_CONTRACT,
|
|
holders: 5000000,
|
|
});
|
|
expect(filterTransactions([spoof], filters()).transactions).toEqual([]);
|
|
});
|
|
|
|
test("a native transaction has no contract and is never spoof-filtered", () => {
|
|
const native = nativeTx({ symbol: "ETH" });
|
|
const result = filterTransactions([native], filters());
|
|
expect(result.transactions).toEqual([native]);
|
|
expect(result.newFraudContracts).toEqual([]);
|
|
});
|
|
|
|
test("a repeated fraud contract is reported only once", () => {
|
|
const result = filterTransactions(
|
|
[fakeEthTokenTransfer(), fakeEthTokenTransfer()],
|
|
filters(),
|
|
);
|
|
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
|
});
|
|
|
|
test("an already-known fraud contract is not reported as new", () => {
|
|
const result = filterTransactions(
|
|
[fakeEthTokenTransfer()],
|
|
filters({ fraudContracts: [FAKE_ETH_CONTRACT] }),
|
|
);
|
|
expect(result.transactions).toEqual([]);
|
|
expect(result.newFraudContracts).toEqual([]);
|
|
});
|
|
|
|
test("an already-known fraud contract given in checksummed form is not reported as new", () => {
|
|
const result = filterTransactions(
|
|
[fakeEthTokenTransfer()],
|
|
filters({
|
|
fraudContracts: ["0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82"],
|
|
}),
|
|
);
|
|
expect(result.newFraudContracts).toEqual([]);
|
|
});
|
|
|
|
// Documents current behaviour, not desired behaviour: the spoof check
|
|
// compares tx.contractAddress against a lowercased known address with
|
|
// ===, so a caller passing a checksummed address for a genuine token has
|
|
// it treated as a spoof. In the app this cannot happen because
|
|
// parseTokenTransfer lowercases, but the exported function is not
|
|
// defensive about it the way the blocklist check is.
|
|
test("current behaviour: a checksummed genuine contract is treated as a spoof", () => {
|
|
const genuineButChecksummed = tokenTx({
|
|
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
|
});
|
|
const result = filterTransactions([genuineButChecksummed], filters());
|
|
expect(result.transactions).toEqual([]);
|
|
});
|
|
|
|
// Documents current behaviour: README.md:810-814 says all four filters
|
|
// "default to on but can be individually disabled". There is no setting
|
|
// for known-symbol verification, and filterTransactions applies it
|
|
// unconditionally, so it cannot be turned off.
|
|
test("current behaviour: spoof filtering cannot be disabled by any setting", () => {
|
|
const allFiltersOff = {
|
|
hideLowHolderTokens: false,
|
|
hideFraudContracts: false,
|
|
hideDustTransactions: false,
|
|
dustThresholdGwei: 1,
|
|
fraudContracts: [],
|
|
};
|
|
const result = filterTransactions(
|
|
[fakeEthTokenTransfer()],
|
|
allFiltersOff,
|
|
);
|
|
expect(result.transactions).toEqual([]);
|
|
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
|
});
|
|
|
|
test("current behaviour: spoof filtering also applies with no filters argument", () => {
|
|
const result = filterTransactions([fakeEthTokenTransfer()]);
|
|
expect(result.transactions).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("low-holder token filtering (the 1,000-holder rule)", () => {
|
|
function spamWithHolders(holders) {
|
|
return tokenTx({
|
|
symbol: NOVEL_SPAM_SYMBOL,
|
|
contractAddress: NOVEL_SPAM_CONTRACT,
|
|
holders: holders,
|
|
});
|
|
}
|
|
|
|
test("a zero-holder poisoning token is filtered", () => {
|
|
const result = filterTransactions([spamWithHolders(0)], filters());
|
|
expect(result.transactions).toEqual([]);
|
|
});
|
|
|
|
test("boundary: 999 holders is filtered", () => {
|
|
const result = filterTransactions([spamWithHolders(999)], filters());
|
|
expect(result.transactions).toEqual([]);
|
|
});
|
|
|
|
test("boundary: exactly 1000 holders is kept", () => {
|
|
const tx = spamWithHolders(1000);
|
|
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
|
});
|
|
|
|
test("boundary: 1001 holders is kept", () => {
|
|
const tx = spamWithHolders(1001);
|
|
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
|
});
|
|
|
|
test("the rule is bypassed when hideLowHolderTokens is off", () => {
|
|
const tx = spamWithHolders(0);
|
|
const result = filterTransactions(
|
|
[tx],
|
|
filters({ hideLowHolderTokens: false }),
|
|
);
|
|
expect(result.transactions).toEqual([tx]);
|
|
});
|
|
|
|
test("native transactions have no holder count and are unaffected", () => {
|
|
const tx = legitimateEthSend();
|
|
expect(tx.holders).toBeNull();
|
|
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
|
});
|
|
});
|
|
|
|
describe("fraud contract blocklist", () => {
|
|
function blocklistedTx() {
|
|
return tokenTx({
|
|
symbol: NOVEL_SPAM_SYMBOL,
|
|
contractAddress: NOVEL_SPAM_CONTRACT,
|
|
holders: 50000,
|
|
});
|
|
}
|
|
|
|
test("a transfer from a blocklisted contract is filtered", () => {
|
|
const result = filterTransactions(
|
|
[blocklistedTx()],
|
|
filters({ fraudContracts: [NOVEL_SPAM_CONTRACT] }),
|
|
);
|
|
expect(result.transactions).toEqual([]);
|
|
});
|
|
|
|
test("the blocklist is matched case-insensitively", () => {
|
|
const result = filterTransactions(
|
|
[blocklistedTx()],
|
|
filters({
|
|
fraudContracts: [NOVEL_SPAM_CONTRACT.toUpperCase()],
|
|
}),
|
|
);
|
|
expect(result.transactions).toEqual([]);
|
|
});
|
|
|
|
test("the blocklist is bypassed when hideFraudContracts is off", () => {
|
|
const tx = blocklistedTx();
|
|
const result = filterTransactions(
|
|
[tx],
|
|
filters({
|
|
hideFraudContracts: false,
|
|
fraudContracts: [NOVEL_SPAM_CONTRACT],
|
|
}),
|
|
);
|
|
expect(result.transactions).toEqual([tx]);
|
|
});
|
|
|
|
test("a contract not on the blocklist is unaffected", () => {
|
|
const tx = blocklistedTx();
|
|
const result = filterTransactions(
|
|
[tx],
|
|
filters({ fraudContracts: [FAKE_ETH_CONTRACT] }),
|
|
);
|
|
expect(result.transactions).toEqual([tx]);
|
|
});
|
|
|
|
test("native transactions are never blocklist-filtered", () => {
|
|
const tx = legitimateEthSend();
|
|
const result = filterTransactions(
|
|
[tx],
|
|
filters({ fraudContracts: [FAKE_ETH_CONTRACT] }),
|
|
);
|
|
expect(result.transactions).toEqual([tx]);
|
|
});
|
|
|
|
test("a contract caught spoofing earlier in the batch blocks its later transfers", () => {
|
|
// The fake "Ethereum" contract is detected as a spoof, added to the
|
|
// working blocklist, and its later transfer under a novel symbol is
|
|
// then filtered by the blocklist rule rather than the spoof rule.
|
|
const later = tokenTx({
|
|
hash: "0x" + "d".repeat(64),
|
|
symbol: NOVEL_SPAM_SYMBOL,
|
|
contractAddress: FAKE_ETH_CONTRACT,
|
|
holders: 50000,
|
|
});
|
|
const result = filterTransactions(
|
|
[fakeEthTokenTransfer(), later],
|
|
filters(),
|
|
);
|
|
expect(result.transactions).toEqual([]);
|
|
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
|
});
|
|
|
|
test("that later transfer survives when hideFraudContracts is off", () => {
|
|
const later = tokenTx({
|
|
hash: "0x" + "d".repeat(64),
|
|
symbol: NOVEL_SPAM_SYMBOL,
|
|
contractAddress: FAKE_ETH_CONTRACT,
|
|
holders: 50000,
|
|
});
|
|
const result = filterTransactions(
|
|
[fakeEthTokenTransfer(), later],
|
|
filters({ hideFraudContracts: false }),
|
|
);
|
|
expect(hashesOf(result)).toEqual([later.hash]);
|
|
});
|
|
});
|
|
|
|
describe("dust threshold filtering", () => {
|
|
function dustOf(gwei) {
|
|
return nativeTx({ valueGwei: gwei });
|
|
}
|
|
|
|
test("boundary: 99,999 gwei is filtered at the 100,000 gwei default", () => {
|
|
const result = filterTransactions([dustOf(99999)], filters());
|
|
expect(result.transactions).toEqual([]);
|
|
});
|
|
|
|
test("boundary: exactly 100,000 gwei is kept", () => {
|
|
const tx = dustOf(100000);
|
|
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
|
});
|
|
|
|
test("boundary: 100,001 gwei is kept", () => {
|
|
const tx = dustOf(100001);
|
|
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
|
});
|
|
|
|
test("the 100,000 gwei default applies when no threshold is supplied", () => {
|
|
const below = dustOf(99999);
|
|
const at = dustOf(100000);
|
|
const opts = {
|
|
hideLowHolderTokens: true,
|
|
hideFraudContracts: true,
|
|
hideDustTransactions: true,
|
|
fraudContracts: [],
|
|
};
|
|
expect(filterTransactions([below], opts).transactions).toEqual([]);
|
|
expect(filterTransactions([at], opts).transactions).toEqual([at]);
|
|
});
|
|
|
|
test("a user-raised threshold is honoured on both sides", () => {
|
|
const opts = filters({ dustThresholdGwei: 5000000 });
|
|
const below = dustOf(4999999);
|
|
const at = dustOf(5000000);
|
|
expect(filterTransactions([below], opts).transactions).toEqual([]);
|
|
expect(filterTransactions([at], opts).transactions).toEqual([at]);
|
|
});
|
|
|
|
test("dust filtering is bypassed when hideDustTransactions is off", () => {
|
|
const tx = nativeDustTransfer();
|
|
const result = filterTransactions(
|
|
[tx],
|
|
filters({ hideDustTransactions: false }),
|
|
);
|
|
expect(result.transactions).toEqual([tx]);
|
|
});
|
|
|
|
test("zero-value contract calls are never treated as dust", () => {
|
|
const approve = nativeTx({
|
|
hash: "0x" + "e".repeat(64),
|
|
valueGwei: 0,
|
|
value: "",
|
|
exactValue: "",
|
|
direction: "contract",
|
|
directionLabel: "Approve",
|
|
isContractCall: true,
|
|
method: "approve",
|
|
});
|
|
expect(filterTransactions([approve], filters()).transactions).toEqual([
|
|
approve,
|
|
]);
|
|
});
|
|
|
|
test("token transfers carry no gwei value and are never dust-filtered", () => {
|
|
const tx = tokenTx({ valueGwei: null });
|
|
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
|
});
|
|
|
|
// Documents current behaviour: the threshold is read as
|
|
// `filters.dustThresholdGwei || 100000`, so a user who sets the threshold
|
|
// to 0 (the natural way to ask for no dust filtering while leaving the
|
|
// toggle on) silently gets the 100,000 gwei default instead.
|
|
test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => {
|
|
const result = filterTransactions(
|
|
[dustOf(50)],
|
|
filters({ dustThresholdGwei: 0 }),
|
|
);
|
|
expect(result.transactions).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("filter defaults promised by the README and Settings", () => {
|
|
test("all three toggles default to on and the threshold to 100,000 gwei", () => {
|
|
expect(state.hideLowHolderTokens).toBe(true);
|
|
expect(state.hideFraudContracts).toBe(true);
|
|
expect(state.hideDustTransactions).toBe(true);
|
|
expect(state.dustThresholdGwei).toBe(100000);
|
|
});
|
|
|
|
test("the fraud contract list starts empty", () => {
|
|
expect(state.fraudContracts).toEqual([]);
|
|
});
|
|
|
|
// Documents current behaviour: filterTransactions itself defaults every
|
|
// optional filter to off. The "default to on" promise is satisfied by
|
|
// the state defaults above, which every caller passes in; the pure
|
|
// function makes no assumption of its own.
|
|
test("current behaviour: with no filters argument only spoof filtering runs", () => {
|
|
const dust = nativeDustTransfer();
|
|
const lowHolder = tokenTx({
|
|
symbol: NOVEL_SPAM_SYMBOL,
|
|
contractAddress: NOVEL_SPAM_CONTRACT,
|
|
holders: 0,
|
|
});
|
|
const result = filterTransactions([dust, lowHolder]);
|
|
expect(hashesOf(result)).toEqual([dust.hash, lowHolder.hash]);
|
|
});
|
|
});
|
|
|
|
describe("legitimate transactions are never filtered", () => {
|
|
test("a plain ETH transfer of an ordinary amount survives all four rules", () => {
|
|
const tx = nativeTx();
|
|
const result = filterTransactions([tx], filters());
|
|
expect(result.transactions).toEqual([tx]);
|
|
expect(result.newFraudContracts).toEqual([]);
|
|
});
|
|
|
|
test("a genuine high-holder USDC transfer survives all four rules", () => {
|
|
const tx = tokenTx();
|
|
const result = filterTransactions([tx], filters());
|
|
expect(result.transactions).toEqual([tx]);
|
|
expect(result.newFraudContracts).toEqual([]);
|
|
});
|
|
|
|
test("a genuine WETH transfer survives all four rules", () => {
|
|
const tx = tokenTx({
|
|
hash: "0x" + "f".repeat(64),
|
|
symbol: "WETH",
|
|
contractAddress: WETH_CONTRACT,
|
|
holders: 850000,
|
|
value: "1.2500",
|
|
exactValue: "1.25",
|
|
});
|
|
const result = filterTransactions([tx], filters());
|
|
expect(result.transactions).toEqual([tx]);
|
|
});
|
|
|
|
test("a mixed history keeps exactly the legitimate entries, in order", () => {
|
|
const ethIn = nativeTx();
|
|
const usdcIn = tokenTx();
|
|
const ethOut = legitimateEthSend();
|
|
const result = filterTransactions(
|
|
[
|
|
fakeEthTokenTransfer(),
|
|
ethIn,
|
|
nativeDustTransfer(),
|
|
usdcIn,
|
|
tokenTx({
|
|
hash: "0x" + "9".repeat(64),
|
|
symbol: NOVEL_SPAM_SYMBOL,
|
|
contractAddress: NOVEL_SPAM_CONTRACT,
|
|
holders: 0,
|
|
}),
|
|
ethOut,
|
|
],
|
|
filters(),
|
|
);
|
|
expect(hashesOf(result)).toEqual([
|
|
ethIn.hash,
|
|
usdcIn.hash,
|
|
ethOut.hash,
|
|
]);
|
|
});
|
|
|
|
test("surviving entries are the same objects, unmodified", () => {
|
|
const tx = tokenTx();
|
|
const before = JSON.stringify(tx);
|
|
const result = filterTransactions([tx], filters());
|
|
expect(result.transactions[0]).toBe(tx);
|
|
expect(JSON.stringify(tx)).toBe(before);
|
|
});
|
|
|
|
test("an empty history yields empty results", () => {
|
|
const result = filterTransactions([], filters());
|
|
expect(result.transactions).toEqual([]);
|
|
expect(result.newFraudContracts).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// fetchRecentTransactions owns the per-address merge of normal transactions
|
|
// with ERC-20 transfers. (The cross-address merge Home performs lives in
|
|
// src/popup/views/home.js.) debugFetch is mocked, so these tests exercise the
|
|
// merge logic against static fixture payloads with no network involved.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
|
|
const TS = "2026-02-27T12:00:00.000Z";
|
|
const TS_EPOCH = Math.floor(Date.parse(TS) / 1000);
|
|
|
|
function respondWith(txItems, tokenTransferItems) {
|
|
debugFetch.mockImplementation(async (url) => ({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: "OK",
|
|
json: async () =>
|
|
url.includes("/token-transfers")
|
|
? { items: tokenTransferItems }
|
|
: { items: txItems },
|
|
}));
|
|
}
|
|
|
|
describe("fetchRecentTransactions merge and dedup", () => {
|
|
beforeEach(() => {
|
|
debugFetch.mockReset();
|
|
});
|
|
|
|
test("queries only the two Blockscout endpoints for the address", async () => {
|
|
respondWith([], []);
|
|
await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
|
expect(debugFetch).toHaveBeenCalledTimes(2);
|
|
const urls = debugFetch.mock.calls.map((c) => c[0]);
|
|
expect(urls).toContain(
|
|
BLOCKSCOUT + "/addresses/" + VICTIM + "/transactions",
|
|
);
|
|
expect(urls).toContain(
|
|
BLOCKSCOUT +
|
|
"/addresses/" +
|
|
VICTIM +
|
|
"/token-transfers?type=ERC-20",
|
|
);
|
|
});
|
|
|
|
test("a swap consolidates its token transfers into the single tx entry", async () => {
|
|
const hash = "0x" + "1".repeat(64);
|
|
respondWith(
|
|
[
|
|
{
|
|
hash: hash,
|
|
block_number: 21000010,
|
|
timestamp: TS,
|
|
from: { hash: VICTIM },
|
|
to: {
|
|
hash: "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad",
|
|
is_contract: true,
|
|
},
|
|
value: "0",
|
|
method: "execute",
|
|
status: "ok",
|
|
},
|
|
],
|
|
[
|
|
{
|
|
transaction_hash: hash,
|
|
block_number: 21000010,
|
|
timestamp: TS,
|
|
from: { hash: VICTIM },
|
|
to: { hash: "0x66a9893cc07d91d95644aedd05d03f95e1dba8af" },
|
|
total: { value: "1500500000", decimals: "6" },
|
|
token: {
|
|
symbol: "USDC",
|
|
address_hash: USDC_CONTRACT,
|
|
holders_count: "3500000",
|
|
},
|
|
},
|
|
{
|
|
transaction_hash: hash,
|
|
block_number: 21000010,
|
|
timestamp: TS,
|
|
from: {
|
|
hash: "0x66a9893cc07d91d95644aedd05d03f95e1dba8af",
|
|
},
|
|
to: { hash: VICTIM },
|
|
total: { value: "250000000000000000", decimals: "18" },
|
|
token: {
|
|
symbol: "WETH",
|
|
address_hash: WETH_CONTRACT,
|
|
holders_count: "850000",
|
|
},
|
|
},
|
|
],
|
|
);
|
|
|
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
|
expect(txs).toHaveLength(1);
|
|
const merged = txs[0];
|
|
// The received leg (the swap output) supplies the display amount.
|
|
expect(merged.symbol).toBe("WETH");
|
|
expect(merged.value).toBe("0.2500");
|
|
expect(merged.contractAddress).toBe(WETH_CONTRACT);
|
|
expect(merged.holders).toBe(850000);
|
|
// The user's own address and the contract they called are preserved,
|
|
// not the router addresses from the token transfer legs.
|
|
expect(merged.from).toBe(VICTIM);
|
|
expect(merged.to).toBe("0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad");
|
|
expect(merged.direction).toBe("contract");
|
|
expect(merged.directionLabel).toBe("Swap");
|
|
expect(merged.timestamp).toBe(TS_EPOCH);
|
|
});
|
|
|
|
test("a token transfer with no matching transaction gets its own entry", async () => {
|
|
respondWith(
|
|
[],
|
|
[
|
|
{
|
|
transaction_hash: "0x" + "2".repeat(64),
|
|
block_number: 21000020,
|
|
timestamp: TS,
|
|
from: { hash: ORDINARY_PEER },
|
|
to: { hash: VICTIM },
|
|
total: { value: "1500500000", decimals: "6" },
|
|
token: {
|
|
symbol: "USDC",
|
|
address_hash: USDC_CONTRACT,
|
|
holders_count: "3500000",
|
|
},
|
|
},
|
|
],
|
|
);
|
|
|
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
|
expect(txs).toHaveLength(1);
|
|
expect(txs[0].symbol).toBe("USDC");
|
|
expect(txs[0].value).toBe("1500.5000");
|
|
expect(txs[0].direction).toBe("received");
|
|
expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
|
|
expect(txs[0].holders).toBe(3500000);
|
|
});
|
|
|
|
test("two transfers of the same token in one transaction collapse to one entry", async () => {
|
|
const hash = "0x" + "3".repeat(64);
|
|
const leg = (value) => ({
|
|
transaction_hash: hash,
|
|
block_number: 21000030,
|
|
timestamp: TS,
|
|
from: { hash: ORDINARY_PEER },
|
|
to: { hash: VICTIM },
|
|
total: { value: value, decimals: "6" },
|
|
token: {
|
|
symbol: "USDC",
|
|
address_hash: USDC_CONTRACT,
|
|
holders_count: "3500000",
|
|
},
|
|
});
|
|
respondWith([], [leg("1000000"), leg("2000000")]);
|
|
|
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
|
expect(txs).toHaveLength(1);
|
|
// Keyed by hash plus contract, so the later leg wins.
|
|
expect(txs[0].exactValue).toBe("2.0");
|
|
});
|
|
|
|
test("two different tokens in one transaction stay as separate entries", async () => {
|
|
const hash = "0x" + "4".repeat(64);
|
|
respondWith(
|
|
[],
|
|
[
|
|
{
|
|
transaction_hash: hash,
|
|
block_number: 21000040,
|
|
timestamp: TS,
|
|
from: { hash: ORDINARY_PEER },
|
|
to: { hash: VICTIM },
|
|
total: { value: "1000000", decimals: "6" },
|
|
token: {
|
|
symbol: "USDC",
|
|
address_hash: USDC_CONTRACT,
|
|
holders_count: "3500000",
|
|
},
|
|
},
|
|
{
|
|
transaction_hash: hash,
|
|
block_number: 21000040,
|
|
timestamp: TS,
|
|
from: { hash: ORDINARY_PEER },
|
|
to: { hash: VICTIM },
|
|
total: { value: "1000000000000000000", decimals: "18" },
|
|
token: {
|
|
symbol: "WETH",
|
|
address_hash: WETH_CONTRACT,
|
|
holders_count: "850000",
|
|
},
|
|
},
|
|
],
|
|
);
|
|
|
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
|
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
|
|
});
|
|
|
|
// Documents current behaviour: for a plain ERC-20 transfer the method is
|
|
// "transfer", so parseTx does not mark the entry as a contract call in
|
|
// the display sense and the merge loop does not consolidate the token
|
|
// transfer into it. The result is two entries for one transaction: a
|
|
// zero-value native row and the real token row. The zero-value row also
|
|
// escapes dust filtering because isContractCall is true.
|
|
test("current behaviour: a plain ERC-20 transfer produces two entries", async () => {
|
|
const hash = "0x" + "5".repeat(64);
|
|
respondWith(
|
|
[
|
|
{
|
|
hash: hash,
|
|
block_number: 21000050,
|
|
timestamp: TS,
|
|
from: { hash: VICTIM },
|
|
to: { hash: USDC_CONTRACT, is_contract: true },
|
|
value: "0",
|
|
method: "transfer",
|
|
status: "ok",
|
|
},
|
|
],
|
|
[
|
|
{
|
|
transaction_hash: hash,
|
|
block_number: 21000050,
|
|
timestamp: TS,
|
|
from: { hash: VICTIM },
|
|
to: { hash: ORDINARY_PEER },
|
|
total: { value: "1000000", decimals: "6" },
|
|
token: {
|
|
symbol: "USDC",
|
|
address_hash: USDC_CONTRACT,
|
|
holders_count: "3500000",
|
|
},
|
|
},
|
|
],
|
|
);
|
|
|
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
|
expect(txs).toHaveLength(2);
|
|
expect(txs.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
|
|
const nativeRow = txs.find((t) => t.symbol === "ETH");
|
|
expect(nativeRow.exactValue).toBe("0.0");
|
|
expect(nativeRow.isContractCall).toBe(true);
|
|
// And the zero-value row is not removed by the dust filter.
|
|
const kept = filterTransactions(txs, filters()).transactions;
|
|
expect(kept).toHaveLength(2);
|
|
});
|
|
|
|
test("entries are sorted by block number descending and capped at count", async () => {
|
|
const item = (n) => ({
|
|
hash: "0x" + String(n).repeat(64),
|
|
block_number: 21000000 + n,
|
|
timestamp: TS,
|
|
from: { hash: ORDINARY_PEER },
|
|
to: { hash: VICTIM, is_contract: false },
|
|
value: "1000000000000000000",
|
|
method: null,
|
|
status: "ok",
|
|
});
|
|
respondWith([item(6), item(8), item(7)], []);
|
|
|
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, 2);
|
|
expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]);
|
|
});
|
|
|
|
test("the fake token transfer survives fetching and is then filtered", async () => {
|
|
respondWith(
|
|
[],
|
|
[
|
|
{
|
|
transaction_hash: FAKE_ETH_TRANSFER_HASH,
|
|
block_number: 21000060,
|
|
timestamp: TS,
|
|
from: { hash: VICTIM },
|
|
to: { hash: TOKEN_SCAM_LOOKALIKE },
|
|
total: { value: "5000000000000000", decimals: "18" },
|
|
token: {
|
|
symbol: "ETH",
|
|
address_hash: FAKE_ETH_CONTRACT,
|
|
holders_count: "0",
|
|
},
|
|
},
|
|
],
|
|
);
|
|
|
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
|
expect(txs).toHaveLength(1);
|
|
expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT);
|
|
expect(txs[0].holders).toBe(0);
|
|
|
|
const result = filterTransactions(txs, filters());
|
|
expect(result.transactions).toEqual([]);
|
|
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
|
});
|
|
|
|
test("failed responses yield an empty list rather than throwing", async () => {
|
|
debugFetch.mockImplementation(async () => ({
|
|
ok: false,
|
|
status: 502,
|
|
statusText: "Bad Gateway",
|
|
json: async () => {
|
|
throw new Error("body must not be read on a failed response");
|
|
},
|
|
}));
|
|
await expect(
|
|
fetchRecentTransactions(VICTIM, BLOCKSCOUT),
|
|
).resolves.toEqual([]);
|
|
});
|
|
|
|
test("no test in this file performed a network request", () => {
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|