From 3394775bae202d25f64bf122e2571b962256bc93 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 3 Jun 2026 10:44:56 +0200 Subject: [PATCH 1/6] add REST API to resolve SNRC --- scripts/resolver/README.md | 79 ++++++ scripts/resolver/snrc-resolve.py | 443 +++++++++++++++++++++++++++++++ 2 files changed, 522 insertions(+) create mode 100755 scripts/resolver/snrc-resolve.py diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 4007479d2..1e4998d50 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -44,3 +44,82 @@ Then `docker compose down -v && docker compose up -d` (the `-v` wipes state so N - 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. Same dependency surface as +`ens-lookup.py`: + +```sh +pip install --break-system-packages 'eth-hash[pycryptodome]' +``` + +### Pointing at a Sepolia RPC + +The SNRC `.testing` TLD currently lives on Sepolia (registry +`0x2f97af21ca3eb3f5311f439c05234ca94163bc33`). Pick an RPC, then run the +script: + +```sh +# Option A — public RPC, no setup +SNRC_RPC=https://ethereum-sepolia-rpc.publicnode.com \ + ./scripts/resolver/snrc-resolve.py + +# Option B — local Reth synced to Sepolia (set NETWORK=sepolia in .env first, +# then `docker compose up -d` and wait for sync). RPC defaults already match. +./scripts/resolver/snrc-resolve.py +``` + +Listens on `0.0.0.0:8000`. Override with `SNRC_PORT` / `SNRC_BIND`. + +### Resolving a name + +Use `foobar.testing` — a name registered on Sepolia with every field +populated for end-to-end testing (text records + multicoin addresses +across ETH/BTC/DOT/XMR): + +```sh +curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq . +``` + +```json +{ + "name": "foobar.testing", + "nickname": "mynickname", + "website": "https://foobar.com", + "location": "alpha centauri", + "simplex.contact": "https://smp16.simplex.im/a#Q_f00bar", + "simplex.channel": "https://smp16.simplex.im/c#wsonsavos", + "ETH": "0xC14ccEc78342e3DAf136E6C36025b397C377614e", + "BTC": "bc1qpzht4wp64yg7z6sgl07vvrnepyux740juynfcn", + "XMR": "46PS3HXYoH3VcGsneFCyHkfSygkp8p1hHHAMb3ePP8BuPdqSbsTcPiuH7xDmVudaq8W24EryzRYDS5Whz7ZQu8NqEpHtHQx", + "DOT": "16P39egDdQgZjAAhGP1pUs6ik23RgBXwohNh93GH6Qmk4W9q", + "owner": "0xc14ccec78342e3daf136e6c36025b397c377614e", + "resolver": "0xb35a2f76379437638426acb4d9a45546acbf4f5c" +} +``` + +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. + +### Health check + +```sh +curl -s http://127.0.0.1:8000/health +# → {"ok": true, "rpc": "...", "registry": "..."} +``` + +### Switching to a different SNRC deployment + +`.simplex` (mainnet) and any future deployment use the same script with +a different registry address: + +```sh +SNRC_RPC=https://ethereum-rpc.publicnode.com \ +SNRC_REGISTRY=0x...mainnet-ENSRegistry... \ + ./scripts/resolver/snrc-resolve.py +``` diff --git a/scripts/resolver/snrc-resolve.py b/scripts/resolver/snrc-resolve.py new file mode 100755 index 000000000..d1500c175 --- /dev/null +++ b/scripts/resolver/snrc-resolve.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +"""SimpleX Namespace (SNRC) resolver — REST API. + +Resolves names like `alice.testing` / `bob.simplex` against the SNRC +deployment on Sepolia (or any compatible ENS-shaped registry) and +returns a flat JSON document with: + + name, nickname, website, location, + simplex.contact, simplex.channel, + ETH, BTC, XMR, DOT, + owner, resolver + +Usage: + ./snrc-resolve.py # serve on :8000 + + curl -s http://127.0.0.1:8000/resolve/secondtest.testing | jq . + curl -s http://127.0.0.1:8000/health + +Environment: + SNRC_RPC JSON-RPC endpoint (default: http://127.0.0.1:8545) + SNRC_REGISTRY ENSRegistry address + (default: SNRC .testing on Sepolia, + 0x2f97af21ca3eb3f5311f439c05234ca94163bc33) + SNRC_PORT Listen port (default: 8000) + SNRC_BIND Bind address (default: 0.0.0.0) + +Same dependency surface as ens-lookup.py: + pip install --break-system-packages 'eth-hash[pycryptodome]' + +Addresses are returned in each chain's canonical presentation: + ETH EIP-55 mixed-case checksummed hex (e.g. 0xEa65A0…1572) + BTC bech32(m) for segwit/taproot, base58check for P2PKH/P2SH + (e.g. bc1q… / 1A1zP1…) + DOT SS58 with Polkadot network prefix 0 (e.g. 15oF4u…) + XMR Monero base58 (e.g. 4Aux5y…) +Unrecognised payloads fall back to `0x`-prefixed raw hex. +""" + +import hashlib +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import unquote, urlparse +from urllib.request import Request, urlopen + +from eth_hash.auto import keccak + +RPC = os.environ.get("SNRC_RPC", "http://127.0.0.1:8545") +ENS_REGISTRY = os.environ.get( + "SNRC_REGISTRY", "0x2f97af21ca3eb3f5311f439c05234ca94163bc33" +) +BIND = os.environ.get("SNRC_BIND", "0.0.0.0") +PORT = int(os.environ.get("SNRC_PORT", "8000")) + +# SLIP-44 coin types (https://github.com/satoshilabs/slips/blob/master/slip-0044.md) +COIN_ETH = 60 +COIN_BTC = 0 +COIN_XMR = 128 +COIN_DOT = 354 + +ZERO_ADDR = "0x0000000000000000000000000000000000000000" + + +# ---------- RPC + ABI helpers (mirrors ens-lookup.py shape) ---------- + +def rpc(method, params): + body = json.dumps( + {"jsonrpc": "2.0", "method": method, "params": params, "id": 1} + ).encode() + # Set a non-default User-Agent; Cloudflare-fronted public RPCs (drpc, + # publicnode, etc.) reject `Python-urllib/3.x` with 403. + req = Request( + RPC, + data=body, + headers={ + "Content-Type": "application/json", + "User-Agent": "snrc-resolve/1.0", + }, + ) + res = json.loads(urlopen(req, timeout=15).read()) + if "error" in res: + raise RuntimeError(res["error"]) + return res["result"] + + +def namehash(name: str) -> bytes: + node = b"\x00" * 32 + if name: + for label in reversed(name.split(".")): + node = keccak(node + keccak(label.encode())) + return node + + +def selector(signature: str) -> str: + return "0x" + keccak(signature.encode())[:4].hex() + + +def eth_call(to: str, data: str) -> str: + return rpc("eth_call", [{"to": to, "data": data}, "latest"]) + + +def decode_address(hex_data: str) -> str: + return "0x" + hex_data[-40:] + + +def decode_bytes(hex_data: str) -> bytes: + raw = bytes.fromhex(hex_data[2:] if hex_data.startswith("0x") else hex_data) + if len(raw) < 64: + return b"" + length = int.from_bytes(raw[32:64], "big") + return raw[64:64 + length] + + +def encode_text_call(node: bytes, key: str) -> str: + sel = selector("text(bytes32,string)") + head = node.hex() + (0x40).to_bytes(32, "big").hex() + key_bytes = key.encode() + body = len(key_bytes).to_bytes(32, "big").hex() + key_bytes.hex() + body += "00" * ((-len(key_bytes)) % 32) + return sel + head + body + + +def text(resolver: str, node: bytes, key: str) -> str: + raw = decode_bytes(eth_call(resolver, encode_text_call(node, key))) + return raw.decode("utf-8", errors="replace") if raw else "" + + +def encode_addr_multicoin_call(node: bytes, coin_type: int) -> str: + """ENSIP-9 addr(bytes32 node, uint256 coinType) — both static, no offsets.""" + return ( + selector("addr(bytes32,uint256)") + + node.hex() + + coin_type.to_bytes(32, "big").hex() + ) + + +def addr_multicoin(resolver: str, node: bytes, coin_type: int): + """Read ENSIP-9 raw bytes for `coinType`, then encode to that chain's + canonical presentation form. Falls back to `0x`-prefixed hex if the + payload doesn't match any recognised on-chain shape. Returns None when + the record is unset.""" + try: + raw = decode_bytes(eth_call(resolver, encode_addr_multicoin_call(node, coin_type))) + except RuntimeError: + return None + if not raw: + return None + encoder = COIN_ENCODERS.get(coin_type) + if encoder is None: + return "0x" + raw.hex() + try: + return encoder(raw) or ("0x" + raw.hex()) + except Exception: + return "0x" + raw.hex() + + +# ---------- Coin-specific address encoders ---------- +# Each takes raw bytes as stored under ENSIP-9 and returns the canonical +# user-facing string for that chain (EIP-55 for ETH, bech32/base58check +# for BTC, SS58 for DOT, Monero-base58 for XMR). All stdlib + eth_hash. + + +B58_ALPHA = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + +def _b58_encode(b: bytes) -> str: + n = int.from_bytes(b, "big") + out = "" + while n: + n, r = divmod(n, 58) + out = B58_ALPHA[r] + out + # leading zero bytes → leading '1's + pad = len(b) - len(b.lstrip(b"\x00")) + return "1" * pad + out + + +def _b58check_encode(payload: bytes) -> str: + """Base58Check used by BTC legacy/P2SH: payload + dSHA256(payload)[:4].""" + chk = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4] + return _b58_encode(payload + chk) + + +# ---- Bech32 / Bech32m (BIP-173 / BIP-350) ---- + +_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +_BECH32_GEN = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] + + +def _bech32_polymod(values): + chk = 1 + for v in values: + b = chk >> 25 + chk = ((chk & 0x1FFFFFF) << 5) ^ v + for i in range(5): + if (b >> i) & 1: + chk ^= _BECH32_GEN[i] + return chk + + +def _bech32_hrp_expand(hrp): + return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + + +def _bech32_create_checksum(hrp, data, spec): + const = 1 if spec == "bech32" else 0x2BC830A3 # bech32m + values = _bech32_hrp_expand(hrp) + data + [0] * 6 + polymod = _bech32_polymod(values) ^ const + return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] + + +def _bech32_encode(hrp, data, spec): + combined = data + _bech32_create_checksum(hrp, data, spec) + return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined) + + +def _convertbits(data, frombits, tobits, pad=True): + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + if value < 0 or (value >> frombits): + return None + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad and bits: + ret.append((acc << (tobits - bits)) & maxv) + elif not pad and (bits >= frombits or ((acc << (tobits - bits)) & maxv)): + return None + return ret + + +def _segwit_encode(hrp: str, witver: int, witprog: bytes) -> str: + spec = "bech32" if witver == 0 else "bech32m" + data = [witver] + _convertbits(list(witprog), 8, 5) + return _bech32_encode(hrp, data, spec) + + +# ---- BTC scriptPubKey → address ---- +# ENSIP-9 stores the raw output script. Dispatch by length + opcode prefix. + +def _btc_encode(raw: bytes) -> str | None: + hrp = "bc" # mainnet + if len(raw) == 25 and raw[:3] == b"\x76\xa9\x14" and raw[23:25] == b"\x88\xac": + return _b58check_encode(b"\x00" + raw[3:23]) # P2PKH + if len(raw) == 23 and raw[:2] == b"\xa9\x14" and raw[22:23] == b"\x87": + return _b58check_encode(b"\x05" + raw[2:22]) # P2SH + if len(raw) == 22 and raw[:2] == b"\x00\x14": + return _segwit_encode(hrp, 0, raw[2:22]) # P2WPKH + if len(raw) == 34 and raw[:2] == b"\x00\x20": + return _segwit_encode(hrp, 0, raw[2:34]) # P2WSH + if len(raw) == 34 and raw[:2] == b"\x51\x20": + return _segwit_encode(hrp, 1, raw[2:34]) # P2TR + return None + + +# ---- Polkadot SS58 ---- +# Per SS58 spec: base58( prefix_byte + pubkey + blake2b-512("SS58PRE" + body)[:2] ) +# Polkadot mainnet uses network prefix 0 (single byte); Kusama uses 2. + +_SS58_PRE = b"SS58PRE" + + +def _ss58_encode(pubkey: bytes, network_prefix: int = 0) -> str: + if len(pubkey) != 32: + return None + body = bytes([network_prefix]) + pubkey + checksum = hashlib.blake2b(_SS58_PRE + body, digest_size=64).digest()[:2] + return _b58_encode(body + checksum) + + +def _dot_encode(raw: bytes) -> str | None: + return _ss58_encode(raw, network_prefix=0) + + +# ---- Monero base58 ---- +# Monero base58 encodes in 8-byte blocks; each full block → 11 chars, partial +# block sizes per fixed table. Alphabet is identical to Bitcoin's. + +_XMR_BLOCK_SIZES = [0, 2, 3, 5, 6, 7, 9, 10, 11] + + +def _xmr_encode(raw: bytes) -> str: + out = [] + for i in range(0, len(raw), 8): + chunk = raw[i:i + 8] + n = int.from_bytes(chunk, "big") + width = 11 if len(chunk) == 8 else _XMR_BLOCK_SIZES[len(chunk)] + block = [] + for _ in range(width): + n, r = divmod(n, 58) + block.append(B58_ALPHA[r]) + out.append("".join(reversed(block))) + return "".join(out) + + +# ---- ETH EIP-55 mixed-case checksum ---- + +def _eth_encode(raw: bytes) -> str | None: + if len(raw) != 20: + return None + hex_addr = raw.hex() + hash_hex = keccak(hex_addr.encode()).hex() + return "0x" + "".join( + c.upper() if c.isalpha() and int(hash_hex[i], 16) >= 8 else c + for i, c in enumerate(hex_addr) + ) + + +COIN_ENCODERS = { + COIN_ETH: _eth_encode, + COIN_BTC: _btc_encode, + COIN_XMR: _xmr_encode, + COIN_DOT: _dot_encode, +} + + +# ---------- Resolution logic ---------- + +# Text-record keys we read from the resolver. Surfaced under the response +# field names listed in the docstring above. `name` and `description` are +# common ENS fallbacks for a human-readable nickname. +TEXT_KEYS = [ + "name", + "nickname", + "description", + "url", + "location", + "simplex.contact", + "simplex.channel", +] + + +def resolve(name: str): + node = namehash(name) + node_hex = node.hex() + + resolver_raw = eth_call(ENS_REGISTRY, selector("resolver(bytes32)") + node_hex) + resolver_addr = decode_address(resolver_raw) + if resolver_addr == ZERO_ADDR: + return 404, {"name": name, "error": "no resolver set for this name"} + + owner_raw = eth_call(ENS_REGISTRY, selector("owner(bytes32)") + node_hex) + owner = decode_address(owner_raw) + + texts = {} + for k in TEXT_KEYS: + try: + v = text(resolver_addr, node, k) + except RuntimeError: + v = "" + if v: + texts[k] = v + + # The user-facing "nickname" prefers an explicit `nickname` record, + # falls back to `name`, then `description` (ENSIP-5 convention). + nickname = texts.get("nickname") or texts.get("name") or texts.get("description") or "" + + return 200, { + "name": name, + "nickname": nickname, + "website": texts.get("url", ""), + "location": texts.get("location", ""), + "simplex.contact": texts.get("simplex.contact", ""), + "simplex.channel": texts.get("simplex.channel", ""), + "ETH": addr_multicoin(resolver_addr, node, COIN_ETH), + "BTC": addr_multicoin(resolver_addr, node, COIN_BTC), + "XMR": addr_multicoin(resolver_addr, node, COIN_XMR), + "DOT": addr_multicoin(resolver_addr, node, COIN_DOT), + "owner": owner, + "resolver": resolver_addr, + } + + +# ---------- HTTP layer ---------- + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 - http.server contract + path = urlparse(self.path).path + parts = [unquote(p) for p in path.split("/") if p] + + if parts == ["health"]: + self._respond(200, {"ok": True, "rpc": RPC, "registry": ENS_REGISTRY}) + return + + if len(parts) == 2 and parts[0] == "resolve": + name = parts[1].strip().lower() + if not name or "." not in name: + self._respond( + 400, + { + "error": "expected fully-qualified name, e.g. /resolve/alice.testing", + "got": name, + }, + ) + return + try: + status, body = resolve(name) + except Exception as e: # surface upstream errors as 502 + status, body = 502, {"name": name, "error": f"{type(e).__name__}: {e}"} + self._respond(status, body) + return + + self._respond( + 404, + {"error": "not found", "routes": ["/health", "/resolve/"]}, + ) + + def _respond(self, status: int, body: dict): + data = json.dumps(body, indent=2).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, fmt, *args): + # Quiet the default per-request access log; route to stderr in one line. + sys.stderr.write(f"{self.address_string()} - {fmt % args}\n") + + +def main(): + server = ThreadingHTTPServer((BIND, PORT), Handler) + sys.stderr.write( + f"snrc-resolve listening on {BIND}:{PORT}\n" + f" RPC = {RPC}\n" + f" Registry = {ENS_REGISTRY}\n" + f" GET /resolve/ GET /health\n" + ) + try: + server.serve_forever() + except KeyboardInterrupt: + sys.stderr.write("\nshutting down\n") + server.server_close() + + +if __name__ == "__main__": + main() From 5f592ebf22500854c1f79a47efadb14c3d5ba2f7 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 3 Jun 2026 11:11:43 +0200 Subject: [PATCH 2/6] fix unset fields --- scripts/resolver/snrc-resolve.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/resolver/snrc-resolve.py b/scripts/resolver/snrc-resolve.py index d1500c175..fbec3fef5 100755 --- a/scripts/resolver/snrc-resolve.py +++ b/scripts/resolver/snrc-resolve.py @@ -146,6 +146,11 @@ def addr_multicoin(resolver: str, node: bytes, coin_type: int): return None if not raw: return None + # An all-zero payload is the ENS convention for "unset" — many tools + # write 20 zero bytes for coinType=60 instead of clearing the slot. + # Treat it as null so the response doesn't surface a zero address. + if raw == b"\x00" * len(raw): + return None encoder = COIN_ENCODERS.get(coin_type) if encoder is None: return "0x" + raw.hex() From 5830b60f45ebb67b60fbbd9bf69012316bc28c7a Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 3 Jun 2026 11:46:46 +0200 Subject: [PATCH 3/6] support multi-TLD deployments --- scripts/resolver/README.md | 16 ++++++--- scripts/resolver/snrc-resolve.py | 58 +++++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 1e4998d50..08cf6c798 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -110,16 +110,22 @@ DOT, Monero-base58 for XMR. Unrecognised payloads fall back to ```sh curl -s http://127.0.0.1:8000/health -# → {"ok": true, "rpc": "...", "registry": "..."} +# → {"ok": true, "rpc": "...", "registries": {"testing": "0x...", "simplex": ""}} ``` -### Switching to a different SNRC deployment +### Pointing at multiple deployments -`.simplex` (mainnet) and any future deployment use the same script with -a different registry address: +`.testing` and `.simplex` are independent SNRC deployments with separate +ENSRegistry contracts. The resolver routes each request to the right +registry by the queried name's rightmost label — one server, both TLDs: ```sh SNRC_RPC=https://ethereum-rpc.publicnode.com \ -SNRC_REGISTRY=0x...mainnet-ENSRegistry... \ +SNRC_REGISTRY_TESTING=0x...sepolia-ENSRegistry... \ +SNRC_REGISTRY_SIMPLEX=0x...mainnet-ENSRegistry... \ ./scripts/resolver/snrc-resolve.py ``` + +Queries for a TLD with no registry configured return HTTP 400 with the +list of supported TLDs. `.simplex` is not deployed yet, so its env var +defaults to empty. diff --git a/scripts/resolver/snrc-resolve.py b/scripts/resolver/snrc-resolve.py index fbec3fef5..fe435bede 100755 --- a/scripts/resolver/snrc-resolve.py +++ b/scripts/resolver/snrc-resolve.py @@ -17,12 +17,17 @@ curl -s http://127.0.0.1:8000/health Environment: - SNRC_RPC JSON-RPC endpoint (default: http://127.0.0.1:8545) - SNRC_REGISTRY ENSRegistry address - (default: SNRC .testing on Sepolia, - 0x2f97af21ca3eb3f5311f439c05234ca94163bc33) - SNRC_PORT Listen port (default: 8000) - SNRC_BIND Bind address (default: 0.0.0.0) + SNRC_RPC JSON-RPC endpoint (default: http://127.0.0.1:8545) + SNRC_REGISTRY_TESTING ENSRegistry for the .testing deployment + (default: Sepolia, + 0x2f97af21ca3eb3f5311f439c05234ca94163bc33) + SNRC_REGISTRY_SIMPLEX ENSRegistry for the .simplex deployment + (default: empty — TLD not yet deployed) + SNRC_PORT Listen port (default: 8000) + SNRC_BIND Bind address (default: 0.0.0.0) + +Each TLD is a separate SNRC deployment with its own ENSRegistry; the +resolver dispatches by the queried name's rightmost label. Same dependency surface as ens-lookup.py: pip install --break-system-packages 'eth-hash[pycryptodome]' @@ -47,12 +52,20 @@ from eth_hash.auto import keccak RPC = os.environ.get("SNRC_RPC", "http://127.0.0.1:8545") -ENS_REGISTRY = os.environ.get( - "SNRC_REGISTRY", "0x2f97af21ca3eb3f5311f439c05234ca94163bc33" -) BIND = os.environ.get("SNRC_BIND", "0.0.0.0") PORT = int(os.environ.get("SNRC_PORT", "8000")) +# Each TLD is its own SNRC deployment with its own ENSRegistry. Dispatch +# happens on the rightmost label of the queried name. Empty / unset means +# "not deployed" — requests for that TLD return 400 with a clear error. +REGISTRIES = { + "testing": os.environ.get( + "SNRC_REGISTRY_TESTING", + "0x2f97af21ca3eb3f5311f439c05234ca94163bc33", # Sepolia .testing + ), + "simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet +} + # SLIP-44 coin types (https://github.com/satoshilabs/slips/blob/master/slip-0044.md) COIN_ETH = 60 COIN_BTC = 0 @@ -342,15 +355,25 @@ def _eth_encode(raw: bytes) -> str | None: def resolve(name: str): + tld = name.rsplit(".", 1)[-1] + registry = REGISTRIES.get(tld) + if not registry: + configured = [k for k, v in REGISTRIES.items() if v] + return 400, { + "name": name, + "error": f"TLD '{tld}' is not configured on this resolver", + "configured_tlds": configured, + } + node = namehash(name) node_hex = node.hex() - resolver_raw = eth_call(ENS_REGISTRY, selector("resolver(bytes32)") + node_hex) + resolver_raw = eth_call(registry, selector("resolver(bytes32)") + node_hex) resolver_addr = decode_address(resolver_raw) if resolver_addr == ZERO_ADDR: return 404, {"name": name, "error": "no resolver set for this name"} - owner_raw = eth_call(ENS_REGISTRY, selector("owner(bytes32)") + node_hex) + owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex) owner = decode_address(owner_raw) texts = {} @@ -390,7 +413,10 @@ def do_GET(self): # noqa: N802 - http.server contract parts = [unquote(p) for p in path.split("/") if p] if parts == ["health"]: - self._respond(200, {"ok": True, "rpc": RPC, "registry": ENS_REGISTRY}) + self._respond( + 200, + {"ok": True, "rpc": RPC, "registries": REGISTRIES}, + ) return if len(parts) == 2 and parts[0] == "resolve": @@ -433,10 +459,12 @@ def main(): server = ThreadingHTTPServer((BIND, PORT), Handler) sys.stderr.write( f"snrc-resolve listening on {BIND}:{PORT}\n" - f" RPC = {RPC}\n" - f" Registry = {ENS_REGISTRY}\n" - f" GET /resolve/ GET /health\n" + f" RPC = {RPC}\n" + f" Registries:\n" ) + for tld, addr in REGISTRIES.items(): + sys.stderr.write(f" .{tld:<8s} = {addr or '(not configured)'}\n") + sys.stderr.write(" GET /resolve/ GET /health\n") try: server.serve_forever() except KeyboardInterrupt: From 2dd03f9e0ba91565d2b24fa68356c1bf727d500a Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 5 Jun 2026 17:43:35 +0200 Subject: [PATCH 4/6] update for mainnet tests --- scripts/resolver/README.md | 93 ++++++++++++++++++++------------ scripts/resolver/snrc-resolve.py | 6 +-- 2 files changed, 61 insertions(+), 38 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 08cf6c798..85ab1337f 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -48,36 +48,55 @@ Then `docker compose down -v && docker compose up -d` (the `-v` wipes state so N ## 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. Same dependency surface as -`ens-lookup.py`: +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. + +Install the only runtime dependency (same as `ens-lookup.py`): ```sh pip install --break-system-packages 'eth-hash[pycryptodome]' ``` -### Pointing at a Sepolia RPC +### Deployed registries -The SNRC `.testing` TLD currently lives on Sepolia (registry -`0x2f97af21ca3eb3f5311f439c05234ca94163bc33`). Pick an RPC, then run the -script: +| TLD | Network | ENSRegistry address | +|------------|------------------|----------------------------------------------| +| `.testing` | Ethereum mainnet | `0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6` | +| `.simplex` | — (not deployed) | — | -```sh -# Option A — public RPC, no setup -SNRC_RPC=https://ethereum-sepolia-rpc.publicnode.com \ - ./scripts/resolver/snrc-resolve.py +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. -# Option B — local Reth synced to Sepolia (set NETWORK=sepolia in .env first, -# then `docker compose up -d` and wait for sync). RPC defaults already match. +### 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: + +```sh ./scripts/resolver/snrc-resolve.py ``` -Listens on `0.0.0.0:8000`. Override with `SNRC_PORT` / `SNRC_BIND`. +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/ GET /health +``` + +Override the listen port or bind address with `SNRC_PORT` / `SNRC_BIND`. ### Resolving a name -Use `foobar.testing` — a name registered on Sepolia with every field -populated for end-to-end testing (text records + multicoin addresses -across ETH/BTC/DOT/XMR): +`foobar.testing` is registered on mainnet with every text and +multicoin record populated (useful as a smoke-test target): ```sh curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq . @@ -86,17 +105,17 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq . ```json { "name": "foobar.testing", - "nickname": "mynickname", - "website": "https://foobar.com", - "location": "alpha centauri", - "simplex.contact": "https://smp16.simplex.im/a#Q_f00bar", - "simplex.channel": "https://smp16.simplex.im/c#wsonsavos", - "ETH": "0xC14ccEc78342e3DAf136E6C36025b397C377614e", + "nickname": "Foo", + "website": "https://foo.bar", + "location": "", + "simplex.contact": "https://smp16.simplex.im/a#Q_F00BA7", + "simplex.channel": "", + "ETH": null, "BTC": "bc1qpzht4wp64yg7z6sgl07vvrnepyux740juynfcn", - "XMR": "46PS3HXYoH3VcGsneFCyHkfSygkp8p1hHHAMb3ePP8BuPdqSbsTcPiuH7xDmVudaq8W24EryzRYDS5Whz7ZQu8NqEpHtHQx", - "DOT": "16P39egDdQgZjAAhGP1pUs6ik23RgBXwohNh93GH6Qmk4W9q", - "owner": "0xc14ccec78342e3daf136e6c36025b397c377614e", - "resolver": "0xb35a2f76379437638426acb4d9a45546acbf4f5c" + "XMR": "4ANzdVJFxLtCKcBgNGkFSEA41zJFgrTX93LWt9UR6xpg7YNCsdrSV817cw2xKT8NXeS5euBBqTApS2u8kRTxMhyiDGN3Qgt", + "DOT": "139GgyEsXDyGLhmhBTPmDmGCyTvTVuLad3YjHax2PWLK6p3s", + "owner": "0xd83bb610fbad567fb5d8755ec162881e46d1fbc9", + "resolver": "0x80fa1903e70af03e79c73fb7feae2fb33aebae01" } ``` @@ -110,22 +129,26 @@ DOT, Monero-base58 for XMR. Unrecognised payloads fall back to ```sh curl -s http://127.0.0.1:8000/health -# → {"ok": true, "rpc": "...", "registries": {"testing": "0x...", "simplex": ""}} +# → {"ok": true, "rpc": "http://127.0.0.1:8545", "registries": {"testing": "0x…", "simplex": ""}} ``` ### Pointing at multiple deployments -`.testing` and `.simplex` are independent SNRC deployments with separate -ENSRegistry contracts. The resolver routes each request to the right -registry by the queried name's rightmost label — one server, both TLDs: +Once `.simplex` deploys, point a single resolver instance at both +registries — requests are dispatched by the rightmost label: ```sh -SNRC_RPC=https://ethereum-rpc.publicnode.com \ -SNRC_REGISTRY_TESTING=0x...sepolia-ENSRegistry... \ -SNRC_REGISTRY_SIMPLEX=0x...mainnet-ENSRegistry... \ +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. `.simplex` is not deployed yet, so its env var -defaults to empty. +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) | diff --git a/scripts/resolver/snrc-resolve.py b/scripts/resolver/snrc-resolve.py index fe435bede..ddc0458f7 100755 --- a/scripts/resolver/snrc-resolve.py +++ b/scripts/resolver/snrc-resolve.py @@ -19,8 +19,8 @@ Environment: SNRC_RPC JSON-RPC endpoint (default: http://127.0.0.1:8545) SNRC_REGISTRY_TESTING ENSRegistry for the .testing deployment - (default: Sepolia, - 0x2f97af21ca3eb3f5311f439c05234ca94163bc33) + (default: mainnet, + 0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6) SNRC_REGISTRY_SIMPLEX ENSRegistry for the .simplex deployment (default: empty — TLD not yet deployed) SNRC_PORT Listen port (default: 8000) @@ -61,7 +61,7 @@ REGISTRIES = { "testing": os.environ.get( "SNRC_REGISTRY_TESTING", - "0x2f97af21ca3eb3f5311f439c05234ca94163bc33", # Sepolia .testing + "0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6", # mainnet .testing ), "simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet } From 6b81f862bb90b8002f5c06003005db6c55216715 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 5 Jun 2026 17:46:25 +0200 Subject: [PATCH 5/6] haskell-friendly fieldnames --- scripts/resolver/README.md | 42 ++++++++++++++++++++++++++------ scripts/resolver/snrc-resolve.py | 39 +++++++++++++++++------------ 2 files changed, 58 insertions(+), 23 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 85ab1337f..fac09efc5 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -108,21 +108,47 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq . "nickname": "Foo", "website": "https://foo.bar", "location": "", - "simplex.contact": "https://smp16.simplex.im/a#Q_F00BA7", - "simplex.channel": "", - "ETH": null, - "BTC": "bc1qpzht4wp64yg7z6sgl07vvrnepyux740juynfcn", - "XMR": "4ANzdVJFxLtCKcBgNGkFSEA41zJFgrTX93LWt9UR6xpg7YNCsdrSV817cw2xKT8NXeS5euBBqTApS2u8kRTxMhyiDGN3Qgt", - "DOT": "139GgyEsXDyGLhmhBTPmDmGCyTvTVuLad3YjHax2PWLK6p3s", + "simplexContact": "https://smp16.simplex.im/a#Q_F00BA7", + "simplexChannel": "", + "eth": null, + "btc": "bc1qpzht4wp64yg7z6sgl07vvrnepyux740juynfcn", + "xmr": "4ANzdVJFxLtCKcBgNGkFSEA41zJFgrTX93LWt9UR6xpg7YNCsdrSV817cw2xKT8NXeS5euBBqTApS2u8kRTxMhyiDGN3Qgt", + "dot": "139GgyEsXDyGLhmhBTPmDmGCyTvTVuLad3YjHax2PWLK6p3s", "owner": "0xd83bb610fbad567fb5d8755ec162881e46d1fbc9", "resolver": "0x80fa1903e70af03e79c73fb7feae2fb33aebae01" } ``` +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: + +```haskell +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 +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 +`dot`, Monero-base58 for `xmr`. Unrecognised payloads fall back to `0x`-prefixed hex. ### Health check diff --git a/scripts/resolver/snrc-resolve.py b/scripts/resolver/snrc-resolve.py index ddc0458f7..7614958c9 100755 --- a/scripts/resolver/snrc-resolve.py +++ b/scripts/resolver/snrc-resolve.py @@ -2,18 +2,22 @@ """SimpleX Namespace (SNRC) resolver — REST API. Resolves names like `alice.testing` / `bob.simplex` against the SNRC -deployment on Sepolia (or any compatible ENS-shaped registry) and -returns a flat JSON document with: +deployment on Ethereum mainnet (or any compatible ENS-shaped registry) +and returns a flat JSON document with these fields: name, nickname, website, location, - simplex.contact, simplex.channel, - ETH, BTC, XMR, DOT, + simplexContact, simplexChannel, + eth, btc, xmr, dot, owner, resolver +All keys are valid Haskell record-field identifiers (lowercase initial, +no dots), so consumers can derive aeson FromJSON instances directly +without a key-rewriting layer. + Usage: ./snrc-resolve.py # serve on :8000 - curl -s http://127.0.0.1:8000/resolve/secondtest.testing | jq . + curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq . curl -s http://127.0.0.1:8000/health Environment: @@ -33,11 +37,11 @@ pip install --break-system-packages 'eth-hash[pycryptodome]' Addresses are returned in each chain's canonical presentation: - ETH EIP-55 mixed-case checksummed hex (e.g. 0xEa65A0…1572) - BTC bech32(m) for segwit/taproot, base58check for P2PKH/P2SH + eth EIP-55 mixed-case checksummed hex (e.g. 0xEa65A0…1572) + btc bech32(m) for segwit/taproot, base58check for P2PKH/P2SH (e.g. bc1q… / 1A1zP1…) - DOT SS58 with Polkadot network prefix 0 (e.g. 15oF4u…) - XMR Monero base58 (e.g. 4Aux5y…) + dot SS58 with Polkadot network prefix 0 (e.g. 15oF4u…) + xmr Monero base58 (e.g. 4Aux5y…) Unrecognised payloads fall back to `0x`-prefixed raw hex. """ @@ -389,17 +393,22 @@ def resolve(name: str): # falls back to `name`, then `description` (ENSIP-5 convention). nickname = texts.get("nickname") or texts.get("name") or texts.get("description") or "" + # Keys chosen to be valid Haskell record-field identifiers (lowercase + # initial, no dots) so consumers can derive aeson FromJSON instances + # without a key-rewriting layer. On-chain text-record names still + # use the ENSIP-5 dot convention (e.g. "simplex.contact") — only the + # resolver's JSON surface camelCases them. return 200, { "name": name, "nickname": nickname, "website": texts.get("url", ""), "location": texts.get("location", ""), - "simplex.contact": texts.get("simplex.contact", ""), - "simplex.channel": texts.get("simplex.channel", ""), - "ETH": addr_multicoin(resolver_addr, node, COIN_ETH), - "BTC": addr_multicoin(resolver_addr, node, COIN_BTC), - "XMR": addr_multicoin(resolver_addr, node, COIN_XMR), - "DOT": addr_multicoin(resolver_addr, node, COIN_DOT), + "simplexContact": texts.get("simplex.contact", ""), + "simplexChannel": texts.get("simplex.channel", ""), + "eth": addr_multicoin(resolver_addr, node, COIN_ETH), + "btc": addr_multicoin(resolver_addr, node, COIN_BTC), + "xmr": addr_multicoin(resolver_addr, node, COIN_XMR), + "dot": addr_multicoin(resolver_addr, node, COIN_DOT), "owner": owner, "resolver": resolver_addr, } From 40e6510500465f6781969d4b1ae22cd30a8a122a Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 5 Jun 2026 19:31:13 +0200 Subject: [PATCH 6/6] add subname hint --- scripts/resolver/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index fac09efc5..f80923814 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -151,7 +151,9 @@ EIP-55 mixed-case for `eth`, bech32/bech32m for `btc` segwit/taproot `dot`, Monero-base58 for `xmr`. Unrecognised payloads fall back to `0x`-prefixed hex. -### Health check +#### Subnames + +Subnames work exactly the same. try `bar.foobar.testing`. ```sh curl -s http://127.0.0.1:8000/health