rmi-backend/app/governance_attack_detector.py

585 lines
21 KiB
Python

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