Self-hosted SNRC stack
One docker compose up runs the self-hosted SimpleX Namespace (SNRC) backend
against Ethereum mainnet (where the .testing contracts live):
| # | 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) |
Requirements
- 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) and9000(tcp/udp).
1. Configure
Edit .env — the defaults work as-is; override only if needed:
NETWORK=mainnet # default
TRUSTED_NODE_URL=https://mainnet-checkpoint-sync.attestant.io # default
Everything else (NAT) has a working default baked into docker-compose.yml;
uncomment the hints in .env only to override.
2. Run
cd scripts/resolver
docker compose up -d
docker compose logs -f reth resolver
depends_on handles ordering automatically (start node → start resolver).
3. Wait for the node to sync
docker compose logs --tail=20 reth
This is the long pole (~1 day on mainnet). Until reth is synced the resolver
returns 502.
Verify
Run these once the stack is up (the node-dependent ones pass after sync):
1. reth is reachable and reporting a block:
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
2. resolver is healthy:
curl -s http://127.0.0.1:8000/health | jq
# → {"ok": true, "rpc": "http://reth:8545", "registries": {"testing": "0x…", "simplex": ""}}
3. resolver resolves a live name (foobar.testing is a populated test name):
curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq
# → {"name":"foobar.testing","nickname":"Foo","simplexContact":["https://smp16.simplex.im/a#…"], … }
Wire your smp-server: in its [NAMES] section set
resolver_endpoint: http://127.0.0.1:8000 (no auth needed for loopback).
Ports (all loopback unless noted)
| 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) |
Caveats
- All images track
:latest(reth, nimbus) — you get upstream fixes on eachdocker compose pull; re-run the verify checks after pulling. - All ports bind to loopback; expose only what you put behind a TLS reverse proxy.
Teardown
docker compose down # stop, keep all state
docker compose down -v # also wipe volumes → full re-sync
down -v wipes the chain data (full re-sync on the next up).
Resolver API reference
The resolver (snrc-resolve.py, host 127.0.0.1:8000) is also runnable
standalone for local dev (no Docker), via uv:
uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + mainnet .testing
Response shape
{
"name": "foobar.testing",
"nickname": "Foo", "website": "https://foo.bar", "location": "",
"simplexContact": ["https://smp16.simplex.im/a#…", "https://smp11…"], // primary first, fallbacks after
"simplexChannel": [],
"eth": null, "btc": "bc1q…", "xmr": "4ANz…", "dot": "139G…",
"owner": "0xd83b…", "resolver": "0x80fa…"
}
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).
Asking without naming the name
A client checking whether a name is free is usually about to register it, so
the question itself is worth front-running. Substitute the label's keccak hash,
written in ENS's [<64 hex>] form, and the answer is identical:
# instead of /resolve/acme.testing
curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d' ' -f1)].testing"
namehash is defined as keccak(parent || keccak(label)), so supplying
keccak(label) reaches the same node: the resolver reads exactly the record it
would have read, and learns which name you meant only if it already guessed it.
The brackets are what keep the two forms apart — [ and ] cannot occur in a
normalised name, so no registrable label can take this shape, and it is the
same encoding ENS itself uses for a label whose preimage is unknown. A bare
0x… label would not do: that is an ordinary, registrable name.
Only 2LDs may be queried by hash: that is the name a registration is bought
for, so the only one worth hiding. Subnames of any depth are excluded — a
subname is created by the 2LD's owner, nobody can race you for one, so there is
nothing to front-run. A label in [<64 hex>] form there is hashed literally,
not decoded; as brackets cannot occur in a real registration, such a query
names a node nobody can own.
Registration itself stays public: this hides the interest, and the commit-reveal in the controller is what protects the registration.
Status codes
| 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 |
Configuring registries
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.