Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions scripts/resolver/.env
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions scripts/resolver/README.md
Original file line number Diff line number Diff line change
@@ -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`.
133 changes: 133 additions & 0 deletions scripts/resolver/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
168 changes: 168 additions & 0 deletions scripts/resolver/ens-lookup.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading