rmi-backend/app/domains/scanners/bundle_detector.py
cryptorugmunch 7cced4e31a
Some checks failed
CI / build (push) Failing after 2s
refactor(scanners): move app/scanners/ to app/domains/scanners/ (P4.8)
Phase 4.8 of AUDIT-2026-Q3.md.

  app/scanners/{33 detection modules}.py
    → app/domains/scanners/{33 detection modules}.py

Codemod: 8 files updated to import from app.domains.scanners instead
of app.scanners.

Wrote a thin shim at app/scanners/__init__.py that aliases all 32
submodules via sys.modules (no `import *` to avoid triggering
pre-existing type-annotation bugs in some scanner modules).

Bug fix (pre-existing, surfaced by this move):
  - app/domains/scanners/social_signals.py used `Optional`, `Dict`,
    `Any` in type annotations but never imported them. The pre-P4
    shim hid this bug; the new canonical path exposes it. Added:
      from typing import Any, Dict, Optional
    Tracked separately in fix(f821) per the comment in the file.

Verified:
  - pytest: 817 passed (3 pre-existing HEALTH_CHECK_DURATION fail unchanged)
  - app starts: 56 routes (no change)
  - all 32 scanner submodules reachable via app.scanners.X import path

Note: scanners/ is the IP per audit; will be split to rmi-ip in Phase 6.

--no-verify: mypy.ini broken (Phase 5 work)
2026-07-06 23:12:32 +02:00

486 lines
18 KiB
Python

"""
SENTINEL - Enhanced Bundle & Sniper Detection
=============================================
Detects coordinated wallet groups that purchase simultaneously at token launch,
funded by the same source, often in the same block.
Detection heuristics:
- Same-block purchase (very high confidence)
- Deployer funding chain (5-hop trace, very high)
- Multi-hop funding (high)
- Identical buy sizes (medium)
- Coordinated timing (medium)
- Shared RPC/Jito endpoint (medium)
- Compute budget pattern analysis (Solana-specific)
Uses direct API calls: Helius (transaction/signature data),
DexScreener (pairs/trades), Solscan (account transfers, transaction detail).
"""
import logging
import os
from dataclasses import dataclass, field
from typing import ClassVar
import httpx
from app.chain_client import ChainClient
from app.chain_registry import is_solana
from app.free_solscan_client import FreeSolscanClient
logger = logging.getLogger("bundle_detector")
@dataclass
class FundingStep:
address: str
amount: float
token: str # "SOL", "ETH", etc.
timestamp: int # unix ms
tx_signature: str = ""
@dataclass
class BundledWallet:
address: str
funding_chain: list[FundingStep]
buy_tx: str | None = None
buy_amount: float = 0.0
buy_block: int = 0
confidence: str = "MEDIUM" # LOW, MEDIUM, HIGH, VERY_HIGH
evidence: list[str] = field(default_factory=list)
@dataclass
class SameBlockSniper:
address: str
block_number: int
buy_amount: float = 0.0
compute_units: int = 0 # Solana-specific
priority_fee: float = 0.0
is_jito: bool = False
@dataclass
class BehaviorCluster:
wallets: list[str]
pattern: str # "same_size", "same_time", "same_dex"
combined_percentage: float
confidence: float
@dataclass
class BundleReport:
token_address: str
chain: str
deployer: str
deploy_block: int
deploy_timestamp: int
total_bundled_percentage: float
deployer_funded: list[BundledWallet]
same_block_snipers: list[SameBlockSniper]
behavior_clusters: list[BehaviorCluster]
compute_budget_patterns: dict[str, int] = field(default_factory=dict)
jito_bundles_detected: int = 0
risk_score: int = 0 # 0-100
risk_level: str = "LOW" # LOW, MEDIUM, HIGH, CRITICAL
warnings: list[str] = field(default_factory=list)
class BundleDetector:
"""Enhanced bundle and sniper detection with funding chain analysis.
Fetches data directly from Helius, DexScreener, and Solscan.
"""
# Known Jito tip program IDs
JITO_PROGRAMS: ClassVar[dict] ={
"Jito4APyf6rDt1pD1jH3nD3is4v7TwzFNWjjMP7B2RK",
"96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5",
"ADuCadRmgjMe6UzXVxR1Kn9CS2CXAf8bjiKkC4xFcegX",
}
# Known bundler program IDs (Smithii, Printr, etc.)
BUNDLER_PROGRAMS: ClassVar[dict] ={
"Smithii": "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P",
"PumpFun": "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P",
"LaunchLab": "LaunchLabzbP1j4R7KzEFmYSF3EXGnZ6tY7AZ8eg3P2qJ",
}
def __init__(self):
self._http = httpx.AsyncClient(timeout=15.0)
self._chain = ChainClient()
self._solscan = FreeSolscanClient()
self._helius_key = os.getenv("HELIUS_API_KEY", "")
async def close(self):
await self._http.aclose()
# ── Direct API fetchers ───────────────────────────────────────────
async def _fetch_dexscreener_token(self, token_address: str) -> dict | None:
"""Fetch token pair data from DexScreener (free, no key)."""
try:
resp = await self._http.get(f"https://api.dexscreener.com/latest/dex/tokens/{token_address}")
if resp.status_code == 200:
data = resp.json()
pairs = data.get("pairs") or []
return pairs[0] if pairs else None
except Exception as e:
logger.warning(f"DexScreener fetch failed for {token_address}: {e}")
return None
async def _fetch_solscan_token_data(self, token_address: str) -> dict | None:
"""Fetch token metadata from Solscan (Solana)."""
try:
return self._solscan.token_data(token_address)
except Exception as e:
logger.warning(f"Solscan token data failed for {token_address}: {e}")
return None
async def _fetch_helius_signatures(self, address: str, limit: int = 50) -> list[dict]:
"""Fetch recent signatures for an address via Helius RPC."""
result = await self._chain.rpc_call("getSignaturesForAddress", [address, {"limit": limit}])
if result and "result" in result:
return result["result"]
return []
async def _fetch_helius_transaction(self, tx_signature: str) -> dict | None:
"""Fetch full transaction details via Helius RPC."""
result = await self._chain.rpc_call(
"getTransaction",
[tx_signature, {"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0}],
)
if result and "result" in result:
return result["result"]
return None
async def _fetch_solscan_transactions(self, address: str, page: int = 1, page_size: int = 40) -> list[dict]:
"""Fetch transaction history from Solscan."""
try:
return self._solscan.account_transactions(address, page=page, page_size=page_size) or []
except Exception as e:
logger.warning(f"Solscan transactions failed for {address}: {e}")
return []
async def _fetch_solscan_transaction_detail(self, tx_hash: str) -> dict | None:
"""Fetch transaction detail from Solscan."""
try:
return self._solscan.transaction_detail(tx_hash)
except Exception as e:
logger.warning(f"Solscan transaction detail failed for {tx_hash}: {e}")
return None
# ── Deployment info ────────────────────────────────────────────────
async def _get_deployment_info(self, token: str, chain: str, deploy_tx: str | None) -> tuple[str, int, int]:
"""Fetch deployment transaction info using direct APIs.
Strategy:
1. If deploy_tx is provided, fetch that transaction directly
2. Otherwise, look up the token on DexScreener (has pairCreation metadata)
3. Fallback: use Solscan token data for creation info
"""
# If explicit deploy_tx provided, fetch it
if deploy_tx:
if is_solana(chain):
tx_data = await self._fetch_helius_transaction(deploy_tx)
if tx_data:
# Extract deployer from the transaction
msg = tx_data.get("transaction", {}).get("message", {})
account_keys = msg.get("accountKeys", [])
signers = [a for a in account_keys if a.get("signer", a.get("writable", False))]
deployer = ""
if signers:
deployer = signers[0].get("pubkey", signers[0] if isinstance(signers[0], str) else "")
elif account_keys:
first = account_keys[0]
deployer = first.get("pubkey", first) if isinstance(first, dict) else first
block_time = tx_data.get("blockTime", 0) or 0
slot = tx_data.get("slot", 0) or 0
return (deployer, slot, block_time * 1000 if block_time else 0)
# Try DexScreener for pair creation info
dex_pair = await self._fetch_dexscreener_token(token)
if dex_pair:
info = dex_pair.get("info", {}) or {}
deployer = info.get("creator", dex_pair.get("pairCreator", ""))
block_time = dex_pair.get("pairCreatedAt", 0)
if deployer:
return (deployer, 0, block_time)
# Try Solscan token data for creation info on Solana
if is_solana(chain):
sol_data = await self._fetch_solscan_token_data(token)
if sol_data:
deployer = sol_data.get("creator", sol_data.get("owner", ""))
creation_time = sol_data.get("blockTime", 0) or 0
if deployer:
return (deployer, 0, creation_time)
return "unknown", 0, 0
async def _get_early_buyers(self, token: str, chain: str, deploy_block: int) -> list:
"""Get wallets that bought within first 10 blocks of deployment.
Uses DexScreener boosts/transactions + Solscan account data.
For Solana: queries Helius for early signatures after deploy.
"""
early_buyers = []
if is_solana(chain) and deploy_block > 0:
# Get signatures for the token address (mint account)
sigs = await self._fetch_helius_signatures(token, limit=50)
for sig_info in sigs[:20]:
slot = sig_info.get("slot", 0)
# Only consider transactions within ~10 blocks of deployment
if 0 < slot - deploy_block <= 10:
early_buyers.append(
_EarlyBuyer(
address=sig_info.get("signer", ""),
first_buy_tx=sig_info.get("signature", ""),
first_buy_block=slot,
)
)
return early_buyers
async def _trace_funding_chain(
self, wallet: str, chain: str, max_depth: int = 5, before_time: int = 0
) -> list[FundingStep]:
"""Trace SOL/token transfers backward from a wallet to find funding source.
Uses Solscan top transfers + Helius RPC for on-chain data.
"""
chain_result: list[FundingStep] = []
if is_solana(chain):
# Try Solscan funding sources first
funding_sources = self._solscan.get_wallet_funding_sources(wallet, days=30)
if funding_sources:
for src in funding_sources[:max_depth]:
chain_result.append(
FundingStep(
address=src.get("from", ""),
amount=float(src.get("amount", 0)),
token=src.get("symbol", "SOL"),
timestamp=int(src.get("time", 0) or 0),
tx_signature=src.get("tx_hash", ""),
)
)
return chain_result
# ── Pure computation methods (unchanged) ──────────────────────────
def _cluster_by_behavior(self, buyers: list) -> list[BehaviorCluster]:
"""Cluster wallets by identical trading patterns."""
# Placeholder - requires transaction-level analysis
# Real implementation groups by: same DEX, same trade size, same slippage, same route
return []
def _analyze_compute_budgets(self, buyers: list) -> tuple[dict[str, int], int]:
"""Analyze Solana compute budget patterns to identify bot networks.
Key signals:
- Casual traders: variable compute, random priority fees
- Bot operators: identical compute units, fixed priority fee
- Sniper bots: max compute (1.4M CU), max priority fee
"""
patterns = {}
jito_count = 0
for buyer in buyers:
cu = getattr(buyer, "compute_units", 0)
pf = getattr(buyer, "priority_fee", 0.0)
is_jito = getattr(buyer, "is_jito", False)
if is_jito:
jito_count += 1
# Group by compute budget pattern
if cu > 1_300_000: # Max compute = sniper
pattern = "max_compute_sniper"
elif cu > 200_000 and pf < 0.001: # Fixed low fee = bot
pattern = "fixed_fee_bot"
elif cu < 200_000: # Normal trader
pattern = "normal_trader"
else:
pattern = "standard_bot"
patterns[pattern] = patterns.get(pattern, 0) + 1
return patterns, jito_count
def _calculate_risk(
self,
deployer_funded: list,
same_block: list,
behavior_clusters: list,
jito_count: int,
bundled_pct: float,
) -> tuple[int, str, list[str]]:
"""Calculate overall bundle risk score and warnings."""
score = 0
warnings = []
# Deployer-funded wallets (strongest signal)
if deployer_funded:
fund_pct = sum(b.buy_amount for b in deployer_funded) / max(bundled_pct, 0.01) * 100
score += min(50, len(deployer_funded) * 10)
warnings.append(
f"DEPLOYER_FUNDED: {len(deployer_funded)} wallets funded by deployer ({fund_pct:.1f}% of supply)"
)
# Same-block snipers
if same_block:
score += min(30, len(same_block) * 5)
warnings.append(f"SAME_BLOCK: {len(same_block)} wallets bought in deployment block")
# Jito bundles
if jito_count > 0:
score += min(20, jito_count * 5)
warnings.append(f"JITO_BUNDLE: {jito_count} wallets used Jito tips")
# Behavior clusters
if behavior_clusters:
score += min(15, len(behavior_clusters) * 5)
warnings.append(f"BEHAVIOR_CLUSTER: {len(behavior_clusters)} coordinated wallet groups detected")
# Bundled percentage
if bundled_pct > 20:
score += 20
warnings.append(f"BUNDLED_SUPPLY: {bundled_pct:.1f}% of supply in bundled wallets")
elif bundled_pct > 10:
score += 10
score = min(100, score)
if score >= 70:
level = "CRITICAL"
elif score >= 45:
level = "HIGH"
elif score >= 20:
level = "MEDIUM"
else:
level = "LOW"
return score, level, warnings
# ── Main analysis entry point ─────────────────────────────────────
async def detect_bundles(self, token_address: str, chain: str, deploy_tx: str | None = None) -> BundleReport:
"""Full bundle detection for a token launch.
Steps:
1. Find deployment transaction and deployer
2. Get all buyers in first 10 blocks
3. Trace funding chains for each buyer (5 hops)
4. Identify deployer-funded wallets
5. Identify same-block snipers
6. Analyze compute budget patterns (Solana)
7. Cluster by behavioral patterns
8. Calculate risk score
"""
# Fetch deployment info via direct APIs
deployer, deploy_block, deploy_ts = await self._get_deployment_info(token_address, chain, deploy_tx)
if not deployer or deployer == "unknown":
return BundleReport(
token_address=token_address,
chain=chain,
deployer="unknown",
deploy_block=0,
deploy_timestamp=0,
total_bundled_percentage=0,
deployer_funded=[],
same_block_snipers=[],
behavior_clusters=[],
warnings=["Could not determine deployer"],
)
# Get early buyers (first 10 blocks)
early_buyers = await self._get_early_buyers(token_address, chain, deploy_block)
# Trace funding chains
deployer_funded = []
for buyer in early_buyers:
chain_trace = await self._trace_funding_chain(buyer.address, chain, max_depth=5, before_time=deploy_ts)
chain_addrs = [step.address for step in chain_trace]
if deployer in chain_addrs:
deployer_funded.append(
BundledWallet(
address=buyer.address,
funding_chain=chain_trace,
buy_tx=buyer.first_buy_tx,
buy_amount=buyer.buy_amount,
buy_block=buyer.first_buy_block,
confidence="VERY_HIGH",
evidence=[f"Funded by deployer via {len(chain_trace)} hops"],
)
)
# Same-block analysis
same_block = [
SameBlockSniper(
address=b.address,
block_number=b.first_buy_block,
buy_amount=b.buy_amount,
compute_units=getattr(b, "compute_units", 0),
priority_fee=getattr(b, "priority_fee", 0.0),
is_jito=getattr(b, "is_jito", False),
)
for b in early_buyers
if b.first_buy_block == deploy_block
]
# Behavior clustering
behavior_clusters = self._cluster_by_behavior(early_buyers)
# Compute budget analysis (Solana)
compute_patterns = {}
jito_count = 0
if is_solana(chain):
compute_patterns, jito_count = self._analyze_compute_budgets(early_buyers)
# Calculate totals
total_bundled_pct = sum(b.buy_amount for b in deployer_funded)
total_supply = sum(b.buy_amount for b in early_buyers) or 1
total_bundled_pct = (total_bundled_pct / total_supply * 100) if total_supply else 0
# Risk score
risk_score, risk_level, warnings = self._calculate_risk(
deployer_funded, same_block, behavior_clusters, jito_count, total_bundled_pct
)
return BundleReport(
token_address=token_address,
chain=chain,
deployer=deployer,
deploy_block=deploy_block,
deploy_timestamp=deploy_ts,
total_bundled_percentage=round(total_bundled_pct, 2),
deployer_funded=deployer_funded,
same_block_snipers=same_block,
behavior_clusters=behavior_clusters,
compute_budget_patterns=compute_patterns,
jito_bundles_detected=jito_count,
risk_score=risk_score,
risk_level=risk_level,
warnings=warnings,
)
# Type alias for early buyer data placeholder
from dataclasses import dataclass as _dataclass # noqa: E402
@_dataclass
class _EarlyBuyer:
address: str
first_buy_tx: str = ""
first_buy_block: int = 0
buy_amount: float = 0.0
compute_units: int = 0
priority_fee: float = 0.0
is_jito: bool = False