All checks were successful
check / check (push) Successful in 33s
Seven bundled tokens were filtered as spoofs at their own address, so a user holding FRAX, TON, REUSD, EURE, MSUSD, MUSD or JPYC could not see or spend the one the wallet happened not to pick. The known-symbol table is derived from the bundled token list, first-wins in market-cap order, so a symbol that appears twice silently condemned its second contract. Both are real tokens from the same fetch and neither is stale -- three pairs are one issuer's old and new contract, four are unrelated issuers sharing a ticker. Picking a winner would have been guessing, and dropping the ambiguous symbols would have ended spoof filtering for those tickers entirely. The table now maps a symbol to the set of addresses that legitimately bear it. A contract outside the set is still a spoof, so the check is not weakened: a third contract bearing any of the seven shared tickers is refused, and that is tested. The filter decides what is fake, not what is worth holding, so a legacy contract stays in the set -- it still holds real balances. A test walks the whole bundled list asserting no token is filtered at its own address, which is the guard whose absence let this ship.
694 lines
28 KiB
JavaScript
694 lines
28 KiB
JavaScript
// Tests for the known-symbol spoof rule (src/shared/symbolSpoof.js) and for
|
|
// its application on all three surfaces that show tokens: the transaction
|
|
// history, the Send token selector, and the balance list.
|
|
//
|
|
// Issue #235: the three surfaces disagreed about what a `null` entry in
|
|
// KNOWN_SYMBOLS means. The history and the selector read it as "no contract
|
|
// may bear this symbol" and filtered a fake `ETH` ERC-20; the balance list
|
|
// read it as "no comparison is possible" and listed the fake token next to
|
|
// the user's real ETH, which is where a user forms their belief about what
|
|
// they own. The rule now lives in one module, so a fourth surface cannot
|
|
// reintroduce a fourth reading, and these tests assert the same attack on
|
|
// each surface.
|
|
//
|
|
// Nothing here touches the network: global.fetch is a throwing stub and the
|
|
// only fetch path in the modules under test (debugFetch, from
|
|
// src/shared/log) is mocked at the module boundary.
|
|
|
|
// The RPC provider is replaced so that refreshBalances can be driven end to
|
|
// end: the native balance it reports must survive a balance list in which
|
|
// every ERC-20 row is a fake ETH. Everything else in ethers is the real
|
|
// module, including the formatters the assertions depend on.
|
|
jest.mock("ethers", () => {
|
|
const actual = jest.requireActual("ethers");
|
|
class StubProvider {
|
|
async getBalance() {
|
|
return 1234500000000000000n;
|
|
}
|
|
async lookupAddress() {
|
|
return null;
|
|
}
|
|
}
|
|
return {
|
|
...actual,
|
|
JsonRpcProvider: StubProvider,
|
|
Network: { from: () => ({}) },
|
|
};
|
|
});
|
|
|
|
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");
|
|
});
|
|
global.chrome = {
|
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
};
|
|
|
|
const { isSpoofedSymbol } = require("../src/shared/symbolSpoof");
|
|
const { TOKENS, KNOWN_SYMBOLS } = require("../src/shared/tokenList");
|
|
const { filterTransactions } = require("../src/shared/transactions");
|
|
const {
|
|
fetchTokenBalances,
|
|
refreshBalances,
|
|
} = require("../src/shared/balances");
|
|
const { renderSendTokenSelect } = require("../src/popup/views/send");
|
|
const { state } = require("../src/shared/state");
|
|
const { debugFetch } = require("../src/shared/log");
|
|
|
|
// The fake "Ethereum" token with symbol "ETH" from the attack documented in
|
|
// README.md, given a holder count high enough to clear every other filter so
|
|
// that only the known-symbol rule can catch it.
|
|
const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
|
|
const HOLDER = "0x66133e8ea0f5d1d612d2502a968757d1048c214a";
|
|
const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
|
const WETH_CONTRACT = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";
|
|
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
|
|
|
|
describe("the shared rule", () => {
|
|
test('"ETH" is still the null-mapped symbol these tests assume', () => {
|
|
expect(KNOWN_SYMBOLS.get("ETH")).toBeNull();
|
|
});
|
|
|
|
test("a contract bearing a null-mapped symbol is a spoof", () => {
|
|
expect(isSpoofedSymbol("ETH", FAKE_ETH_CONTRACT)).toBe(true);
|
|
});
|
|
|
|
test("even a genuine contract may not bear a null-mapped symbol", () => {
|
|
expect(isSpoofedSymbol("ETH", WETH_CONTRACT)).toBe(true);
|
|
});
|
|
|
|
test("the native asset carries no contract and is never a spoof", () => {
|
|
expect(isSpoofedSymbol("ETH", null)).toBe(false);
|
|
expect(isSpoofedSymbol("ETH", undefined)).toBe(false);
|
|
expect(isSpoofedSymbol("ETH", "")).toBe(false);
|
|
});
|
|
|
|
// The native exemption is "has no contract address", not "the symbol is
|
|
// ETH". A second null-mapped symbol added to the table later inherits
|
|
// both halves of the rule without any call site being revisited.
|
|
test("a newly null-mapped symbol behaves the same way", () => {
|
|
const added = !KNOWN_SYMBOLS.has("XTZTEST");
|
|
KNOWN_SYMBOLS.set("XTZTEST", null);
|
|
try {
|
|
expect(isSpoofedSymbol("XTZTEST", FAKE_ETH_CONTRACT)).toBe(true);
|
|
expect(isSpoofedSymbol("XTZTEST", null)).toBe(false);
|
|
} finally {
|
|
if (added) KNOWN_SYMBOLS.delete("XTZTEST");
|
|
}
|
|
});
|
|
|
|
test("a known symbol from its own contract is not a spoof", () => {
|
|
expect(isSpoofedSymbol("USDC", USDC_CONTRACT)).toBe(false);
|
|
expect(isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase())).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("a known symbol from another contract is a spoof", () => {
|
|
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT)).toBe(true);
|
|
});
|
|
|
|
test("a symbol that is not in the table is not judged here", () => {
|
|
expect(isSpoofedSymbol("SPAMTKN", FAKE_ETH_CONTRACT)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Issue #260: the symbol is whatever the ERC-20 contract returns, and HTML
|
|
// collapses leading and trailing whitespace, so a token calling itself
|
|
// `" ETH "` reaches the user's eye as `ETH` while missing a raw
|
|
// KNOWN_SYMBOLS lookup. Normalizing inside the shared rule fixes all three
|
|
// surfaces at once, which is what consolidating the rule bought.
|
|
//
|
|
// Every character under test here is built from its code point rather than
|
|
// pasted in: most of them are invisible, and an invisible character in a
|
|
// test file is unreviewable.
|
|
const cp = (...codes) => String.fromCodePoint(...codes);
|
|
const NBSP = cp(0x00a0); // no-break space
|
|
const FIGURE_SPACE = cp(0x2007);
|
|
const IDEOGRAPHIC_SPACE = cp(0x3000);
|
|
const ZWSP = cp(0x200b); // zero-width space
|
|
const BOM = cp(0xfeff); // zero-width no-break space
|
|
const WORD_JOINER = cp(0x2060);
|
|
const SOFT_HYPHEN = cp(0x00ad);
|
|
const LRM = cp(0x200e); // left-to-right mark
|
|
const RLO = cp(0x202e); // right-to-left override
|
|
const HANGUL_FILLER = cp(0x3164);
|
|
const CHOSEONG_FILLER = cp(0x115f);
|
|
const VS16 = cp(0xfe0f); // variation selector-16
|
|
const VS1 = cp(0xfe00); // variation selector-1
|
|
const NEL = cp(0x0085); // next line, a C1 control
|
|
const DEL = cp(0x007f);
|
|
const FULLWIDTH_ETH = cp(0xff25, 0xff34, 0xff28);
|
|
const FULLWIDTH_USDC = cp(0xff55, 0xff53, 0xff44, 0xff43); // lowercase
|
|
const CYRILLIC_CAPITAL_IE = cp(0x0415);
|
|
|
|
describe("the shared rule: symbols that render as a known symbol", () => {
|
|
test("ASCII padding does not buy a pass", () => {
|
|
expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true);
|
|
expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true);
|
|
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT)).toBe(true);
|
|
});
|
|
|
|
test("non-breaking and other Unicode spaces do not either", () => {
|
|
expect(isSpoofedSymbol(NBSP + "ETH" + NBSP, FAKE_ETH_CONTRACT)).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
isSpoofedSymbol(
|
|
FIGURE_SPACE + "ETH" + IDEOGRAPHIC_SPACE,
|
|
FAKE_ETH_CONTRACT,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
// These render as nothing at all, in any position, so they are removed
|
|
// wherever they sit rather than only at the ends.
|
|
test("zero-width characters are stripped wherever they sit", () => {
|
|
expect(isSpoofedSymbol("E" + ZWSP + "TH", FAKE_ETH_CONTRACT)).toBe(
|
|
true,
|
|
);
|
|
expect(isSpoofedSymbol(BOM + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
|
|
expect(
|
|
isSpoofedSymbol("ET" + WORD_JOINER + "H", FAKE_ETH_CONTRACT),
|
|
).toBe(true);
|
|
expect(
|
|
isSpoofedSymbol("E" + SOFT_HYPHEN + "TH", FAKE_ETH_CONTRACT),
|
|
).toBe(true);
|
|
});
|
|
|
|
// An LRM is invisible and, in all-Latin text, moves nothing: dropping it
|
|
// leaves exactly the string the user saw.
|
|
test("an invisible bidi mark does not hide a known symbol", () => {
|
|
expect(isSpoofedSymbol(LRM + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
|
|
});
|
|
|
|
// Invisibility is not confined to \p{Cf}. A Hangul filler is Lo and a
|
|
// variation selector is Mn, yet each of these four measures 32.00px in
|
|
// the repo's pinned e2e Chromium at 16px sans-serif — exactly the width
|
|
// of a plain `ETH` — so each reaches the user's eye as `ETH`. They are
|
|
// caught by \p{Default_Ignorable_Code_Point}, not by \p{Cf}.
|
|
test("invisible non-format characters are stripped too", () => {
|
|
expect(isSpoofedSymbol(HANGUL_FILLER + "ETH", FAKE_ETH_CONTRACT)).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
isSpoofedSymbol(CHOSEONG_FILLER + "ETH", FAKE_ETH_CONTRACT),
|
|
).toBe(true);
|
|
expect(isSpoofedSymbol("ETH" + VS16, FAKE_ETH_CONTRACT)).toBe(true);
|
|
expect(isSpoofedSymbol("E" + VS1 + "TH", FAKE_ETH_CONTRACT)).toBe(true);
|
|
});
|
|
|
|
// Nor is it confined to the Unicode classes. U+007F is a control (Cc)
|
|
// and is not default-ignorable, so neither class reaches it, but it
|
|
// measures 32.00px in the same browser — it paints nothing, so a
|
|
// symbol carrying it reaches the eye as `ETH`. It is named on its own
|
|
// in the strip for exactly that reason.
|
|
test("U+007F paints nothing and is stripped", () => {
|
|
expect(isSpoofedSymbol(DEL + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
|
|
});
|
|
|
|
// The other side of the boundary, which is not the class boundary but
|
|
// the visibility one: the remaining C0 and C1 controls render as a
|
|
// visible 48.00px box in the same browser, so a symbol carrying one
|
|
// does not look like `ETH` and must not be judged a spoof. Widening
|
|
// the strip to \p{Cc} — the obvious over-correction once U+007F is in
|
|
// it — fails this test.
|
|
test("visible control characters do not make a symbol a spoof", () => {
|
|
expect(isSpoofedSymbol(NEL + "ETH", FAKE_ETH_CONTRACT)).toBe(false);
|
|
expect(isSpoofedSymbol(cp(0x0001) + "ETH", FAKE_ETH_CONTRACT)).toBe(
|
|
false,
|
|
);
|
|
expect(isSpoofedSymbol(cp(0x0090) + "ETH", FAKE_ETH_CONTRACT)).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("compatibility forms fold onto the symbol they imitate", () => {
|
|
expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true);
|
|
expect(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true);
|
|
});
|
|
|
|
// The two knowingly open classes, asserted here so that the boundary is
|
|
// a fact in the suite and not a claim in a PR body. A Cyrillic capital
|
|
// Ie is a distinct letter rather than a compatibility variant, so NFKC
|
|
// leaves it alone; and a right-to-left override reverses the rendering
|
|
// of what follows it, which dropping the control character does not
|
|
// undo. Closing either needs a confusables table or a bidi resolver,
|
|
// and both are a separate change from this one.
|
|
test("a Cyrillic homoglyph is knowingly still not caught", () => {
|
|
expect(
|
|
isSpoofedSymbol(CYRILLIC_CAPITAL_IE + "TH", FAKE_ETH_CONTRACT),
|
|
).toBe(false);
|
|
});
|
|
|
|
test("a bidi-reordered symbol is knowingly still not caught", () => {
|
|
expect(isSpoofedSymbol(RLO + "HTE", FAKE_ETH_CONTRACT)).toBe(false);
|
|
});
|
|
|
|
// Normalization does not reach the native-asset exemption, which turns
|
|
// on the absence of a contract address and never on the symbol.
|
|
test("a padded symbol with no contract is still not a spoof", () => {
|
|
expect(isSpoofedSymbol(" ETH ", null)).toBe(false);
|
|
expect(isSpoofedSymbol(NBSP + "ETH", "")).toBe(false);
|
|
});
|
|
|
|
test("a genuine contract still bears its own padded symbol", () => {
|
|
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false);
|
|
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false);
|
|
});
|
|
|
|
// Normalization must not invent a match. Interior ASCII whitespace is
|
|
// left alone: `E T H` renders as `E T H`, not as `ETH`, so folding it
|
|
// would filter a token no user could confuse with the native asset.
|
|
test("a symbol that renders differently is not judged a spoof", () => {
|
|
expect(isSpoofedSymbol("E T H", FAKE_ETH_CONTRACT)).toBe(false);
|
|
expect(isSpoofedSymbol("ETH2", FAKE_ETH_CONTRACT)).toBe(false);
|
|
expect(isSpoofedSymbol("MY ETH", FAKE_ETH_CONTRACT)).toBe(false);
|
|
});
|
|
|
|
// The false-positive question, answered against the shipped data rather
|
|
// than by assertion: no bundled symbol carries whitespace or a
|
|
// non-ASCII character, so the normalization cannot newly filter one.
|
|
// The character class starts at `!` rather than at the space so that it
|
|
// asserts the claim it stands for — `[ -~]` would admit an interior
|
|
// space and let a whitespace-bearing entry through the guard.
|
|
test("no bundled symbol is touched by the normalization", () => {
|
|
for (const [symbol, addresses] of KNOWN_SYMBOLS) {
|
|
expect(symbol).toBe(symbol.trim());
|
|
expect(symbol).toMatch(/^[!-~]+$/);
|
|
if (addresses === null) continue;
|
|
for (const address of addresses) {
|
|
expect(isSpoofedSymbol(symbol, address)).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Issue #276: the guard that was missing. The suite walked KNOWN_SYMBOLS,
|
|
// which is built from TOKENS, so it could only ever assert that the table
|
|
// agrees with itself. Seven symbols appear twice in the bundled list at two
|
|
// different real contracts, and the table kept whichever came first, so the
|
|
// other seven contracts — tokens in our own shipped list, at their own
|
|
// addresses — were judged spoofs and hidden from the balance list, the
|
|
// history and the send selector. That is the over-filtering direction: it
|
|
// hides a holding the user cannot then spend.
|
|
//
|
|
// This walk is over TOKENS, the data the wallet actually ships, so it fails
|
|
// whenever a bundled token would be filtered at its own address no matter
|
|
// which side of the table the mistake is on.
|
|
describe("the shipped token list", () => {
|
|
test("no bundled token is filtered at its own address", () => {
|
|
const filtered = TOKENS.filter((t) =>
|
|
isSpoofedSymbol(t.symbol, t.address),
|
|
).map((t) => t.symbol + " @ " + t.address);
|
|
expect(filtered).toEqual([]);
|
|
});
|
|
|
|
// The third failure mode the issue asks about: a symbol whose table entry
|
|
// names an address that is in neither the table nor the list would be a
|
|
// contract we vouch for and do not ship. There is none, and the table is
|
|
// built from the list, so this asserts the derivation has not acquired a
|
|
// hand-written entry.
|
|
test("every address the table vouches for is a bundled token", () => {
|
|
const bundled = new Set(TOKENS.map((t) => t.address.toLowerCase()));
|
|
for (const [symbol, addresses] of KNOWN_SYMBOLS) {
|
|
if (addresses === null) continue;
|
|
expect(addresses.size).toBeGreaterThan(0);
|
|
for (const address of addresses) {
|
|
expect(address).toBe(address.toLowerCase());
|
|
expect(bundled.has(address)).toBe(true);
|
|
// And it is the token that actually reports that symbol.
|
|
const token = TOKENS.find(
|
|
(t) => t.address.toLowerCase() === address,
|
|
);
|
|
expect(token.symbol.toUpperCase()).toBe(symbol);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Both contracts behind a shared ticker must pass, from either side: a
|
|
// rule that admits only the one the table happens to visit first is the
|
|
// bug, not the fix.
|
|
test("both contracts behind a shared ticker are admitted", () => {
|
|
const bySymbol = new Map();
|
|
for (const t of TOKENS) {
|
|
const upper = t.symbol.toUpperCase();
|
|
if (!bySymbol.has(upper)) bySymbol.set(upper, []);
|
|
bySymbol.get(upper).push(t);
|
|
}
|
|
const shared = [...bySymbol].filter(([, list]) => list.length > 1);
|
|
// The shared tickers are a fact about the shipped data; if a future
|
|
// list has none, this test would silently assert nothing.
|
|
expect(shared.length).toBeGreaterThan(0);
|
|
for (const [, list] of shared) {
|
|
for (const t of list) {
|
|
expect(isSpoofedSymbol(t.symbol, t.address)).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
// The seven from issue #276, named so that the reconciliation is a fact
|
|
// in the suite: each is two real contracts from the same source fetch,
|
|
// and the table now holds both rather than the one that came first.
|
|
test("the seven shared tickers each name both bundled contracts", () => {
|
|
const expected = {
|
|
TON: [
|
|
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1", // Toncoin
|
|
"0x2be5e8c109e2197d077d13a82daead6a9b3433c5", // Tokamak Network
|
|
],
|
|
FRAX: [
|
|
"0x853d955acef822db058eb8505911ed77f175b99e", // Legacy Frax Dollar
|
|
"0x3432b6a60d23ca0dfca7761b7ab56459d9c964d0", // Frax (prev. FXS)
|
|
],
|
|
REUSD: [
|
|
"0x5086bf358635b81d8c47c66d1c8b9e567db70c72", // Re Protocol reUSD
|
|
"0x57ab1e0003f623289cd798b1824be09a793e4bec", // Resupply USD
|
|
],
|
|
EURE: [
|
|
"0x39b8b6385416f4ca36a20319f70d28621895279d", // Monerium EUR emoney
|
|
"0x3231cb76718cdef2155fc47b5286d82e6eda273f", // Monerium EUR emoney [OLD]
|
|
],
|
|
MSUSD: [
|
|
"0x4ba01f22827018b4772cd326c7627fb4956a7c00", // Main Street USD
|
|
"0xab5eb14c09d416f0ac63661e57edb7aecdb9befa", // Metronome Synth USD
|
|
],
|
|
MUSD: [
|
|
"0xaca92e438df0b2401ff60da7e4337b687a2435da", // MetaMask USD
|
|
"0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD
|
|
],
|
|
JPYC: [
|
|
"0x431d5dff03120afa4bdf332c61a6e1766ef37bdb", // JPY Coin
|
|
"0x2370f9d504c7a6e775bf6e14b3f12846b594cd53", // JPY Coin v1
|
|
],
|
|
};
|
|
for (const [symbol, addresses] of Object.entries(expected)) {
|
|
expect([...KNOWN_SYMBOLS.get(symbol)].sort()).toEqual(
|
|
[...addresses].sort(),
|
|
);
|
|
for (const address of addresses) {
|
|
expect(isSpoofedSymbol(symbol, address)).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
// The other direction, on the same symbols: widening the table to hold
|
|
// every bundled address for a ticker must not turn it into a pass for
|
|
// any other contract.
|
|
test("a shared ticker from a third contract is still a spoof", () => {
|
|
const bySymbol = new Map();
|
|
for (const t of TOKENS) {
|
|
const upper = t.symbol.toUpperCase();
|
|
if (!bySymbol.has(upper)) bySymbol.set(upper, []);
|
|
bySymbol.get(upper).push(t);
|
|
}
|
|
for (const [symbol, list] of bySymbol) {
|
|
if (list.length < 2) continue;
|
|
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("surface 1: the transaction history", () => {
|
|
function fakeEthTransfer() {
|
|
return {
|
|
hash: "0x" + "1".repeat(64),
|
|
symbol: "ETH",
|
|
contractAddress: FAKE_ETH_CONTRACT,
|
|
holders: 900000,
|
|
valueGwei: null,
|
|
isContractCall: false,
|
|
};
|
|
}
|
|
|
|
test("a fake ETH token transfer is filtered", () => {
|
|
const result = filterTransactions([fakeEthTransfer()], {
|
|
hideSpoofedSymbols: true,
|
|
hideFraudContracts: true,
|
|
hideLowHolderTokens: true,
|
|
hideDustTransactions: true,
|
|
dustThresholdGwei: 100000,
|
|
});
|
|
expect(result.transactions).toEqual([]);
|
|
});
|
|
|
|
// Issue #260 on this surface: the same transfer with a padded symbol.
|
|
test("a padded fake ETH token transfer is filtered too", () => {
|
|
const padded = { ...fakeEthTransfer(), symbol: " ETH " };
|
|
const result = filterTransactions([padded], {
|
|
hideSpoofedSymbols: true,
|
|
hideFraudContracts: true,
|
|
hideLowHolderTokens: true,
|
|
hideDustTransactions: true,
|
|
dustThresholdGwei: 100000,
|
|
});
|
|
expect(result.transactions).toEqual([]);
|
|
// The contract is learned as fraudulent, exactly as for the
|
|
// unpadded symbol: the padding must not cost the blocklist entry.
|
|
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
|
});
|
|
|
|
test("a real native ETH transfer survives", () => {
|
|
const native = {
|
|
hash: "0x" + "2".repeat(64),
|
|
symbol: "ETH",
|
|
contractAddress: null,
|
|
holders: null,
|
|
valueGwei: 5000000,
|
|
isContractCall: false,
|
|
};
|
|
const result = filterTransactions([native], {
|
|
hideSpoofedSymbols: true,
|
|
hideFraudContracts: true,
|
|
hideLowHolderTokens: true,
|
|
hideDustTransactions: true,
|
|
dustThresholdGwei: 100000,
|
|
});
|
|
expect(result.transactions).toEqual([native]);
|
|
});
|
|
});
|
|
|
|
describe("surface 2: the Send token selector", () => {
|
|
let select;
|
|
|
|
function render(tokenBalances) {
|
|
select = { innerHTML: "", children: [] };
|
|
select.appendChild = (child) => select.children.push(child);
|
|
globalThis.document = {
|
|
getElementById: (id) => (id === "send-token" ? select : null),
|
|
createElement: () => ({ value: "", textContent: "" }),
|
|
};
|
|
renderSendTokenSelect({
|
|
address: "0x" + "a".repeat(40),
|
|
tokenBalances,
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
state.fraudContracts = [];
|
|
state.hideLowHolderTokens = true;
|
|
});
|
|
|
|
test("a fake ETH token is not selectable", () => {
|
|
render([
|
|
{
|
|
address: FAKE_ETH_CONTRACT,
|
|
symbol: "ETH",
|
|
decimals: 18,
|
|
balance: "0.005",
|
|
holders: 900000,
|
|
},
|
|
]);
|
|
expect(select.children).toEqual([]);
|
|
});
|
|
|
|
// Issue #260 on this surface: the option text is rendered into HTML,
|
|
// which collapses the padding, so an unfiltered padded token would sit
|
|
// in the selector reading exactly `ETH`.
|
|
test("a padded fake ETH token is not selectable either", () => {
|
|
render([
|
|
{
|
|
address: FAKE_ETH_CONTRACT,
|
|
symbol: " ETH ",
|
|
decimals: 18,
|
|
balance: "0.005",
|
|
holders: 900000,
|
|
},
|
|
]);
|
|
expect(select.children).toEqual([]);
|
|
});
|
|
|
|
test("a genuine token with a padded symbol stays selectable", () => {
|
|
render([
|
|
{
|
|
address: USDC_CONTRACT,
|
|
symbol: " USDC ",
|
|
decimals: 6,
|
|
balance: "12.5",
|
|
holders: 900000,
|
|
},
|
|
]);
|
|
expect(select.children).toHaveLength(1);
|
|
expect(select.children[0].value).toBe(USDC_CONTRACT);
|
|
});
|
|
|
|
test("native ETH remains the always-present option", () => {
|
|
render([]);
|
|
expect(select.innerHTML).toBe('<option value="ETH">ETH</option>');
|
|
});
|
|
});
|
|
|
|
describe("surface 3: the balance list", () => {
|
|
function respondWith(items) {
|
|
debugFetch.mockImplementation(async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: "OK",
|
|
json: async () => items,
|
|
}));
|
|
}
|
|
|
|
function fakeEthItem(overrides = {}) {
|
|
return {
|
|
value: "5000000000000000",
|
|
token: {
|
|
type: "ERC-20",
|
|
address_hash: FAKE_ETH_CONTRACT,
|
|
symbol: "ETH",
|
|
name: "Ethereum",
|
|
decimals: "18",
|
|
holders_count: "900000",
|
|
...overrides,
|
|
},
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
debugFetch.mockReset();
|
|
});
|
|
|
|
// The bug in issue #235: this token cleared the balance list's own
|
|
// 1,000-holder floor and was listed as a holding named ETH.
|
|
test("a fake ETH token clearing the holder floor is filtered", async () => {
|
|
respondWith([fakeEthItem()]);
|
|
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
|
});
|
|
|
|
test("tracking the fake token manually does not admit it either", async () => {
|
|
respondWith([fakeEthItem({ holders_count: "0" })]);
|
|
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
|
|
{ address: FAKE_ETH_CONTRACT },
|
|
]);
|
|
expect(balances).toEqual([]);
|
|
});
|
|
|
|
// Issue #260 on this surface: the balance list is where the user forms
|
|
// their belief about what they own, and it renders the symbol into HTML.
|
|
test("a padded fake ETH token is filtered too", async () => {
|
|
respondWith([fakeEthItem({ symbol: " ETH " })]);
|
|
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
|
});
|
|
|
|
test("a fake ETH token padded with a no-break space is filtered", async () => {
|
|
respondWith([fakeEthItem({ symbol: NBSP + "ETH" + NBSP })]);
|
|
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
|
});
|
|
|
|
// The false-positive direction on the surface that matters most: a real
|
|
// holding whose symbol happens to carry padding is still listed, and the
|
|
// list still shows the symbol the token actually reports.
|
|
test("a genuine token with a padded symbol is not newly filtered", async () => {
|
|
respondWith([
|
|
fakeEthItem({
|
|
address_hash: USDC_CONTRACT,
|
|
symbol: " USDC ",
|
|
name: "USD Coin",
|
|
decimals: "6",
|
|
}),
|
|
]);
|
|
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
|
expect(balances).toHaveLength(1);
|
|
expect(balances[0].symbol).toBe(" USDC ");
|
|
});
|
|
|
|
test("a genuine token keeps its place in the list", async () => {
|
|
respondWith([
|
|
fakeEthItem({
|
|
address_hash: USDC_CONTRACT,
|
|
symbol: "USDC",
|
|
name: "USD Coin",
|
|
decimals: "6",
|
|
}),
|
|
]);
|
|
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
|
expect(balances).toHaveLength(1);
|
|
expect(balances[0].symbol).toBe("USDC");
|
|
});
|
|
|
|
// The trap in this change: the user's real ETH balance is not an ERC-20
|
|
// and is fetched over RPC in refreshBalances, so it never passes through
|
|
// this loop at all. An explorer row that is not an ERC-20 is dropped
|
|
// before the symbol rule is consulted.
|
|
test("a non-ERC-20 row claiming ETH never reaches the symbol rule", async () => {
|
|
respondWith([fakeEthItem({ type: "ERC-721" })]);
|
|
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
|
});
|
|
|
|
// The adjacent finding from the same review as issue #260: the type gate
|
|
// compared exactly, so an explorer that ever varied the casing would
|
|
// silently drop a real holding before any filter ran. The comparison is
|
|
// now case-insensitive, which changes nothing about which types are
|
|
// admitted.
|
|
test("a differently-cased ERC-20 type still lists a real holding", async () => {
|
|
respondWith([
|
|
fakeEthItem({
|
|
type: "erc-20",
|
|
address_hash: USDC_CONTRACT,
|
|
symbol: "USDC",
|
|
name: "USD Coin",
|
|
decimals: "6",
|
|
}),
|
|
]);
|
|
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
|
expect(balances).toHaveLength(1);
|
|
expect(balances[0].symbol).toBe("USDC");
|
|
});
|
|
|
|
test("case insensitivity does not admit another token type", async () => {
|
|
respondWith([fakeEthItem({ type: "erc-721" })]);
|
|
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
|
respondWith([fakeEthItem({ type: "ERC-20-EXTRA" })]);
|
|
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
|
});
|
|
|
|
// The money test: the user holds real ETH and has been airdropped a fake
|
|
// ETH ERC-20. The fake is gone from the list of tokens; the real balance
|
|
// is exactly what the node reported.
|
|
test("the real native ETH balance survives a fake ETH airdrop", async () => {
|
|
respondWith([fakeEthItem()]);
|
|
const addr = { address: HOLDER };
|
|
await refreshBalances(
|
|
[{ addresses: [addr] }],
|
|
"https://rpc.example.invalid",
|
|
BLOCKSCOUT,
|
|
[],
|
|
);
|
|
expect(addr.balance).toBe("1.2345");
|
|
expect(addr.tokenBalances).toEqual([]);
|
|
});
|
|
|
|
test("no test in this file performed a network request", () => {
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|