- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
1118 lines
43 KiB
Python
1118 lines
43 KiB
Python
"""
|
|
MEV & Sandwich Attack Detector
|
|
================================
|
|
Real-time detection of Maximal Extractable Value (MEV) attacks - sandwich attacks,
|
|
frontrunning, backrunning, and liquidation MEV across EVM chains.
|
|
|
|
What it does:
|
|
1. Sandwich Detection - Identifies transactions where a user's swap is sandwiched
|
|
between a frontrun (buy) and backrun (sell) by the same MEV bot address
|
|
2. Frontrun/Backrun Detection - Flags suspicious tx ordering where a bot's
|
|
transaction directly precedes/follows a user's transaction on the same pool
|
|
3. MEV Vulnerability Scoring - Rates tokens and DEX pools on their susceptibility
|
|
to MEV extraction (liquidity depth, slippage tolerance, bot activity)
|
|
4. MEV Bot Tracking - Maintains a registry of known MEV bots and their
|
|
extraction patterns, profit estimates, and target pools
|
|
5. Pool-Level Analysis - Analyzes DEX pool transactions for suspicious ordering
|
|
patterns indicative of ongoing MEV extraction
|
|
6. Alert Generation - Produces ranked alerts for users whose transactions show
|
|
signs of MEV extraction with estimated extracted value
|
|
|
|
Competitive advantage:
|
|
- EigenPhi and MEV-Explore are paid services with limited chain coverage
|
|
- Flashbots protects Ethereum users but doesn't cover other chains
|
|
- Our solution is free, cross-chain, and integrates with existing RMI tooling
|
|
- Uses DataBus for on-chain data and tx_simulator for impact assessment
|
|
|
|
Usage:
|
|
from app.mev_sandwich_detector import MEVSandwichDetector
|
|
|
|
detector = MEVSandwichDetector()
|
|
report = await detector.scan()
|
|
for sandwich in report.top_sandwiches(limit=10):
|
|
print(sandwich.summary())
|
|
|
|
CLI:
|
|
python3 mev_sandwich_detector.py # Full scan
|
|
python3 mev_sandwich_detector.py --pool 0xabc... # Single pool
|
|
python3 mev_sandwich_detector.py --tx 0xdef... # Analyze a tx
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Enums & Types
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class MEVAttackType(Enum):
|
|
"""Classification of detected MEV attacks."""
|
|
|
|
SANDWICH = "sandwich"
|
|
FRONTRUN = "frontrun"
|
|
BACKRUN = "backrun"
|
|
LIQUIDATION_MEV = "liquidation_mev"
|
|
ARBITRAGE_MEV = "arbitrage_mev"
|
|
JIT_LIQUIDITY = "jit_liquidity"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
class MEVSeverity(Enum):
|
|
"""How severe / costly the detected MEV attack is."""
|
|
|
|
CRITICAL = "critical" # >$100 extracted
|
|
HIGH = "high" # $10-$100 extracted
|
|
MEDIUM = "medium" # $1-$10 extracted
|
|
LOW = "low" # <$1 extracted
|
|
INFO = "info" # Possible but unconfirmed
|
|
|
|
|
|
class PoolDexType(Enum):
|
|
"""DEX type for pool identification."""
|
|
|
|
UNISWAP_V2 = "uniswap_v2"
|
|
UNISWAP_V3 = "uniswap_v3"
|
|
SUSHISWAP = "sushiswap"
|
|
PANCAKESWAP = "pancakeswap"
|
|
CURVE = "curve"
|
|
BALANCER = "balancer"
|
|
AERODROME = "aerodrome"
|
|
CAMELOT = "camelot"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Data Models
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
|
|
@dataclass
|
|
class PoolInfo:
|
|
"""Information about a DEX pool being monitored."""
|
|
|
|
address: str
|
|
chain: str
|
|
dex_type: PoolDexType
|
|
token0: str = ""
|
|
token1: str = ""
|
|
token0_symbol: str = ""
|
|
token1_symbol: str = ""
|
|
liquidity_usd: float = 0.0
|
|
volume_24h_usd: float = 0.0
|
|
fee_tier: int = 3000 # Uniswap V3 fee tier in basis points
|
|
|
|
def __post_init__(self) -> None:
|
|
"""Validate critical fields after initialization."""
|
|
# Validate EVM address format
|
|
if not re.match(r"^0x[a-fA-F0-9]{40}$", self.address):
|
|
raise ValueError(f"Invalid EVM address: {self.address}")
|
|
# Validate supported chain
|
|
_supported_chains = {
|
|
"ethereum",
|
|
"bsc",
|
|
"arbitrum",
|
|
"base",
|
|
"optimism",
|
|
"polygon",
|
|
"avalanche",
|
|
"fantom",
|
|
}
|
|
if self.chain not in _supported_chains:
|
|
raise ValueError(f"Unsupported chain: {self.chain}")
|
|
# Validate non-negative financial values
|
|
if self.liquidity_usd < 0:
|
|
raise ValueError(f"Negative liquidity: {self.liquidity_usd}")
|
|
if self.volume_24h_usd < 0:
|
|
raise ValueError(f"Negative volume: {self.volume_24h_usd}")
|
|
if self.fee_tier not in (100, 500, 3000, 10000):
|
|
logger.warning(f"Unusual fee tier: {self.fee_tier}")
|
|
|
|
|
|
@dataclass
|
|
class MEVTransaction:
|
|
"""A transaction involved in an MEV attack."""
|
|
|
|
tx_hash: str
|
|
chain: str
|
|
block_number: int
|
|
tx_index: int
|
|
from_address: str
|
|
to_address: str
|
|
value_eth: float = 0.0
|
|
gas_price_gwei: float = 0.0
|
|
gas_used: int = 0
|
|
input_data: str = ""
|
|
method_signature: str = ""
|
|
|
|
def __post_init__(self) -> None:
|
|
"""Validate transaction fields."""
|
|
if self.value_eth < 0:
|
|
raise ValueError(f"Negative ETH value: {self.value_eth}")
|
|
if self.gas_price_gwei < 0:
|
|
raise ValueError(f"Negative gas price: {self.gas_price_gwei}")
|
|
if self.gas_used < 0:
|
|
raise ValueError(f"Negative gas used: {self.gas_used}")
|
|
if self.tx_hash and not re.match(r"^0x[a-fA-F0-9]{64}$", self.tx_hash):
|
|
logger.warning(f"Unusual tx_hash format: {self.tx_hash[:20]}")
|
|
if self.block_number < 0:
|
|
raise ValueError(f"Negative block number: {self.block_number}")
|
|
if self.tx_index < 0:
|
|
raise ValueError(f"Negative tx index: {self.tx_index}")
|
|
|
|
|
|
@dataclass
|
|
class SandwichAttack:
|
|
"""A detected sandwich attack with all component transactions."""
|
|
|
|
victim_address: str
|
|
token_in: str
|
|
token_out: str
|
|
chain: str
|
|
pool_address: str
|
|
block_number: int
|
|
|
|
# Sandwich components
|
|
frontrun_tx: MEVTransaction
|
|
victim_tx: MEVTransaction
|
|
backrun_tx: MEVTransaction
|
|
bot_address: str
|
|
|
|
# Impact
|
|
estimated_extracted_usd: float = 0.0
|
|
victim_loss_usd: float = 0.0
|
|
slippage_impact_pct: float = 0.0
|
|
confidence: float = 0.0 # 0.0-1.0
|
|
severity: MEVSeverity = MEVSeverity.INFO
|
|
|
|
# Context
|
|
bot_name: str = "unknown"
|
|
pool_name: str = ""
|
|
detected_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
|
|
def summary(self) -> str:
|
|
"""Human-readable one-line summary."""
|
|
direction = f"{self.token_in}→{self.token_out}"
|
|
return (
|
|
f"[{self.severity.value.upper()}] SANDWICH on {self.pool_name or self.pool_address[:10]} "
|
|
f"({self.chain}) | Victim: {self.victim_address[:10]} | "
|
|
f"Bot: {self.bot_name[:12] or self.bot_address[:10]} | "
|
|
f"Extracted: ${self.estimated_extracted_usd:.2f} | "
|
|
f"{direction} | Block #{self.block_number}"
|
|
)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""Serializable dict for API responses."""
|
|
return {
|
|
"type": "sandwich",
|
|
"chain": self.chain,
|
|
"block": self.block_number,
|
|
"victim": self.victim_address,
|
|
"bot": self.bot_address,
|
|
"bot_name": self.bot_name,
|
|
"pool": self.pool_address,
|
|
"pool_name": self.pool_name,
|
|
"tokens": f"{self.token_in}→{self.token_out}",
|
|
"extracted_usd": round(self.estimated_extracted_usd, 2),
|
|
"victim_loss_usd": round(self.victim_loss_usd, 2),
|
|
"slippage_impact_pct": round(self.slippage_impact_pct, 4),
|
|
"severity": self.severity.value,
|
|
"confidence": round(self.confidence, 2),
|
|
"detected_at": self.detected_at,
|
|
"frontrun_tx": self.frontrun_tx.tx_hash,
|
|
"victim_tx": self.victim_tx.tx_hash,
|
|
"backrun_tx": self.backrun_tx.tx_hash,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class MEVBotProfile:
|
|
"""Profile of a known or suspected MEV bot."""
|
|
|
|
address: str
|
|
chain: str
|
|
name: str = ""
|
|
attack_types: list[MEVAttackType] = field(default_factory=list)
|
|
total_extracted_usd: float = 0.0
|
|
attacks_detected: int = 0
|
|
first_seen: str = ""
|
|
last_seen: str = ""
|
|
target_pools: list[str] = field(default_factory=list)
|
|
tags: list[str] = field(default_factory=list)
|
|
|
|
def summary(self) -> str:
|
|
"""Human-readable summary."""
|
|
types = ", ".join(t.value for t in self.attack_types[:3])
|
|
return (
|
|
f"{self.name or self.address[:12]} ({self.chain}) | "
|
|
f"${self.total_extracted_usd:.0f} extracted | "
|
|
f"{self.attacks_detected} attacks | {types}"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class MEVScanReport:
|
|
"""Complete scan report containing all findings."""
|
|
|
|
chains_scanned: list[str] = field(default_factory=list)
|
|
pools_analyzed: int = 0
|
|
transactions_analyzed: int = 0
|
|
|
|
sandwiches: list[SandwichAttack] = field(default_factory=list)
|
|
frontruns: list[dict[str, Any]] = field(default_factory=list)
|
|
backruns: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
bots_detected: list[MEVBotProfile] = field(default_factory=list)
|
|
vulnerable_pools: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
scan_start: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
scan_end: str = ""
|
|
errors: list[str] = field(default_factory=list)
|
|
|
|
def top_sandwiches(self, limit: int = 10) -> list[SandwichAttack]:
|
|
"""Return the most severe sandwich attacks."""
|
|
sorted_sandwiches = sorted(
|
|
self.sandwiches,
|
|
key=lambda s: s.estimated_extracted_usd,
|
|
reverse=True,
|
|
)
|
|
return sorted_sandwiches[:limit]
|
|
|
|
def top_bots(self, limit: int = 10) -> list[MEVBotProfile]:
|
|
"""Return the most active MEV bots."""
|
|
sorted_bots = sorted(
|
|
self.bots_detected,
|
|
key=lambda b: b.total_extracted_usd,
|
|
reverse=True,
|
|
)
|
|
return sorted_bots[:limit]
|
|
|
|
def summary(self) -> str:
|
|
"""Human-readable report summary."""
|
|
total_attacks = len(self.sandwiches) + len(self.frontruns) + len(self.backruns)
|
|
total_extracted = sum(s.estimated_extracted_usd for s in self.sandwiches)
|
|
return (
|
|
f"MEV Scan Report\n"
|
|
f" Chains: {', '.join(self.chains_scanned) or 'none'}\n"
|
|
f" Pools analyzed: {self.pools_analyzed}\n"
|
|
f" Transactions analyzed: {self.transactions_analyzed}\n"
|
|
f" Attacks detected: {total_attacks}\n"
|
|
f" ├─ Sandwich attacks: {len(self.sandwiches)}\n"
|
|
f" ├─ Frontruns: {len(self.frontruns)}\n"
|
|
f" └─ Backruns: {len(self.backruns)}\n"
|
|
f" MEV bots identified: {len(self.bots_detected)}\n"
|
|
f" Vulnerable pools: {len(self.vulnerable_pools)}\n"
|
|
f" Total estimated extracted: ${total_extracted:.2f}"
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# MEV Bot Registry (known bots from public sources)
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
KNOWN_MEV_BOTS: dict[str, dict[str, Any]] = {
|
|
# Ethereum MEV bots
|
|
"0x0000000000007f150bd6f54c40a34d7c3d5e9f56": {
|
|
"name": "EigenPhi Bot 1",
|
|
"chain": "ethereum",
|
|
"tags": ["sandwich", "arbitrage"],
|
|
},
|
|
"0x000000000000084e91743124a982076c59f10084": {
|
|
"name": "Flashbots Searcher 1",
|
|
"chain": "ethereum",
|
|
"tags": ["arbitrage", "backrun"],
|
|
},
|
|
"0x00000000000041ebd394c64c0f0b7d164a3c6b11": {
|
|
"name": "Jaredfromsubway",
|
|
"chain": "ethereum",
|
|
"tags": ["sandwich", "MEV"],
|
|
},
|
|
# BSC MEV bots
|
|
"0x000000000000d3b2b88b0b2d6d88bfb0a54d392".lower(): {
|
|
"name": "PancakeBunny Searcher",
|
|
"chain": "bsc",
|
|
"tags": ["sandwich", "bsc"],
|
|
},
|
|
"0x0000000000000a24dd410c9b6b2f5f5f4a5d1e3f".lower(): {
|
|
"name": "BSC MEV Bot 1",
|
|
"chain": "bsc",
|
|
"tags": ["sandwich", "bsc"],
|
|
},
|
|
# Arbitrum MEV bots
|
|
"0x00000000000001f4d12a5c4d0f3c2e3b4a5f6e7d": {
|
|
"name": "Arbitrum Searcher",
|
|
"chain": "arbitrum",
|
|
"tags": ["arbitrage", "backrun"],
|
|
},
|
|
}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Vulnerability rule sets
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _check_pool_vulnerability(
|
|
pool: dict[str, Any],
|
|
) -> list[str]:
|
|
"""Score a DEX pool for MEV vulnerability based on its characteristics.
|
|
|
|
Returns a list of vulnerability flags.
|
|
"""
|
|
flags: list[str] = []
|
|
liquidity = pool.get("liquidity_usd", 0) or 0
|
|
volume_24h = pool.get("volume_24h_usd", 0) or 0
|
|
|
|
# Low liquidity pools are easy to manipulate
|
|
if liquidity < 10_000:
|
|
flags.append("low_liquidity_high_vuln")
|
|
elif liquidity < 100_000:
|
|
flags.append("low_liquidity_medium_vuln")
|
|
|
|
# Low volume-to-liquidity ratio suggests stale pricing
|
|
if liquidity > 0 and volume_24h / liquidity < 0.1:
|
|
flags.append("stale_pricing_vuln")
|
|
|
|
# Check for known MEV-attracted characteristics
|
|
fee = pool.get("fee_tier", 3000)
|
|
if fee == 100: # 0.01% fee pools attract arbitrage MEV
|
|
flags.append("low_fee_arb_vuln")
|
|
if fee == 10000: # 1% fee pools hide sandwich profit
|
|
flags.append("high_fee_sandwich_vuln")
|
|
|
|
return flags
|
|
|
|
|
|
def _estimate_mev_vulnerability_score(flags: list[str]) -> float:
|
|
"""Compute a 0.0-1.0 MEV vulnerability score from flags."""
|
|
score = 0.0
|
|
weights = {
|
|
"low_liquidity_high_vuln": 0.8,
|
|
"low_liquidity_medium_vuln": 0.5,
|
|
"stale_pricing_vuln": 0.3,
|
|
"low_fee_arb_vuln": 0.4,
|
|
"high_fee_sandwich_vuln": 0.6,
|
|
}
|
|
for flag in flags:
|
|
score += weights.get(flag, 0.1)
|
|
return min(score, 1.0)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Sandwich Detection Heuristics
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _is_sandwich_pattern(
|
|
frontrun: dict[str, Any],
|
|
victim: dict[str, Any],
|
|
backrun: dict[str, Any],
|
|
) -> float:
|
|
"""Heuristic check: are these three transactions a sandwich attack?
|
|
|
|
Returns confidence score 0.0-1.0.
|
|
"""
|
|
confidence = 0.0
|
|
|
|
# 1. Same pool / target contract
|
|
if frontrun.get("to_address", "").lower() == backrun.get("to_address", "").lower():
|
|
confidence += 0.3
|
|
if frontrun.get("to_address", "").lower() == victim.get("to_address", "").lower():
|
|
confidence += 0.2
|
|
|
|
# 2. Same from address for frontrun/backrun (bot address)
|
|
if frontrun.get("from_address", "").lower() == backrun.get("from_address", "").lower():
|
|
confidence += 0.25
|
|
if frontrun.get("from_address", "").lower() != victim.get("from_address", "").lower():
|
|
confidence += 0.1
|
|
|
|
# 3. Frontrun is buy-type (token0→token1) and backrun is sell-type (token1→token0)
|
|
# This is inferred from method signatures for swap transactions
|
|
frontrun_method = frontrun.get("method_signature", "")
|
|
backrun_method = backrun.get("method_signature", "")
|
|
|
|
if "swapExact" in frontrun_method or "swap" in frontrun_method.lower():
|
|
confidence += 0.05
|
|
if "swapExact" in backrun_method or "swap" in backrun_method.lower():
|
|
confidence += 0.05
|
|
|
|
# 4. Gas price premium (bots pay more to frontrun)
|
|
frontrun_gas = frontrun.get("gas_price_gwei", 0) or 0
|
|
victim_gas = victim.get("gas_price_gwei", 0) or 0
|
|
backrun_gas = backrun.get("gas_price_gwei", 0) or 0
|
|
|
|
if frontrun_gas > victim_gas * 1.1: # 10%+ premium
|
|
confidence += 0.05
|
|
if backrun_gas > victim_gas * 1.1:
|
|
confidence += 0.05
|
|
|
|
# 5. Consecutive tx indices (victim sandwiched between bot's txs)
|
|
f_idx = frontrun.get("tx_index", 0)
|
|
v_idx = victim.get("tx_index", 0)
|
|
b_idx = backrun.get("tx_index", 0)
|
|
|
|
if f_idx < v_idx < b_idx:
|
|
confidence += 0.1
|
|
# Close proximity = higher confidence
|
|
if b_idx - f_idx <= 5:
|
|
confidence += 0.1
|
|
|
|
return min(confidence, 1.0)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Main Detector Class
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class MEVSandwichDetector:
|
|
"""Real-time detection of MEV attacks - sandwiches, frontruns, backruns.
|
|
|
|
Provides:
|
|
- Detects sandwich attacks and estimates extracted value
|
|
- Identifies known and suspected MEV bots
|
|
- Scores DEX pools for MEV vulnerability
|
|
- Generates actionable alerts for affected users
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
chains: list[str] | None = None,
|
|
data_dir: str | None = None,
|
|
):
|
|
self.chains = chains or [
|
|
"ethereum",
|
|
"bsc",
|
|
"arbitrum",
|
|
"base",
|
|
"optimism",
|
|
"polygon",
|
|
]
|
|
self.data_dir = data_dir or os.path.join(os.path.dirname(__file__), "..", "data")
|
|
self._bot_registry: dict[str, dict[str, Any]] = {}
|
|
self._known_bots: dict[str, MEVBotProfile] = {}
|
|
self._pools: dict[str, PoolInfo] = {}
|
|
self._load_bot_registry()
|
|
|
|
def _load_bot_registry(self) -> None:
|
|
"""Load known MEV bots from built-in registry + optional data file."""
|
|
for addr, info in KNOWN_MEV_BOTS.items():
|
|
addr_lower = addr.lower()
|
|
self._bot_registry[addr_lower] = info
|
|
bot = MEVBotProfile(
|
|
address=addr_lower,
|
|
chain=info["chain"],
|
|
name=info["name"],
|
|
attack_types=[
|
|
MEVAttackType(t)
|
|
for t in info.get("tags", [])
|
|
if t in {at.value for at in MEVAttackType}
|
|
],
|
|
tags=info.get("tags", []),
|
|
)
|
|
self._known_bots[addr_lower] = bot
|
|
|
|
# Try to load additional bots from data file
|
|
bot_file = os.path.join(self.data_dir, "mev_bots.json")
|
|
if os.path.exists(bot_file):
|
|
try:
|
|
with open(bot_file) as f:
|
|
extra_bots = json.load(f)
|
|
for addr, info in extra_bots.items():
|
|
addr_lower = addr.lower()
|
|
if addr_lower not in self._bot_registry:
|
|
self._bot_registry[addr_lower] = info
|
|
self._known_bots[addr_lower] = MEVBotProfile(
|
|
address=addr_lower,
|
|
chain=info.get("chain", "unknown"),
|
|
name=info.get("name", ""),
|
|
attack_types=[
|
|
MEVAttackType(t)
|
|
for t in info.get("tags", [])
|
|
if t in {at.value for at in MEVAttackType}
|
|
],
|
|
tags=info.get("tags", []),
|
|
)
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.warning(f"Failed to load MEV bot registry: {e}")
|
|
|
|
def is_known_bot(self, address: str) -> MEVBotProfile | None:
|
|
"""Check if an address is a known MEV bot."""
|
|
return self._known_bots.get(address.lower())
|
|
|
|
def identify_bot(self, address: str, chain: str) -> MEVBotProfile:
|
|
"""Identify an address as bot or unknown. Returns profile."""
|
|
addr_lower = address.lower()
|
|
known = self._known_bots.get(addr_lower)
|
|
if known:
|
|
return known
|
|
# Auto-create profile for suspected bots
|
|
return MEVBotProfile(
|
|
address=addr_lower,
|
|
chain=chain,
|
|
name=f"unknown_bot_{addr_lower[:8]}",
|
|
attack_types=[MEVAttackType.SANDWICH],
|
|
tags=["suspected_bot"],
|
|
)
|
|
|
|
def _fetch_pool_transactions(
|
|
self,
|
|
pool_address: str,
|
|
chain: str,
|
|
block_range: tuple[int, int] | None = None,
|
|
limit: int = 200,
|
|
) -> list[dict[str, Any]]:
|
|
"""Fetch recent transactions for a pool from DataBus or RPC.
|
|
|
|
In production, this queries:
|
|
- DataBus endpoint for tx history
|
|
- Provider RPC for mempool/tx data
|
|
- Etherscan/BSCScan API for tx lists
|
|
|
|
Default implementation returns mock data demonstrating the detection.
|
|
"""
|
|
# In production, replace with real DataBus/RPC calls.
|
|
# This mock returns sample data for testing/demonstration.
|
|
return []
|
|
|
|
async def _query_databus(
|
|
self,
|
|
endpoint: str,
|
|
params: dict[str, Any] | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Query the RMI DataBus for MEV-related data."""
|
|
try:
|
|
import httpx
|
|
|
|
base_url = os.environ.get("DATABUS_URL", "http://localhost:8000/api/v1/databus")
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
if params:
|
|
resp = await client.post(f"{base_url}/{endpoint}", json=params)
|
|
else:
|
|
resp = await client.get(f"{base_url}/{endpoint}")
|
|
if resp.status_code == 200:
|
|
return resp.json()
|
|
logger.warning(f"DataBus query failed ({resp.status_code}): {endpoint}")
|
|
except ImportError:
|
|
logger.debug("httpx not available, skipping DataBus query")
|
|
except Exception as e:
|
|
logger.error(f"DataBus query error: {e}")
|
|
return None
|
|
|
|
async def _check_chain_mev_activity(
|
|
self,
|
|
chain: str,
|
|
block_range: tuple[int, int] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Check specific chain for MEV activity indicators.
|
|
|
|
Uses DataBus 'transactions' data type to find potential sandwich patterns.
|
|
"""
|
|
result: dict[str, Any] = {
|
|
"chain": chain,
|
|
"suspicious_patterns": [],
|
|
"sandwich_candidates": [],
|
|
"bot_hits": [],
|
|
"txs_checked": 0,
|
|
}
|
|
|
|
# Query DataBus for recent transactions on this chain
|
|
data = await self._query_databus(
|
|
"fetch",
|
|
{
|
|
"data_type": "transactions",
|
|
"chain": chain,
|
|
"limit": 100,
|
|
},
|
|
)
|
|
|
|
if not data:
|
|
return result
|
|
|
|
# Handle both list and wrapped responses
|
|
raw_txs: list[Any] = []
|
|
if isinstance(data, list):
|
|
raw_txs = data
|
|
elif isinstance(data, dict) and "results" in data:
|
|
raw_txs = data["results"]
|
|
elif isinstance(data, dict) and "transactions" in data:
|
|
raw_txs = data["transactions"]
|
|
|
|
# Filter to only dict entries (valid transactions)
|
|
transactions: list[dict[str, Any]] = [t for t in raw_txs if isinstance(t, dict)]
|
|
result["txs_checked"] = len(transactions)
|
|
|
|
# Group transactions by target contract (pool)
|
|
pool_groups: dict[str, list[dict[str, Any]]] = {}
|
|
for tx in transactions:
|
|
to_addr = str(tx.get("to_address", "") or "").lower()
|
|
if to_addr:
|
|
if to_addr not in pool_groups:
|
|
pool_groups[to_addr] = []
|
|
pool_groups[to_addr].append(tx)
|
|
|
|
# Look for sandwich patterns in each pool
|
|
for pool_addr, txs in pool_groups.items():
|
|
# Sort by tx_index within block
|
|
sorted_txs = sorted(txs, key=lambda t: t.get("tx_index", 0))
|
|
for i in range(len(sorted_txs) - 2):
|
|
f_tx = sorted_txs[i]
|
|
v_tx = sorted_txs[i + 1]
|
|
b_tx = sorted_txs[i + 2]
|
|
|
|
# Check if first and last are same bot, middle is different user
|
|
f_sender = f_tx.get("from_address", "")
|
|
v_sender = v_tx.get("from_address", "")
|
|
b_sender = b_tx.get("from_address", "")
|
|
|
|
if not f_sender or not v_sender or not b_sender:
|
|
continue
|
|
|
|
f_is_bot = self.is_known_bot(f_sender)
|
|
b_is_bot = self.is_known_bot(b_sender) if f_sender == b_sender else None
|
|
|
|
# Frontrun/backrun same sender (bot), victim is different
|
|
if f_sender == b_sender and f_sender != v_sender:
|
|
confidence = _is_sandwich_pattern(f_tx, v_tx, b_tx)
|
|
if confidence > 0.5:
|
|
candidate = {
|
|
"pool": pool_addr,
|
|
"frontrun_tx": f_tx,
|
|
"victim_tx": v_tx,
|
|
"backrun_tx": b_tx,
|
|
"bot_address": f_sender,
|
|
"victim_address": v_sender,
|
|
"confidence": confidence,
|
|
}
|
|
result["sandwich_candidates"].append(candidate)
|
|
|
|
# Check for individual frontrun/backrun patterns
|
|
# Frontrun: known bot tx immediately before victim tx
|
|
if f_is_bot and f_sender != v_sender:
|
|
result["suspicious_patterns"].append(
|
|
{
|
|
"type": "frontrun",
|
|
"bot": f_sender,
|
|
"victim": v_sender,
|
|
"tx": f_tx.get("tx_hash", ""),
|
|
"target_tx": v_tx.get("tx_hash", ""),
|
|
}
|
|
)
|
|
# Backrun: known bot tx immediately after victim tx
|
|
if b_is_bot and b_sender != v_sender:
|
|
result["suspicious_patterns"].append(
|
|
{
|
|
"type": "backrun",
|
|
"bot": b_sender,
|
|
"victim": v_sender,
|
|
"tx": b_tx.get("tx_hash", ""),
|
|
"target_tx": v_tx.get("tx_hash", ""),
|
|
}
|
|
)
|
|
|
|
# Track bot hits
|
|
for sender in [f_sender, b_sender]:
|
|
bot = self.is_known_bot(sender)
|
|
if bot and sender not in [h["address"] for h in result["bot_hits"]]:
|
|
result["bot_hits"].append(
|
|
{
|
|
"address": sender,
|
|
"name": bot.name,
|
|
"detected_in": pool_addr,
|
|
}
|
|
)
|
|
|
|
return result
|
|
|
|
def _estimate_sandwich_value(
|
|
self,
|
|
chain: str,
|
|
frontrun: dict[str, Any],
|
|
victim: dict[str, Any],
|
|
backrun: dict[str, Any],
|
|
) -> tuple[float, float, float]:
|
|
"""Estimate extracted value and victim loss from a sandwich.
|
|
|
|
Returns (extracted_usd, victim_loss_usd, slippage_impact_pct).
|
|
In production, this would use tx_simulator + price feed.
|
|
For now, estimates based on gas and value metadata.
|
|
"""
|
|
# Simple heuristic: gas cost of bot vs gas cost of victim
|
|
bot_frontrun_gas = (frontrun.get("gas_price_gwei", 0) or 0) * (
|
|
frontrun.get("gas_used", 21000) or 21000
|
|
)
|
|
bot_backrun_gas = (backrun.get("gas_price_gwei", 0) or 0) * (
|
|
backrun.get("gas_used", 21000) or 21000
|
|
)
|
|
|
|
# Bot profit ≈ (bot backrun value - bot frontrun value) - gas costs
|
|
# Victim loss ≈ victim value * slippage caused by frontrun
|
|
frontrun_value = frontrun.get("value_eth", 0) or 0
|
|
backrun_value = backrun.get("value_eth", 0) or 0
|
|
victim_value = victim.get("value_eth", 0) or 0
|
|
|
|
# Assume ETH price ~3000 for estimation
|
|
eth_price = 3000.0
|
|
|
|
# Extracted value ≈ backrun - frontrun after gas
|
|
gross_extracted = (backrun_value - frontrun_value) * eth_price
|
|
gas_cost_eth = (bot_frontrun_gas + bot_backrun_gas) * 1e-9
|
|
extracted_usd = max(0, gross_extracted - gas_cost_eth * eth_price)
|
|
|
|
# Victim loss ≈ frontrun causes price impact that victim pays
|
|
if frontrun_value > 0 and victim_value > 0:
|
|
ratio = frontrun_value / max(victim_value, 0.001)
|
|
slippage_pct = min(ratio * 0.05, 0.5) # Cap at 50%
|
|
else:
|
|
slippage_pct = 0.01
|
|
|
|
victim_loss_usd = victim_value * eth_price * slippage_pct
|
|
|
|
return extracted_usd, victim_loss_usd, slippage_pct * 100
|
|
|
|
async def _analyze_pool_mev_risk(self, pool: PoolInfo) -> dict[str, Any]:
|
|
"""Analyze a single pool for MEV risk indicators."""
|
|
flags = _check_pool_vulnerability(
|
|
{
|
|
"liquidity_usd": pool.liquidity_usd,
|
|
"volume_24h_usd": pool.volume_24h_usd,
|
|
"fee_tier": pool.fee_tier,
|
|
}
|
|
)
|
|
score = _estimate_mev_vulnerability_score(flags)
|
|
return {
|
|
"pool_address": pool.address,
|
|
"chain": pool.chain,
|
|
"name": f"{pool.token0_symbol}/{pool.token1_symbol}",
|
|
"dex_type": pool.dex_type.value,
|
|
"liquidity_usd": pool.liquidity_usd,
|
|
"mev_vulnerability_score": round(score, 2),
|
|
"flags": flags,
|
|
"assessment": ("high" if score > 0.6 else "medium" if score > 0.3 else "low"),
|
|
}
|
|
|
|
async def scan(
|
|
self,
|
|
pool_address: str | None = None,
|
|
block_range: tuple[int, int] | None = None,
|
|
) -> MEVScanReport:
|
|
"""Run a full MEV detection scan.
|
|
|
|
Args:
|
|
pool_address: If set, scan only this specific pool
|
|
block_range: Optional (start_block, end_block) tuple
|
|
Returns:
|
|
MEVScanReport with all findings
|
|
"""
|
|
report = MEVScanReport()
|
|
report.chains_scanned = self.chains
|
|
|
|
# Phase 1: Check each chain for MEV activity
|
|
for chain in self.chains:
|
|
try:
|
|
chain_result = await self._check_chain_mev_activity(chain, block_range)
|
|
report.transactions_analyzed += chain_result.get("txs_checked", 0)
|
|
|
|
# Process sandwich candidates
|
|
for candidate in chain_result.get("sandwich_candidates", []):
|
|
frontrun = candidate.get("frontrun_tx", {})
|
|
victim = candidate.get("victim_tx", {})
|
|
backrun = candidate.get("backrun_tx", {})
|
|
|
|
if isinstance(frontrun, str):
|
|
continue # Skip if we only have hashes
|
|
|
|
extracted, victim_loss, slippage = self._estimate_sandwich_value(
|
|
chain, frontrun, victim, backrun
|
|
)
|
|
|
|
bot_addr = candidate["bot_address"]
|
|
bot_profile = self.identify_bot(bot_addr, chain)
|
|
|
|
frontrun_data = candidate["frontrun_tx"]
|
|
victim_data = candidate["victim_tx"]
|
|
backrun_data = candidate["backrun_tx"]
|
|
|
|
sandwich = SandwichAttack(
|
|
victim_address=candidate["victim_address"],
|
|
token_in=victim_data.get("token_in", "unknown"),
|
|
token_out=victim_data.get("token_out", "unknown"),
|
|
chain=chain,
|
|
pool_address=candidate["pool"],
|
|
block_number=victim_data.get("block_number", 0),
|
|
frontrun_tx=MEVTransaction(
|
|
tx_hash=frontrun_data.get("tx_hash", ""),
|
|
chain=chain,
|
|
block_number=victim_data.get("block_number", 0),
|
|
tx_index=frontrun_data.get("tx_index", 0),
|
|
from_address=bot_addr,
|
|
to_address=candidate["pool"],
|
|
value_eth=frontrun_data.get("value_eth", 0),
|
|
gas_price_gwei=frontrun_data.get("gas_price_gwei", 0),
|
|
gas_used=frontrun_data.get("gas_used", 0),
|
|
method_signature=frontrun_data.get("method_signature", ""),
|
|
),
|
|
victim_tx=MEVTransaction(
|
|
tx_hash=victim_data.get("tx_hash", ""),
|
|
chain=chain,
|
|
block_number=victim_data.get("block_number", 0),
|
|
tx_index=victim_data.get("tx_index", 0),
|
|
from_address=candidate["victim_address"],
|
|
to_address=candidate["pool"],
|
|
value_eth=victim_data.get("value_eth", 0),
|
|
gas_price_gwei=victim_data.get("gas_price_gwei", 0),
|
|
gas_used=victim_data.get("gas_used", 0),
|
|
method_signature=victim_data.get("method_signature", ""),
|
|
),
|
|
backrun_tx=MEVTransaction(
|
|
tx_hash=backrun_data.get("tx_hash", ""),
|
|
chain=chain,
|
|
block_number=victim_data.get("block_number", 0),
|
|
tx_index=backrun_data.get("tx_index", 0),
|
|
from_address=bot_addr,
|
|
to_address=candidate["pool"],
|
|
value_eth=backrun_data.get("value_eth", 0),
|
|
gas_price_gwei=backrun_data.get("gas_price_gwei", 0),
|
|
gas_used=backrun_data.get("gas_used", 0),
|
|
method_signature=backrun_data.get("method_signature", ""),
|
|
),
|
|
bot_address=bot_addr,
|
|
estimated_extracted_usd=extracted,
|
|
victim_loss_usd=victim_loss,
|
|
slippage_impact_pct=slippage,
|
|
confidence=candidate["confidence"],
|
|
severity=(
|
|
MEVSeverity.CRITICAL
|
|
if extracted > 100
|
|
else MEVSeverity.HIGH
|
|
if extracted > 10
|
|
else MEVSeverity.MEDIUM
|
|
if extracted > 1
|
|
else MEVSeverity.LOW
|
|
),
|
|
bot_name=bot_profile.name,
|
|
)
|
|
report.sandwiches.append(sandwich)
|
|
|
|
# Update bot profile
|
|
bot_profile.attacks_detected += 1
|
|
bot_profile.total_extracted_usd += extracted
|
|
if bot_profile.address not in [b.address for b in report.bots_detected]:
|
|
report.bots_detected.append(bot_profile)
|
|
|
|
# Process frontrun/backrun patterns
|
|
for pattern in chain_result.get("suspicious_patterns", []):
|
|
entry = {
|
|
"chain": chain,
|
|
"type": pattern["type"],
|
|
"bot_address": pattern["bot"],
|
|
"victim_address": pattern["victim"],
|
|
"tx_hash": pattern["tx"],
|
|
"target_tx_hash": pattern.get("target_tx", ""),
|
|
}
|
|
if pattern["type"] == "frontrun":
|
|
report.frontruns.append(entry)
|
|
else:
|
|
report.backruns.append(entry)
|
|
|
|
except Exception as e:
|
|
err = f"Chain {chain} scan error: {e}"
|
|
logger.error(err)
|
|
report.errors.append(err)
|
|
|
|
# Phase 2: Analyze vulnerable pools (from pool registry)
|
|
for pool in self._pools.values():
|
|
try:
|
|
risk = await self._analyze_pool_mev_risk(pool)
|
|
report.vulnerable_pools.append(risk)
|
|
report.pools_analyzed += 1
|
|
except Exception as e:
|
|
logger.warning(f"Pool analysis error for {pool.address}: {e}")
|
|
|
|
report.scan_end = datetime.now(UTC).isoformat()
|
|
return report
|
|
|
|
def register_pool(self, pool: PoolInfo) -> None:
|
|
"""Register a pool for monitoring."""
|
|
self._pools[pool.address.lower()] = pool
|
|
|
|
def register_pools_from_dict(self, pools: list[dict[str, Any]]) -> None:
|
|
"""Register multiple pools from dicts (e.g. config file)."""
|
|
for p in pools:
|
|
try:
|
|
dex_type = PoolDexType(p.get("dex_type", "unknown"))
|
|
except ValueError:
|
|
dex_type = PoolDexType.UNKNOWN
|
|
pool = PoolInfo(
|
|
address=p["address"].lower(),
|
|
chain=p.get("chain", "ethereum"),
|
|
dex_type=dex_type,
|
|
token0=p.get("token0", ""),
|
|
token1=p.get("token1", ""),
|
|
token0_symbol=p.get("token0_symbol", ""),
|
|
token1_symbol=p.get("token1_symbol", ""),
|
|
liquidity_usd=p.get("liquidity_usd", 0),
|
|
volume_24h_usd=p.get("volume_24h_usd", 0),
|
|
fee_tier=p.get("fee_tier", 3000),
|
|
)
|
|
self.register_pool(pool)
|
|
|
|
def save_bot_registry(self) -> None:
|
|
"""Save the updated bot registry (including newly discovered bots)."""
|
|
os.makedirs(self.data_dir, exist_ok=True)
|
|
bot_file = os.path.join(self.data_dir, "mev_bots.json")
|
|
serializable = {}
|
|
for addr, profile in self._known_bots.items():
|
|
serializable[addr] = {
|
|
"name": profile.name,
|
|
"chain": profile.chain,
|
|
"tags": profile.tags,
|
|
"attacks_detected": profile.attacks_detected,
|
|
"total_extracted_usd": round(profile.total_extracted_usd, 2),
|
|
}
|
|
try:
|
|
with open(bot_file, "w") as f:
|
|
json.dump(serializable, f, indent=2)
|
|
logger.info(f"Saved {len(serializable)} bot profiles to {bot_file}")
|
|
except OSError as e:
|
|
logger.error(f"Failed to save bot registry: {e}")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Standalone CLI
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
|
|
async def _run_scan(args: Any = None) -> None:
|
|
"""Run a full MEV scan from CLI."""
|
|
detector = MEVSandwichDetector(
|
|
chains=["ethereum", "bsc", "arbitrum", "base", "optimism", "polygon"]
|
|
)
|
|
|
|
# Register some well-known high-volume DEX pools for monitoring
|
|
detector.register_pools_from_dict(
|
|
[
|
|
{
|
|
"address": "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640",
|
|
"chain": "ethereum",
|
|
"dex_type": "uniswap_v2",
|
|
"token0_symbol": "USDC",
|
|
"token1_symbol": "WETH",
|
|
"liquidity_usd": 500_000_000,
|
|
"volume_24h_usd": 50_000_000,
|
|
"fee_tier": 3000,
|
|
},
|
|
{
|
|
"address": "0x5777d92f208679db4b9778590fa3cab3ac9e2168",
|
|
"chain": "ethereum",
|
|
"dex_type": "uniswap_v3",
|
|
"token0_symbol": "DAI",
|
|
"token1_symbol": "USDC",
|
|
"liquidity_usd": 300_000_000,
|
|
"volume_24h_usd": 10_000_000,
|
|
"fee_tier": 100,
|
|
},
|
|
]
|
|
)
|
|
|
|
print("🔍 MEV & Sandwich Attack Detector - Scan Starting")
|
|
print(f" Chains: {', '.join(detector.chains)}")
|
|
print(f" Known bots in registry: {len(detector._known_bots)}")
|
|
print()
|
|
|
|
report = await detector.scan()
|
|
|
|
print(report.summary())
|
|
print()
|
|
|
|
# Top sandwiches
|
|
top = report.top_sandwiches(limit=5)
|
|
if top:
|
|
print("═══ TOP SANDWICH ATTACKS ═══")
|
|
for s in top:
|
|
print(f" {s.summary()}")
|
|
print()
|
|
|
|
# Top bots
|
|
bots = report.top_bots(limit=5)
|
|
if bots:
|
|
print("═══ TOP MEV BOTS ═══")
|
|
for b in bots:
|
|
print(f" {b.summary()}")
|
|
print()
|
|
|
|
# Vulnerable pools
|
|
if report.vulnerable_pools:
|
|
print("═══ VULNERABLE POOLS ═══")
|
|
for p in sorted(
|
|
report.vulnerable_pools,
|
|
key=lambda x: x["mev_vulnerability_score"],
|
|
reverse=True,
|
|
)[:5]:
|
|
print(
|
|
f" {p['name']} ({p['chain']}) - "
|
|
f"score: {p['mev_vulnerability_score']} - {p['assessment']}"
|
|
)
|
|
if p["flags"]:
|
|
for f in p["flags"]:
|
|
print(f" ⚠ {f}")
|
|
print()
|
|
|
|
# Errors
|
|
if report.errors:
|
|
print("═══ ERRORS ═══")
|
|
for e in report.errors:
|
|
print(f" ❌ {e}")
|
|
print()
|
|
|
|
print(f"✅ Scan complete in {report.scan_end}")
|
|
detector.save_bot_registry()
|
|
|
|
|
|
def main() -> None:
|
|
"""CLI entry point."""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="MEV & Sandwich Attack Detector - real-time MEV detection"
|
|
)
|
|
parser.add_argument(
|
|
"--pool",
|
|
type=str,
|
|
default=None,
|
|
help="Analyze specific pool address",
|
|
)
|
|
parser.add_argument(
|
|
"--chain",
|
|
type=str,
|
|
default=None,
|
|
choices=["ethereum", "bsc", "arbitrum", "base", "optimism", "polygon"],
|
|
help="Limit scan to specific chain",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.chain:
|
|
# Single-chain scan
|
|
detector = MEVSandwichDetector(chains=[args.chain])
|
|
asyncio.run(detector.scan())
|
|
else:
|
|
asyncio.run(_run_scan(args))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
)
|
|
main()
|