Type a ticker. Fourteen tokens share the name β Nansen labels decide which one is real.
Every verdict is deterministic arithmetic over Nansen fields β no market cap, no volume, no search rank. npm run verify replays 12 recorded verdicts offline and reproduces every decision hash.
| Ticker in | Cards appear pending, reorder as Nansen facts land | One turns green, impostors go red |
|---|---|---|
PEPE |
14 same-name tokens across 7 chains; the 8 best-ranked by Nansen search are checked | ethereum 0x6982β¦1933 β 92 labelled wallets, $1.2M exchange flow, 18/20 top holders tagged (live, 2026-09-17) |
PEPEGA |
1 candidate, 0 labelled wallets, no exchange flow, 283 holders β IMPOSTOR | abstains: "none of these looks real β the best candidate trips the impostor rule" (live, 2026-09-19) |
XQZPLM |
0 results | abstains: "no token named XQZPLM on Nansen" |
Every verdict ships with a provenance drawer: every Nansen call, its credits, latency, whether it was cached, and the exact response fields that entered the score. The CLI prints the same table with --explain. The verdict hash on the share card covers the decision only, so a cached replay and a live run that reach the same answer hash identically.
Loading β TRUMP: 7 cards pending, progress strip filling, the call rail streaming |
Abstain β PEPEGA: no winner, the red card says why |
Mobile β PEPE verdict at 390 px |
|---|---|---|
![]() |
![]() |
![]() |
Maya heard "buy PEPE", typed it into her wallet, and got fourteen tokens with the same name and the same frog. She bought the three-day-old one. Every safety scanner needs a contract address first β she didn't have one. Explorers show all fourteen as equally valid contracts. DEX search ranks by liquidity or volume, which is exactly what an impostor buys.
Which One's Real ranks same-name tokens by who actually holds and trades them β labelled Smart Money, whales, top-PnL and public-figure wallets, exchange flow, holder tags β and turns one card green. Market cap, volume and search rank are not in the score at all.
score = 3.0Β·ln(1+labelled_wallets) + 0.6Β·max(0, log10|exchange_net_flow_usd|β3)
+ 0.6Β·log10(1+total_holders) + 0.4Β·log10(1+liquidity_usd)
β 2.0Β·[age<7d] β 1.0Β·[age<30d] β 1.5Β·fresh_shareΒ·[labelled<3]
+ 0.8Β·ln(1+recognised_holders) # finalists only; pools/deployers/ENS names don't count
IMPOSTOR = 0 labelled β§ exchange flow < $10K β§ (age < 14d β¨ holders < 500)
ABSTAIN = best < 2.0 β¨ (0 labelled β§ recognised_holders < 3) β¨ best is IMPOSTOR
Worked with real numbers in docs/SCORING.md. Weights live in one object (packages/core/src/score.ts) and are printed with every verdict.
One verdict function, three views. No database, no accounts, no LLM.
Mermaid source β expand to see the diagram as text (renders on GitHub)
flowchart TB
subgraph views
W[apps/web Β· page.tsx] -->|NDJSON stream| R[/api/verdict/]
RL[Nansen call rail Β· live meter] -.->|call:start Β· call:end| R
Q[/q/:query permalink/] --> K
OG[/api/og share card/] --> K
C[packages/cli] --> K
end
R --> K[packages/core Β· whichOnesReal]
K --> S[search/general Β· 0 cr]
K --> FI[tgm/flow-intelligence 7d Β· 1 cr Γ β€8]
K --> TI[tgm/token-information Β· 1 cr Γ β€8]
K --> H[tgm/holders p1 Β· 5 cr Γ 2 finalists]
K --> SC[score.ts Β· WEIGHTS]
SC --> V[(Verdict: ranked, winner, hash, provenance)]
K -. read-through, TTL 30 min .-> CA[(cache Β· .cache/ or /tmp on Vercel)]
CA -. NANSEN_OFFLINE=1 .-> FX[(fixtures/*.json)]
| Layer | Choice | Why |
|---|---|---|
| Engine | TypeScript, packages/core β search β facts β score β tiebreak β verdict |
one pure pipeline shared by CLI and web; score() is a pure function |
| Client | native fetch, apikey header, 10 rps bucket, 6 s timeout, 1 retry, sha256 of every response |
every call β hit, miss or failure β is a Call in provenance |
| Web | Next.js 15 App Router, React 19, plain CSS | streaming /api/verdict, /q/[query] permalink, /api/og share card via next/og |
| CLI | npm run whichone -- <ticker> |
same engine, --explain prints the arithmetic |
| Cache | disk, TTL 30 min (.cache/ locally, /tmp on Vercel); NANSEN_OFFLINE=1 replays fixtures |
0 credits on a hit, labelled as cached |
| Tests / CI | vitest + fast-check + Playwright; 7-stage GitHub Actions pipeline (gates β Vercel production deploy) | no Nansen key anywhere in CI |
Full detail: ARCHITECTURE.md.
The engine, not decoration β every term in the score is a Nansen response field.
| Endpoint | Credits | Fields that enter the score | Decides |
|---|---|---|---|
search/general (result_type: token) |
0 | tokens[].name/symbol/chain/address |
the candidate set β every token named X across chains |
tgm/flow-intelligence (7d) |
1 Γ β€8 | smart_trader/whale/top_pnl/public_figure_wallet_count, exchange_net_flow_usd, fresh_wallets_net_flow_usd |
the core signal: who is trading it |
tgm/token-information |
1 Γ β€8 | token_deployment_date, total_holders, liquidity_usd |
age, breadth, depth; the impostor rule |
tgm/holders (page 1, 20 rows) |
5 Γ 2 | address_label, ownership_percentage |
the top-2 tiebreak: how many top holders Nansen tags |
β€ 26 credits per verdict, 0 on a cache hit. Cached calls are labelled and never counted. Failed calls are shown in the drawer, never hidden β a candidate whose lookups failed is marked "could not check" and is never crowned or called an impostor.
Watch the calls happen. The Nansen call rail on the right of the page streams every request the moment it is issued and the moment it lands β POST tgm/flow-intelligence Β· PEPE Β· base Β· 7d, a pending ring that turns green (live), hollow grey (cached) or red (failed), the credit chip, the latency and a short sha256 of the response β with a call / credit / wall-time meter that ticks as rows land. The rows are the call:start / call:end events of /api/verdict?stream=1, and the call:end payloads are the very Call objects the provenance drawer prints afterwards, so the rail and the drawer agree to the credit (packages/core/test/rail.test.ts pins it). On load the rail already shows the recorded PEPE example's 17 calls, labelled replayed Β· 0 cr.
An RPC or explorer shows transfers; the decision needs who. Take Nansen out and you would need a multi-chain token index, a wallet-labelling graph and a holder indexer β and still could not answer "which one is real". There is deliberately no fallback ranking by market cap.
Not used, on purpose: profiler/address/labels (100 cr) and premium labels (150 cr) β a public tool has to stay under ~26 credits a query. tgm/position-intelligence is perp positioning only. Everything we learned the hard way is in docs/DX-REPORT.md.
| Metric | Value | Source |
|---|---|---|
| Tests | 149 tests (npm test) β regression tests named for the defect they pin |
packages/core/test/ |
| Property-based verification | 50,000 generated cases (fast-check, 5 properties Γ 10,000) on the decision function: the crown rule, rank() as a total order, score() blind to every buyable field |
packages/core/test/property.test.ts |
| Permission boundary | the server key never reaches a client; 10,000 generated malformed queries rejected with zero network calls | packages/core/test/boundary.test.ts, SECURITY.md |
| Spend guard | public route capped at 6 verdicts/min per address and 3,000 live credits/day; past the ceiling a recorded fixture replays at 0 credits, labelled, or the request gets an honest 503 | apps/web/lib/guard.ts, packages/core/test/guard.test.ts |
| E2E | 5 Playwright suites (44 runs), desktop + Pixel 7, built app run without a key | e2e/ |
| Fixtures | 12/12 verdicts reproduced offline, zero network, zero credits | npm run verify, fixtures/*.json |
| Cold latency | p50 3.6 s Β· p95 7.2 s (12 queries Γ 2 runs, live) | docs/BENCH.md |
| Warm latency | p50 3 ms | docs/BENCH.md |
| Credits per verdict | mean 18.6, max 26 | docs/BENCH.md |
| Clean clone β first verdict | 22 s | see Getting Started |
- Fixtures are replays, the default path is live.
fixtures/*.jsonhold 12 real verdicts recorded on 2026-09-16 with every raw Nansen response byte-for-byte.npm run verifyreplays them withNANSEN_OFFLINE=1and requires the same decision hash, the same ranking and zero network calls. The CLI never reads them; the web app reads one only after the day's live credit ceiling is spent, and says so in the verdict (degraded: true, a warning line on the card). - Numbers come from scripts. docs/BENCH.md is the output of
npm run bench.USDC(24 canonical issues) is the slow outlier at 15 s cold; Nansen times out on a few of its solana lookups, which the drawer shows. - What the tests cover: the ranking function table-driven, the abstain/impostor/unchecked paths, hash stability, cache bypass, client retry and timeout accounting, fixture round-trip, the structural-tag rule, the holders tiebreak flip, the address-pasted hint β plus three high-signal categories: defect-named regression tests (the test list reads as the changelog of real bugs found in live QA and code review), property-based verification of the crown rule / ranking / scorer over 50,000 generated cases, and a permission-boundary suite proving the key stays server-side and validation runs before any fetch.
- Label coverage is uneven across chains β a real token on a thinly-labelled chain can lose to a bridged copy on a busy one; the chain filter exists for that.
- Flow-intelligence is a 7-day window; a real but dormant token can look quiet.
search/generaldecides the candidate set: an impostor Nansen has not indexed cannot be warned about.DOGEcrowns a Solana meme DOGE because native DOGE has no contract to compare against.- The holders tiebreak counts wealth/activity tags ("Token Millionaire", "High Activity"), not exchange/fund entities β those are the premium tier and are not used.
- A token with 0 labelled wallets can still be crowned when β₯ 3 top holders carry a wealth tag (
AI16Z,PEPE UNCHAINEDon 2026-09-16) β the card says "0 labelled wallets" so the weakness is visible. - Dead tokens were being crowned on a Uniswap-pool + deployer tag alone (
SHIB2, found in live QA 2026-09-16) β fixed by excluding structural tags; kept as a regression test. - A lone impostor-flagged token could still be crowned on wealth-tagged holders (
PEPEGA, found in live QA 2026-09-19: green card and IMPOSTOR badge at once) β the crown rule now abstains; regression test + property assertion. - Independent code review (2026-09-16) found the web input truncated pasted addresses at 32 chars, a stream ending early left the spinner stuck,
/q/%25threw on a double decode, and OG images were uncached (every link-preview crawler spent β€ 26 credits) β all fixed the same day.
- Node 22 (20+ works)
- A Nansen API key from app.nansen.ai/api β the only configuration
git clone https://github.com/edycutjong/whichone && cd whichone
npm install # ~40 s
export NANSEN_API_KEY=nsn_... # one env var, nothing else
npm run whichone -- PEPE # β€ 26 credits, ~4 s cold, 0 credits and ~0 s on the second runnpm run whichone -- PEPE --explain # every term of the score
npm run whichone -- PEPE --chain base --json # chain filter, machine output
npm run verify # replays 12 recorded verdicts offline β no key, no network, 12/12
npm run dev # http://localhost:3000Measured on a clean clone from GitHub (macOS, Node 22, warm npm cache, 2026-09-16): clone 2 s Β· install 4 s Β· first live verdict 4 s Β· verify < 1 s Β· next build 8 s Β· tests 4 s β 22 s of machine time plus pasting the API key.
7-stage pipeline: Quality β Security β Build β E2E β Performance β Deploy Gate β Production Deploy (prebuilt vercel deploy to whichone.edycu.dev, main only, after every gate) β no API key anywhere in CI; the one secret is VERCEL_TOKEN.
# ββ Code Quality ββββββββββββββββββββββββββββ
npm run lint # ESLint (flat config: TypeScript, React hooks, Next)
npm run format:check # Prettier
npm run typecheck # tsc, strict
npm test # 149 vitest tests (unit + property + boundary)
npm run test:coverage # + v8 coverage report
npm run verify # 12 fixtures, offline, exit 1 on any hash/ranking drift
npm run ci # audit Β· format Β· lint Β· typecheck Β· coverage Β· verify Β· check
# ββ Advanced Testing ββββββββββββββββββββββββ
npm run e2e # Playwright: home, verdict flow, responsive, /judge β built app, no key
npm run e2e:ui # Playwright interactive mode
npm run lighthouse # Lighthouse CI (a11y β₯ 0.9 hard gate; perf/SEO/best-practices advisory)
# ββ Live (spends credits) βββββββββββββββββββ
npm run bench -- --runs 2 > docs/BENCH.md # ~450 credits
npm run check # submission readiness: README claims vs tree, kitchen/secret scan, links| Layer | Tool | Status |
|---|---|---|
| Code Quality | ESLint + Prettier + TypeScript strict | β |
| Unit Testing | vitest, 149 tests, v8 coverage | β |
| High-signal tests | defect-named regressions Β· 50,000 property cases (fast-check) Β· permission boundary | β |
| E2E Testing | Playwright, 5 suites Γ 2 devices, no key | β |
| Security (SAST) | CodeQL (javascript-typescript) | β |
| Security (SCA) | Dependabot (4 manifests + actions, grouped, no majors) + npm audit + license-checker | β |
| Secret Scanning | gitleaks (full history) + TruffleHog (verified only) + npm run check history grep |
β |
| Performance | Lighthouse CI + bundle budget (2 MB) | β |
| Releases | release.yml β semantic tags from conventional commits |
β |
| Judge surface | /judge Β· JUDGE.md β no auth, static | β |
packages/core/ whichOnesReal() β search β facts β score β tiebreak β verdict (+ cache, fixtures)
packages/cli/ npm run whichone -- <ticker>
apps/web/ Next.js 15: streaming /api/verdict, /q/[query] permalink, /api/og share card
e2e/ Playwright: demo-mode Β· verdict-flow Β· responsive Β· judge-route
scripts/ spike Β· seed Β· verify Β· bench Β· check_submission_readiness
fixtures/ 12 recorded verdicts (raw responses + verdict + clock)
docs/ SCORING.md Β· BENCH.md Β· DX-REPORT.md Β· screenshots/
JUDGE.md the /judge page: claim Β· 30-second path Β· receipts Β· reproduce Β· limitations
- Live: whichone.edycu.dev
- For judges: whichone.edycu.dev/judge (mirrored in JUDGE.md)
- Reproduce the recording step by step: DEMO.md
MIT β built for the Nansen Meridian Buildathon by @edycutjong.





