From 3394775bae202d25f64bf122e2571b962256bc93 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 3 Jun 2026 10:44:56 +0200 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 From c95d4bcdc16a3df347e3eecaeaeb4844254d8bbe Mon Sep 17 00:00:00 2001 From: sh Date: Mon, 8 Jun 2026 15:09:25 +0000 Subject: [PATCH 07/10] resolver: dockerize --- scripts/resolver/Dockerfile | 48 +++++++++++++++++++++++++++++ scripts/resolver/README.md | 47 ++++++++++++++++++++++++++-- scripts/resolver/docker-compose.yml | 28 +++++++++++++++++ scripts/resolver/pyproject.toml | 13 ++++++++ scripts/resolver/snrc-resolve.py | 21 +++++++++---- 5 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 scripts/resolver/Dockerfile create mode 100644 scripts/resolver/pyproject.toml diff --git a/scripts/resolver/Dockerfile b/scripts/resolver/Dockerfile new file mode 100644 index 000000000..8df898aac --- /dev/null +++ b/scripts/resolver/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1.7 +# ---------- builder ---------- +# Use the official uv image (Astral) on top of a slim Python base. +# uv resolves and installs the lockfile-free pyproject.toml in seconds and +# produces a portable .venv we can copy into the runtime stage. +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder + +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_PYTHON_DOWNLOADS=never \ + UV_NO_PROGRESS=1 + +WORKDIR /app + +# Install deps first (separate layer) — script edits won't bust this cache. +COPY pyproject.toml ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev --no-install-project + +# Script is added after the dep layer for cache friendliness. +COPY snrc-resolve.py ./ + +# ---------- runtime ---------- +# Slim runtime — only the venv + script. No uv, no apt. +FROM python:3.13-slim AS runtime + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/app/.venv/bin:$PATH" + +# Non-root user (matches resolver privacy posture: it has no need for root). +RUN groupadd --system --gid 10001 snrc && \ + useradd --system --uid 10001 --gid snrc --no-create-home --shell /usr/sbin/nologin snrc + +WORKDIR /app +COPY --from=builder --chown=snrc:snrc /app /app + +USER snrc:snrc + +EXPOSE 8000 + +# Liveness check hits the script's own /health route. ThreadingHTTPServer is +# fast enough that 3s is generous for a localhost probe; restart if it stops +# responding entirely. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["python", "-c", "import urllib.request, sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).status == 200 else 1)"] + +ENTRYPOINT ["python", "snrc-resolve.py"] diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index f80923814..49d1a4e89 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -52,10 +52,21 @@ 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`): +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`](https://docs.astral.sh/uv/): ```sh -pip install --break-system-packages 'eth-hash[pycryptodome]' +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: + +```sh +pip install 'eth-hash[pycryptodome]>=0.7' +python scripts/resolver/snrc-resolve.py ``` ### Deployed registries @@ -93,6 +104,38 @@ snrc-resolve listening on 0.0.0.0:8000 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`: + +```sh +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`: + +```yaml +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 diff --git a/scripts/resolver/docker-compose.yml b/scripts/resolver/docker-compose.yml index 15fee9029..06f0d27d8 100644 --- a/scripts/resolver/docker-compose.yml +++ b/scripts/resolver/docker-compose.yml @@ -127,6 +127,34 @@ services: --nat=${NAT:-any} restart: unless-stopped + # SNRC REST resolver. Talks to reth on the compose-internal network, + # exposes /resolve and /health on 127.0.0.1:8000 by default. The + # smp-server points its [NAMES] resolver_endpoint at this URL. + # To change the host port, edit the LEFT side of the port mapping below. + resolver: + build: + context: . + dockerfile: Dockerfile + depends_on: + # reth's `service_started` is sufficient — the resolver tolerates + # eth_call failures gracefully (returns 502 with the error body), so + # starting before reth has finished snapshot replay just yields a few + # 502s until the chain is queryable. The upstream reth image doesn't + # ship a HEALTHCHECK, so we can't gate on healthy. + reth: + condition: service_started + environment: + SNRC_RPC: http://reth:8545 + SNRC_BIND: 0.0.0.0 + # Registry addresses cascade through the script's own defaults + # (mainnet `.testing`; `.simplex` unconfigured). Set explicitly here + # only if you're deploying against a different network or contract. + # SNRC_REGISTRY_TESTING: 0x... + # SNRC_REGISTRY_SIMPLEX: 0x... + ports: + - "127.0.0.1:8000:8000" + restart: unless-stopped + volumes: reth-data: nimbus-data: diff --git a/scripts/resolver/pyproject.toml b/scripts/resolver/pyproject.toml new file mode 100644 index 000000000..cba4a289b --- /dev/null +++ b/scripts/resolver/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "snrc-resolve" +version = "0.1.0" +description = "SimpleX Namespace (SNRC) resolver — REST API over ENS-shaped Ethereum registries" +readme = "README.md" +requires-python = ">=3.11" +license = "AGPL-3.0-only" +dependencies = [ + "eth-hash[pycryptodome]>=0.7", +] + +[tool.uv] +package = false diff --git a/scripts/resolver/snrc-resolve.py b/scripts/resolver/snrc-resolve.py index 7614958c9..469d9f22c 100755 --- a/scripts/resolver/snrc-resolve.py +++ b/scripts/resolver/snrc-resolve.py @@ -1,4 +1,10 @@ #!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "eth-hash[pycryptodome]>=0.7", +# ] +# /// """SimpleX Namespace (SNRC) resolver — REST API. Resolves names like `alice.testing` / `bob.simplex` against the SNRC @@ -33,8 +39,9 @@ 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]' +Dependencies are declared inline (PEP 723) at the top of this file. Run with: + uv run snrc-resolve.py # uv resolves & caches deps; one-line setup + python snrc-resolve.py # if eth-hash[pycryptodome] is already installed Addresses are returned in each chain's canonical presentation: eth EIP-55 mixed-case checksummed hex (e.g. 0xEa65A0…1572) @@ -62,11 +69,13 @@ # 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. +# `... or "..."` makes the script's defaults the single source of truth: +# unset AND empty-string both fall through to the literal. docker-compose +# can therefore pass `SNRC_REGISTRY_TESTING=${SNRC_REGISTRY_TESTING:-}` +# without duplicating the registry address. REGISTRIES = { - "testing": os.environ.get( - "SNRC_REGISTRY_TESTING", - "0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6", # mainnet .testing - ), + "testing": os.environ.get("SNRC_REGISTRY_TESTING", "") + or "0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6", # mainnet .testing "simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet } From bbea84c60529b30135e56876d272bed298743aba Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 9 Jun 2026 14:32:36 +0200 Subject: [PATCH 08/10] support multiple fallback links for splx contact and channels --- scripts/resolver/README.md | 18 ++++++++++++++---- scripts/resolver/snrc-resolve.py | 23 ++++++++++++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 49d1a4e89..78df84b9c 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -151,8 +151,11 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq . "nickname": "Foo", "website": "https://foo.bar", "location": "", - "simplexContact": "https://smp16.simplex.im/a#Q_F00BA7", - "simplexChannel": "", + "simplexContact": [ + "https://smp16.simplex.im/a#Q_F00BA7", + "https://smp11.simplex.im/a#Q_F00BA8" + ], + "simplexChannel": [], "eth": null, "btc": "bc1qpzht4wp64yg7z6sgl07vvrnepyux740juynfcn", "xmr": "4ANzdVJFxLtCKcBgNGkFSEA41zJFgrTX93LWt9UR6xpg7YNCsdrSV817cw2xKT8NXeS5euBBqTApS2u8kRTxMhyiDGN3Qgt", @@ -162,6 +165,13 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq . } ``` +`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 @@ -173,8 +183,8 @@ data SnrcRecord = SnrcRecord , nickname :: Text , website :: Text , location :: Text - , simplexContact :: Text - , simplexChannel :: Text + , simplexContact :: [Text] + , simplexChannel :: [Text] , eth :: Maybe Text , btc :: Maybe Text , xmr :: Maybe Text diff --git a/scripts/resolver/snrc-resolve.py b/scripts/resolver/snrc-resolve.py index 469d9f22c..f3de0cdff 100755 --- a/scripts/resolver/snrc-resolve.py +++ b/scripts/resolver/snrc-resolve.py @@ -12,10 +12,15 @@ and returns a flat JSON document with these fields: name, nickname, website, location, - simplexContact, simplexChannel, + simplexContact, simplexChannel, -- list[str], primary first eth, btc, xmr, dot, owner, resolver +`simplexContact` and `simplexChannel` are arrays so a name can advertise +multiple SMP servers for redundancy. Clients SHOULD try the URLs in the +order returned. The on-chain text record stores them as a single +comma-separated string; this resolver splits and trims into a list. + 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. @@ -367,6 +372,18 @@ def _eth_encode(raw: bytes) -> str | None: ] +def split_csv(value: str) -> list: + """Split a comma-separated text record into an ordered list of entries. + + Trims whitespace around each element and drops empties so trailing + commas, doubled commas, and all-whitespace inputs all yield clean + output. Single-value records yield a 1-element list; empty inputs + yield `[]`. Used for `simplex.contact` / `simplex.channel`, which + store one-or-more SMP-server URLs as a single comma-joined string. + """ + return [item.strip() for item in value.split(",") if item.strip()] + + def resolve(name: str): tld = name.rsplit(".", 1)[-1] registry = REGISTRIES.get(tld) @@ -412,8 +429,8 @@ def resolve(name: str): "nickname": nickname, "website": texts.get("url", ""), "location": texts.get("location", ""), - "simplexContact": texts.get("simplex.contact", ""), - "simplexChannel": texts.get("simplex.channel", ""), + "simplexContact": split_csv(texts.get("simplex.contact", "")), + "simplexChannel": split_csv(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), From 4b835bcef7ace2cd7e44173cb0723395429d5d4a Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 9 Jun 2026 15:09:30 +0200 Subject: [PATCH 09/10] add test --- scripts/resolver/test_snrc_resolve.py | 85 +++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 scripts/resolver/test_snrc_resolve.py diff --git a/scripts/resolver/test_snrc_resolve.py b/scripts/resolver/test_snrc_resolve.py new file mode 100644 index 000000000..107c0e90d --- /dev/null +++ b/scripts/resolver/test_snrc_resolve.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Unit tests for snrc-resolve helpers. + +Run with `python3 -m unittest scripts/resolver/test_snrc_resolve.py`. +""" + +import importlib.util +import os +import unittest + +# snrc-resolve.py has a hyphen, so import it via importlib instead of `import`. +_HERE = os.path.dirname(os.path.abspath(__file__)) +_SPEC = importlib.util.spec_from_file_location( + "snrc_resolve", os.path.join(_HERE, "snrc-resolve.py") +) +snrc = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(snrc) + + +class SplitCsvTests(unittest.TestCase): + """`split_csv` decodes the multi-URL convention for simplex.contact / + simplex.channel text records. Reuses the same rule the dApp's + `parseSimplexUrls` will use, so the two sides round-trip cleanly.""" + + def test_empty_string_yields_empty_list(self): + self.assertEqual(snrc.split_csv(""), []) + + def test_whitespace_only_yields_empty_list(self): + self.assertEqual(snrc.split_csv(" "), []) + self.assertEqual(snrc.split_csv(" , , "), []) + + def test_single_url_yields_singleton_list(self): + self.assertEqual( + snrc.split_csv("https://smp16.simplex.im/a#H1"), + ["https://smp16.simplex.im/a#H1"], + ) + + def test_two_urls_split_on_comma(self): + self.assertEqual( + snrc.split_csv( + "https://smp16.simplex.im/a#H1,https://smp19.simplex.im/a#H1" + ), + [ + "https://smp16.simplex.im/a#H1", + "https://smp19.simplex.im/a#H1", + ], + ) + + def test_whitespace_around_commas_is_trimmed(self): + self.assertEqual( + snrc.split_csv( + " https://smp16.simplex.im/a#H1 ,\thttps://smp19.simplex.im/a#H1 " + ), + [ + "https://smp16.simplex.im/a#H1", + "https://smp19.simplex.im/a#H1", + ], + ) + + def test_trailing_comma_does_not_produce_empty_entry(self): + self.assertEqual( + snrc.split_csv("https://smp16.simplex.im/a#H1,"), + ["https://smp16.simplex.im/a#H1"], + ) + + def test_doubled_comma_does_not_produce_empty_entry(self): + self.assertEqual( + snrc.split_csv( + "https://smp16.simplex.im/a#H1,,https://smp19.simplex.im/a#H1" + ), + [ + "https://smp16.simplex.im/a#H1", + "https://smp19.simplex.im/a#H1", + ], + ) + + def test_order_is_preserved(self): + self.assertEqual( + snrc.split_csv("c,a,b"), + ["c", "a", "b"], + ) + + +if __name__ == "__main__": + unittest.main() From 1aec2eb638f0651b6d1114db2bf98ed231968802 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 11 Jun 2026 09:11:01 +0200 Subject: [PATCH 10/10] change url separator to semicolon --- scripts/resolver/snrc-resolve.py | 22 +++++++++------ scripts/resolver/test_snrc_resolve.py | 39 ++++++++++++++------------- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/scripts/resolver/snrc-resolve.py b/scripts/resolver/snrc-resolve.py index f3de0cdff..8b5f21e70 100755 --- a/scripts/resolver/snrc-resolve.py +++ b/scripts/resolver/snrc-resolve.py @@ -19,7 +19,7 @@ `simplexContact` and `simplexChannel` are arrays so a name can advertise multiple SMP servers for redundancy. Clients SHOULD try the URLs in the order returned. The on-chain text record stores them as a single -comma-separated string; this resolver splits and trims into a list. +`LINK_SEPARATOR` (`;`)-joined string; this resolver splits and trims into a list. All keys are valid Haskell record-field identifiers (lowercase initial, no dots), so consumers can derive aeson FromJSON instances directly @@ -372,16 +372,22 @@ def _eth_encode(raw: bytes) -> str | None: ] -def split_csv(value: str) -> list: - """Split a comma-separated text record into an ordered list of entries. +# Separator that joins the SMP-server URL list inside a simplex.contact / +# simplex.channel text record. MUST match SIMPLEX_LINK_SEPARATOR in the dApp +# (ens-app-v3 src/constants/simplex.ts) — the two sides decode the same record. +LINK_SEPARATOR = ";" + + +def split_links(value: str) -> list: + """Split a separator-joined text record into an ordered list of entries. Trims whitespace around each element and drops empties so trailing - commas, doubled commas, and all-whitespace inputs all yield clean + separators, doubled separators, and all-whitespace inputs all yield clean output. Single-value records yield a 1-element list; empty inputs yield `[]`. Used for `simplex.contact` / `simplex.channel`, which - store one-or-more SMP-server URLs as a single comma-joined string. + store one-or-more SMP-server URLs as a single `LINK_SEPARATOR`-joined string. """ - return [item.strip() for item in value.split(",") if item.strip()] + return [item.strip() for item in value.split(LINK_SEPARATOR) if item.strip()] def resolve(name: str): @@ -429,8 +435,8 @@ def resolve(name: str): "nickname": nickname, "website": texts.get("url", ""), "location": texts.get("location", ""), - "simplexContact": split_csv(texts.get("simplex.contact", "")), - "simplexChannel": split_csv(texts.get("simplex.channel", "")), + "simplexContact": split_links(texts.get("simplex.contact", "")), + "simplexChannel": split_links(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), diff --git a/scripts/resolver/test_snrc_resolve.py b/scripts/resolver/test_snrc_resolve.py index 107c0e90d..64e924c06 100644 --- a/scripts/resolver/test_snrc_resolve.py +++ b/scripts/resolver/test_snrc_resolve.py @@ -17,28 +17,29 @@ _SPEC.loader.exec_module(snrc) -class SplitCsvTests(unittest.TestCase): - """`split_csv` decodes the multi-URL convention for simplex.contact / +class SplitLinksTests(unittest.TestCase): + """`split_links` decodes the multi-URL convention for simplex.contact / simplex.channel text records. Reuses the same rule the dApp's - `parseSimplexUrls` will use, so the two sides round-trip cleanly.""" + `parseSimplexUrls` uses (separator `;`), so the two sides round-trip + cleanly.""" def test_empty_string_yields_empty_list(self): - self.assertEqual(snrc.split_csv(""), []) + self.assertEqual(snrc.split_links(""), []) def test_whitespace_only_yields_empty_list(self): - self.assertEqual(snrc.split_csv(" "), []) - self.assertEqual(snrc.split_csv(" , , "), []) + self.assertEqual(snrc.split_links(" "), []) + self.assertEqual(snrc.split_links(" ; ; "), []) def test_single_url_yields_singleton_list(self): self.assertEqual( - snrc.split_csv("https://smp16.simplex.im/a#H1"), + snrc.split_links("https://smp16.simplex.im/a#H1"), ["https://smp16.simplex.im/a#H1"], ) - def test_two_urls_split_on_comma(self): + def test_two_urls_split_on_separator(self): self.assertEqual( - snrc.split_csv( - "https://smp16.simplex.im/a#H1,https://smp19.simplex.im/a#H1" + snrc.split_links( + "https://smp16.simplex.im/a#H1;https://smp19.simplex.im/a#H1" ), [ "https://smp16.simplex.im/a#H1", @@ -46,10 +47,10 @@ def test_two_urls_split_on_comma(self): ], ) - def test_whitespace_around_commas_is_trimmed(self): + def test_whitespace_around_separators_is_trimmed(self): self.assertEqual( - snrc.split_csv( - " https://smp16.simplex.im/a#H1 ,\thttps://smp19.simplex.im/a#H1 " + snrc.split_links( + " https://smp16.simplex.im/a#H1 ;\thttps://smp19.simplex.im/a#H1 " ), [ "https://smp16.simplex.im/a#H1", @@ -57,16 +58,16 @@ def test_whitespace_around_commas_is_trimmed(self): ], ) - def test_trailing_comma_does_not_produce_empty_entry(self): + def test_trailing_separator_does_not_produce_empty_entry(self): self.assertEqual( - snrc.split_csv("https://smp16.simplex.im/a#H1,"), + snrc.split_links("https://smp16.simplex.im/a#H1;"), ["https://smp16.simplex.im/a#H1"], ) - def test_doubled_comma_does_not_produce_empty_entry(self): + def test_doubled_separator_does_not_produce_empty_entry(self): self.assertEqual( - snrc.split_csv( - "https://smp16.simplex.im/a#H1,,https://smp19.simplex.im/a#H1" + snrc.split_links( + "https://smp16.simplex.im/a#H1;;https://smp19.simplex.im/a#H1" ), [ "https://smp16.simplex.im/a#H1", @@ -76,7 +77,7 @@ def test_doubled_comma_does_not_produce_empty_entry(self): def test_order_is_preserved(self): self.assertEqual( - snrc.split_csv("c,a,b"), + snrc.split_links("c;a;b"), ["c", "a", "b"], )