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

716 lines
28 KiB
Python

"""
SENTINEL - Three Hop Gas Trace Module
======================================
Traces deployer gas money up to 3 wallet hops deep to identify
funding sources. Flags projects where the gas chain dead-ends at a
freshly created exchange hot wallet with zero prior history - a classic
sniper/rug deployment pattern.
Key insight: professional ruggers fund deployer wallets through
a chain of intermediary wallets (often from CEX hot wallets) to
obfuscate the source. If the chain collapses to a fresh wallet
with no history, that's a CRITICAL signal.
Uses:
- Etherscan-family gettxlist API for EVM chains
- Solscan account transfers/transactions for Solana
- Known CEX hot wallet registry from chain_registry
"""
import logging
import os
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
import httpx
from app.chain_registry import (
get_cex_wallets,
is_evm,
is_solana,
)
from app.free_solscan_client import FreeSolscanClient, is_known_exchange
logger = logging.getLogger("gas_trace")
# ── Etherscan Network Config (mirrors dev_reputation pattern) ─────────────
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", "")
BASESCAN_API_KEY = os.getenv("BASESCAN_API_KEY", "")
ARBISCAN_API_KEY = os.getenv("ARBISCAN_API_KEY", "")
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": ARBISCAN_API_KEY},
"optimism": {"url": "https://api-optimistic.etherscan.io/api", "key": ETHERSCAN_API_KEY},
"base": {"url": "https://api.basescan.org/api", "key": BASESCAN_API_KEY},
"linea": {"url": "https://api.lineascan.build/api", "key": ETHERSCAN_API_KEY},
"zksync": {"url": "https://api.zksync.blockscout.run/api", "key": ""},
"scroll": {"url": "https://api.scrollscan.com/api", "key": ETHERSCAN_API_KEY},
}
# ── Data Models ────────────────────────────────────────────────────────────
@dataclass
class TraceHop:
"""A single hop in the gas trace chain."""
address: str
relationship: str # "deployer", "funder", "funder_of_funder", "cex_hot_wallet", etc.
first_seen: str # ISO timestamp or "unknown"
tx_hash: str = "" # The transaction that connected this hop
@dataclass
class GasTraceReport:
"""Report for the 3-hop gas trace analysis."""
deployer_address: str
chain: str
trace_complete: bool # True if we resolved the chain fully
hop_count: int # Number of hops traced (0-3+)
dead_end: bool # True if chain dead-ends at a fresh/empty wallet
dead_end_reason: str # Why it dead-ended ("fresh_wallet", "cex_hot_wallet", "no_funding_tx", "api_error")
risk_label: str # "LOW", "MEDIUM", "HIGH", "CRITICAL"
cex_funded: bool # True if any hop touches a known CEX hot wallet
cex_name: str = "" # Name of the CEX identified (if applicable)
traced_chain: list[TraceHop] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
"""Convert to dict for JSON serialization."""
return {
"deployer_address": self.deployer_address,
"chain": self.chain,
"trace_complete": self.trace_complete,
"hop_count": self.hop_count,
"traced_chain": [
{
"address": h.address,
"relationship": h.relationship,
"first_seen": h.first_seen,
"tx_hash": h.tx_hash,
}
for h in self.traced_chain
],
"dead_end": self.dead_end,
"dead_end_reason": self.dead_end_reason,
"risk_label": self.risk_label,
"cex_funded": self.cex_funded,
"cex_name": self.cex_name,
"errors": self.errors,
}
# ── Gas Trace Analyzer ─────────────────────────────────────────────────────
class GasTraceAnalyzer:
"""
Traces deployer gas money up to 3 wallet hops deep.
For EVM chains: uses Etherscan-family 'txlist' API to find
incoming transactions and walks back the sender chain.
For Solana: uses FreeSolscanClient to find incoming transfers
and traces the source.
Logic:
- Start at deployer_address → find earliest incoming TX → get sender
- That sender is hop 1 → find their earliest incoming TX → get sender
- Repeat up to 3 hops
- At each hop, classify the wallet:
* CEX hot wallet with >1000 TXs → legitimate, stop
* Fresh wallet (<7 days old, <2 prior TXs) → dead_end, CRITICAL
* No incoming funding TX found → dead_end
"""
MAX_HOPS = 3
FRESH_WALLET_DAYS = 7
CEX_TX_THRESHOLD = 1000
def __init__(self):
self._http = httpx.AsyncClient(timeout=20.0)
async def analyze(self, deployer_address: str, chain: str) -> GasTraceReport:
"""
Full 3-hop gas trace analysis for a deployer address.
Args:
deployer_address: The deployer's wallet address.
chain: Chain name (e.g. "ethereum", "solana", "bsc", "base").
Returns:
GasTraceReport with trace chain, dead-end flags, and risk label.
"""
logger.info(f"GasTrace analyzing {deployer_address} on {chain}")
report = GasTraceReport(
deployer_address=deployer_address,
chain=chain,
trace_complete=False,
hop_count=0,
dead_end=False,
dead_end_reason="",
risk_label="LOW",
cex_funded=False,
)
# Record deployer as hop 0
deployer_info = await self._get_wallet_first_seen(deployer_address, chain)
report.traced_chain.append(
TraceHop(
address=deployer_address,
relationship="deployer",
first_seen=deployer_info.get("first_seen", "unknown"),
)
)
# Trace backwards
current_address = deployer_address
chain_visited = {deployer_address.lower()}
for hop in range(1, self.MAX_HOPS + 1):
try:
# Find the funding source for the current address
funding = await self._find_funding_source(current_address, chain)
except Exception as e:
logger.warning(f"Gas trace hop {hop} error for {current_address}: {e}")
report.errors.append(f"Hop {hop} error: {e}")
report.dead_end = True
report.dead_end_reason = "api_error"
break
if funding is None:
# No funding source found - dead end
report.dead_end = True
report.dead_end_reason = "no_funding_tx"
report.trace_complete = False
logger.info(f"Gas trace dead end at hop {hop}: no funding TX for {current_address}")
break
source_address = funding["from"]
tx_hash = funding.get("tx_hash", "")
# Detect cycles (shouldn't happen in a funding chain but be safe)
if source_address.lower() in chain_visited:
logger.info(f"Gas trace cycle detected at hop {hop}{source_address}")
report.dead_end = True
report.dead_end_reason = "cycle_detected"
break
chain_visited.add(source_address.lower())
# Classify the source wallet
classification = await self._classify_wallet(source_address, chain)
relationship = classification.get("relationship", "funder")
first_seen = classification.get("first_seen", "unknown")
is_cex = classification.get("is_cex", False)
cex_name = classification.get("cex_name", "")
classification.get("is_fresh", False)
classification.get("tx_count", 0)
is_dead_end = classification.get("is_dead_end", False)
dead_end_reason = classification.get("dead_end_reason", "")
# Record the hop
report.traced_chain.append(
TraceHop(
address=source_address,
relationship=relationship,
first_seen=first_seen,
tx_hash=tx_hash,
)
)
report.hop_count = hop
if is_cex:
report.cex_funded = True
report.cex_name = cex_name or report.cex_name
# Determine if this is terminal
if is_dead_end:
report.dead_end = True
report.dead_end_reason = dead_end_reason
report.trace_complete = True
logger.info(f"Gas trace terminated at hop {hop}: {dead_end_reason} ({source_address})")
break
# Continue tracing
current_address = source_address
# If we've reached the max hops, stop
if hop == self.MAX_HOPS:
report.trace_complete = True
logger.info(f"Gas trace reached max {self.MAX_HOPS} hops to {source_address}")
else:
# Loop completed without break - all hops exhausted
report.trace_complete = True
# Calculate risk label
report.risk_label = self._calculate_risk_label(report)
logger.info(
f"GasTrace complete: {report.hop_count} hops, "
f"dead_end={report.dead_end}, "
f"risk={report.risk_label}, "
f"cex_funded={report.cex_funded}"
)
return report
# ── Funding Source Resolution ───────────────────────────────────────────
async def _find_funding_source(self, address: str, chain: str) -> dict | None:
"""
Find the funding source for a given address.
Returns dict with 'from', 'tx_hash', and optionally 'timestamp'
or None if no funding source can be found.
For EVM: uses gettxlist to find the earliest incoming transaction.
For Solana: uses Solscan account transfers filtered by 'in' flow.
"""
if is_solana(chain):
return await self._find_funding_source_solana(address)
elif is_evm(chain):
return await self._find_funding_source_evm(address, chain)
else:
logger.warning(f"Unsupported chain: {chain}")
return None
async def _find_funding_source_evm(self, address: str, chain: str) -> dict | None:
"""Find the earliest incoming funding TX for an EVM address via block explorer API."""
net = ETHERSCAN_NETWORKS.get(chain.lower())
if not net:
logger.warning(f"No Etherscan network config for chain: {chain}")
return None
url = net["url"]
api_key = net["key"]
params = {
"module": "account",
"action": "txlist",
"address": address,
"startblock": 0,
"endblock": 99999999,
"page": 1,
"offset": 50,
"sort": "asc", # Oldest first
}
if api_key:
params["apikey"] = api_key
try:
resp = await self._http.get(url, params=params, timeout=15.0)
if resp.status_code != 200:
logger.warning(f"Etherscan API returned {resp.status_code} for {address}")
return None
data = resp.json()
if data.get("status") != "1":
logger.debug(f"Etherscan API status not OK for {address}: {data.get('message', '')}")
return None
txs = data.get("result", [])
if not txs:
return None
# Find first incoming (value > 0) transaction to this address
addr_lower = address.lower()
for tx in txs:
to_addr = tx.get("to", "").lower()
value = int(tx.get("value", "0"))
if to_addr == addr_lower and value > 0:
return {
"from": tx.get("from", ""),
"tx_hash": tx.get("hash", ""),
"timestamp": int(tx.get("timeStamp", 0)),
}
return None
except httpx.TimeoutException:
logger.warning(f"Etherscan API timeout for {address} on {chain}")
return None
except Exception as e:
logger.warning(f"Etherscan API error for {address}: {e}")
return None
async def _find_funding_source_solana(self, address: str) -> dict | None:
"""Find the earliest incoming funding transfer for a Solana address."""
try:
# Try account transfers first (incoming SOL/SPL)
transfers = FreeSolscanClient.account_transfers(address, page=1, page_size=50, flow="in")
if transfers and len(transfers) > 0:
# Sort by block_time ascending (oldest first)
sorted_tx = sorted(transfers, key=lambda x: x.get("block_time", 0))
earliest = sorted_tx[0]
return {
"from": earliest.get("fromAddress", earliest.get("from", "")),
"tx_hash": earliest.get("trans_id", earliest.get("txHash", "")),
"timestamp": earliest.get("block_time", 0),
}
# Fall back to account transactions
txs = FreeSolscanClient.account_transactions(address, page=1, page_size=50)
if txs and len(txs) > 0:
sorted_tx = sorted(txs, key=lambda x: x.get("block_time", 0))
earliest = sorted_tx[0]
# Determine sender - depends on Solscan's response format
sender = (
earliest.get("signer", [None])[0]
if isinstance(earliest.get("signer"), list)
else earliest.get("signer", "")
)
if not sender:
sender = earliest.get("fromAddress", "")
if not sender:
sender = earliest.get("owner", "")
return {
"from": sender,
"tx_hash": earliest.get("trans_id", earliest.get("txHash", "")),
"timestamp": earliest.get("block_time", 0),
}
return None
except Exception as e:
logger.warning(f"Solscan funding source error for {address}: {e}")
return None
# ── Wallet Classification ──────────────────────────────────────────────
async def _classify_wallet(self, address: str, chain: str) -> dict:
"""
Classify a wallet in the trace chain.
Returns dict with:
- relationship: human-readable label
- first_seen: ISO timestamp or "unknown"
- is_cex: bool
- cex_name: str
- is_fresh: bool
- tx_count: int
- is_dead_end: bool
- dead_end_reason: str
"""
result = {
"relationship": "funder",
"first_seen": "unknown",
"is_cex": False,
"cex_name": "",
"is_fresh": False,
"tx_count": 0,
"is_dead_end": False,
"dead_end_reason": "",
}
# ── Step 1: Check if known CEX hot wallet ──
cex_name = await self._identify_cex_wallet(address, chain)
if cex_name:
result["is_cex"] = True
result["cex_name"] = cex_name
result["relationship"] = f"cex_hot_wallet_{cex_name}"
# Get TX count for CEX wallet
tx_count = await self._get_wallet_tx_count(address, chain)
result["tx_count"] = tx_count
# CEX with >1000 TXs = legitimate, stop here
if tx_count >= self.CEX_TX_THRESHOLD:
result["is_dead_end"] = True
result["dead_end_reason"] = "cex_legitimate"
result["relationship"] = f"cex_hot_wallet_{cex_name}"
else:
# CEX wallet with low TX count - suspicious, flag as dead end
result["is_dead_end"] = True
result["dead_end_reason"] = "fresh_cex_wallet"
result["is_fresh"] = True
result["relationship"] = f"fresh_cex_wallet_{cex_name}"
# Get first_seen
first_seen = await self._get_wallet_first_seen(address, chain)
result["first_seen"] = first_seen.get("first_seen", "unknown")
return result
# ── Step 2: Get wallet age and TX count ──
if is_solana(chain):
wallet_info = await self._get_solana_wallet_info(address)
else:
wallet_info = await self._get_evm_wallet_info(address, chain)
first_seen_ts = wallet_info.get("first_seen_ts", 0)
tx_count = wallet_info.get("tx_count", 0)
result["tx_count"] = tx_count
result["first_seen"] = wallet_info.get("first_seen", "unknown")
# ── Step 3: Check if fresh wallet (created within 7 days, <2 TXs) ──
is_fresh = await self._check_fresh_wallet(first_seen_ts, tx_count, chain)
result["is_fresh"] = is_fresh
if is_fresh:
result["is_dead_end"] = True
result["dead_end_reason"] = "fresh_wallet"
result["relationship"] = "fresh_wallet (no history)"
return result
# ── Step 4: Not CEX, not fresh - continue tracing
if tx_count == 0:
# Wallet with 0 TXs - dead end
result["is_dead_end"] = True
result["dead_end_reason"] = "empty_wallet"
result["relationship"] = "empty_wallet"
return result
async def _identify_cex_wallet(self, address: str, chain: str) -> str:
"""Check if an address is a known CEX hot wallet. Returns CEX name or empty string."""
addr_lower = address.lower()
# Check chain's CEX wallets from registry
cex_wallets = get_cex_wallets(chain)
for exchange_name, addresses in cex_wallets.items():
for cex_addr in addresses:
if addr_lower == cex_addr.lower():
return exchange_name
# Also check Solscan's known exchange list for Solana
if is_solana(chain):
result = is_known_exchange(address) or ""
return result
return ""
async def _check_fresh_wallet(self, first_seen_ts: int, tx_count: int, chain: str) -> bool:
"""
Determine if a wallet is 'fresh' - created within FRESH_WALLET_DAYS
and has virtually no history.
"""
if tx_count > 2:
return False # Has activity
if first_seen_ts == 0:
return True # Cannot determine age → treat as fresh
now = datetime.now(UTC)
try:
# first_seen_ts can be in seconds or milliseconds
if first_seen_ts > 1_000_000_000_000: # Milliseconds
wallet_created = datetime.fromtimestamp(first_seen_ts / 1000, tz=UTC)
else: # Seconds
wallet_created = datetime.fromtimestamp(first_seen_ts, tz=UTC)
age = now - wallet_created
return age <= timedelta(days=self.FRESH_WALLET_DAYS)
except (ValueError, OSError):
return True # On error, treat as fresh
# ── Wallet Info Fetchers ───────────────────────────────────────────────────
async def _get_wallet_first_seen(self, address: str, chain: str) -> dict:
"""Get the first seen timestamp for a wallet."""
if is_solana(chain):
return await self._get_solana_wallet_info(address)
else:
return await self._get_evm_wallet_info(address, chain)
async def _get_wallet_tx_count(self, address: str, chain: str) -> int:
"""Get the total transaction count for a wallet."""
if is_solana(chain):
info = await self._get_solana_wallet_info(address)
else:
info = await self._get_evm_wallet_info(address, chain)
return info.get("tx_count", 0)
async def _get_solana_wallet_info(self, address: str) -> dict:
"""
Get Solana wallet info: first_seen timestamp and approximate TX count.
Uses Solscan's account info and transaction/transfer endpoints.
"""
result = {"first_seen": "unknown", "first_seen_ts": 0, "tx_count": 0}
try:
# Get account info (includes creation info on Solscan)
acct_info = FreeSolscanClient.account_info(address)
if acct_info and isinstance(acct_info, dict):
# Some Solscan responses have 'createdTime' or similar
created = acct_info.get("createdTime", 0)
if created:
result["first_seen_ts"] = int(created)
try:
dt = datetime.fromtimestamp(
int(created) / 1000 if int(created) > 1_000_000_000_000 else int(created),
tz=UTC,
)
result["first_seen"] = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
except Exception:
pass
# Get transaction count (first page gives us an idea)
txs = FreeSolscanClient.account_transactions(address, page=1, page_size=20)
if txs and isinstance(txs, list):
result["tx_count"] = len(txs)
# If we have txs, get the earliest timestamp
if not result["first_seen_ts"]:
sorted_tx = sorted(txs, key=lambda x: x.get("block_time", 0))
if sorted_tx:
bt = sorted_tx[0].get("block_time", 0)
if bt:
result["first_seen_ts"] = int(bt)
try:
dt = datetime.fromtimestamp(int(bt), tz=UTC)
result["first_seen"] = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
except Exception:
pass
# Also check transfers to get a more complete picture
transfers = FreeSolscanClient.account_transfers(address, page=1, page_size=20)
if transfers and isinstance(transfers, list):
tx_count = result.get("tx_count", 0)
result["tx_count"] = max(tx_count, len(transfers))
except Exception as e:
logger.warning(f"Solscan wallet info error for {address}: {e}")
return result
async def _get_evm_wallet_info(self, address: str, chain: str) -> dict:
"""
Get EVM wallet info from block explorer API.
Uses txlist to find first transaction and count total TXs.
"""
result = {"first_seen": "unknown", "first_seen_ts": 0, "tx_count": 0}
net = ETHERSCAN_NETWORKS.get(chain.lower())
if not net:
return result
url = net["url"]
api_key = net["key"]
# Get first transaction (oldest) for first_seen timestamp
params = {
"module": "account",
"action": "txlist",
"address": address,
"startblock": 0,
"endblock": 99999999,
"page": 1,
"offset": 1,
"sort": "asc",
}
if api_key:
params["apikey"] = api_key
try:
resp = await self._http.get(url, params=params, timeout=15.0)
if resp.status_code == 200:
data = resp.json()
if data.get("status") == "1":
txs = data.get("result", [])
if txs:
first_tx = txs[0]
ts = int(first_tx.get("timeStamp", 0))
if ts:
result["first_seen_ts"] = ts
try:
dt = datetime.fromtimestamp(ts, tz=UTC)
result["first_seen"] = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
except Exception:
pass
except Exception as e:
logger.warning(f"EVM wallet info error for {address}: {e}")
# Get total TX count using txlist count endpoint
try:
count_params = dict(params) # Copy base params
count_params["action"] = "txlist"
count_params["offset"] = 10000 # Max results
count_params["page"] = 1
count_params["sort"] = "desc"
count_resp = await self._http.get(url, params=count_params, timeout=15.0)
if count_resp.status_code == 200:
count_data = count_resp.json()
if count_data.get("status") == "1":
all_txs = count_data.get("result", [])
result["tx_count"] = len(all_txs)
except Exception as e:
logger.warning(f"EVM TX count error for {address}: {e}")
return result
# ── Risk Calculation ───────────────────────────────────────────────────
def _calculate_risk_label(self, report: GasTraceReport) -> str:
"""
Calculate risk label based on trace results.
CRITICAL: Fresh wallet dead end (sniper/rug pattern)
HIGH: Dead end for other reasons, or CEX funded with short chain
MEDIUM: CEX funded but chain stops at legitimate CEX
LOW: Chain resolves naturally or unknown
"""
if not report.dead_end and report.cex_funded:
return "MEDIUM"
if not report.dead_end:
return "LOW"
if report.dead_end_reason == "fresh_wallet":
return "CRITICAL"
if report.dead_end_reason == "fresh_cex_wallet":
return "CRITICAL"
if report.dead_end_reason == "empty_wallet":
return "CRITICAL"
if report.dead_end_reason == "no_funding_tx":
# Deployer funded directly with no traceable source
if report.hop_count == 0:
return "HIGH"
return "MEDIUM"
if report.dead_end_reason == "api_error":
return "MEDIUM"
if report.dead_end_reason == "cex_legitimate":
return "LOW"
if report.dead_end_reason == "cycle_detected":
return "LOW"
return "LOW"
# ── Standalone helper (matching pipeline pattern) ─────────────────────────
async def run_gas_trace_analysis(
deployer_address: str,
chain: str,
) -> GasTraceReport:
"""
Standalone entry point for gas trace analysis.
Creates a GasTraceAnalyzer, runs analysis, returns the report.
Matches the SENTINEL pipeline callable pattern.
"""
analyzer = GasTraceAnalyzer()
return await analyzer.analyze(deployer_address, chain)