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 4007479d2..78df84b9c 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -44,3 +44,192 @@ 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. It talks to the same local +Reth + Nimbus stack described above (set `NETWORK=mainnet` in `.env`), +reading the SNRC contracts directly on Ethereum mainnet. + +Dependencies are declared inline (PEP 723) at the top of `snrc-resolve.py` +and in a sibling `pyproject.toml`. The simplest local run uses +[`uv`](https://docs.astral.sh/uv/): + +```sh +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 + +| TLD | Network | ENSRegistry address | +|------------|------------------|----------------------------------------------| +| `.testing` | Ethereum mainnet | `0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6` | +| `.simplex` | — (not deployed) | — | + +Each TLD is an independent ENS-shaped deployment with its own +`ENSRegistry`. The resolver dispatches by the queried name's rightmost +label, so a single instance can serve both TLDs concurrently once +`.simplex` launches. + +### Running + +With Reth bound to `127.0.0.1:8545` (the default Quickstart layout +above), no env vars are required — the script defaults to that RPC and +to the mainnet `.testing` registry: + +```sh +./scripts/resolver/snrc-resolve.py +``` + +Output on startup: + +``` +snrc-resolve listening on 0.0.0.0:8000 + RPC = http://127.0.0.1:8545 + Registries: + .testing = 0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6 + .simplex = (not configured) + GET /resolve/ GET /health +``` + +Override the listen port or bind address with `SNRC_PORT` / `SNRC_BIND`. + +### Running in Docker + +The compose file ships a `resolver` service alongside reth and nimbus. +`docker compose up -d` builds the image from `Dockerfile` (multi-stage, +non-root, `uv`-based) and exposes the API on `127.0.0.1:8000`: + +```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 +multicoin record populated (useful as a smoke-test target): + +```sh +curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq . +``` + +```json +{ + "name": "foobar.testing", + "nickname": "Foo", + "website": "https://foo.bar", + "location": "", + "simplexContact": [ + "https://smp16.simplex.im/a#Q_F00BA7", + "https://smp11.simplex.im/a#Q_F00BA8" + ], + "simplexChannel": [], + "eth": null, + "btc": "bc1qpzht4wp64yg7z6sgl07vvrnepyux740juynfcn", + "xmr": "4ANzdVJFxLtCKcBgNGkFSEA41zJFgrTX93LWt9UR6xpg7YNCsdrSV817cw2xKT8NXeS5euBBqTApS2u8kRTxMhyiDGN3Qgt", + "dot": "139GgyEsXDyGLhmhBTPmDmGCyTvTVuLad3YjHax2PWLK6p3s", + "owner": "0xd83bb610fbad567fb5d8755ec162881e46d1fbc9", + "resolver": "0x80fa1903e70af03e79c73fb7feae2fb33aebae01" +} +``` + +`simplexContact` and `simplexChannel` are arrays so a name can advertise +multiple SMP servers for redundancy. Clients SHOULD try the URLs in +order; the first entry is the primary and the rest are fallbacks. The +on-chain text record stores them as a single comma-separated string +(`"url1,url2,url3"`); this resolver splits, trims whitespace, and drops +empty entries before returning. + +All field names are lowercase-initial and contain no dots, so they map +directly onto Haskell record fields and can be consumed via aeson's +`Generic`-derived `FromJSON` without a key-rewriting layer. Equivalent +Haskell record: + +```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 +(base58check for legacy P2PKH/P2SH), SS58 with Polkadot prefix 0 for +`dot`, Monero-base58 for `xmr`. Unrecognised payloads fall back to +`0x`-prefixed hex. + +#### Subnames + +Subnames work exactly the same. try `bar.foobar.testing`. + +```sh +curl -s http://127.0.0.1:8000/health +# → {"ok": true, "rpc": "http://127.0.0.1:8545", "registries": {"testing": "0x…", "simplex": ""}} +``` + +### Pointing at multiple deployments + +Once `.simplex` deploys, point a single resolver instance at both +registries — requests are dispatched by the rightmost label: + +```sh +SNRC_REGISTRY_SIMPLEX=0x...mainnet-simplex-ENSRegistry... \ + ./scripts/resolver/snrc-resolve.py +``` + +Queries for a TLD with no registry configured return HTTP 400 with the +list of supported TLDs. + +### Error responses + +| Status | When | +|--------|-----------------------------------------------------------------------| +| 400 | TLD not configured (`/resolve/foo.simplex` while `.simplex` is empty) or path not a fully-qualified name | +| 404 | Name has no resolver set on the registry (`ENSRegistry.resolver(node)` is zero) | +| 502 | Upstream RPC error / unreachable (Reth not running or not synced) | 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 new file mode 100755 index 000000000..8b5f21e70 --- /dev/null +++ b/scripts/resolver/snrc-resolve.py @@ -0,0 +1,517 @@ +#!/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 +deployment on Ethereum mainnet (or any compatible ENS-shaped registry) +and returns a flat JSON document with these fields: + + name, nickname, website, location, + 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 +`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 +without a key-rewriting layer. + +Usage: + ./snrc-resolve.py # serve on :8000 + + curl -s http://127.0.0.1:8000/resolve/foobar.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_TESTING ENSRegistry for the .testing deployment + (default: mainnet, + 0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6) + 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. + +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) + 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") +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. +# `... 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", "") + or "0x03f438da0bd44da3c6c1d0392f8ba183b8b3a7a6", # mainnet .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 +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 + # 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() + 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", +] + + +# 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 + 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 `LINK_SEPARATOR`-joined string. + """ + return [item.strip() for item in value.split(LINK_SEPARATOR) if item.strip()] + + +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(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(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 "" + + # 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", ""), + "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), + "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, "registries": REGISTRIES}, + ) + 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" 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: + sys.stderr.write("\nshutting down\n") + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/scripts/resolver/test_snrc_resolve.py b/scripts/resolver/test_snrc_resolve.py new file mode 100644 index 000000000..64e924c06 --- /dev/null +++ b/scripts/resolver/test_snrc_resolve.py @@ -0,0 +1,86 @@ +#!/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 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` uses (separator `;`), so the two sides round-trip + cleanly.""" + + def test_empty_string_yields_empty_list(self): + self.assertEqual(snrc.split_links(""), []) + + def test_whitespace_only_yields_empty_list(self): + self.assertEqual(snrc.split_links(" "), []) + self.assertEqual(snrc.split_links(" ; ; "), []) + + def test_single_url_yields_singleton_list(self): + self.assertEqual( + snrc.split_links("https://smp16.simplex.im/a#H1"), + ["https://smp16.simplex.im/a#H1"], + ) + + def test_two_urls_split_on_separator(self): + self.assertEqual( + snrc.split_links( + "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_separators_is_trimmed(self): + self.assertEqual( + snrc.split_links( + " 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_separator_does_not_produce_empty_entry(self): + self.assertEqual( + snrc.split_links("https://smp16.simplex.im/a#H1;"), + ["https://smp16.simplex.im/a#H1"], + ) + + def test_doubled_separator_does_not_produce_empty_entry(self): + self.assertEqual( + snrc.split_links( + "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_links("c;a;b"), + ["c", "a", "b"], + ) + + +if __name__ == "__main__": + unittest.main()