Skip to content

Latest commit

Β 

History

92 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Which One's Real mark β€” three stacked token cards, the middle one turns green as Nansen labels land

Which One's Real 🟒

Type a ticker. Fourteen tokens share the name β€” Nansen labels decide which one is real.

Which One's Real β€” identical grey PEPE cards; Nansen labels land, exactly one turns green and the impostor turns red

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.


Live Demo For Judges Built for Nansen Meridian Submission on X


Next.js TypeScript Nansen API tests property cases fixtures License CI CodeQL Release


πŸ“Έ See it in Action

Which One's Real β€” 17 s demo: type PEPE, 14 same-name cards appear; on the right the Nansen call rail streams every call as it happens (endpoint, chain, credits, ms, response hash, pending ring β†’ green dot, counters climbing to 19 calls Β· 26 credits Β· 4.3 s); one turns green with its address, impostors go red, then the drawer lists every Nansen call behind the verdict

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.

Provenance drawer over the PEPE verdict: every Nansen call with its endpoint, chain, credits, latency and cached flag β€” the red IMPOSTOR card and the greyed cards visible behind it
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
TRUMP mid-stream: 7 candidates, checking labels 1/7, every card pending, rail rows pending on the right PEPEGA: amber No winner banner, one red IMPOSTOR card with 0 labelled wallets PEPE verdict on a phone: green banner and the green ethereum card

πŸ’‘ The Problem & Solution

The Problem

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.

The Solution

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.

πŸ—οΈ Architecture & Tech Stack

One verdict function, three views. No database, no accounts, no LLM.

Which One's Real architecture β€” views (web page, live Nansen call rail, permalink, OG card, CLI) β†’ /api/verdict with spend guard β†’ packages/core whichOnesReal β†’ four Nansen endpoints with credits β†’ verdict with provenance; read-through cache and offline fixtures

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)]
Loading
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.

πŸ† Nansen Integration

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.

Why only Nansen

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.

πŸ“Š Engineering Rigor

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

Honesty

  • Fixtures are replays, the default path is live. fixtures/*.json hold 12 real verdicts recorded on 2026-09-16 with every raw Nansen response byte-for-byte. npm run verify replays them with NANSEN_OFFLINE=1 and 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.

Honest limits (9)

  1. 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.
  2. Flow-intelligence is a 7-day window; a real but dormant token can look quiet.
  3. search/general decides the candidate set: an impostor Nansen has not indexed cannot be warned about.
  4. DOGE crowns a Solana meme DOGE because native DOGE has no contract to compare against.
  5. The holders tiebreak counts wealth/activity tags ("Token Millionaire", "High Activity"), not exchange/fund entities β€” those are the premium tier and are not used.
  6. A token with 0 labelled wallets can still be crowned when β‰₯ 3 top holders carry a wealth tag (AI16Z, PEPE UNCHAINED on 2026-09-16) β€” the card says "0 labelled wallets" so the weakness is visible.
  7. 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.
  8. 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.
  9. 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/%25 threw on a double decode, and OG images were uncached (every link-preview crawler spent ≀ 26 credits) β€” all fixed the same day.

πŸš€ Getting Started

Prerequisites

  • Node 22 (20+ works)
  • A Nansen API key from app.nansen.ai/api β€” the only configuration

Installation

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 run

Run it in under 10 minutes

npm 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:3000

Measured 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.

πŸ§ͺ Testing & CI

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 βœ…

πŸ“ Project Structure

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

πŸ“½οΈ Demo Materials

πŸ“„ License

MIT β€” built for the Nansen Meridian Buildathon by @edycutjong.

About

🟒 Type a ticker. Fourteen tokens share the name β€” Nansen labels decide which one is real. One green card, deterministic score, full provenance.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages