From fc5daee950fea72fbadff566814214ab8b411317 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 15:51:46 +0200 Subject: [PATCH 1/2] fix resolver v2 for subnames --- scripts/resolver/README.md | 12 +++++- scripts/resolver/service/snrc-resolve.py | 38 ++++++++++++---- scripts/resolver/service/test_snrc_resolve.py | 43 +++++++++++++++++-- 3 files changed, 80 insertions(+), 13 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index f667f536d..0fdee8dbe 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -155,6 +155,12 @@ Error bodies carry `name` and a fixed `error` code to branch on. Only label, so a hashed query cannot be answered with a name. See [Querying by labelhash](#querying-by-labelhash). +A subname's lifetime is bounded by the 2LD above it, so one that exists reports +that name's expiry and grace. The registrar tracks only 2LDs, though, so the +parent's registration says nothing about whether the subname itself was ever +created. One that was not has no owner on its node, and is reported as not +registered rather than inheriting the parent's registration. + ### v1: `/resolve/` What routers before SMP v22 call. Its shape is unrelated to v2's: the record is @@ -212,8 +218,10 @@ holds for a name nobody ever registered (`0 + GRACE_PERIOD < now`), so a zero expiry is what separates *never registered* from *registered and since released*. -A subname reports the status of the 2LD above it, which is only as good as the -name it sits under. +A subname that exists reports the status of the 2LD above it, which is only as +good as the name it sits under. One that was never created is not reported as +registered: the registrar tracks only 2LDs, so a node with no owner is the only +signal there is, and it answers 404 `unregistered`. #### v1 errors diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index dcf673642..13853f782 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -722,13 +722,25 @@ def registration(name: str): # hashed query the registrar cannot name is refused rather than answered if rec["name"] is None: return 502, {"name": name, "error": "labelNotRecorded"} - return 200, { - "type": "registered", - "expires": reg["expires"], - "graceUntil": reg["graceEnds"], - "reservedReason_": reg["reasonCode"], - "nameRecord": rec, - } + # The registrar only tracks 2LDs, so a subname inherits its status from + # the name above it. That says nothing about whether the subname itself + # was ever created: one that was not has no owner on its node. + if len(name.split(".")) > 2 and rec["owner"] == ZERO_ADDR: + # answer as a subname under an unregistered name already does, so + # pricing has to be read here: name_status only reads it when the + # name above was itself unregistered + status = "unregistered" + pricing = pricing_params(tld) + if pricing: + reg.update({k: v for k, v in pricing.items() if not k.startswith("_")}) + else: + return 200, { + "type": "registered", + "expires": reg["expires"], + "graceUntil": reg["graceEnds"], + "reservedReason_": reg["reasonCode"], + "nameRecord": rec, + } if reg["reasonCode"]: return 200, {"type": "reserved", "reservedReason": reg["reasonCode"]} if status in ("unregistered", "expired"): @@ -781,8 +793,18 @@ def resolve(name: str): if resolver_addr == ZERO_ADDR: # A registered name always resolves: with no resolver set the record is # still returned with every field unset, so "taken until " stays - # answerable. + # answerable. A subname is different: it inherits the 2LD's status, so a + # node with no owner is one nobody created rather than one with no + # record, and reporting it as registered would invent a name. owner = decode_address(eth_call(registry, selector("owner(bytes32)") + node_hex)) + if len(name.split(".")) > 2 and owner == ZERO_ADDR: + return 404, { + "name": name, + **reg, + "status": "unregistered", + "error": "unregistered", + "message": "this subname has never been created", + } return 200, { "name": canonical_name(name) or name, "nickname": "", diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 84520a49a..ac142e266 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -834,11 +834,13 @@ def _abi_bytes(value: bytes) -> str: return ("0x" + snrc.encode_uint(0x20) + snrc.encode_uint(len(value)) + (value + b"\x00" * pad).hex()) - def _chain(self, expires, reserved=0, oracle=None, label=b"acme"): + def _chain(self, expires, reserved=0, oracle=None, label=b"acme", owner=None): """The registry answers a zero resolver, so name_record returns the empty record a registered name still has. `label` is what the registrar - recorded for the 2LD; b"" means it recorded none.""" + recorded for the 2LD; b"" means it recorded none. `owner` is the owner of + the queried node; ZERO_ADDR means that node was never created.""" oracle = self.ORACLE if oracle is None else oracle + owner = self.OWNER if owner is None else owner def eth_call(to, data): if data.startswith(snrc.selector("labelOf(uint256)")): @@ -858,7 +860,7 @@ def eth_call(to, data): if data.startswith(snrc.selector("resolver(bytes32)")): return "0x" + snrc.encode_uint(0) if data.startswith(snrc.selector("owner(bytes32)")): - return "0x" + snrc.encode_uint(int(self.OWNER, 16)) + return "0x" + snrc.encode_uint(int(owner, 16)) return self.fail("unexpected call " + data[:10]) return eth_call @@ -971,6 +973,41 @@ def test_a_hashed_query_is_answered_with_the_name_the_registrar_recorded(self): status, body = snrc.registration(hashed + ".testing") self.assertEqual(status, 200) self.assertEqual(body["nameRecord"]["name"], "acme.testing") + def test_a_subname_that_exists_is_registered_with_its_parents_dates(self): + expires = self.now + 3600 + snrc.eth_call = self._chain(expires) + status, body = snrc.registration("sub.acme.testing") + self.assertEqual(status, 200) + self.assertEqual(body["type"], "registered") + self.assertEqual(body["expires"], expires) + self.assertEqual(body["nameRecord"]["name"], "sub.acme.testing") + + def test_a_subname_nobody_created_is_not_registered(self): + """The registrar only tracks 2LDs, so the parent's registration says + nothing about a child that was never created: its node has no owner.""" + snrc.eth_call = self._chain(self.now + 3600, owner=snrc.ZERO_ADDR) + status, body = snrc.registration("sub.acme.testing") + self.assertEqual(status, 200) + self.assertEqual(body["type"], "available") + + def test_a_2ld_is_not_subject_to_the_owner_check(self): + """Only a subname can be absent under a registered parent.""" + snrc.eth_call = self._chain(self.now + 3600, owner=snrc.ZERO_ADDR) + _, body = snrc.registration("acme.testing") + self.assertEqual(body["type"], "registered") + def test_v1_does_not_report_an_uncreated_subname_as_registered(self): + """v1 has no availability, so the only honest answer is not-found. The + 2LD case is untouched: a registered name with no resolver still resolves.""" + snrc.eth_call = self._chain(self.now + 3600, owner=snrc.ZERO_ADDR) + status, body = snrc.resolve("sub.acme.testing") + self.assertEqual(status, 404) + self.assertEqual(body["error"], "unregistered") + + def test_v1_still_resolves_a_2ld_with_no_resolver_set(self): + snrc.eth_call = self._chain(self.now + 3600, owner=snrc.ZERO_ADDR) + status, body = snrc.resolve("acme.testing") + self.assertEqual(status, 200) + self.assertEqual(body["resolver"], snrc.ZERO_ADDR) if __name__ == "__main__": unittest.main() From 2c10c84efe474fdf8926c034476de0576bea9dd3 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 15:58:08 +0200 Subject: [PATCH 2/2] simplify doc --- scripts/resolver/README.md | 13 ++++--------- scripts/resolver/service/snrc-resolve.py | 14 +++++--------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 0fdee8dbe..e1570d60a 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -155,11 +155,8 @@ Error bodies carry `name` and a fixed `error` code to branch on. Only label, so a hashed query cannot be answered with a name. See [Querying by labelhash](#querying-by-labelhash). -A subname's lifetime is bounded by the 2LD above it, so one that exists reports -that name's expiry and grace. The registrar tracks only 2LDs, though, so the -parent's registration says nothing about whether the subname itself was ever -created. One that was not has no owner on its node, and is reported as not -registered rather than inheriting the parent's registration. +A subname reports the expiry and grace of the 2LD above it, since that is what +bounds its lifetime. A subname nobody created reports as not registered. ### v1: `/resolve/` @@ -218,10 +215,8 @@ holds for a name nobody ever registered (`0 + GRACE_PERIOD < now`), so a zero expiry is what separates *never registered* from *registered and since released*. -A subname that exists reports the status of the 2LD above it, which is only as -good as the name it sits under. One that was never created is not reported as -registered: the registrar tracks only 2LDs, so a node with no owner is the only -signal there is, and it answers 404 `unregistered`. +A subname reports the status of the 2LD above it, which is only as good as the +name it sits under. A subname nobody created answers 404 `unregistered`. #### v1 errors diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 13853f782..7925c2ff3 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -722,13 +722,11 @@ def registration(name: str): # hashed query the registrar cannot name is refused rather than answered if rec["name"] is None: return 502, {"name": name, "error": "labelNotRecorded"} - # The registrar only tracks 2LDs, so a subname inherits its status from - # the name above it. That says nothing about whether the subname itself - # was ever created: one that was not has no owner on its node. + # a subname inherits the 2LD's status, so only its node's owner says + # whether anyone created it if len(name.split(".")) > 2 and rec["owner"] == ZERO_ADDR: - # answer as a subname under an unregistered name already does, so - # pricing has to be read here: name_status only reads it when the - # name above was itself unregistered + # name_status reads pricing only when the name was already + # unregistered, so read it here status = "unregistered" pricing = pricing_params(tld) if pricing: @@ -793,9 +791,7 @@ def resolve(name: str): if resolver_addr == ZERO_ADDR: # A registered name always resolves: with no resolver set the record is # still returned with every field unset, so "taken until " stays - # answerable. A subname is different: it inherits the 2LD's status, so a - # node with no owner is one nobody created rather than one with no - # record, and reporting it as registered would invent a name. + # answerable. For a subname, no owner means nobody created it. owner = decode_address(eth_call(registry, selector("owner(bytes32)") + node_hex)) if len(name.split(".")) > 2 and owner == ZERO_ADDR: return 404, {