- 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>
842 lines
32 KiB
Python
842 lines
32 KiB
Python
"""
|
|
DEX Liquidity Pool Manipulation Analyzer
|
|
=========================================
|
|
Analyzes DEX pools for manipulation, fake liquidity, and attack vectors:
|
|
|
|
- Concentrated liquidity manipulation (Uniswap V3-style tick ranges)
|
|
- Liquidity depth analysis and concentration detection
|
|
- Sandwich vulnerability scoring
|
|
- Fake/wash liquidity detection (liquidity that exists only briefly)
|
|
- Pool owner risk assessment (fee changes, mint cap, pool config)
|
|
- Price impact simulation
|
|
- MEV vulnerability estimation
|
|
|
|
Features:
|
|
- Multi-DEX support (Uniswap V2/V3, PancakeSwap, Raydium, Orca)
|
|
- Chain-agnostic (EVM + Solana)
|
|
- Confidence-scored manipulation risk (0-100)
|
|
- Per-signal breakdown with evidence
|
|
- Price impact curves for trade simulation
|
|
|
|
Tier: Premium ($0.10)
|
|
Endpoint: POST /api/v1/x402-tools/dex_pool_manipulation
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger("dex_pool_manipulation_analyzer")
|
|
|
|
# ── Constants ──────────────────────────────────────────────────
|
|
EVM_ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
|
|
SOLANA_ADDRESS_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")
|
|
|
|
BASIS_POINTS_DENOM = 10000
|
|
|
|
# ── Risk signal definitions ────────────────────────────────────
|
|
|
|
|
|
class RiskCategory(Enum):
|
|
LIQUIDITY_CONCENTRATION = "liquidity_concentration"
|
|
SANDWICH_VULNERABILITY = "sandwich_vulnerability"
|
|
POOL_OWNER_RISK = "pool_owner_risk"
|
|
FAKE_LIQUIDITY = "fake_liquidity"
|
|
PRICE_MANIPULATION = "price_manipulation"
|
|
MEV_EXPOSURE = "mev_exposure"
|
|
FEE_TIER_ABUSE = "fee_tier_abuse"
|
|
|
|
|
|
RISK_WEIGHTS = {
|
|
RiskCategory.LIQUIDITY_CONCENTRATION: 25,
|
|
RiskCategory.SANDWICH_VULNERABILITY: 15,
|
|
RiskCategory.POOL_OWNER_RISK: 20,
|
|
RiskCategory.FAKE_LIQUIDITY: 25,
|
|
RiskCategory.PRICE_MANIPULATION: 30,
|
|
RiskCategory.MEV_EXPOSURE: 10,
|
|
RiskCategory.FEE_TIER_ABUSE: 15,
|
|
}
|
|
|
|
MAX_RISK_SCORE = sum(RISK_WEIGHTS.values()) # 140
|
|
|
|
|
|
# ── Data models ─────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class PoolConfig:
|
|
"""DEX pool configuration."""
|
|
|
|
address: str
|
|
chain: str
|
|
dex: str
|
|
version: str # "v2" | "v3" | "clmm" (concentrated liquidity)
|
|
token0: str
|
|
token1: str
|
|
token0_symbol: str = ""
|
|
token1_symbol: str = ""
|
|
fee_tier: int = 0 # in basis points
|
|
tick_spacing: int = 0 # V3
|
|
sqrt_price: int = 0 # V3
|
|
liquidity: int = 0 # V3
|
|
total_liquidity_usd: float = 0.0
|
|
owner: str = ""
|
|
created_at: int = 0
|
|
|
|
|
|
@dataclass
|
|
class Position:
|
|
"""A concentrated liquidity position."""
|
|
|
|
owner: str
|
|
tick_lower: int
|
|
tick_upper: int
|
|
liquidity: int
|
|
usd_value: float = 0.0
|
|
|
|
|
|
@dataclass
|
|
class SwapEvent:
|
|
"""Recent swap on this pool."""
|
|
|
|
tx_hash: str
|
|
block: int
|
|
timestamp: int
|
|
amount_in: float
|
|
amount_out: float
|
|
price_before: float
|
|
price_after: float
|
|
price_impact_pct: float = 0.0
|
|
|
|
|
|
@dataclass
|
|
class RiskSignal:
|
|
"""A single risk signal with evidence."""
|
|
|
|
category: RiskCategory
|
|
severity: float # 0.0 - 1.0
|
|
description: str
|
|
evidence: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class PoolRiskReport:
|
|
"""Full risk analysis for a pool."""
|
|
|
|
pool: PoolConfig
|
|
risk_score: float # 0-100
|
|
signals: list[RiskSignal] = field(default_factory=list)
|
|
price_impact_1eth: float = 0.0
|
|
price_impact_10eth: float = 0.0
|
|
price_impact_100eth: float = 0.0
|
|
top_5_concentration_pct: float = 0.0
|
|
liquidity_depth_1pct: float = 0.0
|
|
sandwich_profit_estimate: float = 0.0
|
|
recommendations: list[str] = field(default_factory=list)
|
|
analysis_time_ms: int = 0
|
|
|
|
|
|
# ── Address validation ──────────────────────────────────────────
|
|
|
|
|
|
def is_valid_address(addr: str) -> bool:
|
|
addr = addr.strip()
|
|
return bool(EVM_ADDRESS_RE.match(addr) or SOLANA_ADDRESS_RE.match(addr))
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# Core Analyzer
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
class DEXPoolManipulationAnalyzer:
|
|
"""Analyze a DEX pool for manipulation signals."""
|
|
|
|
def __init__(self, chain: str = "ethereum", dex: str = "uniswap_v3"):
|
|
self.chain = chain
|
|
self.dex = dex
|
|
|
|
async def analyze_pool(
|
|
self,
|
|
pool_address: str,
|
|
recent_swaps: list[dict] | None = None,
|
|
positions: list[dict] | None = None,
|
|
pool_metadata: dict | None = None,
|
|
) -> PoolRiskReport:
|
|
"""Full pool risk analysis."""
|
|
start = time.monotonic()
|
|
|
|
if not is_valid_address(pool_address):
|
|
raise ValueError(f"Invalid pool address: {pool_address}")
|
|
|
|
# Build pool config from metadata
|
|
pool = self._build_pool_config(pool_address, pool_metadata or {})
|
|
|
|
# Parse raw data into typed models
|
|
parsed_swaps = self._parse_swaps(recent_swaps or [])
|
|
parsed_positions = self._parse_positions(positions or [])
|
|
|
|
signals: list[RiskSignal] = []
|
|
recommendations: list[str] = []
|
|
|
|
# ── Analysis 1: Liquidity concentration ──
|
|
conc_signal, conc_pct = self._analyze_concentration(parsed_positions, pool)
|
|
if conc_signal:
|
|
signals.append(conc_signal)
|
|
top5_pct = conc_pct
|
|
|
|
# ── Analysis 2: Sandwich vulnerability ──
|
|
sandwich_signal, sandwich_profit = self._analyze_sandwich_vulnerability(parsed_swaps, pool)
|
|
if sandwich_signal:
|
|
signals.append(sandwich_signal)
|
|
sand_profit = sandwich_profit
|
|
|
|
# ── Analysis 3: Pool owner risk ──
|
|
owner_signal = self._analyze_pool_owner_risk(pool)
|
|
if owner_signal:
|
|
signals.append(owner_signal)
|
|
|
|
# ── Analysis 4: Fake liquidity detection ──
|
|
fake_liq_signal = self._analyze_fake_liquidity(parsed_swaps, parsed_positions, pool)
|
|
if fake_liq_signal:
|
|
signals.append(fake_liq_signal)
|
|
|
|
# ── Analysis 5: Price manipulation ──
|
|
price_manip_signal = self._analyze_price_manipulation(parsed_swaps, pool)
|
|
if price_manip_signal:
|
|
signals.append(price_manip_signal)
|
|
|
|
# ── Analysis 6: MEV exposure ──
|
|
mev_signal = self._analyze_mev_exposure(parsed_swaps, pool)
|
|
if mev_signal:
|
|
signals.append(mev_signal)
|
|
|
|
# ── Analysis 7: Fee tier abuse ──
|
|
fee_signal = self._analyze_fee_tier_abuse(pool)
|
|
if fee_signal:
|
|
signals.append(fee_signal)
|
|
|
|
# ── Calculate risk score ──
|
|
risk_score = self._calculate_risk_score(signals)
|
|
|
|
# ── Price impact simulation ──
|
|
base_liquidity = pool.total_liquidity_usd or 100_000 # default fallback
|
|
impact_1eth = self._simulate_price_impact(1, base_liquidity)
|
|
impact_10eth = self._simulate_price_impact(10, base_liquidity)
|
|
impact_100eth = self._simulate_price_impact(100, base_liquidity)
|
|
depth_1pct = self._estimate_liquidity_depth(base_liquidity)
|
|
|
|
# ── Generate recommendations ──
|
|
recommendations = self._generate_recommendations(signals, risk_score, pool)
|
|
|
|
elapsed = int((time.monotonic() - start) * 1000)
|
|
|
|
return PoolRiskReport(
|
|
pool=pool,
|
|
risk_score=round(risk_score, 1),
|
|
signals=signals,
|
|
price_impact_1eth=round(impact_1eth, 4),
|
|
price_impact_10eth=round(impact_10eth, 4),
|
|
price_impact_100eth=round(impact_100eth, 4),
|
|
top_5_concentration_pct=round(top5_pct, 1),
|
|
liquidity_depth_1pct=round(depth_1pct, 2),
|
|
sandwich_profit_estimate=round(sand_profit, 4),
|
|
recommendations=recommendations,
|
|
analysis_time_ms=elapsed,
|
|
)
|
|
|
|
def _build_pool_config(self, address: str, meta: dict) -> PoolConfig:
|
|
"""Build pool config from metadata dict."""
|
|
return PoolConfig(
|
|
address=address,
|
|
chain=meta.get("chain", self.chain),
|
|
dex=meta.get("dex", self.dex),
|
|
version=meta.get("version", "v3"),
|
|
token0=meta.get("token0", ""),
|
|
token1=meta.get("token1", ""),
|
|
token0_symbol=meta.get("token0_symbol", ""),
|
|
token1_symbol=meta.get("token1_symbol", ""),
|
|
fee_tier=meta.get("fee_tier", 0),
|
|
tick_spacing=meta.get("tick_spacing", 0),
|
|
sqrt_price=meta.get("sqrt_price", 0),
|
|
liquidity=meta.get("liquidity", 0),
|
|
total_liquidity_usd=float(meta.get("total_liquidity_usd", 0)),
|
|
owner=meta.get("owner", ""),
|
|
created_at=meta.get("created_at", 0),
|
|
)
|
|
|
|
def _parse_swaps(self, raw_swaps: list[dict]) -> list[SwapEvent]:
|
|
"""Parse raw swap data into typed swap events."""
|
|
parsed = []
|
|
for s in raw_swaps:
|
|
try:
|
|
amount_in = float(s.get("amount_in", 0))
|
|
amount_out = float(s.get("amount_out", 0))
|
|
price_before = float(s.get("price_before", 0))
|
|
price_after = float(s.get("price_after", 0))
|
|
impact = 0.0
|
|
if price_before > 0:
|
|
impact = abs(price_after - price_before) / price_before * 100
|
|
parsed.append(
|
|
SwapEvent(
|
|
tx_hash=s.get("tx_hash", ""),
|
|
block=int(s.get("block", 0)),
|
|
timestamp=int(s.get("timestamp", 0)),
|
|
amount_in=amount_in,
|
|
amount_out=amount_out,
|
|
price_before=price_before,
|
|
price_after=price_after,
|
|
price_impact_pct=impact,
|
|
)
|
|
)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
return parsed
|
|
|
|
def _parse_positions(self, raw_positions: list[dict]) -> list[Position]:
|
|
"""Parse raw position data into typed positions."""
|
|
parsed = []
|
|
for p in raw_positions:
|
|
try:
|
|
parsed.append(
|
|
Position(
|
|
owner=p.get("owner", ""),
|
|
tick_lower=int(p.get("tick_lower", 0)),
|
|
tick_upper=int(p.get("tick_upper", 0)),
|
|
liquidity=int(p.get("liquidity", 0)),
|
|
usd_value=float(p.get("usd_value", 0)),
|
|
)
|
|
)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
return parsed
|
|
|
|
# ── Analysis methods ────────────────────────────────────────
|
|
|
|
def _analyze_concentration(
|
|
self, positions: list[Position], pool: PoolConfig
|
|
) -> tuple[RiskSignal | None, float]:
|
|
"""
|
|
Detect extreme liquidity concentration.
|
|
If top 5 positions control >70% of liquidity, flag it.
|
|
"""
|
|
if not positions:
|
|
return None, 0.0
|
|
|
|
total_liq = sum(p.liquidity for p in positions)
|
|
if total_liq <= 0:
|
|
return None, 0.0
|
|
|
|
sorted_positions = sorted(positions, key=lambda p: p.liquidity, reverse=True)
|
|
top5 = sorted_positions[:5]
|
|
top5_liq = sum(p.liquidity for p in top5)
|
|
top5_pct = (top5_liq / total_liq) * 100
|
|
|
|
severity = min(top5_pct / 100, 1.0) # 70% → 0.7, 100% → 1.0
|
|
|
|
# Check if single owner dominates
|
|
owner_liq: dict[str, int] = {}
|
|
for p in positions:
|
|
owner_liq[p.owner] = owner_liq.get(p.owner, 0) + p.liquidity
|
|
top_owner_pct = (max(owner_liq.values()) / total_liq) * 100 if owner_liq else 0
|
|
|
|
evidence = [
|
|
f"Top 5 positions control {top5_pct:.1f}% of total liquidity",
|
|
f"Largest LP provider holds {top_owner_pct:.1f}% of liquidity ({max(owner_liq, key=lambda k: owner_liq[k])[:10]}...)"
|
|
if owner_liq
|
|
else "",
|
|
]
|
|
evidence = [e for e in evidence if e]
|
|
|
|
if severity >= 0.3:
|
|
signal = RiskSignal(
|
|
category=RiskCategory.LIQUIDITY_CONCENTRATION,
|
|
severity=round(severity, 2),
|
|
description=f"High liquidity concentration: top 5 positions hold {top5_pct:.1f}%",
|
|
evidence=evidence,
|
|
)
|
|
return signal, top5_pct
|
|
|
|
if severity >= 0.15:
|
|
signal = RiskSignal(
|
|
category=RiskCategory.LIQUIDITY_CONCENTRATION,
|
|
severity=round(severity, 2),
|
|
description=f"Moderate liquidity concentration: top 5 positions hold {top5_pct:.1f}%",
|
|
evidence=evidence,
|
|
)
|
|
return signal, top5_pct
|
|
|
|
return None, top5_pct
|
|
|
|
def _analyze_sandwich_vulnerability(
|
|
self, swaps: list[SwapEvent], pool: PoolConfig
|
|
) -> tuple[RiskSignal | None, float]:
|
|
"""
|
|
Estimate sandwich vulnerability.
|
|
Pools with low liquidity and large swap-to-reserve ratios are
|
|
sandwichable. Also check if past swaps show sandwich patterns.
|
|
"""
|
|
if not swaps:
|
|
return None, 0.0
|
|
|
|
# Look for sandwich patterns: two swaps from same block with price reversal
|
|
sandwich_count = 0
|
|
total_profit_est = 0.0
|
|
|
|
# Group by block
|
|
block_groups: dict[int, list[SwapEvent]] = {}
|
|
for s in swaps:
|
|
block_groups.setdefault(s.block, []).append(s)
|
|
|
|
for _block, block_swaps in block_groups.items():
|
|
if len(block_swaps) >= 2:
|
|
# Check for price up then down pattern
|
|
sorted_swaps = sorted(block_swaps, key=lambda s: s.timestamp)
|
|
for i in range(len(sorted_swaps) - 1):
|
|
for j in range(i + 1, len(sorted_swaps)):
|
|
s1 = sorted_swaps[i]
|
|
s2 = sorted_swaps[j]
|
|
# If first swap pushed price up and second moved it back
|
|
if (
|
|
s1.price_after > s1.price_before
|
|
and s2.price_after < s2.price_before
|
|
and abs(s2.price_after - s1.price_before) / max(s1.price_before, 0.001)
|
|
< 0.02
|
|
):
|
|
sandwich_count += 1
|
|
# Estimate profit as USD value of price displacement
|
|
mid_price = (s1.price_before + s2.price_after) / 2
|
|
total_profit_est += (
|
|
abs(s1.price_after - s1.price_before)
|
|
* min(s1.amount_in, s1.amount_out)
|
|
/ max(mid_price, 0.001)
|
|
)
|
|
|
|
# Also check if low liquidity makes it vulnerable
|
|
base_liq = pool.total_liquidity_usd
|
|
vulnerability_score = 0.0
|
|
swap_to_reserve = 0.0
|
|
avg_swap_size = 0.0
|
|
if swaps:
|
|
avg_swap_size = sum(s.amount_in for s in swaps) / len(swaps)
|
|
|
|
if base_liq > 0 and avg_swap_size > 0:
|
|
swap_to_reserve = avg_swap_size / base_liq
|
|
vulnerability_score = min(swap_to_reserve * 10, 1.0) # 10% swap → 1.0
|
|
|
|
severity = max(vulnerability_score * 0.7, min(sandwich_count / 10, 0.3))
|
|
evidence = []
|
|
|
|
if sandwich_count > 0:
|
|
evidence.append(
|
|
f"Detected {sandwich_count} potential sandwich attack patterns in recent blocks"
|
|
)
|
|
evidence.append(f"Estimated profit from sandwich activity: ${total_profit_est:.2f}")
|
|
|
|
if vulnerability_score > 0.3:
|
|
evidence.append(f"Large swap-to-reserve ratio ({swap_to_reserve:.4f}) - pool is thin")
|
|
|
|
if severity >= 0.2:
|
|
signal = RiskSignal(
|
|
category=RiskCategory.SANDWICH_VULNERABILITY,
|
|
severity=round(severity, 2),
|
|
description=f"Pool is {('highly' if severity > 0.5 else 'moderately')} vulnerable to sandwich attacks",
|
|
evidence=evidence,
|
|
)
|
|
return signal, total_profit_est
|
|
|
|
return None, total_profit_est
|
|
|
|
def _analyze_pool_owner_risk(self, pool: PoolConfig) -> RiskSignal | None:
|
|
"""
|
|
Assess risk from pool owner/creator.
|
|
Flag if owner can change fees, collect fees, or has special powers.
|
|
"""
|
|
risk_factors = []
|
|
severity = 0.0
|
|
|
|
# Fee tier can indicate risk
|
|
if pool.fee_tier == 0 and pool.version in ("v3", "clmm"):
|
|
risk_factors.append("Pool has 0% fee tier - possible fee manipulation")
|
|
severity += 0.2
|
|
|
|
if pool.fee_tier > 1000: # >10%
|
|
risk_factors.append(f"High fee tier ({pool.fee_tier / 100}%) - likely rent-seeking")
|
|
severity += 0.3
|
|
|
|
# Pool with no liquidity
|
|
if pool.total_liquidity_usd <= 0:
|
|
risk_factors.append("Pool has zero reported liquidity - possible ghost pool")
|
|
severity += 0.3
|
|
|
|
# Check if pool is very new with high liquidity (suspicious)
|
|
if pool.created_at > 0 and pool.total_liquidity_usd > 500_000:
|
|
age_hours = (time.time() - pool.created_at) / 3600
|
|
if age_hours < 24:
|
|
risk_factors.append(
|
|
f"Pool is {age_hours:.1f}h old with ${pool.total_liquidity_usd:,.0f} liquidity - rapid ramp is suspicious"
|
|
)
|
|
severity += 0.15
|
|
|
|
if not risk_factors:
|
|
return None
|
|
|
|
severity = min(severity, 1.0)
|
|
signal = RiskSignal(
|
|
category=RiskCategory.POOL_OWNER_RISK,
|
|
severity=round(severity, 2),
|
|
description="Pool configuration carries owner-related risks",
|
|
evidence=risk_factors,
|
|
)
|
|
return signal
|
|
|
|
def _analyze_fake_liquidity(
|
|
self, swaps: list[SwapEvent], positions: list[Position], pool: PoolConfig
|
|
) -> RiskSignal | None:
|
|
"""
|
|
Detect fake/wash liquidity patterns:
|
|
- Large liquidity added then immediately removed
|
|
- Liquidity that never gets traded against
|
|
- Symmetric trades that wash volume
|
|
"""
|
|
risk_factors = []
|
|
severity = 0.0
|
|
|
|
# Check if swaps exist at all
|
|
if not swaps and positions and pool.total_liquidity_usd > 10_000:
|
|
risk_factors.append(
|
|
f"${pool.total_liquidity_usd:,.0f} liquidity with zero recent swaps - liquidity may be fake/unused"
|
|
)
|
|
severity += 0.3
|
|
|
|
# Check if all liquidity is from one provider
|
|
if positions:
|
|
unique_owners = {p.owner for p in positions}
|
|
if len(unique_owners) <= 1 and len(positions) > 1:
|
|
risk_factors.append(
|
|
f"All {len(positions)} positions belong to a single owner - possible wash/self-dealing"
|
|
)
|
|
severity += 0.35
|
|
|
|
# Check for wash trading pattern: symmetric buy/sell pairs
|
|
if swaps:
|
|
wash_pairs = 0
|
|
for i in range(0, len(swaps) - 1, 2):
|
|
if i + 1 < len(swaps):
|
|
s1, s2 = swaps[i], swaps[i + 1]
|
|
# Buy then sell of similar magnitude (within 100% of each other)
|
|
if (
|
|
abs(s1.amount_in - s2.amount_out) / max(s1.amount_in, s2.amount_out, 0.001)
|
|
< 1.0
|
|
and s1.price_before != s2.price_before
|
|
):
|
|
# Check if price returned to near-original
|
|
price_change = abs(s2.price_after - s1.price_before) / max(
|
|
s1.price_before, 0.001
|
|
)
|
|
if price_change < 0.01: # <1% net change after pair
|
|
wash_pairs += 1
|
|
|
|
if wash_pairs >= 3:
|
|
risk_factors.append(
|
|
f"Detected {wash_pairs} potential wash-trading pairs (buy/sell with <1% net price impact)"
|
|
)
|
|
severity += 0.25
|
|
|
|
if not risk_factors:
|
|
return None
|
|
|
|
severity = min(severity, 1.0)
|
|
signal = RiskSignal(
|
|
category=RiskCategory.FAKE_LIQUIDITY,
|
|
severity=round(severity, 2),
|
|
description="Liquidity shows signs of being artificial or wash-generated",
|
|
evidence=risk_factors,
|
|
)
|
|
return signal
|
|
|
|
def _analyze_price_manipulation(
|
|
self, swaps: list[SwapEvent], pool: PoolConfig
|
|
) -> RiskSignal | None:
|
|
"""
|
|
Detect abnormal price movement patterns.
|
|
- Large price swings with low volume
|
|
- Price pumps followed by dumps
|
|
- Abnormal price deviation from market
|
|
"""
|
|
if not swaps or len(swaps) < 2:
|
|
return None
|
|
|
|
risk_factors = []
|
|
|
|
# Calculate cumulative price change
|
|
try:
|
|
price_changes = [abs(s.price_impact_pct) for s in swaps]
|
|
avg_impact = sum(price_changes) / len(price_changes)
|
|
max_impact = max(price_changes)
|
|
|
|
# Track price direction
|
|
start_price = swaps[0].price_before
|
|
end_price = swaps[-1].price_after
|
|
if start_price > 0:
|
|
_ = abs(end_price - start_price) / start_price * 100 # total price change
|
|
|
|
# Large individual impact
|
|
if max_impact > 5.0:
|
|
risk_factors.append(
|
|
f"Single swap caused {max_impact:.2f}% price impact - pool is very thin"
|
|
)
|
|
elif max_impact > 2.0:
|
|
risk_factors.append(f"Single swap caused {max_impact:.2f}% price impact")
|
|
|
|
# High average impact indicates thin pool
|
|
if avg_impact > 1.0:
|
|
risk_factors.append(
|
|
f"Average swap impact {avg_impact:.2f}% - persistent thin liquidity"
|
|
)
|
|
|
|
# Total price manipulation score
|
|
manip_severity = 0.0
|
|
if max_impact > 5.0:
|
|
manip_severity += 0.4
|
|
elif max_impact > 2.0:
|
|
manip_severity += 0.2
|
|
|
|
if avg_impact > 2.0:
|
|
manip_severity += 0.3
|
|
elif avg_impact > 1.0:
|
|
manip_severity += 0.15
|
|
|
|
severities = [manip_severity]
|
|
evidence = risk_factors
|
|
|
|
if severities and severities[0] >= 0.2:
|
|
signal = RiskSignal(
|
|
category=RiskCategory.PRICE_MANIPULATION,
|
|
severity=round(severities[0], 2),
|
|
description=f"Pool shows signs of price manipulation (avg impact {avg_impact:.2f}%, max {max_impact:.2f}%)",
|
|
evidence=evidence,
|
|
)
|
|
return signal
|
|
|
|
except (ZeroDivisionError, IndexError):
|
|
pass
|
|
|
|
return None
|
|
|
|
def _analyze_mev_exposure(self, swaps: list[SwapEvent], pool: PoolConfig) -> RiskSignal | None:
|
|
"""Estimate MEV exposure risk."""
|
|
if not swaps:
|
|
return None
|
|
|
|
# Count rapid successive trades (potential frontrunning)
|
|
rapid_trades = 0
|
|
for i in range(len(swaps) - 1):
|
|
if (
|
|
swaps[i + 1].timestamp - swaps[i].timestamp < 3 # within 3 seconds
|
|
and swaps[i + 1].block == swaps[i].block
|
|
):
|
|
rapid_trades += 1
|
|
|
|
mev_ratio = rapid_trades / len(swaps) if swaps else 0
|
|
|
|
if mev_ratio >= 0.2:
|
|
signal = RiskSignal(
|
|
category=RiskCategory.MEV_EXPOSURE,
|
|
severity=round(min(mev_ratio, 1.0), 2),
|
|
description=f"{rapid_trades}/{len(swaps)} trades in same block within 3s - high MEV activity",
|
|
evidence=[
|
|
f"{rapid_trades} rapid trades detected in same block timestamps",
|
|
f"{mev_ratio * 100:.0f}% of trades are potential frontrun/backrun targets",
|
|
],
|
|
)
|
|
return signal
|
|
|
|
return None
|
|
|
|
def _analyze_fee_tier_abuse(self, pool: PoolConfig) -> RiskSignal | None:
|
|
"""Flag suspicious fee tier configurations."""
|
|
if pool.version not in ("v3", "clmm"):
|
|
return None
|
|
|
|
risk_factors = []
|
|
severity = 0.0
|
|
|
|
# Suspiciously high fee for common pairs
|
|
common_pairs = {"WETH/USDC", "WETH/USDT", "WETH/DAI", "WBTC/USDC", "SOL/USDC", "SOL/USDT"}
|
|
pair_key = f"{pool.token0_symbol}/{pool.token1_symbol}"
|
|
pair_rev = f"{pool.token1_symbol}/{pool.token0_symbol}"
|
|
|
|
if (pair_key in common_pairs or pair_rev in common_pairs) and pool.fee_tier > 100:
|
|
risk_factors.append(
|
|
f"High fee tier ({pool.fee_tier / 100}%) for common pair {pair_key} - above standard 0.01-1% range"
|
|
)
|
|
severity += 0.3
|
|
|
|
# Zero fee with active liquidity - possible fee manipulation
|
|
if pool.fee_tier == 0 and pool.total_liquidity_usd > 10_000:
|
|
risk_factors.append(
|
|
"Zero fee tier with active liquidity - unusual, may indicate fee manipulation"
|
|
)
|
|
severity += 0.2
|
|
|
|
if not risk_factors:
|
|
return None
|
|
|
|
signal = RiskSignal(
|
|
category=RiskCategory.FEE_TIER_ABUSE,
|
|
severity=round(severity, 2),
|
|
description="Pool fee tier configuration is unusual",
|
|
evidence=risk_factors,
|
|
)
|
|
return signal
|
|
|
|
# ── Scoring ──────────────────────────────────────────────────
|
|
|
|
def _calculate_risk_score(self, signals: list[RiskSignal]) -> float:
|
|
"""Calculate weighted risk score 0-100."""
|
|
if not signals:
|
|
return 0.0
|
|
|
|
total_weighted = 0.0
|
|
for signal in signals:
|
|
weight = RISK_WEIGHTS.get(signal.category, 10)
|
|
total_weighted += weight * signal.severity
|
|
|
|
raw_score = (total_weighted / MAX_RISK_SCORE) * 100
|
|
return min(raw_score, 100.0)
|
|
|
|
# ── Price impact simulation ──────────────────────────────────
|
|
|
|
def _simulate_price_impact(self, eth_amount: float, pool_liquidity_usd: float) -> float:
|
|
"""
|
|
Simulate price impact using constant product formula approximation.
|
|
Returns percentage price impact.
|
|
"""
|
|
if pool_liquidity_usd <= 0:
|
|
return 999.99 # infinite impact for empty pool
|
|
|
|
# Using constant product: k = x * y
|
|
# Impact = 1 - (k / (k + eth_in * reserve_out))
|
|
# Simplified: impact ≈ eth_amount / (2 * reserve + eth_amount)
|
|
# For a 50/50 pool, reserve ≈ sqrt(k) ≈ liquidity / 2
|
|
reserve = pool_liquidity_usd / 2
|
|
if reserve <= 0:
|
|
return 999.99
|
|
|
|
impact = (eth_amount) / (2 * reserve + eth_amount) * 100
|
|
return min(impact, 99.99)
|
|
|
|
def _estimate_liquidity_depth(self, pool_liquidity_usd: float) -> float:
|
|
"""
|
|
Estimate how much trade volume causes 1% price impact.
|
|
"""
|
|
if pool_liquidity_usd <= 0:
|
|
return 0.0
|
|
|
|
# For constant product: 1% price impact ≈ 1% of reserve
|
|
# Simplified: depth_1pct ≈ 0.02 * total_liquidity
|
|
return pool_liquidity_usd * 0.02
|
|
|
|
# ── Recommendations ──────────────────────────────────────────
|
|
|
|
def _generate_recommendations(
|
|
self, signals: list[RiskSignal], risk_score: float, pool: PoolConfig
|
|
) -> list[str]:
|
|
"""Generate actionable recommendations based on findings."""
|
|
recs: list[str] = []
|
|
categories = {s.category for s in signals}
|
|
|
|
if max((s.severity for s in signals), default=0) > 0.7:
|
|
recs.append(
|
|
"🚨 CRITICAL: Multiple high-severity risks detected. Avoid trading this pool."
|
|
)
|
|
|
|
if RiskCategory.LIQUIDITY_CONCENTRATION in categories:
|
|
recs.append(
|
|
"Consider splitting large trades across multiple pools to reduce concentration risk."
|
|
)
|
|
|
|
if RiskCategory.SANDWICH_VULNERABILITY in categories:
|
|
recs.append(
|
|
"Use MEV-protected RPC endpoints or private mempools for any trades on this pool."
|
|
)
|
|
|
|
if RiskCategory.POOL_OWNER_RISK in categories:
|
|
recs.append("Verify pool owner/creator reputation before providing liquidity.")
|
|
|
|
if RiskCategory.FAKE_LIQUIDITY in categories:
|
|
recs.append("⚠️ Liquidity appears artificial. Cross-check with on-chain position data.")
|
|
|
|
if RiskCategory.PRICE_MANIPULATION in categories:
|
|
recs.append("Monitor price closely - pool has shown abnormal price movements.")
|
|
|
|
if RiskCategory.MEV_EXPOSURE in categories:
|
|
recs.append("High MEV activity detected. Avoid placing market orders on this pool.")
|
|
|
|
if RiskCategory.FEE_TIER_ABUSE in categories:
|
|
recs.append(
|
|
f"Fee tier ({pool.fee_tier / 100}%) is unusual for this pair. Verify against market standards."
|
|
)
|
|
|
|
if risk_score < 20 and not recs:
|
|
recs.append("✅ Pool appears low risk based on available data.")
|
|
elif not recs:
|
|
recs.append(f"Pool risk score: {risk_score:.0f}/100 - exercise standard caution.")
|
|
|
|
return recs
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# Report formatting
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
def format_risk_report(report: PoolRiskReport) -> dict[str, Any]:
|
|
"""Convert report to API-friendly dict."""
|
|
return {
|
|
"pool_address": report.pool.address,
|
|
"chain": report.pool.chain,
|
|
"dex": f"{report.pool.dex}_{report.pool.version}",
|
|
"pair": f"{report.pool.token0_symbol}/{report.pool.token1_symbol}",
|
|
"risk_score": report.risk_score,
|
|
"risk_level": _risk_level(report.risk_score),
|
|
"signals": [
|
|
{
|
|
"category": s.category.value,
|
|
"severity": s.severity,
|
|
"description": s.description,
|
|
"evidence": s.evidence,
|
|
}
|
|
for s in report.signals
|
|
],
|
|
"metrics": {
|
|
"price_impact": {
|
|
"1_eth": report.price_impact_1eth,
|
|
"10_eth": report.price_impact_10eth,
|
|
"100_eth": report.price_impact_100eth,
|
|
},
|
|
"top_5_concentration_pct": report.top_5_concentration_pct,
|
|
"liquidity_depth_1pct_change_usd": report.liquidity_depth_1pct,
|
|
"sandwich_profit_estimate_usd": report.sandwich_profit_estimate,
|
|
},
|
|
"recommendations": report.recommendations,
|
|
"total_liquidity_usd": report.pool.total_liquidity_usd,
|
|
"fee_tier_bps": report.pool.fee_tier,
|
|
"analysis_time_ms": report.analysis_time_ms,
|
|
}
|
|
|
|
|
|
def _risk_level(score: float) -> str:
|
|
if score >= 70:
|
|
return "critical"
|
|
if score >= 45:
|
|
return "high"
|
|
if score >= 25:
|
|
return "medium"
|
|
if score >= 10:
|
|
return "low"
|
|
return "minimal"
|