chore: move Solana skill to optional-skills/

Solana blockchain queries are a niche use case — not needed by every user.
Moved from skills/ (bundled) to optional-skills/ (installable via Skills Hub).
This commit is contained in:
teknium1 2026-03-08 18:52:02 -07:00
parent 93c5997290
commit 99f7582175
2 changed files with 0 additions and 0 deletions

View file

@ -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 <address>
python3 solana_client.py tx <signature>
python3 solana_client.py token <mint_address>
python3 solana_client.py activity <address> [--limit N]
python3 solana_client.py nft <address>
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()