Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,30 @@ jobs:
echo "All "$attempts" attempts failed."
exit 1
fi

# =============================
# Resolver test job
# =============================

# 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)"
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
# Must match 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
27 changes: 27 additions & 0 deletions scripts/resolver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,33 @@ 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`).

### Querying by labelhash

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"
```

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

| Status | Meaning |
Expand Down
32 changes: 31 additions & 1 deletion scripts/resolver/service/snrc-resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -123,6 +124,35 @@ def namehash(name: str) -> bytes:
return node


# 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 + "]"


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.

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]):
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()

Expand Down Expand Up @@ -401,7 +431,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)
Expand Down
68 changes: 68 additions & 0 deletions scripts/resolver/service/test_snrc_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,73 @@ def test_order_is_preserved(self):
)


class EncodedLabelhashTests(unittest.TestCase):
"""`node_of` accepts a 2LD's label as an encoded labelhash `[<64 hex>]`,
reaching the same node as the label itself."""

# keccak-256("alice"), written out in full wherever a test needs it.
# 9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501

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 is rejected because the handler lowercases the whole name
self.assertFalse(snrc.is_encoded_labelhash("[" + "A" * 64 + "]"))
self.assertFalse(snrc.is_encoded_labelhash("[0x9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"))

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):
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):
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()
Loading