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)