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.
109 lines
5.4 KiB
JavaScript
109 lines
5.4 KiB
JavaScript
// The known-symbol spoof rule, in one place.
|
|
//
|
|
// A token that borrows a known symbol from a contract that is not the one
|
|
// that symbol belongs to is a spoof, and the wallet hides it. Three surfaces
|
|
// ask that question — the transaction history, the Send token selector and
|
|
// the balance list — and they must answer it identically: a token the history
|
|
// calls fake while the balance list lists it as a holding is worse than
|
|
// either verdict alone, because the balance list is where the user forms
|
|
// their belief about what they own (issue #235).
|
|
//
|
|
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
|
|
// that may bear it, or to null. Null means the symbol belongs to the native
|
|
// asset, which has no contract at all, so no contract may bear it and every
|
|
// one that does is a spoof. "ETH" is the only such entry today; the rule is
|
|
// written so that a second one needs no change here or at any call site.
|
|
//
|
|
// The value is a set because a ticker is not unique: seven symbols in the
|
|
// bundled list belong to two real contracts each, and answering with one of
|
|
// them hid the other one's holders' money (issue #276). Membership, not
|
|
// equality, is therefore the question — but it is the same question, asked of
|
|
// a table that can now state the truth. Every address in a set is one the
|
|
// wallet ships as a real token; a contract outside the set is still a spoof.
|
|
//
|
|
// The symbol is attacker-controlled — it is whatever the ERC-20 contract
|
|
// returns — so the lookup is done on a normalized form (issue #260): the
|
|
// question is whether the symbol reaches the user's eye as a known one,
|
|
// since that is what the user acts on.
|
|
|
|
const { KNOWN_SYMBOLS } = require("./tokenList");
|
|
|
|
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
|
// over the address, not part of its identity.
|
|
function normalizeAddress(addr) {
|
|
return (addr || "").toLowerCase();
|
|
}
|
|
|
|
// Fold a symbol onto what a user actually sees, and no further:
|
|
//
|
|
// NFKC collapses compatibility variants that render as the ASCII
|
|
// letters they imitate — fullwidth ETH, styled mathematical
|
|
// letters — and maps the non-ASCII spaces onto U+0020.
|
|
// strip drops what paints nothing: \p{Cf} plus
|
|
// \p{Default_Ignorable_Code_Point} plus U+007F. That covers
|
|
// the format characters (zero-width space, joiner and
|
|
// non-joiner, word joiner, soft hyphen, byte-order mark, bidi
|
|
// marks and overrides), the variation selectors, the Hangul
|
|
// fillers, and DELETE. Removed everywhere, not merely at the
|
|
// ends.
|
|
// trim removes surrounding whitespace, which HTML collapses:
|
|
// `" ETH "` is painted next to the user's real ETH as `ETH`.
|
|
// toUpperCase makes the comparison case-insensitive, as before.
|
|
//
|
|
// The rule is "strip what paints nothing". The Unicode classes are how
|
|
// that is spelled, not what it means, which is why U+007F is named on its
|
|
// own: it is a control rather than a default-ignorable character, so no
|
|
// class here reaches it, yet it paints nothing all the same. Measured in
|
|
// the repo's pinned e2e Chromium (16px sans-serif, plain `ETH` = 32.00px,
|
|
// so an invisible prefix leaves 32.00px):
|
|
//
|
|
// U+007F, U+3164, U+115F, U+FE0F, U+FE00 32.00px — invisible
|
|
// U+FFA0 40.00px — a box
|
|
// U+1160 48.00px — a box
|
|
// U+0001, U+0085, U+0090 48.00px — a box
|
|
//
|
|
// U+1160 and U+FFA0 are `Default_Ignorable_Code_Point` members that font
|
|
// fallback nonetheless draws, and they are stripped anyway: erring toward
|
|
// hiding a token that does not look like `ETH` is the harmless direction of
|
|
// the two. The other controls are left alone for the same reason read the
|
|
// other way — a symbol carrying a visible box does not reach the eye as
|
|
// `ETH`, so filtering it would hide a token the user could not have
|
|
// confused with the native asset.
|
|
//
|
|
// Deliberately not folded, and asserted as open in tests/symbolSpoof.test.js:
|
|
// interior whitespace (`E T H` renders as `E T H`, so folding it would filter
|
|
// a token nobody could confuse with the native asset), confusables that are
|
|
// distinct letters rather than compatibility variants (Cyrillic capital Ie,
|
|
// U+0415; Greek capital Epsilon, U+0395), bidi reordering, which needs the
|
|
// bidi algorithm rather than a character filter, and the visible controls.
|
|
//
|
|
// This decides only how the question is asked. Nothing here changes what a
|
|
// surface displays; a token still shows the symbol it reports.
|
|
function normalizeSymbol(symbol) {
|
|
return String(symbol || "")
|
|
.normalize("NFKC")
|
|
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
|
|
.trim()
|
|
.toUpperCase();
|
|
}
|
|
|
|
// True when a token bearing `symbol` from contract `contractAddress` is
|
|
// impersonating a known symbol.
|
|
//
|
|
// An empty contract address is the native asset, which is never a spoof:
|
|
// this is what keeps the user's real ETH out of the rule, and it holds for
|
|
// any symbol that becomes null-mapped later, not just for ETH.
|
|
function isSpoofedSymbol(symbol, contractAddress) {
|
|
const contract = normalizeAddress(contractAddress);
|
|
if (!contract) return false;
|
|
const sym = normalizeSymbol(symbol);
|
|
if (!KNOWN_SYMBOLS.has(sym)) return false;
|
|
const legit = KNOWN_SYMBOLS.get(sym);
|
|
if (legit === null) return true;
|
|
return !legit.has(contract);
|
|
}
|
|
|
|
module.exports = {
|
|
isSpoofedSymbol,
|
|
};
|