fix: filter a fake ETH token from the balance list too (closes #235) #257

Merged
clawbot merged 1 commits from fix/issue-235-fake-eth-balance-list into next 2026-08-12 11:10:39 +02:00
Collaborator

Closes #235.

The disagreement

KNOWN_SYMBOLS maps "ETH" to null. The transaction history and the Send
token selector read that as "no contract may bear this symbol" and filtered a
fake ETH ERC-20. The balance list's guard was
legitAddr !== undefined && legitAddr !== null && tokenAddr !== legitAddr, so a
null mapping failed the guard and the same token was listed as a holding named
ETH, next to the user's real ETH.

Verdict: oversight, not a deliberate native-token exemption. The commit that
added the balance-list check, 5af8a78, says in its own message: "Also rejects
tokens spoofing a known symbol from a different contract address (same check
used for transaction filtering)". The check it names had shipped the opposite
reading a day earlier in b5b4f75
(if (legit === null) return true; // "ETH" as ERC-20 is always fake). The
exemption is also uncommented, in a file that comments its holder-count
reasoning at length, and it protects nothing: the native ETH balance never
enters that loop.

The fix

The rule moves to src/shared/symbolSpoof.js and all three sites call it, the
way #230 extracted
src/shared/holders.js. A fourth reading is no longer available to a future
call site.

isSpoofedSymbol(symbol, contractAddress)
  • No contract address means the native asset, which is never a spoof.
  • A known symbol mapped to null belongs to the native asset, so no contract may
    bear it and every contract bearing it is a spoof.
  • Otherwise, a case-insensitive address comparison.

Correct by construction for a future null-mapped symbol: the native exemption is
"has no contract address", not "the symbol is ETH", so a second null entry
inherits both halves of the rule with no call-site change. A test adds one and
asserts it.

src/shared/transactions.js, src/popup/views/send.js and
src/shared/balances.js lose their local copies; behaviour on the first two is
unchanged, which the existing suites confirm.

The real ETH balance is unaffected

It is not an ERC-20. refreshBalances reads it over RPC with
provider.getBalance(); fetchTokenBalances iterates explorer rows and drops
anything that is not item.token.type === "ERC-20", so the rule never sees it.
tests/symbolSpoof.test.js drives refreshBalances end to end with a stubbed
provider against a balance list whose only row is a fake ETH: the native balance
comes back exactly as the node reported it and the fake token is gone.

Tests

tests/symbolSpoof.test.js runs the same fake ETH token — the contract from
the attack documented in the README, given 900,000 holders so no other filter can
catch it — through all three surfaces, plus unit coverage of the rule itself.

Demonstrated failing first: with the module added but the three call sites
untouched, exactly the two balance-list cases failed while the history and
selector cases passed — the disagreement itself, reproduced:

● surface 3: the balance list › a fake ETH token clearing the holder floor is filtered
- Array []
+ Array [ Object { "symbol": "ETH", "balance": "0.005", "holders": 900000, ... } ]

Docs

The two README.md passages describing the balance list's null exemption now
state that all three surfaces apply the check identically and name
src/shared/symbolSpoof.js as the single source; the Data Model paragraph on
fetchTokenBalances() gains the null-mapped case; the source tree gains the new
module. TODO.md updated in the same commit.

Verification

make check green, run in the container via script/cibuild after the rebase
onto next at bd4bdca:

#11 [7/8] RUN make check
#11 11.44 Test Suites: 20 passed, 20 total
#11 11.44 Tests:       434 passed, 434 total
#11 14.79 All matched files use Prettier code style!

An uncached docker build --no-cache run before the rebase showed the same
layer executing rather than replaying cache.

Closes [#235](https://git.eeqj.de/sneak/AutistMask/issues/235). ## The disagreement `KNOWN_SYMBOLS` maps `"ETH"` to `null`. The transaction history and the Send token selector read that as "no contract may bear this symbol" and filtered a fake `ETH` ERC-20. The balance list's guard was `legitAddr !== undefined && legitAddr !== null && tokenAddr !== legitAddr`, so a `null` mapping failed the guard and the same token was listed as a holding named ETH, next to the user's real ETH. **Verdict: oversight, not a deliberate native-token exemption.** The commit that added the balance-list check, `5af8a78`, says in its own message: "Also rejects tokens spoofing a known symbol from a different contract address (same check used for transaction filtering)". The check it names had shipped the opposite reading a day earlier in `b5b4f75` (`if (legit === null) return true; // "ETH" as ERC-20 is always fake`). The exemption is also uncommented, in a file that comments its holder-count reasoning at length, and it protects nothing: the native ETH balance never enters that loop. ## The fix The rule moves to `src/shared/symbolSpoof.js` and all three sites call it, the way [#230](https://git.eeqj.de/sneak/AutistMask/issues/230) extracted `src/shared/holders.js`. A fourth reading is no longer available to a future call site. isSpoofedSymbol(symbol, contractAddress) - No contract address means the native asset, which is never a spoof. - A known symbol mapped to `null` belongs to the native asset, so no contract may bear it and every contract bearing it is a spoof. - Otherwise, a case-insensitive address comparison. Correct by construction for a future null-mapped symbol: the native exemption is "has no contract address", not "the symbol is ETH", so a second `null` entry inherits both halves of the rule with no call-site change. A test adds one and asserts it. `src/shared/transactions.js`, `src/popup/views/send.js` and `src/shared/balances.js` lose their local copies; behaviour on the first two is unchanged, which the existing suites confirm. ## The real ETH balance is unaffected It is not an ERC-20. `refreshBalances` reads it over RPC with `provider.getBalance()`; `fetchTokenBalances` iterates explorer rows and drops anything that is not `item.token.type === "ERC-20"`, so the rule never sees it. `tests/symbolSpoof.test.js` drives `refreshBalances` end to end with a stubbed provider against a balance list whose only row is a fake ETH: the native balance comes back exactly as the node reported it and the fake token is gone. ## Tests `tests/symbolSpoof.test.js` runs the same fake `ETH` token — the contract from the attack documented in the README, given 900,000 holders so no other filter can catch it — through all three surfaces, plus unit coverage of the rule itself. Demonstrated failing first: with the module added but the three call sites untouched, exactly the two balance-list cases failed while the history and selector cases passed — the disagreement itself, reproduced: ● surface 3: the balance list › a fake ETH token clearing the holder floor is filtered - Array [] + Array [ Object { "symbol": "ETH", "balance": "0.005", "holders": 900000, ... } ] ## Docs The two `README.md` passages describing the balance list's `null` exemption now state that all three surfaces apply the check identically and name `src/shared/symbolSpoof.js` as the single source; the Data Model paragraph on `fetchTokenBalances()` gains the null-mapped case; the source tree gains the new module. `TODO.md` updated in the same commit. ## Verification `make check` green, run in the container via `script/cibuild` after the rebase onto `next` at `bd4bdca`: #11 [7/8] RUN make check #11 11.44 Test Suites: 20 passed, 20 total #11 11.44 Tests: 434 passed, 434 total #11 14.79 All matched files use Prettier code style! An uncached `docker build --no-cache` run before the rebase showed the same layer executing rather than replaying cache.
clawbot added the needs-review label 2026-08-12 10:47:43 +02:00
clawbot self-assigned this 2026-08-12 10:47:46 +02:00
Author
Collaborator

Review: PASS

Independent review of 88a5c92 against #235: definition of done met, archaeology verified (b5b4f75 2026-02-26 precedes 5af8a78 2026-02-27 and contains the quoted legit === null line), all six new guards mutation-tested and each kills tests, no local copy of the rule remains, fast-forwardable onto origin/next at bd4bdca, one commit, correct author/committer, TODO.md one bullet at top with no landed entry lost, README passages match behaviour, no forbidden references.

Money check, done independently rather than taken on the author's word: with a balance list holding a fake ETH, real WETH (both checksummed and lowercase forms) and USDC, exactly the fake ETH disappears; addr.balance from provider.getBalance() is unchanged. A WETH-symbol token from a foreign contract is still caught. No input to isSpoofedSymbol was found that hides real ETH or a legitimate token.

Anomalies and disclosures:

  • The failing-first claim reproduces, but with three of this PR's own tests failing against the unmodified call sites, not two as the PR body states: a fake ETH token clearing the holder floor is filtered, tracking the fake token manually does not admit it either, and the real native ETH balance survives a fake ETH airdrop (the last on its tokenBalances assertion only; the native balance itself is correct in both directions). History and Send-selector cases pass alongside, which is the disagreement. Substance unaffected; the count in the PR body is wrong.
  • Pre-existing and out of scope, noted because it sits next to this rule: a symbol with surrounding whitespace (" ETH ") misses KNOWN_SYMBOLS on all three surfaces and is not a spoof by this rule, while HTML collapses the whitespace on display. Unchanged by this PR, in place before it; worth its own issue.
  • Tracker CI status ignored per #220. Checks were run here instead: script/cibuild executed make check in the container (layer DONE 17.2s, not CACHED) reporting 20 suites / 434 tests and Prettier clean, matching the PR body.
## Review: PASS Independent review of `88a5c92` against [#235](https://git.eeqj.de/sneak/AutistMask/issues/235): definition of done met, archaeology verified (`b5b4f75` 2026-02-26 precedes `5af8a78` 2026-02-27 and contains the quoted `legit === null` line), all six new guards mutation-tested and each kills tests, no local copy of the rule remains, fast-forwardable onto `origin/next` at `bd4bdca`, one commit, correct author/committer, TODO.md one bullet at top with no landed entry lost, README passages match behaviour, no forbidden references. Money check, done independently rather than taken on the author's word: with a balance list holding a fake `ETH`, real WETH (both checksummed and lowercase forms) and USDC, exactly the fake `ETH` disappears; `addr.balance` from `provider.getBalance()` is unchanged. A `WETH`-symbol token from a foreign contract is still caught. No input to `isSpoofedSymbol` was found that hides real ETH or a legitimate token. Anomalies and disclosures: - The failing-first claim reproduces, but with three of this PR's own tests failing against the unmodified call sites, not two as the PR body states: `a fake ETH token clearing the holder floor is filtered`, `tracking the fake token manually does not admit it either`, and `the real native ETH balance survives a fake ETH airdrop` (the last on its `tokenBalances` assertion only; the native balance itself is correct in both directions). History and Send-selector cases pass alongside, which is the disagreement. Substance unaffected; the count in the PR body is wrong. - Pre-existing and out of scope, noted because it sits next to this rule: a symbol with surrounding whitespace (`" ETH "`) misses `KNOWN_SYMBOLS` on all three surfaces and is not a spoof by this rule, while HTML collapses the whitespace on display. Unchanged by this PR, in place before it; worth its own issue. - Tracker CI status ignored per [#220](https://git.eeqj.de/sneak/AutistMask/issues/220). Checks were run here instead: `script/cibuild` executed `make check` in the container (layer `DONE 17.2s`, not `CACHED`) reporting 20 suites / 434 tests and Prettier clean, matching the PR body.
clawbot added needs-rebase and removed needs-review labels 2026-08-12 10:56:57 +02:00
clawbot force-pushed fix/issue-235-fake-eth-balance-list from 88a5c92547 to b75bd197b2 2026-08-12 11:09:51 +02:00 Compare
clawbot merged commit 1f41a07df2 into next 2026-08-12 11:10:39 +02:00
clawbot deleted branch fix/issue-235-fake-eth-balance-list 2026-08-12 11:10:39 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#257