You hear "buy PEPE", type PEPE into your wallet, and get fourteen tokens with the same name and the same frog. Which one do you buy?
I built a tool to answer that. You type a ticker, and it asks Nansen's API for every token with that name across chains. Then it checks who actually holds and trades each one: labelled Smart Money, whales, top-PnL wallets, exchange flow, tagged top holders. Exactly one card turns green, or the tool abstains.
Last week it crowned a token that its own scorer had flagged as an impostor. My property test ran 50,000 generated cases on that exact decision, and they all passed. While writing this post I found out why, and the answer was worse than I expected.
Two rules in two files
The engine judges each candidate twice.
The impostor rule lives in score.ts. A candidate is an impostor when nothing labelled has touched it, it has no meaningful exchange flow, and it is either brand new or barely held:
// packages/core/src/score.ts
const impostor = f.labelledWallets === 0 && exchMag < 10_000 && ((f.ageDays != null && f.ageDays < 14) || (f.totalHolders != null && f.totalHolders < 500));
if (impostor) reasons.push("IMPOSTOR: nothing labelled has ever touched it");
The crown rule lives in verdict.ts. It decides whether ranked[0] gets the green card or the tool says "none of these looks real". This is how it read before the fix (abridged: I left out the guards for zero results, unscorable chains and failed lookups, which didn't change):
// packages/core/src/verdict.ts, before commit 3b5f16f (abridged)
export function crown(ranked: Scored[], sameNameCount: number, q: string): { winner: Scored | null; abstainReason?: string } {
const best = ranked[0];
/* ... guards: 0 results, unscorable, every lookup failed, holders lookup failed ... */
if (best.score < ABSTAIN_THRESHOLD || (best.labelledWallets === 0 && (best.recognisedHolders ?? 0) < MIN_RECOGNISED_TO_CROWN))
return { winner: null, abstainReason: "none of these looks real — nothing labelled has touched any of them" };
return { winner: best };
}
best.impostor isn't read anywhere in it. The score module had already answered the question, and the crown rule never asked.
A fix for one bug opened the door
Three days earlier, live testing turned up a different failure. SHIB2 was a dead token, about $36K market cap, three years old, with zero labelled wallets, and the tool crowned it REAL. The cause was the holders tiebreak. Nansen's tgm/holders returns an address_label for each top holder, and SHIB2's top holders included a UniswapV2 pool and a SHIB2 Token Deployer. Every token has a pool and a deployer, so those labels prove nothing.
That fix excluded structural tags (pools, deployers, ENS/SNS names, burn addresses, the contract itself). It also raised the bar for a candidate with zero labelled wallets: it can be crowned only if at least 3 of its top 20 holders carry a wealth or activity tag, like "Token Millionaire" or "High Activity". That is MIN_RECOGNISED_TO_CROWN = 3.
I added that clause on purpose as an escape hatch. Nansen's labels are uneven, and some real tokens have no labelled wallets in a 7-day window but plenty of tagged holders.
On 2026-09-19, PEPEGA got through that hatch. It had one candidate:
- 0 labelled wallets in 7 days
- no meaningful exchange flow
- 283 holders
- 7 of its top 20 holders wealth-tagged
283 holders is under 500, so score() set impostor: true. The score cleared the abstain threshold and 7 tagged holders beat the bar of 3, so crown() crowned it anyway. The fix commit put it this way: "a green card and an IMPOSTOR badge at once."
The fix takes two lines:
// packages/core/src/verdict.ts, after commit 3b5f16f
// a card cannot be "this is the one" and IMPOSTOR at once: when the best candidate trips the impostor rule, abstain
if (best.impostor) return { winner: null, abstainReason: "none of these looks real — the best candidate trips the impostor rule" };
Here is the CLI output now:
PEPEGA — 1 same-name token on Nansen
✖ ethereum 0x9634…c879 PEPEGA 4.68 0 labelled wallets in 7 days · no meaningful exchange flow · 283 holders IMPOSTOR
no winner — none of these looks real — the best candidate trips the impostor rule
7 credits · 4 calls (0 cached) · 2.1s · verdict 47b4ac4fc907
Why 50,000 property cases missed it, part one
Two days before PEPEGA, I had added property-based tests with fast-check. The crown-rule property generates random lists of scored candidates, ranks them, calls crown(), and checks every winner. It runs 10,000 times for each of five properties, which is where the 50,000 comes from. Everything was green.
This is what the property asserted about a winner before the fix:
// packages/core/test/property.test.ts, before commit 3b5f16f (the winner check only)
return (
winner === ranked[0] &&
winner.scorable &&
!winner.unchecked &&
winner.score >= ABSTAIN_THRESHOLD &&
!(winner.labelledWallets === 0 && (winner.recognisedHolders ?? 0) < MIN_RECOGNISED_TO_CROWN) &&
abstainReason === undefined
);
Each line restates one of crown()'s own if statements, so the property only checked that the function did what the function said. The rule I had missed wasn't in crown() at all. It was a rule about what the user sees: a card is never green and IMPOSTOR at once. That rule crosses two modules, and my tests were organized by module.
The fix commit added !winner.impostor && to that list. At the time I thought that closed it.
Part two: the generator never made an impostor
While writing this post I wanted to say how fast the new assertion would have caught the bug. So I copied the test's generator into a scratch script, paired it with the pre-fix crown(), and counted.
The generator draws each wallet class separately, and the labelled count is their sum:
// packages/core/test/property.test.ts (abridged: 4 of the 17 fields)
smartTraderWallets: fc.nat(500),
whaleWallets: fc.nat(500),
topPnlWallets: fc.nat(500),
publicFigureWallets: fc.nat(500),
// ...
.map((r) => facts({ ...r, labelledWallets: r.smartTraderWallets + r.whaleWallets + r.topPnlWallets + r.publicFigureWallets }));
For a candidate to have zero labelled wallets, all four independent draws have to land on 0. Across five seeded runs of 10,000 lists, about 44,000 candidates per run, the generator produced zero candidates with zero labelled wallets. So it produced zero impostors, and zero cases where the pre-fix crown() crowned one. Even with the right assertion, the old code passed.
It is not just the impostor line. Every branch that matters for a zero-labelled token (the SHIB2 bar, the PEPEGA veto, the failed-holders abstain) was sitting in a corner of the input space the generator never visited.
I changed one thing in the scratch copy, not in the repo. Each wallet count became fc.oneof(fc.constant(0), fc.nat(500)), so zero comes up about half the time for each class. Then I ran it against the pre-fix crown() with the !winner.impostor property. fast-check found a counterexample on all five seeds, after 47, 53, 71, 109 and 244 cases.
Two things went wrong, and fixing either one alone would not have caught the bug:
- The invariant was the implementation restated. Write properties about what the output must never do, not about which branches the code takes.
- The generator had no weight where the bugs were. A uniform draw almost never produces the edge case your domain revolves around. Here that case was "zero". If a value drives a branch, put it in the generator on purpose.
As of this writing, the repo's generator still has that gap. The PEPEGA regression test pins the actual bug, so the crown rule is covered. The property test just doesn't cover as much of it as "50,000 cases" suggests.
The rest of the system
The score is plain arithmetic over four Nansen endpoints:
-
search/general: 0 credits -
tgm/flow-intelligenceandtgm/token-information: 1 credit each, for up to 8 candidates -
tgm/holders: 5 credits, for the top two finalists only
Market cap, volume and search rank are left out of the score, because those are what an impostor can buy.
The repo includes a benchmark: 12 queries × 2 cold runs against the live API, on 2026-09-16. Cold p50 was 3.6 s, p95 7.2 s, warm p50 3 ms. Verdicts cost 18.6 credits on average and 26 at most. npm run verify replays twelve recorded verdicts offline with the same decision hash. It needs no API access and no network. The web page streams every Nansen call into a side rail as it fires, with the endpoint, credits, latency and a short hash of each response.
Limitations
-
search/generaldecides the candidate set. The tool can't warn about an impostor Nansen hasn't indexed. - Label coverage is uneven across chains. A real token on a thinly labelled chain can lose to a bridged copy on a busier one, which is why there's a chain filter.
- Flow data covers a 7-day window, so a real but dormant token can look quiet.
- The zero-labelled escape hatch is still there. On 2026-09-16,
AI16ZandPEPE UNCHAINEDwere crowned on wealth-tagged holders alone. The card says "0 labelled wallets" so you can see the weakness, but it's a weaker verdict than a green card backed by 90 labelled wallets. -
DOGEcrowns a Solana meme DOGE, because native DOGE has no contract to compare against. -
USDCis the slow outlier, about 15 s cold. Nansen times out on some of its lookups, and the call drawer shows each timeout.
Try it
- Live: https://whichone.edycu.dev. Try
PEPE, thenPEPEGA. - Code: https://github.com/edycutjong/whichone.
npm run whichone -- PEPE --explainprints every term of the score (you need your own Nansen key).
If you write property tests, count how often your generator actually produces the values your branches depend on. I hadn't, until this post.
Top comments (0)