feat: implement FundingTracer for wallet funding source analysis (#10)

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 <noreply@anthropic.com>
This commit is contained in:
Patrick Selamy
2026-01-04 16:51:57 -05:00
co-authored by Claude Opus 4.5
parent c68a6cc32d
commit b9c0d8304f
4 changed files with 1211 additions and 0 deletions
@@ -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",
@@ -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))
@@ -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
+760
View File
@@ -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,
}