Files
simplexmq/scripts/resolver

Ethereum stack for SMP names role

Reth (execution) + Nimbus (consensus) on Holesky testnet by default.

Quickstart

cd scripts/docker/reth-nimbus
docker compose up -d
docker compose logs -f reth nimbus

Sync takes a few hours on Holesky, ~1 day on mainnet. When synced:

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}'

Point smp-server: [NAMES] ethereum_endpoint: http://127.0.0.1:8545.

How the trust bootstrap works

  • Reth holds Ethereum state and runs the EVM. It does not decide which fork is canonical.
  • Nimbus follows the beacon chain and tells Reth which payloads to execute.
  • Nimbus needs one trusted starting point to break the chicken-and-egg of peer-claims. --trusted-node-url fetches that checkpoint once from a public beacon API; from that point on every block is verified locally against the validator set.
  • The default TRUSTED_NODE_URL is publicnode.com (no API key, no rate limits). Replace with any beacon API you trust — only consulted once on first sync.

Switching to mainnet

Edit .env:

NETWORK=mainnet
TRUSTED_NODE_URL=https://ethereum-beacon-api.publicnode.com

Then docker compose down -v && docker compose up -d (the -v wipes state so Nimbus re-bootstraps against the new network). Reth on mainnet needs ~260 GB pruned NVMe.

Notes

  • Reth's RPC is bound to 127.0.0.1:8545 only. For remote access (multiple smp-server hosts → one Reth), put Caddy + Let's Encrypt + Basic auth in front — see plans/20260522_01_smp_public_namespaces.md §"Operator deployment".
  • Ports 30303/9000 are p2p — open on your firewall for sync.
  • jwt.hex is generated on first run by the jwt-init service and shared between Reth and Nimbus via the jwt volume.
  • To wipe state and re-sync: docker compose down -v.

SNRC resolver REST API (snrc-resolve.py)

The companion script snrc-resolve.py exposes the SimpleX Namespace Registry (SNRC) over a small JSON HTTP API. It talks to the same local Reth + Nimbus stack described above (set NETWORK=mainnet in .env), reading the SNRC contracts directly on Ethereum mainnet.

Dependencies are declared inline (PEP 723) at the top of snrc-resolve.py and in a sibling pyproject.toml. The simplest local run uses uv:

uv run scripts/resolver/snrc-resolve.py

uv resolves and caches eth-hash[pycryptodome] on first run. No virtualenv juggling, no --break-system-packages. If you'd rather manage Python deps yourself:

pip install 'eth-hash[pycryptodome]>=0.7'
python scripts/resolver/snrc-resolve.py

Deployed registries

TLD Network ENSRegistry address
.testing Ethereum mainnet 0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6
.simplex — (not deployed)

Each TLD is an independent ENS-shaped deployment with its own ENSRegistry. The resolver dispatches by the queried name's rightmost label, so a single instance can serve both TLDs concurrently once .simplex launches.

Running

With Reth bound to 127.0.0.1:8545 (the default Quickstart layout above), no env vars are required — the script defaults to that RPC and to the mainnet .testing registry:

./scripts/resolver/snrc-resolve.py

Output on startup:

snrc-resolve listening on 0.0.0.0:8000
  RPC = http://127.0.0.1:8545
  Registries:
    .testing  = 0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6
    .simplex  = (not configured)
  GET /resolve/<name>   GET /health

Override the listen port or bind address with SNRC_PORT / SNRC_BIND.

Running in Docker

The compose file ships a resolver service alongside reth and nimbus. docker compose up -d builds the image from Dockerfile (multi-stage, non-root, uv-based) and exposes the API on 127.0.0.1:8000:

docker compose up -d resolver
docker compose logs -f resolver
curl -s http://127.0.0.1:8000/health

The container points SNRC_RPC at http://reth:8545 (the compose-internal DNS name) so the resolver and reth share the bridge network without exposing reth's RPC to the host beyond loopback.

To change the host-side port, edit the LEFT side of the port mapping in docker-compose.yml:

resolver:
  ports:
    - "127.0.0.1:8000:8000"   # host:container

The registry address defaults to mainnet .testing — to override (Holesky, a private deployment, or future .simplex), uncomment and set the values in docker-compose.yml under the resolver service's environment: block.

The image declares a HEALTHCHECK against /health; docker compose ps will mark the service (healthy) once reth is queryable.

Resolving a name

foobar.testing is registered on mainnet with every text and multicoin record populated (useful as a smoke-test target):

curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq .
{
  "name": "foobar.testing",
  "nickname": "Foo",
  "website": "https://foo.bar",
  "location": "",
  "simplexContact": [
    "https://smp16.simplex.im/a#Q_F00BA7",
    "https://smp11.simplex.im/a#Q_F00BA8"
  ],
  "simplexChannel": [],
  "eth": null,
  "btc": "bc1qpzht4wp64yg7z6sgl07vvrnepyux740juynfcn",
  "xmr": "4ANzdVJFxLtCKcBgNGkFSEA41zJFgrTX93LWt9UR6xpg7YNCsdrSV817cw2xKT8NXeS5euBBqTApS2u8kRTxMhyiDGN3Qgt",
  "dot": "139GgyEsXDyGLhmhBTPmDmGCyTvTVuLad3YjHax2PWLK6p3s",
  "owner": "0xd83bb610fbad567fb5d8755ec162881e46d1fbc9",
  "resolver": "0x80fa1903e70af03e79c73fb7feae2fb33aebae01"
}

simplexContact and simplexChannel are arrays so a name can advertise multiple SMP servers for redundancy. Clients SHOULD try the URLs in order; the first entry is the primary and the rest are fallbacks. The on-chain text record stores them as a single comma-separated string ("url1,url2,url3"); this resolver splits, trims whitespace, and drops empty entries before returning.

All field names are lowercase-initial and contain no dots, so they map directly onto Haskell record fields and can be consumed via aeson's Generic-derived FromJSON without a key-rewriting layer. Equivalent Haskell record:

data SnrcRecord = SnrcRecord
  { name           :: Text
  , nickname       :: Text
  , website        :: Text
  , location       :: Text
  , simplexContact :: [Text]
  , simplexChannel :: [Text]
  , eth            :: Maybe Text
  , btc            :: Maybe Text
  , xmr            :: Maybe Text
  , dot            :: Maybe Text
  , owner          :: Text
  , resolver       :: Text
  } deriving (Generic, FromJSON)

(The on-chain text-record keys still use the ENSIP-5 dot convention — simplex.contact and simplex.channel. Only the resolver's JSON surface camelCases them.)

Address encoding matches each chain's canonical user-facing form: EIP-55 mixed-case for eth, bech32/bech32m for btc segwit/taproot (base58check for legacy P2PKH/P2SH), SS58 with Polkadot prefix 0 for dot, Monero-base58 for xmr. Unrecognised payloads fall back to 0x-prefixed hex.

Subnames

Subnames work exactly the same. try bar.foobar.testing.

curl -s http://127.0.0.1:8000/health
# → {"ok": true, "rpc": "http://127.0.0.1:8545", "registries": {"testing": "0x…", "simplex": ""}}

Pointing at multiple deployments

Once .simplex deploys, point a single resolver instance at both registries — requests are dispatched by the rightmost label:

SNRC_REGISTRY_SIMPLEX=0x...mainnet-simplex-ENSRegistry... \
  ./scripts/resolver/snrc-resolve.py

Queries for a TLD with no registry configured return HTTP 400 with the list of supported TLDs.

Error responses

Status When
400 TLD not configured (/resolve/foo.simplex while .simplex is empty) or path not a fully-qualified name
404 Name has no resolver set on the registry (ENSRegistry.resolver(node) is zero)
502 Upstream RPC error / unreachable (Reth not running or not synced)