""" 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