From 53bc0fe66390ab8b6500505303efa8d1363ea588 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:24:56 +0000 Subject: [PATCH] scripts: add docker-compose resolver setup (#1793) --- scripts/resolver/.env | 19 +++ scripts/resolver/README.md | 46 ++++++ scripts/resolver/docker-compose.yml | 133 +++++++++++++++ scripts/resolver/ens-lookup.py | 168 +++++++++++++++++++ scripts/resolver/progress.py | 246 ++++++++++++++++++++++++++++ 5 files changed, 612 insertions(+) create mode 100644 scripts/resolver/.env create mode 100644 scripts/resolver/README.md create mode 100644 scripts/resolver/docker-compose.yml create mode 100755 scripts/resolver/ens-lookup.py create mode 100755 scripts/resolver/progress.py diff --git a/scripts/resolver/.env b/scripts/resolver/.env new file mode 100644 index 000000000..f36893b87 --- /dev/null +++ b/scripts/resolver/.env @@ -0,0 +1,19 @@ +# Ethereum network: holesky (default test instance) or mainnet +NETWORK=holesky + +# Checkpoint sync URL — used ONCE on first sync. Must expose the heavy +# /eth/v2/debug/beacon/states/finalized endpoint (most generic beacon APIs +# do not — use a dedicated checkpoint-sync provider). +# Community list: https://eth-clients.github.io/checkpoint-sync-endpoints/ +# +# For mainnet, switch to one of: +# https://beaconstate.info +# https://sync-mainnet.beaconcha.in +# https://mainnet-checkpoint-sync.attestant.io +TRUSTED_NODE_URL=https://checkpoint-sync.holesky.ethpandaops.io + +# Nimbus NAT mode. Default "any" tries UPnP/PMP/auto-detect (often fails on cloud). +# For a stable public node, set explicit external IP: +# NAT=extip:1.2.3.4 +# Find your server's public IPv4 with: curl -s ifconfig.me +NAT=any diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md new file mode 100644 index 000000000..4007479d2 --- /dev/null +++ b/scripts/resolver/README.md @@ -0,0 +1,46 @@ +# Ethereum stack for SMP names role + +Reth (execution) + Nimbus (consensus) on Holesky testnet by default. + +## Quickstart + +```sh +cd scripts/docker/reth-nimbus +docker compose up -d +docker compose logs -f reth nimbus +``` + +Sync takes a few hours on Holesky, ~1 day on mainnet. When synced: + +```sh +curl -s -X POST http://127.0.0.1:8545 \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' +``` + +Point smp-server: `[NAMES] ethereum_endpoint: http://127.0.0.1:8545`. + +## How the trust bootstrap works + +- **Reth** holds Ethereum state and runs the EVM. It does not decide which fork is canonical. +- **Nimbus** follows the beacon chain and tells Reth which payloads to execute. +- Nimbus needs **one trusted starting point** to break the chicken-and-egg of peer-claims. `--trusted-node-url` fetches that checkpoint once from a public beacon API; from that point on every block is verified locally against the validator set. +- The default `TRUSTED_NODE_URL` is publicnode.com (no API key, no rate limits). Replace with any beacon API you trust — only consulted once on first sync. + +## Switching to mainnet + +Edit `.env`: + +``` +NETWORK=mainnet +TRUSTED_NODE_URL=https://ethereum-beacon-api.publicnode.com +``` + +Then `docker compose down -v && docker compose up -d` (the `-v` wipes state so Nimbus re-bootstraps against the new network). Reth on mainnet needs ~260 GB pruned NVMe. + +## Notes + +- Reth's RPC is bound to `127.0.0.1:8545` only. For remote access (multiple smp-server hosts → one Reth), put Caddy + Let's Encrypt + Basic auth in front — see `plans/20260522_01_smp_public_namespaces.md` §"Operator deployment". +- Ports 30303/9000 are p2p — open on your firewall for sync. +- `jwt.hex` is generated on first run by the `jwt-init` service and shared between Reth and Nimbus via the `jwt` volume. +- To wipe state and re-sync: `docker compose down -v`. diff --git a/scripts/resolver/docker-compose.yml b/scripts/resolver/docker-compose.yml new file mode 100644 index 000000000..15fee9029 --- /dev/null +++ b/scripts/resolver/docker-compose.yml @@ -0,0 +1,133 @@ +services: + # One-shot setup (runs as root): generates /jwt/jwt.hex and chowns the + # nimbus-data volume to UID 1000 (the user Nimbus runs as inside its image). + # Without this chown Nimbus gets "Permission denied" on its data dir + # because docker creates fresh named volumes owned by root. + init: + image: alpine:latest + volumes: + - jwt:/jwt + - nimbus-data:/nimbus-data + command: > + sh -c ' + set -e; + if [ ! -f /jwt/jwt.hex ]; then + apk add --no-cache openssl >/dev/null; + openssl rand -hex 32 | tr -d "\n" > /jwt/jwt.hex; + chmod 644 /jwt/jwt.hex; + echo "Generated /jwt/jwt.hex"; + else + echo "jwt.hex already exists"; + fi; + chown 1000:1000 /nimbus-data; + echo "Chowned /nimbus-data to 1000:1000"; + ' + restart: "no" + + # One-shot: fetches a recent finalised checkpoint into the Nimbus data dir + # using the trustedNodeSync subcommand. Skipped if the data dir is already + # initialised, so subsequent compose-ups are no-ops. + nimbus-checkpoint-sync: + image: statusim/nimbus-eth2:multiarch-latest + depends_on: + init: + condition: service_completed_successfully + volumes: + - nimbus-data:/home/user/nimbus-eth2/build/data + entrypoint: + - sh + - -c + - | + if [ -d /home/user/nimbus-eth2/build/data/${NETWORK}/db ]; then + echo "Nimbus data dir already initialised — skipping checkpoint sync"; + exit 0; + fi; + /home/user/nimbus-eth2/build/nimbus_beacon_node trustedNodeSync \ + --network=${NETWORK} \ + --data-dir=/home/user/nimbus-eth2/build/data/${NETWORK} \ + --trusted-node-url=${TRUSTED_NODE_URL} \ + --backfill=false + restart: "no" + + # One-shot: downloads a pre-synced snapshot from snapshots.reth.rs into the + # Reth data dir. Turns a multi-day from-scratch sync into a ~hour download. + # Skipped if the data dir is already initialised — re-runs are no-ops. + # Privacy note: snapshots.reth.rs sees this download (operator existence). + # Subsequent eth_call traffic stays local. + reth-snapshot-init: + image: ghcr.io/paradigmxyz/reth:latest + depends_on: + init: + condition: service_completed_successfully + volumes: + - reth-data:/data + entrypoint: + - sh + - -c + - | + if [ -f /data/.snapshot-done ] || [ -d /data/db ]; then + echo "Reth data already initialised — skipping snapshot download"; + exit 0; + fi; + echo "Downloading Reth ${NETWORK} --minimal snapshot..."; + reth download --datadir /data --chain ${NETWORK} --minimal && \ + touch /data/.snapshot-done && \ + echo "Snapshot download complete" + restart: "no" + + reth: + image: ghcr.io/paradigmxyz/reth:latest + depends_on: + reth-snapshot-init: + condition: service_completed_successfully + volumes: + - reth-data:/data + - jwt:/jwt:ro + ports: + # JSON-RPC for smp-server. Bound to loopback — put Caddy in front for remote access. + - "127.0.0.1:8545:8545" + # p2p (Ethereum network). Open these on your firewall for sync. + - "30303:30303/tcp" + - "30303:30303/udp" + command: > + node + --datadir /data + --chain ${NETWORK} + --minimal + --authrpc.jwtsecret /jwt/jwt.hex + --authrpc.addr 0.0.0.0 --authrpc.port 8551 + --http + --http.addr 0.0.0.0 --http.port 8545 + --http.api eth,net + --rpc.gascap 50000000 + --rpc.max-response-size 5 + --port 30303 + --discovery.port 30303 + restart: unless-stopped + + nimbus: + image: statusim/nimbus-eth2:multiarch-latest + depends_on: + nimbus-checkpoint-sync: + condition: service_completed_successfully + volumes: + - nimbus-data:/home/user/nimbus-eth2/build/data + - jwt:/jwt:ro + ports: + - "9000:9000/tcp" + - "9000:9000/udp" + - "127.0.0.1:5052:5052" + command: > + --network=${NETWORK} + --data-dir=/home/user/nimbus-eth2/build/data/${NETWORK} + --el=http://reth:8551 + --jwt-secret=/jwt/jwt.hex + --non-interactive + --rest --rest-address=0.0.0.0 --rest-port=5052 + --nat=${NAT:-any} + restart: unless-stopped + +volumes: + reth-data: + nimbus-data: + jwt: diff --git a/scripts/resolver/ens-lookup.py b/scripts/resolver/ens-lookup.py new file mode 100755 index 000000000..c3da165ba --- /dev/null +++ b/scripts/resolver/ens-lookup.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Resolve an ENS name via local Reth (the same shape SNRC will use). + +Usage: + ./ens-lookup.py # defaults to simplexchat.eth + ./ens-lookup.py vitalik.eth + ./ens-lookup.py corevo.eth + +Requires: pip install --break-system-packages 'eth-hash[pycryptodome]' +""" + +import base64 +import json +import sys +from urllib.request import Request, urlopen + +from eth_hash.auto import keccak + +RPC = "http://127.0.0.1:8545" +# ENS Registry (current, post-2020 migration) +ENS_REGISTRY = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" + + +def rpc(method, params): + body = json.dumps({"jsonrpc": "2.0", "method": method, "params": params, "id": 1}).encode() + req = Request(RPC, data=body, headers={"Content-Type": "application/json"}) + res = json.loads(urlopen(req, timeout=15).read()) + if "error" in res: + raise RuntimeError(res["error"]) + return res["result"] + + +def namehash(name: str) -> bytes: + """ENS namehash — recursive keccak256 over reversed labels.""" + node = b"\x00" * 32 + if name: + for label in reversed(name.split(".")): + node = keccak(node + keccak(label.encode())) + return node + + +def selector(signature: str) -> str: + return "0x" + keccak(signature.encode())[:4].hex() + + +def eth_call(to: str, data: str) -> str: + return rpc("eth_call", [{"to": to, "data": data}, "latest"]) + + +def decode_address(hex_data: str) -> str: + return "0x" + hex_data[-40:] + + +def decode_bytes(hex_data: str) -> bytes: + raw = bytes.fromhex(hex_data[2:] if hex_data.startswith("0x") else hex_data) + if len(raw) < 64: + return b"" + length = int.from_bytes(raw[32:64], "big") + return raw[64:64 + length] + + +def encode_text_call(node: bytes, key: str) -> str: + """ABI-encode text(bytes32 node, string key). String arg is dynamic: + offset (=0x40) + length + right-padded data.""" + sel = selector("text(bytes32,string)") + head = node.hex() + (0x40).to_bytes(32, "big").hex() + key_bytes = key.encode() + body = len(key_bytes).to_bytes(32, "big").hex() + key_bytes.hex() + # right-pad to 32-byte boundary + pad = (-len(key_bytes)) % 32 + body += "00" * pad + return sel + head + body + + +def text(resolver: str, node: bytes, key: str) -> str: + raw = decode_bytes(eth_call(resolver, encode_text_call(node, key))) + return raw.decode("utf-8", errors="replace") if raw else "" + + +# Common ENS text keys (ENSIP-5). Resolvers may return empty for any of these. +TEXT_KEYS = [ + "url", + "avatar", + "description", + "email", + "notice", + "keywords", + "com.twitter", + "com.github", + "com.discord", + "org.telegram", + "io.keybase", + "xyz.farcaster", +] + + +def decode_contenthash(raw: bytes) -> str: + """ENS contenthash → human-readable URI (best-effort).""" + if not raw: + return "(empty)" + # Multicodec prefixes: + # 0xe301 = ipfs-ns + dag-pb (CIDv0/v1) + # 0xe501 = ipns-ns + # 0xe40101701b... = swarm + if raw[:2] == b"\xe3\x01": + cid_bytes = raw[2:] + # Base32 lowercase + 'b' prefix per CIDv1 spec + b32 = base64.b32encode(cid_bytes).decode().lower().rstrip("=") + return f"ipfs://b{b32}" + if raw[:2] == b"\xe5\x01": + cid_bytes = raw[2:] + b32 = base64.b32encode(cid_bytes).decode().lower().rstrip("=") + return f"ipns://b{b32}" + return "0x" + raw.hex() + + +def main(): + name = sys.argv[1] if len(sys.argv) > 1 else "simplexchat.eth" + + print(f" name: {name}") + node = namehash(name) + print(f" namehash: 0x{node.hex()}") + + # 1. Ask the registry which resolver is responsible for this name + resolver_data = selector("resolver(bytes32)") + node.hex() + resolver_raw = eth_call(ENS_REGISTRY, resolver_data) + resolver = decode_address(resolver_raw) + print(f" resolver: {resolver}") + if resolver == "0x0000000000000000000000000000000000000000": + print(" → no resolver set for this name") + return + + node_hex = node.hex() + + # 2. Ask the resolver for the address + try: + addr = decode_address(eth_call(resolver, selector("addr(bytes32)") + node_hex)) + print(f" address: {addr}") + except Exception as e: + print(f" address: (error: {e})") + + # 3. Ask the resolver for the content hash (IPFS pointer) + try: + ch = decode_bytes(eth_call(resolver, selector("contenthash(bytes32)") + node_hex)) + print(f" contenthash: {decode_contenthash(ch)}") + except Exception as e: + print(f" contenthash: (not supported: {e})") + + # 4. Owner from the registry + try: + owner = decode_address(eth_call(ENS_REGISTRY, selector("owner(bytes32)") + node_hex)) + print(f" owner: {owner}") + except Exception as e: + print(f" owner: (error: {e})") + + # 5. Text records (EIP-634). Print only the non-empty ones. + print(" text records:") + for key in TEXT_KEYS: + try: + v = text(resolver, node, key) + if v: + print(f" {key:<16s} {v}") + except Exception as e: + print(f" {key:<16s} (error: {e})") + + +if __name__ == "__main__": + main() diff --git a/scripts/resolver/progress.py b/scripts/resolver/progress.py new file mode 100755 index 000000000..4178cb4ca --- /dev/null +++ b/scripts/resolver/progress.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Sync progress for the Reth + Nimbus stack. + +Usage: + ./progress.py # continuous (Ctrl-C to exit, auto-exits when synced) + ./progress.py --once # single snapshot + +Requires Nimbus REST port exposed at 127.0.0.1:5052 (add --rest flag in compose). +""" + +import json +import sys +import time +from collections import deque +from datetime import timedelta +from urllib.error import URLError +from urllib.request import Request, urlopen + +RETH = "http://127.0.0.1:8545" +NIMBUS = "http://127.0.0.1:5052" +INTERVAL = 5 +WINDOW = 60 +BAR_W = 40 + +# ANSI helpers +def c(s, code): return f"\033[{code}m{s}\033[0m" +GREEN, YELLOW, RED, DIM, BOLD = "32", "33", "31", "2;37", "1" + + +def rpc(method): + body = json.dumps({"jsonrpc": "2.0", "method": method, "params": [], "id": 1}).encode() + req = Request(RETH, data=body, headers={"Content-Type": "application/json"}) + return json.loads(urlopen(req, timeout=5).read())["result"] + + +def get_reth(): + try: + r = rpc("eth_syncing") + try: + peers = int(rpc("net_peerCount"), 16) + except Exception: + peers = -1 # net namespace not exposed + if r is False: + head = int(rpc("eth_blockNumber"), 16) + return {"state": "synced", "current": head, "target": head, "peers": peers, + "stage": None, "stages": {}, "err": None} + current = int(r["currentBlock"], 16) + highest = int(r["highestBlock"], 16) + # Build stage map (name -> block). + stages = {s["name"]: int(s["block"], 16) for s in r.get("stages", [])} + active_stages = {k: v for k, v in stages.items() if v > 0} + # Headers download phase: nothing has progressed yet. + if current == 0 and highest == 0 and not active_stages: + return {"state": "headers", "current": 0, "target": 0, "peers": peers, + "stage": "Headers", "stages": stages, "err": None} + # Derive progress from the stages pipeline. + # Bottleneck (rate-limiting stage) = stage with lowest non-zero block. + # Target = leading stage block (typically Headers = chain tip). + # Reth's top-level currentBlock/highestBlock are unreliable during initial + # sync (often 0 until execution stage runs), so prefer stages-derived values. + if active_stages: + bottleneck = min(active_stages, key=active_stages.get) + stage_current = active_stages[bottleneck] + stage_target = max(stages.values()) if stages else 0 + # Trust the stages-derived values if highest is unset or stages tip is higher. + if highest <= 0 or stage_target > highest: + current = stage_current + highest = stage_target + elif current <= 0: + current = stage_current + else: + bottleneck = None + return {"state": "syncing", "current": current, "target": highest, + "peers": peers, "stage": bottleneck, "stages": stages, "err": None} + except URLError as e: + return {"state": "down", "current": 0, "target": 0, "peers": 0, + "stage": None, "stages": {}, "err": str(e.reason)} + except Exception as e: + return {"state": "error", "current": 0, "target": 0, "peers": 0, + "stage": None, "stages": {}, "err": str(e)} + + +def get_nimbus(): + try: + d = json.loads(urlopen(f"{NIMBUS}/eth/v1/node/syncing", timeout=5).read())["data"] + peers_d = json.loads(urlopen(f"{NIMBUS}/eth/v1/node/peer_count", timeout=5).read())["data"] + head = int(d["head_slot"]) + dist = int(d["sync_distance"]) + peers = int(peers_d.get("connected", "0")) + return {"state": "synced" if not d["is_syncing"] else "syncing", + "current": head, "target": head + dist, "peers": peers, + "optimistic": bool(d.get("is_optimistic", False)), + "el_offline": bool(d.get("el_offline", False)), + "err": None} + except URLError as e: + return {"state": "down", "current": 0, "target": 0, "peers": 0, + "optimistic": False, "el_offline": False, "err": str(e.reason)} + except Exception as e: + return {"state": "error", "current": 0, "target": 0, "peers": 0, + "optimistic": False, "el_offline": False, "err": str(e)} + + +def format_num(n): return f"{n:,}" + + +def format_eta(seconds): + if seconds is None: return "?" + if seconds < 0: return "?" + if seconds < 60: return f"{int(seconds)}s" + if seconds < 3600: + return f"{int(seconds // 60)}m {int(seconds % 60)}s" + if seconds < 86400: + return f"{int(seconds // 3600)}h {int((seconds % 3600) // 60)}m" + return f"{int(seconds // 86400)}d {int((seconds % 86400) // 3600)}h" + + +def rate_per_sec(history): + if len(history) < 2: return None + t0, c0 = history[0] + t1, c1 = history[-1] + if t1 <= t0: return None + return (c1 - c0) / (t1 - t0) + + +def eta_seconds(history, target): + r = rate_per_sec(history) + if r is None or r <= 0: return None + remaining = target - history[-1][1] + if remaining <= 0: return 0 + return remaining / r + + +def progress_bar(pct): + pct = max(0.0, min(100.0, pct)) + filled = int(pct / 100 * BAR_W) + return c("█" * filled, GREEN) + c("░" * (BAR_W - filled), DIM) + + +def peers_label(peers): + if peers < 0: + return c("· peers unknown (enable net namespace)", DIM) + return c(f"· {peers} peers", DIM) + + +def stages_summary(stages): + """One-line view: stages that have progressed, with their block numbers.""" + if not stages: + return "" + advanced = [(n, b) for n, b in stages.items() if b > 0] + if not advanced: + return c(" stages: all 0 (headers downloading)", DIM) + advanced.sort(key=lambda kv: kv[1], reverse=True) + parts = [f"{n}={format_num(b)}" for n, b in advanced[:4]] + return c(" stages: " + ", ".join(parts), DIM) + + +def render_one(name, x, hist): + state = x["state"] + peers = x.get("peers", 0) + extras = [] + if name == "Nimbus": + if x.get("optimistic"): + extras.append(c("(optimistic head — Reth not yet verifying)", YELLOW)) + if x.get("el_offline"): + extras.append(c("⚠ EL OFFLINE", RED)) + if state == "synced": + out = [f" {c(name, BOLD):<14s} {c('✓ synced', GREEN)} {c(format_num(x['current']), BOLD)} {peers_label(peers)}"] + elif state == "headers": + out = [ + f" {c(name, BOLD):<14s} {c('⧗ headers', YELLOW)} {c('downloading initial chain', DIM)} {peers_label(peers)}", + f" {c('(per-block progress unavailable until headers validated — see docker logs)', DIM)}", + ] + elif state == "syncing" and x["target"] <= 0: + out = [f" {c(name, BOLD):<14s} {c('⧗ syncing', YELLOW)} {c('waiting for fork-choice', DIM)} {peers_label(peers)}"] + elif state == "syncing": + pct = x["current"] / x["target"] * 100 + r = rate_per_sec(hist) + eta = eta_seconds(hist, x["target"]) + rate_s = f"{format_num(int(r))} /s" if r and r > 0 else c("stalled", RED) + eta_s = format_eta(eta) if eta is not None else "?" + stage = x.get("stage") + stage_s = c(f"[{stage}]", DIM) if stage else "" + out = [ + f" {c(name, BOLD):<14s} {c('⧗ syncing', YELLOW)} {format_num(x['current'])} / {format_num(x['target'])} {stage_s} {peers_label(peers)}", + f" {progress_bar(pct)} {c(f'{pct:6.2f}%', BOLD)}", + f" {c(rate_s, DIM)} ETA {c(eta_s, BOLD)}", + ] + else: + out = [ + f" {c(name, BOLD):<14s} {c('✗ ' + state, RED)}", + f" {c(x.get('err') or '', DIM)}", + ] + # Reth-only: stages summary + if name == "Reth" and x.get("stages"): + out.append(f" {stages_summary(x['stages'])}") + for e in extras: + out.append(f" {e}") + return out + + +def render(reth, nimbus, reth_hist, nim_hist): + print("\033[2J\033[H", end="") + width = 64 + title = f"Reth + Nimbus sync" + ts = time.strftime("%H:%M:%S") + print() + print(f" {c(title, BOLD)} {c(ts, DIM)}") + print(f" {c('─' * width, DIM)}") + print() + for line in render_one("Reth", reth, reth_hist): + print(line) + print() + for line in render_one("Nimbus", nimbus, nim_hist): + print(line) + print() + win_s = (len(reth_hist) - 1) * INTERVAL if len(reth_hist) > 1 else 0 + print(f" {c(f'window {win_s}s · refresh {INTERVAL}s · Ctrl-C to exit', DIM)}") + print() + + +def main(): + once = "--once" in sys.argv + reth_hist = deque(maxlen=WINDOW) + nim_hist = deque(maxlen=WINDOW) + try: + while True: + r = get_reth() + n = get_nimbus() + now = time.time() + if r["target"] > 0 or r["state"] == "syncing": + reth_hist.append((now, r["current"])) + if n["target"] > 0 or n["state"] == "syncing": + nim_hist.append((now, n["current"])) + render(r, n, reth_hist, nim_hist) + if once: + break + if r["state"] == "synced" and n["state"] == "synced": + print(f" {c('✓ all synced.', GREEN)}\n") + break + time.sleep(INTERVAL) + except KeyboardInterrupt: + print() + + +if __name__ == "__main__": + main()