- Use contextlib.suppress instead of try/except/pass (SIM105) - Prefix unused fixture arguments with underscore (ARG002) - Replace asyncio.TimeoutError with TimeoutError (UP041) - Apply ruff formatting to all files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
526 lines
17 KiB
Python
526 lines
17 KiB
Python
"""Polygon blockchain client with connection pooling and caching.
|
|
|
|
This module provides a Polygon client for wallet data queries with:
|
|
- Connection pooling for concurrent requests
|
|
- Redis caching to avoid redundant RPC calls
|
|
- Retry logic with exponential backoff
|
|
- Rate limiting to respect provider limits
|
|
- Failover to secondary RPC URL
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Any, cast
|
|
|
|
from redis.asyncio import Redis
|
|
from web3 import AsyncWeb3
|
|
from web3.exceptions import Web3Exception
|
|
from web3.providers import AsyncHTTPProvider
|
|
|
|
from polymarket_insider_tracker.profiler.models import Transaction, WalletInfo
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Default configuration
|
|
DEFAULT_CACHE_TTL_SECONDS = 300 # 5 minutes
|
|
DEFAULT_MAX_REQUESTS_PER_SECOND = 25
|
|
DEFAULT_MAX_RETRIES = 3
|
|
DEFAULT_RETRY_DELAY_SECONDS = 1.0
|
|
DEFAULT_CONNECTION_POOL_SIZE = 10
|
|
DEFAULT_REQUEST_TIMEOUT = 30
|
|
|
|
|
|
class PolygonClientError(Exception):
|
|
"""Base exception for Polygon client errors."""
|
|
|
|
|
|
class RPCError(PolygonClientError):
|
|
"""Raised when RPC call fails."""
|
|
|
|
|
|
class RateLimitError(PolygonClientError):
|
|
"""Raised when rate limit is exceeded."""
|
|
|
|
|
|
@dataclass
|
|
class RateLimiter:
|
|
"""Token bucket rate limiter."""
|
|
|
|
max_tokens: float
|
|
refill_rate: float # tokens per second
|
|
tokens: float
|
|
last_refill: float
|
|
|
|
@classmethod
|
|
def create(cls, max_requests_per_second: float) -> "RateLimiter":
|
|
"""Create a rate limiter with specified max requests per second."""
|
|
return cls(
|
|
max_tokens=max_requests_per_second,
|
|
refill_rate=max_requests_per_second,
|
|
tokens=max_requests_per_second,
|
|
last_refill=time.monotonic(),
|
|
)
|
|
|
|
def _refill(self) -> None:
|
|
"""Refill tokens based on elapsed time."""
|
|
now = time.monotonic()
|
|
elapsed = now - self.last_refill
|
|
self.tokens = min(self.max_tokens, self.tokens + elapsed * self.refill_rate)
|
|
self.last_refill = now
|
|
|
|
async def acquire(self, tokens: float = 1.0) -> None:
|
|
"""Acquire tokens, waiting if necessary."""
|
|
while True:
|
|
self._refill()
|
|
if self.tokens >= tokens:
|
|
self.tokens -= tokens
|
|
return
|
|
# Wait for tokens to refill
|
|
wait_time = (tokens - self.tokens) / self.refill_rate
|
|
await asyncio.sleep(wait_time)
|
|
|
|
|
|
class PolygonClient:
|
|
"""Polygon blockchain client with caching and rate limiting.
|
|
|
|
Provides efficient access to wallet data with:
|
|
- Connection pooling for concurrent requests
|
|
- Redis caching with configurable TTL
|
|
- Rate limiting to respect provider limits
|
|
- Retry logic with exponential backoff
|
|
- Failover to secondary RPC
|
|
|
|
Example:
|
|
```python
|
|
redis = Redis.from_url("redis://localhost:6379")
|
|
client = PolygonClient(
|
|
rpc_url="https://polygon-rpc.com",
|
|
fallback_rpc_url="https://polygon-bor.publicnode.com",
|
|
redis=redis,
|
|
)
|
|
|
|
# Get single wallet info
|
|
nonce = await client.get_transaction_count("0x...")
|
|
|
|
# Batch query multiple wallets
|
|
nonces = await client.get_transaction_counts(["0x...", "0x..."])
|
|
```
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
rpc_url: str,
|
|
*,
|
|
fallback_rpc_url: str | None = None,
|
|
redis: Redis | None = None,
|
|
cache_ttl_seconds: int = DEFAULT_CACHE_TTL_SECONDS,
|
|
max_requests_per_second: float = DEFAULT_MAX_REQUESTS_PER_SECOND,
|
|
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
retry_delay_seconds: float = DEFAULT_RETRY_DELAY_SECONDS,
|
|
) -> None:
|
|
"""Initialize the Polygon client.
|
|
|
|
Args:
|
|
rpc_url: Primary Polygon RPC endpoint URL.
|
|
fallback_rpc_url: Optional fallback RPC URL for failover.
|
|
redis: Optional Redis client for caching.
|
|
cache_ttl_seconds: Cache TTL in seconds.
|
|
max_requests_per_second: Rate limit for RPC calls.
|
|
max_retries: Maximum retry attempts on failure.
|
|
retry_delay_seconds: Initial delay between retries.
|
|
"""
|
|
self._rpc_url = rpc_url
|
|
self._fallback_rpc_url = fallback_rpc_url
|
|
self._redis = redis
|
|
self._cache_ttl = cache_ttl_seconds
|
|
self._max_retries = max_retries
|
|
self._retry_delay = retry_delay_seconds
|
|
|
|
# Create web3 instances
|
|
self._w3 = AsyncWeb3(AsyncHTTPProvider(rpc_url))
|
|
self._w3_fallback: AsyncWeb3[AsyncHTTPProvider] | None = None
|
|
if fallback_rpc_url:
|
|
self._w3_fallback = AsyncWeb3(AsyncHTTPProvider(fallback_rpc_url))
|
|
|
|
# Rate limiter
|
|
self._rate_limiter = RateLimiter.create(max_requests_per_second)
|
|
|
|
# Track primary RPC health
|
|
self._primary_healthy = True
|
|
self._last_primary_check = 0.0
|
|
self._primary_recovery_interval = 60.0 # Try primary again after 60s
|
|
|
|
# Cache key prefix
|
|
self._cache_prefix = "polygon:"
|
|
|
|
def _cache_key(self, key_type: str, address: str) -> str:
|
|
"""Generate a cache key."""
|
|
return f"{self._cache_prefix}{key_type}:{address.lower()}"
|
|
|
|
async def _get_cached(self, key: str) -> str | None:
|
|
"""Get value from cache."""
|
|
if not self._redis:
|
|
return None
|
|
try:
|
|
value = await self._redis.get(key)
|
|
if isinstance(value, bytes):
|
|
return value.decode()
|
|
return str(value) if value is not None else None
|
|
except Exception as e:
|
|
logger.warning("Cache get failed: %s", e)
|
|
return None
|
|
|
|
async def _set_cached(self, key: str, value: str, ttl: int | None = None) -> None:
|
|
"""Set value in cache."""
|
|
if not self._redis:
|
|
return
|
|
try:
|
|
await self._redis.set(key, value, ex=ttl or self._cache_ttl)
|
|
except Exception as e:
|
|
logger.warning("Cache set failed: %s", e)
|
|
|
|
def _should_try_primary(self) -> bool:
|
|
"""Check if we should try the primary RPC."""
|
|
if self._primary_healthy:
|
|
return True
|
|
# Periodically retry primary
|
|
now = time.monotonic()
|
|
if now - self._last_primary_check > self._primary_recovery_interval:
|
|
self._last_primary_check = now
|
|
return True
|
|
return False
|
|
|
|
async def _execute_with_retry(
|
|
self,
|
|
func_name: str,
|
|
*args: Any,
|
|
**kwargs: Any,
|
|
) -> Any:
|
|
"""Execute an RPC call with retry and failover logic.
|
|
|
|
Args:
|
|
func_name: Name of the web3.eth method to call.
|
|
*args: Positional arguments for the method.
|
|
**kwargs: Keyword arguments for the method.
|
|
|
|
Returns:
|
|
Result from the RPC call.
|
|
|
|
Raises:
|
|
RPCError: If all retries and failover fail.
|
|
"""
|
|
await self._rate_limiter.acquire()
|
|
|
|
last_error: Exception | None = None
|
|
delay = self._retry_delay
|
|
|
|
# Try primary RPC
|
|
if self._should_try_primary():
|
|
for attempt in range(self._max_retries):
|
|
try:
|
|
method = getattr(self._w3.eth, func_name)
|
|
result = await method(*args, **kwargs)
|
|
self._primary_healthy = True
|
|
return result
|
|
except Web3Exception as e:
|
|
last_error = e
|
|
logger.warning(
|
|
"Primary RPC %s failed (attempt %d/%d): %s",
|
|
func_name,
|
|
attempt + 1,
|
|
self._max_retries,
|
|
e,
|
|
)
|
|
if attempt < self._max_retries - 1:
|
|
await asyncio.sleep(delay)
|
|
delay *= 2 # Exponential backoff
|
|
|
|
# Mark primary as unhealthy
|
|
self._primary_healthy = False
|
|
self._last_primary_check = time.monotonic()
|
|
|
|
# Try fallback RPC
|
|
if self._w3_fallback:
|
|
delay = self._retry_delay
|
|
for attempt in range(self._max_retries):
|
|
try:
|
|
method = getattr(self._w3_fallback.eth, func_name)
|
|
result = await method(*args, **kwargs)
|
|
logger.info("Fallback RPC succeeded for %s", func_name)
|
|
return result
|
|
except Web3Exception as e:
|
|
last_error = e
|
|
logger.warning(
|
|
"Fallback RPC %s failed (attempt %d/%d): %s",
|
|
func_name,
|
|
attempt + 1,
|
|
self._max_retries,
|
|
e,
|
|
)
|
|
if attempt < self._max_retries - 1:
|
|
await asyncio.sleep(delay)
|
|
delay *= 2
|
|
|
|
raise RPCError(f"RPC call {func_name} failed after all retries: {last_error}")
|
|
|
|
async def get_transaction_count(self, address: str) -> int:
|
|
"""Get wallet transaction count (nonce).
|
|
|
|
Args:
|
|
address: Wallet address.
|
|
|
|
Returns:
|
|
Transaction count.
|
|
"""
|
|
cache_key = self._cache_key("nonce", address)
|
|
|
|
# Check cache
|
|
cached = await self._get_cached(cache_key)
|
|
if cached is not None:
|
|
return int(cached)
|
|
|
|
# Query blockchain
|
|
count = await self._execute_with_retry(
|
|
"get_transaction_count",
|
|
AsyncWeb3.to_checksum_address(address),
|
|
)
|
|
|
|
# Cache result
|
|
await self._set_cached(cache_key, str(count))
|
|
|
|
return int(count)
|
|
|
|
async def get_transaction_counts(
|
|
self,
|
|
addresses: Sequence[str],
|
|
) -> dict[str, int]:
|
|
"""Batch get transaction counts for multiple addresses.
|
|
|
|
Args:
|
|
addresses: List of wallet addresses.
|
|
|
|
Returns:
|
|
Dictionary mapping address to transaction count.
|
|
"""
|
|
if not addresses:
|
|
return {}
|
|
|
|
results: dict[str, int] = {}
|
|
uncached: list[str] = []
|
|
|
|
# Check cache for each address
|
|
for address in addresses:
|
|
cache_key = self._cache_key("nonce", address)
|
|
cached = await self._get_cached(cache_key)
|
|
if cached is not None:
|
|
results[address.lower()] = int(cached)
|
|
else:
|
|
uncached.append(address)
|
|
|
|
# Query uncached addresses concurrently
|
|
if uncached:
|
|
tasks = [self.get_transaction_count(addr) for addr in uncached]
|
|
counts = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
for addr, count in zip(uncached, counts, strict=True):
|
|
if isinstance(count, BaseException):
|
|
logger.warning("Failed to get nonce for %s: %s", addr, count)
|
|
results[addr.lower()] = 0
|
|
else:
|
|
results[addr.lower()] = count
|
|
|
|
return results
|
|
|
|
async def get_balance(self, address: str) -> Decimal:
|
|
"""Get wallet MATIC balance in Wei.
|
|
|
|
Args:
|
|
address: Wallet address.
|
|
|
|
Returns:
|
|
Balance in Wei as Decimal.
|
|
"""
|
|
cache_key = self._cache_key("balance", address)
|
|
|
|
# Check cache
|
|
cached = await self._get_cached(cache_key)
|
|
if cached is not None:
|
|
return Decimal(cached)
|
|
|
|
# Query blockchain
|
|
balance = await self._execute_with_retry(
|
|
"get_balance",
|
|
AsyncWeb3.to_checksum_address(address),
|
|
)
|
|
|
|
# Cache result
|
|
await self._set_cached(cache_key, str(balance))
|
|
|
|
return Decimal(balance)
|
|
|
|
async def get_token_balance(
|
|
self,
|
|
address: str,
|
|
token_address: str,
|
|
) -> Decimal:
|
|
"""Get ERC20 token balance.
|
|
|
|
Args:
|
|
address: Wallet address.
|
|
token_address: ERC20 token contract address.
|
|
|
|
Returns:
|
|
Token balance in smallest unit as Decimal.
|
|
"""
|
|
cache_key = self._cache_key(f"token:{token_address.lower()}", address)
|
|
|
|
# Check cache
|
|
cached = await self._get_cached(cache_key)
|
|
if cached is not None:
|
|
return Decimal(cached)
|
|
|
|
# ERC20 balanceOf ABI
|
|
erc20_abi = [
|
|
{
|
|
"constant": True,
|
|
"inputs": [{"name": "_owner", "type": "address"}],
|
|
"name": "balanceOf",
|
|
"outputs": [{"name": "balance", "type": "uint256"}],
|
|
"type": "function",
|
|
}
|
|
]
|
|
|
|
await self._rate_limiter.acquire()
|
|
|
|
try:
|
|
w3 = self._w3 if self._primary_healthy else (self._w3_fallback or self._w3)
|
|
contract = w3.eth.contract(
|
|
address=AsyncWeb3.to_checksum_address(token_address),
|
|
abi=erc20_abi,
|
|
)
|
|
balance = await contract.functions.balanceOf(
|
|
AsyncWeb3.to_checksum_address(address)
|
|
).call()
|
|
except Web3Exception as e:
|
|
raise RPCError(f"Failed to get token balance: {e}") from e
|
|
|
|
# Cache result
|
|
await self._set_cached(cache_key, str(balance))
|
|
|
|
return Decimal(balance)
|
|
|
|
async def get_block(self, block_number: int) -> dict[str, Any]:
|
|
"""Get block by number.
|
|
|
|
Args:
|
|
block_number: Block number.
|
|
|
|
Returns:
|
|
Block data dictionary.
|
|
"""
|
|
cache_key = f"{self._cache_prefix}block:{block_number}"
|
|
|
|
# Check cache
|
|
cached = await self._get_cached(cache_key)
|
|
if cached is not None:
|
|
return cast(dict[str, Any], json.loads(cached))
|
|
|
|
block = await self._execute_with_retry("get_block", block_number)
|
|
|
|
# Convert to serializable dict
|
|
block_dict = dict(block)
|
|
block_dict["timestamp"] = int(block_dict["timestamp"])
|
|
|
|
# Cache result (blocks are immutable, use longer TTL)
|
|
await self._set_cached(cache_key, json.dumps(block_dict), ttl=3600)
|
|
|
|
return dict(block_dict)
|
|
|
|
async def get_first_transaction(self, address: str) -> Transaction | None:
|
|
"""Get the first transaction for a wallet.
|
|
|
|
This is useful for determining wallet age. Note: This is an expensive
|
|
operation as it may require scanning transaction history.
|
|
|
|
Args:
|
|
address: Wallet address.
|
|
|
|
Returns:
|
|
First transaction or None if no transactions.
|
|
"""
|
|
cache_key = self._cache_key("first_tx", address)
|
|
|
|
# Check cache
|
|
cached = await self._get_cached(cache_key)
|
|
if cached is not None:
|
|
if cached == "null":
|
|
return None
|
|
data = json.loads(cached)
|
|
return Transaction(
|
|
hash=data["hash"],
|
|
block_number=data["block_number"],
|
|
timestamp=datetime.fromisoformat(data["timestamp"]),
|
|
from_address=data["from_address"],
|
|
to_address=data["to_address"],
|
|
value=Decimal(data["value"]),
|
|
gas_used=data["gas_used"],
|
|
gas_price=Decimal(data["gas_price"]),
|
|
)
|
|
|
|
# Check if wallet has any transactions
|
|
nonce = await self.get_transaction_count(address)
|
|
if nonce == 0:
|
|
await self._set_cached(cache_key, "null", ttl=60) # Short TTL for empty
|
|
return None
|
|
|
|
# Note: Getting the actual first transaction requires using an indexer
|
|
# or scanning blocks, which is expensive. For now, we'll return None
|
|
# and recommend using an indexer service for production.
|
|
logger.warning(
|
|
"get_first_transaction requires an indexer service for %s (nonce=%d)",
|
|
address,
|
|
nonce,
|
|
)
|
|
return None
|
|
|
|
async def get_wallet_info(self, address: str) -> WalletInfo:
|
|
"""Get aggregated wallet information.
|
|
|
|
Args:
|
|
address: Wallet address.
|
|
|
|
Returns:
|
|
WalletInfo with transaction count, balance, and first transaction.
|
|
"""
|
|
# Fetch data concurrently
|
|
nonce_task = self.get_transaction_count(address)
|
|
balance_task = self.get_balance(address)
|
|
first_tx_task = self.get_first_transaction(address)
|
|
|
|
nonce, balance, first_tx = await asyncio.gather(nonce_task, balance_task, first_tx_task)
|
|
|
|
return WalletInfo(
|
|
address=address.lower(),
|
|
transaction_count=nonce,
|
|
balance_wei=balance,
|
|
first_transaction=first_tx,
|
|
)
|
|
|
|
async def health_check(self) -> bool:
|
|
"""Check if the client can connect to the RPC.
|
|
|
|
Returns:
|
|
True if healthy, False otherwise.
|
|
"""
|
|
try:
|
|
await self._execute_with_retry("block_number")
|
|
return True
|
|
except RPCError:
|
|
return False
|