#!/usr/bin/env python3 """ Solana Blockchain CLI Tool for Hermes Agent -------------------------------------------- Queries the Solana JSON-RPC API using only Python standard library. No external packages required. Usage: python3 solana_client.py stats python3 solana_client.py wallet
python3 solana_client.py tx python3 solana_client.py token python3 solana_client.py activity
[--limit N] python3 solana_client.py nft
python3 solana_client.py whales [--min-sol N] Environment: SOLANA_RPC_URL Override the default RPC endpoint (default: mainnet-beta public) """ import argparse import json import os import sys import urllib.request import urllib.error from typing import Any RPC_URL = os.environ.get( "SOLANA_RPC_URL", "https://api.mainnet-beta.solana.com" ) LAMPORTS_PER_SOL = 1_000_000_000 # --------------------------------------------------------------------------- # RPC helpers # --------------------------------------------------------------------------- def rpc(method: str, params: list = None) -> Any: """Send a JSON-RPC request and return the result field.""" payload = json.dumps({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params or [], }).encode() req = urllib.request.Request( RPC_URL, data=payload, headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=15) as resp: body = json.load(resp) except urllib.error.URLError as exc: sys.exit(f"RPC connection error: {exc}") if "error" in body: sys.exit(f"RPC error: {body['error']}") return body.get("result") def rpc_batch(calls: list) -> list: """Send a batch of JSON-RPC requests.""" payload = json.dumps([ {"jsonrpc": "2.0", "id": i, "method": c["method"], "params": c.get("params", [])} for i, c in enumerate(calls) ]).encode() req = urllib.request.Request( RPC_URL, data=payload, headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=15) as resp: return json.load(resp) except urllib.error.URLError as exc: sys.exit(f"RPC batch error: {exc}") def lamports_to_sol(lamports: int) -> float: return lamports / LAMPORTS_PER_SOL def print_json(obj: Any) -> None: print(json.dumps(obj, indent=2)) # --------------------------------------------------------------------------- # 1. Network Stats # --------------------------------------------------------------------------- def cmd_stats(_args): """Live Solana network: slot, epoch, TPS, supply, version.""" results = rpc_batch([ {"method": "getSlot"}, {"method": "getEpochInfo"}, {"method": "getRecentPerformanceSamples", "params": [1]}, {"method": "getSupply"}, {"method": "getVersion"}, ]) by_id = {r["id"]: r.get("result") for r in results} slot = by_id[0] epoch_info = by_id[1] perf_samples = by_id[2] supply = by_id[3] version = by_id[4] tps = None if perf_samples: s = perf_samples[0] tps = round(s["numTransactions"] / s["samplePeriodSecs"], 1) total_supply = lamports_to_sol(supply["value"]["total"]) if supply else None circ_supply = lamports_to_sol(supply["value"]["circulating"]) if supply else None print_json({ "slot": slot, "epoch": epoch_info.get("epoch") if epoch_info else None, "slot_in_epoch": epoch_info.get("slotIndex") if epoch_info else None, "tps": tps, "total_supply_SOL": round(total_supply, 2) if total_supply else None, "circulating_supply_SOL": round(circ_supply, 2) if circ_supply else None, "validator_version": version.get("solana-core") if version else None, }) # --------------------------------------------------------------------------- # 2. Wallet Info # --------------------------------------------------------------------------- def cmd_wallet(args): """SOL balance + SPL token accounts for an address.""" address = args.address balance_result = rpc("getBalance", [address]) sol_balance = lamports_to_sol(balance_result["value"]) token_result = rpc("getTokenAccountsByOwner", [ address, {"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"}, {"encoding": "jsonParsed"}, ]) tokens = [] for acct in (token_result.get("value") or []): info = acct["account"]["data"]["parsed"]["info"] token_amount = info["tokenAmount"] amount = float(token_amount["uiAmountString"] or 0) if amount > 0: tokens.append({ "mint": info["mint"], "amount": amount, "decimals": token_amount["decimals"], }) print_json({ "address": address, "balance_SOL": round(sol_balance, 9), "spl_tokens": tokens, }) # --------------------------------------------------------------------------- # 3. Transaction Details # --------------------------------------------------------------------------- def cmd_tx(args): """Full transaction details by signature.""" result = rpc("getTransaction", [ args.signature, {"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0}, ]) if result is None: sys.exit("Transaction not found (may be too old for public RPC history).") meta = result.get("meta", {}) or {} msg = result.get("transaction", {}).get("message", {}) account_keys = msg.get("accountKeys", []) pre = meta.get("preBalances", []) post = meta.get("postBalances", []) balance_changes = [] for i, key in enumerate(account_keys): acct_key = key["pubkey"] if isinstance(key, dict) else key if i < len(pre) and i < len(post): change = lamports_to_sol(post[i] - pre[i]) if change != 0: balance_changes.append({"account": acct_key, "change_SOL": round(change, 9)}) programs = [] for ix in msg.get("instructions", []): prog = ix.get("programId") if prog is None and "programIdIndex" in ix: k = account_keys[ix["programIdIndex"]] prog = k["pubkey"] if isinstance(k, dict) else k if prog: programs.append(prog) print_json({ "signature": args.signature, "slot": result.get("slot"), "block_time": result.get("blockTime"), "fee_SOL": lamports_to_sol(meta.get("fee", 0)), "status": "success" if meta.get("err") is None else "failed", "balance_changes": balance_changes, "programs_invoked": list(dict.fromkeys(programs)), }) # --------------------------------------------------------------------------- # 4. Token Info # --------------------------------------------------------------------------- def cmd_token(args): """SPL token metadata, supply, decimals, top holders.""" mint = args.mint mint_info = rpc("getAccountInfo", [mint, {"encoding": "jsonParsed"}]) if mint_info is None or mint_info.get("value") is None: sys.exit("Mint account not found.") parsed = mint_info["value"]["data"]["parsed"]["info"] decimals = parsed.get("decimals", 0) supply_raw = int(parsed.get("supply", 0)) supply_human = supply_raw / (10 ** decimals) mint_authority = parsed.get("mintAuthority") freeze_authority = parsed.get("freezeAuthority") largest = rpc("getTokenLargestAccounts", [mint]) holders = [] for acct in (largest.get("value") or [])[:5]: amount = float(acct.get("uiAmountString") or 0) pct = round((amount / supply_human * 100), 4) if supply_human > 0 else 0 holders.append({ "account": acct["address"], "amount": amount, "percent": pct, }) print_json({ "mint": mint, "decimals": decimals, "supply": round(supply_human, decimals), "mint_authority": mint_authority, "freeze_authority": freeze_authority, "top_5_holders": holders, }) # --------------------------------------------------------------------------- # 5. Recent Activity # --------------------------------------------------------------------------- def cmd_activity(args): """Recent transaction signatures for an address.""" limit = min(args.limit, 25) result = rpc("getSignaturesForAddress", [args.address, {"limit": limit}]) txs = [ { "signature": item["signature"], "slot": item.get("slot"), "block_time": item.get("blockTime"), "err": item.get("err"), } for item in (result or []) ] print_json({"address": args.address, "transactions": txs}) # --------------------------------------------------------------------------- # 6. NFT Portfolio # --------------------------------------------------------------------------- def cmd_nft(args): """NFTs owned by a wallet (amount=1 && decimals=0 heuristic).""" result = rpc("getTokenAccountsByOwner", [ args.address, {"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"}, {"encoding": "jsonParsed"}, ]) nfts = [ acct["account"]["data"]["parsed"]["info"]["mint"] for acct in (result.get("value") or []) if acct["account"]["data"]["parsed"]["info"]["tokenAmount"]["decimals"] == 0 and int(acct["account"]["data"]["parsed"]["info"]["tokenAmount"]["amount"]) == 1 ] print_json({ "address": args.address, "nft_count": len(nfts), "nfts": nfts, "note": "Heuristic only. Compressed NFTs (cNFTs) are not detected.", }) # --------------------------------------------------------------------------- # 7. Whale Detector # --------------------------------------------------------------------------- def cmd_whales(args): """Scan the latest block for large SOL transfers.""" min_lamports = int(args.min_sol * LAMPORTS_PER_SOL) slot = rpc("getSlot") block = rpc("getBlock", [ slot, { "encoding": "jsonParsed", "transactionDetails": "full", "maxSupportedTransactionVersion": 0, "rewards": False, }, ]) if block is None: sys.exit("Could not retrieve latest block.") whales = [] for tx in (block.get("transactions") or []): meta = tx.get("meta", {}) or {} if meta.get("err") is not None: continue msg = tx["transaction"].get("message", {}) account_keys = msg.get("accountKeys", []) pre = meta.get("preBalances", []) post = meta.get("postBalances", []) for i in range(len(pre)): change = post[i] - pre[i] if change >= min_lamports: k = account_keys[i] receiver = k["pubkey"] if isinstance(k, dict) else k sender = None for j in range(len(pre)): if pre[j] - post[j] >= min_lamports: sk = account_keys[j] sender = sk["pubkey"] if isinstance(sk, dict) else sk break whales.append({ "sender": sender, "receiver": receiver, "amount_SOL": round(lamports_to_sol(change), 4), }) print_json({ "slot": slot, "min_threshold_SOL": args.min_sol, "large_transfers": whales, }) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser( prog="solana_client.py", description="Solana blockchain query tool for Hermes Agent", ) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("stats", help="Network stats: slot, epoch, TPS, supply, version") p_wallet = sub.add_parser("wallet", help="SOL balance + SPL tokens for an address") p_wallet.add_argument("address") p_tx = sub.add_parser("tx", help="Transaction details by signature") p_tx.add_argument("signature") p_token = sub.add_parser("token", help="SPL token metadata and top holders") p_token.add_argument("mint") p_activity = sub.add_parser("activity", help="Recent transactions for an address") p_activity.add_argument("address") p_activity.add_argument("--limit", type=int, default=10, help="Number of transactions (max 25, default 10)") p_nft = sub.add_parser("nft", help="NFT portfolio for a wallet") p_nft.add_argument("address") p_whales = sub.add_parser("whales", help="Large SOL transfers in the latest block") p_whales.add_argument("--min-sol", type=float, default=1000.0, help="Minimum SOL transfer size (default: 1000)") args = parser.parse_args() dispatch = { "stats": cmd_stats, "wallet": cmd_wallet, "tx": cmd_tx, "token": cmd_token, "activity": cmd_activity, "nft": cmd_nft, "whales": cmd_whales, } dispatch[args.command](args) if __name__ == "__main__": main()