""" Exit Scam Forecaster (Rug Pull Predictor) ========================================== Predictive model identifying tokens at high risk of rug pull. Multi-signal analysis combining liquidity patterns, holder behavior, deployer history, and social signals for early warning. The model evaluates 6 risk dimensions, each weighted by predictive power, to produce a single 0-100 rug score with explainable per-signal breakdown. TOOL : rug_pull_predictor TIER : premium / intelligence PRICE : $0.10 (100000 atoms) TRIAL : 1 free check Signals Analyzed: - LIQUIDITY: LP lock status, liquidity depth vs market cap, sudden changes - HOLDER_CONCENTRATION: Top-10 concentration, single-entity cluster ratio - DEPLOYER: Previous project history, rug count, anonymous deployer - LAUNCH_PATTERN: Bundled distribution, sniper activity, presale structure - TRADING_BEHAVIOR: Seller concentration, wash trading, suspicious transfers - SOCIAL_SIGNALS: Low engagement, anonymous team, no community presence Data Sources (all free): - DexScreener - token pairs, liquidity, price - Solscan (public) - SPL token holder data - Etherscan/BscScan (public free) - EVM token info - Birdeye public - basic token metrics - GoPlus (free) - token security info (if available) """ import asyncio import json import logging import re import time from dataclasses import dataclass, field from datetime import UTC, datetime from enum import Enum from typing import Any import httpx __all__ = ["RiskLevel", "RugPullAnalysis", "SignalResult", "analyze", "predict_rug_pull"] logger = logging.getLogger(__name__) # ── Constants ──────────────────────────────────────────────────── FREE_APIS = { "dexscreener": "https://api.dexscreener.com/latest/dex", "birdeye_public": "https://public-api.birdeye.so", "goplus": "https://api.gopluslabs.io/api/v1", } EVM_ADDR_RE = re.compile(r"^0x[a-fA-F0-9]{40}$") SOLANA_ADDR_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$") # Default risk thresholds DEFAULT_WEIGHTS = { "liquidity": 0.25, "holder_concentration": 0.20, "deployer": 0.15, "launch_pattern": 0.18, "trading_behavior": 0.12, "social_signals": 0.10, } CHAIN_MAP: dict[str, str] = { "solana": "solana", "ethereum": "ethereum", "bsc": "bsc", "polygon": "polygon", "arbitrum": "arbitrum", "optimism": "optimism", "base": "base", "avalanche": "avalanche", } # ── Data Models ────────────────────────────────────────────────── class RiskLevel(Enum): SAFE = "safe" LOW = "low" MEDIUM = "medium" HIGH = "high" CRITICAL = "critical" @dataclass class SignalResult: """Individual signal analysis result.""" signal: str score: float # 0 (safe) - 100 (risky) weight: float findings: list[str] = field(default_factory=list) details: dict[str, Any] = field(default_factory=dict) @property def weighted_score(self) -> float: return self.score * self.weight @property def level(self) -> RiskLevel: if self.score < 20: return RiskLevel.SAFE elif self.score < 40: return RiskLevel.LOW elif self.score < 60: return RiskLevel.MEDIUM elif self.score < 80: return RiskLevel.HIGH return RiskLevel.CRITICAL @dataclass class RugPullAnalysis: """Complete rug pull risk analysis result.""" token_address: str chain: str token_symbol: str token_name: str overall_score: float # 0 (safe) - 100 (critical risk) overall_level: RiskLevel signals: list[SignalResult] = field(default_factory=list) summary: str = "" recommendations: list[str] = field(default_factory=list) analysis_timestamp: str = "" def to_dict(self) -> dict[str, Any]: return { "token_address": self.token_address, "chain": self.chain, "token_symbol": self.token_symbol, "token_name": self.token_name, "rug_score": round(self.overall_score, 1), "risk_level": self.overall_level.value, "signals": [ { "name": s.signal, "score": round(s.score, 1), "level": s.level.value, "findings": s.findings, } for s in self.signals ], "summary": self.summary, "recommendations": self.recommendations, "analyzed_at": self.analysis_timestamp, } # ── Helpers ────────────────────────────────────────────────────── def is_valid_address(addr: str) -> bool: addr = addr.strip() return bool(EVM_ADDR_RE.match(addr) or SOLANA_ADDR_RE.match(addr)) def _normalize_chain(chain: str) -> str: chain = chain.strip().lower() return CHAIN_MAP.get(chain, chain) def _level_from_score(score: float) -> RiskLevel: if score < 20: return RiskLevel.SAFE elif score < 40: return RiskLevel.LOW elif score < 60: return RiskLevel.MEDIUM elif score < 80: return RiskLevel.HIGH return RiskLevel.CRITICAL # ── Signal Analyzers ──────────────────────────────────────────── async def _analyze_liquidity(client: httpx.AsyncClient, token_address: str, chain: str) -> SignalResult: """Analyze liquidity risk: depth, lock status, sudden changes.""" signal = SignalResult( signal="liquidity", score=0.0, weight=DEFAULT_WEIGHTS["liquidity"], findings=[], ) try: # Try DexScreener for pair data url = f"{FREE_APIS['dexscreener']}/search?q={token_address}" resp = await client.get(url, timeout=10) if resp.status_code == 200: data = resp.json() pairs = data.get("pairs", []) if pairs: pair = pairs[0] liquidity_usd = float(pair.get("liquidity", {}).get("usd", 0) or 0) fdv = float(pair.get("fdv", 0) or 0) volume_24h = float(pair.get("volume", {}).get("h24", 0) or 0) signal.details["liquidity_usd"] = liquidity_usd signal.details["fdv"] = fdv signal.details["volume_24h"] = volume_24h # Score: low liquidity = higher risk if liquidity_usd < 1000: signal.score += 40 signal.findings.append("Extremely low liquidity (<$1K)") elif liquidity_usd < 10000: signal.score += 25 signal.findings.append("Low liquidity (<$10K)") elif liquidity_usd < 100000: signal.score += 15 else: signal.score += 5 # Score: low liquidity-to-FDV ratio = manipulation risk if fdv > 0 and liquidity_usd > 0: liq_ratio = liquidity_usd / fdv if liq_ratio < 0.01: signal.score += 20 signal.findings.append("Critical: Liquidity-to-FDV ratio <1% - easy to dump") elif liq_ratio < 0.05: signal.score += 10 signal.findings.append("Low liquidity-to-FDV ratio") # Score: zero volume or very low = suspicious if volume_24h < 100: signal.score += 15 signal.findings.append("Near-zero 24h trading volume") except Exception as e: logger.warning("Liquidity analysis failed: %s", e) signal.findings.append("Liquidity data unavailable - scoring conservative") signal.score = min(signal.score, 100) return signal async def _analyze_holder_concentration(client: httpx.AsyncClient, token_address: str, chain: str) -> SignalResult: """Analyze token holder distribution for concentration risk.""" signal = SignalResult( signal="holder_concentration", score=0.0, weight=DEFAULT_WEIGHTS["holder_concentration"], findings=[], ) if chain == "solana": try: # Try Birdeye public API for holder info resp = await client.get( f"{FREE_APIS['birdeye_public']}/public/token/token_owner?address={token_address}", headers={"accept": "application/json"}, timeout=10, ) if resp.status_code == 200: data = resp.json() holders = data.get("data", {}).get("tokenOwner", []) or data.get("data", {}).get("list", []) or [] if holders: total_supply_analyzed = sum(float(h.get("uiAmount", 0) or 0) for h in holders) top10 = sorted(holders, key=lambda h: float(h.get("uiAmount", 0) or 0), reverse=True)[:10] top10_pct = ( sum(float(h.get("uiAmount", 0) or 0) for h in top10) / total_supply_analyzed if total_supply_analyzed > 0 else 0 ) signal.details["top_10_concentration_pct"] = round(top10_pct * 100, 1) if top10_pct > 0.8: signal.score += 45 signal.findings.append( f"Top 10 holders control {top10_pct * 100:.0f}% of supply - extreme concentration" ) elif top10_pct > 0.5: signal.score += 30 signal.findings.append(f"Top 10 holders control {top10_pct * 100:.0f}% - high concentration") elif top10_pct > 0.3: signal.score += 15 else: signal.score += 5 except Exception as e: logger.warning("Holder concentration analysis failed: %s", e) signal.findings.append("Holder data unavailable") # Conservative scoring if we lack data if not signal.findings: signal.score = 30 # moderate default signal.findings.append("Limited holder data - scored conservatively") signal.score = min(signal.score, 100) return signal async def _analyze_deployer(client: httpx.AsyncClient, deployer: str | None, chain: str) -> SignalResult: """Analyze deployer risk based on available data.""" signal = SignalResult( signal="deployer", score=0.0, weight=DEFAULT_WEIGHTS["deployer"], findings=[], ) if not deployer: signal.score = 50 signal.findings.append("Deployer address unknown - cannot verify history") signal.score = min(signal.score, 100) return signal try: if chain == "solana": # Try to check if deployer has multiple tokens resp = await client.get( f"{FREE_APIS['birdeye_public']}/public/token/list?creator={deployer}", headers={"accept": "application/json"}, timeout=10, ) if resp.status_code == 200: data = resp.json() tokens_deployed = data.get("data", {}).get("items", []) token_count = len(tokens_deployed) signal.details["deployer_token_count"] = token_count if token_count > 20: signal.score += 40 signal.findings.append(f"Serial deployer: {token_count} tokens created - potential rug factory") elif token_count > 10: signal.score += 25 signal.findings.append(f"Active deployer: {token_count} tokens created") elif token_count > 5: signal.score += 10 else: signal.score += 5 else: # EVM - check if deployer is a known contract factory signal.score += 10 signal.findings.append("EVM deployer - limited public history available") except Exception as e: logger.warning("Deployer analysis failed: %s", e) signal.findings.append("Deployer data unavailable") signal.score = min(signal.score, 100) return signal async def _analyze_launch_pattern(client: httpx.AsyncClient, token_address: str, chain: str) -> SignalResult: """Analyze launch pattern for manipulation signs.""" signal = SignalResult( signal="launch_pattern", score=20.0, # Start at moderate risk as baseline weight=DEFAULT_WEIGHTS["launch_pattern"], findings=[], ) try: # Use DexScreener for trading data url = f"{FREE_APIS['dexscreener']}/search?q={token_address}" resp = await client.get(url, timeout=10) if resp.status_code == 200: data = resp.json() pairs = data.get("pairs", []) if pairs: pair = pairs[0] pair_age_hours = None if pair.get("pairCreatedAt"): created = pair["pairCreatedAt"] / 1000 # ms to s pair_age_hours = (time.time() - created) / 3600 signal.details["age_hours"] = round(pair_age_hours, 1) txns = pair.get("txns", {}) buys_h24 = int(txns.get("h24", {}).get("buys", 0)) sells_h24 = int(txns.get("h24", {}).get("sells", 0)) signal.details["buys_24h"] = buys_h24 signal.details["sells_24h"] = sells_h24 # Very young pair = higher risk if pair_age_hours is not None: if pair_age_hours < 1: signal.score += 25 signal.findings.append(f"Pair is {pair_age_hours:.1f}h old - extremely young, high risk") elif pair_age_hours < 24: signal.score += 15 signal.findings.append(f"Pair is {pair_age_hours:.1f}h old - recent launch") # Abnormal buy/sell ratio total_txns = buys_h24 + sells_h24 if total_txns > 0: sell_ratio = sells_h24 / total_txns if sell_ratio > 0.8: signal.score += 20 signal.findings.append(f"Sell ratio {sell_ratio * 100:.0f}% - heavy selling pressure") elif sell_ratio > 0.6: signal.score += 10 elif sell_ratio < 0.1 and buys_h24 > 50: signal.score += 15 signal.findings.append("Abnormally low sell ratio - possible bot wash trading") except Exception as e: logger.warning("Launch pattern analysis failed: %s", e) signal.findings.append("Launch data partially available") signal.score = min(signal.score, 100) return signal async def _analyze_trading_behavior(client: httpx.AsyncClient, token_address: str, chain: str) -> SignalResult: """Analyze trading behavior for manipulation signs.""" signal = SignalResult( signal="trading_behavior", score=10.0, weight=DEFAULT_WEIGHTS["trading_behavior"], findings=[], ) try: url = f"{FREE_APIS['dexscreener']}/search?q={token_address}" resp = await client.get(url, timeout=10) if resp.status_code == 200: data = resp.json() pairs = data.get("pairs", []) if pairs: pair = pairs[0] liquidity_usd = float(pair.get("liquidity", {}).get("usd", 0) or 0) volume_24h = float(pair.get("volume", {}).get("h24", 0) or 0) # Suspicious volume-to-liquidity ratio (wash trading indicator) if liquidity_usd > 0 and volume_24h > 0: vol_liq_ratio = volume_24h / liquidity_usd if vol_liq_ratio > 20: signal.score += 25 signal.findings.append( f"Volume/liquidity ratio of {vol_liq_ratio:.0f}x - possible wash trading" ) elif vol_liq_ratio > 10: signal.score += 10 elif liquidity_usd == 0 and volume_24h > 0: signal.score += 20 signal.findings.append("Volume with zero liquidity - suspicious") # Price change indicators price_change_h24 = pair.get("priceChange", {}).get("h24", 0) if isinstance(price_change_h24, int | float): if price_change_h24 < -50: signal.score += 20 signal.findings.append(f"Price dropped {abs(price_change_h24):.0f}% in 24h") elif price_change_h24 > 500: signal.score += 10 signal.findings.append(f"Price surged {price_change_h24:.0f}% in 24h - potential pump") except Exception as e: logger.warning("Trading behavior analysis failed: %s", e) signal.score = min(signal.score, 100) return signal async def _analyze_social_signals(token_address: str, chain: str) -> SignalResult: """Analyze social signals (placeholder - relies on chain metadata).""" signal = SignalResult( signal="social_signals", score=20.0, # Default conservative weight=DEFAULT_WEIGHTS["social_signals"], findings=["Social signal analysis limited with free data sources"], ) # Conservative scoring: no social data = moderate risk signal.score = 30 return signal # ── Main Analysis ─────────────────────────────────────────────── async def predict_rug_pull( token_address: str, chain: str = "solana", deployer: str | None = None, weights: dict[str, float] | None = None, ) -> RugPullAnalysis: """ Predict rug pull risk for a given token. Args: token_address: The token contract/mint address chain: Blockchain (solana, ethereum, bsc, etc.) deployer: Optional deployer/creator address for history check weights: Optional custom signal weights (must sum to 1.0) Returns: RugPullAnalysis with overall score and per-signal breakdown """ if not is_valid_address(token_address): raise ValueError(f"Invalid token address: {token_address}") chain = _normalize_chain(chain) effective_weights = weights or DEFAULT_WEIGHTS # Validate weights if abs(sum(effective_weights.values()) - 1.0) > 0.01: raise ValueError("Signal weights must sum to 1.0") token_symbol = "" token_name = "" # Run all signal analyzers in parallel (single client for efficiency) async with httpx.AsyncClient(timeout=30.0) as client: # Quick metadata fetch for display try: search_resp = await client.get(f"{FREE_APIS['dexscreener']}/search?q={token_address}", timeout=10) if search_resp.status_code == 200: pairs = search_resp.json().get("pairs", []) if pairs: token_symbol = pairs[0].get("baseToken", {}).get("symbol", "") token_name = pairs[0].get("baseToken", {}).get("name", "") except Exception: pass results = await asyncio.gather( _analyze_liquidity(client, token_address, chain), _analyze_holder_concentration(client, token_address, chain), _analyze_deployer(client, deployer, chain), _analyze_launch_pattern(client, token_address, chain), _analyze_trading_behavior(client, token_address, chain), _analyze_social_signals(token_address, chain), return_exceptions=True, ) signals: list[SignalResult] = [] for r in results: if isinstance(r, SignalResult): signals.append(r) elif isinstance(r, Exception): logger.warning("Signal analysis exception: %s", r) signals.append( SignalResult( signal="unknown", score=50.0, weight=0.05, findings=[f"Analysis error: {r}"], ) ) # Calculate weighted overall score using ternary total_weight = sum(s.weight for s in signals) overall_score = sum(s.score * s.weight for s in signals) / total_weight if total_weight > 0 else 50.0 overall_score = min(max(overall_score, 0), 100) overall_level = _level_from_score(overall_score) # Generate summary if overall_score >= 70: summary = ( f"CRITICAL RISK: This token shows strong rug pull indicators " f"(score: {overall_score:.0f}/100). Multiple red flags detected " f"across {sum(1 for s in signals if s.score > 50)} risk dimensions." ) elif overall_score >= 50: summary = ( f"HIGH RISK: This token has concerning signals " f"(score: {overall_score:.0f}/100). Proceed with extreme caution." ) elif overall_score >= 30: summary = ( f"MODERATE RISK: Some concerning patterns detected " f"(score: {overall_score:.0f}/100). Standard due diligence recommended." ) else: summary = ( f"LOW RISK: No major rug pull indicators detected " f"(score: {overall_score:.0f}/100). Continue standard safety checks." ) # Generate recommendations recommendations = [] if overall_score >= 50: recommendations.append("AVOID trading this token - high rug pull probability") if any(s.score > 60 for s in signals if s.signal == "liquidity"): recommendations.append("Liquidity is critically low or manipulable") if any(s.score > 50 for s in signals if s.signal == "holder_concentration"): recommendations.append("Top holders control excessive supply - potential dump risk") if any(s.score > 50 for s in signals if s.signal == "deployer"): recommendations.append("Deployer has concerning history - check their other projects") if recommendations: recommendations.append("Always verify with RugShield or SENTINEL deep scan before trading") return RugPullAnalysis( token_address=token_address, chain=chain, token_symbol=token_symbol or "UNKNOWN", token_name=token_name or "Unknown Token", overall_score=overall_score, overall_level=overall_level, signals=signals, summary=summary, recommendations=recommendations or ["No specific concerns detected"], analysis_timestamp=datetime.now(UTC).isoformat(), ) # ── Synchronous Wrapper ───────────────────────────────────────── def analyze(token_address: str, chain: str = "solana", **kwargs: Any) -> dict[str, Any]: """ Synchronous entry point for rug pull prediction. Args: token_address: Token contract/mint address chain: Blockchain name **kwargs: Additional options (deployer, weights) Returns: Dict with full analysis results """ result = asyncio.run(predict_rug_pull(token_address, chain, **kwargs)) return result.to_dict() # ── CLI Entry Point ───────────────────────────────────────────── if __name__ == "__main__": import sys logging.basicConfig(level=logging.INFO) if len(sys.argv) < 2: print("Usage: python3 rug_pull_predictor.py [chain] [deployer]") sys.exit(1) addr = sys.argv[1] chain = sys.argv[2] if len(sys.argv) > 2 else "solana" deployer = sys.argv[3] if len(sys.argv) > 3 else None result = analyze(addr, chain, deployer=deployer) print(json.dumps(result, indent=2))