Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.
So the reachability goes rather than a sixth call site.
The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile. The
write is the whole record, and updateState()'s header now names what that costs:
a popup write landing inside that one-round-trip window is reverted.
- Every handler takes one snapshot and answers from it, including the address
it names: activeAddressOf(s) replaced a second, later storage read that
could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
chainSwitch.js, which keeps the singleton path for the popup) inside
updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
balances that came back by address, so it never publishes an object other
in-flight work holds, and a wallet added or deleted during the round trip
survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
snapshot. They used to come from different moments, so a chain switch
committed in between moved the endpoint under an artifact already verified
against the old chain.
getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.
The prohibition is enforced mechanically, not by review, and it is enforced by
the bundler rather than by a guess at what the bundler does. The table of
modules an entry point's bundle may not contain lives in
script/lib/forbiddenBundleInputs.js — one copy, read by both layers that act on
it — and build.js's assertNoForbiddenInputs() fails the build when esbuild's
metafile reports src/shared/state.js as an input of a background bundle, naming
the import chain from the metafile's own graph. That is the resolution the
shipped bundle was built from, so no specifier syntax, no hop and no resolution
rule can slip past it; Dockerfile:42 runs make build, so it holds in CI.
A background entry point the table does not name fails the build as well. The
five defects were accidents, and so is adding a second worker entry point
without knowing that a table elsewhere needs a line for it: entry points under
src/background/ are prohibited by default and must be listed, rather than
protected only when someone remembers. That prefix is the build's only notion of
"the background", and eslint.config.js scopes the lint rule from the same
constant so the two layers cannot disagree about it.
Every way the table can rot is a failure rather than a quiet pass: a key no
bundled entry point matched, a listed module this build bundled nowhere, and an
entry that lists no modules. The second is what makes a rename of
src/shared/state.js loud instead of silently disarming the check, and it is
stronger than an existsSync() because it also fails when the module is still
there but has dropped out of every bundle. The third is refused at require time,
where the table is defined, because an empty list also empties the lint rule's
forbidden set — one character, and a plain require of the singleton in the
worker was green in make test, make lint and make build alike.
An entry is recorded as checked only once its bundle's inputs are in hand. It
used to be recorded before the output lookup that produces them, so an early
return past that point left both halves of the guarantee satisfied by a bundle
nothing had examined.
What the assertion does NOT cover is a COPY of the singleton at another path: it
is keyed by path, so a copy builds and lints clean. That is stated where the
table lives, with what the residual actually is — a copy carries the singleton's
own guard, so an unloaded read is a loud StateNotLoadedError and defects 1-3
cannot recur silently, but a copy carries loadState() too, so defects 4 and 5
(a stale read several awaits after a load, a load detaching objects an in-flight
handler is mutating) would recur over it in silence.
make check does not run make build, so the assertion is unit tested against
synthetic metafiles in tests/buildForbiddenInputs.test.js: build.js runs its
build() only as a program now and exports the checks. Executing a check in CI
is not testing it — without that file, inverting the condition leaves every
check in this repo green with the singleton back in the worker. Each vacuous
pass above has a case, including the output lookup that finds nothing, the empty
list, the unlisted second entry point, and recordBundledInputs() itself, which
every other case used to hand-seed.
A custom ESLint rule walks the CommonJS require graph from every src/background/
file and reports the same thing in the editor, before a full bundle. It reads
the same table, and it matches specifiers textually, so it is best-effort fast
feedback and not the guarantee — two earlier revisions of it shipped holes (a
template literal, a dynamic import(), a comment inside the call, a directory
resolved through package.json main). Those are covered now and pinned by
tests/backgroundStateLintRule.test.js. Two shapes it does not report are pinned
there as asserted non-reports, so the header's list of its bounds is measured
rather than claimed: a computed specifier (require("../shared/" + "state"),
which esbuild constant-folds into the bundle) and a symlink to the module
(esbuild reports the real path). Each is make lint exit 0 and make build exit 2.
Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.
Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.
closes #320
695 lines
28 KiB
JavaScript
695 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",
|
|
"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,
|
|
[],
|
|
"mainnet",
|
|
);
|
|
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();
|
|
});
|
|
});
|