rmi-backend/app/alchemy_connector.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

253 lines
10 KiB
Python

"""
Alchemy Connector - NFT API, Enhanced APIs, Transaction API.
Free tier: 300M compute credits/month (~10M/day).
Supports: Ethereum, Polygon, Arbitrum, Optimism, Base, Solana (via partnerships).
Key features:
- NFT API: getNFTs, getNFTMetadata, getOwnersForCollection
- Enhanced API: getTokenBalances, getAssetTransfers
- Transaction API: getTransactionReceipts, debugTraceTransaction
- WebSocket: Real-time event streaming (not implemented here)
"""
import asyncio
import logging
import os
import time
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# ── API Keys ────────────────────────────────────────────────
ALCHEMY_API_KEY = os.getenv("ALCHEMY_API_KEY", "")
# Network endpoints
NETWORKS = {
"eth": "https://eth-mainnet.g.alchemy.com/v2",
"eth_goerli": "https://eth-goerli.g.alchemy.com/v2",
"eth_sepolia": "https://eth-sepolia.g.alchemy.com/v2",
"polygon": "https://polygon-mainnet.g.alchemy.com/v2",
"polygon_mumbai": "https://polygon-mumbai.g.alchemy.com/v2",
"arbitrum": "https://arb-mainnet.g.alchemy.com/v2",
"optimism": "https://opt-mainnet.g.alchemy.com/v2",
"base": "https://base-mainnet.g.alchemy.com/v2",
}
class AlchemyConnector:
"""Alchemy API connector for NFTs, enhanced APIs, and transactions."""
def __init__(self):
self.api_key = ALCHEMY_API_KEY
self._cache: dict[str, tuple] = {}
self._cache_ttl = 300 # 5 min
self._last_request = 0.0
self._min_interval = 0.1 # 10 req/sec (Alchemy is generous)
def _network_url(self, network: str) -> str:
"""Get base URL for network."""
return NETWORKS.get(network, NETWORKS["eth"])
async def _rate_limit(self):
now = time.monotonic()
elapsed = now - self._last_request
if elapsed < self._min_interval:
await asyncio.sleep(self._min_interval - elapsed)
self._last_request = time.monotonic()
def _cached(self, key: str) -> Any | None:
if key in self._cache:
data, ts = self._cache[key]
if time.time() - ts < self._cache_ttl:
return data
return None
def _set_cache(self, key: str, data: Any):
self._cache[key] = (data, time.time())
if len(self._cache) > 500:
oldest = min(self._cache, key=lambda k: self._cache[k][1])
del self._cache[oldest]
async def _rpc_call(self, network: str, method: str, params: list[Any]) -> dict | None:
"""Make JSON-RPC call to Alchemy."""
url = f"{self._network_url(network)}/{self.api_key}"
cache_key = f"rpc:{network}:{method}:{str(params)[:100]}"
cached = self._cached(cache_key)
if cached is not None:
return cached
await self._rate_limit()
try:
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.post(
url,
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
headers={"Content-Type": "application/json"},
)
if r.status_code == 200:
data = r.json()
if "error" in data:
logger.debug(f"Alchemy error: {data['error'].get('message', '')}")
return None
result = data.get("result")
self._set_cache(cache_key, result)
return result
elif r.status_code == 429:
logger.warning("Alchemy rate limited")
return None
else:
logger.debug(f"Alchemy {r.status_code}: {url[:80]}")
return None
except Exception as e:
logger.debug(f"Alchemy request failed: {e}")
return None
async def _get(self, endpoint: str, network: str = "eth", params: dict | None = None) -> dict | None:
"""REST API call to Alchemy."""
base = self._network_url(network)
url = f"{base}/{self.api_key}/{endpoint}"
cache_key = f"rest:{network}:{endpoint}:{params or {}!s}"
cached = self._cached(cache_key)
if cached is not None:
return cached
await self._rate_limit()
try:
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(url, params=params)
if r.status_code == 200:
data = r.json()
self._set_cache(cache_key, data)
return data
elif r.status_code == 429:
logger.warning("Alchemy rate limited")
return None
else:
logger.debug(f"Alchemy REST {r.status_code}: {endpoint}")
return None
except Exception as e:
logger.debug(f"Alchemy REST failed: {e}")
return None
# ── NFT API ──────────────────────────────────────────────
async def get_nfts(
self, owner: str, network: str = "eth", page_size: int = 50, page_key: str | None = None
) -> dict:
"""Get NFTs owned by an address."""
params = {"owner": owner, "pageSize": page_size}
if page_key:
params["pageKey"] = page_key
return await self._get("getNFTs", network, params) or {}
async def get_nft_metadata(self, contract: str, token_id: str, network: str = "eth") -> dict:
"""Get metadata for a specific NFT."""
params = {"contractAddress": contract, "tokenId": token_id}
return await self._get("getNFTMetadata", network, params) or {}
async def get_owners_for_collection(self, contract: str, network: str = "eth", page_size: int = 50) -> dict:
"""Get all owners of an NFT collection."""
params = {"contractAddress": contract, "pageSize": page_size}
return await self._get("getOwnersForCollection", network, params) or {}
async def get_nft_sales(self, contract: str | None = None, network: str = "eth", limit: int = 50) -> dict:
"""Get recent NFT sales (optional: filter by contract)."""
params = {"limit": limit}
if contract:
params["contractAddress"] = contract
return await self._get("getNFTSales", network, params) or {}
async def get_contract_metadata(self, contract: str, network: str = "eth") -> dict:
"""Get NFT contract metadata (name, symbol, totalSupply)."""
params = {"contractAddress": contract}
result = await self._get("getContractMetadata", network, params) or {}
# Alchemy returns {address, contractMetadata: {...}}
if "contractMetadata" in result:
return result["contractMetadata"]
return result
# ── Enhanced API ─────────────────────────────────────────
async def get_token_balances(self, address: str, network: str = "eth") -> dict:
"""Get all ERC-20 token balances for an address."""
params = {"address": address}
return await self._get("getTokenBalances", network, params) or {}
async def get_token_metadata(self, contract: str, network: str = "eth") -> dict:
"""Get ERC-20 token metadata."""
params = {"contractAddress": contract}
return await self._get("getTokenMetadata", network, params) or {}
async def get_asset_transfers(
self,
from_address: str | None = None,
to_address: str | None = None,
network: str = "eth",
category: list[str] | None = None,
max_count: int = 100,
) -> dict:
"""Get asset transfers (tokens, NFTs, internal)."""
params = {"maxCount": max_count}
if from_address:
params["fromAddress"] = from_address
if to_address:
params["toAddress"] = to_address
if category:
params["category"] = category
return await self._get("getAssetTransfers", network, params) or {}
# ── Transaction API ──────────────────────────────────────
async def get_transaction_receipt(self, tx_hash: str, network: str = "eth") -> dict:
"""Get transaction receipt with enhanced data."""
return await self._rpc_call(network, "eth_getTransactionReceipt", [tx_hash]) or {}
async def get_block_by_number(self, block_number: int, network: str = "eth", include_txs: bool = False) -> dict:
"""Get block data."""
return await self._rpc_call(network, "eth_getBlockByNumber", [hex(block_number), include_txs]) or {}
async def get_balance(self, address: str, network: str = "eth", block: str = "latest") -> str:
"""Get native token balance (hex wei)."""
return await self._rpc_call(network, "eth_getBalance", [address, block]) or "0x0"
async def get_code(self, address: str, network: str = "eth") -> str:
"""Get contract bytecode."""
return await self._rpc_call(network, "eth_getCode", [address, "latest"]) or "0x"
async def call_contract(
self, contract: str, data: str, network: str = "eth", from_address: str | None = None
) -> str:
"""Call a contract read function."""
params = {"to": contract, "data": data}
if from_address:
params["from"] = from_address
return await self._rpc_call(network, "eth_call", [params, "latest"]) or "0x"
# ── Utility ──────────────────────────────────────────────
def status(self) -> dict:
"""Return connector status."""
return {
"api_key_set": bool(self.api_key),
"key_prefix": self.api_key[:12] + "..." if self.api_key else "NOT SET",
"supported_networks": list(NETWORKS.keys()),
"cache_entries": len(self._cache),
}
# Singleton
_alchemy: AlchemyConnector | None = None
def get_alchemy_connector() -> AlchemyConnector:
global _alchemy
if _alchemy is None:
_alchemy = AlchemyConnector()
return _alchemy