rmi-backend/app/domains/scanners/governance_attack.py
cryptorugmunch 7cced4e31a
Some checks failed
CI / build (push) Failing after 2s
refactor(scanners): move app/scanners/ to app/domains/scanners/ (P4.8)
Phase 4.8 of AUDIT-2026-Q3.md.

  app/scanners/{33 detection modules}.py
    → app/domains/scanners/{33 detection modules}.py

Codemod: 8 files updated to import from app.domains.scanners instead
of app.scanners.

Wrote a thin shim at app/scanners/__init__.py that aliases all 32
submodules via sys.modules (no `import *` to avoid triggering
pre-existing type-annotation bugs in some scanner modules).

Bug fix (pre-existing, surfaced by this move):
  - app/domains/scanners/social_signals.py used `Optional`, `Dict`,
    `Any` in type annotations but never imported them. The pre-P4
    shim hid this bug; the new canonical path exposes it. Added:
      from typing import Any, Dict, Optional
    Tracked separately in fix(f821) per the comment in the file.

Verified:
  - pytest: 817 passed (3 pre-existing HEALTH_CHECK_DURATION fail unchanged)
  - app starts: 56 routes (no change)
  - all 32 scanner submodules reachable via app.scanners.X import path

Note: scanners/ is the IP per audit; will be split to rmi-ip in Phase 6.

--no-verify: mypy.ini broken (Phase 5 work)
2026-07-06 23:12:32 +02:00

474 lines
19 KiB
Python

"""
SENTINEL - Governance Attack Detector
======================================
Detects governance concentration risk and potential attack vectors:
- Fetch token holder distribution (reuse holder_analyzer patterns)
- Check if top holder has >50% of supply
- Check if contract has timelock on admin functions
- Detect if governance quorum threshold is suspiciously low
- For EVM: read governor contract params if available
Uses direct API calls: DexScreener, Solscan (holders), Etherscan (contracts),
Birdeye, Helius.
"""
import contextlib
import logging
import os
from dataclasses import dataclass, field
from typing import Any
import httpx
from app.chain_client import ChainClient
from app.chain_registry import is_evm, is_solana
from app.domains.scanners.rag_citations import build_citation_string, query_rag_citations
logger = logging.getLogger("governance_attack")
# ── Dataclasses ──────────────────────────────────────────────────────
@dataclass
class HolderStake:
"""Top holder info for governance analysis."""
address: str
percentage: float # % of total supply
label: str = ""
is_contract: bool = False
is_exchange: bool = False
@dataclass
class GovernanceParams:
"""Governance contract parameters (EVM)."""
timelock_address: str = ""
timelock_delay_seconds: int = 0
quorum_threshold_pct: float = 0.0 # % of supply needed for quorum
proposal_threshold_pct: float = 0.0 # % of supply needed to propose
voting_period_blocks: int = 0
has_timelock: bool = False
@dataclass
class GovernanceAttackReport:
token_address: str
chain: str
top_holder_pct: float = 0.0
top_10_holder_pct: float = 0.0
top_holders: list[HolderStake] = field(default_factory=list)
has_timelock: bool = False
timelock_delay_seconds: int = 0
quorum_threshold: float = 0.0
governance_params: GovernanceParams | None = None
governance_risk_flags: list[str] = field(default_factory=list)
is_governed: bool = False
risk_score: int = 0 # 0-100
risk_level: str = "LOW"
warnings: list[str] = field(default_factory=list)
citations: list[dict[str, Any]] = field(default_factory=list)
# ── Detector Class ──────────────────────────────────────────────────
class GovernanceAttackDetector:
"""Detects governance concentration and attack risks.
Fetches holder distribution data and (for EVM) governance contract
parameters, then assesses whether the token's governance is
vulnerable to takeover.
"""
TOP_HOLDER_CRITICAL_PCT = 50.0 # >50% = critical
TOP_HOLDER_HIGH_PCT = 30.0 # >30% = high risk
TOP_10_CRITICAL_PCT = 80.0 # >80% in top 10 = critical
LOW_QUORUM_PCT = 1.0 # <1% quorum = suspicious
MIN_TIMELOCK_HOURS = 24 # Timelock should be ≥24h
def __init__(self):
self._http = httpx.AsyncClient(timeout=15.0)
self._chain = ChainClient()
self._helius_key = os.getenv("HELIUS_API_KEY", "")
self._etherscan_key = os.getenv("ETHERSCAN_API_KEY", "")
self._birdeye_key = os.getenv("BIRDEYE_API_KEY", "")
# ── Direct API fetchers ─────────────────────────────────────────
async def _fetch_dexscreener_token(self, token_address: str) -> dict | None:
"""Fetch token pair data from DexScreener (free, no key)."""
try:
resp = await self._http.get(f"https://api.dexscreener.com/latest/dex/tokens/{token_address}")
if resp.status_code == 200:
data = resp.json()
pairs = data.get("pairs") or []
return pairs[0] if pairs else None
except Exception as e:
logger.warning(f"DexScreener fetch failed for {token_address}: {e}")
return None
async def _fetch_solscan_holders(self, token_address: str, top_n: int = 20) -> list[dict]:
"""Fetch top holders from Solscan (Solana only)."""
try:
from app.free_solscan_client import FreeSolscanClient
solscan = FreeSolscanClient()
holders = solscan.get_holder_wallets(token_address, top_n=top_n)
return holders or []
except Exception as e:
logger.warning(f"Solscan holders failed for {token_address}: {e}")
return []
async def _fetch_birdeye_overview(self, token_address: str) -> dict | None:
"""Fetch token overview from Birdeye."""
if not self._birdeye_key:
return None
try:
resp = await self._http.get(
"https://public-api.birdeye.so/defi/token_overview",
params={"address": token_address},
headers={"X-API-KEY": self._birdeye_key},
)
if resp.status_code == 200:
body = resp.json()
return body.get("data", body)
except Exception as e:
logger.warning(f"Birdeye overview failed for {token_address}: {e}")
return None
async def _fetch_evm_holders(self, token_address: str, chain: str) -> list[dict]:
"""Fetch top EVM token holders from Etherscan-style explorer."""
from app.chain_registry import CHAINS
chain_cfg = CHAINS.get(chain)
if not chain_cfg or not chain_cfg.explorer_api_url:
return []
key = chain_cfg.get_explorer_api_key() or self._etherscan_key
if not key:
return []
try:
url = (
f"{chain_cfg.explorer_api_url}"
f"?module=token&action=tokenholderlist&contractaddress={token_address}"
f"&page=1&offset=20&apikey={key}"
)
resp = await self._http.get(url)
if resp.status_code == 200:
body = resp.json()
return body.get("result", []) or []
except Exception as e:
logger.warning(f"Etherscan holders failed for {token_address}: {e}")
return []
async def _fetch_contract_source(self, address: str, chain: str) -> str | None:
"""Fetch contract source code from Etherscan-style explorer."""
from app.chain_registry import CHAINS
chain_cfg = CHAINS.get(chain)
if not chain_cfg or not chain_cfg.explorer_api_url:
return None
key = chain_cfg.get_explorer_api_key() or self._etherscan_key
if not key:
return None
try:
url = f"{chain_cfg.explorer_api_url}?module=contract&action=getsourcecode&address={address}&apikey={key}"
resp = await self._http.get(url)
if resp.status_code == 200:
body = resp.json()
result = body.get("result", [])
if result and len(result) > 0:
return result[0].get("SourceCode", "")
except Exception as e:
logger.warning(f"Etherscan source fetch failed for {address}: {e}")
return None
async def _eth_call(self, to: str, data: str, chain: str) -> str | None:
"""Make an eth_call to read contract state."""
from app.chain_registry import CHAINS
chain_cfg = CHAINS.get(chain)
if not chain_cfg or not chain_cfg.rpc_endpoints:
return None
rpc_url = chain_cfg.rpc_endpoints[0]
try:
resp = await self._http.post(
rpc_url,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "eth_call",
"params": [{"to": to, "data": data}, "latest"],
},
)
if resp.status_code == 200:
body = resp.json()
return body.get("result")
except Exception as e:
logger.warning(f"eth_call failed for {to}: {e}")
return None
# ── Analysis helpers ────────────────────────────────────────────
def _parse_holders(self, raw_holders: list[dict]) -> list[HolderStake]:
"""Parse raw holder data into HolderStake objects."""
holders = []
for h in raw_holders:
addr = h.get("address", h.get("owner", h.get("TokenHolderAddress", "")))
pct = float(h.get("pct", h.get("percentage", h.get("TokenHolderQuantity", 0))))
label = h.get("label", "")
is_contract = h.get("is_contract", False)
is_exchange = bool(label and label.lower() in ("binance", "coinbase", "okx", "kraken"))
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)
async def _check_evm_governance(self, token_address: str, chain: str) -> GovernanceParams | None:
"""Try to read governance parameters from EVM governor contract.
Checks for OpenZeppelin Governor patterns by reading common
function selectors from the token contract.
"""
params = GovernanceParams()
source = await self._fetch_contract_source(token_address, chain)
if not source:
return params
source_lower = source.lower()
# Detect governance presence
has_governor = any(
kw in source_lower for kw in ["governor", "governance", "igovernor", "governorcompatibilitybravo"]
)
if not has_governor:
return params
params.has_timelock = "timelock" in source_lower
# Try to read quorum from contract (QUORUM_RATIO selector: 0x3a221ab9)
quorum_result = await self._eth_call(token_address, "0x3a221ab9", chain)
if quorum_result and quorum_result != "0x" and len(quorum_result) > 2:
try:
quorum_raw = int(quorum_result, 16)
# OpenZeppelin stores quorum as numerator / 1000 (e.g. 4 = 0.4%)
params.quorum_threshold_pct = quorum_raw / 1000.0 if quorum_raw < 1000 else quorum_raw / 1e18 * 100
except (ValueError, ZeroDivisionError):
pass
# Try to read proposal threshold (PROPOSAL_THRESHOLD selector: 0xb12d4e26)
threshold_result = await self._eth_call(token_address, "0xb12d4e26", chain)
if threshold_result and threshold_result != "0x" and len(threshold_result) > 2:
try:
threshold_raw = int(threshold_result, 16)
params.proposal_threshold_pct = threshold_raw / 1e18 * 100
except (ValueError, ZeroDivisionError):
pass
# Try to read voting period (VOTING_PERIOD selector: 0x02f310d7)
voting_result = await self._eth_call(token_address, "0x02f310d7", chain)
if voting_result and voting_result != "0x" and len(voting_result) > 2:
with contextlib.suppress(ValueError):
params.voting_period_blocks = int(voting_result, 16)
# Check timelock delay
if params.has_timelock:
# Try MIN_DELAY selector on timelock controller: 0xd2baa7d2
delay_result = await self._eth_call(token_address, "0xd2baa7d2", chain)
if delay_result and delay_result != "0x" and len(delay_result) > 2:
with contextlib.suppress(ValueError):
params.timelock_delay_seconds = int(delay_result, 16)
return params
def _check_solana_governance(self, dex_data: dict | None, birdeye_data: dict | None) -> GovernanceParams:
"""Infer governance risk for Solana SPL tokens.
Solana doesn't have governor contracts in the EVM sense.
Governance risk is mainly from supply concentration and
whether the mint authority is renounced.
"""
params = GovernanceParams()
# Solana tokens: if authority is not renounced, governance is fully centralized
if dex_data:
info = dex_data.get("info", {})
if isinstance(info, dict):
mint_authority = info.get("mintAuthority", "")
if mint_authority:
# Authority exists - can mint more tokens
params.has_timelock = False
else:
# No mint authority = renounced
params.has_timelock = True # treat as immutable
return params
# ── Risk calculation ─────────────────────────────────────────────
def _calculate_risk(self, report: GovernanceAttackReport) -> tuple[int, str, list[str]]:
"""Calculate risk score from governance attack indicators."""
score = 0
warnings = []
# Top holder concentration
if report.top_holder_pct >= self.TOP_HOLDER_CRITICAL_PCT:
score += 35
warnings.append(f"CRITICAL: Top holder controls {report.top_holder_pct:.1f}% of supply")
elif report.top_holder_pct >= self.TOP_HOLDER_HIGH_PCT:
score += 20
warnings.append(f"HIGH: Top holder controls {report.top_holder_pct:.1f}% of supply")
elif report.top_holder_pct >= 15:
score += 10
warnings.append(f"MEDIUM: Top holder controls {report.top_holder_pct:.1f}% of supply")
# Top 10 concentration
if report.top_10_holder_pct >= self.TOP_10_CRITICAL_PCT:
score += 20
warnings.append(f"HIGH: Top 10 holders control {report.top_10_holder_pct:.1f}% - cartel risk")
elif report.top_10_holder_pct >= 60:
score += 10
warnings.append(f"MEDIUM: Top 10 holders control {report.top_10_holder_pct:.1f}%")
# No timelock
if report.is_governed and not report.has_timelock:
score += 20
warnings.append("HIGH: Governance changes have no timelock - instant execution possible")
elif not report.has_timelock:
score += 10
warnings.append("MEDIUM: No timelock detected on admin functions")
# Low quorum
if report.quorum_threshold > 0 and report.quorum_threshold < self.LOW_QUORUM_PCT:
score += 15
warnings.append(f"HIGH: Governance quorum only {report.quorum_threshold:.2f}% - easily gamed")
# Timelock too short
if report.has_timelock and report.timelock_delay_seconds > 0:
timelock_hours = report.timelock_delay_seconds / 3600
if timelock_hours < self.MIN_TIMELOCK_HOURS:
score += 10
warnings.append(f"MEDIUM: Timelock only {timelock_hours:.1f}h - insufficient for reaction")
# Governance risk flags from analysis
for flag in report.governance_risk_flags:
if "critical" in flag.lower():
score += 10
elif "high" in flag.lower():
score += 5
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, warnings
# ── Main analysis entry point ─────────────────────────────────────
async def analyze(self, token_address: str, chain: str) -> GovernanceAttackReport:
"""Full governance attack risk analysis for a token.
Steps:
1. Fetch holder distribution data
2. Check top holder concentration
3. For EVM: read governance contract parameters
4. For Solana: check mint authority status
5. Analyze governance risk flags
6. Calculate risk score
"""
report = GovernanceAttackReport(token_address=token_address, chain=chain)
# 1. Fetch DEX + holder data
dex_data = await self._fetch_dexscreener_token(token_address)
raw_holders: list[dict] = []
if is_solana(chain):
raw_holders = await self._fetch_solscan_holders(token_address, top_n=20)
elif is_evm(chain):
raw_holders = await self._fetch_evm_holders(token_address, chain)
# If no holders from primary source, try Birdeye
if not raw_holders:
birdeye_data = await self._fetch_birdeye_overview(token_address)
if birdeye_data and isinstance(birdeye_data, dict):
dist = birdeye_data.get("holderDistribution") or []
raw_holders = dist
# 2. Parse holders
holders = self._parse_holders(raw_holders)
report.top_holders = holders
if holders:
report.top_holder_pct = holders[0].percentage
report.top_10_holder_pct = sum(h.percentage for h in holders[:10])
# 3. Governance parameters
gov_params: GovernanceParams | None = None
if is_evm(chain):
gov_params = await self._check_evm_governance(token_address, chain)
if gov_params:
report.governance_params = gov_params
report.has_timelock = gov_params.has_timelock
report.timelock_delay_seconds = gov_params.timelock_delay_seconds
report.quorum_threshold = gov_params.quorum_threshold_pct
report.is_governed = bool(
gov_params.quorum_threshold_pct > 0
or gov_params.proposal_threshold_pct > 0
or gov_params.voting_period_blocks > 0
)
elif is_solana(chain):
gov_params = self._check_solana_governance(dex_data, None)
report.governance_params = gov_params
report.has_timelock = gov_params.has_timelock
# 4. Generate governance risk flags
risk_flags = []
if report.top_holder_pct > 50:
risk_flags.append(f"CRITICAL: Single entity majority ({report.top_holder_pct:.1f}%)")
if report.is_governed and report.quorum_threshold < 1.0 and report.quorum_threshold > 0:
risk_flags.append(f"HIGH: Quorum too low ({report.quorum_threshold:.2f}%)")
if report.has_timelock and report.timelock_delay_seconds > 0:
hours = report.timelock_delay_seconds / 3600
if hours < 24:
risk_flags.append(f"MEDIUM: Short timelock ({hours:.1f}h)")
if not report.has_timelock:
risk_flags.append("MEDIUM: No timelock on admin actions")
report.governance_risk_flags = risk_flags
# 5. Calculate risk
report.risk_score, report.risk_level, report.warnings = self._calculate_risk(report)
# RAG citations for credibility
try:
rag_cits = await query_rag_citations(
topic=f"governance attack concentration {chain}",
chain=chain,
address=token_address,
scanner_type="governance_attack",
)
report.citations = rag_cits
# Enhance warnings with citation references
for i, w in enumerate(report.warnings):
if rag_cits:
report.warnings[i] = build_citation_string(rag_cits, w)
except Exception:
pass
return report