""" Governance Attack & Concentration Risk Detector ================================================ Detects governance takeover vulnerabilities, voting concentration, timelock bypass risks, and DAO governance attacks in crypto tokens. Signals detected: - Top holder supply concentration (>50% = critical) - Top 10 holder cartel risk (>80%) - Missing or insufficient timelocks on admin functions - Dangerously low quorum thresholds (enabling flash-loan governance attacks) - Single-entity veto power (Solana mint authority not renounced) - Governance parameter manipulation vectors - Flash-loan governance attack feasibility assessment Tier: Premium ($0.08) Endpoint: POST /api/v1/x402-tools/governance_attack """ import contextlib import logging import os from dataclasses import asdict, dataclass, field from datetime import datetime from typing import Any logger = logging.getLogger("governance_attack_detector") # ── Risk thresholds ────────────────────────────────────────────── TOP_HOLDER_CRITICAL_PCT = 50.0 TOP_HOLDER_HIGH_PCT = 30.0 TOP_10_CRITICAL_PCT = 80.0 TOP_10_HIGH_PCT = 60.0 LOW_QUORUM_PCT = 1.0 MIN_TIMELOCK_HOURS = 24 CRITICAL_VOTE_DIFFERENTIAL_PCT = 5.0 # If votes swing >5% in block, suspicious # ── Free API endpoints ────────────────────────────────────────── DEXSCREENER_API = "https://api.dexscreener.com/latest/dex/tokens/{}" SOLSCAN_TOKEN_HOLDERS = "https://api.solscan.io/v2/token/holders?token={}&limit=20" BIRDEYE_HOLDER = "https://public-api.birdeye.so/defi/v3/token/holder?address={}" HELIUS_WEBHOOK = "https://api.helius.xyz/v0/token-metadata?mintAddress={}" # ── Dataclasses ────────────────────────────────────────────────── @dataclass class HolderStake: address: str = "" percentage: float = 0.0 label: str = "" is_contract: bool = False is_exchange: bool = False @dataclass class GovernanceParams: timelock_address: str = "" timelock_delay_seconds: int = 0 quorum_threshold_pct: float = 0.0 proposal_threshold_pct: float = 0.0 voting_period_blocks: int = 0 has_timelock: bool = False is_governance_contract: bool = False @dataclass class GovernanceAttackResult: token_address: str chain: str timestamp: str = "" top_holder_pct: float = 0.0 top_10_holder_pct: float = 0.0 top_holders: list[dict] = field(default_factory=list) governance_params: dict | None = None is_governed: bool = False flash_loan_feasible: bool = False risk_score: int = 0 risk_level: str = "LOW" flags: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) details: dict[str, Any] = field(default_factory=dict) # ─── HTTP helpers (minimal dependencies) ───────────────────────── async def _fetch_json( url: str, headers: dict | None = None, params: dict | None = None, timeout: int = 10 ) -> Any: """Single URL fetch returning parsed JSON or None.""" import aiohttp try: async with aiohttp.ClientSession() as session, session.get( url, headers=headers, params=params, timeout=aiohttp.ClientTimeout(total=timeout) ) as resp: if resp.status == 200: return await resp.json() except Exception as e: logger.debug(f"Fetch failed: {url} - {e}") return None async def _eth_call(rpc_url: str, to: str, data: str) -> str | None: """Low-level eth_call to read contract state.""" import aiohttp try: async with aiohttp.ClientSession() as session, session.post( rpc_url, json={ "jsonrpc": "2.0", "id": 1, "method": "eth_call", "params": [{"to": to, "data": data}, "latest"], }, timeout=aiohttp.ClientTimeout(total=10), ) as resp: if resp.status == 200: body = await resp.json() return body.get("result") except Exception as e: logger.debug(f"eth_call failed for {to}: {e}") return None # ─── Data fetching ─────────────────────────────────────────────── async def _fetch_dexscreener(token_address: str) -> dict | None: """Fetch token pair data from DexScreener (free, no key).""" return await _fetch_json(DEXSCREENER_API.format(token_address)) async def _fetch_solscan_holders(token_address: str) -> list[dict]: """Fetch top holders from Solscan (Solana).""" data = await _fetch_json( "https://api-v2.solscan.io/v2/token/holders", params={"token": token_address, "limit": 20, "offset": 0}, headers={"Accept": "application/json"}, ) if data and isinstance(data, dict): return data.get("data", []) return [] async def _fetch_birdeye_holders(token_address: str) -> list[dict]: """Fetch holders from Birdeye.""" key = os.getenv("BIRDEYE_API_KEY", "") if not key: return [] data = await _fetch_json( BIRDEYE_HOLDER.format(token_address), headers={"X-API-KEY": key, "accept": "application/json"}, ) if data and isinstance(data, dict): items = ( data.get("data", {}).get("items", []) if isinstance(data.get("data"), dict) else data.get("items", []) ) return items return [] async def _fetch_evm_holders(token_address: str, chain: str) -> list[dict]: """Fetch top EVM token holders from explorer API.""" from app.chain_registry import CHAINS chain_cfg = CHAINS.get(chain) if not chain_cfg or not chain_cfg.explorer_api_url: return [] etherscan_key = os.getenv("ETHERSCAN_API_KEY", "") key = ( chain_cfg.get_explorer_api_key() if hasattr(chain_cfg, "get_explorer_api_key") else etherscan_key ) key = key or etherscan_key if not key: return [] url = ( f"{chain_cfg.explorer_api_url}" f"?module=token&action=tokenholderlist" f"&contractaddress={token_address}" f"&page=1&offset=20&apikey={key}" ) data = await _fetch_json(url) if data and isinstance(data, dict): return data.get("result", []) return [] async def _fetch_contract_source(address: str, chain: str) -> str | None: """Fetch verified contract source code.""" from app.chain_registry import CHAINS chain_cfg = CHAINS.get(chain) if not chain_cfg or not chain_cfg.explorer_api_url: return None etherscan_key = os.getenv("ETHERSCAN_API_KEY", "") key = ( chain_cfg.get_explorer_api_key() if hasattr(chain_cfg, "get_explorer_api_key") else etherscan_key ) key = key or etherscan_key if not key: return None url = ( f"{chain_cfg.explorer_api_url}" f"?module=contract&action=getsourcecode&address={address}" f"&apikey={key}" ) data = await _fetch_json(url) if data and isinstance(data, dict): result = data.get("result", []) if result and len(result) > 0: return result[0].get("SourceCode", "") return None def _get_rpc_url(chain: str) -> str | None: """Get first RPC URL for a chain.""" from app.chain_registry import CHAINS chain_cfg = CHAINS.get(chain) if chain_cfg and chain_cfg.rpc_endpoints: return chain_cfg.rpc_endpoints[0] return None # ─── Governance parameter extraction ───────────────────────────── async def _extract_evm_governance_params(token_address: str, chain: str) -> GovernanceParams: """Extract governance parameters from EVM governor contract.""" params = GovernanceParams() source = await _fetch_contract_source(token_address, chain) if not source: return params source_lower = source.lower() rpc_url = _get_rpc_url(chain) if not rpc_url: return params # Detect governance patterns in source code governance_kws = [ "governor", "governance", "igovernor", "governorcompatibilitybravo", "governoralpha", "governorbeta", ] has_governor = any(kw in source_lower for kw in governance_kws) if not has_governor: return params params.is_governance_contract = True params.has_timelock = "timelock" in source_lower # Try to read quorum via eth_call (QUORUM selector: 0x3a221ab9) q_result = await _eth_call(rpc_url, token_address, "0x3a221ab9") if q_result and q_result != "0x" and len(q_result) > 2: try: q_raw = int(q_result, 16) # OZ stores quorum as numerator/1000 (e.g. 4 = 0.4%) params.quorum_threshold_pct = q_raw / 1000.0 if q_raw < 1000 else q_raw / 1e18 * 100 except (ValueError, OverflowError): logger.debug(f"Could not parse quorum result: {q_result}") # Try proposal threshold (selector: 0xb12d4e26) p_result = await _eth_call(rpc_url, token_address, "0xb12d4e26") if p_result and p_result != "0x" and len(p_result) > 2: with contextlib.suppress(ValueError): p_raw = int(p_result, 16) params.proposal_threshold_pct = p_raw / 1e18 * 100 # Try voting period (selector: 0x02f310d7) v_result = await _eth_call(rpc_url, token_address, "0x02f310d7") if v_result and v_result != "0x" and len(v_result) > 2: with contextlib.suppress(ValueError): params.voting_period_blocks = int(v_result, 16) # Check timelock delay if timelock detected (MIN_DELAY selector: 0xd2baa7d2) if params.has_timelock: td_result = await _eth_call(rpc_url, token_address, "0xd2baa7d2") if td_result and td_result != "0x" and len(td_result) > 2: with contextlib.suppress(ValueError): params.timelock_delay_seconds = int(td_result, 16) return params def _check_solana_governance(dex_data: dict | None) -> GovernanceParams: """Infer governance risk for Solana SPL tokens (no EVM gov contracts).""" params = GovernanceParams() if dex_data: pairs = dex_data.get("pairs") or [] for pair in pairs: info = ( pair.get("info", {}) if isinstance(pair.get("info"), dict) else pair.get("baseToken", {}) ) if isinstance(info, dict): # Check for mint authority in info mint_auth = info.get("mintAuthority", info.get("authority", "")) if mint_auth and mint_auth not in ( "", "0x0000000000000000000000000000000000000000", "11111111111111111111111111111111", ): # Authority exists = not renounced = centralized params.is_governance_contract = True params.has_timelock = False else: params.is_governance_contract = True params.has_timelock = True # Immutable = safe return params # ─── Holders parsing ───────────────────────────────────────────── def _parse_holders(raw_holders: list[dict]) -> list[HolderStake]: """Parse raw explorer holder data into structured objects.""" holders = [] for h in raw_holders: addr = h.get("address", h.get("owner", h.get("TokenHolderAddress", ""))) raw_pct = h.get("pct", h.get("percentage", h.get("TokenHolderQuantity", "0"))) try: pct = float(raw_pct) if raw_pct else 0.0 except (ValueError, TypeError): pct = 0.0 label = h.get("label", "") is_contract = bool(h.get("is_contract", h.get("isContract", False))) is_exchange = bool( label and any( ex in label.lower() for ex in ("binance", "coinbase", "okx", "kraken", "bybit", "gate") ) ) holders.append( HolderStake( address=addr, percentage=pct, label=label, is_contract=is_contract, is_exchange=is_exchange, ) ) return sorted(holders, key=lambda h: h.percentage, reverse=True) # ─── Risk scoring ──────────────────────────────────────────────── def _score_governance_risk( top_holder_pct: float, top_10_holder_pct: float, params: GovernanceParams | None, ) -> tuple[int, str, list[str]]: """Calculate governance attack risk score (0-100).""" score = 0 flags = [] # Top holder concentration if top_holder_pct >= TOP_HOLDER_CRITICAL_PCT: score += 35 flags.append(f"CRITICAL: Top holder controls {top_holder_pct:.1f}% of supply") elif top_holder_pct >= TOP_HOLDER_HIGH_PCT: score += 20 flags.append(f"HIGH: Top holder controls {top_holder_pct:.1f}% of supply") elif top_holder_pct >= 15: score += 10 flags.append(f"MEDIUM: Top holder controls {top_holder_pct:.1f}% of supply") # Top 10 concentration if top_10_holder_pct >= TOP_10_CRITICAL_PCT: score += 20 flags.append(f"HIGH: Top 10 holders control {top_10_holder_pct:.1f}% - cartel risk") elif top_10_holder_pct >= TOP_10_HIGH_PCT: score += 10 flags.append(f"MEDIUM: Top 10 holders control {top_10_holder_pct:.1f}%") # Governance parameter risks if params: # Timelock if params.is_governance_contract and not params.has_timelock: score += 25 flags.append("CRITICAL: No timelock - single-transaction governance takeover") elif not params.has_timelock and not params.is_governance_contract: score += 10 flags.append("MEDIUM: No governance timelock detected") # Low quorum if params.quorum_threshold_pct > 0: if params.quorum_threshold_pct < LOW_QUORUM_PCT: score += 20 flags.append( f"HIGH: Quorum only {params.quorum_threshold_pct:.2f}% - " f"flash-loan governance attack feasible" ) elif params.quorum_threshold_pct < 2.0: score += 10 flags.append( f"MEDIUM: Quorum {params.quorum_threshold_pct:.2f}% - below industry standard" ) # Timelock too short if params.has_timelock and params.timelock_delay_seconds > 0: timelock_hours = params.timelock_delay_seconds / 3600 if timelock_hours < MIN_TIMELOCK_HOURS: score += 10 flags.append( f"MEDIUM: Timelock only {timelock_hours:.1f}h - insufficient reaction window" ) # Very short voting period if params.voting_period_blocks > 0: if params.voting_period_blocks < 200: # ~40 min on ETH score += 15 flags.append( f"HIGH: Voting period only {params.voting_period_blocks} blocks - " f"too short for community coordination" ) elif params.voting_period_blocks < 1000: score += 5 flags.append(f"MEDIUM: Voting period {params.voting_period_blocks} blocks") # Low proposal threshold if params.proposal_threshold_pct > 0 and params.proposal_threshold_pct < 0.1: score += 5 flags.append( f"MEDIUM: Proposal threshold {params.proposal_threshold_pct:.3f}% - " f"anyone can propose" ) # Flash loan governance attack feasibility flash_loan_feasible = ( params.is_governance_contract and params.quorum_threshold_pct < LOW_QUORUM_PCT and params.quorum_threshold_pct > 0 ) if flash_loan_feasible: score += 15 flags.append( "CRITICAL: Flash-loan governance attack feasible - " "borrow tokens, pass malicious proposal, repay in one tx" ) score = min(100, score) if score >= 70: level = "CRITICAL" elif score >= 40: level = "HIGH" elif score >= 20: level = "MEDIUM" else: level = "LOW" return score, level, flags # ─── Main detection function ───────────────────────────────────── async def detect_governance_attack( token_address: str, chain: str, ) -> dict[str, Any]: """Run full governance attack risk analysis. Args: token_address: The token/contract address to analyze chain: Blockchain name (e.g. 'ethereum', 'solana', 'bsc') Returns: Dict with risk_score, risk_level, flags, warnings, and full data. """ from app.chain_registry import is_evm, is_solana result = GovernanceAttackResult( token_address=token_address, chain=chain, timestamp=datetime.utcnow().isoformat(), ) # 1. Fetch DEX data + holders dex_data = await _fetch_dexscreener(token_address) raw_holders: list[dict] = [] if is_solana(chain): raw_holders = await _fetch_solscan_holders(token_address) elif is_evm(chain): raw_holders = await _fetch_evm_holders(token_address, chain) # Fallback to Birdeye if primary holder source returns nothing if not raw_holders: raw_holders = await _fetch_birdeye_holders(token_address) # 2. Parse holders holders = _parse_holders(raw_holders) result.top_holders = [asdict(h) for h in holders] if holders: result.top_holder_pct = holders[0].percentage result.top_10_holder_pct = sum(h.percentage for h in holders[:10]) # 3. Extract governance parameters gov_params: GovernanceParams | None = None if is_evm(chain): gov_params = await _extract_evm_governance_params(token_address, chain) elif is_solana(chain): gov_params = _check_solana_governance(dex_data) if gov_params: result.governance_params = { "has_timelock": gov_params.has_timelock, "timelock_delay_seconds": gov_params.timelock_delay_seconds, "quorum_threshold_pct": round(gov_params.quorum_threshold_pct, 4), "proposal_threshold_pct": round(gov_params.proposal_threshold_pct, 4), "voting_period_blocks": gov_params.voting_period_blocks, "is_governance_contract": gov_params.is_governance_contract, } result.is_governed = gov_params.is_governance_contract # Check flash loan feasibility result.flash_loan_feasible = ( gov_params.is_governance_contract and gov_params.quorum_threshold_pct > 0 and gov_params.quorum_threshold_pct < LOW_QUORUM_PCT ) # 4. Calculate risk score result.risk_score, result.risk_level, result.flags = _score_governance_risk( result.top_holder_pct, result.top_10_holder_pct, gov_params ) # 5. Build warnings summary warnings = [] if result.risk_level in ("CRITICAL", "HIGH"): if result.flash_loan_feasible: warnings.append( "FLASH-LOAN GOVERNANCE ATTACK: Quorum threshold is low enough " "that an attacker can borrow enough tokens via flash loan, " "pass any proposal, and repay - all in one transaction." ) if result.top_holder_pct > 50: warnings.append( f"A single wallet controls {result.top_holder_pct:.1f}% - " f"they can unilaterally pass any governance action." ) if not result.is_governed and not is_solana(chain): warnings.append( "Token does not appear to have on-chain governance - " "no governor contract detected in verified source." ) if result.is_governed and result.governance_params: if not result.governance_params.get("has_timelock"): warnings.append( "Governance actions can execute instantly - no timelock delay protects holders." ) result.warnings = warnings # 6. Build details result.details = { "holder_count": len(holders), "top_holder_pct": round(result.top_holder_pct, 2), "top_10_holder_pct": round(result.top_10_holder_pct, 2), "is_governed": result.is_governed, "flash_loan_attack_feasible": result.flash_loan_feasible, "governance_params": result.governance_params, } return asdict(result)