// 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, };