From a8cee354eb0c6243be82f9a0ba4fc544a9aa1abc Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 3 Sep 2026 17:23:47 +0200 Subject: [PATCH 1/4] implement resolving 2LD names by labelhash --- .github/workflows/build.yml | 28 +++++++ scripts/resolver/README.md | 30 +++++++ scripts/resolver/service/snrc-resolve.py | 44 ++++++++++- scripts/resolver/service/test_snrc_resolve.py | 79 +++++++++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c841ec073..13141dd40 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 88fa6fde5..cd2025743 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -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 | diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index ffddbeb02..b72cc9a1d 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -29,6 +29,7 @@ ./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) diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 2bb42f991..9dfee3145 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -82,5 +82,84 @@ def test_order_is_preserved(self): ) +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() From bd4b07ab794db266accbda76abf17802b27fe32b Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 3 Sep 2026 17:53:47 +0200 Subject: [PATCH 2/4] tiny test addition --- scripts/resolver/service/test_snrc_resolve.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 9dfee3145..aea000a04 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -112,6 +112,8 @@ 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 + "]")) + # explicitly disallowed prefix + self.assertFalse(snrc.is_encoded_labelhash("[0x9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]")) def test_the_wrong_length_is_not(self): self.assertFalse(snrc.is_encoded_labelhash("[" + "a" * 63 + "]")) From e670d9fa37dd52e4596529b8e306c2184fe36941 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 4 Sep 2026 08:40:04 +0200 Subject: [PATCH 3/4] simplify language --- scripts/resolver/README.md | 47 ++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index cd2025743..1930b3345 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -129,35 +129,38 @@ 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 +### Querying by labelhash -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: +A client that asks whether a name is free is usually about to register it. +Whoever runs the resolver sees that question and could register the name first. +To avoid that, send the keccak hash of the label instead of the label itself, +written in ENS's `[<64 hex>]` form. The answer is the same: ```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. +This works because namehash is `keccak(parent || keccak(label))`. Passing +`keccak(label)` gives the same node, so the resolver reads the same record. It +learns which name you meant only if it guesses the label and hashes it. + +Brackets keep the two forms from colliding. `[` and `]` are not valid in a +normalised ENS name, and the dApp normalises before it registers, so no name +registered through it can look like this. Nothing on chain checks the character +set, but a `[<64 hex>]` label is 66 bytes and the registrar's `maxLabelLength` +is 63, so it cannot be registered directly either. ENS uses this same encoding +for a label whose preimage it does not know. A plain `0x…` label would not work +here, because that is an ordinary name anyone can register. + +Only 2LDs can be queried by hash. A 2LD is what a registration buys, so it is +the only name worth hiding. Subnames are left out because nobody can race you +for one: the owner of the 2LD creates them. In a subname the resolver hashes a +`[<64 hex>]` label as written instead of decoding it, so such a query points at +a node nobody can own. + +This hides your interest in a name, and nothing more. The registration itself +is public, and the controller's commit-reveal protects that step. ### Status codes From ad4077bf145c259e9eb8f341190887f8c94e8312 Mon Sep 17 00:00:00 2001 From: sh Date: Fri, 4 Sep 2026 14:49:07 +0000 Subject: [PATCH 4/4] resolver: reduce comments --- .github/workflows/build.yml | 7 ++-- scripts/resolver/README.md | 42 ++++++++----------- scripts/resolver/service/snrc-resolve.py | 26 ++++-------- scripts/resolver/service/test_snrc_resolve.py | 23 +++------- 4 files changed, 33 insertions(+), 65 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 13141dd40..85b7522a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -306,8 +306,8 @@ jobs: # 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. +# The SNRC resolver is Python, so this job needs none of the Haskell toolchain +# above and runs independently of it. resolver-test: name: "resolver (python)" @@ -323,8 +323,7 @@ jobs: python-version: "3.13" - name: Install resolver dependencies - # The only runtime dependency; declared in - # scripts/resolver/service/pyproject.toml. + # Must match scripts/resolver/service/pyproject.toml. run: python -m pip install "eth-hash[pycryptodome]>=0.7" - name: Test diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 1930b3345..9be07c4e1 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -131,36 +131,30 @@ identically (`bar.foobar.testing`). ### Querying by labelhash -A client that asks whether a name is free is usually about to register it. -Whoever runs the resolver sees that question and could register the name first. -To avoid that, send the keccak hash of the label instead of the label itself, -written in ENS's `[<64 hex>]` form. The answer is the same: +A client asking whether a name is free is usually about to register it, and +whoever runs the resolver could register it first. To avoid that, send the +keccak hash of the label in ENS's `[<64 hex>]` form instead of the label: ```sh # instead of /resolve/acme.testing curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d' ' -f1)].testing" ``` -This works because namehash is `keccak(parent || keccak(label))`. Passing -`keccak(label)` gives the same node, so the resolver reads the same record. It -learns which name you meant only if it guesses the label and hashes it. - -Brackets keep the two forms from colliding. `[` and `]` are not valid in a -normalised ENS name, and the dApp normalises before it registers, so no name -registered through it can look like this. Nothing on chain checks the character -set, but a `[<64 hex>]` label is 66 bytes and the registrar's `maxLabelLength` -is 63, so it cannot be registered directly either. ENS uses this same encoding -for a label whose preimage it does not know. A plain `0x…` label would not work -here, because that is an ordinary name anyone can register. - -Only 2LDs can be queried by hash. A 2LD is what a registration buys, so it is -the only name worth hiding. Subnames are left out because nobody can race you -for one: the owner of the 2LD creates them. In a subname the resolver hashes a -`[<64 hex>]` label as written instead of decoding it, so such a query points at -a node nobody can own. - -This hides your interest in a name, and nothing more. The registration itself -is public, and the controller's commit-reveal protects that step. +namehash is `keccak(parent || keccak(label))`, so this reaches the same node and +returns the same record. The resolver learns the name only by guessing the label +and hashing it. + +Brackets cannot collide with a real name: they are invalid in a normalised ENS +name, and a `[<64 hex>]` label is 66 bytes against the registrar's +`maxLabelLength` of 63. A plain `0x…` label is not treated as a hash, since that +is an ordinary, registrable name. + +Only 2LDs can be queried this way, as only a 2LD can be raced for: subnames are +created by the 2LD's owner. A bracket label in a subname is hashed as written, +so it points at a node nobody can own. + +This hides interest in a name and nothing else: the registration itself is +public, and commit-reveal covers that step. ### Status codes diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index b72cc9a1d..8fc30b68d 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -124,14 +124,8 @@ 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. +# ENS's encoding for a label whose preimage is unknown. Brackets are outside +# the normalised character set, so it cannot collide with a registrable name. ENCODED_LABELHASH_LEN = 66 # "[" + 64 hex + "]" @@ -147,17 +141,11 @@ def is_encoded_labelhash(label: str) -> bool: 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. + keccak(parent || keccak(label)) reaches the same node without the label, + so a caller can check a 2LD without disclosing which one they are about to + register. Subnames are excluded - only the 2LD's owner creates them, so + there is nothing to front-run - and a bracket label there is hashed as + written. """ labels = name.split(".") if len(labels) == 2 and is_encoded_labelhash(labels[0]): diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index aea000a04..6a56f5bef 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -83,18 +83,11 @@ def test_order_is_preserved(self): class EncodedLabelhashTests(unittest.TestCase): - """Querying by labelhash instead of by label. + """`node_of` accepts a 2LD's label as an encoded labelhash `[<64 hex>]`, + reaching the same node as the label itself.""" - 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. + # keccak-256("alice"), written out in full wherever a test needs it. + # 9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501 def test_the_encoded_form_is_recognised(self): self.assertTrue( @@ -110,9 +103,8 @@ def test_an_ordinary_label_is_not(self): 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 + # uppercase is rejected because the handler lowercases the whole name self.assertFalse(snrc.is_encoded_labelhash("[" + "A" * 64 + "]")) - # explicitly disallowed prefix self.assertFalse(snrc.is_encoded_labelhash("[0x9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]")) def test_the_wrong_length_is_not(self): @@ -132,9 +124,6 @@ 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]" @@ -152,8 +141,6 @@ def test_an_encoded_subname_is_not_the_name_it_would_decode_to(self): ) 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"))