From 9b2ff82cc7a2e56bebcd0dac9d63b95c4b88d361 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 3 Sep 2026 17:55:00 +0200 Subject: [PATCH 1/7] report expiry and availability correctly --- scripts/resolver/README.md | 88 +++++- scripts/resolver/docker-compose.yml | 8 + scripts/resolver/service/snrc-resolve.py | 185 +++++++++++- scripts/resolver/service/test_snrc_resolve.py | 274 ++++++++++++++++++ 4 files changed, 541 insertions(+), 14 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 1930b3345..7d6808e60 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -119,7 +119,10 @@ uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + main "simplexContact": ["https://smp16.simplex.im/a#…", "https://smp11…"], // primary first, fallbacks after "simplexChannel": [], "eth": null, "btc": "bc1q…", "xmr": "4ANz…", "dot": "139G…", - "owner": "0xd83b…", "resolver": "0x80fa…" + "owner": "0xd83b…", "resolver": "0x80fa…", + "status": "registered", // registered | grace | expired | unregistered | reserved | noResolver | unknown + "expires": 1780000000, // Unix seconds; when the registration ends + "graceEnds": 1787776000 // expires + GRACE_PERIOD; last moment the owner can renew } ``` @@ -129,6 +132,34 @@ text record; the resolver splits/trims/drops-empties. Address encodings are canonical per chain (EIP-55 / bech32 / SS58 / Monero-base58). Subnames work identically (`bar.foobar.testing`). +### Registration status and expiry + +`status`, `expires` and `graceEnds` are on every response that got far enough +to know them, including a successful resolve — so a client that has just +resolved a name already holds its expiry and needs no second request to warn +about it. `expires` and `graceEnds` are Unix timestamps in seconds; both are +`null` when unknown. + +| `status` | Meaning | +|---|---| +| `registered` | live; `expires` is when that ends | +| `grace` | lapsed, but only the previous owner may renew it, until `graceEnds` | +| `expired` | lapsed and past grace — anyone may register it now | +| `unregistered` | never registered, and free to take | +| `reserved` | not registered, and held back — registration will be refused; the body carries a `reason` | +| `noResolver` | registered, but points nowhere | +| `unknown` | no `SNRC_REGISTRAR_` configured, so status could not be read | + +The split between `grace` and `expired` mirrors the registrar's own +`available(id)` rule (`expires + GRACE_PERIOD < now`), with `GRACE_PERIOD` read +from the contract rather than assumed. Note that `available(id)` alone cannot +distinguish these: it is also true for a name nobody ever registered, since +`0 + GRACE_PERIOD < now`. A zero expiry is what separates *never taken* from +*taken and since released*. + +Subnames report the status of the 2LD they sit under, which is the useful +answer — a subname is only as valid as the name above it. + ### Querying by labelhash A client that asks whether a name is free is usually about to register it. @@ -142,8 +173,20 @@ curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d' ``` This works because namehash is `keccak(parent || keccak(label))`. Passing -`keccak(label)` gives the same node, so the resolver reads the same record. It -learns which name you meant only if it guesses the label and hashes it. +`keccak(label)` gives the same node, so the resolver reads the same record — +and the registrar also keys `nameExpires` and `reservedNames` on the labelhash, +so availability needs the label no more than the record does. It learns which +name you meant only if it guesses the label and hashes it. + +Read the answer by `status`: a name is free exactly when the body says +`unregistered` (a 404). Every other answer is a name somebody holds or held +recently — note that `noResolver` is also a 404, and it is a taken name. + +Two practical notes. The hash must be keccak-256: `openssl dgst -sha3-256` and +`sha3sum` compute SHA3-256, a different function, and produce 64 well-formed +hex of nothing you meant. And queries are lowercased before matching, so +uppercase hex works too; strict HTTP stacks that reject raw brackets in a path +can percent-encode them (`%5B`/`%5D`) — both forms load the same name. Brackets keep the two forms from colliding. `[` and `]` are not valid in a normalised ENS name, and the dApp normalises before it registers, so no name @@ -157,23 +200,42 @@ Only 2LDs can be queried by hash. A 2LD is what a registration buys, so it is the only name worth hiding. Subnames are left out because nobody can race you for one: the owner of the 2LD creates them. In a subname the resolver hashes a `[<64 hex>]` label as written instead of decoding it, so such a query points at -a node nobody can own. +a node nobody can own. (ENS tooling reads the bracketed form at any depth; this +resolver deliberately does not.) This hides your interest in a name, and nothing more. The registration itself -is public, and the controller's commit-reveal protects that step. +is public, and the controller's commit-reveal protects that step. Guessing +stays cheap — for a short or brand-like label the hash is a speed bump, not +secrecy — and once the reveal makes the labelhash public, an operator who +logged the probe can link the two. ### Status codes | Status | Meaning | |---|---| -| 200 | resolved | +| 200 | resolved (`status` is `registered`, or `unknown` when no registrar is configured) | | 400 | TLD not configured, or not a fully-qualified name | -| 404 | name has no resolver set on the registry | +| 404 | `unregistered`, `reserved` or `noResolver` — the `status` field says which | +| 410 | registration lapsed — `status` says whether the owner can still renew (`grace`) or anyone may take it (`expired`) | | 502 | upstream RPC error / reth not synced | -### Configuring registries - -Defaults to mainnet `.testing` (`0x03f438…`); `.simplex` is unset until -deployed. Override per TLD via env on the `resolver` service in -`docker-compose.yml` (`SNRC_REGISTRY_TESTING` / `SNRC_REGISTRY_SIMPLEX`), or as -env vars for the standalone script. +### Configuring addresses + +Three maps, all per TLD. The **registry** answers *who owns this node* and is +what `/resolve` reads records from. The **registrar** (ERC-721) holds +`nameExpires` / `GRACE_PERIOD`, and is what every expiry field is read from — +without one for a TLD, `/resolve` still works and reports `"status": "unknown"`. +The **controller** holds `reservedNames`, and is what the `reserved` status is +read from; without one a reserved name reads as `unregistered`. + +All three default to the mainnet `.testing` deployment; `.simplex` is unset +until deployed. Note that the controller default is the **proxy**, not +`SimplexControllerImpl`: storage lives in the proxy, so the implementation +answers nothing. `deployments.mainnet.testing.json` records it under the ENS +role name `ETHRegistrarController` and `verification.mainnet.testing.json` +names it `SimplexControllerProxy` — the same address, and the one defaulted to +here. + +Override per TLD via env on the `resolver` service in `docker-compose.yml` +(`SNRC_REGISTRY_` / `SNRC_REGISTRAR_` / `SNRC_CONTROLLER_`), or +as env vars for the standalone script. diff --git a/scripts/resolver/docker-compose.yml b/scripts/resolver/docker-compose.yml index 570b63df3..84d64e2c7 100644 --- a/scripts/resolver/docker-compose.yml +++ b/scripts/resolver/docker-compose.yml @@ -150,6 +150,14 @@ services: # only if you're deploying against a different network or contract. # SNRC_REGISTRY_TESTING: 0x... # SNRC_REGISTRY_SIMPLEX: 0x... + # Registrar and controller addresses, same cascade. The registrar + # drives the expiry status on /resolve; without it status reads + # "unknown". The controller drives the "reserved" status; without it a + # reserved name reads as "unregistered". + # SNRC_REGISTRAR_TESTING: 0x... + # SNRC_REGISTRAR_SIMPLEX: 0x... + # SNRC_CONTROLLER_TESTING: 0x... + # SNRC_CONTROLLER_SIMPLEX: 0x... ports: - "127.0.0.1:8000:8000" restart: unless-stopped diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index b72cc9a1d..3971f5011 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -39,6 +39,10 @@ 0x58fc46996d975c57883564648bda5206d1a0102b) SNRC_REGISTRY_SIMPLEX ENSRegistry for the .simplex deployment (default: empty — TLD not yet deployed) + SNRC_REGISTRAR_ BaseRegistrar (ERC-721) for the TLD; expiry and status + (default: mainnet for .testing, empty for .simplex) + SNRC_CONTROLLER_ SimplexController (proxy) for the TLD; `reserved` status + (default: mainnet for .testing, empty for .simplex) SNRC_PORT Listen port (default: 8000) SNRC_BIND Bind address (default: 0.0.0.0) @@ -62,6 +66,7 @@ import json import os import sys +import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import unquote, urlparse from urllib.request import Request, urlopen @@ -85,6 +90,35 @@ "simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet } +# The BaseRegistrar (ERC-721) per TLD, used for expiry and status. Separate +# from the registry above: the registry answers "who owns this node", the +# registrar holds nameExpires and GRACE_PERIOD. Not a proxy, so the address in +# deployments is the one that answers. Without one for a TLD, /resolve still +# works and reports "status": "unknown". +REGISTRARS = { + "testing": os.environ.get("SNRC_REGISTRAR_TESTING", "") + or "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a", # mainnet .testing + "simplex": os.environ.get("SNRC_REGISTRAR_SIMPLEX", ""), # not deployed yet +} + +# The SimplexController per TLD, which holds `reservedNames`. Without one for a +# TLD, `reserved` is never reported and a reserved name reads as unregistered. +CONTROLLERS = { + "testing": os.environ.get("SNRC_CONTROLLER_TESTING", "") + # The proxy, not SimplexControllerImpl: storage and events live in the + # proxy, so the implementation address answers nothing. deployments.json + # records this one under the ENS role name ETHRegistrarController; + # verification.json names it SimplexControllerProxy. Same address. + or "0xeeb9b6bf5fb68fb726005f7ba549c2f4b32f2dad", # mainnet .testing + "simplex": os.environ.get("SNRC_CONTROLLER_SIMPLEX", ""), # not deployed yet +} + +# Why a name is reserved. `reservedNames` stores only the fact, so every +# reserved name gets this same sentence; a per-name lookup (table or REST) is +# the intended replacement. Callers should render whatever this field holds +# rather than matching on its text. +RESERVED_REASON = "reserved for a brand or public interest" + # SLIP-44 coin types (https://github.com/satoshilabs/slips/blob/master/slip-0044.md) COIN_ETH = 60 COIN_BTC = 0 @@ -165,6 +199,102 @@ def node_of(name: str) -> bytes: return namehash(name) +# ---------- Registration status ---------- + + +def grace_period(registrar: str) -> int: + """The registrar's own GRACE_PERIOD, in seconds. + + Read from the chain rather than hardcoded, so a deployment that chooses a + different window is reported correctly instead of confidently wrongly. One + call per request, not per name. + """ + return decode_uint(eth_call(registrar, selector("GRACE_PERIOD()"))) + + +def expiry_status(expires: int, grace: int, now: int) -> str: + """Registration state from an expiry timestamp. + + Mirrors the registrar's `available(id)`, which is + `expiries[id] + GRACE_PERIOD < block.timestamp`. Note that `available` + alone cannot be used for this: it is also true for a name nobody ever + registered, since `0 + GRACE_PERIOD < now`. The zero expiry is what + separates "never taken" from "lapsed and now free". + """ + if expires == 0: + return "unregistered" + if expires > now: + return "registered" + if expires + grace >= now: + # Expired, but only the previous owner may renew it - nobody else can + # take it yet. + return "grace" + return "expired" + + +def is_reserved(tld: str, token: int) -> bool: + """Whether the controller holds this label for a brand. + + Keyed by labelhash on chain, so this answers for a hashed query too. + """ + controller = CONTROLLERS.get(tld) + if not controller: + return False + raw = eth_call(controller, selector("reservedNames(bytes32)") + encode_uint(token)) + return decode_uint(raw) != 0 + + +def name_status(name: str): + """Registration status of the 2LD a name sits under. + + Names expire lazily: the registrar keeps the record and simply stops + treating it as live, so "never registered" and "expired last Tuesday" are + both readable rather than both being absence. `nameExpires` returns 0 for a + label that was never registered, which is what separates the two. + + Subnames are not registered here, so the status of `x.alice.testing` is the + status of `alice.testing` - which is the useful answer, since a subname is + only as valid as the 2LD above it. + """ + labels = name.split(".") + tld = labels[-1] + registrar = REGISTRARS.get(tld) + if not registrar or len(labels) < 2: + # No registrar configured for this TLD: say so rather than guess. + return {"status": "unknown", "expires": None, "graceEnds": None} + + # The registration facts (nameExpires, reservedNames) are keyed on + # uint256(keccak(label)), so a 2LD queried by its encoded labelhash gets + # the same answer without the label. The bracket form decodes only there - + # the same rule node_of applies to the node itself. + label = labels[-2] + if len(labels) == 2 and is_encoded_labelhash(label): + token = int(label[1:-1], 16) + else: + token = int.from_bytes(keccak(label.encode()), "big") + expires = decode_uint( + eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token)) + ) + if expires == 0: + status, grace = "unregistered", 0 + else: + grace = grace_period(registrar) + status = expiry_status(expires, grace, int(time.time())) + + # `reserved` only displaces the two states that read as "you could take + # this". A registered name is registered, and one in grace belongs to its + # owner either way - in both cases the reservation is not the answer to the + # question being asked. + if status in ("unregistered", "expired") and is_reserved(tld, token): + status = "reserved" + + return { + "status": status, + "expires": expires or None, + "graceEnds": (expires + grace) if expires else None, + } + + def selector(signature: str) -> str: return "0x" + keccak(signature.encode())[:4].hex() @@ -185,6 +315,15 @@ def decode_bytes(hex_data: str) -> bytes: return raw[64:64 + length] +def decode_uint(hex_data: str) -> int: + raw = hex_data[2:] if hex_data.startswith("0x") else hex_data + return int(raw[-64:], 16) if raw else 0 + + +def encode_uint(value: int) -> str: + return value.to_bytes(32, "big").hex() + + def encode_text_call(node: bytes, key: str) -> str: sel = selector("text(bytes32,string)") head = node.hex() + (0x40).to_bytes(32, "big").hex() @@ -446,10 +585,51 @@ def resolve(name: str): node = node_of(name) node_hex = node.hex() + # Registration first, because it is the fact that separates the failures a + # caller has to tell apart: a name nobody has taken, one whose registration + # lapsed and may still be renewed, one that lapsed and is now open to + # anyone, and one that is held but not pointed anywhere. + reg = name_status(name) + if reg["status"] in ("unregistered", "reserved"): + body = { + "name": name, + "status": reg["status"], + "expires": reg["expires"], + "graceEnds": reg["graceEnds"], + "error": ( + "this name is reserved and cannot be registered" + if reg["status"] == "reserved" + else "this name has never been registered" + ), + } + # Only reserved names carry a reason, so its presence is the signal + # that one is known. + if reg["status"] == "reserved": + body["reason"] = RESERVED_REASON + return 404, body + if reg["status"] in ("grace", "expired"): + return 410, { + "name": name, + "status": reg["status"], + "expires": reg["expires"], + "graceEnds": reg["graceEnds"], + "error": ( + "this registration expired and can be renewed by its owner" + if reg["status"] == "grace" + else "this registration expired and is open to anyone" + ), + } + 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"} + return 404, { + "name": name, + "status": "noResolver", + "expires": reg["expires"], + "graceEnds": reg["graceEnds"], + "error": "no resolver set for this name", + } owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex) owner = decode_address(owner_raw) @@ -485,6 +665,9 @@ def resolve(name: str): "dot": addr_multicoin(resolver_addr, node, COIN_DOT), "owner": owner, "resolver": resolver_addr, + "status": reg["status"], + "expires": reg["expires"], + "graceEnds": reg["graceEnds"], } diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index aea000a04..857a2bf5c 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -6,6 +6,7 @@ import importlib.util import os +import time import unittest # snrc-resolve.py has a hyphen, so import it via importlib instead of `import`. @@ -96,6 +97,17 @@ class EncodedLabelhashTests(unittest.TestCase): # keccak-256("alice") = 9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501 # - written out in full wherever a test needs a real labelhash. + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + GRACE = 90 * 86400 + + def setUp(self): + self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call) + snrc.REGISTRARS = {"testing": self.REGISTRAR} + snrc.CONTROLLERS = {"testing": ""} + + def tearDown(self): + snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved + def test_the_encoded_form_is_recognised(self): self.assertTrue( snrc.is_encoded_labelhash( @@ -162,6 +174,268 @@ def test_a_malformed_bracket_label_falls_back_to_a_literal_name(self): name = "[nothex].testing" self.assertEqual(snrc.node_of(name), snrc.namehash(name)) + def test_status_by_hash_matches_status_by_name(self): + """The registrar keys registration data on the labelhash too, so a + hashed query answers "is it free?" - not only "what does it say?" - + without the label.""" + future = int(time.time()) + 86400 + seen = [] + + def eth_call(to, data): + seen.append(data) + if data.startswith(snrc.selector("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(self.GRACE) + return "0x" + snrc.encode_uint(future) + + snrc.eth_call = eth_call + by_name = snrc.name_status("alice.testing") + by_hash = snrc.name_status( + "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]" + ".testing" + ) + self.assertEqual(by_name, by_hash) + self.assertEqual(by_name["status"], "registered") + # nothing in either request carried the label itself + self.assertTrue(all("alice".encode().hex() not in d for d in seen)) + + +class NameStatusTests(unittest.TestCase): + """unresolvable has three causes and a caller has to tell them apart. + Names expire lazily, so the chain still holds the answer.""" + + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + + GRACE = 90 * 86400 + + def _expiry(self, value): + def eth_call(to, data): + if data.startswith(snrc.selector("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(self.GRACE) + self.assertTrue(data.startswith(snrc.selector("nameExpires(uint256)"))) + return "0x" + snrc.encode_uint(value) + + return eth_call + + def setUp(self): + self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call) + snrc.REGISTRARS = {"testing": self.REGISTRAR} + # These cases are about expiry alone. ReservedTests covers what a + # configured controller adds. + snrc.CONTROLLERS = {"testing": ""} + + def tearDown(self): + snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved + + def test_zero_expiry_means_never_registered(self): + snrc.eth_call = self._expiry(0) + self.assertEqual( + snrc.name_status("alice.testing"), + {"status": "unregistered", "expires": None, "graceEnds": None}, + ) + + def test_recently_expired_is_in_grace_and_says_when_it_ends(self): + """Only the previous owner may renew during grace - nobody else can + take the name yet, so this is a different answer from `expired`.""" + past = int(time.time()) - 3600 + snrc.eth_call = self._expiry(past) + self.assertEqual( + snrc.name_status("alice.testing"), + {"status": "grace", "expires": past, "graceEnds": past + self.GRACE}, + ) + + def test_past_the_grace_window_it_is_expired_and_claimable(self): + past = int(time.time()) - self.GRACE - 3600 + snrc.eth_call = self._expiry(past) + self.assertEqual(snrc.name_status("alice.testing")["status"], "expired") + + def test_the_boundary_belongs_to_grace(self): + """The registrar frees a name when expires + GRACE < now, so the last + second of the window is still the owner's.""" + now = int(time.time()) + snrc.eth_call = self._expiry(now - self.GRACE) + self.assertEqual(snrc.name_status("alice.testing")["status"], "grace") + + def test_future_expiry_is_registered(self): + future = int(time.time()) + 3600 + snrc.eth_call = self._expiry(future) + self.assertEqual( + snrc.name_status("alice.testing"), + {"status": "registered", "expires": future, "graceEnds": future + self.GRACE}, + ) + + def test_never_registered_is_not_confused_with_claimable(self): + """`available(id)` is true for both, since 0 + GRACE < now. The zero + expiry is the only thing that separates them.""" + snrc.eth_call = self._expiry(0) + self.assertEqual(snrc.name_status("alice.testing")["status"], "unregistered") + self.assertNotEqual(snrc.name_status("alice.testing")["status"], "expired") + + def test_a_subname_reports_the_status_of_its_2ld(self): + future = int(time.time()) + 3600 + seen = [] + + def eth_call(to, data): + seen.append(data) + return "0x" + snrc.encode_uint(future) + + snrc.eth_call = eth_call + self.assertEqual(snrc.name_status("x.alice.testing")["status"], "registered") + # the token asked about is keccak("alice"), not keccak("x") + self.assertTrue(seen[0].endswith(snrc.keccak(b"alice").hex())) + + def test_unconfigured_tld_is_unknown_rather_than_unregistered(self): + snrc.REGISTRARS = {"testing": ""} + snrc.eth_call = lambda *a: self.fail("must not reach the chain") + self.assertEqual( + snrc.name_status("alice.testing"), + {"status": "unknown", "expires": None, "graceEnds": None}, + ) + + def test_every_branch_returns_the_same_keys(self): + """Callers read status/expires/graceEnds unconditionally, so a branch + that omits one is a KeyError in the caller rather than a missing field + in the JSON.""" + keys = {"status", "expires", "graceEnds"} + snrc.eth_call = self._expiry(0) + self.assertEqual(set(snrc.name_status("alice.testing")), keys) + snrc.eth_call = self._expiry(int(time.time()) + 3600) + self.assertEqual(set(snrc.name_status("alice.testing")), keys) + snrc.REGISTRARS = {"testing": ""} + snrc.eth_call = lambda *a: self.fail("must not reach the chain") + self.assertEqual(set(snrc.name_status("alice.testing")), keys) + + +class ReservedTests(unittest.TestCase): + """A reserved name is unregistered and still unavailable, which a client + intending to register needs to know before it tries.""" + + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" + + def setUp(self): + self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call) + snrc.REGISTRARS = {"testing": self.REGISTRAR} + snrc.CONTROLLERS = {"testing": self.CONTROLLER} + + def tearDown(self): + snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved + + def _chain(self, expires, reserved): + def eth_call(to, data): + if data.startswith(snrc.selector("reservedNames(bytes32)")): + self.assertEqual(to, self.CONTROLLER) + return "0x" + snrc.encode_uint(1 if reserved else 0) + if data.startswith(snrc.selector("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(90 * 86400) + return "0x" + snrc.encode_uint(expires) + + return eth_call + + def test_unregistered_and_reserved_reads_reserved(self): + snrc.eth_call = self._chain(0, True) + self.assertEqual(snrc.name_status("acme.testing")["status"], "reserved") + + def test_unregistered_and_not_reserved_reads_unregistered(self): + snrc.eth_call = self._chain(0, False) + self.assertEqual(snrc.name_status("acme.testing")["status"], "unregistered") + + def test_a_lapsed_reserved_name_is_reserved_not_claimable(self): + past = int(time.time()) - 91 * 86400 + snrc.eth_call = self._chain(past, True) + self.assertEqual(snrc.name_status("acme.testing")["status"], "reserved") + + def test_a_live_name_is_registered_even_if_reserved(self): + """It was handed to its brand; the reservation is no longer the answer.""" + snrc.eth_call = self._chain(int(time.time()) + 86400, True) + self.assertEqual(snrc.name_status("acme.testing")["status"], "registered") + + def test_a_name_in_grace_belongs_to_its_owner_not_the_reserved_set(self): + snrc.eth_call = self._chain(int(time.time()) - 3600, True) + self.assertEqual(snrc.name_status("acme.testing")["status"], "grace") + + def test_no_controller_configured_means_reserved_is_never_reported(self): + snrc.CONTROLLERS = {"testing": ""} + snrc.eth_call = self._chain(0, True) # would say reserved if asked + self.assertEqual(snrc.name_status("acme.testing")["status"], "unregistered") + + def test_reserved_is_asked_by_labelhash_so_a_hashed_query_works(self): + # keccak-256("acme") + hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]" + snrc.eth_call = self._chain(0, True) + self.assertEqual(snrc.name_status(hashed + ".testing")["status"], "reserved") + + +class ReservedReasonTests(unittest.TestCase): + """Why a name is reserved travels in its own field, so a client can show it + without parsing the message, and so a per-name reason can replace the fixed + one without moving anything.""" + + REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" + + def setUp(self): + self._saved = ( + snrc.REGISTRIES, + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + ) + snrc.REGISTRIES = {"testing": self.REGISTRY} + snrc.REGISTRARS = {"testing": self.REGISTRAR} + snrc.CONTROLLERS = {"testing": self.CONTROLLER} + + def tearDown(self): + ( + snrc.REGISTRIES, + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + ) = self._saved + + def _chain(self, expires, reserved): + def eth_call(to, data): + if data.startswith(snrc.selector("reservedNames(bytes32)")): + return "0x" + snrc.encode_uint(1 if reserved else 0) + if data.startswith(snrc.selector("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(90 * 86400) + return "0x" + snrc.encode_uint(expires) + + return eth_call + + def test_a_reserved_name_carries_the_reason(self): + snrc.eth_call = self._chain(0, True) + status, body = snrc.resolve("acme.testing") + self.assertEqual(status, 404) + self.assertEqual(body["status"], "reserved") + self.assertEqual(body["reason"], "reserved for a brand or public interest") + + def test_the_message_does_not_claim_a_trademark(self): + snrc.eth_call = self._chain(0, True) + _, body = snrc.resolve("acme.testing") + self.assertNotIn("trademark", body["error"]) + + def test_an_unregistered_name_has_no_reason(self): + snrc.eth_call = self._chain(0, False) + status, body = snrc.resolve("acme.testing") + self.assertEqual(status, 404) + self.assertEqual(body["status"], "unregistered") + self.assertNotIn("reason", body) + + def test_an_expired_name_has_no_reason(self): + snrc.eth_call = self._chain(1, False) + status, body = snrc.resolve("acme.testing") + self.assertEqual(status, 410) + self.assertEqual(body["status"], "expired") + self.assertNotIn("reason", body) + + def test_a_hashed_query_gets_the_reason_too(self): + snrc.eth_call = self._chain(0, True) + # keccak-256("acme") + hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]" + _, body = snrc.resolve(hashed + ".testing") + self.assertEqual(body["reason"], "reserved for a brand or public interest") + if __name__ == "__main__": unittest.main() From 41678c74bb3299baab60a0d1465bf04b43c578af Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 3 Sep 2026 18:55:53 +0200 Subject: [PATCH 2/7] compare block instead of wall clock --- scripts/resolver/README.md | 10 ++-- scripts/resolver/service/snrc-resolve.py | 13 ++++- scripts/resolver/service/test_snrc_resolve.py | 47 ++++++++++++++++--- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 7d6808e60..9893af5c4 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -152,10 +152,12 @@ about it. `expires` and `graceEnds` are Unix timestamps in seconds; both are The split between `grace` and `expired` mirrors the registrar's own `available(id)` rule (`expires + GRACE_PERIOD < now`), with `GRACE_PERIOD` read -from the contract rather than assumed. Note that `available(id)` alone cannot -distinguish these: it is also true for a name nobody ever registered, since -`0 + GRACE_PERIOD < now`. A zero expiry is what separates *never taken* from -*taken and since released*. +from the contract rather than assumed and `now` taken from the latest block's +timestamp rather than the host clock — the registrar compares against that same +clock, so a skewed machine cannot misstate a registration. Note that +`available(id)` alone cannot distinguish these: it is also true for a name +nobody ever registered, since `0 + GRACE_PERIOD < now`. A zero expiry is what +separates *never taken* from *taken and since released*. Subnames report the status of the 2LD they sit under, which is the useful answer — a subname is only as valid as the name above it. diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 3971f5011..c44b2e9db 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -66,7 +66,6 @@ import json import os import sys -import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import unquote, urlparse from urllib.request import Request, urlopen @@ -202,6 +201,16 @@ def node_of(name: str) -> bytes: # ---------- Registration status ---------- +def chain_now() -> int: + """The latest block's timestamp - the same clock the registrar reads. + + Asked explicitly rather than taken from the host clock, so a skewed clock + on this machine cannot misstate a registration. + """ + block = rpc("eth_getBlockByNumber", ["latest", False]) + return decode_uint(block["timestamp"]) + + def grace_period(registrar: str) -> int: """The registrar's own GRACE_PERIOD, in seconds. @@ -279,7 +288,7 @@ def name_status(name: str): status, grace = "unregistered", 0 else: grace = grace_period(registrar) - status = expiry_status(expires, grace, int(time.time())) + status = expiry_status(expires, grace, chain_now()) # `reserved` only displaces the two states that read as "you could take # this". A registered name is registered, and one in grace belongs to its diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 857a2bf5c..02374831f 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -101,12 +101,13 @@ class EncodedLabelhashTests(unittest.TestCase): GRACE = 90 * 86400 def setUp(self): - self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call) + self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now) snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": ""} + snrc.chain_now = lambda: int(time.time()) def tearDown(self): - snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved + snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved def test_the_encoded_form_is_recognised(self): self.assertTrue( @@ -217,14 +218,44 @@ def eth_call(to, data): return eth_call def setUp(self): - self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call) + self._saved = ( + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + snrc.chain_now, + snrc.rpc, + ) snrc.REGISTRARS = {"testing": self.REGISTRAR} # These cases are about expiry alone. ReservedTests covers what a # configured controller adds. snrc.CONTROLLERS = {"testing": ""} + snrc.chain_now = lambda: int(time.time()) def tearDown(self): - snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved + ( + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + snrc.chain_now, + snrc.rpc, + ) = self._saved + + def test_now_is_the_latest_blocks_timestamp(self): + # setUp replaces chain_now with the fixture clock; this is about the + # real one, saved as the 4th element of the setUp snapshot + real_chain_now = self._saved[3] + snrc.rpc = lambda method, params: {"timestamp": "0x65f1a2c0", "number": "0x123"} + self.assertEqual(real_chain_now(), 0x65F1A2C0) + + def test_status_reads_the_chain_clock_not_the_host_clock(self): + """The registrar compares expiry to block.timestamp, so the resolver + must too - a host clock years ahead must not turn a live name into a + claimable one.""" + future = int(time.time()) + 3600 + snrc.eth_call = self._expiry(future) + self.assertEqual(snrc.name_status("alice.testing")["status"], "registered") + snrc.chain_now = lambda: future + 3650 * 86400 + self.assertEqual(snrc.name_status("alice.testing")["status"], "expired") def test_zero_expiry_means_never_registered(self): snrc.eth_call = self._expiry(0) @@ -313,12 +344,13 @@ class ReservedTests(unittest.TestCase): CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" def setUp(self): - self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call) + self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now) snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": self.CONTROLLER} + snrc.chain_now = lambda: int(time.time()) def tearDown(self): - snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved + snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved def _chain(self, expires, reserved): def eth_call(to, data): @@ -380,10 +412,12 @@ def setUp(self): snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, + snrc.chain_now, ) snrc.REGISTRIES = {"testing": self.REGISTRY} snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": self.CONTROLLER} + snrc.chain_now = lambda: int(time.time()) def tearDown(self): ( @@ -391,6 +425,7 @@ def tearDown(self): snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, + snrc.chain_now, ) = self._saved def _chain(self, expires, reserved): From 2cfc6d3a76feece7f4f07de1b75146e8b70fe033 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 4 Sep 2026 08:46:10 +0200 Subject: [PATCH 3/7] simplify language --- scripts/resolver/README.md | 148 ++++++++++++++++++++----------------- 1 file changed, 81 insertions(+), 67 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 9893af5c4..a6b7ec959 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -19,15 +19,15 @@ against **Ethereum mainnet** (where the `.testing` contracts live): ## 1. Configure -Edit `.env` — the defaults work as-is; override only if needed: +Edit `.env`. The defaults work as they are; change them only if you need to: ```sh NETWORK=mainnet # default TRUSTED_NODE_URL=https://mainnet-checkpoint-sync.attestant.io # default ``` -Everything else (NAT) has a working default baked into `docker-compose.yml`; -uncomment the hints in `.env` only to override. +Everything else (NAT) already has a working default in `docker-compose.yml`. +Uncomment the hints in `.env` only if you need to change one. ## 2. Run @@ -37,7 +37,7 @@ docker compose up -d docker compose logs -f reth resolver ``` -`depends_on` handles ordering automatically (start node → start resolver). +Compose starts the node before the resolver; `depends_on` takes care of that. ## 3. Wait for the node to sync @@ -45,12 +45,13 @@ docker compose logs -f reth resolver docker compose logs --tail=20 reth ``` -This is the long pole (~1 day on mainnet). Until reth is synced the resolver -returns `502`. +This is the slow step: about a day on mainnet. Until reth has synced, the +resolver returns `502`. ## Verify -Run these once the stack is up (the node-dependent ones pass after sync): +Run the three checks below once the stack is up. The ones that need chain data +pass only after the node has synced. **1. reth is reachable and reporting a block:** ```sh @@ -71,7 +72,7 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq # → {"name":"foobar.testing","nickname":"Foo","simplexContact":["https://smp16.simplex.im/a#…"], … } ``` -**Wire your smp-server:** in its `[NAMES]` section set +**Point your smp-server at it:** in its `[NAMES]` section set `resolver_endpoint: http://127.0.0.1:8000` (no auth needed for loopback). ## Ports (all loopback unless noted) @@ -86,9 +87,10 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq ## Caveats -- **All images track `:latest`** (reth, nimbus) — you get upstream fixes on each - `docker compose pull`; re-run the verify checks after pulling. -- All ports bind to loopback; expose only what you put behind a TLS reverse proxy. +- **All images track `:latest`** (reth, nimbus). Each `docker compose pull` + brings upstream fixes, so re-run the checks above afterwards. +- All ports bind to loopback. Expose only what you put behind a TLS reverse + proxy. ## Teardown @@ -103,8 +105,9 @@ docker compose down -v # also wipe volumes → full re-sync ## Resolver API reference -The resolver (`snrc-resolve.py`, host `127.0.0.1:8000`) is also runnable -standalone for local dev (no Docker), via [`uv`](https://docs.astral.sh/uv/): +You can also run the resolver (`snrc-resolve.py`, host `127.0.0.1:8000`) on its +own for local development, without Docker, using +[`uv`](https://docs.astral.sh/uv/): ```sh uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + mainnet .testing @@ -126,19 +129,20 @@ uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + main } ``` -`simplexContact`/`simplexChannel` are arrays (a name can advertise multiple SMP -servers; clients try them in order). On-chain they're a single comma-separated -text record; the resolver splits/trims/drops-empties. Address encodings are -canonical per chain (EIP-55 / bech32 / SS58 / Monero-base58). Subnames work -identically (`bar.foobar.testing`). +`simplexContact` and `simplexChannel` are arrays, because a name can advertise +several SMP servers; clients try them in order. On chain each one is a single +text record with the entries joined by `;`. The resolver splits that record, +trims each entry and drops the empty ones. Addresses come back in each chain's +usual format (EIP-55, bech32, SS58, Monero base58). Subnames work the same way +(`bar.foobar.testing`). ### Registration status and expiry -`status`, `expires` and `graceEnds` are on every response that got far enough -to know them, including a successful resolve — so a client that has just -resolved a name already holds its expiry and needs no second request to warn -about it. `expires` and `graceEnds` are Unix timestamps in seconds; both are -`null` when unknown. +A response carries `status`, `expires` and `graceEnds` whenever the resolver +got far enough to read them, a successful resolve included. A client that has +just resolved a name therefore already has its expiry, and needs no second +request to warn about it. `expires` and `graceEnds` are Unix timestamps in +seconds, and both are `null` when the resolver could not read them. | `status` | Meaning | |---|---| @@ -150,17 +154,19 @@ about it. `expires` and `graceEnds` are Unix timestamps in seconds; both are | `noResolver` | registered, but points nowhere | | `unknown` | no `SNRC_REGISTRAR_` configured, so status could not be read | -The split between `grace` and `expired` mirrors the registrar's own -`available(id)` rule (`expires + GRACE_PERIOD < now`), with `GRACE_PERIOD` read -from the contract rather than assumed and `now` taken from the latest block's -timestamp rather than the host clock — the registrar compares against that same -clock, so a skewed machine cannot misstate a registration. Note that -`available(id)` alone cannot distinguish these: it is also true for a name -nobody ever registered, since `0 + GRACE_PERIOD < now`. A zero expiry is what -separates *never taken* from *taken and since released*. +The resolver tells `grace` and `expired` apart with the registrar's own +`available(id)` rule, `expires + GRACE_PERIOD < now`. The resolver reads +`GRACE_PERIOD` from the contract instead of assuming it, and takes `now` from +the latest block's timestamp instead of the host clock. The registrar compares +against that same block timestamp, so a machine with a wrong clock cannot +misreport a registration. -Subnames report the status of the 2LD they sit under, which is the useful -answer — a subname is only as valid as the name above it. +`available(id)` on its own cannot tell the two apart, because it is also true +for a name nobody ever registered: `0 + GRACE_PERIOD < now`. The resolver uses +a zero expiry to tell *never registered* from *registered and since released*. + +A subname reports the status of the 2LD above it. That is the answer a client +needs, because a subname is only as good as the name it sits under. ### Querying by labelhash @@ -175,20 +181,23 @@ curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d' ``` This works because namehash is `keccak(parent || keccak(label))`. Passing -`keccak(label)` gives the same node, so the resolver reads the same record — -and the registrar also keys `nameExpires` and `reservedNames` on the labelhash, -so availability needs the label no more than the record does. It learns which -name you meant only if it guesses the label and hashes it. +`keccak(label)` gives the same node, so the resolver reads the same record. The +registrar keys `nameExpires` and `reservedNames` on the labelhash as well, so +the status fields do not need the label either. The resolver learns which name +you meant only if it guesses the label and hashes it. + +Read the answer from `status`. A name is free only when the body says +`unregistered`, which comes with a 404. Every other status means somebody holds +the name or held it recently. Watch out for `noResolver`: it is also a 404, but +the name is taken. -Read the answer by `status`: a name is free exactly when the body says -`unregistered` (a 404). Every other answer is a name somebody holds or held -recently — note that `noResolver` is also a 404, and it is a taken name. +The hash must be keccak-256. `openssl dgst -sha3-256` and `sha3sum` compute +SHA3-256, which is a different function. They return 64 valid-looking hex +characters that point at the wrong node. -Two practical notes. The hash must be keccak-256: `openssl dgst -sha3-256` and -`sha3sum` compute SHA3-256, a different function, and produce 64 well-formed -hex of nothing you meant. And queries are lowercased before matching, so -uppercase hex works too; strict HTTP stacks that reject raw brackets in a path -can percent-encode them (`%5B`/`%5D`) — both forms load the same name. +The resolver lowercases the query before matching, so uppercase hex works too. +HTTP clients that refuse raw brackets in a path can percent-encode them as +`%5B` and `%5D`. Both forms reach the same name. Brackets keep the two forms from colliding. `[` and `]` are not valid in a normalised ENS name, and the dApp normalises before it registers, so no name @@ -202,14 +211,14 @@ Only 2LDs can be queried by hash. A 2LD is what a registration buys, so it is the only name worth hiding. Subnames are left out because nobody can race you for one: the owner of the 2LD creates them. In a subname the resolver hashes a `[<64 hex>]` label as written instead of decoding it, so such a query points at -a node nobody can own. (ENS tooling reads the bracketed form at any depth; this -resolver deliberately does not.) +a node nobody can own. ENS tooling accepts the bracketed form at any depth; +this resolver does not, on purpose. This hides your interest in a name, and nothing more. The registration itself -is public, and the controller's commit-reveal protects that step. Guessing -stays cheap — for a short or brand-like label the hash is a speed bump, not -secrecy — and once the reveal makes the labelhash public, an operator who -logged the probe can link the two. +is public, and the controller's commit-reveal protects that step. The hash is +also easy to guess for a short or well-known label, since an operator can hash +candidate labels and compare. And once you register, the reveal publishes the +labelhash, so an operator who logged your query can match it to the name. ### Status codes @@ -223,21 +232,26 @@ logged the probe can link the two. ### Configuring addresses -Three maps, all per TLD. The **registry** answers *who owns this node* and is -what `/resolve` reads records from. The **registrar** (ERC-721) holds -`nameExpires` / `GRACE_PERIOD`, and is what every expiry field is read from — -without one for a TLD, `/resolve` still works and reports `"status": "unknown"`. -The **controller** holds `reservedNames`, and is what the `reserved` status is -read from; without one a reserved name reads as `unregistered`. - -All three default to the mainnet `.testing` deployment; `.simplex` is unset -until deployed. Note that the controller default is the **proxy**, not -`SimplexControllerImpl`: storage lives in the proxy, so the implementation -answers nothing. `deployments.mainnet.testing.json` records it under the ENS -role name `ETHRegistrarController` and `verification.mainnet.testing.json` -names it `SimplexControllerProxy` — the same address, and the one defaulted to +The resolver reads three contracts, each configured per TLD. + +The **registry** answers who owns a node, and `/resolve` reads the records from +it. The **registrar** (ERC-721) holds `nameExpires` and `GRACE_PERIOD`, which +is where every expiry field comes from. With no registrar for a TLD, `/resolve` +still works and reports `"status": "unknown"`. The **controller** holds +`reservedNames`, which is where the `reserved` status comes from. With no +controller, a reserved name reads as `unregistered`. + +All three default to the mainnet `.testing` deployment. `.simplex` is unset +until it is deployed. + +The controller default is the **proxy**, not `SimplexControllerImpl`. Storage +lives in the proxy, so the implementation address answers nothing. The two +deployment files use different names for that proxy: +`deployments.mainnet.testing.json` records it under the ENS role name +`ETHRegistrarController`, and `verification.mainnet.testing.json` calls it +`SimplexControllerProxy`. Both are the same address, and it is the one used here. -Override per TLD via env on the `resolver` service in `docker-compose.yml` -(`SNRC_REGISTRY_` / `SNRC_REGISTRAR_` / `SNRC_CONTROLLER_`), or -as env vars for the standalone script. +To override any of them, set `SNRC_REGISTRY_`, `SNRC_REGISTRAR_` or +`SNRC_CONTROLLER_` on the `resolver` service in `docker-compose.yml`, or +as env vars when you run the script directly. \ No newline at end of file From e4e3d0dc7cb8c3a60455ae6dd2a584d47fa6855e Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 4 Sep 2026 08:51:12 +0200 Subject: [PATCH 4/7] simplify language --- scripts/resolver/service/snrc-resolve.py | 47 ++++++++++--------- scripts/resolver/service/test_snrc_resolve.py | 20 ++++---- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index c44b2e9db..ef2a123ba 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -157,14 +157,16 @@ def namehash(name: str) -> bytes: return node -# ENS writes a label whose preimage it does not know as `[<64 hex>]`, and that -# is the form reused here for a label the caller deliberately withholds. The -# brackets are what keep the two forms apart: `[` and `]` are outside the -# normalised character set, so no registrable name can take this shape, and the -# ecosystem already reads it back as a hash (ensjs `isEncodedLabelhash`; the -# subgraph refuses any real label containing a bracket). A bare `0x…` label -# would not be safe this way - that is an ordinary, registrable name, kept from -# clashing only by a registrar length cap that its owner can raise. +# ENS writes a label whose preimage it does not know as `[<64 hex>]`, and this +# resolver reuses that form for a label the caller withholds on purpose. +# Brackets keep the two forms from colliding: `[` and `]` are not valid in a +# normalised ENS name, and the dApp normalises before it registers. Nothing on +# chain checks the character set, but a bracketed labelhash is 66 bytes and the +# registrar's maxLabelLength is 63, so it cannot be registered directly either. +# The ecosystem already reads this form back as a hash (ensjs +# `isEncodedLabelhash`; the subgraph rejects any real label containing a +# bracket). A plain `0x…` label would not work: that is an ordinary name anyone +# can register. ENCODED_LABELHASH_LEN = 66 # "[" + 64 hex + "]" @@ -180,17 +182,16 @@ def is_encoded_labelhash(label: str) -> bool: def node_of(name: str) -> bytes: """namehash, accepting an encoded labelhash in place of a 2LD's label. - A client checking whether a name is free is usually about to register it, - so the question itself is worth front-running. namehash is defined as - keccak(parent || keccak(label)), so a caller who supplies keccak(label) - reaches the same node having never sent the label. - - Only 2LDs may be queried this way: that is the name a registration is - bought for, so the only one worth hiding. Subnames of any depth are - excluded - a subname is created by the 2LD's owner, nobody can race a - caller for one, so there is nothing to front-run. A label in `[<64 hex>]` - form there is hashed literally, not decoded; as brackets cannot occur in a - real registration, such a query names a node nobody can own. + A client that asks whether a name is free is usually about to register it, + and whoever runs the resolver could register it first. namehash is + keccak(parent || keccak(label)), so passing keccak(label) reaches the same + node without sending the label. + + Only 2LDs can be queried this way. A 2LD is what a registration buys, so it + is the only name worth hiding. Subnames are left out because nobody can + race a caller for one: the owner of the 2LD creates them. In a subname a + `[<64 hex>]` label is hashed as written instead of decoded, so such a query + points at a node nobody can own. """ labels = name.split(".") if len(labels) == 2 and is_encoded_labelhash(labels[0]): @@ -272,10 +273,10 @@ def name_status(name: str): # No registrar configured for this TLD: say so rather than guess. return {"status": "unknown", "expires": None, "graceEnds": None} - # The registration facts (nameExpires, reservedNames) are keyed on - # uint256(keccak(label)), so a 2LD queried by its encoded labelhash gets - # the same answer without the label. The bracket form decodes only there - - # the same rule node_of applies to the node itself. + # The registrar keys the registration facts (nameExpires, reservedNames) on + # uint256(keccak(label)), so a 2LD queried by its encoded labelhash gets the + # same answer without the label. Decode the bracket form for a 2LD only, + # which is the rule node_of applies to the node. label = labels[-2] if len(labels) == 2 and is_encoded_labelhash(label): token = int(label[1:-1], 16) diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 02374831f..127238af8 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -86,13 +86,13 @@ def test_order_is_preserved(self): class EncodedLabelhashTests(unittest.TestCase): """Querying by labelhash instead of by label. - A client asking whether a name is free is usually about to register it, so - the question itself is worth front-running by whoever runs the resolver. - namehash is keccak(parent || keccak(label)), so supplying keccak(label) - yields the same node and the same answer, having never sent the label. + A client that asks whether a name is free is usually about to register it, + and whoever runs the resolver could register it first. namehash is + keccak(parent || keccak(label)), so supplying keccak(label) gives the same + node and the same answer without sending the label. - The encoding is ENS's own `[<64 hex>]`, which cannot collide with a real - name: brackets are outside the normalised character set.""" + The encoding is ENS's own `[<64 hex>]`. It cannot collide with a real name, + because brackets are not valid in a normalised ENS name.""" # keccak-256("alice") = 9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501 # - written out in full wherever a test needs a real labelhash. @@ -165,8 +165,8 @@ def test_an_encoded_subname_is_not_the_name_it_would_decode_to(self): ) def test_a_0x_prefixed_label_is_taken_literally(self): - """`0x<64 hex>` is a registrable name, not a hash - the brackets are - what make the hashed form unambiguous.""" + """`0x<64 hex>` is a registrable name, not a hash. Only the bracket + form is read as a labelhash.""" name = "0x9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501.testing" self.assertEqual(snrc.node_of(name), snrc.namehash(name)) self.assertNotEqual(snrc.node_of(name), snrc.node_of("alice.testing")) @@ -176,8 +176,8 @@ def test_a_malformed_bracket_label_falls_back_to_a_literal_name(self): self.assertEqual(snrc.node_of(name), snrc.namehash(name)) def test_status_by_hash_matches_status_by_name(self): - """The registrar keys registration data on the labelhash too, so a - hashed query answers "is it free?" - not only "what does it say?" - + """The registrar keys registration data on the labelhash too. A hashed + query therefore answers "is it free?" as well as "what does it say?", without the label.""" future = int(time.time()) + 86400 seen = [] From 7e91f2e58238ec14fa13d6aacb3aacae09f3ba8c Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 4 Sep 2026 09:09:31 +0200 Subject: [PATCH 5/7] map resolver 410 to NAME NOT_FOUND (a lapsed name is an answer, not a failure) --- src/Simplex/Messaging/Server/Names.hs | 4 ++++ tests/RSLVTests.hs | 14 +++++++++++++- tests/SMPNamesTests.hs | 10 +++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 19bae15fc..a5287956b 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -76,6 +76,10 @@ fetch NamesEnv {resolverEnv} d = mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case HttpStatusErr 404 -> NOT_FOUND + -- 410 is a lapsed registration (past expiry, in grace or beyond): a correct + -- answer about the name, not a resolver failure, so it must not become + -- RESOLVER - that is reserved for the backing resolver/RPC breaking. + HttpStatusErr 410 -> NOT_FOUND HttpStatusErr 400 -> NOT_FOUND HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code)) HttpFailure _ -> RESOLVER "transport failure" diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 2416d851e..fbff33a43 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -19,7 +19,7 @@ import Data.List.NonEmpty (NonEmpty (..)) import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (getCurrentTime) -import Network.HTTP.Types (Status, status200, status404, status502) +import Network.HTTP.Types (Status, status200, status404, status410, status502) import NamesResolverServer (memCfg, memCfg2, memProxyCfg, withNames) import qualified NamesResolverServer as NRS import SMPClient @@ -74,6 +74,7 @@ rslvTests :: Spec rslvTests = do describe "RSLV direct (non-forwarded)" $ do it "resolver replies 404 -> NAME NOT_FOUND (reached, not CMD PROHIBITED)" testRslvBackendNotFound + it "resolver replies 410 -> NAME NOT_FOUND (a lapsed name, not a resolver failure)" testRslvBackendGone it "resolver replies 502 -> NAME (RESOLVER ..)" testRslvBackendHttpErr it "no names config -> NAME NO_RESOLVER" testRslvDisabled it "refuses to send RSLV on a session below namesSMPVersion" testRslvVersion @@ -91,6 +92,17 @@ testRslvBackendNotFound = corrId `shouldBe` CorrId "rs01" resp `shouldBe` Right (ERR (NAME NOT_FOUND)) +-- The resolver answers 410 for a registration that has lapsed (in grace or +-- past it). That is a correct answer about the name, so it has to arrive as +-- NOT_FOUND; RESOLVER would make the client treat it as a broken resolver and +-- abort domain verification instead of reporting the name as unverified. +testRslvBackendGone :: IO () +testRslvBackendGone = + withResolverServer (status410, "{}") $ + testSMPClient @TLS $ \h -> do + (_, _, resp) <- sendRslv h "rs08" (domain "lapsed.simplex") + resp `shouldBe` Right (ERR (NAME NOT_FOUND)) + testRslvBackendHttpErr :: IO () testRslvBackendHttpErr = withResolverServer (status502, "{}") $ diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 0101a40a7..783a5d4e3 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -13,7 +13,7 @@ import Data.IORef (readIORef) import Data.List (sort) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) -import Network.HTTP.Types (status200, status400, status404, status500, status502) +import Network.HTTP.Types (status200, status400, status404, status410, status500, status502) import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed) import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Encoding.String (strDecode) @@ -156,6 +156,14 @@ resolverSpec = do env <- newNamesEnv (testNamesConfig port) resolveName env aliceDomain `shouldReturn` Left NOT_FOUND + it "returns NOT_FOUND on 410 (registration lapsed)" $ + -- A lapsed name is a correct answer, not a resolver failure: RESOLVER + -- would make the client abort domain verification instead of reporting + -- the name as unverified. + withResolverServer (resolveResp status410 "{}") $ \port _ -> do + env <- newNamesEnv (testNamesConfig port) + resolveName env aliceDomain `shouldReturn` Left NOT_FOUND + it "returns RESOLVER on 502 (upstream failure)" $ withResolverServer (resolveResp status502 "{}") $ \port _ -> do env <- newNamesEnv (testNamesConfig port) From 17451a65787a499f85319065254057347181190f Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 4 Sep 2026 09:09:43 +0200 Subject: [PATCH 6/7] split error into a machine-readable code and a human message --- scripts/resolver/README.md | 21 ++ scripts/resolver/REVIEW.html | 304 ++++++++++++++++++ scripts/resolver/service/snrc-resolve.py | 42 ++- scripts/resolver/service/test_snrc_resolve.py | 97 +++++- 4 files changed, 454 insertions(+), 10 deletions(-) create mode 100644 scripts/resolver/REVIEW.html diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index a6b7ec959..f9bacc17e 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -220,6 +220,27 @@ also easy to guess for a short or well-known label, since an operator can hash candidate labels and compare. And once you register, the reveal publishes the labelhash, so an operator who logged your query can match it to the name. +### Errors + +Every non-2xx body carries two fields: `error` is a fixed code to branch on, +and `message` is a sentence for a human. Match on `error`, never on `message`, +which is free to change. + +```jsonc +{"name": "nope.testing", "error": "unregistered", + "message": "this name has never been registered", + "status": "unregistered", "expires": null, "graceEnds": null} +``` + +The codes are `tldNotConfigured`, `notFullyQualified`, `unregistered`, +`reserved`, `grace`, `expired`, `noResolver`, `noSuchRoute` and +`upstreamError`. When the registration is what went wrong, `error` and `status` +hold the same value, so one field is enough to read. + +`upstreamError` says only which exception type the RPC call raised. The text +goes to the resolver's log instead, because `SNRC_RPC` can carry a provider key +and urlopen puts the URL it failed on into the message. + ### Status codes | Status | Meaning | diff --git a/scripts/resolver/REVIEW.html b/scripts/resolver/REVIEW.html new file mode 100644 index 000000000..84bda90ff --- /dev/null +++ b/scripts/resolver/REVIEW.html @@ -0,0 +1,304 @@ +Fifteen Calls a Name + + + + + + +
+ +
+

SNRC resolver · adversarial + developer experience

+

Fifteen Calls a Name

+

Two reviews of the resolver as a whole, not only of the endpoint added in this branch: one assuming the caller is hostile, one assuming they are a developer trying to use it and getting no help.

+

The severe findings are all in the service that already shipped. The embarrassing ones are mine, in code written this session — including a field that claims to be a bytes32 and is not.

+
+ 1 critical + 3 high + 2 medium + 7 DX + 3 of them mine +
+
+ +
+

Adversarial

+

Assume the caller is hostile and the resolver is reachable. Every finding here is in code that predates this branch except where marked.

+ +
+
A1Critical
+

/health hands out the upstream RPC URL to anyone who asks

+

The health endpoint answers, unauthenticated:

+
{"ok": true, "rpc": "http://reth:8545", "registries": {...}}
+

Against the documented local reth that is harmless. Against any hosted provider it is a credential: Alchemy, Infura, QuickNode and Ankr all put the API key in the URL path. A resolver pointed at one of those publishes its owner's node key to every caller, and node keys are metered — the finder does not need to break anything, only to spend the budget.

+

The same value leaks a second way. Both 502 handlers return f"{type(e).__name__}: {e}", and urlopen's exception text carries the URL it failed on. So an attacker who cannot reach /health gets the same string by asking for a name while the node is unreachable — which they can arrange by asking for enough of them.

+
Fix

Report reachability, not configuration: {"ok": true, "rpc": "reachable", "chainId": 1}. If the endpoint must be identifiable, publish the host without the path. Then stop putting exception text in response bodies — log it, return a correlation id. The URL is the one string in this process that must never be echoed.

+
snrc-resolve.py · Handler.do_GET /health · resolve/owned-by 502 handlers
+
+ +
+
A2High
+

The client can authenticate; the server cannot check

+

HttpResolver.hs implements RpcAuth with bearer and basic modes, redacts the secret from Show so it cannot land in logs, and attaches Authorization to every request. The Python resolver never reads that header. There is no auth check anywhere in the service.

+

An operator who configures a token has done real work — chosen a secret, wired it through config, kept it out of logs — and has protected nothing. That is worse than having no auth feature: an absent one prompts a firewall rule, a decorative one prompts confidence.

+
Fix

Verify the header when a secret is configured, with a constant-time compare, and refuse to start when a resolver binds beyond loopback without one. Until then the Haskell side's RpcAuth should say in its haddock that no known resolver validates it.

+
HttpResolver.hs · RpcAuth, authHeader · snrc-resolve.py · Handler (no auth path)
+
+ +
+
A3HighPartly mine
+

One request buys up to 770 upstream calls

+

Nothing is cached and nothing is rate limited, so the amplification is the whole story:

+
/resolve/<name>    15 RPC calls   nameExpires, GRACE_PERIOD, resolver,
+                                  owner, 7 text records, 4 coin addresses
+/owned-by/<addr>  770 RPC calls   GRACE_PERIOD + balanceOf
+                                  + 3 per token x MAX_OWNED (256)
+

/owned-by is mine, and it is the worse of the two by a factor of fifty. MAX_OWNED bounds a single response; it does nothing about the rate, and the expensive request is the cheap one to send. Against a metered provider this is someone else's invoice. Against self-hosted reth it is a queue nobody else gets through.

+

The address in /owned-by need not even hold anything — the cost is paid before the balance is known to be zero.

+
Fix

Cache resolved records for a short TTL, which is safe because the underlying data changes at block cadence, not per request. Then rate-limit per client. If the resolver is meant to be public, /owned-by wants a lower default bound than 256, since the tail of that range is rare and the cost is linear in it.

+
snrc-resolve.py · resolve, owned_by, TEXT_KEYS, MAX_OWNED
+
+ +
+
A4High
+

The path documented for casual use is the one that listens to the world

+

SNRC_BIND defaults to 0.0.0.0. The compose file publishes 127.0.0.1:8000:8000, so the Docker path is safe — and the README offers a second path for anyone who wants a quick look:

+
uv run scripts/resolver/service/snrc-resolve.py  # defaults to local reth
+

That inherits the default and listens on every interface, with no auth (A2) and an endpoint that publishes the node URL (A1). The safe path is the one behind a container; the casual path is the exposed one. That is the wrong way round — defaults should be safe and deployments should opt into exposure.

+
Fix

Default SNRC_BIND to 127.0.0.1 and set 0.0.0.0 explicitly in docker-compose.yml, where publishing is already deliberate and already scoped to loopback on the host.

+
snrc-resolve.py · BIND · docker-compose.yml · README "runnable standalone"
+
+ +
+
A5Medium
+

The resolver protects its caller and not itself

+

HttpResolver.hs is careful about exactly this: brReadSome maxResponseBytes, redirectCount = 0, an explicit timeout, with a comment explaining that adversarial endpoints must not be able to exhaust memory. The resolver then calls its own upstream with urlopen(req, timeout=15).read() — a timeout, and no size cap at all.

+

An RPC endpoint that is compromised, misconfigured or simply pointed at the wrong host can return a body large enough to end the process. The threat model was written down one layer up and not applied one layer down.

+
Fix

Read with a cap and fail closed past it, mirroring the bound the Haskell client already uses. Consider following redirects zero times there too, for the same reason it is spelled out in HttpResolver.hs.

+
snrc-resolve.py · rpc() · HttpResolver.hs · httpGet
+
+ +
+
A6Medium
+

http.server is documented as not for production, and this is production

+

CPython's own docs say http.server "is not recommended for production. It only implements basic security checks." There are no request size limits, no header count limits and no slow-read protection. It is fronted by nothing — the compose file exposes the port directly.

+

This is a reasonable choice for a script and an unreasonable one for a service an smp-server depends on for name resolution. Worth a decision rather than an accident: either it stays a dev tool and the deployment path puts a real server in front, or it becomes a service and gets one.

+
Fix

Put it behind something that terminates connections properly, or move to a WSGI/ASGI server. Either way the README should say which posture is intended, because right now docker-compose implies production and the implementation implies development.

+
snrc-resolve.py · ThreadingHTTPServer · docker-compose.yml
+
+
+ +
+

Developer experience

+

Assume a developer writing a client against this, with only the README and the responses. Three of these are defects in the branch under review.

+ +
+
D1HighMine
+

labelhash is not a hash, it is a Python integer literal

+

I emit it with hex(token), which produces the shortest representation:

+
"labelhash": "0xb"          what it returns
+"labelhash": "0x0000…000b"  what a bytes32 labelhash is
+

Every other party in this system — the contract, a block explorer, any client comparing against chain state — represents a labelhash as 32 bytes. The value returned cannot be pasted into a contract call, cannot be compared textually with an on-chain topic, and will silently mismatch rather than fail loudly. It also makes the list's own sort order wrong, since it sorts as a string.

+
Fix

"0x" + format(token, "064x"). One line, and it should carry a test, because the wrong version looks right for the common case where the leading bytes happen to be non-zero.

+
snrc-resolve.py · owned_by · this branch
+
+ +
+
D2Medium
+

Three error shapes, so a client needs three error handlers

+

An unhappy response is one of:

+
{"name": …, "error": …, "configured_tlds": [...]}   TLD not configured
+{"error": …, "got": …}                              not fully qualified
+{"address": …, "error": …}                          bad address
+{"name": …, "status": …, "error": …, …}             lapsed / unregistered
+

The key naming the subject changes, status is present on some and not others, and configured_tlds appears in two of them and not the third. A client cannot write one function that turns a failure into a message.

+
Fix

One envelope on every non-2xx: a stable error code a client can branch on, a human message, and the subject under a fixed key. The current strings are messages pretending to be codes.

+
snrc-resolve.py · resolve, owned_by, Handler.do_GET
+
+ +
+
D3MediumPartly mine
+

Two casing conventions in one document

+

simplexContact, simplexChannel, checkedTlds, graceEnds are camelCase — the file says why, so aeson can derive field names without a rewriting layer. configured_tlds is snake_case, in the same response body.

+

I added checkedTlds directly alongside the existing configured_tlds without noticing they disagree, which is how a convention with one exception becomes a convention with two.

+
Fix

Rename configured_tlds to configuredTlds. It appears only in error bodies, so the blast radius is small — and it will only get harder once anything depends on it.

+
snrc-resolve.py · resolve, owned_by
+
+ +
+
D4MediumMine
+

/health cannot tell an operator whether the new endpoint will work

+

Health reports registries and not REGISTRARS. Since /owned-by and every expiry field depend entirely on the registrar being configured, an operator who has set only the registry gets a healthy resolver, a working /resolve, "status": "unknown" on every name, and a 400 from /owned-by — with nothing in the health check hinting why.

+

I added the configuration and the dependency on it, and left the diagnostic reporting the older half.

+
Fix

Report both maps in /health, subject to A1 — the addresses are public on chain, so unlike the RPC URL they are safe to publish.

+
snrc-resolve.py · Handler /health · REGISTRARS · this branch
+
+ +
+
D5Medium
+

Nothing anywhere says which resolver you are talking to

+

The response payload just grew three fields. A client written last month and one written today receive different documents from the same URL, and neither can ask which it is. There is no version in the path, no version in /health, and no capability list.

+

This matters more now than it did: status and expires are load-bearing for a renewal reminder, and a client cannot tell whether their absence means "not supported here" or "not knowable for this name".

+
Fix

A version in /health is enough, and cheapest now. The distinction the client actually needs — unsupported versus unknowable — is otherwise impossible to make from a null.

+
snrc-resolve.py · Handler /health · README response shape
+
+ +
+
D6MediumMine
+

Truncation is a dead end, not a page

+

/owned-by stops at SNRC_MAX_OWNED and sets truncated: true. There is no offset, no cursor and no ordering guarantee a caller could resume from, so an address holding more than 256 names in a TLD has no way to see the rest — the flag is honest about the problem and offers no way out of it.

+

truncated is also a single boolean over a merged multi-TLD result, so it does not say which registrar ran out.

+
Fix

Take ?offset= and echo it back; enumeration is index-based on the registrar, so this is nearly free. Failing that, say in the response which TLD truncated, so the caller can at least narrow the query.

+
snrc-resolve.py · owned_by · MAX_OWNED · this branch
+
+ +
+
D7Medium
+

The schema is a Haskell type in another repository

+

Names/Record.hs says "the Haskell type IS the schema", which serves the one consumer written in Haskell and nobody else. A TypeScript or Python client has the README and a curl example. There is no OpenAPI document, no JSON Schema, and no fixture file to test against.

+

The claim is also now slightly untrue: the resolver returns status, expires and graceEnds, and the record type has none of them — safely, since aeson ignores unknown fields, but the two have diverged and only a comment asserts they have not.

+
Fix

Publish the response shape as a schema next to the script and generate the examples in the README from it, so drift shows up as a failing test rather than as a stale sentence.

+
Names/Record.hs · scripts/resolver/README.md
+
+
+ +
+

What held

+

Specific things I tried to break and could not.

+
+

Path handling. Names arrive percent-encoded from the Haskell client and are unquoted into path segments, but they only ever reach keccak — there is no filesystem, no database and no shell in the path, so a crafted name is a hash of a crafted name and nothing more.

+

ABI decoding. decode_bytes checks its length before slicing and returns empty rather than throwing on a short return, so a contract answering with garbage produces an empty field rather than a crash.

+

The available() trap. The obvious way to compute registration status is to call available(id). It is true for a name nobody ever registered, since 0 + GRACE_PERIOD < now, so it silently conflates "never taken" with "released". The implementation uses nameExpires and applies the rule on top, and the test suite pins it.

+

Address validation. /owned-by rejects a malformed address before any RPC call, so the validation cannot be used as an oracle or as a way to spend upstream budget.

+
+
+ +
+ Reviewed scripts/resolver/ at ab/snrc-resolver-owned-by, together with Server/Names/HttpResolver.hs as its only in-tree consumer, and BaseRegistrarImplementation.sol for the contract behaviour the resolver mirrors. Call counts are from reading the code, not measured against a node. Findings marked mine are defects in this branch rather than in the service as it stands on master. +
+ +
diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index ef2a123ba..aaf0048fe 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -581,6 +581,21 @@ def split_links(value: str) -> list: return [item.strip() for item in value.split(LINK_SEPARATOR) if item.strip()] +def upstream_error(subject: dict, e: Exception) -> dict: + """A 502 body that names the failure without quoting the exception. + + urlopen puts the URL it failed on into its message, and SNRC_RPC may carry + a provider key, so the text goes to the log and only a type goes to the + caller. + """ + print(f"upstream error: {type(e).__name__}: {e}", file=sys.stderr) + return { + **subject, + "error": "upstreamError", + "message": f"upstream RPC failed ({type(e).__name__})", + } + + def resolve(name: str): tld = name.rsplit(".", 1)[-1] registry = REGISTRIES.get(tld) @@ -588,8 +603,9 @@ def resolve(name: str): 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, + "error": "tldNotConfigured", + "message": f"TLD '{tld}' is not configured on this resolver", + "configuredTlds": configured, } node = node_of(name) @@ -606,7 +622,8 @@ def resolve(name: str): "status": reg["status"], "expires": reg["expires"], "graceEnds": reg["graceEnds"], - "error": ( + "error": reg["status"], + "message": ( "this name is reserved and cannot be registered" if reg["status"] == "reserved" else "this name has never been registered" @@ -623,7 +640,8 @@ def resolve(name: str): "status": reg["status"], "expires": reg["expires"], "graceEnds": reg["graceEnds"], - "error": ( + "error": reg["status"], + "message": ( "this registration expired and can be renewed by its owner" if reg["status"] == "grace" else "this registration expired and is open to anyone" @@ -638,7 +656,8 @@ def resolve(name: str): "status": "noResolver", "expires": reg["expires"], "graceEnds": reg["graceEnds"], - "error": "no resolver set for this name", + "error": "noResolver", + "message": "no resolver set for this name", } owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex) @@ -701,21 +720,26 @@ def do_GET(self): # noqa: N802 - http.server contract self._respond( 400, { - "error": "expected fully-qualified name, e.g. /resolve/alice.testing", - "got": name, + "name": name, + "error": "notFullyQualified", + "message": "expected a fully-qualified name, e.g. alice.testing", }, ) 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}"} + status, body = 502, upstream_error({"name": name}, e) self._respond(status, body) return self._respond( 404, - {"error": "not found", "routes": ["/health", "/resolve/"]}, + { + "error": "noSuchRoute", + "message": "not found", + "routes": ["/health", "/resolve/"], + }, ) def _respond(self, status: int, body: dict): diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 127238af8..f06e77543 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -4,7 +4,9 @@ Run with `python3 -m unittest scripts/resolver/service/test_snrc_resolve.py`. """ +import contextlib import importlib.util +import io import os import time import unittest @@ -448,7 +450,7 @@ def test_a_reserved_name_carries_the_reason(self): def test_the_message_does_not_claim_a_trademark(self): snrc.eth_call = self._chain(0, True) _, body = snrc.resolve("acme.testing") - self.assertNotIn("trademark", body["error"]) + self.assertNotIn("trademark", body["message"]) def test_an_unregistered_name_has_no_reason(self): snrc.eth_call = self._chain(0, False) @@ -472,5 +474,98 @@ def test_a_hashed_query_gets_the_reason_too(self): self.assertEqual(body["reason"], "reserved for a brand or public interest") +class ErrorCodeTests(unittest.TestCase): + """`error` is a fixed code a client can branch on, and `message` is the + sentence for a human. Matching on the sentence would break the moment the + wording changes, which is why the two are separate fields.""" + + REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + + def setUp(self): + self._saved = ( + snrc.REGISTRIES, + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + snrc.chain_now, + ) + snrc.REGISTRIES = {"testing": self.REGISTRY, "simplex": ""} + snrc.REGISTRARS = {"testing": self.REGISTRAR} + snrc.CONTROLLERS = {"testing": ""} + snrc.chain_now = lambda: int(time.time()) + + def tearDown(self): + ( + snrc.REGISTRIES, + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + snrc.chain_now, + ) = self._saved + + def _chain(self, expires, resolver=None): + def eth_call(to, data): + if data.startswith(snrc.selector("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(90 * 86400) + if data.startswith(snrc.selector("resolver(bytes32)")): + return "0x" + "00" * 12 + (resolver or "00" * 20) + return "0x" + snrc.encode_uint(expires) + + return eth_call + + def test_an_unconfigured_tld_names_the_ones_that_are(self): + status, body = snrc.resolve("alice.nosuchtld") + self.assertEqual(status, 400) + self.assertEqual(body["error"], "tldNotConfigured") + self.assertEqual(body["configuredTlds"], ["testing"]) + self.assertIn("nosuchtld", body["message"]) + + def test_a_registration_problem_reports_the_status_as_the_code(self): + """For these the status is the error, so a client needs to read only + one field.""" + for expires, code in ( + (0, "unregistered"), + (int(time.time()) - 3600, "grace"), + (int(time.time()) - 91 * 86400, "expired"), + ): + with self.subTest(code=code): + snrc.eth_call = self._chain(expires) + _, body = snrc.resolve("alice.testing") + self.assertEqual(body["error"], code) + self.assertEqual(body["status"], code) + + def test_a_registered_name_pointing_nowhere_is_noResolver(self): + snrc.eth_call = self._chain(int(time.time()) + 86400) + status, body = snrc.resolve("alice.testing") + self.assertEqual(status, 404) + self.assertEqual(body["error"], "noResolver") + self.assertEqual(body["status"], "noResolver") + + def test_every_error_body_carries_both_fields(self): + snrc.eth_call = self._chain(0) + for name in ("alice.nosuchtld", "alice.testing"): + with self.subTest(name=name): + _, body = snrc.resolve(name) + self.assertIsInstance(body["error"], str) + self.assertIsInstance(body["message"], str) + self.assertNotEqual(body["error"], body["message"]) + + def test_an_upstream_failure_does_not_echo_the_exception(self): + """urlopen puts the failing URL in its message and SNRC_RPC can carry + a provider key, so only the exception type reaches the caller.""" + with contextlib.redirect_stderr(io.StringIO()) as log: + body = snrc.upstream_error( + {"name": "alice.testing"}, + RuntimeError("http://user:secret@rpc.example/kEy8 refused"), + ) + # the operator still gets the detail, in the log + self.assertIn("secret", log.getvalue()) + self.assertEqual(body["error"], "upstreamError") + self.assertIn("RuntimeError", body["message"]) + self.assertNotIn("secret", body["message"]) + self.assertNotIn("kEy8", body["message"]) + + if __name__ == "__main__": unittest.main() From c47aeddb1877120e9e69c58d1ffe25ac0e4db718 Mon Sep 17 00:00:00 2001 From: sh Date: Fri, 4 Sep 2026 15:05:14 +0000 Subject: [PATCH 7/7] resolver: reduce comments, remove REVIEW.html --- scripts/resolver/README.md | 31 +- scripts/resolver/REVIEW.html | 304 ------------------ scripts/resolver/docker-compose.yml | 6 +- scripts/resolver/service/snrc-resolve.py | 94 +----- scripts/resolver/service/test_snrc_resolve.py | 49 +-- src/Simplex/Messaging/Server/Names.hs | 5 +- tests/RSLVTests.hs | 4 - tests/SMPNamesTests.hs | 3 - 8 files changed, 38 insertions(+), 458 deletions(-) delete mode 100644 scripts/resolver/REVIEW.html diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 8f5907375..97126c7be 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -139,10 +139,9 @@ usual format (EIP-55, bech32, SS58, Monero base58). Subnames work the same way ### Registration status and expiry A response carries `status`, `expires` and `graceEnds` whenever the resolver -got far enough to read them, a successful resolve included. A client that has -just resolved a name therefore already has its expiry, and needs no second -request to warn about it. `expires` and `graceEnds` are Unix timestamps in -seconds, and both are `null` when the resolver could not read them. +read them, a successful resolve included, so a client that has just resolved a +name already knows when it expires. Both timestamps are Unix seconds, and +`null` when they could not be read. | `status` | Meaning | |---|---| @@ -154,19 +153,17 @@ seconds, and both are `null` when the resolver could not read them. | `noResolver` | registered, but points nowhere | | `unknown` | no `SNRC_REGISTRAR_` configured, so status could not be read | -The resolver tells `grace` and `expired` apart with the registrar's own -`available(id)` rule, `expires + GRACE_PERIOD < now`. The resolver reads -`GRACE_PERIOD` from the contract instead of assuming it, and takes `now` from -the latest block's timestamp instead of the host clock. The registrar compares -against that same block timestamp, so a machine with a wrong clock cannot -misreport a registration. - -`available(id)` on its own cannot tell the two apart, because it is also true -for a name nobody ever registered: `0 + GRACE_PERIOD < now`. The resolver uses -a zero expiry to tell *never registered* from *registered and since released*. - -A subname reports the status of the 2LD above it. That is the answer a client -needs, because a subname is only as good as the name it sits under. +`grace` and `expired` are told apart by the registrar's own `available(id)` +rule, `expires + GRACE_PERIOD < now`. `GRACE_PERIOD` is read from the contract +rather than assumed, and `now` is the latest block's timestamp rather than the +host clock, which the registrar compares against too, so a machine with a wrong +clock cannot misreport a registration. That rule alone is not enough: it also +holds for a name nobody ever registered (`0 + GRACE_PERIOD < now`), so a zero +expiry is what separates *never registered* from *registered and since +released*. + +A subname reports the status of the 2LD above it, which is only as good as the +name it sits under. ### Querying by labelhash diff --git a/scripts/resolver/REVIEW.html b/scripts/resolver/REVIEW.html deleted file mode 100644 index 84bda90ff..000000000 --- a/scripts/resolver/REVIEW.html +++ /dev/null @@ -1,304 +0,0 @@ -Fifteen Calls a Name - - - - - - -
- -
-

SNRC resolver · adversarial + developer experience

-

Fifteen Calls a Name

-

Two reviews of the resolver as a whole, not only of the endpoint added in this branch: one assuming the caller is hostile, one assuming they are a developer trying to use it and getting no help.

-

The severe findings are all in the service that already shipped. The embarrassing ones are mine, in code written this session — including a field that claims to be a bytes32 and is not.

-
- 1 critical - 3 high - 2 medium - 7 DX - 3 of them mine -
-
- -
-

Adversarial

-

Assume the caller is hostile and the resolver is reachable. Every finding here is in code that predates this branch except where marked.

- -
-
A1Critical
-

/health hands out the upstream RPC URL to anyone who asks

-

The health endpoint answers, unauthenticated:

-
{"ok": true, "rpc": "http://reth:8545", "registries": {...}}
-

Against the documented local reth that is harmless. Against any hosted provider it is a credential: Alchemy, Infura, QuickNode and Ankr all put the API key in the URL path. A resolver pointed at one of those publishes its owner's node key to every caller, and node keys are metered — the finder does not need to break anything, only to spend the budget.

-

The same value leaks a second way. Both 502 handlers return f"{type(e).__name__}: {e}", and urlopen's exception text carries the URL it failed on. So an attacker who cannot reach /health gets the same string by asking for a name while the node is unreachable — which they can arrange by asking for enough of them.

-
Fix

Report reachability, not configuration: {"ok": true, "rpc": "reachable", "chainId": 1}. If the endpoint must be identifiable, publish the host without the path. Then stop putting exception text in response bodies — log it, return a correlation id. The URL is the one string in this process that must never be echoed.

-
snrc-resolve.py · Handler.do_GET /health · resolve/owned-by 502 handlers
-
- -
-
A2High
-

The client can authenticate; the server cannot check

-

HttpResolver.hs implements RpcAuth with bearer and basic modes, redacts the secret from Show so it cannot land in logs, and attaches Authorization to every request. The Python resolver never reads that header. There is no auth check anywhere in the service.

-

An operator who configures a token has done real work — chosen a secret, wired it through config, kept it out of logs — and has protected nothing. That is worse than having no auth feature: an absent one prompts a firewall rule, a decorative one prompts confidence.

-
Fix

Verify the header when a secret is configured, with a constant-time compare, and refuse to start when a resolver binds beyond loopback without one. Until then the Haskell side's RpcAuth should say in its haddock that no known resolver validates it.

-
HttpResolver.hs · RpcAuth, authHeader · snrc-resolve.py · Handler (no auth path)
-
- -
-
A3HighPartly mine
-

One request buys up to 770 upstream calls

-

Nothing is cached and nothing is rate limited, so the amplification is the whole story:

-
/resolve/<name>    15 RPC calls   nameExpires, GRACE_PERIOD, resolver,
-                                  owner, 7 text records, 4 coin addresses
-/owned-by/<addr>  770 RPC calls   GRACE_PERIOD + balanceOf
-                                  + 3 per token x MAX_OWNED (256)
-

/owned-by is mine, and it is the worse of the two by a factor of fifty. MAX_OWNED bounds a single response; it does nothing about the rate, and the expensive request is the cheap one to send. Against a metered provider this is someone else's invoice. Against self-hosted reth it is a queue nobody else gets through.

-

The address in /owned-by need not even hold anything — the cost is paid before the balance is known to be zero.

-
Fix

Cache resolved records for a short TTL, which is safe because the underlying data changes at block cadence, not per request. Then rate-limit per client. If the resolver is meant to be public, /owned-by wants a lower default bound than 256, since the tail of that range is rare and the cost is linear in it.

-
snrc-resolve.py · resolve, owned_by, TEXT_KEYS, MAX_OWNED
-
- -
-
A4High
-

The path documented for casual use is the one that listens to the world

-

SNRC_BIND defaults to 0.0.0.0. The compose file publishes 127.0.0.1:8000:8000, so the Docker path is safe — and the README offers a second path for anyone who wants a quick look:

-
uv run scripts/resolver/service/snrc-resolve.py  # defaults to local reth
-

That inherits the default and listens on every interface, with no auth (A2) and an endpoint that publishes the node URL (A1). The safe path is the one behind a container; the casual path is the exposed one. That is the wrong way round — defaults should be safe and deployments should opt into exposure.

-
Fix

Default SNRC_BIND to 127.0.0.1 and set 0.0.0.0 explicitly in docker-compose.yml, where publishing is already deliberate and already scoped to loopback on the host.

-
snrc-resolve.py · BIND · docker-compose.yml · README "runnable standalone"
-
- -
-
A5Medium
-

The resolver protects its caller and not itself

-

HttpResolver.hs is careful about exactly this: brReadSome maxResponseBytes, redirectCount = 0, an explicit timeout, with a comment explaining that adversarial endpoints must not be able to exhaust memory. The resolver then calls its own upstream with urlopen(req, timeout=15).read() — a timeout, and no size cap at all.

-

An RPC endpoint that is compromised, misconfigured or simply pointed at the wrong host can return a body large enough to end the process. The threat model was written down one layer up and not applied one layer down.

-
Fix

Read with a cap and fail closed past it, mirroring the bound the Haskell client already uses. Consider following redirects zero times there too, for the same reason it is spelled out in HttpResolver.hs.

-
snrc-resolve.py · rpc() · HttpResolver.hs · httpGet
-
- -
-
A6Medium
-

http.server is documented as not for production, and this is production

-

CPython's own docs say http.server "is not recommended for production. It only implements basic security checks." There are no request size limits, no header count limits and no slow-read protection. It is fronted by nothing — the compose file exposes the port directly.

-

This is a reasonable choice for a script and an unreasonable one for a service an smp-server depends on for name resolution. Worth a decision rather than an accident: either it stays a dev tool and the deployment path puts a real server in front, or it becomes a service and gets one.

-
Fix

Put it behind something that terminates connections properly, or move to a WSGI/ASGI server. Either way the README should say which posture is intended, because right now docker-compose implies production and the implementation implies development.

-
snrc-resolve.py · ThreadingHTTPServer · docker-compose.yml
-
-
- -
-

Developer experience

-

Assume a developer writing a client against this, with only the README and the responses. Three of these are defects in the branch under review.

- -
-
D1HighMine
-

labelhash is not a hash, it is a Python integer literal

-

I emit it with hex(token), which produces the shortest representation:

-
"labelhash": "0xb"          what it returns
-"labelhash": "0x0000…000b"  what a bytes32 labelhash is
-

Every other party in this system — the contract, a block explorer, any client comparing against chain state — represents a labelhash as 32 bytes. The value returned cannot be pasted into a contract call, cannot be compared textually with an on-chain topic, and will silently mismatch rather than fail loudly. It also makes the list's own sort order wrong, since it sorts as a string.

-
Fix

"0x" + format(token, "064x"). One line, and it should carry a test, because the wrong version looks right for the common case where the leading bytes happen to be non-zero.

-
snrc-resolve.py · owned_by · this branch
-
- -
-
D2Medium
-

Three error shapes, so a client needs three error handlers

-

An unhappy response is one of:

-
{"name": …, "error": …, "configured_tlds": [...]}   TLD not configured
-{"error": …, "got": …}                              not fully qualified
-{"address": …, "error": …}                          bad address
-{"name": …, "status": …, "error": …, …}             lapsed / unregistered
-

The key naming the subject changes, status is present on some and not others, and configured_tlds appears in two of them and not the third. A client cannot write one function that turns a failure into a message.

-
Fix

One envelope on every non-2xx: a stable error code a client can branch on, a human message, and the subject under a fixed key. The current strings are messages pretending to be codes.

-
snrc-resolve.py · resolve, owned_by, Handler.do_GET
-
- -
-
D3MediumPartly mine
-

Two casing conventions in one document

-

simplexContact, simplexChannel, checkedTlds, graceEnds are camelCase — the file says why, so aeson can derive field names without a rewriting layer. configured_tlds is snake_case, in the same response body.

-

I added checkedTlds directly alongside the existing configured_tlds without noticing they disagree, which is how a convention with one exception becomes a convention with two.

-
Fix

Rename configured_tlds to configuredTlds. It appears only in error bodies, so the blast radius is small — and it will only get harder once anything depends on it.

-
snrc-resolve.py · resolve, owned_by
-
- -
-
D4MediumMine
-

/health cannot tell an operator whether the new endpoint will work

-

Health reports registries and not REGISTRARS. Since /owned-by and every expiry field depend entirely on the registrar being configured, an operator who has set only the registry gets a healthy resolver, a working /resolve, "status": "unknown" on every name, and a 400 from /owned-by — with nothing in the health check hinting why.

-

I added the configuration and the dependency on it, and left the diagnostic reporting the older half.

-
Fix

Report both maps in /health, subject to A1 — the addresses are public on chain, so unlike the RPC URL they are safe to publish.

-
snrc-resolve.py · Handler /health · REGISTRARS · this branch
-
- -
-
D5Medium
-

Nothing anywhere says which resolver you are talking to

-

The response payload just grew three fields. A client written last month and one written today receive different documents from the same URL, and neither can ask which it is. There is no version in the path, no version in /health, and no capability list.

-

This matters more now than it did: status and expires are load-bearing for a renewal reminder, and a client cannot tell whether their absence means "not supported here" or "not knowable for this name".

-
Fix

A version in /health is enough, and cheapest now. The distinction the client actually needs — unsupported versus unknowable — is otherwise impossible to make from a null.

-
snrc-resolve.py · Handler /health · README response shape
-
- -
-
D6MediumMine
-

Truncation is a dead end, not a page

-

/owned-by stops at SNRC_MAX_OWNED and sets truncated: true. There is no offset, no cursor and no ordering guarantee a caller could resume from, so an address holding more than 256 names in a TLD has no way to see the rest — the flag is honest about the problem and offers no way out of it.

-

truncated is also a single boolean over a merged multi-TLD result, so it does not say which registrar ran out.

-
Fix

Take ?offset= and echo it back; enumeration is index-based on the registrar, so this is nearly free. Failing that, say in the response which TLD truncated, so the caller can at least narrow the query.

-
snrc-resolve.py · owned_by · MAX_OWNED · this branch
-
- -
-
D7Medium
-

The schema is a Haskell type in another repository

-

Names/Record.hs says "the Haskell type IS the schema", which serves the one consumer written in Haskell and nobody else. A TypeScript or Python client has the README and a curl example. There is no OpenAPI document, no JSON Schema, and no fixture file to test against.

-

The claim is also now slightly untrue: the resolver returns status, expires and graceEnds, and the record type has none of them — safely, since aeson ignores unknown fields, but the two have diverged and only a comment asserts they have not.

-
Fix

Publish the response shape as a schema next to the script and generate the examples in the README from it, so drift shows up as a failing test rather than as a stale sentence.

-
Names/Record.hs · scripts/resolver/README.md
-
-
- -
-

What held

-

Specific things I tried to break and could not.

-
-

Path handling. Names arrive percent-encoded from the Haskell client and are unquoted into path segments, but they only ever reach keccak — there is no filesystem, no database and no shell in the path, so a crafted name is a hash of a crafted name and nothing more.

-

ABI decoding. decode_bytes checks its length before slicing and returns empty rather than throwing on a short return, so a contract answering with garbage produces an empty field rather than a crash.

-

The available() trap. The obvious way to compute registration status is to call available(id). It is true for a name nobody ever registered, since 0 + GRACE_PERIOD < now, so it silently conflates "never taken" with "released". The implementation uses nameExpires and applies the rule on top, and the test suite pins it.

-

Address validation. /owned-by rejects a malformed address before any RPC call, so the validation cannot be used as an oracle or as a way to spend upstream budget.

-
-
- - - -
diff --git a/scripts/resolver/docker-compose.yml b/scripts/resolver/docker-compose.yml index 84d64e2c7..24a90488e 100644 --- a/scripts/resolver/docker-compose.yml +++ b/scripts/resolver/docker-compose.yml @@ -150,10 +150,8 @@ services: # only if you're deploying against a different network or contract. # SNRC_REGISTRY_TESTING: 0x... # SNRC_REGISTRY_SIMPLEX: 0x... - # Registrar and controller addresses, same cascade. The registrar - # drives the expiry status on /resolve; without it status reads - # "unknown". The controller drives the "reserved" status; without it a - # reserved name reads as "unregistered". + # Registrar and controller, same cascade. Without the registrar `status` + # is "unknown"; without the controller a reserved name is "unregistered". # SNRC_REGISTRAR_TESTING: 0x... # SNRC_REGISTRAR_SIMPLEX: 0x... # SNRC_CONTROLLER_TESTING: 0x... diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index e0cb3061d..cdadc1f48 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -89,33 +89,21 @@ "simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet } -# The BaseRegistrar (ERC-721) per TLD, used for expiry and status. Separate -# from the registry above: the registry answers "who owns this node", the -# registrar holds nameExpires and GRACE_PERIOD. Not a proxy, so the address in -# deployments is the one that answers. Without one for a TLD, /resolve still -# works and reports "status": "unknown". REGISTRARS = { "testing": os.environ.get("SNRC_REGISTRAR_TESTING", "") or "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a", # mainnet .testing "simplex": os.environ.get("SNRC_REGISTRAR_SIMPLEX", ""), # not deployed yet } -# The SimplexController per TLD, which holds `reservedNames`. Without one for a -# TLD, `reserved` is never reported and a reserved name reads as unregistered. CONTROLLERS = { "testing": os.environ.get("SNRC_CONTROLLER_TESTING", "") - # The proxy, not SimplexControllerImpl: storage and events live in the - # proxy, so the implementation address answers nothing. deployments.json - # records this one under the ENS role name ETHRegistrarController; - # verification.json names it SimplexControllerProxy. Same address. + # Proxy address, not SimplexControllerImpl: storage is held by the proxy. + # Recorded in deployments.json as ETHRegistrarController. or "0xeeb9b6bf5fb68fb726005f7ba549c2f4b32f2dad", # mainnet .testing "simplex": os.environ.get("SNRC_CONTROLLER_SIMPLEX", ""), # not deployed yet } -# Why a name is reserved. `reservedNames` stores only the fact, so every -# reserved name gets this same sentence; a per-name lookup (table or REST) is -# the intended replacement. Callers should render whatever this field holds -# rather than matching on its text. +# `reservedNames` stores the fact only, never a reason. RESERVED_REASON = "reserved for a brand or public interest" # SLIP-44 coin types (https://github.com/satoshilabs/slips/blob/master/slip-0044.md) @@ -172,14 +160,8 @@ def is_encoded_labelhash(label: str) -> bool: def node_of(name: str) -> bytes: - """namehash, accepting an encoded labelhash in place of a 2LD's label. - - keccak(parent || keccak(label)) reaches the same node without the label, - so a caller can check a 2LD without disclosing which one they are about to - register. Subnames are excluded - only the 2LD's owner creates them, so - there is nothing to front-run - and a bracket label there is hashed as - written. - """ + """namehash, accepting an encoded labelhash in place of a 2LD's label. In a + subname a bracket label is hashed as written, not decoded.""" labels = name.split(".") if len(labels) == 2 and is_encoded_labelhash(labels[0]): return keccak(namehash(labels[1]) + bytes.fromhex(labels[0][1:-1])) @@ -190,50 +172,29 @@ def node_of(name: str) -> bytes: def chain_now() -> int: - """The latest block's timestamp - the same clock the registrar reads. - - Asked explicitly rather than taken from the host clock, so a skewed clock - on this machine cannot misstate a registration. - """ + """Expiry is compared against the block timestamp, never the host clock.""" block = rpc("eth_getBlockByNumber", ["latest", False]) return decode_uint(block["timestamp"]) def grace_period(registrar: str) -> int: - """The registrar's own GRACE_PERIOD, in seconds. - - Read from the chain rather than hardcoded, so a deployment that chooses a - different window is reported correctly instead of confidently wrongly. One - call per request, not per name. - """ + """A deployment can configure a different window, so it is read on chain.""" return decode_uint(eth_call(registrar, selector("GRACE_PERIOD()"))) def expiry_status(expires: int, grace: int, now: int) -> str: - """Registration state from an expiry timestamp. - - Mirrors the registrar's `available(id)`, which is - `expiries[id] + GRACE_PERIOD < block.timestamp`. Note that `available` - alone cannot be used for this: it is also true for a name nobody ever - registered, since `0 + GRACE_PERIOD < now`. The zero expiry is what - separates "never taken" from "lapsed and now free". - """ + """The registrar's `available(id)` is not enough on its own: it is also + true for a name nobody registered, since 0 + GRACE_PERIOD < now.""" if expires == 0: return "unregistered" if expires > now: return "registered" if expires + grace >= now: - # Expired, but only the previous owner may renew it - nobody else can - # take it yet. return "grace" return "expired" def is_reserved(tld: str, token: int) -> bool: - """Whether the controller holds this label for a brand. - - Keyed by labelhash on chain, so this answers for a hashed query too. - """ controller = CONTROLLERS.get(tld) if not controller: return False @@ -242,28 +203,14 @@ def is_reserved(tld: str, token: int) -> bool: def name_status(name: str): - """Registration status of the 2LD a name sits under. - - Names expire lazily: the registrar keeps the record and simply stops - treating it as live, so "never registered" and "expired last Tuesday" are - both readable rather than both being absence. `nameExpires` returns 0 for a - label that was never registered, which is what separates the two. - - Subnames are not registered here, so the status of `x.alice.testing` is the - status of `alice.testing` - which is the useful answer, since a subname is - only as valid as the 2LD above it. - """ labels = name.split(".") tld = labels[-1] registrar = REGISTRARS.get(tld) if not registrar or len(labels) < 2: - # No registrar configured for this TLD: say so rather than guess. return {"status": "unknown", "expires": None, "graceEnds": None} - # The registrar keys the registration facts (nameExpires, reservedNames) on - # uint256(keccak(label)), so a 2LD queried by its encoded labelhash gets the - # same answer without the label. Decode the bracket form for a 2LD only, - # which is the rule node_of applies to the node. + # nameExpires and reservedNames are keyed on uint256(keccak(label)). + # Decoded for a 2LD only, the same rule node_of applies to the node. label = labels[-2] if len(labels) == 2 and is_encoded_labelhash(label): token = int(label[1:-1], 16) @@ -278,10 +225,6 @@ def name_status(name: str): grace = grace_period(registrar) status = expiry_status(expires, grace, chain_now()) - # `reserved` only displaces the two states that read as "you could take - # this". A registered name is registered, and one in grace belongs to its - # owner either way - in both cases the reservation is not the answer to the - # question being asked. if status in ("unregistered", "expired") and is_reserved(tld, token): status = "reserved" @@ -569,12 +512,8 @@ def split_links(value: str) -> list: def upstream_error(subject: dict, e: Exception) -> dict: - """A 502 body that names the failure without quoting the exception. - - urlopen puts the URL it failed on into its message, and SNRC_RPC may carry - a provider key, so the text goes to the log and only a type goes to the - caller. - """ + """urlopen puts the failing URL into its message and SNRC_RPC can carry a + provider key, so the text goes to the log and only the type to the caller.""" print(f"upstream error: {type(e).__name__}: {e}", file=sys.stderr) return { **subject, @@ -598,10 +537,7 @@ def resolve(name: str): node = node_of(name) node_hex = node.hex() - # Registration first, because it is the fact that separates the failures a - # caller has to tell apart: a name nobody has taken, one whose registration - # lapsed and may still be renewed, one that lapsed and is now open to - # anyone, and one that is held but not pointed anywhere. + # Before the resolver lookup, so a lapsed name is not reported as noResolver. reg = name_status(name) if reg["status"] in ("unregistered", "reserved"): body = { @@ -616,8 +552,6 @@ def resolve(name: str): else "this name has never been registered" ), } - # Only reserved names carry a reason, so its presence is the signal - # that one is known. if reg["status"] == "reserved": body["reason"] = RESERVED_REASON return 404, body diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 6c90afd03..16c82f610 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -86,9 +86,6 @@ def test_order_is_preserved(self): class EncodedLabelhashTests(unittest.TestCase): - """`node_of` accepts a 2LD's label as an encoded labelhash `[<64 hex>]`, - reaching the same node as the label itself.""" - # keccak-256("alice"), written out in full wherever a test needs it. # 9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501 @@ -165,9 +162,6 @@ def test_a_malformed_bracket_label_falls_back_to_a_literal_name(self): self.assertEqual(snrc.node_of(name), snrc.namehash(name)) def test_status_by_hash_matches_status_by_name(self): - """The registrar keys registration data on the labelhash too. A hashed - query therefore answers "is it free?" as well as "what does it say?", - without the label.""" future = int(time.time()) + 86400 seen = [] @@ -190,9 +184,6 @@ def eth_call(to, data): class NameStatusTests(unittest.TestCase): - """unresolvable has three causes and a caller has to tell them apart. - Names expire lazily, so the chain still holds the answer.""" - REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" GRACE = 90 * 86400 @@ -215,8 +206,7 @@ def setUp(self): snrc.rpc, ) snrc.REGISTRARS = {"testing": self.REGISTRAR} - # These cases are about expiry alone. ReservedTests covers what a - # configured controller adds. + # Expiry alone; ReservedTests covers a configured controller. snrc.CONTROLLERS = {"testing": ""} snrc.chain_now = lambda: int(time.time()) @@ -230,16 +220,12 @@ def tearDown(self): ) = self._saved def test_now_is_the_latest_blocks_timestamp(self): - # setUp replaces chain_now with the fixture clock; this is about the - # real one, saved as the 4th element of the setUp snapshot + # setUp replaced chain_now with the fixture clock; test the real one real_chain_now = self._saved[3] snrc.rpc = lambda method, params: {"timestamp": "0x65f1a2c0", "number": "0x123"} self.assertEqual(real_chain_now(), 0x65F1A2C0) def test_status_reads_the_chain_clock_not_the_host_clock(self): - """The registrar compares expiry to block.timestamp, so the resolver - must too - a host clock years ahead must not turn a live name into a - claimable one.""" future = int(time.time()) + 3600 snrc.eth_call = self._expiry(future) self.assertEqual(snrc.name_status("alice.testing")["status"], "registered") @@ -254,8 +240,6 @@ def test_zero_expiry_means_never_registered(self): ) def test_recently_expired_is_in_grace_and_says_when_it_ends(self): - """Only the previous owner may renew during grace - nobody else can - take the name yet, so this is a different answer from `expired`.""" past = int(time.time()) - 3600 snrc.eth_call = self._expiry(past) self.assertEqual( @@ -269,8 +253,7 @@ def test_past_the_grace_window_it_is_expired_and_claimable(self): self.assertEqual(snrc.name_status("alice.testing")["status"], "expired") def test_the_boundary_belongs_to_grace(self): - """The registrar frees a name when expires + GRACE < now, so the last - second of the window is still the owner's.""" + """The registrar frees a name only when expires + GRACE < now.""" now = int(time.time()) snrc.eth_call = self._expiry(now - self.GRACE) self.assertEqual(snrc.name_status("alice.testing")["status"], "grace") @@ -284,8 +267,7 @@ def test_future_expiry_is_registered(self): ) def test_never_registered_is_not_confused_with_claimable(self): - """`available(id)` is true for both, since 0 + GRACE < now. The zero - expiry is the only thing that separates them.""" + """`available(id)` is true for both, since 0 + GRACE < now.""" snrc.eth_call = self._expiry(0) self.assertEqual(snrc.name_status("alice.testing")["status"], "unregistered") self.assertNotEqual(snrc.name_status("alice.testing")["status"], "expired") @@ -312,9 +294,6 @@ def test_unconfigured_tld_is_unknown_rather_than_unregistered(self): ) def test_every_branch_returns_the_same_keys(self): - """Callers read status/expires/graceEnds unconditionally, so a branch - that omits one is a KeyError in the caller rather than a missing field - in the JSON.""" keys = {"status", "expires", "graceEnds"} snrc.eth_call = self._expiry(0) self.assertEqual(set(snrc.name_status("alice.testing")), keys) @@ -326,9 +305,6 @@ def test_every_branch_returns_the_same_keys(self): class ReservedTests(unittest.TestCase): - """A reserved name is unregistered and still unavailable, which a client - intending to register needs to know before it tries.""" - REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" @@ -366,7 +342,6 @@ def test_a_lapsed_reserved_name_is_reserved_not_claimable(self): self.assertEqual(snrc.name_status("acme.testing")["status"], "reserved") def test_a_live_name_is_registered_even_if_reserved(self): - """It was handed to its brand; the reservation is no longer the answer.""" snrc.eth_call = self._chain(int(time.time()) + 86400, True) self.assertEqual(snrc.name_status("acme.testing")["status"], "registered") @@ -376,7 +351,7 @@ def test_a_name_in_grace_belongs_to_its_owner_not_the_reserved_set(self): def test_no_controller_configured_means_reserved_is_never_reported(self): snrc.CONTROLLERS = {"testing": ""} - snrc.eth_call = self._chain(0, True) # would say reserved if asked + snrc.eth_call = self._chain(0, True) # reserved on chain, but unread self.assertEqual(snrc.name_status("acme.testing")["status"], "unregistered") def test_reserved_is_asked_by_labelhash_so_a_hashed_query_works(self): @@ -387,10 +362,6 @@ def test_reserved_is_asked_by_labelhash_so_a_hashed_query_works(self): class ReservedReasonTests(unittest.TestCase): - """Why a name is reserved travels in its own field, so a client can show it - without parsing the message, and so a per-name reason can replace the fixed - one without moving anything.""" - REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" @@ -462,10 +433,6 @@ def test_a_hashed_query_gets_the_reason_too(self): class ErrorCodeTests(unittest.TestCase): - """`error` is a fixed code a client can branch on, and `message` is the - sentence for a human. Matching on the sentence would break the moment the - wording changes, which is why the two are separate fields.""" - REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" @@ -509,8 +476,6 @@ def test_an_unconfigured_tld_names_the_ones_that_are(self): self.assertIn("nosuchtld", body["message"]) def test_a_registration_problem_reports_the_status_as_the_code(self): - """For these the status is the error, so a client needs to read only - one field.""" for expires, code in ( (0, "unregistered"), (int(time.time()) - 3600, "grace"), @@ -539,14 +504,12 @@ def test_every_error_body_carries_both_fields(self): self.assertNotEqual(body["error"], body["message"]) def test_an_upstream_failure_does_not_echo_the_exception(self): - """urlopen puts the failing URL in its message and SNRC_RPC can carry - a provider key, so only the exception type reaches the caller.""" with contextlib.redirect_stderr(io.StringIO()) as log: body = snrc.upstream_error( {"name": "alice.testing"}, RuntimeError("http://user:secret@rpc.example/kEy8 refused"), ) - # the operator still gets the detail, in the log + # the operator still sees the detail in the log self.assertIn("secret", log.getvalue()) self.assertEqual(body["error"], "upstreamError") self.assertIn("RuntimeError", body["message"]) diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index a5287956b..856339bc8 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -76,9 +76,8 @@ fetch NamesEnv {resolverEnv} d = mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case HttpStatusErr 404 -> NOT_FOUND - -- 410 is a lapsed registration (past expiry, in grace or beyond): a correct - -- answer about the name, not a resolver failure, so it must not become - -- RESOLVER - that is reserved for the backing resolver/RPC breaking. + -- 410 is a lapsed registration: an answer about the name, not a resolver + -- failure, so it must not become RESOLVER. HttpStatusErr 410 -> NOT_FOUND HttpStatusErr 400 -> NOT_FOUND HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code)) diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index fbff33a43..d453c5553 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -92,10 +92,6 @@ testRslvBackendNotFound = corrId `shouldBe` CorrId "rs01" resp `shouldBe` Right (ERR (NAME NOT_FOUND)) --- The resolver answers 410 for a registration that has lapsed (in grace or --- past it). That is a correct answer about the name, so it has to arrive as --- NOT_FOUND; RESOLVER would make the client treat it as a broken resolver and --- abort domain verification instead of reporting the name as unverified. testRslvBackendGone :: IO () testRslvBackendGone = withResolverServer (status410, "{}") $ diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 783a5d4e3..16a332d5f 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -157,9 +157,6 @@ resolverSpec = do resolveName env aliceDomain `shouldReturn` Left NOT_FOUND it "returns NOT_FOUND on 410 (registration lapsed)" $ - -- A lapsed name is a correct answer, not a resolver failure: RESOLVER - -- would make the client abort domain verification instead of reporting - -- the name as unverified. withResolverServer (resolveResp status410 "{}") $ \port _ -> do env <- newNamesEnv (testNamesConfig port) resolveName env aliceDomain `shouldReturn` Left NOT_FOUND