fix: filter a fake ETH token from the balance list too (closes #235)
All checks were successful
check / check (push) Successful in 26s

KNOWN_SYMBOLS maps "ETH" to null, and the three surfaces that show tokens
disagreed about what that means. The transaction history and the Send
token selector read it as "no contract may bear this symbol" and filtered
a fake ETH ERC-20; the balance list's guard required a non-null mapping,
so the same token was listed as a holding named ETH next to the user's
real ETH. That is the surface where the user forms their belief about
what they own.

The rule now lives in src/shared/symbolSpoof.js and all three sites call
it, so a fourth reading is not available to a future call site. A symbol
mapped to null belongs to the native asset and may be borne by no
contract at all; the native exemption is "has no contract address", not
"the symbol is ETH", so a second null-mapped entry needs no call-site
change.

The user's real ETH balance is untouched: it is read over RPC in
refreshBalances and never enters fetchTokenBalances, whose loop only
considers explorer rows of type ERC-20.

tests/symbolSpoof.test.js drives the same fake ETH token through all
three surfaces plus refreshBalances, which reports the native balance
unchanged while the fake token is gone. Its two balance-list cases were
watched failing against the unmodified call sites first.
This commit is contained in:
2026-08-12 08:46:02 +00:00
parent bd4bdcafc7
commit 88a5c92547
7 changed files with 383 additions and 54 deletions

View File

@@ -221,6 +221,7 @@ src/
prices.js — ETH/USD and token/USD via CoinDesk API
scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage)
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
tokenList.js — top ERC-20 tokens by market cap (hardcoded)
transactions.js — tx history fetching + anti-poisoning filters
uniswap.js — Uniswap Universal Router calldata decoder
@@ -450,11 +451,12 @@ Which tokens an address shows is decided by `fetchTokenBalances()` in
tokens do appear without the user adding them. An ERC-20 is shown when its
balance is nonzero and it is in the bundled known-token list, is tracked by the
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
list from any other contract address is always dropped. That filter is
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
the transaction history and the send-screen token selector, not this list.
Tracked tokens with a zero balance are listed as well while "Show tracked tokens
with zero balance" is on.
list from any other contract address is always dropped, and so is any token
claiming a symbol that belongs to the native asset and therefore has no
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
tokens with fewer than 1,000 holders" setting governs the transaction history
and the send-screen token selector, not this list. Tracked tokens with a zero
balance are listed as well while "Show tracked tokens with zero balance" is on.
#### Navigation
@@ -1246,14 +1248,15 @@ indexes it as a real token transfer.
that is the only thing that populates it. In the transaction history the check
is the "Hide fake tokens impersonating a known symbol" setting, on by default;
with it off, spoofed transfers are shown and no new blocklist entries are
learned from them. The send-screen token selector applies the same check
unconditionally, because it decides which tokens the user can act on rather
than what the history displays. The balance list applies it unconditionally
too, but not identically: it exempts symbols that `KNOWN_SYMBOLS` maps to
`null`, and `"ETH"` is the only one. So the fake "Ethereum" token above is
filtered from the transaction history and from the send selector, but a
fake-`ETH` ERC-20 that clears the balance list's own 1,000-holder floor — or
that the user tracked manually — is still shown in the balance list.
learned from them. The send-screen token selector and the balance list apply
the same check unconditionally, because they decide which tokens the user can
act on and what the user believes they own rather than what the history
displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`,
so they cannot answer the question differently. A symbol the list maps to no
contract at all — `"ETH"`, the native asset, is the only one — may be borne by
no contract, so every ERC-20 claiming it is a spoof on all three. The user's
real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees
it.
- **Low-holder token filtering**: Token transfers from ERC-20 contracts with
fewer than 1,000 holders are hidden from transaction history by default.
@@ -1289,13 +1292,12 @@ indexes it as a real token transfer.
a sharp tool — users who understand the risks can configure the wallet to show
everything unfiltered, unix-style. All four settings govern the transaction
history; what else each one reaches varies. The known-symbol check also runs
unconditionally on the send-screen token selector, and on the balance list
except for symbols mapped to `null` (`"ETH"` alone), which the balance list
does not filter. The fraud contract blocklist is applied unconditionally on
that selector and is not consulted by the balance list at all. The low-holder
setting also gates the send selector, while the balance list's own
1,000-holder floor is unconditional (see Data Model). The dust threshold
applies to the transaction history alone.
unconditionally on the send-screen token selector and on the balance list, in
both cases identically to the history. The fraud contract blocklist is applied
unconditionally on that selector and is not consulted by the balance list at
all. The low-holder setting also gates the send selector, while the balance
list's own 1,000-holder floor is unconditional (see Data Model). The dust
threshold applies to the transaction history alone.
#### Phishing Domain Protection