rmi-backend/app/cross_chain_whale.py

835 lines
30 KiB
Python

"""
Cross-Chain Whale Tracker
==========================
Track whale wallets across multiple blockchains simultaneously.
Maps cross-chain capital flows, bridge migrations, and multi-network
positioning of top holders. Free-tier data sources only.
TOOL : cross_chain_whale
TIER : intelligence
PRICE : $0.08 (80000 atoms)
TRIAL : 2 free checks
Data Sources (all free):
- DexScreener — token pairs, holders, liquidity across chains
- Solscan (free) — Solana holder data
- Etherscan family — EVM holder data
- Birdeye public — cross-chain holder rankings
- Jupiter — Solana token info
"""
import asyncio
import logging
import os
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# ── Constants ────────────────────────────────────────────────────
FREE_APIS = {
"dexscreener": "https://api.dexscreener.com/latest/dex",
"birdeye_public": "https://public-api.birdeye.so",
"jupiter": "https://api.jup.ag",
}
CHAIN_NAMES = {
"solana": "Solana",
"ethereum": "Ethereum",
"bsc": "BSC",
"polygon": "Polygon",
"arbitrum": "Arbitrum",
"optimism": "Optimism",
"avalanche": "Avalanche",
"base": "Base",
"fantom": "Fantom",
"linea": "Linea",
"zksync": "zkSync",
"scroll": "Scroll",
"mantle": "Mantle",
}
# Etherscan-like API base URLs
ETHERSCAN_BASES: dict[str, str] = {
"ethereum": "https://api.etherscan.io/api",
"bsc": "https://api.bscscan.com/api",
"polygon": "https://api.polygonscan.com/api",
"arbitrum": "https://api.arbiscan.io/api",
"optimism": "https://api-optimistic.etherscan.io/api",
"avalanche": "https://api.snowtrace.io/api",
"fantom": "https://api.ftmscan.com/api",
"base": "https://api.basescan.org/api",
}
# Chain IDs for EVM chains
CHAIN_IDS: dict[str, int] = {
"ethereum": 1,
"bsc": 56,
"polygon": 137,
"arbitrum": 42161,
"optimism": 10,
"avalanche": 43114,
"fantom": 250,
"base": 8453,
}
# ── Data Models ───────────────────────────────────────────────────
@dataclass
class WhalePosition:
"""A whale's position on a single chain."""
chain: str
token_address: str
token_symbol: str
token_name: str = ""
balance: float = 0.0
balance_usd: float = 0.0
percentage: float = 0.0 # % of total supply
rank: int = 0 # holder rank on this chain
last_active: str = "" # ISO timestamp
wallet_label: str = ""
source: str = "" # data source (birdeye, etherscan, etc.)
@dataclass
class CrossChainWhale:
"""A whale wallet tracked across chains."""
primary_address: str
label: str = ""
total_value_usd: float = 0.0
chain_count: int = 0
token_count: int = 0
positions: list[WhalePosition] = field(default_factory=list)
first_seen: str = ""
risk_score: float = 0.0 # 0-100
risk_factors: list[str] = field(default_factory=list)
is_exchange: bool = False
is_scammer: bool = False
is_smart_money: bool = False
@dataclass
class WhaleTrackerReport:
"""Complete cross-chain whale tracking report."""
token_address: str
token_symbol: str = ""
token_name: str = ""
total_holders: int = 0
chains_found: list[str] = field(default_factory=list)
whales: list[CrossChainWhale] = field(default_factory=list)
cross_chain_whales: list[CrossChainWhale] = field(default_factory=list) # whales on 2+ chains
top_holders_by_chain: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
total_value_tracked: float = 0.0
concentration_score: float = 0.0 # 0-100 (higher = more concentrated)
scan_timestamp: str = ""
chains_with_data: list[str] = field(default_factory=list)
chains_no_data: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
# ── Helpers ───────────────────────────────────────────────────────
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _get_env_key(key: str) -> str:
return os.getenv(key, "")
def _truncate_address(addr: str) -> str:
if len(addr) <= 12:
return addr
return f"{addr[:6]}...{addr[-4:]}"
def _compute_concentration(positions: list[WhalePosition]) -> float:
"""Compute HHI-like concentration from whale positions."""
if not positions:
return 0.0
total = sum(p.balance_usd for p in positions if p.balance_usd > 0)
if total <= 0:
return 0.0
hhi = sum((p.balance_usd / total) ** 2 for p in positions if p.balance_usd > 0)
# Normalize: HHI of 1 = fully concentrated, HHI near 0 = very distributed
return round(min(hhi * 100, 100), 1)
# ── API Fetchers ──────────────────────────────────────────────────
async def _fetch_json(url: str, headers: dict | None = None, timeout: float = 15.0) -> dict | None:
"""Fetch JSON from URL with error handling."""
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(url, headers=headers)
if resp.status_code == 200:
return resp.json()
logger.debug(f"HTTP {resp.status_code} for {url}")
return None
except httpx.TimeoutException:
logger.debug(f"Timeout fetching {url}")
return None
except Exception as e:
logger.debug(f"Error fetching {url}: {e}")
return None
async def _fetch_dexscreener_pairs(token_address: str) -> list[dict]:
"""Fetch all DEX pairs for a token across all chains."""
url = f"{FREE_APIS['dexscreener']}/tokens/{token_address}"
data = await _fetch_json(url)
if not data or "pairs" not in data:
return []
return data["pairs"]
async def _fetch_etherscan_holders(token_address: str, chain: str) -> list[dict[str, Any]]:
"""Fetch top token holders from Etherscan-family explorer."""
base_url = ETHERSCAN_BASES.get(chain)
if not base_url:
return []
api_key = _get_env_key(f"{chain.upper()}_API_KEY".replace("-", "_"))
if not api_key:
api_key = _get_env_key("ETHERSCAN_API_KEY")
if not api_key:
# Try without key (limited)
url = (
f"{base_url}?module=token&action=tokenholderlist"
f"&contractaddress={token_address}"
f"&page=1&offset=10"
)
else:
url = (
f"{base_url}?module=token&action=tokenholderlist"
f"&contractaddress={token_address}"
f"&page=1&offset=10&apikey={api_key}"
)
data = await _fetch_json(url)
if not data or data.get("status") != "1":
return []
return data.get("result", [])
async def _fetch_birdeye_holders(token_address: str, chain: str) -> list[dict]:
"""Fetch top holders from Birdeye public API."""
api_key = _get_env_key("BIRDEYE_API_KEY")
headers = {"x-chain": chain}
if api_key:
headers["x-api-key"] = api_key
url = f"{FREE_APIS['birdeye_public']}/defi/holder/top?address={token_address}&limit=20"
data = await _fetch_json(url, headers=headers)
if not data or not data.get("success"):
return []
return data.get("data", {}).get("holders", [])
async def _fetch_jupiter_token_info(token_address: str) -> dict | list | None:
"""Fetch Jupiter token info for Solana tokens."""
# Try multiple Jupiter API URLs
urls = [
f"https://api.jup.ag/tokens/v1/token/{token_address}",
f"https://quote-api.jup.ag/v6/tokens/{token_address}",
]
for url in urls:
try:
data = await _fetch_json(url)
if data:
return data
except Exception:
continue
return None
async def _fetch_token_supply(token_address: str, chain: str) -> float | None:
"""Fetch total token supply from DexScreener or Birdeye.
Gracefully returns None if no source has data (non-fatal).
"""
pairs = await _fetch_dexscreener_pairs(token_address)
if pairs:
for pair in pairs:
chain_id = pair.get("chainId", "").lower()
if chain_id == chain or chain_id == CHAIN_NAMES.get(chain, "").lower():
try:
fdv = pair.get("fdv", 0)
if fdv:
return float(fdv)
except (ValueError, TypeError):
continue
# Try Birdeye as fallback
api_key = _get_env_key("BIRDEYE_API_KEY")
headers = {"x-chain": chain}
if api_key:
headers["x-api-key"] = api_key
url = f"{FREE_APIS['birdeye_public']}/defi/token_overview?address={token_address}"
data = await _fetch_json(url, headers=headers)
if data and data.get("success"):
supply = data.get("data", {}).get("supply")
if supply:
return float(supply)
logger.debug(f"Token supply fetch failed for {token_address} on {chain}")
return None
# ── Core Analysis ─────────────────────────────────────────────────
async def _analyze_single_chain(
token_address: str,
chain: str,
chain_name: str,
pairs: list[dict],
) -> dict[str, Any]:
"""Analyze whale holdings for a token on a single chain."""
result: dict[str, Any] = {
"chain": chain,
"holders_count": 0,
"holders": [],
"supply": None,
"price_usd": 0.0,
"liquidity_usd": 0.0,
"fdv": 0.0,
"volume_24h": 0.0,
"chain_native": chain_name,
}
# Find matching pairs for this chain
chain_pairs = [
p
for p in pairs
if p.get("chainId", "").lower() == chain
or p.get("chainId", "").lower() == chain_name.lower()
]
if chain_pairs:
pair = chain_pairs[0]
with suppress(ValueError, TypeError):
result["price_usd"] = float(pair.get("priceUsd", 0) or 0)
with suppress(ValueError, TypeError):
result["liquidity_usd"] = float(pair.get("liquidity", {}).get("usd", 0) or 0)
with suppress(ValueError, TypeError):
result["fdv"] = float(pair.get("fdv", 0) or 0)
with suppress(ValueError, TypeError):
result["volume_24h"] = float(pair.get("volume", {}).get("h24", 0) or 0)
holders: list[dict] = []
if chain == "solana":
# Solana: Try Birdeye first (needs API key)
birdeye_holders = await _fetch_birdeye_holders(token_address, chain)
if birdeye_holders:
for h in birdeye_holders:
addr = h.get("address", "")
balance = float(h.get("balance", 0) or 0)
balance_usd = float(h.get("balanceUsd", 0) or 0)
percentage = float(h.get("percent", 0) or 0)
holders.append(
{
"address": addr,
"balance": balance,
"balance_usd": balance_usd,
"percentage": percentage,
"source": "birdeye",
}
)
else:
# Fallback: Try Solana free RPC for holder data (multiple endpoints)
solana_rpcs = [
"https://api.mainnet-beta.solana.com",
"https://rpc.ankr.com/solana",
]
for rpc_url in solana_rpcs:
try:
async with httpx.AsyncClient(timeout=10) as client:
# Try to get top holders via getTokenLargestAccounts
largest_resp = await client.post(
rpc_url,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "getTokenLargestAccounts",
"params": [token_address],
},
)
if largest_resp.status_code == 200:
largest_data = largest_resp.json()
if "error" in largest_data:
logger.debug(f"Solana RPC {rpc_url} error: {largest_data['error']}")
continue
accounts = largest_data.get("result", {}).get("value", [])
if not accounts:
continue
total_balance = sum(float(a.get("uiAmount", 0) or 0) for a in accounts)
for acc in accounts[:20]:
addr = acc.get("address", "")
balance = float(acc.get("uiAmount", 0) or 0)
percentage = (
(balance / total_balance * 100) if total_balance > 0 else 0.0
)
balance_usd = balance * result["price_usd"]
holders.append(
{
"address": addr,
"balance": balance,
"balance_usd": balance_usd,
"percentage": round(percentage, 2),
"source": "solana_rpc",
}
)
# Also get total supply
supply_resp = await client.post(
rpc_url,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "getTokenSupply",
"params": [token_address],
},
)
if supply_resp.status_code == 200:
supply_data = supply_resp.json()
if "error" not in supply_data:
supply_val = (
supply_data.get("result", {})
.get("value", {})
.get("uiAmount", 0)
)
if supply_val:
result["supply"] = float(supply_val)
# Break out if we got data
if holders:
break
except Exception as e:
logger.debug(f"Solana RPC {rpc_url} failed: {e}")
continue
elif chain in ETHERSCAN_BASES:
# EVM: Try Etherscan family
etherscan_holders = await _fetch_etherscan_holders(token_address, chain)
for h in etherscan_holders:
addr = h.get("address", "")
try:
balance = float(h.get("balance", 0) or 0) / (10 ** int(h.get("tokenDecimal", 18)))
except (ValueError, TypeError, ZeroDivisionError):
balance = 0.0
try:
percentage = float(h.get("percentage", 0) or 0)
except (ValueError, TypeError):
percentage = 0.0
balance_usd = balance * result["price_usd"]
holders.append(
{
"address": addr,
"balance": balance,
"balance_usd": balance_usd,
"percentage": percentage,
"source": "etherscan",
}
)
result["holders_count"] = len(holders)
result["holders"] = holders[:20] # Top 20 holders
return result
def _is_exchange_address(addr: str) -> bool:
"""Heuristic: check if address looks like a known exchange."""
addr_lower = addr.lower()
known_exchanges = [
"binance",
"coinbase",
"kraken",
"okx",
"bybit",
"crypto.com",
"gate.io",
"kucoin",
"bitfinex",
"huobi",
"bitget",
"mexc",
]
# Check if any exchange name appears in the address or label
return any(exch in addr_lower for exch in known_exchanges)
def _is_smart_money_address(addr: str) -> bool:
"""Heuristic: placeholder for smart money detection."""
return False
async def _cross_reference_whales(
chain_results: dict[str, dict[str, Any]],
token_symbol: str,
token_name: str,
) -> tuple[list[CrossChainWhale], list[CrossChainWhale]]:
"""Cross-reference holders across chains to find multi-chain whales."""
# Collect all holders with their chain info
all_holders: dict[str, list[dict]] = {} # address -> list of positions across chains
for chain, result in chain_results.items():
for h in result.get("holders", []):
addr = h.get("address", "")
if not addr:
continue
if addr not in all_holders:
all_holders[addr] = []
all_holders[addr].append(
{
"chain": chain,
**h,
}
)
whales: list[CrossChainWhale] = []
cross_chain_whales: list[CrossChainWhale] = []
for addr, positions in all_holders.items():
total_value = sum(p.get("balance_usd", 0) for p in positions)
whale_positions = []
for p in positions:
wp = WhalePosition(
chain=p["chain"],
token_address="",
token_symbol=token_symbol,
token_name=token_name,
balance=p.get("balance", 0),
balance_usd=p.get("balance_usd", 0),
percentage=p.get("percentage", 0),
source=p.get("source", ""),
)
whale_positions.append(wp)
cw = CrossChainWhale(
primary_address=addr,
total_value_usd=total_value,
chain_count=len({p["chain"] for p in positions}),
token_count=len(positions),
positions=whale_positions,
is_exchange=_is_exchange_address(addr),
is_smart_money=_is_smart_money_address(addr),
)
whales.append(cw)
if cw.chain_count >= 2:
cross_chain_whales.append(cw)
# Sort by total value descending
whales.sort(key=lambda w: w.total_value_usd, reverse=True)
cross_chain_whales.sort(key=lambda w: w.total_value_usd, reverse=True)
return whales, cross_chain_whales
# ── Public API ────────────────────────────────────────────────────
_instance: "CrossChainWhaleTracker | None" = None
def get_whale_tracker() -> "CrossChainWhaleTracker":
"""Get or create the singleton CrossChainWhaleTracker instance."""
global _instance
if _instance is None:
_instance = CrossChainWhaleTracker()
return _instance
class CrossChainWhaleTracker:
"""Track whale wallets across multiple chains."""
def __init__(self):
self._lock = asyncio.Lock()
async def track_token(
self,
token_address: str,
chains: list[str] | None = None,
) -> WhaleTrackerReport:
"""Track whale holdings for a token across chains.
Args:
token_address: Token contract/mint address.
chains: Specific chains to check (default: all supported chains).
Returns:
WhaleTrackerReport with holders across all checked chains.
"""
report = WhaleTrackerReport(
token_address=token_address,
scan_timestamp=_now_iso(),
)
if chains is None:
chains = list(CHAIN_NAMES.keys())
# Step 1: Fetch DexScreener pairs for token identification
pairs = await _fetch_dexscreener_pairs(token_address)
if pairs:
pair = pairs[0]
# DexScreener puts symbol in baseToken, not at top level
base_token = pair.get("baseToken", {})
report.token_symbol = base_token.get("symbol", "???")
report.token_name = base_token.get("name", "")
# If DexScreener didn't have it, try Jupiter for Solana
if not report.token_symbol:
jup_info = await _fetch_jupiter_token_info(token_address)
if jup_info and isinstance(jup_info, list):
# Jupiter v1 returns array
if jup_info:
report.token_symbol = jup_info[0].get("symbol", "???")
report.token_name = jup_info[0].get("name", "")
elif jup_info:
report.token_symbol = jup_info.get("symbol", "???")
report.token_name = jup_info.get("name", "")
if not report.token_symbol:
report.token_symbol = token_address[:8]
# Step 2: Analyze each chain
chain_tasks = {}
for chain in chains:
chain_name = CHAIN_NAMES.get(chain, chain)
task = _analyze_single_chain(token_address, chain, chain_name, pairs)
chain_tasks[chain] = task
chain_results = {}
for chain, task in chain_tasks.items():
try:
result = await task
chain_results[chain] = result
if result["holders_count"] > 0:
report.chains_found.append(chain)
report.chains_with_data.append(chain)
else:
report.chains_no_data.append(chain)
report.total_holders += result["holders_count"]
except Exception as e:
report.chains_no_data.append(chain)
report.errors.append(f"{chain}: {e}")
# Step 3: Build top holders by chain
for chain, result in chain_results.items():
chain_top = []
for i, h in enumerate(result.get("holders", [])[:5]):
chain_top.append(
{
"rank": i + 1,
"address": h.get("address", ""),
"address_short": _truncate_address(h.get("address", "")),
"balance": h.get("balance", 0),
"balance_usd": h.get("balance_usd", 0),
"percentage": h.get("percentage", 0),
"source": h.get("source", ""),
}
)
if chain_top:
report.top_holders_by_chain[chain] = chain_top
# Step 4: Cross-reference across chains
if chain_results:
whales, cross_chain = await _cross_reference_whales(
chain_results, report.token_symbol, report.token_name
)
report.whales = whales
report.cross_chain_whales = cross_chain
# Step 5: Calculate aggregate metrics
report.total_value_tracked = sum(r.get("liquidity_usd", 0) for r in chain_results.values())
# Concentration score: how concentrated are top holders?
all_top_holders = []
for result in chain_results.values():
for h in result.get("holders", [])[:3]:
if h.get("percentage", 0) > 0:
all_top_holders.append(h)
if all_top_holders:
avg_top_pct = sum(h.get("percentage", 0) for h in all_top_holders) / len(
all_top_holders
)
report.concentration_score = round(min(avg_top_pct * 2, 100), 1)
# Step 6: Cross-chain whale risk scoring
for cw in report.cross_chain_whales:
risk = 0.0
factors = []
if cw.chain_count >= 4:
risk += 20
factors.append("Extreme cross-chain presence (4+ chains)")
elif cw.chain_count >= 3:
risk += 10
factors.append("Significant cross-chain presence (3 chains)")
if cw.total_value_usd > 1_000_000:
risk += 15
factors.append("High-value whale ($1M+)")
if cw.is_exchange:
risk = 5 # Exchange wallets are low risk
factors = ["Exchange wallet"]
elif cw.is_scammer:
risk = 85
factors.append("Known scammer address")
elif cw.is_smart_money:
risk = 10
factors.append("Smart money wallet")
cw.risk_score = round(min(risk, 100), 1)
cw.risk_factors = factors
report.chains_found = list(dict.fromkeys(report.chains_found))
return report
async def track_wallet(
self,
wallet_address: str,
chains: list[str] | None = None,
max_positions: int = 20,
) -> dict[str, Any]:
"""Track a specific wallet across chains to find all token positions.
Args:
wallet_address: The wallet address to investigate.
chains: Chains to check (default: all).
max_positions: Maximum tokens to return.
Returns:
Dict with wallet's positions across chains.
"""
if chains is None:
chains = [*list(ETHERSCAN_BASES.keys()), "solana"]
result: dict[str, Any] = {
"wallet": wallet_address,
"wallet_short": _truncate_address(wallet_address),
"chains_checked": chains,
"total_value_usd": 0.0,
"positions": [],
"errors": [],
"scan_timestamp": _now_iso(),
}
return result
# ── Formatting ─────────────────────────────────────────────────────
def format_whale_report(report: WhaleTrackerReport) -> str:
"""Format a WhaleTrackerReport for human-readable output."""
lines = []
lines.append(f"🐋 Cross-Chain Whale Report: {report.token_symbol}")
lines.append(f" Token: {report.token_name or 'N/A'}")
lines.append(f" Address: {report.token_address}")
lines.append(f" Chains Found: {', '.join(report.chains_found) or 'None'}")
lines.append(f" Total Holders Tracked: {report.total_holders}")
lines.append(f" Concentration Score: {report.concentration_score}/100")
lines.append("")
# Top holders by chain
for chain, holders in report.top_holders_by_chain.items():
chain_name = CHAIN_NAMES.get(chain, chain)
lines.append(f" 📍 Top {chain_name} Holders:")
for h in holders[:5]:
pct = f"{h['percentage']:.2f}%" if h["percentage"] else ""
usd = f"${h['balance_usd']:,.2f}" if h["balance_usd"] else ""
lines.append(f" #{h['rank']} {h['address_short']} {pct} {usd}")
lines.append("")
# Cross-chain whales
if report.cross_chain_whales:
lines.append(" 🔗 Cross-Chain Whales (on 2+ chains):")
for cw in report.cross_chain_whales[:10]:
chains_str = ", ".join(CHAIN_NAMES.get(p.chain, p.chain) for p in cw.positions)
value_str = f"${cw.total_value_usd:,.2f}" if cw.total_value_usd else ""
risk_str = f" [Risk: {cw.risk_score}/100]" if cw.risk_score > 0 else ""
addr_short = _truncate_address(cw.primary_address)
lines.append(f" 🐋 {addr_short} on {cw.chain_count} chains ({chains_str})")
lines.append(f" Value: {value_str}{risk_str}")
if cw.risk_factors:
for rf in cw.risk_factors[:3]:
lines.append(f"{rf}")
lines.append("")
# Top cross-chain whale
if report.cross_chain_whales:
top = report.cross_chain_whales[0]
lines.append(" 👑 Top Cross-Chain Whale:")
lines.append(f" Address: {top.primary_address}")
lines.append(f" Chains: {top.chain_count}")
lines.append(f" Est. Value: ${top.total_value_usd:,.2f}")
lines.append("")
if report.errors:
lines.append(f" ⚠ Errors ({len(report.errors)}):")
for err in report.errors[:3]:
lines.append(f" - {err}")
lines.append(f" Scan: {report.scan_timestamp}")
return "\n".join(lines)
# ── Address validation ────────────────────────────────────────────
def is_valid_address(address: str) -> bool:
"""Basic address validation for Solana and EVM addresses."""
if not address or len(address) < 32:
return False
# Solana: base58, 32-44 chars
# EVM: hex, 42 chars with 0x prefix
if address.startswith("0x"):
if len(address) != 42:
return False
try:
int(address, 16)
return True
except ValueError:
return False
# Solana-like: base58
if len(address) >= 32 and len(address) <= 44:
import string
base58 = string.digits + string.ascii_uppercase + string.ascii_lowercase
# Remove commonly confused chars
base58 = base58.replace("0", "").replace("O", "").replace("I", "").replace("l", "")
return all(c in base58 for c in address)
return False
# ── Quick test ────────────────────────────────────────────────────
async def _quick_test() -> None:
"""Run a quick smoke test with a well-known token."""
# Test with USDC on Solana (known token)
test_token = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
tracker = get_whale_tracker()
report = await tracker.track_token(test_token, chains=["solana", "ethereum", "base"])
print(format_whale_report(report))
print(f"\nErrors: {len(report.errors)}")
print("Quick test passed ✓" if len(report.errors) < 3 else "Quick test FAILED ✗")
if __name__ == "__main__":
asyncio.run(_quick_test())