implement resolving 2LD names by labelhash

This commit is contained in:
Alain Brenzikofer
2026-09-03 17:23:47 +02:00
parent ac4bf690b6
commit a8cee354eb
4 changed files with 180 additions and 1 deletions
+28
View File
@@ -301,3 +301,31 @@ jobs:
echo "All "$attempts" attempts failed."
exit 1
fi
# =============================
# Resolver test job
# =============================
# The SNRC resolver is Python and stdlib-only apart from keccak, so it needs
# none of the Haskell toolchain above and runs independently of it.
resolver-test:
name: "resolver (python)"
runs-on: ubuntu-latest
steps:
- name: Clone project
uses: actions/checkout@v3
- name: Set up Python
# Matches the runtime stage of scripts/resolver/service/Dockerfile.
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install resolver dependencies
# The only runtime dependency; declared in
# scripts/resolver/service/pyproject.toml.
run: python -m pip install "eth-hash[pycryptodome]>=0.7"
- name: Test
run: python -m unittest discover -s scripts/resolver/service -v
+30
View File
@@ -129,6 +129,36 @@ text record; the resolver splits/trims/drops-empties. Address encodings are
canonical per chain (EIP-55 / bech32 / SS58 / Monero-base58). Subnames work
identically (`bar.foobar.testing`).
### Asking without naming the name
A client checking whether a name is free is usually about to register it, so
the question itself is worth front-running. Substitute the label's keccak hash,
written in ENS's `[<64 hex>]` form, and the answer is identical:
```sh
# instead of /resolve/acme.testing
curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d' ' -f1)].testing"
```
namehash is defined as `keccak(parent || keccak(label))`, so supplying
`keccak(label)` reaches the same node: the resolver reads exactly the record it
would have read, and learns which name you meant only if it already guessed it.
The brackets are what keep the two forms apart — `[` and `]` cannot occur in a
normalised name, so no registrable label can take this shape, and it is the
same encoding ENS itself uses for a label whose preimage is unknown. A bare
`0x…` label would not do: that is an ordinary, registrable name.
Only 2LDs may be queried by hash: that is the name a registration is bought
for, so the only one worth hiding. Subnames of any depth are excluded — a
subname is created by the 2LD's owner, nobody can race you for one, so there is
nothing to front-run. A label in `[<64 hex>]` form there is hashed literally,
not decoded; as brackets cannot occur in a real registration, such a query
names a node nobody can own.
Registration itself stays public: this hides the *interest*, and the
commit-reveal in the controller is what protects the registration.
### Status codes
| Status | Meaning |
+43 -1
View File
@@ -29,6 +29,7 @@ Usage:
./snrc-resolve.py # serve on :8000
curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq .
curl -s 'http://127.0.0.1:8000/resolve/[<64-hex labelhash>].testing' | jq .
curl -s http://127.0.0.1:8000/health
Environment:
@@ -123,6 +124,47 @@ def namehash(name: str) -> bytes:
return node
# ENS writes a label whose preimage it does not know as `[<64 hex>]`, and that
# is the form reused here for a label the caller deliberately withholds. The
# brackets are what keep the two forms apart: `[` and `]` are outside the
# normalised character set, so no registrable name can take this shape, and the
# ecosystem already reads it back as a hash (ensjs `isEncodedLabelhash`; the
# subgraph refuses any real label containing a bracket). A bare `0x…` label
# would not be safe this way - that is an ordinary, registrable name, kept from
# clashing only by a registrar length cap that its owner can raise.
ENCODED_LABELHASH_LEN = 66 # "[" + 64 hex + "]"
def is_encoded_labelhash(label: str) -> bool:
return (
len(label) == ENCODED_LABELHASH_LEN
and label.startswith("[")
and label.endswith("]")
and all(c in "0123456789abcdef" for c in label[1:-1])
)
def node_of(name: str) -> bytes:
"""namehash, accepting an encoded labelhash in place of a 2LD's label.
A client checking whether a name is free is usually about to register it,
so the question itself is worth front-running. namehash is defined as
keccak(parent || keccak(label)), so a caller who supplies keccak(label)
reaches the same node having never sent the label.
Only 2LDs may be queried this way: that is the name a registration is
bought for, so the only one worth hiding. Subnames of any depth are
excluded - a subname is created by the 2LD's owner, nobody can race a
caller for one, so there is nothing to front-run. A label in `[<64 hex>]`
form there is hashed literally, not decoded; as brackets cannot occur in a
real registration, such a query names a node nobody can own.
"""
labels = name.split(".")
if len(labels) == 2 and is_encoded_labelhash(labels[0]):
return keccak(namehash(labels[1]) + bytes.fromhex(labels[0][1:-1]))
return namehash(name)
def selector(signature: str) -> str:
return "0x" + keccak(signature.encode())[:4].hex()
@@ -401,7 +443,7 @@ def resolve(name: str):
"configured_tlds": configured,
}
node = namehash(name)
node = node_of(name)
node_hex = node.hex()
resolver_raw = eth_call(registry, selector("resolver(bytes32)") + node_hex)
@@ -82,5 +82,84 @@ class SplitLinksTests(unittest.TestCase):
)
class EncodedLabelhashTests(unittest.TestCase):
"""Querying by labelhash instead of by label.
A client asking whether a name is free is usually about to register it, so
the question itself is worth front-running by whoever runs the resolver.
namehash is keccak(parent || keccak(label)), so supplying keccak(label)
yields the same node and the same answer, having never sent the label.
The encoding is ENS's own `[<64 hex>]`, which cannot collide with a real
name: brackets are outside the normalised character set."""
# keccak-256("alice") = 9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501
# - written out in full wherever a test needs a real labelhash.
def test_the_encoded_form_is_recognised(self):
self.assertTrue(
snrc.is_encoded_labelhash(
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
)
)
def test_an_ordinary_label_is_not(self):
self.assertFalse(snrc.is_encoded_labelhash("alice"))
self.assertFalse(snrc.is_encoded_labelhash("[alice]"))
self.assertFalse(snrc.is_encoded_labelhash("9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501"))
def test_non_hex_between_the_brackets_is_not(self):
self.assertFalse(snrc.is_encoded_labelhash("[" + "z" * 64 + "]"))
# uppercase hex is not it either: the handler lowercases the whole name
self.assertFalse(snrc.is_encoded_labelhash("[" + "A" * 64 + "]"))
def test_the_wrong_length_is_not(self):
self.assertFalse(snrc.is_encoded_labelhash("[" + "a" * 63 + "]"))
self.assertFalse(snrc.is_encoded_labelhash("[" + "a" * 65 + "]"))
def test_hash_and_label_reach_the_same_node(self):
self.assertEqual(
snrc.node_of("alice.testing"),
snrc.node_of(
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
".testing"
),
)
def test_a_plain_name_is_unaffected(self):
self.assertEqual(snrc.node_of("alice.testing"), snrc.namehash("alice.testing"))
def test_an_encoded_subname_is_not_the_name_it_would_decode_to(self):
"""Only 2LDs are queried by hash. If a label in `[<64 hex>]` form were
decoded in a subname, that subname would silently be the name the hash
stands for - here `alice.alice.testing`."""
self.assertNotEqual(
snrc.node_of(
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
".alice.testing"
),
snrc.namehash("alice.alice.testing"),
)
self.assertNotEqual(
snrc.node_of(
"alice."
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
".testing"
),
snrc.namehash("alice.alice.testing"),
)
def test_a_0x_prefixed_label_is_taken_literally(self):
"""`0x<64 hex>` is a registrable name, not a hash - the brackets are
what make the hashed form unambiguous."""
name = "0x9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501.testing"
self.assertEqual(snrc.node_of(name), snrc.namehash(name))
self.assertNotEqual(snrc.node_of(name), snrc.node_of("alice.testing"))
def test_a_malformed_bracket_label_falls_back_to_a_literal_name(self):
name = "[nothex].testing"
self.assertEqual(snrc.node_of(name), snrc.namehash(name))
if __name__ == "__main__":
unittest.main()