# 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) and `9000` (tcp/udp). ## 1. Configure Edit `.env`. The defaults work as they are; change them only if you need to: ```sh NETWORK=mainnet # default TRUSTED_NODE_URL=https://mainnet-checkpoint-sync.attestant.io # default ``` Everything else (NAT) already has a working default in `docker-compose.yml`. Uncomment the hints in `.env` only if you need to change one. ## 2. Run ```sh cd scripts/resolver docker compose up -d docker compose logs -f reth resolver ``` Compose starts the node before the resolver; `depends_on` takes care of that. ## 3. Wait for the node to sync ```sh docker compose logs --tail=20 reth ``` This is the slow step: about a day on mainnet. Until reth has synced, the resolver returns `502`. ## Verify Run the three checks below once the stack is up. The ones that need chain data pass only after the node has synced. **1. reth is reachable and reporting a block:** ```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}' | jq ``` **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": ""}} ``` **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#…"], … } ``` **Point your smp-server at it:** 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). Each `docker compose pull` brings upstream fixes, so re-run the checks above afterwards. - All ports bind to loopback. Expose only what you put behind a TLS reverse proxy. ## Teardown ```sh 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 You can also run the resolver (`snrc-resolve.py`, host `127.0.0.1:8000`) on its own for local development, without Docker, using [`uv`](https://docs.astral.sh/uv/): ```sh uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + mainnet .testing ``` ### Response shape ```jsonc { "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…", "status": "registered", // registered | grace | expired | unregistered | reserved | noResolver | unknown "expires": 1780000000, // Unix seconds; when the registration ends "graceEnds": 1787776000 // expires + GRACE_PERIOD; last moment the owner can renew } ``` `simplexContact` and `simplexChannel` are arrays, because a name can advertise several SMP servers; clients try them in order. On chain each one is a single text record with the entries joined by `;`. The resolver splits that record, trims each entry and drops the empty ones. Addresses come back in each chain's usual format (EIP-55, bech32, SS58, Monero base58). Subnames work the same way (`bar.foobar.testing`). ### Registration status and expiry A response carries `status`, `expires` and `graceEnds` whenever the resolver got far enough to read them, a successful resolve included. A client that has just resolved a name therefore already has its expiry, and needs no second request to warn about it. `expires` and `graceEnds` are Unix timestamps in seconds, and both are `null` when the resolver could not read them. | `status` | Meaning | |---|---| | `registered` | live; `expires` is when that ends | | `grace` | lapsed, but only the previous owner may renew it, until `graceEnds` | | `expired` | lapsed and past grace — anyone may register it now | | `unregistered` | never registered, and free to take | | `reserved` | not registered, and held back — registration will be refused; the body carries a `reason` | | `noResolver` | registered, but points nowhere | | `unknown` | no `SNRC_REGISTRAR_` configured, so status could not be read | The resolver tells `grace` and `expired` apart with the registrar's own `available(id)` rule, `expires + GRACE_PERIOD < now`. The resolver reads `GRACE_PERIOD` from the contract instead of assuming it, and takes `now` from the latest block's timestamp instead of the host clock. The registrar compares against that same block timestamp, so a machine with a wrong clock cannot misreport a registration. `available(id)` on its own cannot tell the two apart, because it is also true for a name nobody ever registered: `0 + GRACE_PERIOD < now`. The resolver uses a zero expiry to tell *never registered* from *registered and since released*. A subname reports the status of the 2LD above it. That is the answer a client needs, because a subname is only as good as the name it sits under. ### Querying by labelhash A client that asks whether a name is free is usually about to register it. Whoever runs the resolver sees that question and could register the name first. To avoid that, send the keccak hash of the label instead of the label itself, written in ENS's `[<64 hex>]` form. The answer is the same: ```sh # instead of /resolve/acme.testing curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d' ' -f1)].testing" ``` This works because namehash is `keccak(parent || keccak(label))`. Passing `keccak(label)` gives the same node, so the resolver reads the same record. The registrar keys `nameExpires` and `reservedNames` on the labelhash as well, so the status fields do not need the label either. The resolver learns which name you meant only if it guesses the label and hashes it. Read the answer from `status`. A name is free only when the body says `unregistered`, which comes with a 404. Every other status means somebody holds the name or held it recently. Watch out for `noResolver`: it is also a 404, but the name is taken. The hash must be keccak-256. `openssl dgst -sha3-256` and `sha3sum` compute SHA3-256, which is a different function. They return 64 valid-looking hex characters that point at the wrong node. The resolver lowercases the query before matching, so uppercase hex works too. HTTP clients that refuse raw brackets in a path can percent-encode them as `%5B` and `%5D`. Both forms reach the same name. Brackets keep the two forms from colliding. `[` and `]` are not valid in a normalised ENS name, and the dApp normalises before it registers, so no name registered through it can look like this. Nothing on chain checks the character set, but a `[<64 hex>]` label is 66 bytes and the registrar's `maxLabelLength` is 63, so it cannot be registered directly either. ENS uses this same encoding for a label whose preimage it does not know. A plain `0x…` label would not work here, because that is an ordinary name anyone can register. Only 2LDs can be queried by hash. A 2LD is what a registration buys, so it is the only name worth hiding. Subnames are left out because nobody can race you for one: the owner of the 2LD creates them. In a subname the resolver hashes a `[<64 hex>]` label as written instead of decoding it, so such a query points at a node nobody can own. ENS tooling accepts the bracketed form at any depth; this resolver does not, on purpose. This hides your interest in a name, and nothing more. The registration itself is public, and the controller's commit-reveal protects that step. The hash is also easy to guess for a short or well-known label, since an operator can hash candidate labels and compare. And once you register, the reveal publishes the labelhash, so an operator who logged your query can match it to the name. ### Status codes | Status | Meaning | |---|---| | 200 | resolved (`status` is `registered`, or `unknown` when no registrar is configured) | | 400 | TLD not configured, or not a fully-qualified name | | 404 | `unregistered`, `reserved` or `noResolver` — the `status` field says which | | 410 | registration lapsed — `status` says whether the owner can still renew (`grace`) or anyone may take it (`expired`) | | 502 | upstream RPC error / reth not synced | ### Configuring addresses The resolver reads three contracts, each configured per TLD. The **registry** answers who owns a node, and `/resolve` reads the records from it. The **registrar** (ERC-721) holds `nameExpires` and `GRACE_PERIOD`, which is where every expiry field comes from. With no registrar for a TLD, `/resolve` still works and reports `"status": "unknown"`. The **controller** holds `reservedNames`, which is where the `reserved` status comes from. With no controller, a reserved name reads as `unregistered`. All three default to the mainnet `.testing` deployment. `.simplex` is unset until it is deployed. The controller default is the **proxy**, not `SimplexControllerImpl`. Storage lives in the proxy, so the implementation address answers nothing. The two deployment files use different names for that proxy: `deployments.mainnet.testing.json` records it under the ENS role name `ETHRegistrarController`, and `verification.mainnet.testing.json` calls it `SimplexControllerProxy`. Both are the same address, and it is the one used here. To override any of them, set `SNRC_REGISTRY_`, `SNRC_REGISTRAR_` or `SNRC_CONTROLLER_` on the `resolver` service in `docker-compose.yml`, or as env vars when you run the script directly.