fix: a fake token impersonating ETH is filtered from history and send, but still shows in the balance list #235

Closed
opened 2026-08-11 15:20:53 +02:00 by clawbot · 2 comments
Collaborator

KNOWN_SYMBOLS.set("ETH", null) (src/shared/tokenList.js:3613) is the only null-mapped entry in the known-symbol table, and the three spoof checks treat it inconsistently:

  • src/shared/transactions.js:245if (legit === null) return true; → a fake ETH IS caught in transaction history
  • src/popup/views/send.js:122 — same → caught in the send selector
  • src/shared/balances.js:84-89legitAddr !== undefined && legitAddr !== null && tokenAddr !== legitAddr → a null mapping fails the guard, so a fake ETH is NOT caught in the balance list

So an ERC-20 calling itself ETH is hidden from history and cannot be selected to send, but appears in the user's balance list as a holding named ETH.

That is the worst of the three surfaces to miss. The balance list is where a user forms their belief about what they own, and a token displaying as ETH next to their real ETH is precisely the confusion the known-symbol check exists to prevent. It is also the exact scenario the README uses as its worked example for this filter.

Pre-existing; not introduced by #176. Found by the independent review of #226, which is separately correcting the documentation to describe the current behaviour.

The question to settle first

Establish whether the balances.js exemption is a deliberate native-token special case or an oversight. ETH being the sole null entry suggests the null was meant to mean "the native asset has no contract address", and the other two call sites read that as "anything claiming to be it is a spoof" while balances.js reads it as "no check possible". One of those readings is wrong.

If the code cannot settle it, say so and escalate rather than guessing — but the consequence strongly favours filtering.

Implementation requirements

  • Make the three call sites agree on what a null mapping means, rather than patching balances.js alone.
  • A null-mapped symbol should mean "no legitimate contract can bear this symbol, so any token bearing it is a spoof" — that is what the other two sites already do and what protects the user.
  • Preserve the real native ETH balance, which is not an ERC-20 and must not be caught by this.
  • Check whether any other symbol could become null-mapped later and make the behaviour correct by construction rather than by ETH being the only case.

Definition of done

  • An ERC-20 with symbol ETH and a contract address is filtered from the balance list.
  • The user's real native ETH balance is unaffected.
  • All three surfaces — history, send selector, balance list — agree on null-mapped symbols.
  • Tests cover a fake ETH token on all three surfaces, demonstrated failing against the current code first.
  • The README statements about the filter's reach match the result.
  • TODO.md updated in the same commit.
  • make check passes.
`KNOWN_SYMBOLS.set("ETH", null)` (`src/shared/tokenList.js:3613`) is the only null-mapped entry in the known-symbol table, and the three spoof checks treat it inconsistently: - `src/shared/transactions.js:245` — `if (legit === null) return true;` → a fake `ETH` IS caught in transaction history - `src/popup/views/send.js:122` — same → caught in the send selector - `src/shared/balances.js:84-89` — `legitAddr !== undefined && legitAddr !== null && tokenAddr !== legitAddr` → a null mapping fails the guard, so a fake `ETH` is **NOT** caught in the balance list So an ERC-20 calling itself `ETH` is hidden from history and cannot be selected to send, but appears in the user's balance list as a holding named ETH. That is the worst of the three surfaces to miss. The balance list is where a user forms their belief about what they own, and a token displaying as `ETH` next to their real ETH is precisely the confusion the known-symbol check exists to prevent. It is also the exact scenario the README uses as its worked example for this filter. Pre-existing; not introduced by https://git.eeqj.de/sneak/AutistMask/issues/176. Found by the independent review of https://git.eeqj.de/sneak/AutistMask/pulls/226, which is separately correcting the documentation to describe the current behaviour. ## The question to settle first Establish whether the `balances.js` exemption is a deliberate native-token special case or an oversight. `ETH` being the sole null entry suggests the null was meant to mean "the native asset has no contract address", and the other two call sites read that as "anything claiming to be it is a spoof" while `balances.js` reads it as "no check possible". One of those readings is wrong. If the code cannot settle it, say so and escalate rather than guessing — but the consequence strongly favours filtering. ## Implementation requirements - Make the three call sites agree on what a null mapping means, rather than patching `balances.js` alone. - A null-mapped symbol should mean "no legitimate contract can bear this symbol, so any token bearing it is a spoof" — that is what the other two sites already do and what protects the user. - Preserve the real native ETH balance, which is not an ERC-20 and must not be caught by this. - Check whether any other symbol could become null-mapped later and make the behaviour correct by construction rather than by ETH being the only case. ## Definition of done - [ ] An ERC-20 with symbol `ETH` and a contract address is filtered from the balance list. - [ ] The user's real native ETH balance is unaffected. - [ ] All three surfaces — history, send selector, balance list — agree on null-mapped symbols. - [ ] Tests cover a fake `ETH` token on all three surfaces, demonstrated failing against the current code first. - [ ] The README statements about the filter's reach match the result. - [ ] `TODO.md` updated in the same commit. - [ ] `make check` passes.
clawbot added this to the 1.0.0 milestone 2026-08-11 15:20:53 +02:00
Author
Collaborator

The question, settled: oversight, not a deliberate exemption

The commit that added the balance-list check, 5af8a78 "Filter spam tokens from
balance display", says in its own message:

> Also rejects tokens spoofing a known symbol from a different contract address
> (same check used for transaction filtering).

It was written to be the same check as transactions.js, and it is not. The
legitAddr !== null clause reads as a reflexive nullish guard rather than a
native-token carve-out: the author states the intent to duplicate a check that
had already shipped the opposite reading a day earlier in b5b4f75
(if (legit === null) return true; // "ETH" as ERC-20 is always fake).

Two further points against "deliberate": the exemption is nowhere commented,
where the same file comments its holder-count reasoning at length; and nothing
in the balance list needs it, because the native ETH balance never enters that
loop — it comes from provider.getBalance() in refreshBalances, while
fetchTokenBalances iterates explorer rows and skips anything that is not
item.token.type === "ERC-20". The clause protects nothing and costs the check.

Plan

Extract the rule to one module, src/shared/symbolSpoof.js, the way
#230 extracted
src/shared/holders.js, and have all three sites call it:

  • isSpoofedSymbol(symbol, contractAddress) — no contract address means the
    native asset, which is never a spoof; a known symbol mapped to null means no
    contract may bear it, so any contract bearing it is a spoof; otherwise compare
    addresses case-insensitively.
  • Native safety is by construction and not by ETH being the only null entry:
    the native guard is "has no contract", which holds for any symbol that becomes
    null-mapped later.
  • src/shared/transactions.js, src/popup/views/send.js and
    src/shared/balances.js lose their local copies and call the module.
  • Tests for a fake ETH ERC-20 on all three surfaces, written and watched
    failing on the balance list against unmodified next first.
  • README: the two passages describing the balance list's null exemption become
    a statement that all three surfaces apply the check identically.
## The question, settled: oversight, not a deliberate exemption The commit that added the balance-list check, `5af8a78` "Filter spam tokens from balance display", says in its own message: > Also rejects tokens spoofing a known symbol from a different contract address > (same check used for transaction filtering). It was written to be the same check as `transactions.js`, and it is not. The `legitAddr !== null` clause reads as a reflexive nullish guard rather than a native-token carve-out: the author states the intent to duplicate a check that had already shipped the opposite reading a day earlier in `b5b4f75` (`if (legit === null) return true; // "ETH" as ERC-20 is always fake`). Two further points against "deliberate": the exemption is nowhere commented, where the same file comments its holder-count reasoning at length; and nothing in the balance list needs it, because the native ETH balance never enters that loop — it comes from `provider.getBalance()` in `refreshBalances`, while `fetchTokenBalances` iterates explorer rows and skips anything that is not `item.token.type === "ERC-20"`. The clause protects nothing and costs the check. ## Plan Extract the rule to one module, `src/shared/symbolSpoof.js`, the way [#230](https://git.eeqj.de/sneak/AutistMask/issues/230) extracted `src/shared/holders.js`, and have all three sites call it: - `isSpoofedSymbol(symbol, contractAddress)` — no contract address means the native asset, which is never a spoof; a known symbol mapped to `null` means no contract may bear it, so any contract bearing it is a spoof; otherwise compare addresses case-insensitively. - Native safety is by construction and not by `ETH` being the only null entry: the native guard is "has no contract", which holds for any symbol that becomes null-mapped later. - `src/shared/transactions.js`, `src/popup/views/send.js` and `src/shared/balances.js` lose their local copies and call the module. - Tests for a fake `ETH` ERC-20 on all three surfaces, written and watched failing on the balance list against unmodified `next` first. - README: the two passages describing the balance list's `null` exemption become a statement that all three surfaces apply the check identically.
Author
Collaborator

Built as planned in #257; that
PR body is the record of what changed and why.

Verification: the two balance-list cases in tests/symbolSpoof.test.js were
watched failing with the module in place and the three call sites untouched —
the history and Send selector cases passed alongside them, which is the
disagreement itself — and pass after the call sites were switched over. make check runs green in the container via script/cibuild on the rebased branch:
20 suites, 434 tests, Prettier clean. refreshBalances is driven end to end
against a balance list whose only row is a fake ETH; the native balance comes
back exactly as the node reported it.

Built as planned in [#257](https://git.eeqj.de/sneak/AutistMask/pulls/257); that PR body is the record of what changed and why. Verification: the two balance-list cases in `tests/symbolSpoof.test.js` were watched failing with the module in place and the three call sites untouched — the history and Send selector cases passed alongside them, which is the disagreement itself — and pass after the call sites were switched over. `make check` runs green in the container via `script/cibuild` on the rebased branch: 20 suites, 434 tests, Prettier clean. `refreshBalances` is driven end to end against a balance list whose only row is a fake `ETH`; the native balance comes back exactly as the node reported it.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#235