"""Small JSON-RPC client for NodeReal MegaNode. The on-chain monitor only needs a narrow subset of NodeReal right now: standard EVM logs plus a few enhanced token APIs. Keeping this adapter small prevents provider-specific details from leaking into strategy code. """ from __future__ import annotations from dataclasses import dataclass from typing import Any import requests DEFAULT_CHAIN_ENDPOINTS = { "ethereum": "https://eth-mainnet.nodereal.io/v1/{api_key}", "bsc": "https://bsc-mainnet.nodereal.io/v1/{api_key}", } @dataclass(frozen=True) class NodeRealConfig: api_key: str timeout: int = 15 endpoints: dict[str, str] | None = None class NodeRealClient: def __init__(self, config: NodeRealConfig): self.config = config self.endpoints = {**DEFAULT_CHAIN_ENDPOINTS, **(config.endpoints or {})} def supports_chain(self, chain: str) -> bool: return bool(self._endpoint(chain)) def call(self, chain: str, method: str, params: list[Any] | None = None) -> Any: endpoint = self._endpoint(chain) if not endpoint: raise ValueError(f"nodereal_chain_not_configured:{chain}") payload = { "jsonrpc": "2.0", "id": 1, "method": method, "params": params or [], } resp = requests.post( endpoint, json=payload, timeout=self.config.timeout, headers={"Content-Type": "application/json", "User-Agent": "AlphaX-Agent-Crypto/1.0"}, ) if resp.status_code >= 400: raise RuntimeError(f"nodereal_http_{resp.status_code}:{resp.text[:200]}") data = resp.json() if data.get("error"): raise RuntimeError(f"nodereal_rpc_error:{data['error']}") return data.get("result") def block_number(self, chain: str) -> int: return _hex_to_int(self.call(chain, "eth_blockNumber", [])) def get_logs(self, chain: str, log_filter: dict[str, Any]) -> list[dict[str, Any]]: result = self.call(chain, "eth_getLogs", [log_filter]) return result if isinstance(result, list) else [] def eth_call(self, chain: str, to_address: str, data: str, block: str = "latest") -> str: result = self.call(chain, "eth_call", [{"to": to_address, "data": data}, block]) return str(result or "") def token_holder_count(self, chain: str, contract_address: str) -> int: return _hex_to_int(self.call(chain, "nr_getTokenHolderCount", [contract_address])) def _endpoint(self, chain: str) -> str: chain_key = str(chain or "").lower().strip() template = self.endpoints.get(chain_key, "") if not template or not self.config.api_key: return "" return template.format(api_key=self.config.api_key) def _hex_to_int(value: Any) -> int: if value is None: return 0 if isinstance(value, int): return value text = str(value).strip() if not text: return 0 try: return int(text, 16) if text.startswith("0x") else int(text) except Exception: return 0