From 61754116162597e81674f46f4453142fc147225e Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 31 Aug 2026 09:25:40 +0200 Subject: [PATCH 1/6] new owned_by endpoint and expiry status handling --- scripts/resolver/README.md | 38 +++- scripts/resolver/docker-compose.yml | 5 + scripts/resolver/service/snrc-resolve.py | 194 ++++++++++++++++- scripts/resolver/service/test_snrc_resolve.py | 205 ++++++++++++++++++ 4 files changed, 439 insertions(+), 3 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 88fa6fde5..ae6e406da 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -71,6 +71,42 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq # → {"name":"foobar.testing","nickname":"Foo","simplexContact":["https://smp16.simplex.im/a#…"], … } ``` +**4. resolver distinguishes the three ways a name fails to resolve.** Names +expire lazily, so the chain still holds the answer and the resolver reports it +rather than returning a bare 404 for every case: +```sh +curl -s http://127.0.0.1:8000/resolve/never-taken.testing | jq +# 404 → {"status":"unregistered", …} never registered +curl -s http://127.0.0.1:8000/resolve/lapsed.testing | jq +# 410 → {"status":"expired","expires":1750…} registered, then lapsed +curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq +# 200 → {"status":"registered","expires":1780…, …} +``` +A held name that points nowhere answers 404 with `"status":"noResolver"`, which +is a different problem from either of the above. Status needs +`SNRC_REGISTRAR_` configured; without it the field reads `"unknown"` and +the endpoint behaves as it did before. + +**5. resolver lists the names an address holds:** +```sh +curl -s http://127.0.0.1:8000/owned-by/0x69a6000000000000000000000000000000002d32 | jq +# → {"address":"0x69a6…","names":[ +# {"name":"foobar.testing","tld":"testing","labelhash":"0x…", +# "expires":1780…,"status":"registered"}, +# {"name":"lapsed.testing","tld":"testing","labelhash":"0x…", +# "expires":1750…,"status":"expired"}], +# "truncated":false,"checkedTlds":["testing"]} +``` +Read from the ERC-721 registrar (`balanceOf` / `tokenOfOwnerByIndex` / +`labelOf`), so it reflects names acquired by transfer as well as by +registration, and needs no log scan. + +**Expired names are listed, not filtered**, each carrying the same `status` +vocabulary `/resolve` uses. A wallet scanning for the names a key holds is +precisely the caller who needs to be told one has lapsed, so it can offer to +renew it. Filter on `status == "registered"` for the live set only. Bounded by +`SNRC_MAX_OWNED` (default 256), and the response says when it truncated. + **Wire your smp-server:** in its `[NAMES]` section set `resolver_endpoint: http://127.0.0.1:8000` (no auth needed for loopback). @@ -82,7 +118,7 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq | reth p2p | `:30303` tcp/udp | Ethereum sync (open on firewall) | | nimbus p2p | `:9000` tcp/udp | beacon sync (open on firewall) | | nimbus REST | `127.0.0.1:5052` | beacon API | -| **resolver** | `127.0.0.1:8000` | SNRC REST (`/resolve`, `/health`) | +| **resolver** | `127.0.0.1:8000` | SNRC REST (`/resolve`, `/owned-by`, `/health`) | ## Caveats diff --git a/scripts/resolver/docker-compose.yml b/scripts/resolver/docker-compose.yml index 570b63df3..f03cc2028 100644 --- a/scripts/resolver/docker-compose.yml +++ b/scripts/resolver/docker-compose.yml @@ -150,6 +150,11 @@ services: # only if you're deploying against a different network or contract. # SNRC_REGISTRY_TESTING: 0x... # SNRC_REGISTRY_SIMPLEX: 0x... + # Registrar (ERC-721) addresses, same cascade. These drive /owned-by and + # the expiry status on /resolve; without them status reads "unknown". + # SNRC_REGISTRAR_TESTING: 0x... + # SNRC_REGISTRAR_SIMPLEX: 0x... + # SNRC_MAX_OWNED: 256 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 ffddbeb02..1711aa553 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -29,6 +29,7 @@ ./snrc-resolve.py # serve on :8000 curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq . + curl -s http://127.0.0.1:8000/owned-by/0x69a6...2d32 | jq . curl -s http://127.0.0.1:8000/health Environment: @@ -61,6 +62,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 @@ -84,6 +86,21 @@ "simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet } +# The BaseRegistrar (ERC-721) per TLD, used for owner -> names. Separate from +# the registry above: the registry answers "who owns this node", the registrar +# is the NFT that can be asked the reverse. Not a proxy, so the address in +# deployments is the one that answers. +REGISTRARS = { + "testing": os.environ.get("SNRC_REGISTRAR_TESTING", "") + or "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a", # mainnet .testing + "simplex": os.environ.get("SNRC_REGISTRAR_SIMPLEX", ""), # not deployed yet +} + +# An address can hold any number of names, and enumeration costs one call per +# name. Bounded so a single request cannot pin the resolver to one caller's +# balance; the response says when it truncated rather than lying by omission. +MAX_OWNED = int(os.environ.get("SNRC_MAX_OWNED", "256")) + # SLIP-44 coin types (https://github.com/satoshilabs/slips/blob/master/slip-0044.md) COIN_ETH = 60 COIN_BTC = 0 @@ -143,6 +160,32 @@ 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_address(addr: str) -> str: + return "00" * 12 + addr[2:].lower() + + +def decode_string(hex_data: str) -> str: + raw = decode_bytes(hex_data) + return raw.decode("utf-8", errors="replace") if raw else "" + + +def is_address(value: str) -> bool: + return ( + len(value) == 42 + and value.startswith("0x") + and all(c in "0123456789abcdefABCDEF" for c in value[2:]) + ) + + def encode_text_call(node: bytes, key: str) -> str: sel = selector("text(bytes32,string)") head = node.hex() + (0x40).to_bytes(32, "big").hex() @@ -404,10 +447,33 @@ def resolve(name: str): node = namehash(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 one that is held but not pointed anywhere. + reg = name_status(name) + if reg["status"] == "unregistered": + return 404, { + "name": name, + "status": "unregistered", + "error": "this name has never been registered", + } + if reg["status"] == "expired": + return 410, { + "name": name, + "status": "expired", + "expires": reg["expires"], + "error": "this registration expired", + } + 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"], + "error": "no resolver set for this name", + } owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex) owner = decode_address(owner_raw) @@ -443,6 +509,118 @@ def resolve(name: str): "dot": addr_multicoin(resolver_addr, node, COIN_DOT), "owner": owner, "resolver": resolver_addr, + "status": reg["status"], + "expires": reg["expires"], + } + + +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} + + token = int.from_bytes(keccak(labels[-2].encode()), "big") + expires = decode_uint( + eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token)) + ) + if expires == 0: + return {"status": "unregistered", "expires": None} + if expires <= int(time.time()): + return {"status": "expired", "expires": expires} + return {"status": "registered", "expires": expires} + + +def owned_by(address: str): + """Every live name an address holds, across every configured TLD. + + Read straight off the ERC-721 registrar rather than from logs: the token + is the name, so `balanceOf` / `tokenOfOwnerByIndex` is the current answer + and it includes names acquired by transfer, which a scan of registration + events would miss. `labelOf` returns the plaintext label, recorded + write-once at registration, so no off-chain index is needed to turn a + token id back into a name. + + Enumeration is deliberately not maintained on expiry - the registrar + documents this - so an expired name stays in the list until someone + re-registers it. That is reported rather than filtered: a caller scanning + for the names a key holds is exactly the caller who needs to be told one of + them has lapsed and can be renewed. Every entry carries `status`, using the + same vocabulary as /resolve, so "still yours" and "yours until you lose it" + are never confused for each other. + + Callers wanting only the live set filter on `status == "registered"`, which + is the check the registrar's invariant asks of readers - applied by whoever + knows whether expired names matter to them, rather than here. + """ + if not is_address(address): + return 400, {"address": address, "error": "expected a 0x-prefixed 20-byte address"} + + configured = {t: r for t, r in REGISTRARS.items() if r} + if not configured: + return 400, { + "address": address, + "error": "no registrar is configured on this resolver", + "configured_tlds": [], + } + + now = int(time.time()) + names, truncated = [], False + for tld, registrar in configured.items(): + held = decode_uint( + eth_call(registrar, selector("balanceOf(address)") + encode_address(address)) + ) + if held > MAX_OWNED: + truncated = True + held = MAX_OWNED + for i in range(held): + token = decode_uint( + eth_call( + registrar, + selector("tokenOfOwnerByIndex(address,uint256)") + + encode_address(address) + + encode_uint(i), + ) + ) + expires = decode_uint( + eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token)) + ) + label = decode_string( + eth_call(registrar, selector("labelOf(uint256)") + encode_uint(token)) + ) + # A label of "" means the token is real but its name is not + # recoverable from chain state - registered before labels were + # recorded, or by a path that does not record them. Reported + # without a name rather than silently dropped. + names.append( + { + "name": (label + "." + tld) if label else None, + "tld": tld, + "labelhash": hex(token), + "expires": expires, + "status": "registered" if expires > now else "expired", + } + ) + + names.sort(key=lambda n: (n["tld"], n["name"] or n["labelhash"])) + return 200, { + "address": address, + "names": names, + "truncated": truncated, + "checkedTlds": sorted(configured), } @@ -478,9 +656,21 @@ def do_GET(self): # noqa: N802 - http.server contract self._respond(status, body) return + if len(parts) == 2 and parts[0] == "owned-by": + address = parts[1].strip().lower() + try: + status, body = owned_by(address) + except Exception as e: # surface upstream errors as 502 + status, body = 502, {"address": address, "error": f"{type(e).__name__}: {e}"} + self._respond(status, body) + return + self._respond( 404, - {"error": "not found", "routes": ["/health", "/resolve/"]}, + { + "error": "not found", + "routes": ["/health", "/resolve/", "/owned-by/
"], + }, ) 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 2bb42f991..adf3d8404 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`. @@ -17,6 +18,210 @@ _SPEC.loader.exec_module(snrc) +class AbiCodecTests(unittest.TestCase): + """The word-level codec the owner lookup is built from. Wrong padding here + is a silently empty answer rather than an error, so each direction is + pinned.""" + + def test_address_is_left_padded_to_a_word_and_lowercased(self): + self.assertEqual( + snrc.encode_address("0xEF47eb4384b46C89E4482a677c2cbcbd2a6fd85a"), + "00" * 12 + "ef47eb4384b46c89e4482a677c2cbcbd2a6fd85a", + ) + + def test_uint_round_trips_through_a_word(self): + for n in (0, 1, 42, 2**64, 2**255): + self.assertEqual(snrc.decode_uint("0x" + snrc.encode_uint(n)), n) + + def test_decode_uint_of_empty_is_zero(self): + self.assertEqual(snrc.decode_uint(""), 0) + self.assertEqual(snrc.decode_uint("0x"), 0) + + def test_decode_string_reads_the_dynamic_layout(self): + label = b"alice" + word = (32).to_bytes(32, "big") + len(label).to_bytes(32, "big") + padded = label + b"\x00" * (32 - len(label)) + self.assertEqual(snrc.decode_string("0x" + (word + padded).hex()), "alice") + + def test_decode_string_of_empty_return_is_empty(self): + self.assertEqual(snrc.decode_string("0x"), "") + + def test_is_address_accepts_only_20_byte_hex(self): + self.assertTrue(snrc.is_address("0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a")) + self.assertTrue(snrc.is_address("0xEF47EB4384B46C89E4482A677C2CBCBD2A6FD85A")) + self.assertFalse(snrc.is_address("ef47eb4384b46c89e4482a677c2cbcbd2a6fd85a")) + self.assertFalse(snrc.is_address("0xef47eb")) + self.assertFalse(snrc.is_address("0x" + "g" * 40)) + + +class OwnedByTests(unittest.TestCase): + """owner -> names, read off the ERC-721 registrar. + + The registrar's own invariant is that enumeration is maintained on + transfer/mint/burn and NOT on expiry, so an expired name stays enumerable + until it is re-registered. These pin the filter that follows from it. + """ + + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + OWNER = "0x69a6000000000000000000000000000000002d32" + + def _fake_chain(self, tokens): + """tokens :: [(labelhash, label, expires)] held by OWNER.""" + sel = snrc.selector + + def eth_call(to, data): + self.assertEqual(to, self.REGISTRAR) + if data.startswith(sel("balanceOf(address)")): + return "0x" + snrc.encode_uint(len(tokens)) + if data.startswith(sel("tokenOfOwnerByIndex(address,uint256)")): + i = int(data[-64:], 16) + return "0x" + snrc.encode_uint(tokens[i][0]) + if data.startswith(sel("nameExpires(uint256)")): + tid = int(data[-64:], 16) + return "0x" + snrc.encode_uint(dict((t[0], t[2]) for t in tokens)[tid]) + if data.startswith(sel("labelOf(uint256)")): + tid = int(data[-64:], 16) + label = dict((t[0], t[1]) for t in tokens)[tid].encode() + head = (32).to_bytes(32, "big") + len(label).to_bytes(32, "big") + pad = b"\x00" * ((-len(label)) % 32) + return "0x" + (head + label + pad).hex() + raise AssertionError("unexpected call " + data[:10]) + + return eth_call + + def setUp(self): + self._registrars = snrc.REGISTRARS + self._eth_call = snrc.eth_call + snrc.REGISTRARS = {"testing": self.REGISTRAR, "simplex": ""} + + def tearDown(self): + snrc.REGISTRARS = self._registrars + snrc.eth_call = self._eth_call + + def test_lists_names_with_their_labels(self): + future = int(time.time()) + 86400 + snrc.eth_call = self._fake_chain([(11, "alice", future), (22, "bob", future)]) + status, body = snrc.owned_by(self.OWNER) + self.assertEqual(status, 200) + self.assertEqual([n["name"] for n in body["names"]], ["alice.testing", "bob.testing"]) + self.assertFalse(body["truncated"]) + self.assertEqual(body["checkedTlds"], ["testing"]) + + def test_expired_names_are_reported_not_dropped(self): + """A scan is how a user finds out a name lapsed, so an expired name has + to come back labelled rather than vanish.""" + now = int(time.time()) + snrc.eth_call = self._fake_chain( + [(11, "live", now + 86400), (22, "lapsed", now - 1)] + ) + _, body = snrc.owned_by(self.OWNER) + self.assertEqual([n["name"] for n in body["names"]], ["lapsed.testing", "live.testing"]) + by_name = {n["name"]: n for n in body["names"]} + self.assertEqual(by_name["live.testing"]["status"], "registered") + self.assertEqual(by_name["lapsed.testing"]["status"], "expired") + self.assertEqual(by_name["lapsed.testing"]["expires"], now - 1) + + def test_status_uses_the_same_vocabulary_as_resolve(self): + now = int(time.time()) + snrc.eth_call = self._fake_chain([(11, "live", now + 86400)]) + _, body = snrc.owned_by(self.OWNER) + self.assertIn(body["names"][0]["status"], ("registered", "expired")) + + def test_a_name_with_no_recorded_label_is_reported_by_labelhash(self): + future = int(time.time()) + 86400 + snrc.eth_call = self._fake_chain([(11, "", future)]) + _, body = snrc.owned_by(self.OWNER) + self.assertEqual(body["names"][0]["name"], None) + self.assertEqual(body["names"][0]["labelhash"], hex(11)) + + def test_enumeration_is_bounded_and_says_so(self): + future = int(time.time()) + 86400 + snrc.MAX_OWNED, keep = 2, snrc.MAX_OWNED + try: + snrc.eth_call = self._fake_chain( + [(i, "n%d" % i, future) for i in range(1, 6)] + ) + _, body = snrc.owned_by(self.OWNER) + self.assertEqual(len(body["names"]), 2) + self.assertTrue(body["truncated"]) + finally: + snrc.MAX_OWNED = keep + + def test_a_malformed_address_is_refused_before_any_rpc(self): + snrc.eth_call = lambda *a: self.fail("must not reach the chain") + status, body = snrc.owned_by("0xnope") + self.assertEqual(status, 400) + self.assertIn("address", body["error"]) + + def test_no_configured_registrar_is_an_error_not_an_empty_list(self): + snrc.REGISTRARS = {"testing": "", "simplex": ""} + snrc.eth_call = lambda *a: self.fail("must not reach the chain") + status, body = snrc.owned_by(self.OWNER) + self.assertEqual(status, 400) + self.assertEqual(body["configured_tlds"], []) + + +class NameStatusTests(unittest.TestCase): + """simplexmq#1821: unresolvable has three causes and a caller has to tell + them apart. Names expire lazily, so the chain still holds the answer.""" + + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + + def _expiry(self, value): + def eth_call(to, data): + self.assertTrue(data.startswith(snrc.selector("nameExpires(uint256)"))) + return "0x" + snrc.encode_uint(value) + + return eth_call + + def setUp(self): + self._registrars, self._eth_call = snrc.REGISTRARS, snrc.eth_call + snrc.REGISTRARS = {"testing": self.REGISTRAR} + + def tearDown(self): + snrc.REGISTRARS, snrc.eth_call = self._registrars, self._eth_call + + 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} + ) + + def test_past_expiry_is_expired_and_keeps_the_date(self): + past = int(time.time()) - 3600 + snrc.eth_call = self._expiry(past) + self.assertEqual( + snrc.name_status("alice.testing"), {"status": "expired", "expires": past} + ) + + 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} + ) + + 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} + ) + + class SplitLinksTests(unittest.TestCase): """`split_links` decodes the multi-URL convention for simplex.contact / simplex.channel text records. Reuses the same rule the dApp's From e8652a2bd442b736a3ba7195d556088e9b2b2010 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 31 Aug 2026 09:59:09 +0200 Subject: [PATCH 2/6] fixes --- scripts/resolver/README.md | 177 ++++++++++++++---- scripts/resolver/service/snrc-resolve.py | 74 +++++++- scripts/resolver/service/test_snrc_resolve.py | 69 ++++++- 3 files changed, 267 insertions(+), 53 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index ae6e406da..8be9c2a06 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -71,41 +71,11 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq # → {"name":"foobar.testing","nickname":"Foo","simplexContact":["https://smp16.simplex.im/a#…"], … } ``` -**4. resolver distinguishes the three ways a name fails to resolve.** Names -expire lazily, so the chain still holds the answer and the resolver reports it -rather than returning a bare 404 for every case: +**4. resolver answers the reverse lookup:** ```sh -curl -s http://127.0.0.1:8000/resolve/never-taken.testing | jq -# 404 → {"status":"unregistered", …} never registered -curl -s http://127.0.0.1:8000/resolve/lapsed.testing | jq -# 410 → {"status":"expired","expires":1750…} registered, then lapsed -curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq -# 200 → {"status":"registered","expires":1780…, …} +curl -s http://127.0.0.1:8000/owned-by/0x69a6000000000000000000000000000000002d32 | jq '.names' +# → [{"name":"foobar.testing","status":"registered","expires":1780…, …}] ``` -A held name that points nowhere answers 404 with `"status":"noResolver"`, which -is a different problem from either of the above. Status needs -`SNRC_REGISTRAR_` configured; without it the field reads `"unknown"` and -the endpoint behaves as it did before. - -**5. resolver lists the names an address holds:** -```sh -curl -s http://127.0.0.1:8000/owned-by/0x69a6000000000000000000000000000000002d32 | jq -# → {"address":"0x69a6…","names":[ -# {"name":"foobar.testing","tld":"testing","labelhash":"0x…", -# "expires":1780…,"status":"registered"}, -# {"name":"lapsed.testing","tld":"testing","labelhash":"0x…", -# "expires":1750…,"status":"expired"}], -# "truncated":false,"checkedTlds":["testing"]} -``` -Read from the ERC-721 registrar (`balanceOf` / `tokenOfOwnerByIndex` / -`labelOf`), so it reflects names acquired by transfer as well as by -registration, and needs no log scan. - -**Expired names are listed, not filtered**, each carrying the same `status` -vocabulary `/resolve` uses. A wallet scanning for the names a key holds is -precisely the caller who needs to be told one has lapsed, so it can offer to -renew it. Filter on `status == "registered"` for the live set only. Bounded by -`SNRC_MAX_OWNED` (default 256), and the response says when it truncated. **Wire your smp-server:** in its `[NAMES]` section set `resolver_endpoint: http://127.0.0.1:8000` (no auth needed for loopback). @@ -155,7 +125,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 | noResolver | unknown + "expires": 1780000000, // Unix seconds; when the registration ends + "graceEnds": 1787776000 // expires + GRACE_PERIOD; last moment the owner can renew } ``` @@ -165,18 +138,150 @@ 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 | +| `noResolver` | registered, but points nowhere | +| `unknown` | no `SNRC_REGISTRAR_` configured, so status could not be read | + +Which HTTP code carries each, and what every other input does, is in +[Every case](#every-case-and-what-comes-back) at the end. + +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. + ### Status codes | Status | Meaning | |---|---| -| 200 | resolved | +| 200 | resolved; `status` is `registered` | | 400 | TLD not configured, or not a fully-qualified name | -| 404 | name has no resolver set on the registry | +| 404 | never registered (`unregistered`), or registered with no resolver set (`noResolver`) | +| 410 | registration has lapsed — `status` says whether it is still renewable | | 502 | upstream RPC error / reth not synced | +### `GET /owned-by/
` + +Every name an Ethereum address holds, across every configured TLD. + +```jsonc +{ + "address": "0x69a6…", + "names": [ + {"name": "foobar.testing", "tld": "testing", "labelhash": "0x…", + "expires": 1780000000, "graceEnds": 1787776000, "status": "registered"}, + {"name": "lapsed.testing", "tld": "testing", "labelhash": "0x…", + "expires": 1750000000, "graceEnds": 1757776000, "status": "grace"} + ], + "truncated": false, + "checkedTlds": ["testing"] +} +``` + +Read from the ERC-721 registrar (`balanceOf` → `tokenOfOwnerByIndex` → +`nameExpires` → `labelOf`), so it needs no log scan and includes names acquired +by transfer as well as by registration. `labelOf` is the plaintext label +recorded write-once at registration, so a token id turns back into a name +without an off-chain index; a token whose label was never recorded is returned +with `"name": null` and its `labelhash`, rather than being dropped. + +**Lapsed names are listed, not filtered**, with the same `status` vocabulary as +`/resolve` — a wallet scanning a key is exactly the caller who needs to be told +a name has lapsed and can still be renewed. Filter on `status == "registered"` +for the live set only. Enumeration is deliberately not maintained on expiry (the +registrar documents this), which is why `status` rather than presence is the +thing to read. + +`truncated` is `true` when an address holds more than `SNRC_MAX_OWNED` names +(default 256) in one TLD, so a caller can tell a short list from a complete one. +Requires `SNRC_REGISTRAR_`; with none configured the endpoint answers 400 +rather than an empty list. + ### 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. + +`SNRC_REGISTRAR_` is the matching ERC-721 registrar, and is what `/owned-by` +and the expiry status are read from — the registry answers *who owns this node*, +the registrar is the NFT that can be asked the reverse and when it expires. +Without it `/resolve` still works and reports `"status": "unknown"`, and +`/owned-by` answers 400. `SNRC_MAX_OWNED` bounds one `/owned-by` response +(default 256). + +## Every case, and what comes back + +Every input either endpoint can be given, and the exact answer. Written out +because the interesting cases are the ones that are hard to reach on purpose — +a name in its grace period, a token whose label predates label recording — and +a caller has to handle them without having seen one. + +Timestamps are Unix seconds. `status`, `expires` and `graceEnds` are present on +every `/resolve` response that got as far as looking the name up — `null` where +not knowable — so a client can read them without checking for the key first. +The two 400s below are the exception: they fail on the request itself, before +any lookup, and carry none of the three. + +### `GET /resolve/` + +| Situation | HTTP | `status` | Body | +|---|---|---|---| +| Live name with records | 200 | `registered` | full record; `expires` is when it ends, `graceEnds` when it would stop being renewable | +| Live name, no text records set | 200 | `registered` | full record; text fields `""`, link arrays `[]`, coin fields `null` | +| Live subname (`bar.foo.testing`) | 200 | `registered` | its own records, with the expiry of the 2LD `foo.testing` above it | +| Registered, resolver never set | 404 | `noResolver` | `expires`, `graceEnds`, `error` — held, but points nowhere | +| Lapsed, still in grace | 410 | `grace` | `expires` (when it lapsed), `graceEnds` (last moment its owner can renew) | +| Lapsed, past grace | 410 | `expired` | same fields; anyone may register it now | +| Never registered | 404 | `unregistered` | `expires` and `graceEnds` are `null` | +| TLD has no registry configured | 400 | — | `configured_tlds`, listing the ones that are | +| TLD has no *registrar* configured | 200 / 404 | `unknown` | resolves as it otherwise would; expiry cannot be read, so `expires` and `graceEnds` are `null` | +| Not fully qualified (`alice`) | 400 | — | `error` naming the expected form | +| RPC unreachable or node unsynced | 502 | — | `error` with the underlying exception type | + +A name in grace still has its records on chain — expiry is lazy — but the +resolver answers 410 rather than serving them, so a stale name cannot be +resolved by accident. Read `expires` from that response to say when it lapsed. + +### `GET /owned-by/
` + +Answers 200 with a `names` array in every case where the address is well formed +and a registrar is configured; the interesting variation is per entry. + +| Situation | HTTP | Result | +|---|---|---| +| Address holds live names | 200 | one entry each, `status` `registered` | +| Address holds a name in grace | 200 | entry with `status` `grace` and `graceEnds` — the renewal reminder case | +| Address holds a name past grace | 200 | entry with `status` `expired`; still listed, because the holder is who needs to know | +| Address holds nothing | 200 | `names: []` — an answer, not an error | +| Token whose label was never recorded | 200 | entry with `"name": null` and its `labelhash`; the token is real, the name is not recoverable from chain state | +| Address holds more than `SNRC_MAX_OWNED` in a TLD | 200 | first 256, and `truncated: true` | +| Several TLDs configured | 200 | all of them merged, sorted by TLD then name; `checkedTlds` says which were asked | +| Malformed address | 400 | `error`; no RPC call is made | +| No registrar configured for any TLD | 400 | `error` and `configured_tlds: []` — distinct from "holds nothing" | +| RPC unreachable or node unsynced | 502 | `error` with the underlying exception type | + +Names are **not** filtered by expiry. Enumeration on the registrar is +maintained on transfer, mint and burn but deliberately not on expiry, so a +lapsed name stays enumerable until someone re-registers it — and that is +exactly the name its holder needs to be told about. Filter on +`status == "registered"` for the live set. diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 1711aa553..6ed87fb02 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -449,20 +449,28 @@ def resolve(name: str): # 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 one that is held but not pointed anywhere. + # 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"] == "unregistered": return 404, { "name": name, "status": "unregistered", + "expires": None, + "graceEnds": None, "error": "this name has never been registered", } - if reg["status"] == "expired": + if reg["status"] in ("grace", "expired"): return 410, { "name": name, - "status": "expired", + "status": reg["status"], "expires": reg["expires"], - "error": "this registration expired", + "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) @@ -472,6 +480,7 @@ def resolve(name: str): "name": name, "status": "noResolver", "expires": reg["expires"], + "graceEnds": reg["graceEnds"], "error": "no resolver set for this name", } @@ -511,9 +520,43 @@ def resolve(name: str): "resolver": resolver_addr, "status": reg["status"], "expires": reg["expires"], + "graceEnds": reg["graceEnds"], } +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`. It is computed here rather + than called per name because the answer is needed for every token in a + listing and the inputs are one constant plus a value already fetched. + + 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 name_status(name: str): """Registration status of the 2LD a name sits under. @@ -531,17 +574,20 @@ def name_status(name: str): 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} + return {"status": "unknown", "expires": None, "graceEnds": None} token = int.from_bytes(keccak(labels[-2].encode()), "big") expires = decode_uint( eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token)) ) if expires == 0: - return {"status": "unregistered", "expires": None} - if expires <= int(time.time()): - return {"status": "expired", "expires": expires} - return {"status": "registered", "expires": expires} + return {"status": "unregistered", "expires": None, "graceEnds": None} + grace = grace_period(registrar) + return { + "status": expiry_status(expires, grace, int(time.time())), + "expires": expires, + "graceEnds": expires + grace, + } def owned_by(address: str): @@ -565,6 +611,12 @@ def owned_by(address: str): Callers wanting only the live set filter on `status == "registered"`, which is the check the registrar's invariant asks of readers - applied by whoever knows whether expired names matter to them, rather than here. + + A lapsed name is reported as `grace` while only its previous owner may + renew it, and `expired` once anyone can take it. The difference is the + whole content of a renewal reminder: one is "renew this", the other is + "this is gone unless you are quick", and `graceEnds` says when the first + becomes the second. """ if not is_address(address): return 400, {"address": address, "error": "expected a 0x-prefixed 20-byte address"} @@ -580,6 +632,7 @@ def owned_by(address: str): now = int(time.time()) names, truncated = [], False for tld, registrar in configured.items(): + grace = grace_period(registrar) held = decode_uint( eth_call(registrar, selector("balanceOf(address)") + encode_address(address)) ) @@ -611,7 +664,8 @@ def owned_by(address: str): "tld": tld, "labelhash": hex(token), "expires": expires, - "status": "registered" if expires > now else "expired", + "graceEnds": expires + grace if expires else None, + "status": expiry_status(expires, grace, now), } ) diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index adf3d8404..4b58140db 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -64,6 +64,7 @@ class OwnedByTests(unittest.TestCase): REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" OWNER = "0x69a6000000000000000000000000000000002d32" + GRACE = 90 * 86400 def _fake_chain(self, tokens): """tokens :: [(labelhash, label, expires)] held by OWNER.""" @@ -71,6 +72,8 @@ def _fake_chain(self, tokens): def eth_call(to, data): self.assertEqual(to, self.REGISTRAR) + if data.startswith(sel("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(self.GRACE) if data.startswith(sel("balanceOf(address)")): return "0x" + snrc.encode_uint(len(tokens)) if data.startswith(sel("tokenOfOwnerByIndex(address,uint256)")): @@ -118,14 +121,24 @@ def test_expired_names_are_reported_not_dropped(self): self.assertEqual([n["name"] for n in body["names"]], ["lapsed.testing", "live.testing"]) by_name = {n["name"]: n for n in body["names"]} self.assertEqual(by_name["live.testing"]["status"], "registered") - self.assertEqual(by_name["lapsed.testing"]["status"], "expired") + # lapsed an hour ago, so still renewable by its owner + self.assertEqual(by_name["lapsed.testing"]["status"], "grace") self.assertEqual(by_name["lapsed.testing"]["expires"], now - 1) + self.assertEqual(by_name["lapsed.testing"]["graceEnds"], now - 1 + self.GRACE) + + def test_a_name_past_grace_is_reported_as_claimable(self): + now = int(time.time()) + snrc.eth_call = self._fake_chain([(11, "gone", now - self.GRACE - 3600)]) + _, body = snrc.owned_by(self.OWNER) + self.assertEqual(body["names"][0]["status"], "expired") def test_status_uses_the_same_vocabulary_as_resolve(self): now = int(time.time()) snrc.eth_call = self._fake_chain([(11, "live", now + 86400)]) _, body = snrc.owned_by(self.OWNER) - self.assertIn(body["names"][0]["status"], ("registered", "expired")) + self.assertIn( + body["names"][0]["status"], ("registered", "grace", "expired", "unregistered") + ) def test_a_name_with_no_recorded_label_is_reported_by_labelhash(self): future = int(time.time()) + 86400 @@ -167,8 +180,12 @@ class NameStatusTests(unittest.TestCase): 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) @@ -184,23 +201,47 @@ def tearDown(self): 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} + snrc.name_status("alice.testing"), + {"status": "unregistered", "expires": None, "graceEnds": None}, ) - def test_past_expiry_is_expired_and_keeps_the_date(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( - snrc.name_status("alice.testing"), {"status": "expired", "expires": past} + 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} + 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 = [] @@ -218,9 +259,23 @@ 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} + 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 SplitLinksTests(unittest.TestCase): """`split_links` decodes the multi-URL convention for simplex.contact / From a3903887f537a2724b40eb58ddd80eefdba5d625 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 31 Aug 2026 10:44:37 +0200 Subject: [PATCH 3/6] some security hardening --- scripts/resolver/README.md | 79 ++++++-- scripts/resolver/docker-compose.yml | 8 + scripts/resolver/service/snrc-resolve.py | 182 +++++++++++++++--- scripts/resolver/service/test_snrc_resolve.py | 106 +++++++++- 4 files changed, 327 insertions(+), 48 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 8be9c2a06..345e6b287 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -168,6 +168,22 @@ distinguish these: it is also true for a name nobody ever registered, since 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. +### Errors + +Every non-2xx body carries a stable `error` code to branch on and a human +`message`, alongside the subject (`name` or `address`): + +```jsonc +{"name": "alice.testing", "error": "unregistered", + "message": "this name has never been registered", + "status": "unregistered", "expires": null, "graceEnds": null} +``` + +Codes: `tldNotConfigured`, `notFullyQualified`, `unregistered`, `grace`, +`expired`, `noResolver`, `badAddress`, `badOffset`, `noRegistrarConfigured`, +`unauthorized`, `noSuchRoute`, `upstreamError`. For a name whose registration +is the problem, the code equals `status`. + ### Status codes | Status | Meaning | @@ -176,6 +192,7 @@ answer — a subname is only as valid as the name above it. | 400 | TLD not configured, or not a fully-qualified name | | 404 | never registered (`unregistered`), or registered with no resolver set (`noResolver`) | | 410 | registration has lapsed — `status` says whether it is still renewable | +| 401 | `Authorization` missing or wrong, when a secret is configured | | 502 | upstream RPC error / reth not synced | ### `GET /owned-by/
` @@ -215,19 +232,45 @@ thing to read. Requires `SNRC_REGISTRAR_`; with none configured the endpoint answers 400 rather than an empty list. -### Configuring registries +### Configuring addresses + +Two maps, both per TLD. The **registry** answers *who owns this node* and is +what `/resolve` reads; it defaults to mainnet `.testing`, with `.simplex` unset +until deployed. The **registrar** is the ERC-721 that can be asked the reverse +and when a name expires — it is what `/owned-by` and every expiry field are +read from. Without a registrar for a TLD, `/resolve` still works and reports +`"status": "unknown"`, and `/owned-by` answers 400. + +| Variable | Purpose | +|---|---| +| `SNRC_REGISTRY_` | ENS registry; resolution | +| `SNRC_REGISTRAR_` | ERC-721 registrar; `/owned-by`, expiry and status | +| `SNRC_MAX_OWNED` | names per `/owned-by` page (default 256) | + +Set them on the `resolver` service in `docker-compose.yml`, or as env vars for +the standalone script. + +### Hardening + +The script binds `127.0.0.1` by default; `docker-compose.yml` sets `0.0.0.0` +because it must listen on the container bridge, and publishes the port to host +loopback only. Anything beyond loopback wants `SNRC_AUTH_BEARER` (or +`SNRC_AUTH_BASIC`, `user:password`) — the header is compared in constant time, +and it is the header the smp-server's `HttpResolver` already sends. Unset means +no check. + +`SNRC_CACHE_TTL` (default 15s) memoises `eth_call` by target and calldata, which +matters because one `/resolve` is 15 upstream calls and one `/owned-by` page can +be hundreds; set it to `0` to disable. `SNRC_MAX_RPC_BYTES` (default 2 MiB) +refuses an oversized JSON-RPC response rather than reading it. -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. +`/health` reports the RPC URL and both address maps, so **do not expose it** — +a hosted RPC URL usually carries the provider key in its path. 502 bodies name +the exception type only, and the detail goes to the log, for the same reason. -`SNRC_REGISTRAR_` is the matching ERC-721 registrar, and is what `/owned-by` -and the expiry status are read from — the registry answers *who owns this node*, -the registrar is the NFT that can be asked the reverse and when it expires. -Without it `/resolve` still works and reports `"status": "unknown"`, and -`/owned-by` answers 400. `SNRC_MAX_OWNED` bounds one `/owned-by` response -(default 256). +`http.server` is a development server. This deployment is loopback-only and +that is the posture it is written for; anything public wants a real server in +front of it. ## Every case, and what comes back @@ -253,10 +296,10 @@ any lookup, and carry none of the three. | Lapsed, still in grace | 410 | `grace` | `expires` (when it lapsed), `graceEnds` (last moment its owner can renew) | | Lapsed, past grace | 410 | `expired` | same fields; anyone may register it now | | Never registered | 404 | `unregistered` | `expires` and `graceEnds` are `null` | -| TLD has no registry configured | 400 | — | `configured_tlds`, listing the ones that are | +| TLD has no registry configured | 400 | — | `error: tldNotConfigured`, plus `configuredTlds` | | TLD has no *registrar* configured | 200 / 404 | `unknown` | resolves as it otherwise would; expiry cannot be read, so `expires` and `graceEnds` are `null` | | Not fully qualified (`alice`) | 400 | — | `error` naming the expected form | -| RPC unreachable or node unsynced | 502 | — | `error` with the underlying exception type | +| RPC unreachable or node unsynced | 502 | — | `error: upstreamError`; the detail goes to the log, not the body | A name in grace still has its records on chain — expiry is lazy — but the resolver answers 410 rather than serving them, so a stale name cannot be @@ -274,11 +317,13 @@ and a registrar is configured; the interesting variation is per entry. | Address holds a name past grace | 200 | entry with `status` `expired`; still listed, because the holder is who needs to know | | Address holds nothing | 200 | `names: []` — an answer, not an error | | Token whose label was never recorded | 200 | entry with `"name": null` and its `labelhash`; the token is real, the name is not recoverable from chain state | -| Address holds more than `SNRC_MAX_OWNED` in a TLD | 200 | first 256, and `truncated: true` | +| Address holds more than `SNRC_MAX_OWNED` in a TLD | 200 | one page, `truncated: true` and `nextOffset` to resume from | | Several TLDs configured | 200 | all of them merged, sorted by TLD then name; `checkedTlds` says which were asked | -| Malformed address | 400 | `error`; no RPC call is made | -| No registrar configured for any TLD | 400 | `error` and `configured_tlds: []` — distinct from "holds nothing" | -| RPC unreachable or node unsynced | 502 | `error` with the underlying exception type | +| Malformed address | 400 | `error: badAddress`; no RPC call is made | +| Negative or non-numeric `?offset=` | 400 | `error: badOffset` | +| `?offset=` past the end | 200 | `names: []` and `nextOffset: null` | +| No registrar configured for any TLD | 400 | `error: noRegistrarConfigured`, `configuredTlds: []` — distinct from "holds nothing" | +| RPC unreachable or node unsynced | 502 | `error: upstreamError`; the detail goes to the log, not the body | Names are **not** filtered by expiry. Enumeration on the registrar is maintained on transfer, mint and burn but deliberately not on expiry, so a diff --git a/scripts/resolver/docker-compose.yml b/scripts/resolver/docker-compose.yml index f03cc2028..11bcd1318 100644 --- a/scripts/resolver/docker-compose.yml +++ b/scripts/resolver/docker-compose.yml @@ -144,6 +144,8 @@ services: condition: service_started environment: SNRC_RPC: http://reth:8545 + # The script defaults to 127.0.0.1; inside a container it has to listen + # on the bridge, and the port below is still published to loopback only. SNRC_BIND: 0.0.0.0 # Registry addresses cascade through the script's own defaults # (mainnet `.testing`; `.simplex` unconfigured). Set explicitly here @@ -155,6 +157,12 @@ services: # SNRC_REGISTRAR_TESTING: 0x... # SNRC_REGISTRAR_SIMPLEX: 0x... # SNRC_MAX_OWNED: 256 + # SNRC_CACHE_TTL: 15 # seconds to memoise eth_call; 0 disables + # SNRC_MAX_RPC_BYTES: 2097152 # refuse a larger JSON-RPC response + # Shared secret the caller must present. Unset = no check, which is + # right while the port is published to loopback only. + # SNRC_AUTH_BEARER: + # SNRC_AUTH_BASIC: : 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 6ed87fb02..4845a42c7 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -40,7 +40,7 @@ SNRC_REGISTRY_SIMPLEX ENSRegistry for the .simplex deployment (default: empty — TLD not yet deployed) SNRC_PORT Listen port (default: 8000) - SNRC_BIND Bind address (default: 0.0.0.0) + SNRC_BIND Bind address (default: 127.0.0.1; compose sets 0.0.0.0) Each TLD is a separate SNRC deployment with its own ENSRegistry; the resolver dispatches by the queried name's rightmost label. @@ -58,19 +58,21 @@ Unrecognised payloads fall back to `0x`-prefixed raw hex. """ +import base64 import hashlib +import hmac import json import os import sys import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import unquote, urlparse +from urllib.parse import parse_qs, unquote, urlparse from urllib.request import Request, urlopen from eth_hash.auto import keccak RPC = os.environ.get("SNRC_RPC", "http://127.0.0.1:8545") -BIND = os.environ.get("SNRC_BIND", "0.0.0.0") +BIND = os.environ.get("SNRC_BIND", "127.0.0.1") PORT = int(os.environ.get("SNRC_PORT", "8000")) # Each TLD is its own SNRC deployment with its own ENSRegistry. Dispatch @@ -86,6 +88,15 @@ "simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet } +# Shared secret the caller must present. Unset means no check - correct for a +# loopback deployment, and the reason the check exists at all is that the +# Haskell client has always been able to send `Authorization` and nothing here +# ever read it, so configuring auth protected nothing. +# SNRC_AUTH_BEARER= -> Authorization: Bearer +# SNRC_AUTH_BASIC=: -> Authorization: Basic base64(user:pass) +AUTH_BEARER = os.environ.get("SNRC_AUTH_BEARER", "") +AUTH_BASIC = os.environ.get("SNRC_AUTH_BASIC", "") + # The BaseRegistrar (ERC-721) per TLD, used for owner -> names. Separate from # the registry above: the registry answers "who owns this node", the registrar # is the NFT that can be asked the reverse. Not a proxy, so the address in @@ -112,6 +123,20 @@ # ---------- RPC + ABI helpers (mirrors ens-lookup.py shape) ---------- +# A JSON-RPC body larger than this is refused rather than read. The Haskell +# client that calls this resolver caps its own reads for the same reason; an +# upstream that is compromised or simply misconfigured must not be able to +# decide how much memory this process allocates. +MAX_RPC_BYTES = int(os.environ.get("SNRC_MAX_RPC_BYTES", str(2 * 1024 * 1024))) + +# eth_call answers change at block cadence, not per request, so repeating one +# within a few seconds asks the node a question it has already answered. One +# /resolve is 15 calls and one /owned-by can be hundreds, which makes this the +# difference between a warm resolver and a busy node. +CACHE_TTL = float(os.environ.get("SNRC_CACHE_TTL", "15")) +_CALL_CACHE = {} + + def rpc(method, params): body = json.dumps( {"jsonrpc": "2.0", "method": method, "params": params, "id": 1} @@ -126,7 +151,11 @@ def rpc(method, params): "User-Agent": "snrc-resolve/1.0", }, ) - res = json.loads(urlopen(req, timeout=15).read()) + with urlopen(req, timeout=15) as r: + raw = r.read(MAX_RPC_BYTES + 1) + if len(raw) > MAX_RPC_BYTES: + raise RuntimeError(f"RPC response exceeds {MAX_RPC_BYTES} bytes") + res = json.loads(raw) if "error" in res: raise RuntimeError(res["error"]) return res["result"] @@ -145,7 +174,28 @@ def selector(signature: str) -> str: def eth_call(to: str, data: str) -> str: - return rpc("eth_call", [{"to": to, "data": data}, "latest"]) + """A read against `latest`, memoised for CACHE_TTL seconds. + + Keyed on the call itself, so the cache is shared across endpoints: a name + resolved just after it was listed costs nothing the second time. Set + SNRC_CACHE_TTL=0 to disable. + """ + if CACHE_TTL <= 0: + return rpc("eth_call", [{"to": to, "data": data}, "latest"]) + key = (to, data) + now = time.monotonic() + hit = _CALL_CACHE.get(key) + if hit and hit[0] > now: + return hit[1] + result = rpc("eth_call", [{"to": to, "data": data}, "latest"]) + # Evict lazily: this only grows while requests are arriving, and a sweep + # on write keeps it proportional to traffic rather than to uptime. + if len(_CALL_CACHE) > 4096: + for k, v in list(_CALL_CACHE.items()): + if v[0] <= now: + del _CALL_CACHE[k] + _CALL_CACHE[key] = (now + CACHE_TTL, result) + return result def decode_address(hex_data: str) -> str: @@ -178,6 +228,37 @@ def decode_string(hex_data: str) -> str: return raw.decode("utf-8", errors="replace") if raw else "" +def expected_auth_header() -> str: + """The Authorization value this resolver requires, or "" for none.""" + if AUTH_BEARER: + return "Bearer " + AUTH_BEARER + if AUTH_BASIC: + return "Basic " + base64.b64encode(AUTH_BASIC.encode()).decode() + return "" + + +def auth_ok(header: str) -> bool: + """Constant-time compare, so a wrong token cannot be found a byte at a time.""" + expected = expected_auth_header() + if not expected: + return True + return hmac.compare_digest(header or "", expected) + + +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 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 is_address(value: str) -> bool: return ( len(value) == 42 @@ -440,8 +521,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 = namehash(name) @@ -458,7 +540,8 @@ def resolve(name: str): "status": "unregistered", "expires": None, "graceEnds": None, - "error": "this name has never been registered", + "error": "unregistered", + "message": "this name has never been registered", } if reg["status"] in ("grace", "expired"): return 410, { @@ -466,7 +549,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" @@ -481,7 +565,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) @@ -590,7 +675,7 @@ def name_status(name: str): } -def owned_by(address: str): +def owned_by(address: str, offset: int = 0): """Every live name an address holds, across every configured TLD. Read straight off the ERC-721 registrar rather than from logs: the token @@ -619,14 +704,19 @@ def owned_by(address: str): becomes the second. """ if not is_address(address): - return 400, {"address": address, "error": "expected a 0x-prefixed 20-byte address"} + return 400, { + "address": address, + "error": "badAddress", + "message": "expected a 0x-prefixed 20-byte address", + } configured = {t: r for t, r in REGISTRARS.items() if r} if not configured: return 400, { "address": address, - "error": "no registrar is configured on this resolver", - "configured_tlds": [], + "error": "noRegistrarConfigured", + "message": "no registrar is configured on this resolver", + "configuredTlds": [], } now = int(time.time()) @@ -636,10 +726,11 @@ def owned_by(address: str): held = decode_uint( eth_call(registrar, selector("balanceOf(address)") + encode_address(address)) ) - if held > MAX_OWNED: + first = min(offset, held) + last = min(first + MAX_OWNED, held) + if last < held: truncated = True - held = MAX_OWNED - for i in range(held): + for i in range(first, last): token = decode_uint( eth_call( registrar, @@ -662,7 +753,7 @@ def owned_by(address: str): { "name": (label + "." + tld) if label else None, "tld": tld, - "labelhash": hex(token), + "labelhash": "0x" + format(token, "064x"), "expires": expires, "graceEnds": expires + grace if expires else None, "status": expiry_status(expires, grace, now), @@ -673,6 +764,11 @@ def owned_by(address: str): return 200, { "address": address, "names": names, + "offset": offset, + # `nextOffset` is the cursor to resume from, or null when the listing + # is complete - so "there is more" and "here is how to get it" are the + # same answer rather than a flag with no way to act on it. + "nextOffset": offset + MAX_OWNED if truncated else None, "truncated": truncated, "checkedTlds": sorted(configured), } @@ -680,15 +776,36 @@ def owned_by(address: str): # ---------- HTTP layer ---------- +# Bumped when the response shape changes, so a client can tell "this resolver +# does not report that" from "that is not knowable for this name". +API_VERSION = 2 + + class Handler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 - http.server contract - path = urlparse(self.path).path - parts = [unquote(p) for p in path.split("/") if p] + parsed = urlparse(self.path) + parts = [unquote(p) for p in parsed.path.split("/") if p] + + if not auth_ok(self.headers.get("Authorization")): + self._respond( + 401, + {"error": "unauthorized", "message": "missing or invalid Authorization"}, + ) + return if parts == ["health"]: self._respond( 200, - {"ok": True, "rpc": RPC, "registries": REGISTRIES}, + { + "ok": True, + "version": API_VERSION, + "rpc": RPC, + "registries": REGISTRIES, + # /owned-by and every expiry field are read from these, so + # an operator who configured only the registries can see + # here why status reads "unknown". + "registrars": REGISTRARS, + }, ) return @@ -698,31 +815,42 @@ 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 if len(parts) == 2 and parts[0] == "owned-by": address = parts[1].strip().lower() try: - status, body = owned_by(address) + offset = int(parse_qs(parsed.query).get("offset", ["0"])[0]) + if offset < 0: + raise ValueError("offset must not be negative") + except ValueError as e: + self._respond( + 400, {"address": address, "error": "badOffset", "message": str(e)} + ) + return + try: + status, body = owned_by(address, offset) except Exception as e: # surface upstream errors as 502 - status, body = 502, {"address": address, "error": f"{type(e).__name__}: {e}"} + status, body = 502, upstream_error({"address": address}, e) self._respond(status, body) return self._respond( 404, { - "error": "not found", + "error": "noSuchRoute", + "message": "not found", "routes": ["/health", "/resolve/", "/owned-by/
"], }, ) diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 4b58140db..f42157208 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -145,9 +145,10 @@ def test_a_name_with_no_recorded_label_is_reported_by_labelhash(self): snrc.eth_call = self._fake_chain([(11, "", future)]) _, body = snrc.owned_by(self.OWNER) self.assertEqual(body["names"][0]["name"], None) - self.assertEqual(body["names"][0]["labelhash"], hex(11)) + # a labelhash is bytes32, not the shortest integer literal that fits + self.assertEqual(body["names"][0]["labelhash"], "0x" + "0" * 63 + "b") - def test_enumeration_is_bounded_and_says_so(self): + def test_enumeration_is_bounded_and_offers_a_cursor(self): future = int(time.time()) + 86400 snrc.MAX_OWNED, keep = 2, snrc.MAX_OWNED try: @@ -157,21 +158,50 @@ def test_enumeration_is_bounded_and_says_so(self): _, body = snrc.owned_by(self.OWNER) self.assertEqual(len(body["names"]), 2) self.assertTrue(body["truncated"]) + # a flag with no way to act on it is a dead end, so it carries one + self.assertEqual(body["nextOffset"], 2) finally: snrc.MAX_OWNED = keep + def test_the_cursor_walks_the_whole_list_without_repeats(self): + future = int(time.time()) + 86400 + snrc.MAX_OWNED, keep = 2, snrc.MAX_OWNED + try: + snrc.eth_call = self._fake_chain( + [(i, "n%d" % i, future) for i in range(1, 6)] + ) + seen, offset = [], 0 + while offset is not None: + _, body = snrc.owned_by(self.OWNER, offset) + seen += [n["name"] for n in body["names"]] + offset = body["nextOffset"] + self.assertEqual(sorted(seen), sorted("n%d.testing" % i for i in range(1, 6))) + self.assertEqual(len(seen), len(set(seen))) + finally: + snrc.MAX_OWNED = keep + + def test_an_offset_past_the_end_is_an_empty_page_not_an_error(self): + future = int(time.time()) + 86400 + snrc.eth_call = self._fake_chain([(1, "only", future)]) + status, body = snrc.owned_by(self.OWNER, 99) + self.assertEqual(status, 200) + self.assertEqual(body["names"], []) + self.assertIsNone(body["nextOffset"]) + def test_a_malformed_address_is_refused_before_any_rpc(self): snrc.eth_call = lambda *a: self.fail("must not reach the chain") status, body = snrc.owned_by("0xnope") self.assertEqual(status, 400) - self.assertIn("address", body["error"]) + self.assertEqual(body["error"], "badAddress") + self.assertIn("address", body["message"]) def test_no_configured_registrar_is_an_error_not_an_empty_list(self): snrc.REGISTRARS = {"testing": "", "simplex": ""} snrc.eth_call = lambda *a: self.fail("must not reach the chain") status, body = snrc.owned_by(self.OWNER) self.assertEqual(status, 400) - self.assertEqual(body["configured_tlds"], []) + self.assertEqual(body["error"], "noRegistrarConfigured") + self.assertEqual(body["configuredTlds"], []) class NameStatusTests(unittest.TestCase): @@ -277,6 +307,74 @@ def test_every_branch_returns_the_same_keys(self): self.assertEqual(set(snrc.name_status("alice.testing")), keys) +class AuthTests(unittest.TestCase): + """The Haskell client has always been able to send `Authorization`; until + now nothing here read it, so configuring auth protected nothing.""" + + def setUp(self): + self._saved = (snrc.AUTH_BEARER, snrc.AUTH_BASIC) + + def tearDown(self): + snrc.AUTH_BEARER, snrc.AUTH_BASIC = self._saved + + def test_no_secret_configured_accepts_anything(self): + snrc.AUTH_BEARER = snrc.AUTH_BASIC = "" + self.assertTrue(snrc.auth_ok("")) + self.assertTrue(snrc.auth_ok("Bearer whatever")) + + def test_bearer_accepts_only_the_configured_token(self): + snrc.AUTH_BEARER, snrc.AUTH_BASIC = "sekrit", "" + self.assertTrue(snrc.auth_ok("Bearer sekrit")) + self.assertFalse(snrc.auth_ok("Bearer sekri")) + self.assertFalse(snrc.auth_ok("Bearer sekrit2")) + self.assertFalse(snrc.auth_ok("")) + self.assertFalse(snrc.auth_ok(None)) + + def test_basic_matches_what_the_haskell_client_builds(self): + snrc.AUTH_BEARER, snrc.AUTH_BASIC = "", "user:pass" + # HttpResolver.hs: "Basic " <> base64(user <> ":" <> password) + self.assertEqual(snrc.expected_auth_header(), "Basic dXNlcjpwYXNz") + self.assertTrue(snrc.auth_ok("Basic dXNlcjpwYXNz")) + self.assertFalse(snrc.auth_ok("Basic bm9wZQ==")) + + +class CallCacheTests(unittest.TestCase): + """One /resolve is 15 upstream calls and one /owned-by can be hundreds, so + repeating a call the node just answered is the cost worth removing.""" + + def setUp(self): + self._saved = (snrc.eth_call, snrc.rpc, snrc.CACHE_TTL, dict(snrc._CALL_CACHE)) + snrc._CALL_CACHE.clear() + + def tearDown(self): + snrc.eth_call, snrc.rpc, snrc.CACHE_TTL, cache = self._saved + snrc._CALL_CACHE.clear() + snrc._CALL_CACHE.update(cache) + + def test_a_repeated_call_asks_the_node_once(self): + calls = [] + snrc.rpc = lambda method, params: calls.append(params) or "0x2a" + snrc.CACHE_TTL = 60 + self.assertEqual(snrc.eth_call("0xto", "0xdata"), "0x2a") + self.assertEqual(snrc.eth_call("0xto", "0xdata"), "0x2a") + self.assertEqual(len(calls), 1) + + def test_different_calls_are_not_confused(self): + snrc.rpc = lambda method, params: params[0]["data"] + snrc.CACHE_TTL = 60 + self.assertEqual(snrc.eth_call("0xto", "0xaa"), "0xaa") + self.assertEqual(snrc.eth_call("0xto", "0xbb"), "0xbb") + self.assertEqual(snrc.eth_call("0xother", "0xaa"), "0xaa") + + def test_zero_ttl_disables_it(self): + calls = [] + snrc.rpc = lambda method, params: calls.append(1) or "0x" + snrc.CACHE_TTL = 0 + snrc.eth_call("0xto", "0xdata") + snrc.eth_call("0xto", "0xdata") + self.assertEqual(len(calls), 2) + + class SplitLinksTests(unittest.TestCase): """`split_links` decodes the multi-URL convention for simplex.contact / simplex.channel text records. Reuses the same rule the dApp's From 685c3ff264bba90a6396086645aa87854024189f Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 31 Aug 2026 11:03:44 +0200 Subject: [PATCH 4/6] support query name by labelhash --- scripts/resolver/README.md | 46 ++++++- scripts/resolver/docker-compose.yml | 5 + scripts/resolver/service/snrc-resolve.py | 103 +++++++++++++-- scripts/resolver/service/test_snrc_resolve.py | 118 +++++++++++++++++- 4 files changed, 252 insertions(+), 20 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 345e6b287..e19e191ef 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -151,7 +151,8 @@ unknown. | `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 | +| `unregistered` | never registered, and free to take | +| `reserved` | not registered, and held for a brand — registration will be refused | | `noResolver` | registered, but points nowhere | | `unknown` | no `SNRC_REGISTRAR_` configured, so status could not be read | @@ -168,6 +169,31 @@ distinguish these: it is also true for a name nobody ever registered, since 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. +### Asking without naming the name + +A client checking whether a name is free is usually about to register it, so +the question itself is worth front-running. Substitute the label's keccak hash +for the label and the answer is identical: + +```sh +# instead of /resolve/acme.testing +curl -s http://127.0.0.1:8000/resolve/0x$(printf acme | keccak-256sum | cut -d' ' -f1).testing +``` + +namehash is defined as `keccak(parent || keccak(label))`, so supplying +`keccak(label)` yields the same node — and the registrar keys both +`nameExpires` and `reservedNames` on the labelhash, so status needs nothing +else. Whoever runs the resolver sees a hash and learns which name you are +interested in only if they already guessed it. + +The two forms cannot be confused: a hashed label is `0x` and 64 hex characters, +66 in total, and the registrar caps real labels well below that. Only the +leftmost label may be hashed, and only for a 2LD. + +Registration is still a public act — this hides the *interest*, not the +eventual registration, and the commit-reveal in the controller is what protects +the registration itself. + ### Errors Every non-2xx body carries a stable `error` code to branch on and a human @@ -179,9 +205,9 @@ Every non-2xx body carries a stable `error` code to branch on and a human "status": "unregistered", "expires": null, "graceEnds": null} ``` -Codes: `tldNotConfigured`, `notFullyQualified`, `unregistered`, `grace`, -`expired`, `noResolver`, `badAddress`, `badOffset`, `noRegistrarConfigured`, -`unauthorized`, `noSuchRoute`, `upstreamError`. For a name whose registration +Codes: `tldNotConfigured`, `notFullyQualified`, `unregistered`, `reserved`, +`grace`, `expired`, `noResolver`, `badAddress`, `badOffset`, +`noRegistrarConfigured`, `unauthorized`, `noSuchRoute`, `upstreamError`. For a name whose registration is the problem, the code equals `status`. ### Status codes @@ -239,12 +265,20 @@ what `/resolve` reads; it defaults to mainnet `.testing`, with `.simplex` unset until deployed. The **registrar** is the ERC-721 that can be asked the reverse and when a name expires — it is what `/owned-by` and every expiry field are read from. Without a registrar for a TLD, `/resolve` still works and reports -`"status": "unknown"`, and `/owned-by` answers 400. +`"status": "unknown"`, and `/owned-by` answers 400. The **controller** holds `reservedNames`, and is what the `reserved` status is +read from; without one for a TLD, a reserved name reads as `unregistered`. + +Note that the controller address 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. | Variable | Purpose | |---|---| | `SNRC_REGISTRY_` | ENS registry; resolution | | `SNRC_REGISTRAR_` | ERC-721 registrar; `/owned-by`, expiry and status | +| `SNRC_CONTROLLER_` | SimplexController; the `reserved` status | | `SNRC_MAX_OWNED` | names per `/owned-by` page (default 256) | Set them on the `resolver` service in `docker-compose.yml`, or as env vars for @@ -296,6 +330,8 @@ any lookup, and carry none of the three. | Lapsed, still in grace | 410 | `grace` | `expires` (when it lapsed), `graceEnds` (last moment its owner can renew) | | Lapsed, past grace | 410 | `expired` | same fields; anyone may register it now | | Never registered | 404 | `unregistered` | `expires` and `graceEnds` are `null` | +| Reserved for a brand | 404 | `reserved` | not registered and not registrable; overrides `unregistered` and `expired` | +| Queried by labelhash (`0x…64hex.testing`) | as the label | as the label | identical answer; the label is never sent | | TLD has no registry configured | 400 | — | `error: tldNotConfigured`, plus `configuredTlds` | | TLD has no *registrar* configured | 200 / 404 | `unknown` | resolves as it otherwise would; expiry cannot be read, so `expires` and `graceEnds` are `null` | | Not fully qualified (`alice`) | 400 | — | `error` naming the expected form | diff --git a/scripts/resolver/docker-compose.yml b/scripts/resolver/docker-compose.yml index 11bcd1318..9a86b1c7f 100644 --- a/scripts/resolver/docker-compose.yml +++ b/scripts/resolver/docker-compose.yml @@ -156,6 +156,11 @@ services: # the expiry status on /resolve; without them status reads "unknown". # SNRC_REGISTRAR_TESTING: 0x... # SNRC_REGISTRAR_SIMPLEX: 0x... + # SimplexController, which holds reservedNames. No default: it is behind + # a UUPS proxy and deployments records the implementation, so the address + # has to be the proxy and has to be given. + # SNRC_CONTROLLER_TESTING: 0x... + # SNRC_CONTROLLER_SIMPLEX: 0x... # SNRC_MAX_OWNED: 256 # SNRC_CACHE_TTL: 15 # seconds to memoise eth_call; 0 disables # SNRC_MAX_RPC_BYTES: 2097152 # refuse a larger JSON-RPC response diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 4845a42c7..2cb17f4fe 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -88,6 +88,18 @@ "simplex": os.environ.get("SNRC_REGISTRY_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 +} + # Shared secret the caller must present. Unset means no check - correct for a # loopback deployment, and the reason the check exists at all is that the # Haskell client has always been able to send `Authorization` and nothing here @@ -526,7 +538,7 @@ def resolve(name: str): "configuredTlds": configured, } - node = namehash(name) + node = node_of(name) node_hex = node.hex() # Registration first, because it is the fact that separates the failures a @@ -534,14 +546,18 @@ def resolve(name: str): # 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"] == "unregistered": + if reg["status"] in ("unregistered", "reserved"): return 404, { "name": name, - "status": "unregistered", - "expires": None, - "graceEnds": None, - "error": "unregistered", - "message": "this name has never been registered", + "status": reg["status"], + "expires": reg["expires"], + "graceEnds": reg["graceEnds"], + "error": reg["status"], + "message": ( + "this name is held for its trademark owner and cannot be registered" + if reg["status"] == "reserved" + else "this name has never been registered" + ), } if reg["status"] in ("grace", "expired"): return 410, { @@ -619,6 +635,57 @@ def grace_period(registrar: str) -> int: return decode_uint(eth_call(registrar, selector("GRACE_PERIOD()"))) +# A labelhash standing in for a label: "0x" and 32 bytes of hex. A real label +# cannot collide with this, because the registrar caps labels well below the 66 +# characters this takes - so the two forms are distinguishable without a flag. +HASHED_LABEL_LEN = 66 + + +def is_labelhash(label: str) -> bool: + return ( + len(label) == HASHED_LABEL_LEN + and label.startswith("0x") + and all(c in "0123456789abcdef" for c in label[2:]) + ) + + +def label_token(label: str) -> int: + """The registrar token id for a label, given either the label or its hash. + + Querying by hash is what lets a client ask "is this name free?" without + telling the resolver which name it is about to register - the answer is + the same, and the intent does not leak to whoever runs the resolver. + """ + return int(label, 16) if is_labelhash(label) else int.from_bytes(keccak(label.encode()), "big") + + +def node_of(name: str) -> bytes: + """namehash, accepting a hashed leftmost label. + + namehash is defined recursively as keccak(parent || keccak(label)), so a + caller who supplies keccak(label) directly gets the same node without ever + sending the label. + """ + labels = name.split(".") + if len(labels) == 2 and is_labelhash(labels[0]): + return keccak(namehash(labels[1]) + bytes.fromhex(labels[0][2:])) + return namehash(name) + + +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 expiry_status(expires: int, grace: int, now: int) -> str: """Registration state from an expiry timestamp. @@ -661,17 +728,27 @@ def name_status(name: str): # No registrar configured for this TLD: say so rather than guess. return {"status": "unknown", "expires": None, "graceEnds": None} - token = int.from_bytes(keccak(labels[-2].encode()), "big") + token = label_token(labels[-2]) expires = decode_uint( eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token)) ) if expires == 0: - return {"status": "unregistered", "expires": None, "graceEnds": None} - grace = grace_period(registrar) + 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": expiry_status(expires, grace, int(time.time())), - "expires": expires, - "graceEnds": expires + grace, + "status": status, + "expires": expires or None, + "graceEnds": (expires + grace) if expires else None, } diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index f42157208..cc9eb039a 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -204,6 +204,117 @@ def test_no_configured_registrar_is_an_error_not_an_empty_list(self): self.assertEqual(body["configuredTlds"], []) +class LabelhashQueryTests(unittest.TestCase): + """Asking by labelhash instead of by label. + + A client checking whether a name is free is about to register it, so + telling the resolver which name that is hands whoever runs it a + front-running opportunity. namehash is keccak(parent || keccak(label)), so + a caller who supplies keccak(label) gets an identical answer having said + nothing about the name.""" + + 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_a_hashed_label_is_recognised_and_a_real_one_is_not(self): + self.assertTrue(snrc.is_labelhash("0x" + snrc.keccak(b"alice").hex())) + self.assertFalse(snrc.is_labelhash("alice")) + self.assertFalse(snrc.is_labelhash("0x" + "z" * 64)) + # a label cannot be this long, which is what keeps the forms apart + self.assertFalse(snrc.is_labelhash("0x" + "a" * 62)) + + def test_hash_and_label_give_the_same_token_and_node(self): + h = "0x" + snrc.keccak(b"alice").hex() + self.assertEqual(snrc.label_token("alice"), snrc.label_token(h)) + self.assertEqual(snrc.node_of("alice.testing"), snrc.node_of(h + ".testing")) + + def test_status_by_hash_matches_status_by_name(self): + 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 + h = "0x" + snrc.keccak(b"alice").hex() + by_name = snrc.name_status("alice.testing") + by_hash = snrc.name_status(h + ".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 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): + h = "0x" + snrc.keccak(b"acme").hex() + snrc.eth_call = self._chain(0, True) + self.assertEqual(snrc.name_status(h + ".testing")["status"], "reserved") + + class NameStatusTests(unittest.TestCase): """simplexmq#1821: unresolvable has three causes and a caller has to tell them apart. Names expire lazily, so the chain still holds the answer.""" @@ -222,11 +333,14 @@ def eth_call(to, data): return eth_call def setUp(self): - self._registrars, self._eth_call = snrc.REGISTRARS, snrc.eth_call + 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.eth_call = self._registrars, self._eth_call + snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved def test_zero_expiry_means_never_registered(self): snrc.eth_call = self._expiry(0) From a40fdb8b36921b3eefc3171544101708d7149a7d Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 31 Aug 2026 11:25:40 +0200 Subject: [PATCH 5/6] add tests and CI --- .github/workflows/build.yml | 28 ++ scripts/resolver/README.md | 19 +- scripts/resolver/service/snrc-resolve.py | 15 +- scripts/resolver/service/test_snrc_resolve.py | 315 ++++++++++++++++++ 4 files changed, 373 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c841ec073..13141dd40 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -301,3 +301,31 @@ jobs: echo "All "$attempts" attempts failed." exit 1 fi + +# ============================= +# Resolver test job +# ============================= + +# The SNRC resolver is Python and stdlib-only apart from keccak, so it needs +# none of the Haskell toolchain above and runs independently of it. + + resolver-test: + name: "resolver (python)" + runs-on: ubuntu-latest + steps: + - name: Clone project + uses: actions/checkout@v3 + + - name: Set up Python + # Matches the runtime stage of scripts/resolver/service/Dockerfile. + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install resolver dependencies + # The only runtime dependency; declared in + # scripts/resolver/service/pyproject.toml. + run: python -m pip install "eth-hash[pycryptodome]>=0.7" + + - name: Test + run: python -m unittest discover -s scripts/resolver/service -v diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index e19e191ef..f0fefdefb 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -152,7 +152,7 @@ unknown. | `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 for a brand — registration will be refused | +| `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 | @@ -205,6 +205,21 @@ Every non-2xx body carries a stable `error` code to branch on and a human "status": "unregistered", "expires": null, "graceEnds": null} ``` +A `reserved` body carries one extra field, `reason`, explaining why the name is +held back: + +```jsonc +{"name": "support.testing", "error": "reserved", + "message": "this name is reserved and cannot be registered", + "reason": "reserved for a brand or public interest", + "status": "reserved", "expires": null, "graceEnds": null} +``` + +The contract records only that a name is reserved, not why, so today every +reserved name gets that same sentence; a per-name lookup is expected to replace +it. `reason` appears on no other status, so its presence is the signal that one +is known — render it rather than matching on its text. + Codes: `tldNotConfigured`, `notFullyQualified`, `unregistered`, `reserved`, `grace`, `expired`, `noResolver`, `badAddress`, `badOffset`, `noRegistrarConfigured`, `unauthorized`, `noSuchRoute`, `upstreamError`. For a name whose registration @@ -330,7 +345,7 @@ any lookup, and carry none of the three. | Lapsed, still in grace | 410 | `grace` | `expires` (when it lapsed), `graceEnds` (last moment its owner can renew) | | Lapsed, past grace | 410 | `expired` | same fields; anyone may register it now | | Never registered | 404 | `unregistered` | `expires` and `graceEnds` are `null` | -| Reserved for a brand | 404 | `reserved` | not registered and not registrable; overrides `unregistered` and `expired` | +| Reserved | 404 | `reserved` | not registered and not registrable; adds `reason`; overrides `unregistered` and `expired` | | Queried by labelhash (`0x…64hex.testing`) | as the label | as the label | identical answer; the label is never sent | | TLD has no registry configured | 400 | — | `error: tldNotConfigured`, plus `configuredTlds` | | TLD has no *registrar* configured | 200 / 404 | `unknown` | resolves as it otherwise would; expiry cannot be read, so `expires` and `graceEnds` are `null` | diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 2cb17f4fe..ab14854cb 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -100,6 +100,12 @@ "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" + # Shared secret the caller must present. Unset means no check - correct for a # loopback deployment, and the reason the check exists at all is that the # Haskell client has always been able to send `Authorization` and nothing here @@ -547,18 +553,23 @@ def resolve(name: str): # anyone, and one that is held but not pointed anywhere. reg = name_status(name) if reg["status"] in ("unregistered", "reserved"): - return 404, { + body = { "name": name, "status": reg["status"], "expires": reg["expires"], "graceEnds": reg["graceEnds"], "error": reg["status"], "message": ( - "this name is held for its trademark owner and cannot be registered" + "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, diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index cc9eb039a..628887803 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -5,9 +5,14 @@ """ import importlib.util +import json import os +import threading import time import unittest +import urllib.error +import urllib.request +from http.server import ThreadingHTTPServer # snrc-resolve.py has a hyphen, so import it via importlib instead of `import`. _HERE = os.path.dirname(os.path.abspath(__file__)) @@ -315,6 +320,77 @@ def test_reserved_is_asked_by_labelhash_so_a_hashed_query_works(self): self.assertEqual(snrc.name_status(h + ".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["message"]) + + 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) + h = "0x" + snrc.keccak(b"acme").hex() + _, body = snrc.resolve(h + ".testing") + self.assertEqual(body["reason"], "reserved for a brand or public interest") + + class NameStatusTests(unittest.TestCase): """simplexmq#1821: unresolvable has three causes and a caller has to tell them apart. Names expire lazily, so the chain still holds the answer.""" @@ -554,5 +630,244 @@ def test_order_is_preserved(self): ) +class HandlerTests(unittest.TestCase): + """The HTTP layer: routing, auth, query parsing, and the mapping from a + (status, body) pair to a response. + + These go over a real socket because that is the only way to reach them — + every branch here lives in `do_GET`, which no function-level test calls. + """ + + REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" + RESOLVER = "0x1111111111111111111111111111111111111111" + OWNER = "0x69a6000000000000000000000000000000002d32" + FUTURE = 4102444800 # 2100-01-01 + + def setUp(self): + self._saved = { + k: getattr(snrc, k) + for k in ( + "REGISTRIES", + "REGISTRARS", + "CONTROLLERS", + "AUTH_BEARER", + "AUTH_BASIC", + "eth_call", + "text", + "addr_multicoin", + ) + } + snrc.REGISTRIES = {"testing": self.REGISTRY} + snrc.REGISTRARS = {"testing": self.REGISTRAR} + snrc.CONTROLLERS = {"testing": self.CONTROLLER} + snrc.AUTH_BEARER = "" + snrc.AUTH_BASIC = "" + self.chain(expires=self.FUTURE) + + class Quiet(snrc.Handler): + def log_message(self, fmt, *args): + pass + + self.srv = ThreadingHTTPServer(("127.0.0.1", 0), Quiet) + # Default poll_interval is 0.5s and shutdown() waits for it, which + # would cost half a second per test in this class alone. + threading.Thread( + target=self.srv.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True + ).start() + self.base = "http://127.0.0.1:%d" % self.srv.server_address[1] + + def tearDown(self): + self.srv.shutdown() + self.srv.server_close() + for k, v in self._saved.items(): + setattr(snrc, k, v) + + # -- fixtures --------------------------------------------------------- + + def chain(self, expires, reserved=False, resolver=None, raises=None): + """Install a fake chain. `raises` makes every call fail, which is how + the 502 path is reached.""" + resolver = self.RESOLVER if resolver is None else resolver + sel = snrc.selector + + def eth_call(to, data): + if raises is not None: + raise raises + if data.startswith(sel("reservedNames(bytes32)")): + return "0x" + snrc.encode_uint(1 if reserved else 0) + if data.startswith(sel("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(90 * 86400) + if data.startswith(sel("nameExpires(uint256)")): + return "0x" + snrc.encode_uint(expires) + if data.startswith(sel("resolver(bytes32)")): + return "0x" + snrc.encode_uint(int(resolver, 16)) + if data.startswith(sel("owner(bytes32)")): + return "0x" + snrc.encode_uint(int(self.OWNER, 16)) + if data.startswith(sel("balanceOf(address)")): + return "0x" + snrc.encode_uint(1) + if data.startswith(sel("tokenOfOwnerByIndex(address,uint256)")): + return "0x" + snrc.encode_uint(int.from_bytes(snrc.keccak(b"acme"), "big")) + if data.startswith(sel("labelOf(uint256)")): + label = b"acme" + head = (32).to_bytes(32, "big") + len(label).to_bytes(32, "big") + return "0x" + (head + label + b"\x00" * 28).hex() + raise AssertionError("unexpected call " + data[:10]) + + snrc.eth_call = eth_call + snrc.text = lambda r, node, key: {"name": "Acme", "url": "https://acme.example"}.get(key, "") + snrc.addr_multicoin = lambda r, node, coin: ( + self.OWNER if coin == snrc.COIN_ETH else None + ) + + def get(self, path, auth=None): + req = urllib.request.Request(self.base + path) + if auth is not None: + req.add_header("Authorization", auth) + try: + with urllib.request.urlopen(req, timeout=5) as r: + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: + with e: + return e.code, json.loads(e.read()) + + # -- routing ---------------------------------------------------------- + + def test_health_reports_the_version_and_the_registrars(self): + status, body = self.get("/health") + self.assertEqual(status, 200) + self.assertTrue(body["ok"]) + self.assertEqual(body["version"], snrc.API_VERSION) + # Present so an operator can see why status would read "unknown". + self.assertEqual(body["registrars"], {"testing": self.REGISTRAR}) + + def test_an_unknown_route_names_the_routes_that_exist(self): + status, body = self.get("/nope") + self.assertEqual(status, 404) + self.assertEqual(body["error"], "noSuchRoute") + self.assertIn("/resolve/", body["routes"]) + + def test_the_root_path_is_not_a_route(self): + status, body = self.get("/") + self.assertEqual(status, 404) + self.assertEqual(body["error"], "noSuchRoute") + + # -- auth ------------------------------------------------------------- + + def test_no_auth_configured_means_no_header_is_needed(self): + self.assertEqual(self.get("/health")[0], 200) + + def test_a_configured_token_is_required(self): + snrc.AUTH_BEARER = "s3cret" + status, body = self.get("/health") + self.assertEqual(status, 401) + self.assertEqual(body["error"], "unauthorized") + + def test_the_right_token_is_accepted(self): + snrc.AUTH_BEARER = "s3cret" + self.assertEqual(self.get("/health", auth="Bearer s3cret")[0], 200) + + def test_a_wrong_token_is_refused(self): + snrc.AUTH_BEARER = "s3cret" + self.assertEqual(self.get("/health", auth="Bearer nope")[0], 401) + + def test_auth_is_checked_before_the_route_exists(self): + # An unauthenticated caller learns nothing about which routes exist. + snrc.AUTH_BEARER = "s3cret" + status, body = self.get("/nope") + self.assertEqual(status, 401) + self.assertNotIn("routes", body) + + # -- /resolve --------------------------------------------------------- + + def test_a_live_name_returns_its_record(self): + status, body = self.get("/resolve/acme.testing") + self.assertEqual(status, 200) + self.assertEqual(body["name"], "acme.testing") + self.assertEqual(body["nickname"], "Acme") + self.assertEqual(body["website"], "https://acme.example") + self.assertEqual(body["owner"], self.OWNER) + self.assertEqual(body["status"], "registered") + self.assertEqual(body["expires"], self.FUTURE) + + def test_a_bare_label_is_rejected_before_any_rpc(self): + self.chain(expires=0, raises=AssertionError("must not reach the chain")) + status, body = self.get("/resolve/acme") + self.assertEqual(status, 400) + self.assertEqual(body["error"], "notFullyQualified") + + def test_a_name_is_lowercased(self): + status, body = self.get("/resolve/ACME.TESTING") + self.assertEqual(status, 200) + self.assertEqual(body["name"], "acme.testing") + + def test_a_reserved_name_is_404_with_its_reason(self): + self.chain(expires=0, reserved=True) + status, body = self.get("/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_an_expired_name_is_410(self): + self.chain(expires=1) + status, body = self.get("/resolve/acme.testing") + self.assertEqual(status, 410) + self.assertEqual(body["status"], "expired") + + def test_a_name_with_no_resolver_is_404(self): + self.chain(expires=self.FUTURE, resolver=snrc.ZERO_ADDR) + status, body = self.get("/resolve/acme.testing") + self.assertEqual(status, 404) + self.assertEqual(body["error"], "noResolver") + + def test_an_unconfigured_tld_is_400(self): + status, body = self.get("/resolve/acme.example") + self.assertEqual(status, 400) + self.assertEqual(body["error"], "tldNotConfigured") + self.assertEqual(body["configuredTlds"], ["testing"]) + + # -- /owned-by -------------------------------------------------------- + + def test_owned_by_lists_the_names_held(self): + status, body = self.get("/owned-by/" + self.OWNER) + self.assertEqual(status, 200) + self.assertEqual([n["name"] for n in body["names"]], ["acme.testing"]) + self.assertEqual(body["offset"], 0) + + def test_a_negative_offset_is_rejected(self): + status, body = self.get("/owned-by/%s?offset=-1" % self.OWNER) + self.assertEqual(status, 400) + self.assertEqual(body["error"], "badOffset") + + def test_a_non_numeric_offset_is_rejected(self): + status, body = self.get("/owned-by/%s?offset=abc" % self.OWNER) + self.assertEqual(status, 400) + self.assertEqual(body["error"], "badOffset") + + def test_a_bad_address_is_rejected(self): + status, body = self.get("/owned-by/not-an-address") + self.assertEqual(status, 400) + self.assertEqual(body["error"], "badAddress") + + # -- upstream failure ------------------------------------------------- + + def test_an_rpc_failure_is_502_and_does_not_leak_the_rpc_url(self): + # SNRC_RPC can carry a key, and urlopen puts the URL it failed on into + # the exception message, so the body must not quote the exception. + self.chain(expires=0, raises=RuntimeError("failed on http://user:key@rpc.internal:8545")) + status, body = self.get("/resolve/acme.testing") + self.assertEqual(status, 502) + self.assertEqual(body["error"], "upstreamError") + self.assertNotIn("rpc.internal", json.dumps(body)) + self.assertNotIn("key", json.dumps(body)) + + def test_an_rpc_failure_on_owned_by_is_also_502(self): + self.chain(expires=0, raises=RuntimeError("boom")) + status, body = self.get("/owned-by/" + self.OWNER) + self.assertEqual(status, 502) + self.assertEqual(body["error"], "upstreamError") + + if __name__ == "__main__": unittest.main() From 075b1aa6e20980c240425cf7cc6094b13613ce5b Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 31 Aug 2026 11:48:37 +0200 Subject: [PATCH 6/6] fix a possible breaking --- 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)