From 7185a66b9662b4dcc806a8cdb7792471d748e0fb Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sun, 8 Mar 2026 19:15:11 -0700 Subject: [PATCH] feat: enhance Solana skill with USD pricing, token names, smart wallet output Enhancements to the Solana blockchain skill (PR #212 by gizdusum): - CoinGecko price integration (free, no API key) - Wallet shows tokens with USD values, sorted by value - Token info includes price and market cap - Transaction details show USD amounts for balance changes - Whale detector shows USD alongside SOL amounts - Stats includes SOL price and market cap - New `price` command for quick lookups by symbol or mint - Smart wallet output - Tokens sorted by USD value (highest first) - Default limit of 20 tokens (--limit N to adjust) - Dust filtering (< $0.01 tokens hidden, count shown) - --all flag to see everything - --no-prices flag for fast RPC-only mode - NFT summary (count + first 10) - Portfolio total in USD - Token name resolution - 25+ well-known tokens mapped (SOL, USDC, BONK, JUP, etc.) - CoinGecko fallback for unknown tokens - Abbreviated mint addresses for unlabeled tokens - Reliability - Retry with exponential backoff on 429 rate-limit (RPC + CoinGecko) - Graceful degradation when price data unavailable - Capped API calls to respect CoinGecko free-tier limits - Updated SKILL.md with all new capabilities and flags --- optional-skills/blockchain/solana/SKILL.md | 146 +++--- .../solana/scripts/solana_client.py | 459 ++++++++++++++---- 2 files changed, 445 insertions(+), 160 deletions(-) diff --git a/optional-skills/blockchain/solana/SKILL.md b/optional-skills/blockchain/solana/SKILL.md index 3731ef13f..59b988392 100644 --- a/optional-skills/blockchain/solana/SKILL.md +++ b/optional-skills/blockchain/solana/SKILL.md @@ -1,8 +1,8 @@ --- 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) +description: Query Solana blockchain data with USD pricing — wallet balances, token portfolios with values, transaction details, NFTs, whale detection, and live network stats. Uses Solana RPC + CoinGecko. No API key required. +version: 0.2.0 +author: Deniz Alagoz (gizdusum), enhanced by Hermes Agent license: MIT metadata: hermes: @@ -12,34 +12,34 @@ metadata: # 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. +Query Solana on-chain data enriched with USD pricing via CoinGecko. +8 commands: wallet portfolio, token info, transactions, activity, NFTs, +whale detection, network stats, and price lookup. -No API key needed for mainnet public endpoint. -For high-volume use, set SOLANA_RPC_URL to a private RPC (Helius, QuickNode, etc.). +No API key needed. Uses only Python standard library (urllib, json, argparse). --- ## When to Use -- User asks for a Solana wallet balance or token holdings +- User asks for a Solana wallet balance, token holdings, or portfolio value - User wants to inspect a specific transaction by signature -- User wants SPL token metadata, supply, or top holders +- User wants SPL token metadata, price, 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 +- User wants Solana network health, TPS, epoch, or SOL price +- User asks "what's the price of BONK/JUP/SOL?" --- ## Prerequisites The helper script uses only Python standard library (urllib, json, argparse). -No external packages required for basic operation. +No external packages required. -Optional: httpx (faster async I/O) and base58 (address validation). -Install via your project's dependency manager before use if needed. +Pricing data comes from CoinGecko's free API (no key needed, rate-limited +to ~10-30 requests/minute). For faster lookups, use `--no-prices` flag. --- @@ -50,13 +50,16 @@ 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 +``` +python3 solana_client.py wallet
[--limit N] [--all] [--no-prices] +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 +python3 solana_client.py price +``` --- @@ -65,7 +68,6 @@ Helper script path: ~/.hermes/skills/blockchain/solana/scripts/solana_client.py ### 0. Setup Check ```bash -# Verify Python 3 is available python3 --version # Optional: set a private RPC for better rate limits @@ -75,38 +77,50 @@ export SOLANA_RPC_URL="https://api.mainnet-beta.solana.com" python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py stats ``` -### 1. Wallet Info +### 1. Wallet Portfolio -Get SOL balance and all SPL token holdings for an address. +Get SOL balance, SPL token holdings with USD values, NFT count, and +portfolio total. Tokens sorted by value, dust filtered, known tokens +labeled by name (BONK, JUP, USDC, etc.). ```bash python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ wallet 9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM ``` -Output: SOL balance (human readable), list of SPL tokens with mint + amount. +Flags: +- `--limit N` — show top N tokens (default: 20) +- `--all` — show all tokens, no dust filter, no limit +- `--no-prices` — skip CoinGecko price lookups (faster, RPC-only) + +Output includes: SOL balance + USD value, token list with prices sorted +by value, dust count, NFT summary, total portfolio value in USD. ### 2. Transaction Details -Inspect a full transaction by its base58 signature. +Inspect a full transaction by its base58 signature. Shows balance changes +in both SOL and USD. ```bash python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ tx 5j7s8K...your_signature_here ``` -Output: slot, timestamp, fee, status, balance changes, program invocations. +Output: slot, timestamp, fee, status, balance changes (SOL + USD), +program invocations. ### 3. Token Info -Get SPL token metadata, supply, decimals, mint/freeze authorities, top holders. +Get SPL token metadata, current price, market cap, supply, decimals, +mint/freeze authorities, and top 5 holders. ```bash python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ token DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263 ``` -Output: decimals, supply (human readable), top 5 holders and their percentages. +Output: name, symbol, decimals, supply, price, market cap, top 5 +holders with percentages. ### 4. Recent Activity @@ -117,8 +131,6 @@ 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). @@ -128,78 +140,68 @@ 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). +Scan the most recent block for large SOL transfers with USD values. ```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. +Note: scans the latest block only — point-in-time snapshot, not historical. ### 7. Network Stats -Live Solana network health: current slot, epoch, TPS, supply, validator version. +Live Solana network health: current slot, epoch, TPS, supply, validator +version, SOL price, and market cap. ```bash -python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ - stats +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py stats ``` -Output: slot, epoch, transactions per second, total/circulating supply, node version. +### 8. Price Lookup ---- +Quick price check for any token by mint address or known symbol. -## 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') -" +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py price BONK +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py price JUP +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py price SOL +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py price DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263 ``` -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'])" -``` +Known symbols: SOL, USDC, USDT, BONK, JUP, WETH, JTO, mSOL, stSOL, +PYTH, HNT, RNDR, WEN, W, TNSR, DRIFT, bSOL, JLP, WIF, MEW, BOME, PENGU. --- ## 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. +- **CoinGecko rate-limits** — free tier allows ~10-30 requests/minute. + Price lookups use 1 request per token. Wallets with many tokens may + not get prices for all of them. Use `--no-prices` for speed. +- **Public RPC rate-limits** — Solana mainnet public RPC limits requests. + For production use, set SOLANA_RPC_URL to a private endpoint + (Helius, QuickNode, Triton). +- **NFT detection is heuristic** — amount=1 + decimals=0. Compressed + NFTs (cNFTs) and Token-2022 NFTs won't appear. +- **Whale detector scans latest block only** — not historical. Results + vary by the moment you query. +- **Transaction history** — public RPC keeps ~2 days. Older transactions + may not be available. +- **Token names** — ~25 well-known tokens are labeled by name. Others + show abbreviated mint addresses. Use the `token` command for full info. +- **Retry on 429** — both RPC and CoinGecko calls retry up to 2 times + with exponential backoff on rate-limit errors. --- ## 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'])" +# Should print current Solana slot, TPS, and SOL price +python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py stats ``` diff --git a/optional-skills/blockchain/solana/scripts/solana_client.py b/optional-skills/blockchain/solana/scripts/solana_client.py index 11ca9213c..7a1cc91e4 100644 --- a/optional-skills/blockchain/solana/scripts/solana_client.py +++ b/optional-skills/blockchain/solana/scripts/solana_client.py @@ -2,17 +2,18 @@ """ Solana Blockchain CLI Tool for Hermes Agent -------------------------------------------- -Queries the Solana JSON-RPC API using only Python standard library. -No external packages required. +Queries the Solana JSON-RPC API and CoinGecko for enriched on-chain data. +Uses only Python standard library — no external packages required. Usage: python3 solana_client.py stats - python3 solana_client.py wallet
+ python3 solana_client.py wallet
[--limit N] [--all] [--no-prices] 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 price Environment: SOLANA_RPC_URL Override the default RPC endpoint (default: mainnet-beta public) @@ -22,65 +23,134 @@ import argparse import json import os import sys +import time import urllib.request import urllib.error -from typing import Any +from typing import Any, Dict, List, Optional RPC_URL = os.environ.get( "SOLANA_RPC_URL", - "https://api.mainnet-beta.solana.com" + "https://api.mainnet-beta.solana.com", ) LAMPORTS_PER_SOL = 1_000_000_000 +# Well-known Solana token names — avoids API calls for common tokens. +# Maps mint address → (symbol, name). +KNOWN_TOKENS: Dict[str, tuple] = { + "So11111111111111111111111111111111111111112": ("SOL", "Solana"), + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": ("USDC", "USD Coin"), + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB": ("USDT", "Tether"), + "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263": ("BONK", "Bonk"), + "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN": ("JUP", "Jupiter"), + "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs": ("WETH", "Wrapped Ether"), + "jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL": ("JTO", "Jito"), + "mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So": ("mSOL", "Marinade Staked SOL"), + "7dHbWXmci3dT8UFYWYZweBLXgycu7Y3iL6trKn1Y7ARj": ("stSOL", "Lido Staked SOL"), + "HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3": ("PYTH", "Pyth Network"), + "RLBxxFkseAZ4RgJH3Sqn8jXxhmGoz9jWxDNJMh8pL7a": ("RLBB", "Rollbit"), + "hntyVP6YFm1Hg25TN9WGLqM12b8TQmcknKrdu1oxWux": ("HNT", "Helium"), + "rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof": ("RNDR", "Render"), + "WENWENvqqNya429ubCdR81ZmD69brwQaaBYY6p91oHQQ": ("WEN", "Wen"), + "85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ": ("W", "Wormhole"), + "TNSRxcUxoT9xBG3de7PiJyTDYu7kskLqcpddxnEJAS6": ("TNSR", "Tensor"), + "DriFtupJYLTosbwoN8koMbEYSx54aFAVLddWsbksjwg7": ("DRIFT", "Drift"), + "bSo13r4TkiE4KumL71LsHTPpL2euBYLFx6h9HP3piy1": ("bSOL", "BlazeStake Staked SOL"), + "27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4": ("JLP", "Jupiter LP"), + "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm": ("WIF", "dogwifhat"), + "MEW1gQWJ3nEXg2qgERiKu7FAFj79PHvQVREQUzScPP5": ("MEW", "cat in a dogs world"), + "ukHH6c7mMyiWCf1b9pnWe25TSpkDDt3H5pQZgZ74J82": ("BOME", "Book of Meme"), + "A8C3xuqscfmyLrte3VwJvtPHXvcSN3FjDbUaSMAkQrCS": ("PENGU", "Pudgy Penguins"), +} + +# Reverse lookup: symbol → mint (for the `price` command). +_SYMBOL_TO_MINT = {v[0].upper(): k for k, v in KNOWN_TOKENS.items()} + # --------------------------------------------------------------------------- -# RPC helpers +# HTTP / RPC helpers # --------------------------------------------------------------------------- -def rpc(method: str, params: list = None) -> Any: - """Send a JSON-RPC request and return the result field.""" +def _http_get_json(url: str, timeout: int = 10, retries: int = 2) -> Any: + """GET JSON from a URL with retry on 429 rate-limit. Returns parsed JSON or None.""" + for attempt in range(retries + 1): + req = urllib.request.Request( + url, headers={"Accept": "application/json", "User-Agent": "HermesAgent/1.0"}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.load(resp) + except urllib.error.HTTPError as exc: + if exc.code == 429 and attempt < retries: + time.sleep(2.0 * (attempt + 1)) + continue + return None + except Exception: + return None + return None + + +def _rpc_call(method: str, params: list = None, retries: int = 2) -> Any: + """Send a JSON-RPC request with retry on 429 rate-limit.""" payload = json.dumps({ - "jsonrpc": "2.0", - "id": 1, - "method": method, - "params": params or [], + "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}") + for attempt in range(retries + 1): + req = urllib.request.Request( + RPC_URL, data=payload, + headers={"Content-Type": "application/json"}, method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + body = json.load(resp) + if "error" in body: + err = body["error"] + # Rate-limit: retry after delay + if isinstance(err, dict) and err.get("code") == 429: + if attempt < retries: + time.sleep(1.5 * (attempt + 1)) + continue + sys.exit(f"RPC error: {err}") + return body.get("result") + except urllib.error.HTTPError as exc: + if exc.code == 429 and attempt < retries: + time.sleep(1.5 * (attempt + 1)) + continue + sys.exit(f"RPC HTTP error: {exc}") + except urllib.error.URLError as exc: + sys.exit(f"RPC connection error: {exc}") + return None - if "error" in body: - sys.exit(f"RPC error: {body['error']}") - return body.get("result") + +# Keep backward compat — the rest of the code uses `rpc()`. +rpc = _rpc_call def rpc_batch(calls: list) -> list: - """Send a batch of JSON-RPC requests.""" + """Send a batch of JSON-RPC requests (with retry on 429).""" 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}") + + for attempt in range(3): + req = urllib.request.Request( + RPC_URL, data=payload, + headers={"Content-Type": "application/json"}, method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + return json.load(resp) + except urllib.error.HTTPError as exc: + if exc.code == 429 and attempt < 2: + time.sleep(1.5 * (attempt + 1)) + continue + sys.exit(f"RPC batch HTTP error: {exc}") + except urllib.error.URLError as exc: + sys.exit(f"RPC batch error: {exc}") + return [] def lamports_to_sol(lamports: int) -> float: @@ -91,12 +161,80 @@ def print_json(obj: Any) -> None: print(json.dumps(obj, indent=2)) +def _short_mint(mint: str) -> str: + """Abbreviate a mint address for display: first 4 + last 4.""" + if len(mint) <= 12: + return mint + return f"{mint[:4]}...{mint[-4:]}" + + +# --------------------------------------------------------------------------- +# Price & token name helpers (CoinGecko — free, no API key) +# --------------------------------------------------------------------------- + +def fetch_prices(mints: List[str], max_lookups: int = 20) -> Dict[str, float]: + """Fetch USD prices for mint addresses via CoinGecko (one per request). + + CoinGecko free tier doesn't support batch Solana token lookups, + so we do individual calls — capped at *max_lookups* to stay within + rate limits. Returns {mint: usd_price}. + """ + prices: Dict[str, float] = {} + for i, mint in enumerate(mints[:max_lookups]): + url = ( + f"https://api.coingecko.com/api/v3/simple/token_price/solana" + f"?contract_addresses={mint}&vs_currencies=usd" + ) + data = _http_get_json(url, timeout=10) + if data and isinstance(data, dict): + for addr, info in data.items(): + if isinstance(info, dict) and "usd" in info: + prices[mint] = info["usd"] + break + # Pause between calls to respect CoinGecko free-tier rate-limits + if i < len(mints[:max_lookups]) - 1: + time.sleep(1.0) + return prices + + +def fetch_sol_price() -> Optional[float]: + """Fetch current SOL price in USD via CoinGecko.""" + data = _http_get_json( + "https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd" + ) + if data and "solana" in data: + return data["solana"].get("usd") + return None + + +def resolve_token_name(mint: str) -> Optional[Dict[str, str]]: + """Look up token name and symbol from CoinGecko by mint address. + + Returns {"name": ..., "symbol": ...} or None. + """ + if mint in KNOWN_TOKENS: + sym, name = KNOWN_TOKENS[mint] + return {"symbol": sym, "name": name} + url = f"https://api.coingecko.com/api/v3/coins/solana/contract/{mint}" + data = _http_get_json(url, timeout=10) + if data and "symbol" in data: + return {"symbol": data["symbol"].upper(), "name": data.get("name", "")} + return None + + +def _token_label(mint: str) -> str: + """Return a human-readable label for a mint: symbol if known, else abbreviated address.""" + if mint in KNOWN_TOKENS: + return KNOWN_TOKENS[mint][0] + return _short_mint(mint) + + # --------------------------------------------------------------------------- # 1. Network Stats # --------------------------------------------------------------------------- def cmd_stats(_args): - """Live Solana network: slot, epoch, TPS, supply, version.""" + """Live Solana network: slot, epoch, TPS, supply, version, SOL price.""" results = rpc_batch([ {"method": "getSlot"}, {"method": "getEpochInfo"}, @@ -107,11 +245,11 @@ def cmd_stats(_args): 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] + slot = by_id.get(0) + epoch_info = by_id.get(1) + perf_samples = by_id.get(2) + supply = by_id.get(3) + version = by_id.get(4) tps = None if perf_samples: @@ -121,51 +259,134 @@ def cmd_stats(_args): 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, - }) + sol_price = fetch_sol_price() + + out = { + "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, + } + if sol_price is not None: + out["sol_price_usd"] = sol_price + if circ_supply: + out["market_cap_usd"] = round(sol_price * circ_supply, 0) + print_json(out) # --------------------------------------------------------------------------- -# 2. Wallet Info +# 2. Wallet Info (enhanced with prices, sorting, filtering) # --------------------------------------------------------------------------- def cmd_wallet(args): - """SOL balance + SPL token accounts for an address.""" + """SOL balance + SPL token holdings with USD values.""" address = args.address + show_all = getattr(args, "all", False) + limit = getattr(args, "limit", 20) or 20 + skip_prices = getattr(args, "no_prices", False) + # Fetch SOL balance balance_result = rpc("getBalance", [address]) sol_balance = lamports_to_sol(balance_result["value"]) + # Fetch all SPL token accounts token_result = rpc("getTokenAccountsByOwner", [ address, {"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"}, {"encoding": "jsonParsed"}, ]) - tokens = [] + raw_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) + ta = info["tokenAmount"] + amount = float(ta.get("uiAmountString") or 0) if amount > 0: - tokens.append({ + raw_tokens.append({ "mint": info["mint"], "amount": amount, - "decimals": token_amount["decimals"], + "decimals": ta["decimals"], }) - print_json({ + # Separate NFTs (amount=1, decimals=0) from fungible tokens + nfts = [t for t in raw_tokens if t["decimals"] == 0 and t["amount"] == 1] + fungible = [t for t in raw_tokens if not (t["decimals"] == 0 and t["amount"] == 1)] + + # Fetch prices for fungible tokens (cap lookups to avoid API abuse) + sol_price = None + prices: Dict[str, float] = {} + if not skip_prices and fungible: + sol_price = fetch_sol_price() + # Prioritize known tokens, then a small sample of unknowns. + # CoinGecko free tier = 1 request per mint, so we cap lookups. + known_mints = [t["mint"] for t in fungible if t["mint"] in KNOWN_TOKENS] + other_mints = [t["mint"] for t in fungible if t["mint"] not in KNOWN_TOKENS][:15] + mints_to_price = known_mints + other_mints + if mints_to_price: + prices = fetch_prices(mints_to_price, max_lookups=30) + + # Enrich tokens with labels and USD values + enriched = [] + dust_count = 0 + dust_value = 0.0 + for t in fungible: + mint = t["mint"] + label = _token_label(mint) + usd_price = prices.get(mint) + usd_value = round(usd_price * t["amount"], 2) if usd_price else None + + # Filter dust (< $0.01) unless --all + if not show_all and usd_value is not None and usd_value < 0.01: + dust_count += 1 + dust_value += usd_value + continue + + entry = {"token": label, "mint": mint, "amount": t["amount"]} + if usd_price is not None: + entry["price_usd"] = usd_price + entry["value_usd"] = usd_value + enriched.append(entry) + + # Sort: tokens with known USD value first (highest→lowest), then unknowns + enriched.sort(key=lambda x: (x.get("value_usd") is not None, x.get("value_usd") or 0), reverse=True) + + # Apply limit unless --all + total_tokens = len(enriched) + if not show_all and len(enriched) > limit: + enriched = enriched[:limit] + + # Compute portfolio total + total_usd = sum(t.get("value_usd", 0) for t in enriched) + sol_value_usd = round(sol_price * sol_balance, 2) if sol_price else None + if sol_value_usd: + total_usd += sol_value_usd + total_usd += dust_value + + output = { "address": address, - "balance_SOL": round(sol_balance, 9), - "spl_tokens": tokens, - }) + "sol_balance": round(sol_balance, 9), + } + if sol_price: + output["sol_price_usd"] = sol_price + output["sol_value_usd"] = sol_value_usd + output["tokens_shown"] = len(enriched) + if total_tokens > len(enriched): + output["tokens_hidden"] = total_tokens - len(enriched) + output["spl_tokens"] = enriched + if dust_count > 0: + output["dust_filtered"] = {"count": dust_count, "total_value_usd": round(dust_value, 4)} + output["nft_count"] = len(nfts) + if nfts: + output["nfts"] = [_token_label(n["mint"]) + f" ({_short_mint(n['mint'])})" for n in nfts[:10]] + if len(nfts) > 10: + output["nfts"].append(f"... and {len(nfts) - 10} more") + if total_usd > 0: + output["portfolio_total_usd"] = round(total_usd, 2) + + print_json(output) # --------------------------------------------------------------------------- @@ -206,6 +427,12 @@ def cmd_tx(args): if prog: programs.append(prog) + # Add USD value for SOL changes + sol_price = fetch_sol_price() + if sol_price and balance_changes: + for bc in balance_changes: + bc["change_USD"] = round(bc["change_SOL"] * sol_price, 2) + print_json({ "signature": args.signature, "slot": result.get("slot"), @@ -218,23 +445,21 @@ def cmd_tx(args): # --------------------------------------------------------------------------- -# 4. Token Info +# 4. Token Info (enhanced with name + price) # --------------------------------------------------------------------------- def cmd_token(args): - """SPL token metadata, supply, decimals, top holders.""" + """SPL token metadata, supply, decimals, price, 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") + 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) if decimals else supply_raw largest = rpc("getTokenLargestAccounts", [mint]) holders = [] @@ -247,14 +472,24 @@ def cmd_token(args): "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, - }) + # Resolve name + price + token_meta = resolve_token_name(mint) + price_data = fetch_prices([mint]) + + out = {"mint": mint} + if token_meta: + out["name"] = token_meta["name"] + out["symbol"] = token_meta["symbol"] + out["decimals"] = decimals + out["supply"] = round(supply_human, min(decimals, 6)) + out["mint_authority"] = parsed.get("mintAuthority") + out["freeze_authority"] = parsed.get("freezeAuthority") + if mint in price_data: + out["price_usd"] = price_data[mint] + out["market_cap_usd"] = round(price_data[mint] * supply_human, 0) + out["top_5_holders"] = holders + + print_json(out) # --------------------------------------------------------------------------- @@ -307,7 +542,7 @@ def cmd_nft(args): # --------------------------------------------------------------------------- -# 7. Whale Detector +# 7. Whale Detector (enhanced with USD values) # --------------------------------------------------------------------------- def cmd_whales(args): @@ -328,6 +563,8 @@ def cmd_whales(args): if block is None: sys.exit("Could not retrieve latest block.") + sol_price = fetch_sol_price() + whales = [] for tx in (block.get("transactions") or []): meta = tx.get("meta", {}) or {} @@ -350,17 +587,53 @@ def cmd_whales(args): sk = account_keys[j] sender = sk["pubkey"] if isinstance(sk, dict) else sk break - whales.append({ + entry = { "sender": sender, "receiver": receiver, "amount_SOL": round(lamports_to_sol(change), 4), - }) + } + if sol_price: + entry["amount_USD"] = round(lamports_to_sol(change) * sol_price, 2) + whales.append(entry) - print_json({ + out = { "slot": slot, "min_threshold_SOL": args.min_sol, "large_transfers": whales, - }) + "note": "Scans latest block only — point-in-time snapshot.", + } + if sol_price: + out["sol_price_usd"] = sol_price + print_json(out) + + +# --------------------------------------------------------------------------- +# 8. Price Lookup +# --------------------------------------------------------------------------- + +def cmd_price(args): + """Quick price lookup for a token by mint address or known symbol.""" + query = args.token + + # Check if it's a known symbol + mint = _SYMBOL_TO_MINT.get(query.upper(), query) + + # Try to resolve name + token_meta = resolve_token_name(mint) + + # Fetch price + prices = fetch_prices([mint]) + + out = {"query": query, "mint": mint} + if token_meta: + out["name"] = token_meta["name"] + out["symbol"] = token_meta["symbol"] + if mint in prices: + out["price_usd"] = prices[mint] + else: + out["price_usd"] = None + out["note"] = "Price not available — token may not be listed on CoinGecko." + print_json(out) # --------------------------------------------------------------------------- @@ -374,15 +647,21 @@ def main(): ) sub = parser.add_subparsers(dest="command", required=True) - sub.add_parser("stats", help="Network stats: slot, epoch, TPS, supply, version") + sub.add_parser("stats", help="Network stats: slot, epoch, TPS, supply, SOL price") - p_wallet = sub.add_parser("wallet", help="SOL balance + SPL tokens for an address") + p_wallet = sub.add_parser("wallet", help="SOL balance + SPL tokens with USD values") p_wallet.add_argument("address") + p_wallet.add_argument("--limit", type=int, default=20, + help="Max tokens to display (default: 20)") + p_wallet.add_argument("--all", action="store_true", + help="Show all tokens (no limit, no dust filter)") + p_wallet.add_argument("--no-prices", action="store_true", + help="Skip price lookups (faster, RPC-only)") 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 = sub.add_parser("token", help="SPL token metadata, price, and top holders") p_token.add_argument("mint") p_activity = sub.add_parser("activity", help="Recent transactions for an address") @@ -397,6 +676,9 @@ def main(): p_whales.add_argument("--min-sol", type=float, default=1000.0, help="Minimum SOL transfer size (default: 1000)") + p_price = sub.add_parser("price", help="Quick price lookup by mint or symbol") + p_price.add_argument("token", help="Mint address or known symbol (SOL, BONK, JUP, ...)") + args = parser.parse_args() dispatch = { @@ -407,6 +689,7 @@ def main(): "activity": cmd_activity, "nft": cmd_nft, "whales": cmd_whales, + "price": cmd_price, } dispatch[args.command](args)