Skip to content

Commit 3ceedd6

Browse files
ehsan6shaclaude
andcommitted
tools: chain.py foundation — bytes32 peerId + eth_call wrapper
Phase 0.5a partial — the foundational helpers for the upcoming diag/identity_health tool that needs to check pool membership + online status on Fula's PoolStorage + RewardEngine contracts (base + skale chains). Built: - peer_id_to_bytes32(): ported FAITHFULLY from mainnet-claim-web/app.js:peerIdToBytes32 — two branches (CIDv1 with header [0x00,0x24,0x08,0x01,0x12] takes last 32 bytes; legacy multihash with header [0x12,0x20] takes bytes[2:]). Built-in advisor caught that an earlier gemini suggestion of "always take last 32 bytes" would silently break the legacy path; this implementation matches the JS reference. - _b58_decode(): tiny base58btc decoder (~25 LoC). Avoids adding a base58 pip dep for one function. - encode_uint32() / encode_bytes32() / encode_call(): minimal ABI encoding for view-method calls. NO web3.py (its transitive deps are ~80MB and we only need eth_call). - eth_call(): JSON-RPC eth_call over the existing http_post_json helper. Tristate CallResult per advisor: * state='ok' + value = definitive answer * state='unknown' + reason='rpc_unreachable' = network failure; trees branch into the rpc-unreachable path * state='error' + reason = chain revert OR malformed RPC - 60s in-memory cache keyed on (chain, address, data) so a troubleshoot session that hits the same view method multiple times doesn't burn the RPC budget. - DEFAULT_RPC_URLS for base + skale (public endpoints; override via config.yaml is a Phase 0.6 nice-to-have). - CONTRACTS map with the user-provided addresses for PoolStorage + RewardEngine on both chains. NOT yet built (explicit TODOs in code): - FUNCTION_SELECTORS dict has placeholders. Phase 0.5b must: 1. Read mainnet-claim-web/contracts/RewardEngine.json + PoolStorage ABI to confirm the EXACT view methods we call (isMemberOfPool? isPeerOnline? signatures with which param order?) 2. Compute keccak256 selectors offline via `cast sig` or equivalent (no keccak runtime dep — selectors are static constants) 3. Paste real values in FUNCTION_SELECTORS dict Structure is set so this is a paste-the-hex change with zero algorithm risk. 27 unit tests, all passing. bytes32 algorithm pinned against the real lab device's ipfs_cluster peerId so any future "optimization" that breaks the encoding gets caught immediately. Next sprint (Phase 0.5b): diag/uniondrive + diag/identity_health using these helpers, plus the ABI selector pinning above. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fec2975 commit 3ceedd6

2 files changed

Lines changed: 528 additions & 0 deletions

File tree

src/tools/chain.py

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
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

Comments
 (0)