From 9c4a814fdab9f7350b5f259bb5afe5145b5bd383 Mon Sep 17 00:00:00 2001 From: brenzi-agent Date: Wed, 4 Nov 2026 00:40:05 +0100 Subject: [PATCH] resolver: answer a subname only while its name's holder holds it (#2013) --- scripts/resolver/README.md | 21 ++++--- scripts/resolver/service/snrc-resolve.py | 55 +++++++++++++++---- scripts/resolver/service/test_snrc_resolve.py | 34 +++++++++++- 3 files changed, 89 insertions(+), 21 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 995267837..adc0745d8 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -173,7 +173,10 @@ label, so a hashed query cannot be answered with a name. See [Querying by labelhash](#querying-by-labelhash). 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. +bounds its lifetime. A subname is answered only while it is held: owned in the +registry by the 2LD's holder, or by the subname registrar that the registrar's +`subnameHook()` names, while its `ownerOf` names a holder. Any other subname, +created or not, reports as not registered. ### v1: `/resolve/` @@ -233,7 +236,8 @@ 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 nobody created answers 404 `unregistered`. +name it sits under. A subname that is not held, as v2 defines it, answers 404 +`unregistered`. #### v1 errors @@ -358,16 +362,18 @@ from before the enum stores a boolean, whose `true` decodes as 1, which is why ### Configuring addresses -The resolver reads three contracts, each configured per TLD, and the name -policy a controller names. +The resolver reads three contracts, each configured per TLD, the name policy a +controller names, and the subname registrar a registrar names. The **registry** answers who owns a node, and `/resolve` reads the records from it. The **registrar** (ERC-721) holds `nameExpires` and `GRACE_PERIOD`, which is where every expiry field comes from, and `labelOf`, which is how a hashed query is answered with a name. A name registered without recording its label cannot answer one, and `/v2/resolve` refuses it rather than answer with a name -the client will reject. With no registrar for a TLD, `/resolve` still works and -reports `"status": "unknown"`. The **controller** holds +the client will reject. Its `subnameHook()` names the subname registrar, whose +`ownerOf` says who holds a subname it owns. With no registrar for a TLD, +`/resolve` still works and reports `"status": "unknown"`, and only subnames the +2LD's holder owns in the registry are answered. The **controller** holds `reservedNames`, which is where `reasonCode` comes from. With no controller a held-back name reads as not reserved, and no name can be priced. @@ -403,7 +409,8 @@ as env vars when you run the script directly. A lookup reads the chain in at most three rounds: the name's status and registry entries, then its record from its resolver, then prices for a name -that is free. The node runs the calls of a JSON-RPC batch one after another, +that is free. A subname owned by the subname registrar takes one more round, for +its holder. The node runs the calls of a JSON-RPC batch one after another, so the contract reads of a round go to the chain as one `eth_call` to [Multicall3](https://github.com/mds1/multicall). A node that is slow for a moment therefore delays a lookup a few times, not once per read. Connections to diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 5ab92a4a4..b017f196e 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -839,6 +839,14 @@ def owner_call(registry: str, node: bytes): return registry, selector("owner(bytes32)") + node.hex() +def subname_hook_call(registrar: str): + return registrar, selector("subnameHook()") + + +def subname_owner_call(subnames: str, node: bytes): + return subnames, selector("ownerOf(uint256)") + node.hex() + + def text_call(resolver: str, node: bytes, key: str): return resolver, encode_text_call(node, key) @@ -858,12 +866,16 @@ def lookup_reads(name: str): reads += [BLOCK_READ, eth_call_read(*expires_call(registrar, token)), eth_call_read(*grace_call(registrar))] if len(labels) == 2 and is_encoded_labelhash(labels[0]): reads.append(eth_call_read(*label_call(registrar, token))) + if len(labels) > 2: + reads.append(eth_call_read(*subname_hook_call(registrar))) if CONTROLLERS.get(tld): reads.append(eth_call_read(*reserved_call(NAME_POLICIES.get(tld, CONTROLLERS[tld]), token))) registry = REGISTRIES.get(tld) if registry: node = node_of(name) reads += [eth_call_read(*resolver_call(registry, node)), eth_call_read(*owner_call(registry, node))] + if len(labels) > 2: + reads.append(eth_call_read(*owner_call(registry, namehash(".".join(labels[-2:]))))) return reads @@ -1132,6 +1144,25 @@ def name_record(name: str): return rec +def subname_holder(name: str) -> str: + labels = name.split(".") + tld = labels[-1] + registry, registrar = REGISTRIES[tld], REGISTRARS.get(tld) + node = node_of(name) + owner = decode_address(eth_call(*owner_call(registry, node))) + holder = decode_address(eth_call(*owner_call(registry, namehash(".".join(labels[-2:]))))) + if owner in (ZERO_ADDR, holder): + return owner + if not registrar: + return ZERO_ADDR + try: + if owner != decode_address(eth_call(*subname_hook_call(registrar))): + return ZERO_ADDR + return decode_address(eth_call(*subname_owner_call(owner, node))) + except RuntimeError: + return ZERO_ADDR + + def name_response(reg, registration_body): """The SMP protocol's NameResponse: the registration and the block read at.""" return 200, {"lastBlockTs": reg["lastBlockTs"], "registration": registration_body} @@ -1152,9 +1183,9 @@ 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"} - # 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: + # a subname inherits the 2LD's status, so only its holder says whether + # it exists + if len(name.split(".")) > 2 and subname_holder(name) == ZERO_ADDR: # name_status reads pricing only when the name was already # unregistered, so read it here status = "unregistered" @@ -1217,19 +1248,19 @@ def resolve(name: str): return (404 if reg["status"] == "unregistered" else 410), body resolver_addr = decode_address(eth_call(*resolver_call(registry, node))) + if len(name.split(".")) > 2 and subname_holder(name) == ZERO_ADDR: + return 404, { + "name": name, + **reg, + "status": "unregistered", + "error": "unregistered", + "message": "nobody holds this subname", + } 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. For a subname, no owner means nobody created it. + # answerable. owner = decode_address(eth_call(*owner_call(registry, node))) - 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 2cf6a792c..46d756769 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -838,6 +838,8 @@ class RegistrationV2Tests(unittest.TestCase): CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" ORACLE = "0x1e0c9a2b9d1a4c8f7b3e5d6a9c2f4b8e1d7a3c50" OWNER = "0xd83bd7e0e6b8a4c1f2593a7b0c4e8d1a6f9b2c37" + FORMER_OWNER = "0x5a0e1c2b3d4f5e6a7b8c9d0e1f2a3b4c5d6e7f80" + SUBNAMES = "0x3d749792ea31a74fca465f361659dc112a31fb70" GRACE = 90 * 86400 BASE = 200 @@ -881,13 +883,16 @@ class RegistrationV2Tests(unittest.TestCase): 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", owner=None): + def _chain(self, expires, reserved=0, oracle=None, label=b"acme", owner=None, holder=None, subname_holder=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. `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 + holder = owner if holder is None else holder + subname_holder = snrc.ZERO_ADDR if subname_holder is None else subname_holder + parent = snrc.namehash("acme.testing").hex() def eth_call(to, data): if data.startswith(snrc.selector("labelOf(uint256)")): @@ -907,7 +912,11 @@ class RegistrationV2Tests(unittest.TestCase): 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(owner, 16)) + return "0x" + snrc.encode_uint(int(holder if data.endswith(parent) else owner, 16)) + if data.startswith(snrc.selector("subnameHook()")): + return "0x" + snrc.encode_uint(int(self.SUBNAMES, 16)) + if data.startswith(snrc.selector("ownerOf(uint256)")): + return "0x" + snrc.encode_uint(int(subname_holder, 16)) return self.fail("unexpected call " + data[:10]) return eth_call @@ -1037,6 +1046,27 @@ class RegistrationV2Tests(unittest.TestCase): self.assertEqual(status, 200) self.assertEqual(body["type"], "available") + def test_a_subname_the_subname_registrar_holds_for_the_holder_is_registered(self): + snrc.eth_call = self._chain(self.now + 3600, owner=self.SUBNAMES, holder=self.OWNER, subname_holder=self.OWNER) + _, body = registration("sub.acme.testing") + self.assertEqual(body["type"], "registered") + + def test_a_subname_the_subname_registrar_no_longer_holds_is_not_registered(self): + snrc.eth_call = self._chain(self.now + 3600, owner=self.SUBNAMES, holder=self.OWNER) + _, body = registration("sub.acme.testing") + self.assertEqual(body["type"], "available") + + def test_a_subnode_a_former_holder_owns_is_not_registered(self): + snrc.eth_call = self._chain(self.now + 3600, owner=self.FORMER_OWNER, holder=self.OWNER) + _, body = registration("sub.acme.testing") + self.assertEqual(body["type"], "available") + + def test_v1_does_not_resolve_a_subnode_a_former_holder_owns(self): + snrc.eth_call = self._chain(self.now + 3600, owner=self.FORMER_OWNER, holder=self.OWNER) + status, body = snrc.resolve("sub.acme.testing") + self.assertEqual(status, 404) + self.assertEqual(body["error"], "unregistered") + 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)