Some checks failed
CI / build (push) Failing after 2s
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)
474 lines
19 KiB
Python
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
|