review fixes

This commit is contained in:
Alain Brenzikofer
2026-09-05 18:37:55 +02:00
parent e3f00b46f6
commit 69cce33675
15 changed files with 425 additions and 173 deletions
-1
View File
@@ -12,4 +12,3 @@ cabal.project.local~
*.tix
.coverage
__pycache__/
+38 -16
View File
@@ -86,7 +86,7 @@ It's designed with the focus on communication security and integrity, under the
It is designed as a low level protocol for other application protocols to solve the problem of secure and private message transmission, making [MITM attack][1] very difficult at any part of the message transmission system.
This document describes SMP protocol version 20. Versions 1-5 are discontinued. The version history:
This document describes SMP protocol version 22. Versions 1-5 are discontinued. The version history:
- v1: binary protocol encoding
- v2: message flags (used to control notifications)
@@ -1469,18 +1469,31 @@ rslv = %s"RSLV" SP domain ; domain = canonical name as non-space bytes, consum
explicit (e.g. `privacy.simplex`, `test.testing`, `example.com`), bounded to
253 bytes.
**Hashed labels.** A label MAY instead be given as `[` followed by 64 lowercase
hex characters and `]` — the keccak-256 hash of the label — so a client can ask
about a name without disclosing it to the names router. This is ENS's encoding
**Hashed labels.** The second-level label MAY instead be given as `[` followed
by 64 lowercase hex characters and `]` — the keccak-256 hash of that label — so
a router can answer about a name without being told it. This is ENS's encoding
for a label whose preimage is unknown; the brackets are outside the name
character set, so the form cannot collide with a registrable name, and the
backing resolver uses the hash as the registry key rather than hashing the label
again. A hashed label is 66 characters and is therefore exempt from the 63-byte
DNS label limit: it is a key into the registry, not a DNS label. A bare `0x`
hex string is NOT a hashed label — it is an ordinary label, and would be hashed
again, keying a different name. A router answering a hashed query cannot know
the name's length, and so cannot know its price or whether it meets a
minimum-length policy.
DNS label limit: it is a key into the registry, not a DNS label.
**Only the second-level label.** It is the only label the registry is keyed on;
subname labels are needed as text to reach the record, so they are never hashed.
`[<hash>].simplex` and `sub.[<hash>].simplex` both reach the node their plain
names would, and a bracket label in any other position is an ordinary label,
hashed as written. Routers MUST reject a name whose hashed label is not the
second-level one, so that client and resolver cannot disagree about which node
was asked about. A bare `0x` hex string is likewise NOT a hashed label — it is an
ordinary label, and would be hashed again, keying a different name.
**Clients send the hash.** From v22 a client MUST hash the second-level label of
every `RSLV` and `NAVL` it sends, so a registrable name never reaches a router in
the clear. Routers below v22 cannot parse the form, so a client on an older
session sends the name itself. The record returned for a hashed query names the
hash, because that is what was asked; the client restores the name it used.
A router answering a hashed query cannot know the name's length, and so cannot
know its price or whether it meets a minimum-length policy.
**Server-side validation.** The names router parses `domain` as a
fully-qualified name (TLD required — bare labels are rejected) and forwards it
@@ -1591,7 +1604,7 @@ reason = %s"UNSPECIFIED" / %s"TRADEMARK" / %s"PUBLIC_INTEREST"
| Answer | Condition | Client action |
|---|---|---|
| `AVAILABLE` | registrable at the ordinary price | offer it |
| `TAKEN` | held by someone until `expires`, or the router could not answer completely | do not offer it |
| `TAKEN` | held by someone until `expires` | do not offer it |
| `GRACE` | lapsed, but renewable by its previous owner until `grace-ends` | do not offer it; it may free up then |
| `AUCTION` | registrable by anyone, at `premium` above the ordinary price, decaying to nothing by `auction-ends` | offer it only with the premium shown |
| `RESERVED` | held back by the registry for `reason` | do not offer it; explain `reason` |
@@ -1608,9 +1621,15 @@ countdown; it MUST NOT treat either deadline as authorisation to register, which
only the registry grants.
A router that cannot obtain the payload for `GRACE` or `AUCTION` MUST answer
`TAKEN` rather than `AVAILABLE`. Quoting the ordinary price for a name that
carries a premium is the one materially harmful answer here, and withholding a
name the user could have had is the smaller error.
`TAKEN` with no `expires`, rather than `AVAILABLE`. Quoting the ordinary price
for a name that carries a premium is the one materially harmful answer here, and
withholding a name the user could have had is the smaller error.
A router that cannot read the name's status at all MUST answer `ERR NAME
RESOLVER <detail>` and MUST NOT answer `TAKEN`, which would assert a
registration nobody read, or `NOT_FOUND`, which a client may read as "no such
name, therefore free". This covers an unreachable chain, a TLD the backing
resolver has no registry for, and any status the router does not recognise.
`RESERVED` carries a reason code rather than a sentence so the client can word
it in the user's language. A client MUST treat a reason it does not recognise as
@@ -1620,9 +1639,12 @@ it in the user's language. A client MUST treat a reason it does not recognise as
resolver, `ERR NAME RESOLVER <detail>` on a transient backing failure. It is
gated on SMP v22 and MUST NOT be sent to a router that negotiated a lower
version. Like `RSLV` it is unauthenticated and accepted directly or inside a
`PFWD` block, and clients SHOULD prefer the forwarded path for the same reason:
an availability query discloses the lookup key, and a hashed label protects the
name but not the client's IP.
`PFWD` block, and clients SHOULD prefer the forwarded path: a hashed label keeps
the name from the router, but only the proxy keeps the client's IP from it. A
client whose proxy cannot carry `NAVL` — every proxy below v22, since the proxy
caps the relay version at `proxiedSMPRelayVersion` — falls back to a direct send
if its network configuration allows one, so during rollout the names router sees
the client's IP alongside the hash, and never the name.
## Transport connection with the SMP router
+19 -1
View File
@@ -187,7 +187,18 @@ base price itself.
The oracle is found through the controller's `prices()`, so no extra
configuration is needed. Its window is read from the chain rather than assumed,
because the owner can retune it; a window of zero days switches the auction off,
and every lapsed name then reports `expired` directly.
and every lapsed name then reports `expired` directly. The curve
(`startPremium`, `totalDays`, `endValue`) is cached for `AUCTION_PARAMS_TTL`
seconds, 5 minutes by default, since it changes only when the owner calls
`setPremium`; the decaying premium itself is read from the oracle on every
query. A retune is therefore visible within the TTL, not immediately.
**Known gap.** When the auction cannot be read at all — no controller
configured, or the oracle unreachable — the name reports `expired`, which routers
map to "available at the ordinary price". A name still inside its auction would
then be quoted at list price while the registrar charges the premium. Configure
`SNRC_CONTROLLER_<TLD>` wherever `SNRC_REGISTRAR_<TLD>` is set, and upgrade this
service before the routers that query it.
Upgrade the resolver before the router that queries it. A resolver without this
status reports a name in its auction as plain `expired`, which reads as "free at
@@ -231,6 +242,13 @@ returns the same record. The registrar keys `nameExpires` and `reservedNames` on
the labelhash too, so the status fields do not need the label either. The
resolver learns the name only by guessing the label and hashing it.
Only the second-level label is a registry key, so only it is decoded — but it is
decoded wherever it sits, so `sub.[<hash>].testing` reaches the node
`sub.name.testing` does. Subname labels are needed as text to walk down to the
record and are never hashed; a bracket label to the left of the 2LD is an
ordinary label and is hashed as written. SMP routers from v22 send every 2LD
this way, so in normal operation a registrable name never reaches this service.
Read the answer from `status`. A name is free when the body says
`unregistered` (a 404), and also when it says `expired` or `auction` (a 410) —
though `auction` costs a premium on top. Every other status means somebody holds
+43 -13
View File
@@ -66,6 +66,7 @@ Unrecognised payloads fall back to `0x`-prefixed raw hex.
import hashlib
import json
import os
import time
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote, urlparse
@@ -170,12 +171,17 @@ 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. In a
subname a bracket label is hashed as written, not decoded."""
"""namehash, accepting the 2LD's label as an encoded labelhash at any depth,
so `[hash].tld` and `sub.[hash].tld` both reach the node the name itself
would. Only that label is a registry key: a bracket label anywhere else is
hashed as written, which is what the routers also enforce."""
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)
if len(labels) < 2 or not is_encoded_labelhash(labels[-2]):
return namehash(name)
node = keccak(namehash(labels[-1]) + bytes.fromhex(labels[-2][1:-1]))
for label in reversed(labels[:-2]):
node = keccak(node + keccak(label.encode()))
return node
# ---------- Registration status ----------
@@ -213,23 +219,47 @@ def reservation_reason(tld: str, token: int) -> int:
return decode_uint(raw)
# The oracle address and its curve change only when the owner retunes the
# auction, so they are read at most once per AUCTION_PARAMS_TTL seconds instead
# of on every lapsed-name query. The premium itself is never cached: it decays
# continuously and is read from the oracle each time.
AUCTION_PARAMS_TTL = 300
_auction_params: dict = {}
def auction_params(tld: str):
"""(oracle, startPremium, totalDays, endValue) for the TLD's controller, or
(ZERO_ADDR, 0, 0, 0) when no controller or no oracle is configured."""
cached = _auction_params.get(tld)
if cached and time.time() - cached[0] < AUCTION_PARAMS_TTL:
return cached[1]
params = (ZERO_ADDR, 0, 0, 0)
controller = CONTROLLERS.get(tld)
if controller:
oracle = decode_address(eth_call(controller, selector("prices()")))
if oracle != ZERO_ADDR:
params = (
oracle,
decode_uint(eth_call(oracle, selector("startPremium()"))),
decode_uint(eth_call(oracle, selector("totalDays()"))),
decode_uint(eth_call(oracle, selector("endValue()"))),
)
_auction_params[tld] = (time.time(), params)
return params
def auction(tld: str, grace_ends: int, now: int):
"""Past its grace period a name is registrable again, but at a premium that
decays to zero over the price oracle's auction window. Returns when the
premium reaches zero and what it is now, in attoUSD, or (None, None) once
prices are back to normal - which includes an auction switched off by
setting totalDays to 0."""
controller = CONTROLLERS.get(tld)
if not controller:
return None, None
oracle = decode_address(eth_call(controller, selector("prices()")))
oracle, start, total_days, floor = auction_params(tld)
if oracle == ZERO_ADDR:
return None, None
ends = grace_ends + decode_uint(eth_call(oracle, selector("totalDays()"))) * 86400
ends = grace_ends + total_days * 86400
if now >= ends:
return None, None
start = decode_uint(eth_call(oracle, selector("startPremium()")))
floor = decode_uint(eth_call(oracle, selector("endValue()")))
# decayedPremium is `pure`, so the premium quoted here is the oracle's own
# arithmetic rather than a reimplementation of its decay curve.
decayed = decode_uint(
@@ -261,7 +291,7 @@ def name_status(name: str):
# nameExpires and reservedNames are keyed on uint256(keccak(label)).
# Decoded for a 2LD only, the same rule node_of applies to the node.
label = labels[-2]
if len(labels) == 2 and is_encoded_labelhash(label):
if is_encoded_labelhash(label):
token = int(label[1:-1], 16)
else:
token = int.from_bytes(keccak(label.encode()), "big")
+90 -79
View File
@@ -97,6 +97,7 @@ class EncodedLabelhashTests(unittest.TestCase):
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": ""}
snrc.chain_now = lambda: int(time.time())
snrc._auction_params.clear()
def tearDown(self):
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved
@@ -135,7 +136,9 @@ class EncodedLabelhashTests(unittest.TestCase):
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):
def test_a_bracket_subname_label_stays_literal(self):
"""Only the 2LD is a registry key, so a bracket label to the left of it
is a name in its own right and is hashed as written."""
self.assertNotEqual(
snrc.node_of(
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
@@ -143,13 +146,25 @@ class EncodedLabelhashTests(unittest.TestCase):
),
snrc.namehash("alice.alice.testing"),
)
self.assertNotEqual(
def test_a_hashed_2ld_under_a_subname_reaches_the_same_node(self):
"""Clients hash the 2LD and leave subname labels as text, so
`sub.[hash].tld` must reach the node `sub.name.tld` does."""
self.assertEqual(
snrc.node_of(
"alice."
"sub."
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
".testing"
),
snrc.namehash("alice.alice.testing"),
snrc.namehash("sub.alice.testing"),
)
self.assertEqual(
snrc.node_of(
"a.b."
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
".testing"
),
snrc.namehash("a.b.alice.testing"),
)
def test_a_0x_prefixed_label_is_taken_literally(self):
@@ -221,6 +236,7 @@ class NameStatusTests(unittest.TestCase):
# Expiry alone; ReservedTests covers a configured controller.
snrc.CONTROLLERS = {"testing": ""}
snrc.chain_now = lambda: int(time.time())
snrc._auction_params.clear()
def tearDown(self):
(
@@ -297,6 +313,20 @@ class NameStatusTests(unittest.TestCase):
# the token asked about is keccak("alice"), not keccak("x")
self.assertTrue(seen[0].endswith(snrc.keccak(b"alice").hex()))
def test_a_hashed_2ld_is_queried_by_its_hash_at_any_depth(self):
"""Clients hash the 2LD and leave subname labels as text, so the token
must come from the hash, not from hashing the bracket text again."""
seen = []
def eth_call(to, data):
seen.append(data)
return "0x" + snrc.encode_uint(0)
snrc.eth_call = eth_call
hashed = "[" + snrc.keccak(b"alice").hex() + "]"
snrc.name_status("x." + hashed + ".testing")
self.assertTrue(seen[0].endswith(snrc.keccak(b"alice").hex()))
def test_unconfigured_tld_is_unknown_rather_than_unregistered(self):
snrc.REGISTRARS = {"testing": ""}
snrc.eth_call = lambda *a: self.fail("must not reach the chain")
@@ -333,6 +363,7 @@ class ReservedTests(unittest.TestCase):
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
snrc.chain_now = lambda: int(time.time())
snrc._auction_params.clear()
def tearDown(self):
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved
@@ -400,6 +431,7 @@ class ReservedReasonTests(unittest.TestCase):
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
snrc.chain_now = lambda: int(time.time())
snrc._auction_params.clear()
def tearDown(self):
(
@@ -422,6 +454,47 @@ class ReservedReasonTests(unittest.TestCase):
return eth_call
def _reserved_as(self, code):
def eth_call(to, data):
if data.startswith(snrc.selector("reservedNames(bytes32)")):
return "0x" + snrc.encode_uint(code)
if data.startswith(snrc.selector("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(90 * 86400)
if data.startswith(snrc.selector("prices()")):
return "0x" + snrc.encode_uint(0)
return "0x" + snrc.encode_uint(0)
return eth_call
def test_every_enum_value_has_a_code_and_a_sentence(self):
for code, (name, sentence) in snrc.RESERVED_REASONS.items():
snrc.eth_call = self._reserved_as(code)
reg = snrc.name_status("acme.testing")
self.assertEqual(reg["status"], "reserved", name)
self.assertEqual(reg["reasonCode"], name)
self.assertEqual(reg["reason"], sentence)
def test_a_trademark_reservation_says_so(self):
snrc.eth_call = self._reserved_as(2)
_, body = snrc.resolve("acme.testing")
self.assertEqual(body["reasonCode"], "trademark")
def test_a_controller_storing_a_bool_reads_as_unspecified(self):
"""Before the enum, `reservedNames` was a bool; its `true` decodes as 1,
which is the value this table already describes as unspecified."""
snrc.eth_call = self._reserved_as(1)
reg = snrc.name_status("acme.testing")
self.assertEqual(reg["reasonCode"], "unspecified")
self.assertEqual(reg["reason"], "reserved for a brand or public interest")
def test_an_enum_value_this_resolver_predates_is_not_dropped(self):
"""A controller upgraded with a new Reason still reports the name as
reserved; only the wording falls back."""
snrc.eth_call = self._reserved_as(99)
reg = snrc.name_status("acme.testing")
self.assertEqual(reg["status"], "reserved")
self.assertEqual(reg["reasonCode"], "unspecified")
def test_a_reserved_name_carries_the_reason(self):
snrc.eth_call = self._chain(0, True)
status, body = snrc.resolve("acme.testing")
@@ -484,6 +557,7 @@ class AuctionTests(unittest.TestCase):
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
self.now = int(time.time())
snrc.chain_now = lambda: self.now
snrc._auction_params.clear()
def tearDown(self):
(
@@ -576,10 +650,17 @@ class AuctionTests(unittest.TestCase):
self.assertEqual(snrc.name_status("acme.testing")["status"], "grace")
self.assertEqual(self.oracle_calls, [])
def test_a_live_name_never_reaches_the_oracle(self):
snrc.eth_call = self._chain(self.now + 3600)
self.assertEqual(snrc.name_status("acme.testing")["status"], "registered")
self.assertEqual(self.oracle_calls, [])
def test_the_oracle_curve_is_read_once_not_per_query(self):
"""The curve changes only when the owner retunes the auction, so only the
decaying premium is re-read; the rest would be four RPC calls per query."""
snrc.eth_call = self._chain(self._lapsed(1))
snrc.name_status("acme.testing")
seen_first = len(self.oracle_calls)
snrc.name_status("acme.testing")
self.assertEqual(
self.oracle_calls[seen_first:],
[snrc.selector("decayedPremium(uint256,uint256)")],
)
def test_a_reserved_lapsed_name_stays_reserved_rather_than_auctioned(self):
snrc.eth_call = self._chain(self._lapsed(0), reserved=2)
@@ -616,77 +697,6 @@ class AuctionTests(unittest.TestCase):
self.assertIsNotNone(body["premium"])
class ReasonCodeTests(unittest.TestCase):
"""The reason a name is held back is the controller's `Reason` enum, so the
app can word it in the user's language instead of showing a server string."""
REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b"
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
def setUp(self):
self._saved = (
snrc.REGISTRIES,
snrc.REGISTRARS,
snrc.CONTROLLERS,
snrc.eth_call,
snrc.chain_now,
)
snrc.REGISTRIES = {"testing": self.REGISTRY}
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
snrc.chain_now = lambda: int(time.time())
def tearDown(self):
(
snrc.REGISTRIES,
snrc.REGISTRARS,
snrc.CONTROLLERS,
snrc.eth_call,
snrc.chain_now,
) = self._saved
def _reserved_as(self, code):
def eth_call(to, data):
if data.startswith(snrc.selector("reservedNames(bytes32)")):
return "0x" + snrc.encode_uint(code)
if data.startswith(snrc.selector("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(90 * 86400)
if data.startswith(snrc.selector("prices()")):
return "0x" + snrc.encode_uint(0)
return "0x" + snrc.encode_uint(0)
return eth_call
def test_every_enum_value_has_a_code_and_a_sentence(self):
for code, (name, sentence) in snrc.RESERVED_REASONS.items():
snrc.eth_call = self._reserved_as(code)
reg = snrc.name_status("acme.testing")
self.assertEqual(reg["status"], "reserved", name)
self.assertEqual(reg["reasonCode"], name)
self.assertEqual(reg["reason"], sentence)
def test_a_trademark_reservation_says_so(self):
snrc.eth_call = self._reserved_as(2)
_, body = snrc.resolve("acme.testing")
self.assertEqual(body["reasonCode"], "trademark")
def test_a_controller_storing_a_bool_reads_as_unspecified(self):
"""Before the enum, `reservedNames` was a bool; its `true` decodes as 1,
which is the value this table already describes as unspecified."""
snrc.eth_call = self._reserved_as(1)
reg = snrc.name_status("acme.testing")
self.assertEqual(reg["reasonCode"], "unspecified")
self.assertEqual(reg["reason"], "reserved for a brand or public interest")
def test_an_enum_value_this_resolver_predates_is_not_dropped(self):
"""A controller upgraded with a new Reason still reports the name as
reserved; only the wording falls back."""
snrc.eth_call = self._reserved_as(99)
reg = snrc.name_status("acme.testing")
self.assertEqual(reg["status"], "reserved")
self.assertEqual(reg["reasonCode"], "unspecified")
class ErrorCodeTests(unittest.TestCase):
REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b"
@@ -704,6 +714,7 @@ class ErrorCodeTests(unittest.TestCase):
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": ""}
snrc.chain_now = lambda: int(time.time())
snrc._auction_params.clear()
def tearDown(self):
(
+21 -8
View File
@@ -168,7 +168,7 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON
import Simplex.Messaging.Protocol
import Simplex.Messaging.Protocol.Types
import Simplex.Messaging.Server.QueueStore.QueueInfo
import Simplex.Messaging.SimplexName (SimplexDomain)
import Simplex.Messaging.SimplexName (SimplexDomain, fullDomainName, hashedDomain)
import Simplex.Messaging.TMap (TMap)
import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Transport
@@ -1059,8 +1059,8 @@ proxySMPMessage c nm proxiedRelay spKey sId flags msg = proxyOKSMPCommand c nm p
proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameRecord)
proxyResolveName c nm proxiedRelay name
| prVersion proxiedRelay >= namesSMPVersion =
proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV name) >>= \case
Right (RNAME nr) -> pure $ Right nr
proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (queryDomain (prVersion proxiedRelay) name)) >>= \case
Right (RNAME nr) -> pure $ Right (namedFor name nr)
Right r -> throwE $ unexpectedResponse r
Left e -> pure $ Left e
| otherwise = throwE $ PCETransportError TEVersion
@@ -1072,11 +1072,24 @@ proxyResolveName c nm proxiedRelay name
-- encoder, so an old server never receives RSLV.
directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameRecord
directResolveName c nm name
| thVersion (thParams c) >= namesSMPVersion =
sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV name)) >>= \case
RNAME nr -> pure nr
| v >= namesSMPVersion =
sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (queryDomain v name))) >>= \case
RNAME nr -> pure (namedFor name nr)
r -> throwE $ unexpectedResponse r
| otherwise = throwE $ PCETransportError TEVersion
where
v = thVersion (thParams c)
-- | How a name travels to the router. From `nameAvailSMPVersion` the
-- second-level label is replaced by its hash, so the router answers about the
-- name without being told it; an older router can only parse the name itself.
queryDomain :: VersionSMP -> SimplexDomain -> SimplexDomain
queryDomain v d = if v >= nameAvailSMPVersion then hashedDomain d else d
-- | The record names whatever was asked for, which for a hashed query is the
-- hash, so the name the caller used is put back.
namedFor :: SimplexDomain -> NameRecord -> NameRecord
namedFor d nr = nr {nrName = fullDomainName d}
-- | Ask whether a name can be registered, over PFWD. Availability is a second
-- question about the same name rather than a variant of resolution, so it has
@@ -1084,7 +1097,7 @@ directResolveName c nm name
proxyNameAvailability :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameAvailability)
proxyNameAvailability c nm proxiedRelay name
| prVersion proxiedRelay >= nameAvailSMPVersion =
proxySMPCommand c nm proxiedRelay Nothing NoEntity (NAVL name) >>= \case
proxySMPCommand c nm proxiedRelay Nothing NoEntity (NAVL (hashedDomain name)) >>= \case
Right (NAVAIL a) -> pure $ Right a
Right r -> throwE $ unexpectedResponse r
Left e -> pure $ Left e
@@ -1095,7 +1108,7 @@ proxyNameAvailability c nm proxiedRelay name
directNameAvailability :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameAvailability
directNameAvailability c nm name
| thVersion (thParams c) >= nameAvailSMPVersion =
sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (NAVL name)) >>= \case
sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (NAVL (hashedDomain name))) >>= \case
NAVAIL a -> pure a
r -> throwE $ unexpectedResponse r
| otherwise = throwE $ PCETransportError TEVersion
+7 -4
View File
@@ -1496,10 +1496,13 @@ client
-- Runs on a forked thread so RSLV does not block other commands;
-- concurrency is limited by serverResolverConcurrency in forkCmd.
nameAvailMsg :: NamesEnv -> SimplexDomain -> M s BrokerMsg
nameAvailMsg nenv d =
liftIO (nameAvailability nenv d) <&> \case
Right a -> NAVAIL a
Left e -> ERR $ NAME e
nameAvailMsg nenv d = do
st <- asks (rslvStats . serverStats)
(selector, msg) <-
liftIO (nameAvailability nenv d) <&> \case
Right a -> (rslvSucc, NAVAIL a)
Left e -> (rslvResolverErrs, ERR $ NAME e)
incStat (selector st) $> msg
resolveNameMsg :: NamesEnv -> SimplexDomain -> M s BrokerMsg
resolveNameMsg nenv d = do
st <- asks (rslvStats . serverStats)
+28 -13
View File
@@ -18,7 +18,7 @@ where
import qualified Control.Exception as E
import Control.Logger.Simple (logError)
import Data.Bifunctor (bimap, first)
import Data.Bifunctor (first)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
@@ -88,23 +88,38 @@ nameAvailability env d = do
fetchAvail :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameAvailability)
fetchAvail NamesEnv {resolverEnv} d =
bimap mapResolverError mapAvailability <$> availabilityHttp resolverEnv (fullDomainName d)
either (Left . mapAvailError) mapAvailability <$> availabilityHttp resolverEnv (fullDomainName d)
-- | NAVL answers whether a name can be registered, so a resolver failure must
-- never look like an answer about the name: NOT_FOUND, which 'mapResolverError'
-- returns for 404/410/400, would read as "no such name, therefore free".
mapAvailError :: ResolverError -> NameErrorType
mapAvailError = \case
HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code))
e -> mapResolverError e
-- | The resolver's own vocabulary. A lapsed registration past its grace period
-- is available again; one still in grace belongs to its previous owner; one in
-- the auction that follows grace is registrable, but not at the usual price. A
-- status whose payload is missing is reported as taken - refusing a name the
-- user could have had is a smaller harm than quoting the wrong price for it.
mapAvailability :: NameStatusResp -> NameAvailability
-- the auction that follows grace is registrable, but not at the usual price.
-- Only the statuses that describe the name are answers - anything else means the
-- resolver could not answer, and saying "taken" to that would assert a
-- registration that was never read.
mapAvailability :: NameStatusResp -> Either NameErrorType NameAvailability
mapAvailability NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsAuctionEnds, nsPremium, nsReasonCode} = case nsStatus of
"unregistered" -> NAVailable
"expired" -> NAVailable
"grace" -> maybe taken NAInGrace nsGraceEnds
"auction" -> fromMaybe taken (NAAuction <$> nsPremium <*> nsAuctionEnds)
"reserved" -> NAReserved (maybe NRUnspecified mapReason nsReasonCode)
_ -> taken
"unregistered" -> Right NAVailable
"expired" -> Right NAVailable
"grace" -> Right $ maybe lapsed NAInGrace nsGraceEnds
"auction" -> Right $ fromMaybe lapsed (NAAuction <$> nsPremium <*> nsAuctionEnds)
"reserved" -> Right $ NAReserved (maybe NRUnspecified mapReason nsReasonCode)
"registered" -> Right $ NATaken nsExpires
-- registered, but its records point nowhere
"noResolver" -> Right $ NATaken nsExpires
s -> Left (RESOLVER s)
where
taken = NATaken nsExpires
-- A lapsed name missing the deadline or price that its status carries:
-- withholding it is safer than quoting the ordinary price, but its expiry is
-- in the past, so it is not "registered until" anything.
lapsed = NATaken Nothing
-- | The controller's reservation reasons, as the resolver spells them.
mapReason :: Text -> NameReservedReason
@@ -40,9 +40,11 @@ import qualified Data.Aeson.KeyMap as JKM
import Data.Bifunctor (first)
import qualified Data.ByteArray.Encoding as BAE
import Data.ByteString.Char8 (ByteString)
import Data.Char (isDigit)
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy as BL
import Data.Int (Int64)
import qualified Data.Text as T
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import Network.HTTP.Client
@@ -141,7 +143,7 @@ resolveHttp env name =
-- 200 and "error" otherwise, alongside the deadline or price that status
-- carries.
availabilityHttp :: ResolverEnv -> Text -> IO (Either ResolverError NameStatusResp)
availabilityHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro} name = do
availabilityHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseBytes} name = do
req0 <- parseRequest (baseUrl <> "/resolve/" <> B.unpack (urlEncode True (encodeUtf8 name)))
let req =
req0
@@ -152,22 +154,34 @@ availabilityHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro} name = do
result <- E.try $ withResponse req manager $ \res -> do
let status = HT.statusCode (responseStatus res)
field = if status < 400 then "status" else "error"
bs <- brReadSome (responseBody res) statusBodyBytes
pure $ case J.decode bs of
Just (J.Object o)
| Just (J.String t) <- JKM.lookup field o ->
Right
NameStatusResp
{ nsStatus = t,
nsExpires = jsonField o "expires",
nsGraceEnds = jsonField o "graceEnds",
nsAuctionEnds = jsonField o "auctionEnds",
nsPremium = jsonField o "premium",
nsReasonCode = jsonField o "reasonCode"
}
_ -> Left (HttpStatusErr status)
bs <- brReadSome (responseBody res) (maxResponseBytes + 1)
pure $
if BL.length bs > fromIntegral maxResponseBytes
then Left BodyTooLarge
else case J.decode bs of
Just (J.Object o)
| Just (J.String t) <- JKM.lookup field o ->
Right
NameStatusResp
{ nsStatus = t,
nsExpires = jsonField o "expires",
nsGraceEnds = jsonField o "graceEnds",
nsAuctionEnds = jsonField o "auctionEnds",
nsPremium = jsonField o "premium" >>= decimalPrice,
nsReasonCode = jsonField o "reasonCode"
}
_ -> Left (HttpStatusErr status)
pure (either (Left . HttpFailure) id result)
-- | A price is a 256-bit integer written in decimal, so at most 78 digits. The
-- wire format prefixes it with a single length byte, which would wrap silently
-- on a longer string and leave the whole response unparseable, so anything else
-- is dropped rather than re-encoded.
decimalPrice :: Text -> Maybe Text
decimalPrice t
| not (T.null t) && T.length t <= 78 && T.all isDigit t = Just t
| otherwise = Nothing
-- | A field the resolver omits, or sends as null, for the statuses that do not
-- carry it.
jsonField :: J.FromJSON a => J.Object -> Key -> Maybe a
@@ -175,10 +189,6 @@ jsonField o k = case J.fromJSON <$> JKM.lookup k o of
Just (J.Success v) -> Just v
_ -> Nothing
-- | Enough of a body to reach the status field; the rest is not read.
statusBodyBytes :: Int
statusBodyBytes = 4096
-- | GET <baseUrl>/health; success = reachable with status < 400. The body is
-- size-capped but NOT decoded — the probe only checks reachability.
healthHttp :: ResolverEnv -> IO (Either ResolverError ())
+40 -15
View File
@@ -10,14 +10,18 @@ module Simplex.Messaging.SimplexName
SimplexTLD (..),
SimplexNameType (..),
fullDomainName,
hashedDomain,
shortNameInfoStr,
)
where
import Control.Applicative (optional, (<|>))
import Crypto.Hash (Digest, hash)
import Crypto.Hash.Algorithms (Keccak_256)
import qualified Data.Aeson.TH as J
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.Attoparsec.Text as AT
import qualified Data.ByteArray.Encoding as BAE
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Char (isDigit)
@@ -57,22 +61,12 @@ instance StrEncoding SimplexNameType where
strP = A.char '#' $> NTPublicGroup <|> A.char '@' $> NTContact
nameLabelP :: AT.Parser Text
nameLabelP = labelhashP <|> do
nameLabelP = do
label <- T.intercalate "-" <$> AT.takeWhile1 (\c -> isNameLetter c || isDigit c) `AT.sepBy1` AT.char '-'
-- DNS label limit: each dot-separated component is at most 63 bytes (labels
-- are ASCII, so character count == byte count)
if T.length label > 63 then fail "name label exceeds 63 bytes" else pure label
where
-- A label given as its own keccak256 hash, so a client can ask whether a
-- name is taken without saying which name. ENS's encoding for a label whose
-- preimage is unknown: the brackets are outside the name character set, so
-- the form cannot collide with a registrable name, and the resolver reads
-- the hash as the registry key instead of hashing the label again. 66
-- characters, so it is exempt from the DNS limit above: it is a key into the
-- registry, not a DNS label.
labelhashP = do
hex <- AT.char '[' *> AT.takeWhile1 (\c -> isDigit c || c >= 'a' && c <= 'f') <* AT.char ']'
if T.length hex == 64 then pure ("[" <> hex <> "]") else fail "labelhash: expected 64 hex digits"
-- ASCII letters only. SNRC contracts hash byte sequences via keccak; ENS
-- uses UTS-46 + Punycode for IDN, which we do not implement. Admitting
-- Cyrillic / Greek / etc. via Data.Char.isAlpha would (a) make namehash
@@ -80,6 +74,31 @@ nameLabelP = labelhashP <|> do
-- (Cyrillic а vs ASCII a hash to different on-chain records).
isNameLetter c = c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
-- | A second-level label given as its own keccak256 hash, so a router never
-- learns the name it is asked about. ENS's encoding for a label whose preimage
-- is unknown: the brackets are outside the name character set, so the form
-- cannot collide with a registrable name, and the resolver reads the hash as the
-- registry key instead of hashing the label again. 66 characters, so it is
-- exempt from the DNS label limit: it is a key into the registry, not a label.
labelHashP :: AT.Parser Text
labelHashP = do
hex <- AT.char '[' *> AT.takeWhile1 (\c -> isDigit c || c >= 'a' && c <= 'f') <* AT.char ']'
if T.length hex == 64 then pure ("[" <> hex <> "]") else fail "labelhash: expected 64 hex digits"
isLabelHash :: Text -> Bool
isLabelHash t = T.length t == 66 && T.head t == '[' && T.last t == ']'
-- | The name with its second-level label replaced by that label's keccak256
-- hash, which is what the registry is keyed on - so a router can answer about
-- the name without being told it. Subname labels are left as text, as reaching
-- the record needs them, and a web TLD has no registry to key into.
hashedDomain :: SimplexDomain -> SimplexDomain
hashedDomain d@SimplexDomain {nameTLD, domain}
| nameTLD == TLDWeb || isLabelHash domain = d
| otherwise = d {domain = "[" <> labelHash <> "]"}
where
labelHash = decodeLatin1 $ BAE.convertToBase BAE.Base16 (hash (encodeUtf8 domain) :: Digest Keccak_256)
-- | Cap the name at 253 bytes (DNS full-domain limit)
boundedNonSpace :: A.Parser ByteString
boundedNonSpace = do
@@ -103,15 +122,21 @@ instance StrEncoding SimplexDomain where
strEncode = encodeUtf8 . fullDomainName
strP = parseDomain . safeDecodeUtf8 <$?> boundedNonSpace
where
parseDomain s = AT.parseOnly (nameLabelP `AT.sepBy1` AT.char '.' <* AT.endOfInput) s >>= mkDomain
parseDomain s = AT.parseOnly ((labelHashP <|> nameLabelP) `AT.sepBy1` AT.char '.' <* AT.endOfInput) s >>= mkDomain
mkDomain labels = case reverse lowered of
[] -> Left "empty name"
[_] -> Left "domain requires TLD"
"simplex" : name : sub -> Right (SimplexDomain TLDSimplex name sub)
"testing" : name : sub -> Right (SimplexDomain TLDTesting name sub)
_ -> Right (SimplexDomain TLDWeb (T.intercalate "." lowered) [])
"simplex" : name : sub -> registryDomain TLDSimplex name sub
"testing" : name : sub -> registryDomain TLDTesting name sub
_
| any isLabelHash lowered -> Left "labelhash requires a registry TLD"
| otherwise -> Right (SimplexDomain TLDWeb (T.intercalate "." lowered) [])
where
lowered = map T.toLower labels
-- Only the second-level label is a registry key, so only it may be hashed.
registryDomain tld name sub
| any isLabelHash sub = Left "only the second-level label may be a labelhash"
| otherwise = Right (SimplexDomain tld name sub)
instance Encoding SimplexDomain where
smpEncode = strEncode
+1
View File
@@ -172,6 +172,7 @@ smpBlockSize = 16384
-- 19 - service subscriptions to messages (10/20/2025)
-- 20 - public namespaces resolver, RSLV command (6/20/2026)
-- 21 - server public information in handshake (7/5/2026)
-- 22 - name availability (NAVL command, NAVAIL response)
data SMPVersion
+59 -1
View File
@@ -15,6 +15,7 @@ import Control.Monad.Trans.Except (ExceptT, runExceptT)
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy as LB
import Data.IORef (IORef, readIORef)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
@@ -59,6 +60,11 @@ withResolverServer (st, body) runTest =
NRS.withResolverServer (NRS.resolveResp st body) $ \port _ ->
withSmpServerConfigOn (transport @TLS) (withNames port memCfg) testPort (const runTest)
withResolverServerReqs :: (Status, LB.ByteString) -> (IORef [[Text]] -> IO a) -> IO a
withResolverServerReqs (st, body) runTest =
NRS.withResolverServer (NRS.resolveResp st body) $ \port reqs ->
withSmpServerConfigOn (transport @TLS) (withNames port memCfg) testPort (const (runTest reqs))
withProxyAndResolver :: (Status, LB.ByteString) -> IO a -> IO a
withProxyAndResolver (st, body) runTest =
NRS.withResolverServer (NRS.resolveResp st body) $ \port _ ->
@@ -99,6 +105,10 @@ rslvTests = do
it "no names config -> NAME NO_RESOLVER" testNavlDisabled
it "refuses to send NAVL on a session below nameAvailSMPVersion" testNavlVersion
it "PFWD-wrapped NAVL reaches the resolver via the proxy" testNavlForwarded
describe "hashed lookups" $ do
it "RSLV sends the second-level label as its hash, never the name" testRslvSendsTheHash
it "NAVL sends the second-level label as its hash, never the name" testNavlSendsTheHash
it "a subname keeps its own labels as text, hashing only the 2LD" testSubnameKeepsItsLabels
testRslvBackendNotFound :: IO ()
testRslvBackendNotFound =
@@ -214,7 +224,9 @@ testNavlVersion =
g <- C.newRandom
ts <- getCurrentTime
let srv = SMPServer testHost testPort testKeyHash
oldCfg = defaultSMPClientConfig {serverVRange = mkVersionRange minServerSMPRelayVersion rcvServiceSMPVersion}
-- the version immediately below the gate: a range ending lower would
-- also pass for a gate at 20 or 21 and prove nothing about v22
oldCfg = defaultSMPClientConfig {serverVRange = mkVersionRange minServerSMPRelayVersion serverInfoSMPVersion}
pcE <- getProtocolClient g NRMInteractive (1, srv, Nothing) oldCfg [] Nothing ts (\_ -> pure ())
pc <- either (fail . show) pure pcE
r <- runExceptT (directNameAvailability pc NRMInteractive (domain "alice.simplex"))
@@ -242,5 +254,51 @@ testNavlForwarded =
auctionBody :: LB.ByteString
auctionBody = "{\"error\":\"auction\",\"premium\":\"99999952316384526016153087\",\"auctionEnds\":1798191621}"
-- keccak-256("alice"), the key the registry is keyed on
aliceHash :: Text
aliceHash = "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
-- | A client on a current session must never put a registrable name on the
-- wire: the router answers about the hash and learns only that.
resolvePaths :: IORef [[Text]] -> IO [[Text]]
resolvePaths reqs = filter isResolve <$> readIORef reqs
where
isResolve = \case ("resolve" : _) -> True; _ -> False
currentClient :: IO SMPClient
currentClient = do
g <- C.newRandom
ts <- getCurrentTime
let srv = SMPServer testHost testPort testKeyHash
pcE <- getProtocolClient g NRMInteractive (1, srv, Nothing) defaultSMPClientConfig [] Nothing ts (\_ -> pure ())
either (fail . show) pure pcE
testRslvSendsTheHash :: IO ()
testRslvSendsTheHash =
withResolverServerReqs (status200, J.encode echoed) $ \reqs -> do
pc <- currentClient
nr <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex"))
resolvePaths reqs `shouldReturn` [["resolve", aliceHash <> ".simplex"]]
-- the record names what the caller asked for, not what went on the wire
SMP.nrName nr `shouldBe` "alice.simplex"
where
-- the resolver echoes the name it was asked about, which is the hash
echoed = testNameRecord {SMP.nrName = aliceHash <> ".simplex"}
testNavlSendsTheHash :: IO ()
testNavlSendsTheHash =
withResolverServerReqs (status404, "{\"error\":\"unregistered\"}") $ \reqs -> do
pc <- currentClient
a <- runExceptT' (directNameAvailability pc NRMInteractive (domain "alice.simplex"))
a `shouldBe` NAVailable
resolvePaths reqs `shouldReturn` [["resolve", aliceHash <> ".simplex"]]
testSubnameKeepsItsLabels :: IO ()
testSubnameKeepsItsLabels =
withResolverServerReqs (status404, "{\"error\":\"unregistered\"}") $ \reqs -> do
pc <- currentClient
_ <- runExceptT' (directNameAvailability pc NRMInteractive (domain "x.alice.simplex"))
resolvePaths reqs `shouldReturn` [["resolve", "x." <> aliceHash <> ".simplex"]]
runExceptT' :: Show e => ExceptT e IO a -> IO a
runExceptT' a = runExceptT a >>= either (fail . show) pure
+50 -3
View File
@@ -28,7 +28,7 @@ import Simplex.Messaging.Server.Names
resolveName,
)
import Simplex.Messaging.Server.Names.HttpResolver (ResolverError (..))
import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexTLD (..))
import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexTLD (..), fullDomainName, hashedDomain)
import Test.Hspec
testNameRecord :: NameRecord
@@ -132,6 +132,29 @@ availabilitySpec = do
answers status410 "{\"error\":\"grace\"}" (NATaken Nothing)
it "an auction without its price is reported as taken" $
answers status410 "{\"error\":\"auction\",\"auctionEnds\":1798191621}" (NATaken Nothing)
it "a registered name whose records point nowhere is still taken" $
answers status404 "{\"error\":\"noResolver\",\"expires\":1811232000}" (NATaken (Just 1811232000))
-- a price is a 256-bit integer in decimal; the wire length-prefixes it with one
-- byte, so a longer or non-numeric string is dropped rather than re-encoded
it "a premium too long to encode is not quoted" $
answers status410 (jsonBody ("{\"error\":\"auction\",\"premium\":\"" <> replicate 300 '9' <> "\",\"auctionEnds\":1798191621}")) (NATaken Nothing)
it "a premium that is not a decimal integer is not quoted" $
answers status410 "{\"error\":\"auction\",\"premium\":\"1e26\",\"auctionEnds\":1798191621}" (NATaken Nothing)
-- a resolver that could not answer must not be reported as an answer: saying
-- TAKEN would assert a registration nobody read, and NOT_FOUND would read as
-- "no such name, therefore free"
it "an upstream RPC failure is a resolver error, not a taken name" $
refuses status502 "{\"error\":\"upstreamError\"}" (RESOLVER "upstreamError")
it "a TLD this resolver has no registry for is a resolver error" $
refuses status400 "{\"error\":\"tldNotConfigured\"}" (RESOLVER "tldNotConfigured")
it "a TLD with no registrar, so status could not be read, is a resolver error" $
refuses status200 "{\"status\":\"unknown\",\"expires\":null}" (RESOLVER "unknown")
it "a body that is not the resolver's JSON is never NOT_FOUND" $
refuses status404 "<html>gateway</html>" (RESOLVER "HTTP 404")
it "a body past the configured cap is a resolver error" $
withResolverServer (resolveResp status200 (jsonBody ("{\"status\":\"registered\",\"pad\":\"" <> replicate 400 'x' <> "\"}"))) $ \port _ -> do
env <- newNamesEnv (testNamesConfig port) {resolverMaxResponseBytes = 200}
nameAvailability env navlDomain `shouldReturn` Left (RESOLVER "response too large")
it "every answer survives the wire" $
mapM_
(\a -> smpDecode (smpEncode a) `shouldBe` Right a)
@@ -148,10 +171,13 @@ availabilitySpec = do
NAReserved NRPremium
]
where
answers st body expected =
jsonBody = LB.fromStrict . B.pack
answers st body expected = asks_ st body (Right expected)
refuses st body err = asks_ st body (Left err)
asks_ st body expected =
withResolverServer (resolveResp st body) $ \port _ -> do
env <- newNamesEnv (testNamesConfig port)
nameAvailability env navlDomain `shouldReturn` Right expected
nameAvailability env navlDomain `shouldReturn` expected
navlDomain = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []}
parseNameSpec :: Spec
@@ -169,6 +195,27 @@ parseNameSpec = do
it "keeps the brackets, which are what the resolver reads as a hash" $
(strEncode <$> parseN ("[" <> T.replicate 64 "b" <> "].simplex"))
`shouldBe` Right (encodeUtf8 ("[" <> T.replicate 64 "b" <> "].simplex"))
-- only the second-level label is a registry key, so only it may be hashed;
-- a subname label is needed as text to reach the record
it "accepts a hashed second-level label under a subname" $
parseN ("x.[" <> T.replicate 64 "b" <> "].simplex") `shouldSatisfy` isRight
it "refuses a hashed subname label" $
parseN ("[" <> T.replicate 64 "b" <> "].alice.simplex") `shouldSatisfy` isLeft
it "refuses a labelhash under a web TLD, which has no registry" $
parseN ("[" <> T.replicate 64 "b" <> "].com") `shouldSatisfy` isLeft
-- the hash the client sends must be the one the resolver keys on: this is
-- keccak-256("alice"), the same constant the resolver's own tests use
it "hashes the second-level label to the registry key" $
(fullDomainName . hashedDomain <$> parseN "alice.simplex")
`shouldBe` Right "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex"
it "leaves subname labels as text" $
(fullDomainName . hashedDomain <$> parseN "x.alice.simplex")
`shouldBe` Right "x.[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex"
it "leaves a web name alone, as it has no registry to key into" $
(fullDomainName . hashedDomain <$> parseN "example.com") `shouldBe` Right "example.com"
it "does not hash a name that is already a hash" $
(fullDomainName . hashedDomain . hashedDomain <$> parseN "alice.simplex")
`shouldBe` Right "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex"
it "accepts a valid simplex-TLD name" $
case parseN "privacy.simplex" of
Right d -> do