fix: seven bundled tokens are filtered as spoofs of their own duplicate symbol #276

Closed
opened 2026-08-12 13:07:15 +02:00 by clawbot · 2 comments
Collaborator

KNOWN_SYMBOLS maps seven symbols to a different contract address than the one TOKENS ships for that same symbol, so isSpoofedSymbol(token.symbol, token.address) returns true for a token in our own bundled list, at its own address.

Affected: FRAX (KNOWN_SYMBOLS has 0x3432B6A6…, TOKENS has 0x853d955a…), REUSD, TON, EURE, MSUSD, MUSD, JPYC.

This is the over-filtering direction — the one that hides a user's real holding. A user holding any of these seven sees it filtered from the balance list, the transaction history and the send selector, which means they cannot spend it through the UI.

Pre-existing and identical on next; found by the independent review of #270 and confirmed unchanged by that PR, which is why it was not blocked there. The cause is duplicate symbols in the source data: two real contracts legitimately share a ticker, and the two tables disagree about which one is canonical.

The reason no test caught it is that the spoof-filter tests walk KNOWN_SYMBOLS only. A walk over TOKENS asserting isSpoofedSymbol(t.symbol, t.address) === false would have.

Implementation requirements

  • Add a test walking the bundled TOKENS list and asserting no bundled token is filtered at its own address. That is the regression guard and it should go in whether or not the data is reconciled the way you expect.
  • Reconcile the seven conflicting addresses. Establish which contract is canonical for each symbol rather than picking one — both tables were generated from real sources, so a disagreement means one is stale or they were built at different times.
  • Decide what should happen when two legitimate contracts genuinely share a ticker, and say so in the PR body. A one-address-per-symbol table cannot express it, and the current answer — silently filtering the loser — is the worst available.
  • Check whether any symbol maps to an address in neither table, which would be a third failure mode.

Definition of done

  • No token in the bundled list is filtered at its own address, pinned by a test walking TOKENS.
  • The seven conflicts are reconciled, with the PR body stating how canonicality was established for each.
  • The duplicate-ticker case is handled deliberately, not by whichever address happens to be in KNOWN_SYMBOLS.
  • A genuine spoof is still filtered — the fix must not weaken the check.
  • TODO.md updated in the same commit.
  • make check passes.
`KNOWN_SYMBOLS` maps seven symbols to a different contract address than the one `TOKENS` ships for that same symbol, so `isSpoofedSymbol(token.symbol, token.address)` returns **true** for a token in our own bundled list, at its own address. Affected: `FRAX` (`KNOWN_SYMBOLS` has `0x3432B6A6…`, `TOKENS` has `0x853d955a…`), `REUSD`, `TON`, `EURE`, `MSUSD`, `MUSD`, `JPYC`. This is the over-filtering direction — the one that hides a user's real holding. A user holding any of these seven sees it filtered from the balance list, the transaction history and the send selector, which means they cannot spend it through the UI. Pre-existing and identical on `next`; found by the independent review of https://git.eeqj.de/sneak/AutistMask/pulls/270 and confirmed unchanged by that PR, which is why it was not blocked there. The cause is duplicate symbols in the source data: two real contracts legitimately share a ticker, and the two tables disagree about which one is canonical. The reason no test caught it is that the spoof-filter tests walk `KNOWN_SYMBOLS` only. A walk over `TOKENS` asserting `isSpoofedSymbol(t.symbol, t.address) === false` would have. ## Implementation requirements - Add a test walking the bundled `TOKENS` list and asserting no bundled token is filtered at its own address. That is the regression guard and it should go in whether or not the data is reconciled the way you expect. - Reconcile the seven conflicting addresses. Establish which contract is canonical for each symbol rather than picking one — both tables were generated from real sources, so a disagreement means one is stale or they were built at different times. - Decide what should happen when two legitimate contracts genuinely share a ticker, and say so in the PR body. A one-address-per-symbol table cannot express it, and the current answer — silently filtering the loser — is the worst available. - Check whether any symbol maps to an address in neither table, which would be a third failure mode. ## Definition of done - [ ] No token in the bundled list is filtered at its own address, pinned by a test walking `TOKENS`. - [ ] The seven conflicts are reconciled, with the PR body stating how canonicality was established for each. - [ ] The duplicate-ticker case is handled deliberately, not by whichever address happens to be in `KNOWN_SYMBOLS`. - [ ] A genuine spoof is still filtered — the fix must not weaken the check. - [ ] `TODO.md` updated in the same commit. - [ ] `make check` passes.
clawbot added this to the 1.0.0 milestone 2026-08-12 13:07:22 +02:00
Author
Collaborator

Plan, after establishing the provenance the issue asks for.

There are not two tables. KNOWN_SYMBOLS is built at module load from TOKENS itself (src/shared/tokenList.js), first-wins by market-cap order:

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());
}

So both addresses in each of the seven pairs come from the same CoinGecko fetch of 2026-02-27 with decimals verified on-chain. Neither is stale relative to the other, and there is no external source to prefer one over the other: 512 tokens, 505 distinct uppercased symbols, seven appearing twice. KNOWN_SYMBOLS holds the earlier index and the later one is filtered.

That settles the duplicate-ticker question rather than leaving it open: the seven are genuinely shared tickers between two real bundled contracts, so a one-address-per-symbol table cannot represent the data we ship. I will make KNOWN_SYMBOLS map a symbol to a Set of every bundled address that bears it (still null for the native asset), and have isSpoofedSymbol() ask set membership. Dropping the ambiguous symbols from the table instead would stop filtering genuine spoofs of FRAX, TON and the rest, so that is not on the table.

Third failure mode checked and clean: no KNOWN_SYMBOLS value points at an address absent from TOKENS, no bundled symbol normalizes differently from its uppercase key, and no bundled symbol folds onto ETH.

Guard test is written and failing first with exactly the seven, before any data change. The native exemption keyed on absence of a contract address, the U+007F folding boundary pinned from both sides, and the single-module rule read by all three surfaces are all preserved; a new test asserts a spoof of a shared ticker from a third contract is still filtered.

Plan, after establishing the provenance the issue asks for. There are not two tables. `KNOWN_SYMBOLS` is built at module load from `TOKENS` itself (`src/shared/tokenList.js`), first-wins by market-cap order: ``` 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()); } ``` So both addresses in each of the seven pairs come from the same CoinGecko fetch of 2026-02-27 with decimals verified on-chain. Neither is stale relative to the other, and there is no external source to prefer one over the other: 512 tokens, 505 distinct uppercased symbols, seven appearing twice. `KNOWN_SYMBOLS` holds the earlier index and the later one is filtered. That settles the duplicate-ticker question rather than leaving it open: the seven are genuinely shared tickers between two real bundled contracts, so a one-address-per-symbol table cannot represent the data we ship. I will make `KNOWN_SYMBOLS` map a symbol to a `Set` of every bundled address that bears it (still `null` for the native asset), and have `isSpoofedSymbol()` ask set membership. Dropping the ambiguous symbols from the table instead would stop filtering genuine spoofs of `FRAX`, `TON` and the rest, so that is not on the table. Third failure mode checked and clean: no `KNOWN_SYMBOLS` value points at an address absent from `TOKENS`, no bundled symbol normalizes differently from its uppercase key, and no bundled symbol folds onto `ETH`. Guard test is written and failing first with exactly the seven, before any data change. The native exemption keyed on absence of a contract address, the U+007F folding boundary pinned from both sides, and the single-module rule read by all three surfaces are all preserved; a new test asserts a spoof of a shared ticker from a third contract is still filtered.
Author
Collaborator

Built in #277 — full reconciliation, the per-symbol determination table and the duplicate-ticker decision are in that PR body.

Short version: KNOWN_SYMBOLS now maps a symbol to the set of addresses that bear it. All fourteen addresses of the seven pairs stay; none was picked over another, because both sides of every pair come from the same source fetch and neither is stale. isSpoofedSymbol() asks set membership instead of equality, which does not loosen the rule.

Verification: the guard test walking TOKENS was written first and failed with exactly the seven named in the issue, with the rest of the suite passing. After the fix, make check green (27 suites, 664 tests; script/test-verify-build 18/18; prettier clean) and make test-e2e green 27/27 in the pinned container. Third failure mode checked and clean: no table entry names an address outside the bundled list.

Built in https://git.eeqj.de/sneak/AutistMask/pulls/277 — full reconciliation, the per-symbol determination table and the duplicate-ticker decision are in that PR body. Short version: `KNOWN_SYMBOLS` now maps a symbol to the **set** of addresses that bear it. All fourteen addresses of the seven pairs stay; none was picked over another, because both sides of every pair come from the same source fetch and neither is stale. `isSpoofedSymbol()` asks set membership instead of equality, which does not loosen the rule. Verification: the guard test walking `TOKENS` was written first and failed with exactly the seven named in the issue, with the rest of the suite passing. After the fix, `make check` green (27 suites, 664 tests; `script/test-verify-build` 18/18; prettier clean) and `make test-e2e` green 27/27 in the pinned container. Third failure mode checked and clean: no table entry names an address outside the bundled list.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#276