resolver errors say what went wrong, not "no such name"

/v2/resolve answers 200, 400 or 502, and an unregistered name is
  NRAvailable, so no status means "not registered". Mapping 400/404/410 to
  NOT_FOUND made a misconfigured relay deny every name, and hid a relay
  upgraded ahead of its resolver. All three now surface as RESOLVER.

  rslvNotFound would have gone dead, so it counts what its name says: an
  availability answer the encoder downgrades for a session below v22. The
  wire is unchanged.

  A hashed query the registrar cannot name is refused with 502 rather than
  answered with a record named "unknown", which the client rejects anyway.
  NRRUnknown is capped to 32 printable characters again, as the spec says.
  A registered name that is also reserved no longer offers a date it will
  never free up on. rentPrices is registrationPrices throughout, and
  yearPriceUSD is USDCents rather than a bare Int64.
This commit is contained in:
Alain Brenzikofer
2026-09-10 15:16:11 +02:00
parent a3fb63ff06
commit ef97b86ea6
10 changed files with 80 additions and 51 deletions
+5
View File
@@ -182,6 +182,11 @@ every query.
**Set `SNRC_CONTROLLER_<TLD>` wherever `SNRC_REGISTRAR_<TLD>` is.** Without a
controller there is no oracle, so no name can be priced.
**Upgrade this service before the routers that query it.** Routers from SMP v22
call `/v2/resolve`, which an older resolver does not serve. Every name then
answers `ERR NAME RESOLVER "HTTP 404"` until this service is upgraded, while
`/health` still reports it as ready.
### Why a name is reserved
A held-back name carries `reasonCode`, the controller's reason, and `reason`, an
+18 -12
View File
@@ -272,7 +272,7 @@ def read_oracle_prices(controller: str, oracle: str):
min_len = decode_uint(eth_call(controller, selector("minCharLength()")))
return {
# lengths the registry refuses are left out rather than priced at zero
"rentPrices": {n: c for n, c in tiers.items() if n >= min_len},
"registrationPrices": {n: c for n, c in tiers.items() if n >= min_len},
"basePrice": base,
"minLabelLength": min_len,
"_premiumUnknown": premium_unknown,
@@ -388,22 +388,23 @@ def decode_bytes(hex_data: str) -> bytes:
return raw[64:64 + length]
def registered_label(registrar: str, token: int) -> str:
def registered_label(registrar: str, token: int):
"""The plaintext label the registrar recorded at registration, keyed by the
hash of that label. A name registered without registerWithLabel has none,
and answers "unknown" instead."""
hash of that label. None when the name was registered without
registerWithLabel, so the registrar cannot name it."""
raw = decode_bytes(eth_call(registrar, selector("labelOf(uint256)") + encode_uint(token)))
return raw.decode("utf-8", errors="replace") if raw else "unknown"
return raw.decode("utf-8", errors="replace") if raw else None
def canonical_name(name: str) -> str:
def canonical_name(name: str):
"""The name to answer with: a hashed query does not carry one, so the
registrar's record of the label fills it in."""
registrar's record of the label fills it in. None when it recorded none."""
labels = name.split(".")
registrar = REGISTRARS.get(labels[-1])
if not registrar or len(labels) != 2 or not is_encoded_labelhash(labels[0]):
return name
return registered_label(registrar, label_token(labels[0])) + "." + labels[1]
label = registered_label(registrar, label_token(labels[0]))
return label + "." + labels[1] if label else None
def label_token(label: str) -> int:
@@ -738,12 +739,17 @@ def registration(name: str):
reg = name_status(name)
status = reg["status"]
if status in ("registered", "grace"):
rec = name_record(name)
# the client checks that the record names what it asked about, so a
# 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": name_record(name),
"nameRecord": rec,
}
if reg["reasonCode"]:
return 200, {"type": "reserved", "reservedReason": reg["reasonCode"]}
@@ -753,7 +759,7 @@ def registration(name: str):
return 200, {
"type": "available",
"pricing": {
"registrationPrices": reg["rentPrices"],
"registrationPrices": reg["registrationPrices"],
"basePrice": reg["basePrice"],
"minLabelLength": reg["minLabelLength"],
},
@@ -800,7 +806,7 @@ def resolve(name: str):
# answerable.
owner = decode_address(eth_call(registry, selector("owner(bytes32)") + node_hex))
return 200, {
"name": canonical_name(name),
"name": canonical_name(name) or name,
"nickname": "",
"website": "",
"location": "",
@@ -837,7 +843,7 @@ def resolve(name: str):
# use the ENSIP-5 dot convention (e.g. "simplex.contact") — only the
# resolver's JSON surface camelCases them.
return 200, {
"name": canonical_name(name),
"name": canonical_name(name) or name,
"nickname": nickname,
"website": texts.get("url", ""),
"location": texts.get("location", ""),
+28 -3
View File
@@ -606,7 +606,7 @@ class PricingTests(unittest.TestCase):
snrc.eth_call = self._chain(self._lapsed(0))
reg = snrc.name_status("acme.testing")
# 1 and 2 are below minCharLength
self.assertEqual(reg["rentPrices"], {3: 1600, 4: 800, 5: 500})
self.assertEqual(reg["registrationPrices"], {3: 1600, 4: 800, 5: 500})
self.assertEqual(reg["basePrice"], self.BASE)
self.assertEqual(reg["minLabelLength"], self.MIN_LENGTH)
@@ -856,12 +856,22 @@ class RegistrationV2Tests(unittest.TestCase):
words += [snrc.encode_uint(length), snrc.encode_uint(cents)]
return "0x" + "".join(words)
def _chain(self, expires, reserved=0, oracle=None):
@staticmethod
def _abi_bytes(value: bytes) -> str:
"""head offset, length, then the payload padded to a 32-byte word."""
pad = (-len(value)) % 32
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"):
"""The registry answers a zero resolver, so name_record returns the
empty record a registered name still has."""
empty record a registered name still has. `label` is what the registrar
recorded for the 2LD; b"" means it recorded none."""
oracle = self.ORACLE if oracle is None else oracle
def eth_call(to, data):
if data.startswith(snrc.selector("labelOf(uint256)")):
return self._abi_bytes(label)
if data.startswith(snrc.selector("nameExpires(uint256)")):
return "0x" + snrc.encode_uint(expires)
if data.startswith(snrc.selector("GRACE_PERIOD()")):
@@ -976,6 +986,21 @@ class RegistrationV2Tests(unittest.TestCase):
_, body = snrc.registration("acme.testing")
self.assertEqual(body["type"], expected_type)
self.assertEqual(set(body), keys)
def test_a_hashed_query_the_registrar_cannot_name_is_refused(self):
"""The client checks the record names what it asked about, so answering
with a record the registrar could not name would only fail there."""
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
snrc.eth_call = self._chain(self.now + 3600, label=b"")
status, body = snrc.registration(hashed + ".testing")
self.assertEqual(status, 502)
self.assertEqual(body["error"], "labelNotRecorded")
def test_a_hashed_query_is_answered_with_the_name_the_registrar_recorded(self):
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
snrc.eth_call = self._chain(self.now + 3600)
status, body = snrc.registration(hashed + ".testing")
self.assertEqual(status, 200)
self.assertEqual(body["nameRecord"]["name"], "acme.testing")
if __name__ == "__main__":
unittest.main()