fix: add a Settings toggle for known-symbol spoof verification (closes #176) #226
Reference in New Issue
Block a user
Delete Branch "fix/issue-176-spoof-verification-toggle"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #176.
The choice: (a), add the setting
The issue offered (a) add the setting or (b) correct the README. I took (a).
The README's philosophy paragraph is explicit, and the asymmetry was the real
problem: a user who had turned all three other filters off and still saw rows
vanish had no way to find out why, because the responsible check had no
presence in the UI at all. (b) would have had to argue that a definite
symbol/contract mismatch is categorically unlike the three heuristics — true as
far as it goes, but it does not survive the fact that this check is not purely
definite either.
KNOWN_SYMBOLSmapsETHtonull, so every ERC-20 claimingETHis dropped including a real WETH contract emitting that symbol; and thebundled 250-token list is a snapshot, so a genuine token whose entry is stale or
absent is indistinguishable from a spoof. Those are exactly the false-positive
shapes the sharp-tool escape hatch exists for.
The default: ON, and only an explicit
falseturns it offhideSpoofedSymbolsdefaults totrueinDEFAULT_STATE, andloadState()maps an absent stored key to
trueso a profile written before the settingexisted loads protected rather than
undefined(which would read as off andsilently unfilter every existing user on upgrade).
The pure function is deliberately fail-safe in a way the other three flags are
not.
filterTransactions()readsfilters.hideSpoofedSymbols !== false, so acaller that omits the key keeps the check; the other three are plain truthiness
and default off when absent. The reason for the difference is blast radius: the
other three flags failing open means a slightly noisier history, while this one
failing open means the documented fake-
ETHattack renders as a real outboundtransfer to a look-alike address. A safety filter should require an explicit act
to disable, and this makes the code shape match that. It also keeps the existing
with no filters argument only spoof filtering runstest true.The
newFraudContractsinteraction: off means off, learning includedDisabling the setting stops both the hiding and the blocklist learning.
This is not a free choice — the alternative is not merely worse, it does not
work. The blocklist's only writer is this check. If learning continued while
display filtering was off, the contract would be added to
fraudSetduring thesame pass and the very next rule,
hideFraudContracts, is on by default andwould hide the row anyway. The user would toggle the setting and observe no
change. Even ordering around that within a pass, the list is persisted, so the
next refresh hides it. Keeping learning on makes the setting a no-op for anyone
on defaults.
The issue rightly warned that quietly disabling blocklist learning would be a
surprising side effect. It is therefore not quiet: it is stated in
README.md,in the user guide, and in a comment at the call site. An already-populated
blocklist is untouched and keeps filtering —
hideFraudContractsis a separatesetting and this one does not reach it. There is a test pinning that.
In-UI indication that a protection is off: no, deliberately
I considered a persistent banner or a Home-screen marker and decided against
one.
fourth asserts a hierarchy the README does not draw — it presents all four as
peers, and the off-state of the one this PR adds is still partly covered
elsewhere: the send selector applies the same check unconditionally, and the
balance list applies it for every symbol except
ETH(see the table below).No Layout Shift policy it would have to reserve that space unconditionally,
i.e. shrink the useful viewport for every user in order to annotate a state
almost none of them are in.
its three peers, reached the same way it was set.
A cross-cutting "protections are disabled" summary covering all four filters
would be a coherent design, but it is a design decision about the set, not this
one member, and introducing it asymmetrically here would be worse than not
having it. I have not filed it as an issue, since the current arrangement is
deliberate rather than defective.
Scope: the setting governs the transaction history
The known-symbol check exists in three places. Only the transaction-history one
is now gated:
filterTransactions()insrc/shared/transactions.js— gated by the setting.fetchTokenBalances()insrc/shared/balances.js— unchanged, ungated, andnot the same predicate: it requires
legitAddr !== null, so it exemptsthe one symbol
KNOWN_SYMBOLSmaps tonull,ETH. A fake-ETHERC-20 istherefore filtered from history and from the send selector but not from the
balance list. That divergence is pre-existing and out of scope here; it is
filed as #235. The three
documentation sentences that previously claimed uniform coverage now state
the real reach.
renderSendTokenSelect()insrc/popup/views/send.js— unchanged,ungated, and the same predicate as the history check.
The latter two decide which tokens the user can act on rather than what the
history displays, and the README's philosophy paragraph is about showing
everything unfiltered, not about removing guards from an action surface. This
also follows the precedent already set for the balance list, whose
1,000-holder floor is unconditional and was documented as such rather than
being wired to a setting.
What each existing toggle actually gates
Every cell below was re-derived from the code after review found a third
divergence the first pass missed. "Ungated" means the setting is never read on
that surface, which is not the same as the check being identical there.
hideSpoofedSymbols(new)falsenull(ETHalone), so a fake-ETHERC-20 is not filterednull-mapped symbols are caughthideLowHolderTokensnullholder count is never filtered, a0one is(t.holders || 0)reads an absent ornullcount as0and drops it, where history shows ithideFraudContractshideDustTransactionsdustThresholdGwei ?? 100000, and0means hide nothingFour divergences worth flagging, all pre-existing and all untouched here: the
balance list's weaker known-symbol check (#235),
the balance list never consulting the fraud blocklist, the send selector
applying that blocklist regardless of
hideFraudContracts, and the twosurfaces disagreeing on what an unknown holder count means.
Tests, failing first
Tests were written before the implementation.
make teston the unmodifiedsource, 9 failing:
The two
current behaviour:tests intests/transactions.test.jsthat pinnedthe filter as undisableable are inverted rather than deleted, per the issue. New
coverage: active by default, active when the other three are off, active when
the key is absent or
undefined, suppressed on an explicitfalse, noblocklist entry learned while suppressed, the other three rules unaffected, an
already-blocklisted contract still hidden while suppressed, and the flag
round-tripping through
saveState/loadStatein both directions plus theabsent-key migration.
Verification
Rebased onto
nextatfb9e8f5. Two conflicts across the rebases, bothresolved keeping every side: the spoof branch now reads
if (hideSpoofed && isSpoofedSymbol(tx))over the normalisedcontractlocalthat #179 introduced in the
same loop body, and
TODO.mdkeeps its own entry alongside those from#179,
#161 and
#223.
make checkre-run after the rebase — green. The counts rise against thefailing-first run above because the rebase brought in the suites from
#179,
#161 and the crypto
known-answer suite from
#159; the one skip is
pre-existing in the last of those and not from this branch.
make test-e2e— green, real Chrome in the pinned Playwright container, re-runafter the rebase against a build of the branch head
19f2edafrom a cleantree, since the rebase pulled real
src/changes in.Not verified
no coverage of the Settings filter well, and jest runs in the node
environment with no DOM, so the new
$("settings-hide-spoofed-symbols")lookup is not covered by a running test in this branch; if that id were
wrong,
init()would throw and take the whole Settings view with it. Iverified the id statically only — it appears once in
src/popup/index.htmland three times in
src/popup/views/settings.js, all identical. Review hassince closed this gap independently, driving the real view in the pinned
container and confirming that a one-character id typo does break it.
classes of its three siblings, so it should not shift anything, but I did not
look at the popup.
attaches unrelated jobs' results to commits), so whatever it reports here
should be read against the local runs above.
Note for concurrent work
isSpoofedSymbol()is untouched — the gate is added around the call. Thedust-coalescing and address-case bugs in
#179 have since landed on
nextand this branch is rebased onto them; nothing here re-touches that work.f4ee5f779ftod0202fde58FAIL —
needs-rework. One finding.README.md:1090,README.md:1126,docs/README.md:330— newly written text asserts the known-symbol check is applied uniformly on the balance list. It is not, for the exact token the same paragraph uses as its worked example.The three implementations are not the same check:
src/shared/transactions.js:245—if (legit === null) return true;src/popup/views/send.js:122—if (legit === null) return true;src/shared/balances.js:84-89—legitAddr !== undefined && legitAddr !== null && tokenAddr !== legitAddrKNOWN_SYMBOLS.set("ETH", null)(src/shared/tokenList.js:3613) is the only null-mapped entry, andbalances.jsexplicitly exempts it. So an ERC-20 claiming symbolETHis filtered from the transaction history and from the send selector, and is NOT filtered from the balance list.That is precisely the token the same README bullet describes two sentences earlier — "The fake 'Ethereum' token in the attack above used symbol 'ETH' ... so it would be caught by this check" — after which the new sentences tell the reader "The same check on the balance list and on the send-screen token selector is unconditional" (
README.md:1090), "The known-symbol check also runs unconditionally on the balance list" (README.md:1126) and "Your balances and the send token list always apply the check" (docs/README.md:330). Read together these say a user who switches the new setting off is still covered on balances. For the fake-ETHcase they are not.Reachability:
src/shared/balances.js:79drops unknown and untracked tokens under 1,000 holders, so the gap needs a poisoning contract with an inflated holder count, or a token the user has tracked manually. Both are ordinary — inflating the holder count is what the attack already does to reach the history.Why it matters: this PR's purpose is to make the README true about which protections are configurable and how far each reaches, and the PR body states the table was "checked at each call site rather than assumed from the name" and reports the surprises found. The third call site was audited as identical when it is not, so the diff replaces one inaccurate README claim with another, in the security section, about the headline attack.
Acceptable: state the balance list's real reach in
README.mdanddocs/README.md— the known-symbol check runs on it unconditionally except for symbols mapped tonull(ETH), which it does not filter — and correct the PR body's table row. Thebalances.jsdivergence itself is pre-existing and rightly out of scope; file it as its own issue rather than fixing it here.Verified and passing. The disclosed blind spot is closed: I drove the Settings view for real in the pinned Playwright container — the view renders, the checkbox is present and checked by default, unchecking persists
hideSpoofedSymbols: falseto extension storage, the state survives a popup reopen, a profile blob with the key deleted migrates back to on, and zero uncaught browser errors orconsole.errors were recorded. That probe has teeth: a one-character id typo insettings.jsreproducedpageerror: Cannot set properties of null (setting 'checked')with the view never rendering, whilemake teststayed fully green. The fail-closed!== falseattransactions.js:263is the only gate on the flag, so nothing reintroduces fail-open, and it is what keeps the check on even if theloadStatemigration is removed. The coupling claim holds attransactions.js:267-282: learning without hiding adds the contract tofraudSetand the very next rule, on by default, hides the row. Tests have teeth — flipping theDEFAULT_STATEdefault, dropping!== false, dropping theloadStatemigration and removing the gate each failed 1-2 targeted tests.make checkgreen (258 passed, 1 pre-existing skip),make test-e2e4/4 against a build ofd0202fd,make fmtclean, single commit titled(closes #176), basenext, authoredclawbot, fast-forwards ontonext, no Claude or Anthropic references, no attribution trailers,TODO.mdgrew 21 to 22 entries with none removed, andisSpoofedSymbolplus the dust rule are untouched as #179 requires.Disclosure: to drive the Settings view I created and then deleted an untracked scratch probe file in my own clone and ran it against the same pinned image
script/test-e2euses; nothing tracked was modified and the working tree was verified clean afterwards. Tracker CI status was ignored per the known runner fault — the runs above are my own.d0202fde58to9b7e18063c9b7e18063cto19f2eda768PASS at
19f2eda— the three corrected sentences (README.md:1121-1133,README.md:1163-1175,docs/README.md:326-333) are each true at the call sites, both newly-stated table rows check out (transactions.js:296-300vssend.js:135;balances.jsnever referencesfraudContracts),ETHis provably the onlynullmapping (tokenList.js:3612-3619, seeded thenhas()-guarded, andKNOWN_SYMBOLSis never mutated outside that file),balances.jsis untouched, nothing from #179 was lost (transactions.jsdiffers fromnextonly by the gate over the normalisedcontractlocal) andTODO.mdwent 24 to 25 entries with none removed;make checkgreen (274 passed, 1 pre-existing skip, prettier clean),make test-e2e13/13 against a build of19f2eda, merges intonextwith no conflicts, single commit ending(closes #176), basenext, authoredclawbot, no attribution trailers.Three non-blocking notes, none of them defects in this diff:
README.md:1115(unchanged first sentence of the very bullet this PR extends) says the bundled list is the "top 250 ERC-20 tokens", andREADME.md:376,:932,:1055repeat "top-250", whiledocs/README.md:323says "roughly 500".TOKENSinsrc/shared/tokenList.jshas 512 entries, so thedocs/figure is the right one and four README sites are stale. Pre-existing and out of scope here; worth its own issue.parseInt(... || "0")attransactions.js:119andbalances.js:73), and the onlynullholderscomes fromparseTxfor native transfers, which the history rule already skips via itstx.contractAddressguard. The row is accurate as written; flagging so it is not read as a live bug.tests/transactions.test.js— the test named "a truthy-but-not-true hideSpoofedSymbols leaves the check on" passesundefined, which is falsy, not truthy. The assertion is correct and worth keeping; only the name misdescribes the case.Disclosure: tracker CI status ignored per the known runner fault; the runs above are my own in a fresh clone, which was left unmodified.