diff --git a/skills/blockchain/solana/SKILL.md b/skills/blockchain/solana/SKILL.md new file mode 100644 index 000000000..3731ef13f --- /dev/null +++ b/skills/blockchain/solana/SKILL.md @@ -0,0 +1,205 @@ +--- +name: solana +description: Query Solana blockchain data — wallet balances, SPL token holdings, transaction details, NFT portfolios, whale detection, and live network stats via public Solana RPC API. No API key required for basic usage. +version: 0.1.0 +author: Deniz Alagoz (gizdusum) +license: MIT +metadata: + hermes: + tags: [Solana, Blockchain, Crypto, Web3, RPC, DeFi, NFT] + related_skills: [] +--- + +# Solana Blockchain Skill + +Query Solana on-chain data using the public Solana JSON-RPC API. +Includes 7 intelligence tools: wallet info, transactions, token metadata, +recent activity, NFT portfolios, whale detection, and network stats. + +No API key needed for mainnet public endpoint. +For high-volume use, set SOLANA_RPC_URL to a private RPC (Helius, QuickNode, etc.). + +--- + +## When to Use + +- User asks for a Solana wallet balance or token holdings +- User wants to inspect a specific transaction by signature +- User wants SPL token metadata, supply, or top holders +- User wants recent transaction history for an address +- User wants NFTs owned by a wallet +- User wants to find large SOL transfers (whale detection) +- User wants Solana network health, TPS, epoch, or slot info + +--- + +## Prerequisites + +The helper script uses only Python standard library (urllib, json, argparse). +No external packages required for basic operation. + +Optional: httpx (faster async I/O) and base58 (address validation). +Install via your project's dependency manager before use if needed. + +--- + +## Quick Reference + +RPC endpoint (default): https://api.mainnet-beta.solana.com +Override: export SOLANA_RPC_URL=https://your-private-rpc.com + +Helper script path: ~/.hermes/skills/blockchain/solana/scripts/solana_client.py + + 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] + python3 solana_client.py stats + +--- + +## Procedure + +### 0. Setup Check + +```bash +# Verify Python 3 is available +python3 --version + +# Optional: set a private RPC for better rate limits +export SOLANA_RPC_URL="https://api.mainnet-beta.solana.com" + +# Confirm connectivity +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py stats +``` + +### 1. Wallet Info + +Get SOL balance and all SPL token holdings for an address. + +```bash +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ + wallet 9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM +``` + +Output: SOL balance (human readable), list of SPL tokens with mint + amount. + +### 2. Transaction Details + +Inspect a full transaction by its base58 signature. + +```bash +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ + tx 5j7s8K...your_signature_here +``` + +Output: slot, timestamp, fee, status, balance changes, program invocations. + +### 3. Token Info + +Get SPL token metadata, supply, decimals, mint/freeze authorities, top holders. + +```bash +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ + token DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263 +``` + +Output: decimals, supply (human readable), top 5 holders and their percentages. + +### 4. Recent Activity + +List recent transactions for an address (default: last 10, max: 25). + +```bash +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ + activity 9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM --limit 25 +``` + +Output: list of transaction signatures with slot and timestamp. + +### 5. NFT Portfolio + +List NFTs owned by a wallet (heuristic: SPL tokens with amount=1, decimals=0). + +```bash +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ + nft 9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM +``` + +Output: list of NFT mint addresses. +Note: Compressed NFTs (cNFTs) are not detected by this heuristic. + +### 6. Whale Detector + +Scan the most recent block for large SOL transfers (default threshold: 1000 SOL). + +```bash +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ + whales --min-sol 500 +``` + +Output: list of large transfers with sender, receiver, amount in SOL. +Note: scans the latest block only — point-in-time snapshot. + +### 7. Network Stats + +Live Solana network health: current slot, epoch, TPS, supply, validator version. + +```bash +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ + stats +``` + +Output: slot, epoch, transactions per second, total/circulating supply, node version. + +--- + +## Raw curl Examples (no script needed) + +SOL balance: +```bash +curl -s https://api.mainnet-beta.solana.com \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0","id":1,"method":"getBalance", + "params":["9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"] + }' | python3 -c " +import sys,json +r=json.load(sys.stdin) +lamports=r['result']['value'] +print(f'Balance: {lamports/1e9:.4f} SOL') +" +``` + +Network slot check: +```bash +curl -s https://api.mainnet-beta.solana.com \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"getSlot"}' \ + | python3 -c "import sys,json; print('Slot:', json.load(sys.stdin)['result'])" +``` + +--- + +## Pitfalls + +- Public RPC rate-limits apply. For production use, get a private endpoint (Helius, QuickNode, Triton). +- NFT detection is heuristic (amount=1, decimals=0). Compressed NFTs (cNFTs) won't appear. +- Transactions older than ~2 days may not be on the public RPC history. +- Whale detector scans only the latest block; old large transfers won't show. +- Token supply is a raw integer — divide by 10^decimals for human-readable value. +- Some RPC methods (e.g. getTokenLargestAccounts) may require commitment=finalized. + +--- + +## Verification + +```bash +# Should print current Solana slot number if RPC is reachable +curl -s https://api.mainnet-beta.solana.com \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"getSlot"}' \ + | python3 -c "import sys,json; r=json.load(sys.stdin); print('OK, slot:', r['result'])" +``` diff --git a/skills/blockchain/solana/scripts/solana_client.py b/skills/blockchain/solana/scripts/solana_client.py new file mode 100644 index 000000000..11ca9213c --- /dev/null +++ b/skills/blockchain/solana/scripts/solana_client.py @@ -0,0 +1,415 @@ +#!/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()