networks.js gives each network a nativeCurrency (ETH, SepoliaETH) and nothing read it: every screen wrote ETH. The wallet's balances and the Send and confirmation screens now use the active network's. A transaction's figures use the network its chain id names, through nativeCurrencyByChainId(): the approval value and fee, the wait, success and error screens, history entries, the detail screen and the fee-limit refusal, so a site switching networks cannot make one read as another network's coin. The "ETH" that selectedToken and txInfo.token hold is the native token's id and is unchanged. A token reporting any network's nativeCurrency is a spoof, and the detail screen calls an entry a token transfer when it has a token contract. Model: opus-5-5
115 lines
5.7 KiB
JavaScript
115 lines
5.7 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 one such entry, and every network's
|
|
// `nativeCurrency` in networks.js (`SepoliaETH`) is another, on every network.
|
|
//
|
|
// 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();
|
|
}
|
|
|
|
// The characters that paint nothing; normalizeSymbol below says which they
|
|
// are. The signature screen marks them in a personal message
|
|
// (src/popup/views/approval.js).
|
|
const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
|
|
|
|
// 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(INVISIBLE_CHARACTERS, "")
|
|
.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 = {
|
|
INVISIBLE_CHARACTERS,
|
|
isSpoofedSymbol,
|
|
};
|