- 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>
325 lines
12 KiB
Python
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
|