scripts/solana_client.py
scripts/solana_client.pyBrowse 2 files
6,781 tokens
26,039 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""3Solana Blockchain CLI Tool for Hermes Agent4--------------------------------------------5Queries the Solana JSON-RPC API and CoinGecko for enriched on-chain data.6Uses only Python standard library — no external packages required.7 8Usage:9 python3 solana_client.py stats10 python3 solana_client.py wallet <address> [--limit N] [--all] [--no-prices]11 python3 solana_client.py tx <signature>12 python3 solana_client.py token <mint_address>13 python3 solana_client.py activity <address> [--limit N]14 python3 solana_client.py nft <address>15 python3 solana_client.py whales [--min-sol N]16 python3 solana_client.py price <mint_address_or_symbol>17 18Environment:19 SOLANA_RPC_URL Override the default RPC endpoint (default: mainnet-beta public)20"""21 22import argparse23import json24import os25import sys26import time27import urllib.request28import urllib.error29from typing import Any, Dict, List, Optional30 31RPC_URL = os.environ.get(32 "SOLANA_RPC_URL",33 "https://api.mainnet-beta.solana.com",34)35 36LAMPORTS_PER_SOL = 1_000_000_00037 38# Well-known Solana token names — avoids API calls for common tokens.39# Maps mint address → (symbol, name).40KNOWN_TOKENS: Dict[str, tuple] = {41 "So11111111111111111111111111111111111111112": ("SOL", "Solana"),42 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": ("USDC", "USD Coin"),43 "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB": ("USDT", "Tether"),44 "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263": ("BONK", "Bonk"),45 "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN": ("JUP", "Jupiter"),46 "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs": ("WETH", "Wrapped Ether"),47 "jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL": ("JTO", "Jito"),48 "mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So": ("mSOL", "Marinade Staked SOL"),49 "7dHbWXmci3dT8UFYWYZweBLXgycu7Y3iL6trKn1Y7ARj": ("stSOL", "Lido Staked SOL"),50 "HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3": ("PYTH", "Pyth Network"),51 "RLBxxFkseAZ4RgJH3Sqn8jXxhmGoz9jWxDNJMh8pL7a": ("RLBB", "Rollbit"),52 "hntyVP6YFm1Hg25TN9WGLqM12b8TQmcknKrdu1oxWux": ("HNT", "Helium"),53 "rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof": ("RNDR", "Render"),54 "WENWENvqqNya429ubCdR81ZmD69brwQaaBYY6p91oHQQ": ("WEN", "Wen"),55 "85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ": ("W", "Wormhole"),56 "TNSRxcUxoT9xBG3de7PiJyTDYu7kskLqcpddxnEJAS6": ("TNSR", "Tensor"),57 "DriFtupJYLTosbwoN8koMbEYSx54aFAVLddWsbksjwg7": ("DRIFT", "Drift"),58 "bSo13r4TkiE4KumL71LsHTPpL2euBYLFx6h9HP3piy1": ("bSOL", "BlazeStake Staked SOL"),59 "27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4": ("JLP", "Jupiter LP"),60 "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm": ("WIF", "dogwifhat"),61 "MEW1gQWJ3nEXg2qgERiKu7FAFj79PHvQVREQUzScPP5": ("MEW", "cat in a dogs world"),62 "ukHH6c7mMyiWCf1b9pnWe25TSpkDDt3H5pQZgZ74J82": ("BOME", "Book of Meme"),63 "A8C3xuqscfmyLrte3VwJvtPHXvcSN3FjDbUaSMAkQrCS": ("PENGU", "Pudgy Penguins"),64}65 66# Reverse lookup: symbol → mint (for the `price` command).67_SYMBOL_TO_MINT = {v[0].upper(): k for k, v in KNOWN_TOKENS.items()}68 69 70# ---------------------------------------------------------------------------71# HTTP / RPC helpers72# ---------------------------------------------------------------------------73 74def _http_get_json(url: str, timeout: int = 10, retries: int = 2) -> Any:75 """GET JSON from a URL with retry on 429 rate-limit. Returns parsed JSON or None."""76 for attempt in range(retries + 1):77 req = urllib.request.Request(78 url, headers={"Accept": "application/json", "User-Agent": "HermesAgent/1.0"},79 )80 try:81 with urllib.request.urlopen(req, timeout=timeout) as resp:82 return json.load(resp)83 except urllib.error.HTTPError as exc:84 if exc.code == 429 and attempt < retries:85 time.sleep(2.0 * (attempt + 1))86 continue87 return None88 except Exception:89 return None90 return None91 92 93def _rpc_call(method: str, params: list = None, retries: int = 2) -> Any:94 """Send a JSON-RPC request with retry on 429 rate-limit."""95 payload = json.dumps({96 "jsonrpc": "2.0", "id": 1,97 "method": method, "params": params or [],98 }).encode()99 100 for attempt in range(retries + 1):101 req = urllib.request.Request(102 RPC_URL, data=payload,103 headers={"Content-Type": "application/json"}, method="POST",104 )105 try:106 with urllib.request.urlopen(req, timeout=20) as resp:107 body = json.load(resp)108 if "error" in body:109 err = body["error"]110 # Rate-limit: retry after delay111 if isinstance(err, dict) and err.get("code") == 429:112 if attempt < retries:113 time.sleep(1.5 * (attempt + 1))114 continue115 sys.exit(f"RPC error: {err}")116 return body.get("result")117 except urllib.error.HTTPError as exc:118 if exc.code == 429 and attempt < retries:119 time.sleep(1.5 * (attempt + 1))120 continue121 sys.exit(f"RPC HTTP error: {exc}")122 except urllib.error.URLError as exc:123 sys.exit(f"RPC connection error: {exc}")124 return None125 126 127# Keep backward compat — the rest of the code uses `rpc()`.128rpc = _rpc_call129 130 131def rpc_batch(calls: list) -> list:132 """Send a batch of JSON-RPC requests (with retry on 429)."""133 payload = json.dumps([134 {"jsonrpc": "2.0", "id": i, "method": c["method"], "params": c.get("params", [])}135 for i, c in enumerate(calls)136 ]).encode()137 138 for attempt in range(3):139 req = urllib.request.Request(140 RPC_URL, data=payload,141 headers={"Content-Type": "application/json"}, method="POST",142 )143 try:144 with urllib.request.urlopen(req, timeout=20) as resp:145 return json.load(resp)146 except urllib.error.HTTPError as exc:147 if exc.code == 429 and attempt < 2:148 time.sleep(1.5 * (attempt + 1))149 continue150 sys.exit(f"RPC batch HTTP error: {exc}")151 except urllib.error.URLError as exc:152 sys.exit(f"RPC batch error: {exc}")153 return []154 155 156def lamports_to_sol(lamports: int) -> float:157 return lamports / LAMPORTS_PER_SOL158 159 160def print_json(obj: Any) -> None:161 print(json.dumps(obj, indent=2))162 163 164def _short_mint(mint: str) -> str:165 """Abbreviate a mint address for display: first 4 + last 4."""166 if len(mint) <= 12:167 return mint168 return f"{mint[:4]}...{mint[-4:]}"169 170 171# ---------------------------------------------------------------------------172# Price & token name helpers (CoinGecko — free, no API key)173# ---------------------------------------------------------------------------174 175def fetch_prices(mints: List[str], max_lookups: int = 20) -> Dict[str, float]:176 """Fetch USD prices for mint addresses via CoinGecko (one per request).177 178 CoinGecko free tier doesn't support batch Solana token lookups,179 so we do individual calls — capped at *max_lookups* to stay within180 rate limits. Returns {mint: usd_price}.181 """182 prices: Dict[str, float] = {}183 for i, mint in enumerate(mints[:max_lookups]):184 url = (185 f"https://api.coingecko.com/api/v3/simple/token_price/solana"186 f"?contract_addresses={mint}&vs_currencies=usd"187 )188 data = _http_get_json(url, timeout=10)189 if data and isinstance(data, dict):190 for addr, info in data.items():191 if isinstance(info, dict) and "usd" in info:192 prices[mint] = info["usd"]193 break194 # Pause between calls to respect CoinGecko free-tier rate-limits195 if i < len(mints[:max_lookups]) - 1:196 time.sleep(1.0)197 return prices198 199 200def fetch_sol_price() -> Optional[float]:201 """Fetch current SOL price in USD via CoinGecko."""202 data = _http_get_json(203 "https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd"204 )205 if data and "solana" in data:206 return data["solana"].get("usd")207 return None208 209 210def resolve_token_name(mint: str) -> Optional[Dict[str, str]]:211 """Look up token name and symbol from CoinGecko by mint address.212 213 Returns {"name": ..., "symbol": ...} or None.214 """215 if mint in KNOWN_TOKENS:216 sym, name = KNOWN_TOKENS[mint]217 return {"symbol": sym, "name": name}218 url = f"https://api.coingecko.com/api/v3/coins/solana/contract/{mint}"219 data = _http_get_json(url, timeout=10)220 if data and "symbol" in data:221 return {"symbol": data["symbol"].upper(), "name": data.get("name", "")}222 return None223 224 225def _token_label(mint: str) -> str:226 """Return a human-readable label for a mint: symbol if known, else abbreviated address."""227 if mint in KNOWN_TOKENS:228 return KNOWN_TOKENS[mint][0]229 return _short_mint(mint)230 231 232# ---------------------------------------------------------------------------233# 1. Network Stats234# ---------------------------------------------------------------------------235 236def cmd_stats(_args):237 """Live Solana network: slot, epoch, TPS, supply, version, SOL price."""238 results = rpc_batch([239 {"method": "getSlot"},240 {"method": "getEpochInfo"},241 {"method": "getRecentPerformanceSamples", "params": [1]},242 {"method": "getSupply"},243 {"method": "getVersion"},244 ])245 246 by_id = {r["id"]: r.get("result") for r in results}247 248 slot = by_id.get(0)249 epoch_info = by_id.get(1)250 perf_samples = by_id.get(2)251 supply = by_id.get(3)252 version = by_id.get(4)253 254 tps = None255 if perf_samples:256 s = perf_samples[0]257 tps = round(s["numTransactions"] / s["samplePeriodSecs"], 1)258 259 total_supply = lamports_to_sol(supply["value"]["total"]) if supply else None260 circ_supply = lamports_to_sol(supply["value"]["circulating"]) if supply else None261 262 sol_price = fetch_sol_price()263 264 out = {265 "slot": slot,266 "epoch": epoch_info.get("epoch") if epoch_info else None,267 "slot_in_epoch": epoch_info.get("slotIndex") if epoch_info else None,268 "tps": tps,269 "total_supply_SOL": round(total_supply, 2) if total_supply else None,270 "circulating_supply_SOL": round(circ_supply, 2) if circ_supply else None,271 "validator_version": version.get("solana-core") if version else None,272 }273 if sol_price is not None:274 out["sol_price_usd"] = sol_price275 if circ_supply:276 out["market_cap_usd"] = round(sol_price * circ_supply, 0)277 print_json(out)278 279 280# ---------------------------------------------------------------------------281# 2. Wallet Info (enhanced with prices, sorting, filtering)282# ---------------------------------------------------------------------------283 284def cmd_wallet(args):285 """SOL balance + SPL token holdings with USD values."""286 address = args.address287 show_all = getattr(args, "all", False)288 limit = getattr(args, "limit", 20) or 20289 skip_prices = getattr(args, "no_prices", False)290 291 # Fetch SOL balance292 balance_result = rpc("getBalance", [address])293 sol_balance = lamports_to_sol(balance_result["value"])294 295 # Fetch all SPL token accounts296 token_result = rpc("getTokenAccountsByOwner", [297 address,298 {"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},299 {"encoding": "jsonParsed"},300 ])301 302 raw_tokens = []303 for acct in (token_result.get("value") or []):304 info = acct["account"]["data"]["parsed"]["info"]305 ta = info["tokenAmount"]306 amount = float(ta.get("uiAmountString") or 0)307 if amount > 0:308 raw_tokens.append({309 "mint": info["mint"],310 "amount": amount,311 "decimals": ta["decimals"],312 })313 314 # Separate NFTs (amount=1, decimals=0) from fungible tokens315 nfts = [t for t in raw_tokens if t["decimals"] == 0 and t["amount"] == 1]316 fungible = [t for t in raw_tokens if not (t["decimals"] == 0 and t["amount"] == 1)]317 318 # Fetch prices for fungible tokens (cap lookups to avoid API abuse)319 sol_price = None320 prices: Dict[str, float] = {}321 if not skip_prices and fungible:322 sol_price = fetch_sol_price()323 # Prioritize known tokens, then a small sample of unknowns.324 # CoinGecko free tier = 1 request per mint, so we cap lookups.325 known_mints = [t["mint"] for t in fungible if t["mint"] in KNOWN_TOKENS]326 other_mints = [t["mint"] for t in fungible if t["mint"] not in KNOWN_TOKENS][:15]327 mints_to_price = known_mints + other_mints328 if mints_to_price:329 prices = fetch_prices(mints_to_price, max_lookups=30)330 331 # Enrich tokens with labels and USD values332 enriched = []333 dust_count = 0334 dust_value = 0.0335 for t in fungible:336 mint = t["mint"]337 label = _token_label(mint)338 usd_price = prices.get(mint)339 usd_value = round(usd_price * t["amount"], 2) if usd_price else None340 341 # Filter dust (< $0.01) unless --all342 if not show_all and usd_value is not None and usd_value < 0.01:343 dust_count += 1344 dust_value += usd_value345 continue346 347 entry = {"token": label, "mint": mint, "amount": t["amount"]}348 if usd_price is not None:349 entry["price_usd"] = usd_price350 entry["value_usd"] = usd_value351 enriched.append(entry)352 353 # Sort: tokens with known USD value first (highest→lowest), then unknowns354 enriched.sort(key=lambda x: (x.get("value_usd") is not None, x.get("value_usd") or 0), reverse=True)355 356 # Apply limit unless --all357 total_tokens = len(enriched)358 if not show_all and len(enriched) > limit:359 enriched = enriched[:limit]360 361 # Compute portfolio total362 total_usd = sum(t.get("value_usd", 0) for t in enriched)363 sol_value_usd = round(sol_price * sol_balance, 2) if sol_price else None364 if sol_value_usd:365 total_usd += sol_value_usd366 total_usd += dust_value367 368 output = {369 "address": address,370 "sol_balance": round(sol_balance, 9),371 }372 if sol_price:373 output["sol_price_usd"] = sol_price374 output["sol_value_usd"] = sol_value_usd375 output["tokens_shown"] = len(enriched)376 if total_tokens > len(enriched):377 output["tokens_hidden"] = total_tokens - len(enriched)378 output["spl_tokens"] = enriched379 if dust_count > 0:380 output["dust_filtered"] = {"count": dust_count, "total_value_usd": round(dust_value, 4)}381 output["nft_count"] = len(nfts)382 if nfts:383 output["nfts"] = [_token_label(n["mint"]) + f" ({_short_mint(n['mint'])})" for n in nfts[:10]]384 if len(nfts) > 10:385 output["nfts"].append(f"... and {len(nfts) - 10} more")386 if total_usd > 0:387 output["portfolio_total_usd"] = round(total_usd, 2)388 389 print_json(output)390 391 392# ---------------------------------------------------------------------------393# 3. Transaction Details394# ---------------------------------------------------------------------------395 396def cmd_tx(args):397 """Full transaction details by signature."""398 result = rpc("getTransaction", [399 args.signature,400 {"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0},401 ])402 403 if result is None:404 sys.exit("Transaction not found (may be too old for public RPC history).")405 406 meta = result.get("meta", {}) or {}407 msg = result.get("transaction", {}).get("message", {})408 account_keys = msg.get("accountKeys", [])409 410 pre = meta.get("preBalances", [])411 post = meta.get("postBalances", [])412 413 balance_changes = []414 for i, key in enumerate(account_keys):415 acct_key = key["pubkey"] if isinstance(key, dict) else key416 if i < len(pre) and i < len(post):417 change = lamports_to_sol(post[i] - pre[i])418 if change != 0:419 balance_changes.append({"account": acct_key, "change_SOL": round(change, 9)})420 421 programs = []422 for ix in msg.get("instructions", []):423 prog = ix.get("programId")424 if prog is None and "programIdIndex" in ix:425 k = account_keys[ix["programIdIndex"]]426 prog = k["pubkey"] if isinstance(k, dict) else k427 if prog:428 programs.append(prog)429 430 # Add USD value for SOL changes431 sol_price = fetch_sol_price()432 if sol_price and balance_changes:433 for bc in balance_changes:434 bc["change_USD"] = round(bc["change_SOL"] * sol_price, 2)435 436 print_json({437 "signature": args.signature,438 "slot": result.get("slot"),439 "block_time": result.get("blockTime"),440 "fee_SOL": lamports_to_sol(meta.get("fee", 0)),441 "status": "success" if meta.get("err") is None else "failed",442 "balance_changes": balance_changes,443 "programs_invoked": list(dict.fromkeys(programs)),444 })445 446 447# ---------------------------------------------------------------------------448# 4. Token Info (enhanced with name + price)449# ---------------------------------------------------------------------------450 451def cmd_token(args):452 """SPL token metadata, supply, decimals, price, top holders."""453 mint = args.mint454 455 mint_info = rpc("getAccountInfo", [mint, {"encoding": "jsonParsed"}])456 if mint_info is None or mint_info.get("value") is None:457 sys.exit("Mint account not found.")458 459 parsed = mint_info["value"]["data"]["parsed"]["info"]460 decimals = parsed.get("decimals", 0)461 supply_raw = int(parsed.get("supply", 0))462 supply_human = supply_raw / (10 ** decimals) if decimals else supply_raw463 464 largest = rpc("getTokenLargestAccounts", [mint])465 holders = []466 for acct in (largest.get("value") or [])[:5]:467 amount = float(acct.get("uiAmountString") or 0)468 pct = round((amount / supply_human * 100), 4) if supply_human > 0 else 0469 holders.append({470 "account": acct["address"],471 "amount": amount,472 "percent": pct,473 })474 475 # Resolve name + price476 token_meta = resolve_token_name(mint)477 price_data = fetch_prices([mint])478 479 out = {"mint": mint}480 if token_meta:481 out["name"] = token_meta["name"]482 out["symbol"] = token_meta["symbol"]483 out["decimals"] = decimals484 out["supply"] = round(supply_human, min(decimals, 6))485 out["mint_authority"] = parsed.get("mintAuthority")486 out["freeze_authority"] = parsed.get("freezeAuthority")487 if mint in price_data:488 out["price_usd"] = price_data[mint]489 out["market_cap_usd"] = round(price_data[mint] * supply_human, 0)490 out["top_5_holders"] = holders491 492 print_json(out)493 494 495# ---------------------------------------------------------------------------496# 5. Recent Activity497# ---------------------------------------------------------------------------498 499def cmd_activity(args):500 """Recent transaction signatures for an address."""501 limit = min(args.limit, 25)502 result = rpc("getSignaturesForAddress", [args.address, {"limit": limit}])503 504 txs = [505 {506 "signature": item["signature"],507 "slot": item.get("slot"),508 "block_time": item.get("blockTime"),509 "err": item.get("err"),510 }511 for item in (result or [])512 ]513 514 print_json({"address": args.address, "transactions": txs})515 516 517# ---------------------------------------------------------------------------518# 6. NFT Portfolio519# ---------------------------------------------------------------------------520 521def cmd_nft(args):522 """NFTs owned by a wallet (amount=1 && decimals=0 heuristic)."""523 result = rpc("getTokenAccountsByOwner", [524 args.address,525 {"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},526 {"encoding": "jsonParsed"},527 ])528 529 nfts = [530 acct["account"]["data"]["parsed"]["info"]["mint"]531 for acct in (result.get("value") or [])532 if acct["account"]["data"]["parsed"]["info"]["tokenAmount"]["decimals"] == 0533 and int(acct["account"]["data"]["parsed"]["info"]["tokenAmount"]["amount"]) == 1534 ]535 536 print_json({537 "address": args.address,538 "nft_count": len(nfts),539 "nfts": nfts,540 "note": "Heuristic only. Compressed NFTs (cNFTs) are not detected.",541 })542 543 544# ---------------------------------------------------------------------------545# 7. Whale Detector (enhanced with USD values)546# ---------------------------------------------------------------------------547 548def cmd_whales(args):549 """Scan the latest block for large SOL transfers."""550 min_lamports = int(args.min_sol * LAMPORTS_PER_SOL)551 552 slot = rpc("getSlot")553 block = rpc("getBlock", [554 slot,555 {556 "encoding": "jsonParsed",557 "transactionDetails": "full",558 "maxSupportedTransactionVersion": 0,559 "rewards": False,560 },561 ])562 563 if block is None:564 sys.exit("Could not retrieve latest block.")565 566 sol_price = fetch_sol_price()567 568 whales = []569 for tx in (block.get("transactions") or []):570 meta = tx.get("meta", {}) or {}571 if meta.get("err") is not None:572 continue573 574 msg = tx["transaction"].get("message", {})575 account_keys = msg.get("accountKeys", [])576 pre = meta.get("preBalances", [])577 post = meta.get("postBalances", [])578 579 for i in range(len(pre)):580 change = post[i] - pre[i]581 if change >= min_lamports:582 k = account_keys[i]583 receiver = k["pubkey"] if isinstance(k, dict) else k584 sender = None585 for j in range(len(pre)):586 if pre[j] - post[j] >= min_lamports:587 sk = account_keys[j]588 sender = sk["pubkey"] if isinstance(sk, dict) else sk589 break590 entry = {591 "sender": sender,592 "receiver": receiver,593 "amount_SOL": round(lamports_to_sol(change), 4),594 }595 if sol_price:596 entry["amount_USD"] = round(lamports_to_sol(change) * sol_price, 2)597 whales.append(entry)598 599 out = {600 "slot": slot,601 "min_threshold_SOL": args.min_sol,602 "large_transfers": whales,603 "note": "Scans latest block only — point-in-time snapshot.",604 }605 if sol_price:606 out["sol_price_usd"] = sol_price607 print_json(out)608 609 610# ---------------------------------------------------------------------------611# 8. Price Lookup612# ---------------------------------------------------------------------------613 614def cmd_price(args):615 """Quick price lookup for a token by mint address or known symbol."""616 query = args.token617 618 # Check if it's a known symbol619 mint = _SYMBOL_TO_MINT.get(query.upper(), query)620 621 # Try to resolve name622 token_meta = resolve_token_name(mint)623 624 # Fetch price625 prices = fetch_prices([mint])626 627 out = {"query": query, "mint": mint}628 if token_meta:629 out["name"] = token_meta["name"]630 out["symbol"] = token_meta["symbol"]631 if mint in prices:632 out["price_usd"] = prices[mint]633 else:634 out["price_usd"] = None635 out["note"] = "Price not available — token may not be listed on CoinGecko."636 print_json(out)637 638 639# ---------------------------------------------------------------------------640# CLI641# ---------------------------------------------------------------------------642 643def main():644 parser = argparse.ArgumentParser(645 prog="solana_client.py",646 description="Solana blockchain query tool for Hermes Agent",647 )648 sub = parser.add_subparsers(dest="command", required=True)649 650 sub.add_parser("stats", help="Network stats: slot, epoch, TPS, supply, SOL price")651 652 p_wallet = sub.add_parser("wallet", help="SOL balance + SPL tokens with USD values")653 p_wallet.add_argument("address")654 p_wallet.add_argument("--limit", type=int, default=20,655 help="Max tokens to display (default: 20)")656 p_wallet.add_argument("--all", action="store_true",657 help="Show all tokens (no limit, no dust filter)")658 p_wallet.add_argument("--no-prices", action="store_true",659 help="Skip price lookups (faster, RPC-only)")660 661 p_tx = sub.add_parser("tx", help="Transaction details by signature")662 p_tx.add_argument("signature")663 664 p_token = sub.add_parser("token", help="SPL token metadata, price, and top holders")665 p_token.add_argument("mint")666 667 p_activity = sub.add_parser("activity", help="Recent transactions for an address")668 p_activity.add_argument("address")669 p_activity.add_argument("--limit", type=int, default=10,670 help="Number of transactions (max 25, default 10)")671 672 p_nft = sub.add_parser("nft", help="NFT portfolio for a wallet")673 p_nft.add_argument("address")674 675 p_whales = sub.add_parser("whales", help="Large SOL transfers in the latest block")676 p_whales.add_argument("--min-sol", type=float, default=1000.0,677 help="Minimum SOL transfer size (default: 1000)")678 679 p_price = sub.add_parser("price", help="Quick price lookup by mint or symbol")680 p_price.add_argument("token", help="Mint address or known symbol (SOL, BONK, JUP, ...)")681 682 args = parser.parse_args()683 684 dispatch = {685 "stats": cmd_stats,686 "wallet": cmd_wallet,687 "tx": cmd_tx,688 "token": cmd_token,689 "activity": cmd_activity,690 "nft": cmd_nft,691 "whales": cmd_whales,692 "price": cmd_price,693 }694 dispatch[args.command](args)695 696 697if __name__ == "__main__":698 main()699