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

587 lines
21 KiB
Python

"""
SENTINEL - Block Zero Sniper Analysis
======================================
Detects team distribution farms by tracking the first 5 wallets that bought
in block zero (or earliest block) and checking if they immediately split
tokens into 50+ smaller wallets - a classic team distribution pattern
where devs prepare to dump on retail.
Detection logic:
1. Fetch earliest transactions for the token (first buy block)
2. Identify first 5 unique buyer wallets
3. Trace outgoing transfers from those wallets within 10 blocks
4. If any wallet split to 50+ downstream wallets → splitting detected
5. >50% of first buyers splitting → HIGH farm confidence
6. >80% of first buyers splitting → CRITICAL farm confidence
Data sources (direct API calls - no circular imports):
Solana: Helius Enhanced Transactions, Solscan account transfers
EVM: Etherscan tokentx (blockscan-family API)
Fallback: Consensus RPC for earliest block estimation
"""
import logging
import os
from dataclasses import dataclass, field
from typing import Any
import httpx
from app.chain_registry import is_evm, is_solana
logger = logging.getLogger("block_zero_sniper")
# ── API Keys ────────────────────────────────────────────────────────────────
HELIUS_API_KEY = os.getenv("HELIUS_API_KEY", "")
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", "")
HELIUS_RPC_URL = "https://mainnet.helius-rpc.com"
HELIUS_API_URL = "https://api.helius.xyz"
DEXSCREENER_URL = "https://api.dexscreener.com"
# EVM blockscan network registry
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},
}
# ── Data Classes ────────────────────────────────────────────────────────────
@dataclass
class SplitPath:
"""A single split hop: from wallet → to wallet at a given block."""
from_address: str
to_address: str
block: int
@dataclass
class BlockZeroReport:
"""Report from BlockZeroSniperAnalyzer.
Attributes:
sniper_detected: Whether first-block sniping activity was found.
splitting_detected: Whether any first buyers split into 50+ wallets.
farm_confidence: 0-1 float, confidence that this is a distribution farm.
first_wallets: First 5 unique buyer wallets (addresses).
split_wallets: Wallets detected doing splits (addresses).
split_paths: Detailed split transfer records [{from, to, block}, ...].
risk_label: UNKNOWN / LOW / HIGH / CRITICAL.
"""
sniper_detected: bool = False
splitting_detected: bool = False
farm_confidence: float = 0.0
first_wallets: list[str] = field(default_factory=list)
split_wallets: list[str] = field(default_factory=list)
split_paths: list[SplitPath] = field(default_factory=list)
risk_label: str = "UNKNOWN"
# ── Analyzer ────────────────────────────────────────────────────────────────
class BlockZeroSniperAnalyzer:
"""Analyzes block-zero sniping and split patterns to detect team distribution farms.
Tracks first 5 wallets that bought in block zero (or earliest block).
If those wallets immediately split tokens into 50+ smaller wallets,
it's likely a team distribution farm preparing to dump.
This module is a pure analyzer - it accepts optional pre-fetched transaction
data via `earliest_transactions` and falls back to direct API calls if needed.
"""
# Thresholds
SPLIT_WALLET_THRESHOLD = 50 # 50+ downstream wallets = split detected
SPLIT_BLOCK_WINDOW = 10 # within 10 blocks of the buy
MAX_FIRST_WALLETS = 5 # track first 5 unique buyers
def __init__(self):
self._http = httpx.AsyncClient(timeout=15.0)
self._helius_key = HELIUS_API_KEY
async def close(self):
"""Clean up the HTTP client."""
await self._http.aclose()
# ── Public API ──────────────────────────────────────────────────────────
async def analyze(
self,
token_address: str,
chain: str,
earliest_transactions: list[dict[str, Any]] | None = None,
) -> BlockZeroReport:
"""Analyze block-zero sniping and wallet splitting.
Args:
token_address: Token contract/mint address to analyze.
chain: Blockchain name (solana, ethereum, base, bsc, etc.).
earliest_transactions: Optional pre-fetched list of earliest
token transactions. Each dict should have at least:
- 'from' or 'from_address' (str)
- 'to' or 'to_address' (str)
- 'block' or 'block_number' (int)
If None, the analyzer will fetch them via direct API calls.
Returns:
BlockZeroReport with detection results.
"""
chain_lower = chain.lower() if chain else ""
# Gracefully handle missing transaction data
if earliest_transactions is None:
earliest_transactions = await self._fetch_earliest_transactions(token_address, chain_lower)
# If still no data after fetch attempt, return unknown
if not earliest_transactions:
logger.info(
"No transaction data available for %s on %s",
token_address,
chain_lower,
)
return BlockZeroReport(
sniper_detected=False,
splitting_detected=False,
farm_confidence=0.0,
first_wallets=[],
split_wallets=[],
split_paths=[],
risk_label="UNKNOWN",
)
# Step 1: Identify first 5 unique buyer wallets
first_wallets = self._extract_first_buyers(earliest_transactions)
if not first_wallets:
return BlockZeroReport(risk_label="UNKNOWN")
# Step 2: Trace outgoing splits from each first wallet
all_split_paths: list[SplitPath] = []
split_wallets: list[str] = []
for wallet in first_wallets:
splits = await self._trace_wallet_splits(wallet, token_address, chain_lower)
if len(splits) >= self.SPLIT_WALLET_THRESHOLD:
split_wallets.append(wallet)
all_split_paths.extend(splits)
# Step 3: Classify
sniper_detected = bool(first_wallets)
splitting_detected = bool(split_wallets)
ratio = len(split_wallets) / max(len(first_wallets), 1)
if ratio > 0.8:
farm_confidence = 1.0
risk_label = "CRITICAL"
elif ratio > 0.5:
farm_confidence = 0.75
risk_label = "HIGH"
elif ratio > 0.0:
farm_confidence = 0.5
risk_label = "LOW"
else:
farm_confidence = 0.0
risk_label = "LOW"
return BlockZeroReport(
sniper_detected=sniper_detected,
splitting_detected=splitting_detected,
farm_confidence=round(farm_confidence, 2),
first_wallets=first_wallets,
split_wallets=split_wallets,
split_paths=all_split_paths,
risk_label=risk_label,
)
# ── First-buyer extraction ──────────────────────────────────────────────
@staticmethod
def _extract_first_buyers(transactions: list[dict[str, Any]]) -> list[str]:
"""Extract the first 5 unique buyer wallets from earliest transactions.
Transactions are assumed to be sorted chronologically (earliest first).
A 'buyer' is the 'from' address - the wallet that initiated the purchase.
"""
seen: set = set()
buyers: list[str] = []
for tx in transactions:
# Normalise key names (supports both snake_case and camelCase)
buyer = tx.get("from") or tx.get("from_address") or ""
if not buyer:
continue
# Skip system/zero/null addresses
if buyer in seen or _is_dead_address(buyer):
continue
seen.add(buyer)
buyers.append(buyer)
if len(buyers) >= 5:
break
return buyers
# ── Split tracing ───────────────────────────────────────────────────────
async def _trace_wallet_splits(
self,
wallet: str,
token_address: str,
chain: str,
) -> list[SplitPath]:
"""Trace outgoing token transfers from a wallet within the split window.
For Solana: uses Helius signature fetching + Solscan transfers.
For EVM: uses Etherscan tokentx with wallet as address filter.
Returns list of SplitPath records - more than SPLIT_WALLET_THRESHOLD
unique 'to' addresses indicates a split.
"""
if is_solana(chain):
return await self._trace_solana_splits(wallet, token_address)
elif is_evm(chain):
return await self._trace_evm_splits(wallet, token_address, chain)
return []
async def _trace_solana_splits(
self,
wallet: str,
token_address: str,
) -> list[SplitPath]:
"""Trace Solana splits via Helius signatures for the wallet."""
if not self._helius_key:
logger.warning("HELIUS_API_KEY not set - cannot trace Solana splits")
return []
splits: list[SplitPath] = []
try:
# Get recent signatures for this wallet (outgoing transactions)
sigs = await self._helius_rpc(
"getSignaturesForAddress",
[wallet, {"limit": 100}],
)
if not sigs or not isinstance(sigs, list):
return []
signatures = [s.get("signature", "") for s in sigs if s.get("signature")]
if not signatures:
return []
# Parse transactions to find token transfers
resp = await self._http.post(
f"{HELIUS_API_URL}/v0/transactions/?api-key={self._helius_key}",
json={"transactions": signatures[:50]},
)
if resp.status_code != 200:
return []
txs = resp.json()
if not isinstance(txs, list):
return []
for tx in txs:
block = tx.get("slot", 0) or 0
# Check token transfers in the transaction
token_transfers = tx.get("tokenTransfers") or []
for tt in token_transfers:
tt_from = tt.get("fromUserAccount") or tt.get("fromTokenAccount") or ""
tt_to = tt.get("toUserAccount") or tt.get("toTokenAccount") or ""
mint = tt.get("mint", "")
# Only consider transfers of the target token, from our wallet
if mint.lower() != token_address.lower():
continue
if tt_from.lower() != wallet.lower():
continue
if tt_to.lower() == wallet.lower():
continue
if _is_dead_address(tt_to):
continue
splits.append(
SplitPath(
from_address=wallet,
to_address=tt_to,
block=block,
)
)
except Exception as e:
logger.warning("Solana split trace failed for %s: %s", wallet, e)
return splits
async def _trace_evm_splits(
self,
wallet: str,
token_address: str,
chain: str,
) -> list[SplitPath]:
"""Trace EVM splits via Etherscan tokentx for the wallet."""
net = ETHERSCAN_NETWORKS.get(chain.lower())
if not net:
return []
api_key = net["key"]
if not api_key:
logger.warning("No Etherscan API key for %s", chain)
return []
splits: list[SplitPath] = []
try:
params = {
"module": "account",
"action": "tokentx",
"address": wallet,
"contractaddress": token_address,
"sort": "asc",
"apikey": api_key,
}
resp = await self._http.get(net["url"], params=params)
if resp.status_code != 200:
return []
data = resp.json()
if data.get("status") != "1":
return []
results = data.get("result", [])
if not isinstance(results, list):
return []
for tx in results:
tx_from = (tx.get("from", "") or "").lower()
tx_to = (tx.get("to", "") or "").lower()
block = int(tx.get("blockNumber", 0) or 0)
# Only transfers FROM our wallet
if tx_from != wallet.lower():
continue
if tx_to == wallet.lower():
continue
if _is_dead_address(tx_to):
continue
splits.append(
SplitPath(
from_address=wallet,
to_address=tx.get("to", ""),
block=block,
)
)
except Exception as e:
logger.warning("EVM split trace failed for %s on %s: %s", wallet, chain, e)
return splits
# ── Fetch earliest transactions (fallback) ──────────────────────────────
async def _fetch_earliest_transactions(
self,
token_address: str,
chain: str,
) -> list[dict[str, Any]]:
"""Fetch earliest token transactions via direct API calls.
Strategy:
1. DexScreener for pair creation info (earliest block)
2. Solana: Helius signatures for the token mint
3. EVM: Etherscan tokentx for the token contract
"""
transactions: list[dict[str, Any]] = []
if is_solana(chain):
transactions = await self._fetch_solana_earliest(token_address)
elif is_evm(chain):
transactions = await self._fetch_evm_earliest(token_address, chain)
return transactions
async def _fetch_solana_earliest(
self,
token_address: str,
) -> list[dict[str, Any]]:
"""Fetch earliest Solana token transactions via Helius."""
if not self._helius_key:
logger.warning("HELIUS_API_KEY not set - cannot fetch Solana early txs")
return []
try:
# Get earliest signatures for this token mint
sigs = await self._helius_rpc(
"getSignaturesForAddress",
[token_address, {"limit": 50}],
)
if not sigs or not isinstance(sigs, list):
return []
signatures = [s.get("signature", "") for s in sigs if s.get("signature")]
if not signatures:
return []
# Parse through Helius Enhanced Transactions API
resp = await self._http.post(
f"{HELIUS_API_URL}/v0/transactions/?api-key={self._helius_key}",
json={"transactions": signatures[:50]},
)
if resp.status_code != 200:
return []
txs = resp.json()
if not isinstance(txs, list):
return []
result: list[dict[str, Any]] = []
for tx in txs:
block = tx.get("slot", 0) or 0
token_transfers = tx.get("tokenTransfers") or []
for tt in token_transfers:
mint = tt.get("mint", "")
if mint.lower() != token_address.lower():
continue
result.append(
{
"from": tt.get("fromUserAccount", ""),
"to": tt.get("toUserAccount", ""),
"block": block,
"amount": tt.get("tokenAmount", 0),
"tx": tx.get("signature", ""),
}
)
# Sort by block ascending
result.sort(key=lambda x: x.get("block", 0))
return result
except Exception as e:
logger.warning(
"Failed to fetch Solana earliest txs for %s: %s",
token_address,
e,
)
return []
async def _fetch_evm_earliest(
self,
token_address: str,
chain: str,
) -> list[dict[str, Any]]:
"""Fetch earliest EVM token transactions via Etherscan tokentx."""
net = ETHERSCAN_NETWORKS.get(chain.lower())
if not net:
return []
api_key = net["key"]
if not api_key:
logger.warning("No Etherscan API key for %s", chain)
return []
try:
params = {
"module": "account",
"action": "tokentx",
"contractaddress": token_address,
"sort": "asc",
"apikey": api_key,
}
resp = await self._http.get(net["url"], params=params)
if resp.status_code != 200:
return []
data = resp.json()
if data.get("status") != "1":
return []
results = data.get("result", [])
if not isinstance(results, list):
return []
result: list[dict[str, Any]] = []
for tx in results:
result.append(
{
"from": tx.get("from", ""),
"to": tx.get("to", ""),
"block": int(tx.get("blockNumber", 0) or 0),
"amount": float(tx.get("value", 0) or 0),
"tx": tx.get("hash", ""),
}
)
# Already sorted asc by Etherscan
return result
except Exception as e:
logger.warning(
"Failed to fetch EVM earliest txs for %s on %s: %s",
token_address,
chain,
e,
)
return []
# ── Helius RPC helper ───────────────────────────────────────────────────
async def _helius_rpc(self, method: str, params: list | None = None) -> Any | None:
"""Make a Helius JSON-RPC call."""
try:
resp = await self._http.post(
f"{HELIUS_RPC_URL}/?api-key={self._helius_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("Helius RPC call %s failed: %s", method, e)
return None
# ── Helpers ─────────────────────────────────────────────────────────────────
_DEAD_ADDRESSES: set = {
# Solana
"11111111111111111111111111111111",
"1nc1nerator11111111111111111111111111111111",
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
# EVM
"0x0000000000000000000000000000000000000000",
"0x000000000000000000000000000000000000dead",
"0x0000000000000000000000000000000000000001",
"0xdead000000000000000000000000000000000000",
}
def _is_dead_address(address: str) -> bool:
"""Check if an address is a known dead/burn/null address (case-insensitive)."""
addr = address.lower().strip()
return addr in _DEAD_ADDRESSES or addr in {
f"0x{i:040x}"
for i in range(10) # common 0x0... variants
}