331 lines
11 KiB
Python
331 lines
11 KiB
Python
"""
|
|
Web3.py Integration - EVM Blockchain Connectors
|
|
===============================================
|
|
|
|
Web3 integration with Infura/Alchemy/Local node support.
|
|
Includes:
|
|
- Contract reading (view/pure functions)
|
|
- Event filtering
|
|
- Gas estimation
|
|
- Transaction signing (via wallet)
|
|
- Chain abstraction (Ethereum, Base, Arbitrum, etc.)
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
try:
|
|
from web3 import HTTPProvider, Web3
|
|
|
|
try:
|
|
from web3.middleware import geth_poa_middleware # web3 < v7
|
|
except ImportError:
|
|
from web3.middleware import ExtraDataToPOAMiddleware as geth_poa_middleware # web3 v7+
|
|
from web3.types import BlockNumber, FilterParams, TxParams, Wei
|
|
|
|
WEB3_AVAILABLE = True
|
|
except ImportError:
|
|
logger.warning("web3.py not installed, using stub mode")
|
|
WEB3_AVAILABLE = False
|
|
Web3 = None
|
|
HTTPProvider = None
|
|
geth_poa_middleware = None
|
|
TxParams = dict
|
|
Wei = int
|
|
BlockNumber = int
|
|
FilterParams = dict
|
|
|
|
|
|
# ─── CHAIN CONFIGURATIONS ─────────────────────────────────────────
|
|
|
|
CHAIN_CONFIGS = {
|
|
"ethereum": {
|
|
"rpc": "https://ethereum-rpc.publicnode.com",
|
|
"chain_id": 1,
|
|
"native_symbol": "ETH",
|
|
"decimals": 18,
|
|
"poa": False, # Proof of Authority chains need middleware
|
|
},
|
|
"base": {
|
|
"rpc": "https://base-rpc.publicnode.com",
|
|
"chain_id": 8453,
|
|
"native_symbol": "ETH",
|
|
"decimals": 18,
|
|
"poa": False,
|
|
},
|
|
"arbitrum": {
|
|
"rpc": "https://arbitrum-rpc.publicnode.com",
|
|
"chain_id": 42161,
|
|
"native_symbol": "ETH",
|
|
"decimals": 18,
|
|
"poa": False,
|
|
},
|
|
"optimism": {
|
|
"rpc": "https://optimism-rpc.publicnode.com",
|
|
"chain_id": 10,
|
|
"native_symbol": "ETH",
|
|
"decimals": 18,
|
|
"poa": False,
|
|
},
|
|
"polygon": {
|
|
"rpc": "https://polygon-rpc.publicnode.com",
|
|
"chain_id": 137,
|
|
"native_symbol": "MATIC",
|
|
"decimals": 18,
|
|
"poa": True,
|
|
},
|
|
"bsc": {
|
|
"rpc": "https://bsc-rpc.publicnode.com",
|
|
"chain_id": 56,
|
|
"native_symbol": "BNB",
|
|
"decimals": 18,
|
|
"poa": True,
|
|
},
|
|
"avalanche": {
|
|
"rpc": "https://avalanche-c-chain-rpc.publicnode.com",
|
|
"chain_id": 43114,
|
|
"native_symbol": "AVAX",
|
|
"decimals": 18,
|
|
"poa": False,
|
|
},
|
|
"fantom": {
|
|
"rpc": "https://fantom-rpc.publicnode.com",
|
|
"chain_id": 250,
|
|
"native_symbol": "FTM",
|
|
"decimals": 18,
|
|
"poa": True,
|
|
},
|
|
}
|
|
|
|
|
|
# ─── MORALIS API KEY ──────────────────────────────────────────────
|
|
|
|
MORALIS_API_KEY = ""
|
|
try:
|
|
with open("/root/.secrets/moralis_api_key") as f:
|
|
MORALIS_API_KEY = f.read().strip()
|
|
except Exception:
|
|
logger.warning("Moralis API key not found at /root/.secrets/moralis_api_key")
|
|
|
|
# ─── MORALIS RPC ENDPOINTS ────────────────────────────────────────
|
|
|
|
MORALIS_RPC = {
|
|
"ethereum": "https://deep-index.moralis.io/api/v2.2/eth/mainnet",
|
|
"base": "https://deep-index.moralis.io/api/v2.2/base/mainnet",
|
|
"arbitrum": "https://deep-index.moralis.io/api/v2.2/arbitrum/mainnet",
|
|
"optimism": "https://deep-index.moralis.io/api/v2.2/optimism/mainnet",
|
|
"polygon": "https://deep-index.moralis.io/api/v2.2/polygon/mainnet",
|
|
"bsc": "https://deep-index.moralis.io/api/v2.2/bsc/mainnet",
|
|
"avalanche": "https://deep-index.moralis.io/api/v2.2/avalanche/mainnet",
|
|
"fantom": "https://deep-index.moralis.io/api/v2.2/fantom/mainnet",
|
|
}
|
|
|
|
|
|
# ─── WEB3 CLIENT ──────────────────────────────────────────────────
|
|
|
|
|
|
class EVMClient:
|
|
"""
|
|
EVM blockchain client with chain abstraction.
|
|
Can read contracts, fetch events, estimate gas.
|
|
"""
|
|
|
|
def __init__(self, chain: str = "base", rpc_url: str | None = None, use_moralis: bool = False):
|
|
self.chain = chain.lower()
|
|
self.config = CHAIN_CONFIGS.get(self.chain, CHAIN_CONFIGS["base"])
|
|
self._w3: Web3 | None = None
|
|
self._use_moralis = use_moralis
|
|
|
|
# Use custom RPC if provided, otherwise use chain default or moralis
|
|
if rpc_url:
|
|
self.rpc_url = rpc_url
|
|
elif use_moralis and MORALIS_API_KEY:
|
|
self.rpc_url = MORALIS_RPC.get(self.chain, self.config["rpc"])
|
|
else:
|
|
self.rpc_url = self.config["rpc"]
|
|
|
|
@property
|
|
def w3(self) -> Web3:
|
|
"""Get or create Web3 instance."""
|
|
if self._w3 is None:
|
|
if not WEB3_AVAILABLE:
|
|
raise RuntimeError("web3.py not installed")
|
|
|
|
# Build provider with optional Moralis auth header
|
|
if self._use_moralis and MORALIS_API_KEY:
|
|
provider = HTTPProvider(self.rpc_url, request_kwargs={"headers": {"X-API-Key": MORALIS_API_KEY}})
|
|
else:
|
|
provider = HTTPProvider(self.rpc_url)
|
|
|
|
self._w3 = Web3(provider)
|
|
|
|
# Add POA middleware for PoA chains
|
|
if self.config.get("poa"):
|
|
self._w3.middleware_onion.inject(geth_poa_middleware, layer=0)
|
|
|
|
logger.info(f"Connected to {self.chain}: {self._w3.eth.chain_id}")
|
|
|
|
return self._w3
|
|
|
|
def is_connected(self) -> bool:
|
|
"""Check if blockchain is reachable."""
|
|
try:
|
|
return self.w3.is_connected()
|
|
except Exception:
|
|
return False
|
|
|
|
def get_block_number(self) -> int:
|
|
"""Get latest block number."""
|
|
return self.w3.eth.block_number
|
|
|
|
def get_gas_price(self) -> int:
|
|
"""Get current gas price in wei."""
|
|
return self.w3.eth.gas_price
|
|
|
|
def get_balance(self, address: str, block: int | None = None) -> int:
|
|
"""Get ETH balance of address in wei."""
|
|
address = self.w3.to_checksum_address(address)
|
|
if block is None:
|
|
block = "latest"
|
|
return self.w3.eth.get_balance(address, block)
|
|
|
|
def get_balance_ether(self, address: str) -> float:
|
|
"""Get ETH balance in ether (wei * 10^18)."""
|
|
wei = self.get_balance(address)
|
|
return self.w3.from_wei(wei, "ether")
|
|
|
|
def get_nonce(self, address: str, block: int | None = None) -> int:
|
|
"""Get account nonce."""
|
|
address = self.w3.to_checksum_address(address)
|
|
if block is None:
|
|
block = "latest"
|
|
return self.w3.eth.get_transaction_count(address, block)
|
|
|
|
def get_block(self, block_number: int) -> dict[str, Any] | None:
|
|
"""Get block details."""
|
|
try:
|
|
return self.w3.eth.get_block(block_number)
|
|
except Exception as e:
|
|
logger.error(f"Error fetching block {block_number}: {e}")
|
|
return None
|
|
|
|
def get_transaction(self, tx_hash: str) -> dict[str, Any] | None:
|
|
"""Get transaction details."""
|
|
try:
|
|
return self.w3.eth.get_transaction(tx_hash)
|
|
except Exception as e:
|
|
logger.error(f"Error fetching tx {tx_hash}: {e}")
|
|
return None
|
|
|
|
def estimate_gas(self, tx_params: TxParams) -> int:
|
|
"""Estimate gas for transaction."""
|
|
return self.w3.eth.estimate_gas(tx_params)
|
|
|
|
def get_contract(self, address: str, abi: list[dict[str, Any]]) -> Any:
|
|
"""
|
|
Get Web3 contract instance.
|
|
|
|
Args:
|
|
address: Contract address
|
|
abi: Contract ABI as list of dicts
|
|
|
|
Returns:
|
|
Web3 contract instance
|
|
"""
|
|
address = self.w3.to_checksum_address(address)
|
|
return self.w3.eth.contract(address=address, abi=abi)
|
|
|
|
def get_events(
|
|
self, contract: Any, event_name: str, from_block: int = 0, to_block: int | None = None
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Get historical events from a contract.
|
|
|
|
Args:
|
|
contract: Web3 contract instance
|
|
event_name: Name of event to filter
|
|
from_block: Starting block
|
|
to_block: Ending block (default: latest)
|
|
|
|
Returns:
|
|
List of event logs
|
|
"""
|
|
if to_block is None:
|
|
to_block = self.get_block_number()
|
|
|
|
event = getattr(contract.events, event_name)
|
|
filter_params = event.create_filter(from_block=from_block, to_block=to_block)
|
|
events = filter_params.get_all_entries()
|
|
|
|
return [self._decode_event(e) for e in events]
|
|
|
|
def _decode_event(self, event: Any) -> dict[str, Any]:
|
|
"""Decode event log to dict."""
|
|
return {
|
|
"event": event.event,
|
|
"args": dict(event.args),
|
|
"block_number": event.blockNumber,
|
|
"transaction_hash": event.transactionHash.hex(),
|
|
"log_index": event.logIndex,
|
|
}
|
|
|
|
def call_contract_function(self, contract: Any, function_name: str, *args, **kwargs) -> Any:
|
|
"""
|
|
Call a view/pure contract function.
|
|
|
|
Args:
|
|
contract: Web3 contract instance
|
|
function_name: Name of function to call
|
|
*args: Function arguments
|
|
**kwargs: Additional params (block, from_block)
|
|
|
|
Returns:
|
|
Function return value
|
|
"""
|
|
func = getattr(contract.functions, function_name)
|
|
return func(*args, **kwargs).call()
|
|
|
|
def parse_address(self, address: str) -> str:
|
|
"""Parse and normalize address."""
|
|
return self.w3.to_checksum_address(address)
|
|
|
|
def to_wei(self, amount: int | float | str, unit: str = "ether") -> int:
|
|
"""Convert to wei."""
|
|
return self.w3.to_wei(amount, unit)
|
|
|
|
def from_wei(self, amount: int, unit: str = "ether") -> float:
|
|
"""Convert from wei."""
|
|
return self.w3.from_wei(amount, unit)
|
|
|
|
|
|
# ─── CLIENT MANAGER ───────────────────────────────────────────────
|
|
|
|
|
|
class ClientManager:
|
|
"""Manages multiple EVM clients (one per chain)."""
|
|
|
|
def __init__(self):
|
|
self.clients: dict[str, EVMClient] = {}
|
|
|
|
def get_client(self, chain: str = "base", rpc_url: str | None = None, use_moralis: bool = False) -> EVMClient:
|
|
"""Get or create client for chain."""
|
|
key = f"{chain}:{rpc_url or 'default'}:{use_moralis}"
|
|
if key not in self.clients:
|
|
self.clients[key] = EVMClient(chain=chain, rpc_url=rpc_url, use_moralis=use_moralis)
|
|
return self.clients[key]
|
|
|
|
def clear(self):
|
|
"""Clear all cached clients."""
|
|
self.clients.clear()
|
|
|
|
|
|
# Global singleton
|
|
_client_manager = ClientManager()
|
|
|
|
|
|
def get_evm_client(chain: str = "base", rpc_url: str | None = None, use_moralis: bool = False) -> EVMClient:
|
|
"""Get global EVM client."""
|
|
return _client_manager.get_client(chain, rpc_url, use_moralis)
|