mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-09-09 16:06:12 +00:00
288 lines
12 KiB
Markdown
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.
|