feat: implement Polygon RPC client with caching and rate limiting (#8)
## Summary - Add PolygonClient for blockchain data queries with rate limiting, retry logic, and Redis caching - Implement Transaction and WalletInfo data models with unit conversions - Add comprehensive test coverage (51 tests) ## Features - Token bucket rate limiter to respect RPC provider limits - Exponential backoff retry logic with configurable attempts - Automatic failover to secondary RPC URL - Redis caching with configurable TTL - Methods: get_transaction_count, get_balance, get_token_balance, get_block, get_wallet_info ## Test plan - [x] All 51 profiler tests pass - [x] Ruff lint passes - [x] Mypy type check passes Closes #8 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
0293610b23
commit
7eee2a3b24
@@ -1 +1,23 @@
|
||||
"""Wallet profiler layer - Blockchain analysis for trader intelligence."""
|
||||
"""Wallet profiler - Blockchain analysis for trader intelligence."""
|
||||
|
||||
from polymarket_insider_tracker.profiler.chain import (
|
||||
PolygonClient,
|
||||
PolygonClientError,
|
||||
RateLimitError,
|
||||
RPCError,
|
||||
)
|
||||
from polymarket_insider_tracker.profiler.models import (
|
||||
Transaction,
|
||||
WalletInfo,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Polygon Client
|
||||
"PolygonClient",
|
||||
"PolygonClientError",
|
||||
"RateLimitError",
|
||||
"RPCError",
|
||||
# Models
|
||||
"Transaction",
|
||||
"WalletInfo",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
"""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
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Data models for the profiler module."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Transaction:
|
||||
"""Represents a blockchain transaction."""
|
||||
|
||||
hash: str
|
||||
block_number: int
|
||||
timestamp: datetime
|
||||
from_address: str
|
||||
to_address: str | None
|
||||
value: Decimal # In Wei
|
||||
gas_used: int
|
||||
gas_price: Decimal # In Wei
|
||||
|
||||
@property
|
||||
def value_matic(self) -> Decimal:
|
||||
"""Return value in MATIC (10^18 Wei = 1 MATIC)."""
|
||||
return self.value / Decimal("1000000000000000000")
|
||||
|
||||
@property
|
||||
def gas_cost_wei(self) -> Decimal:
|
||||
"""Return total gas cost in Wei."""
|
||||
return Decimal(self.gas_used) * self.gas_price
|
||||
|
||||
@property
|
||||
def gas_cost_matic(self) -> Decimal:
|
||||
"""Return total gas cost in MATIC."""
|
||||
return self.gas_cost_wei / Decimal("1000000000000000000")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WalletInfo:
|
||||
"""Aggregated wallet information from blockchain queries."""
|
||||
|
||||
address: str
|
||||
transaction_count: int # Nonce
|
||||
balance_wei: Decimal
|
||||
first_transaction: Transaction | None = None
|
||||
|
||||
@property
|
||||
def balance_matic(self) -> Decimal:
|
||||
"""Return balance in MATIC."""
|
||||
return self.balance_wei / Decimal("1000000000000000000")
|
||||
|
||||
@property
|
||||
def is_fresh(self) -> bool:
|
||||
"""Return True if wallet has very few transactions (potential fresh wallet)."""
|
||||
return self.transaction_count < 10
|
||||
|
||||
@property
|
||||
def wallet_age_days(self) -> float | None:
|
||||
"""Return wallet age in days based on first transaction."""
|
||||
if self.first_transaction is None:
|
||||
return None
|
||||
delta = datetime.now(tz=self.first_transaction.timestamp.tzinfo) - self.first_transaction.timestamp
|
||||
return delta.total_seconds() / 86400
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Tests for the Polygon blockchain client."""
|
||||
|
||||
import asyncio
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from web3.exceptions import Web3Exception
|
||||
|
||||
from polymarket_insider_tracker.profiler.chain import (
|
||||
DEFAULT_CACHE_TTL_SECONDS,
|
||||
PolygonClient,
|
||||
RateLimiter,
|
||||
RPCError,
|
||||
)
|
||||
|
||||
# Valid Ethereum addresses for testing
|
||||
VALID_ADDRESS = "0x742d35Cc6634C0532925a3b844Bc9e7595f5eaE2"
|
||||
VALID_ADDRESS_2 = "0x8ba1f109551bD432803012645Ac136ddd64DBA72"
|
||||
VALID_ADDRESS_3 = "0x1234567890AbCdEf1234567890ABcDeF12345678"
|
||||
VALID_TOKEN = "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0" # MATIC token
|
||||
|
||||
|
||||
class TestRateLimiter:
|
||||
"""Tests for the RateLimiter class."""
|
||||
|
||||
def test_create(self) -> None:
|
||||
"""Test creating a rate limiter."""
|
||||
limiter = RateLimiter.create(10.0)
|
||||
|
||||
assert limiter.max_tokens == 10.0
|
||||
assert limiter.refill_rate == 10.0
|
||||
assert limiter.tokens == 10.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_available(self) -> None:
|
||||
"""Test acquiring when tokens are available."""
|
||||
limiter = RateLimiter.create(10.0)
|
||||
|
||||
await limiter.acquire(1.0)
|
||||
|
||||
assert limiter.tokens < 10.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_multiple(self) -> None:
|
||||
"""Test acquiring multiple tokens."""
|
||||
limiter = RateLimiter.create(10.0)
|
||||
|
||||
for _ in range(5):
|
||||
await limiter.acquire(1.0)
|
||||
|
||||
assert limiter.tokens < 6.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_waits_when_empty(self) -> None:
|
||||
"""Test that acquire waits when tokens are depleted."""
|
||||
limiter = RateLimiter.create(2.0)
|
||||
|
||||
# Deplete tokens
|
||||
await limiter.acquire(2.0)
|
||||
|
||||
# This should wait briefly for refill
|
||||
start = asyncio.get_event_loop().time()
|
||||
await limiter.acquire(0.5)
|
||||
elapsed = asyncio.get_event_loop().time() - start
|
||||
|
||||
# Should have waited some time
|
||||
assert elapsed >= 0.1
|
||||
|
||||
|
||||
class TestPolygonClient:
|
||||
"""Tests for the PolygonClient class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis(self) -> AsyncMock:
|
||||
"""Create a mock Redis client."""
|
||||
redis = AsyncMock()
|
||||
redis.get = AsyncMock(return_value=None)
|
||||
redis.set = AsyncMock()
|
||||
return redis
|
||||
|
||||
@pytest.fixture
|
||||
def mock_w3(self) -> MagicMock:
|
||||
"""Create a mock Web3 instance."""
|
||||
w3 = MagicMock()
|
||||
w3.eth = MagicMock()
|
||||
w3.eth.get_transaction_count = AsyncMock(return_value=42)
|
||||
w3.eth.get_balance = AsyncMock(return_value=1000000000000000000)
|
||||
w3.eth.get_block = AsyncMock(return_value={"timestamp": 1704369600})
|
||||
w3.eth.block_number = AsyncMock(return_value=50000000)
|
||||
return w3
|
||||
|
||||
def test_init(self) -> None:
|
||||
"""Test initialization."""
|
||||
client = PolygonClient("https://polygon-rpc.com")
|
||||
|
||||
assert client._rpc_url == "https://polygon-rpc.com"
|
||||
assert client._fallback_rpc_url is None
|
||||
assert client._cache_ttl == DEFAULT_CACHE_TTL_SECONDS
|
||||
|
||||
def test_init_with_fallback(self) -> None:
|
||||
"""Test initialization with fallback RPC."""
|
||||
client = PolygonClient(
|
||||
"https://polygon-rpc.com",
|
||||
fallback_rpc_url="https://fallback.com",
|
||||
)
|
||||
|
||||
assert client._fallback_rpc_url == "https://fallback.com"
|
||||
assert client._w3_fallback is not None
|
||||
|
||||
def test_init_custom_config(self) -> None:
|
||||
"""Test initialization with custom config."""
|
||||
client = PolygonClient(
|
||||
"https://polygon-rpc.com",
|
||||
cache_ttl_seconds=600,
|
||||
max_requests_per_second=50,
|
||||
max_retries=5,
|
||||
)
|
||||
|
||||
assert client._cache_ttl == 600
|
||||
assert client._max_retries == 5
|
||||
assert client._rate_limiter.max_tokens == 50
|
||||
|
||||
def test_cache_key(self) -> None:
|
||||
"""Test cache key generation."""
|
||||
client = PolygonClient("https://polygon-rpc.com")
|
||||
|
||||
key = client._cache_key("nonce", "0xAbC123")
|
||||
|
||||
assert key == "polygon:nonce:0xabc123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_miss(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test cache miss."""
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
result = await client._get_cached("test:key")
|
||||
|
||||
assert result is None
|
||||
mock_redis.get.assert_called_once_with("test:key")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_hit(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test cache hit."""
|
||||
mock_redis.get = AsyncMock(return_value=b"cached_value")
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
result = await client._get_cached("test:key")
|
||||
|
||||
assert result == "cached_value"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_error_handling(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test that cache errors are handled gracefully."""
|
||||
mock_redis.get = AsyncMock(side_effect=Exception("Redis error"))
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
result = await client._get_cached("test:key")
|
||||
|
||||
assert result is None # Should not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_cached(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test setting cache."""
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
await client._set_cached("test:key", "value")
|
||||
|
||||
mock_redis.set.assert_called_once_with(
|
||||
"test:key", "value", ex=DEFAULT_CACHE_TTL_SECONDS
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_cached_custom_ttl(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test setting cache with custom TTL."""
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
await client._set_cached("test:key", "value", ttl=3600)
|
||||
|
||||
mock_redis.set.assert_called_once_with("test:key", "value", ex=3600)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_transaction_count_cached(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test getting transaction count from cache."""
|
||||
mock_redis.get = AsyncMock(return_value=b"42")
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
count = await client.get_transaction_count(VALID_ADDRESS)
|
||||
|
||||
assert count == 42
|
||||
mock_redis.get.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_transaction_count_uncached(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test getting transaction count from blockchain."""
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
with patch.object(client, "_execute_with_retry", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = 42
|
||||
|
||||
count = await client.get_transaction_count(VALID_ADDRESS)
|
||||
|
||||
assert count == 42
|
||||
mock_exec.assert_called_once()
|
||||
mock_redis.set.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_transaction_counts_batch(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test batch getting transaction counts."""
|
||||
mock_redis.get = AsyncMock(side_effect=[b"10", None, b"30"])
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
with patch.object(client, "get_transaction_count", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = 20
|
||||
|
||||
addresses = [VALID_ADDRESS, VALID_ADDRESS_2, VALID_ADDRESS_3]
|
||||
counts = await client.get_transaction_counts(addresses)
|
||||
|
||||
assert counts[VALID_ADDRESS.lower()] == 10 # From cache
|
||||
assert counts[VALID_ADDRESS_2.lower()] == 20 # From blockchain
|
||||
assert counts[VALID_ADDRESS_3.lower()] == 30 # From cache
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_transaction_counts_empty(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test batch with empty list."""
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
counts = await client.get_transaction_counts([])
|
||||
|
||||
assert counts == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_balance_cached(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test getting balance from cache."""
|
||||
mock_redis.get = AsyncMock(return_value=b"1000000000000000000")
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
balance = await client.get_balance(VALID_ADDRESS)
|
||||
|
||||
assert balance == Decimal("1000000000000000000")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_balance_uncached(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test getting balance from blockchain."""
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
with patch.object(client, "_execute_with_retry", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = 2000000000000000000
|
||||
|
||||
balance = await client.get_balance(VALID_ADDRESS)
|
||||
|
||||
assert balance == Decimal("2000000000000000000")
|
||||
mock_redis.set.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_wallet_info(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test getting aggregated wallet info."""
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
with (
|
||||
patch.object(client, "get_transaction_count", new_callable=AsyncMock) as mock_nonce,
|
||||
patch.object(client, "get_balance", new_callable=AsyncMock) as mock_balance,
|
||||
patch.object(client, "get_first_transaction", new_callable=AsyncMock) as mock_tx,
|
||||
):
|
||||
mock_nonce.return_value = 42
|
||||
mock_balance.return_value = Decimal("1000000000000000000")
|
||||
mock_tx.return_value = None
|
||||
|
||||
info = await client.get_wallet_info(VALID_ADDRESS)
|
||||
|
||||
assert info.address == VALID_ADDRESS.lower()
|
||||
assert info.transaction_count == 42
|
||||
assert info.balance_wei == Decimal("1000000000000000000")
|
||||
assert info.first_transaction is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_first_transaction_no_transactions(
|
||||
self, mock_redis: AsyncMock
|
||||
) -> None:
|
||||
"""Test get_first_transaction when wallet has no transactions."""
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
with patch.object(client, "get_transaction_count", new_callable=AsyncMock) as mock_nonce:
|
||||
mock_nonce.return_value = 0
|
||||
|
||||
tx = await client.get_first_transaction(VALID_ADDRESS)
|
||||
|
||||
assert tx is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_success(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test successful health check."""
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
with patch.object(client, "_execute_with_retry", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = 50000000
|
||||
|
||||
healthy = await client.health_check()
|
||||
|
||||
assert healthy is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_failure(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test failed health check."""
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
with patch.object(client, "_execute_with_retry", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.side_effect = RPCError("Connection failed")
|
||||
|
||||
healthy = await client.health_check()
|
||||
|
||||
assert healthy is False
|
||||
|
||||
|
||||
class TestPolygonClientRetryLogic:
|
||||
"""Tests for retry and failover logic."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis(self) -> AsyncMock:
|
||||
"""Create a mock Redis client."""
|
||||
redis = AsyncMock()
|
||||
redis.get = AsyncMock(return_value=None)
|
||||
redis.set = AsyncMock()
|
||||
return redis
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_failure(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test that client retries on RPC failure."""
|
||||
client = PolygonClient(
|
||||
"https://polygon-rpc.com",
|
||||
redis=mock_redis,
|
||||
max_retries=3,
|
||||
retry_delay_seconds=0.01,
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_get_tx_count(*_args: object, **_kwargs: object) -> int:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise Web3Exception("Temporary error")
|
||||
return 42
|
||||
|
||||
client._w3.eth.get_transaction_count = mock_get_tx_count
|
||||
|
||||
count = await client.get_transaction_count(VALID_ADDRESS)
|
||||
|
||||
assert count == 42
|
||||
assert call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failover_to_secondary(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test failover to secondary RPC."""
|
||||
client = PolygonClient(
|
||||
"https://polygon-rpc.com",
|
||||
fallback_rpc_url="https://fallback.com",
|
||||
redis=mock_redis,
|
||||
max_retries=1,
|
||||
retry_delay_seconds=0.01,
|
||||
)
|
||||
|
||||
# Primary always fails
|
||||
async def primary_fail(*_args: object, **_kwargs: object) -> int:
|
||||
raise Web3Exception("Primary down")
|
||||
|
||||
client._w3.eth.get_transaction_count = primary_fail
|
||||
|
||||
# Fallback works
|
||||
client._w3_fallback.eth.get_transaction_count = AsyncMock(return_value=42)
|
||||
|
||||
count = await client.get_transaction_count(VALID_ADDRESS)
|
||||
|
||||
assert count == 42
|
||||
assert not client._primary_healthy
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_retries_exhausted(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test error when all retries are exhausted."""
|
||||
client = PolygonClient(
|
||||
"https://polygon-rpc.com",
|
||||
redis=mock_redis,
|
||||
max_retries=2,
|
||||
retry_delay_seconds=0.01,
|
||||
)
|
||||
|
||||
async def always_fail(*_args: object, **_kwargs: object) -> int:
|
||||
raise Web3Exception("Always fails")
|
||||
|
||||
client._w3.eth.get_transaction_count = always_fail
|
||||
|
||||
with pytest.raises(RPCError):
|
||||
await client.get_transaction_count(VALID_ADDRESS)
|
||||
|
||||
|
||||
class TestPolygonClientRateLimiting:
|
||||
"""Tests for rate limiting."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limiting_enforced(self) -> None:
|
||||
"""Test that rate limiting delays requests."""
|
||||
redis = AsyncMock()
|
||||
redis.get = AsyncMock(return_value=None)
|
||||
redis.set = AsyncMock()
|
||||
|
||||
client = PolygonClient(
|
||||
"https://polygon-rpc.com",
|
||||
redis=redis,
|
||||
max_requests_per_second=5.0,
|
||||
)
|
||||
|
||||
# Deplete rate limit
|
||||
client._rate_limiter.tokens = 0
|
||||
|
||||
with patch.object(client._w3.eth, "get_transaction_count", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = 42
|
||||
|
||||
start = asyncio.get_event_loop().time()
|
||||
await client.get_transaction_count(VALID_ADDRESS)
|
||||
elapsed = asyncio.get_event_loop().time() - start
|
||||
|
||||
# Should have waited for token refill
|
||||
assert elapsed >= 0.1
|
||||
|
||||
|
||||
class TestPolygonClientTokenBalance:
|
||||
"""Tests for ERC20 token balance queries."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis(self) -> AsyncMock:
|
||||
"""Create a mock Redis client."""
|
||||
redis = AsyncMock()
|
||||
redis.get = AsyncMock(return_value=None)
|
||||
redis.set = AsyncMock()
|
||||
return redis
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_token_balance_cached(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test getting token balance from cache."""
|
||||
mock_redis.get = AsyncMock(return_value=b"1000000")
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
balance = await client.get_token_balance(VALID_ADDRESS, VALID_TOKEN)
|
||||
|
||||
assert balance == Decimal("1000000")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_token_balance_uncached(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test getting token balance from blockchain."""
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
# Mock the contract call
|
||||
mock_contract = MagicMock()
|
||||
mock_contract.functions.balanceOf.return_value.call = AsyncMock(
|
||||
return_value=5000000
|
||||
)
|
||||
client._w3.eth.contract = MagicMock(return_value=mock_contract)
|
||||
|
||||
balance = await client.get_token_balance(VALID_ADDRESS, VALID_TOKEN)
|
||||
|
||||
assert balance == Decimal("5000000")
|
||||
mock_redis.set.assert_called_once()
|
||||
|
||||
|
||||
class TestPolygonClientBlock:
|
||||
"""Tests for block queries."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis(self) -> AsyncMock:
|
||||
"""Create a mock Redis client."""
|
||||
redis = AsyncMock()
|
||||
redis.get = AsyncMock(return_value=None)
|
||||
redis.set = AsyncMock()
|
||||
return redis
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_block_cached(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test getting block from cache."""
|
||||
mock_redis.get = AsyncMock(return_value=b'{"timestamp": 1704369600}')
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
block = await client.get_block(50000000)
|
||||
|
||||
assert block["timestamp"] == 1704369600
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_block_uncached(self, mock_redis: AsyncMock) -> None:
|
||||
"""Test getting block from blockchain."""
|
||||
client = PolygonClient("https://polygon-rpc.com", redis=mock_redis)
|
||||
|
||||
with patch.object(client, "_execute_with_retry", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = {"timestamp": 1704369600, "number": 50000000}
|
||||
|
||||
block = await client.get_block(50000000)
|
||||
|
||||
assert block["timestamp"] == 1704369600
|
||||
# Block cache uses 1 hour TTL
|
||||
mock_redis.set.assert_called_once()
|
||||
call_args = mock_redis.set.call_args
|
||||
assert call_args[1]["ex"] == 3600
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Tests for the profiler data models."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from polymarket_insider_tracker.profiler.models import Transaction, WalletInfo
|
||||
|
||||
|
||||
class TestTransaction:
|
||||
"""Tests for the Transaction dataclass."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_transaction(self) -> Transaction:
|
||||
"""Create a sample transaction."""
|
||||
return Transaction(
|
||||
hash="0xabc123",
|
||||
block_number=50000000,
|
||||
timestamp=datetime(2026, 1, 4, 12, 0, 0, tzinfo=UTC),
|
||||
from_address="0xsender",
|
||||
to_address="0xreceiver",
|
||||
value=Decimal("1000000000000000000"), # 1 MATIC
|
||||
gas_used=21000,
|
||||
gas_price=Decimal("50000000000"), # 50 Gwei
|
||||
)
|
||||
|
||||
def test_transaction_creation(self, sample_transaction: Transaction) -> None:
|
||||
"""Test creating a transaction."""
|
||||
assert sample_transaction.hash == "0xabc123"
|
||||
assert sample_transaction.block_number == 50000000
|
||||
assert sample_transaction.from_address == "0xsender"
|
||||
assert sample_transaction.to_address == "0xreceiver"
|
||||
|
||||
def test_value_matic(self, sample_transaction: Transaction) -> None:
|
||||
"""Test value in MATIC conversion."""
|
||||
assert sample_transaction.value_matic == Decimal("1")
|
||||
|
||||
def test_value_matic_fractional(self) -> None:
|
||||
"""Test fractional MATIC value."""
|
||||
tx = Transaction(
|
||||
hash="0x123",
|
||||
block_number=1,
|
||||
timestamp=datetime.now(UTC),
|
||||
from_address="0x1",
|
||||
to_address="0x2",
|
||||
value=Decimal("500000000000000000"), # 0.5 MATIC
|
||||
gas_used=21000,
|
||||
gas_price=Decimal("50000000000"),
|
||||
)
|
||||
|
||||
assert tx.value_matic == Decimal("0.5")
|
||||
|
||||
def test_gas_cost_wei(self, sample_transaction: Transaction) -> None:
|
||||
"""Test gas cost in Wei."""
|
||||
expected = 21000 * 50000000000
|
||||
assert sample_transaction.gas_cost_wei == Decimal(expected)
|
||||
|
||||
def test_gas_cost_matic(self, sample_transaction: Transaction) -> None:
|
||||
"""Test gas cost in MATIC."""
|
||||
# 21000 * 50 Gwei = 1050000 Gwei = 0.00105 MATIC
|
||||
expected = Decimal("21000") * Decimal("50000000000") / Decimal("1000000000000000000")
|
||||
assert sample_transaction.gas_cost_matic == expected
|
||||
|
||||
def test_transaction_frozen(self, sample_transaction: Transaction) -> None:
|
||||
"""Test that transaction is immutable."""
|
||||
with pytest.raises(AttributeError):
|
||||
sample_transaction.hash = "0xnew" # type: ignore[misc]
|
||||
|
||||
def test_transaction_no_recipient(self) -> None:
|
||||
"""Test transaction with no recipient (contract creation)."""
|
||||
tx = Transaction(
|
||||
hash="0x123",
|
||||
block_number=1,
|
||||
timestamp=datetime.now(UTC),
|
||||
from_address="0x1",
|
||||
to_address=None,
|
||||
value=Decimal("0"),
|
||||
gas_used=100000,
|
||||
gas_price=Decimal("50000000000"),
|
||||
)
|
||||
|
||||
assert tx.to_address is None
|
||||
|
||||
|
||||
class TestWalletInfo:
|
||||
"""Tests for the WalletInfo dataclass."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_wallet(self) -> WalletInfo:
|
||||
"""Create a sample wallet info."""
|
||||
return WalletInfo(
|
||||
address="0xwallet123",
|
||||
transaction_count=100,
|
||||
balance_wei=Decimal("5000000000000000000"), # 5 MATIC
|
||||
first_transaction=None,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def wallet_with_transaction(self) -> WalletInfo:
|
||||
"""Create a wallet with first transaction."""
|
||||
first_tx = Transaction(
|
||||
hash="0xfirst",
|
||||
block_number=1000000,
|
||||
timestamp=datetime.now(UTC) - timedelta(days=365),
|
||||
from_address="0xfaucet",
|
||||
to_address="0xwallet123",
|
||||
value=Decimal("1000000000000000000"),
|
||||
gas_used=21000,
|
||||
gas_price=Decimal("50000000000"),
|
||||
)
|
||||
return WalletInfo(
|
||||
address="0xwallet123",
|
||||
transaction_count=100,
|
||||
balance_wei=Decimal("5000000000000000000"),
|
||||
first_transaction=first_tx,
|
||||
)
|
||||
|
||||
def test_wallet_creation(self, sample_wallet: WalletInfo) -> None:
|
||||
"""Test creating wallet info."""
|
||||
assert sample_wallet.address == "0xwallet123"
|
||||
assert sample_wallet.transaction_count == 100
|
||||
assert sample_wallet.balance_wei == Decimal("5000000000000000000")
|
||||
|
||||
def test_balance_matic(self, sample_wallet: WalletInfo) -> None:
|
||||
"""Test balance in MATIC conversion."""
|
||||
assert sample_wallet.balance_matic == Decimal("5")
|
||||
|
||||
def test_is_fresh_false(self, sample_wallet: WalletInfo) -> None:
|
||||
"""Test that wallet with many transactions is not fresh."""
|
||||
assert sample_wallet.is_fresh is False
|
||||
|
||||
def test_is_fresh_true(self) -> None:
|
||||
"""Test that wallet with few transactions is fresh."""
|
||||
wallet = WalletInfo(
|
||||
address="0xnewwallet",
|
||||
transaction_count=5,
|
||||
balance_wei=Decimal("1000000000000000000"),
|
||||
)
|
||||
|
||||
assert wallet.is_fresh is True
|
||||
|
||||
def test_is_fresh_boundary(self) -> None:
|
||||
"""Test fresh wallet boundary (10 transactions)."""
|
||||
wallet_9 = WalletInfo(
|
||||
address="0x1",
|
||||
transaction_count=9,
|
||||
balance_wei=Decimal("0"),
|
||||
)
|
||||
wallet_10 = WalletInfo(
|
||||
address="0x2",
|
||||
transaction_count=10,
|
||||
balance_wei=Decimal("0"),
|
||||
)
|
||||
|
||||
assert wallet_9.is_fresh is True
|
||||
assert wallet_10.is_fresh is False
|
||||
|
||||
def test_wallet_age_days_no_transaction(self, sample_wallet: WalletInfo) -> None:
|
||||
"""Test wallet age when no first transaction."""
|
||||
assert sample_wallet.wallet_age_days is None
|
||||
|
||||
def test_wallet_age_days_with_transaction(
|
||||
self, wallet_with_transaction: WalletInfo
|
||||
) -> None:
|
||||
"""Test wallet age calculation."""
|
||||
age = wallet_with_transaction.wallet_age_days
|
||||
|
||||
assert age is not None
|
||||
# Should be approximately 365 days
|
||||
assert 364 < age < 366
|
||||
|
||||
def test_wallet_frozen(self, sample_wallet: WalletInfo) -> None:
|
||||
"""Test that wallet info is immutable."""
|
||||
with pytest.raises(AttributeError):
|
||||
sample_wallet.address = "0xnew" # type: ignore[misc]
|
||||
|
||||
def test_wallet_zero_balance(self) -> None:
|
||||
"""Test wallet with zero balance."""
|
||||
wallet = WalletInfo(
|
||||
address="0xempty",
|
||||
transaction_count=0,
|
||||
balance_wei=Decimal("0"),
|
||||
)
|
||||
|
||||
assert wallet.balance_matic == Decimal("0")
|
||||
assert wallet.is_fresh is True
|
||||
|
||||
def test_wallet_very_small_balance(self) -> None:
|
||||
"""Test wallet with very small balance."""
|
||||
wallet = WalletInfo(
|
||||
address="0xdust",
|
||||
transaction_count=1,
|
||||
balance_wei=Decimal("1"), # 1 Wei
|
||||
)
|
||||
|
||||
# Should be a very small fraction
|
||||
expected = Decimal("1") / Decimal("1000000000000000000")
|
||||
assert wallet.balance_matic == expected
|
||||
|
||||
|
||||
class TestTransactionEquality:
|
||||
"""Tests for transaction equality and hashing."""
|
||||
|
||||
def test_equal_transactions(self) -> None:
|
||||
"""Test that identical transactions are equal."""
|
||||
tx1 = Transaction(
|
||||
hash="0xabc",
|
||||
block_number=1,
|
||||
timestamp=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
from_address="0x1",
|
||||
to_address="0x2",
|
||||
value=Decimal("1000"),
|
||||
gas_used=21000,
|
||||
gas_price=Decimal("50"),
|
||||
)
|
||||
tx2 = Transaction(
|
||||
hash="0xabc",
|
||||
block_number=1,
|
||||
timestamp=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
from_address="0x1",
|
||||
to_address="0x2",
|
||||
value=Decimal("1000"),
|
||||
gas_used=21000,
|
||||
gas_price=Decimal("50"),
|
||||
)
|
||||
|
||||
assert tx1 == tx2
|
||||
|
||||
def test_different_transactions(self) -> None:
|
||||
"""Test that different transactions are not equal."""
|
||||
tx1 = Transaction(
|
||||
hash="0xabc",
|
||||
block_number=1,
|
||||
timestamp=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
from_address="0x1",
|
||||
to_address="0x2",
|
||||
value=Decimal("1000"),
|
||||
gas_used=21000,
|
||||
gas_price=Decimal("50"),
|
||||
)
|
||||
tx2 = Transaction(
|
||||
hash="0xdef", # Different hash
|
||||
block_number=1,
|
||||
timestamp=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
from_address="0x1",
|
||||
to_address="0x2",
|
||||
value=Decimal("1000"),
|
||||
gas_used=21000,
|
||||
gas_price=Decimal("50"),
|
||||
)
|
||||
|
||||
assert tx1 != tx2
|
||||
|
||||
def test_transaction_hashable(self) -> None:
|
||||
"""Test that transactions can be used in sets."""
|
||||
tx = Transaction(
|
||||
hash="0xabc",
|
||||
block_number=1,
|
||||
timestamp=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
from_address="0x1",
|
||||
to_address="0x2",
|
||||
value=Decimal("1000"),
|
||||
gas_used=21000,
|
||||
gas_price=Decimal("50"),
|
||||
)
|
||||
|
||||
tx_set = {tx}
|
||||
assert tx in tx_set
|
||||
Reference in New Issue
Block a user