Skip to content

Commit 1950634

Browse files
committed
Merge remote-tracking branch 'origin/master' into sh/smp-namespace
2 parents 9cfdb55 + 53bc0fe commit 1950634

5 files changed

Lines changed: 612 additions & 0 deletions

File tree

scripts/resolver/.env

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Ethereum network: holesky (default test instance) or mainnet
2+
NETWORK=holesky
3+
4+
# Checkpoint sync URL — used ONCE on first sync. Must expose the heavy
5+
# /eth/v2/debug/beacon/states/finalized endpoint (most generic beacon APIs
6+
# do not — use a dedicated checkpoint-sync provider).
7+
# Community list: https://eth-clients.github.io/checkpoint-sync-endpoints/
8+
#
9+
# For mainnet, switch to one of:
10+
# https://beaconstate.info
11+
# https://sync-mainnet.beaconcha.in
12+
# https://mainnet-checkpoint-sync.attestant.io
13+
TRUSTED_NODE_URL=https://checkpoint-sync.holesky.ethpandaops.io
14+
15+
# Nimbus NAT mode. Default "any" tries UPnP/PMP/auto-detect (often fails on cloud).
16+
# For a stable public node, set explicit external IP:
17+
# NAT=extip:1.2.3.4
18+
# Find your server's public IPv4 with: curl -s ifconfig.me
19+
NAT=any

scripts/resolver/README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Ethereum stack for SMP names role
2+
3+
Reth (execution) + Nimbus (consensus) on Holesky testnet by default.
4+
5+
## Quickstart
6+
7+
```sh
8+
cd scripts/docker/reth-nimbus
9+
docker compose up -d
10+
docker compose logs -f reth nimbus
11+
```
12+
13+
Sync takes a few hours on Holesky, ~1 day on mainnet. When synced:
14+
15+
```sh
16+
curl -s -X POST http://127.0.0.1:8545 \
17+
-H 'Content-Type: application/json' \
18+
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
19+
```
20+
21+
Point smp-server: `[NAMES] ethereum_endpoint: http://127.0.0.1:8545`.
22+
23+
## How the trust bootstrap works
24+
25+
- **Reth** holds Ethereum state and runs the EVM. It does not decide which fork is canonical.
26+
- **Nimbus** follows the beacon chain and tells Reth which payloads to execute.
27+
- 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.
28+
- 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.
29+
30+
## Switching to mainnet
31+
32+
Edit `.env`:
33+
34+
```
35+
NETWORK=mainnet
36+
TRUSTED_NODE_URL=https://ethereum-beacon-api.publicnode.com
37+
```
38+
39+
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.
40+
41+
## Notes
42+
43+
- 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".
44+
- Ports 30303/9000 are p2p — open on your firewall for sync.
45+
- `jwt.hex` is generated on first run by the `jwt-init` service and shared between Reth and Nimbus via the `jwt` volume.
46+
- To wipe state and re-sync: `docker compose down -v`.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
services:
2+
# One-shot setup (runs as root): generates /jwt/jwt.hex and chowns the
3+
# nimbus-data volume to UID 1000 (the user Nimbus runs as inside its image).
4+
# Without this chown Nimbus gets "Permission denied" on its data dir
5+
# because docker creates fresh named volumes owned by root.
6+
init:
7+
image: alpine:latest
8+
volumes:
9+
- jwt:/jwt
10+
- nimbus-data:/nimbus-data
11+
command: >
12+
sh -c '
13+
set -e;
14+
if [ ! -f /jwt/jwt.hex ]; then
15+
apk add --no-cache openssl >/dev/null;
16+
openssl rand -hex 32 | tr -d "\n" > /jwt/jwt.hex;
17+
chmod 644 /jwt/jwt.hex;
18+
echo "Generated /jwt/jwt.hex";
19+
else
20+
echo "jwt.hex already exists";
21+
fi;
22+
chown 1000:1000 /nimbus-data;
23+
echo "Chowned /nimbus-data to 1000:1000";
24+
'
25+
restart: "no"
26+
27+
# One-shot: fetches a recent finalised checkpoint into the Nimbus data dir
28+
# using the trustedNodeSync subcommand. Skipped if the data dir is already
29+
# initialised, so subsequent compose-ups are no-ops.
30+
nimbus-checkpoint-sync:
31+
image: statusim/nimbus-eth2:multiarch-latest
32+
depends_on:
33+
init:
34+
condition: service_completed_successfully
35+
volumes:
36+
- nimbus-data:/home/user/nimbus-eth2/build/data
37+
entrypoint:
38+
- sh
39+
- -c
40+
- |
41+
if [ -d /home/user/nimbus-eth2/build/data/${NETWORK}/db ]; then
42+
echo "Nimbus data dir already initialised — skipping checkpoint sync";
43+
exit 0;
44+
fi;
45+
/home/user/nimbus-eth2/build/nimbus_beacon_node trustedNodeSync \
46+
--network=${NETWORK} \
47+
--data-dir=/home/user/nimbus-eth2/build/data/${NETWORK} \
48+
--trusted-node-url=${TRUSTED_NODE_URL} \
49+
--backfill=false
50+
restart: "no"
51+
52+
# One-shot: downloads a pre-synced snapshot from snapshots.reth.rs into the
53+
# Reth data dir. Turns a multi-day from-scratch sync into a ~hour download.
54+
# Skipped if the data dir is already initialised — re-runs are no-ops.
55+
# Privacy note: snapshots.reth.rs sees this download (operator existence).
56+
# Subsequent eth_call traffic stays local.
57+
reth-snapshot-init:
58+
image: ghcr.io/paradigmxyz/reth:latest
59+
depends_on:
60+
init:
61+
condition: service_completed_successfully
62+
volumes:
63+
- reth-data:/data
64+
entrypoint:
65+
- sh
66+
- -c
67+
- |
68+
if [ -f /data/.snapshot-done ] || [ -d /data/db ]; then
69+
echo "Reth data already initialised — skipping snapshot download";
70+
exit 0;
71+
fi;
72+
echo "Downloading Reth ${NETWORK} --minimal snapshot...";
73+
reth download --datadir /data --chain ${NETWORK} --minimal && \
74+
touch /data/.snapshot-done && \
75+
echo "Snapshot download complete"
76+
restart: "no"
77+
78+
reth:
79+
image: ghcr.io/paradigmxyz/reth:latest
80+
depends_on:
81+
reth-snapshot-init:
82+
condition: service_completed_successfully
83+
volumes:
84+
- reth-data:/data
85+
- jwt:/jwt:ro
86+
ports:
87+
# JSON-RPC for smp-server. Bound to loopback — put Caddy in front for remote access.
88+
- "127.0.0.1:8545:8545"
89+
# p2p (Ethereum network). Open these on your firewall for sync.
90+
- "30303:30303/tcp"
91+
- "30303:30303/udp"
92+
command: >
93+
node
94+
--datadir /data
95+
--chain ${NETWORK}
96+
--minimal
97+
--authrpc.jwtsecret /jwt/jwt.hex
98+
--authrpc.addr 0.0.0.0 --authrpc.port 8551
99+
--http
100+
--http.addr 0.0.0.0 --http.port 8545
101+
--http.api eth,net
102+
--rpc.gascap 50000000
103+
--rpc.max-response-size 5
104+
--port 30303
105+
--discovery.port 30303
106+
restart: unless-stopped
107+
108+
nimbus:
109+
image: statusim/nimbus-eth2:multiarch-latest
110+
depends_on:
111+
nimbus-checkpoint-sync:
112+
condition: service_completed_successfully
113+
volumes:
114+
- nimbus-data:/home/user/nimbus-eth2/build/data
115+
- jwt:/jwt:ro
116+
ports:
117+
- "9000:9000/tcp"
118+
- "9000:9000/udp"
119+
- "127.0.0.1:5052:5052"
120+
command: >
121+
--network=${NETWORK}
122+
--data-dir=/home/user/nimbus-eth2/build/data/${NETWORK}
123+
--el=http://reth:8551
124+
--jwt-secret=/jwt/jwt.hex
125+
--non-interactive
126+
--rest --rest-address=0.0.0.0 --rest-port=5052
127+
--nat=${NAT:-any}
128+
restart: unless-stopped
129+
130+
volumes:
131+
reth-data:
132+
nimbus-data:
133+
jwt:

scripts/resolver/ens-lookup.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
#!/usr/bin/env python3
2+
"""Resolve an ENS name via local Reth (the same shape SNRC will use).
3+
4+
Usage:
5+
./ens-lookup.py # defaults to simplexchat.eth
6+
./ens-lookup.py vitalik.eth
7+
./ens-lookup.py corevo.eth
8+
9+
Requires: pip install --break-system-packages 'eth-hash[pycryptodome]'
10+
"""
11+
12+
import base64
13+
import json
14+
import sys
15+
from urllib.request import Request, urlopen
16+
17+
from eth_hash.auto import keccak
18+
19+
RPC = "http://127.0.0.1:8545"
20+
# ENS Registry (current, post-2020 migration)
21+
ENS_REGISTRY = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"
22+
23+
24+
def rpc(method, params):
25+
body = json.dumps({"jsonrpc": "2.0", "method": method, "params": params, "id": 1}).encode()
26+
req = Request(RPC, data=body, headers={"Content-Type": "application/json"})
27+
res = json.loads(urlopen(req, timeout=15).read())
28+
if "error" in res:
29+
raise RuntimeError(res["error"])
30+
return res["result"]
31+
32+
33+
def namehash(name: str) -> bytes:
34+
"""ENS namehash — recursive keccak256 over reversed labels."""
35+
node = b"\x00" * 32
36+
if name:
37+
for label in reversed(name.split(".")):
38+
node = keccak(node + keccak(label.encode()))
39+
return node
40+
41+
42+
def selector(signature: str) -> str:
43+
return "0x" + keccak(signature.encode())[:4].hex()
44+
45+
46+
def eth_call(to: str, data: str) -> str:
47+
return rpc("eth_call", [{"to": to, "data": data}, "latest"])
48+
49+
50+
def decode_address(hex_data: str) -> str:
51+
return "0x" + hex_data[-40:]
52+
53+
54+
def decode_bytes(hex_data: str) -> bytes:
55+
raw = bytes.fromhex(hex_data[2:] if hex_data.startswith("0x") else hex_data)
56+
if len(raw) < 64:
57+
return b""
58+
length = int.from_bytes(raw[32:64], "big")
59+
return raw[64:64 + length]
60+
61+
62+
def encode_text_call(node: bytes, key: str) -> str:
63+
"""ABI-encode text(bytes32 node, string key). String arg is dynamic:
64+
offset (=0x40) + length + right-padded data."""
65+
sel = selector("text(bytes32,string)")
66+
head = node.hex() + (0x40).to_bytes(32, "big").hex()
67+
key_bytes = key.encode()
68+
body = len(key_bytes).to_bytes(32, "big").hex() + key_bytes.hex()
69+
# right-pad to 32-byte boundary
70+
pad = (-len(key_bytes)) % 32
71+
body += "00" * pad
72+
return sel + head + body
73+
74+
75+
def text(resolver: str, node: bytes, key: str) -> str:
76+
raw = decode_bytes(eth_call(resolver, encode_text_call(node, key)))
77+
return raw.decode("utf-8", errors="replace") if raw else ""
78+
79+
80+
# Common ENS text keys (ENSIP-5). Resolvers may return empty for any of these.
81+
TEXT_KEYS = [
82+
"url",
83+
"avatar",
84+
"description",
85+
"email",
86+
"notice",
87+
"keywords",
88+
"com.twitter",
89+
"com.github",
90+
"com.discord",
91+
"org.telegram",
92+
"io.keybase",
93+
"xyz.farcaster",
94+
]
95+
96+
97+
def decode_contenthash(raw: bytes) -> str:
98+
"""ENS contenthash → human-readable URI (best-effort)."""
99+
if not raw:
100+
return "(empty)"
101+
# Multicodec prefixes:
102+
# 0xe301 = ipfs-ns + dag-pb (CIDv0/v1)
103+
# 0xe501 = ipns-ns
104+
# 0xe40101701b... = swarm
105+
if raw[:2] == b"\xe3\x01":
106+
cid_bytes = raw[2:]
107+
# Base32 lowercase + 'b' prefix per CIDv1 spec
108+
b32 = base64.b32encode(cid_bytes).decode().lower().rstrip("=")
109+
return f"ipfs://b{b32}"
110+
if raw[:2] == b"\xe5\x01":
111+
cid_bytes = raw[2:]
112+
b32 = base64.b32encode(cid_bytes).decode().lower().rstrip("=")
113+
return f"ipns://b{b32}"
114+
return "0x" + raw.hex()
115+
116+
117+
def main():
118+
name = sys.argv[1] if len(sys.argv) > 1 else "simplexchat.eth"
119+
120+
print(f" name: {name}")
121+
node = namehash(name)
122+
print(f" namehash: 0x{node.hex()}")
123+
124+
# 1. Ask the registry which resolver is responsible for this name
125+
resolver_data = selector("resolver(bytes32)") + node.hex()
126+
resolver_raw = eth_call(ENS_REGISTRY, resolver_data)
127+
resolver = decode_address(resolver_raw)
128+
print(f" resolver: {resolver}")
129+
if resolver == "0x0000000000000000000000000000000000000000":
130+
print(" → no resolver set for this name")
131+
return
132+
133+
node_hex = node.hex()
134+
135+
# 2. Ask the resolver for the address
136+
try:
137+
addr = decode_address(eth_call(resolver, selector("addr(bytes32)") + node_hex))
138+
print(f" address: {addr}")
139+
except Exception as e:
140+
print(f" address: (error: {e})")
141+
142+
# 3. Ask the resolver for the content hash (IPFS pointer)
143+
try:
144+
ch = decode_bytes(eth_call(resolver, selector("contenthash(bytes32)") + node_hex))
145+
print(f" contenthash: {decode_contenthash(ch)}")
146+
except Exception as e:
147+
print(f" contenthash: (not supported: {e})")
148+
149+
# 4. Owner from the registry
150+
try:
151+
owner = decode_address(eth_call(ENS_REGISTRY, selector("owner(bytes32)") + node_hex))
152+
print(f" owner: {owner}")
153+
except Exception as e:
154+
print(f" owner: (error: {e})")
155+
156+
# 5. Text records (EIP-634). Print only the non-empty ones.
157+
print(" text records:")
158+
for key in TEXT_KEYS:
159+
try:
160+
v = text(resolver, node, key)
161+
if v:
162+
print(f" {key:<16s} {v}")
163+
except Exception as e:
164+
print(f" {key:<16s} (error: {e})")
165+
166+
167+
if __name__ == "__main__":
168+
main()

0 commit comments

Comments
 (0)