Methodology

GEMHOG answers one question: are the early buyers of this token still holding, or already leaving? Everything below is computed from public chain state; nothing is predicted, simulated or estimated from price.

The early cohort

The cohort is the set of unique CurveBuy recipients in the first 60 seconds after launchedAt. A quiet launch widens the window to the first 20 buyers, but never past 10 minutes.

Excluded from the cohort:

  • the deployer and the creator-fee recipient;
  • the curve, the pool manager, the locker, the router and other infrastructure;
  • the declared bundle: wallets listed as opening-tax exemptions in the launch transaction's calldata.

The pons opening tax starts at 99% and decays to zero over about three seconds, charged per recipient. That makes the buy list unusually informative: a buyer whose recorded tax was ~99% of the buy raced the first block with a bot; a buyer near zero waited on purpose. Buyers at or below 15% form the human cohort. When the human cohort has at least 10 wallets it carries the score; otherwise the full cohort does. Both counts are printed on every certificate.

Checkpoints and retention

Checkpoints are measured from launchedAt: 5m, 15m, 1h, 6h, 24h, 7d — only the ones that have already happened.

Balances are replayed from the token's Transfer history, never read from archive state. For each cohort wallet at each checkpoint:

  • peak is the wallet's largest balance seen up to that checkpoint;
  • the wallet is holding if its balance at the checkpoint is at least 0.8 × peak.

held(t) is the share of the cohort holding at checkpoint t. The half-life is the first checkpoint where held < 0.5; a token that never crosses it shows never once all six checkpoints pass, and not reached before that.

Tokens younger than 5 minutes print TOO EARLY with the time until the first checkpoint.

The four components (sum = 100)

cut — retention, 0-40

cut = 40 · Σ w(t) · held(t) / Σ w(t)
w = { 5m: 1, 15m: 1, 1h: 2, 6h: 2, 24h: 3, 7d: 3 }        (reached checkpoints only)

clarity — concentration, 0-20

Top-10 holder share of supply, with the curve, pool and locker excluded:

top10 < 20%          -> 20
20% <= top10 <= 30%  -> 20 down to 8, linear
30% <  top10 <= 50%  -> 8 down to 0, linear
top10 > 50%          -> 0
bundle holds > 10%   -> additional -5

color — dev behaviour, 0-20

Starts at 20:

first dev exit   -8       (a CurveSell, or an outbound transfer that is not
second dev exit  -12       a burn and not the leg of a counted curve sell,
                           from the deployer or the fee recipient)
fee claim in the first 24h   -4 each, capped at -8
dev bought > 8% of supply    -6
floor at 0

A dev who burns their bag is not penalized: a burn is the opposite of an exit.

carat — weight, 0-20

carat = min(10, holders / 25)
      + min(6, cohortQuote / 2)          (ETH spent by the scored cohort)
      + 4 if the top 3 cohort wallets bought < 35% of the cohort's volume

Grade

Score Grade Score Grade
95-100 FL 50-59 SI1
90-94 IF 40-49 SI2
85-89 VVS1 25-39 I1
80-84 VVS2 10-24 I2
70-79 VS1 0-9 I3
60-69 VS2

Sources and their limits

  • RPC (publicnode for state, the official Robinhood endpoint for logs): launch record, curve state, CurveBuy/CurveSell, Transfer, escrow Credited/Claimed. Log reads are chunked adaptively; a chunk the RPC refuses even at the minimum size is reported as partial, never silently treated as empty.
  • Pons Portal API: holder pages (a Blockscout proxy). Holder counts stop mattering to carat past 250, so pagination stops soon after and the certificate prints N+ for a floor.
  • Blockscout API (optional key): ticker search for bonding-curve tokens.
  • DexScreener: ticker search for graduated tokens.

A ticker proves nothing — anyone can relaunch the same symbol. An ambiguous ticker returns the whole cluster and the certificate is always issued for one contract address.

A grade is a measurement of past holder behaviour. It is not a prediction, not an endorsement, and not a proof that a token is safe.

Command reference

Every command accepts --format text|json|markdown and --output <file>. Exports refuse to overwrite an existing file. Nothing needs an API key; .env values are optional accelerators.

check <token|ticker> (alias: grade)

The certificate: early cohort, retention at every reached checkpoint, concentration, dev behaviour, weight, grade. A ticker fans out to DexScreener (and Blockscout with a key), every candidate is verified against the pons factory, and an ambiguous ticker returns the cluster table instead of guessing.

gemhog check 0xac79255f6f404eba14f316e8669d76573a2d7b1e
gemhog check PEANUT
gemhog check 0x… --format json --output certificate.json

Tokens younger than 5 minutes print TOO EARLY. A week-old token replays its whole transfer history and takes up to a minute or two.

--card out.png additionally writes the 1080x1080 share card (red / yellow / green by score band); exclusive like every export, and refused for a token under 5 minutes.

hunt

The flagship, and it lives only in the CLI. Indexes every launch in the window, grades the funded candidates best-funded first inside a time budget, prints the ranked table.

gemhog hunt --window 6h                 # 1h 3h 6h 12h 24h
gemhog hunt --min-grade VS2 --top 10
gemhog hunt --budget 120                # seconds of grading
gemhog hunt --follow                    # keep digging; see below

--follow re-digs every two minutes, prints tokens entering the top, appends VS1+ findings to ~/.gemhog/alerts.json for serve, and posts a Telegram alert when TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set. With both empty, nothing is ever posted anywhere.

watch <token>

Re-grades one token every 30 seconds and prints only what changed: grade, score, holder count, dev sells.

top

The ten best rows of the last hunt from ~/.gemhog/last-hunt.json; re-digs briefly when the cache is older than 20 minutes. Made for the bot.

holders <wallet>

Every pons token on a public address, graded in turn, with balances and share of supply — the CLI twin of /holders on the site. Wallet listing needs Blockscout; on networks where its API answers with a bot challenge, set a free BLOCKSCOUT_API_KEY.

export

The last hunt as CSV (default) or JSON.

gemhog export --format csv --output stones.csv

serve

The local JSON API for the bot: GET /check/:token, /top, /holders/:wallet, /alerts?since=, /health. Binds to 127.0.0.1 only. See BOT.md.

demo

The offline walkthrough: the recorded fixture graded by the real engine, every line marked DEMO, no network.

doctor

Measured checks of every source: both RPC endpoints, the factory addresses re-read from the live factory, opening-tax parameters, Pons API, Blockscout, DexScreener. --probe additionally grades the example token end to end. Exit code 2 when any line fails.

Architecture

One engine, three doors. The grading engine lives in lib/gemhog/ and is the only place the formulas exist; the CLI, the site's API routes and (through serve) the Telegram bot all import the same code. If two surfaces ever disagree about a grade, that is a bug by construction, not a judgement call.

                       lib/gemhog  (TypeScript, ESM, one runtime dep: viem)
                      ┌──────────────────────────────────────────────┐
   chain reads        │  read/    launches · logs · holders · wallet │
   (rpc gate, chunked)│  grade/   cohort · checkpoints · components  │   pure math,
                      │  resolve · certificate · card · hunt · serve │   fixture-tested
                      └──────┬──────────────┬──────────────┬─────────┘
                             │              │              │
                   bin/gemhog.mjs      app/api/*      gemhog serve (127.0.0.1)
                     the CLI          Next.js site      └─ bot/ (aiogram)

Layers

  • lib/gemhog/rpc.ts — every JSON-RPC request goes through one gate: a list of public endpoints with capabilities (publicnode for state, the official Robinhood endpoint for logs), bounded concurrency, minimum spacing, a process-wide cooldown after a 429, and a penalty box per endpoint.
  • lib/gemhog/read/ — everything that touches the network. Log reads are chunked adaptively (AIMD): 1M-block strides through quiet ranges, 500-block bites through launch-hour hot zones, and a chunk the RPC refuses even at minimum size is reported as partial, never silently treated as empty.
  • lib/gemhog/grade/ — pure functions, no network anywhere. GradeSource in, certificate out; the fixtures in test/fixtures/ exercise exactly this boundary.
  • lib/gemhog/card.ts — the share-card renderer (@napi-rs/canvas), a one-to-one port of the reference in assets/cards/render_cards.py; served by GET /api/card on the site and GET /card/:token on serve.
  • bin/gemhog.mjs — a thin commander shell over the compiled engine (pnpm build:cli.gemhog-build/).
  • app/ — Next.js App Router. The API routes import the engine directly; the pages are the browser skin. The site builds with webpack (next build --webpack): the engine uses NodeNext .js specifiers so the compiled CLI runs on plain Node, and webpack's extensionAlias maps them back to the TypeScript sources, which Turbopack currently will not.
  • bot/ — Python, aiogram 3. Zero grading logic; it formats answers from gemhog serve and keeps its subscriptions in SQLite.

The expensive-read rule

Costly reads (transfers, holders, funding) run only for a short list, never across a whole window. hunt indexes thousands of launches with one event filter and three multicall reads each, then spends a wall-clock budget grading candidates best-funded first. check reads one token's history capped at the 7-day checkpoint.

Data sources

Source Used for Failure mode
Robinhood RPC (logs) TokenLaunched, CurveBuy/Sell, Transfer, escrow events adaptive chunking; partial when refused
publicnode RPC (state) multicall3 reads, blocks, transactions gate retries, endpoint penalty box
Pons Portal API holder pages, cluster holder counts falls back to Transfer replay
Blockscout API wallet token lists, ticker search (with key) typed error with the exact fix
DexScreener ticker search for graduated tokens resolve degrades to CA-only

Hosting

The site deploys to Vercel as a standard Next.js project (pnpm build; env: RPC_URL and BLOCKSCOUT_API_KEY, both optional). It would run equally well on Cloudflare or any Node host — nothing in the repository is Vercel-specific beyond the defaults, the same way hop-out hosts theirs elsewhere.

The pulse workflow (.github/workflows/pulse.yml) refreshes assets/pulse.json every 20 minutes from GitHub Actions and commits only on change, so the landing counter stays honest without a server.

Testing

pnpm check        # typecheck + lint + tests + site build; the commit gate
pnpm test         # engine and CLI tests only
cd bot && pytest  # bot tests (pip install -r requirements-dev.txt once)

What is tested, and how

The grade math is tested on fixtures with hand-derived expectations. The three fixtures in test/fixtures/ — a VVS1 keeper, an SI2 dumper, an I2 rug with a declared bundle — were built by computing every expected component score from the formulas in METHODOLOGY.md before the code ran. If a formula changes, the spec changes first, then the fixture expectation, then the code.

  • grade.test.mjs — components and the final grade on all three fixtures; the boundary cases: a token under 5 minutes, retention exactly at the 0.5 half-life edge, a dev who never bought, grade band edges.
  • cohort.test.mjs — exclusions (deployer, declared bundle), the human-cohort tax split, quiet-launch window widening, one wallet buying twice.
  • hunt.test.mjs — candidate ranking, the grade order, the table and CSV renderers on the spec's own sample shape.
  • receipt.test.mjs — holder tables and the EXAMPLE-marked receipt.
  • cli.test.mjs — the demo walkthrough (every line marked DEMO), formats, exclusive exports that refuse to overwrite.
  • serve.test.mjs — the serve contract's offline surface: liveness, validation, empty states, 405s.
  • bot/tests/ — message formatting (pure) and the API client against a mocked serve: a real local HTTP server with canned answers.

What is deliberately not in CI

Anything that talks to a live provider: doctor, check against real tokens, holder listings. Those are exercised by hand at every milestone gate — the workflow in the spec compares certificates against Blockscout token pages — and by the pulse workflow, which effectively runs a bounded hunt from GitHub Actions every 20 minutes. CI failing because a public RPC had a bad minute teaches nothing; CI passing while the math is wrong would be worse. The split keeps each side honest.

Live verification checklist (per milestone)

  1. pnpm gemhog doctor — all reachable sources green, addresses match the live factory.
  2. pnpm gemhog check on a graduated token, a curve token and a dead one; compare holder counts and dev sells against Blockscout by eye.
  3. pnpm gemhog hunt --window 6h — completes inside 90 seconds on the public RPC without a 429 crash.
  4. curl localhost:3000/api/grade (site running) — byte-identical JSON to the CLI for the same token.

The Telegram bot

The bot is a thin shell over gemhog serve: it implements zero grading logic and never talks to the chain. Every answer a user sees was computed by the same engine that powers the CLI and the site.

telegram <-> bot (python, aiogram)  <->  gemhog serve (127.0.0.1:4664)  <->  the engine

Commands

Command What happens
/check 0x… GET /check/:token on serve; the certificate as a <pre> block, grade bold, links to Blockscout and the site terminal. A bare pasted address does the same.
/top GET /top: the ten best rows of the last hunt. This is the owner's bot, so the list is allowed here — the public API still returns only counters.
/watch 0x… Re-grades every 15 minutes through serve and messages on any change of grade, score or dev behaviour. /unwatch stops all.
/alerts on|off VS1-or-better findings from gemhog hunt --follow, polled from GET /alerts?since=. Needs a follow running next to serve.

English, no emoji — with one exception fixed by the spec: a single diamond before VVS2 and better.

Running it

Two processes, one machine:

# terminal 1: the engine's local API (127.0.0.1 only, by design)
pnpm build:cli && node bin/gemhog.mjs serve

# terminal 2 (optional, feeds /top and /alerts):
node bin/gemhog.mjs hunt --window 6h --follow

# terminal 3: the bot
cd bot
pip install -r requirements.txt
TELEGRAM_BOT_TOKEN=123:abc python main.py

Or with Docker: TELEGRAM_BOT_TOKEN=123:abc docker compose up.

Environment:

Variable Meaning
TELEGRAM_BOT_TOKEN required; the bot refuses to start without it
GEMHOG_SERVE_URL serve base URL, default http://127.0.0.1:4664
GEMHOG_SITE_URL when set, certificates link to /terminal?token= on the site
GEMHOG_BOT_DB SQLite path, default bot/state.db

State

One SQLite file with three tables: watches (chat, token, last seen grade), alert subscriptions (chat, since), and sent alerts (so a restart never re-sends). Delete the file to reset everything.

Tests

cd bot && pip install -r requirements-dev.txt && pytest

Formatting tests are pure; the API client is tested against a mocked serve — a real local HTTP server with canned answers. Nothing in the test suite touches Telegram or the chain.