resolver: answer a subname only while its name's holder holds it (#2013)

This commit is contained in:
brenzi-agent authored and GitHub committed 2026-11-03 23:40:05 +00:00
1 parent 360f6b5530
commit 9c4a814fda
3 files changed
+89 -21

No files matched your search

+14 -7
View File
@@ -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/<name>`
@@ -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
+43 -12
View File
@@ -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 <date>" 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": "",
+32 -2
View File
@@ -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)