test: cover the address-poisoning filters in transactions.js (closes #160) #175
Reference in New Issue
Block a user
Delete Branch "test/issue-160-transactions-filters"
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?
Adds
tests/transactions.test.js, the first coverage ofsrc/shared/transactions.js. 47 new tests; the suite goes from 62 to 109 across 5 files.No source file is touched.
src/shared/transactions.jsis unmodified, per the scope rule on the issue.What is covered
The two real attacks from
README.md:730-814, as static fixtures0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82with zero holders, carrying the real fraudulent transfer hash0x85215772...from the victim to the0xC3C0/0xC3c6look-alike. Proven filtered under defaults, and proven to be reported throughnewFraudContractsso it persists to the blocklist.0x2708ebdd...from the look-alike sender0xC3c6B3b4402bD78A9582aB6b00E747769344F37E. Proven filtered under defaults.Known-symbol spoof verification.
isSpoofedSymbolis not exported (transactions.js:268exports onlyfetchRecentTransactionsandfilterTransactions), and adding an export would mean modifying the file, so it is covered throughfilterTransactions, which calls it unconditionally and surfaces the result vianewFraudContracts. Cases: a known symbol from a non-matching contract is spoofed; the genuine contract for that symbol is not; an unknown symbol from an unknown contract is not flagged by this check;"ETH"as an ERC-20 is spoofed from any contract including the real WETH one; symbol comparison is case-insensitive; native transactions are never spoof-filtered; a repeated fraud contract is reported once; an already-known fraud contract is not reported as new, including when supplied checksummed.Each of the four filters, on and off. Known-symbol verification, the <1000-holder rule, the fraud contract blocklist, and the dust threshold each get a proven-working case and a proven-bypassed case, except known-symbol verification — see finding 1 below.
Boundaries. Holder count 0 / 999 / 1000 / 1001 against the 1,000 rule. Dust 99,999 / 100,000 / 100,001 gwei against the 100,000 gwei default, again with the default applied implicitly (no
dustThresholdGweikey) and once more against a user-raised threshold.Defaults.
state.hideLowHolderTokens,hideFraudContracts,hideDustTransactionsare assertedtrueanddustThresholdGwei100000, matchingREADME.md:810-814and the Settings view.No false positives. A plain 0.05 ETH transfer between ordinary addresses, a genuine 1500.5 USDC transfer from
0xa0b86991...with 3.5M holders, and a genuine WETH transfer, each proven to survive all four rules with defaults on. Also asserted that surviving entries are the same object references and are not mutated.Merge/dedup. The cross-address merge lives in
home.js:178-186, not here; whattransactions.jsowns is the per-address merge of normal transactions with ERC-20 transfers insidefetchRecentTransactions. Covered: swap consolidation into one entry preferring the received leg while preserving the user's own from/to; a standalone token transfer getting its own entry; two legs of the same token in one transaction collapsing to one entry; two different tokens in one transaction staying separate; descending block-number sort with thecountcap; and failed Blockscout responses yielding[]rather than throwing.No network.
../src/shared/logis mocked at the module boundary sodebugFetchreturns static fixture payloads,global.fetchis a stub that throws, and the final test assertsglobal.fetchwas never called.Bugs found and deliberately NOT fixed
Left in place per the scope rule; each has a test asserting the actual current behaviour, named with a
current behaviour:prefix so nobody mistakes it for an endorsement. Suggest filing these separately.Known-symbol verification cannot be disabled —
src/shared/transactions.js:222-229.README.md:810-814says all four filters "default to on but can be individually disabled by the user". There is no setting for this one: no state flag (src/shared/state.js:24-27has only the other three plus the threshold), no checkbox (src/popup/index.html:954-976), andfilterTransactionsapplies the spoof check before any flag is consulted. Either the README overpromises or the setting is missing. Test:current behaviour: spoof filtering cannot be disabled by any setting.A dust threshold of 0 silently becomes 100,000 —
src/shared/transactions.js:257readsfilters.dustThresholdGwei || 100000. Setting the threshold to 0 is the natural way to ask for no dust filtering while leaving the toggle on, and it instead reinstates the default.src/popup/views/settings.js:305-310lets the value be edited, so a user can reach this. Test:current behaviour: a threshold of 0 falls back to the 100,000 gwei default.A plain ERC-20 transfer produces two history rows —
src/shared/transactions.js:35and:163-190. For a plain transfer the Blockscoutmethodis"transfer", soparseTxdoes not mark the entry as a contract call in the display sense, the merge loop'sexisting.direction === "contract"test fails, and the token transfer is stored under its own key alongside the native row. The result is a duplicated transaction: a zero-value "Sent 0.0000 ETH" row plus the real token row. The zero-value row also escapes the dust filter, becauseisContractCallis true for it andtransactions.js:253-260exempts contract calls. Test:current behaviour: a plain ERC-20 transfer produces two entries, which asserts both rows survive filtering.isSpoofedSymbolis case-sensitive on the contract address —src/shared/transactions.js:209comparestx.contractAddress !== legitagainst a lowercased known address, with no normalisation, while the blocklist check two branches up does lowercase its input (:215-217). Inside the app this is unreachable becauseparseTokenTransferlowercases, butfilterTransactionsis exported as a pure function and a caller passing a checksummed address has a genuine token classified as a spoof and added to the fraud blocklist. Hardening rather than a live bug. Test:current behaviour: a checksummed genuine contract is treated as a spoof.Verification
make checkgreen: 5 suites, 109 tests, all passing, plusprettier --check. Whole target runs in about 8 seconds;script/testalone takes ~1 second, far inside its 30-secondtimeout.make fmtrun; the formatted result is in the commit.isSpoofedSymbolforced to returnfalse, 10 tests fail. With both<comparisons changed to<=(transactions.js:245and:257), exactly the 4 boundary tests fail and nothing else. Both mutations were reverted before committing;git diffconfirms no source file is modified on this branch.Not in scope
The four findings above are documented, not fixed. Nothing outside
tests/transactions.test.jsand a one-lineTODO.mdentry under Completed Steps is touched; the stale Status and Next Step blocks are left alone as instructed.Built and verified.
What landed: one new file,
tests/transactions.test.js(1002 lines, 47 tests), plus a single added line inTODO.mdunder Completed Steps.src/shared/transactions.jsand every other source file are untouched —git diff main...HEAD --statis two files, 1004 insertions, 0 deletions.How it is structured. Six describe blocks: a guard block asserting the token-list facts the fixtures depend on (so the suite fails loudly rather than silently going vacuous if
KNOWN_SYMBOLSchanges andSPAMTKNorUSDCstarts resolving differently); the two documented README attacks; known-symbol spoof verification; the low-holder rule; the fraud blocklist; the dust threshold; the defaults; the false-positive cases; and thefetchRecentTransactionsmerge/dedup block.How it was verified.
make checkgreen on the branch: 5 suites, 109 tests (up from 62), plusprettier --checkon the whole tree. Total wall time about 8 seconds.script/testalone is ~1 second against its 30-secondtimeout, so there is plenty of headroom.make fmtrun before committing;make fmt-checkclean afterwards.The pre-commit hook ran
make checkagain at commit time and passed.Mutation testing, since the whole point is catching a filter that silently stops filtering. Three deliberate defects were injected into
src/shared/transactions.jsone at a time and the suite re-run:isSpoofedSymbolforced to returnfalse→ 10 tests fail.tx.holders < 1000changed to<= 1000andtx.valueGwei < thresholdchanged to<=→ exactly 4 tests fail, all four of them the boundary assertions, and nothing else.All mutations were reverted with
git checkout --before staging; the committed tree has no source changes.No network:
../src/shared/logis mocked sodebugFetchnever reachesfetch,global.fetchis a stub that throws if called, and the last test in the file asserts it was never called.Four divergences from documented behaviour are recorded but not fixed, per the scope rule on #160 — details and file:line are in the PR description. Briefly: known-symbol verification has no off switch despite
README.md:810-814promising one; a dust threshold of 0 silently reverts to 100,000 gwei; a plain ERC-20 transfer yields two history rows, one of them a zero-value native row that also escapes the dust filter; andisSpoofedSymboldoes not normalise the case oftx.contractAddressthe way the blocklist check does. Each has a test pinning the actual behaviour, named with acurrent behaviour:prefix. Happy to take any of them as a follow-up issue.Review: PASS
Independent adversarial review of
3d87f8bagainst issue #160 and the managernote on it. Reviewer did not author this change.
Scope and mergeability
tests/transactions.test.js(new, 1002lines) and
TODO.md(+2).src/shared/transactions.jsis not modified,as manager note 2 requires. Verified with
git diff --stat main..3d87f8b.RULES.mdunmodified. No source file touched. No scope creep.origin/mainis still23aeae4, identical to the PR base, so the branch isa fast-forward. No conflicts. Gitea reports
mergeable: true.check / check (push), "Successful in 20s".make checkrun locally on the head commit: green. 5 suites, 109 tests,script/test0.65s wall, whole target ~7s. Far inside the 30stimeoutinscript/test.prettier --checkclean, somake fmtwas run.1. Mutation testing — the core of this review
I re-ran the author's two claimed mutations and added nine of my own. All
mutations were applied to
src/shared/transactions.js, run viamake test,and reverted;
git statusis clean and the working tree matches3d87f8b.Author's claims, both confirmed exactly:
isSpoofedSymbolforced to returnfalse<changed to<=(:245,:257)The
<=run failed precisely:boundary: exactly 1000 holders is kept,boundary: exactly 100,000 gwei is kept,the 100,000 gwei default applies when no threshold is supplied,a user-raised threshold is honoured on both sides. Nothing else. The claim is honest.My own mutations:
:232-238forced false)<to>,:257)1000to10(:245)boundary: 999 holders is filtered)hideLowHolderTokensflag (:242)||changed to??in the dust default (:257) — i.e. fixing documented bug 2current behaviour: a threshold of 0 falls back to the 100,000 gwei default.toLowerCase()dropped from the blocklist normalisation (:216)legit === nullreturnsfalseinstead oftrue(:208).toUpperCase()dropped from the symbol comparison (:205)newFrauddedup removed (:224)!tx.isContractCall,:255):194)isReceived && !needsAmount(:173)isReceivedonly (:173)tx.holders !== nullnull-guard removed (:244)1000000and dust default to10000000a genuine WETH transfer survives all four rules13 of 15 mutations killed; 2 survived. Both survivors are in non-security
edge branches and are noted below as non-blocking. Mutation 6 is the important
one: it proves the "current behaviour" tests genuinely pin the bugs rather
than blessing them — fixing the bug fails exactly the test named for it, which
is what a future fixer needs.
These are not restated-implementation tests. They constrain the code.
2. Boundary correctness against the README
The assertions encode the README's semantics, not merely the code's:
README.md:786-789— "fewer than 1,000 holders are hidden". Code istx.holders < 1000(transactions.js:245). Tests assert 0 and 999filtered, 1000 and 1001 kept. Correct: 1000 is not "fewer than 1,000".
README.md:803-807— "hides transactions below a configurable dustthreshold (default: 100,000 gwei / 0.0001 ETH)". Code is
valueGwei < threshold(transactions.js:257). Tests assert 99,999 filtered, 100,000and 100,001 kept. Correct: 100,000 is not "below" 100,000. The unit maths
also checks out (1e5 gwei = 1e-4 ETH).
RULES.mdagrees on both ("Tokens with < 1,000 holders hidden", "Dusttransactions below configurable threshold hidden").
No off-by-one exists to catch, and the tests would catch one if introduced —
mutation 2 proves it.
3. The four "current behaviour:" tests
All four are genuine defects, genuinely pinned, and unmistakably named.
tests/transactions.test.js:349— spoof filtering cannot be disabled.Verified:
src/shared/state.jsDEFAULT_STATEhas onlyhideLowHolderTokens,hideFraudContracts,hideDustTransactions,dustThresholdGwei— no spoof flag — whiletransactions.js:223appliesisSpoofedSymbolbefore any flag is read.README.md:810-814promises"known symbol verification" is individually disableable. Real divergence.
tests/transactions.test.js:581— threshold 0 falls back to 100,000.Verified by mutation 6.
tests/transactions.test.js:895— plain ERC-20 transfer yields two rows.Verified by reading
transactions.js:35(method !== "transfer"keepsdirectionas sent/received) and:166(merge requiresdirection === "contract"), so the token transfer is keyed separately at:188. Thezero-value native row also escapes dust filtering because
isContractCallis true (
:255). Real bug.tests/transactions.test.js:337— checksummed genuine contract read as aspoof. Verified:
:209compares against a lowercased address with!==while
:216lowercases its blocklist input. Hardening gap, correctlycharacterised as unreachable in-app.
Each carries an explanatory comment above it and the
current behaviour:nameprefix. A future fixer will see the right failing test.
Note on the issue's DoD bullet "each of the four filters ... proven to be
bypassed when off": this is not literally satisfiable for known-symbol
verification because no such setting exists. Documenting rather than fixing is
what manager note 2 requires, and finding 1 above does exactly that. Accepted.
4. False-positive coverage
Realistic and load-bearing.
nativeTx()is 0.05 ETH (valueGwei: 50000000,arithmetically correct),
tokenTx()is 1500.5 USDC from the genuine0xa0b8...eb48with 3.5M holders, plus a genuine WETH transfer at 850kholders and the real 0.005 ETH send at 5,000,000 gwei. Mutation 16 confirms
these bite: raising the holder threshold to 1,000,000 fails
a genuine WETH transfer survives all four rules, and raising the dust default fails thedefault-threshold test. Over-filtering would not ship silently past this file.
surviving entries are the same objects, unmodified(
tests/transactions.test.js:673) additionally pins thatfilterTransactionsstays pure.
5. No network
Confirmed by inspection, not just by the assertion:
src/shared/logis mocked at the module boundary(
tests/transactions.test.js:16-26), sodebugFetchis ajest.fn().src/shared/tokenList.jsrequires the same mocked./log; its only networkcall is
debugFetchattokenList.js:3636, inside a function, neverinvoked here.
src/shared/state.jsperforms no fetch; it touches onlychrome.storage.local, stubbed attests/transactions.test.js:34.ethersis used only forformatEther/formatUnits, both pure.global.fetchis a throwing stub and asserted uncalled at:1000.No transitive path to the network exists.
6. Test hygiene
--runInBand --randomizethree times; 109/109 passed every time. Allfixtures are builder functions returning fresh objects,
filters()spreadsDEFAULT_FILTERSper call, and thedebugFetchmock is reset inbeforeEach(:712-714).jest.mock("../src/shared/log")), notreaching into internals.
isSpoofedSymbolis correctly exercised throughthe exported
filterTransactionsplusnewFraudContractsrather than byadding an export — the right call given the no-source-changes rule.
TS_EPOCHderives from a fixed UTC string (:696-697). NoDate.now().7. Policy
the PR body. No attribution trailers. (The
claude*strings insrc/shared/phishingBlocklist.jsonare pre-existing vendored scam-domaindata on
main, untouched here.)(closes #160).RULES.mdunmodified.TODO.mdedit is surgical: 2 lines added under Completed Steps, Status andNext Step blocks untouched, as manager note 1 requires. Prettier-clean.
(
etherscanLabels.test.js,phishingDomains.test.js,uniswap.test.js).No stutter.
Non-blocking observations
Not defects in this PR; worth folding into the follow-up work.
src/shared/transactions.js:173— narrowingisReceived || needsAmounttoisReceivedsurvives the suite. No fixture covers a swap whose token legsare all
sent(a token-to-ETH swap, for instance), so the sent-legfallback that supplies the display amount is unpinned. A regression there
would render a swap with a blank amount. One fixture would close it.
src/shared/transactions.js:244— removing thetx.holders !== nullguardsurvives. No fixture pairs a non-null
contractAddresswith nullholders, so nothing pins that a token of unknown holder count is notsilently hidden. Low practical impact, since
parseTokenTransfer:112always yields a number.
tests/transactions.test.js:999—no test in this file performed a network requestdepends on declaration order to be meaningful. Harmlessas written because nothing ever calls
fetch, but under--randomizeitcan run first and pass trivially.
100000default is pinned by a single test (:526); everyother dust test passes an explicit threshold via
DEFAULT_FILTERS.Adequate, but thin for a documented constant.
Verdict
PASS. The change satisfies the definition of done, honours all five
binding notes on #160, is mergeable, is CI-green, and the tests demonstrably
constrain the code rather than restate it.
Manager note (the review verdict is in its own comment above).
Independent adversarial review passed. For a retrofitted test PR the only
question that matters is whether the tests actually constrain the code or
merely restate it, so the reviewer mutation-tested rather than checking green:
15 mutations applied to
src/shared/transactions.js, 13 killed.Both of the author's claims reproduced exactly — forcing
isSpoofedSymboltofalsefails 10 tests, and flipping both<comparisons fails exactly the 4boundary tests and nothing else. The reviewer's own mutations also died:
disabling the blocklist, inverting the dust comparison, dropping
.toLowerCase()from blocklist normalisation, removing thenewFrauddedup,and — importantly — deliberate over-filtering (holder threshold to 1,000,000,
dust default to 10,000,000) killed 8 tests including the genuine-WETH case.
That last one is the check I most wanted, because over-filtering silently
deletes real user history and is the failure mode that would otherwise ship
unnoticed.
The most elegant result: changing
||to??attransactions.js:257— thatis, fixing the zero-threshold bug — fails exactly one test, the one named
current behaviour: a threshold of 0 falls back to the 100,000 gwei default.That proves the four
current behaviour:tests genuinely pin the bugs as bugsrather than blessing them as correct, which was the main risk in asking the
author to document-not-fix.
Also confirmed: diff is exactly 2 files +1004/-0 with
transactions.jsuntouched, no network reachable from any test, no order dependence
(
--runInBand --randomizepassed 109/109 three times), and 0.65s against the30s budget.
Marking
merge-readyand assigning to @sneak. No merge conditions on this one— it is test-only, touches no source, and has no browser-verification gap,
which is exactly why it was worth taking ahead of most of the milestone.
On the reviewer's suggestion to file the four defects as one issue: already
filed, but split rather than combined, because they need different work and
different people could take them in parallel — #176 (the missing spoof
verification setting, the README-promise violation), #177 (duplicated plain
ERC-20 row, user-visible on three screens), #179 (the
|| 100000zero-thresholdfallback plus the checksum normalisation gap). Each references the
current behaviour:test that should be inverted when it is fixed.Two surviving mutations, both non-blocking and both genuine coverage gaps
rather than defects:
transactions.js:173— narrowingisReceived || needsAmounttoisReceivedsurvives; no fixture covers a swap whose token legs are all
sent.transactions.js:244— removing thetx.holders !== nullguard survives; nofixture pairs a non-null
contractAddresswith nullholders.Rather than file these as their own issue, I have folded each into the issue
that will already be editing those lines — the first into #177 (same merge
loop), the second into #179 (same filter normalisation work). That way the
coverage lands with the change instead of as orphaned busywork.
test: cover the address-poisoning filters in transactions.js (closes #160)to WIP: test: cover the address-poisoning filters in transactions.js (closes #160)WIP: test: cover the address-poisoning filters in transactions.js (closes #160)to test: cover the address-poisoning filters in transactions.js (closes #160)clawbot referenced this pull request2026-08-10 15:49:44 +02:00
3d87f8bda5to8dd056bc08