Files
polymarket-insider-tracker/src/polymarket_insider_tracker/profiler/funding.py
T
Patrick SelamyandClaude Opus 4.5 5c5440d776 fix: resolve all mypy type errors for strict type checking (#48)
## Summary

Fixed 30 mypy type errors across 8 files to enable strict type checking.

## Changes by Category

### no-any-return errors (fixed with explicit type casts)
- alerter/history.py: Cast Redis zcount/zremrangebyscore returns to int
- ingestor/clob_client.py: Cast API response values appropriately
- ingestor/publisher.py: Cast Redis xack, xlen, xtrim returns to int
- ingestor/metadata_sync.py: Cast Redis delete return to int
- detector/scorer.py: Cast Redis delete return to int

### operator errors (fixed with null checks)
- alerter/formatter.py: Added `if age_hours is not None:` guards

### External library typing issues (fixed with type: ignore)
- ingestor/publisher.py: redis-py typing issue with xadd dict parameter
- profiler/funding.py: web3 typing issue with block parameters
- storage/repos.py: SQLAlchemy Result.rowcount typing issue

### assignment errors (fixed by renaming)
- storage/repos.py: Renamed SQLite insert stmt to avoid type conflict

Closes #48

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:15:32 -05:00

360 lines
12 KiB
Python

"""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
# Note: web3 typing is overly restrictive for block params
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, # type: ignore[typeddict-item]
"toBlock": to_block, # type: ignore[typeddict-item]
}
)
# 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, BaseException):
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))