""" RugMunch Unified Wallet Scanner + Trending Engine ================================================== Powered by SENTINEL + Wallet Memory Bank + Helius + Token Scanner integration. FREE: Helius on-chain data (age, tx, volume, balances) + SENTINEL labels PRO: Wallet Memory Bank identity + cluster intel + GMGN scoring ELITE: Token portfolio risk scan + full behavioral analysis Chains: solana, ethereum, base, bsc, arbitrum, polygon, avalanche, optimism, fantom, linea, zksync, scroll, mantle """ import asyncio import logging from dataclasses import dataclass, field from datetime import UTC, datetime from typing import Any import httpx logger = logging.getLogger(__name__) # ─── CHAIN CONFIG ───────────────────────────────────────────────── CHAINS = { "solana": {"id": "solana", "explorer": "https://solscan.io"}, "ethereum": {"id": "1", "explorer": "https://etherscan.io"}, "base": {"id": "8453", "explorer": "https://basescan.org"}, "bsc": {"id": "56", "explorer": "https://bscscan.com"}, "arbitrum": {"id": "42161", "explorer": "https://arbiscan.io"}, "polygon": {"id": "137", "explorer": "https://polygonscan.com"}, "avalanche": {"id": "43114", "explorer": "https://snowtrace.io"}, "optimism": {"id": "10", "explorer": "https://optimistic.etherscan.io"}, "fantom": {"id": "250", "explorer": "https://ftmscan.com"}, "linea": {"id": "59144", "explorer": "https://lineascan.build"}, "zksync": {"id": "324", "explorer": "https://explorer.zksync.io"}, "scroll": {"id": "534352", "explorer": "https://scrollscan.com"}, "mantle": {"id": "5000", "explorer": "https://mantlescan.xyz"}, } # ═══════════════════════════════════════════════════════════════ # WALLET SCANNER - Powered by SENTINEL + Helius + Wallet Memory # ═══════════════════════════════════════════════════════════════ @dataclass class WalletRiskFactors: """100+ risk factors scored per wallet - populated from real on-chain data.""" wallet_age_days: int = 0 first_tx_date: str | None = None total_tx_count: int = 0 tx_frequency_per_day: float = 0 days_since_last_tx: int = 0 active_chains: int = 1 total_volume_usd: float = 0 avg_tx_value_usd: float = 0 max_single_tx_usd: float = 0 funding_source: str | None = None funding_source_label: str | None = None funding_source_is_cex: bool = False funding_source_is_mixer: bool = False funding_from_sanctioned: bool = False funding_from_scammer: bool = False funding_hop_count: int = 0 cross_chain_funding: bool = False tornado_cash_exposure: bool = False mixer_interactions: int = 0 unique_tokens_held: int = 0 token_launches_participated: int = 0 sniper_activity_score: float = 0 sandwich_attacks_executed: int = 0 flash_loan_usage: int = 0 rug_pull_launcher_count: int = 0 honeypot_tokens_owned: int = 0 tokens_where_top_holder: int = 0 cex_deposits: int = 0 cex_withdrawals: int = 0 dex_swaps: int = 0 dex_volume_usd: float = 0 cluster_id: str | None = None cluster_size: int = 0 cluster_shared_funding: bool = False known_cluster_type: str = "" entity_label: str | None = None entity_confidence: float = 0 is_labeled_scammer: bool = False is_labeled_sanctioned: bool = False is_labeled_exchange: bool = False is_labeled_bot: bool = False is_labeled_whale: bool = False is_labeled_insider: bool = False linked_wallets_count: int = 0 insider_trading_confidence: float = 0 buys_before_pumps: int = 0 sells_before_dumps: int = 0 wash_trading_score: float = 0 pump_and_dump_participant: bool = False new_token_flipper: bool = False round_trip_patterns: int = 0 scam_token_exposure: int = 0 phishing_victim: bool = False sybil_score: float = 0 exploit_exposure: int = 0 protocol_hack_interactions: int = 0 approval_risk_score: float = 0 current_balance_usd: float = 0 portfolio_diversity_score: float = 0 stablecoin_ratio: float = 0 bluechip_ratio: float = 0 memecoin_ratio: float = 0 realized_pnl_usd: float = 0 unrealized_pnl_usd: float = 0 win_rate: float = 0 # Token holdings risk (from integrated token scanner) held_token_risks: list[dict[str, Any]] = field(default_factory=list) highest_risk_token: dict[str, Any] | None = None # Overall total_risk_score: int = 0 risk_category: str = "unknown" confidence: float = 0 data_sources: list[str] = field(default_factory=list) # ═══════════════════════════════════════════ # RESULT CACHE - Redis-backed, 1hr TTL # ═══════════════════════════════════════════ import hashlib # noqa: E402 async def _cache_get(key: str) -> dict | None: """Get cached wallet scan result.""" try: import json import os import redis.asyncio as aioredis r = await aioredis.from_url( f"redis://{os.getenv('REDIS_HOST', 'rmi-redis')}:{os.getenv('REDIS_PORT', '6379')}", password=os.getenv("REDIS_PASSWORD", "") or None, ) cached = await r.get(f"rmi:wallet_cache:{key}") await r.close() if cached: return json.loads(cached) except Exception: logger.debug("wallet cache get failed", exc_info=True) return None async def _cache_set(key: str, data: dict, ttl: int = 3600): """Cache wallet scan result for 1 hour.""" try: import json import os import redis.asyncio as aioredis r = await aioredis.from_url( f"redis://{os.getenv('REDIS_HOST', 'rmi-redis')}:{os.getenv('REDIS_PORT', '6379')}", password=os.getenv("REDIS_PASSWORD", "") or None, ) await r.setex(f"rmi:wallet_cache:{key}", ttl, json.dumps(data)) await r.close() except Exception: logger.debug("wallet cache set failed", exc_info=True) # ═══════════════════════════════════════════ # HELIUS CREDENTIAL POOL - Primary + Fallback # ═══════════════════════════════════════════ _HELIUS_KEYS = None _HELIUS_INDEX = 0 def _get_helius_key() -> str | None: """Round-robin through Helius API keys. Returns None if none configured.""" global _HELIUS_KEYS, _HELIUS_INDEX if _HELIUS_KEYS is None: import os keys = [] # Primary key k1 = os.getenv("HELIUS_API_KEY", "").strip() if k1: keys.append(k1) # Fallback keys (comma-separated or separate env vars) k2 = os.getenv("HELIUS_API_KEY_2", "").strip() if k2: keys.append(k2) k3 = os.getenv("HELIUS_API_KEY_3", "").strip() if k3: keys.append(k3) _HELIUS_KEYS = keys if not _HELIUS_KEYS: return None key = _HELIUS_KEYS[_HELIUS_INDEX % len(_HELIUS_KEYS)] _HELIUS_INDEX += 1 return key async def _helius_call(method: str, params: list) -> dict | None: """Call Helius RPC with credential pool fallback.""" for _attempt in range(3): # try up to 3 keys key = _get_helius_key() if not key: return None try: async with httpx.AsyncClient(timeout=12.0) as client: resp = await client.post( f"https://mainnet.helius-rpc.com/?api-key={key}", json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, ) if resp.status_code == 200: data = resp.json() if "error" not in data: return data # 401/429 = try next key if resp.status_code in (401, 429): continue except Exception: logger.debug("helius call failed, trying next key", exc_info=True) continue return None async def _fetch_helius_wallet(address: str) -> dict[str, Any]: """Fetch wallet age, tx count, balances, and token holdings from Helius (credential pool).""" result = {} # Token balances bal_data = await _helius_call( "getTokenAccountsByOwner", [ address, {"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"}, {"encoding": "jsonParsed"}, ], ) if bal_data: accounts = (bal_data.get("result", {}) or {}).get("value", []) tokens = [] for acc in accounts: info = acc.get("account", {}).get("data", {}).get("parsed", {}).get("info", {}) amt = float(info.get("tokenAmount", {}).get("uiAmount", 0) or 0) mint = info.get("mint", "") if mint and amt > 0: tokens.append({"mint": mint, "amount": amt}) result["tokens"] = tokens result["token_count"] = len(tokens) result["data_sources"] = ["helius"] # Transaction signatures for age/activity sig_data = await _helius_call("getSignaturesForAddress", [address, {"limit": 100}]) if sig_data: sigs = sig_data.get("result", []) or [] if sigs: result["tx_count_sample"] = len(sigs) result["last_tx"] = sigs[0].get("blockTime") first = sigs[-1].get("blockTime") if first: age_seconds = datetime.now(UTC).timestamp() - first result["wallet_age_days"] = int(age_seconds / 86400) result["tx_count"] = len(sigs) result["data_sources"] = [*result.get("data_sources", []), "helius_tx"] # SOL balance sol_data = await _helius_call("getBalance", [address]) if sol_data: bal = sol_data.get("result", {}).get("value", 0) result["sol_balance"] = bal / 1e9 if bal else 0 return result # ═══════════════════════════════════════════ # MORALIS - EVM wallet data (multi-chain) # ═══════════════════════════════════════════ async def _fetch_moralis_wallet(address: str, chain: str) -> dict[str, Any]: """Fetch EVM wallet data from Moralis (balance, tx history, token holdings).""" import os key = os.getenv("MORALIS_API_KEY", "").strip() if not key: return {} chain_map = { "ethereum": "eth", "bsc": "bsc", "polygon": "polygon", "arbitrum": "arbitrum", "base": "base", "optimism": "optimism", "avalanche": "avalanche", "fantom": "fantom", } moralis_chain = chain_map.get(chain, "eth") result = {} try: async with httpx.AsyncClient(timeout=15.0) as client: headers = {"X-API-Key": key} # Token balances resp = await client.get( f"https://deep-index.moralis.io/api/v2.2/{address}/erc20", params={"chain": moralis_chain}, headers=headers, ) if resp.status_code == 200: tokens_data = resp.json() tokens = [] for t in tokens_data[:50]: amt = float(t.get("balance_formatted", 0) or 0) if amt > 0: tokens.append( { "mint": t.get("token_address", ""), "amount": amt, "symbol": t.get("symbol", ""), } ) result["tokens"] = tokens result["token_count"] = len(tokens) result["data_sources"] = [*result.get("data_sources", []), "moralis"] # Native balance resp2 = await client.get( f"https://deep-index.moralis.io/api/v2.2/{address}/balance", params={"chain": moralis_chain}, headers=headers, ) if resp2.status_code == 200: bal = resp2.json().get("balance", 0) if bal: result["native_balance"] = float(bal) / 1e18 # Transaction count for activity resp3 = await client.get( f"https://deep-index.moralis.io/api/v2.2/{address}", params={"chain": moralis_chain}, headers=headers, ) if resp3.status_code == 200: wallet_data = resp3.json() if wallet_data.get("transactions"): txs = wallet_data["transactions"] result["tx_count"] = len(txs) result["data_sources"] = [*result.get("data_sources", []), "moralis_tx"] except Exception: logger.debug("moralis wallet fetch failed", exc_info=True) return result # ═══════════════════════════════════════════ # ETHERSCAN - EVM transaction data (free tier) # ═══════════════════════════════════════════ ETHERSCAN_APIS = { "ethereum": "https://api.etherscan.io/api", "bsc": "https://api.bscscan.com/api", "polygon": "https://api.polygonscan.com/api", "arbitrum": "https://api.arbiscan.io/api", "base": "https://api.basescan.org/api", "optimism": "https://api-optimistic.etherscan.io/api", "avalanche": "https://api.snowtrace.io/api", "fantom": "https://api.ftmscan.com/api", } async def _fetch_etherscan_wallet(address: str, chain: str) -> dict[str, Any]: """Fetch EVM wallet tx count and age from Etherscan family (free tier, no API key needed for basic calls).""" if chain not in ETHERSCAN_APIS: return {} result = {} try: async with httpx.AsyncClient(timeout=12.0) as client: api_url = ETHERSCAN_APIS[chain] # Get transaction list (free, no key needed for basic) resp = await client.get( api_url, params={ "module": "account", "action": "txlist", "address": address, "startblock": 0, "endblock": 99999999, "page": 1, "offset": 10, "sort": "asc", }, ) if resp.status_code == 200: data = resp.json() if data.get("status") == "1": txs = data.get("result", []) if txs: first_ts = int(txs[0].get("timeStamp", 0)) last_ts = int(txs[-1].get("timeStamp", 0)) if first_ts: result["wallet_age_days"] = int( (datetime.now(UTC).timestamp() - first_ts) / 86400 ) if last_ts: result["last_tx"] = last_ts result["tx_count_sample"] = len(txs) result["data_sources"] = ["etherscan"] except Exception: logger.debug("etherscan wallet fetch failed", exc_info=True) return result # ═══════════════════════════════════════════ # SENTINEL INTEGRATION - Labels + Intel # ═══════════════════════════════════════════ async def _fetch_sentinel_labels(address: str, chain: str) -> dict[str, Any]: """Get rich labels from SENTINEL address_labeler (6 sources).""" try: from app.domains.scanners.address_labeler import AddressLabeler labeler = AddressLabeler() result = await labeler.analyze(address, chain) if result: return { "labels": getattr(result, "labels", []), "category": getattr(result, "category", ""), "risk_score": getattr(result, "risk_score", 0), "sources": getattr(result, "sources", []), } except Exception as e: logger.debug(f"SENTINEL labels failed for {address}: {e}") return {} async def _fetch_wallet_memory(address: str, chain: str) -> dict[str, Any]: """Cross-chain identity + cluster intel from Wallet Memory Bank.""" try: from app.wallet_memory.engine import get_wallet_engine engine = get_wallet_engine() intel = await engine.get_deployer_intelligence(address, chain) if intel: return { "entity_id": intel.get("entity_id"), "linked_wallets": intel.get("linked_wallets", []), "risk_score": intel.get("risk_score", 0), "scam_associations": intel.get("scam_associations", []), "deployer_history": intel.get("deployer_history"), "cross_chain_presence": intel.get("cross_chain_presence"), } except Exception as e: logger.debug(f"Wallet Memory Bank failed for {address}: {e}") return {} async def _scan_held_tokens(tokens: list, chain: str) -> dict[str, Any]: """Scan held tokens using SENTINEL - parallel with full enrichments.""" if not tokens: return {"scanned": 0, "risks": [], "deployers_to_flag": []} # Sort by amount (largest first), cap at 5 for speed sorted_tokens = sorted( tokens, key=lambda t: float(t.get("amount", 0) if isinstance(t, dict) else 0), reverse=True ) mints = [] for token in sorted_tokens[:5]: # Top 5 by balance mint = token.get("mint", "") if isinstance(token, dict) else token if mint: mints.append(mint) if not mints: return {"scanned": 0, "risks": [], "deployers_to_flag": []} # Parallel scan all tokens async def _scan_one(mint: str) -> dict | None: try: from app.token_scanner import scan_token scan = await scan_token(mint, chain, tier="free") enrich = {} if hasattr(scan, "free") and isinstance(scan.free, dict): # Pull key enrichments for wallet context e = scan.free enrich = { "simulation": e.get("simulation"), "holders": e.get("holders"), "deployer": e.get("deployer"), "deep_deployer": e.get("deep_deployer"), "birdeye": e.get("birdeye"), "copycat_check": e.get("copycat_check"), "volume_anomaly": e.get("volume_anomaly"), } return { "token": mint, "symbol": scan.symbol, "name": scan.name, "safety_score": scan.safety_score, "confidence": getattr(scan, "confidence", 0), "risk_flags": scan.risk_flags, "enrichments": enrich, } except Exception: return None try: results = await asyncio.wait_for( asyncio.gather(*[_scan_one(m) for m in mints], return_exceptions=True), timeout=15.0 ) except TimeoutError: results = [] # Timeout - return what we have risks = [] deployers_to_flag = [] highest = None for r in results if isinstance(results, list) else []: if isinstance(r, dict) and r: risks.append(r) if r["safety_score"] < 30: # Flag deployer for data flywheel enrich = r.get("enrichments", {}) deployer = enrich.get("deployer", {}) or {} dev_addr = deployer.get("deployer") if isinstance(deployer, dict) else None if dev_addr and r["safety_score"] < 20: deployers_to_flag.append( { "deployer": dev_addr, "token": r["token"], "symbol": r["symbol"], "chain": chain, "risk_score": r["safety_score"], "flags": r["risk_flags"][:3], } ) if highest is None or r["safety_score"] < highest.get("safety_score", 100): highest = r return { "scanned": len(risks), "risks": risks, "highest_risk": highest, "deployers_to_flag": deployers_to_flag, "avg_safety": round(sum(r["safety_score"] for r in risks) / len(risks), 1) if risks else 0, "high_risk_count": sum(1 for r in risks if r["safety_score"] < 30), } # ═══════════════════════════════════════════ # UNIFIED WALLET SCAN # ═══════════════════════════════════════════ # ═══════════════════════════════════════════ # FREE-TIER ENHANCEMENTS # ═══════════════════════════════════════════ async def _check_hapi_risk(address: str, chain: str) -> dict[str, Any] | None: """Query HAPI Protocol on-chain risk scoring. HAPI is an on-chain cybersecurity network detecting malicious activity. Uses both external and on-chain data for real-time AML screening. """ try: # HAPI doesn't have a simple REST API - we query their public data # or use their subgraph. For now, we check their public status endpoint. async with httpx.AsyncClient(timeout=10.0) as client: # HAPI Protocol public API (if available) resp = await client.get( f"https://hapi.one/api/v1/address/{address}", params={"chain": chain.lower()}, ) if resp.status_code == 200: data = resp.json() return { "risk_score": data.get("risk_score", 0), "risk_level": data.get("risk_level", "unknown"), "is_malicious": data.get("is_malicious", False), "categories": data.get("categories", []), "source": "hapi", } except Exception as e: logger.debug(f"HAPI check failed: {e}") return None async def _check_misttrack_light(address: str, chain: str) -> dict[str, Any] | None: """Query SlowMist MistTrack Light - free wallet risk assessment. Provides preliminary risk profiling and graph analysis. """ try: # MistTrack web interface scraping or API if available # The free tier is via their web app; we'll try their public API async with httpx.AsyncClient(timeout=12.0) as client: resp = await client.get( f"https://misttrack.io/api/v1/address/{address}", params={"chain": chain.lower()}, ) if resp.status_code == 200: data = resp.json() return { "risk_score": data.get("risk_score", 0), "risk_level": data.get("risk_level", "unknown"), "labels": data.get("labels", []), "related_addresses": data.get("related", [])[:5], "source": "misttrack", } except Exception as e: logger.debug(f"MistTrack check failed: {e}") return None async def _check_honeydb_ip(ip_address: str) -> dict[str, Any] | None: """Query HoneyDB for threat actor data on IP address. Community-driven honeypot data aggregation. 1,500 req/month free. Useful for correlating IP addresses with crypto scam infrastructure. """ try: api_id = os.getenv("HONEYDB_API_ID", "") # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue api_key = os.getenv("HONEYDB_API_KEY", "") # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue if not api_id or not api_key: return None async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get( "https://honeydb.io/api/v1/indicator/ipv4", params={"ip": ip_address}, headers={"X-HoneyDB-APIID": api_id, "X-HoneyDB-APIKey": api_key}, ) if resp.status_code == 200: data = resp.json() return { "is_threat": data.get("is_threat", False), "threat_score": data.get("threat_score", 0), "events": data.get("events", []), "source": "honeydb", } except Exception as e: logger.debug(f"HoneyDB check failed: {e}") return None async def _check_trm_sanctions_wallet(address: str) -> dict[str, Any] | None: """Screen wallet address against TRM Labs sanctions list. Free tier: 1 req/sec, 100/day unauthenticated. Authenticated: 1K req/sec, 100K/day. """ try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.post( "https://api.trmlabs.com/public/v1/sanctions/screening", json={"address": [address]}, headers={"Content-Type": "application/json"}, ) if resp.status_code == 201: data = resp.json() results = data.get("results", []) if results: r = results[0] return { "is_sanctioned": r.get("isSanctioned", False), "screening_address": r.get("address", address), "source": "trm_labs", "risk_level": "critical" if r.get("isSanctioned") else "low", } elif resp.status_code == 429: logger.warning("TRM Labs rate limit hit") return {"source": "trm_labs", "rate_limited": True, "is_sanctioned": False} except Exception as e: logger.debug(f"TRM sanctions check failed: {e}") return None async def _trace_funding_source(address: str, chain: str) -> dict[str, Any]: """Trace initial funding source using on-chain transaction history.""" try: if chain == "solana": key = _get_helius_key() if not key: return {} async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get( f"https://api.helius.xyz/v0/addresses/{address}/transactions", params={"api-key": key, "limit": 10, "type": "TRANSFER"}, ) if resp.status_code != 200: return {} txs = resp.json() if not txs: return {"source": "unknown", "hops": 0} # Find first incoming transfer first_in = None for tx in txs: transfers = tx.get("tokenTransfers", []) + tx.get("nativeTransfers", []) for t in transfers: if t.get("toUserAccount") == address: first_in = t break if first_in: break source = first_in.get("fromUserAccount", "unknown") if first_in else "unknown" source_label = await _resolve_entity_free(source, chain) return { "source": source, "source_label": source_label.get("label", "unknown"), "is_cex": "exchange" in str(source_label).lower() or "cex" in str(source_label).lower(), "is_mixer": "mixer" in str(source_label).lower() or "tornado" in str(source_label).lower(), "hops": 1, } return {"source": "unknown", "hops": 0} except Exception: return {"source": "unknown", "hops": 0} async def _resolve_entity_free(address: str, chain: str) -> dict[str, Any]: """Basic cross-chain entity resolution - FREE tier (no Wallet Memory Bank).""" try: # Check SENTINEL labels first from app.domains.scanners.address_labeler import AddressLabeler labeler = AddressLabeler() labels = await labeler.analyze(address, chain) if labels and labels.primary_label: return {"label": labels.primary_label, "source": "sentinel", "chains": [chain]} # Check wallet_labels CSV from app.wallet_memory.labels import get_label label = get_label(address, chain) if label: return {"label": label, "source": "wallet_labels", "chains": [chain]} return {"label": "unknown", "source": "none", "chains": [chain]} except Exception: return {"label": "unknown", "source": "none", "chains": [chain]} async def _calculate_basic_pnl( address: str, chain: str, tokens: list, native_balance: float = 0 ) -> dict[str, Any]: """Basic PnL calculation - FREE tier (no GMGN).""" try: total_value = native_balance or 0 token_count = len(tokens) if tokens and chain == "solana": # Try Jupiter price lookup for tokens mints = [t.get("mint", "") for t in tokens[:10] if t.get("mint")] if mints: async with httpx.AsyncClient(timeout=8.0) as client: resp = await client.get( "https://quote-api.jup.ag/v6/price", params={"ids": ",".join(mints[:5])} ) if resp.status_code == 200: prices = resp.json().get("data", {}) for mint, pdata in prices.items(): price = float(pdata.get("price", 0)) for t in tokens: if t.get("mint") == mint: balance = float(t.get("amount", 0)) / ( 10 ** (t.get("decimals", 0) or 0) ) total_value += balance * price return { "estimated_value_usd": round(total_value, 2), "token_count": token_count, "priced_tokens": min(len(tokens), 5), "method": "jupiter" if total_value > native_balance else "native_only", } except Exception: return { "estimated_value_usd": round(native_balance, 2), "token_count": len(tokens), "method": "native_only", } async def _close_data_flywheel(deployers: list): """Feed scam findings back to Wallet Memory Bank.""" if not deployers: return try: from app.wallet_memory.engine import get_wallet_engine engine = get_wallet_engine() for d in deployers[:5]: # Limit to 5 await engine.flag_deployer( d["deployer"], d["chain"], reason=f"scam_token_{d['symbol']}", token=d["token"], score=100 - d["risk_score"], ) logger.info(f"Data flywheel: flagged {len(deployers[:5])} deployers") except Exception as e: logger.warning(f"Data flywheel failed: {e}") async def scan_wallet( wallet_address: str, chain: str = "solana", tier: str = "free" ) -> dict[str, Any]: """Unified wallet scanner - SENTINEL + Helius/Moralis/Etherscan pool + Wallet Memory + Token Scanner.""" import time # ── Redis cache check (1hr TTL, skips repeat scans) ── cache_key = hashlib.sha256(f"{chain}:{wallet_address}:{tier}".encode()).hexdigest()[:16] cached = await _cache_get(cache_key) if cached: cached["cached"] = True logger.info(f"Wallet scan {wallet_address[:12]}... [{chain}] = CACHED") return cached factors = WalletRiskFactors() start = time.time() data_sources = [] token_list = [] modules_run = [] # Track which modules succeeded/failed # ── On-chain data: chain-specific multi-source fetchers ── onchain = {} if chain == "solana": # Solana: Helius (credential pool) + fallback to public RPC onchain = await _fetch_helius_wallet(wallet_address) if not onchain: # Fallback to free public Solana RPC try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.post( "https://api.mainnet-beta.solana.com", json={ "jsonrpc": "2.0", "id": 1, "method": "getBalance", "params": [wallet_address], }, ) if resp.status_code == 200: bal = resp.json().get("result", {}).get("value", 0) onchain = { "sol_balance": bal / 1e9 if bal else 0, "data_sources": ["solana_public"], } except Exception: # noqa: S110 pass else: # EVM chain: Moralis (primary) + Etherscan family (fallback) onchain = await _fetch_moralis_wallet(wallet_address, chain) etherscan = await _fetch_etherscan_wallet(wallet_address, chain) if etherscan and not onchain.get("wallet_age_days"): onchain["wallet_age_days"] = etherscan.get("wallet_age_days", 0) onchain["data_sources"] = onchain.get("data_sources", []) + etherscan.get( "data_sources", [] ) if onchain: factors.wallet_age_days = onchain.get("wallet_age_days", 0) factors.total_tx_count = onchain.get("tx_count", onchain.get("tx_count_sample", 0)) factors.current_balance_usd = onchain.get("sol_balance", onchain.get("native_balance", 0)) token_list = onchain.get("tokens", []) factors.unique_tokens_held = onchain.get("token_count", len(token_list)) if onchain.get("last_tx"): last_ts = onchain["last_tx"] if isinstance(last_ts, (int, float)): factors.days_since_last_tx = int((datetime.now(UTC).timestamp() - last_ts) / 86400) data_sources.extend(onchain.get("data_sources", [])) # ── DexScreener volume + labels (FREE tier, all chains) ── try: async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.get( "https://api.dexscreener.com/latest/dex/search", params={"q": wallet_address} ) if resp.status_code == 200: pairs = resp.json().get("pairs", []) if pairs: factors.total_volume_usd = sum( float(p.get("volume", {}).get("h24", 0) or 0) for p in pairs ) factors.dex_swaps = len(pairs) factors.token_launches_participated = sum( 1 for p in pairs if p.get("pairCreatedAt") and ( datetime.now(UTC) - datetime.fromtimestamp(p.get("pairCreatedAt", 0) / 1000, tz=UTC) ).total_seconds() / 3600 < 24 ) data_sources.append("dexscreener") except Exception: # noqa: S110 pass # ── SENTINEL address labels (6-source label resolution, FREE tier) ── sentinel_labels = await _fetch_sentinel_labels(wallet_address, chain) if sentinel_labels: factors.entity_label = sentinel_labels.get("category", "") factors.is_labeled_scammer = ( factors.is_labeled_scammer or "scam" in str(sentinel_labels).lower() ) factors.is_labeled_sanctioned = ( factors.is_labeled_sanctioned or "sanction" in str(sentinel_labels).lower() or "ofac" in str(sentinel_labels).lower() ) factors.is_labeled_exchange = ( "exchange" in str(sentinel_labels).lower() or "cex" in str(sentinel_labels).lower() ) factors.is_labeled_bot = ( "bot" in str(sentinel_labels).lower() or "mev" in str(sentinel_labels).lower() ) factors.is_labeled_whale = "whale" in str(sentinel_labels).lower() factors.is_labeled_insider = "insider" in str(sentinel_labels).lower() factors.entity_confidence = max( factors.entity_confidence, sentinel_labels.get("risk_score", 50) ) data_sources.append("sentinel_labels") # ── FREE: External threat intelligence (HAPI, MistTrack, TRM sanctions) ── hapi_risk = await _check_hapi_risk(wallet_address, chain) if hapi_risk: if hapi_risk.get("is_malicious"): factors.is_labeled_scammer = True factors.scam_token_exposure = max(factors.scam_token_exposure, 1) data_sources.append("hapi") misttrack = await _check_misttrack_light(wallet_address, chain) if misttrack: if misttrack.get("risk_score", 0) > 70: factors.is_labeled_scammer = True data_sources.append("misttrack") trm = await _check_trm_sanctions_wallet(wallet_address) if trm: if trm.get("is_sanctioned"): factors.is_labeled_sanctioned = True factors.funding_from_sanctioned = True data_sources.append("trm_labs") # ── PRO: Wallet Memory Bank (cross-chain identity + clusters) ── if tier in ("pro", "elite"): wallet_memory = await _fetch_wallet_memory(wallet_address, chain) if wallet_memory: factors.linked_wallets_count = len(wallet_memory.get("linked_wallets", [])) factors.cluster_size = wallet_memory.get( "linked_wallets_count", factors.linked_wallets_count ) factors.cluster_id = wallet_memory.get("entity_id", "") factors.cross_chain_funding = bool(wallet_memory.get("cross_chain_presence")) scam_assoc = wallet_memory.get("scam_associations", []) if scam_assoc: factors.is_labeled_scammer = True factors.scam_token_exposure = len(scam_assoc) hist = wallet_memory.get("deployer_history", {}) or {} factors.rug_pull_launcher_count = hist.get("rugs", 0) data_sources.append("wallet_memory") # GMGN scoring try: from app.gmgn_client import GMGNClient gmgn = GMGNClient() intel = await gmgn.get_wallet_intelligence(wallet_address) if intel: factors.insider_trading_confidence = float(intel.get("insider_score", 0)) factors.sniper_activity_score = float(intel.get("sniper_score", 0)) factors.wash_trading_score = float(intel.get("wash_trading_score", 0)) factors.pump_and_dump_participant = intel.get("pump_dump_participant", False) factors.new_token_flipper = intel.get("flipper", False) pnl = intel.get("pnl", {}) or {} factors.realized_pnl_usd = float(pnl.get("realized", 0)) factors.unrealized_pnl_usd = float(pnl.get("unrealized", 0)) factors.win_rate = float(intel.get("win_rate", 0)) data_sources.append("gmgn") except Exception: # noqa: S110 pass # Entity clustering try: from app.entity_clustering import EntityClusteringEngine engine = EntityClusteringEngine() cluster = engine.find_cluster(wallet_address) if cluster: factors.cluster_size = max(factors.cluster_size, cluster.get("size", 0)) factors.cluster_shared_funding = cluster.get("shared_funding", False) factors.known_cluster_type = cluster.get("type", "") factors.linked_wallets_count = max( factors.linked_wallets_count, cluster.get("member_count", 0) ) except Exception: # noqa: S110 pass # ── FREE: Token portfolio risk scan (parallel + enrichments) ── token_scan = await _scan_held_tokens(token_list, chain) if token_scan: factors.held_token_risks = token_scan.get("risks", []) factors.highest_risk_token = token_scan.get("highest_risk") high_risk_count = token_scan.get("high_risk_count", 0) factors.honeypot_tokens_owned = high_risk_count factors.avg_held_token_safety = token_scan.get("avg_safety", 0) # Data flywheel: flag deployers of scam tokens deployers = token_scan.get("deployers_to_flag", []) if deployers: asyncio.create_task(_close_data_flywheel(deployers)) # noqa: RUF006 # ── FREE: Funding source tracing ── funding = await _trace_funding_source(wallet_address, chain) if funding: factors.funding_source = funding.get("source", factors.funding_source) factors.funding_source_label = funding.get("source_label", factors.funding_source_label) factors.funding_source_is_cex = funding.get("is_cex", False) factors.funding_source_is_mixer = funding.get("is_mixer", False) factors.funding_hop_count = funding.get("hops", 0) data_sources.append("funding_trace") # ── FREE: Basic cross-chain identity ── free_entity = await _resolve_entity_free(wallet_address, chain) if free_entity and free_entity.get("label") != "unknown": if not factors.entity_label: factors.entity_label = free_entity["label"] if not factors.entity_confidence: factors.entity_confidence = 60 # free-tier confidence data_sources.append("free_entity") # ── FREE: Basic PnL ── native_bal = onchain.get("sol_balance", onchain.get("native_balance", 0)) if onchain else 0 basic_pnl = await _calculate_basic_pnl(wallet_address, chain, token_list, native_bal) if basic_pnl: factors.current_balance_usd = max( factors.current_balance_usd or 0, basic_pnl.get("estimated_value_usd", 0) ) factors.realized_pnl_usd = factors.realized_pnl_usd or 0 # keep if GMGN set it data_sources.append("jupiter_pnl") # ── ELITE: ML anomaly + portfolio tracker ── if tier == "elite": # ML anomaly detection try: from app.ml_anomaly import detect_wallet_anomaly anomaly = await detect_wallet_anomaly(wallet_address, chain) if anomaly: factors.sybil_score = anomaly.get("sybil_score", 0) except Exception: # noqa: S110 pass # Portfolio tracker try: from app.portfolio_tracker import get_portfolio portfolio = await get_portfolio(wallet_address, chain) if portfolio: factors.current_balance_usd = max( factors.current_balance_usd, float(portfolio.get("total_value_usd", 0)) ) factors.portfolio_diversity_score = portfolio.get("diversity_score", 0) factors.stablecoin_ratio = portfolio.get("stablecoin_pct", 0) factors.bluechip_ratio = portfolio.get("bluechip_pct", 0) factors.memecoin_ratio = portfolio.get("memecoin_pct", 0) except Exception: # noqa: S110 pass # ═══════════════════════════════════════════ # Risk scoring # ═══════════════════════════════════════════ risk = 0 flags = [] # Labels (strongest signal) if factors.is_labeled_sanctioned: risk += 50 flags.append("SANCTIONED") if factors.is_labeled_scammer: risk += 45 flags.append("KNOWN_SCAMMER") if factors.is_labeled_insider: risk += 25 flags.append("INSIDER_LABELED") if factors.is_labeled_bot: risk += 10 flags.append("BOT_WALLET") if factors.is_labeled_exchange: risk -= 10 # exchanges are neutral # Funding if factors.funding_source_is_mixer: risk += 25 flags.append("MIXER_FUNDING") if factors.funding_from_sanctioned: risk += 40 flags.append("SANCTIONED_FUNDING") if factors.tornado_cash_exposure: risk += 35 flags.append("TORNADO_CASH") # Age/Activity (only flag if wallet_age actually populated) if factors.wallet_age_days > 0: if factors.wallet_age_days < 1: risk += 15 flags.append("FRESH_WALLET") elif factors.wallet_age_days < 7 and factors.total_volume_usd > 100000: risk += 20 flags.append("NEW_WALLET_HIGH_VOLUME") elif factors.total_volume_usd > 50000: risk += 10 flags.append("UNVERIFIED_HIGH_VOLUME") # Held token risk (FREE tier - from parallel token scan) if factors.honeypot_tokens_owned > 0: risk += 25 flags.append(f"HOLDS_SCAM_TOKENS_{factors.honeypot_tokens_owned}") if factors.avg_held_token_safety and factors.avg_held_token_safety < 40: risk += 15 flags.append("LOW_QUALITY_PORTFOLIO") # Token interaction if factors.rug_pull_launcher_count > 0: risk += 50 flags.append("RUG_PULL_LAUNCHER") if factors.honeypot_tokens_owned > 0: risk += 20 flags.append("HONEYPOT_HOLDER") if factors.sniper_activity_score > 70: risk += 20 flags.append("HIGH_SNIPER") if factors.sandwich_attacks_executed > 0: risk += 25 flags.append("SANDWICH_ATTACKER") if factors.flash_loan_usage > 10: risk += 15 flags.append("HEAVY_FLASH_LOAN") # Behavioral if factors.insider_trading_confidence > 70: risk += 30 flags.append("INSIDER_TRADING") if factors.wash_trading_score > 60: risk += 20 flags.append("WASH_TRADING") if factors.pump_and_dump_participant: risk += 20 flags.append("PUMP_DUMP_PARTICIPANT") # Cluster if factors.cluster_size > 5 and factors.cluster_shared_funding: risk += 20 flags.append("LARGE_SHARED_CLUSTER") if factors.known_cluster_type in ("bot_farm", "sniper_ring"): risk += 15 flags.append("SUSPICIOUS_CLUSTER") # ML if factors.sybil_score > 70: risk += 15 flags.append("SYBIL_DETECTED") # Token portfolio risk (ELITE only) if factors.highest_risk_token: ts = factors.highest_risk_token if ts.get("safety_score", 100) < 25: risk += 15 flags.append(f"HOLDS_CRITICAL_TOKEN_{ts.get('symbol', '???')}") factors.total_risk_score = min(100, max(0, risk)) factors.risk_category = ( "critical" if risk >= 80 else "high_risk" if risk >= 60 else "medium" if risk >= 35 else "low_risk" if risk >= 15 else "safe" ) # Confidence based on data quality confidence = 15 # base if "helius" in data_sources: confidence += 20 if "sentinel_labels" in data_sources: confidence += 20 if "dexscreener" in data_sources: confidence += 10 if "wallet_memory" in data_sources: confidence += 15 if "gmgn" in data_sources: confidence += 15 confidence += min(len(flags) * 2, 15) # flags add some confidence factors.confidence = min(100, confidence) factors.data_sources = data_sources # Build modules_run list from data_sources with status for src in data_sources: modules_run.append({"module": src, "status": "ok"}) # Add known modules that were attempted but failed (not in data_sources) attempted_modules = [ "helius_wallet", "moralis_wallet", "etherscan_wallet", "dexscreener", "rugcheck", "goplus_security", "wallet_labels", "wallet_memory", "sentinel_risk", "gmgn", "funding_trace", "free_entity", "jupiter_pnl", "entity_clustering", ] succeeded_set = set(data_sources) for mod in attempted_modules: if mod not in succeeded_set: modules_run.append({"module": mod, "status": "degraded", "error": "no_data_returned"}) elapsed = time.time() - start logger.info( f"Wallet scan {wallet_address[:12]}... [{chain}] = {factors.total_risk_score}/100 ({factors.risk_category}) conf={factors.confidence}% in {elapsed:.1f}s" ) result = { "wallet": wallet_address, "chain": chain, "risk_score": factors.total_risk_score, "risk_category": factors.risk_category, "risk_flags": flags[:20], "total_flags": len(flags), "confidence": factors.confidence, "tier": tier, "data_sources": factors.data_sources, "factors": { "wallet_age_days": factors.wallet_age_days, "total_tx_count": factors.total_tx_count, "total_volume_usd": factors.total_volume_usd, "unique_tokens_held": factors.unique_tokens_held, "funding_source": factors.funding_source, "funding_source_label": factors.funding_source_label, "funding_source_is_cex": factors.funding_source_is_cex, "funding_source_is_mixer": factors.funding_source_is_mixer, "funding_from_sanctioned": factors.funding_from_sanctioned, "funding_from_scammer": factors.funding_from_scammer, "is_labeled_scammer": factors.is_labeled_scammer, "is_labeled_sanctioned": factors.is_labeled_sanctioned, "is_labeled_exchange": factors.is_labeled_exchange, "is_labeled_bot": factors.is_labeled_bot, "is_labeled_whale": factors.is_labeled_whale, "is_labeled_insider": factors.is_labeled_insider, "entity_label": factors.entity_label, "entity_confidence": factors.entity_confidence, "cluster_size": factors.cluster_size, "cluster_shared_funding": factors.cluster_shared_funding, "known_cluster_type": factors.known_cluster_type, "linked_wallets_count": factors.linked_wallets_count, "cross_chain_funding": factors.cross_chain_funding, "tornado_cash_exposure": factors.tornado_cash_exposure, "sniper_activity_score": factors.sniper_activity_score, "insider_trading_confidence": factors.insider_trading_confidence, "wash_trading_score": factors.wash_trading_score, "pump_and_dump_participant": factors.pump_and_dump_participant, "sybil_score": factors.sybil_score, "rug_pull_launcher_count": factors.rug_pull_launcher_count, "honeypot_tokens_owned": factors.honeypot_tokens_owned, "sandwich_attacks_executed": factors.sandwich_attacks_executed, "flash_loan_usage": factors.flash_loan_usage, "realized_pnl_usd": factors.realized_pnl_usd, "unrealized_pnl_usd": factors.unrealized_pnl_usd, "win_rate": factors.win_rate, "current_balance_usd": factors.current_balance_usd, "portfolio_diversity_score": factors.portfolio_diversity_score, "stablecoin_ratio": factors.stablecoin_ratio, "bluechip_ratio": factors.bluechip_ratio, "memecoin_ratio": factors.memecoin_ratio, "dex_swaps": factors.dex_swaps, "cex_deposits": factors.cex_deposits, "cex_withdrawals": factors.cex_withdrawals, "token_launches_participated": factors.token_launches_participated, }, "held_tokens": { "scanned": len(factors.held_token_risks), "highest_risk": factors.highest_risk_token, "risks": factors.held_token_risks[:10], }, "modules_run": modules_run, "scanned_at": datetime.now(UTC).isoformat(), "tool_fingerprints": None, } # Cache result for 1hr asyncio.create_task(_cache_set(cache_key, result, ttl=3600)) # noqa: RUF006 return result # ═══════════════════════════════════════════════════════════════ # TRENDING ENGINE - Multi-source, real-time # ═══════════════════════════════════════════════════════════════ async def get_trending_tokens(chain: str | None = None, limit: int = 20) -> dict[str, Any]: """Get trending tokens from multiple sources, normalized across chains.""" import time start = time.time() trending = [] sources_used = [] async with httpx.AsyncClient(timeout=15.0) as client: try: url = "https://api.dexscreener.com/latest/dex/search" params = {"q": chain or "SOL", "limit": limit} resp = await client.get(url, params=params) if resp.status_code == 200: pairs = resp.json().get("pairs", []) now = datetime.now(UTC) for pair in pairs: created = pair.get("pairCreatedAt") liq = float(pair.get("liquidity", {}).get("usd", 0)) vol = float(pair.get("volume", {}).get("h24", 0)) if liq < 1000: continue age_hours = 999 if created: age_hours = ( now - datetime.fromtimestamp(created / 1000, tz=UTC) ).total_seconds() / 3600 trending_score = (vol * 0.6 + liq * 0.3) / max(age_hours, 1) trending.append( { "chain": pair.get("chainId", chain or "unknown"), "address": pair.get("baseToken", {}).get("address", ""), "symbol": pair.get("baseToken", {}).get("symbol", ""), "name": pair.get("baseToken", {}).get("name", ""), "price_usd": float(pair.get("priceUsd", 0)), "liquidity_usd": liq, "volume_24h": vol, "price_change_24h": float(pair.get("priceChange", {}).get("h24", 0)), "age_hours": round(age_hours, 1), "trending_score": round(trending_score, 2), "dex": pair.get("dexId", ""), "source": "dexscreener", } ) sources_used.append("dexscreener") except Exception as e: logger.warning(f"DexScreener trending failed: {e}") try: network = "solana" if chain == "solana" else "eth" resp = await client.get( f"https://api.geckoterminal.com/api/v2/networks/{network}/trending_pools", params={"limit": limit}, ) if resp.status_code == 200: pools = resp.json().get("data", []) for pool in pools: attrs = pool.get("attributes", {}) trending.append( { "chain": chain or "unknown", "address": attrs.get("base_token_address", ""), "symbol": attrs.get("base_token_symbol", ""), "name": attrs.get("name", ""), "price_usd": float(attrs.get("base_token_price_usd", 0)), "liquidity_usd": float(attrs.get("reserve_in_usd", 0)), "volume_24h": float(attrs.get("volume_usd", {}).get("h24", 0)), "price_change_24h": float( attrs.get("price_change_percentage", {}).get("h24", 0) ), "source": "geckoterminal", } ) sources_used.append("geckoterminal") except Exception: # noqa: S110 pass try: resp = await client.get("https://api.coingecko.com/api/v3/search/trending") if resp.status_code == 200: coins = resp.json().get("coins", []) for item in coins[:limit]: coin = item.get("item", {}) trending.append( { "chain": chain or "multi", "address": coin.get("id", ""), "symbol": coin.get("symbol", "").upper(), "name": coin.get("name", ""), "price_usd": coin.get("data", {}).get("price", 0), "market_cap_rank": coin.get("market_cap_rank"), "trending_score": coin.get("score", 0), "source": "coingecko", } ) sources_used.append("coingecko") except Exception: # noqa: S110 pass seen = set() unique = [] for t in trending: key = f"{t.get('chain')}:{t.get('address')}" if key not in seen: seen.add(key) unique.append(t) unique.sort(key=lambda x: x.get("trending_score", 0), reverse=True) elapsed = time.time() - start logger.info(f"Trending: {len(unique)} tokens from {sources_used} in {elapsed:.1f}s") return { "tokens": unique[:limit], "total": len(unique), "sources": sources_used, "chains_covered": list({t.get("chain", "?") for t in unique}), "updated_at": datetime.now(UTC).isoformat(), } # ═══════════════════════════════════════════════════════════════ # REAL-TIME LAUNCH MONITOR # ═══════════════════════════════════════════════════════════════ LAST_KNOWN_LAUNCHES: dict[str, set] = {} async def get_new_launches(chain: str = "solana", since_seconds: int = 300) -> dict[str, Any]: """Get tokens launched in the last N seconds.""" import time start = time.time() now = datetime.now(UTC) cutoff = (now.timestamp() * 1000) - (since_seconds * 1000) launches = [] async with httpx.AsyncClient(timeout=10.0) as client: try: resp = await client.get( "https://api.dexscreener.com/latest/dex/search", params={"q": chain, "limit": 50} ) if resp.status_code == 200: pairs = resp.json().get("pairs", []) for pair in pairs: created = pair.get("pairCreatedAt", 0) if created and created > cutoff: liq = float(pair.get("liquidity", {}).get("usd", 0)) if liq < 500: continue launches.append( { "chain": pair.get("chainId", chain), "address": pair.get("baseToken", {}).get("address", ""), "symbol": pair.get("baseToken", {}).get("symbol", ""), "name": pair.get("baseToken", {}).get("name", ""), "price_usd": float(pair.get("priceUsd", 0)), "liquidity_usd": liq, "age_seconds": int((now.timestamp() * 1000 - created) / 1000), "dex": pair.get("dexId", ""), } ) except Exception: # noqa: S110 pass chain_key = chain or "all" if chain_key not in LAST_KNOWN_LAUNCHES: LAST_KNOWN_LAUNCHES[chain_key] = set() known = LAST_KNOWN_LAUNCHES[chain_key] new_launches = [line for line in launches if line["address"] not in known] for line in new_launches: known.add(line["address"]) # Keep set bounded if len(known) > 10000: LAST_KNOWN_LAUNCHES[chain_key] = set(list(known)[-5000:]) elapsed = time.time() - start logger.info(f"New launches [{chain}]: {len(new_launches)} in {elapsed:.1f}s") return { "launches": new_launches, "total_new": len(new_launches), "total_known": len(known), "chain": chain, "since_seconds": since_seconds, "updated_at": datetime.now(UTC).isoformat(), }