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
37 changes: 20 additions & 17 deletions scripts/resolver/.env
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
# Ethereum network: holesky (default test instance) or mainnet
NETWORK=holesky
# ============================================================================
# Required settings — the stack will not start without these.
# ============================================================================

# 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
# Ethereum network: mainnet (the SNRC `.testing` contracts live on mainnet)
# or holesky (test). Mainnet full sync needs ~1 day and ~1.2 TB NVMe.
NETWORK=mainnet

# 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
# Beacon checkpoint-sync URL — used ONCE on first sync. Must expose the heavy
# /eth/v2/debug/beacon/states/finalized endpoint (generic beacon APIs do not;
# use a dedicated checkpoint provider). List: https://eth-clients.github.io/checkpoint-sync-endpoints/
# mainnet: https://mainnet-checkpoint-sync.attestant.io (also beaconstate.info, sync-mainnet.beaconcha.in)
# holesky: https://checkpoint-sync.holesky.ethpandaops.io
TRUSTED_NODE_URL=https://mainnet-checkpoint-sync.attestant.io

# ============================================================================
# Optional overrides — sensible defaults are baked into docker-compose.yml,
# so leave these commented unless you need to change them.
# ============================================================================

# Nimbus NAT (default: any). For a stable public node set an explicit IP:
# NAT=extip:1.2.3.4 # your public IPv4: curl -s ifconfig.me
277 changes: 94 additions & 183 deletions scripts/resolver/README.md
Original file line number Diff line number Diff line change
@@ -1,235 +1,146 @@
# Ethereum stack for SMP names role
# Self-hosted SNRC stack

Reth (execution) + Nimbus (consensus) on Holesky testnet by default.
One `docker compose up` runs the self-hosted SimpleX Namespace (SNRC) backend
against **Ethereum mainnet** (where the `.testing` contracts live):

## Quickstart
| # | Component | What it does |
|---|---|---|
| 1 | **reth + nimbus** | self-hosted Ethereum node (`--minimal` — enough for the resolver's `eth_call` at chain head) |
| 2 | **resolver** | the REST resolver the smp-server's `[NAMES]` role queries (`snrc-resolve.py`) |

```sh
cd scripts/docker/reth-nimbus
docker compose up -d
docker compose logs -f reth nimbus
```
## Requirements

Sync takes a few hours on Holesky, ~1 day on mainnet. When synced:
- **Docker** + Compose v2.
- **≥ 300 GB NVMe SSD** for `reth --minimal` (~260 GB on mainnet; TLC, not QLC
— QLC stalls during sync) + **32 GB RAM**, fast multi-core CPU.
- **~1 day** for the initial reth sync. The resolver returns errors until reth
has caught up — that's expected.
- Firewall: open p2p ports `30303` (tcp/udp) and `9000` (tcp/udp).

```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}'
```
## 1. Configure

Point smp-server: `[NAMES] ethereum_endpoint: http://127.0.0.1:8545`.
Edit `.env` — the defaults work as-is; override only if needed:

## 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
```sh
NETWORK=mainnet # default
TRUSTED_NODE_URL=https://mainnet-checkpoint-sync.attestant.io # default
```

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`.

## SNRC resolver REST API (`snrc-resolve.py`)

The companion script `snrc-resolve.py` exposes the SimpleX Namespace
Registry (SNRC) over a small JSON HTTP API. It talks to the same local
Reth + Nimbus stack described above (set `NETWORK=mainnet` in `.env`),
reading the SNRC contracts directly on Ethereum mainnet.
Everything else (NAT) has a working default baked into `docker-compose.yml`;
uncomment the hints in `.env` only to override.

Dependencies are declared inline (PEP 723) at the top of `snrc-resolve.py`
and in a sibling `pyproject.toml`. The simplest local run uses
[`uv`](https://docs.astral.sh/uv/):
## 2. Run

```sh
uv run scripts/resolver/snrc-resolve.py
cd scripts/resolver
docker compose up -d
docker compose logs -f reth resolver
```

`uv` resolves and caches `eth-hash[pycryptodome]` on first run. No
virtualenv juggling, no `--break-system-packages`. If you'd rather
manage Python deps yourself:
`depends_on` handles ordering automatically (start node → start resolver).

## 3. Wait for the node to sync

```sh
pip install 'eth-hash[pycryptodome]>=0.7'
python scripts/resolver/snrc-resolve.py
docker compose logs --tail=20 reth
```

### Deployed registries

| TLD | Network | ENSRegistry address |
|------------|------------------|----------------------------------------------|
| `.testing` | Ethereum mainnet | `0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6` |
| `.simplex` | — (not deployed) | — |
This is the long pole (~1 day on mainnet). Until reth is synced the resolver
returns `502`.

Each TLD is an independent ENS-shaped deployment with its own
`ENSRegistry`. The resolver dispatches by the queried name's rightmost
label, so a single instance can serve both TLDs concurrently once
`.simplex` launches.
## Verify

### Running

With Reth bound to `127.0.0.1:8545` (the default Quickstart layout
above), no env vars are required — the script defaults to that RPC and
to the mainnet `.testing` registry:
Run these once the stack is up (the node-dependent ones pass after sync):

**1. reth is reachable and reporting a block:**
```sh
./scripts/resolver/snrc-resolve.py
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}' | jq
```

Output on startup:

**2. resolver is healthy:**
```sh
curl -s http://127.0.0.1:8000/health | jq
# → {"ok": true, "rpc": "http://reth:8545", "registries": {"testing": "0x…", "simplex": ""}}
```
snrc-resolve listening on 0.0.0.0:8000
RPC = http://127.0.0.1:8545
Registries:
.testing = 0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6
.simplex = (not configured)
GET /resolve/<name> GET /health

**3. resolver resolves a live name** (`foobar.testing` is a populated test name):
```sh
curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq
# → {"name":"foobar.testing","nickname":"Foo","simplexContact":["https://smp16.simplex.im/a#…"], … }
```

Override the listen port or bind address with `SNRC_PORT` / `SNRC_BIND`.
**Wire your smp-server:** in its `[NAMES]` section set
`resolver_endpoint: http://127.0.0.1:8000` (no auth needed for loopback).

### Running in Docker
## Ports (all loopback unless noted)

The compose file ships a `resolver` service alongside reth and nimbus.
`docker compose up -d` builds the image from `Dockerfile` (multi-stage,
non-root, `uv`-based) and exposes the API on `127.0.0.1:8000`:
| Service | Host | Purpose |
|---|---|---|
| reth JSON-RPC | `127.0.0.1:8545` | smp-server RPC |
| reth p2p | `:30303` tcp/udp | Ethereum sync (open on firewall) |
| nimbus p2p | `:9000` tcp/udp | beacon sync (open on firewall) |
| nimbus REST | `127.0.0.1:5052` | beacon API |
| **resolver** | `127.0.0.1:8000` | SNRC REST (`/resolve`, `/health`) |

```sh
docker compose up -d resolver
docker compose logs -f resolver
curl -s http://127.0.0.1:8000/health
```
## Caveats

The container points `SNRC_RPC` at `http://reth:8545` (the compose-internal
DNS name) so the resolver and reth share the bridge network without
exposing reth's RPC to the host beyond loopback.
- **All images track `:latest`** (reth, nimbus) — you get upstream fixes on each
`docker compose pull`; re-run the verify checks after pulling.
- All ports bind to loopback; expose only what you put behind a TLS reverse proxy.

To change the host-side port, edit the LEFT side of the port mapping in
`docker-compose.yml`:
## Teardown

```yaml
resolver:
ports:
- "127.0.0.1:8000:8000" # host:container
```sh
docker compose down # stop, keep all state
docker compose down -v # also wipe volumes → full re-sync
```

The registry address defaults to mainnet `.testing` — to override (Holesky,
a private deployment, or future `.simplex`), uncomment and set the values
in `docker-compose.yml` under the resolver service's `environment:` block.
`down -v` wipes the chain data (full re-sync on the next `up`).

The image declares a `HEALTHCHECK` against `/health`; `docker compose ps`
will mark the service `(healthy)` once reth is queryable.
---

### Resolving a name
## Resolver API reference

`foobar.testing` is registered on mainnet with every text and
multicoin record populated (useful as a smoke-test target):
The resolver (`snrc-resolve.py`, host `127.0.0.1:8000`) is also runnable
standalone for local dev (no Docker), via [`uv`](https://docs.astral.sh/uv/):

```sh
curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq .
uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + mainnet .testing
```

```json
### Response shape

```jsonc
{
"name": "foobar.testing",
"nickname": "Foo",
"website": "https://foo.bar",
"location": "",
"simplexContact": [
"https://smp16.simplex.im/a#Q_F00BA7",
"https://smp11.simplex.im/a#Q_F00BA8"
],
"nickname": "Foo", "website": "https://foo.bar", "location": "",
"simplexContact": ["https://smp16.simplex.im/a#…", "https://smp11…"], // primary first, fallbacks after
"simplexChannel": [],
"eth": null,
"btc": "bc1qpzht4wp64yg7z6sgl07vvrnepyux740juynfcn",
"xmr": "4ANzdVJFxLtCKcBgNGkFSEA41zJFgrTX93LWt9UR6xpg7YNCsdrSV817cw2xKT8NXeS5euBBqTApS2u8kRTxMhyiDGN3Qgt",
"dot": "139GgyEsXDyGLhmhBTPmDmGCyTvTVuLad3YjHax2PWLK6p3s",
"owner": "0xd83bb610fbad567fb5d8755ec162881e46d1fbc9",
"resolver": "0x80fa1903e70af03e79c73fb7feae2fb33aebae01"
"eth": null, "btc": "bc1q…", "xmr": "4ANz…", "dot": "139G…",
"owner": "0xd83b…", "resolver": "0x80fa…"
}
```

`simplexContact` and `simplexChannel` are arrays so a name can advertise
multiple SMP servers for redundancy. Clients SHOULD try the URLs in
order; the first entry is the primary and the rest are fallbacks. The
on-chain text record stores them as a single comma-separated string
(`"url1,url2,url3"`); this resolver splits, trims whitespace, and drops
empty entries before returning.

All field names are lowercase-initial and contain no dots, so they map
directly onto Haskell record fields and can be consumed via aeson's
`Generic`-derived `FromJSON` without a key-rewriting layer. Equivalent
Haskell record:

```haskell
data SnrcRecord = SnrcRecord
{ name :: Text
, nickname :: Text
, website :: Text
, location :: Text
, simplexContact :: [Text]
, simplexChannel :: [Text]
, eth :: Maybe Text
, btc :: Maybe Text
, xmr :: Maybe Text
, dot :: Maybe Text
, owner :: Text
, resolver :: Text
} deriving (Generic, FromJSON)
```

(The on-chain text-record keys still use the ENSIP-5 dot convention —
`simplex.contact` and `simplex.channel`. Only the resolver's JSON
surface camelCases them.)
`simplexContact`/`simplexChannel` are arrays (a name can advertise multiple SMP
servers; clients try them in order). On-chain they're a single comma-separated
text record; the resolver splits/trims/drops-empties. Address encodings are
canonical per chain (EIP-55 / bech32 / SS58 / Monero-base58). Subnames work
identically (`bar.foobar.testing`).

Address encoding matches each chain's canonical user-facing form:
EIP-55 mixed-case for `eth`, bech32/bech32m for `btc` segwit/taproot
(base58check for legacy P2PKH/P2SH), SS58 with Polkadot prefix 0 for
`dot`, Monero-base58 for `xmr`. Unrecognised payloads fall back to
`0x`-prefixed hex.

#### Subnames

Subnames work exactly the same. try `bar.foobar.testing`.

```sh
curl -s http://127.0.0.1:8000/health
# → {"ok": true, "rpc": "http://127.0.0.1:8545", "registries": {"testing": "0x…", "simplex": ""}}
```

### Pointing at multiple deployments

Once `.simplex` deploys, point a single resolver instance at both
registries — requests are dispatched by the rightmost label:

```sh
SNRC_REGISTRY_SIMPLEX=0x...mainnet-simplex-ENSRegistry... \
./scripts/resolver/snrc-resolve.py
```
### Status codes

Queries for a TLD with no registry configured return HTTP 400 with the
list of supported TLDs.
| Status | Meaning |
|---|---|
| 200 | resolved |
| 400 | TLD not configured, or not a fully-qualified name |
| 404 | name has no resolver set on the registry |
| 502 | upstream RPC error / reth not synced |

### Error responses
### Configuring registries

| Status | When |
|--------|-----------------------------------------------------------------------|
| 400 | TLD not configured (`/resolve/foo.simplex` while `.simplex` is empty) or path not a fully-qualified name |
| 404 | Name has no resolver set on the registry (`ENSRegistry.resolver(node)` is zero) |
| 502 | Upstream RPC error / unreachable (Reth not running or not synced) |
Defaults to mainnet `.testing` (`0x03f438…`); `.simplex` is unset until
deployed. Override per TLD via env on the `resolver` service in
`docker-compose.yml` (`SNRC_REGISTRY_TESTING` / `SNRC_REGISTRY_SIMPLEX`), or as
env vars for the standalone script.
3 changes: 1 addition & 2 deletions scripts/resolver/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ services:
--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
Expand Down Expand Up @@ -133,7 +132,7 @@ services:
# To change the host port, edit the LEFT side of the port mapping below.
resolver:
build:
context: .
context: ./service
dockerfile: Dockerfile
depends_on:
# reth's `service_started` is sufficient — the resolver tolerates
Expand Down
File renamed without changes.
Loading
Loading