""" 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())