gmgn-openapi-service already exposes two read-only call-out routes on
openapi.gmgn.ai behind the normal API-key auth, so the CLI can reach them
without the separate papi AK/SK channel:
- gmgn-cli callout record -> POST /v1/callout/get_record: one wallet's
call-outs over the last 30 days plus the 7d/30d summary card
- gmgn-cli callout global -> POST /v1/callout/global: multi-chain feed with
only-kol / only-declaration / only-safe / holding-range filters
Both are weight 3. Chains are validated against the service's 13-chain
callout list, kept separate from the tradable VALID_CHAINS. Limit is checked
against the service's 0-50 bound; holding bounds are sent as decimal
strings. /v1/callout/token is deliberately left out: the service removed it
(prod returns 404) and publishing call-outs is not part of OpenAPI.
Docs: new skills/gmgn-callout/SKILL.md, CLI reference in docs/cli-usage.md
and both Readmes, routing rows in CLAUDE.md.
Co-Authored-By: Claude <noreply@anthropic.com>
Adds two wallet skills and wires them into the routing.
gmgn-wallet-review (new): a glanceable trading portrait of one wallet. It
gives one style tag, a one-sentence recap of the record and a one-sentence
next step, for 1D / 7D / 30D / all, voiced 你 for the user's own wallet and
他 for anyone else's. It is a single SKILL.md with read-only gmgn-cli
portfolio calls, fixed rules and closed sentence templates. It covers SOL and
all 8 EVM chains, probing the chain for a 0x address.
- The recap names only problems in the trader's own trading: fees, holding
time, loss cutting, sizing, adds, open bags, return trend and launches.
Follower count appears only as a warning in visitor advice.
- Discipline rules from holdings, checked on 22 wallets: losers held 2x
longer, losers sized 1.5x bigger (median per-buy ticket), adds 10 points
worse, and 3+ open bags down 50% with $50+ cost.
- The next step never repeats the recap's problem.
gmgn-wallet-style (new to the repo): the full style tagging, with a 20-cell
title, speed subtitle and badges, each tag stating its triggering number.
It has a nine-chain allowlist and address patterns checked before any
command, and escapes every string it renders.
Both skills' tag rules match freq_level / pnl_level / style_title /
style_speed in gmgn-wallet-analysis/analyze.py. P5 needs ROI > 50% plus
token_num >= 5 plus ONE of {win rate >= 50%, heavy-loss share < 15%}. A diff
against the imported functions over 5,000+ cases gave 0 mismatches. The one
intended difference is gmgn-wallet-review's 3% 净盈 line at machine speed.
Routing:
- CLAUDE.md lists both skills and sends 这个钱包什么风格 / 交易风格 /
wallet style to gmgn-wallet-style instead of the smart-money-profile
workflow.
- gmgn-wallet-analysis hands style-only asks and one-line recap / next-step
asks to the new skills.
- gmgn-wallet-score hands one-line recap asks to gmgn-wallet-review. Its
description becomes a >- block (the old plain scalar failed yaml.safe_load)
with identical text.
- Readme.md and Readme.zh.md list both skills.
No trigger phrase is claimed by two skill descriptions.
Both skills stop on a rejected API key (401 AUTH_KEY_INVALID) as well as on a
rate limit, and reference only skills that exist in this repo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`buy_tx_count_cur == 0` was taken to mean "these chips were transferred or
airdropped in, the holder paid nothing". Measured data refutes that inference.
On musebook (robinhood) 38 wallets read `buy_tx_count_cur: 0` and
`avg_cost: null`, but 34 of them carry a finite `unrealized_pnl` ratio — +2.7%,
+76.5%, +546% — and a chip with no cost cannot produce a finite PnL ratio; a
+2.7% ratio is only possible against a cost basis near the current price. 32 of
the 38 also carry a `fomo` frontend tag and 2 a `gmgn` tag, which is the trace of
active trading, the opposite of receiving an airdrop. What is actually missing is
buy-transaction indexing on that chain, while the PnL fields come from a separate
computation and arrive populated.
The error was systematic per chain rather than per token. Never-bought share of
supply across one batch: robinhood 27.0% and 41.2%, arc 26.3%, sol 4.8%. The 20%
airdrop warn gate therefore fired on essentially every robinhood and arc token and
essentially never on a sol token — a difference in index coverage, not in chip
structure. Reporting an unpopulated field as a risk conclusion is what this skill
explicitly forbids elsewhere.
So zero cost now requires positive evidence of no cost:
def has_cost_basis(h):
return (h.get('avg_cost') or 0) > 0 or h.get('unrealized_pnl') is not None
Six consumers of the old predicate move to it, all the same root cause:
- the `airdrop` cohort and its >20% warn gate, plus the chip-quality buckets and
the exit-signal line that read it
- `diamond`, whose own stated rationale is "a wallet that never paid has no cost
to hold through" — a proven cost basis satisfies that, and requiring an indexed
buy count discarded most real diamond hands (JOLLY: 34 → 75 wallets,
21.1% → 55.7% of float)
- `idle_airdrop`, whose "zero cost" caption was a false statement for those rows
- Top5 sell-risk classification, which labelled a wallet sitting at +6.6x as
"zero-cost airdrop — can dump anytime" and simultaneously dropped it from the
trapped and in-profit counts
Wallets with no indexed buy count but a real cost basis are not dropped from the
report — they get their own neutral line, `买入未记录 / Buy count unindexed`, with
their float share. That upstream did not index their buys is true and stays
visible; it just no longer drives a zero-cost gate.
Known limit, stated in SKILL.md: a genuine airdrop wallet whose `unrealized_pnl`
is a huge ratio computed against a dust-level cost is missed here. No second ratio
threshold is added — there is no measured sample to calibrate one on.
Also in the Advice section: a ⚠️ Caution rating needs two or more warns by
definition, but only `warns[0]` was printed, so a reader could never see why the
rating was what it was. Every triggered reason is now listed.
Ratings, up/main → this branch, measured end to end:
musebook robinhood 🔴 Not Recommended → 🟡 Light Position
JOLLY robinhood 🟡 Light Position → ✅ Normal
ARGUS arc ⚠️ Caution → 🟡 Light Position
SI sol 🟡 Light Position → 🟡 Light Position (control)
SI is the control: sol indexes buy counts normally, so it barely moves and its
🔴 risk-wallet reading of 45.36% is untouched. This is not a blanket loosening —
it only takes effect where the index is thin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upstream returns the token's own CA in `token holders` with `addr_type: 0`, i.e.
as an ordinary wallet. Measured on musebook (robinhood,
0x91a2dae9699f0b82540b5886b0d8759c22820ba3): `addr_type: 0`, 0 buys, 0 sells,
0 transfer-out, 15.01% of total supply, $5.06M, carrying a `fresh_wallet` tag.
Left in the wallet cohort that one row polluted `biggest`, Top10, Top20, airdrop,
fresh wallets, risk wallets and the Top5 display at once, and on its own tripped
the danger gate as "largest wallet holds 16.30% — extreme concentration", rating
the token 🔴 Not Recommended. The largest genuine wallet there is 4.45% of float.
Controls on the same batch — JOLLY (robinhood), SI (sol), ARGUS (arc) — carry no
such row, so this is a per-token upstream classification gap, not a chain
convention, and cannot be handled by skipping a chain.
The address is partitioned out of `normal` and reported on its own line on a
total-supply basis, with its own warn gate at >10% of supply. It is not removed
from the judgement: contract-held supply reaches the market as soon as one release
transaction lands, so it stays visible and still counts toward the rating. Warn
rather than danger because that release is an observable prior step, unlike a
whale who can sell at will; no higher danger tier is added, because one measured
sample cannot calibrate a second threshold.
It is deliberately not deducted from the float denominator. Burn is permanent and
the DEX pool is the market itself, so neither can dump; self-held supply can.
Removing it from the denominator would raise every other wallet's percentage on
every token carrying this row, manufacturing new false positives while fixing one.
The same address is also excluded from `top100_map`, which feeds the dev
sock-puppet danger gate: chips sent back to the token contract, or gas paid to it,
is not "transferred to an internal wallet".
musebook: 🔴 Not Recommended → ⚠️ Caution. Top10 34.7% → 19.4%, Top20 43.4% →
27.8%, risk wallets 21.01% → 4.71%, fresh wallets 18.25% → 1.95%.
Regression on the three control tokens: JOLLY and SI byte-identical, ARGUS differs
only on the market-cap header line (live price drift between runs).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fixes found by testing the age split, not by reading it.
1. holders/day and KOLs/day still divided by the pool-open age. Those counts
accumulate from the moment the token exists, so a token that spent 18 days on
a bonding curve and migrated yesterday reported 10,836 holders/day and 22.0
KOLs/day against a true 993 and 2.0 -- a wrong number printed on the address
line and fed into an axis carrying weight 0.15. They now divide by the same
creation age the track and the freshness axis read, which falls back to the
open-time age when Step 3b has no answer and is clamped never to read younger,
so a lookup can only lower a row's own rate. `c['age']` had no other reader
and is retired rather than left beside `cage` as a second age.
2. The created.json loader judged every entry under one try block, and comparing
a non-number against the plausibility window raises. So a single unreadable
entry threw away every entry after it, and those rows returned in the next
NEEDS-CREATED block -- a loop with no exit if the feed keeps answering the same
value. Each entry is now judged on its own and no comparison can raise.
Measured: ten bad values x first and last position, 20 runs, every one exits 0,
the readable entries survive, and each unreadable one falls back to open time.
3. Pass 1 stopped with SystemExit(0), so "stopped to ask for Step 3b" and "ran to
completion" were the same status. It now exits 3, leaving 0 = the list is the
answer, 3 = incomplete, 1 = could not read its inputs.
Net effect on the same saved sweep: the same four names in the same order, ARGUS
72.1 -> 69.5 as its growth rate corrects. Two claims the first commits left
imprecise are amended: the second pass is not purely subtractive, since the
growth axis is a within-chain percentile, and only gate-surviving rows are looked
up, so a listed migrated token is measured on its true rate against neighbours
still on their inflated one -- an error that costs listed names points rather
than granting them, now stated under `## Known limits`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-audit of the change in this PR, all four in code or claims the PR itself
introduced.
1. A creation time that was not a readable number crashed the whole run. A
string-typed timestamp in `created.json` raised `TypeError: unsupported
operand type(s) for -: 'float' and 'str'` out of `creage()` and took the
entire scoring pass with it, instead of dropping one row. The loader now
coerces once, up front.
2. Worse than the crash: the values that did NOT crash were accepted and
silently moved the list. `NaN` compares False against everything, so it
passed the track test as "not young" AND came out of the freshness clamp
at 1.0 — the easier mature track and a full newborn bonus at the same
time, which is precisely the bug this PR exists to remove. A negative or
absurdly small number read as "very old" and also bought the mature
track. Unreadable is now stored as None (asked, no answer), which falls
back to open time and keeps the row on the stricter track. Verified
against string / list / NaN / negative / float / null / bool / zero /
future-dated: nine cases, no crash, and every unreadable one now resolves
in the strict direction instead of the generous one.
3. A lookup that never landed was indistinguishable from one that came back
empty, and both were written as null. Exit status cannot separate them —
`gmgn-cli token info` answers 0 with a fully zeroed body for an address it
does not know (measured: `creation_timestamp: 0`, empty stderr). So the
body itself decides: one that parses and echoes the address back is a real
answer and a zero creation time is written as null, while one that does not
parse or does not echo it back is a call that never landed, is left out of
`created.json` entirely, and is named on stdout so Step 3 lists it again
rather than scoring the row off an age nobody read. Step 3b also keeps its
stderr now, in an `.err` file beside the response, the way Step 1 does,
instead of sending it to /dev/null.
4. The report could state an age that was off by weeks. The table's age column
is the pool-open age, which is right for the 7-day ceiling, but after this
PR a token can be listed whose real age is far older — and nothing printed
that number, so the writer could not have known. arc/ARGUS would have been
reported as "1.9d" on a list whose headline promise is recency, when the
token is 19.5 days old. The address line now prints `created=N.Nd ago`
whenever the two differ by more than a day, and stating it is required in
`## What the answer has to contain`.
Two claims elsewhere in the file that this PR had made false are corrected
rather than left to contradict it: the ceiling bullet said `MAX_AGE_D` stops a
months-old token reaching the list, which is now true only of months of
trading and not of the token's age, and the `YOUNG_D` cliff note did not say
which of the two ages the cliff is measured on.
No threshold, weight, gate constant or routing description changes. Against
the same saved 7-chain sweep, pass 1 names the same 5 rows and pass 2 returns
the same 4 names in the same order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`open_timestamp` was doing all three of the age's jobs: the 7-day recency
ceiling, the new-launch/mature track choice, and the freshness axis. For a
token that sat on a bonding curve for weeks before it migrated, that one
number pulls in two opposite directions at once -- the token reads as hours
old, so it is held to the new-launch run-rate floors (thresholds set for
tokens that have no history to measure) while simultaneously being paid a
newborn's freshness bonus.
Measured on a live sweep this cost a correct listing: arc/ARGUS, real age
18.4d, was dropped for missing the $150,000 1h floor by $824 while carrying
a 21.8h freshness score.
The jobs are now split onto the timestamps that actually mean them:
- the 7-day ceiling (`--max-created` / `MAX_AGE_D`) still reads
`open_timestamp`, because "recently hot" is a claim about the tape and
migration is that event;
- the track choice (`YOUNG_D`) and the freshness axis read
`creation_timestamp` from `token info`, because those are claims about the
token's own history.
Cost is bounded rather than per-candidate. Step 3 now runs twice: pass 1
emits a `NEEDS-CREATED` block naming only the rows whose outcome can still
turn on their age -- a row already dead on liquidity, rug score or a
one-sided tape gets no call -- new Step 3b fetches exactly those, pass 2
prints the list. On the verification sweep that was 5 lookups against 58
candidates, so a whole run is 21-27 calls instead of 21. The block warns
itself if it ever asks for more than 20.
Guards on the new code:
- a creation time that reads newer than the pool open time is clamped, so a
lookup can never move a row onto the easier track or buy it a freshness
bonus it did not already have;
- a key present with a null value means "the lookup ran and there is no
answer" and falls back to open time, which keeps the row on the stricter
track instead of re-asking forever;
- Step 3b refuses to run with `DATA` unset, validates each chain name
against the literal seven and rejects any address that is not
alphanumeric, and refuses to write an empty `created.json` that would send
pass 1 back to the same `NEEDS-CREATED` block in a loop.
No thresholds, weights, gate constants or report rules change, and the
frontmatter is untouched. Verified against a saved 7-chain sweep: pass 1
names the same 5 rows, pass 2 returns the same ranking, and the only list
difference the rule itself makes is the one row it was written for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from a final read-through before requesting merge:
- SKILL.md's Supported Chains section still said arbitrum/hyperevm were
"untested" — stale as of this skill's own development, where 4 real
tokens (2 per chain) were actually run through the full workflow with
no chain-specific issue found. Corrected to describe that spot-check
honestly (a signal, not a validation pass) instead of leaving a claim
that had already been overtaken by the skill's own test history.
- interfaces.md's web-fetch address-range control named the private/
loopback/link-local ranges to block but didn't say to pin the resolved
address between the check and the connection — a hostname re-resolved
a second time at connect can return a different (and unblocked)
address, which is exactly what a DNS-rebinding attack targets. Added
the one line closing that gap.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review caught a real internal contradiction: step 4 requires opening a
linked source (a post's URL, a project's site) to verify a claim, but hard
rule 6 said the skill may only read from the two declared interfaces —
neither of which is a general web fetch. Declares a third interface
(web content fetch) with explicit controls: http/https only, private/
loopback/link-local address ranges rejected (closes the SSRF vector a
token's own attacker-controlled link.website field would otherwise open),
a timeout, a response-size cap, and re-validation on redirect. Rule 6 now
scopes "no browser automation" against open-ended browsing rather than
against fetching one already-known URL, which is what it was actually
contradicting.
Also, per the same review:
- New hard rule 7: a hard ~30-tool-call ceiling per card, so an
adversarially ambiguous token can't turn one card into unbounded tool
use — write the card with what's verified so far and say the cap was
hit, same "neutral over fabricated" principle applied to cost/time.
- New hard rule 8: a product-facing note that a hedged single-sourced
negative claim about a named/identifiable party still carries
reputational and legal exposure, even when the hedge itself is correct.
- interfaces.md's turn-budget row and "what this skill must never do"
section updated to match (three interfaces, not two; the 30-call cap
is this skill's own stop condition, not one for the runtime to
second-guess with a lower limit of its own).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Produces a two-field narrative card (叙事背景 + 传播观察) for a token from
on-chain data (gmgn-cli) and independently-verified social posts, built to
catch the failure modes automated narrative cards on the market commonly
have: under-sampling social search, uncited/misattributed claims, launchpad
vs issuer confusion, and fabrication when evidence is thin.
Provider-agnostic by design: the social evidence step is a pluggable
interface (X API v2 or xAI Grok API), never browser automation or a
personal login — see references/interfaces.md for the configuration
contract and a validation-pass checklist for testing on a new model/runtime.
references/patterns.md is the red-flag checklist accumulated during
development (ticker collisions, similarly-named token mixups, creator
wallet history, paired-token mechanics, citation failures).
Explicitly out of scope for gmgn-contract-dd (safety score),
gmgn-heat-rank (screened hot list), and gmgn-token-buy/gmgn-swap (buy
decisions) — this only answers "what story is this token telling and
what's verifiable about its spread."
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two leftover passages still described direction as a hard veto gate
that "must pass" alongside volume, contradicting the new rule in
section 三 where only the 5m ≥10% drawdown hard-stops and everything
else downgrades execution instead of vetoing the order.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Upstream added `arbitrum` and `hyperevm` (#227), making the file's chain counts
nine / eight rather than seven / six. Three things follow.
1. Chain counts in the passages this branch added still said six EVM chains and
seven supported chains. Corrected, and the `burn_status: ""` coverage claim is
extended with a 2026-09-11 measurement on the two new chains: ARB, WBTC, ODYS,
SIGNAL, CPU and CHAMELEON all return `""`, so the burn half is missing there too.
2. The Step 3 tightening — deduct −12 and cap 79 on `lock_summary.is_locked ===
false` alone — was calibrated on the six chains that existed then, where 12 of
12 sampled tokens returned `is_locked: true`. It does not hold on the two new
ones: measured 2026-09-11 on the top three of each chain's 24h trending list,
ARB and WBTC on `arbitrum` and SIGNAL and CPU on `hyperevm` all return
`is_locked: false` — 4 of 6, including the chain's own governance token and
wrapped Bitcoin. Deep DEX liquidity from real market makers is not locked and
is not meant to be, so there the deduction would fire hardest on the tokens it
exists to clear. The row is now skipped on `arbitrum` and `hyperevm` and the
tightening applies only to `bsc`, `base`, `eth`, `robinhood`, `arc`, `stable`.
Cross-references in the cap summary, the cap-cause list and the Step 3 table
row name the scope.
3. The two dated bsc trenches passages contradicted each other on the same
180-row sample — 130 flap / 50 four.meme against flap 143 / fourmeme 37. The
per-platform split is not a stable quantity: two captures of the same 180 rows
hours apart on 2026-09-11 gave 150 / 30 and 137 / 43. The load-bearing claim is
the mapping, not the mix, so the suffix passage now states the mapping with
today's figures and says outright that a platform's share must not be read off
one capture, and the non-load-bearing counts are dropped from the launchpad
passage, whose actual claim is that `market trenches` never emits `openfour`.
No band boundary, weight or cap value changes. Inventory totals are unchanged at
23 on `sol` and 25 on the EVM chains — the skipped lock row stays in the
denominator as a skipped check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in the arbitrum + hyperevm chain support merged upstream in #227, so the
chain counts in this file are the nine-chain / eight-EVM ones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four gates were one flat "any single failure means no order" list, but they
answer two different questions. Volume, depth and security answer "can I get
back out once I am in" — structural facts that do not flip within hours.
Direction and volatility answer "where will a market order fill this second" —
which changes minute by minute. Merging them makes the same token get opposite
verdicts on the same day.
Measured: VC on BSC (0x36f01746a86639d0c3b6a850d328be1b634a4444) scored 91.4
"buyable" from gmgn-contract-dd, one-sided depth $91,442, 95% of LP burned to
the black hole, $6.6M 24h volume, 2,560 holders, zero taxes, owner renounced —
and was still reported as "not recommended" purely because 5m was -8.2% and all
three net-flow windows were negative. Two separate rules produced that: the
compound hard rule (three windows negative AND 5m drawdown >= 5%), and the
sentence that merged this section's warnings into the security section's
">= 2 warnings fails" count.
Section 三 now has three exits instead of two: 5m drawdown >= 10% remains the
one hard stop (a market order genuinely cannot execute there), everything else
downgrades execution — the order card is still produced, but it must name the
degraded execution path. Added a table of the three downgrade actions so none
of them can be given by feel: the limit trigger price is derived as
price.price * (1 - |5m drawdown|) and handed to gmgn-swap as
`order strategy create --order-type limit_order --sub-order-type buy_low
--check-price <price>`; waiting names the condition that has to converge (5m
drawdown back inside 5%, or buy_volume_5m - sell_volume_5m turning positive);
reducing size reuses the existing price-impact wording. The downgrade needs its
own separate confirmation — nodding yes to buying a token is not the same as
knowing you are no longer getting a market order.
Direction now produces no veto count at all, and missing direction data
downgrades to the strictest tier rather than failing the order: not being able
to read direction is not the same as a quiet market.
Nothing about the original intent is relaxed. The slippage formula is
untouched (its |5m change| * 0.5 term already prices volatility), the
>= 100x-with-negative-flow warning still refuses to widen slippage, and the
security gate's ten warnings and red flags are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 6 still told the reader to print the section weights while Step 8 and
Display Templates both forbid printing coverage and weights, so the same
response could ship two different report shapes depending on which sentence
was read last. Step 5B's rug_ratio cap table still named the pre-reword band
("high risk") in a live rule, which also falsified the neighbouring claim that
every live occurrence had been corrected — that count is now three, with the
grep procedure that found the last one written down. Step 8's cap list named
Step 2, 5B and 7 but not Step 3's EVM lock cap, which the assembly list in
Step 6 already carries.
No threshold, weight or band boundary moves; every measured score in the file
is unchanged. Dated measurement passages keep the original audit vocabulary by
the policy stated in the file — all 32 remaining occurrences were classified
and every one of them is a record of a run, not a rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CA block was headed `CA (verify at https://gmgn.ai/<chain>/token/<CA>)`.
That path shape is the one thing in this file nobody was allowed to check: the
skill's own rules forbid reaching gmgn.ai by web search, WebFetch, curl or a
browser, so the URL was printed on the strength of an assumption. If the shape
is wrong the reader lands on a 404 and doubts the list rather than the link.
The addresses themselves were already complete and one per line, so dropping
the link costs nothing -- an address pastes into whatever front-end the reader
actually uses. The heading now says exactly that and names no host.
Behaviour is otherwise unchanged: replayed against five stored sweeps, the
scorer's output differs on that single heading line and nowhere else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five problems found by rendering the report against a live bsc token
(4Stock, 2026-09-10) and checking every printed line back against the
response. All five are output-wording problems; no scoring table and no
composite in this file moves.
- The stat percentages were rendered as shares of the supply. They are
not: bot_degen_count 1068 / bot_degen_rate 0.5416 gives a denominator
of 1971.9 on a token with 20,930 holders, and top_bot_degen_percentage
returned exactly 0.5416 alongside it, which ties the whole
top_*_trader_percentage family to that same analysed-trader cohort.
The worked example in Display Templates carried the wrong wording and
a live report inherited it, telling the reader that 61.1% of the float
sat in entrapment wallets — a claim the response never made.
- The Limits of this read skeleton asked for off-chain presence
unconditionally, so an all-empty link block was written up as "no
off-chain presence at all". An empty link block cannot be told apart
from GMGN not having collected the socials, so the bullet is now
dropped unless a social field or the verification status has a value.
- An isolated stat zero was being printed as a passing check. The report
now needs a second field from the same response to corroborate it:
creator_hold_rate 0 qualifies (creator_token_balance "0" plus
creator_token_status "creator_close"), top70_sniper_hold_rate 0
qualifies (wallet_tags_stat.sniper_wallets 0), private_vault_hold_rate
and dev_team_hold_rate do not and are dropped. Scoring is untouched —
the zero is still an executed check in coverage.
- Market cap was being printed with no rule saying where it comes from,
and the response carries no market_cap key at all. It is now fixed as
price * circulating_supply, verified against a figure GMGN does
publish: ath_price * circulating_supply = 82,633,663 matches
dev.ath_token_info.ath_mc exactly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 1 held the chain list in a plain string and iterated it as
`for ch in $CHAINS`. That works under bash and silently does not under zsh,
which performs no word splitting on an unquoted parameter expansion: the loop
runs once with all seven names in one variable. The sweep then collapses to a
single call with `--chain "sol bsc base eth robinhood arc stable"`, which the
CLI refuses -- no data, no list, on every run in a zsh shell.
The whitelist did not catch it either. `case " sol bsc ... " in *" $ch "*` is a
substring test, so the whole concatenated string matched its own haystack and
was waved through; it accepts any run of adjacent names. It is now an exact
alternation over whole names, which also refuses `sol bsc` and anything
carrying shell metacharacters. `$ch` and the two output redirections are
quoted.
Introduced by b4b54ad, whose own verification ran the extracted block under
bash and so could not see it.
Verified: `-n` clean under zsh, bash and bash --posix; seven iterations with
the correct per-chain filters in all three; and a live run under zsh now makes
15 calls across all seven chains (eth/arc/stable short-circuiting on an empty
24h window as designed), 56 candidates -> 10 passed -> 3 listed. The scorer
block is byte-identical, so scoring is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`rage` fell back to `now` when a row carried neither `open_timestamp` nor
`creation_timestamp`, so a row with no age read as "launched this instant":
it walked through the `MAX_AGE_D` ceiling -- the one gate the whole list
rests on -- was judged on the wrong (new-launch) track, and would have taken
full freshness credit had it cleared that track's gates. That is the same
mistake as reading a missing risk field as clean, which this skill refuses
to make anywhere else.
An unreadable age is now placed past the ceiling and refused as
`no timestamp (age unknown)`, reported as the data fault it is rather than
as a token that failed a check. Both timestamp fields are normalised as
counts, so an unparseable value arrives as 0 and is caught by the same test
as an absent one.
Verified: on a row with both timestamps stripped, before/after is
rage=0.000 age=0.500 young=True (age gate silent) -> rage=8.000 young=False
fail=['no timestamp (age unknown)'], with no duplicate age-backstop reason.
Output is byte-identical on four unmodified fixtures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`is_out_market` is not a filter tag the API knows, and an unrecognised tag is not
refused -- it is silently ignored. Worse, a filters list made only of unrecognised
tags does not fall back to the server's defaults: it replaces them with nothing.
Measured on sol with the skill's own pool parameters: `--filter is_out_market` and
`--filter zzz_fake_tag_qqq` returned the identical 22 rows, both a superset of the
18 returned with no --filter at all.
On sol / bsc / base / eth the tag sat alongside real tags and was inert -- dropping
it returns the identical address set on both chains tested. On robinhood / arc /
stable it was the *only* tag, so those three chains were fetched with server-side
screening switched off. On one robinhood sweep that admitted 6 rows the defaults
exclude: 4 with is_honeypot=1, plus CLUBY and BRIAN, neither renounced nor open
source and neither caught by any local gate in this file.
So the fourth branch now sends no --filter and lets the server's defaults apply,
with a comment saying not to invent a tag to fill it, and `## Known limits` records
the measurement.
Verified end to end on a live sweep with the fix in place: 15 calls, the three
empty chains short-circuited, robinhood 20 rows instead of 26, and zero
is_honeypot=1 rows anywhere in the candidate pool. 52 candidates, 10 passed,
3 listed. Step 1 passes bash -n; an empty F array expands to no arguments; the
scorer is byte-identical to the previous commit's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verdict band words were audit vocabulary — `relatively clean · mixed,
needs manual review · high risk · very high risk`. "Mixed" names the average
of the three sections, which is the one reading that hides a one-rotten
two-clean profile, and "needs manual review" hands the work back without
naming what to review. A reader who pastes an address is asking whether they
can buy it, so the bands now answer that: `buyable` / `buyable with
conditions` / `not advised` / `do not buy`, each paired with a clause derived
by rule from which sections fell under 60, so the pair carries how buyable
from the word and what is in the way from the clause.
The 80 / 60 / 40 boundaries did not move, so no composite in this file
changes grade. Dated measurement passages keep the original words as records
and a decoder maps them one-for-one; two live rules that still named a band
in the old vocabulary are corrected.
Three guards come with the new axis, because `buyable` is easier to
over-read than the audit words were: it means the token can be bought and
sold back, never that it will appreciate; `Buy or not` may not contradict the
band word nor merely repeat it; and the band word is not permission to size a
position, which stays gmgn-token-buy's job.
Also drops the launchpad line from the report. `info.launchpad` is not
reliable enough to name: the vocabulary is unstable across endpoints (4Stock
returns `openfour` from `token info` while all 180 `market trenches` bsc rows
use only `flap` and `fourmeme`), and 9 of 100 bsc trending rows return an
empty string with no way to tell "no launchpad" from "unknown launchpad".
Measured coverage is complete on sol and unmeasured on base/eth/robinhood/
arc/stable, so the claim goes rather than ships hedged. The address-suffix
rule stays — it rests on its own 180-row tally.
And corrects an over-absolute claim: `pool.initial_liquidity` is *usually* 0
on a migrated pool, not always. Measured on 4Stock, GMGN had switched the
reported main pool to a pancake_v3 pool created about half an hour after
launch, which returns 19893.57. A non-zero value is that pool's own opening
depth and must never be reported as the token's launch depth; a shrink ratio
above 1 means the pool deepened and is never a deduction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CLI docs promised behavior the API does not deliver:
- --limit: was "max 80 (default 80)". In reality it is best-effort only —
the new_creation category is served from a fixed live window (~60), so
larger and smaller --limit values both tend to return ~60. Documented as
best-effort.
- --launchpad-platform: was "default: all platforms for the chain". In
reality, when omitted the server injects a fixed per-chain default
allow-list (not literally all platforms), and passing values REPLACES that
allow-list (fewer platforms => less data). Platforms outside the list
(incl. some newer RWA / tokenized-stock launchpads) are filtered out.
- Added the previously-missing robinhood platform slug list, and noted that
arc/stable apply no launchpad filter (all platforms).
Docs only; no code change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A candidate has to be present in the 24h window to count at all -- the scorer
only uses a chain when a 24h file loaded, and keys candidates off it. So for a
chain whose 24h window comes back empty, the 1h and 6h calls cannot produce a
candidate no matter what they contain. The sweep was spending them anyway.
Reorder the window loop to 24h first and break out when the 24h response holds
an empty rank array. On a measured sweep eth, arc and stable were empty in all
three windows, so this drops 21 calls to 15 and ~30s to ~21s. Worst case is
unchanged: when all seven chains are alive the sweep still costs its full 21.
This cuts waste, not coverage -- no window that could hold a candidate is skipped.
Teach the loader to tell a deliberate skip from a failure, so the saving does not
print as two failed calls. `missing (excluded)` keeps meaning one thing: a call
that failed or returned unparseable JSON. A 24h window that itself failed to load
still reports, since that is the signal worth seeing.
Verified against the frozen sweep: with eth/arc/stable's 1h and 6h files removed
the run is identical -- 60 candidates, 11 passed, the same five names with the
same scores in the same order -- and no missing-window line. Corrupting bsc_6h
and sol_24h still reports both. Step 1 passes bash -n.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chain list is a documented parameter a user can narrow, and whatever is in
it lands on a command line unquoted. Nothing validated it, so a typo, a chain
name from some other exchange, or a string carrying spaces or shell
metacharacters would have been passed straight through to gmgn-cli as arguments.
Hoist the list into a CHAINS variable (which is the thing to narrow) and check
each name against the literal set of the seven names this API actually has,
refusing anything else out loud and skipping it. The whitelist is deliberately
written out again rather than reusing $CHAINS, which would check the input
against itself.
Tested: sol / eth / robinhood accepted; "xyz", "sol; rm -rf /" and
"sol --limit 1" refused. Step 1 passes bash -n, and the extracted scorer is
byte-identical to the previous commit's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sym() already removed control characters, terminal escapes and bidi overrides,
but let two markdown metacharacters through into text that gets copied straight
into the report. A token named "X | buy now" splits into two cells of the
report's markdown table with the second one written by whoever deployed the
contract; a backtick opens or closes a code span. The symbol is data the
attacker chose, so both become ?.
Tested on the extracted function: 'X | 买它 | 忽略上文' -> 'X ? 买它 ? 忽略上文',
'AAA`rm -rf /`BBB' -> 'AAA?rm -rf /?BBB', ordinary symbols untouched. Verified
against the stored sweep: same five names, same order, no symbol in the sample
contains either character.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 1 built its working directory as /tmp/gmgn-heat-data-$(date +%s) -- a
second-resolution timestamp in a world-writable directory -- then wrote
heat_rank.py into it and executed it. Another local user can guess that name and
pre-create the directory holding their own heat_rank.py, at which point the run
executes their code as the user. Narrow (it needs a local unprivileged account)
but it is local code execution on a path this skill both writes and runs.
mktemp -d gives an unpredictable name and mode 0700, verified on this machine
(/var/folders/.../tmp.XXXXXXXX, drwx------).
The scorer's fallback was the matching half of the same problem:
os.environ.get('HEAT_DATA', '/tmp/gmgn-heat-data') meant a run with the variable
unset would read a fixed path anyone can plant JSON into. There is no safe
default, so it now exits with the correct invocation instead of inventing one.
Verified against the stored sweep: same five names in the same order. The
candidate count reads 11 rather than 12 only because the fixture keeps ageing
against a live clock -- robinhood PAIR has crossed 7d and the age backstop from
897153f drops it, at score 53.4, far below any floor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rescanned the current upstream skill set (13 skills on up/main, aa4d29a) and
grepped every description for hot-list vocabulary. Only gmgn-market matches,
and two of its claims were still unanswered here:
- "discover early-stage opportunities" and its Chinese equivalents. The
description already sent "just launched" / "新盘" to `market trenches`, but not
the ambush framing (值得埋伏吗, 新币筛选, 早期机会), which is exactly where the two
skills read alike. Named, with the reason: the pool here starts at 500k market
cap and 100k liquidity and never holds a bonding-curve token.
- gmgn-track's "what smart money is buying" / "on-chain alpha" /
"copy-trade signals". The body table routed it; the description, which is what
a router actually reads, did not.
Also added what the description never said about using it: no input is needed at
all -- the trigger phrase alone runs it, no address, chain or amount -- and the
four values a user can narrow. Runtime and pacing stay out of the description,
where no skill in this repo puts them; they are already in ## Pacing.
Documentation only: the extracted scorer is byte-identical to the previous
commit's and the stored sweep returns the same five names in the same order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
argument-hint offered "--chains sol,bsc,... --max-age 7d --top 10
--min-score 60". Measured against `gmgn-cli market trending --help`, none of
the four exists: the CLI takes one `--chain` at a time (the seven-chain sweep
is the Step 1 loop, not a list argument), the age flag is spelled
`--max-created`, and TOP_N / MIN_SCORE are Python constants no flag reaches.
So a request like "this time only sol and bsc" or "raise the floor to 70" led
straight to an invalid command line, even though all four values are in fact
tunable and already listed in Parameters under their real names.
Rename the hint to the names used in the body, and add a paragraph under
Parameters saying these are spoken options rather than flags and mapping each
one to the single place it changes -- including that max-created now moves
together with MAX_AGE_D.
Documentation only: the extracted scorer is byte-identical to the previous
commit's and the stored sweep produces the same five names in the same order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Recently hot" is the premise of this list, and ## Rules calls age a hard gate
with no compensation logic. Its only enforcer was --max-created 7d in Step 1 --
a server-side filter. The script computes each row's true age from
open_timestamp and used it for the young/mature track split and the 24h-change
blanking, but never compared it to the ceiling, so an endpoint that ignored or
re-interpreted the parameter would put a months-old token on the list under the
word "recent" with nothing local to stop it.
Add MAX_AGE_D = 7.0 and reject any row above it alongside the other
unconditional gates, with its own rejection reason. Document it in Parameters as
tied to --max-created, and say in ## Rules that the gate is enforced in both
places and the two values move together.
Verified against the stored seven-chain sweep: no row is affected -- the oldest
candidate is 6.99d, so the gate is silent, which is the intended steady state.
Re-running the same data with MAX_AGE_D lowered to 5.0 drops robinhood ZZZ
(5.3d) and MEME (6.4d) with reason "age>5d(local backstop)", 5 listed -> 3,
confirming the gate fires and reports itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The floor is two-valued -- MIN_SCORE for a row every gate could judge,
MIN_SCORE + U_SCORE_ADD for one no manipulation gate could -- and floor_for()
applies it correctly. The diagnostic line printed only MIN_SCORE, so the one
number carried into the report was wrong for half the rows.
The visible damage was in the near-miss block, which exists so the boundary can
be inspected and instead made the skill look broken: on a measured sweep bsc
BREW at 63.4 was dropped while sol LOOM at 63.3 was listed. Correct -- BREW
sits on a chain with no rug model and needed 68 -- but a report claiming a
floor of 60 alongside those two lines reads as padding or a bug.
Three changes, none touching selection:
- the diagnostic line names both floors and how many rows fall in the higher
tier ("floor 60, or 68 for the 6 of 12 rows no manipulation gate could judge")
- each near miss prints the bar it actually had to clear
- the reporting checklist forbids writing the floor as a single number, and
still forbids explaining the two tiers, which would disclose what ## Rules
keeps out of the report
Verified against the stored seven-chain sweep: same five names, same order,
same three near misses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 24h% column is only meaningful once the token has 24h of history. Below
that the window opens before the token existed, so price_change_percent is
measured from the launch price and prints figures like +128168.0% -- correct
arithmetic, no information about momentum, and wide enough to break the
column's alignment.
This was latent until the previous commit: the reference row used to be
whichever window loaded last, so a young token showed its 1h change under the
24h heading and happened to look plausible. Reading the right window exposed
the real number.
Print n/a instead when age < 1 day, matching how an unknown ATH position is
already rendered. The age column sits immediately left of the cell, so the
reason the cell is empty is visible without a footnote. Rows a day or older
are untouched.
Verified against the stored seven-chain sweep: same five names in the same
order, the four rows aged 2.2d-6.4d keep their 24h figures unchanged
(-21.2 / -0.4 / -18.7 / +39.7), and the 8.2h-old row now reads n/a.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>