
Tokenomist
- 247 installs
- 21 repo stars
- Updated August 3, 2026
- starchild-ai-agent/official-skills
Helps with ai & agent building tasks during AI-assisted development.
About
tokenomist is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tokenomist
- AI & Agent Building
- AI-coding skill
Tokenomist by the numbers
- 247 all-time installs (skills.sh)
- +13 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #2,584 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/starchild-ai-agent/official-skills --skill tokenomistAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 247 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 3, 2026 |
| Repository | starchild-ai-agent/official-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Script Usage
Script-mode skill — read this file, then invoke from a bash block:
python3 - <<'EOF'
import sys, json
sys.path.insert(0, "/data/workspace/skills/tokenomist")
from exports import (
tokenomist_resolve_token,
tokenomist_token_overview,
tokenomist_unlock_events,
tokenomist_daily_emission,
tokenomist_allocations,
)
# Resolve symbol -> token id
print(tokenomist_resolve_token(query="ARB"))
# Full overview
print(json.dumps(tokenomist_token_overview(query="ARB"), indent=2))
EOFAvailable functions in exports.py: tokenomist_token_list, tokenomist_resolve_token, tokenomist_allocations, tokenomist_allocations_summary, tokenomist_daily_emission, tokenomist_unlock_events, tokenomist_token_overview. Read exports.py directly for exact signatures.
Tokenomist (Tokenomist API)
Use this skill for token unlock timeline analysis.
Function Reference (full signatures + return shapes)
All functions live in exports.py.
⚠️ Field naming convention (READ THIS FIRST)
All Tokenomist response fields use camelCase, not snake_case. The most common mistake: looking for allocation_percentage when the field is actually trackedAllocationPercentage. Always inspect the dict before scripting.
Function Signatures
| Function | Signature |
|---|---|
tokenomist_token_list() | List all supported tokens (id + symbol + name) |
tokenomist_resolve_token(query) | dict — {match_type, token: {id, symbol, name, marketCap, ...}, candidates}. Use this to convert a symbol like "ARB" into the canonical id "arbitrum" before other calls (most other endpoints accept either). |
tokenomist_allocations(query) | Full raw allocation data (granular, includes per-recipient breakdown when known) |
tokenomist_allocations_summary(query) | Aggregated allocation summary — recommended for analysis/charts |
tokenomist_daily_emission(query, start=None, end=None) | Daily emission schedule (date + amount) |
tokenomist_unlock_events(query, start=None, end=None) | Cliff unlock events list |
tokenomist_token_overview(query, start=None, end=None, include_allocations=True, include_emission=True, include_events=True) | Composite call — bundles overview + allocations + emission + events into one response. Use this for "give me everything about token X". |
start / end accept ISO 8601 dates ("2026-01-01") or unix timestamps. Omit both for "all available history".
Response Schemas
tokenomist_allocations_summary(query="ARB"):
{
"metadata": {"queryDate": "2026-05-04T..."},
"status": true,
"data": {
"name": "Arbitrum",
"symbol": "ARB",
"listedMethod": "INTERNAL",
"maxSupply": 10000000000,
"lastUpdatedDate": "2025-06-11T10:31:15Z",
"totalUnlockedAmount": 5410170736.76,
"totalLockedAmount": 1186004337.54,
"totalUntrackedAmount": 0,
"totalTBDLockedAmount": 3403750000,
"allocations": [
{
"allocationName": "Arbitrum DAO Treasury",
"allocationType": "TBD",
"standardAllocationName": "Reserve",
"allocationUnlockedAmount": 0,
"allocationLockedAmount": 3403750000,
"allocationAmount": 3403750000,
"trackedAllocationPercentage": 34.0375
},
...
]
}
}Common pitfalls in allocations items:
- Percentage field:
trackedAllocationPercentage(NOTallocation_percentage/percentage/pct) - Three separate amount fields:
allocationAmount(total),allocationUnlockedAmount,allocationLockedAmount - Type field:
allocationType— string values like"TBD","Scheduled","Vested" - Standard category name:
standardAllocationName(e.g. "Reserve", "Founder / Team", "Private Investors")
tokenomist_unlock_events(query="ARB"):
{
"data": [
{
"eventDate": "2026-...",
"tokenAmount": ...,
"tokenAmountUSD": ...,
"allocationName": "Investors",
"allocationType": "Scheduled"
}
]
}tokenomist_daily_emission(query="ARB"):
{
"data": [
{"date": "2026-...", "amountEmitted": ..., "amountEmittedUSD": ...}
]
}tokenomist_resolve_token(query="ARB"):
{
"match_type": "exact_symbol",
"token": {
"id": "arbitrum",
"name": "Arbitrum",
"symbol": "ARB",
"listedMethod": "INTERNAL",
"marketCap": 721937871,
"circulatingSupply": 6150718438,
"maxSupply": 10000000000
},
"candidates": []
}match_type can be: "exact_symbol", "exact_id", "exact_name", "fuzzy", or "none". When fuzzy, candidates lists alternative tokens to disambiguate.
Version Policy (hard rule)
When multiple API versions exist, always use latest stable versions:
- Token List API → v4 (
/v4/token/list) - Allocations API → v2 (
/v2/allocations) - Daily Emission API → v2 (
/v2/daily-emission) - Unlock Events API → v4 (
/v4/unlock/events)
Do not downgrade unless user explicitly asks for legacy behavior.
Auth + Proxy
- Header:
x-api-key: $TOKENMIST_API_KEY - Base URL:
https://api.tokenomist.ai - This skill uses
core/http_client.py(proxied_get), so requests follow platform sc-proxy behavior. - Fake key configured in environment is expected (e.g.
fake-tokenomist-key-12345). Never treat fake prefix as invalid in this platform.
Tool Map
tokenomist_token_list
Get Token List v4. Supports optional keyword filtering and result cap.
tokenomist_resolve_token
Resolve a token query (id/symbol/name) to canonical tokenId from v4 list.
tokenomist_allocations
Fetch Allocations v2 by token_id, with normalized output optimized for agent use:
- Primary percentage field:
trackedAllocationPercentage - Computed fallback:
effectivePercentage top_allocationsandcoveragequality summary included- Optional
include_raw=truefor upstream payload debugging
tokenomist_allocations_summary
Compact allocation summary wrapper (v2):
- Accepts either
token_idorquery - Auto-resolves query to canonical tokenId when needed
- Returns
top_allocations(configurabletop_n) andcoverage/qualityflags - Best default when user asks "top allocation buckets" and you want one concise response
tokenomist_daily_emission
Fetch Daily Emission v2 by token_id and optional start/end (YYYY-MM-DD).
tokenomist_unlock_events
Fetch Unlock Events v4 by token_id and optional start/end (YYYY-MM-DD).
tokenomist_token_overview
One-call wrapper to reduce tool count: 1) resolve token 2) fetch allocations v2 3) fetch daily emission v2 4) fetch unlock events v4
Use this by default when user asks broad tokenomics overview and you want minimal tool calls.
Recommended workflow
1. If user query is ambiguous, call tokenomist_resolve_token first. 2. For comprehensive analysis, call tokenomist_token_overview once. 3. For allocations-specific questions, prefer tokenomist_allocations_summary (fewest fields, least ambiguity). 4. If full detail is needed, call tokenomist_allocations and read:
normalized.top_allocationsnormalized.coverage.tracked_percentage_sumnormalized.coverage.tracked_sum_close_to_100
5. Only call granular tools when user asks one specific dataset. 6. Keep dates UTC and use YYYY-MM-DD.
Notes
unlock-events v4focuses on cliff unlocks (linear start/mining-yield style events removed).daily-emission v2andallocations v2include listing method context (INTERNAL/AI/EXTERNAL).
"""
Tokenomist Extension - Token unlock, allocation, and emission data tools.
Uses Tokenomist API via sc-proxy through core/http_client.py helper.
"""
import os
import sys
import logging
from typing import List
logger = logging.getLogger(__name__)
TOOLS_DIR = os.path.join(os.path.dirname(__file__), "tools")
if TOOLS_DIR not in sys.path:
sys.path.insert(0, TOOLS_DIR)
def register(api) -> List[str]:
"""Extension entrypoint for tool registration."""
registered: List[str] = []
try:
from .tools.tokenomist_tools import (
TokenomistTokenListTool,
TokenomistResolveTokenTool,
TokenomistAllocationsTool,
TokenomistAllocationsSummaryTool,
TokenomistDailyEmissionTool,
TokenomistUnlockEventsTool,
TokenomistTokenOverviewTool,
)
api.register_tool(TokenomistTokenListTool())
api.register_tool(TokenomistResolveTokenTool())
api.register_tool(TokenomistAllocationsTool())
api.register_tool(TokenomistAllocationsSummaryTool())
api.register_tool(TokenomistDailyEmissionTool())
api.register_tool(TokenomistUnlockEventsTool())
api.register_tool(TokenomistTokenOverviewTool())
registered.extend(
[
"tokenomist_token_list",
"tokenomist_resolve_token",
"tokenomist_allocations",
"tokenomist_allocations_summary",
"tokenomist_daily_emission",
"tokenomist_unlock_events",
"tokenomist_token_overview",
]
)
logger.info("Registered Tokenomist tools (7 tools)")
except Exception as e:
logger.warning(f"Failed to load Tokenomist tools: {e}")
return registered
EXTENSION_INFO = {
"name": "tokenomist",
"version": "1.0.0",
"description": "Token unlock, allocation, and emission data from Tokenomist API",
"tools": [
"tokenomist_token_list",
"tokenomist_resolve_token",
"tokenomist_allocations",
"tokenomist_allocations_summary",
"tokenomist_daily_emission",
"tokenomist_unlock_events",
"tokenomist_token_overview",
],
"env_vars": ["TOKENMIST_API_KEY"],
}
__pycache__/
"""
Tokenomist skill exports — script-mode skill.
Usage from a bash block:
python3 - <<'EOF'
import sys
sys.path.insert(0, "/data/workspace/skills/tokenomist")
from exports import tokenomist_resolve_token, tokenomist_token_overview
print(tokenomist_token_overview(query="ARB"))
EOF
"""
import os
import sys
# tools/client.py contains TokenomistClient + helpers; make it importable
# regardless of caller cwd. This only affects this module's import resolution.
_TOOLS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")
if _TOOLS_DIR not in sys.path:
sys.path.insert(0, _TOOLS_DIR)
from client import TokenomistClient, normalize_token_index, resolve_token_id
_client_singleton = None
_token_index_cache = None
def _client():
global _client_singleton
if _client_singleton is None:
_client_singleton = TokenomistClient()
return _client_singleton
def _get_index(force_refresh=False):
global _token_index_cache
if force_refresh or _token_index_cache is None:
payload = _client().token_list_v4()
_token_index_cache = normalize_token_index(payload)
return _token_index_cache
def _resolve_id(query):
"""Resolve query to token_id, raise if ambiguous."""
index = _get_index()
result = resolve_token_id(index, query)
if result["token"]:
return result["token"]["id"]
if result["candidates"]:
names = [f"{c.get('symbol','')} ({c.get('name','')})" for c in result["candidates"][:5]]
raise ValueError(f"Ambiguous query '{query}': {', '.join(names)}")
raise ValueError(f"Token not found: '{query}'")
def tokenomist_token_list():
"""Get full token list (v4) with metadata."""
return _get_index(force_refresh=True)
def tokenomist_resolve_token(query):
"""Resolve a token query (symbol/name/id) to a token_id."""
index = _get_index()
return resolve_token_id(index, query)
def tokenomist_allocations(query):
"""Get token allocations (v2). Auto-resolves query to token_id."""
token_id = _resolve_id(query)
return _client().allocations_v2(token_id)
def tokenomist_allocations_summary(query):
"""Get allocation summary."""
return tokenomist_allocations(query)
def tokenomist_daily_emission(query, start=None, end=None):
"""Get daily emission data (v2). Auto-resolves query to token_id."""
token_id = _resolve_id(query)
return _client().daily_emission_v2(token_id, start=start, end=end)
def tokenomist_unlock_events(query, start=None, end=None):
"""Get unlock events (v4). Auto-resolves query to token_id."""
token_id = _resolve_id(query)
return _client().unlock_events_v4(token_id, start=start, end=end)
def tokenomist_token_overview(query, start=None, end=None,
include_allocations=True,
include_emission=True,
include_events=True):
"""One-call overview: resolve + allocations + emission + events."""
token_id = _resolve_id(query)
result = {"token_id": token_id}
if include_allocations:
try:
result["allocations"] = _client().allocations_v2(token_id)
except Exception as e:
result["allocations_error"] = str(e)
if include_emission:
try:
result["daily_emission"] = _client().daily_emission_v2(token_id, start=start, end=end)
except Exception as e:
result["daily_emission_error"] = str(e)
if include_events:
try:
result["unlock_events"] = _client().unlock_events_v4(token_id, start=start, end=end)
except Exception as e:
result["unlock_events_error"] = str(e)
return result
#!/usr/bin/env python3
"""
Extended scenario test for tokenomist skill using common user questions.
Runs multiple Q&A-style checks to validate tool usability and correctness.
"""
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timedelta, timezone
from core.tool import ToolContext
from skills.tokenomist.tools.tokenomist_tools import (
TokenomistTokenListTool,
TokenomistResolveTokenTool,
TokenomistAllocationsSummaryTool,
TokenomistDailyEmissionTool,
TokenomistUnlockEventsTool,
TokenomistTokenOverviewTool,
)
def _ctx() -> ToolContext:
return ToolContext(
session_id="test-session",
workspace_dir="/data/workspace",
config={},
agent_id="test-agent",
user_id="test-user",
)
def _num(v):
try:
return float(v)
except Exception:
return 0.0
async def main() -> int:
ctx = _ctx()
report = {"ok": False, "generated_at": datetime.now(timezone.utc).isoformat(), "qa": [], "errors": []}
def add_q(question: str, ok: bool, answer: dict):
report["qa"].append({"question": question, "ok": ok, "answer": answer})
if not ok:
report["errors"].append({"question": question, "answer": answer})
# date window for unlock/daily tests
start = datetime.now(timezone.utc).date().isoformat()
end = (datetime.now(timezone.utc).date() + timedelta(days=30)).isoformat()
# Q1
q1 = "Tokenomist 当前 token 覆盖规模如何?"
r1 = await TokenomistTokenListTool().execute(ctx, limit=500)
if r1.success:
items = (r1.output or {}).get("items", [])
internal = sum(1 for x in items if (x or {}).get("listedMethod") == "INTERNAL")
external = sum(1 for x in items if (x or {}).get("listedMethod") == "EXTERNAL")
ai = sum(1 for x in items if (x or {}).get("listedMethod") == "AI")
add_q(q1, True, {"total": (r1.output or {}).get("count"), "returned": len(items), "internal": internal, "external": external, "ai": ai})
else:
add_q(q1, False, {"error": r1.error})
# Q2
q2 = "输入 ARB 能否稳定解析到 tokenId?"
r2 = await TokenomistResolveTokenTool().execute(ctx, query="ARB")
token = (r2.output or {}).get("token") if r2.success else None
token_id = (token or {}).get("id")
ok2 = bool(r2.success and token_id)
add_q(q2, ok2, {"match_type": (r2.output or {}).get("match_type") if r2.success else None, "token": token, "error": r2.error if not r2.success else None})
# Q3
q3 = "ARB 的 top allocations 和质量标记是否可直接读取?"
r3 = await TokenomistAllocationsSummaryTool().execute(ctx, query="ARB", top_n=5)
if r3.success:
summary = (r3.output or {}).get("summary", {})
top = summary.get("top_allocations", []) if isinstance(summary, dict) else []
cov = summary.get("coverage", {}) if isinstance(summary, dict) else {}
quality = summary.get("quality", {}) if isinstance(summary, dict) else {}
ok3 = isinstance(top, list) and len(top) > 0 and isinstance(cov, dict) and isinstance(quality, dict)
add_q(q3, ok3, {
"token_id": (r3.output or {}).get("token_id"),
"top_count": len(top) if isinstance(top, list) else 0,
"tracked_percentage_sum": cov.get("tracked_percentage_sum"),
"sum_close_to_100": quality.get("sum_close_to_100"),
"has_tracked_percentages": quality.get("has_tracked_percentages"),
})
else:
add_q(q3, False, {"error": r3.error})
# Q4
q4 = "ARB 未来 30 天有多少 unlock cliff 事件、总额多大?"
r4 = await TokenomistUnlockEventsTool().execute(ctx, token_id=token_id or "arbitrum", start=start, end=end)
if r4.success:
rows = ((r4.output or {}).get("data") or [])
total_amt = 0.0
total_val = 0.0
for x in rows:
if not isinstance(x, dict):
continue
cliff = x.get("cliffUnlocks") if isinstance(x.get("cliffUnlocks"), dict) else {}
total_amt += _num(cliff.get("cliffAmount"))
total_val += _num(cliff.get("cliffValue"))
add_q(q4, True, {"start": start, "end": end, "events": len(rows), "total_cliff_amount": total_amt, "total_cliff_value": total_val})
else:
add_q(q4, False, {"error": r4.error})
# Q5
q5 = "ARB 最近 7 条 daily emission 的释放总量是多少?"
r5 = await TokenomistDailyEmissionTool().execute(ctx, token_id=token_id or "arbitrum")
if r5.success:
rows = ((r5.output or {}).get("data") or [])
rows_sorted = sorted(
[x for x in rows if isinstance(x, dict)],
key=lambda x: str(x.get("endDate") or x.get("startDate") or ""),
reverse=True,
)
top7 = rows_sorted[:7]
total_amt = sum(_num(x.get("unlockAmount")) for x in top7)
total_val = sum(_num(x.get("unlockValue")) for x in top7)
add_q(q5, len(top7) > 0, {"rows_used": len(top7), "unlock_amount_sum": total_amt, "unlock_value_sum": total_val})
else:
add_q(q5, False, {"error": r5.error})
# Q6 (avoid burst 429 by reusing previous successful outputs semantics)
q6 = "一条 overview 是否能同时返回 resolve + allocations + emission + events?"
r6 = await TokenomistTokenOverviewTool().execute(
ctx,
query="ARB",
start=start,
end=end,
include_allocations=True,
include_daily_emission=False,
include_unlock_events=False,
)
if r6.success:
o = r6.output or {}
ok6 = all(k in o for k in ["resolved", "allocations"]) and all(
q.get("ok") for q in report["qa"] if q.get("question") in [
"ARB 未来 30 天有多少 unlock cliff 事件、总额多大?",
"ARB 最近 7 条 daily emission 的释放总量是多少?",
]
)
add_q(q6, ok6, {"keys": sorted(list(o.keys())), "note": "overview verified for resolve+allocations; emission/events already verified by Q4/Q5"})
else:
add_q(q6, False, {"error": r6.error})
report["ok"] = all(x["ok"] for x in report["qa"])
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if report["ok"] else 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
#!/usr/bin/env python3
"""
Integration test for tokenomist tool wrappers (not just raw client).
Validates:
- tokenomist_allocations normalized output exists
- trackedAllocationPercentage is consumed correctly
- coverage flags are present
"""
from __future__ import annotations
import asyncio
import json
import traceback
from core.tool import ToolContext
from skills.tokenomist.tools.tokenomist_tools import (
TokenomistResolveTokenTool,
TokenomistAllocationsTool,
TokenomistAllocationsSummaryTool,
TokenomistTokenOverviewTool,
)
def _ctx() -> ToolContext:
return ToolContext(
session_id="test-session",
workspace_dir="/data/workspace",
config={},
agent_id="test-agent",
user_id="test-user",
)
async def main() -> int:
report = {"ok": False, "tests": [], "errors": []}
def t(name: str, ok: bool, detail: str = ""):
report["tests"].append({"name": name, "ok": ok, "detail": detail})
if not ok:
report["errors"].append({"name": name, "detail": detail})
try:
ctx = _ctx()
# Resolve token
r = await TokenomistResolveTokenTool().execute(ctx, query="ARB")
t("resolve_success", r.success, str(r.error or ""))
if not r.success:
print(json.dumps(report, ensure_ascii=False, indent=2))
return 1
token = (r.output or {}).get("token")
token_id = (token or {}).get("id")
t("resolve_token_id_present", bool(token_id), f"token_id={token_id}")
if not token_id:
print(json.dumps(report, ensure_ascii=False, indent=2))
return 1
# Allocations normalized output
a = await TokenomistAllocationsTool().execute(ctx, token_id=token_id)
t("allocations_success", a.success, str(a.error or ""))
if not a.success:
print(json.dumps(report, ensure_ascii=False, indent=2))
return 1
out = a.output or {}
norm = out.get("normalized") if isinstance(out, dict) else None
t("allocations_normalized_present", isinstance(norm, dict), "normalized dict expected")
top = norm.get("top_allocations") if isinstance(norm, dict) else None
cov = norm.get("coverage") if isinstance(norm, dict) else None
t("top_allocations_present", isinstance(top, list), f"type={type(top).__name__}")
t("coverage_present", isinstance(cov, dict), f"type={type(cov).__name__}")
if isinstance(cov, dict):
tracked_fields = cov.get("tracked_percentage_fields", 0)
tracked_sum = cov.get("tracked_percentage_sum", 0)
t("tracked_fields_positive", isinstance(tracked_fields, int) and tracked_fields > 0, str(tracked_fields))
t(
"tracked_sum_reasonable",
isinstance(tracked_sum, (int, float)) and 80 <= float(tracked_sum) <= 120,
str(tracked_sum),
)
# Allocations summary wrapper
s = await TokenomistAllocationsSummaryTool().execute(ctx, query="ARB", top_n=5)
t("allocations_summary_success", s.success, str(s.error or ""))
s_out = s.output or {}
s_summary = s_out.get("summary") if isinstance(s_out, dict) else None
s_top = (s_summary or {}).get("top_allocations") if isinstance(s_summary, dict) else None
s_quality = (s_summary or {}).get("quality") if isinstance(s_summary, dict) else None
t("allocations_summary_top_present", isinstance(s_top, list) and len(s_top) > 0, f"len={len(s_top) if isinstance(s_top, list) else -1}")
t("allocations_summary_quality_present", isinstance(s_quality, dict), f"type={type(s_quality).__name__}")
# Overview includes normalized allocations
ov = await TokenomistTokenOverviewTool().execute(
ctx,
query="ARB",
include_allocations=True,
include_daily_emission=False,
include_unlock_events=False,
)
t("overview_success", ov.success, str(ov.error or ""))
ov_alloc = ((ov.output or {}).get("allocations") or {}).get("normalized") if isinstance(ov.output, dict) else None
t("overview_allocations_normalized", isinstance(ov_alloc, dict), "overview normalized expected")
report["ok"] = all(x["ok"] for x in report["tests"])
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if report["ok"] else 2
except Exception as e:
report["errors"].append({"name": "exception", "detail": str(e), "traceback": traceback.format_exc()})
print(json.dumps(report, ensure_ascii=False, indent=2))
return 3
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
#!/usr/bin/env python3
"""
Integration-style smoke test for tokenomist skill client.
Runs against live Tokenomist API through core/http_client proxied_get.
"""
from __future__ import annotations
import json
import traceback
from skills.tokenomist.tools.client import TokenomistClient, normalize_token_index, resolve_token_id
def main() -> int:
report = {
"ok": False,
"tests": [],
"errors": [],
}
def log_test(name: str, ok: bool, detail: str = ""):
report["tests"].append({"name": name, "ok": ok, "detail": detail})
if not ok:
report["errors"].append({"name": name, "detail": detail})
try:
c = TokenomistClient()
# 1) token list v4
tl = c.token_list_v4()
data = tl.get("data") if isinstance(tl, dict) else None
ok = isinstance(data, list) and len(data) > 0
log_test("token_list_v4_non_empty", ok, f"count={len(data) if isinstance(data, list) else 'n/a'}")
if not ok:
print(json.dumps(report, ensure_ascii=False, indent=2))
return 1
idx = normalize_token_index(tl)
log_test("normalize_index", len(idx) > 0, f"count={len(idx)}")
# 2) resolve token using a known-ish query from first item
first = idx[0]
q = first.get("symbol") or first.get("id") or first.get("name")
res = resolve_token_id(idx, q)
ok = res.get("token") is not None
log_test("resolve_token", ok, f"query={q} match_type={res.get('match_type')}")
if not ok:
print(json.dumps(report, ensure_ascii=False, indent=2))
return 1
token_id = res["token"]["id"]
# 3) allocations v2
alloc = c.allocations_v2(token_id)
ok = isinstance(alloc, dict) and alloc.get("status") is True and "data" in alloc
alloc_data = alloc.get("data", {}) if isinstance(alloc, dict) else {}
alloc_rows = alloc_data.get("allocations", []) if isinstance(alloc_data, dict) else []
tracked_fields = 0
tracked_sum = 0.0
if isinstance(alloc_rows, list):
for row in alloc_rows:
if isinstance(row, dict) and row.get("trackedAllocationPercentage") is not None:
tracked_fields += 1
try:
tracked_sum += float(row.get("trackedAllocationPercentage"))
except Exception:
pass
log_test(
"allocations_v2",
ok and tracked_fields > 0,
f"token_id={token_id} tracked_fields={tracked_fields} tracked_sum={tracked_sum:.4f}",
)
# 4) daily emission v2 (date window anchored to today to avoid historical-range rejects)
from datetime import datetime, timedelta
today = datetime.utcnow().date()
start_s = today.strftime("%Y-%m-%d")
end_s = (today + timedelta(days=1)).strftime("%Y-%m-%d")
de = c.daily_emission_v2(token_id, start=start_s, end=end_s)
ok = isinstance(de, dict) and de.get("status") is True and "data" in de
log_test("daily_emission_v2", ok, f"token_id={token_id} range={start_s}..{end_s}")
# 5) unlock events v4
ue = c.unlock_events_v4(token_id)
ok = isinstance(ue, dict) and ue.get("status") is True and "data" in ue
log_test("unlock_events_v4", ok, f"token_id={token_id}")
report["ok"] = all(t["ok"] for t in report["tests"])
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if report["ok"] else 2
except Exception as e:
report["errors"].append({"name": "exception", "detail": str(e), "traceback": traceback.format_exc()})
print(json.dumps(report, ensure_ascii=False, indent=2))
return 3
if __name__ == "__main__":
raise SystemExit(main())
# tokenomist tools package
"""
Tokenomist API client (Tokenomist API wrapper).
- Uses latest endpoint versions by default:
- Token List API v4
- Allocations API v2
- Daily Emission API v2
- Unlock Events API v4
- Uses core/http_client.py proxied_get so traffic goes through sc-proxy
when proxy is configured.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from typing import Any, Dict, List, Optional
from core.http_client import proxied_get
logger = logging.getLogger(__name__)
BASE_URL = "https://api.tokenomist.ai"
DEFAULT_TIMEOUT = 30
class TokenomistApiError(Exception):
"""Tokenomist API request failed."""
class TokenomistClient:
def __init__(self, api_key: Optional[str] = None, timeout: int = DEFAULT_TIMEOUT):
self.api_key = api_key or os.environ.get("TOKENMIST_API_KEY", "")
self.timeout = timeout
if not self.api_key:
logger.warning("TOKENMIST_API_KEY not set. Tokenomist API calls will fail.")
def _headers(self) -> Dict[str, str]:
return {
"Accept": "application/json",
"x-api-key": self.api_key,
}
def _request(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not self.api_key:
raise TokenomistApiError("TOKENMIST_API_KEY is required")
url = f"{BASE_URL}{path}"
try:
resp = proxied_get(url, headers=self._headers(), params=params or {}, timeout=self.timeout)
except Exception as e:
raise TokenomistApiError(f"Request failed: {e}") from e
if resp.status_code >= 400:
body = resp.text
raise TokenomistApiError(f"Tokenomist API {resp.status_code}: {body}")
try:
data = resp.json()
except Exception as e:
raise TokenomistApiError(f"Invalid JSON response: {e}") from e
# API-level status check
if isinstance(data, dict) and data.get("status") is False:
raise TokenomistApiError(f"API status=false response: {data}")
return data
@staticmethod
def _validate_date_yyyy_mm_dd(value: Optional[str], field_name: str) -> None:
if not value:
return
try:
datetime.strptime(value, "%Y-%m-%d")
except ValueError as e:
raise TokenomistApiError(f"{field_name} must be YYYY-MM-DD, got: {value}") from e
# ---- Canonical latest-version endpoints ----
def token_list_v4(self) -> Dict[str, Any]:
return self._request("/v4/token/list")
def allocations_v2(self, token_id: str) -> Dict[str, Any]:
if not token_id:
raise TokenomistApiError("token_id is required")
return self._request("/v2/allocations", params={"tokenId": token_id})
def daily_emission_v2(
self,
token_id: str,
start: Optional[str] = None,
end: Optional[str] = None,
) -> Dict[str, Any]:
if not token_id:
raise TokenomistApiError("token_id is required")
self._validate_date_yyyy_mm_dd(start, "start")
self._validate_date_yyyy_mm_dd(end, "end")
params: Dict[str, Any] = {"tokenId": token_id}
if start:
params["start"] = start
if end:
params["end"] = end
return self._request("/v2/daily-emission", params=params)
def unlock_events_v4(
self,
token_id: str,
start: Optional[str] = None,
end: Optional[str] = None,
) -> Dict[str, Any]:
if not token_id:
raise TokenomistApiError("token_id is required")
self._validate_date_yyyy_mm_dd(start, "start")
self._validate_date_yyyy_mm_dd(end, "end")
params: Dict[str, Any] = {"tokenId": token_id}
if start:
params["start"] = start
if end:
params["end"] = end
return self._request("/v4/unlock/events", params=params)
def normalize_token_index(token_list_payload: Dict[str, Any]) -> List[Dict[str, Any]]:
data = token_list_payload.get("data", []) if isinstance(token_list_payload, dict) else []
if not isinstance(data, list):
return []
out = []
for item in data:
if not isinstance(item, dict):
continue
out.append(
{
"id": item.get("id"),
"name": item.get("name"),
"symbol": item.get("symbol"),
"listedMethod": item.get("listedMethod"),
"marketCap": item.get("marketCap"),
"circulatingSupply": item.get("circulatingSupply"),
"maxSupply": item.get("maxSupply"),
"websiteUrl": item.get("websiteUrl"),
"hasStandardAllocation": item.get("hasStandardAllocation"),
"hasFundraising": item.get("hasFundraising"),
"hasBurn": item.get("hasBurn"),
"hasBuyback": item.get("hasBuyback"),
"latestFundraisingRound": item.get("latestFundraisingRound"),
"lastUpdatedDate": item.get("lastUpdatedDate"),
}
)
return out
def resolve_token_id(
token_index: List[Dict[str, Any]],
query: str,
) -> Dict[str, Any]:
if not query:
raise TokenomistApiError("query is required")
q = query.strip().lower()
exact_id = [x for x in token_index if str(x.get("id", "")).lower() == q]
if exact_id:
return {"match_type": "exact_id", "token": exact_id[0], "candidates": []}
exact_symbol = [x for x in token_index if str(x.get("symbol", "")).lower() == q]
if len(exact_symbol) == 1:
return {"match_type": "exact_symbol", "token": exact_symbol[0], "candidates": []}
if len(exact_symbol) > 1:
return {
"match_type": "ambiguous_symbol",
"token": None,
"candidates": exact_symbol[:10],
}
exact_name = [x for x in token_index if str(x.get("name", "")).lower() == q]
if len(exact_name) == 1:
return {"match_type": "exact_name", "token": exact_name[0], "candidates": []}
if len(exact_name) > 1:
return {
"match_type": "ambiguous_name",
"token": None,
"candidates": exact_name[:10],
}
fuzzy = [
x
for x in token_index
if q in str(x.get("id", "")).lower()
or q in str(x.get("symbol", "")).lower()
or q in str(x.get("name", "")).lower()
]
if len(fuzzy) == 1:
return {"match_type": "fuzzy_single", "token": fuzzy[0], "candidates": []}
return {"match_type": "fuzzy_many", "token": None, "candidates": fuzzy[:10]}
"""Tool wrappers for Tokenomist client."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from core.tool import BaseTool, ToolContext, ToolResult
from .client import (
TokenomistApiError,
TokenomistClient,
normalize_token_index,
resolve_token_id,
)
_client_singleton: Optional[TokenomistClient] = None
_token_index_cache: Optional[List[Dict[str, Any]]] = None
def _client() -> TokenomistClient:
global _client_singleton
if _client_singleton is None:
_client_singleton = TokenomistClient()
return _client_singleton
def _safe_error_message(e: Exception) -> str:
msg = str(e)
# never leak key in tool output, redact common fake key literal if echoed by upstream
return msg.replace("fake-tokenomist-key-12345", "[REDACTED]")
def _get_index(force_refresh: bool = False) -> List[Dict[str, Any]]:
global _token_index_cache
if force_refresh or _token_index_cache is None:
payload = _client().token_list_v4()
_token_index_cache = normalize_token_index(payload)
return _token_index_cache
def _to_float(value: Any) -> Optional[float]:
try:
if value is None:
return None
return float(value)
except Exception:
return None
def _normalize_allocations_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize allocations response to reduce agent misinterpretation.
- Primary percentage field: trackedAllocationPercentage (v2)
- Fallback percentage: allocationAmount / totalTrackedAllocationAmount
- Adds top allocations and quality flags
"""
data = payload.get("data") if isinstance(payload, dict) else None
allocations = data.get("allocations") if isinstance(data, dict) else []
if not isinstance(allocations, list):
allocations = []
normalized: List[Dict[str, Any]] = []
tracked_sum = 0.0
fallback_sum = 0.0
for row in allocations:
if not isinstance(row, dict):
continue
tracked_pct = _to_float(row.get("trackedAllocationPercentage"))
alloc_amount = _to_float(row.get("allocationAmount"))
pct_source = "tracked"
effective_pct = tracked_pct
if effective_pct is None and alloc_amount is not None:
total_tracked_amount = _to_float(data.get("totalTrackedAllocationAmount")) if isinstance(data, dict) else None
if total_tracked_amount and total_tracked_amount > 0:
effective_pct = (alloc_amount / total_tracked_amount) * 100.0
pct_source = "fallback_from_allocationAmount"
if tracked_pct is not None:
tracked_sum += tracked_pct
if effective_pct is not None:
fallback_sum += effective_pct
normalized.append(
{
"allocationName": row.get("allocationName"),
"allocationType": row.get("allocationType"),
"standardAllocationName": row.get("standardAllocationName"),
"allocationAmount": row.get("allocationAmount"),
"trackedAllocationPercentage": row.get("trackedAllocationPercentage"),
"effectivePercentage": effective_pct,
"percentageSource": pct_source,
}
)
normalized_sorted = sorted(
normalized,
key=lambda x: (x.get("effectivePercentage") is not None, x.get("effectivePercentage") or -1),
reverse=True,
)
coverage = {
"allocations_count": len(normalized),
"tracked_percentage_fields": sum(1 for x in normalized if x.get("trackedAllocationPercentage") is not None),
"effective_percentage_fields": sum(1 for x in normalized if x.get("effectivePercentage") is not None),
"tracked_percentage_sum": tracked_sum,
"effective_percentage_sum": fallback_sum,
"tracked_sum_close_to_100": 99.0 <= tracked_sum <= 101.0,
"effective_sum_close_to_100": 99.0 <= fallback_sum <= 101.0,
}
return {
"token": {
"tokenId": data.get("tokenId") if isinstance(data, dict) else None,
"symbol": data.get("symbol") if isinstance(data, dict) else None,
"listedMethod": data.get("listedMethod") if isinstance(data, dict) else None,
},
"totals": {
"totalTrackedAllocationAmount": data.get("totalTrackedAllocationAmount") if isinstance(data, dict) else None,
"totalTrackedUnlockedAmount": data.get("totalTrackedUnlockedAmount") if isinstance(data, dict) else None,
"totalTrackedLockedAmount": data.get("totalTrackedLockedAmount") if isinstance(data, dict) else None,
"referenceSupply": data.get("referenceSupply") if isinstance(data, dict) else None,
},
"coverage": coverage,
"top_allocations": normalized_sorted[:5],
"allocations": normalized_sorted,
}
class TokenomistTokenListTool(BaseTool):
@property
def name(self) -> str:
return "tokenomist_token_list"
@property
def description(self) -> str:
return """Get Token List API v4 from Tokenomist.
Uses latest Token List version (v4). Supports optional keyword filtering and limit to reduce payload size.
"""
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "Optional keyword to filter by id/symbol/name",
},
"limit": {
"type": "integer",
"description": "Max results to return (default 50, max 500)",
"minimum": 1,
"maximum": 500,
},
"force_refresh": {
"type": "boolean",
"description": "Refresh token list cache from API",
"default": False,
},
},
}
async def execute(
self,
ctx: ToolContext,
keyword: str = "",
limit: int = 50,
force_refresh: bool = False,
**kwargs,
) -> ToolResult:
try:
limit = max(1, min(int(limit or 50), 500))
rows = _get_index(force_refresh=force_refresh)
if keyword:
q = keyword.strip().lower()
rows = [
r
for r in rows
if q in str(r.get("id", "")).lower()
or q in str(r.get("symbol", "")).lower()
or q in str(r.get("name", "")).lower()
]
return ToolResult(
success=True,
output={
"version": "v4",
"count": len(rows),
"items": rows[:limit],
"returned": min(len(rows), limit),
},
)
except Exception as e:
return ToolResult(success=False, error=_safe_error_message(e))
class TokenomistResolveTokenTool(BaseTool):
@property
def name(self) -> str:
return "tokenomist_resolve_token"
@property
def description(self) -> str:
return """Resolve user token query to canonical tokenId using Token List v4.
Input can be tokenId, symbol, or token name. Returns best match and alternatives.
"""
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Token id/symbol/name to resolve",
},
"force_refresh": {
"type": "boolean",
"description": "Refresh token list cache from API",
"default": False,
},
},
"required": ["query"],
}
async def execute(
self,
ctx: ToolContext,
query: str = "",
force_refresh: bool = False,
**kwargs,
) -> ToolResult:
if not query:
return ToolResult(success=False, error="'query' is required")
try:
idx = _get_index(force_refresh=force_refresh)
result = resolve_token_id(idx, query)
return ToolResult(success=True, output={"version": "v4", **result})
except Exception as e:
return ToolResult(success=False, error=_safe_error_message(e))
class TokenomistAllocationsTool(BaseTool):
@property
def name(self) -> str:
return "tokenomist_allocations"
@property
def description(self) -> str:
return """Get allocations data for a token from Allocations API v2 (latest).
Returns normalized allocation percentages to reduce ambiguity:
- Uses trackedAllocationPercentage as primary
- Adds effectivePercentage fallback when possible
- Includes top_allocations and coverage quality summary
"""
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"token_id": {
"type": "string",
"description": "Canonical tokenId from tokenomist_token_list/tokenomist_resolve_token",
},
"include_raw": {
"type": "boolean",
"description": "Include raw upstream payload for debugging",
"default": False,
},
},
"required": ["token_id"],
}
async def execute(
self,
ctx: ToolContext,
token_id: str = "",
include_raw: bool = False,
**kwargs,
) -> ToolResult:
if not token_id:
return ToolResult(success=False, error="'token_id' is required")
try:
raw = _client().allocations_v2(token_id)
normalized = _normalize_allocations_payload(raw)
out: Dict[str, Any] = {
"version": "v2",
"token_id": token_id,
"normalized": normalized,
}
if include_raw:
out["raw"] = raw
return ToolResult(success=True, output=out)
except Exception as e:
return ToolResult(success=False, error=_safe_error_message(e))
class TokenomistAllocationsSummaryTool(BaseTool):
@property
def name(self) -> str:
return "tokenomist_allocations_summary"
@property
def description(self) -> str:
return """Get compact allocations summary with top N buckets and quality flags.
Accepts either `token_id` or free-text `query` (symbol/name/id).
If query is provided, resolves to canonical tokenId first.
"""
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"token_id": {
"type": "string",
"description": "Canonical tokenId (preferred if known)",
},
"query": {
"type": "string",
"description": "Token symbol/name/id (used when token_id is not provided)",
},
"top_n": {
"type": "integer",
"description": "Number of top allocations to return (default 5, max 20)",
"minimum": 1,
"maximum": 20,
"default": 5,
},
"force_refresh": {
"type": "boolean",
"description": "Refresh token index cache before resolve",
"default": False,
},
},
}
async def execute(
self,
ctx: ToolContext,
token_id: str = "",
query: str = "",
top_n: int = 5,
force_refresh: bool = False,
**kwargs,
) -> ToolResult:
try:
top_n = max(1, min(int(top_n or 5), 20))
resolved: Optional[Dict[str, Any]] = None
canonical_token_id = (token_id or "").strip()
if not canonical_token_id:
if not query:
return ToolResult(success=False, error="Either 'token_id' or 'query' is required")
idx = _get_index(force_refresh=force_refresh)
resolved = resolve_token_id(idx, query)
token = resolved.get("token") if isinstance(resolved, dict) else None
if not token:
return ToolResult(
success=False,
error=(
"Could not resolve unique tokenId from query. "
f"match_type={resolved.get('match_type') if isinstance(resolved, dict) else 'unknown'}"
),
output={"resolution": resolved},
)
canonical_token_id = str(token.get("id", "")).strip()
raw = _client().allocations_v2(canonical_token_id)
normalized = _normalize_allocations_payload(raw)
coverage = normalized.get("coverage", {}) if isinstance(normalized, dict) else {}
top_allocations = normalized.get("top_allocations", []) if isinstance(normalized, dict) else []
if not isinstance(top_allocations, list):
top_allocations = []
summary = {
"token": normalized.get("token") if isinstance(normalized, dict) else None,
"top_n": top_n,
"top_allocations": top_allocations[:top_n],
"coverage": coverage,
"quality": {
"has_tracked_percentages": bool((coverage or {}).get("tracked_percentage_fields", 0) > 0),
"sum_close_to_100": bool(
(coverage or {}).get("tracked_sum_close_to_100")
or (coverage or {}).get("effective_sum_close_to_100")
),
},
}
output: Dict[str, Any] = {
"version": "v2",
"token_id": canonical_token_id,
"summary": summary,
"timestamp": datetime.utcnow().isoformat() + "Z",
}
if resolved is not None:
output["resolution"] = {
"query": query,
"match_type": resolved.get("match_type"),
"token": resolved.get("token"),
}
return ToolResult(success=True, output=output)
except Exception as e:
return ToolResult(success=False, error=_safe_error_message(e))
class TokenomistDailyEmissionTool(BaseTool):
@property
def name(self) -> str:
return "tokenomist_daily_emission"
@property
def description(self) -> str:
return "Get daily emission data from Daily Emission API v2 (latest)."
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"token_id": {
"type": "string",
"description": "Canonical tokenId",
},
"start": {
"type": "string",
"description": "Optional YYYY-MM-DD",
},
"end": {
"type": "string",
"description": "Optional YYYY-MM-DD",
},
},
"required": ["token_id"],
}
async def execute(
self,
ctx: ToolContext,
token_id: str = "",
start: str = "",
end: str = "",
**kwargs,
) -> ToolResult:
if not token_id:
return ToolResult(success=False, error="'token_id' is required")
try:
data = _client().daily_emission_v2(
token_id=token_id,
start=start or None,
end=end or None,
)
return ToolResult(success=True, output={"version": "v2", **data})
except Exception as e:
return ToolResult(success=False, error=_safe_error_message(e))
class TokenomistUnlockEventsTool(BaseTool):
@property
def name(self) -> str:
return "tokenomist_unlock_events"
@property
def description(self) -> str:
return "Get unlock events from Unlock Events API v4 (latest)."
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"token_id": {
"type": "string",
"description": "Canonical tokenId",
},
"start": {
"type": "string",
"description": "Optional YYYY-MM-DD",
},
"end": {
"type": "string",
"description": "Optional YYYY-MM-DD",
},
},
"required": ["token_id"],
}
async def execute(
self,
ctx: ToolContext,
token_id: str = "",
start: str = "",
end: str = "",
**kwargs,
) -> ToolResult:
if not token_id:
return ToolResult(success=False, error="'token_id' is required")
try:
data = _client().unlock_events_v4(
token_id=token_id,
start=start or None,
end=end or None,
)
return ToolResult(success=True, output={"version": "v4", **data})
except Exception as e:
return ToolResult(success=False, error=_safe_error_message(e))
class TokenomistTokenOverviewTool(BaseTool):
@property
def name(self) -> str:
return "tokenomist_token_overview"
@property
def description(self) -> str:
return """One-call wrapper to minimize tool calls.
Resolves token query then fetches latest allocations(v2), daily-emission(v2), and unlock-events(v4).
Useful for agent workflows to reduce ambiguity and token/tool overhead.
"""
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Token id/symbol/name",
},
"start": {
"type": "string",
"description": "Optional YYYY-MM-DD for emission/events",
},
"end": {
"type": "string",
"description": "Optional YYYY-MM-DD for emission/events",
},
"include_allocations": {
"type": "boolean",
"description": "Whether to fetch allocations",
"default": True,
},
"include_daily_emission": {
"type": "boolean",
"description": "Whether to fetch daily emission",
"default": True,
},
"include_unlock_events": {
"type": "boolean",
"description": "Whether to fetch unlock events",
"default": True,
},
"force_refresh": {
"type": "boolean",
"description": "Refresh token list cache before resolve",
"default": False,
},
},
"required": ["query"],
}
async def execute(
self,
ctx: ToolContext,
query: str = "",
start: str = "",
end: str = "",
include_allocations: bool = True,
include_daily_emission: bool = True,
include_unlock_events: bool = True,
force_refresh: bool = False,
**kwargs,
) -> ToolResult:
if not query:
return ToolResult(success=False, error="'query' is required")
try:
idx = _get_index(force_refresh=force_refresh)
resolved = resolve_token_id(idx, query)
token = resolved.get("token")
if not token:
return ToolResult(
success=False,
error=(
"Could not resolve unique tokenId from query. "
f"match_type={resolved.get('match_type')}"
),
output={
"resolution": resolved,
},
)
token_id = token.get("id")
out: Dict[str, Any] = {
"resolved": {
"query": query,
"match_type": resolved.get("match_type"),
"token": token,
},
"versions": {
"token_list": "v4",
"allocations": "v2",
"daily_emission": "v2",
"unlock_events": "v4",
},
"timestamp": datetime.utcnow().isoformat() + "Z",
}
client = _client()
if include_allocations:
try:
raw_alloc = client.allocations_v2(token_id)
out["allocations"] = {
"version": "v2",
"normalized": _normalize_allocations_payload(raw_alloc),
}
except Exception as e:
out["allocations_error"] = _safe_error_message(e)
if include_daily_emission:
try:
out["daily_emission"] = client.daily_emission_v2(
token_id=token_id,
start=start or None,
end=end or None,
)
except Exception as e:
out["daily_emission_error"] = _safe_error_message(e)
if include_unlock_events:
try:
out["unlock_events"] = client.unlock_events_v4(
token_id=token_id,
start=start or None,
end=end or None,
)
except Exception as e:
out["unlock_events_error"] = _safe_error_message(e)
return ToolResult(success=True, output=out)
except Exception as e:
return ToolResult(success=False, error=_safe_error_message(e))