fix: treat an unreported holders_count as unknown, not as zero holders (closes #230) #244
Reference in New Issue
Block a user
Delete Branch "fix/issue-230-unknown-holders-count"
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 #230.
holders_countis optional in the explorer's response. Reading it asholders_count || "0"recorded "the explorer said nothing" as "this token hasno holders" — the strongest spam signal the wallet has. Consequences: a
legitimate transfer vanished from history, a token the user holds vanished from
the Send selector, and the
tx.holders !== nullguard infilterTransactionswas unreachable for token transfers, because the coercion guaranteed a number.
What changed
New
src/shared/holders.jsowns the null-versus-zero rule and the1,000-holder threshold. The rule was open-coded at three call sites and was
wrong at all three; now there is one place to be wrong.
parseHoldersCount(raw)-> number, ornullwhen the count is omitted,null, empty, or unparseable. A count we cannot read is not a count of zero.isLowHolderCount(holders)-> true only for a reported count below thethreshold.
Call sites:
parseTokenTransfer(src/shared/transactions.js) emitsholders: nullinstead of
0.filterTransactionslow-holder rule now usesisLowHolderCount, which makesthe previously dead
holders !== nullguard live.renderSendTokenSelect(src/popup/views/send.js:135) uses the samepredicate instead of
(t.holders || 0) < 1000.fetchTokenBalances(src/shared/balances.js) stops recording an unreportedcount as
0— see the decision below for why its own gate is unchanged.fetchTokenBalancesis now exported so the gate can be tested.The decision: an unknown count shows the token
In the two user-facing low-holder filters (history, Send selector): unknown
means shown. Hiding an asset the user owns is a worse failure than showing a
spam row they can see is unusual — in the Send selector it is strictly worse,
since the token becomes unspendable through the UI rather than merely hidden.
Both filters sit behind the "Hide tokens with fewer than 1,000 holders"
setting, so a user who wants maximum strictness is not being overruled; and the
holders !== nullguard already infilterTransactionsshows this was theintended policy all along, defeated by the coercion upstream of it.
In the balance-list spam gate (
fetchTokenBalances): unknown staysexcluded. This one is deliberately the other way, and it is not the same
filter. It has no off switch, it governs what the balance list contains at all
rather than what a filter hides, and the tokens most likely to be missing their
holder count are exactly the newly-indexed ones — which is what a spam airdrop
is. Admitting unknowns there would put unfilterable spam on the home screen,
with the setting powerless over it. A legitimate token still reaches the list
through the bundled token list or by the user tracking it, and it now carries
holders: nullthrough, so the downstream filters no longer hide it on thestrength of a zero it never reported. That is the case the Send-selector fix
actually rescues.
Other
|| 0/|| "0"coercionsSwept
src/for the same pattern. Nothing else collapses a meaningful absentinto zero:
balance || "0"(prices.js,home.js,helpers.js,addressToken.js,send.js,confirmTx.js) — feeds numeric display and arithmetic, never ahide/show decision. An address with no fetched balance has nothing to show
and "0" is the honest placeholder.
txParams.value || "0"(approval.js) — EIP-1193 omitsvaluefor azero-value call, so absent is zero by spec.
tx.value || "0",total.value || "0",rawAmount || "0"(
transactions.js) — the value of a transaction is not optional in the API,and a call that moves nothing is genuinely zero-valued.
lastBalanceRefresh || 0(state.js,background/index.js) — absent means"never refreshed" and 0 (the epoch) produces the identical outcome: the
staleness check passes and a refresh runs. Fail-open in the right direction.
.replace(/0+$/, "") || "0"(balances.js,confirmTx.js) — stringformatting of an all-zero fraction, not a value decision.
decimals || "18"(transactions.js,balances.js) — worth naming because0 decimals is a legal ERC-20 value: Blockscout sends
decimalsas a string,and
"0"is truthy, so absent and zero stay distinct. A numeric0wouldcollapse; left alone rather than hardened, as it is a different field and out
of this issue's scope.
Verification
Tests were written first and confirmed failing on unmodified
next— 5 failed(3 in
tests/transactions.test.js, 2 intests/sendTokenSelect.test.js), withthe zero-holder regression assertions passing before and after, so they pin the
old behaviour rather than the new.
tests/transactions.test.js— omitted andnullholders_countparse tonulland survive the low-holder filter end-to-end throughfetchRecentTransactions; a reported"0"still parses to0and is stillfiltered. The zero fixture uses a symbol absent from the token list, so the
holder rule is the only rule that can catch it.
tests/sendTokenSelect.test.js(new) — drivesrenderSendTokenSelectagainst a stub document: unknown and missing counts are offered, 0 and 999
are withheld, 1000 is offered, the setting still bypasses the rule, and the
spoof and fraud-contract rules still withhold a token with an unknown count.
tests/holders.test.js(new) — the parse and threshold rules directly, plusthe balance-list gate: zero and unknown both stay excluded for an unvouched
token, while a known-list or tracked token is listed with
holders: null.make checkgreen after the rebase ontoba35282: 16 suites, 388 testspassed,
prettier --checkclean. Rebased onto currentnextimmediatelybefore pushing; the
TODO.mdconflict against#239 was resolved keeping
both entries, and
make checkwas re-run after resolving.PASS — independent review of
bac1c23. Every DoD item in #230 verified by execution: the 5 claimed pre-change failures reproduce exactly (3tests/transactions.test.js, 2tests/sendTokenSelect.test.js) at merge baseba35282, and the zero-holder regression assertions are non-vacuous — mutatingisLowHolderCountto treat a reported 0 as unknown kills 6 tests, mutatingparseHoldersCountto returnnullfor 0 kills 3, and reverting it to return0for an unknown kills 6.make checkand containerizedscript/cibuildboth green, 16 suites / 388 tests, executed not cached.Non-blocking notes:
src/shared/holders.js:13-18— the comment says "Anything unparseable is unknown too", butparseIntpartial-parses:"1,000"->1and"0x10"->0, i.e. a malformed count beginning with a digit becomes a reported low count and triggers the exact hide this PR exists to prevent. Not a regression (pre-change used the sameparseInt) and no Blockscout output looks like this, so it is a contract overstatement rather than a live bug. A strict form (/^\s*\d+\s*$/before parsing) would make the code match the comment.src/shared/balances.js:87— theholders !== null &&conjunct is inert;null >= 1000is alreadyfalse. Removing it passes all 388 tests. Defensive explicitness, not an untested guard.src/popup/views/addressToken.js:189now omits the "Holders:" row for an unreported count instead of displaying "Holders: 0". An improvement; worth a line in the PR body.decimals || "18"numeric-0collapse named and deferred in the PR body is a real latent bug with no tracker issue behind it. Worth filing.Split decision: coherent.
tokenBalancesis the single source for both the balance list and the Send selector, and the balance list applies no filter of its own (src/popup/views/helpers.js:195), so the "no off switch" claim holds and "spendable but invisible" is impossible by construction; the change strictly reduces the pre-existing visible-but-unspendable divergence.#235: unchanged, neither better nor worse. The balance gate's admission outcome is identical pre/post (
0andnullboth fail>= 1000) and the null-mapped-symbol check atsrc/shared/balances.js:95-100is untouched. In the Send selector an unknown-count fakeETHis no longer withheld by the low-holder rule, butisSpoofedTokencatches it first — pinned by the new test attests/sendTokenSelect.test.js:106.nextmoved tobf1dbecafter this was pushed. The branch is no longer a fast-forward but merges cleanly, and the merged tree is green (18 suites / 394 tests), so no rebase is required for correctness.