""" SENTINEL — Developer Reputation Engine ====================================== Tracks every token ever launched by a wallet, their outcomes, holding patterns, and time-to-rug metrics. Detects serial ruggers through pattern matching and cross-chain address portability. Scoring: rug_rate >= 80% with 3+ launches → score 0-20 (serial rugger) rug_rate >= 50% → score 20-40 (high risk) avg_lifespan < 7 days with 2+ launches → score 25-35 dev holding 0% at rug time → high confidence rug signal Cross-chain same address → score 0-15 (same operator everywhere) Data sources (direct API calls — no self.api_base): DexScreener: Token search by deployer address (free, no key needed) Etherscan-family: EVM deployer transaction history Moralis: Cross-chain wallet token history """ import logging import os from dataclasses import dataclass, field from datetime import UTC, datetime from enum import Enum import httpx from app.chain_registry import CHAINS logger = logging.getLogger("dev_reputation") # ── API Keys ──────────────────────────────────────────────── ETHERSCAN_API_KEY = os.getenv("ETHERSCAN_API_KEY", "") BSCSCAN_API_KEY = os.getenv("BSCSCAN_API_KEY", "") POLYGONSCAN_API_KEY = os.getenv("POLYGONSCAN_API_KEY", "") SNOWTRACE_API_KEY = os.getenv("SNOWTRACE_API_KEY", "") FTMSCAN_API_KEY = os.getenv("FTMSCAN_API_KEY", "") MORALIS_API_KEY = os.getenv("MORALIS_API_KEY", "") DEXSCREENER_URL = "https://api.dexscreener.com" MORALIS_DATA_URL = "https://deep-index.moralis.io/api/v2.2" ETHERSCAN_NETWORKS = { "ethereum": {"url": "https://api.etherscan.io/api", "key": ETHERSCAN_API_KEY}, "eth": {"url": "https://api.etherscan.io/api", "key": ETHERSCAN_API_KEY}, "bsc": {"url": "https://api.bscscan.com/api", "key": BSCSCAN_API_KEY}, "polygon": {"url": "https://api.polygonscan.com/api", "key": POLYGONSCAN_API_KEY}, "avalanche": {"url": "https://api.snowtrace.io/api", "key": SNOWTRACE_API_KEY}, "fantom": {"url": "https://api.ftmscan.com/api", "key": FTMSCAN_API_KEY}, "arbitrum": {"url": "https://api.arbiscan.io/api", "key": ETHERSCAN_API_KEY}, "optimism": {"url": "https://api-optimistic.etherscan.io/api", "key": ETHERSCAN_API_KEY}, "base": {"url": "https://api.basescan.org/api", "key": ETHERSCAN_API_KEY}, } MORALIS_CHAIN_MAP = { "ethereum": "eth", "eth": "eth", "bsc": "bsc", "polygon": "polygon", "avalanche": "avalanche", "fantom": "fantom", "arbitrum": "arbitrum", "optimism": "optimism", "base": "base", } # DexScreener chain ID mapping (used in pair responses) DEXSCREENER_CHAIN_MAP = { "ethereum": "ethereum", "eth": "ethereum", "bsc": "bsc", "polygon": "polygon", "avalanche": "avalanche", "fantom": "fantom", "arbitrum": "arbitrum", "optimism": "optimism", "base": "base", "solana": "solana", } class ReputationLevel(Enum): TRUSTED = "trusted" # score >= 70 NEUTRAL = "neutral" # score 40-69 SUSPICIOUS = "suspicious" # score 20-39 DANGEROUS = "dangerous" # score < 20 @dataclass class TokenLaunch: address: str chain: str name: str = "" symbol: str = "" launch_timestamp: int = 0 max_market_cap: float = 0.0 lifespan_days: float = 0.0 outcome: str = "unknown" # "active", "rugged", "abandoned", "unknown" dev_holds_percentage: float = 0.0 # Dev's holding at time of outcome is_renounced: bool = False # Did dev renounce ownership? has_locked_liquidity: bool = False @dataclass class DevReputationReport: dev_wallet: str chains: list[str] total_launches: int rugged_count: int rug_rate: float # 0.0-1.0 avg_lifespan_days: float avg_max_mcap: float retention_rate: float # % of launches where dev still holds tokens serial_patterns: list[str] # Detected serial rugger patterns cross_chain_addresses: list[dict[str, str]] # Same dev on other chains launches: list[TokenLaunch] = field(default_factory=list) dev_threat_index: float = 0.0 # (Holdings% × RugRate) / LockDays reputation_score: int = 50 # 0-100, higher = safer reputation_level: ReputationLevel = ReputationLevel.NEUTRAL confidence: str = "LOW" # LOW, MEDIUM, HIGH warnings: list[str] = field(default_factory=list) class DevReputationEngine: """Calculates developer reputation based on launch history and patterns. Uses direct API calls: - DexScreener for token launch discovery (free, no key) - Etherscan-family for EVM transaction history - Moralis for cross-chain wallet data """ def __init__(self): self.client = httpx.AsyncClient(timeout=15.0) # ── DexScreener helpers ──────────────────────────────────── async def _dexscreener_search(self, query: str) -> dict | None: """Search DexScreener for pairs matching a query (deployer address, etc.).""" try: resp = await self.client.get(f"{DEXSCREENER_URL}/latest/dex/search", params={"q": query}) if resp.status_code == 200: return resp.json() except Exception as e: logger.warning(f"DexScreener search failed for '{query}': {e}") return None async def _dexscreener_token_info(self, token_address: str) -> dict | None: """Get DexScreener pair data for a specific token.""" try: resp = await self.client.get(f"{DEXSCREENER_URL}/latest/dex/tokens/{token_address}") if resp.status_code == 200: return resp.json() except Exception as e: logger.warning(f"DexScreener token lookup failed for {token_address}: {e}") return None # ── Etherscan helpers ────────────────────────────────────── async def _etherscan_get(self, chain: str, params: dict) -> dict | None: """Make an Etherscan-family API call.""" net = ETHERSCAN_NETWORKS.get(chain.lower()) if not net: return None api_key = net["key"] if not api_key: logger.warning(f"No Etherscan API key for {chain}") return None params["apikey"] = api_key try: resp = await self.client.get(net["url"], params=params) if resp.status_code == 200: data = resp.json() if data.get("status") == "1": return data.get("result") return data except Exception as e: logger.warning(f"Etherscan call failed for {chain}: {e}") return None # ── Moralis helpers ──────────────────────────────────────── def _moralis_chain(self, chain: str) -> str: """Convert chain name to Moralis chain param.""" return MORALIS_CHAIN_MAP.get(chain.lower(), "eth") async def _moralis_get(self, url: str) -> dict | None: """Make a Moralis Data API call.""" if not MORALIS_API_KEY: logger.warning("MORALIS_API_KEY not set") return None try: resp = await self.client.get(url, headers={"X-API-Key": MORALIS_API_KEY, "Accept": "application/json"}) if resp.status_code == 200: data = resp.json() if isinstance(data, list): return data return data.get("result", data.get("data", data)) except Exception as e: logger.warning(f"Moralis call failed: {e}") return None # ── Core logic ────────────────────────────────────────── def calculate_reputation(self, launches: list[TokenLaunch], dev_wallet: str) -> DevReputationReport: """Calculate reputation from known token launches.""" if not launches: return DevReputationReport( dev_wallet=dev_wallet, chains=[], total_launches=0, rugged_count=0, rug_rate=0.0, avg_lifespan_days=0.0, avg_max_mcap=0.0, retention_rate=0.0, serial_patterns=[], cross_chain_addresses=[], reputation_score=50, reputation_level=ReputationLevel.NEUTRAL, confidence="LOW", warnings=["New deployer — no history"], ) # Calculate core metrics total = len(launches) rugged = sum(1 for l in launches if l.outcome == "rugged") rug_rate = rugged / total if total else 0 avg_lifespan = sum(l.lifespan_days for l in launches) / total if total else 0 avg_mcap = sum(l.max_market_cap for l in launches) / total if total else 0 retention = sum(1 for l in launches if l.dev_holds_percentage > 0) / total if total else 0 # Detect serial patterns patterns = self._detect_serial_patterns(launches) # Calculate reputation score score, level, confidence = self._calculate_score(total, rugged, rug_rate, avg_lifespan, retention, patterns) # Calculate Dev Threat Index: (Holdings% × Reputation) / LockDays avg_holdings = sum(l.dev_holds_percentage for l in launches) / total if total else 0 avg_lock_days = max( sum(l.lifespan_days for l in launches if l.has_locked_liquidity) / max(sum(1 for l in launches if l.has_locked_liquidity), 1), 1, ) threat_index = (avg_holdings * max(rug_rate, 0.1)) / max(avg_lock_days, 1) # Build warnings warnings = [] if rug_rate >= 0.8 and total >= 3: warnings.append(f"SERIAL RUGGER: {rugged}/{total} projects rugged") elif rug_rate >= 0.5: warnings.append(f"HIGH RUG RATE: {rugged}/{total} projects rugged ({rug_rate * 100:.0f}%)") if avg_lifespan < 7 and total >= 2: warnings.append(f"SHORT-LIVED: Average lifespan {avg_lifespan:.1f} days — quick-exit pattern") if retention < 0.2 and total >= 2: warnings.append(f"DUMP & RUN: Dev holds 0% in {(1 - retention) * 100:.0f}% of projects") for pattern in patterns: warnings.append(f"PATTERN: {pattern}") chains = list({l.chain for l in launches}) report = DevReputationReport( dev_wallet=dev_wallet, chains=chains, total_launches=total, rugged_count=rugged, rug_rate=round(rug_rate, 3), avg_lifespan_days=round(avg_lifespan, 1), avg_max_mcap=round(avg_mcap, 0), retention_rate=round(retention, 3), serial_patterns=patterns, cross_chain_addresses=[], # Filled by cross-chain lookup launches=launches, dev_threat_index=round(min(threat_index, 100), 2), reputation_score=score, reputation_level=level, confidence=confidence, warnings=warnings, ) return report def _detect_serial_patterns(self, launches: list[TokenLaunch]) -> list[str]: """Detect common serial rugger patterns.""" patterns = [] if not launches: return patterns # Pattern 1: High rug rate (3+ launches, 80%+ rugged) total = len(launches) rugged = sum(1 for l in launches if l.outcome == "rugged") if total >= 3 and rugged / total >= 0.8: patterns.append(f"Serial rugger: {rugged}/{total} projects rugged") # Pattern 2: Short-lived projects (avg < 7 days) avg_lifespan = sum(l.lifespan_days for l in launches) / total if total >= 2 and avg_lifespan < 7: patterns.append(f"Short-lived projects (avg {avg_lifespan:.1f} days)") # Pattern 3: Dev dumps entire allocation before rug zero_hold = sum(1 for l in launches if l.outcome == "rugged" and l.dev_holds_percentage < 1) if zero_hold >= 2: patterns.append(f"Dev dumps before rug ({zero_hold} times)") # Pattern 4: Similar naming/metadata patterns names = [l.name.lower() for l in launches if l.name] if len(set(names)) < len(names) * 0.5 and len(names) >= 3: patterns.append("Similar naming pattern across launches") # Pattern 5: No renunciation across all launches if total >= 3 and not any(l.is_renounced for l in launches): patterns.append("No authority renunciation in any launch") # Pattern 6: Same launch timing (multiple launches in same week) timestamps = [l.launch_timestamp for l in launches if l.launch_timestamp > 0] if len(timestamps) >= 3: timestamps.sort() close_launches = sum( 1 for i in range(1, len(timestamps)) if (timestamps[i] - timestamps[i - 1]) < 7 * 86400000 ) if close_launches >= 2: patterns.append(f"Rapid-fire launches ({close_launches + 1} in same week)") return patterns def _calculate_score( self, total: int, rugged: int, rug_rate: float, avg_lifespan: float, retention: float, patterns: list[str], ) -> tuple: """Calculate reputation score (0-100, higher = safer).""" # Base score starts at 70 (neutral for new deployers) score = 70 # Adjust for rug rate if rug_rate >= 0.8 and total >= 3: score = max(0, 20 - total * 2) # Serial rugger: 0-20 elif rug_rate >= 0.5: score = 30 # High risk elif avg_lifespan < 7 and total >= 2: score = 35 # Short-lived else: score = 70 + int((1 - rug_rate) * 30) # 70-100 # Adjust for retention (dev holding tokens = less likely to rug) if retention >= 0.5: score = min(100, score + 10) elif retention < 0.1: score = max(0, score - 15) # Adjust for pattern count score = max(0, score - len(patterns) * 5) score = max(0, min(100, score)) if score >= 70: level = ReputationLevel.TRUSTED confidence = "HIGH" elif score >= 40: level = ReputationLevel.NEUTRAL confidence = "MEDIUM" elif score >= 20: level = ReputationLevel.SUSPICIOUS confidence = "HIGH" else: level = ReputationLevel.DANGEROUS confidence = "VERY_HIGH" return score, level, confidence async def analyze(self, dev_wallet: str, chains: list[str] | None = None) -> DevReputationReport: """Full dev reputation analysis. Steps: 1. Fetch all tokens launched by dev_wallet (DexScreener search) 2. For each, determine outcome (active/rugged/abandoned) 3. Check dev holding percentage at rug time 4. Detect serial patterns 5. Cross-chain address lookup (Moralis) """ if chains is None: chains = list(CHAINS.keys())[:4] # Fetch token launches from DexScreener launches = [] for chain in chains: chain_launches = await self._fetch_launches(dev_wallet, chain) launches.extend(chain_launches) # Build and return reputation report report = self.calculate_reputation(launches, dev_wallet) # Cross-chain address lookup via Moralis report.cross_chain_addresses = await self._cross_chain_lookup(dev_wallet) return report async def _fetch_launches(self, dev_wallet: str, chain: str) -> list[TokenLaunch]: """Fetch all tokens launched by dev on a specific chain. Uses DexScreener to find pairs where the deployer matches. DexScreener's /latest/dex/search?q=
returns pairs whose baseToken or quoteToken involves the address. We filter for pairs where the creator/deployer matches. """ launches = [] # ── Strategy 1: DexScreener search by deployer address ── search_data = await self._dexscreener_search(dev_wallet) if search_data and search_data.get("pairs"): chain_id = DEXSCREENER_CHAIN_MAP.get(chain, chain) for pair in search_data.get("pairs", [])[:25]: # Filter by chain if pair.get("chainId") != chain_id and chain != "all": continue # Extract deployer/creator info pair_creator = pair.get("info", {}).get("deployer", "") base_addr = pair.get("baseToken", {}).get("address", "") base_name = pair.get("baseToken", {}).get("name", "") base_symbol = pair.get("baseToken", {}).get("symbol", "") created_at = pair.get("pairCreatedAt", 0) # Accept pairs where the deployer matches, or the address # appears as base/quote token (indirect match) is_creator = pair_creator.lower() == dev_wallet.lower() if pair_creator else False is_match = dev_wallet.lower() in ( base_addr.lower(), pair.get("quoteToken", {}).get("address", "").lower(), ) if not is_creator and not is_match: continue # Determine outcome from liquidity and volume liquidity = float(pair.get("liquidity", {}).get("usd", 0) or 0) volume_24h = float(pair.get("volume", {}).get("h24", 0) or 0) mcap = float(pair.get("marketCap", 0) or pair.get("mc", 0) or 0) # Heuristic outcome determination now_ms = int(datetime.now(UTC).timestamp() * 1000) age_days = ((now_ms - created_at) / 86400000) if created_at else 0 if liquidity < 100 and age_days > 7: outcome = "rugged" elif volume_24h == 0 and age_days > 30: outcome = "abandoned" elif mcap > 0 or liquidity > 0: outcome = "active" else: outcome = "unknown" launches.append( TokenLaunch( address=base_addr, chain=chain, name=base_name, symbol=base_symbol, launch_timestamp=int(created_at / 1000) if created_at else 0, max_market_cap=mcap, lifespan_days=round(age_days, 1), outcome=outcome, dev_holds_percentage=0, # Requires deeper analysis is_renounced=False, # Requires contract verification has_locked_liquidity=liquidity > 0, ) ) # ── Strategy 2 (EVM only): Etherscan contract creation ── if chain in ETHERSCAN_NETWORKS: etherscan_launches = await self._fetch_launches_etherscan(dev_wallet, chain) # Merge, avoiding duplicates by address existing_addrs = {l.address.lower() for l in launches} for launch in etherscan_launches: if launch.address.lower() not in existing_addrs: launches.append(launch) existing_addrs.add(launch.address.lower()) return launches async def _fetch_launches_etherscan(self, dev_wallet: str, chain: str) -> list[TokenLaunch]: """Fetch contract creations by dev_wallet from Etherscan-family APIs. Uses the 'txlist' action to find contract creation transactions. """ launches = [] tx_list = await self._etherscan_get( chain, { "module": "account", "action": "txlist", "address": dev_wallet, "startblock": 0, "endblock": 99999999, "page": 1, "offset": 50, "sort": "asc", }, ) if tx_list and isinstance(tx_list, list): for tx in tx_list[:25]: # Contract creation: to is empty or starts with "0x0" and input is non-empty to_addr = tx.get("to", "") tx_input = tx.get("input", "") is_creation = to_addr == "" or tx.get("contractAddress", "") != "" if is_creation and tx_input and len(tx_input) > 10: contract_addr = tx.get("contractAddress", "") if not contract_addr: continue timestamp = int(tx.get("timeStamp", 0)) launches.append( TokenLaunch( address=contract_addr, chain=chain, name="", # Would need separate contract ABI call symbol="", launch_timestamp=timestamp, max_market_cap=0, lifespan_days=0, outcome="unknown", dev_holds_percentage=0, ) ) return launches async def _cross_chain_lookup(self, dev_wallet: str) -> list[dict[str, str]]: """Find the same dev address on other chains via Moralis wallet data. Checks if the wallet has activity (tokens, NFTs, transactions) on other EVM chains. """ if not MORALIS_API_KEY: return [] cross_chain = [] for chain_key in ["eth", "bsc", "polygon", "arbitrum", "base", "avalanche"]: try: url = f"{MORALIS_DATA_URL}/{dev_wallet}/erc20?chain={chain_key}&limit=5" result = await self._moralis_get(url) if result and isinstance(result, list) and len(result) > 0: chain_name = MORALIS_CHAIN_MAP.get(chain_key, chain_key) # Only include if different from the chains already known cross_chain.append( { "chain": chain_name, "address": dev_wallet, "token_count": str(len(result)), "has_activity": "true", } ) except Exception: continue return cross_chain