mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-09-12 11:17:50 +00:00
257 lines
11 KiB
Markdown
257 lines
11 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 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_<TLD>` 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_<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. |