All checks were successful
check / check (push) Successful in 35s
A token whose symbol is " ETH " missed KNOWN_SYMBOLS on all three surfaces
while HTML collapsed the padding and painted it as ETH next to the user's
real ETH. isSpoofedSymbol() now normalizes before the lookup: NFKC, then
every character that paints nothing removed wherever it sits, then trimmed,
then uppercased. All three surfaces inherit it unchanged.
The strip is "renders as nothing", spelled as \p{Cf} plus
\p{Default_Ignorable_Code_Point} plus U+007F. The classes are the spelling
and not the rule, which is why U+007F is named on its own: it is a control
rather than a default-ignorable character, so no class reaches it, yet it
measures 32.00px in the repo's pinned e2e Chromium at 16px sans-serif --
exactly a plain ETH -- so it paints nothing and would otherwise be a live
bypass. The remaining C0/C1 controls measure 48.00px, a visible box, and are
left alone; both directions are pinned by tests, and widening the strip to
\p{Cc} fails the visible-controls test.
Covered: ASCII and Unicode whitespace padding, zero-width and other
invisible characters, and compatibility variants such as fullwidth letters.
Knowingly left open, and asserted as open in the suite: confusables that are
distinct letters (Cyrillic capital Ie), bidi reordering, the visible
controls, and interior whitespace, which renders differently and so is not
the confusion.
No symbol in KNOWN_SYMBOLS or the bundled token list contains whitespace or
a non-ASCII character, so nothing legitimate is newly filtered; a test walks
the whole table and asserts it.
The balance list's token-type gate is now case-insensitive. It compared
exactly, so an explorer writing "erc-20" would silently drop a real holding
before any filter ran. Which types are admitted is unchanged.
568 lines
22 KiB
JavaScript
568 lines
22 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 { 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, address] of KNOWN_SYMBOLS) {
|
|
expect(symbol).toBe(symbol.trim());
|
|
expect(symbol).toMatch(/^[!-~]+$/);
|
|
if (address === null) continue;
|
|
expect(isSpoofedSymbol(symbol, address)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|