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:
parent
2394e18729
commit
7185a66b96
2 changed files with 445 additions and 160 deletions
|
|
@ -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'])"
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue