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

700 lines
28 KiB
Python

"""
SENTINEL - Wash Trading / Circular Transfer Detection
==============================================
Builds directed graph of token transfers and detects:
- Multi-hop circular trades (A→B→C→D→A over days)
- Cross-DEX loops (buy Raydium, sell Orca, re-buy Jupiter)
- Token-denominated wash trades
- Volume inflation through coordinated activity
Uses networkx for graph cycle detection with time-decay weighting.
A transfer graph with >15% cycle volume indicates wash trading.
Data sources (direct API calls - no self.api_base):
Solana: Helius Enhanced Transactions API (parsed transfers)
Helius DAS API (token metadata)
EVM chains: Moralis (token transfer history)
Etherscan-family (tokentx endpoint)
"""
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime
from typing import ClassVar
import httpx
from app.chain_registry import is_solana
logger = logging.getLogger("wash_trading")
try:
import networkx as nx
HAS_NETWORKX = True
except ImportError:
HAS_NETWORKX = False
logger.warning("networkx not installed - wash trading cycle detection disabled. pip install networkx")
# ── API Keys ────────────────────────────────────────────────
ETHERSCAN_API_KEY = os.getenv("ETHERSCAN_API_KEY", "")
BSCSCAN_API_KEY = os.getenv("BSCSCAN_API_KEY", "")
POLYGONSCAN_API_KEY = os.getenv("POLYGONSCAN_API_KEY", "")
SNOWTRACE_API_KEY = os.getenv("SNOWTRACE_API_KEY", "")
FTMSCAN_API_KEY = os.getenv("FTMSCAN_API_KEY", "")
MORALIS_API_KEY = os.getenv("MORALIS_API_KEY", "")
HELIUS_API_KEY = os.getenv("HELIUS_API_KEY", "")
DEXSCREENER_URL = "https://api.dexscreener.com"
HELIUS_RPC_URL = "https://mainnet.helius-rpc.com"
HELIUS_API_URL = "https://api.helius.xyz"
MORALIS_DATA_URL = "https://deep-index.moralis.io/api/v2.2"
ETHERSCAN_NETWORKS = {
"ethereum": {"url": "https://api.etherscan.io/api", "key": ETHERSCAN_API_KEY},
"eth": {"url": "https://api.etherscan.io/api", "key": ETHERSCAN_API_KEY},
"bsc": {"url": "https://api.bscscan.com/api", "key": BSCSCAN_API_KEY},
"polygon": {"url": "https://api.polygonscan.com/api", "key": POLYGONSCAN_API_KEY},
"avalanche": {"url": "https://api.snowtrace.io/api", "key": SNOWTRACE_API_KEY},
"fantom": {"url": "https://api.ftmscan.com/api", "key": FTMSCAN_API_KEY},
"arbitrum": {"url": "https://api.arbiscan.io/api", "key": ETHERSCAN_API_KEY},
"optimism": {"url": "https://api-optimistic.etherscan.io/api", "key": ETHERSCAN_API_KEY},
"base": {"url": "https://api.basescan.org/api", "key": ETHERSCAN_API_KEY},
}
MORALIS_CHAIN_MAP = {
"ethereum": "eth",
"eth": "eth",
"bsc": "bsc",
"polygon": "polygon",
"avalanche": "avalanche",
"fantom": "fantom",
"arbitrum": "arbitrum",
"optimism": "optimism",
"base": "base",
}
@dataclass
class TransferRecord:
from_address: str
to_address: str
amount: float
token: str # "SOL", "ETH", "USDC", or token address
timestamp: int # unix ms
tx_hash: str = ""
dex: str = "" # "raydium", "orca", "jupiter", "uniswap", etc.
block_number: int = 0
@dataclass
class WashCycle:
wallets: list[str]
combined_volume: float
cycle_length: int # number of hops
time_span_hours: float # how long the cycle took
confidence: float # 0-1
pattern_type: str # "simple_roundtrip", "multi_hop", "cross_dex", "token_denominated"
evidence: list[str] = field(default_factory=list)
@dataclass
class WashTradingReport:
token_address: str
chain: str
total_volume: float
wash_volume: float
wash_score: float # 0-100, higher = more wash trading
detected_cycles: list[WashCycle]
cross_dex_loops: int # Number of cycles crossing multiple DEXes
token_denominated_wash: int # Cycles using the same token
risk_level: str = "LOW" # LOW, MEDIUM, HIGH, CRITICAL
warnings: list[str] = field(default_factory=list)
class WashTradingDetector:
"""Detects wash trading through circular transfer analysis.
Uses direct API calls:
- Helius Enhanced Transactions + DAS for Solana
- Moralis for EVM token transfers
- Etherscan-family as fallback for EVM tx data
"""
# Known DEX router addresses for cross-DEX detection
DEX_ROUTERS: ClassVar[dict] ={
"solana": {
"JUP4Fb2cqiRUr6pNQqvZJQ2SjYqQsBfFidNFBFNB2eq": "Jupiter",
"675kPX9MHTjS2zt1qfr1NYHuzeL7Gxq8RcE3E4A8N2W5": "Raydium AMM",
"whirLbMiicVdup4W1M5VQ8c2m6F5aYsbEbU35JrhP5Y": "Orca Whirlpool",
"9W959Dq2BqZ5EbC3mZV4Gfe8D39VNgVUJ9Kny3ePHF8r": "Raydium CLMM",
},
"ethereum": {
"0x68b346670356c372847478248a90E8eBBaED8e1f": "Uniswap V3",
"0xEf1c6E67703c7BD7107eed8306F688f3a6E76c3d": "SushiSwap",
"0xd9e1cE17a55C3D9f834a4b46Ec8a0DD9Cc10fC69": "1inch",
},
"base": {
"0x6fF56910b4216Ee0915811c3c4E0EE1a9E6d4418": "Aerodrome",
"0x3262D07119Dc7a7F5F0ee7E724F6FA5F4215E3E6": "Uniswap V3 Base",
},
"bsc": {
"0x13f4EA83D0bd40E6F45244DA8a0A0A5ae61A6B31b": "PancakeSwap V3",
"0x10ED43C718714eb63d5aA57B78B54704E25637fE": "PancakeSwap V2",
},
}
def __init__(self):
self.client = httpx.AsyncClient(timeout=15.0)
# ── Helius helpers ────────────────────────────────────────
async def _helius_rpc(self, method: str, params: list | None = None) -> dict | None:
"""Make a Helius JSON-RPC call."""
if not HELIUS_API_KEY:
logger.warning("HELIUS_API_KEY not set")
return None
try:
resp = await self.client.post(
f"{HELIUS_RPC_URL}/?api-key={HELIUS_API_KEY}",
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or []},
)
if resp.status_code == 200:
data = resp.json()
return data.get("result")
except Exception as e:
logger.warning(f"Helius RPC call failed: {e}")
return None
async def _helius_enhanced_txs(self, address: str, limit: int = 100) -> list[dict]:
"""Fetch Enhanced Transactions for a Solana address via Helius.
Returns parsed transaction data including token transfers,
native transfers, and DEX interaction details.
"""
if not HELIUS_API_KEY:
return []
try:
# First get signatures
sigs_result = await self._helius_rpc("getSignaturesForAddress", [address, {"limit": limit}])
if not sigs_result or not isinstance(sigs_result, list):
return []
signatures = [s.get("signature", "") for s in sigs_result if s.get("signature")]
if not signatures:
return []
# Then parse transactions using Helius Enhanced Transactions API
resp = await self.client.post(
f"{HELIUS_API_URL}/v0/transactions/?api-key={HELIUS_API_KEY}",
json={"transactions": signatures[:50]}, # Batch limit
)
if resp.status_code == 200:
data = resp.json()
if isinstance(data, list):
return data
return data if data else []
except Exception as e:
logger.warning(f"Helius Enhanced Transactions failed for {address}: {e}")
return []
# ── Moralis helpers ────────────────────────────────────────
def _moralis_chain(self, chain: str) -> str:
"""Convert chain name to Moralis chain param."""
return MORALIS_CHAIN_MAP.get(chain.lower(), "eth")
async def _moralis_get(self, url: str) -> dict | None:
"""Make a Moralis Data API call with key rotation."""
if not MORALIS_API_KEY:
logger.warning("MORALIS_API_KEY not set")
return None
try:
resp = await self.client.get(url, headers={"X-API-Key": MORALIS_API_KEY, "Accept": "application/json"})
if resp.status_code == 200:
data = resp.json()
if isinstance(data, list):
return data
return data.get("result", data.get("data", data))
except Exception as e:
logger.warning(f"Moralis call failed: {e}")
return None
# ── Etherscan helpers ──────────────────────────────────────
async def _etherscan_get(self, chain: str, params: dict) -> dict | None:
"""Make an Etherscan-family API call."""
net = ETHERSCAN_NETWORKS.get(chain.lower())
if not net:
return None
api_key = net["key"]
if not api_key:
return None
params["apikey"] = api_key
try:
resp = await self.client.get(net["url"], params=params)
if resp.status_code == 200:
data = resp.json()
if data.get("status") == "1":
return data.get("result")
return data
except Exception as e:
logger.warning(f"Etherscan call failed for {chain}: {e}")
return None
# ── DexScreener helper ──────────────────────────────────────
async def _dexscreener_token_pairs(self, token_address: str) -> list[dict]:
"""Get DexScreener pairs for a token (to identify DEX interactions)."""
try:
resp = await self.client.get(f"{DEXSCREENER_URL}/latest/dex/tokens/{token_address}")
if resp.status_code == 200:
data = resp.json()
return data.get("pairs") or []
except Exception as e:
logger.warning(f"DexScreener lookup failed for {token_address}: {e}")
return []
# ── Transfer fetching ──────────────────────────────────────
def _identify_dex(self, address: str, chain: str) -> str:
"""Identify DEX from router address."""
routers = self.DEX_ROUTERS.get(chain, {})
return routers.get(address, "")
async def _fetch_solana_transfers(self, token_address: str) -> list[TransferRecord]:
"""Fetch Solana token transfer data using Helius Enhanced Transactions.
Parses tokenTransfers from enhanced transactions to build transfer records.
"""
transfers = []
# Get signatures for the token address
sigs_result = await self._helius_rpc("getSignaturesForAddress", [token_address, {"limit": 100}])
if not sigs_result or not isinstance(sigs_result, list):
return []
signatures = [s.get("signature", "") for s in sigs_result if s.get("signature")]
if not signatures:
return []
# Batch fetch enhanced transactions
try:
resp = await self.client.post(
f"{HELIUS_API_URL}/v0/transactions/?api-key={HELIUS_API_KEY}",
json={"transactions": signatures[:50]},
)
if resp.status_code != 200:
# Fallback: use individual transaction parsing
for sig in signatures[:30]:
tx = await self._helius_rpc(
"getTransaction",
[sig, {"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0}],
)
if not tx:
continue
# Parse Solana transaction
msg = tx.get("transaction", {}).get("message", {})
accounts = msg.get("accountKeys", [])
account_addrs = []
for a in accounts:
if isinstance(a, dict):
account_addrs.append(a.get("pubkey", ""))
elif isinstance(a, str):
account_addrs.append(a)
# Parse inner instructions for token transfers
inner = tx.get("meta", {}).get("innerInstructions", [])
for inst_group in inner:
for inst in inst_group.get("instructions", []):
parsed = inst.get("parsed", {})
if parsed.get("type") == "transfer" or parsed.get("type") == "transferChecked":
info = parsed.get("info", {})
token_amount = float(
info.get("tokenAmount", {}).get("uiAmount", 0) or info.get("amount", 0)
)
if token_amount <= 0:
continue
from_addr = info.get("authority", info.get("source", ""))
to_addr = info.get("destination", "")
dex = ""
for addr in account_addrs:
dex_name = self._identify_dex(addr, "solana")
if dex_name:
dex = dex_name
break
transfers.append(
TransferRecord(
from_address=from_addr,
to_address=to_addr,
amount=token_amount,
token=token_address,
timestamp=tx.get("blockTime", 0) * 1000,
tx_hash=sig,
dex=dex,
)
)
return transfers
# Parse enhanced transactions
txs = resp.json()
if isinstance(txs, list):
for tx in txs:
# Extract token transfers
for transfer in tx.get("tokenTransfers", []):
mint = transfer.get("mint", "")
# Filter to our token
if mint != token_address:
continue
amount = float(transfer.get("tokenAmount", 0) or 0)
if amount <= 0:
continue
from_addr = transfer.get("fromUserAccount", transfer.get("from", ""))
to_addr = transfer.get("toUserAccount", transfer.get("to", ""))
dex = ""
# Check DEX involvement from instructions
for inst in tx.get("instructions", []):
program_id = inst.get("programId", "")
dex_name = self._identify_dex(program_id, "solana")
if dex_name:
dex = dex_name
break
transfers.append(
TransferRecord(
from_address=from_addr,
to_address=to_addr,
amount=amount,
token=token_address,
timestamp=tx.get("timestamp", 0) * 1000,
tx_hash=tx.get("signature", ""),
dex=dex,
)
)
# Also check native SOL transfers for round-trip detection
for native_transfer in tx.get("nativeTransfers", []):
# Native transfers are SOL - include if they might be part of wash
from_addr = native_transfer.get("fromUserAccount", native_transfer.get("from", ""))
to_addr = native_transfer.get("toUserAccount", native_transfer.get("to", ""))
amount = float(native_transfer.get("amount", 0) or 0) / 1e9 # lamports to SOL
if amount < 0.01: # Skip dust
continue
transfers.append(
TransferRecord(
from_address=from_addr,
to_address=to_addr,
amount=amount,
token="SOL",
timestamp=tx.get("timestamp", 0) * 1000,
tx_hash=tx.get("signature", ""),
dex="",
)
)
except Exception as e:
logger.warning(f"Solana transfer parsing failed: {e}")
return transfers
async def _fetch_evm_transfers(self, token_address: str, chain: str) -> list[TransferRecord]:
"""Fetch EVM token transfer data using Moralis + Etherscan.
Uses Moralis for token transfer history (preferred, has better data)
and falls back to Etherscan tokentx.
"""
transfers = []
moralis_chain = self._moralis_chain(chain)
# ── Strategy 1: Moralis token transfers ──
if MORALIS_API_KEY:
try:
url = f"{MORALIS_DATA_URL}/erc20/{token_address}/transfers?chain={moralis_chain}&limit=100"
result = await self._moralis_get(url)
if result and isinstance(result, list):
for tx in result:
from_addr = tx.get("from_address", "") or tx.get("from", "")
to_addr = tx.get("to_address", "") or tx.get("to", "")
value_raw = tx.get("value", "0")
decimals = int(tx.get("decimals", "18") or "18")
try:
amount = float(value_raw) / (10**decimals) if value_raw else 0
except (ValueError, ZeroDivisionError):
amount = 0
if amount <= 0:
continue
# Determine DEX from to_address
dex = self._identify_dex(to_addr, chain) or self._identify_dex(from_addr, chain)
# Parse timestamp
block_ts = tx.get("block_timestamp", "")
timestamp = 0
if block_ts:
try:
dt = datetime.fromisoformat(block_ts.replace("Z", "+00:00"))
timestamp = int(dt.timestamp() * 1000)
except (ValueError, AttributeError):
pass
transfers.append(
TransferRecord(
from_address=from_addr,
to_address=to_addr,
amount=amount,
token=token_address,
timestamp=timestamp,
tx_hash=tx.get("transaction_hash", tx.get("hash", "")),
dex=dex,
block_number=int(tx.get("block_number", 0) or 0),
)
)
if transfers:
return transfers
except Exception as e:
logger.warning(f"Moralis transfer fetch failed for {token_address} on {chain}: {e}")
# ── Strategy 2: Etherscan tokentx ──
if chain in ETHERSCAN_NETWORKS:
result = await self._etherscan_get(
chain,
{
"module": "account",
"action": "tokentx",
"address": token_address,
"contractaddress": token_address,
"page": 1,
"offset": 100,
"sort": "desc",
},
)
if result and isinstance(result, list):
for tx in result:
from_addr = tx.get("from", "")
to_addr = tx.get("to", "")
value_raw = tx.get("value", "0")
token_decimal = int(tx.get("tokenDecimal", "18") or "18")
try:
amount = float(value_raw) / (10**token_decimal) if value_raw else 0
except (ValueError, ZeroDivisionError):
amount = 0
if amount <= 0:
continue
dex = self._identify_dex(to_addr, chain) or self._identify_dex(from_addr, chain)
timestamp = int(tx.get("timeStamp", 0)) * 1000
transfers.append(
TransferRecord(
from_address=from_addr,
to_address=to_addr,
amount=amount,
token=token_address,
timestamp=timestamp,
tx_hash=tx.get("hash", ""),
dex=dex,
block_number=int(tx.get("blockNumber", 0)),
)
)
return transfers
# ── Graph & cycle detection ────────────────────────────────
def build_transfer_graph(self, transfers: list[TransferRecord]) -> "nx.DiGraph":
"""Build directed graph of transfers for cycle detection."""
if not HAS_NETWORKX:
raise ImportError("networkx required for wash trading detection")
G = nx.DiGraph()
for tx in transfers:
G.add_edge(
tx.from_address,
tx.to_address,
weight=tx.amount,
timestamp=tx.timestamp,
token=tx.token,
dex=tx.dex,
tx_hash=tx.tx_hash,
)
return G
def detect_cycles(self, transfers: list[TransferRecord], max_cycle_length: int = 6) -> list[WashCycle]:
"""Detect circular trading patterns in transfer data.
Uses DFS-based cycle detection on the transfer graph.
Filters cycles by:
- Time span (must complete within 72 hours)
- Volume significance (must involve >0.1% of total supply)
- Economic irrationality (no real trading purpose)
"""
if not HAS_NETWORKX or not transfers:
return []
G = self.build_transfer_graph(transfers)
cycles = []
# Find simple cycles (A→B→A, A→B→C→A)
try:
simple_cycles = list(nx.simple_cycles(G))
except nx.NetworkXError:
return []
for cycle in simple_cycles:
if len(cycle) > max_cycle_length:
continue
# Calculate cycle volume
cycle_volume = 0
time_stamps = []
dexes = set()
for i, wallet in enumerate(cycle):
next_wallet = cycle[(i + 1) % len(cycle)]
if G.has_edge(wallet, next_wallet):
edge_data = G[wallet][next_wallet]
cycle_volume += edge_data.get("weight", 0)
time_stamps.append(edge_data.get("timestamp", 0))
if edge_data.get("dex"):
dexes.add(edge_data["dex"])
# Time span check
if time_stamps and max(time_stamps) > 0 and min(time_stamps) > 0:
time_span_hours = (max(time_stamps) - min(time_stamps)) / 3600000
else:
time_span_hours = 0
# Skip cycles that took too long (not coordinated)
if time_span_hours > 72:
continue
# Determine pattern type
if len(dexes) > 1:
pattern_type = "cross_dex"
elif len(cycle) == 2:
pattern_type = "simple_roundtrip"
else:
pattern_type = "multi_hop"
# Calculate confidence based on:
# - Time span (shorter = more suspicious)
# - Volume significance
# - Number of hops (fewer = more suspicious)
confidence = 0.5
if time_span_hours < 1:
confidence += 0.3 # Very fast = high confidence
elif time_span_hours < 6:
confidence += 0.2
if len(cycle) == 2:
confidence += 0.1 # Roundtrip = obvious
confidence = min(0.99, confidence)
cycles.append(
WashCycle(
wallets=cycle,
combined_volume=round(cycle_volume / 2, 4), # Divide by 2 to avoid double counting
cycle_length=len(cycle),
time_span_hours=round(time_span_hours, 2),
confidence=round(confidence, 2),
pattern_type=pattern_type,
evidence=[
f"{len(cycle)}-wallet {pattern_type} over {time_span_hours:.1f}h",
f"Involves {len(dexes)} DEX(es): {', '.join(dexes) or 'unknown'}",
],
)
)
# Sort by volume (largest first)
cycles.sort(key=lambda c: c.combined_volume, reverse=True)
return cycles
async def analyze(self, token_address: str, chain: str) -> WashTradingReport:
"""Full wash trading analysis for a token.
Steps:
1. Fetch transfer data from direct API sources
2. Build transfer graph
3. Detect circular cycles
4. Classify by pattern type
5. Calculate wash trading score
"""
# Fetch transfer data
transfers = await self._fetch_transfers(token_address, chain)
if not transfers:
return WashTradingReport(
token_address=token_address,
chain=chain,
total_volume=0,
wash_volume=0,
wash_score=0,
detected_cycles=[],
cross_dex_loops=0,
token_denominated_wash=0,
warnings=["No transfer data available"],
)
total_volume = sum(t.amount for t in transfers)
# Detect cycles
cycles = self.detect_cycles(transfers)
# Calculate wash volume
wash_volume = sum(c.combined_volume for c in cycles)
wash_score = min(100, (wash_volume / total_volume * 100) * 2) if total_volume > 0 else 0
# Count cross-DEX loops
cross_dex = sum(1 for c in cycles if c.pattern_type == "cross_dex")
token_wash = sum(1 for c in cycles if c.pattern_type == "token_denominated")
# Calculate risk
warnings = []
if wash_score > 50:
warnings.append(f"CRITICAL: {wash_score:.0f}% wash trading detected - likely artificial volume")
elif wash_score > 25:
warnings.append(f"HIGH: {wash_score:.0f}% wash trading detected - significant volume inflation")
elif wash_score > 10:
warnings.append(f"MODERATE: {wash_score:.0f}% wash trading detected")
if cross_dex > 0:
warnings.append(f"CROSS-DEX: {cross_dex} cycles involving multiple DEXes")
risk_level = (
"CRITICAL" if wash_score > 50 else "HIGH" if wash_score > 25 else "MEDIUM" if wash_score > 10 else "LOW"
)
return WashTradingReport(
token_address=token_address,
chain=chain,
total_volume=round(total_volume, 4),
wash_volume=round(wash_volume, 4),
wash_score=round(wash_score, 1),
detected_cycles=cycles[:20], # Top 20 cycles
cross_dex_loops=cross_dex,
token_denominated_wash=token_wash,
risk_level=risk_level,
warnings=warnings,
)
async def _fetch_transfers(self, token_address: str, chain: str) -> list[TransferRecord]:
"""Fetch recent transfer data for a token from direct API calls.
Routes to chain-specific data sources:
- Solana: Helius Enhanced Transactions + DAS
- EVM: Moralis token transfers (primary) + Etherscan tokentx (fallback)
"""
if is_solana(chain):
return await self._fetch_solana_transfers(token_address)
else:
return await self._fetch_evm_transfers(token_address, chain)