|
| 1 | +"""On-chain helpers for diag/identity_health. |
| 2 | +
|
| 3 | +Read-only eth_call against Fula's PoolStorage + RewardEngine contracts |
| 4 | +on base + skale. No signing, no gas, no keys — pure view-function reads. |
| 5 | +
|
| 6 | +Why no web3.py: that lib's transitive deps are ~80MB. We only need |
| 7 | +eth_call (no transactions, no wallet) and `eth_abi` is the only piece |
| 8 | +that handles padding/encoding cleanly. JSON-RPC over the existing |
| 9 | +stdlib `urllib`-backed `http_post_json` helper. |
| 10 | +
|
| 11 | +bytes32(peerId) conversion ported faithfully from |
| 12 | +`mainnet-claim-web/app.js:peerIdToBytes32`. TWO paths: |
| 13 | + - CIDv1 (Ed25519): leading bytes [0x00, 0x24, 0x08, 0x01, 0x12], |
| 14 | + total length >= 37, take the LAST 32 bytes (the raw pubkey). |
| 15 | + - Legacy multihash: leading bytes [0x12, 0x20], total length == 34, |
| 16 | + take bytes [2:] (the sha256 digest). |
| 17 | +Gemini's "always take last 32" guess gets the legacy path wrong; advisor |
| 18 | +caught it; algorithm is verified against the JS reference. |
| 19 | +
|
| 20 | +Tristate contract for chain-derived facts (per codex + gemini): |
| 21 | + - True / False — definitive answer from the chain |
| 22 | + - 'unknown' (string) with `unknown_reason` — RPC unreachable, chain |
| 23 | + revert, malformed peerId, etc. Trees branch explicitly on unknown. |
| 24 | +""" |
| 25 | +from __future__ import annotations |
| 26 | + |
| 27 | +import base64 |
| 28 | +import hashlib |
| 29 | +import logging |
| 30 | +import threading |
| 31 | +import time |
| 32 | +from dataclasses import dataclass |
| 33 | +from typing import Any |
| 34 | + |
| 35 | +from src.tools.diag_impls._helpers import http_post_json |
| 36 | + |
| 37 | + |
| 38 | +logger = logging.getLogger("blox-ai.chain") |
| 39 | + |
| 40 | + |
| 41 | +# Default public RPC endpoints per chain. Trees can branch on the |
| 42 | +# `rpc_reachable` fact when these are blocked; in 0.6 we may add a |
| 43 | +# config.yaml override field. |
| 44 | +DEFAULT_RPC_URLS: dict[str, str] = { |
| 45 | + "base": "https://mainnet.base.org", |
| 46 | + "skale": "https://mainnet.skalenodes.com/v1/elated-tan-skat", |
| 47 | +} |
| 48 | + |
| 49 | +# Fula contract addresses, per user-provided spec 2026-05-28. |
| 50 | +CONTRACTS: dict[str, dict[str, str]] = { |
| 51 | + "base": { |
| 52 | + "PoolStorage": "0xb093fF4B3B3B87a712107B26566e0cCE5E752b4D", |
| 53 | + "RewardEngine": "0x31029f90405fd3D9cB0835c6d21b9DFF058Df45A", |
| 54 | + }, |
| 55 | + "skale": { |
| 56 | + "PoolStorage": "0xf9176Ffde541bF0aa7884298Ce538c471Ad0F015", |
| 57 | + "RewardEngine": "0xF7c64248294C45Eb3AcdD282b58675F1831fb047", |
| 58 | + }, |
| 59 | +} |
| 60 | + |
| 61 | + |
| 62 | +# --------------------------------------------------------------------------- |
| 63 | +# bytes32(peerId) |
| 64 | +# --------------------------------------------------------------------------- |
| 65 | + |
| 66 | + |
| 67 | +# Multibase btc-alphabet (used by libp2p peerId "z..." encoding). Stdlib |
| 68 | +# `base64.b58decode` doesn't exist; we hand-roll because adding a base58 |
| 69 | +# pip dep for one function is excessive. |
| 70 | +_BASE58_BTC_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" |
| 71 | + |
| 72 | + |
| 73 | +def _b58_decode(s: str) -> bytes: |
| 74 | + """Bitcoin-alphabet base58 decode. Matches the JS multibase 'z' prefix |
| 75 | + decoder used in mainnet-claim-web.""" |
| 76 | + n = 0 |
| 77 | + for c in s: |
| 78 | + try: |
| 79 | + n = n * 58 + _BASE58_BTC_ALPHABET.index(c) |
| 80 | + except ValueError: |
| 81 | + raise ValueError(f"invalid base58 character: {c!r}") |
| 82 | + # Convert int to bytes, then prepend a leading-zero byte per leading |
| 83 | + # '1' in the input (base58 maps leading zeros to '1'). |
| 84 | + out = bytearray() |
| 85 | + while n > 0: |
| 86 | + out.append(n & 0xFF) |
| 87 | + n >>= 8 |
| 88 | + leading_zeros = 0 |
| 89 | + for c in s: |
| 90 | + if c == "1": |
| 91 | + leading_zeros += 1 |
| 92 | + else: |
| 93 | + break |
| 94 | + out.extend(b"\x00" * leading_zeros) |
| 95 | + return bytes(reversed(out)) |
| 96 | + |
| 97 | + |
| 98 | +def peer_id_to_bytes32(peer_id: str) -> str: |
| 99 | + """Port of `mainnet-claim-web/app.js:peerIdToBytes32`. |
| 100 | +
|
| 101 | + Accepts the libp2p peerId string (with or without the leading 'z' |
| 102 | + multibase prefix). Returns a 0x-prefixed 64-hex-char string suitable |
| 103 | + for passing as a `bytes32` parameter to PoolStorage / RewardEngine. |
| 104 | +
|
| 105 | + Raises ValueError when the decoded length doesn't match either |
| 106 | + expected format — callers should treat this as `unknown_reason |
| 107 | + = 'invalid_peerid_format'`. |
| 108 | + """ |
| 109 | + if not isinstance(peer_id, str) or not peer_id: |
| 110 | + raise ValueError("peer_id must be a non-empty string") |
| 111 | + |
| 112 | + # Multibase 'z' prefix (base58btc) — JS code prepends if missing. |
| 113 | + stripped = peer_id[1:] if peer_id.startswith("z") else peer_id |
| 114 | + decoded = _b58_decode(stripped) |
| 115 | + |
| 116 | + # CIDv1 (Ed25519) — header [0x00, 0x24, 0x08, 0x01, 0x12], total >= 37 |
| 117 | + cidv1_header = (0x00, 0x24, 0x08, 0x01, 0x12) |
| 118 | + if ( |
| 119 | + len(decoded) >= 37 |
| 120 | + and tuple(decoded[:5]) == cidv1_header |
| 121 | + ): |
| 122 | + pubkey = decoded[-32:] |
| 123 | + return "0x" + pubkey.hex() |
| 124 | + |
| 125 | + # Legacy multihash — header [0x12, 0x20], total == 34 |
| 126 | + if len(decoded) == 34 and decoded[0] == 0x12 and decoded[1] == 0x20: |
| 127 | + digest = decoded[2:] |
| 128 | + return "0x" + digest.hex() |
| 129 | + |
| 130 | + raise ValueError( |
| 131 | + f"unsupported peerId format (decoded length {len(decoded)})" |
| 132 | + ) |
| 133 | + |
| 134 | + |
| 135 | +# --------------------------------------------------------------------------- |
| 136 | +# JSON-RPC eth_call |
| 137 | +# --------------------------------------------------------------------------- |
| 138 | + |
| 139 | + |
| 140 | +def _keccak256(data: bytes) -> bytes: |
| 141 | + """keccak256 — needed for Ethereum function selectors. hashlib has |
| 142 | + sha3_256 (NIST SHA-3) which is DIFFERENT from keccak256 (the |
| 143 | + pre-standardization variant Ethereum uses). On Python 3.6+ we get |
| 144 | + keccak via `Cryptodome.Hash.keccak` OR `eth_utils`. Both are heavy. |
| 145 | + pysha3 was the standard but is unmaintained. |
| 146 | +
|
| 147 | + Workaround: most function selectors are SHORT + KNOWN. Precompute |
| 148 | + them at module load so we never need keccak at runtime. This dict |
| 149 | + is the entire selector surface for our two contracts' view methods |
| 150 | + we use. |
| 151 | + """ |
| 152 | + raise NotImplementedError( |
| 153 | + "keccak256 not implemented; use precomputed FUNCTION_SELECTORS" |
| 154 | + ) |
| 155 | + |
| 156 | + |
| 157 | +# Precomputed Ethereum 4-byte function selectors for the view methods |
| 158 | +# we call. Each selector is the first 4 bytes of keccak256(signature). |
| 159 | +# Generated offline via `cast sig "<signature>"` (foundry) and pinned |
| 160 | +# here so we have ZERO runtime keccak dependency. When adding a new |
| 161 | +# method: compute via `cast sig` + paste below. |
| 162 | +# |
| 163 | +# Signatures match the canonical ABI types — peerId is bytes32, poolId |
| 164 | +# is uint32 (per Fula contract source). |
| 165 | +FUNCTION_SELECTORS: dict[str, str] = { |
| 166 | + # PoolStorage view methods |
| 167 | + "isMemberOfPool(uint32,bytes32)": "0x00000000", # PLACEHOLDER |
| 168 | + "members(uint32,bytes32)": "0x00000000", # PLACEHOLDER |
| 169 | + # RewardEngine view methods |
| 170 | + "isOnline(uint32,bytes32)": "0x00000000", # PLACEHOLDER |
| 171 | + "isPeerOnline(uint32,bytes32)": "0x00000000", # PLACEHOLDER |
| 172 | +} |
| 173 | +# IMPORTANT: the selectors above are PLACEHOLDERS. Phase 0.5b must: |
| 174 | +# 1. Read RewardEngine.json + PoolStorage ABI from mainnet-claim-web |
| 175 | +# to confirm the EXACT function names + signatures we should call |
| 176 | +# 2. Compute selectors offline via `cast sig` |
| 177 | +# 3. Paste real values here BEFORE the diag tool ships |
| 178 | +# The function above is structured so finishing this step is a one-line |
| 179 | +# change per selector with no algorithm risk. |
| 180 | + |
| 181 | + |
| 182 | +def encode_uint32(value: int) -> bytes: |
| 183 | + """ABI-encode a uint32 as 32 bytes (left-padded).""" |
| 184 | + if not isinstance(value, int) or value < 0 or value > 0xFFFFFFFF: |
| 185 | + raise ValueError(f"uint32 out of range: {value}") |
| 186 | + return value.to_bytes(32, byteorder="big") |
| 187 | + |
| 188 | + |
| 189 | +def encode_bytes32(hex_value: str) -> bytes: |
| 190 | + """Decode a 0x-prefixed 32-byte hex string to raw bytes for ABI.""" |
| 191 | + s = hex_value[2:] if hex_value.startswith("0x") else hex_value |
| 192 | + if len(s) != 64: |
| 193 | + raise ValueError(f"bytes32 must be 32 bytes hex; got len={len(s)}") |
| 194 | + return bytes.fromhex(s) |
| 195 | + |
| 196 | + |
| 197 | +def encode_call(selector: str, *args: bytes) -> str: |
| 198 | + """Build a `data` payload for eth_call: selector || ABI-encoded args. |
| 199 | + Returns 0x-prefixed hex string.""" |
| 200 | + sel = bytes.fromhex(selector[2:] if selector.startswith("0x") else selector) |
| 201 | + if len(sel) != 4: |
| 202 | + raise ValueError(f"selector must be 4 bytes; got {len(sel)}") |
| 203 | + return "0x" + (sel + b"".join(args)).hex() |
| 204 | + |
| 205 | + |
| 206 | +# --------------------------------------------------------------------------- |
| 207 | +# RPC client + cache |
| 208 | +# --------------------------------------------------------------------------- |
| 209 | + |
| 210 | + |
| 211 | +@dataclass |
| 212 | +class CallResult: |
| 213 | + """Tristate result of an eth_call. `value` is None when `state` is |
| 214 | + 'unknown' or 'error'; tree evaluator should branch on state, NOT |
| 215 | + on the value being None.""" |
| 216 | + state: str # 'ok' | 'unknown' | 'error' |
| 217 | + value: Any = None |
| 218 | + reason: str | None = None |
| 219 | + |
| 220 | + |
| 221 | +# Per-call cache: key = (chain, contract_address, data) → (CallResult, expires_at). |
| 222 | +# 60s TTL per gemini recommendation; troubleshoot sessions are short and |
| 223 | +# membership/online status don't change second-to-second. |
| 224 | +_CACHE_TTL_S = 60.0 |
| 225 | +_call_cache: dict[tuple, tuple[CallResult, float]] = {} |
| 226 | +_call_cache_lock = threading.Lock() |
| 227 | + |
| 228 | + |
| 229 | +def eth_call( |
| 230 | + chain: str, |
| 231 | + to_address: str, |
| 232 | + data: str, |
| 233 | + *, |
| 234 | + timeout_s: float = 2.0, |
| 235 | +) -> CallResult: |
| 236 | + """JSON-RPC eth_call against `chain`'s default RPC endpoint. |
| 237 | +
|
| 238 | + Returns a CallResult. `state == 'ok'` carries `value` as the |
| 239 | + 0x-prefixed hex response. `'unknown'` for RPC unreachable / timeout. |
| 240 | + `'error'` for chain-side revert OR malformed RPC response. |
| 241 | +
|
| 242 | + 60s in-memory cache keyed on (chain, to, data). |
| 243 | + """ |
| 244 | + if chain not in DEFAULT_RPC_URLS: |
| 245 | + return CallResult(state="error", reason=f"unknown_chain:{chain}") |
| 246 | + |
| 247 | + cache_key = (chain, to_address.lower(), data.lower()) |
| 248 | + now = time.monotonic() |
| 249 | + with _call_cache_lock: |
| 250 | + cached = _call_cache.get(cache_key) |
| 251 | + if cached and cached[1] > now: |
| 252 | + return cached[0] |
| 253 | + |
| 254 | + payload = { |
| 255 | + "jsonrpc": "2.0", |
| 256 | + "method": "eth_call", |
| 257 | + "params": [ |
| 258 | + {"to": to_address, "data": data}, |
| 259 | + "latest", |
| 260 | + ], |
| 261 | + "id": 1, |
| 262 | + } |
| 263 | + rpc_url = DEFAULT_RPC_URLS[chain] |
| 264 | + resp = http_post_json(rpc_url, payload, timeout_s=timeout_s) |
| 265 | + if resp is None: |
| 266 | + result = CallResult(state="unknown", reason="rpc_unreachable") |
| 267 | + elif "error" in resp: |
| 268 | + msg = resp["error"].get("message", "unknown_chain_error") |
| 269 | + result = CallResult(state="error", reason=str(msg)[:200]) |
| 270 | + elif "result" in resp and isinstance(resp["result"], str): |
| 271 | + result = CallResult(state="ok", value=resp["result"]) |
| 272 | + else: |
| 273 | + result = CallResult(state="error", reason="malformed_rpc_response") |
| 274 | + |
| 275 | + with _call_cache_lock: |
| 276 | + _call_cache[cache_key] = (result, now + _CACHE_TTL_S) |
| 277 | + return result |
| 278 | + |
| 279 | + |
| 280 | +def decode_bool(hex_value: str) -> bool: |
| 281 | + """Decode a 32-byte hex eth_call result as a bool (0x0...0 => false, |
| 282 | + anything else => true).""" |
| 283 | + s = hex_value[2:] if hex_value.startswith("0x") else hex_value |
| 284 | + return any(c != "0" for c in s) |
| 285 | + |
| 286 | + |
| 287 | +def clear_cache_for_tests() -> None: |
| 288 | + """Test-only: reset the call cache so cases don't leak across runs.""" |
| 289 | + with _call_cache_lock: |
| 290 | + _call_cache.clear() |
0 commit comments