Files
simplexmq/scripts/resolver/README.md
T
2026-08-31 09:59:09 +02:00

288 lines
12 KiB
Markdown

# 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-is; override only if needed:
```sh
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
```sh
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
```sh
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:**
```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#…"], … }
```
**4. resolver answers the reverse lookup:**
```sh
curl -s http://127.0.0.1:8000/owned-by/0x69a6000000000000000000000000000000002d32 | jq '.names'
# → [{"name":"foobar.testing","status":"registered","expires":1780…, …}]
```
**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`, `/owned-by`, `/health`) |
## Caveats
- **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.
## 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
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
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 | noResolver | unknown
"expires": 1780000000, // Unix seconds; when the registration ends
"graceEnds": 1787776000 // expires + GRACE_PERIOD; last moment the owner can renew
}
```
`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`).
### Registration status and expiry
`status`, `expires` and `graceEnds` are on every response that got far enough to
know them, including a successful resolve — so a client that has just resolved a
name already holds its expiry and needs no second request to warn about it.
`expires` and `graceEnds` are Unix timestamps in seconds; both are `null` when
unknown.
| `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 |
| `noResolver` | registered, but points nowhere |
| `unknown` | no `SNRC_REGISTRAR_<TLD>` configured, so status could not be read |
Which HTTP code carries each, and what every other input does, is in
[Every case](#every-case-and-what-comes-back) at the end.
The split between `grace` and `expired` mirrors the registrar's own
`available(id)` rule (`expires + GRACE_PERIOD < now`), with `GRACE_PERIOD` read
from the contract rather than assumed. Note that `available(id)` alone cannot
distinguish these: it is also true for a name nobody ever registered, since
`0 + GRACE_PERIOD < now`. A zero expiry is what separates *never taken* from
*taken and since released*.
Subnames report the status of the 2LD they sit under, which is the useful
answer — a subname is only as valid as the name above it.
### Status codes
| Status | Meaning |
|---|---|
| 200 | resolved; `status` is `registered` |
| 400 | TLD not configured, or not a fully-qualified name |
| 404 | never registered (`unregistered`), or registered with no resolver set (`noResolver`) |
| 410 | registration has lapsed — `status` says whether it is still renewable |
| 502 | upstream RPC error / reth not synced |
### `GET /owned-by/<address>`
Every name an Ethereum address holds, across every configured TLD.
```jsonc
{
"address": "0x69a6…",
"names": [
{"name": "foobar.testing", "tld": "testing", "labelhash": "0x…",
"expires": 1780000000, "graceEnds": 1787776000, "status": "registered"},
{"name": "lapsed.testing", "tld": "testing", "labelhash": "0x…",
"expires": 1750000000, "graceEnds": 1757776000, "status": "grace"}
],
"truncated": false,
"checkedTlds": ["testing"]
}
```
Read from the ERC-721 registrar (`balanceOf``tokenOfOwnerByIndex`
`nameExpires``labelOf`), so it needs no log scan and includes names acquired
by transfer as well as by registration. `labelOf` is the plaintext label
recorded write-once at registration, so a token id turns back into a name
without an off-chain index; a token whose label was never recorded is returned
with `"name": null` and its `labelhash`, rather than being dropped.
**Lapsed names are listed, not filtered**, with the same `status` vocabulary as
`/resolve` — a wallet scanning a key is exactly the caller who needs to be told
a name has lapsed and can still be renewed. Filter on `status == "registered"`
for the live set only. Enumeration is deliberately not maintained on expiry (the
registrar documents this), which is why `status` rather than presence is the
thing to read.
`truncated` is `true` when an address holds more than `SNRC_MAX_OWNED` names
(default 256) in one TLD, so a caller can tell a short list from a complete one.
Requires `SNRC_REGISTRAR_<TLD>`; with none configured the endpoint answers 400
rather than an empty list.
### 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.
`SNRC_REGISTRAR_<TLD>` is the matching ERC-721 registrar, and is what `/owned-by`
and the expiry status are read from — the registry answers *who owns this node*,
the registrar is the NFT that can be asked the reverse and when it expires.
Without it `/resolve` still works and reports `"status": "unknown"`, and
`/owned-by` answers 400. `SNRC_MAX_OWNED` bounds one `/owned-by` response
(default 256).
## Every case, and what comes back
Every input either endpoint can be given, and the exact answer. Written out
because the interesting cases are the ones that are hard to reach on purpose —
a name in its grace period, a token whose label predates label recording — and
a caller has to handle them without having seen one.
Timestamps are Unix seconds. `status`, `expires` and `graceEnds` are present on
every `/resolve` response that got as far as looking the name up — `null` where
not knowable — so a client can read them without checking for the key first.
The two 400s below are the exception: they fail on the request itself, before
any lookup, and carry none of the three.
### `GET /resolve/<name>`
| Situation | HTTP | `status` | Body |
|---|---|---|---|
| Live name with records | 200 | `registered` | full record; `expires` is when it ends, `graceEnds` when it would stop being renewable |
| Live name, no text records set | 200 | `registered` | full record; text fields `""`, link arrays `[]`, coin fields `null` |
| Live subname (`bar.foo.testing`) | 200 | `registered` | its own records, with the expiry of the 2LD `foo.testing` above it |
| Registered, resolver never set | 404 | `noResolver` | `expires`, `graceEnds`, `error` — held, but points nowhere |
| Lapsed, still in grace | 410 | `grace` | `expires` (when it lapsed), `graceEnds` (last moment its owner can renew) |
| Lapsed, past grace | 410 | `expired` | same fields; anyone may register it now |
| Never registered | 404 | `unregistered` | `expires` and `graceEnds` are `null` |
| TLD has no registry configured | 400 | — | `configured_tlds`, listing the ones that are |
| TLD has no *registrar* configured | 200 / 404 | `unknown` | resolves as it otherwise would; expiry cannot be read, so `expires` and `graceEnds` are `null` |
| Not fully qualified (`alice`) | 400 | — | `error` naming the expected form |
| RPC unreachable or node unsynced | 502 | — | `error` with the underlying exception type |
A name in grace still has its records on chain — expiry is lazy — but the
resolver answers 410 rather than serving them, so a stale name cannot be
resolved by accident. Read `expires` from that response to say when it lapsed.
### `GET /owned-by/<address>`
Answers 200 with a `names` array in every case where the address is well formed
and a registrar is configured; the interesting variation is per entry.
| Situation | HTTP | Result |
|---|---|---|
| Address holds live names | 200 | one entry each, `status` `registered` |
| Address holds a name in grace | 200 | entry with `status` `grace` and `graceEnds` — the renewal reminder case |
| Address holds a name past grace | 200 | entry with `status` `expired`; still listed, because the holder is who needs to know |
| Address holds nothing | 200 | `names: []` — an answer, not an error |
| Token whose label was never recorded | 200 | entry with `"name": null` and its `labelhash`; the token is real, the name is not recoverable from chain state |
| Address holds more than `SNRC_MAX_OWNED` in a TLD | 200 | first 256, and `truncated: true` |
| Several TLDs configured | 200 | all of them merged, sorted by TLD then name; `checkedTlds` says which were asked |
| Malformed address | 400 | `error`; no RPC call is made |
| No registrar configured for any TLD | 400 | `error` and `configured_tlds: []` — distinct from "holds nothing" |
| RPC unreachable or node unsynced | 502 | `error` with the underlying exception type |
Names are **not** filtered by expiry. Enumeration on the registrar is
maintained on transfer, mint and burn but deliberately not on expiry, so a
lapsed name stays enumerable until someone re-registers it — and that is
exactly the name its holder needs to be told about. Filter on
`status == "registered"` for the live set.