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:
Patrick Selamy
2026-01-04 15:33:49 -05:00
co-authored by Claude Opus 4.5
parent 0293610b23
commit 7eee2a3b24
6 changed files with 3816 additions and 1 deletions
+500
View File
@@ -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