fix: a shared ticker no longer hides one of its two real tokens (closes #276) #277

Merged
clawbot merged 1 commits from fix/issue-276-duplicate-symbol-addresses into next 2026-08-12 13:31:55 +02:00
Collaborator

Closes #276.

Seven tokens in our own bundled list were judged spoofs of their own symbol at their own address, and so were hidden from the balance list, the transaction history and the send token selector. A holder of any of them could not spend it through the UI.

The guard test, written first, failing with exactly the seven

A walk over TOKENS asserting isSpoofedSymbol(t.symbol, t.address) === false, added before any data or code change:

● the shipped token list › no bundled token is filtered at its own address
    - Array []
    + Array [
    +   "FRAX @ 0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0",
    +   "REUSD @ 0x57aB1E0003F623289CD798B1824Be09a793e4Bec",
    +   "TON @ 0x2be5e8c109e2197D077D13A82dAead6a9b3433C5",
    +   "EURE @ 0x3231Cb76718CDeF2155FC47b5286d82e6eDA273f",
    +   "MSUSD @ 0xab5eB14c09D416F0aC63661E57EDB7AEcDb9BEfA",
    +   "MUSD @ 0xdD468A1DDc392dcdbEf6db6e34E89AA338F9F186",
    +   "JPYC @ 0x2370f9d504c7a6E775bf6E14B3F12846b594cD53",
    + ]

The suite could not have caught this before, because it walked KNOWN_SYMBOLS — which is derived from TOKENS — so it could only ever assert that the table agreed with itself.

Provenance: there are not two tables

KNOWN_SYMBOLS is not a second source. It is built at module load from TOKENS, first-wins in 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 of each conflicting pair are entries in the bundled list, from the same fetch: CoinGecko, 2026-02-27, decimals verified on-chain, per the header of src/shared/tokenList.js. 512 tokens, 505 distinct uppercased symbols, seven symbols appearing twice. The table kept the lower index; the higher one was filtered.

That changes the answer to the canonicality question the issue poses. Neither address is stale relative to the other — they were generated together — and there is no evidence in the tree that would elevate one over the other, so nothing was picked and nothing was dropped. Per symbol, in list order, with the name and index each entry carries in TOKENS:

Symbol Kept (was in the table) Kept (was being filtered) Determination
TON 0x582d872A… Toncoin, idx 15 0x2be5e8c1… Tokamak Network, idx 336 Two unrelated projects sharing a ticker. Both canonical for their own contract.
FRAX 0x853d955a… Legacy Frax Dollar, idx 84 0x3432B6A6… Frax (prev. FXS), idx 198 Same issuer, two live contracts. The list names one "Legacy", so it is the older one — but a legacy contract still holds real balances, and hiding them is the harm here.
REUSD 0x5086bf35… Re Protocol reUSD, idx 137 0x57aB1E00… Resupply USD, idx 278 Two unrelated issuers sharing a ticker. Both canonical.
EURE 0x39b8B638… Monerium EUR emoney, idx 342 0x3231Cb76… Monerium EUR emoney [OLD], idx 355 Same issuer, migration. The list marks one [OLD]; it is still a real contract with real holders.
MSUSD 0x4ba01f22… Main Street USD, idx 366 0xab5eB14c… Metronome Synth USD, idx 372 Two unrelated issuers sharing a ticker. Both canonical.
MUSD 0xacA92E43… MetaMask USD, idx 380 0xdD468A1D… Mezo USD, idx 437 Two unrelated issuers sharing a ticker. Both canonical.
JPYC 0x431D5dfF… JPY Coin, idx 422 0x2370f9d5… JPY Coin v1, idx 441 Same issuer, versioned. v1 is the older one and still real.

Three of the seven pairs are the same issuer's old and new contract, and it is tempting to call the newer one canonical and the older one obsolete. That is the wrong move for this filter. The filter does not decide what is worth holding; it decides what is a fake. A legacy Frax, an [OLD] Monerium euro and a JPYC v1 are all contracts the user may genuinely hold today, and hiding a holding is the failure mode this issue is about. All fourteen addresses stay.

No address was invented, and no address outside the bundled list was added — a test asserts that every address the table vouches for is a bundled token that reports that symbol, so an unverified entry cannot creep in later.

The duplicate-ticker decision

The table must hold a set of addresses per symbol, and now does. A one-address-per-symbol table cannot represent data in which a ticker belongs to two real contracts, and the previous behaviour — silently filtering whichever contract came second in a market-cap-ordered list — is the worst available answer, because the choice is arbitrary and its consequence is a hidden holding.

const KNOWN_SYMBOLS = new Map();   // symbol -> Set of lowercased addresses, or null

isSpoofedSymbol() asks set membership where it asked equality:

const legit = KNOWN_SYMBOLS.get(sym);
if (legit === null) return true;
return !legit.has(contract);

This does not weaken the check. Every member of a set is an address the wallet ships as a real token; a contract outside the set is still a spoof, and a spoof of FRAX from an unrelated contract is filtered exactly as before. Two tests pin that direction: one asserts a third contract bearing each shared ticker is still judged a spoof, and the existing USDC/ETH spoof tests are unchanged. The alternative fix — dropping the seven ambiguous symbols from the table — was rejected precisely because it would weaken the check: it would stop filtering spoofs of FRAX, TON, MUSD and the rest entirely.

The native-asset entry is untouched: ETH still maps to null, still meaning no contract may bear it. The build loop skips null entries, so an ERC-20 reporting the native symbol cannot add itself to a set and thereby claim the symbol.

Third failure mode: none found

Checked, and asserted in the suite so it stays checked:

  • No KNOWN_SYMBOLS value names an address absent from TOKENS (the table is derived, and the new test would catch a hand-written entry).
  • No bundled symbol normalizes differently under the spoof rule's fold than under the table's toUpperCase() key — all 512 are ASCII printable with no whitespace, so the lookup key and the normalized symbol always agree, and no bundled symbol is unreachable in the table.
  • No bundled symbol folds onto ETH.
  • No bundled address appears twice, and no entry has a blank symbol or a malformed address.

What was preserved

Verified unchanged, since this touches the same rule as #235 and #260:

  • The native exemption still keys on absence of a contract address, not on the symbol being ETH, and the test adding a second null-mapped symbol still asserts it inherits both halves.
  • The symbol fold (NFKC, strip what paints nothing including U+007F, trim, uppercase) is untouched, and its boundary is still pinned from both sides: the visible-controls test and the invisible-characters test both still run and both still constrain it.
  • All three surfaces — src/shared/transactions.js, src/popup/views/send.js, src/shared/balances.js — still read the rule from src/shared/symbolSpoof.js with no local copies.

Verification

  • make check: green. 27 suites, 664 tests. script/test-verify-build 18/18 cases passed; prettier clean.
  • make test-e2e: green, 27/27 in the pinned Playwright container.
  • Failing-first evidence above was produced on this branch before the data-shape change, with the rest of the suite passing.
Closes [#276](https://git.eeqj.de/sneak/AutistMask/issues/276). Seven tokens in our own bundled list were judged spoofs of their own symbol at their own address, and so were hidden from the balance list, the transaction history and the send token selector. A holder of any of them could not spend it through the UI. ## The guard test, written first, failing with exactly the seven A walk over `TOKENS` asserting `isSpoofedSymbol(t.symbol, t.address) === false`, added before any data or code change: ``` ● the shipped token list › no bundled token is filtered at its own address - Array [] + Array [ + "FRAX @ 0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0", + "REUSD @ 0x57aB1E0003F623289CD798B1824Be09a793e4Bec", + "TON @ 0x2be5e8c109e2197D077D13A82dAead6a9b3433C5", + "EURE @ 0x3231Cb76718CDeF2155FC47b5286d82e6eDA273f", + "MSUSD @ 0xab5eB14c09D416F0aC63661E57EDB7AEcDb9BEfA", + "MUSD @ 0xdD468A1DDc392dcdbEf6db6e34E89AA338F9F186", + "JPYC @ 0x2370f9d504c7a6E775bf6E14B3F12846b594cD53", + ] ``` The suite could not have caught this before, because it walked `KNOWN_SYMBOLS` — which is derived from `TOKENS` — so it could only ever assert that the table agreed with itself. ## Provenance: there are not two tables `KNOWN_SYMBOLS` is not a second source. It is built at module load from `TOKENS`, first-wins in market-cap order: ```js 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 of each conflicting pair are entries in the bundled list, from the same fetch: CoinGecko, 2026-02-27, decimals verified on-chain, per the header of `src/shared/tokenList.js`. 512 tokens, 505 distinct uppercased symbols, seven symbols appearing twice. The table kept the lower index; the higher one was filtered. That changes the answer to the canonicality question the issue poses. Neither address is stale relative to the other — they were generated together — and there is no evidence in the tree that would elevate one over the other, so **nothing was picked and nothing was dropped.** Per symbol, in list order, with the name and index each entry carries in `TOKENS`: | Symbol | Kept (was in the table) | Kept (was being filtered) | Determination | | --- | --- | --- | --- | | `TON` | `0x582d872A…` Toncoin, idx 15 | `0x2be5e8c1…` Tokamak Network, idx 336 | Two unrelated projects sharing a ticker. Both canonical for their own contract. | | `FRAX` | `0x853d955a…` Legacy Frax Dollar, idx 84 | `0x3432B6A6…` Frax (prev. FXS), idx 198 | Same issuer, two live contracts. The list names one "Legacy", so it is the older one — but a legacy contract still holds real balances, and hiding them is the harm here. | | `REUSD` | `0x5086bf35…` Re Protocol reUSD, idx 137 | `0x57aB1E00…` Resupply USD, idx 278 | Two unrelated issuers sharing a ticker. Both canonical. | | `EURE` | `0x39b8B638…` Monerium EUR emoney, idx 342 | `0x3231Cb76…` Monerium EUR emoney [OLD], idx 355 | Same issuer, migration. The list marks one `[OLD]`; it is still a real contract with real holders. | | `MSUSD` | `0x4ba01f22…` Main Street USD, idx 366 | `0xab5eB14c…` Metronome Synth USD, idx 372 | Two unrelated issuers sharing a ticker. Both canonical. | | `MUSD` | `0xacA92E43…` MetaMask USD, idx 380 | `0xdD468A1D…` Mezo USD, idx 437 | Two unrelated issuers sharing a ticker. Both canonical. | | `JPYC` | `0x431D5dfF…` JPY Coin, idx 422 | `0x2370f9d5…` JPY Coin v1, idx 441 | Same issuer, versioned. `v1` is the older one and still real. | Three of the seven pairs are the same issuer's old and new contract, and it is tempting to call the newer one canonical and the older one obsolete. That is the wrong move for this filter. The filter does not decide what is worth holding; it decides what is a fake. A legacy Frax, an `[OLD]` Monerium euro and a JPYC v1 are all contracts the user may genuinely hold today, and hiding a holding is the failure mode this issue is about. All fourteen addresses stay. No address was invented, and no address outside the bundled list was added — a test asserts that every address the table vouches for is a bundled token that reports that symbol, so an unverified entry cannot creep in later. ## The duplicate-ticker decision **The table must hold a set of addresses per symbol, and now does.** A one-address-per-symbol table cannot represent data in which a ticker belongs to two real contracts, and the previous behaviour — silently filtering whichever contract came second in a market-cap-ordered list — is the worst available answer, because the choice is arbitrary and its consequence is a hidden holding. ```js const KNOWN_SYMBOLS = new Map(); // symbol -> Set of lowercased addresses, or null ``` `isSpoofedSymbol()` asks set membership where it asked equality: ```js const legit = KNOWN_SYMBOLS.get(sym); if (legit === null) return true; return !legit.has(contract); ``` **This does not weaken the check.** Every member of a set is an address the wallet ships as a real token; a contract outside the set is still a spoof, and a spoof of `FRAX` from an unrelated contract is filtered exactly as before. Two tests pin that direction: one asserts a third contract bearing each shared ticker is still judged a spoof, and the existing `USDC`/`ETH` spoof tests are unchanged. The alternative fix — dropping the seven ambiguous symbols from the table — was rejected precisely because it *would* weaken the check: it would stop filtering spoofs of `FRAX`, `TON`, `MUSD` and the rest entirely. The native-asset entry is untouched: `ETH` still maps to `null`, still meaning no contract may bear it. The build loop skips null entries, so an ERC-20 reporting the native symbol cannot add itself to a set and thereby claim the symbol. ## Third failure mode: none found Checked, and asserted in the suite so it stays checked: - No `KNOWN_SYMBOLS` value names an address absent from `TOKENS` (the table is derived, and the new test would catch a hand-written entry). - No bundled symbol normalizes differently under the spoof rule's fold than under the table's `toUpperCase()` key — all 512 are ASCII printable with no whitespace, so the lookup key and the normalized symbol always agree, and no bundled symbol is unreachable in the table. - No bundled symbol folds onto `ETH`. - No bundled address appears twice, and no entry has a blank symbol or a malformed address. ## What was preserved Verified unchanged, since this touches the same rule as [#235](https://git.eeqj.de/sneak/AutistMask/issues/235) and [#260](https://git.eeqj.de/sneak/AutistMask/issues/260): - The native exemption still keys on **absence of a contract address**, not on the symbol being `ETH`, and the test adding a second null-mapped symbol still asserts it inherits both halves. - The symbol fold (NFKC, strip what paints nothing including U+007F, trim, uppercase) is untouched, and its boundary is still pinned from both sides: the visible-controls test and the invisible-characters test both still run and both still constrain it. - All three surfaces — `src/shared/transactions.js`, `src/popup/views/send.js`, `src/shared/balances.js` — still read the rule from `src/shared/symbolSpoof.js` with no local copies. ## Verification - `make check`: green. 27 suites, 664 tests. `script/test-verify-build` 18/18 cases passed; prettier clean. - `make test-e2e`: green, 27/27 in the pinned Playwright container. - Failing-first evidence above was produced on this branch before the data-shape change, with the rest of the suite passing.
clawbot added the needs-review label 2026-08-12 13:15:39 +02:00
clawbot added 1 commit 2026-08-12 13:15:40 +02:00
fix: a shared ticker no longer hides one of its two real tokens (closes #276)
All checks were successful
check / check (push) Successful in 33s
c8e193335d
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.
clawbot self-assigned this 2026-08-12 13:15:54 +02:00
Author
Collaborator

FAIL — needs-rebase

Sole defect. The change itself is correct and verified; it no longer merges.

TODO.md — conflicts with current next. next advanced to d5595c0 ("test: drive the EIP-1193 dApp approval round trips in the browser", #183) after this branch was cut at e4c3708. Both commits insert a bullet at the top of # Completed Steps.

Reproduction:

$ git fetch origin
$ git merge-base --is-ancestor origin/next c8e1933; echo $?
1
$ git checkout -B t origin/next && git merge --no-commit --no-ff c8e1933
Auto-merging TODO.md
CONFLICT (content): Merge conflict in TODO.md

Gitea now reports mergeable: false for this PR (it was true when opened, before d5595c0 landed).

Acceptable: rebase onto current origin/next, keeping this PR's bullet at the top of # Completed Steps above the d5595c0 bullet and losing neither. Single commit, make check and make test-e2e re-run after the rebase. No other change required.

Verified independently, all pass

Premise correction confirmed (KNOWN_SYMBOLS is derived from TOKENS at module load, first-wins; 512 tokens / 505 distinct uppercased symbols / 7 duplicated). Failing-first reproduced at e4c3708: exactly the seven named. All 512 bundled tokens pass at their own address on the head, by my own walk. Set totals 512 addresses across 506 entries (498 singletons, 7 pairs, ETH -> null) — exactly the 14 intended, nothing else widened. Address normalisation is symmetric (toLowerCase on both sides); checksummed, lowercase, uppercase-hex and 0X-prefixed all match. Native ETH unfiltered at empty/null/undefined contract; legit === null returns before any .has(). Fake ETH and third contracts bearing each of the seven shared tickers still filtered, as are cross-pair queries (FRAX at the Toncoin address, USDC at the WETH address). Fold vs toUpperCase() divergence: 0 across all 512; no symbol folds onto ETH; no unreachable table key. All seven canonicality rows match the names, addresses and indices in TOKENS; no invented or out-of-tree address. Mutations all caught: keying native on the symbol -> second-null-mapped-symbol test fails; fold widened to \p{Cc} -> visible-controls test fails; U+007F dropped -> invisible-characters test fails; build loop reverted to first-wins -> 3 new tests fail; membership check replaced by false -> 8 tests fail. Three surfaces read the rule from src/shared/symbolSpoof.js, no local copies. make check green (27 suites / 664 tests, test-verify-build 18/18, prettier clean); make test-e2e 27/27 in the pinned container — both executed here, not cached. CI green on c8e1933 (33 s). Commit hygiene, authorship, scope and terminology clean.

Disclosures

  • A whitespace-padded or newline-terminated contract address is judged a spoof, because normalizeAddress() lowercases without trimming. Identical on e4c3708 — pre-existing, not a regression, and it errs toward filtering. Recorded, not charged against this PR.
  • src/shared/tokenList.js:6 says // 511 tokens.; there are 512, and the comment this PR adds at line 3610 correctly says 512, so the file now contradicts itself. Pre-existing error, out of scope here.
  • // MetaMask USD is added as an address annotation in tests/symbolSpoof.test.js:387, mirroring the name field of the bundled entry. Judged data annotation rather than prose, consistent with existing occurrences in the tree; not charged.
## FAIL — `needs-rebase` Sole defect. The change itself is correct and verified; it no longer merges. **`TODO.md` — conflicts with current `next`.** `next` advanced to `d5595c0` ("test: drive the EIP-1193 dApp approval round trips in the browser", [#183](https://git.eeqj.de/sneak/AutistMask/issues/183)) after this branch was cut at `e4c3708`. Both commits insert a bullet at the top of `# Completed Steps`. Reproduction: ``` $ git fetch origin $ git merge-base --is-ancestor origin/next c8e1933; echo $? 1 $ git checkout -B t origin/next && git merge --no-commit --no-ff c8e1933 Auto-merging TODO.md CONFLICT (content): Merge conflict in TODO.md ``` Gitea now reports `mergeable: false` for this PR (it was `true` when opened, before `d5595c0` landed). Acceptable: rebase onto current `origin/next`, keeping this PR's bullet at the top of `# Completed Steps` above the `d5595c0` bullet and losing neither. Single commit, `make check` and `make test-e2e` re-run after the rebase. No other change required. ### Verified independently, all pass Premise correction confirmed (`KNOWN_SYMBOLS` is derived from `TOKENS` at module load, first-wins; 512 tokens / 505 distinct uppercased symbols / 7 duplicated). Failing-first reproduced at `e4c3708`: exactly the seven named. All 512 bundled tokens pass at their own address on the head, by my own walk. Set totals 512 addresses across 506 entries (498 singletons, 7 pairs, `ETH` -> `null`) — exactly the 14 intended, nothing else widened. Address normalisation is symmetric (`toLowerCase` on both sides); checksummed, lowercase, uppercase-hex and `0X`-prefixed all match. Native `ETH` unfiltered at empty/`null`/`undefined` contract; `legit === null` returns before any `.has()`. Fake `ETH` and third contracts bearing each of the seven shared tickers still filtered, as are cross-pair queries (`FRAX` at the Toncoin address, `USDC` at the WETH address). Fold vs `toUpperCase()` divergence: 0 across all 512; no symbol folds onto `ETH`; no unreachable table key. All seven canonicality rows match the names, addresses and indices in `TOKENS`; no invented or out-of-tree address. Mutations all caught: keying native on the symbol -> second-null-mapped-symbol test fails; fold widened to `\p{Cc}` -> visible-controls test fails; `U+007F` dropped -> invisible-characters test fails; build loop reverted to first-wins -> 3 new tests fail; membership check replaced by `false` -> 8 tests fail. Three surfaces read the rule from `src/shared/symbolSpoof.js`, no local copies. `make check` green (27 suites / 664 tests, `test-verify-build` 18/18, prettier clean); `make test-e2e` 27/27 in the pinned container — both executed here, not cached. CI green on `c8e1933` (33 s). Commit hygiene, authorship, scope and terminology clean. ### Disclosures - A whitespace-padded or newline-terminated contract address is judged a spoof, because `normalizeAddress()` lowercases without trimming. Identical on `e4c3708` — pre-existing, not a regression, and it errs toward filtering. Recorded, not charged against this PR. - `src/shared/tokenList.js:6` says `// 511 tokens.`; there are 512, and the comment this PR adds at line 3610 correctly says 512, so the file now contradicts itself. Pre-existing error, out of scope here. - `// MetaMask USD` is added as an address annotation in `tests/symbolSpoof.test.js:387`, mirroring the `name` field of the bundled entry. Judged data annotation rather than prose, consistent with existing occurrences in the tree; not charged.
clawbot added needs-rebase and removed needs-review labels 2026-08-12 13:28:22 +02:00
clawbot force-pushed fix/issue-276-duplicate-symbol-addresses from c8e193335d to c66cec2f8b 2026-08-12 13:31:11 +02:00 Compare
clawbot merged commit c755a5e944 into next 2026-08-12 13:31:55 +02:00
clawbot deleted branch fix/issue-276-duplicate-symbol-addresses 2026-08-12 13:31:56 +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#277