rmi-backend/app/domains/scanners/flash_loan_detector.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

372 lines
15 KiB
Python

"""
SENTINEL - Flash Loan Attack Detector
======================================
Detects if a token's trades were sandwiched by flash loans.
Pattern: huge buy in block N, huge sell in block N or N+1, wallet had
near-zero balance before the trade - classic flash-loan manipulation.
Uses direct API calls: DexScreener (trades/pairs), Birdeye (trades),
Solscan/Etherscan (account history), Helius (parsed transactions).
"""
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("flash_loan_detector")
# ── Dataclasses ──────────────────────────────────────────────────────
@dataclass
class FlashLoanFlag:
"""A single suspected flash-loan attack."""
attacker_address: str
buy_block: int
sell_block: int
buy_amount_usd: float
sell_amount_usd: float
pool_liquidity_usd: float
volume_vs_liquidity_pct: float # trade volume as % of pool liquidity
same_block: bool
wallet_had_no_prior_balance: bool
block_timestamp: int | None = None
@dataclass
class FlashLoanReport:
token_address: str
chain: str
flash_loan_flags: list[FlashLoanFlag] = field(default_factory=list)
total_suspect_volume: float = 0.0
suspect_trade_count: int = 0
analyzed_trade_count: int = 0
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 FlashLoanDetector:
"""Detects flash-loan sandwich / manipulation patterns.
Fetches recent swap events, checks for single-block or consecutive-block
trades by the same wallet with abnormally large amounts relative to pool
liquidity, and verifies whether the wallet had near-zero balance before
the attack (a strong flash-loan indicator).
"""
# Thresholds
MIN_VOLUME_VS_LIQUIDITY_PCT = 20.0 # trade >20% of pool liquidity → suspicious
MAX_BLOCKS_APART = 3 # buy-sell within 3 blocks → suspicious
MIN_TRADE_USD = 5000.0 # ignore dust trades
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_trades(self, token_address: str) -> list[dict]:
"""Fetch recent trades from DexScreener for a token."""
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
except Exception as e:
logger.warning(f"DexScreener fetch failed for {token_address}: {e}")
return []
async def _fetch_birdeye_trades(self, token_address: str, limit: int = 50) -> list[dict]:
"""Fetch recent trades from Birdeye."""
if not self._birdeye_key:
return []
try:
resp = await self._http.get(
"https://public-api.birdeye.so/defi/tx",
params={"address": token_address, "limit": limit},
headers={"X-API-KEY": self._birdeye_key},
)
if resp.status_code == 200:
body = resp.json()
return body.get("data", {}).get("items", []) or []
except Exception as e:
logger.warning(f"Birdeye trades failed for {token_address}: {e}")
return []
async def _fetch_solana_account_balance_before(self, address: str, slot: int) -> float | None:
"""Check a Solana wallet's SOL balance at a past slot via Helius."""
if not self._helius_key:
return None
try:
result = await self._chain.rpc_call("getBalance", [address, {"slot": max(1, slot - 2)}])
if result and "result" in result:
lamports = result["result"].get("value", 0)
return lamports / 1e9
except Exception as e:
logger.warning(f"Helius balance check failed for {address}: {e}")
return None
async def _fetch_solana_signatures(self, address: str, limit: int = 5) -> list[dict]:
"""Fetch early signatures to determine wallet freshness."""
result = await self._chain.rpc_call("getSignaturesForAddress", [address, {"limit": limit}])
if result and "result" in result:
return result["result"]
return []
async def _fetch_evm_account_history(self, address: str, chain: str) -> dict | None:
"""Fetch EVM account tx count via Etherscan-style API."""
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}"
f"?module=account&action=txlist&address={address}"
f"&startblock=0&endblock=99999999&page=1&offset=5"
f"&sort=asc&apikey={key}"
)
resp = await self._http.get(url)
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.warning(f"Etherscan account history failed for {address}: {e}")
return None
# ── Analysis helpers ────────────────────────────────────────────
def _group_trades_by_wallet(self, trades: list[dict]) -> dict[str, list[dict]]:
"""Group trades by wallet address."""
grouped: dict[str, list[dict]] = {}
for t in trades:
addr = t.get("wallet", t.get("address", t.get("maker", "")))
if addr:
grouped.setdefault(addr, []).append(t)
return grouped
def _detect_flash_pattern(
self,
wallet_trades: list[dict],
pool_liquidity_usd: float,
) -> list[FlashLoanFlag]:
"""Within a single wallet's trades, find buy-then-sell patterns
that look like flash-loan attacks."""
flags = []
# Sort by block number / timestamp
sorted_trades = sorted(wallet_trades, key=lambda t: int(t.get("block", t.get("slot", t.get("blockTime", 0)))))
buys = [t for t in sorted_trades if t.get("side", t.get("type", "")).lower() in ("buy", "0")]
sells = [t for t in sorted_trades if t.get("side", t.get("type", "")).lower() in ("sell", "1")]
for buy in buys:
buy_block = int(buy.get("block", buy.get("slot", buy.get("blockTime", 0))))
buy_usd = float(buy.get("volumeUsd", buy.get("amountUsd", buy.get("value", 0))))
if buy_usd < self.MIN_TRADE_USD:
continue
for sell in sells:
sell_block = int(sell.get("block", sell.get("slot", sell.get("blockTime", 0))))
if sell_block < buy_block:
continue
blocks_apart = sell_block - buy_block
if blocks_apart > self.MAX_BLOCKS_APART:
continue
sell_usd = float(sell.get("volumeUsd", sell.get("amountUsd", sell.get("value", 0))))
if sell_usd < self.MIN_TRADE_USD:
continue
total_volume = buy_usd + sell_usd
vol_vs_liq = (total_volume / pool_liquidity_usd * 100) if pool_liquidity_usd else 0
if vol_vs_liq < self.MIN_VOLUME_VS_LIQUIDITY_PCT:
continue
flags.append(
FlashLoanFlag(
attacker_address=buy.get("wallet", buy.get("address", buy.get("maker", ""))),
buy_block=buy_block,
sell_block=sell_block,
buy_amount_usd=buy_usd,
sell_amount_usd=sell_usd,
pool_liquidity_usd=pool_liquidity_usd,
volume_vs_liquidity_pct=round(vol_vs_liq, 2),
same_block=(blocks_apart == 0),
wallet_had_no_prior_balance=False, # filled later
block_timestamp=buy.get("blockTime", buy.get("timestamp")),
)
)
return flags
# ── Risk calculation ─────────────────────────────────────────────
def _calculate_risk(self, report: FlashLoanReport) -> tuple[int, str, list[str]]:
"""Calculate risk score from flash-loan flags."""
score = 0
warnings = []
if report.suspect_trade_count == 0:
return 0, "LOW", []
# Number of distinct attacks
attack_count = len(report.flash_loan_flags)
if attack_count >= 5:
score += 40
warnings.append(f"CRITICAL: {attack_count} flash-loan attack patterns detected")
elif attack_count >= 3:
score += 25
warnings.append(f"HIGH: {attack_count} flash-loan attack patterns detected")
elif attack_count >= 1:
score += 15
warnings.append(f"MEDIUM: {attack_count} flash-loan attack pattern(s) detected")
# Total suspect volume relative to pool
if report.total_suspect_volume > 0 and report.analyzed_trade_count > 0:
vol_ratio = report.total_suspect_volume / max(1, report.analyzed_trade_count * 1000)
if vol_ratio > 50:
score += 30
warnings.append("CRITICAL: Suspect volume dominates trading activity")
elif vol_ratio > 20:
score += 15
warnings.append("HIGH: Significant suspect volume relative to activity")
# Same-block attacks are most dangerous
same_block_attacks = [f for f in report.flash_loan_flags if f.same_block]
if same_block_attacks:
score += 20
warnings.append(f"HIGH: {len(same_block_attacks)} same-block buy+sell (classic flash loan)")
# Wallets with no prior balance (strongest signal)
no_balance_attacks = [f for f in report.flash_loan_flags if f.wallet_had_no_prior_balance]
if no_balance_attacks:
score += 15
warnings.append(f"HIGH: {len(no_balance_attacks)} attacker wallets had near-zero balance pre-trade")
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) -> FlashLoanReport:
"""Full flash-loan attack analysis for a token.
Steps:
1. Fetch recent DEX pair/trade data from DexScreener & Birdeye
2. Group trades by wallet, detect buy-then-sell patterns
3. For each suspect pattern, check wallet's pre-trade balance
4. Build flags for patterns exceeding thresholds
5. Calculate risk score
"""
report = FlashLoanReport(token_address=token_address, chain=chain)
# 1. Fetch DEX data
pairs = await self._fetch_dexscreener_trades(token_address)
pool_liquidity_usd = 0.0
if pairs:
primary = pairs[0]
liq = primary.get("liquidity", {})
pool_liquidity_usd = float(liq.get("usd", 0)) if isinstance(liq, dict) else float(liq or 0)
# 2. Fetch trades
trades: list[dict] = []
birdeye_trades = await self._fetch_birdeye_trades(token_address, limit=100)
if birdeye_trades:
trades.extend(birdeye_trades)
# DexScreener doesn't return individual trades in pairs endpoint,
# but pairs have volume info we can leverage
report.analyzed_trade_count = len(trades) or 1
if not trades:
# No trade data available - cannot detect flash loans
report.risk_score = 0
report.risk_level = "LOW"
report.warnings = ["No trade data available for flash-loon analysis"]
return report
# 3. Group by wallet and detect flash-loan patterns
grouped = self._group_trades_by_wallet(trades)
all_flags: list[FlashLoanFlag] = []
for _wallet, wallet_trades in grouped.items():
flags = self._detect_flash_pattern(wallet_trades, pool_liquidity_usd)
all_flags.extend(flags)
# 4. For each suspect, check pre-trade balance
for flag in all_flags:
if is_solana(chain):
balance = await self._fetch_solana_account_balance_before(flag.attacker_address, flag.buy_block)
if balance is not None and balance < 0.01:
flag.wallet_had_no_prior_balance = True
# Also check wallet freshness (few transactions = likely borrowed)
sigs = await self._fetch_solana_signatures(flag.attacker_address, limit=5)
if len(sigs) <= 3:
flag.wallet_had_no_prior_balance = True
elif is_evm(chain):
history = await self._fetch_evm_account_history(flag.attacker_address, chain)
if history:
tx_list = history.get("result", [])
# If the wallet's first transactions are these trades, it's suspicious
if len(tx_list) <= 5:
flag.wallet_had_no_prior_balance = True
# 5. Build report
report.flash_loan_flags = all_flags
report.suspect_trade_count = len(all_flags)
report.total_suspect_volume = sum(f.buy_amount_usd + f.sell_amount_usd for f in all_flags)
# 6. 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"flash loan attack {chain}",
chain=chain,
address=token_address,
scanner_type="flash_loan",
)
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