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
This commit is contained in:
teknium1 2026-03-08 19:15:11 -07:00
parent 2394e18729
commit 7185a66b96
2 changed files with 445 additions and 160 deletions

View file

@ -1,8 +1,8 @@
--- ---
name: solana 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. 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.1.0 version: 0.2.0
author: Deniz Alagoz (gizdusum) author: Deniz Alagoz (gizdusum), enhanced by Hermes Agent
license: MIT license: MIT
metadata: metadata:
hermes: hermes:
@ -12,34 +12,34 @@ metadata:
# Solana Blockchain Skill # Solana Blockchain Skill
Query Solana on-chain data using the public Solana JSON-RPC API. Query Solana on-chain data enriched with USD pricing via CoinGecko.
Includes 7 intelligence tools: wallet info, transactions, token metadata, 8 commands: wallet portfolio, token info, transactions, activity, NFTs,
recent activity, NFT portfolios, whale detection, and network stats. whale detection, network stats, and price lookup.
No API key needed for mainnet public endpoint. No API key needed. Uses only Python standard library (urllib, json, argparse).
For high-volume use, set SOLANA_RPC_URL to a private RPC (Helius, QuickNode, etc.).
--- ---
## When to Use ## 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 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 recent transaction history for an address
- User wants NFTs owned by a wallet - User wants NFTs owned by a wallet
- User wants to find large SOL transfers (whale detection) - 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 ## Prerequisites
The helper script uses only Python standard library (urllib, json, argparse). 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). Pricing data comes from CoinGecko's free API (no key needed, rate-limited
Install via your project's dependency manager before use if needed. 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 Helper script path: ~/.hermes/skills/blockchain/solana/scripts/solana_client.py
python3 solana_client.py wallet <address> ```
python3 solana_client.py tx <signature> python3 solana_client.py wallet <address> [--limit N] [--all] [--no-prices]
python3 solana_client.py token <mint_address> python3 solana_client.py tx <signature>
python3 solana_client.py activity <address> [--limit N] python3 solana_client.py token <mint_address>
python3 solana_client.py nft <address> python3 solana_client.py activity <address> [--limit N]
python3 solana_client.py whales [--min-sol N] python3 solana_client.py nft <address>
python3 solana_client.py stats python3 solana_client.py whales [--min-sol N]
python3 solana_client.py stats
python3 solana_client.py price <mint_or_symbol>
```
--- ---
@ -65,7 +68,6 @@ Helper script path: ~/.hermes/skills/blockchain/solana/scripts/solana_client.py
### 0. Setup Check ### 0. Setup Check
```bash ```bash
# Verify Python 3 is available
python3 --version python3 --version
# Optional: set a private RPC for better rate limits # 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 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 ```bash
python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \
wallet 9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM 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 ### 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 ```bash
python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \
tx 5j7s8K...your_signature_here 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 ### 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 ```bash
python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \
token DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263 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 ### 4. Recent Activity
@ -117,8 +131,6 @@ python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \
activity 9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM --limit 25 activity 9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM --limit 25
``` ```
Output: list of transaction signatures with slot and timestamp.
### 5. NFT Portfolio ### 5. NFT Portfolio
List NFTs owned by a wallet (heuristic: SPL tokens with amount=1, decimals=0). 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 nft 9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM
``` ```
Output: list of NFT mint addresses.
Note: Compressed NFTs (cNFTs) are not detected by this heuristic. Note: Compressed NFTs (cNFTs) are not detected by this heuristic.
### 6. Whale Detector ### 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 ```bash
python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \
whales --min-sol 500 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, not historical.
Note: scans the latest block only — point-in-time snapshot.
### 7. Network Stats ### 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 ```bash
python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py \ python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py stats
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 ```bash
curl -s https://api.mainnet-beta.solana.com \ python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py price BONK
-H "Content-Type: application/json" \ python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py price JUP
-d '{ python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py price SOL
"jsonrpc":"2.0","id":1,"method":"getBalance", python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py price DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263
"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: Known symbols: SOL, USDC, USDT, BONK, JUP, WETH, JTO, mSOL, stSOL,
```bash PYTH, HNT, RNDR, WEN, W, TNSR, DRIFT, bSOL, JLP, WIF, MEW, BOME, PENGU.
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 ## Pitfalls
- Public RPC rate-limits apply. For production use, get a private endpoint (Helius, QuickNode, Triton). - **CoinGecko rate-limits** — free tier allows ~10-30 requests/minute.
- NFT detection is heuristic (amount=1, decimals=0). Compressed NFTs (cNFTs) won't appear. Price lookups use 1 request per token. Wallets with many tokens may
- Transactions older than ~2 days may not be on the public RPC history. not get prices for all of them. Use `--no-prices` for speed.
- Whale detector scans only the latest block; old large transfers won't show. - **Public RPC rate-limits** — Solana mainnet public RPC limits requests.
- Token supply is a raw integer — divide by 10^decimals for human-readable value. For production use, set SOLANA_RPC_URL to a private endpoint
- Some RPC methods (e.g. getTokenLargestAccounts) may require commitment=finalized. (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 ## Verification
```bash ```bash
# Should print current Solana slot number if RPC is reachable # Should print current Solana slot, TPS, and SOL price
curl -s https://api.mainnet-beta.solana.com \ python3 ~/.hermes/skills/blockchain/solana/scripts/solana_client.py stats
-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'])"
``` ```

View file

@ -2,17 +2,18 @@
""" """
Solana Blockchain CLI Tool for Hermes Agent Solana Blockchain CLI Tool for Hermes Agent
-------------------------------------------- --------------------------------------------
Queries the Solana JSON-RPC API using only Python standard library. Queries the Solana JSON-RPC API and CoinGecko for enriched on-chain data.
No external packages required. Uses only Python standard library no external packages required.
Usage: Usage:
python3 solana_client.py stats python3 solana_client.py stats
python3 solana_client.py wallet <address> python3 solana_client.py wallet <address> [--limit N] [--all] [--no-prices]
python3 solana_client.py tx <signature> python3 solana_client.py tx <signature>
python3 solana_client.py token <mint_address> python3 solana_client.py token <mint_address>
python3 solana_client.py activity <address> [--limit N] python3 solana_client.py activity <address> [--limit N]
python3 solana_client.py nft <address> python3 solana_client.py nft <address>
python3 solana_client.py whales [--min-sol N] python3 solana_client.py whales [--min-sol N]
python3 solana_client.py price <mint_address_or_symbol>
Environment: Environment:
SOLANA_RPC_URL Override the default RPC endpoint (default: mainnet-beta public) SOLANA_RPC_URL Override the default RPC endpoint (default: mainnet-beta public)
@ -22,65 +23,134 @@ import argparse
import json import json
import os import os
import sys import sys
import time
import urllib.request import urllib.request
import urllib.error import urllib.error
from typing import Any from typing import Any, Dict, List, Optional
RPC_URL = os.environ.get( RPC_URL = os.environ.get(
"SOLANA_RPC_URL", "SOLANA_RPC_URL",
"https://api.mainnet-beta.solana.com" "https://api.mainnet-beta.solana.com",
) )
LAMPORTS_PER_SOL = 1_000_000_000 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: def _http_get_json(url: str, timeout: int = 10, retries: int = 2) -> Any:
"""Send a JSON-RPC request and return the result field.""" """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({ payload = json.dumps({
"jsonrpc": "2.0", "jsonrpc": "2.0", "id": 1,
"id": 1, "method": method, "params": params or [],
"method": method,
"params": params or [],
}).encode() }).encode()
req = urllib.request.Request( for attempt in range(retries + 1):
RPC_URL, req = urllib.request.Request(
data=payload, RPC_URL, data=payload,
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"}, method="POST",
method="POST", )
) try:
try: with urllib.request.urlopen(req, timeout=20) as resp:
with urllib.request.urlopen(req, timeout=15) as resp: body = json.load(resp)
body = json.load(resp) if "error" in body:
except urllib.error.URLError as exc: err = body["error"]
sys.exit(f"RPC connection error: {exc}") # 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']}") # Keep backward compat — the rest of the code uses `rpc()`.
return body.get("result") rpc = _rpc_call
def rpc_batch(calls: list) -> list: 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([ payload = json.dumps([
{"jsonrpc": "2.0", "id": i, "method": c["method"], "params": c.get("params", [])} {"jsonrpc": "2.0", "id": i, "method": c["method"], "params": c.get("params", [])}
for i, c in enumerate(calls) for i, c in enumerate(calls)
]).encode() ]).encode()
req = urllib.request.Request(
RPC_URL, for attempt in range(3):
data=payload, req = urllib.request.Request(
headers={"Content-Type": "application/json"}, RPC_URL, data=payload,
method="POST", headers={"Content-Type": "application/json"}, method="POST",
) )
try: try:
with urllib.request.urlopen(req, timeout=15) as resp: with urllib.request.urlopen(req, timeout=20) as resp:
return json.load(resp) return json.load(resp)
except urllib.error.URLError as exc: except urllib.error.HTTPError as exc:
sys.exit(f"RPC batch error: {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: def lamports_to_sol(lamports: int) -> float:
@ -91,12 +161,80 @@ def print_json(obj: Any) -> None:
print(json.dumps(obj, indent=2)) 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 # 1. Network Stats
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def cmd_stats(_args): 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([ results = rpc_batch([
{"method": "getSlot"}, {"method": "getSlot"},
{"method": "getEpochInfo"}, {"method": "getEpochInfo"},
@ -107,11 +245,11 @@ def cmd_stats(_args):
by_id = {r["id"]: r.get("result") for r in results} by_id = {r["id"]: r.get("result") for r in results}
slot = by_id[0] slot = by_id.get(0)
epoch_info = by_id[1] epoch_info = by_id.get(1)
perf_samples = by_id[2] perf_samples = by_id.get(2)
supply = by_id[3] supply = by_id.get(3)
version = by_id[4] version = by_id.get(4)
tps = None tps = None
if perf_samples: if perf_samples:
@ -121,51 +259,134 @@ def cmd_stats(_args):
total_supply = lamports_to_sol(supply["value"]["total"]) if supply else None total_supply = lamports_to_sol(supply["value"]["total"]) if supply else None
circ_supply = lamports_to_sol(supply["value"]["circulating"]) if supply else None circ_supply = lamports_to_sol(supply["value"]["circulating"]) if supply else None
print_json({ sol_price = fetch_sol_price()
"slot": slot,
"epoch": epoch_info.get("epoch") if epoch_info else None, out = {
"slot_in_epoch": epoch_info.get("slotIndex") if epoch_info else None, "slot": slot,
"tps": tps, "epoch": epoch_info.get("epoch") if epoch_info else None,
"total_supply_SOL": round(total_supply, 2) if total_supply else None, "slot_in_epoch": epoch_info.get("slotIndex") if epoch_info else None,
"circulating_supply_SOL": round(circ_supply, 2) if circ_supply else None, "tps": tps,
"validator_version": version.get("solana-core") if version else None, "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): def cmd_wallet(args):
"""SOL balance + SPL token accounts for an address.""" """SOL balance + SPL token holdings with USD values."""
address = args.address 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]) balance_result = rpc("getBalance", [address])
sol_balance = lamports_to_sol(balance_result["value"]) sol_balance = lamports_to_sol(balance_result["value"])
# Fetch all SPL token accounts
token_result = rpc("getTokenAccountsByOwner", [ token_result = rpc("getTokenAccountsByOwner", [
address, address,
{"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"}, {"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},
{"encoding": "jsonParsed"}, {"encoding": "jsonParsed"},
]) ])
tokens = [] raw_tokens = []
for acct in (token_result.get("value") or []): for acct in (token_result.get("value") or []):
info = acct["account"]["data"]["parsed"]["info"] info = acct["account"]["data"]["parsed"]["info"]
token_amount = info["tokenAmount"] ta = info["tokenAmount"]
amount = float(token_amount["uiAmountString"] or 0) amount = float(ta.get("uiAmountString") or 0)
if amount > 0: if amount > 0:
tokens.append({ raw_tokens.append({
"mint": info["mint"], "mint": info["mint"],
"amount": amount, "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, "address": address,
"balance_SOL": round(sol_balance, 9), "sol_balance": round(sol_balance, 9),
"spl_tokens": tokens, }
}) 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: if prog:
programs.append(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({ print_json({
"signature": args.signature, "signature": args.signature,
"slot": result.get("slot"), "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): def cmd_token(args):
"""SPL token metadata, supply, decimals, top holders.""" """SPL token metadata, supply, decimals, price, top holders."""
mint = args.mint mint = args.mint
mint_info = rpc("getAccountInfo", [mint, {"encoding": "jsonParsed"}]) mint_info = rpc("getAccountInfo", [mint, {"encoding": "jsonParsed"}])
if mint_info is None or mint_info.get("value") is None: if mint_info is None or mint_info.get("value") is None:
sys.exit("Mint account not found.") sys.exit("Mint account not found.")
parsed = mint_info["value"]["data"]["parsed"]["info"] parsed = mint_info["value"]["data"]["parsed"]["info"]
decimals = parsed.get("decimals", 0) decimals = parsed.get("decimals", 0)
supply_raw = int(parsed.get("supply", 0)) supply_raw = int(parsed.get("supply", 0))
supply_human = supply_raw / (10 ** decimals) supply_human = supply_raw / (10 ** decimals) if decimals else supply_raw
mint_authority = parsed.get("mintAuthority")
freeze_authority = parsed.get("freezeAuthority")
largest = rpc("getTokenLargestAccounts", [mint]) largest = rpc("getTokenLargestAccounts", [mint])
holders = [] holders = []
@ -247,14 +472,24 @@ def cmd_token(args):
"percent": pct, "percent": pct,
}) })
print_json({ # Resolve name + price
"mint": mint, token_meta = resolve_token_name(mint)
"decimals": decimals, price_data = fetch_prices([mint])
"supply": round(supply_human, decimals),
"mint_authority": mint_authority, out = {"mint": mint}
"freeze_authority": freeze_authority, if token_meta:
"top_5_holders": holders, 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): def cmd_whales(args):
@ -328,6 +563,8 @@ def cmd_whales(args):
if block is None: if block is None:
sys.exit("Could not retrieve latest block.") sys.exit("Could not retrieve latest block.")
sol_price = fetch_sol_price()
whales = [] whales = []
for tx in (block.get("transactions") or []): for tx in (block.get("transactions") or []):
meta = tx.get("meta", {}) or {} meta = tx.get("meta", {}) or {}
@ -350,17 +587,53 @@ def cmd_whales(args):
sk = account_keys[j] sk = account_keys[j]
sender = sk["pubkey"] if isinstance(sk, dict) else sk sender = sk["pubkey"] if isinstance(sk, dict) else sk
break break
whales.append({ entry = {
"sender": sender, "sender": sender,
"receiver": receiver, "receiver": receiver,
"amount_SOL": round(lamports_to_sol(change), 4), "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, "slot": slot,
"min_threshold_SOL": args.min_sol, "min_threshold_SOL": args.min_sol,
"large_transfers": whales, "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 = 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("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 = sub.add_parser("tx", help="Transaction details by signature")
p_tx.add_argument("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_token.add_argument("mint")
p_activity = sub.add_parser("activity", help="Recent transactions for an address") 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, p_whales.add_argument("--min-sol", type=float, default=1000.0,
help="Minimum SOL transfer size (default: 1000)") 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() args = parser.parse_args()
dispatch = { dispatch = {
@ -407,6 +689,7 @@ def main():
"activity": cmd_activity, "activity": cmd_activity,
"nft": cmd_nft, "nft": cmd_nft,
"whales": cmd_whales, "whales": cmd_whales,
"price": cmd_price,
} }
dispatch[args.command](args) dispatch[args.command](args)