rmi-backend/app/etherscan_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

325 lines
12 KiB
Python

"""
Etherscan Family Connector - EVM Block Explorers.
Single API works for: Ethereum, BSC, Polygon, Avalanche, Fantom, Arbitrum, Optimism, Base.
Free tier: 5 req/sec, 100,000 calls/day.
Supports:
- Account: balance, txlist, tokentx, tokenbalance
- Contract: abi, sourcecode, verify
- Transaction: status, receipt
- Block: blocknumber, blockreward
- Logs: getLogs
"""
import asyncio
import logging
import os
import time
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# ── API Keys ────────────────────────────────────────────────
ETHERSCAN_API_KEY = os.getenv("ETHERSCAN_API_KEY", "")
BSCSCAN_API_KEY = os.getenv("BSCSCAN_API_KEY", "")
POLYGONSCAN_API_KEY = os.getenv("POLYGONSCAN_API_KEY", "")
SNOWTRACE_API_KEY = os.getenv("SNOWTRACE_API_KEY", "") # Avalanche
FTMSCAN_API_KEY = os.getenv("FTMSCAN_API_KEY", "") # Fantom
# Network configurations
NETWORKS = {
"eth": {"name": "Etherscan", "url": "https://api.etherscan.io/api", "key": ETHERSCAN_API_KEY},
"bsc": {"name": "BscScan", "url": "https://api.bscscan.com/api", "key": BSCSCAN_API_KEY},
"polygon": {
"name": "PolygonScan",
"url": "https://api.polygonscan.com/api",
"key": POLYGONSCAN_API_KEY,
},
"avalanche": {
"name": "SnowTrace",
"url": "https://api.snowtrace.io/api",
"key": SNOWTRACE_API_KEY,
},
"fantom": {"name": "FtmScan", "url": "https://api.ftmscan.com/api", "key": FTMSCAN_API_KEY},
"arbitrum": {
"name": "Arbiscan",
"url": "https://api.arbiscan.io/api",
"key": ETHERSCAN_API_KEY,
},
"optimism": {
"name": "Optimism Etherscan",
"url": "https://api-optimistic.etherscan.io/api",
"key": ETHERSCAN_API_KEY,
},
"base": {"name": "Basescan", "url": "https://api.basescan.org/api", "key": ETHERSCAN_API_KEY},
}
class EtherscanConnector:
"""Etherscan family API connector with intelligent key rotation."""
def __init__(self):
self._cache: dict[str, tuple] = {}
self._cache_ttl = 300 # 5 min
self._last_request: dict[str, float] = {} # per-network rate limiting
self._min_interval = 0.2 # 5 req/sec per network
def _get_network(self, network: str) -> dict:
"""Get network config."""
return NETWORKS.get(network, NETWORKS["eth"])
def _network_key(self, network: str) -> str:
"""Get API key for network."""
net = self._get_network(network)
return net.get("key", ETHERSCAN_API_KEY)
async def _rate_limit(self, network: str):
now = time.monotonic()
last = self._last_request.get(network, 0)
elapsed = now - last
if elapsed < self._min_interval:
await asyncio.sleep(self._min_interval - elapsed)
self._last_request[network] = 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 _get(self, network: str, params: dict) -> dict | None:
"""Make API call to Etherscan family."""
net = self._get_network(network)
api_key = self._network_key(network)
params["apikey"] = api_key
cache_key = f"{network}:{params.get('module', '')}:{params.get('action', '')}:{str(params.get('address', params.get('contract', '')))[:30]}"
cached = self._cached(cache_key)
if cached is not None:
return cached
await self._rate_limit(network)
try:
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(net["url"], params=params)
if r.status_code == 200:
data = r.json()
if data.get("status") == "1":
result = data.get("result")
self._set_cache(cache_key, result)
return result
elif data.get("status") == "0":
logger.debug(f"Etherscan error: {data.get('message', '')}")
return None
else:
self._set_cache(cache_key, data)
return data
elif r.status_code == 429:
logger.warning(f"{net['name']} rate limited")
return None
else:
logger.debug(f"{net['name']} {r.status_code}")
return None
except Exception as e:
logger.debug(f"{net['name']} request failed: {e}")
return None
# ── Account API ──────────────────────────────────────────
async def get_balance(self, address: str, network: str = "eth") -> str:
"""Get native token balance (ETH/BNB/MATIC/etc)."""
return (
await self._get(
network,
{"module": "account", "action": "balance", "address": address, "tag": "latest"},
)
or "0"
)
async def get_tx_list(
self,
address: str,
network: str = "eth",
start_block: int = 0,
end_block: int = 99999999,
page: int = 1,
offset: int = 100,
sort: str = "desc",
) -> list[dict]:
"""Get transaction list for address."""
result = await self._get(
network,
{
"module": "account",
"action": "txlist",
"address": address,
"startblock": start_block,
"endblock": end_block,
"page": page,
"offset": offset,
"sort": sort,
},
)
return result if isinstance(result, list) else []
async def get_token_transfers(
self,
address: str,
network: str = "eth",
contract_address: str | None = None,
page: int = 1,
offset: int = 100,
) -> list[dict]:
"""Get ERC-20 token transfer events."""
params = {
"module": "account",
"action": "tokentx",
"address": address,
"page": page,
"offset": offset,
"sort": "desc",
}
if contract_address:
params["contractaddress"] = contract_address
result = await self._get(network, params)
return result if isinstance(result, list) else []
async def get_token_balance(self, contract_address: str, address: str, network: str = "eth") -> str:
"""Get ERC-20 token balance for address."""
return (
await self._get(
network,
{
"module": "account",
"action": "tokenbalance",
"contractaddress": contract_address,
"address": address,
"tag": "latest",
},
)
or "0"
)
async def get_erc721_transfers(
self, address: str, network: str = "eth", page: int = 1, offset: int = 100
) -> list[dict]:
"""Get ERC-721 NFT transfer events."""
result = await self._get(
network,
{
"module": "account",
"action": "tokennfttx",
"address": address,
"page": page,
"offset": offset,
"sort": "desc",
},
)
return result if isinstance(result, list) else []
# ── Contract API ─────────────────────────────────────────
async def get_contract_abi(self, contract: str, network: str = "eth") -> str:
"""Get contract ABI."""
return await self._get(network, {"module": "contract", "action": "getabi", "address": contract}) or ""
async def get_contract_source(self, contract: str, network: str = "eth") -> dict:
"""Get contract source code."""
result = await self._get(network, {"module": "contract", "action": "getsourcecode", "address": contract})
if isinstance(result, list) and len(result) > 0:
return result[0]
return result or {}
async def verify_contract(self, contract: str, network: str = "eth") -> dict:
"""Check if contract is verified."""
source = await self.get_contract_source(contract, network)
return {
"verified": bool(source.get("SourceCode")),
"name": source.get("ContractName"),
"compiler": source.get("CompilerVersion"),
}
# ── Transaction API ──────────────────────────────────────
async def get_transaction_status(self, tx_hash: str, network: str = "eth") -> dict:
"""Get transaction status (success/fail)."""
result = await self._get(network, {"module": "transaction", "action": "getstatus", "txhash": tx_hash})
return result or {}
async def get_transaction_receipt(self, tx_hash: str, network: str = "eth") -> dict:
"""Get transaction receipt."""
result = await self._get(network, {"module": "transaction", "action": "gettxreceiptstatus", "txhash": tx_hash})
return result or {}
# ── Block API ────────────────────────────────────────────
async def get_block_number(self, network: str = "eth") -> int:
"""Get latest block number."""
result = await self._get(network, {"module": "proxy", "action": "eth_blockNumber"})
try:
return int(result, 16) if result and result.startswith("0x") else int(result or 0)
except Exception:
return 0
async def get_block_reward(self, block_number: int, network: str = "eth") -> dict:
"""Get block reward data."""
return await self._get(network, {"module": "block", "action": "getblockreward", "blockno": block_number}) or {}
# ── Logs API ─────────────────────────────────────────────
async def get_logs(
self,
from_block: int,
to_block: int,
address: str | None = None,
topic0: str | None = None,
network: str = "eth",
) -> list[dict]:
"""Get event logs (filtered)."""
params = {
"module": "logs",
"action": "getLogs",
"fromBlock": from_block,
"toBlock": to_block,
}
if address:
params["address"] = address
if topic0:
params["topic0"] = topic0
result = await self._get(network, params)
return result if isinstance(result, list) else []
# ── Utility ──────────────────────────────────────────────
def status(self) -> dict:
"""Return connector status."""
active = [k for k, v in NETWORKS.items() if v.get("key")]
return {
"networks_configured": len(NETWORKS),
"networks_active": len(active),
"active_list": active,
"cache_entries": len(self._cache),
}
# Singleton
_etherscan: EtherscanConnector | None = None
def get_etherscan_connector() -> EtherscanConnector:
global _etherscan
if _etherscan is None:
_etherscan = EtherscanConnector()
return _etherscan