rmi-backend/app/caching_shield/funding_tracer.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- 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>
2026-07-06 15:43:20 +02:00

446 lines
14 KiB
Python

"""
EVM Funding Source Tracer - Self-Built Blockchain Forensics
Traces where a wallet got its initial funding using our existing
public RPC infrastructure and ClickHouse wallet labels. No external
API needed - built entirely on our consensus RPC + local data.
Chains supported: all 9 EVM chains in consensus_rpc (1, 56, 137, 8453,
42161, 10, 43114, 250, 100)
Strategy:
1. Get first N inbound transactions for the wallet
2. Find the earliest ETH transfer (funding tx)
3. Resolve the sender address
4. Classify the sender (CEX, DEX, bridge, mixer, contract, unknown)
5. If sender is also externally funded, trace one hop further
6. Return funding trace with confidence scoring
Usage:
from app.caching_shield.funding_tracer import trace_funding_source
result = await trace_funding_source("0x...", chain_id=1)
# Returns: {source_address, source_type, confidence, hops, ...}
"""
import logging
import os
from dataclasses import dataclass, field
logger = logging.getLogger("funding_tracer")
# Chain config
CHAIN_NAMES = {
1: "ethereum",
56: "bsc",
137: "polygon",
8453: "base",
42161: "arbitrum",
10: "optimism",
43114: "avalanche",
250: "fantom",
100: "gnosis",
}
# Known CEX deposit addresses (from our imported label DB)
CEX_PATTERNS = [
"binance",
"coinbase",
"kraken",
"kucoin",
"okx",
"bybit",
"gate.io",
"mexc",
"bitfinex",
"huobi",
"gemini",
"crypto.com",
"ftx",
"bitstamp",
"bittrex",
"poloniex",
"robinhood",
]
# Known bridge contracts
BRIDGE_PATTERNS = [
"bridge",
"portal",
"wormhole",
"layerzero",
"stargate",
"hop",
"across",
"synapse",
"celer",
"multichain",
"anyswap",
"orbiter",
"socket",
"li.fi",
"bungee",
"jumper",
]
# Known mixer/tumbler patterns
MIXER_PATTERNS = [
"tornado",
"mixer",
"tumbler",
"cyclone",
"typhoon",
]
# Max depth to trace
MAX_TRACE_DEPTH = 3
MAX_TX_TO_CHECK = 20
@dataclass
class FundingTrace:
"""Result of a funding source trace."""
wallet: str
chain_id: int
chain_name: str
funding_tx_hash: str = ""
source_address: str = ""
source_type: str = "unknown" # cex, dex, bridge, mixer, contract, eoa, unknown
source_label: str = "" # human-readable label if known
confidence: float = 0.0 # 0-100
funding_amount_eth: float = 0.0
funding_timestamp: int = 0
hops: list[dict] = field(default_factory=list) # trace chain
errors: list[str] = field(default_factory=list)
async def trace_funding_source(
wallet: str,
chain_id: int = 1,
max_depth: int = MAX_TRACE_DEPTH,
) -> FundingTrace:
"""Trace where a wallet got its initial funding.
Args:
wallet: EVM address to trace
chain_id: Ethereum chain ID (1, 56, 137, etc.)
max_depth: How many hops to trace back (default 3)
Returns:
FundingTrace with source classification
"""
chain_name = CHAIN_NAMES.get(chain_id, f"chain_{chain_id}")
trace = FundingTrace(wallet=wallet, chain_id=chain_id, chain_name=chain_name)
try:
# Step 1: Get early transactions for this wallet
txs = await _get_wallet_transactions(wallet, chain_id)
if not txs:
trace.errors.append("No transactions found")
return trace
# Step 2: Find the earliest inbound ETH transfer
funding_tx = _find_earliest_inbound(txs, wallet)
if not funding_tx:
trace.errors.append("No inbound transfers found")
return trace
trace.funding_tx_hash = funding_tx.get("hash", "")
trace.funding_amount_eth = float(funding_tx.get("value", 0)) / 1e18
trace.funding_timestamp = int(funding_tx.get("timeStamp", 0))
# Step 3: Resolve the funding source
source = funding_tx.get("from", "").lower()
trace.source_address = source
trace.hops.append(
{
"address": source,
"tx_hash": funding_tx.get("hash", ""),
"amount_eth": trace.funding_amount_eth,
"depth": 1,
}
)
# Step 4: Classify the source
source_type, source_label = await _classify_address(source, chain_id)
trace.source_type = source_type
trace.source_label = source_label
trace.confidence = _confidence_for_type(source_type)
# Step 5: If source is an EOA, trace one more hop
if source_type == "eoa" and max_depth > 1:
source_txs = await _get_wallet_transactions(source, chain_id)
grandparent_tx = _find_earliest_inbound(source_txs, source)
if grandparent_tx:
gp_addr = grandparent_tx.get("from", "").lower()
gp_type, gp_label = await _classify_address(gp_addr, chain_id)
trace.hops.append(
{
"address": gp_addr,
"tx_hash": grandparent_tx.get("hash", ""),
"amount_eth": float(grandparent_tx.get("value", 0)) / 1e18,
"depth": 2,
}
)
# If we found a classified source, upgrade
if gp_type != "eoa" and gp_type != "unknown":
trace.source_address = gp_addr
trace.source_type = gp_type
trace.source_label = gp_label
trace.confidence = _confidence_for_type(gp_type)
except Exception as e:
trace.errors.append(f"Trace error: {str(e)[:200]}")
logger.warning(f"Funding trace failed for {wallet}: {e}")
return trace
async def _get_wallet_transactions(address: str, chain_id: int) -> list[dict]:
"""Get recent transactions for a wallet using Blockscout or Etherscan.
Uses our consensus RPC as fallback - walks getLogs for Transfer events.
"""
# Try Blockscout first (covers all chains, one key)
blockscout_key = os.getenv("BLOCKSCOUT_API_KEY", "")
if blockscout_key:
return await _fetch_blockscout_txs(address, chain_id, blockscout_key)
# Fall back to Etherscan API
etherscan_key = os.getenv("ETHERSCAN_API_KEY", "")
if etherscan_key and chain_id == 1:
return await _fetch_etherscan_txs(address, etherscan_key)
# Final fallback: use public RPC with eth_getLogs for Transfer events
return await _fetch_rpc_transfers(address, chain_id)
async def _fetch_blockscout_txs(address: str, chain_id: int, api_key: str) -> list[dict]:
"""Fetch transactions via Blockscout v2 API.
Format: GET /{chain_id}/api/v2/addresses/{address}/transactions?apikey=KEY
Response: {"items": [{"hash": ..., "from": {"hash": ...}, "to": {"hash": ...}, "value": ...}]}
"""
import httpx
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
f"https://api.blockscout.com/{chain_id}/api/v2/addresses/{address}/transactions",
params={
"apikey": api_key,
"filter": "to", # inbound only
},
headers={"Accept": "application/json"},
)
if resp.status_code == 200:
data = resp.json()
items = data.get("items", [])
# Convert v2 format to etherscan-compatible format
txs = []
for item in items:
txs.append(
{
"hash": item.get("hash", ""),
"from": (item.get("from") or {}).get("hash", ""),
"to": (item.get("to") or {}).get("hash", ""),
"value": item.get("value", "0"),
"timeStamp": _parse_timestamp(item.get("timestamp", "")),
"blockNumber": str(item.get("block", 0)),
}
)
return sorted(txs, key=lambda t: int(t.get("blockNumber", 0)))
except Exception as e:
logger.debug(f"Blockscout v2 fetch failed for {address}: {e}")
return []
async def _fetch_etherscan_txs(address: str, api_key: str) -> list[dict]:
"""Fetch transactions via Etherscan API."""
import httpx
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
"https://api.etherscan.io/api",
params={
"module": "account",
"action": "txlist",
"address": address,
"startblock": 0,
"endblock": 99999999,
"page": 1,
"offset": MAX_TX_TO_CHECK,
"sort": "asc",
"apikey": api_key,
},
)
if resp.status_code == 200:
data = resp.json()
if data.get("status") == "1":
return data.get("result", [])
except Exception as e:
logger.debug(f"Etherscan fetch failed for {address}: {e}")
return []
async def _fetch_rpc_transfers(address: str, chain_id: int) -> list[dict]:
"""Fallback: get transactions via public RPC getLogs for Transfer events."""
from app.consensus_rpc import get_consensus_rpc
rpc = get_consensus_rpc()
# Get ETH transfers via getLogs (Transfer event signature)
transfer_sig = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
try:
result = await rpc.evm_query_with_consensus(
chain_id=chain_id,
method="eth_getLogs",
params=[
{
"fromBlock": "0x0",
"toBlock": "latest",
"topics": [
transfer_sig,
None, # any sender
_pad_address(address), # receiver = our wallet
],
}
],
)
if result and result.value:
# Parse logs into transaction-like objects
txs = []
for log in result.value[:MAX_TX_TO_CHECK]:
txs.append(
{
"hash": log.get("transactionHash", ""),
"from": _unpad_address(log.get("topics", ["", "", ""])[1]),
"to": address,
"value": str(int(log.get("data", "0x0"), 16)),
"blockNumber": str(int(log.get("blockNumber", "0x0"), 16)),
}
)
return sorted(txs, key=lambda t: int(t.get("blockNumber", 0)))
except Exception as e:
logger.debug(f"RPC getLogs failed for {address}: {e}")
return []
def _find_earliest_inbound(txs: list[dict], wallet: str) -> dict | None:
"""Find the earliest transaction where this wallet received ETH."""
wallet_lower = wallet.lower()
for tx in txs:
to_addr = tx.get("to", "").lower()
value = int(tx.get("value", 0))
if to_addr == wallet_lower and value > 0:
return tx
return None
async def _classify_address(address: str, chain_id: int) -> tuple[str, str]:
"""Classify an address as CEX, DEX, bridge, mixer, contract, or EOA.
Uses multiple strategies in priority order:
1. Check local ClickHouse wallet labels
2. Check known patterns in address/name
3. Check if it's a contract (has code)
4. Default to EOA
"""
address.lower()
# Strategy 1: Check local wallet labels (ClickHouse)
try:
from app.wallet_label_loader import lookup_wallet_label
label = await lookup_wallet_label(address)
if label:
label_lower = label.lower()
for cex in CEX_PATTERNS:
if cex in label_lower:
return ("cex", label)
for bridge in BRIDGE_PATTERNS:
if bridge in label_lower:
return ("bridge", label)
for mixer in MIXER_PATTERNS:
if mixer in label_lower:
return ("mixer", label)
# Generic label
if any(kw in label_lower for kw in ["dex", "swap", "exchange", "amm"]):
return ("dex", label)
return ("known", label)
except ImportError:
pass
except Exception as e:
logger.debug(f"Label lookup failed: {e}")
# Strategy 2: Check if it's a contract
is_contract = await _is_contract(address, chain_id)
if is_contract:
return ("contract", "contract")
# Strategy 3: Default - it's an externally owned account
return ("eoa", "")
async def _is_contract(address: str, chain_id: int) -> bool:
"""Check if an address is a contract by calling eth_getCode."""
try:
from app.consensus_rpc import get_consensus_rpc
rpc = get_consensus_rpc()
result = await rpc.evm_query_with_consensus(
chain_id=chain_id,
method="eth_getCode",
params=[address, "latest"],
)
if result and result.value:
code = result.value
return code != "0x" and len(str(code)) > 4
except Exception:
pass
return False
def _confidence_for_type(source_type: str) -> float:
"""Return confidence score for a source classification."""
scores = {
"cex": 85.0,
"bridge": 75.0,
"mixer": 70.0,
"dex": 65.0,
"contract": 60.0,
"known": 80.0,
"eoa": 30.0,
"unknown": 10.0,
}
return scores.get(source_type, 10.0)
def _pad_address(address: str) -> str:
"""Pad address to 32 bytes for log topics."""
addr = address.lower().replace("0x", "")
return "0x" + addr.zfill(64)
def _unpad_address(hex_str: str) -> str:
"""Extract 20-byte address from 32-byte padded topic."""
if hex_str and len(hex_str) >= 42:
return "0x" + hex_str[-40:]
return hex_str
def _parse_timestamp(ts: str) -> int:
"""Parse ISO timestamp to Unix epoch int."""
if not ts:
return 0
try:
from datetime import datetime
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
return int(dt.timestamp())
except Exception:
return 0