From b9c0d8304fcb9470c7f40a7615b1a5f9d810e9e4 Mon Sep 17 00:00:00 2001 From: Patrick Selamy Date: Sun, 4 Jan 2026 16:51:57 -0500 Subject: [PATCH] feat: implement FundingTracer for wallet funding source analysis (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FundingTracer class that traces USDC transfers backwards from a target wallet to identify funding sources. Key features: - Traces funding chain up to configurable max hops (default 3) - Identifies terminal entities (CEX hot wallets, bridges) using EntityRegistry - Parses ERC20 Transfer event logs from Polygon blockchain - Calculates suspiciousness scores based on funding patterns - Supports batch tracing multiple addresses concurrently Also adds FundingTransfer and FundingChain dataclasses to models.py for representing funding chain data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../profiler/__init__.py | 9 + .../profiler/funding.py | 358 +++++++++ .../profiler/models.py | 84 ++ tests/profiler/test_funding.py | 760 ++++++++++++++++++ 4 files changed, 1211 insertions(+) create mode 100644 src/polymarket_insider_tracker/profiler/funding.py create mode 100644 tests/profiler/test_funding.py diff --git a/src/polymarket_insider_tracker/profiler/__init__.py b/src/polymarket_insider_tracker/profiler/__init__.py index 353c04b..178dd13 100644 --- a/src/polymarket_insider_tracker/profiler/__init__.py +++ b/src/polymarket_insider_tracker/profiler/__init__.py @@ -15,7 +15,12 @@ from polymarket_insider_tracker.profiler.entities import ( from polymarket_insider_tracker.profiler.entity_data import ( EntityType, ) +from polymarket_insider_tracker.profiler.funding import ( + FundingTracer, +) from polymarket_insider_tracker.profiler.models import ( + FundingChain, + FundingTransfer, Transaction, WalletInfo, WalletProfile, @@ -27,6 +32,10 @@ __all__ = [ # Entity Registry "EntityRegistry", "EntityType", + # Funding Tracer + "FundingChain", + "FundingTracer", + "FundingTransfer", # Polygon Client "PolygonClient", "PolygonClientError", diff --git a/src/polymarket_insider_tracker/profiler/funding.py b/src/polymarket_insider_tracker/profiler/funding.py new file mode 100644 index 0000000..b0b335c --- /dev/null +++ b/src/polymarket_insider_tracker/profiler/funding.py @@ -0,0 +1,358 @@ +"""Funding chain tracer for wallet analysis. + +This module provides the FundingTracer class for tracing the funding chain +of wallets to identify where their USDC/MATIC originated from. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime +from decimal import Decimal +from typing import TYPE_CHECKING, Any + +from web3 import AsyncWeb3 + +from polymarket_insider_tracker.profiler.entities import EntityRegistry +from polymarket_insider_tracker.profiler.models import FundingChain, FundingTransfer + +if TYPE_CHECKING: + from polymarket_insider_tracker.profiler.chain import PolygonClient + +logger = logging.getLogger(__name__) + +# USDC contract addresses on Polygon +USDC_BRIDGED = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" +USDC_NATIVE = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" + +# ERC20 Transfer event signature +TRANSFER_EVENT_SIGNATURE = AsyncWeb3.keccak(text="Transfer(address,address,uint256)") + + +class FundingTracer: + """Traces funding chains to identify wallet funding sources. + + The tracer follows USDC transfers backwards from a target wallet + to find where the funds originated, stopping at known entities + (CEX hot wallets, bridges) or reaching the maximum hop count. + + Attributes: + polygon_client: Client for Polygon blockchain queries. + entity_registry: Registry of known blockchain entities. + max_hops: Maximum number of hops to trace (default 3). + """ + + def __init__( + self, + polygon_client: PolygonClient, + entity_registry: EntityRegistry | None = None, + *, + max_hops: int = 3, + usdc_addresses: list[str] | None = None, + ) -> None: + """Initialize the funding tracer. + + Args: + polygon_client: Polygon blockchain client for queries. + entity_registry: Registry for entity classification. Creates default if None. + max_hops: Maximum hops to trace back (default 3). + usdc_addresses: USDC contract addresses to track. Uses defaults if None. + """ + self.polygon_client = polygon_client + self.entity_registry = entity_registry or EntityRegistry() + self.max_hops = max_hops + self._usdc_addresses = [ + addr.lower() for addr in (usdc_addresses or [USDC_BRIDGED, USDC_NATIVE]) + ] + + async def trace( + self, + address: str, + max_hops: int | None = None, + ) -> FundingChain: + """Trace the funding chain for a wallet. + + Follows the first USDC transfer into the wallet, then recursively + traces the source wallet until reaching a known entity or max hops. + + Args: + address: Target wallet address to trace. + max_hops: Override default max_hops for this trace. + + Returns: + FundingChain with the complete trace result. + """ + effective_max_hops = max_hops if max_hops is not None else self.max_hops + normalized_address = address.lower() + + chain: list[FundingTransfer] = [] + current_address = normalized_address + origin_address = normalized_address + origin_type = "unknown" + + for hop in range(effective_max_hops): + # Check if current address is a known entity + if self.entity_registry.is_terminal(current_address): + origin_address = current_address + origin_type = self.entity_registry.classify(current_address).value + logger.debug( + "Trace terminated at known entity: %s (%s)", + current_address, + origin_type, + ) + break + + # Get first USDC transfer into this address + transfer = await self.get_first_usdc_transfer(current_address) + if transfer is None: + logger.debug( + "No USDC transfer found for %s at hop %d", + current_address, + hop, + ) + origin_address = current_address + break + + chain.append(transfer) + origin_address = transfer.from_address + current_address = transfer.from_address + + # Check if the source is a known entity + if self.entity_registry.is_terminal(origin_address): + origin_type = self.entity_registry.classify(origin_address).value + logger.debug( + "Trace found terminal entity: %s (%s)", + origin_address, + origin_type, + ) + break + + return FundingChain( + target_address=normalized_address, + chain=chain, + origin_address=origin_address, + origin_type=origin_type, + hop_count=len(chain), + traced_at=datetime.now(UTC), + ) + + async def get_first_usdc_transfer( + self, + address: str, + ) -> FundingTransfer | None: + """Get the first USDC transfer into a wallet. + + Queries the blockchain for ERC20 Transfer events to the target + address for known USDC contracts. + + Args: + address: Target wallet address. + + Returns: + First FundingTransfer if found, None otherwise. + """ + normalized = address.lower() + + # Query transfers for each USDC contract + for usdc_address in self._usdc_addresses: + transfer = await self._get_first_token_transfer( + to_address=normalized, + token_address=usdc_address, + ) + if transfer is not None: + return transfer + + return None + + async def _get_first_token_transfer( + self, + to_address: str, + token_address: str, + ) -> FundingTransfer | None: + """Get the first ERC20 transfer to an address for a specific token. + + Args: + to_address: Recipient wallet address. + token_address: ERC20 token contract address. + + Returns: + First FundingTransfer if found, None otherwise. + """ + try: + logs = await self._get_transfer_logs( + to_address=to_address, + token_address=token_address, + limit=1, + ) + except Exception as e: + logger.warning( + "Failed to get transfer logs for %s: %s", + to_address, + e, + ) + return None + + if not logs: + return None + + log = logs[0] + return await self._log_to_funding_transfer(log, token_address) + + async def _get_transfer_logs( + self, + to_address: str, + token_address: str, + limit: int = 10, + from_block: int | str = 0, + to_block: int | str = "latest", + ) -> list[dict[str, Any]]: + """Get ERC20 Transfer event logs. + + Args: + to_address: Filter by recipient address. + token_address: ERC20 token contract address. + limit: Maximum logs to return. + from_block: Starting block number. + to_block: Ending block number. + + Returns: + List of log dictionaries. + """ + # Pad address to 32 bytes for topic filter + padded_to = "0x" + to_address.lower().replace("0x", "").zfill(64) + + await self.polygon_client._rate_limiter.acquire() + + # Use the web3 instance from polygon client + w3 = ( + self.polygon_client._w3 + if self.polygon_client._primary_healthy + else (self.polygon_client._w3_fallback or self.polygon_client._w3) + ) + + # Get logs with Transfer event filtering by recipient + logs = await w3.eth.get_logs( + { + "address": AsyncWeb3.to_checksum_address(token_address), + "topics": [ + TRANSFER_EVENT_SIGNATURE.hex(), # Transfer event + None, # from (any) + padded_to, # to (target address) + ], + "fromBlock": from_block, + "toBlock": to_block, + } + ) + + # Convert to list of dicts and limit + result = [dict(log) for log in logs[:limit]] + return result + + async def _log_to_funding_transfer( + self, + log: dict[str, Any], + token_address: str, + ) -> FundingTransfer: + """Convert a log entry to a FundingTransfer. + + Args: + log: Log dictionary from get_logs. + token_address: Token contract address. + + Returns: + FundingTransfer object. + """ + # Extract addresses from topics (padded to 32 bytes) + from_address = "0x" + log["topics"][1].hex()[-40:] + to_address = "0x" + log["topics"][2].hex()[-40:] + + # Extract amount from data + amount = int(log["data"].hex(), 16) + + # Get block timestamp + block_number = log["blockNumber"] + try: + block = await self.polygon_client.get_block(block_number) + timestamp = datetime.fromtimestamp(block["timestamp"], tz=UTC) + except Exception: + timestamp = datetime.now(UTC) + + # Determine token symbol + token = "USDC" if token_address.lower() in self._usdc_addresses else "OTHER" + + return FundingTransfer( + from_address=from_address.lower(), + to_address=to_address.lower(), + amount=Decimal(amount), + token=token, + tx_hash=log["transactionHash"].hex(), + block_number=block_number, + timestamp=timestamp, + ) + + async def get_funding_chains_batch( + self, + addresses: list[str], + max_hops: int | None = None, + ) -> dict[str, FundingChain]: + """Trace funding chains for multiple addresses concurrently. + + Args: + addresses: List of wallet addresses to trace. + max_hops: Override default max_hops for all traces. + + Returns: + Dictionary mapping address to FundingChain. + """ + tasks = [self.trace(addr, max_hops=max_hops) for addr in addresses] + results = await asyncio.gather(*tasks, return_exceptions=True) + + chains: dict[str, FundingChain] = {} + for addr, result in zip(addresses, results, strict=True): + if isinstance(result, Exception): + logger.warning("Failed to trace %s: %s", addr, result) + chains[addr.lower()] = FundingChain( + target_address=addr.lower(), + origin_type="error", + ) + else: + chains[addr.lower()] = result + + return chains + + def get_suspiciousness_score(self, chain: FundingChain) -> float: + """Calculate a suspiciousness score based on funding chain. + + Higher scores indicate more suspicious funding patterns: + - CEX origin: Lower suspicion (0.0-0.2) + - Bridge origin: Low suspicion (0.2-0.4) + - Unknown origin with few hops: High suspicion (0.8-1.0) + - Unknown origin with many hops: Medium suspicion (0.5-0.8) + + Args: + chain: Funding chain to score. + + Returns: + Suspiciousness score from 0.0 to 1.0. + """ + if chain.is_cex_origin: + # CEX origin is least suspicious + return 0.1 + + if chain.is_bridge_origin: + # Bridge origin is slightly more suspicious + return 0.3 + + # Unknown origin + if chain.hop_count == 0: + # No transfers found - very suspicious (possible contract or new wallet) + return 1.0 + + if chain.hop_count >= self.max_hops: + # Max hops reached without finding known entity + # More hops = more obfuscation = more suspicious + return 0.7 + + # Some hops but didn't reach max - moderately suspicious + return 0.5 + (0.3 * (1 - chain.hop_count / self.max_hops)) diff --git a/src/polymarket_insider_tracker/profiler/models.py b/src/polymarket_insider_tracker/profiler/models.py index 9d85891..6b5e13c 100644 --- a/src/polymarket_insider_tracker/profiler/models.py +++ b/src/polymarket_insider_tracker/profiler/models.py @@ -131,3 +131,87 @@ class WalletProfile: # Weighted average: nonce is slightly more important return 0.6 * nonce_score + 0.4 * age_score + + +@dataclass(frozen=True) +class FundingTransfer: + """Represents an ERC20 token transfer for funding chain analysis. + + Attributes: + from_address: Source wallet address. + to_address: Destination wallet address. + amount: Transfer amount in token decimals. + token: Token symbol (e.g., "USDC", "MATIC"). + tx_hash: Transaction hash. + block_number: Block number of the transaction. + timestamp: Timestamp of the transaction. + """ + + from_address: str + to_address: str + amount: Decimal + token: str + tx_hash: str + block_number: int + timestamp: datetime + + @property + def amount_formatted(self) -> Decimal: + """Return amount in human-readable format. + + Assumes 6 decimals for USDC/USDT, 18 for others. + """ + if self.token in ("USDC", "USDT"): + return self.amount / Decimal("1000000") + return self.amount / Decimal("1000000000000000000") + + +@dataclass +class FundingChain: + """Result of funding chain analysis. + + Represents the path of funds from origin to target wallet, + tracing back through intermediate wallets. + + Attributes: + target_address: The wallet being analyzed. + chain: Ordered list of transfers from target back to origin. + origin_address: The ultimate source of funds. + origin_type: Classification of the origin (cex, bridge, unknown, contract). + hop_count: Number of hops from target to origin. + traced_at: When the trace was performed. + """ + + target_address: str + chain: list[FundingTransfer] = field(default_factory=list) + origin_address: str = "" + origin_type: str = "unknown" + hop_count: int = 0 + traced_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + @property + def is_cex_origin(self) -> bool: + """Return True if funds originated from a CEX.""" + return self.origin_type.startswith("cex") + + @property + def is_bridge_origin(self) -> bool: + """Return True if funds came through a bridge.""" + return self.origin_type.startswith("bridge") + + @property + def is_unknown_origin(self) -> bool: + """Return True if origin could not be determined.""" + return self.origin_type == "unknown" + + @property + def total_amount(self) -> Decimal: + """Return total amount transferred in the chain.""" + if not self.chain: + return Decimal("0") + return self.chain[0].amount + + @property + def funding_depth(self) -> int: + """Return the funding depth (hops from known entity).""" + return self.hop_count diff --git a/tests/profiler/test_funding.py b/tests/profiler/test_funding.py new file mode 100644 index 0000000..85d0539 --- /dev/null +++ b/tests/profiler/test_funding.py @@ -0,0 +1,760 @@ +"""Tests for the FundingTracer module.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from polymarket_insider_tracker.profiler.entities import EntityRegistry +from polymarket_insider_tracker.profiler.entity_data import EntityType +from polymarket_insider_tracker.profiler.funding import ( + TRANSFER_EVENT_SIGNATURE, + USDC_BRIDGED, + USDC_NATIVE, + FundingTracer, +) +from polymarket_insider_tracker.profiler.models import FundingChain, FundingTransfer + +# Test addresses +TEST_WALLET = "0x1234567890abcdef1234567890abcdef12345678" +TEST_SOURCE = "0xabcdef1234567890abcdef1234567890abcdef12" +BINANCE_HOT_WALLET = "0x28c6c06298d514db089934071355e5743bf21d60" + + +@pytest.fixture +def mock_polygon_client() -> MagicMock: + """Create a mock PolygonClient.""" + client = MagicMock() + client._rate_limiter = MagicMock() + client._rate_limiter.acquire = AsyncMock() + client._primary_healthy = True + client._w3 = MagicMock() + client._w3_fallback = None + client.get_block = AsyncMock(return_value={"timestamp": 1704067200}) + return client + + +@pytest.fixture +def entity_registry() -> EntityRegistry: + """Create an EntityRegistry with default entities.""" + return EntityRegistry() + + +@pytest.fixture +def funding_tracer( + mock_polygon_client: MagicMock, + entity_registry: EntityRegistry, +) -> FundingTracer: + """Create a FundingTracer with mocked dependencies.""" + return FundingTracer( + polygon_client=mock_polygon_client, + entity_registry=entity_registry, + max_hops=3, + ) + + +class TestFundingTracerInit: + """Tests for FundingTracer initialization.""" + + def test_init_with_defaults(self, mock_polygon_client: MagicMock) -> None: + """Test initialization with default parameters.""" + tracer = FundingTracer(mock_polygon_client) + + assert tracer.polygon_client is mock_polygon_client + assert tracer.max_hops == 3 + assert USDC_BRIDGED.lower() in tracer._usdc_addresses + assert USDC_NATIVE.lower() in tracer._usdc_addresses + + def test_init_with_custom_max_hops(self, mock_polygon_client: MagicMock) -> None: + """Test initialization with custom max_hops.""" + tracer = FundingTracer(mock_polygon_client, max_hops=5) + assert tracer.max_hops == 5 + + def test_init_with_custom_usdc_addresses( + self, mock_polygon_client: MagicMock + ) -> None: + """Test initialization with custom USDC addresses.""" + custom_addresses = ["0x1111111111111111111111111111111111111111"] + tracer = FundingTracer( + mock_polygon_client, usdc_addresses=custom_addresses + ) + assert tracer._usdc_addresses == [custom_addresses[0].lower()] + + def test_init_with_custom_entity_registry( + self, mock_polygon_client: MagicMock + ) -> None: + """Test initialization with custom entity registry.""" + registry = EntityRegistry() + tracer = FundingTracer(mock_polygon_client, entity_registry=registry) + assert tracer.entity_registry is registry + + def test_init_creates_default_entity_registry( + self, mock_polygon_client: MagicMock + ) -> None: + """Test initialization creates default EntityRegistry if None.""" + tracer = FundingTracer(mock_polygon_client, entity_registry=None) + assert isinstance(tracer.entity_registry, EntityRegistry) + + +class TestFundingTracerTrace: + """Tests for the trace method.""" + + @pytest.mark.asyncio + async def test_trace_terminates_at_known_cex( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test trace terminates when starting at a CEX address.""" + result = await funding_tracer.trace(BINANCE_HOT_WALLET) + + assert result.target_address == BINANCE_HOT_WALLET.lower() + assert result.origin_address == BINANCE_HOT_WALLET.lower() + assert result.origin_type == EntityType.CEX_BINANCE.value + assert result.hop_count == 0 + assert len(result.chain) == 0 + + @pytest.mark.asyncio + async def test_trace_no_transfers_found( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test trace when no USDC transfers are found.""" + funding_tracer._get_transfer_logs = AsyncMock(return_value=[]) + + result = await funding_tracer.trace(TEST_WALLET) + + assert result.target_address == TEST_WALLET.lower() + assert result.origin_address == TEST_WALLET.lower() + assert result.origin_type == "unknown" + assert result.hop_count == 0 + + @pytest.mark.asyncio + async def test_trace_finds_cex_origin( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test trace finds CEX as funding origin.""" + # Mock a transfer from Binance to test wallet + mock_log = _create_mock_log( + from_address=BINANCE_HOT_WALLET, + to_address=TEST_WALLET, + amount=1000000, # 1 USDC + tx_hash="0x" + "ab" * 32, + block_number=50000000, + ) + + funding_tracer._get_transfer_logs = AsyncMock(return_value=[mock_log]) + + result = await funding_tracer.trace(TEST_WALLET) + + assert result.target_address == TEST_WALLET.lower() + assert result.origin_address == BINANCE_HOT_WALLET.lower() + assert result.origin_type == EntityType.CEX_BINANCE.value + assert result.hop_count == 1 + assert len(result.chain) == 1 + + @pytest.mark.asyncio + async def test_trace_multiple_hops( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test trace follows multiple hops.""" + intermediate_wallet = "0x" + "11" * 20 + + # First call: TEST_WALLET received from intermediate + # Second call: intermediate received from Binance + mock_logs = [ + _create_mock_log( + from_address=intermediate_wallet, + to_address=TEST_WALLET, + amount=1000000, + tx_hash="0x" + "aa" * 32, + block_number=50000001, + ), + _create_mock_log( + from_address=BINANCE_HOT_WALLET, + to_address=intermediate_wallet, + amount=1000000, + tx_hash="0x" + "bb" * 32, + block_number=50000000, + ), + ] + + call_count = 0 + + async def mock_get_logs( + *_args: Any, **_kwargs: Any + ) -> list[dict[str, Any]]: + nonlocal call_count + result = [mock_logs[call_count]] if call_count < len(mock_logs) else [] + call_count += 1 + return result + + funding_tracer._get_transfer_logs = mock_get_logs + + result = await funding_tracer.trace(TEST_WALLET) + + assert result.hop_count == 2 + assert result.origin_address == BINANCE_HOT_WALLET.lower() + assert result.origin_type == EntityType.CEX_BINANCE.value + + @pytest.mark.asyncio + async def test_trace_respects_max_hops( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test trace stops at max_hops.""" + # Create a chain of unknown wallets + wallets = [f"0x{i:040x}" for i in range(10)] + + call_count = 0 + + async def mock_get_logs( + *_args: Any, **_kwargs: Any + ) -> list[dict[str, Any]]: + nonlocal call_count + if call_count < len(wallets) - 1: + log = _create_mock_log( + from_address=wallets[call_count + 1], + to_address=wallets[call_count], + amount=1000000, + tx_hash=f"0x{call_count:064x}", + block_number=50000000 + call_count, + ) + call_count += 1 + return [log] + return [] + + funding_tracer._get_transfer_logs = mock_get_logs + + result = await funding_tracer.trace(wallets[0], max_hops=3) + + assert result.hop_count == 3 + assert result.origin_type == "unknown" + + @pytest.mark.asyncio + async def test_trace_override_max_hops( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test trace can override default max_hops.""" + funding_tracer._get_transfer_logs = AsyncMock(return_value=[]) + + # Override to 1 hop + await funding_tracer.trace(TEST_WALLET, max_hops=1) + + # Verify only 1 iteration (no hops since no transfers found) + # The trace should have been called once for the target wallet + + +class TestGetFirstUsdcTransfer: + """Tests for get_first_usdc_transfer method.""" + + @pytest.mark.asyncio + async def test_get_first_usdc_transfer_bridged( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test getting first USDC transfer from bridged contract.""" + mock_log = _create_mock_log( + from_address=TEST_SOURCE, + to_address=TEST_WALLET, + amount=5000000, + tx_hash="0x" + "cc" * 32, + block_number=50000000, + ) + + funding_tracer._get_transfer_logs = AsyncMock(return_value=[mock_log]) + + result = await funding_tracer.get_first_usdc_transfer(TEST_WALLET) + + assert result is not None + assert result.from_address == TEST_SOURCE.lower() + assert result.to_address == TEST_WALLET.lower() + assert result.amount == Decimal(5000000) + assert result.token == "USDC" + + @pytest.mark.asyncio + async def test_get_first_usdc_transfer_native( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test fallback to native USDC contract.""" + call_count = 0 + + async def mock_get_logs( + *_args: Any, **_kwargs: Any + ) -> list[dict[str, Any]]: + nonlocal call_count + call_count += 1 + if call_count == 1: # First call (bridged) returns nothing + return [] + # Second call (native) returns a transfer + return [ + _create_mock_log( + from_address=TEST_SOURCE, + to_address=TEST_WALLET, + amount=1000000, + tx_hash="0x" + "dd" * 32, + block_number=50000000, + ) + ] + + funding_tracer._get_transfer_logs = mock_get_logs + + result = await funding_tracer.get_first_usdc_transfer(TEST_WALLET) + + assert result is not None + assert call_count == 2 + + @pytest.mark.asyncio + async def test_get_first_usdc_transfer_none_found( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test returns None when no USDC transfers found.""" + funding_tracer._get_transfer_logs = AsyncMock(return_value=[]) + + result = await funding_tracer.get_first_usdc_transfer(TEST_WALLET) + + assert result is None + + +class TestGetTransferLogs: + """Tests for _get_transfer_logs method.""" + + @pytest.mark.asyncio + async def test_get_transfer_logs_formats_topics_correctly( + self, + funding_tracer: FundingTracer, + mock_polygon_client: MagicMock, + ) -> None: + """Test that transfer logs query is formatted correctly.""" + mock_w3 = MagicMock() + mock_w3.eth.get_logs = AsyncMock(return_value=[]) + mock_polygon_client._w3 = mock_w3 + + await funding_tracer._get_transfer_logs( + to_address=TEST_WALLET, + token_address=USDC_BRIDGED, + ) + + mock_w3.eth.get_logs.assert_called_once() + call_args = mock_w3.eth.get_logs.call_args[0][0] + + # Verify topics structure + assert len(call_args["topics"]) == 3 + assert call_args["topics"][0] == TRANSFER_EVENT_SIGNATURE.hex() + assert call_args["topics"][1] is None # from (any) + # to address should be padded to 32 bytes + assert call_args["topics"][2].endswith(TEST_WALLET.lower().replace("0x", "")) + + @pytest.mark.asyncio + async def test_get_transfer_logs_respects_limit( + self, + funding_tracer: FundingTracer, + mock_polygon_client: MagicMock, + ) -> None: + """Test that limit parameter works correctly.""" + mock_logs = [MagicMock() for _ in range(10)] + mock_w3 = MagicMock() + mock_w3.eth.get_logs = AsyncMock(return_value=mock_logs) + mock_polygon_client._w3 = mock_w3 + + result = await funding_tracer._get_transfer_logs( + to_address=TEST_WALLET, + token_address=USDC_BRIDGED, + limit=3, + ) + + assert len(result) == 3 + + @pytest.mark.asyncio + async def test_get_transfer_logs_uses_fallback_when_primary_unhealthy( + self, + funding_tracer: FundingTracer, + mock_polygon_client: MagicMock, + ) -> None: + """Test fallback RPC is used when primary is unhealthy.""" + mock_polygon_client._primary_healthy = False + mock_fallback = MagicMock() + mock_fallback.eth.get_logs = AsyncMock(return_value=[]) + mock_polygon_client._w3_fallback = mock_fallback + + await funding_tracer._get_transfer_logs( + to_address=TEST_WALLET, + token_address=USDC_BRIDGED, + ) + + mock_fallback.eth.get_logs.assert_called_once() + + +class TestLogToFundingTransfer: + """Tests for _log_to_funding_transfer method.""" + + @pytest.mark.asyncio + async def test_log_to_funding_transfer_parses_correctly( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test correct parsing of log to FundingTransfer.""" + mock_log = _create_mock_log( + from_address=TEST_SOURCE, + to_address=TEST_WALLET, + amount=1500000, + tx_hash="0x" + "ee" * 32, + block_number=50000000, + ) + + result = await funding_tracer._log_to_funding_transfer( + mock_log, USDC_BRIDGED + ) + + assert result.from_address == TEST_SOURCE.lower() + assert result.to_address == TEST_WALLET.lower() + assert result.amount == Decimal(1500000) + assert result.token == "USDC" + assert result.tx_hash == "ee" * 32 + assert result.block_number == 50000000 + + @pytest.mark.asyncio + async def test_log_to_funding_transfer_handles_block_error( + self, + funding_tracer: FundingTracer, + mock_polygon_client: MagicMock, + ) -> None: + """Test graceful handling when block fetch fails.""" + mock_polygon_client.get_block = AsyncMock(side_effect=Exception("Block error")) + + mock_log = _create_mock_log( + from_address=TEST_SOURCE, + to_address=TEST_WALLET, + amount=1000000, + tx_hash="0x" + "ff" * 32, + block_number=50000000, + ) + + result = await funding_tracer._log_to_funding_transfer( + mock_log, USDC_BRIDGED + ) + + # Should still return a valid transfer with current timestamp + assert result.from_address == TEST_SOURCE.lower() + assert result.timestamp is not None + + +class TestGetFundingChainsBatch: + """Tests for get_funding_chains_batch method.""" + + @pytest.mark.asyncio + async def test_batch_traces_multiple_addresses( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test batch tracing multiple addresses.""" + addresses = [f"0x{i:040x}" for i in range(3)] + + # Mock trace to return simple chains + async def mock_trace( + addr: str, *, max_hops: int | None = None # noqa: ARG001 + ) -> FundingChain: + return FundingChain( + target_address=addr.lower(), + origin_type="unknown", + ) + + funding_tracer.trace = mock_trace + + results = await funding_tracer.get_funding_chains_batch(addresses) + + assert len(results) == 3 + for addr in addresses: + assert addr.lower() in results + + @pytest.mark.asyncio + async def test_batch_handles_exceptions( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test batch handles exceptions gracefully.""" + addresses = ["0x" + "11" * 20, "0x" + "22" * 20] + + call_count = 0 + + async def mock_trace( + addr: str, *, max_hops: int | None = None # noqa: ARG001 + ) -> FundingChain: + nonlocal call_count + call_count += 1 + if call_count == 1: + raise ValueError("Test error") + return FundingChain( + target_address=addr.lower(), + origin_type="cex_binance", + ) + + funding_tracer.trace = mock_trace + + results = await funding_tracer.get_funding_chains_batch(addresses) + + assert len(results) == 2 + # First address should have error origin type + assert results[addresses[0].lower()].origin_type == "error" + # Second address should succeed + assert results[addresses[1].lower()].origin_type == "cex_binance" + + @pytest.mark.asyncio + async def test_batch_empty_list( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test batch with empty address list.""" + results = await funding_tracer.get_funding_chains_batch([]) + assert results == {} + + @pytest.mark.asyncio + async def test_batch_respects_max_hops_override( + self, + funding_tracer: FundingTracer, + ) -> None: + """Test batch passes max_hops to individual traces.""" + addresses = ["0x" + "11" * 20] + captured_max_hops: list[int | None] = [] + + async def mock_trace( + addr: str, max_hops: int | None = None + ) -> FundingChain: + captured_max_hops.append(max_hops) + return FundingChain(target_address=addr.lower()) + + funding_tracer.trace = mock_trace + + await funding_tracer.get_funding_chains_batch(addresses, max_hops=5) + + assert captured_max_hops == [5] + + +class TestGetSuspiciousnessScore: + """Tests for get_suspiciousness_score method.""" + + def test_cex_origin_low_score(self, funding_tracer: FundingTracer) -> None: + """Test CEX origin results in low suspiciousness.""" + chain = FundingChain( + target_address=TEST_WALLET, + origin_type="cex_binance", + hop_count=1, + ) + + score = funding_tracer.get_suspiciousness_score(chain) + + assert score == 0.1 + + def test_bridge_origin_low_score(self, funding_tracer: FundingTracer) -> None: + """Test bridge origin results in low-medium suspiciousness.""" + chain = FundingChain( + target_address=TEST_WALLET, + origin_type="bridge_polygon", + hop_count=1, + ) + + score = funding_tracer.get_suspiciousness_score(chain) + + assert score == 0.3 + + def test_unknown_no_transfers_high_score( + self, funding_tracer: FundingTracer + ) -> None: + """Test unknown origin with no transfers is most suspicious.""" + chain = FundingChain( + target_address=TEST_WALLET, + origin_type="unknown", + hop_count=0, + ) + + score = funding_tracer.get_suspiciousness_score(chain) + + assert score == 1.0 + + def test_unknown_max_hops_high_score( + self, funding_tracer: FundingTracer + ) -> None: + """Test unknown origin at max hops is suspicious.""" + chain = FundingChain( + target_address=TEST_WALLET, + origin_type="unknown", + hop_count=3, # Same as max_hops + ) + + score = funding_tracer.get_suspiciousness_score(chain) + + assert score == 0.7 + + def test_unknown_partial_hops_medium_score( + self, funding_tracer: FundingTracer + ) -> None: + """Test unknown origin with partial hops is moderately suspicious.""" + chain = FundingChain( + target_address=TEST_WALLET, + origin_type="unknown", + hop_count=1, + ) + + score = funding_tracer.get_suspiciousness_score(chain) + + # 0.5 + (0.3 * (1 - 1/3)) = 0.5 + 0.2 = 0.7 + assert 0.5 < score < 0.8 + + +class TestFundingTransferModel: + """Tests for FundingTransfer dataclass.""" + + def test_amount_formatted_usdc(self) -> None: + """Test formatted amount for USDC (6 decimals).""" + transfer = FundingTransfer( + from_address=TEST_SOURCE, + to_address=TEST_WALLET, + amount=Decimal("1500000"), # 1.5 USDC + token="USDC", + tx_hash="0x" + "aa" * 32, + block_number=50000000, + timestamp=datetime.now(UTC), + ) + + assert transfer.amount_formatted == Decimal("1.5") + + def test_amount_formatted_other(self) -> None: + """Test formatted amount for other tokens (18 decimals).""" + transfer = FundingTransfer( + from_address=TEST_SOURCE, + to_address=TEST_WALLET, + amount=Decimal("1500000000000000000"), # 1.5 MATIC + token="MATIC", + tx_hash="0x" + "aa" * 32, + block_number=50000000, + timestamp=datetime.now(UTC), + ) + + assert transfer.amount_formatted == Decimal("1.5") + + +class TestFundingChainModel: + """Tests for FundingChain dataclass.""" + + def test_is_cex_origin(self) -> None: + """Test is_cex_origin property.""" + chain = FundingChain( + target_address=TEST_WALLET, + origin_type="cex_binance", + ) + assert chain.is_cex_origin is True + + chain2 = FundingChain( + target_address=TEST_WALLET, + origin_type="bridge_polygon", + ) + assert chain2.is_cex_origin is False + + def test_is_bridge_origin(self) -> None: + """Test is_bridge_origin property.""" + chain = FundingChain( + target_address=TEST_WALLET, + origin_type="bridge_polygon", + ) + assert chain.is_bridge_origin is True + + chain2 = FundingChain( + target_address=TEST_WALLET, + origin_type="cex_coinbase", + ) + assert chain2.is_bridge_origin is False + + def test_is_unknown_origin(self) -> None: + """Test is_unknown_origin property.""" + chain = FundingChain( + target_address=TEST_WALLET, + origin_type="unknown", + ) + assert chain.is_unknown_origin is True + + def test_total_amount_empty_chain(self) -> None: + """Test total_amount with empty chain.""" + chain = FundingChain(target_address=TEST_WALLET) + assert chain.total_amount == Decimal("0") + + def test_total_amount_with_transfers(self) -> None: + """Test total_amount returns first transfer amount.""" + transfer = FundingTransfer( + from_address=TEST_SOURCE, + to_address=TEST_WALLET, + amount=Decimal("5000000"), + token="USDC", + tx_hash="0x" + "aa" * 32, + block_number=50000000, + timestamp=datetime.now(UTC), + ) + chain = FundingChain( + target_address=TEST_WALLET, + chain=[transfer], + ) + + assert chain.total_amount == Decimal("5000000") + + def test_funding_depth(self) -> None: + """Test funding_depth property.""" + chain = FundingChain( + target_address=TEST_WALLET, + hop_count=3, + ) + assert chain.funding_depth == 3 + + +class TestConstants: + """Tests for module constants.""" + + def test_usdc_bridged_address(self) -> None: + """Test USDC bridged contract address.""" + assert USDC_BRIDGED == "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + + def test_usdc_native_address(self) -> None: + """Test USDC native contract address.""" + assert USDC_NATIVE == "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" + + def test_transfer_event_signature(self) -> None: + """Test Transfer event signature is correct keccak hash.""" + # Transfer(address,address,uint256) hash + assert TRANSFER_EVENT_SIGNATURE is not None + assert len(TRANSFER_EVENT_SIGNATURE) == 32 + + +# Helper functions + + +def _create_mock_log( + from_address: str, + to_address: str, + amount: int, + tx_hash: str, + block_number: int, +) -> dict[str, Any]: + """Create a mock log entry for testing.""" + # Pad addresses to 32 bytes (topics format) + from_padded = bytes.fromhex(from_address.replace("0x", "").zfill(64)) + to_padded = bytes.fromhex(to_address.replace("0x", "").zfill(64)) + + # Amount as 32-byte hex data + amount_hex = bytes.fromhex(f"{amount:064x}") + + return { + "topics": [ + TRANSFER_EVENT_SIGNATURE, + from_padded, + to_padded, + ], + "data": amount_hex, + "transactionHash": bytes.fromhex(tx_hash.replace("0x", "")), + "blockNumber": block_number, + }