""" Multi-Provider Chain Client - Rate-limited with fallback rotation. Helius (primary) → Birdeye → QuickNode → DexScreener. Free tier: ~5 req/sec. Caching layer in unified_provider. """ import asyncio import logging import os import time from typing import Any import httpx logger = logging.getLogger(__name__) # ── Provider Keys ────────────────────────────────────────── HELIUS_KEYS = [ os.getenv("HELIUS_API_KEY", ""), os.getenv("HELIUS_API_KEY_2", ""), os.getenv("HELIUS_API_KEY_3", ""), ] HELIUS_KEYS = [k for k in HELIUS_KEYS if k and k != "your_helius_key_here"] QUICKNODE_KEY = os.getenv("QUICKNODE_KEY", "") # ── Rate Limiter ─────────────────────────────────────────── class TokenBucket: def __init__(self, rate: float = 5.0, burst: int = 10): self.rate = rate self.burst = burst self.tokens = float(burst) self.last_refill = time.monotonic() self._lock = asyncio.Lock() async def acquire(self) -> float: async with self._lock: now = time.monotonic() elapsed = now - self.last_refill self.tokens = min(self.burst, self.tokens + elapsed * self.rate) self.last_refill = now if self.tokens >= 1.0: self.tokens -= 1.0 return 0.0 wait = (1.0 - self.tokens) / self.rate self.tokens = 0.0 return wait class ChainClient: """Rate-limited Solana RPC with Helius primary + QuickNode fallback.""" def __init__(self): self.bucket = TokenBucket(rate=5.0, burst=10) self._helius_idx = 0 async def _rate_limit(self): wait = await self.bucket.acquire() if wait > 0: await asyncio.sleep(wait) def _next_key(self) -> str | None: if not HELIUS_KEYS: return None key = HELIUS_KEYS[self._helius_idx % len(HELIUS_KEYS)] self._helius_idx += 1 return key async def rpc_call(self, method: str, params: list[Any]) -> dict | None: """Rate-limited RPC with Helius fallback.""" await self._rate_limit() # Try Helius if HELIUS_KEYS: for _ in range(len(HELIUS_KEYS)): key = self._next_key() if not key: break try: async with httpx.AsyncClient(timeout=15.0) as client: r = await client.post( f"https://mainnet.helius-rpc.com/?api-key={key}", json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, ) if r.status_code == 200: return r.json() if r.status_code == 429: logger.debug("Helius rate limited, rotating key") continue except Exception: continue # Try QuickNode fallback if QUICKNODE_KEY and QUICKNODE_KEY != "your_quicknode_key_here": try: async with httpx.AsyncClient(timeout=15.0) as client: r = await client.post( f"https://docs-demo.solana-mainnet.quiknode.pro/{QUICKNODE_KEY}/", json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, ) if r.status_code == 200: return r.json() except Exception: pass return None async def get_signatures(self, address: str, limit: int = 20) -> list[dict]: result = await self.rpc_call("getSignaturesForAddress", [address, {"limit": limit}]) if result and "result" in result: return result["result"] return [] async def get_balance(self, address: str) -> float: result = await self.rpc_call("getBalance", [address]) if result and "result" in result: return result["result"].get("value", 0) / 1e9 return 0.0 async def get_token_metadata(self, mint: str) -> dict | None: if not HELIUS_KEYS: return None try: async with httpx.AsyncClient(timeout=10.0) as client: r = await client.post( f"https://api.helius.xyz/v0/token-metadata/?api-key={HELIUS_KEYS[0]}", json={"mintAccounts": [mint]}, ) if r.status_code == 200: data = r.json() if data and isinstance(data, list) and len(data) > 0: return data[0] except Exception: pass return None # Singleton _client: ChainClient | None = None def get_chain_client() -> ChainClient: global _client if _client is None: _client = ChainClient() return _client