14 KiB
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 they are; change them only if you need to:
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
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
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:
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#…"], … }
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). Eachdocker compose pullbrings 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
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:
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…",
"status": "registered", // registered | grace | auction | expired | unregistered | reserved | noResolver | unknown
"expires": 1780000000, // Unix seconds; when the registration ends
"graceEnds": 1787776000, // expires + GRACE_PERIOD; last moment the owner can renew
"auctionEnds": null, // when the premium reaches zero; only on `auction`
"premium": null, // decimal string, attoUSD; only on `auction`
"reasonCode": null, // only on `reserved`
"reason": null // only on `reserved`
}
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
read them, a successful resolve included, so a client that has just resolved a
name already knows when it expires. Both timestamps are Unix seconds, and
null when they could not be read.
status |
Meaning |
|---|---|
registered |
live; expires is when that ends |
grace |
lapsed, but only the previous owner may renew it, until graceEnds |
auction |
past grace, so anyone may register it — but at a premium, until auctionEnds |
expired |
lapsed, past grace, and past the auction — anyone may register it at the ordinary price |
unregistered |
never registered, and free to take |
reserved |
not registered, and held back — registration will be refused; the body carries reasonCode and reason |
noResolver |
registered, but points nowhere |
unknown |
no SNRC_REGISTRAR_<TLD> configured, so status could not be read |
grace and expired are told apart by the registrar's own available(id)
rule, expires + GRACE_PERIOD < now. GRACE_PERIOD is read from the contract
rather than assumed, and now is the latest block's timestamp rather than the
host clock, which the registrar compares against too, so a machine with a wrong
clock cannot misreport a registration. That rule alone is not enough: it also
holds for a name nobody ever registered (0 + GRACE_PERIOD < now), so a zero
expiry is what separates never registered from registered and since
released.
A subname reports the status of the 2LD above it, which is only as good as the name it sits under.
The post-grace auction
When grace ends anyone may register the name, but the price oracle adds a
premium that halves each day until it reaches zero. A name in that window
reports auction instead of expired, with premium (attoUSD as a decimal
string, since no JSON number holds a 256-bit integer) and auctionEnds.
premium is the surcharge alone: it depends only on when the registration
lapsed, so a labelhash query gets it, but the base price depends on the label's
length, which a hash does not carry. The client adds that.
The oracle comes from the controller's prices(), so no extra configuration is
needed. Its window is read from the chain; zero days switches the auction off.
The curve is cached for AUCTION_PARAMS_TTL (5 minutes), so a setPremium
retune shows up within that; the decaying premium is read on every query.
Upgrade this service before the routers that query it. An older resolver
reports a name in its auction as plain expired, which routers read as
"available at the ordinary price" while the registrar charges the premium. It
also fails to decode a bracket label under a subname (sub.[<hash>].tld), which
routers from v22 send. The same wrong quote happens when the auction cannot be
read at all, so set SNRC_CONTROLLER_<TLD> wherever SNRC_REGISTRAR_<TLD> is.
Why a name is reserved
reserved carries reasonCode, the controller's reason, and reason, an
English sentence for a human reading this API. Clients should branch on
reasonCode and word it themselves, in the user's language.
reasonCode |
Meaning |
|---|---|
unspecified |
reserved, with no reason recorded on chain |
trademark |
reserved to protect a trademark |
publicInterest |
reserved in the public interest |
offensive |
reserved as an offensive name |
internal |
reserved for SimpleX |
premium |
reserved as a premium name |
unknown |
a reason added to the contract after this resolver; still reserved |
A controller from before reasons existed stores a boolean; its true reads as
unspecified, so nothing needs migrating.
Querying by labelhash
A client asking whether a name is free is usually about to register it, and
whoever runs the resolver could register it first. To avoid that, send the
keccak hash of the label in ENS's [<64 hex>] form instead of the label:
# instead of /resolve/acme.testing
curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d' ' -f1)].testing"
namehash is keccak(parent || keccak(label)), so this reaches the same node and
returns the same record. The registrar keys nameExpires and reservedNames on
the labelhash too, so the status fields do not need the label either. The
resolver learns the name only by guessing the label and hashing it.
Only the second-level label is a registry key, and it is decoded wherever it
sits: sub.[<hash>].testing reaches the node sub.name.testing does. Subname
labels stay text; a bracket label left of the 2LD is an ordinary label. Routers
from v22 send every 2LD this way, so a registrable name normally never reaches
this service.
Read the answer from status. A name is free on unregistered (404), and on
expired or auction (410) — auction costs a premium on top. Every other
status means somebody holds the name. Watch noResolver: also a 404, but taken.
The hash must be keccak-256. openssl dgst -sha3-256 and sha3sum compute
SHA3-256, a different function that returns 64 valid-looking hex characters
pointing at the wrong node.
The resolver lowercases the query before matching, so uppercase hex works too.
Clients that refuse raw brackets in a path can percent-encode them as %5B and
%5D.
Brackets cannot collide with a real name: they are invalid in a normalised ENS
name, and a [<64 hex>] label is 66 bytes against the registrar's
maxLabelLength of 63. A plain 0x… label is not treated as a hash, since that
is an ordinary, registrable name.
Only 2LDs can be queried this way, as only a 2LD can be raced for: subnames are created by the 2LD's owner. A bracket label in a subname is hashed as written, so it points at a node nobody can own. ENS tooling accepts the bracketed form at any depth; this resolver does not, on purpose.
This hides interest in a name and nothing else: the registration itself is public, and commit-reveal covers that step. A short or well-known label is easy to guess by hashing candidates, and the reveal publishes the labelhash, so an operator who logged the query can match it to the name afterwards.
Errors
Every non-2xx body carries two fields: error is a fixed code to branch on,
and message is a sentence for a human. Match on error, never on message,
which is free to change.
{"name": "nope.testing", "error": "unregistered",
"message": "this name has never been registered",
"status": "unregistered", "expires": null, "graceEnds": null}
The codes are tldNotConfigured, notFullyQualified, unregistered,
reserved, grace, auction, expired, noResolver, noSuchRoute and
upstreamError. When the registration is what went wrong, error and status
hold the same value, so one field is enough to read.
upstreamError says only which exception type the RPC call raised. The text
goes to the resolver's log instead, because SNRC_RPC can carry a provider key
and urlopen puts the URL it failed on into the message.
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), anyone may take it at a premium (auction), or anyone may take it at the ordinary price (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_<TLD>, SNRC_REGISTRAR_<TLD> or
SNRC_CONTROLLER_<TLD> on the resolver service in docker-compose.yml, or
as env vars when you run the script directly.