Skip to content

Commit 685c3ff

Browse files
committed
support query name by labelhash
1 parent a390388 commit 685c3ff

4 files changed

Lines changed: 252 additions & 20 deletions

File tree

scripts/resolver/README.md

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ unknown.
151151
| `registered` | live; `expires` is when that ends |
152152
| `grace` | lapsed, but only the previous owner may renew it, until `graceEnds` |
153153
| `expired` | lapsed and past grace — anyone may register it now |
154-
| `unregistered` | never registered |
154+
| `unregistered` | never registered, and free to take |
155+
| `reserved` | not registered, and held for a brand — registration will be refused |
155156
| `noResolver` | registered, but points nowhere |
156157
| `unknown` | no `SNRC_REGISTRAR_<TLD>` configured, so status could not be read |
157158

@@ -168,6 +169,31 @@ distinguish these: it is also true for a name nobody ever registered, since
168169
Subnames report the status of the 2LD they sit under, which is the useful
169170
answer — a subname is only as valid as the name above it.
170171

172+
### Asking without naming the name
173+
174+
A client checking whether a name is free is usually about to register it, so
175+
the question itself is worth front-running. Substitute the label's keccak hash
176+
for the label and the answer is identical:
177+
178+
```sh
179+
# instead of /resolve/acme.testing
180+
curl -s http://127.0.0.1:8000/resolve/0x$(printf acme | keccak-256sum | cut -d' ' -f1).testing
181+
```
182+
183+
namehash is defined as `keccak(parent || keccak(label))`, so supplying
184+
`keccak(label)` yields the same node — and the registrar keys both
185+
`nameExpires` and `reservedNames` on the labelhash, so status needs nothing
186+
else. Whoever runs the resolver sees a hash and learns which name you are
187+
interested in only if they already guessed it.
188+
189+
The two forms cannot be confused: a hashed label is `0x` and 64 hex characters,
190+
66 in total, and the registrar caps real labels well below that. Only the
191+
leftmost label may be hashed, and only for a 2LD.
192+
193+
Registration is still a public act — this hides the *interest*, not the
194+
eventual registration, and the commit-reveal in the controller is what protects
195+
the registration itself.
196+
171197
### Errors
172198

173199
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
179205
"status": "unregistered", "expires": null, "graceEnds": null}
180206
```
181207

182-
Codes: `tldNotConfigured`, `notFullyQualified`, `unregistered`, `grace`,
183-
`expired`, `noResolver`, `badAddress`, `badOffset`, `noRegistrarConfigured`,
184-
`unauthorized`, `noSuchRoute`, `upstreamError`. For a name whose registration
208+
Codes: `tldNotConfigured`, `notFullyQualified`, `unregistered`, `reserved`,
209+
`grace`, `expired`, `noResolver`, `badAddress`, `badOffset`,
210+
`noRegistrarConfigured`, `unauthorized`, `noSuchRoute`, `upstreamError`. For a name whose registration
185211
is the problem, the code equals `status`.
186212

187213
### Status codes
@@ -239,12 +265,20 @@ what `/resolve` reads; it defaults to mainnet `.testing`, with `.simplex` unset
239265
until deployed. The **registrar** is the ERC-721 that can be asked the reverse
240266
and when a name expires — it is what `/owned-by` and every expiry field are
241267
read from. Without a registrar for a TLD, `/resolve` still works and reports
242-
`"status": "unknown"`, and `/owned-by` answers 400.
268+
`"status": "unknown"`, and `/owned-by` answers 400. The **controller** holds `reservedNames`, and is what the `reserved` status is
269+
read from; without one for a TLD, a reserved name reads as `unregistered`.
270+
271+
Note that the controller address is the **proxy**, not `SimplexControllerImpl`:
272+
storage lives in the proxy, so the implementation answers nothing.
273+
`deployments.mainnet.testing.json` records it under the ENS role name
274+
`ETHRegistrarController` and `verification.mainnet.testing.json` names it
275+
`SimplexControllerProxy` — the same address, and the one defaulted to here.
243276

244277
| Variable | Purpose |
245278
|---|---|
246279
| `SNRC_REGISTRY_<TLD>` | ENS registry; resolution |
247280
| `SNRC_REGISTRAR_<TLD>` | ERC-721 registrar; `/owned-by`, expiry and status |
281+
| `SNRC_CONTROLLER_<TLD>` | SimplexController; the `reserved` status |
248282
| `SNRC_MAX_OWNED` | names per `/owned-by` page (default 256) |
249283

250284
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.
296330
| Lapsed, still in grace | 410 | `grace` | `expires` (when it lapsed), `graceEnds` (last moment its owner can renew) |
297331
| Lapsed, past grace | 410 | `expired` | same fields; anyone may register it now |
298332
| Never registered | 404 | `unregistered` | `expires` and `graceEnds` are `null` |
333+
| Reserved for a brand | 404 | `reserved` | not registered and not registrable; overrides `unregistered` and `expired` |
334+
| Queried by labelhash (`0x…64hex.testing`) | as the label | as the label | identical answer; the label is never sent |
299335
| TLD has no registry configured | 400 || `error: tldNotConfigured`, plus `configuredTlds` |
300336
| TLD has no *registrar* configured | 200 / 404 | `unknown` | resolves as it otherwise would; expiry cannot be read, so `expires` and `graceEnds` are `null` |
301337
| Not fully qualified (`alice`) | 400 || `error` naming the expected form |

scripts/resolver/docker-compose.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ services:
156156
# the expiry status on /resolve; without them status reads "unknown".
157157
# SNRC_REGISTRAR_TESTING: 0x...
158158
# SNRC_REGISTRAR_SIMPLEX: 0x...
159+
# SimplexController, which holds reservedNames. No default: it is behind
160+
# a UUPS proxy and deployments records the implementation, so the address
161+
# has to be the proxy and has to be given.
162+
# SNRC_CONTROLLER_TESTING: 0x...
163+
# SNRC_CONTROLLER_SIMPLEX: 0x...
159164
# SNRC_MAX_OWNED: 256
160165
# SNRC_CACHE_TTL: 15 # seconds to memoise eth_call; 0 disables
161166
# SNRC_MAX_RPC_BYTES: 2097152 # refuse a larger JSON-RPC response

scripts/resolver/service/snrc-resolve.py

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@
8888
"simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet
8989
}
9090

91+
# The SimplexController per TLD, which holds `reservedNames`. Without one for a
92+
# TLD, `reserved` is never reported and a reserved name reads as unregistered.
93+
CONTROLLERS = {
94+
"testing": os.environ.get("SNRC_CONTROLLER_TESTING", "")
95+
# The proxy, not SimplexControllerImpl: storage and events live in the
96+
# proxy, so the implementation address answers nothing. deployments.json
97+
# records this one under the ENS role name ETHRegistrarController;
98+
# verification.json names it SimplexControllerProxy. Same address.
99+
or "0xeeb9b6bf5fb68fb726005f7ba549c2f4b32f2dad", # mainnet .testing
100+
"simplex": os.environ.get("SNRC_CONTROLLER_SIMPLEX", ""), # not deployed yet
101+
}
102+
91103
# Shared secret the caller must present. Unset means no check - correct for a
92104
# loopback deployment, and the reason the check exists at all is that the
93105
# Haskell client has always been able to send `Authorization` and nothing here
@@ -526,22 +538,26 @@ def resolve(name: str):
526538
"configuredTlds": configured,
527539
}
528540

529-
node = namehash(name)
541+
node = node_of(name)
530542
node_hex = node.hex()
531543

532544
# Registration first, because it is the fact that separates the failures a
533545
# caller has to tell apart: a name nobody has taken, one whose registration
534546
# lapsed and may still be renewed, one that lapsed and is now open to
535547
# anyone, and one that is held but not pointed anywhere.
536548
reg = name_status(name)
537-
if reg["status"] == "unregistered":
549+
if reg["status"] in ("unregistered", "reserved"):
538550
return 404, {
539551
"name": name,
540-
"status": "unregistered",
541-
"expires": None,
542-
"graceEnds": None,
543-
"error": "unregistered",
544-
"message": "this name has never been registered",
552+
"status": reg["status"],
553+
"expires": reg["expires"],
554+
"graceEnds": reg["graceEnds"],
555+
"error": reg["status"],
556+
"message": (
557+
"this name is held for its trademark owner and cannot be registered"
558+
if reg["status"] == "reserved"
559+
else "this name has never been registered"
560+
),
545561
}
546562
if reg["status"] in ("grace", "expired"):
547563
return 410, {
@@ -619,6 +635,57 @@ def grace_period(registrar: str) -> int:
619635
return decode_uint(eth_call(registrar, selector("GRACE_PERIOD()")))
620636

621637

638+
# A labelhash standing in for a label: "0x" and 32 bytes of hex. A real label
639+
# cannot collide with this, because the registrar caps labels well below the 66
640+
# characters this takes - so the two forms are distinguishable without a flag.
641+
HASHED_LABEL_LEN = 66
642+
643+
644+
def is_labelhash(label: str) -> bool:
645+
return (
646+
len(label) == HASHED_LABEL_LEN
647+
and label.startswith("0x")
648+
and all(c in "0123456789abcdef" for c in label[2:])
649+
)
650+
651+
652+
def label_token(label: str) -> int:
653+
"""The registrar token id for a label, given either the label or its hash.
654+
655+
Querying by hash is what lets a client ask "is this name free?" without
656+
telling the resolver which name it is about to register - the answer is
657+
the same, and the intent does not leak to whoever runs the resolver.
658+
"""
659+
return int(label, 16) if is_labelhash(label) else int.from_bytes(keccak(label.encode()), "big")
660+
661+
662+
def node_of(name: str) -> bytes:
663+
"""namehash, accepting a hashed leftmost label.
664+
665+
namehash is defined recursively as keccak(parent || keccak(label)), so a
666+
caller who supplies keccak(label) directly gets the same node without ever
667+
sending the label.
668+
"""
669+
labels = name.split(".")
670+
if len(labels) == 2 and is_labelhash(labels[0]):
671+
return keccak(namehash(labels[1]) + bytes.fromhex(labels[0][2:]))
672+
return namehash(name)
673+
674+
675+
def is_reserved(tld: str, token: int) -> bool:
676+
"""Whether the controller holds this label for a brand.
677+
678+
Keyed by labelhash on chain, so this answers for a hashed query too.
679+
"""
680+
controller = CONTROLLERS.get(tld)
681+
if not controller:
682+
return False
683+
raw = eth_call(
684+
controller, selector("reservedNames(bytes32)") + encode_uint(token)
685+
)
686+
return decode_uint(raw) != 0
687+
688+
622689
def expiry_status(expires: int, grace: int, now: int) -> str:
623690
"""Registration state from an expiry timestamp.
624691
@@ -661,17 +728,27 @@ def name_status(name: str):
661728
# No registrar configured for this TLD: say so rather than guess.
662729
return {"status": "unknown", "expires": None, "graceEnds": None}
663730

664-
token = int.from_bytes(keccak(labels[-2].encode()), "big")
731+
token = label_token(labels[-2])
665732
expires = decode_uint(
666733
eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token))
667734
)
668735
if expires == 0:
669-
return {"status": "unregistered", "expires": None, "graceEnds": None}
670-
grace = grace_period(registrar)
736+
status, grace = "unregistered", 0
737+
else:
738+
grace = grace_period(registrar)
739+
status = expiry_status(expires, grace, int(time.time()))
740+
741+
# `reserved` only displaces the two states that read as "you could take
742+
# this". A registered name is registered, and one in grace belongs to its
743+
# owner either way - in both cases the reservation is not the answer to the
744+
# question being asked.
745+
if status in ("unregistered", "expired") and is_reserved(tld, token):
746+
status = "reserved"
747+
671748
return {
672-
"status": expiry_status(expires, grace, int(time.time())),
673-
"expires": expires,
674-
"graceEnds": expires + grace,
749+
"status": status,
750+
"expires": expires or None,
751+
"graceEnds": (expires + grace) if expires else None,
675752
}
676753

677754

scripts/resolver/service/test_snrc_resolve.py

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,117 @@ def test_no_configured_registrar_is_an_error_not_an_empty_list(self):
204204
self.assertEqual(body["configuredTlds"], [])
205205

206206

207+
class LabelhashQueryTests(unittest.TestCase):
208+
"""Asking by labelhash instead of by label.
209+
210+
A client checking whether a name is free is about to register it, so
211+
telling the resolver which name that is hands whoever runs it a
212+
front-running opportunity. namehash is keccak(parent || keccak(label)), so
213+
a caller who supplies keccak(label) gets an identical answer having said
214+
nothing about the name."""
215+
216+
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
217+
GRACE = 90 * 86400
218+
219+
def setUp(self):
220+
self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call)
221+
snrc.REGISTRARS = {"testing": self.REGISTRAR}
222+
snrc.CONTROLLERS = {"testing": ""}
223+
224+
def tearDown(self):
225+
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved
226+
227+
def test_a_hashed_label_is_recognised_and_a_real_one_is_not(self):
228+
self.assertTrue(snrc.is_labelhash("0x" + snrc.keccak(b"alice").hex()))
229+
self.assertFalse(snrc.is_labelhash("alice"))
230+
self.assertFalse(snrc.is_labelhash("0x" + "z" * 64))
231+
# a label cannot be this long, which is what keeps the forms apart
232+
self.assertFalse(snrc.is_labelhash("0x" + "a" * 62))
233+
234+
def test_hash_and_label_give_the_same_token_and_node(self):
235+
h = "0x" + snrc.keccak(b"alice").hex()
236+
self.assertEqual(snrc.label_token("alice"), snrc.label_token(h))
237+
self.assertEqual(snrc.node_of("alice.testing"), snrc.node_of(h + ".testing"))
238+
239+
def test_status_by_hash_matches_status_by_name(self):
240+
future = int(time.time()) + 86400
241+
seen = []
242+
243+
def eth_call(to, data):
244+
seen.append(data)
245+
if data.startswith(snrc.selector("GRACE_PERIOD()")):
246+
return "0x" + snrc.encode_uint(self.GRACE)
247+
return "0x" + snrc.encode_uint(future)
248+
249+
snrc.eth_call = eth_call
250+
h = "0x" + snrc.keccak(b"alice").hex()
251+
by_name = snrc.name_status("alice.testing")
252+
by_hash = snrc.name_status(h + ".testing")
253+
self.assertEqual(by_name, by_hash)
254+
self.assertEqual(by_name["status"], "registered")
255+
# nothing in either request carried the label itself
256+
self.assertTrue(all("alice".encode().hex() not in d for d in seen))
257+
258+
259+
class ReservedTests(unittest.TestCase):
260+
"""A reserved name is unregistered and still unavailable, which a client
261+
intending to register needs to know before it tries."""
262+
263+
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
264+
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
265+
266+
def setUp(self):
267+
self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call)
268+
snrc.REGISTRARS = {"testing": self.REGISTRAR}
269+
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
270+
271+
def tearDown(self):
272+
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved
273+
274+
def _chain(self, expires, reserved):
275+
def eth_call(to, data):
276+
if data.startswith(snrc.selector("reservedNames(bytes32)")):
277+
self.assertEqual(to, self.CONTROLLER)
278+
return "0x" + snrc.encode_uint(1 if reserved else 0)
279+
if data.startswith(snrc.selector("GRACE_PERIOD()")):
280+
return "0x" + snrc.encode_uint(90 * 86400)
281+
return "0x" + snrc.encode_uint(expires)
282+
283+
return eth_call
284+
285+
def test_unregistered_and_reserved_reads_reserved(self):
286+
snrc.eth_call = self._chain(0, True)
287+
self.assertEqual(snrc.name_status("acme.testing")["status"], "reserved")
288+
289+
def test_unregistered_and_not_reserved_reads_unregistered(self):
290+
snrc.eth_call = self._chain(0, False)
291+
self.assertEqual(snrc.name_status("acme.testing")["status"], "unregistered")
292+
293+
def test_a_lapsed_reserved_name_is_reserved_not_claimable(self):
294+
past = int(time.time()) - 91 * 86400
295+
snrc.eth_call = self._chain(past, True)
296+
self.assertEqual(snrc.name_status("acme.testing")["status"], "reserved")
297+
298+
def test_a_live_name_is_registered_even_if_reserved(self):
299+
"""It was handed to its brand; the reservation is no longer the answer."""
300+
snrc.eth_call = self._chain(int(time.time()) + 86400, True)
301+
self.assertEqual(snrc.name_status("acme.testing")["status"], "registered")
302+
303+
def test_a_name_in_grace_belongs_to_its_owner_not_the_reserved_set(self):
304+
snrc.eth_call = self._chain(int(time.time()) - 3600, True)
305+
self.assertEqual(snrc.name_status("acme.testing")["status"], "grace")
306+
307+
def test_no_controller_configured_means_reserved_is_never_reported(self):
308+
snrc.CONTROLLERS = {"testing": ""}
309+
snrc.eth_call = self._chain(0, True) # would say reserved if asked
310+
self.assertEqual(snrc.name_status("acme.testing")["status"], "unregistered")
311+
312+
def test_reserved_is_asked_by_labelhash_so_a_hashed_query_works(self):
313+
h = "0x" + snrc.keccak(b"acme").hex()
314+
snrc.eth_call = self._chain(0, True)
315+
self.assertEqual(snrc.name_status(h + ".testing")["status"], "reserved")
316+
317+
207318
class NameStatusTests(unittest.TestCase):
208319
"""simplexmq#1821: unresolvable has three causes and a caller has to tell
209320
them apart. Names expire lazily, so the chain still holds the answer."""
@@ -222,11 +333,14 @@ def eth_call(to, data):
222333
return eth_call
223334

224335
def setUp(self):
225-
self._registrars, self._eth_call = snrc.REGISTRARS, snrc.eth_call
336+
self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call)
226337
snrc.REGISTRARS = {"testing": self.REGISTRAR}
338+
# These cases are about expiry alone. ReservedTests covers what a
339+
# configured controller adds.
340+
snrc.CONTROLLERS = {"testing": ""}
227341

228342
def tearDown(self):
229-
snrc.REGISTRARS, snrc.eth_call = self._registrars, self._eth_call
343+
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved
230344

231345
def test_zero_expiry_means_never_registered(self):
232346
snrc.eth_call = self._expiry(0)

0 commit comments

Comments
 (0)