fix: a shared ticker no longer hides one of its two real tokens (closes #276)
All checks were successful
check / check (push) Successful in 35s
All checks were successful
check / check (push) Successful in 35s
KNOWN_SYMBOLS maps a symbol to the set of contract addresses that bear it, instead of to one of them. A ticker is not unique, and the bundled list proves it: seven of its 512 tokens -- FRAX, REUSD, TON, EURE, MSUSD, MUSD and JPYC -- share a symbol with another bundled entry at a different real contract. The table is built from that list first-wins, so it kept the earlier entry of each pair and the later one was judged a spoof of its own symbol at its own address. A user holding any of the seven saw it filtered out of the balance list, the transaction history and the send token selector, and so could not spend it through the UI. Both contracts of every pair come from the same source fetch (CoinGecko, 2026-02-27, decimals verified on-chain), so neither is stale relative to the other and there is nothing to prefer between them. The fix is therefore in the shape of the table rather than in its contents: no address was picked and none was dropped. isSpoofedSymbol() asks set membership where it asked equality, which does not loosen the rule -- every address in a set is one the wallet ships as a real token, and a contract outside the set is still a spoof. The native-asset entry stays null and still means no contract may bear the symbol. The suite walked KNOWN_SYMBOLS, which is derived from TOKENS, so it could only assert that the table agreed with itself. It now also walks TOKENS asserting that no bundled token is filtered at its own address -- the walk that would have caught this -- pins both contracts of each of the seven by address, asserts a third contract bearing a shared ticker is still filtered, and asserts every address the table vouches for is a bundled token reporting that symbol.
This commit is contained in:
@@ -8,12 +8,19 @@
|
||||
// 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 lowercased contract address 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
|
||||
// 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,
|
||||
@@ -93,7 +100,7 @@ function isSpoofedSymbol(symbol, contractAddress) {
|
||||
if (!KNOWN_SYMBOLS.has(sym)) return false;
|
||||
const legit = KNOWN_SYMBOLS.get(sym);
|
||||
if (legit === null) return true;
|
||||
return contract !== normalizeAddress(legit);
|
||||
return !legit.has(contract);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -3607,14 +3607,33 @@ for (const t of TOKENS) {
|
||||
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
|
||||
}
|
||||
|
||||
// Build a map of symbol (uppercased) -> legitimate contract address (lowercased).
|
||||
// Used for spoofed-symbol detection. "ETH" maps to null (native token).
|
||||
// Build a map of symbol (uppercased) -> the set of contract addresses
|
||||
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
|
||||
// "ETH" maps to null: the native asset has no contract, so no contract may
|
||||
// bear its symbol.
|
||||
//
|
||||
// The value is a set and not a single address because tickers are not unique
|
||||
// and the list above proves it: seven of these 512 tokens share a symbol with
|
||||
// another entry — FRAX, REUSD, TON, EURE, MSUSD, MUSD and JPYC — at two
|
||||
// different real contracts each, all of them from the same source fetch. A
|
||||
// one-address-per-symbol table can only answer that by picking a winner, and
|
||||
// the loser is then a token in our own bundled list that the spoof filter
|
||||
// hides from the balance list, the history and the send selector at its own
|
||||
// address, so the user cannot spend it (issue #276). Naming every address
|
||||
// that bears the symbol is the only shape that says what is true; it does not
|
||||
// loosen the rule, because a contract outside the set is still a spoof.
|
||||
const KNOWN_SYMBOLS = new Map();
|
||||
KNOWN_SYMBOLS.set("ETH", null);
|
||||
for (const t of TOKENS) {
|
||||
const upper = t.symbol.toUpperCase();
|
||||
if (!KNOWN_SYMBOLS.has(upper)) {
|
||||
KNOWN_SYMBOLS.set(upper, t.address.toLowerCase());
|
||||
KNOWN_SYMBOLS.set(upper, new Set());
|
||||
}
|
||||
const addresses = KNOWN_SYMBOLS.get(upper);
|
||||
// A null entry is the native asset and stays null: an ERC-20 that reports
|
||||
// the native symbol does not thereby become entitled to it.
|
||||
if (addresses !== null) {
|
||||
addresses.add(t.address.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user