From e3f00b46f6668fffce11951834399736fdfb6612 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sat, 5 Sep 2026 16:53:42 +0200 Subject: [PATCH 01/27] extend SMP protocol to support name availability queries with accurate and meaningful replies --- .gitignore | 1 + protocol/simplex-messaging.md | 81 ++++++ scripts/resolver/README.md | 71 ++++- scripts/resolver/service/snrc-resolve.py | 100 ++++++- scripts/resolver/service/test_snrc_resolve.py | 266 +++++++++++++++++- src/Simplex/Messaging/Agent.hs | 11 + src/Simplex/Messaging/Agent/Client.hs | 11 + src/Simplex/Messaging/Client.hs | 24 ++ src/Simplex/Messaging/Protocol.hs | 81 ++++++ src/Simplex/Messaging/Server.hs | 17 +- src/Simplex/Messaging/Server/Names.hs | 53 +++- .../Messaging/Server/Names/HttpResolver.hs | 62 ++++ src/Simplex/Messaging/SimplexName.hs | 12 +- src/Simplex/Messaging/Transport.hs | 20 +- tests/RSLVTests.hs | 79 ++++++ tests/SMPNamesTests.hs | 70 ++++- 16 files changed, 915 insertions(+), 44 deletions(-) diff --git a/.gitignore b/.gitignore index 9d27c4ccb8..9550e48c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ cabal.project.local~ *.tix .coverage +__pycache__/ diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index be7bd448fc..df3f4aa24e 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -108,6 +108,7 @@ This document describes SMP protocol version 20. Versions 1-5 are discontinued. - v19: service subscriptions to messages (SUBS, NSUBS, SOKS, ENDS, ALLS commands) - v20: public namespaces resolver (RSLV command, RNAME response) — direct or forwarded via PFWD - v21: server public information in handshake +- v22: name availability (NAVL command, NAVAIL response) ## Introduction @@ -1468,6 +1469,19 @@ 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 +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. + **Server-side validation.** The names router parses `domain` as a fully-qualified name (TLD required — bare labels are rejected) and forwards it to the configured backing resolver, which is the source of truth for which @@ -1543,6 +1557,73 @@ re-encoded `RNAME` stays within the SMP proxied transmission budget of 16224 bytes; a response over the cap is rejected as `ERR NAME RESOLVER`. The link arrays are bounded by this overall budget rather than a fixed per-field count. +#### Name availability command + +`RSLV` answers with a record or `NOT_FOUND`, which conflates situations a client +offering a name to register must tell apart: a name nobody has registered, a +lapsed registration its previous owner may still renew, a name the registry +holds back, and a name registrable right now but not at the ordinary price. +`NAVL` asks that question directly, and takes the same `domain` payload as +`RSLV`, hashed labels included: + +```abnf +navl = %s"NAVL" SP domain +``` + +The names router answers `NAVAIL` with exactly one of: + +```abnf +navail = %s"NAVAIL" SP availability +availability = %s"AVAILABLE" + / %s"TAKEN" SP optExpires + / %s"GRACE" SP grace-ends + / %s"AUCTION" SP premium auction-ends + / %s"RESERVED" SP reason +optExpires = %s"0" / (%s"1" expires) ; absent when the router could not read the registration +expires = 8*8 OCTET ; as grace-ends +grace-ends = 8*8 OCTET ; Int64, network byte order (big-endian), seconds since the Unix epoch +auction-ends = 8*8 OCTET ; as grace-ends, and follows premium with no separator +premium = shortString ; ASCII decimal integer, in attoUSD (1e-18 USD) +reason = %s"UNSPECIFIED" / %s"TRADEMARK" / %s"PUBLIC_INTEREST" + / %s"OFFENSIVE" / %s"INTERNAL" / %s"PREMIUM" +``` + +| 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 | +| `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` | + +`premium` is a decimal string rather than a wire integer because registry prices +are 256-bit values that fit no fixed-width integer. It is the surcharge alone, +not the total: a router answering a hashed query cannot know the label's length +and so cannot know its ordinary price. The client, which knows the name it +hashed, adds the base price itself. + +All three times are absolute rather than remaining durations, so a client can +render a countdown without re-querying. A client whose clock is wrong renders a wrong +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. + +`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 +`UNSPECIFIED` rather than as "not reserved". + +`NAVL` fails the way `RSLV` does — `ERR NAME NO_RESOLVER` when the router has no +resolver, `ERR NAME RESOLVER ` 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. + ## Transport connection with the SMP router ### General transport protocol considerations diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 97126c7be8..56c4aa5c2a 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -123,9 +123,13 @@ uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + main "simplexChannel": [], "eth": null, "btc": "bc1q…", "xmr": "4ANz…", "dot": "139G…", "owner": "0xd83b…", "resolver": "0x80fa…", - "status": "registered", // registered | grace | expired | unregistered | reserved | noResolver | unknown + "status": "registered", // registered | grace | auction | expired | unregistered | reserved | noResolver | unknown "expires": 1780000000, // Unix seconds; when the registration ends - "graceEnds": 1787776000 // expires + GRACE_PERIOD; last moment the owner can renew + "graceEnds": 1787776000, // expires + GRACE_PERIOD; last moment the owner can renew + "auctionEnds": null, // when the premium reaches zero; only on `auction` + "premium": null, // decimal string, attoUSD; only on `auction` + "reasonCode": null, // only on `reserved` + "reason": null // only on `reserved` } ``` @@ -147,9 +151,10 @@ name already knows when it expires. Both timestamps are Unix seconds, and |---|---| | `registered` | live; `expires` is when that ends | | `grace` | lapsed, but only the previous owner may renew it, until `graceEnds` | -| `expired` | lapsed and past grace — anyone may register it now | +| `auction` | past grace, so anyone may register it — but at a premium, until `auctionEnds` | +| `expired` | lapsed, past grace, and past the auction — anyone may register it at the ordinary price | | `unregistered` | never registered, and free to take | -| `reserved` | not registered, and held back — registration will be refused; the body carries a `reason` | +| `reserved` | not registered, and held back — registration will be refused; the body carries `reasonCode` and `reason` | | `noResolver` | registered, but points nowhere | | `unknown` | no `SNRC_REGISTRAR_` configured, so status could not be read | @@ -165,6 +170,51 @@ released*. A subname reports the status of the 2LD above it, which is only as good as the name it sits under. +### The post-grace auction + +When grace ends the registrar will sell the name to anyone, but the price +oracle adds a premium that halves each day until it reaches zero. A name in +that window reports `auction` rather than `expired`, with `premium` (a decimal +string of attoUSD, because the value is a 256-bit integer that no JSON number +can hold) and `auctionEnds`. + +The premium depends only on when the registration lapsed, never on the label, +so it is answerable for a labelhash query too. The base price is not: it depends +on the label's length, which a hashed query does not carry. `premium` is +therefore the surcharge alone, and a client that knows its own name adds the +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. + +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 +the ordinary price" — the price the registrar actually charges is still the +premium one, so the quote is wrong until the resolver is current. + +### Why a name is reserved + +`reserved` carries both `reasonCode`, the controller's own reservation reason, +and `reason`, an English sentence for a human reading the REST API. Clients +should branch on `reasonCode` and word it themselves, so the wording follows the +user's language rather than the server's. + +| `reasonCode` | Meaning | +|---|---| +| `unspecified` | reserved, with no reason recorded on chain | +| `trademark` | reserved to protect a trademark | +| `publicInterest` | reserved in the public interest | +| `offensive` | reserved as an offensive name | +| `internal` | reserved for SimpleX | +| `premium` | reserved as a premium name | + +A controller deployed before reservation reasons existed stores a plain boolean, +whose `true` reads back as `unspecified`, so nothing needs migrating. A code +this resolver does not know also reads as `unspecified` — the name stays +reserved either way. + ### Querying by labelhash A client asking whether a name is free is usually about to register it, and @@ -181,10 +231,11 @@ 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. -Read the answer from `status`. A name is free only when the body says -`unregistered`, which comes with a 404. Every other status means somebody holds -the name or held it recently. Watch out for `noResolver`: it is also a 404, but -the name is taken. +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 +the name or the registry holds it back. Watch out for `noResolver`: it is also a +404, but the name is taken. The hash must be keccak-256. `openssl dgst -sha3-256` and `sha3sum` compute SHA3-256, a different function that returns 64 valid-looking hex characters @@ -222,7 +273,7 @@ which is free to change. ``` The codes are `tldNotConfigured`, `notFullyQualified`, `unregistered`, -`reserved`, `grace`, `expired`, `noResolver`, `noSuchRoute` and +`reserved`, `grace`, `auction`, `expired`, `noResolver`, `noSuchRoute` and `upstreamError`. When the registration is what went wrong, `error` and `status` hold the same value, so one field is enough to read. @@ -237,7 +288,7 @@ and urlopen puts the URL it failed on into the message. | 200 | resolved (`status` is `registered`, or `unknown` when no registrar is configured) | | 400 | TLD not configured, or not a fully-qualified name | | 404 | `unregistered`, `reserved` or `noResolver` — the `status` field says which | -| 410 | registration lapsed — `status` says whether the owner can still renew (`grace`) or anyone may take it (`expired`) | +| 410 | registration lapsed — `status` says whether the owner can still renew (`grace`), anyone may take it at a premium (`auction`), or anyone may take it at the ordinary price (`expired`) | | 502 | upstream RPC error / reth not synced | ### Configuring addresses diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index cdadc1f487..92c54d89fc 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -41,7 +41,8 @@ (default: empty — TLD not yet deployed) SNRC_REGISTRAR_ BaseRegistrar (ERC-721) for the TLD; expiry and status (default: mainnet for .testing, empty for .simplex) - SNRC_CONTROLLER_ SimplexController (proxy) for the TLD; `reserved` status + SNRC_CONTROLLER_ SimplexController (proxy) for the TLD; `reserved` status, + and through its `prices()` oracle the post-grace auction (default: mainnet for .testing, empty for .simplex) SNRC_PORT Listen port (default: 8000) SNRC_BIND Bind address (default: 0.0.0.0) @@ -103,8 +104,17 @@ "simplex": os.environ.get("SNRC_CONTROLLER_SIMPLEX", ""), # not deployed yet } -# `reservedNames` stores the fact only, never a reason. -RESERVED_REASON = "reserved for a brand or public interest" +# `reservedNames` maps a name to SimplexController.Reason; 0 (None) means the +# name is not reserved. A controller predating the enum stores a bool, whose +# `true` decodes as 1 - the same "unspecified" this table already describes. +RESERVED_REASONS = { + 1: ("unspecified", "reserved for a brand or public interest"), + 2: ("trademark", "reserved to protect a trademark"), + 3: ("publicInterest", "reserved in the public interest"), + 4: ("offensive", "reserved as an offensive name"), + 5: ("internal", "reserved for SimpleX"), + 6: ("premium", "reserved as a premium name"), +} # SLIP-44 coin types (https://github.com/satoshilabs/slips/blob/master/slip-0044.md) COIN_ETH = 60 @@ -194,12 +204,43 @@ def expiry_status(expires: int, grace: int, now: int) -> str: return "expired" -def is_reserved(tld: str, token: int) -> bool: +def reservation_reason(tld: str, token: int) -> int: + """The SimplexController.Reason held for the name, 0 when not reserved.""" controller = CONTROLLERS.get(tld) if not controller: - return False + return 0 raw = eth_call(controller, selector("reservedNames(bytes32)") + encode_uint(token)) - return decode_uint(raw) != 0 + return decode_uint(raw) + + +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()"))) + if oracle == ZERO_ADDR: + return None, None + ends = grace_ends + decode_uint(eth_call(oracle, selector("totalDays()"))) * 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( + eth_call( + oracle, + selector("decayedPremium(uint256,uint256)") + + encode_uint(start) + + encode_uint(now - grace_ends), + ) + ) + return ends, max(decayed - floor, 0) def name_status(name: str): @@ -207,7 +248,15 @@ def name_status(name: str): tld = labels[-1] registrar = REGISTRARS.get(tld) if not registrar or len(labels) < 2: - return {"status": "unknown", "expires": None, "graceEnds": None} + return { + "status": "unknown", + "expires": None, + "graceEnds": None, + "auctionEnds": None, + "premium": None, + "reasonCode": None, + "reason": None, + } # nameExpires and reservedNames are keyed on uint256(keccak(label)). # Decoded for a 2LD only, the same rule node_of applies to the node. @@ -220,18 +269,30 @@ def name_status(name: str): eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token)) ) if expires == 0: - status, grace = "unregistered", 0 + status, grace, now = "unregistered", 0, 0 else: grace = grace_period(registrar) - status = expiry_status(expires, grace, chain_now()) - - if status in ("unregistered", "expired") and is_reserved(tld, token): - status = "reserved" + now = chain_now() + status = expiry_status(expires, grace, now) + + auction_ends = premium = reason = None + if status in ("unregistered", "expired"): + code = reservation_reason(tld, token) + if code: + status, reason = "reserved", RESERVED_REASONS.get(code, RESERVED_REASONS[1]) + elif status == "expired": + auction_ends, premium = auction(tld, expires + grace, now) + if auction_ends: + status = "auction" return { "status": status, "expires": expires or None, "graceEnds": (expires + grace) if expires else None, + "auctionEnds": auction_ends, + "premium": None if premium is None else str(premium), + "reasonCode": reason[0] if reason else None, + "reason": reason[1] if reason else None, } @@ -553,10 +614,11 @@ def resolve(name: str): ), } if reg["status"] == "reserved": - body["reason"] = RESERVED_REASON + body["reasonCode"] = reg["reasonCode"] + body["reason"] = reg["reason"] return 404, body - if reg["status"] in ("grace", "expired"): - return 410, { + if reg["status"] in ("grace", "expired", "auction"): + body = { "name": name, "status": reg["status"], "expires": reg["expires"], @@ -568,6 +630,14 @@ def resolve(name: str): else "this registration expired and is open to anyone" ), } + if reg["status"] == "auction": + body["auctionEnds"] = reg["auctionEnds"] + body["premium"] = reg["premium"] + body["message"] = ( + "this registration expired and is open to anyone, at a premium " + "that decays to zero" + ) + return 410, body resolver_raw = eth_call(registry, selector("resolver(bytes32)") + node_hex) resolver_addr = decode_address(resolver_raw) diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 16c82f6101..30ad640aec 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -197,6 +197,18 @@ def eth_call(to, data): return eth_call + def _keys(self, status, expires, grace_ends): + """Every branch answers with the same keys; only some carry a value.""" + return { + "status": status, + "expires": expires, + "graceEnds": grace_ends, + "auctionEnds": None, + "premium": None, + "reasonCode": None, + "reason": None, + } + def setUp(self): self._saved = ( snrc.REGISTRARS, @@ -236,7 +248,7 @@ def test_zero_expiry_means_never_registered(self): snrc.eth_call = self._expiry(0) self.assertEqual( snrc.name_status("alice.testing"), - {"status": "unregistered", "expires": None, "graceEnds": None}, + self._keys("unregistered", None, None), ) def test_recently_expired_is_in_grace_and_says_when_it_ends(self): @@ -244,7 +256,7 @@ def test_recently_expired_is_in_grace_and_says_when_it_ends(self): snrc.eth_call = self._expiry(past) self.assertEqual( snrc.name_status("alice.testing"), - {"status": "grace", "expires": past, "graceEnds": past + self.GRACE}, + self._keys("grace", past, past + self.GRACE), ) def test_past_the_grace_window_it_is_expired_and_claimable(self): @@ -263,7 +275,7 @@ def test_future_expiry_is_registered(self): snrc.eth_call = self._expiry(future) self.assertEqual( snrc.name_status("alice.testing"), - {"status": "registered", "expires": future, "graceEnds": future + self.GRACE}, + self._keys("registered", future, future + self.GRACE), ) def test_never_registered_is_not_confused_with_claimable(self): @@ -290,11 +302,19 @@ def test_unconfigured_tld_is_unknown_rather_than_unregistered(self): snrc.eth_call = lambda *a: self.fail("must not reach the chain") self.assertEqual( snrc.name_status("alice.testing"), - {"status": "unknown", "expires": None, "graceEnds": None}, + self._keys("unknown", None, None), ) def test_every_branch_returns_the_same_keys(self): - keys = {"status", "expires", "graceEnds"} + keys = { + "status", + "expires", + "graceEnds", + "auctionEnds", + "premium", + "reasonCode", + "reason", + } snrc.eth_call = self._expiry(0) self.assertEqual(set(snrc.name_status("alice.testing")), keys) snrc.eth_call = self._expiry(int(time.time()) + 3600) @@ -324,6 +344,8 @@ def eth_call(to, data): return "0x" + snrc.encode_uint(1 if reserved else 0) 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) # no price oracle, no auction return "0x" + snrc.encode_uint(expires) return eth_call @@ -394,6 +416,8 @@ def eth_call(to, data): return "0x" + snrc.encode_uint(1 if reserved else 0) 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) # no price oracle, no auction return "0x" + snrc.encode_uint(expires) return eth_call @@ -432,6 +456,238 @@ def test_a_hashed_query_gets_the_reason_too(self): self.assertEqual(body["reason"], "reserved for a brand or public interest") +class AuctionTests(unittest.TestCase): + """Once grace ends the registrar will sell the name to anyone, but the price + oracle adds a premium that halves each day until it reaches zero. Reporting + such a name as plainly available would quote the normal price for it.""" + + REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" + ORACLE = "0x1e0c9a2b9d1a4c8f7b3e5d6a9c2f4b8e1d7a3c50" + + GRACE = 90 * 86400 + # The values .testing is deployed with: $100M, halving daily for 21 days. + START_PREMIUM = 10 ** 26 + TOTAL_DAYS = 21 + + 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} + self.now = int(time.time()) + snrc.chain_now = lambda: self.now + + def tearDown(self): + ( + snrc.REGISTRIES, + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + snrc.chain_now, + ) = self._saved + + def _chain(self, expires, total_days=TOTAL_DAYS, oracle=None, reserved=0): + """Answers as SimplexController and SimplexPriceOracle do, including the + oracle's own `decayedPremium` shift, so the arithmetic under test is the + resolver's and not a second copy of the decay curve.""" + oracle = self.ORACLE if oracle is None else oracle + self.oracle_calls = [] + + def eth_call(to, data): + if data.startswith(snrc.selector("nameExpires(uint256)")): + return "0x" + snrc.encode_uint(expires) + if data.startswith(snrc.selector("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(self.GRACE) + if data.startswith(snrc.selector("reservedNames(bytes32)")): + return "0x" + snrc.encode_uint(reserved) + if data.startswith(snrc.selector("prices()")): + self.assertEqual(to, self.CONTROLLER) + return "0x" + snrc.encode_uint(int(oracle, 16)) + self.oracle_calls.append(data[:10]) + self.assertEqual(to, oracle) + if data.startswith(snrc.selector("totalDays()")): + return "0x" + snrc.encode_uint(total_days) + if data.startswith(snrc.selector("startPremium()")): + return "0x" + snrc.encode_uint(self.START_PREMIUM) + if data.startswith(snrc.selector("endValue()")): + return "0x" + snrc.encode_uint(self.START_PREMIUM >> total_days) + if data.startswith(snrc.selector("decayedPremium(uint256,uint256)")): + start = int(data[10:74], 16) + elapsed = int(data[74:138], 16) + return "0x" + snrc.encode_uint(start >> (elapsed // 86400)) + return self.fail("unexpected call " + data[:10]) + + return eth_call + + def _lapsed(self, days_into_auction): + """An expiry whose grace ended `days_into_auction` days ago. The extra + second clears the boundary, which the registrar counts as still in + grace.""" + return self.now - self.GRACE - 1 - days_into_auction * 86400 + + def test_a_name_just_past_grace_is_in_auction_not_merely_expired(self): + snrc.eth_call = self._chain(self._lapsed(0)) + reg = snrc.name_status("acme.testing") + self.assertEqual(reg["status"], "auction") + self.assertEqual( + reg["premium"], str(self.START_PREMIUM - (self.START_PREMIUM >> self.TOTAL_DAYS)) + ) + + def test_the_auction_ends_a_full_window_after_grace(self): + expires = self._lapsed(0) + snrc.eth_call = self._chain(expires) + reg = snrc.name_status("acme.testing") + self.assertEqual(reg["graceEnds"], expires + self.GRACE) + self.assertEqual( + reg["auctionEnds"], expires + self.GRACE + self.TOTAL_DAYS * 86400 + ) + + def test_the_premium_halves_each_day(self): + snrc.eth_call = self._chain(self._lapsed(3)) + reg = snrc.name_status("acme.testing") + floor = self.START_PREMIUM >> self.TOTAL_DAYS + self.assertEqual(reg["premium"], str((self.START_PREMIUM >> 3) - floor)) + + def test_past_the_window_prices_are_back_to_normal(self): + snrc.eth_call = self._chain(self._lapsed(self.TOTAL_DAYS)) + reg = snrc.name_status("acme.testing") + self.assertEqual(reg["status"], "expired") + self.assertIsNone(reg["premium"]) + self.assertIsNone(reg["auctionEnds"]) + + def test_a_zero_day_window_switches_the_auction_off(self): + snrc.eth_call = self._chain(self._lapsed(0), total_days=0) + self.assertEqual(snrc.name_status("acme.testing")["status"], "expired") + + def test_a_controller_with_no_oracle_leaves_the_name_merely_expired(self): + snrc.eth_call = self._chain(self._lapsed(0), oracle=snrc.ZERO_ADDR) + self.assertEqual(snrc.name_status("acme.testing")["status"], "expired") + + def test_a_name_in_grace_never_reaches_the_oracle(self): + snrc.eth_call = self._chain(self.now - 3600) + 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_a_reserved_lapsed_name_stays_reserved_rather_than_auctioned(self): + snrc.eth_call = self._chain(self._lapsed(0), reserved=2) + reg = snrc.name_status("acme.testing") + self.assertEqual(reg["status"], "reserved") + self.assertIsNone(reg["premium"]) + + def test_resolve_reports_the_auction_with_its_price_and_deadline(self): + expires = self._lapsed(1) + snrc.eth_call = self._chain(expires) + status, body = snrc.resolve("acme.testing") + self.assertEqual(status, 410) + self.assertEqual(body["status"], "auction") + floor = self.START_PREMIUM >> self.TOTAL_DAYS + self.assertEqual(body["premium"], str((self.START_PREMIUM >> 1) - floor)) + self.assertEqual( + body["auctionEnds"], expires + self.GRACE + self.TOTAL_DAYS * 86400 + ) + + def test_an_expired_name_past_the_window_carries_no_auction_fields(self): + snrc.eth_call = self._chain(self._lapsed(self.TOTAL_DAYS)) + status, body = snrc.resolve("acme.testing") + self.assertEqual(status, 410) + self.assertEqual(body["status"], "expired") + self.assertNotIn("premium", body) + self.assertNotIn("auctionEnds", body) + + def test_a_hashed_query_is_priced_too(self): + # keccak-256("acme") + hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]" + snrc.eth_call = self._chain(self._lapsed(0)) + _, body = snrc.resolve(hashed + ".testing") + self.assertEqual(body["status"], "auction") + 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" REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 979704fd39..4ab436cf41 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -67,6 +67,7 @@ module Simplex.Messaging.Agent deleteConnShortLink, getConnShortLink, resolveSimplexName, + simplexNameAvailability, getConnLinkPrivKey, deleteLocalInvShortLink, changeConnectionUser, @@ -226,6 +227,7 @@ import Simplex.Messaging.Protocol ErrorType (AUTH), MsgBody, MsgFlags (..), + NameAvailability, NameRecord, NtfServer, ProtoServerWithAuth (..), @@ -463,6 +465,10 @@ resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDoma resolveSimplexName c nm userId domain = withAgentEnv c $ resolveSimplexName' c nm userId domain {-# INLINE resolveSimplexName #-} +simplexNameAvailability :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameAvailability +simplexNameAvailability c nm userId domain = withAgentEnv c $ simplexNameAvailability' c nm userId domain +{-# INLINE simplexNameAvailability #-} + getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) getConnLinkPrivKey c = withAgentEnv c . getConnLinkPrivKey' c {-# INLINE getConnLinkPrivKey #-} @@ -1273,6 +1279,11 @@ resolveSimplexName' c nm userId domain = do resolverSrv <- getNextNameServer c userId resolveName c nm userId resolverSrv domain +simplexNameAvailability' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameAvailability +simplexNameAvailability' c nm userId domain = do + resolverSrv <- getNextNameServer c userId + nameAvailability c nm userId resolverSrv domain + changeConnectionUser' :: AgentClient -> UserId -> ConnId -> UserId -> AM () changeConnectionUser' c oldUserId connId newUserId = do SomeConn _ conn <- withStore c (`getConn` connId) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 45e7695b88..7d51ed3e39 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -69,6 +69,7 @@ module Simplex.Messaging.Agent.Client secureGetQueueLink, getQueueLink, resolveName, + nameAvailability, getNextNameServer, enableQueueNotifications, EnableQueueNtfReq (..), @@ -269,6 +270,7 @@ import Simplex.Messaging.Protocol NetworkError (..), MsgFlags (..), MsgId, + NameAvailability, NameRecord, NtfServer, NtfServerWithAuth, @@ -2000,6 +2002,15 @@ resolveName c nm userId server domain = resolveViaProxy smp proxySess = proxyResolveName smp nm proxySess domain resolveDirectly smp = directResolveName smp nm domain +-- | Ask whether a name can be registered, by the same proxy-preferred path as +-- `resolveName`. +nameAvailability :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameAvailability +nameAvailability c nm userId server domain = + snd <$> sendOrProxySMPCommand c nm userId server "" "NAVL" NoEntity availViaProxy availDirectly + where + availViaProxy smp proxySess = proxyNameAvailability smp nm proxySess domain + availDirectly smp = directNameAvailability smp nm domain + -- | Pick a names-capable server for the user (the agent owns server selection, -- accounting for the names role). nameSrvs is opt-in (a plain list); empty means -- no server resolves names - a declared agent error, never a fallback. diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 6f5234558b..93c8a035dd 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -75,6 +75,8 @@ module Simplex.Messaging.Client proxySMPMessage, proxyResolveName, directResolveName, + proxyNameAvailability, + directNameAvailability, forwardSMPTransmission, getSMPQueueInfo, sendProtocolCommand, @@ -1076,6 +1078,28 @@ directResolveName c nm name r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion +-- | 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 +-- its own command and its own version gate. +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 + Right (NAVAIL a) -> pure $ Right a + Right r -> throwE $ unexpectedResponse r + Left e -> pure $ Left e + | otherwise = throwE $ PCETransportError TEVersion + +-- | Direct (non-PFWD) availability query, exposing the client IP to the +-- resolver exactly as `directResolveName` does. +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 + NAVAIL a -> pure a + r -> throwE $ unexpectedResponse r + | otherwise = throwE $ PCETransportError TEVersion + -- | Acknowledge message delivery (server deletes the message). -- -- https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#acknowledge-message-delivery diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index a28d78fe61..97632e71d2 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -80,6 +80,8 @@ module Simplex.Messaging.Protocol ErrorType (..), CommandError (..), ProxyError (..), + NameAvailability (..), + NameReservedReason (..), NameErrorType (..), BrokerErrorType (..), NetworkError (..), @@ -604,6 +606,9 @@ data Command (p :: Party) where RFWD :: EncFwdTransmission -> Command ProxyService -- use CorrId as CbNonce, proxy to relay -- Resolve SimpleX name. RSLV :: SimplexDomain -> Command Resolver + -- Whether a SimpleX name can be registered. Asked of a labelhash when the + -- client does not want to say which name it is about. + NAVL :: SimplexDomain -> Command Resolver deriving instance Show (Command p) @@ -741,6 +746,7 @@ data BrokerMsg where PONG :: BrokerMsg -- Resolved SimpleX name. RNAME :: NameRecord -> BrokerMsg + NAVAIL :: NameAvailability -> BrokerMsg deriving (Eq, Show) data RcvMessage = RcvMessage @@ -952,6 +958,7 @@ data CommandTag (p :: Party) where NSUB_ :: CommandTag Notifier NSUBS_ :: CommandTag NotifierService RSLV_ :: CommandTag Resolver + NAVL_ :: CommandTag Resolver data CmdTag = forall p. PartyI p => CT (SParty p) (CommandTag p) @@ -979,6 +986,7 @@ data BrokerMsgTag | ERR_ | PONG_ | RNAME_ + | NAVAIL_ deriving (Show) class ProtocolMsgTag t where @@ -1016,6 +1024,7 @@ instance PartyI p => Encoding (CommandTag p) where NSUB_ -> "NSUB" NSUBS_ -> "NSUBS" RSLV_ -> "RSLV" + NAVL_ -> "NAVL" smpP = messageTagP instance ProtocolMsgTag CmdTag where @@ -1045,6 +1054,7 @@ instance ProtocolMsgTag CmdTag where "NSUB" -> Just $ CT SNotifier NSUB_ "NSUBS" -> Just $ CT SNotifierService NSUBS_ "RSLV" -> Just $ CT SResolver RSLV_ + "NAVL" -> Just $ CT SResolver NAVL_ _ -> Nothing instance Encoding CmdTag where @@ -1075,6 +1085,7 @@ instance Encoding BrokerMsgTag where ERR_ -> "ERR" PONG_ -> "PONG" RNAME_ -> "RNAME" + NAVAIL_ -> "NAVAIL" smpP = messageTagP instance ProtocolMsgTag BrokerMsgTag where @@ -1098,6 +1109,7 @@ instance ProtocolMsgTag BrokerMsgTag where "ERR" -> Just ERR_ "PONG" -> Just PONG_ "RNAME" -> Just RNAME_ + "NAVAIL" -> Just NAVAIL_ _ -> Nothing -- | SMP message body format @@ -1589,6 +1601,69 @@ data ErrorType DUPLICATE_ -- not part of SMP protocol, used internally deriving (Eq, Show) +-- | Whether a name can be registered, and when it cannot, what stands in the +-- way. A lapsed registration past its grace period is available again, which is +-- the distinction a caller cannot draw from resolution alone. +data NameAvailability + = NAVailable + | -- | registered to someone until this time, absent when the router could not + -- read the registration + NATaken {naExpires :: Maybe Int64} + | -- | lapsed, and renewable by its previous owner until this time + NAInGrace {naGraceEnds :: Int64} + | -- | registrable by anyone, but at a premium, in attoUSD, that decays to + -- nothing by this time - quoting the usual price would understate it + NAAuction {naPremium :: Text, naAuctionEnds :: Int64} + | NAReserved {naReason :: NameReservedReason} + deriving (Eq, Show) + +instance Encoding NameAvailability where + smpEncode = \case + NAVailable -> "AVAILABLE" + NATaken t -> "TAKEN " <> smpEncode t + NAInGrace t -> "GRACE " <> smpEncode t + NAAuction p t -> "AUCTION " <> smpEncode (p, t) + NAReserved r -> "RESERVED " <> smpEncode r + smpP = + A.takeTill (== ' ') >>= \case + "AVAILABLE" -> pure NAVailable + "TAKEN" -> NATaken <$> _smpP + "GRACE" -> NAInGrace <$> _smpP + "AUCTION" -> NAAuction <$> _smpP <*> smpP + "RESERVED" -> NAReserved <$> _smpP + _ -> fail "bad NameAvailability" + +-- | Why a name is held back, so the app can word it in the user's language +-- instead of showing a sentence chosen by the server. Mirrors the reservation +-- reasons the registry controller stores; "not reserved" has no constructor +-- here, as it is not an answer this type is used to give. +data NameReservedReason + = NRUnspecified + | NRTrademark + | NRPublicInterest + | NROffensive + | NRInternal + | NRPremium + deriving (Eq, Show) + +instance Encoding NameReservedReason where + smpEncode = \case + NRUnspecified -> "UNSPECIFIED" + NRTrademark -> "TRADEMARK" + NRPublicInterest -> "PUBLIC_INTEREST" + NROffensive -> "OFFENSIVE" + NRInternal -> "INTERNAL" + NRPremium -> "PREMIUM" + smpP = + A.takeTill (== ' ') >>= \case + "UNSPECIFIED" -> pure NRUnspecified + "TRADEMARK" -> pure NRTrademark + "PUBLIC_INTEREST" -> pure NRPublicInterest + "OFFENSIVE" -> pure NROffensive + "INTERNAL" -> pure NRInternal + "PREMIUM" -> pure NRPremium + _ -> fail "bad NameReservedReason" + -- | Name resolution error data NameErrorType = -- | the names role / resolver is not configured on this server @@ -1823,6 +1898,7 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where PFWD fwdV pubKey (EncTransmission s) -> e (PFWD_, ' ', fwdV, pubKey, Tail s) RFWD (EncFwdTransmission s) -> e (RFWD_, ' ', Tail s) RSLV d -> e (RSLV_, ' ', d) + NAVL d -> e (NAVL_, ' ', d) where e :: Encoding a => a -> ByteString e = smpEncode @@ -1848,6 +1924,7 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where PFWD {} -> entityCmd RFWD _ -> noAuthCmd RSLV _ -> noAuthCmd + NAVL _ -> noAuthCmd SUB -> serviceCmd NSUB -> serviceCmd -- other client commands must have both signature and queue ID @@ -1930,6 +2007,7 @@ instance ProtocolEncoding SMPVersion ErrorType Cmd where | v >= rcvServiceSMPVersion -> Cmd SNotifierService <$> (NSUBS <$> _smpP <*> smpP) | otherwise -> pure $ Cmd SNotifierService $ NSUBS (-1) mempty CT SResolver RSLV_ -> Cmd SResolver . RSLV <$> _smpP <* A.takeByteString + CT SResolver NAVL_ -> Cmd SResolver . NAVL <$> _smpP <* A.takeByteString fromProtocolError = fromProtocolError @SMPVersion @ErrorType @BrokerMsg {-# INLINE fromProtocolError #-} @@ -1973,6 +2051,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where _ -> err PONG -> e PONG_ RNAME rec -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode rec) + NAVAIL a -> e (NAVAIL_, ' ', a) where e :: Encoding a => a -> ByteString e = smpEncode @@ -2020,6 +2099,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where ERR_ -> ERR <$> _smpP PONG_ -> pure PONG RNAME_ -> fmap RNAME . J.eitherDecodeStrict . unTail <$?> _smpP + NAVAIL_ -> NAVAIL <$> _smpP where serviceRespP resp | v >= rcvServiceSMPVersion = resp <$> _smpP <*> smpP @@ -2043,6 +2123,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where RRES _ -> noEntityMsg ALLS -> noEntityMsg RNAME _ -> noEntityMsg + NAVAIL _ -> noEntityMsg -- other broker responses must have queue ID _ | B.null entId -> Left $ CMD NO_ENTITY diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index fbab1b01c4..3b375fa0f2 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -110,7 +110,7 @@ import Simplex.Messaging.Server.Env.STM as Env import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Server.MsgStore import Simplex.Messaging.Server.MsgStore.Journal (JournalMsgStore, JournalQueue (..), getJournalQueueMessages) -import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, resolveName) +import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, nameAvailability, resolveName) import Simplex.Messaging.Server.MsgStore.STM import Simplex.Messaging.Server.MsgStore.Types import Simplex.Messaging.Server.NtfStore @@ -1277,6 +1277,7 @@ verifyQueueTransmission service thAuth (tAuth, authorized, (corrId, entId, comma vc SProxiedClient _ = VRVerified Nothing vc SProxyService (RFWD _) = VRVerified Nothing vc SResolver (RSLV _) = VRVerified Nothing + vc SResolver (NAVL _) = VRVerified Nothing checkRole = case (service, partyClientRole p) of (Just THClientService {serviceRole}, Just role) -> serviceRole == role _ -> True @@ -1494,6 +1495,11 @@ client Just nenv -> pure (Just nenv) -- 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 resolveNameMsg :: NamesEnv -> SimplexDomain -> M s BrokerMsg resolveNameMsg nenv d = do st <- asks (rslvStats . serverStats) @@ -1520,6 +1526,9 @@ client Cmd SResolver (RSLV d) -> rslvNamesEnv >>= \case Nothing -> pure $ response (corrId, NoEntity, ERR (NAME NO_RESOLVER)) Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity (resolveNameMsg nenv d) + Cmd SResolver (NAVL d) -> rslvNamesEnv >>= \case + Nothing -> pure $ response (corrId, NoEntity, ERR (NAME NO_RESOLVER)) + Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity (nameAvailMsg nenv d) Cmd SSenderLink command -> case command of LKEY k -> withQueue $ \q qr -> checkMode QMMessaging qr $ secureQueue_ q k $>> getQueueLink_ q qr LGET -> withQueue $ \q qr -> checkContact qr $ getQueueLink_ q qr @@ -2152,6 +2161,11 @@ client Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity $ do msg <- resolveNameMsg nenv d either ERR id <$> runExceptT (encodeResp (corrId', entId', msg)) + Cmd SResolver (NAVL d) -> lift $ rslvNamesEnv >>= \case + Nothing -> pure $ Just (corrId', entId', ERR (NAME NO_RESOLVER)) + Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity $ do + msg <- nameAvailMsg nenv d + either ERR id <$> runExceptT (encodeResp (corrId', entId', msg)) -- INTERNAL because processCommand never returns Nothing for sender commands; -- `fst` drops the empty message only returned for SUB. _ -> Just . maybe (corrId', entId', ERR INTERNAL) fst <$> lift (processCommand Nothing (Right (M.empty, M.empty, M.empty)) t'') @@ -2172,6 +2186,7 @@ client Cmd SSenderLink (LKEY _) -> True Cmd SSenderLink LGET -> True Cmd SResolver (RSLV _) -> True + Cmd SResolver (NAVL _) -> True _ -> False verified = \case VRVerified q -> Right (q, t'') diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 856339bc89..d69a1cac8c 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -11,20 +11,24 @@ module Simplex.Messaging.Server.Names newNamesEnv, closeNamesEnv, pingEndpoint, + nameAvailability, resolveName, ) where import qualified Control.Exception as E import Control.Logger.Simple (logError) -import Data.Bifunctor (first) +import Data.Bifunctor (bimap, first) import Data.Maybe (fromMaybe) +import Data.Text (Text) import qualified Data.Text as T -import Simplex.Messaging.Protocol (NameErrorType (..), NameRecord) +import Simplex.Messaging.Protocol (NameAvailability (..), NameErrorType (..), NameRecord, NameReservedReason (..)) import Simplex.Messaging.Server.Names.HttpResolver - ( ResolverEnv, + ( NameStatusResp (..), + ResolverEnv, ResolverError (..), RpcAuth (..), + availabilityHttp, closeResolverEnv, healthHttp, newResolverEnv, @@ -69,6 +73,49 @@ resolveName env d = do logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) pure (Left (RESOLVER "resolver error")) +-- | Whether a name can be registered. Same timeout and failure handling as +-- 'resolveName', which is the other question this server asks the resolver. +nameAvailability :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameAvailability) +nameAvailability env d = do + r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetchAvail env d)) + case r of + Right result -> pure (fromMaybe (Left (RESOLVER "timeout")) result) + Left e + | Just (_ :: E.SomeAsyncException) <- E.fromException e -> E.throwIO e + | otherwise -> do + logError $ "[NAMES] resolver availability raised " <> T.pack (E.displayException e) + pure (Left (RESOLVER "resolver error")) + +fetchAvail :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameAvailability) +fetchAvail NamesEnv {resolverEnv} d = + bimap mapResolverError mapAvailability <$> availabilityHttp resolverEnv (fullDomainName d) + +-- | 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 +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 + where + taken = NATaken nsExpires + +-- | The controller's reservation reasons, as the resolver spells them. +mapReason :: Text -> NameReservedReason +mapReason = \case + "trademark" -> NRTrademark + "publicInterest" -> NRPublicInterest + "offensive" -> NROffensive + "internal" -> NRInternal + "premium" -> NRPremium + _ -> NRUnspecified + fetch :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameRecord) fetch NamesEnv {resolverEnv} d = first mapResolverError <$> resolveHttp resolverEnv (fullDomainName d) diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index 118810a08d..75a690b719 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -24,8 +24,10 @@ module Simplex.Messaging.Server.Names.HttpResolver ( RpcAuth (..), ResolverEnv, ResolverError (..), + NameStatusResp (..), newResolverEnv, closeResolverEnv, + availabilityHttp, resolveHttp, healthHttp, ) @@ -33,11 +35,14 @@ where import qualified Control.Exception as E import qualified Data.Aeson as J +import Data.Aeson.Key (Key) +import qualified Data.Aeson.KeyMap as JKM import Data.Bifunctor (first) import qualified Data.ByteArray.Encoding as BAE import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as BL +import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import Network.HTTP.Client @@ -75,6 +80,18 @@ data ResolverEnv = ResolverEnv maxResponseBytes :: Int } +-- | What the resolver says about a name's registrability. Only some statuses +-- carry the fields below the status, so each is read as optional. +data NameStatusResp = NameStatusResp + { nsStatus :: Text, + nsExpires :: Maybe Int64, + nsGraceEnds :: Maybe Int64, + nsAuctionEnds :: Maybe Int64, + nsPremium :: Maybe Text, + nsReasonCode :: Maybe Text + } + deriving (Show) + data ResolverError = HttpFailure HttpException | HttpStatusErr Int @@ -117,6 +134,51 @@ resolveHttp env name = (>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict) <$> httpGet env ("/resolve/" <> B.unpack (urlEncode True (encodeUtf8 name))) +-- | GET /resolve/, reading what the resolver says about the name +-- rather than only whether it answered. The status code alone cannot separate a +-- name nobody has taken from one held back, nor a lapsed name still renewable by +-- its owner from one anyone may take - that is in the body, under "status" on a +-- 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 + req0 <- parseRequest (baseUrl <> "/resolve/" <> B.unpack (urlEncode True (encodeUtf8 name))) + let req = + req0 + { redirectCount = 0, + requestHeaders = ("Accept", "application/json") : authHdr, + HC.responseTimeout = responseTimeoutMicro timeoutMicro + } + 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) + pure (either (Left . HttpFailure) id result) + +-- | 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 +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 /health; success = reachable with status < 400. The body is -- size-capped but NOT decoded — the probe only checks reachability. healthHttp :: ResolverEnv -> IO (Either ResolverError ()) diff --git a/src/Simplex/Messaging/SimplexName.hs b/src/Simplex/Messaging/SimplexName.hs index 2dd0f8645c..4622f987a4 100644 --- a/src/Simplex/Messaging/SimplexName.hs +++ b/src/Simplex/Messaging/SimplexName.hs @@ -57,12 +57,22 @@ instance StrEncoding SimplexNameType where strP = A.char '#' $> NTPublicGroup <|> A.char '@' $> NTContact nameLabelP :: AT.Parser Text -nameLabelP = do +nameLabelP = labelhashP <|> 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 diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 9c9392c217..9c66998af9 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -53,6 +53,7 @@ module Simplex.Messaging.Transport rcvServiceSMPVersion, namesSMPVersion, serverInfoSMPVersion, + nameAvailSMPVersion, simplexMQVersion, smpBlockSize, TransportConfig (..), @@ -207,6 +208,11 @@ namesSMPVersion = VersionSMP 20 serverInfoSMPVersion :: VersionSMP serverInfoSMPVersion = VersionSMP 21 +-- | NAVL: whether a name can be registered. A server below this does not know +-- the command, so a client must not send it. +nameAvailSMPVersion :: VersionSMP +nameAvailSMPVersion = VersionSMP 22 + minClientSMPRelayVersion :: VersionSMP minClientSMPRelayVersion = VersionSMP 14 @@ -214,20 +220,20 @@ minServerSMPRelayVersion :: VersionSMP minServerSMPRelayVersion = VersionSMP 14 currentClientSMPRelayVersion :: VersionSMP -currentClientSMPRelayVersion = VersionSMP 21 +currentClientSMPRelayVersion = VersionSMP 22 currentServerSMPRelayVersion :: VersionSMP -currentServerSMPRelayVersion = VersionSMP 21 +currentServerSMPRelayVersion = VersionSMP 22 -- Max SMP protocol version to be used in e2e encrypted connection between -- client and server, as defined by SMP proxy. Normally set below the current -- version to prevent client version fingerprinting by the destination relays --- when clients upgrade at different times. Pinned to the current version (20) --- for this release because proxied name resolution is gated on namesSMPVersion --- (20), so the one-version anti-fingerprinting buffer does not apply yet; it --- reappears once the current version advances past 20. +-- when clients upgrade at different times. Pinned to the current version (22) +-- for this release because proxied name availability is gated on +-- nameAvailSMPVersion (22), so the one-version anti-fingerprinting buffer does +-- not apply yet; it reappears once the current version advances past 22. proxiedSMPRelayVersion :: VersionSMP -proxiedSMPRelayVersion = VersionSMP 20 +proxiedSMPRelayVersion = VersionSMP 22 -- minimal supported protocol version is 14 supportedClientSMPRelayVRange :: VersionRangeSMP diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index d453c55537..6faf8916ef 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -33,7 +33,9 @@ import Simplex.Messaging.Protocol Command (..), CorrId (..), ErrorType (..), + NameAvailability (..), NameErrorType (..), + NameReservedReason (..), SParty (..), Transmission, TransmissionForAuth (..), @@ -70,6 +72,13 @@ sendRslv h@THandle {params} corrId d = do r :| _ <- tGetClient h pure r +sendNavl :: Transport c => THandleSMP c 'TClient -> B.ByteString -> SimplexDomain -> IO (Transmission (Either ErrorType BrokerMsg)) +sendNavl h@THandle {params} corrId d = do + let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (NAVL d)) + [Right ()] <- tPut h (Right (Nothing, tToSend) :| []) + r :| _ <- tGetClient h + pure r + rslvTests :: Spec rslvTests = do describe "RSLV direct (non-forwarded)" $ do @@ -83,6 +92,13 @@ rslvTests = do it "PFWD-wrapped RSLV success returns RNAME (record JSON frames over the proxy)" testRslvForwardedSuccess describe "RSLV success path (RNAME response)" $ do it "returns RNAME with NameRecord" testRslvSuccess + describe "NAVL (availability)" $ do + it "a name nobody has taken comes back AVAILABLE" testNavlAvailable + it "a lapsed name in its auction comes back with the premium and deadline" testNavlAuction + it "a reserved name comes back with the reason it is held back" testNavlReserved + 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 testRslvBackendNotFound :: IO () testRslvBackendNotFound = @@ -163,5 +179,68 @@ testRslvSuccess = Right (RNAME nr) -> nr `shouldBe` testNameRecord _ -> expectationFailure $ "expected Right (RNAME ..), got: " <> show resp +testNavlAvailable :: IO () +testNavlAvailable = + withResolverServer (status404, "{\"error\":\"unregistered\"}") $ + testSMPClient @TLS $ \h -> do + (corrId, _entId, resp) <- sendNavl h "na01" (domain "ghost.simplex") + corrId `shouldBe` CorrId "na01" + resp `shouldBe` Right (NAVAIL NAVailable) + +testNavlAuction :: IO () +testNavlAuction = + withResolverServer (status410, auctionBody) $ + testSMPClient @TLS $ \h -> do + (_, _, resp) <- sendNavl h "na02" (domain "lapsed.simplex") + resp `shouldBe` Right (NAVAIL (NAAuction "99999952316384526016153087" 1798191621)) + +testNavlReserved :: IO () +testNavlReserved = + withResolverServer (status404, "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}") $ + testSMPClient @TLS $ \h -> do + (_, _, resp) <- sendNavl h "na03" (domain "acme.simplex") + resp `shouldBe` Right (NAVAIL (NAReserved NRTrademark)) + +testNavlDisabled :: IO () +testNavlDisabled = + withSmpServerConfigOn (transport @TLS) memCfg testPort $ const $ + testSMPClient @TLS $ \h -> do + (_, _, resp) <- sendNavl h "na04" (domain "alice.simplex") + resp `shouldBe` Right (ERR (NAME NO_RESOLVER)) + +testNavlVersion :: IO () +testNavlVersion = + withResolverServer (status404, "{\"error\":\"unregistered\"}") $ do + g <- C.newRandom + ts <- getCurrentTime + let srv = SMPServer testHost testPort testKeyHash + oldCfg = defaultSMPClientConfig {serverVRange = mkVersionRange minServerSMPRelayVersion rcvServiceSMPVersion} + 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")) + case r of + Left (PCETransportError TEVersion) -> pure () + _ -> expectationFailure $ "expected Left (PCETransportError TEVersion), got: " <> show r + +testNavlForwarded :: IO () +testNavlForwarded = + withProxyAndResolver (status410, auctionBody) $ do + g <- C.newRandom + ts <- getCurrentTime + let proxyServ = SMPServer testHost testPort testKeyHash + relayServ = SMPServer testHost2 testPort2 testKeyHash + cfg' = defaultSMPClientConfig {serverVRange = mkVersionRange minServerSMPRelayVersion currentClientSMPRelayVersion} + pcE <- getProtocolClient g NRMInteractive (1, proxyServ, Nothing) cfg' [] Nothing ts (\_ -> pure ()) + pc <- either (fail . show) pure pcE + sess <- runExceptT' (connectSMPProxiedRelay pc NRMInteractive relayServ Nothing) + r <- runExceptT (proxyNameAvailability pc NRMInteractive sess (domain "lapsed.simplex")) + case r of + Right (Right a) -> a `shouldBe` NAAuction "99999952316384526016153087" 1798191621 + _ -> expectationFailure $ "expected Right (Right NAAuction ..), got: " <> show r + +-- a name one day past its grace period, priced by the .testing auction curve +auctionBody :: LB.ByteString +auctionBody = "{\"error\":\"auction\",\"premium\":\"99999952316384526016153087\",\"auctionEnds\":1798191621}" + runExceptT' :: Show e => ExceptT e IO a -> IO a runExceptT' a = runExceptT a >>= either (fail . show) pure diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 16a332d5f5..ac3d9a2fef 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -16,12 +16,13 @@ import Data.Text.Encoding (encodeUtf8) import Network.HTTP.Types (status200, status400, status404, status410, status500, status502) import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed) import Simplex.Messaging.Encoding (smpDecode, smpEncode) -import Simplex.Messaging.Encoding.String (strDecode) -import Simplex.Messaging.Protocol (ErrorType (..), NameErrorType (..), NameRecord (..)) +import Simplex.Messaging.Encoding.String (strDecode, strEncode) +import Simplex.Messaging.Protocol (NameAvailability (..), ErrorType (..), NameErrorType (..), NameRecord (..), NameReservedReason (..)) import Simplex.Messaging.Server.Main (validateUrl) import Simplex.Messaging.Server.Names ( NamesConfig (..), RpcAuth (..), + nameAvailability, newNamesEnv, pingEndpoint, resolveName, @@ -53,6 +54,7 @@ smpNamesTests = do describe "ErrorType NAME wire encoding" errorWireSpec describe "Name parsing (SimplexDomain)" parseNameSpec describe "HTTP resolver" resolverSpec + describe "name availability" availabilitySpec describe "Resolver health probe" healthSpec describe "resolver_endpoint validation" validateUrlSpec @@ -101,8 +103,72 @@ errorWireSpec = -- RESOLVER detail may contain spaces - must survive the round-trip smpDecode (smpEncode (NAME (RESOLVER "HTTP 502"))) `shouldBe` Right (NAME (RESOLVER "HTTP 502")) +availabilitySpec :: Spec +availabilitySpec = do + it "a name nobody has taken is available" $ + answers status404 "{\"error\":\"unregistered\"}" NAVailable + it "a lapsed name past the auction is available at the usual price" $ + answers status410 "{\"error\":\"expired\"}" NAVailable + it "a lapsed name still in grace says when its owner loses it" $ + answers status410 "{\"error\":\"grace\",\"graceEnds\":1796377221}" (NAInGrace 1796377221) + it "a name in the auction after grace carries its premium and deadline" $ + answers + status410 + "{\"error\":\"auction\",\"premium\":\"99999952316384526016153087\",\"auctionEnds\":1798191621}" + (NAAuction "99999952316384526016153087" 1798191621) + it "a reserved name says why it is held back" $ + answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}" (NAReserved NRTrademark) + it "a reserved name with no reason recorded is still reserved" $ + answers status404 "{\"error\":\"reserved\"}" (NAReserved NRUnspecified) + it "a reason this server does not know does not lose the reservation" $ + answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"astrology\"}" (NAReserved NRUnspecified) + it "a live registration is taken, and says until when" $ + answers status200 "{\"status\":\"registered\",\"expires\":1811232000}" (NATaken (Just 1811232000)) + it "a registration whose expiry could not be read is still taken" $ + answers status200 "{\"status\":\"registered\",\"expires\":null}" (NATaken Nothing) + -- quoting the usual price for a name that costs a premium is the one wrong + -- answer here, so an answer missing its payload withholds the name instead + it "grace without its deadline is reported as taken" $ + 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 "every answer survives the wire" $ + mapM_ + (\a -> smpDecode (smpEncode a) `shouldBe` Right a) + [ NAVailable, + NATaken (Just 1811232000), + NATaken Nothing, + NAInGrace 1796377221, + NAAuction "99999952316384526016153087" 1798191621, + NAReserved NRUnspecified, + NAReserved NRTrademark, + NAReserved NRPublicInterest, + NAReserved NROffensive, + NAReserved NRInternal, + NAReserved NRPremium + ] + where + answers st body expected = + withResolverServer (resolveResp st body) $ \port _ -> do + env <- newNamesEnv (testNamesConfig port) + nameAvailability env navlDomain `shouldReturn` Right expected + navlDomain = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} + parseNameSpec :: Spec parseNameSpec = do + -- asking by hash is how a client learns whether a name is taken without + -- saying which name it is asking about + it "accepts a labelhash label" $ + parseN ("[" <> T.replicate 64 "b" <> "].simplex") `shouldSatisfy` isRight + it "refuses a hash of the wrong width" $ + parseN ("[" <> T.replicate 63 "b" <> "].simplex") `shouldSatisfy` isLeft + -- the resolver keys the registry on the bracketed form only; a bare hex string + -- would be hashed again as if it were a name, answering about a different key + it "refuses a bare hex string in place of a labelhash" $ + parseN ("0x" <> T.replicate 64 "b" <> ".simplex") `shouldSatisfy` isLeft + 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")) it "accepts a valid simplex-TLD name" $ case parseN "privacy.simplex" of Right d -> do From 69cce336759aebc90f7c58adc6ceee540a3b6434 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sat, 5 Sep 2026 18:37:55 +0200 Subject: [PATCH 02/27] review fixes --- .gitignore | 1 - protocol/simplex-messaging.md | 54 ++++-- scripts/resolver/README.md | 20 ++- .../__pycache__/snrc-resolve.cpython-314.pyc | Bin 0 -> 40957 bytes .../test_snrc_resolve.cpython-314.pyc | Bin 0 -> 59621 bytes scripts/resolver/service/snrc-resolve.py | 56 ++++-- scripts/resolver/service/test_snrc_resolve.py | 169 ++++++++++-------- src/Simplex/Messaging/Client.hs | 29 ++- src/Simplex/Messaging/Server.hs | 11 +- src/Simplex/Messaging/Server/Names.hs | 41 +++-- .../Messaging/Server/Names/HttpResolver.hs | 48 +++-- src/Simplex/Messaging/SimplexName.hs | 55 ++++-- src/Simplex/Messaging/Transport.hs | 1 + tests/RSLVTests.hs | 60 ++++++- tests/SMPNamesTests.hs | 53 +++++- 15 files changed, 425 insertions(+), 173 deletions(-) create mode 100644 scripts/resolver/service/__pycache__/snrc-resolve.cpython-314.pyc create mode 100644 scripts/resolver/service/__pycache__/test_snrc_resolve.cpython-314.pyc diff --git a/.gitignore b/.gitignore index 9550e48c0a..9d27c4ccb8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,3 @@ cabal.project.local~ *.tix .coverage -__pycache__/ diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index df3f4aa24e..7f01b82fb0 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -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. +`[].simplex` and `sub.[].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 ` 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 ` 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 diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 56c4aa5c2a..13ef998d69 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -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_` wherever `SNRC_REGISTRAR_` 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.[].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 diff --git a/scripts/resolver/service/__pycache__/snrc-resolve.cpython-314.pyc b/scripts/resolver/service/__pycache__/snrc-resolve.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7b33e989ed7a4ab780c329a41d5af62199676f55 GIT binary patch literal 40957 zcmch=3tU{+nJ0MbT~rlS#Zw3&anX|!FNudh=wTs1LNA0a2>Bs_3aWsTfGXW8Bta5~ zbdnB=?Fe<`xRS^nJW)^Z#OWv-r$^~__t@#oCcD3x-691B-)Pp}UU%PKi6+!Qq zv9v-CzfA`mEzX#;#T9e0-{u49E$*1R#S`fW{~6<1?| zRj^eX4IC$VTVC&R=+?5Rgaw1x69Nc_p$1+B9#~Fk>6zy z2D*$wsLLdr=`ssvyDY+BmsJ?5u*Oz(?Ti(4ak151IJguN>jN)az(@iL(t@d_3%7q%e2mBlNBO2n&Je2Y+xcnynh z6}BN>%i@(n9pc+ryh^A?dZp5Ep@omB$#2Z+=R@jSpBa7DwO|k7l zGk))5zw3qlh_|r#4&gv-XE`SfA2q~w2?x<*yM;ptpAZfs+#?)8*dT<3R-`t>_6kSw zWgGjlQ8}pEXHT(b2ZW~)e}=^m zo+>zX@YL#4O{dnJI&iA+)cy){>=3>Y@y%i3WbBA=3TeAo+E$?(@zX4RRPZC-!{Ti@ zToEVq;&FdFgq2KnOlR-w_yi@|7k@O)6@|8D#$FEqDz@C|JT${h|tejeqA=?i`^ zbT-JJ_78@7gXOVcG!`1}Kh0P0r+dOZBQR!)Pm2obUAy^Zo$FC>Dwa9YZ67u@J_RZ$Hw;M}s1sNBKVVq!Uuot?+?~vsLLy& z!(uN|u!&S286}umdaaX(il#w*-Igs|2tIp9#6i9+$`8b1k@|{?t(CRqTkvlyOHiTY zSJ4*^_xMG1((#|=&z$4Sbzg6K^ykm)+*VyS5WI+b_XGzA{Lz8kr_{_gJ(%0_nGFQ} zgRudJqj~szNDL3toTHZ09Pbv|8kaPddbErl2ZsX@Km#UvAlT;z+}1DC)|%=XpO#ni z{sZkD!c*NHKtu;x_w$4g1O*Q;AO{rkYV|ReDtbW%54loBD_DnS=fkco7i;SJdaJi> z-@Yxdy|$*ernauGs%BgDw(7c`fWM}4%eKH)|CX&=DtjKRa{GZJZHJqmd`Oj*+5ebT z4h}_PqYN{5U=|ODWBe%4rrhjcU}+s1gzlY4w;Qe7i|L^Bn_uLMn}x=*+RCjytqYNC z2Oqo`2>~(CWCZlbMxqbznFo52<)jWoDKA%)u#id(w8}LeZS4?_9zNWx)vjHp7ld-f z@Zcb@P;o>IUmRUhz0;Thgx&(DmFhiWQmm80alHjPgbOTp&E4b<^GhtxbzR+8e^a_yYlS z1F)Png9t_7pAWOq-#fq)U;x7cD+eQf(I1m3uMW<5MjWYAK$G0 ztQSO-?~$p+xe?$>pmHKYn}Eba{R6QfbfJt&fC8I>5lr#maBm175y}n(dk1k3022>_ z3h>2k&24;bWtEQy$)*vAg(G~pPbLa|p}}A|FN_Sc8WKJkInRsu`A~Gaj-^l;la0XV zth$&F(H+37!7^586r4f?xkW~Sz#o}gsE??386m}IBBQ#a4ll>{!}H4p@Q2dXNI zhvYdE4fbP{D`I}kBk&)552k-j9m|$Y)V9jDLoF3;mF+F~UYC1M@78n3nFlAwZ*AB* z*0vSDs0>g~p!fEgIvIM}!h>i1^bB0vg|K**8rB!O$Zw&%*42&jZYU8#&B{9Fx-xRdE>ywlX>5gfHjP1`)`^VD6ffm+LmS#Wlm zw8tH*N$@lIki5t5qn| zXy{x;v{wv8Vo{X|7b}Pp0Y|Zv)kj8=UU4fj`Xc_KR}hSIx165K^##}fAacTGl@_5ZZGzR>r@fjbt?l6KF*IWuqBoeSw1@45051?3B_yhMKK z9g8W$anELRTE`pjc{yab`Kz0+o`|;F=x>Oj~aj%+g({IU4Yyk}vt;=6EN+zuMBumdIFdLyu@Pb4BSd@xf zD^fXM*Ebu)AB_iAOKtFG_GZ&V9sL4BihYxLS3GZr5tqFR>D@S>o2ixUo%}Tc24<3^R29v@%7hpr!7Bpz3TeDJ6TjamtULAs{3lw2PQ7J;ytHl{7`C0)NVE- zS2iS{2Aq#c+h7Mci@K(ju_;y;5xlf!N+Q_e!wBPZn{Ia z*Uao!s;)@OEKt&z26uj59a9m2@8yF(sDkPxNz(mOvH$nSe_eF>H z#WDbt0z=&wXf2hIA|*Qv$4m#gK%x9xFs`8aY7i_2&Z~+0_CYD4O#!s7fIH;m0Ca+B z9RohWyp98bTlQPE)c{YcWOzn0oLV&9sIRKhP};)TN>J1x(_$EN)9miVk}DQB*~5i zjT|$9;RN&%STg9_C{L>F=(pC78MSc2_l}qIkl`yF<5Y8`!T@y^xk&3RXAYonX@vQ%tR~ngaLKgozkVQY+ZisbO=Fu}qHZUr7@{ zhrJDgNzdb`0IyD%eoP*u*CuER5v=1*0?_44_a5Bga`~OqIJ!2Ci&MYmfd3cQ0#n#* z&``#*w8MB(Gn6e8Dx2|`r80m*Ia zc|YF+fzMfp&6J7`k6s`(1P^M7?4k~;4i=G+Y?Fi+TrCs@10fWS%q~#YR0&9yVkAKZ z5(=Z0U~ZrX5uZTkw%-01U>NZjqP{e-0XI>gfG`51yE4VD$4|-D2YEAAs^T7c$%wdF zCgN6Bxv038UNDqH6P0*V8SB`n7rMiJ;we046YNDq#<@EV&YgMr=}S+?P0w|Y@4IDl zOlGQK|DsODNx%MaZ^J{khYR&Fjo)vNPbI&I1t8Y0ot`^6)O|5;oKAE}k zwVWTWeswkY)1IGz6HhLR5`xLU<-GN!>h?IO~R+L-TBU+WnFTKx&8a-*vGj&M30 z!!1AOX^xCFH_L)l01VS)kpN7u3hM#&g4hCU5)hi92vkzU(=oUjAaPnRy)T!tvCiu5 z561S=&%L9qBSSrqTL3ahR(=tH!(27HHLfl4RF~a%>yf=NwXq8G=nMA64IDA3u z!w0ONgShlw35rugbih*p2w3hWO`rA6cy$F*Xk10P05nxLd5ziqeFB3N^9BsO+2kMq}K;i`?V7n0H)~t{jwkmWXZC}@rLD4Png0oZi^hsw z04^k)Uyv-Z@Y&!ngCjA7_g(BSH*|VZ=u1?HIjv06!{9K8VWv z7ud>-WNtEBlPMR;^dsHCktBEg0={E|Cu8jQGYVcRyiz!omCV>Me(+sq&O09OEl1{L z{k4mU;yua2hMylwv>i`2brU+qo|Jq zPCY+D?RwSlvAF9WkWAx8G7Xs@a0spe*l%34DpwTU#yZ);p$@^g1I>o**r&b~SaHv%R8|08Y-HKwqTh=y+ zpF{H`GqJu>X2bEujsr(qyW3!JbfmqzqvNoYrd3q@ETwcSEU}_CPvI#UHCY&HlB`T| z9AH`j@$>YZRqi=iC4g0zf}h+=YV$nOve~_W$T)Y;%DGq1r}H!EJn&QchVgyx+S0GC zf64bJzOR=|S*OnZS=wBtZ?<&zZ07C-ES>x>j3(@Px7;h@8S#erxkP#qmTZghn=Rv3 zM&Wh)bpGp4-aM16IgsQJCNmCASQoHrdurCX<~?ujE$^ypEmNJZJ(<|HFIl!fSQ2FrgIakwtVU6 zWaErE52cJBNuel}RV1W9$ms4Lh?>iz;E?%P61Xw0?J!!sO!hb4j`R zp173kF#NGt_;`C0Au+zV6$S{UJcT~2y?QApG74grDyk*HFsZ3vg@??T%xAcFa3nx! zke6&C#FID}rW9De_4UCt#sT{hSp=q{Rchjv^3eD(121mp&su2ln&i-?DobT$#6A*@>KONmuPH_saN& zq?^BQGNc#1k00yi@;4{lCHF1H^wPT)OPb@Z$>y<+@4w^aT;8i$lP6y6p7JL%iWAP_ zgt_=Z)brT98YBu#c0{@{@rwu^wMfVYylb3VC25clTyGR-f9KzlGQ-s7f zE-RzfWV+Bx8U(MNa{`y7Ei;x=A$4jPA)@D!G8mVnEghE1y94aKEWHxUusblr^1uw+ z1G8YqUz%X9u&pEcbH<{&m{XXjQ^PtMXC{|PyeYdnVGkjA4P2L3#TXIGkun|6r}1#= z7vLpR(rfc4kw*)TRNgu+EJw`|xO0UX#|{^TIGu#FU%omm>a$1ULu)7Y{xH0b9)%#l zMI5XckJyeBom{2mrEz%?`$;k$V3*24N77Cfs?Sagbf&3x`4RN_4nxGT!yvxVX%jNH zBT)bnTkz&ZTKWxIE7LpEwff%1RR!z4oe59w|FXqYBJjGue*@%+US^Ica@&+}> z6B|42`kqPE`vRa+{C=lHs}0~AJ>x-p3>`MX)L~62D79TcfV57l_65*Q0e50RpZ^hP zI0S2lL$Gx^`{W$WM;s^BPq9o2|oBYX9X31``4@5igV8uqCwQ#b%CXp%dCpp}7GT+3j+T2qt}Qrgac$9U)Vh z0g5$JK+ASnT=Xr9ku-^sAIYV36x6d|*#4BBPa%-ZB!iB+5hZm+>XKLZPUwGV_JaSigJoUvgh@$D>K_y72?=I)w`!;WOZ&Nn{u%bMg9$L0!-CG7%Cc^Dy6LU!u+n&v#@&8 ztZ)0gZ`X`(*Ui9eQ%B->=Umf?r0>Zq`xelw(b;tV*7}m^#+x-iDSUHXa{ay;SJA}b zcOjcwxpvYDpG>rt_@0_397|(dohwgM)(VZ6S9XD6HCG|5!9ThyxnK8JS z<)2tRfTZ$8Mn#%>AR6i)hAFxjl%;%MM3P4l@B=7a(={6mwI6T@u0gzq+jU4>a7)!x zEs!{PR?|U5U_rFV2xK|wg^Kr8`hrzJzM2^rX5ETFeX4$;$A@SotRJ>q{L9wc)K{%& zGs#hy9#AqtEr^Ex8$8g-`k9E01+1Yyyd~=$&()3bmGdioGb??`l_kl{(rIBXbIW{Y z^-N}UGIQIUb6di^E!A#ygdTSphtlLrFY(t4WsOi!6d}nc8>=HURwS+ZrA~v##K(-c{t?xZRka5( zbo5rb8Aa{6Y>y&K#ASAzzGGD;@=r#6?uoH@$5iFi`RQ1qp!&u@V$~B#=bnUl&x1Wp zG>KGw1yT)*L@gKbm32M)5Gc|{1z!-HIenPfYr%jm?c(Emd`o)gqcrO z0o7%fR)tTaRuo@H9v^Fwa$Mgv7DFLf78a5+3AgLa&ag05FQrJwYIYUsK3qQ(d|75TE$lo`mAC-ZX%?toY+ADvvpt^ zz3cSe#GpbPM;w~#;BbFzV6jc2OA*k?T+Lr29UGjd5WyUCcrKS+Dx0>?7w?!U-f^>O zzPRC6#SL-$ROX+$-n^J}w1GW{PwVY5ENLBvY`TVk({Ico5e16rh@~b9_Co8{JdNXgl@!g{J&sR-EC#xo_ zCr0CIlg`x%bIEiOf>gKsL5533rxxrd#fBz^OGRqPBP>g4+kuX)fJKP`79~-_r6t#* z>{qIm$ctTOgbeBLMdV?F*TqAfp%Ar4u^Jc#qxhG|NS=2_$sP+UOMxHaDJuUn-1O*| zBu7kq#42BaU&JbSFYmszJH9p^Ogh)kJBw$W#Sb&@cwFv4X#}kDbn;Ricff3!BomCV zSf9UYlu-v;e>|e=`>5-SSnWw~yP8zAtFP6N-gY%9{&9N^BY>Mc0$Mfji8g(nMJ9S7 zZwe6u0WND2t}<+0dzM4wpLEppNEIR+!=uc`Bg+b4exK4i&5wepMRl`uT42|ohCX9g zN&w{Jy7*E81?5^K?$!x;B9;^D^f`7~^uLH>7|W(jXeyVP(TM*M zDHuS#fC$l-%tV$i>A)%zjLGG?OLfoH$FslV`KD*uINhAgtDMi$Jbrs2Z;L{eQx)>bM1_CEqK6@q_b?^xpl_5HR-In5&E}7KN|XRc+T03kIeRo zrmMYi-`9sHyQhtF*_-EFC3EHyym7i;sE?cHoCOJUK?;2URM|yq0fs(W5FzyONjGKa zxpUa#$hjfZ+vAW}g1LD}^{k8uKhBspOw$ot7*HcJyxPMIul5*dlLq~|7L@O=F~YXT z@H(SxdlcrKnxkF>Ypi)PX-hHhI)bFLl{k^9VoB|;bkrizXf_iAnURUtw^M;2N3+0FGgtCO3VMV4bf=wEg1MAhZ`OZAieQ=y6aq;pHc z{KQQG!Goaj`=Dm3A-f(x&1t>Zo8KM74lWr%n`8vdn7NO`(lG*yBIGMu*C<;rjqD`$`R?=V`xGRk6#b9J(Rxe+9J|c&8~9o?itivX8w%N01{#^Pj6_Cp((YSuxGxu9 zDt>M=G%;y8?>VyO9R)Lvg19i1lXPsFca+RHN|KH;=w;H%@0iRMWt?b^Df5i`gF{_v z0#PHeRv;)#0o|h*vd`(^E!V)4mY1i+EpD$_*zFCV9{iHwZ5hmCzWZ;G$vC%4C+>NC z+|!{MugHRM+V9cYij$6%8pzyX?#p9~K2wM3QJX;Zb7CFZ z*kLY0y;OmfPJGXDgL|OI%8%C>23ffPW*qHNTFtB|L);G2YA$3z8Z0p63u&NAVa~!H zqnr`rYAiq0{CwuIzZ1x8R-KQu&6_P7nK9dGbq=0}feRV4$4n?UuJMsoDbK zoTRkoi)>3qco+=T;t3{>Qv#dZFQT&BRWrSFFUBY0yGYDt*-sInjRhu4Hnx;rtD9<> zEpPc{cC!4bW6&3@!R$}H$Q9R@${B^)x5iK#$7m- zmUM3(-w$KaKl=QFCu_3zrT#1ZUk}ZB3KRCiJ4TZQsbQ-3+-K)KTN9qGQ|77Y_b$A0 z;k%=A{Fa1$>u>I@;#_(6ID;iyLRlu<|M^}4=gOf+{NfCMg$$8^|6SCF+W*m-y{ovl zs@;3bxSyF;P`JvuceCkdJ_ChiYa267f16=IDAj8_)Ef3SHZ||t-*WKK;UleW$AtEd z`DF5n0CEd>R=R5Fu%-B}0$QlKlcd z9)%0PL%2|u*L}p<4XZw&;3`%~xLt#w54RKxfxOtIA@hI1&ySa8VZo#Rnte$h?_RV8-vU*(VvcV#%QivB2_$&JSV7d)nTby6%Rh8hk%Iy0`ykp}=rGO{2J1@I0xhK!TSi;Va zH!L_@;0PT~+F#?3XFm72OXdj!*@+om7>u`LYJJu8z9W6Y|J=r_Rg>plx_IT{lr6cU zIGN>}bNFa683@o;Jl5$0q|)qO}^3nhYChGtNd@E#UT= ziTz5|6Me!i>Oo8+^WDEeWD$@mz$hn^LpV#&ezq<9J_SC)e683@HXq(z#s0SMD0Y6d^koIF=kVudP-tqs%O)~WkoYl+ZBrm}r za|5FS;yt8eW3vkpY*ck*TrU4=`Lz|_S@W$m)3)TwE%PgDXI9oGS8h)_>gOE|GmeI& zqw&qXbF^k5)9MQ^elF?UkT7px!zc0QB4gs{_KS7>eU-8EfeTxrRcD54s?QGe2Dc53 z_-h9jZ6;O*n7jKnE4|A=d-t)I@igtd2PRorO3Os$A^{3IUIk)2?Xr~W7(2{smzoye z8&V62F3l1Id1LV!F`uZ`=M1X>U9Ku-lNq{0z^vH&uK^Tr3E^7hDqX3@sKi4+_q6Hx zP>*hpzwtKZ9fH`DHbGlN|8Rd$a(3@+ZfvQl?B3tpirvUoCX0-Ucac&uoLBHkXekQu zbjxmjx+CGiQD7wTzasr#(99wMJj8nr=Xm3<)3YWF3oZ{--3JhXemnG{XR2Y&vtc@8 z&b^sb&$*N5zL7W4u;9&j(Hbwh{MoO47Rj!DW@_t;&(3)_{E6wVh0DzUz{a_=@3=tP z#}7U@H3{Gug}cd0rcpa?7%`S}BgTEO(9!q4&OXVa@AXzATyV1f8iCJ-F0Op>cAHO@ zW1)WJ7Uu|ZqFw7yuR{Dq3?W_2XShueh&8_njtwz7rU5Y`w&KyES(B-DJKlC*NQ4bL}D8lKxX>A!qv;?OHuGfwpSv}w*+@>XNAyydON zo9AAO-q`y6^EZt@{LJ)c-fEmFZ<%wpB+MuV z3m-ToVTclLxI{bh{P*~a4&W0^vy7a0-fTwErG}|77?Y$IPMMPF>!)IK=_S+Wrh9KR z{Gfj>y#}V-?$zypl$KdPJd#|`mgQyDKGf7v;gzHUmK zcli=7-;e5MoI4ZdodAZNOq}_aYhy!=c@Yq3oLh`l2Dk||ldls7SPUwIw`fRBP~$FA z;R=T3#30Negem1lLLFFrUL+O~-$!|jUs{CCsbrFIF|%;ZH3asJ02{~^L8Eu!{MSA|eQv&R>#qv8{#BjgBqF~6SegwuJPkpr1LjbJC=ot3V9|v=y*=d8 zRN1?v3j5rPt02NUlKT%-Xe9~-(0Hv2=kLjYBmTffMtHrJRdcyD;#2QV4>kZNEYH+qw)VGWCPF z`28ckv@>$tk37;k(_kiv!R+@C!++w69+j&kGl2TCRX$`ICmQtmcBYlmd{ZtM9-?Q| z3*`7%X}bCgWm!%LWIC@9;0BI6LB6K7D*~*toT=@JW__JHZTd1)P=;p`g-GkR9pMR# z2Q6AFpn1UW<;&O$&4#XT7q#&rC3XUbrAq7H!20hPYvF1Z|HOG}HN;et_pDOhKI3ib z8=5m#xZZ+2p3lLITO>PK4EDfyU$Vx+bQk|0B$Q0>+W>bu^c;?_Vv-s5<>w_E!fw0~ ze}*)S3;{bTHheOIbaexn!fZdrvRZ{9oFM>DBE7yb1GyJM(M93oh>qtrJaPaV{UcbTDpu*$LFUqU3E?=?5MzGxsAe zq!5>3wC23*xa62@oa%V_2(&KKfw`>8S$h>s;fSO8$ilfZCIhp%8(}lo@slHSrF-8D z%$FRTEjb981*WE+=}}?z_fg4cGx;GZ8HQ#gEA|$kgC!g8@ZH$Y7FVidl&whX(b0mpf~bko6ErSOBvtR!?PI{miAh=`_HoD|4dm>frV2 zY1?;q-6*KXp!a#!A0J65L|`k1-ENa0RI?!@*oGVjq*W8PK3ogyJmmXwUUXdw=tBs#7;>t zvUf7Nga}zr*#$Fu2U@SUF@cRZCs_6A^khjz(h!KFRq~Z$V!Ku>s))z#Pt$j6J>et+#1bw=!d|^RH&t-zNHfn^w!MjC-4SjfvXo#Z5GI z$suR!U$)A&X6!wEl8e;xtGfgaUtyu8Uf$1BLZ7gtT(*4U*n#l&VTbWv)hGRp28{H^t$Q54}5bxxxq=;t5x%~)JMUI^H|Ab*guI{hx)B9>~PEET8-Dlkgpdb~v-nyLS#lP?4x)$K z8;$Iti~x~byEMH0zu^(2ix_hG(xDulZ6XIK%iNZZ%z^x==&Ezk(h8fp%xX?;)LTm~ z$SH}BVAo<%bk`oisM;qiC+MzLzFLfEvEtWw=fyTSYW*-N&CGU;I7Fu`C+v=fuy1lR zO$eJjLx`~u?;rr>9rT|KR`y6~-2?&M00Hqsdi#$EkkwXvC00^UM#=5!I(dmR2UmAA znv!_aR#G-v?f3=~Z^biJ&#WzvltkbG%M%$zNm$c#LRtc%>&dnu;0%TslsujwM#CDeTviz1}{8`V2R+ti&x@YXwVz$f*${P_Qr4P_ zLigUoM;md>%z>wy+r_`9FR}mx;y+M~T)2vR5QFM`oF34+So|*tAO;-3mOt_TV(|;1 zKn&jBDQ@T%|8Lyo?a{n)sR}!|tyd?}US!Q+m3D{_Lx3gjt@O;vtuXCd_w|7(!`ta= zWof%VUi4yc(uJK~SfG*@&zh+XNmnuSce&UL{q-|b4OhcgjI$*>-e`Q&@>d7vO7>$3 z?9D`a$mK`AQ83X!PIrsq!q<0QYJ!j+zCaIOI(%*A)Yg{^-ga&L0J8Ztw4ckxmbaBJ z)n2KEQoi}yPb9NS-nN&1kiogv-O1!!?#s28YA36ngFJCXY6_tl?E3GH9Ef<*)givQMCe)8M7z4VX_Ot8JM!70DFDETk$#K|7egfNc z;%qH%V-@zi;@mE~Zt7}m!^g^5Ew>>T#ZHUY5I!hvr4}%0>og)e4Vt`lKgTh>duFF` zIU$)^YfY5oTf9PR$I4!hDpEVVf!ZLH3uLfWkMU9JQS4taDw5p;3KD=oP<8`++e>Zo znIu~%>K}{@_?e_ud_G(mdWD3*x+f%G{7=|Ehv6SRU|?r+bI+3kwic`#NFL?MynNx(h37`W z^uQ!w;HB`Da58sO(o;O|DV_0@COzdOPxef%eQDE`P4}p!?`N-$56ot7x@*CYyPPS@ zdY?1V-c6HY+b0=zEMn}5@O^5+FTRDZ!JTZ!O)NrN9>ugD)1!hkD6Pn0pJ1T<6m-89 zlYc*m3KXPezB`3th&Nddz*DMPb@J*|lbgv;^`k&nofeAh88mZwi&AwOLFEX!0V#b+ zxv1%?pl0+L{Qnvq(M~6v@qYCe(>7l;AJ}7zU82i_XP???{}u z6UEUw9qe>8I3Oi9>>W8l35elHl%2mshw#8VZA^qaLL74f zKPBuyps)zPPl50V{I%emC*3pp8uo8xx27}#5;a*@U4TtIQ;zKi6)X>f9)_H&Nz=H%*UYfJI18$@f8hD^Xu6N z7R}G!XW@;e$(MfNZG)rbGjJ*@8DxWRC|AvaP;X59Pe}Wy`Fo1yZxIp^&#sd4YfX%l zUw?|-Uaw~KU7o^7u3ZZ2ZoW%IO;5{l!~|g&-rSY7xA_+Ysh3R4P22 zCDKuV%$c=(&<>8i>m}c?aB_>|)!=q0d>$tjisX=*`Glpc7&_>OL8)x98pNTL{Va>H z?9&=4U|_DkBx`S+IymMXi2y=_{vn=iTBzs8jP*QL`DQvyOI(j0k;M#KdF1Mi z<*3hjI+~AKf@xSR8GNuuvA{FZF!VxHq-5{zW=WP#`i-i8 zSO3%cIrF|dMzhPh;K+bAy36_=e2x~vM?oTI^J~>N4BxMvFWE6uvLms1=go#h*6t5% zNOOl<)@+@ikI^;ijgMQas(_jXJ9#%ayz=0ad)XNZK%bPOC@C>lcwY55fiiF z0hh5|RihLZ=C2FH0tS5lh4b8%yP+BzcC>>=bO3Ciro&RG7(^nBIm{CCd?b z-g%Um3kzOtNt+VM!G9Uf!q)eUP(fTrAjpJt#tu$Mdmx1qPZphcB)IToku)AwqD%9& zJn%%;Bw!RF4ort0%w1uHVtna$aw^EWp+vSXmX&v=37(CpEuJs(1cm2bdM;#ircr-A zkmnZG#xwsN+L+1Y{&E|$(Z-x*+nB{tLDgQ6^M$#*|i2EdA4t7Btgi zSm)X6FHWMju4e8?drJdU;j{`5K`*m> z1dfiFLLASX)Wm?;lmlwO%yrg8%&WO(!{Ke(W}B_dU)JqO5O3Kx6jfdF!s~WxIM%2- zxeti6xqr+;hmLL)C(6?wXGSscT>PADKg!8WjWxwy(YHUbUQ(*ZQQ#^sA zrqtbIZq_dCQzmwj?CP26bi{;w6dpPWMk?|F>XHeJv1G?-6U=e9)2fCkac6%o0P8oBGHnR#(IX{h@+?++~Q-~aa0^cJWA3w#fD3j zZY!IUvEy0Z0{$G*F>ZDeXq)M}FPB^@ne2^kO*&S`d*&QP5OsT2UViq{v+>@fXYF{) zyUwgz>&xfYSI?}ko?X9fHfQI|`fcBAiZ{Gb^pbzlj~^3`nVg-FTEV7f5{5NdIEo97 z+2T3zXgru$w?DC>WdWyNoq_k^9C&B5kc&3>0=?(vTv?OV&+Q_Ev8>A%FI{|YY`g`# zSfS_(Bps{hY(vLH-IqU?$g7^URlg@^o{g#&zc@sW-=gvDFFs4Y;IeRX$x0szo@|fj zy?6@m-pk6Jy!hg-se`jwD|_wK&SdtM@gobi>}zSWwxZPdWKRx0r29_dd1A%3JLxtW zC>srw4Fi?Q;Uu8E>vf6Joym2(-n7rHBd62wi{Q$Sy|fr^3-%u(px&wK>G#LFKt~|kvCcNT+0XPoO{KcY|fi=dGyj~JU{7KKks=a;dus! z4yL`Dc4K3*c)R@I=J9{`)K8y!^K;2v$K?l!r=A&afg?JXXTk2CG%wgQCtJwJUCm3| zuWXMWpW2(u+C&eVUpjE*K>W<~+GKXwf<5nA30(VyUI`_(G`v}v+_*29zyD!R)1QAf zJ_MU>&&~z=Cy9F|bEv>0aG5u`ZgSv76qf;)Hlr`@hQmF;;;Olv4R;E?8Ca}(X~pWb zSbux>tdYyz^_zRGM$TJ+&5UVTPzOB+88u?RWBTN`+^)mhtZx-s5&qKQI=s{PA9ki6 zNw@q*mjPifrnq*gE4{nB@yL(qdu*PhiT+A-ObEeNO*_6|gLvJHrCXG@Hav%b9VZZyY@J27kflyYhgPIn1l?z+$ z9r+|$mV4E{!phXBY%G8o(Ydut_+b~PKdnvUV6jF8M@^zxtWb@lx-B=b^}ssfdQi`0 zG&aPo!V8i)wIN%uoJ9WeRbf*3RPeSiyw$B+G?gyAbUcaG=(DU0(_*isf{nccsY3at z#lA|HQ?#lp^ERxtmtN{S{rVsPDHv9!cAWYPoGx#nufCZP3j#ccEc3~|7 z+cNn|Z2@8OfX?OfJJGqgXO>STcu`^oO3W0pglr+F+_~Bse(|@d<8nQ9pB(|IOT-z&_7Y$XAnrv;c)v%?|?vO$&IBESKCK2eQ+-HBsylRkn20< zQO_N&*hx&@k5W@K=m}!Z#&LRiAKWMo&=>HCw_)XV#=y^Dh@{@Uw}xC zwUR`3Vn4TmMS;l;N)oSM#sp z6ohZ&ztTNnNqFnugFVoblc2_HVC{YR$fYAN3CFgZ$yogO^~ULf*T)jxz4t7pboU21 z>@w?pc;gv?>z<6~pNNl49eerW^!C}p>V>R4xGb%EY4?@g$*gtrS-u(g`pGJpXnHqm zMZ9V%aP5f&Z}v;+SJD@?ay6tF^{V7R*QF;@0#8)qPSH^cNc=O`h z6B}z2`E|%I-ThA%(9Yj}xSh+`@tb?)T=r^Udt|#%xb}MMjeR$pZtQ+@YjS<_%jSOu z^2dwcMS-fgAEz~KFu!H+ApBcjdedg^=bN3)Zqv_eJk4pQf1hSSe37DnKmn+kyGyId zordK_p6!NbKnf7TMJBVrAf1K07NY?NbIt57p28p^z5|b>^zQE7L4Pz#I)d(QCi7jZ zXk&NM`)m~m^yfPN3xNWBKafrn{<|#jwTR%GQ1#*V>U#D|mX%VK)r%kvzwLLoF(5jr zK3|r$6k#J=7wB89LU)#>?iLT1-XVatDMWSGnD7LGzpYrkSvW}O~3Z3;7z(R!`+Adixkj?=&oSGSg zBLABhx)eZ(MfgD0;j8YE#Fvml@@i0{;4TI+=a8C^N7m)Bua3pnPo10f_~t!ZW;|Px zo=RX4yKCN_KV#32Z=1ERWxw)f?0NAu3Ez%cJ9ss`rU!|YzSpX-;j;Ssqu<{>Yp>T{ z%-Zu2Ckq-mVLbb7yW$s8&%Ibt4ai9BcQ3N~JQ@y{gS+LSO@&5C$vJGyqEeI#PYjis z_kD~co19}NzKoX)j#hMQ4|OZeckz4FeavkKG&*ppO`1$BgsI;pQ{NEOH-51{s+{e% zS+O~QR;gRj07+SD*XtfyG3}y+0ob9%x(;fZFQ}L)s7MxUO{}WCvF=9iPtM#p`DSKf zWz*aCW;TPFAJ|ryUiqm8j_z*kP#zh?+3s}GI}t`ji{$C@&bs8A7@4^i8ii3UntFKq%?%TI8f>a8qiSl&v6+ zVA2Lro!mGsl+YNv+K63VaxH6b$E9*7P)L=4U&fR*AAXrCZ7Mp!>EIfYSPnI);094M zcpApWWqyjfnX&!ek`5ND!>AJuqmP&f(S|zND>a)%*#FhZ%=f1#DJ|8qRm7-k%xJ0$ z)iz;@IM;CyYwDRryrepPijp-oLLp8yZ_@p(Shuv10(X8G&ps17-%D1Bv6*jdk?fWN#?erLWew479$fK*Z5hK zCV>dxB*1RCMxyg+rHn(t(Vnnh3>*OQ5XF&5OmfNg7T^oQQP?~|H%wnZ%gst7;S@tZ zsGy8~u+cCyERwf>Cn^;EHIf0#r}Nj>zh<5)c~( zddJkh>7v(~rnkY8x-#n)1UVZJ%geuGGN!G%N6yObIk;SYK4-&B&W5*hHeE`ClLLAC zpMu?MaAtA)RN8dbbjN(z{sjKl>`#=o{L+|MdFXBXVMt+ei{IB5o5E@EJNnbFXGboFSY&h%CpnRbb0+iUMs%D*0@T6w25EE{F&@sK0XR z&6n{Db{(rw7M2Ael5P73yO{``$!dXEMaJVj!UHQ|u_%v#IJi24`2z|*I&b9+tA54h zU(KH3@;|&_#WRLUQJNF3tgtR(3#GA5m=NqhNhWA!r*A`wkK>rI?E~u~GMj)1KN`b# zX)sj^1c&_M*|9?HD~QfvFA>8si}#Z)lbisl-EsyD81cRC6P)`*78T1B08f0u;b`c5 zaBQ_!0GSEF@=ATf7H-Ji&MJo98q3y_(gCgQp`plN@JVzqlj7%VFPXWKdNvi2I-e!< zKb$zTBQ*52wvD0Rt$bxo=s#8M-4*(APxfY?p##K=newNxo4luK6W~GoZ|LdUL``mk z^Wbh19lA~A<2E_=#%2w+EG5~lI79(?mJ}lth!n&S_>PNj;U?L-u??UXBYX#6G7b+b z->CS<^owS`S6E2AJ;aa8~D?z*?xRjThZsoZcJU2qFW~h=PRZ>bP zOGdKPEccJVE{hv0wet-_YiDjD%+^acA1H^flQ>;3)`Y0 z(jE+PHwAksAZt(Y7G*##)9MuzoZc{>h6;c^432@cf!{{+df;q2pby@m+57yIjfNbNqW&`&Z6< z;mlWtzc4)cbfTy(Y27|8ZyC7;iPOw6}V}R<&SGe=r4B#BSrb1LE7o z-*3BVOnP=a>U%lG!@jS*nF(<%_C8bj6yQLX>LoRh)7)`dJ;re-r2iIc!q%{0wj^w| zEZoV$8WxtYa0?{h*~SFtMSCIHw$ny;1T2Q&Jy+d$+J}w|!^-=`76T<(xhlcs+_zc{ z*87=GgZKVgY_-1cqNB?1J8)+8{XCpId?%mFU76rA7xMDQJxMPAz7;3J-rtM^3-4Fr z;IaE>Fo?DH?R5GDg+=%6IH2IZ7ars9JK%Zzz8zk#?`M+V-TMw!P_BW$vztplU>Hxk zZ-w8t`?+whg`?H7YQ{a1`{20qLp=3-m}@W`Fnnk?8+Lu@HX51@_Z*o9&;89*!%S=& zzO#`_UooEckrlh=?t8Ig=|eA#ef9h~pKmY&$ literal 0 HcmV?d00001 diff --git a/scripts/resolver/service/__pycache__/test_snrc_resolve.cpython-314.pyc b/scripts/resolver/service/__pycache__/test_snrc_resolve.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..025a0b6e52669c1ccd96291ff568dec977dc29b1 GIT binary patch literal 59621 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 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") diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 30ad640aec..d2a2296e47 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -97,6 +97,7 @@ def setUp(self): 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 @@ def test_hash_and_label_reach_the_same_node(self): 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 @@ def test_an_encoded_subname_is_not_the_name_it_would_decode_to(self): ), 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 @@ def setUp(self): # 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 @@ def eth_call(to, data): # 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 @@ def setUp(self): 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 @@ def setUp(self): 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 @@ def eth_call(to, data): 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 @@ def setUp(self): 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 @@ def test_a_name_in_grace_never_reaches_the_oracle(self): 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 @@ def test_a_hashed_query_is_priced_too(self): 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 @@ def setUp(self): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": ""} snrc.chain_now = lambda: int(time.time()) + snrc._auction_params.clear() def tearDown(self): ( diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 93c8a035dd..5b553dacf1 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -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 diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 3b375fa0f2..0e7b6ba2f3 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -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) diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index d69a1cac8c..6e4e905402 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -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 diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index 75a690b719..ccf1933a4e 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -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 /health; success = reachable with status < 400. The body is -- size-capped but NOT decoded — the probe only checks reachability. healthHttp :: ResolverEnv -> IO (Either ResolverError ()) diff --git a/src/Simplex/Messaging/SimplexName.hs b/src/Simplex/Messaging/SimplexName.hs index 4622f987a4..d07b74205b 100644 --- a/src/Simplex/Messaging/SimplexName.hs +++ b/src/Simplex/Messaging/SimplexName.hs @@ -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 diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 9c66998af9..21edf16c1e 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -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 diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 6faf8916ef..83681cf198 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -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 diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index ac3d9a2fef..5ddc2e49db 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -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 "gateway" (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 From 783f0aa4be66114f96ea85407f8bd254cb1f62b7 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sat, 5 Sep 2026 19:51:01 +0200 Subject: [PATCH 03/27] more review fixes --- .gitignore | 1 + scripts/resolver/README.md | 11 ++++--- .../__pycache__/snrc-resolve.cpython-314.pyc | Bin 40957 -> 0 bytes .../test_snrc_resolve.cpython-314.pyc | Bin 59621 -> 0 bytes scripts/resolver/service/snrc-resolve.py | 5 +-- src/Simplex/Messaging/Agent.hs | 14 ++++---- src/Simplex/Messaging/Agent/Client.hs | 6 ++-- src/Simplex/Messaging/Server.hs | 4 +-- src/Simplex/Messaging/Server/Names.hs | 10 +++--- src/Simplex/Messaging/Server/Prometheus.hs | 6 ++-- src/Simplex/Messaging/SimplexName.hs | 2 +- tests/AgentTests/ResolveNameTests.hs | 30 +++++++++++++++++- tests/SMPNamesTests.hs | 8 +++-- 13 files changed, 67 insertions(+), 30 deletions(-) delete mode 100644 scripts/resolver/service/__pycache__/snrc-resolve.cpython-314.pyc delete mode 100644 scripts/resolver/service/__pycache__/test_snrc_resolve.cpython-314.pyc diff --git a/.gitignore b/.gitignore index 9d27c4ccb8..9550e48c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ cabal.project.local~ *.tix .coverage +__pycache__/ diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 13ef998d69..87143a6f30 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -200,10 +200,13 @@ then be quoted at list price while the registrar charges the premium. Configure `SNRC_CONTROLLER_` wherever `SNRC_REGISTRAR_` 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 -the ordinary price" — the price the registrar actually charges is still the -premium one, so the quote is wrong until the resolver is current. +**Upgrade this service before the routers that query it.** Routers from v22 hash +the 2LD of every query, and two things only this version does are needed to +answer them: decoding a bracket label that sits under a subname +(`sub.[].tld`, which an older resolver hashes as literal text and so +answers about a node nobody asked about), and reporting `auction` at all — an +older resolver calls a name in its auction plain `expired`, which reads as "free +at the ordinary price" while the registrar charges the premium. ### Why a name is reserved diff --git a/scripts/resolver/service/__pycache__/snrc-resolve.cpython-314.pyc b/scripts/resolver/service/__pycache__/snrc-resolve.cpython-314.pyc deleted file mode 100644 index 7b33e989ed7a4ab780c329a41d5af62199676f55..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40957 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 diff --git a/scripts/resolver/service/__pycache__/test_snrc_resolve.cpython-314.pyc b/scripts/resolver/service/__pycache__/test_snrc_resolve.cpython-314.pyc deleted file mode 100644 index 025a0b6e52669c1ccd96291ff568dec977dc29b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59621 zcmdsg3s_v&edj#kG7K;vBtSya=q)6~TRenbNT9b69x#4L@@SYD1o1FK?hKY7Nv(8~ zMrxB-PO>#_?aw%gYm~UPvYUQU_G`B8=KJ!+yWIvnAk-UeH{15xUGJl-2-Qxs?e6~m z=iJxK+!+v(<8-gB!<~E2J@?#m{^$Sx|IhaPJckL#4|cusiFZF^GW`wxP>yW*WPZkC zGF>uxOfl0lCXe~3Ic8>mEio(mYm3>~Uwh2X{$|Cp*x&3}_E~$EOKDE~+qFzPSI*MZ zT&~>L{+`WkF!Nu^-W-c^x$?QD_q)LPSW(>jyKR$}tl&t`XRORd{r<=0{>Sj{Zd z%wC{o39BifX3hdNOIb|^YUVCbvy9brqGsL#HCM8l`KVd2K+RRGrVBL-7pPf&cD1Ld z`AL&0P;4@ln^1dq>PW3KHOLp&0){uRe-+EdVvmOhWA0cm8jHI7BceMR7X3TKU^EhX zF6bTzhK7S;w94V|jD+1624e&6bHk&tfk?RCy<^Bdf=B5|choNq4#%R^@^fN!G$=kd z=nq!YT`x81m2XuIkDjY?bV^SB#jyhYTT#WY@4gJ{ruiOR^JHO0(WiM7;!Ptqx@Uzh#8j7*0b5XkD4bi1@?m*BV2?V3=*g()dG!lvp?s(jD!tIZQp9_X# zgOM;Fy1}90Q1D_EF2{WSn7i5$Rjv&9!r@>D!+bI37SWa%h*r5h!I7xkX4E$nbc-XQ zAbUfgZFqP~)NyXuCq{$ayp6}j5cdNm-;- zDr(?>Rbi1#o!6{Vt}hBOh;@8omG{aJ^Zcwk8aWNR`kJ76*OM9v>M8s#SYsdfmm!fx~sZ6Iy79{Sf#A= z!J%s3h9k9U267V{$S9yQ6!eaWA!Q8tl~^PUd;>V$X*iAxJ+_gCaZ}ma~v7QjPUHkA_b4xW2v&}M$HOXvyHW89JPeIEy-gl5-XHA z?kRQBj60)mUf@R>^r)9(A&j<)NTnLXAa zquy{N<{cIzfe|2MPT;_Yh@(=w;lMs%EBk4H(<72Sap&HT9g*zLJhF?xB$=SOo(+B= zGSU|c1{OQ^2Mx#m!5TS~8MZqg9JU_?GOsduET@u5pmogdZ}qkIbyip&w06gmb$rEKF*#GRWY=PHe=T{Cr76i9;C z8wOYH^-2z}cL+-bB-blByao3rHVg)OdpC7Kk z*&P!KM%7tM#v;^nXgWcJdQvAs6+(a_F)E9RQQ1t45||j3!^9{D6QgpO80BPQRGtUI zF$qy93p@~iV=hk~%0dqW+*pyPAhrxbRmm?MI=TvzF5st5>SsKdlugSKG)1IOO#l;m3u&h2_oBdvs{Pc7 zf@%*@MLsmmf+A&XFkLfuUb9Hn!Ej8n#Ri9h;&xQ#(8)v!VRy(k)EDsWm3A0HMO8Ey zdwjTRcPQfbg`#_L!mCq%=L=9=GJSK|H+$ZE>g!K^P`arkI= zkEhMkE#)3O)!E}Yb>c(^eh9(XfEO)>q#XZ%Z!io(dO^zaMty{ia@41!0u(AhGqCc*?7aX<{7pVCV7j%54XYEYyN2b(58X8a5_agW>A-)3No>XK zYgr=EGBzFJ0XjQKMLQK8R3yuoLJZ^E9=Q;f)))__a=#x>&&vY&@l!u(eYbU5I6`Cf zO7Tot#aj(;?fH|9|GFx}m~^6nhZS!WKv%@Uu>+@D{55qun`&ztf_*LZEiJyfmgb%P z&B3Of0bii0v8KkqGtk`9AFOY#Yv}LywX_8Nwf_2GUvpo7eP7Man%c8ONF7{f(j$%p zIV2Z@U`vK*YS{JIkAtJVyW?c8+B)x&s_53II~!p&bj(m`L6Jg@3Nw8>jiI~L`6jkBFVI%5xcoJAXL4|$vwiMpi#fdVUW;) z;6-m=Fm@pr3^PR2C;I%)24hSkK(M(t18gqh`4IwBB5*qCfHbrOrc?EY<7G!?grh&x z>uwY77y#;Mylm{I@Tm-d`n&9Pj|zP(sT~7=C$mn4O?(EO>l|x4?R&W6?AXDFZNleE z103ZHIEd<85F=1;go5Go;LK!XRhI!E(meAi0)oZ`fWTm?2|R8JM^mSom}9U%!aRvg z56doStdgNAMZ*;`69$UH6iD~;7$sqL3UKWAk3Bq>p|k~VLnLX2vl-c82xi_$f66M} z!X}D@HLoumkZFn-^!W$aQYO0<=u03d?a44_3qSrmdJ^5iMp~UD@E9+gxN>5AZQNNQ zpWm^V3s=osNttTTGU7Krs##M!0nkI`&f8H-#tCE&=DmECCg?bxXi#Fg2!1TLRozTL8yscUPoG<+DQ?CiyJOrS8VdHu+>w6wV9dduqw#kShVeV7!WT|nIXShmI_|9bAUD$jb*Stz^jmnplYm>J^9dVra~~r3aPyW$Q|qSRiSf_@Dle_@GA}U-qeMfejIqvBWTFWa?zb)$pLr5gQoo(=QEj4^Wqeg@~0cFaMrHT^c*$&Z>KP z**T6oF1C~wQ~v|1pB6m}^Z$)?z+)OVF9p1N7rC6YdK$gM`$*rGbm*3esuT(GcIUe@19yH3(O~(*D|smrqO<$IGj}Yx|z_JI)*4c+JV1LT3h`8e?zzIDm>t zF_tmcX~>2U9f9_^zFI~F7ib*x^Ux~K!;mjTS})9kLe%(d^h4bn_4YyL^u{7U5b~rj z>yY$V#(7yvlW$Z1$Tmdtv~Iz9x+%0}n1vrf1@ANYY(D$LsN+4RO7urLBDN}1#NKP` zwfCCptsZlUDI@@On9tah8evWEt6no&=pL188@6aq8`e~5=&-fh)J6PE((%LwZJ8U_ z=(Tukl{zO^k3HcDGAkqU6(-KCmF4#Cj3MSeDbZ!b3; zM-SJ)l~hv#d2%+Ga_E|ncn$8UNv$dOmA`l&o~>}af9Wx^$uF}8>}`Y{nZ4!z;AFS; zlBwHjJ;op1;2Sa5swb1uCG}%*QVtuG?s)@z-fio0XzxH5q$*?Alm- z&92#!T~n@Ix0aQ>vT<(Zw&|7ICL7}`>t>hLO*!ir>R>cXB)f=|#d{<=watjAKgWa=>;+H3;v;4yRpRjh|bP^aAMHMamwT{K;?uQeUV5@X8*lJHLJ zpc&He6L?i8oI%{sS@x!lmzT6ovubeBdnJ%x1m)G4`ceA|phdsZH+ED#Bt7^=LdO9lvaSwip zd#Rum!VFnbE)%-=kaGrtJc%SL2u?*712kSQ8*oEaB|0Gh5`UZ8_#G6{EEN6lz}(MW z0VFQkX@Hdh-ZF}Ii)!ItQV+g~;{GMm?ZV=(^}O`dSDyOn>3HF~iH_OAipyEI3JNdV zKD60#>hCyBW$R#O%e!5)VyCB0_-ed{#MMts$=*Q~EtZwkkMkBV{iYP|Fqelu9V zW@dK9&i74kK4+>}E)o_INpU1rQBh6BVJf~r#W5<#f+3O{jCh&~vd4WOQt@dj$ZuDyrGjK%kyee~WrK8$A{Df}qvudCmksjx+jHsYE$50$9d~VZ`=-13 zX8RN7yIFR4ZRA_+7tMDaHv6Z|cbyjdG4ov^8<&gB_9sz;xpWla_g1U@6slS52h1M| z%j_r2cT80BwN9_}0e% z#Ip?4Zn;#@35f4zc(aH6JVbJ9Ng#hj3W1P>`BK(%pPAIGz2wRL8~PK{#k_C1P5)v#|n*>~mCuVZG1O*>6$7 zmKR|=*c%AA)dQAqaZe4&vbL!U%qbriND2_&k#K=y;;sOILO5YC^Vq3E+Hh;S}} zSc2A`x^il~{k2mspL(nP?UUC|PVMT7S3VXm_slrE^|W+nd}ZzIvf3#pcOhYZDo~Bn zmx$k>qMf=zbhE!-7W}g1Zc8O;zUMFK5pq)bTohZWc%awP>`}O-}mIAVKBnhv&+u+vjEzQzd>0?~=0F&m?R{#(xFS1;UhmW;2w9&DQF&1h!5d2=1O`6 zej3FhDx4h<3sb_2EvWdB`in8AA#pdoW;+$sQI0wG;Y`|+3^!@q!~TvQYf~ENtZ;C= zb%3rMq@taQ4iu7A9QJb`rNeaX2o*=EAat#Zeql^RD&VpX{0&t-kK&T)Z#l|=?vDpt zkP;BeaQf4ErIY>~xpL%AZdQqd{Jieuo8Z}{_w@Q1elNk(i+~6SM40~^>je~Y_?vuO zfViX4hezY-rE5f^fZbJbikx%+m z2va}LgOLGa`^8fzW}M}8N<;od8t^Y4;rLcvIe6BFe{8r!){yaVJ!93Y86X)!MWXv$ z3m#<$o;D!0y=KiH5(*Nzwz87m$J-ORAnFq1Xb1yPkTHeMc&ydL``Dx$a-IY)KQt^k zYA!bJ?639J`D>)C@W@bKP?YR77i;V4IW~rbH3a$@2}oJoX_3oT907}L868a>TsVKh z2a?QnCg0BdfSt1#0TH``p<)~ zYUyBr%{TE(kOvu)kPkW%@~S?V31T3(IWOERscDIybDuh2!G`;1WvgFX_42BT$6j4? zIsY%0JqZa%P=%G3*TfOzx9g_Rm^tU~=GeXCBL=g9)_XTR?P zfN|vpV==yruKTs_n@qH}*wR^nliiquZY-W82G=rp0Idv~tbbm_mx-AP-weUayeV56 zy@s$POVI#UM3T}1;SlSm^+A4h?AkyCi-=uHT_-j`N0c}KFtQUXCM$2Q73UJQiGv7iX;<47R~bdiC$$Cu!1eGN52yEV5c)>%WQ5~zzjG=b4opfl*bR9x_ zL}(NcKvG}Q;juhXh&u+Xw;olcgAnmYXpTdOok>@n*K%~q$jm_U1w5-7Fo%86!Z5TU z%dy;u4i9m-CLo@h3xok;1rQj)B{}l>ijp_Ug#nl|kq?1HJL#|?=6LXi@(HA#MjwGY z3W~ng@KVcHTE5yEFIYRVakgMHOlLBj^~6iJEdtOkOxPxS-hTSp)3ckK;?CwY@J2Af z;Ejwf4+C$QQx$H|lzdd`U=V~|_lrTKN;;MLCXiS~k8_4_7(29pBD}?bbZa#YAx>>E zJz@9QKw|A0bTO@f_%7a_NRbGH(xakT+)aR~M#eu9s4>#Wq3FV3I6ypRj9^OvGXgEd z$PEtp5M(JGG6bmyZf6BS6p28o5cTG^*SF0UR8Gcb3+gj~mH}BZrZ+MiIp_b5y>993 zUph?ngsV$s^zaJ#&_mS62RCD_Jy15uet167-7f}_cEslnLxac_PlPbsvX05U-Oa3W z?%i&1^;J5SE{?BCgg1uJ7|=1{u^kmOHb{{ktY{_y?A9}|BL=OHQlC)x17Zx}Sq!c% z!f}~YX9!{$pIJrVbQE2He4k(VwT&-Te5K;6+vE9bE?X7c)cy9ZYrAGQ?TnWa-;$BN zIdAB3@hIt^3?uX>9aQPCjS5|lN{sku>7Pjaf2TNuQvA0l5&(c_c;JR!g#iMx(eXVs zhSQ(niFG>alxQ1UfU`AbgFZdcdp+>IPkiSSvpe?3oo$(^lT&u;!ib_5$;U^@#ipki z=!=nvDJwXjNF+#3I?{@mHXSF-eI%RD`JO|v1EL072Ll!7$R{_3I5?JCsSmC|aEBZW z`-AS9^yuU#c)d)+GVssCR4bW(<}y?=w@d_!#5|;Q>PPOSfS3FabY#m|hI+tODhSXX zL#Hm8ewK!_HlRx38R2mzC2;ti6_AD=c^!y@~)y2C$-Y95H}1&DY42pq}&SAPMan@JiiC(x+E% z7;#LxcNb!Wk`8zp$*VAvvRtX7{DsPosWBLjm0-lQp?#*ecm^lCEsOE{@#r*=hT0+3 zIH(sYC=Q$1_O4mQe};E))xvM#EWvE|+jQ%9P#78QIt}rr-p_V80>>Q2?#U{MNI!VJ zD0$^-3K7M!S5Lsc3Gaf!Ai*Ujy?@JC!No+UV9u)&IhZFdj5!RGuAijSBp(fpcMzFx zhCl)gEn+F_*`VL=dsciA4`DP&E6J?`Nu!~9X7Cg@i~MkD_rnZZH4PHEBK$I)R)zdB zKCoHfmm$c087Uf*sco(CvfVeHoGm-_V<(V>>~1k`8-MK8?77md)1_PADt>#_wN=-j zj92WPt2i)SaUfpN9xv^zJ*cg9^q3#I6bF_w%0)x9CUhgp z+9KSPbSQ=>Jy637P*)3{hw3jQtm3ivTJ-fjS^8hfyIGK@0oF;u9R~PC_kM!Lq+@a4 zr}RVl3*15GUSaI>OO701(E+gt+>jkCWWZ0B;C?YO#BPvVQ6;$X7{5e65n#q{$=}4@xQZz;rAF}}V(ij^3s9>lHRE%8s zbnXRDq*-$GaUk6kBmi>$Nrcu9l9TW#LsE1Txmyt3HtG!ohhqb&D64G|D62NUa>wkl z9muZ6Rt}576#qNAD4EZWSuR%H7gRT97{`eEab)v9>5Nd2$ke=qqs0G#LXVN8EdB*L z6b*kduKL}{1Ro#sDqH+FN*#}6sCLu9d+{XK9}m4=hRH^*N3)Qg{* zXpWau&Xv?mm(;{d>Shb;u6NHCHeb$S$t;+cSn)*GtE(qh&X(2B2o1nxcE~6YMUraz?$h89d2I}%dGmyd&*#IeNH4PDtiqMSRn3-RP-9X*P*x)pl zb^`Vi{F~p5f>}BY-JZ0VM56p-rEKBTB8LG3-Xf57-OPYNxi|a4Y(qD=1r0Ld$Hb{^ z@_J$>k<*4iG6HAYaR1{Gh)AL}H4=Un5_#B*q@)ATHph_e6xTH2OIuTl{qO{4 zfaP%k=u9-c+46eJ)Q-b58@S1qt2?vhR!JAm7R&+aYC9D)!F<6a>cac^qG(}@VksS! zj4h-~Y3dj)jy7}@Ur1?P`o^oFASk$9O36wzJ%O>XI6AR$(gq!R8Yz*0l5z14NM8it zHJ}$m=3z5HP6ch$kq-^MYPO68ft-e%9ojQS@}DN*DYOfSUzQ2i!XRhVpED{I)jagO zV5LhwG()=5Hl$NsCFQEt!(Yu;!J=b>bB-$ch6x_o!O#g34+EWOch$2r=;V=%G?O(1 z&KU>M7p~bg_S;zkHWA5YF8*&S{t-ngvM^paWPE}ujybDA83Q+bn50PM#v<~__bU}O z_yED;|D`vP$)s~H0lWBzRD6kwNh-cg#WgAj#f#rUf!t4T&^bdDDwdF}aMa- zP<17)i#owRM{)1tmb8U?p2m{4@Pi)04D><340kPXWxH&IX)qCS*``#&@5e~`f$8UCng)jS7*|%54H|@M(o87cKUb-g%zodIUJw`Hm zLXil>SJ3TF@l_hY(gmEa*=#WUd<@maPAYy##lNFsiVAZ46D2DCii-b4#oto#cT{{p zg_)p1prV$3)=^Q9Lhp;3qr9_TVV<^%Yn_sjN}Wxs3-(w1A{mrd!);pmAno8{RPSf0HcmS@kw^6cfZJbO-- zXD^TC*~@2n_6j`tF&BKU3q1vp7F<%EM^3HSLmtB4bwDful`A{x1=*1x386`Cs6dg;-ju(6L`PZMH zTGjMVFGZesl7>1Lh2Gng(4@2?aikO%$YGkGE_#Y!(2s?w9(2wYYB^32F>3Zft=MaZ zHkM*Mc{~N2$^nRp@f3DcXE3-QOMpTUmlTgy_)5}JjY+!uyLB*}|z zt>9clN_YvA?OX)uOiyjs>3xXxlLX}rTxul%PLAZ?^mN(viPt_#YTfg7>Px&+`nY{&?4kC+$q*Yu;8Y8(D*aN^EI3R zeHaRSv>TGLtc?UCJY|3&w3>!`78MB78-zSur}7u*TF-r_&rbzV zOEF8SuQ9q!;fDHRv#2wlqTwy^@ri4!xcs?vEWhJGM=^ru{Y0rzKelc0v^p6c=bOR5WmA?N?z#+GBtg7XSE{2oBoOT@_P*^Wa;y zw{yOg^X=UD#)j)7vm1Bc2*gVdE(8K+ZVH}M1o6>>fDUbZtRO(Vm>vY;1Oj3uhBpQO z?`5>qC(o|puB`Lm`WyTEkn_I2t|nMp z+gDdt?+XTf{bzf`djtWD`D2^`WBz1Sz4-UIm*DitGk@tP+!uY8oAz1iFJ{}1QqC{g z1%Pb-qh8u*Fe)82zyM2Y>h}l|xDM}Ug^%-)r-eXZ^YvY`+xEoE_TIQSTXy8f+inVv zbNTHRd}L=uQF`4v6OA>EI^&ecRaM+b1^E(+JE*9kg1oK7CMwcv&vWTE6{IeXp2GVz z?fESvGdY%!$0Qeu^pzG;k_#65<0!ep++y!$S3231$ITx?h29GVIhFjw=nZ}}t{(W! zj`=bC$*dq>x_TT+b(g0AWg$ydQp8e~EJLc2VyMlRvs5L;lB$^WutWzvOnLq~`bT>6 zoz4BwlDD)p23neS`gb-pH`niMY-ns~#_lCM>uMSUwZ59#nz}xwXcwLImCBdV=c&k> zxW$i8gRVp8I%?0xAH~DvBLD$0(21wfuakzLNO`aJH=(x5&L(N&X)M{H`N5#_|l%?(3AK>Zer}3aZ)d3}0Q5_g?qRV)YlImdS^`WVi4d8$Z zj5fJhsv2J190JQ^6@Lre*m$CW`L`d_R+YL$vG(lMam2?)qlCs3@8SG@1m*s zZD=Z84us@H2w#>{e5xx7&yso?zvZ+`s-ZwlyOhyPAds2B^2p5upiV3gp{6=F&` z_AO|ZjzFqW>;WKbNqteFb!mP1|6#?ZTj^}YibpWhdVgxk#x}Pn8j|;mKM1}Cu8w74YMT;Q?7-6TeD)P=YueJzW&Z?$mgT}^-$3wGa z+b2)o6k1X>(DIx)=YP!FO*3cexNGzC;B&^}){W+i)RKD4qFeK6W*CX95}rs&Fg5o1 z13_#V5M+T7v_LD}=M?J3{8!GoFZd{}L0=>iYGvC7ouiQCbM64P)eKO^gQ&Z<(t(ZZ z5nVy?$_TJvYaD0vlY*Juz7Tl@jN+N7A3H?zr$)lj;h=x8ACX8^IvC5xHN1r|4~DlMC(t0MR<^KEjHfz%dK4Me zX~TIRGATqt08Mge0K(FBOjDhvpeaIYs%m}_P5m|Xh(vZ4KvcFm8C>&==JMA}=dS_4 z=2s!__Faclt%jGjIc|8@ySIo@l@Wi!Gi?RW9`z^iK}SMp#+ITJp+HdWy< z%*x{&X zhQ%`}^U8NpA{)Kjj+b^Y*|F<9W)WLT9uo=y(?wIdq5@=wd1e@HAcs9#)dX zF$AAD`;BSWf5zwRAcv1^;>!|vf(0j@`|YHWnigOOJeFZyq^S{0K#RT-L#rGL2dip< zjEFxXSQAKjifkm4@X)?QD9s!)6sh;QNyqHkI_z4LEc${}>&P+> za;#Wk=!3DJKJ_qtw2RJ?95I*#)=M|MR8XoEkwTor2o*6ZE>iIsDn3g^dcTZabeoFT z@sGtwaled$)=NiNXrt_$p#?NL;G0p(iY$90g);8C>&PO<3|E%D^{&fKk&M`brs=N3 zZm*!A$BGY8^uSGnN`|@E3;p;!^&k$6j`?q)SlCU2Vi;qBr!1D^Ss8OYvpkl|q8Xhm znlbNet|#Xiib!%GM%o<9XAzABETYlHA{q-l`Eb@KVlEp=yUXDE3ePf>C7xoGrJm&| z%RDPkuJn|kT;(Z^mBX!LHQR4(mE>$2VcS{sFn^9>fXUcTPf-RjmX*>?nShuW#GUy? zq%pie5)$?m3u)pJwh~5akVrUK>FyhevFj{=#Sy`_86@2Y(2CD3@0Ef3z8JO=h9)TL zW;quEzESr`7@`-{2Rsb}?ohVfD#&Fdib2Y9L`P^Z51u^{Dh0L;G&b*06QV&J8wmy6 zpB#zAxUwRQjShVwycMA|OffJRs}frYU?|*^d7+T+N^P*l-{Pz5Yk`wWgTJ}Isjoh` zGtlU3@z?b?^fd=-15Lhq|IV5O1)a)>-b8ye;X0+TEK?9XD2MJYE&ilKcb8P%Nr&z( zvHwYj?ye*07Jk;-yA$@%KyZD2YOMsn5_}IoJd~K=0en_ad(l2CXO=5(IYVj5+0mt* zDOXIURA+2_52tZzcTvOPsNvQ zzm|0DqH@U%AJNc#x2fI@i0&@yOe=<_XlHQf!$1R26QOD5aMqKg>p>@Jn)@NuD15k)~R7LY#9keQnu-S%f(%5zE|62A-YG9bD}$rB*c z*!A*>(jGDjrTY$@1hXW!5p^z=b|i=t(4c@)8^^%c6mBZ9)0|z_z$QC`@>~=?g~9(XCH!Xc6dxD-=8pxw<9uN z5GPf0u#fjbN{cdPbTgC}*cLir+saJ|3`{!T!~?`Zx=?_FOmby97+>2ySJ3fZL5Dt` z|3Z9C(`-r8l&dMJ`l7aj)+Y>@+M&x=pm{$|UW~74o-Jvfay2KsL7!hBb9RHbqSqU5 z)P4K;_?msQCHtma`S^cdIp@Y{=f;Vi$qjEjHRG(-96&r1D`uQq6W@0>zP5F) zVE20kyY=6nCi_9!?Xc&n+or4A;%g4fmK>OJ9Y}n|nfTgWa|NyM6}0MK5#!Ii^Ym=> zsrZ_%*^;g)S69M=Q#(52YfsG;biG&5b-Q%cc>BciSC3B?!Cm62{r1Y$+knvG|(fvn9u;T*r}=;iZ-rTc%c5U*C8=`o|Tst6SsCc27BX zGX|ITzdnWi%};cQU%|pa=o^6_ca41s7sTJCg281wZt!iPk*q;>K;sUg%M1XqmB4NX z9{C5DHwDwWEnm;STKv+gudKR$2b`(&v~;Q&V2N|IDzO(0c5v`< zO%5C{a0y9ip*`sVD;&HCyO2ZSkwZZrwn8NqS=j`l60MSJ23^y%Jbr8u5o5j^+$262 zb})@l6+={rHe-_Q7x0-%lDNW@o^Uq`F6Kzta^SdBoH#)o^$LE@U0fk8g zbE}y%xiypQ)l#+$Z7j=RC#VXGv3r#kC^0C^;Y)L_L59JYD1H@pB}dPxp0*R-_O_?G zQ@tT}%g$TOr16OnY>>ltx8XbF!2bbzF3Im@hR-2*^=O+iNktUMeBXEr2J2Z89 z`ix>6rhv0kV{0)j8LcAy>2-RuI7BH^Ob`WwmEm*w7c$lV6O4VbmNg9 zxBukSyQgLwPtUr~#LLfqV725pQej(FXFQD&WOxUElMfAw83Y=@xz4TCraZj__(r-c zLoq4ISrA+E5c*C!5?C-9Po_&OPJ2A-naG7(G+qHP`sO1#XtQC0%Y>S1t802`%iwg`f|47wQ*SSdCcJ8b9xpVd^UaW>F+!}yM5n`us;E+ z^JblECbHk&c5U0NyWx85d(VI8`5!NzZR(tLccua6Z1jd9>Y5C|jEOV=;Ac{Tdo-lH z1VE-uL=BYYxM~ErH0km&OiMtOb(E_ngoPxc_%nsdJ>xfF6J1@a$ic7cjR7L&+f08S zZE{5XZJcF9y@YT@f=Qk)fNW$6bP`}n_Wn>LA|`qm@J)gU!eny|T(QYg<0VTC$pF$0 zV}MLkotS{=dGz!FP$@Co>!)4ovH5f4^~kJi=ViMJbnt3?>eV%GEt?F?Rqday+W$`5 zY*hz94m@hbsmxiOw&I)9VNXnu0l?v^iYb+;gyIM7(4qX*694Fe1PEud!w6SuImj8V zhQ2oWU|gZjZsAFE_%{Z=1Z=7Fj$v2h`U%{HR$r$0T3Eol=p9cd{4JajF4U+!11|%F zC^@-5)6r-r41=kpo+TOM3GLY=zv-9itK5)2NY z>&Y-!{Jpi`SsUNjCZ`=)G)*s?7Rtsqj1RtAH6v6o(3GzX&91DxDe(Obj17k0W!(T^ z$v}jk>R}9o8eykY7ELnIG{Ss%UWZSVX0@7zps4DP6NxQCyyQ0}uz(e2k%?l7z(MKf zHIUGtftYg61twWNBqjY2+fx--MF9ZXcg8;;dn)?4MB|P0_e%z3(C1z3)0YSUc=ic} z@~QmXJLd7+MWmg1>ZWiil{1sAFv9#4zVyLxi#Dyg2~x3!GI=KBDOa%)rrbbs(6wlQ zTYyVs;9KmJGr>D~%H_0ED$g(FJl)|r2 z>L=MqD(93;PGzN}E&^vF!wQEUX_parFgQ|o?M?UWWda81yc1}}k=Kt*#@sB6VHY6bZvUh;6Y7EaGN)&Y<9mLB-A$U@~>VmWdHw7u6h0eNRChGCNC#XEC?~Zg_!K zSw$9&$*x9y|cZ(7+AW-WNnD*;}3t|t+$t#Dxe=?%Fl#hR6{Mjkzw%d7yFPyz{ zc52n`c;22V=iW3MyQnnxzp_pN%w%e&feJ)1Lw;VPxzAwx#Z0V}4ipVoDvg3>(Do&8 z<9c=qeKVlSSORIxxpI=YzaZc+bN#o5RGB)QOM=M+IY|j8{vl!3lnA8#GA)K{MG$}# zP!fDHbO{3y)XVBE{oY0L@`ssGg@65wL*j8ppj;LiA}vmtMdow??MX22MhvZ(E=IuKE)8X z*S@u3Qn6P)Hd}q@$D3xWPQ|x%%?OX_smj&Wv(B{>C2ZI6b=dOfPYb?ZfU3=>X4joc zqm@*NN;3vO9U1^IeJn~Mh88GB-lgvhgNU zbk3w2aYf$I<2@4xnS}i+euiXq(~;x#`}=o%rq@xi#xS0zh8|7w zCV!bm`U`YJBW%a$XD1cfSbu}+QDQ{#TU7iJ6)a)nAJf?lDtCTiXTxyIg&EO z{m}jbA{E6nfM3NcS=`UhEf)JpGh95dtC=qT=T=_Jr9*6!v!4~0UpjfKa2X|R&BG>V zYp}@~@(yilxY7RO+Iyz82o5@6w%I$Bjm@&L-C2>1GPzbFeMXa#?_olLZLndGccU2=6Ii=(s@2?-0d^co2CwdRTzZ5Abp=2F6%JO~F^c zz5y-02>~rYXh!TEvAuI?UU2o2mJ5<*~<34#I+?U;4#sMOzMEzyPi7zUjf zFs5B<*o)FL7?I>tpfIj9qqH@%1we(0Pk)elha8AZ}8 zxFOo+MA%6eqw?njQf+t>1NY8~+y@IyIYLh!rGjJ(BK;;gFoLj&RBY6VKf^1dpTg~j zh(o#l)H{}`m9*V}VXjqieBXoamL{w7g!#|0o&nJCH~IK=3OXrC8e1jo(vy08-! z>hUoITKK)R#Ha@_yB|Sf)YXger`EKGdhu|HF%7B#MPWd;$nzX1W__`BTU*V_weuLS za!RUfPki%2e$eSsio(|RS-g(-mVGS7mToTilR zF2D8MvFr8icnhh_k`79L z0i}x0VL|H`PkVE}z=u>^!K!eC0(r3~`ruWJj${uKgCe^>BJW!9|UP% z(wuYtpAu*+u7STM=;%Wa`OclliZiuwXS}3wp|m*XDMi<=8KG6xYK#|8t*M$VtG-@w zebtPx2iIT7yOMYH*@=e9oHzE}6!^||2@D;XUm2>VGm%ms( z(QvhVu5jyY;nrL3jc?|@o;!Is?r!*+?Q40rSFU-j^5x2j=i@7zUd+B~*7nFOo;d%; zYNQ0k9+_%nN%6$M8*8o?&$t>t%rO;JOc!pQ&&9hw#Cs~x{#4=C`%%(|{8@F|#)Ip! zf4DC9U|nv)YFD{WQh}sXU_Po%(3F+21kX3saIF$HBRvlUIE{c{9U0Y0@(x~vh0T&^ zVbeb$t1oGTHIc0mmavHyisT4KJaX8vyl7-ylDbfIF7N8nFrYt5CP@yr@QDj>-lk9V8o;0E}NxIVHmb5o}Z1kUwhP*7(5U3Xxpdo*x(vU1h z7C7lesLqYb7iByt}p03E19FTBgh#d?K_DKaGRf8hJlD*Mb zASjBGWiTQM-7!jneDoBfep0^DvKJL1qIi#R!CwlWK!kaR1~2-9!^8nf2iaGsx^C`ZPSV~3G-5|T zqCkVd3Agh~=JK|@m$&6D%iFo%%KdiUTwdc$UgM1ox0jX7E!#Z3Z1b(clJT`uu8P~P z@~PFw;;!TKc589|<@OJ)ru?QmPE$@ElhFnytZzDBcYY&(MyS@)NeFq91w1<(5?GXTA2T0{lWG2AFc;yn8UKOiLJO; z;WD)7XvUl*o6X8*8zI@{W7^#mEr99g6swkRjPV0agulySq9LO0J?8Wn6~L6*ginY7qglA2Jm(U*(c zsg8>ZLa>sso42=DEW%jhV@=V0DDPO!X7kT&R7C zeIffw_6r493dSw3Wxt<EWW&lR~_-HZIg%LTpXp3;9>_=Y*lwwIyD>YaVflmP>ZDBouz(H$M9tbJ?4xvo|9rX!edvwp%9q7o4AU&Y4Q5 zP4N4hF|E0mwb$&pIxx3l%k+vZ_e}V4?|>z2HlHw$AK@D++(Y^y{Jhs^ead1!Y}V?s zpZ6}>TFkxXt4HRFH%%9Bx@V$a_b!@;&E_8S_}00V+oxAg3Tyl0|c_dLkxXRf>Y NetworkRequestMode -> UserId -> SimplexDoma resolveSimplexName c nm userId domain = withAgentEnv c $ resolveSimplexName' c nm userId domain {-# INLINE resolveSimplexName #-} -simplexNameAvailability :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameAvailability -simplexNameAvailability c nm userId domain = withAgentEnv c $ simplexNameAvailability' c nm userId domain -{-# INLINE simplexNameAvailability #-} +getSimplexNameAvailability :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameAvailability +getSimplexNameAvailability c nm userId domain = withAgentEnv c $ getSimplexNameAvailability' c nm userId domain +{-# INLINE getSimplexNameAvailability #-} getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) getConnLinkPrivKey c = withAgentEnv c . getConnLinkPrivKey' c @@ -1279,10 +1279,10 @@ resolveSimplexName' c nm userId domain = do resolverSrv <- getNextNameServer c userId resolveName c nm userId resolverSrv domain -simplexNameAvailability' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameAvailability -simplexNameAvailability' c nm userId domain = do +getSimplexNameAvailability' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameAvailability +getSimplexNameAvailability' c nm userId domain = do resolverSrv <- getNextNameServer c userId - nameAvailability c nm userId resolverSrv domain + getNameAvailability c nm userId resolverSrv domain changeConnectionUser' :: AgentClient -> UserId -> ConnId -> UserId -> AM () changeConnectionUser' c oldUserId connId newUserId = do diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 7d51ed3e39..4557541af5 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -69,7 +69,7 @@ module Simplex.Messaging.Agent.Client secureGetQueueLink, getQueueLink, resolveName, - nameAvailability, + getNameAvailability, getNextNameServer, enableQueueNotifications, EnableQueueNtfReq (..), @@ -2004,8 +2004,8 @@ resolveName c nm userId server domain = -- | Ask whether a name can be registered, by the same proxy-preferred path as -- `resolveName`. -nameAvailability :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameAvailability -nameAvailability c nm userId server domain = +getNameAvailability :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameAvailability +getNameAvailability c nm userId server domain = snd <$> sendOrProxySMPCommand c nm userId server "" "NAVL" NoEntity availViaProxy availDirectly where availViaProxy smp proxySess = proxyNameAvailability smp nm proxySess domain diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 0e7b6ba2f3..8964d8e0d5 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -110,7 +110,7 @@ import Simplex.Messaging.Server.Env.STM as Env import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Server.MsgStore import Simplex.Messaging.Server.MsgStore.Journal (JournalMsgStore, JournalQueue (..), getJournalQueueMessages) -import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, nameAvailability, resolveName) +import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, getNameAvailability, resolveName) import Simplex.Messaging.Server.MsgStore.STM import Simplex.Messaging.Server.MsgStore.Types import Simplex.Messaging.Server.NtfStore @@ -1499,7 +1499,7 @@ client nameAvailMsg nenv d = do st <- asks (rslvStats . serverStats) (selector, msg) <- - liftIO (nameAvailability nenv d) <&> \case + liftIO (getNameAvailability nenv d) <&> \case Right a -> (rslvSucc, NAVAIL a) Left e -> (rslvResolverErrs, ERR $ NAME e) incStat (selector st) $> msg diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 6e4e905402..50d106d17a 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -11,7 +11,7 @@ module Simplex.Messaging.Server.Names newNamesEnv, closeNamesEnv, pingEndpoint, - nameAvailability, + getNameAvailability, resolveName, ) where @@ -75,8 +75,8 @@ resolveName env d = do -- | Whether a name can be registered. Same timeout and failure handling as -- 'resolveName', which is the other question this server asks the resolver. -nameAvailability :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameAvailability) -nameAvailability env d = do +getNameAvailability :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameAvailability) +getNameAvailability env d = do r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetchAvail env d)) case r of Right result -> pure (fromMaybe (Left (RESOLVER "timeout")) result) @@ -114,7 +114,9 @@ mapAvailability NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsAuctionEnds, "registered" -> Right $ NATaken nsExpires -- registered, but its records point nowhere "noResolver" -> Right $ NATaken nsExpires - s -> Left (RESOLVER s) + -- the resolver's own word for what it could not do, bounded because it is + -- its text, not ours, and it travels to the client inside ERR + s -> Left (RESOLVER (T.take 32 s)) where -- 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 diff --git a/src/Simplex/Messaging/Server/Prometheus.hs b/src/Simplex/Messaging/Server/Prometheus.hs index 575f699c6e..421e0e5d9f 100644 --- a/src/Simplex/Messaging/Server/Prometheus.hs +++ b/src/Simplex/Messaging/Server/Prometheus.hs @@ -465,11 +465,11 @@ prometheusMetrics sm rtm ts = in "# Names\n\ \# -----\n\ \\n\ - \# HELP simplex_smp_names_reqs Total RSLV requests forwarded to this server.\n\ + \# HELP simplex_smp_names_reqs Total RSLV and NAVL requests forwarded to this server.\n\ \# TYPE simplex_smp_names_reqs counter\n\ \simplex_smp_names_reqs " <> mshow _rslvReqs <> "\n# rslvReqs\n\ \\n\ - \# HELP simplex_smp_names_success NameRecord successfully resolved and returned.\n\ + \# HELP simplex_smp_names_success NameRecord resolved, or availability answered.\n\ \# TYPE simplex_smp_names_success counter\n\ \simplex_smp_names_success " <> mshow _rslvSucc <> "\n# rslvSucc\n\ \\n\ @@ -481,7 +481,7 @@ prometheusMetrics sm rtm ts = \# TYPE simplex_smp_names_resolver_errs counter\n\ \simplex_smp_names_resolver_errs " <> mshow _rslvResolverErrs <> "\n# rslvResolverErrs\n\ \\n\ - \# HELP simplex_smp_names_disabled RSLV requests rejected because no resolver is configured (names role off).\n\ + \# HELP simplex_smp_names_disabled RSLV and NAVL requests rejected because no resolver is configured (names role off).\n\ \# TYPE simplex_smp_names_disabled counter\n\ \simplex_smp_names_disabled " <> mshow _rslvDisabled <> "\n# rslvDisabled\n\ \\n" diff --git a/src/Simplex/Messaging/SimplexName.hs b/src/Simplex/Messaging/SimplexName.hs index d07b74205b..b309b661c5 100644 --- a/src/Simplex/Messaging/SimplexName.hs +++ b/src/Simplex/Messaging/SimplexName.hs @@ -97,7 +97,7 @@ 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) + labelHash = decodeLatin1 $ BAE.convertToBase BAE.Base16 (hash (encodeUtf8 (T.toLower domain)) :: Digest Keccak_256) -- | Cap the name at 253 bytes (DNS full-domain limit) boundedNonSpace :: A.Parser ByteString diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index f55faf75f2..04ba926e20 100644 --- a/tests/AgentTests/ResolveNameTests.hs +++ b/tests/AgentTests/ResolveNameTests.hs @@ -23,7 +23,7 @@ import qualified NamesResolverServer as NRS import SMPAgentClient import SMPClient import SMPNamesTests (testNameRecord) -import Simplex.Messaging.Agent (resolveSimplexName) +import Simplex.Messaging.Agent (resolveSimplexName, getSimplexNameAvailability) import Simplex.Messaging.Agent.Client (AgentClient) import Simplex.Messaging.Agent.Env.SQLite (InitialAgentServers (..), ServerCfg, ServerRoles (..), presetServerCfg) import Simplex.Messaging.Agent.Protocol (AgentErrorType (..)) @@ -86,6 +86,34 @@ resolveNameTests = do it "surfaces as SMP host (NAME (RESOLVER ..))" testBackendError describe "success path" $ it "returns NameRecord" testDirectSuccess + describe "name availability" $ do + it "answers through the agent's own server selection" testAvailSuccess + it "answers NAME NO_RESOLVER when the chosen server has none" testAvailNoResolver + it "fails agent-side with NO_NAME_SERVERS when no server has the names role" testAvailNoNameServers + +testAvailSuccess :: HasCallStack => IO () +testAvailSuccess = + withDirectResolver (status404, "{\"error\":\"unregistered\"}") $ \c -> do + r <- runExceptT $ getSimplexNameAvailability c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) + case r of + Right a -> a `shouldBe` SMP.NAVailable + _ -> expectationFailure $ "expected Right NAVailable, got: " <> show r + +testAvailNoResolver :: HasCallStack => IO () +testAvailNoResolver = + withNoResolver $ \c -> do + r <- runExceptT $ getSimplexNameAvailability c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) + case r of + Left (SMP _ (SMP.NAME SMP.NO_RESOLVER)) -> pure () + _ -> expectationFailure $ "expected Left (SMP _ (NAME NO_RESOLVER)), got: " <> show r + +testAvailNoNameServers :: HasCallStack => IO () +testAvailNoNameServers = + withNoNameServers $ \c -> do + r <- runExceptT $ getSimplexNameAvailability c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) + case r of + Left NO_NAME_SERVERS -> pure () + _ -> expectationFailure $ "expected Left NO_NAME_SERVERS, got: " <> show r testDirectNotFound :: HasCallStack => IO () testDirectNotFound = diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 5ddc2e49db..13eb3a294b 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -22,7 +22,7 @@ import Simplex.Messaging.Server.Main (validateUrl) import Simplex.Messaging.Server.Names ( NamesConfig (..), RpcAuth (..), - nameAvailability, + getNameAvailability, newNamesEnv, pingEndpoint, resolveName, @@ -149,12 +149,14 @@ availabilitySpec = do 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 status longer than the error carries is bounded, not passed through" $ + refuses status502 (jsonBody ("{\"error\":\"" <> replicate 400 'e' <> "\"}")) (RESOLVER (T.replicate 32 "e")) it "a body that is not the resolver's JSON is never NOT_FOUND" $ refuses status404 "gateway" (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") + getNameAvailability env navlDomain `shouldReturn` Left (RESOLVER "response too large") it "every answer survives the wire" $ mapM_ (\a -> smpDecode (smpEncode a) `shouldBe` Right a) @@ -177,7 +179,7 @@ availabilitySpec = do asks_ st body expected = withResolverServer (resolveResp st body) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) - nameAvailability env navlDomain `shouldReturn` expected + getNameAvailability env navlDomain `shouldReturn` expected navlDomain = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} parseNameSpec :: Spec From 45d314cfe2786d54b4b3286ef2f9bd7fe523b87f Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sat, 5 Sep 2026 22:52:42 +0200 Subject: [PATCH 04/27] docs shortening and other review fixes --- protocol/simplex-messaging.md | 113 +++++++----------- scripts/resolver/README.md | 91 ++++++-------- scripts/resolver/service/snrc-resolve.py | 35 +++--- scripts/resolver/service/test_snrc_resolve.py | 35 +++--- src/Simplex/Messaging/Agent/Client.hs | 3 +- src/Simplex/Messaging/Client.hs | 35 +++--- src/Simplex/Messaging/Protocol.hs | 18 ++- src/Simplex/Messaging/Server/Names.hs | 46 ++++--- .../Messaging/Server/Names/HttpResolver.hs | 50 ++++---- src/Simplex/Messaging/SimplexName.hs | 18 ++- src/Simplex/Messaging/Transport.hs | 3 +- tests/RSLVTests.hs | 13 +- tests/SMPNamesTests.hs | 37 +++--- 13 files changed, 206 insertions(+), 291 deletions(-) diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index 7f01b82fb0..d87d52210a 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1469,31 +1469,24 @@ 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.** 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. - -**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. -`[].simplex` and `sub.[].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. +**Hashed labels.** The second-level label MAY be given as `[` + 64 lowercase hex ++ `]`, the keccak-256 hash of that label, so a router can answer without being +told the name. This is ENS's encoding for an unknown preimage; brackets are +outside the name character set, so it cannot collide with a real name. A hashed +label is 66 characters and is exempt from the 63-byte label limit — it is a +registry key, not a DNS label. A bare `0x` hex string is an ordinary label, and +would be hashed again, keying a different name. + +Only the second-level label may be hashed; subname labels are needed as text to +reach the record. `[].simplex` and `sub.[].simplex` reach the nodes +their plain names do; a bracket label anywhere else is an ordinary label. Routers +MUST reject a name whose hashed label is not the second-level one. + +From v22 a client MUST hash the second-level label of every `RSLV` and `NAVL`. +Older routers cannot parse the form, so a client on an older session sends the +name. A hashed query's record names the hash; the client restores the name it +used. A router answering a hashed query does not know the name's length, so it +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 @@ -1572,12 +1565,10 @@ arrays are bounded by this overall budget rather than a fixed per-field count. #### Name availability command -`RSLV` answers with a record or `NOT_FOUND`, which conflates situations a client -offering a name to register must tell apart: a name nobody has registered, a -lapsed registration its previous owner may still renew, a name the registry -holds back, and a name registrable right now but not at the ordinary price. -`NAVL` asks that question directly, and takes the same `domain` payload as -`RSLV`, hashed labels included: +`RSLV` answers `NOT_FOUND` for several different cases: never registered, lapsed +but still renewable, held back, and registrable but not at the ordinary price. A +client offering a name to register needs them apart. `NAVL` asks directly, and +takes the same `domain` as `RSLV`, hashed labels included: ```abnf navl = %s"NAVL" SP domain @@ -1609,42 +1600,30 @@ reason = %s"UNSPECIFIED" / %s"TRADEMARK" / %s"PUBLIC_INTEREST" | `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` | -`premium` is a decimal string rather than a wire integer because registry prices -are 256-bit values that fit no fixed-width integer. It is the surcharge alone, -not the total: a router answering a hashed query cannot know the label's length -and so cannot know its ordinary price. The client, which knows the name it -hashed, adds the base price itself. - -All three times are absolute rather than remaining durations, so a client can -render a countdown without re-querying. A client whose clock is wrong renders a wrong -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` 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 ` 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 -`UNSPECIFIED` rather than as "not reserved". - -`NAVL` fails the way `RSLV` does — `ERR NAME NO_RESOLVER` when the router has no -resolver, `ERR NAME RESOLVER ` 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: 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. +`premium` is a decimal string because prices are 256-bit integers. It is the +surcharge only: a router answering a hashed query does not know the label's +length, so it cannot know the base price. The client adds that. + +Times are absolute, not durations, so a client can count down without +re-querying. A deadline is not permission to register; only the registry grants +that. + +A router that cannot read the payload for `GRACE` or `AUCTION` MUST answer +`TAKEN` with no `expires`, never `AVAILABLE`. Quoting the ordinary price for a +name that carries a premium is the harmful answer. + +A router that cannot read the status at all MUST answer `ERR NAME RESOLVER +`. Not `TAKEN`, which asserts a registration it never read, and not +`NOT_FOUND`, which reads as "free". This covers an unreachable chain, an +unconfigured TLD, and any status the router does not recognise. A client MUST +treat an unknown `reason` as `UNSPECIFIED`, not as "not reserved". + +`NAVL` fails as `RSLV` does: `ERR NAME NO_RESOLVER`, or `ERR NAME RESOLVER +`. It is gated on v22 and MUST NOT be sent to a lower version. Like +`RSLV` it is unauthenticated and works directly or in a `PFWD` block; clients +SHOULD use the proxy, because the hash hides the name but only the proxy hides +the IP. Proxies below v22 cannot carry `NAVL`, so during rollout a client that +allows direct fallback reaches the router itself — with the hash, never the name. ## Transport connection with the SMP router diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 87143a6f30..83eee825ac 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -172,48 +172,33 @@ name it sits under. ### The post-grace auction -When grace ends the registrar will sell the name to anyone, but the price -oracle adds a premium that halves each day until it reaches zero. A name in -that window reports `auction` rather than `expired`, with `premium` (a decimal -string of attoUSD, because the value is a 256-bit integer that no JSON number -can hold) and `auctionEnds`. - -The premium depends only on when the registration lapsed, never on the label, -so it is answerable for a labelhash query too. The base price is not: it depends -on the label's length, which a hashed query does not carry. `premium` is -therefore the surcharge alone, and a client that knows its own name adds the -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. 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_` wherever `SNRC_REGISTRAR_` is set, and upgrade this -service before the routers that query it. - -**Upgrade this service before the routers that query it.** Routers from v22 hash -the 2LD of every query, and two things only this version does are needed to -answer them: decoding a bracket label that sits under a subname -(`sub.[].tld`, which an older resolver hashes as literal text and so -answers about a node nobody asked about), and reporting `auction` at all — an -older resolver calls a name in its auction plain `expired`, which reads as "free -at the ordinary price" while the registrar charges the premium. +When grace ends anyone may register the name, but the price oracle adds a +premium that halves each day until it reaches zero. A name in that window +reports `auction` instead of `expired`, with `premium` (attoUSD as a decimal +string, since no JSON number holds a 256-bit integer) and `auctionEnds`. + +The premium depends only on when the registration lapsed, not on the label, so a +labelhash query gets it too. The base price does depend on the label's length, +which a hashed query does not carry, so `premium` is the surcharge alone and the +client adds the base price. + +The oracle comes from the controller's `prices()`, so no extra configuration is +needed. Its window is read from the chain; zero days switches the auction off. +The curve is cached for `AUCTION_PARAMS_TTL` (5 minutes), so a `setPremium` +retune shows up within that; the decaying premium is read on every query. + +**Upgrade this service before the routers that query it.** An older resolver +reports a name in its auction as plain `expired`, which routers read as +"available at the ordinary price" while the registrar charges the premium. It +also fails to decode a bracket label under a subname (`sub.[].tld`), which +routers from v22 send. The same wrong quote happens when the auction cannot be +read at all, so set `SNRC_CONTROLLER_` wherever `SNRC_REGISTRAR_` is. ### Why a name is reserved -`reserved` carries both `reasonCode`, the controller's own reservation reason, -and `reason`, an English sentence for a human reading the REST API. Clients -should branch on `reasonCode` and word it themselves, so the wording follows the -user's language rather than the server's. +`reserved` carries `reasonCode`, the controller's reason, and `reason`, an +English sentence for a human reading this API. Clients should branch on +`reasonCode` and word it themselves, in the user's language. | `reasonCode` | Meaning | |---|---| @@ -224,10 +209,9 @@ user's language rather than the server's. | `internal` | reserved for SimpleX | | `premium` | reserved as a premium name | -A controller deployed before reservation reasons existed stores a plain boolean, -whose `true` reads back as `unspecified`, so nothing needs migrating. A code -this resolver does not know also reads as `unspecified` — the name stays -reserved either way. +A controller from before reasons existed stores a boolean; its `true` reads as +`unspecified`, so nothing needs migrating. An unknown code also reads as +`unspecified` — the name stays reserved either way. ### Querying by labelhash @@ -245,18 +229,15 @@ 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.[].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 -the name or the registry holds it back. Watch out for `noResolver`: it is also a -404, but the name is taken. +Only the second-level label is a registry key, and it is decoded wherever it +sits: `sub.[].testing` reaches the node `sub.name.testing` does. Subname +labels stay text; a bracket label left of the 2LD is an ordinary label. Routers +from v22 send every 2LD this way, so a registrable name normally never reaches +this service. + +Read the answer from `status`. A name is free on `unregistered` (404), and on +`expired` or `auction` (410) — `auction` costs a premium on top. Every other +status means somebody holds the name. Watch `noResolver`: also a 404, but taken. The hash must be keccak-256. `openssl dgst -sha3-256` and `sha3sum` compute SHA3-256, a different function that returns 64 valid-looking hex characters diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 7ada4d98ac..12917c0ae6 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -105,9 +105,8 @@ "simplex": os.environ.get("SNRC_CONTROLLER_SIMPLEX", ""), # not deployed yet } -# `reservedNames` maps a name to SimplexController.Reason; 0 (None) means the -# name is not reserved. A controller predating the enum stores a bool, whose -# `true` decodes as 1 - the same "unspecified" this table already describes. +# `reservedNames` holds a SimplexController.Reason; 0 means not reserved. A +# controller from before the enum stores a bool, whose `true` decodes as 1. RESERVED_REASONS = { 1: ("unspecified", "reserved for a brand or public interest"), 2: ("trademark", "reserved to protect a trademark"), @@ -171,10 +170,9 @@ def is_encoded_labelhash(label: str) -> bool: def node_of(name: str) -> bytes: - """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.""" + """namehash, decoding the 2LD's label as a labelhash wherever it sits, so + `[hash].tld` and `sub.[hash].tld` reach the nodes their names do. A bracket + label anywhere else is hashed as written.""" labels = name.split(".") if len(labels) < 2 or not is_encoded_labelhash(labels[-2]): return namehash(name) @@ -219,10 +217,8 @@ 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. +# The oracle and its curve change only on a retune, so they are read once per +# TTL rather than per query. The premium itself decays, so it is never cached. AUCTION_PARAMS_TTL = 300 _auction_params: dict = {} @@ -249,19 +245,18 @@ def auction_params(tld: str): 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.""" + """Past grace a name is registrable again, but at a premium decaying to zero + over the oracle's window. Returns when the premium reaches zero and what it + is now, in attoUSD, or (None, None) once prices are normal - which includes + an auction switched off with totalDays 0.""" oracle, start, total_days, floor = auction_params(tld) if oracle == ZERO_ADDR: return None, None ends = grace_ends + total_days * 86400 if now >= ends: return None, None - # decayedPremium is `pure`, so the premium quoted here is the oracle's own - # arithmetic rather than a reimplementation of its decay curve. + # decayedPremium is `pure`, so this is the oracle's own arithmetic rather + # than a second copy of its decay curve. decayed = decode_uint( eth_call( oracle, @@ -289,8 +284,8 @@ def name_status(name: str): } # nameExpires and reservedNames are keyed on uint256(keccak(label)). - # Only the 2LD's label is a registry key, wherever in the name it sits, so it - # is the only one decoded - the same rule node_of applies to the node. + # Only the 2LD's label is a registry key, wherever it sits - the same rule + # node_of applies to the node. label = labels[-2] if is_encoded_labelhash(label): token = int(label[1:-1], 16) diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index d2a2296e47..7bc7f9ad08 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -137,8 +137,8 @@ def test_a_plain_name_is_unaffected(self): self.assertEqual(snrc.node_of("alice.testing"), snrc.namehash("alice.testing")) 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.""" + """Only the 2LD is a key, so a bracket label left of it is hashed as + written.""" self.assertNotEqual( snrc.node_of( "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]" @@ -148,8 +148,7 @@ def test_a_bracket_subname_label_stays_literal(self): ) 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.""" + """`sub.[hash].tld` must reach the node `sub.name.tld` does.""" self.assertEqual( snrc.node_of( "sub." @@ -213,7 +212,7 @@ def eth_call(to, data): return eth_call def _keys(self, status, expires, grace_ends): - """Every branch answers with the same keys; only some carry a value.""" + """Every branch answers with the same keys; only some carry values.""" return { "status": status, "expires": expires, @@ -314,8 +313,7 @@ def eth_call(to, data): 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.""" + """The token must come from the hash, not from hashing the brackets.""" seen = [] def eth_call(to, data): @@ -480,16 +478,14 @@ def test_a_trademark_reservation_says_so(self): 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.""" + """Before the enum `reservedNames` was a bool; its `true` decodes as 1.""" 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.""" + """A new Reason still reads 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") @@ -530,9 +526,8 @@ def test_a_hashed_query_gets_the_reason_too(self): class AuctionTests(unittest.TestCase): - """Once grace ends the registrar will sell the name to anyone, but the price - oracle adds a premium that halves each day until it reaches zero. Reporting - such a name as plainly available would quote the normal price for it.""" + """Past grace anyone may register the name, but at a premium that halves + each day. Reporting it as plainly available would quote the normal price.""" REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" @@ -569,9 +564,8 @@ def tearDown(self): ) = self._saved def _chain(self, expires, total_days=TOTAL_DAYS, oracle=None, reserved=0): - """Answers as SimplexController and SimplexPriceOracle do, including the - oracle's own `decayedPremium` shift, so the arithmetic under test is the - resolver's and not a second copy of the decay curve.""" + """Answers as the controller and oracle do, including the oracle's own + `decayedPremium` shift, so the decay curve is not copied here.""" oracle = self.ORACLE if oracle is None else oracle self.oracle_calls = [] @@ -603,8 +597,7 @@ def eth_call(to, data): def _lapsed(self, days_into_auction): """An expiry whose grace ended `days_into_auction` days ago. The extra - second clears the boundary, which the registrar counts as still in - grace.""" + second clears the boundary, which counts as still in grace.""" return self.now - self.GRACE - 1 - days_into_auction * 86400 def test_a_name_just_past_grace_is_in_auction_not_merely_expired(self): @@ -651,8 +644,8 @@ def test_a_name_in_grace_never_reaches_the_oracle(self): 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.""" + """The curve changes only on a retune, 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) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 4557541af5..011ac58999 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -2002,8 +2002,7 @@ resolveName c nm userId server domain = resolveViaProxy smp proxySess = proxyResolveName smp nm proxySess domain resolveDirectly smp = directResolveName smp nm domain --- | Ask whether a name can be registered, by the same proxy-preferred path as --- `resolveName`. +-- | Ask whether a name can be registered. Same path as `resolveName`. getNameAvailability :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameAvailability getNameAvailability c nm userId server domain = snd <$> sendOrProxySMPCommand c nm userId server "" "NAVL" NoEntity availViaProxy availDirectly diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 5b553dacf1..8fc26a6aa8 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -1056,14 +1056,22 @@ proxySMPMessage c nm proxiedRelay spKey sId flags msg = proxyOKSMPCommand c nm p -- through `proxySMPCommand` and pattern-matches the expected RNAME response. -- Version-gated on the destination relay (mirrors `connectSMPProxiedRelay`): -- the client never sends RSLV to a relay that predates names support. +-- | How a name goes on the wire. From v22 the second-level label is sent as its +-- hash; older routers can only parse the name. A hashed query's record names the +-- hash, so callers put back the name they asked for. +queryDomain :: VersionSMP -> SimplexDomain -> SimplexDomain +queryDomain v d = if v >= nameAvailSMPVersion then hashedDomain d else d + 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 (queryDomain (prVersion proxiedRelay) name)) >>= \case - Right (RNAME nr) -> pure $ Right (namedFor name nr) + | v >= namesSMPVersion = + proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (queryDomain v name)) >>= \case + Right (RNAME nr) -> pure $ Right nr {nrName = fullDomainName name} Right r -> throwE $ unexpectedResponse r Left e -> pure $ Left e | otherwise = throwE $ PCETransportError TEVersion + where + v = prVersion proxiedRelay -- | Direct (non-PFWD) name resolution. Exposes the client IP to the resolver; -- callers that want anonymity should use `proxyResolveName` via the standard @@ -1074,26 +1082,13 @@ directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT directResolveName c nm name | v >= namesSMPVersion = sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (queryDomain v name))) >>= \case - RNAME nr -> pure (namedFor name nr) + RNAME nr -> pure nr {nrName = fullDomainName name} 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 --- its own command and its own version gate. +-- | Ask whether a name can be registered, over PFWD. proxyNameAvailability :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameAvailability) proxyNameAvailability c nm proxiedRelay name | prVersion proxiedRelay >= nameAvailSMPVersion = @@ -1103,8 +1098,8 @@ proxyNameAvailability c nm proxiedRelay name Left e -> pure $ Left e | otherwise = throwE $ PCETransportError TEVersion --- | Direct (non-PFWD) availability query, exposing the client IP to the --- resolver exactly as `directResolveName` does. +-- | Direct (non-PFWD) availability query. Exposes the client IP, as +-- `directResolveName` does. directNameAvailability :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameAvailability directNameAvailability c nm name | thVersion (thParams c) >= nameAvailSMPVersion = diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 97632e71d2..98ef27f3d6 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -606,8 +606,7 @@ data Command (p :: Party) where RFWD :: EncFwdTransmission -> Command ProxyService -- use CorrId as CbNonce, proxy to relay -- Resolve SimpleX name. RSLV :: SimplexDomain -> Command Resolver - -- Whether a SimpleX name can be registered. Asked of a labelhash when the - -- client does not want to say which name it is about. + -- Whether a SimpleX name can be registered. NAVL :: SimplexDomain -> Command Resolver deriving instance Show (Command p) @@ -1601,9 +1600,8 @@ data ErrorType DUPLICATE_ -- not part of SMP protocol, used internally deriving (Eq, Show) --- | Whether a name can be registered, and when it cannot, what stands in the --- way. A lapsed registration past its grace period is available again, which is --- the distinction a caller cannot draw from resolution alone. +-- | Whether a name can be registered, and if not, why. Resolution alone cannot +-- tell a lapsed name from a live one. data NameAvailability = NAVailable | -- | registered to someone until this time, absent when the router could not @@ -1611,8 +1609,8 @@ data NameAvailability NATaken {naExpires :: Maybe Int64} | -- | lapsed, and renewable by its previous owner until this time NAInGrace {naGraceEnds :: Int64} - | -- | registrable by anyone, but at a premium, in attoUSD, that decays to - -- nothing by this time - quoting the usual price would understate it + | -- | registrable by anyone, but at a premium in attoUSD that decays to + -- nothing by this time NAAuction {naPremium :: Text, naAuctionEnds :: Int64} | NAReserved {naReason :: NameReservedReason} deriving (Eq, Show) @@ -1633,10 +1631,8 @@ instance Encoding NameAvailability where "RESERVED" -> NAReserved <$> _smpP _ -> fail "bad NameAvailability" --- | Why a name is held back, so the app can word it in the user's language --- instead of showing a sentence chosen by the server. Mirrors the reservation --- reasons the registry controller stores; "not reserved" has no constructor --- here, as it is not an answer this type is used to give. +-- | Why a name is held back, as a code so the app can word it in the user's +-- language. Mirrors the reasons the registry controller stores. data NameReservedReason = NRUnspecified | NRTrademark diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 50d106d17a..d87e9dcfdf 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -73,8 +73,7 @@ resolveName env d = do logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) pure (Left (RESOLVER "resolver error")) --- | Whether a name can be registered. Same timeout and failure handling as --- 'resolveName', which is the other question this server asks the resolver. +-- | Whether a name can be registered. Same timeout handling as 'resolveName'. getNameAvailability :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameAvailability) getNameAvailability env d = do r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetchAvail env d)) @@ -90,37 +89,32 @@ fetchAvail :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameAvailabi fetchAvail NamesEnv {resolverEnv} 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". +-- | NAVL must not fail as NOT_FOUND: a client reads that as "no such name, so +-- it is free". 'mapResolverError' returns it for 404/410/400. 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. --- 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. +-- | The resolver's vocabulary. Only the statuses that describe the name are +-- answers; anything else means it could not answer, and "taken" would assert a +-- registration nobody read. mapAvailability :: NameStatusResp -> Either NameErrorType NameAvailability -mapAvailability NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsAuctionEnds, nsPremium, nsReasonCode} = case nsStatus of - "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 - -- the resolver's own word for what it could not do, bounded because it is - -- its text, not ours, and it travels to the client inside ERR - s -> Left (RESOLVER (T.take 32 s)) +mapAvailability NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsAuctionEnds, nsPremium, nsReasonCode} = + case nsStatus of + "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 + -- the resolver's own words, bounded: they reach the client inside ERR + s -> Left (RESOLVER (T.take 32 s)) where - -- 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, but missing the deadline or price its status carries. Withhold it + -- rather than quote the ordinary price; its expiry is already past. lapsed = NATaken Nothing -- | The controller's reservation reasons, as the resolver spells them. diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index ccf1933a4e..0a54f809fa 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -36,6 +36,7 @@ where import qualified Control.Exception as E import qualified Data.Aeson as J import Data.Aeson.Key (Key) +import qualified Data.Aeson.Types as JT import qualified Data.Aeson.KeyMap as JKM import Data.Bifunctor (first) import qualified Data.ByteArray.Encoding as BAE @@ -82,8 +83,8 @@ data ResolverEnv = ResolverEnv maxResponseBytes :: Int } --- | What the resolver says about a name's registrability. Only some statuses --- carry the fields below the status, so each is read as optional. +-- | What the resolver says about a name. Only some statuses carry the fields +-- below the status. data NameStatusResp = NameStatusResp { nsStatus :: Text, nsExpires :: Maybe Int64, @@ -137,11 +138,9 @@ resolveHttp env name = <$> httpGet env ("/resolve/" <> B.unpack (urlEncode True (encodeUtf8 name))) -- | GET /resolve/, reading what the resolver says about the name --- rather than only whether it answered. The status code alone cannot separate a --- name nobody has taken from one held back, nor a lapsed name still renewable by --- its owner from one anyone may take - that is in the body, under "status" on a --- 200 and "error" otherwise, alongside the deadline or price that status --- carries. +-- rather than only whether it answered. The status code cannot tell an +-- unregistered name from a reserved or lapsed one; that is in the body, under +-- "status" on a 200 and "error" otherwise. availabilityHttp :: ResolverEnv -> Text -> IO (Either ResolverError NameStatusResp) availabilityHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseBytes} name = do req0 <- parseRequest (baseUrl <> "/resolve/" <> B.unpack (urlEncode True (encodeUtf8 name))) @@ -159,35 +158,30 @@ availabilityHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxRespon 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" - } + Just (J.Object o) | Just (J.String t) <- JKM.lookup field o -> Right (statusResp t o) _ -> 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. + where + statusResp t o = + NameStatusResp + { nsStatus = t, + nsExpires = jsonField o "expires", + nsGraceEnds = jsonField o "graceEnds", + nsAuctionEnds = jsonField o "auctionEnds", + nsPremium = jsonField o "premium" >>= decimalPrice, + nsReasonCode = jsonField o "reasonCode" + } + +-- | A price is at most 78 decimal digits. The wire format length-prefixes it +-- with one byte, which would wrap on anything longer, so drop it instead. 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. +-- | A field the resolver omits or nulls for statuses that do not carry it. jsonField :: J.FromJSON a => J.Object -> Key -> Maybe a -jsonField o k = case J.fromJSON <$> JKM.lookup k o of - Just (J.Success v) -> Just v - _ -> Nothing +jsonField o k = JT.parseMaybe J.parseJSON =<< JKM.lookup k o -- | GET /health; success = reachable with status < 400. The body is -- size-capped but NOT decoded — the probe only checks reachability. diff --git a/src/Simplex/Messaging/SimplexName.hs b/src/Simplex/Messaging/SimplexName.hs index b309b661c5..ee0d4a68f2 100644 --- a/src/Simplex/Messaging/SimplexName.hs +++ b/src/Simplex/Messaging/SimplexName.hs @@ -74,12 +74,9 @@ nameLabelP = 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. +-- | A second-level label sent as its keccak256 hash, so a router never learns +-- the name. ENS's bracket form: brackets are outside the name character set, so +-- it cannot collide with a real name. 66 chars, so exempt from the label limit. labelHashP :: AT.Parser Text labelHashP = do hex <- AT.char '[' *> AT.takeWhile1 (\c -> isDigit c || c >= 'a' && c <= 'f') <* AT.char ']' @@ -88,16 +85,15 @@ labelHashP = do 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. +-- | Replace the second-level label with its keccak256 hash, the registry key. +-- Subname labels stay text; a web TLD has no registry. 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 (T.toLower domain)) :: Digest Keccak_256) + keccak = hash (encodeUtf8 (T.toLower domain)) :: Digest Keccak_256 + labelHash = decodeLatin1 (BAE.convertToBase BAE.Base16 keccak) -- | Cap the name at 253 bytes (DNS full-domain limit) boundedNonSpace :: A.Parser ByteString diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 21edf16c1e..4c905094f5 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -209,8 +209,7 @@ namesSMPVersion = VersionSMP 20 serverInfoSMPVersion :: VersionSMP serverInfoSMPVersion = VersionSMP 21 --- | NAVL: whether a name can be registered. A server below this does not know --- the command, so a client must not send it. +-- | NAVL. A server below this does not know the command. nameAvailSMPVersion :: VersionSMP nameAvailSMPVersion = VersionSMP 22 diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 83681cf198..73983421ee 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -224,8 +224,8 @@ testNavlVersion = g <- C.newRandom ts <- getCurrentTime let srv = SMPServer testHost testPort testKeyHash - -- 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 + -- the version just below the gate: a lower ceiling 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 @@ -254,12 +254,11 @@ testNavlForwarded = auctionBody :: LB.ByteString auctionBody = "{\"error\":\"auction\",\"premium\":\"99999952316384526016153087\",\"auctionEnds\":1798191621}" --- keccak-256("alice"), the key the registry is keyed on +-- keccak-256("alice"), the registry key 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. +-- | A current client must never put a registrable name on the wire. resolvePaths :: IORef [[Text]] -> IO [[Text]] resolvePaths reqs = filter isResolve <$> readIORef reqs where @@ -279,10 +278,10 @@ testRslvSendsTheHash = 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 + -- the record names what the caller asked for SMP.nrName nr `shouldBe` "alice.simplex" where - -- the resolver echoes the name it was asked about, which is the hash + -- the resolver echoes what it was asked about, which is the hash echoed = testNameRecord {SMP.nrName = aliceHash <> ".simplex"} testNavlSendsTheHash :: IO () diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 13eb3a294b..6536bd2907 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -120,36 +120,35 @@ availabilitySpec = do answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}" (NAReserved NRTrademark) it "a reserved name with no reason recorded is still reserved" $ answers status404 "{\"error\":\"reserved\"}" (NAReserved NRUnspecified) - it "a reason this server does not know does not lose the reservation" $ + it "an unknown reason still reads as reserved" $ answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"astrology\"}" (NAReserved NRUnspecified) it "a live registration is taken, and says until when" $ answers status200 "{\"status\":\"registered\",\"expires\":1811232000}" (NATaken (Just 1811232000)) it "a registration whose expiry could not be read is still taken" $ answers status200 "{\"status\":\"registered\",\"expires\":null}" (NATaken Nothing) - -- quoting the usual price for a name that costs a premium is the one wrong - -- answer here, so an answer missing its payload withholds the name instead + -- an answer missing its payload withholds the name: quoting the usual price + -- for one that costs a premium is the wrong answer it "grace without its deadline is reported as taken" $ 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 + -- the wire length-prefixes the price 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" + -- a resolver that could not answer must not look like an answer: TAKEN would + -- assert a registration nobody read, NOT_FOUND would read as "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" $ + it "a TLD with no registrar is a resolver error" $ refuses status200 "{\"status\":\"unknown\",\"expires\":null}" (RESOLVER "unknown") - it "a status longer than the error carries is bounded, not passed through" $ + it "a long status is truncated, not passed through" $ refuses status502 (jsonBody ("{\"error\":\"" <> replicate 400 'e' <> "\"}")) (RESOLVER (T.replicate 32 "e")) it "a body that is not the resolver's JSON is never NOT_FOUND" $ refuses status404 "gateway" (RESOLVER "HTTP 404") @@ -174,9 +173,9 @@ availabilitySpec = do ] where 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 = + answers st body a = resolverSays st body (Right a) + refuses st body e = resolverSays st body (Left e) + resolverSays st body expected = withResolverServer (resolveResp st body) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) getNameAvailability env navlDomain `shouldReturn` expected @@ -184,28 +183,24 @@ availabilitySpec = do parseNameSpec :: Spec parseNameSpec = do - -- asking by hash is how a client learns whether a name is taken without - -- saying which name it is asking about + -- asking by hash tells the client if a name is taken without naming it it "accepts a labelhash label" $ parseN ("[" <> T.replicate 64 "b" <> "].simplex") `shouldSatisfy` isRight it "refuses a hash of the wrong width" $ parseN ("[" <> T.replicate 63 "b" <> "].simplex") `shouldSatisfy` isLeft - -- the resolver keys the registry on the bracketed form only; a bare hex string - -- would be hashed again as if it were a name, answering about a different key + -- only the bracketed form is a key; a bare hex string would be hashed again it "refuses a bare hex string in place of a labelhash" $ parseN ("0x" <> T.replicate 64 "b" <> ".simplex") `shouldSatisfy` isLeft 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 + -- only the 2LD is a registry key; subname labels are needed as text 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") @@ -213,7 +208,7 @@ parseNameSpec = do 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" $ + it "leaves a web name alone, it has no registry" $ (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") From c99cc61b696a30b65041fde3e0b86682b6512ccf Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sun, 6 Sep 2026 12:43:30 +0200 Subject: [PATCH 05/27] add catch all for furture variants --- protocol/simplex-messaging.md | 8 ++++++-- src/Simplex/Messaging/Protocol.hs | 11 ++++++++++- src/Simplex/Messaging/Server/Names.hs | 3 ++- tests/SMPNamesTests.hs | 9 +++++++-- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index d87d52210a..94298a897e 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1589,7 +1589,7 @@ grace-ends = 8*8 OCTET ; Int64, network byte order (big-endian), seconds since auction-ends = 8*8 OCTET ; as grace-ends, and follows premium with no separator premium = shortString ; ASCII decimal integer, in attoUSD (1e-18 USD) reason = %s"UNSPECIFIED" / %s"TRADEMARK" / %s"PUBLIC_INTEREST" - / %s"OFFENSIVE" / %s"INTERNAL" / %s"PREMIUM" + / %s"OFFENSIVE" / %s"INTERNAL" / %s"PREMIUM" / %s"UNKNOWN" ``` | Answer | Condition | Client action | @@ -1616,7 +1616,11 @@ A router that cannot read the status at all MUST answer `ERR NAME RESOLVER `. Not `TAKEN`, which asserts a registration it never read, and not `NOT_FOUND`, which reads as "free". This covers an unreachable chain, an unconfigured TLD, and any status the router does not recognise. A client MUST -treat an unknown `reason` as `UNSPECIFIED`, not as "not reserved". +read a `reason` it does not know as `UNKNOWN` and still treat the name as +reserved: a later version may reserve names for reasons this one cannot name, +and losing the reservation over that would offer a name that cannot be +registered. A router sends `UNKNOWN` for a reason its own resolver did not +name. `NAVL` fails as `RSLV` does: `ERR NAME NO_RESOLVER`, or `ERR NAME RESOLVER `. It is gated on v22 and MUST NOT be sent to a lower version. Like diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 98ef27f3d6..34cb863874 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -1640,6 +1640,9 @@ data NameReservedReason | NROffensive | NRInternal | NRPremium + | -- | a reason this version cannot name, so the name stays reserved rather + -- than the answer being lost + NRUnknown deriving (Eq, Show) instance Encoding NameReservedReason where @@ -1650,6 +1653,7 @@ instance Encoding NameReservedReason where NROffensive -> "OFFENSIVE" NRInternal -> "INTERNAL" NRPremium -> "PREMIUM" + NRUnknown -> "UNKNOWN" smpP = A.takeTill (== ' ') >>= \case "UNSPECIFIED" -> pure NRUnspecified @@ -1658,7 +1662,9 @@ instance Encoding NameReservedReason where "OFFENSIVE" -> pure NROffensive "INTERNAL" -> pure NRInternal "PREMIUM" -> pure NRPremium - _ -> fail "bad NameReservedReason" + -- a later version may reserve names for reasons this one has no word for; + -- losing "reserved" over that would be worse than losing the wording + _ -> pure NRUnknown -- | Name resolution error data NameErrorType @@ -2489,3 +2495,6 @@ $(J.deriveJSON defaultJSON ''BlockingInfo) -- run deriveJSON in one TH splice to allow mutual instance $(concat <$> mapM @[] (J.deriveJSON (sumTypeJSON id)) [''ProxyError, ''NameErrorType, ''ErrorType]) + +-- clients report the reason to the user, so it has to reach their API as JSON +$(J.deriveJSON (enumJSON $ dropPrefix "NR") ''NameReservedReason) diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index d87e9dcfdf..d85c06bbaf 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -125,7 +125,8 @@ mapReason = \case "offensive" -> NROffensive "internal" -> NRInternal "premium" -> NRPremium - _ -> NRUnspecified + -- a code this router has no word for: still reserved, just unworded + _ -> NRUnknown fetch :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameRecord) fetch NamesEnv {resolverEnv} d = diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 6536bd2907..777662d2b2 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -120,8 +120,12 @@ availabilitySpec = do answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}" (NAReserved NRTrademark) it "a reserved name with no reason recorded is still reserved" $ answers status404 "{\"error\":\"reserved\"}" (NAReserved NRUnspecified) + -- a later version may name reasons this one cannot; the reservation must + -- survive that, or a client would offer a name it cannot register + it "a reason from a later version still reads as reserved" $ + smpDecode "RESERVED SOMETHING_NEW" `shouldBe` Right (NAReserved NRUnknown) it "an unknown reason still reads as reserved" $ - answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"astrology\"}" (NAReserved NRUnspecified) + answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"astrology\"}" (NAReserved NRUnknown) it "a live registration is taken, and says until when" $ answers status200 "{\"status\":\"registered\",\"expires\":1811232000}" (NATaken (Just 1811232000)) it "a registration whose expiry could not be read is still taken" $ @@ -169,7 +173,8 @@ availabilitySpec = do NAReserved NRPublicInterest, NAReserved NROffensive, NAReserved NRInternal, - NAReserved NRPremium + NAReserved NRPremium, + NAReserved NRUnknown ] where jsonBody = LB.fromStrict . B.pack From 5c480277d6bed73b65efe54c5cd2f2b5afe0046d Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sun, 6 Sep 2026 18:35:02 +0200 Subject: [PATCH 06/27] adversarial review (against simplex-chat) fix --- protocol/simplex-messaging.md | 6 +++--- scripts/resolver/README.md | 11 +++++------ scripts/resolver/service/snrc-resolve.py | 4 +++- scripts/resolver/service/test_snrc_resolve.py | 13 +++++-------- src/Simplex/Messaging/Server/Names.hs | 1 + tests/SMPNamesTests.hs | 5 ++++- 6 files changed, 21 insertions(+), 19 deletions(-) diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index 94298a897e..43da9a3b95 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1486,7 +1486,7 @@ From v22 a client MUST hash the second-level label of every `RSLV` and `NAVL`. Older routers cannot parse the form, so a client on an older session sends the name. A hashed query's record names the hash; the client restores the name it used. A router answering a hashed query does not know the name's length, so it -cannot know its price or whether it meets a minimum-length policy. +cannot check a minimum-length policy either. **Server-side validation.** The names router parses `domain` as a fully-qualified name (TLD required — bare labels are rejected) and forwards it @@ -1601,8 +1601,8 @@ reason = %s"UNSPECIFIED" / %s"TRADEMARK" / %s"PUBLIC_INTEREST" | `RESERVED` | held back by the registry for `reason` | do not offer it; explain `reason` | `premium` is a decimal string because prices are 256-bit integers. It is the -surcharge only: a router answering a hashed query does not know the label's -length, so it cannot know the base price. The client adds that. +surcharge only: the base price depends on the label's length, which a hashed +query does not carry. The client adds that. Times are absolute, not durations, so a client can count down without re-querying. A deadline is not permission to register; only the registry grants diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 83eee825ac..e4f0dfcfce 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -177,10 +177,9 @@ premium that halves each day until it reaches zero. A name in that window reports `auction` instead of `expired`, with `premium` (attoUSD as a decimal string, since no JSON number holds a 256-bit integer) and `auctionEnds`. -The premium depends only on when the registration lapsed, not on the label, so a -labelhash query gets it too. The base price does depend on the label's length, -which a hashed query does not carry, so `premium` is the surcharge alone and the -client adds the base price. +`premium` is the surcharge alone: it depends only on when the registration +lapsed, so a labelhash query gets it, but the base price depends on the label's +length, which a hash does not carry. The client adds that. The oracle comes from the controller's `prices()`, so no extra configuration is needed. Its window is read from the chain; zero days switches the auction off. @@ -208,10 +207,10 @@ English sentence for a human reading this API. Clients should branch on | `offensive` | reserved as an offensive name | | `internal` | reserved for SimpleX | | `premium` | reserved as a premium name | +| `unknown` | a reason added to the contract after this resolver; still reserved | A controller from before reasons existed stores a boolean; its `true` reads as -`unspecified`, so nothing needs migrating. An unknown code also reads as -`unspecified` — the name stays reserved either way. +`unspecified`, so nothing needs migrating. ### Querying by labelhash diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 12917c0ae6..0bc9934ea3 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -115,6 +115,8 @@ 5: ("internal", "reserved for SimpleX"), 6: ("premium", "reserved as a premium name"), } +# a Reason added to the contract after this resolver: still reserved, unworded +UNKNOWN_REASON = ("unknown", "reserved") # SLIP-44 coin types (https://github.com/satoshilabs/slips/blob/master/slip-0044.md) COIN_ETH = 60 @@ -305,7 +307,7 @@ def name_status(name: str): if status in ("unregistered", "expired"): code = reservation_reason(tld, token) if code: - status, reason = "reserved", RESERVED_REASONS.get(code, RESERVED_REASONS[1]) + status, reason = "reserved", RESERVED_REASONS.get(code, UNKNOWN_REASON) elif status == "expired": auction_ends, premium = auction(tld, expires + grace, now) if auction_ends: diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 7bc7f9ad08..0e7987d11f 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -485,11 +485,12 @@ def test_a_controller_storing_a_bool_reads_as_unspecified(self): self.assertEqual(reg["reason"], "reserved for a brand or public interest") def test_an_enum_value_this_resolver_predates_is_not_dropped(self): - """A new Reason still reads as reserved; only the wording falls back.""" + """A new Reason still reads as reserved, and says it is unknown rather + than claiming the chain recorded none.""" snrc.eth_call = self._reserved_as(99) reg = snrc.name_status("acme.testing") self.assertEqual(reg["status"], "reserved") - self.assertEqual(reg["reasonCode"], "unspecified") + self.assertEqual(reg["reasonCode"], "unknown") def test_a_reserved_name_carries_the_reason(self): snrc.eth_call = self._chain(0, True) @@ -601,17 +602,13 @@ def _lapsed(self, days_into_auction): return self.now - self.GRACE - 1 - days_into_auction * 86400 def test_a_name_just_past_grace_is_in_auction_not_merely_expired(self): - snrc.eth_call = self._chain(self._lapsed(0)) + expires = self._lapsed(0) + snrc.eth_call = self._chain(expires) reg = snrc.name_status("acme.testing") self.assertEqual(reg["status"], "auction") self.assertEqual( reg["premium"], str(self.START_PREMIUM - (self.START_PREMIUM >> self.TOTAL_DAYS)) ) - - def test_the_auction_ends_a_full_window_after_grace(self): - expires = self._lapsed(0) - snrc.eth_call = self._chain(expires) - reg = snrc.name_status("acme.testing") self.assertEqual(reg["graceEnds"], expires + self.GRACE) self.assertEqual( reg["auctionEnds"], expires + self.GRACE + self.TOTAL_DAYS * 86400 diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index d85c06bbaf..a533204ff2 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -120,6 +120,7 @@ mapAvailability NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsAuctionEnds, -- | The controller's reservation reasons, as the resolver spells them. mapReason :: Text -> NameReservedReason mapReason = \case + "unspecified" -> NRUnspecified "trademark" -> NRTrademark "publicInterest" -> NRPublicInterest "offensive" -> NROffensive diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 777662d2b2..7ac3a0a18b 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -118,12 +118,15 @@ availabilitySpec = do (NAAuction "99999952316384526016153087" 1798191621) it "a reserved name says why it is held back" $ answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}" (NAReserved NRTrademark) - it "a reserved name with no reason recorded is still reserved" $ + it "a reserved name with no reasonCode at all is still reserved" $ answers status404 "{\"error\":\"reserved\"}" (NAReserved NRUnspecified) -- a later version may name reasons this one cannot; the reservation must -- survive that, or a client would offer a name it cannot register it "a reason from a later version still reads as reserved" $ smpDecode "RESERVED SOMETHING_NEW" `shouldBe` Right (NAReserved NRUnknown) + -- the resolver names this one explicitly; it is not the same as not knowing + it "a reservation the chain recorded no reason for says so" $ + answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"unspecified\"}" (NAReserved NRUnspecified) it "an unknown reason still reads as reserved" $ answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"astrology\"}" (NAReserved NRUnknown) it "a live registration is taken, and says until when" $ From 3b4a7f2295bd81e913096cda63e5ae3651fa8936 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sun, 6 Sep 2026 18:47:23 +0200 Subject: [PATCH 07/27] next iteration fixes --- src/Simplex/Messaging/Server/Names.hs | 2 +- tests/SMPNamesTests.hs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index a533204ff2..6c5faa2fab 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -106,7 +106,7 @@ mapAvailability NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsAuctionEnds, "expired" -> Right NAVailable "grace" -> Right $ maybe lapsed NAInGrace nsGraceEnds "auction" -> Right $ fromMaybe lapsed (NAAuction <$> nsPremium <*> nsAuctionEnds) - "reserved" -> Right $ NAReserved (maybe NRUnspecified mapReason nsReasonCode) + "reserved" -> Right $ NAReserved (maybe NRUnknown mapReason nsReasonCode) "registered" -> Right $ NATaken nsExpires -- registered, but its records point nowhere "noResolver" -> Right $ NATaken nsExpires diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 7ac3a0a18b..2498bda454 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -118,8 +118,9 @@ availabilitySpec = do (NAAuction "99999952316384526016153087" 1798191621) it "a reserved name says why it is held back" $ answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}" (NAReserved NRTrademark) + -- an older resolver sends no reasonCode; that is not the chain saying "none" it "a reserved name with no reasonCode at all is still reserved" $ - answers status404 "{\"error\":\"reserved\"}" (NAReserved NRUnspecified) + answers status404 "{\"error\":\"reserved\"}" (NAReserved NRUnknown) -- a later version may name reasons this one cannot; the reservation must -- survive that, or a client would offer a name it cannot register it "a reason from a later version still reads as reserved" $ From 99adfe9af079dd80b25d63d15fa2ed95155c9cdd Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sun, 6 Sep 2026 19:00:44 +0200 Subject: [PATCH 08/27] adapt house style --- src/Simplex/Messaging/Protocol.hs | 21 ++++------- tests/AgentTests/ResolveNameTests.hs | 6 +-- tests/RSLVTests.hs | 16 ++++---- tests/SMPNamesTests.hs | 56 ++++++++++++++-------------- 4 files changed, 47 insertions(+), 52 deletions(-) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 34cb863874..c59d710fa7 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -1600,19 +1600,17 @@ data ErrorType DUPLICATE_ -- not part of SMP protocol, used internally deriving (Eq, Show) --- | Whether a name can be registered, and if not, why. Resolution alone cannot --- tell a lapsed name from a live one. data NameAvailability - = NAVailable - | -- | registered to someone until this time, absent when the router could not - -- read the registration + = -- | registrable at the ordinary price + NAVailable + | -- | registered, until this time when the router could read it NATaken {naExpires :: Maybe Int64} - | -- | lapsed, and renewable by its previous owner until this time + | -- | lapsed, renewable by its previous owner until this time NAInGrace {naGraceEnds :: Int64} - | -- | registrable by anyone, but at a premium in attoUSD that decays to - -- nothing by this time + | -- | registrable by anyone, at this premium in attoUSD until this time NAAuction {naPremium :: Text, naAuctionEnds :: Int64} - | NAReserved {naReason :: NameReservedReason} + | -- | held back by the registry + NAReserved {naReason :: NameReservedReason} deriving (Eq, Show) instance Encoding NameAvailability where @@ -1631,8 +1629,6 @@ instance Encoding NameAvailability where "RESERVED" -> NAReserved <$> _smpP _ -> fail "bad NameAvailability" --- | Why a name is held back, as a code so the app can word it in the user's --- language. Mirrors the reasons the registry controller stores. data NameReservedReason = NRUnspecified | NRTrademark @@ -1640,8 +1636,7 @@ data NameReservedReason | NROffensive | NRInternal | NRPremium - | -- | a reason this version cannot name, so the name stays reserved rather - -- than the answer being lost + | -- | a reason this version cannot name NRUnknown deriving (Eq, Show) diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index 04ba926e20..eda0d064cf 100644 --- a/tests/AgentTests/ResolveNameTests.hs +++ b/tests/AgentTests/ResolveNameTests.hs @@ -87,9 +87,9 @@ resolveNameTests = do describe "success path" $ it "returns NameRecord" testDirectSuccess describe "name availability" $ do - it "answers through the agent's own server selection" testAvailSuccess - it "answers NAME NO_RESOLVER when the chosen server has none" testAvailNoResolver - it "fails agent-side with NO_NAME_SERVERS when no server has the names role" testAvailNoNameServers + it "answers via agent server selection" testAvailSuccess + it "NAME NO_RESOLVER without a resolver" testAvailNoResolver + it "NO_NAME_SERVERS without a names server" testAvailNoNameServers testAvailSuccess :: HasCallStack => IO () testAvailSuccess = diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 73983421ee..0f45895bfe 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -99,16 +99,16 @@ rslvTests = do describe "RSLV success path (RNAME response)" $ do it "returns RNAME with NameRecord" testRslvSuccess describe "NAVL (availability)" $ do - it "a name nobody has taken comes back AVAILABLE" testNavlAvailable - it "a lapsed name in its auction comes back with the premium and deadline" testNavlAuction - it "a reserved name comes back with the reason it is held back" testNavlReserved + it "unregistered comes back AVAILABLE" testNavlAvailable + it "auction comes back with premium" testNavlAuction + it "reserved comes back with the reason" testNavlReserved 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 + it "refuses NAVL below v22" testNavlVersion + it "PFWD-wrapped NAVL reaches the resolver" 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 + it "RSLV sends the 2LD as its hash" testRslvSendsTheHash + it "NAVL sends the 2LD as its hash" testNavlSendsTheHash + it "subname labels stay text" testSubnameKeepsItsLabels testRslvBackendNotFound :: IO () testRslvBackendNotFound = diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 2498bda454..b041456194 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -105,62 +105,62 @@ errorWireSpec = availabilitySpec :: Spec availabilitySpec = do - it "a name nobody has taken is available" $ + it "unregistered name is available" $ answers status404 "{\"error\":\"unregistered\"}" NAVailable - it "a lapsed name past the auction is available at the usual price" $ + it "expired name is available" $ answers status410 "{\"error\":\"expired\"}" NAVailable - it "a lapsed name still in grace says when its owner loses it" $ + it "name in grace carries graceEnds" $ answers status410 "{\"error\":\"grace\",\"graceEnds\":1796377221}" (NAInGrace 1796377221) - it "a name in the auction after grace carries its premium and deadline" $ + it "auction carries premium and auctionEnds" $ answers status410 "{\"error\":\"auction\",\"premium\":\"99999952316384526016153087\",\"auctionEnds\":1798191621}" (NAAuction "99999952316384526016153087" 1798191621) - it "a reserved name says why it is held back" $ + it "reserved name carries the reason" $ answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}" (NAReserved NRTrademark) -- an older resolver sends no reasonCode; that is not the chain saying "none" - it "a reserved name with no reasonCode at all is still reserved" $ + it "no reasonCode still reads as reserved" $ answers status404 "{\"error\":\"reserved\"}" (NAReserved NRUnknown) -- a later version may name reasons this one cannot; the reservation must -- survive that, or a client would offer a name it cannot register it "a reason from a later version still reads as reserved" $ smpDecode "RESERVED SOMETHING_NEW" `shouldBe` Right (NAReserved NRUnknown) -- the resolver names this one explicitly; it is not the same as not knowing - it "a reservation the chain recorded no reason for says so" $ + it "unspecified reason reads as unspecified" $ answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"unspecified\"}" (NAReserved NRUnspecified) - it "an unknown reason still reads as reserved" $ + it "unknown reason still reads as reserved" $ answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"astrology\"}" (NAReserved NRUnknown) - it "a live registration is taken, and says until when" $ + it "registered name is taken, with expiry" $ answers status200 "{\"status\":\"registered\",\"expires\":1811232000}" (NATaken (Just 1811232000)) - it "a registration whose expiry could not be read is still taken" $ + it "registered without expiry is taken" $ answers status200 "{\"status\":\"registered\",\"expires\":null}" (NATaken Nothing) -- an answer missing its payload withholds the name: quoting the usual price -- for one that costs a premium is the wrong answer - it "grace without its deadline is reported as taken" $ + it "grace without graceEnds is taken" $ answers status410 "{\"error\":\"grace\"}" (NATaken Nothing) - it "an auction without its price is reported as taken" $ + it "auction without premium is taken" $ answers status410 "{\"error\":\"auction\",\"auctionEnds\":1798191621}" (NATaken Nothing) - it "a registered name whose records point nowhere is still taken" $ + it "noResolver is taken" $ answers status404 "{\"error\":\"noResolver\",\"expires\":1811232000}" (NATaken (Just 1811232000)) -- the wire length-prefixes the price 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" $ + it "over-long premium is dropped" $ 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" $ + it "non-decimal premium is dropped" $ answers status410 "{\"error\":\"auction\",\"premium\":\"1e26\",\"auctionEnds\":1798191621}" (NATaken Nothing) -- a resolver that could not answer must not look like an answer: TAKEN would -- assert a registration nobody read, NOT_FOUND would read as "free" - it "an upstream RPC failure is a resolver error, not a taken name" $ + it "upstream failure is a resolver error" $ refuses status502 "{\"error\":\"upstreamError\"}" (RESOLVER "upstreamError") - it "a TLD this resolver has no registry for is a resolver error" $ + it "unconfigured TLD is a resolver error" $ refuses status400 "{\"error\":\"tldNotConfigured\"}" (RESOLVER "tldNotConfigured") - it "a TLD with no registrar is a resolver error" $ + it "unreadable status is a resolver error" $ refuses status200 "{\"status\":\"unknown\",\"expires\":null}" (RESOLVER "unknown") - it "a long status is truncated, not passed through" $ + it "long status is truncated" $ refuses status502 (jsonBody ("{\"error\":\"" <> replicate 400 'e' <> "\"}")) (RESOLVER (T.replicate 32 "e")) - it "a body that is not the resolver's JSON is never NOT_FOUND" $ + it "non-JSON body is never NOT_FOUND" $ refuses status404 "gateway" (RESOLVER "HTTP 404") - it "a body past the configured cap is a resolver error" $ + it "over-cap body is a resolver error" $ withResolverServer (resolveResp status200 (jsonBody ("{\"status\":\"registered\",\"pad\":\"" <> replicate 400 'x' <> "\"}"))) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) {resolverMaxResponseBytes = 200} getNameAvailability env navlDomain `shouldReturn` Left (RESOLVER "response too large") @@ -198,28 +198,28 @@ parseNameSpec = do it "refuses a hash of the wrong width" $ parseN ("[" <> T.replicate 63 "b" <> "].simplex") `shouldSatisfy` isLeft -- only the bracketed form is a key; a bare hex string would be hashed again - it "refuses a bare hex string in place of a labelhash" $ + it "refuses a bare hex string" $ parseN ("0x" <> T.replicate 64 "b" <> ".simplex") `shouldSatisfy` isLeft - it "keeps the brackets, which are what the resolver reads as a hash" $ + it "keeps the brackets" $ (strEncode <$> parseN ("[" <> T.replicate 64 "b" <> "].simplex")) `shouldBe` Right (encodeUtf8 ("[" <> T.replicate 64 "b" <> "].simplex")) -- only the 2LD is a registry key; subname labels are needed as text - it "accepts a hashed second-level label under a subname" $ + it "accepts a hashed 2LD 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" $ + it "refuses a labelhash under a web TLD" $ parseN ("[" <> T.replicate 64 "b" <> "].com") `shouldSatisfy` isLeft -- keccak-256("alice"), the same constant the resolver's own tests use - it "hashes the second-level label to the registry key" $ + it "hashes the 2LD 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, it has no registry" $ + it "leaves a web name alone" $ (fullDomainName . hashedDomain <$> parseN "example.com") `shouldBe` Right "example.com" - it "does not hash a name that is already a hash" $ + it "does not hash a hash" $ (fullDomainName . hashedDomain . hashedDomain <$> parseN "alice.simplex") `shouldBe` Right "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" it "accepts a valid simplex-TLD name" $ From e6dc6c884655029db4c44bd7b8f0d280130b7f2d Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 7 Sep 2026 08:09:43 +0200 Subject: [PATCH 09/27] eth_call guard and cache constants --- scripts/resolver/README.md | 10 +++-- scripts/resolver/service/snrc-resolve.py | 38 +++++++++++++------ scripts/resolver/service/test_snrc_resolve.py | 34 ++++++++++++++--- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index e4f0dfcfce..9d8f0785a6 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -183,8 +183,9 @@ length, which a hash does not carry. The client adds that. The oracle comes from the controller's `prices()`, so no extra configuration is needed. Its window is read from the chain; zero days switches the auction off. -The curve is cached for `AUCTION_PARAMS_TTL` (5 minutes), so a `setPremium` -retune shows up within that; the decaying premium is read on every query. +Deployment constants - the grace period, the oracle and its curve - are cached +for `CONSTANTS_TTL` (5 minutes), so a retune shows up within that. Per-name +values and the decaying premium are read on every query. **Upgrade this service before the routers that query it.** An older resolver reports a name in its auction as plain `expired`, which routers read as @@ -280,7 +281,10 @@ hold the same value, so one field is enough to read. `upstreamError` says only which exception type the RPC call raised. The text goes to the resolver's log instead, because `SNRC_RPC` can carry a provider key -and urlopen puts the URL it failed on into the message. +and urlopen puts the URL it failed on into the message. It is also the answer +when a registrar, controller or oracle address has no contract behind it: the +empty reply is refused rather than read as zero, which would make every name +look free. ### Status codes diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 0bc9934ea3..7cfb01f40d 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -193,9 +193,26 @@ def chain_now() -> int: return decode_uint(block["timestamp"]) +# Deployment constants - the grace period, the oracle and its curve - change only +# when the owner retunes a contract, so they are read once per TTL rather than on +# every query. Per-name values and the decaying premium are never cached. +CONSTANTS_TTL = 300 +_constants: dict = {} + + +def cached(key, read): + """`read()` at most once per CONSTANTS_TTL for `key`.""" + hit = _constants.get(key) + if hit and time.time() - hit[0] < CONSTANTS_TTL: + return hit[1] + value = read() + _constants[key] = (time.time(), value) + return value + + def grace_period(registrar: str) -> int: """A deployment can configure a different window, so it is read on chain.""" - return decode_uint(eth_call(registrar, selector("GRACE_PERIOD()"))) + return cached(("grace", registrar), lambda: decode_uint(eth_call(registrar, selector("GRACE_PERIOD()")))) def expiry_status(expires: int, grace: int, now: int) -> str: @@ -219,18 +236,13 @@ def reservation_reason(tld: str, token: int) -> int: return decode_uint(raw) -# The oracle and its curve change only on a retune, so they are read once per -# TTL rather than per query. The premium itself decays, so it is never cached. -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] + return cached(("auction", tld), lambda: read_auction_params(tld)) + + +def read_auction_params(tld: str): params = (ZERO_ADDR, 0, 0, 0) controller = CONTROLLERS.get(tld) if controller: @@ -242,7 +254,6 @@ def auction_params(tld: str): decode_uint(eth_call(oracle, selector("totalDays()"))), decode_uint(eth_call(oracle, selector("endValue()"))), ) - _auction_params[tld] = (time.time(), params) return params @@ -329,7 +340,10 @@ def selector(signature: str) -> str: def eth_call(to: str, data: str) -> str: - return rpc("eth_call", [{"to": to, "data": data}, "latest"]) + result = rpc("eth_call", [{"to": to, "data": data}, "latest"]) + if result == "0x": + raise RuntimeError(f"empty return from {to}: no contract at that address?") + return result def decode_address(hex_data: str) -> str: diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 0e7987d11f..b764fef8f2 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -97,7 +97,7 @@ def setUp(self): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": ""} snrc.chain_now = lambda: int(time.time()) - snrc._auction_params.clear() + snrc._constants.clear() def tearDown(self): snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved @@ -235,7 +235,7 @@ def setUp(self): # Expiry alone; ReservedTests covers a configured controller. snrc.CONTROLLERS = {"testing": ""} snrc.chain_now = lambda: int(time.time()) - snrc._auction_params.clear() + snrc._constants.clear() def tearDown(self): ( @@ -259,6 +259,28 @@ def test_status_reads_the_chain_clock_not_the_host_clock(self): snrc.chain_now = lambda: future + 3650 * 86400 self.assertEqual(snrc.name_status("alice.testing")["status"], "expired") + def test_a_registrar_that_is_not_a_contract_is_an_error_not_a_free_name(self): + """An address with no code answers eth_call with empty data. Read as + zero, that would make every name look free.""" + snrc.eth_call = self._saved[2] # the real one, so its guard runs + snrc.rpc = lambda method, params: "0x" + with self.assertRaises(RuntimeError): + snrc.name_status("alice.testing") + + def test_the_grace_period_is_read_once_not_per_query(self): + seen = [] + + def eth_call(to, data): + seen.append(data[:10]) + if data.startswith(snrc.selector("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(self.GRACE) + return "0x" + snrc.encode_uint(int(time.time()) - 3600) + + snrc.eth_call = eth_call + snrc.name_status("alice.testing") + snrc.name_status("alice.testing") + self.assertEqual(seen.count(snrc.selector("GRACE_PERIOD()")), 1) + def test_zero_expiry_means_never_registered(self): snrc.eth_call = self._expiry(0) self.assertEqual( @@ -361,7 +383,7 @@ def setUp(self): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": self.CONTROLLER} snrc.chain_now = lambda: int(time.time()) - snrc._auction_params.clear() + snrc._constants.clear() def tearDown(self): snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved @@ -429,7 +451,7 @@ def setUp(self): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": self.CONTROLLER} snrc.chain_now = lambda: int(time.time()) - snrc._auction_params.clear() + snrc._constants.clear() def tearDown(self): ( @@ -553,7 +575,7 @@ def setUp(self): snrc.CONTROLLERS = {"testing": self.CONTROLLER} self.now = int(time.time()) snrc.chain_now = lambda: self.now - snrc._auction_params.clear() + snrc._constants.clear() def tearDown(self): ( @@ -704,7 +726,7 @@ def setUp(self): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": ""} snrc.chain_now = lambda: int(time.time()) - snrc._auction_params.clear() + snrc._constants.clear() def tearDown(self): ( From c2c3bbc5789b1dc068ba966011c712e3997e4067 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 7 Sep 2026 11:01:07 +0200 Subject: [PATCH 10/27] revert NAVL command and wrap it all into RSLV --- protocol/simplex-messaging.md | 147 ++++++++---------- src/Simplex/Messaging/Agent.hs | 17 +- src/Simplex/Messaging/Agent/Client.hs | 14 +- src/Simplex/Messaging/Client.hs | 39 ++--- src/Simplex/Messaging/Protocol.hs | 116 +++++++++----- src/Simplex/Messaging/Server.hs | 22 +-- src/Simplex/Messaging/Server/Names.hs | 82 ++++------ .../Messaging/Server/Names/HttpResolver.hs | 60 +++---- src/Simplex/Messaging/Server/Prometheus.hs | 4 +- src/Simplex/Messaging/Transport.hs | 7 +- tests/AgentTests/ResolveNameTests.hs | 34 +--- tests/RSLVTests.hs | 140 ++++++++--------- tests/SMPNamesTests.hs | 87 ++++++----- 13 files changed, 344 insertions(+), 425 deletions(-) diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index 43da9a3b95..ce484b7506 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -108,7 +108,7 @@ This document describes SMP protocol version 22. Versions 1-5 are discontinued. - v19: service subscriptions to messages (SUBS, NSUBS, SOKS, ENDS, ALLS commands) - v20: public namespaces resolver (RSLV command, RNAME response) — direct or forwarded via PFWD - v21: server public information in handshake -- v22: name availability (NAVL command, NAVAIL response) +- v22: `RNAME` says whether a name can be registered, not only what it resolves to ## Introduction @@ -1482,7 +1482,7 @@ reach the record. `[].simplex` and `sub.[].simplex` reach the nodes their plain names do; a bracket label anywhere else is an ordinary label. Routers MUST reject a name whose hashed label is not the second-level one. -From v22 a client MUST hash the second-level label of every `RSLV` and `NAVL`. +From v22 a client MUST hash the second-level label of every `RSLV`. Older routers cannot parse the form, so a client on an older session sends the name. A hashed query's record names the hash; the client restores the name it used. A router answering a hashed query does not know the name's length, so it @@ -1493,14 +1493,14 @@ fully-qualified name (TLD required — bare labels are rejected) and forwards it to the configured backing resolver, which is the source of truth for which on-chain registry maps to each TLD. -The names router responds with either an `RNAME` response carrying the resolved -record, or an `ERR NAME` error whose subcode a client iterating across several -configured servers can act on distinctly: +The names router responds with either an `RNAME` response saying what it knows +about the name, or an `ERR NAME` error whose subcode a client iterating across +several configured servers can act on distinctly: | Response | Condition | Client action | |---|---|---| -| `RNAME` | record resolved | use it | -| `ERR NAME NOT_FOUND` | name not registered, unknown TLD, or malformed name | authoritative "no such name" — stop | +| `RNAME` | the router read the registry | use it | +| `ERR NAME NOT_FOUND` | unknown TLD or malformed name; below v22 also every name that does not resolve | authoritative "no such name" — stop | | `ERR NAME NO_RESOLVER` | this router has no resolver (names role not enabled) | skip this server, try the next | | `ERR NAME RESOLVER ` | transient failure: backing resolver error (upstream 5xx, transport, timeout, decode) | transient — retry or surface, do not treat as "not found" | @@ -1509,14 +1509,67 @@ router has answered (`NOT_FOUND` or `RESOLVER`), since that router has already seen the lookup key; `NO_RESOLVER` discloses nothing about the name beyond the fact that this router cannot resolve, so iterating past it is safe. -#### Name record response +#### Name response -The `RNAME` response carries a JSON-encoded record as the payload: +Resolving a name and asking whether it can be registered are one question to the +registry, and one lookup answers both: a client offering a taken name to register +wants to show what took it. `RNAME` carries a tag saying which answer follows. ```abnf -rname = %s"RNAME" SP json-bytes ; json-bytes consumes the remainder of the transmission +rname = %s"RNAME" SP answer +answer = %s"RECORD" SP optExpires json-bytes ; json-bytes consumes the remainder + / %s"TAKEN" SP optExpires + / %s"GRACE" SP grace-ends + / %s"AUCTION" SP premium auction-ends + / %s"RESERVED" SP reason + / %s"AVAILABLE" +optExpires = %s"0" / (%s"1" expires) ; absent when the router could not read the registration +expires = 8*8 OCTET ; as grace-ends +grace-ends = 8*8 OCTET ; Int64, network byte order (big-endian), seconds since the Unix epoch +auction-ends = 8*8 OCTET ; as grace-ends, and follows premium with no separator +premium = shortString ; ASCII decimal integer, in attoUSD (1e-18 USD) +reason = %s"UNSPECIFIED" / %s"TRADEMARK" / %s"PUBLIC_INTEREST" + / %s"OFFENSIVE" / %s"INTERNAL" / %s"PREMIUM" / %s"UNKNOWN" ``` +| Answer | Condition | Client action | +|---|---|---| +| `RECORD` | resolves, and the registration runs until `expires` | use the record | +| `TAKEN` | registered until `expires`, but its records point nowhere | 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` | +| `AVAILABLE` | registrable at the ordinary price | offer it | + +Below v22, `RNAME` carries the bare record with no tag and no `expires`, and +every other answer is `ERR NAME NOT_FOUND`, as it was before this version. + +From v22 a client MUST NOT read `ERR NAME NOT_FOUND` as "registrable" — only +`AVAILABLE` says that. `NOT_FOUND` means the router has nothing to say about the +name, which includes a backing resolver whose answer it could not read. + +`premium` is a decimal string because prices are 256-bit integers. It is the +surcharge only: the base price depends on the label's length, which a hashed +query does not carry. The client adds that. + +Times are absolute, not durations, so a client can count down without +re-querying. A deadline is not permission to register; only the registry grants +that. + +A router that cannot read the payload for `GRACE` or `AUCTION` MUST answer +`TAKEN` with no `expires`, never `AVAILABLE`. Quoting the ordinary price for a +name that carries a premium is the harmful answer. + +A router that cannot read the status at all MUST answer `ERR NAME RESOLVER +`. Not `TAKEN`, which asserts a registration it never read, and not +`AVAILABLE`, which offers a name that may be held. This covers an unreachable +chain, an unconfigured TLD, and any status the router does not recognise. + +A client MUST read a `reason` it does not know as `UNKNOWN` and still treat the +name as reserved: a later version may reserve names for reasons this one cannot +name, and losing the reservation over that would offer a name that cannot be +registered. A router sends `UNKNOWN` for a reason its own resolver did not name. + `json-bytes` MUST be a UTF-8 JSON object with the following schema: | Field | JSON type | Constraints | @@ -1541,10 +1594,10 @@ an empty string, not JSON `null` and not an absent key. Link fields empty array `[]` when unset. Coin fields (`eth`, `btc`, `xmr`, `dot`) use JSON `null` as the "unset" sentinel and MAY also be absent from the object entirely. -The backing resolver filters records that are expired or otherwise unavailable -(the names router then returns `ERR NAME NOT_FOUND` to the client), so the wire -format carries no expiry field. Testnet-vs-mainnet status is derived from the -queried TLD rather than an in-record flag. +The backing resolver does not resolve a name whose registration has lapsed; the +router answers `GRACE` or `AUCTION` for those. The record carries no expiry +field of its own — `RECORD` carries it alongside. Testnet-vs-mainnet status is +derived from the queried TLD rather than an in-record flag. Receivers MUST tolerate extra unknown fields (forward-compatibility for future field additions). Adding a required field is a breaking change requiring an @@ -1563,72 +1616,6 @@ re-encoded `RNAME` stays within the SMP proxied transmission budget of 16224 bytes; a response over the cap is rejected as `ERR NAME RESOLVER`. The link arrays are bounded by this overall budget rather than a fixed per-field count. -#### Name availability command - -`RSLV` answers `NOT_FOUND` for several different cases: never registered, lapsed -but still renewable, held back, and registrable but not at the ordinary price. A -client offering a name to register needs them apart. `NAVL` asks directly, and -takes the same `domain` as `RSLV`, hashed labels included: - -```abnf -navl = %s"NAVL" SP domain -``` - -The names router answers `NAVAIL` with exactly one of: - -```abnf -navail = %s"NAVAIL" SP availability -availability = %s"AVAILABLE" - / %s"TAKEN" SP optExpires - / %s"GRACE" SP grace-ends - / %s"AUCTION" SP premium auction-ends - / %s"RESERVED" SP reason -optExpires = %s"0" / (%s"1" expires) ; absent when the router could not read the registration -expires = 8*8 OCTET ; as grace-ends -grace-ends = 8*8 OCTET ; Int64, network byte order (big-endian), seconds since the Unix epoch -auction-ends = 8*8 OCTET ; as grace-ends, and follows premium with no separator -premium = shortString ; ASCII decimal integer, in attoUSD (1e-18 USD) -reason = %s"UNSPECIFIED" / %s"TRADEMARK" / %s"PUBLIC_INTEREST" - / %s"OFFENSIVE" / %s"INTERNAL" / %s"PREMIUM" / %s"UNKNOWN" -``` - -| Answer | Condition | Client action | -|---|---|---| -| `AVAILABLE` | registrable at the ordinary price | 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` | - -`premium` is a decimal string because prices are 256-bit integers. It is the -surcharge only: the base price depends on the label's length, which a hashed -query does not carry. The client adds that. - -Times are absolute, not durations, so a client can count down without -re-querying. A deadline is not permission to register; only the registry grants -that. - -A router that cannot read the payload for `GRACE` or `AUCTION` MUST answer -`TAKEN` with no `expires`, never `AVAILABLE`. Quoting the ordinary price for a -name that carries a premium is the harmful answer. - -A router that cannot read the status at all MUST answer `ERR NAME RESOLVER -`. Not `TAKEN`, which asserts a registration it never read, and not -`NOT_FOUND`, which reads as "free". This covers an unreachable chain, an -unconfigured TLD, and any status the router does not recognise. A client MUST -read a `reason` it does not know as `UNKNOWN` and still treat the name as -reserved: a later version may reserve names for reasons this one cannot name, -and losing the reservation over that would offer a name that cannot be -registered. A router sends `UNKNOWN` for a reason its own resolver did not -name. - -`NAVL` fails as `RSLV` does: `ERR NAME NO_RESOLVER`, or `ERR NAME RESOLVER -`. It is gated on v22 and MUST NOT be sent to a lower version. Like -`RSLV` it is unauthenticated and works directly or in a `PFWD` block; clients -SHOULD use the proxy, because the hash hides the name but only the proxy hides -the IP. Proxies below v22 cannot carry `NAVL`, so during rollout a client that -allows direct fallback reaches the router itself — with the hash, never the name. - ## Transport connection with the SMP router ### General transport protocol considerations diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index b68d8a900b..e0c509019a 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -67,7 +67,6 @@ module Simplex.Messaging.Agent deleteConnShortLink, getConnShortLink, resolveSimplexName, - getSimplexNameAvailability, getConnLinkPrivKey, deleteLocalInvShortLink, changeConnectionUser, @@ -227,8 +226,7 @@ import Simplex.Messaging.Protocol ErrorType (AUTH), MsgBody, MsgFlags (..), - NameAvailability, - NameRecord, + NameResponse, NtfServer, ProtoServerWithAuth (..), ProtocolServer (..), @@ -461,14 +459,10 @@ getConnShortLink c = withAgentEnv c .:. getConnShortLink' c -- | Resolve a SimpleX name (PFWD RSLV). The agent owns server selection: it -- picks a names-capable server (ServerRoles.names) from the user's nameSrvs, so -- chat clients just pass the parsed domain. -resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameRecord +resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameResponse resolveSimplexName c nm userId domain = withAgentEnv c $ resolveSimplexName' c nm userId domain {-# INLINE resolveSimplexName #-} -getSimplexNameAvailability :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameAvailability -getSimplexNameAvailability c nm userId domain = withAgentEnv c $ getSimplexNameAvailability' c nm userId domain -{-# INLINE getSimplexNameAvailability #-} - getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) getConnLinkPrivKey c = withAgentEnv c . getConnLinkPrivKey' c {-# INLINE getConnLinkPrivKey #-} @@ -1274,16 +1268,11 @@ getConnShortLink' c nm userId = \case deleteLocalInvShortLink' :: AgentClient -> ConnShortLink 'CMInvitation -> AM () deleteLocalInvShortLink' c (CSLInvitation _ srv linkId _) = withStore' c $ \db -> deleteInvShortLink db srv linkId -resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameRecord +resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResponse resolveSimplexName' c nm userId domain = do resolverSrv <- getNextNameServer c userId resolveName c nm userId resolverSrv domain -getSimplexNameAvailability' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameAvailability -getSimplexNameAvailability' c nm userId domain = do - resolverSrv <- getNextNameServer c userId - getNameAvailability c nm userId resolverSrv domain - changeConnectionUser' :: AgentClient -> UserId -> ConnId -> UserId -> AM () changeConnectionUser' c oldUserId connId newUserId = do SomeConn _ conn <- withStore c (`getConn` connId) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 011ac58999..e2f9df3288 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -69,7 +69,6 @@ module Simplex.Messaging.Agent.Client secureGetQueueLink, getQueueLink, resolveName, - getNameAvailability, getNextNameServer, enableQueueNotifications, EnableQueueNtfReq (..), @@ -270,8 +269,7 @@ import Simplex.Messaging.Protocol NetworkError (..), MsgFlags (..), MsgId, - NameAvailability, - NameRecord, + NameResponse, NtfServer, NtfServerWithAuth, ProtoServer, @@ -1995,21 +1993,13 @@ getQueueLink c nm userId server lnkId = -- resolver) and falls back to a direct send when the proxy is unavailable -- (faster but exposes the client IP). Mode selection is delegated to -- `sendOrProxySMPCommand`, which honours the network config (SPMNever etc.). -resolveName :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameRecord +resolveName :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameResponse resolveName c nm userId server domain = snd <$> sendOrProxySMPCommand c nm userId server "" "RSLV" NoEntity resolveViaProxy resolveDirectly where resolveViaProxy smp proxySess = proxyResolveName smp nm proxySess domain resolveDirectly smp = directResolveName smp nm domain --- | Ask whether a name can be registered. Same path as `resolveName`. -getNameAvailability :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameAvailability -getNameAvailability c nm userId server domain = - snd <$> sendOrProxySMPCommand c nm userId server "" "NAVL" NoEntity availViaProxy availDirectly - where - availViaProxy smp proxySess = proxyNameAvailability smp nm proxySess domain - availDirectly smp = directNameAvailability smp nm domain - -- | Pick a names-capable server for the user (the agent owns server selection, -- accounting for the names role). nameSrvs is opt-in (a plain list); empty means -- no server resolves names - a declared agent error, never a fallback. diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 8fc26a6aa8..893525e063 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -75,8 +75,6 @@ module Simplex.Messaging.Client proxySMPMessage, proxyResolveName, directResolveName, - proxyNameAvailability, - directNameAvailability, forwardSMPTransmission, getSMPQueueInfo, sendProtocolCommand, @@ -1057,16 +1055,21 @@ proxySMPMessage c nm proxiedRelay spKey sId flags msg = proxyOKSMPCommand c nm p -- Version-gated on the destination relay (mirrors `connectSMPProxiedRelay`): -- the client never sends RSLV to a relay that predates names support. -- | How a name goes on the wire. From v22 the second-level label is sent as its --- hash; older routers can only parse the name. A hashed query's record names the --- hash, so callers put back the name they asked for. +-- hash; older routers can only parse the name. queryDomain :: VersionSMP -> SimplexDomain -> SimplexDomain queryDomain v d = if v >= nameAvailSMPVersion then hashedDomain d else d -proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameRecord) +-- | A hashed query's record names the hash, so put back the name that was asked. +askedName :: SimplexDomain -> NameResponse -> NameResponse +askedName name = \case + r@NRNameRecord {nameRecord} -> r {nameRecord = nameRecord {nrName = fullDomainName name}} + r -> r + +proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameResponse) proxyResolveName c nm proxiedRelay name | v >= namesSMPVersion = proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (queryDomain v name)) >>= \case - Right (RNAME nr) -> pure $ Right nr {nrName = fullDomainName name} + Right (RNAME r) -> pure $ Right (askedName name r) Right r -> throwE $ unexpectedResponse r Left e -> pure $ Left e | otherwise = throwE $ PCETransportError TEVersion @@ -1078,36 +1081,16 @@ proxyResolveName c nm proxiedRelay name -- proxy fallback in the agent. RSLV requires no entity ID or authorization -- (see `noAuthCmd` in Protocol.hs). Version-gated on the session here, not the -- encoder, so an old server never receives RSLV. -directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameRecord +directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameResponse directResolveName c nm name | v >= namesSMPVersion = sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (queryDomain v name))) >>= \case - RNAME nr -> pure nr {nrName = fullDomainName name} + RNAME r -> pure (askedName name r) r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion where v = thVersion (thParams c) --- | Ask whether a name can be registered, over PFWD. -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 (hashedDomain name)) >>= \case - Right (NAVAIL a) -> pure $ Right a - Right r -> throwE $ unexpectedResponse r - Left e -> pure $ Left e - | otherwise = throwE $ PCETransportError TEVersion - --- | Direct (non-PFWD) availability query. Exposes the client IP, as --- `directResolveName` does. -directNameAvailability :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameAvailability -directNameAvailability c nm name - | thVersion (thParams c) >= nameAvailSMPVersion = - sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (NAVL (hashedDomain name))) >>= \case - NAVAIL a -> pure a - r -> throwE $ unexpectedResponse r - | otherwise = throwE $ PCETransportError TEVersion - -- | Acknowledge message delivery (server deletes the message). -- -- https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#acknowledge-message-delivery diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index c59d710fa7..94956f521a 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -80,7 +80,8 @@ module Simplex.Messaging.Protocol ErrorType (..), CommandError (..), ProxyError (..), - NameAvailability (..), + NameResponse (..), + NRTag (..), NameReservedReason (..), NameErrorType (..), BrokerErrorType (..), @@ -606,8 +607,6 @@ data Command (p :: Party) where RFWD :: EncFwdTransmission -> Command ProxyService -- use CorrId as CbNonce, proxy to relay -- Resolve SimpleX name. RSLV :: SimplexDomain -> Command Resolver - -- Whether a SimpleX name can be registered. - NAVL :: SimplexDomain -> Command Resolver deriving instance Show (Command p) @@ -743,9 +742,8 @@ data BrokerMsg where OK :: BrokerMsg ERR :: ErrorType -> BrokerMsg PONG :: BrokerMsg - -- Resolved SimpleX name. - RNAME :: NameRecord -> BrokerMsg - NAVAIL :: NameAvailability -> BrokerMsg + -- What the router knows about a SimpleX name. + RNAME :: NameResponse -> BrokerMsg deriving (Eq, Show) data RcvMessage = RcvMessage @@ -957,7 +955,6 @@ data CommandTag (p :: Party) where NSUB_ :: CommandTag Notifier NSUBS_ :: CommandTag NotifierService RSLV_ :: CommandTag Resolver - NAVL_ :: CommandTag Resolver data CmdTag = forall p. PartyI p => CT (SParty p) (CommandTag p) @@ -985,7 +982,6 @@ data BrokerMsgTag | ERR_ | PONG_ | RNAME_ - | NAVAIL_ deriving (Show) class ProtocolMsgTag t where @@ -1023,7 +1019,6 @@ instance PartyI p => Encoding (CommandTag p) where NSUB_ -> "NSUB" NSUBS_ -> "NSUBS" RSLV_ -> "RSLV" - NAVL_ -> "NAVL" smpP = messageTagP instance ProtocolMsgTag CmdTag where @@ -1053,7 +1048,6 @@ instance ProtocolMsgTag CmdTag where "NSUB" -> Just $ CT SNotifier NSUB_ "NSUBS" -> Just $ CT SNotifierService NSUBS_ "RSLV" -> Just $ CT SResolver RSLV_ - "NAVL" -> Just $ CT SResolver NAVL_ _ -> Nothing instance Encoding CmdTag where @@ -1084,7 +1078,6 @@ instance Encoding BrokerMsgTag where ERR_ -> "ERR" PONG_ -> "PONG" RNAME_ -> "RNAME" - NAVAIL_ -> "NAVAIL" smpP = messageTagP instance ProtocolMsgTag BrokerMsgTag where @@ -1108,7 +1101,6 @@ instance ProtocolMsgTag BrokerMsgTag where "ERR" -> Just ERR_ "PONG" -> Just PONG_ "RNAME" -> Just RNAME_ - "NAVAIL" -> Just NAVAIL_ _ -> Nothing -- | SMP message body format @@ -1600,34 +1592,75 @@ data ErrorType DUPLICATE_ -- not part of SMP protocol, used internally deriving (Eq, Show) -data NameAvailability - = -- | registrable at the ordinary price - NAVailable - | -- | registered, until this time when the router could read it - NATaken {naExpires :: Maybe Int64} +-- | What the router knows about a name. Resolving a name and asking whether it +-- can be registered are the same question to the registry, and a client that +-- offers a taken name to register wants to show what took it. +data NameResponse + = -- | resolves, and the registration runs until this time + NRNameRecord {nameRecord :: NameRecord, expires :: Maybe Int64} + | -- | registered, but its records point nowhere + NRNameTaken {expires :: Maybe Int64} | -- | lapsed, renewable by its previous owner until this time - NAInGrace {naGraceEnds :: Int64} + NRNameInGrace {graceEnds :: Int64} | -- | registrable by anyone, at this premium in attoUSD until this time - NAAuction {naPremium :: Text, naAuctionEnds :: Int64} + NRNameAuction {premium :: Text, auctionEnds :: Int64} | -- | held back by the registry - NAReserved {naReason :: NameReservedReason} + NRNameReserved {reason :: NameReservedReason} + | -- | registrable at the ordinary price + NRNameAvailable deriving (Eq, Show) -instance Encoding NameAvailability where +data NRTag + = NRNameRecord_ + | NRNameTaken_ + | NRNameInGrace_ + | NRNameAuction_ + | NRNameReserved_ + | NRNameAvailable_ + deriving (Show) + +instance Encoding NRTag where + smpEncode = \case + NRNameRecord_ -> "RECORD" + NRNameTaken_ -> "TAKEN" + NRNameInGrace_ -> "GRACE" + NRNameAuction_ -> "AUCTION" + NRNameReserved_ -> "RESERVED" + NRNameAvailable_ -> "AVAILABLE" + smpP = messageTagP + +instance ProtocolMsgTag NRTag where + decodeTag = \case + "RECORD" -> Just NRNameRecord_ + "TAKEN" -> Just NRNameTaken_ + "GRACE" -> Just NRNameInGrace_ + "AUCTION" -> Just NRNameAuction_ + "RESERVED" -> Just NRNameReserved_ + "AVAILABLE" -> Just NRNameAvailable_ + _ -> Nothing + +instance Encoding NameResponse where smpEncode = \case - NAVailable -> "AVAILABLE" - NATaken t -> "TAKEN " <> smpEncode t - NAInGrace t -> "GRACE " <> smpEncode t - NAAuction p t -> "AUCTION " <> smpEncode (p, t) - NAReserved r -> "RESERVED " <> smpEncode r + NRNameRecord {nameRecord, expires} -> e (NRNameRecord_, ' ', expires, Tail $ LB.toStrict $ J.encode nameRecord) + NRNameTaken {expires} -> e (NRNameTaken_, ' ', expires) + NRNameInGrace {graceEnds} -> e (NRNameInGrace_, ' ', graceEnds) + NRNameAuction {premium, auctionEnds} -> e (NRNameAuction_, ' ', premium, auctionEnds) + NRNameReserved {reason} -> e (NRNameReserved_, ' ', reason) + NRNameAvailable -> e NRNameAvailable_ + where + e :: Encoding a => a -> ByteString + e = smpEncode smpP = - A.takeTill (== ' ') >>= \case - "AVAILABLE" -> pure NAVailable - "TAKEN" -> NATaken <$> _smpP - "GRACE" -> NAInGrace <$> _smpP - "AUCTION" -> NAAuction <$> _smpP <*> smpP - "RESERVED" -> NAReserved <$> _smpP - _ -> fail "bad NameAvailability" + smpP >>= \case + NRNameRecord_ -> do + expires <- smpP + nameRecord <- J.eitherDecodeStrict . unTail <$?> smpP + pure NRNameRecord {nameRecord, expires} + NRNameTaken_ -> NRNameTaken <$> smpP + NRNameInGrace_ -> NRNameInGrace <$> smpP + NRNameAuction_ -> NRNameAuction <$> smpP <*> smpP + NRNameReserved_ -> NRNameReserved <$> smpP + NRNameAvailable_ -> pure NRNameAvailable data NameReservedReason = NRUnspecified @@ -1895,7 +1928,6 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where PFWD fwdV pubKey (EncTransmission s) -> e (PFWD_, ' ', fwdV, pubKey, Tail s) RFWD (EncFwdTransmission s) -> e (RFWD_, ' ', Tail s) RSLV d -> e (RSLV_, ' ', d) - NAVL d -> e (NAVL_, ' ', d) where e :: Encoding a => a -> ByteString e = smpEncode @@ -1921,7 +1953,6 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where PFWD {} -> entityCmd RFWD _ -> noAuthCmd RSLV _ -> noAuthCmd - NAVL _ -> noAuthCmd SUB -> serviceCmd NSUB -> serviceCmd -- other client commands must have both signature and queue ID @@ -2004,7 +2035,6 @@ instance ProtocolEncoding SMPVersion ErrorType Cmd where | v >= rcvServiceSMPVersion -> Cmd SNotifierService <$> (NSUBS <$> _smpP <*> smpP) | otherwise -> pure $ Cmd SNotifierService $ NSUBS (-1) mempty CT SResolver RSLV_ -> Cmd SResolver . RSLV <$> _smpP <* A.takeByteString - CT SResolver NAVL_ -> Cmd SResolver . NAVL <$> _smpP <* A.takeByteString fromProtocolError = fromProtocolError @SMPVersion @ErrorType @BrokerMsg {-# INLINE fromProtocolError #-} @@ -2047,8 +2077,12 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where | v < clientNoticesSMPVersion -> BLOCKED info {notice = Nothing} _ -> err PONG -> e PONG_ - RNAME rec -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode rec) - NAVAIL a -> e (NAVAIL_, ' ', a) + RNAME r + | v >= nameAvailSMPVersion -> e (RNAME_, ' ', r) + -- v20/v21 knows only the record, and had NOT_FOUND for every other answer + | otherwise -> case r of + NRNameRecord {nameRecord} -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode nameRecord) + _ -> e (ERR_, ' ', NAME NOT_FOUND) where e :: Encoding a => a -> ByteString e = smpEncode @@ -2095,8 +2129,9 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where OK_ -> pure OK ERR_ -> ERR <$> _smpP PONG_ -> pure PONG - RNAME_ -> fmap RNAME . J.eitherDecodeStrict . unTail <$?> _smpP - NAVAIL_ -> NAVAIL <$> _smpP + RNAME_ + | v >= nameAvailSMPVersion -> RNAME <$> _smpP + | otherwise -> fmap (RNAME . (`NRNameRecord` Nothing)) . J.eitherDecodeStrict . unTail <$?> _smpP where serviceRespP resp | v >= rcvServiceSMPVersion = resp <$> _smpP <*> smpP @@ -2120,7 +2155,6 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where RRES _ -> noEntityMsg ALLS -> noEntityMsg RNAME _ -> noEntityMsg - NAVAIL _ -> noEntityMsg -- other broker responses must have queue ID _ | B.null entId -> Left $ CMD NO_ENTITY diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 8964d8e0d5..adf096a3a6 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -110,7 +110,7 @@ import Simplex.Messaging.Server.Env.STM as Env import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Server.MsgStore import Simplex.Messaging.Server.MsgStore.Journal (JournalMsgStore, JournalQueue (..), getJournalQueueMessages) -import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, getNameAvailability, resolveName) +import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, resolveName) import Simplex.Messaging.Server.MsgStore.STM import Simplex.Messaging.Server.MsgStore.Types import Simplex.Messaging.Server.NtfStore @@ -1277,7 +1277,6 @@ verifyQueueTransmission service thAuth (tAuth, authorized, (corrId, entId, comma vc SProxiedClient _ = VRVerified Nothing vc SProxyService (RFWD _) = VRVerified Nothing vc SResolver (RSLV _) = VRVerified Nothing - vc SResolver (NAVL _) = VRVerified Nothing checkRole = case (service, partyClientRole p) of (Just THClientService {serviceRole}, Just role) -> serviceRole == role _ -> True @@ -1495,20 +1494,12 @@ client Just nenv -> pure (Just nenv) -- 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 = do - st <- asks (rslvStats . serverStats) - (selector, msg) <- - liftIO (getNameAvailability 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) (selector, msg) <- liftIO (resolveName nenv d) <&> \case - Right rec -> (rslvSucc, RNAME rec) + Right r -> (rslvSucc, RNAME r) Left e@NOT_FOUND -> (rslvNotFound, ERR $ NAME e) Left e -> (rslvResolverErrs, ERR $ NAME e) incStat (selector st) $> msg @@ -1529,9 +1520,6 @@ client Cmd SResolver (RSLV d) -> rslvNamesEnv >>= \case Nothing -> pure $ response (corrId, NoEntity, ERR (NAME NO_RESOLVER)) Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity (resolveNameMsg nenv d) - Cmd SResolver (NAVL d) -> rslvNamesEnv >>= \case - Nothing -> pure $ response (corrId, NoEntity, ERR (NAME NO_RESOLVER)) - Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity (nameAvailMsg nenv d) Cmd SSenderLink command -> case command of LKEY k -> withQueue $ \q qr -> checkMode QMMessaging qr $ secureQueue_ q k $>> getQueueLink_ q qr LGET -> withQueue $ \q qr -> checkContact qr $ getQueueLink_ q qr @@ -2164,11 +2152,6 @@ client Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity $ do msg <- resolveNameMsg nenv d either ERR id <$> runExceptT (encodeResp (corrId', entId', msg)) - Cmd SResolver (NAVL d) -> lift $ rslvNamesEnv >>= \case - Nothing -> pure $ Just (corrId', entId', ERR (NAME NO_RESOLVER)) - Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity $ do - msg <- nameAvailMsg nenv d - either ERR id <$> runExceptT (encodeResp (corrId', entId', msg)) -- INTERNAL because processCommand never returns Nothing for sender commands; -- `fst` drops the empty message only returned for SUB. _ -> Just . maybe (corrId', entId', ERR INTERNAL) fst <$> lift (processCommand Nothing (Right (M.empty, M.empty, M.empty)) t'') @@ -2189,7 +2172,6 @@ client Cmd SSenderLink (LKEY _) -> True Cmd SSenderLink LGET -> True Cmd SResolver (RSLV _) -> True - Cmd SResolver (NAVL _) -> True _ -> False verified = \case VRVerified q -> Right (q, t'') diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 6c5faa2fab..0436c4bb45 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -11,24 +11,21 @@ module Simplex.Messaging.Server.Names newNamesEnv, closeNamesEnv, pingEndpoint, - getNameAvailability, resolveName, ) where import qualified Control.Exception as E import Control.Logger.Simple (logError) -import Data.Bifunctor (first) import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T -import Simplex.Messaging.Protocol (NameAvailability (..), NameErrorType (..), NameRecord, NameReservedReason (..)) +import Simplex.Messaging.Protocol (NameErrorType (..), NameRecord, NameReservedReason (..), NameResponse (..)) import Simplex.Messaging.Server.Names.HttpResolver ( NameStatusResp (..), ResolverEnv, ResolverError (..), RpcAuth (..), - availabilityHttp, closeResolverEnv, healthHttp, newResolverEnv, @@ -62,7 +59,7 @@ pingEndpoint :: NamesEnv -> IO (Either ResolverError ()) pingEndpoint NamesEnv {resolverEnv, config} = fromMaybe (Left ResolverTimeout) <$> timeout (resolverTimeoutMs config * 1000) (healthHttp resolverEnv) -resolveName :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameRecord) +resolveName :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResponse) resolveName env d = do r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetch env d)) case r of @@ -73,50 +70,6 @@ resolveName env d = do logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) pure (Left (RESOLVER "resolver error")) --- | Whether a name can be registered. Same timeout handling as 'resolveName'. -getNameAvailability :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameAvailability) -getNameAvailability env d = do - r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetchAvail env d)) - case r of - Right result -> pure (fromMaybe (Left (RESOLVER "timeout")) result) - Left e - | Just (_ :: E.SomeAsyncException) <- E.fromException e -> E.throwIO e - | otherwise -> do - logError $ "[NAMES] resolver availability raised " <> T.pack (E.displayException e) - pure (Left (RESOLVER "resolver error")) - -fetchAvail :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameAvailability) -fetchAvail NamesEnv {resolverEnv} d = - either (Left . mapAvailError) mapAvailability <$> availabilityHttp resolverEnv (fullDomainName d) - --- | NAVL must not fail as NOT_FOUND: a client reads that as "no such name, so --- it is free". 'mapResolverError' returns it for 404/410/400. -mapAvailError :: ResolverError -> NameErrorType -mapAvailError = \case - HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code)) - e -> mapResolverError e - --- | The resolver's vocabulary. Only the statuses that describe the name are --- answers; anything else means it could not answer, and "taken" would assert a --- registration nobody read. -mapAvailability :: NameStatusResp -> Either NameErrorType NameAvailability -mapAvailability NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsAuctionEnds, nsPremium, nsReasonCode} = - case nsStatus of - "unregistered" -> Right NAVailable - "expired" -> Right NAVailable - "grace" -> Right $ maybe lapsed NAInGrace nsGraceEnds - "auction" -> Right $ fromMaybe lapsed (NAAuction <$> nsPremium <*> nsAuctionEnds) - "reserved" -> Right $ NAReserved (maybe NRUnknown mapReason nsReasonCode) - "registered" -> Right $ NATaken nsExpires - -- registered, but its records point nowhere - "noResolver" -> Right $ NATaken nsExpires - -- the resolver's own words, bounded: they reach the client inside ERR - s -> Left (RESOLVER (T.take 32 s)) - where - -- lapsed, but missing the deadline or price its status carries. Withhold it - -- rather than quote the ordinary price; its expiry is already past. - lapsed = NATaken Nothing - -- | The controller's reservation reasons, as the resolver spells them. mapReason :: Text -> NameReservedReason mapReason = \case @@ -129,9 +82,36 @@ mapReason = \case -- a code this router has no word for: still reserved, just unworded _ -> NRUnknown -fetch :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameRecord) +fetch :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResponse) fetch NamesEnv {resolverEnv} d = - first mapResolverError <$> resolveHttp resolverEnv (fullDomainName d) + either (Left . mapResolverError) nameResponse <$> resolveHttp resolverEnv (fullDomainName d) + +-- | A record answers on its own; the status only dates it. Without a record the +-- status is the whole answer, and a status this router has no word for is not +-- one - "taken" would assert a registration nobody read. +nameResponse :: (Maybe NameRecord, Maybe NameStatusResp) -> Either NameErrorType NameResponse +nameResponse = \case + (Just nameRecord, ns_) -> Right NRNameRecord {nameRecord, expires = nsExpires =<< ns_} + (Nothing, Just ns) -> mapStatus ns + (Nothing, Nothing) -> Left NOT_FOUND + +-- | The resolver's vocabulary for a name that does not resolve. +mapStatus :: NameStatusResp -> Either NameErrorType NameResponse +mapStatus NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsAuctionEnds, nsPremium, nsReasonCode} = + case nsStatus of + "unregistered" -> Right NRNameAvailable + "expired" -> Right NRNameAvailable + "grace" -> Right $ maybe lapsed NRNameInGrace nsGraceEnds + "auction" -> Right $ fromMaybe lapsed (NRNameAuction <$> nsPremium <*> nsAuctionEnds) + "reserved" -> Right $ NRNameReserved (maybe NRUnknown mapReason nsReasonCode) + -- registered, but its records point nowhere + "noResolver" -> Right $ NRNameTaken nsExpires + -- the resolver's own words, bounded: they reach the client inside ERR + s -> Left (RESOLVER (T.take 32 s)) + where + -- lapsed, but missing the deadline or price its status carries. Withhold it + -- rather than quote the ordinary price; its expiry is already past. + lapsed = NRNameTaken Nothing mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index 0a54f809fa..4da4ce2df2 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -3,14 +3,16 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} +{-# LANGUAGE TupleSections #-} -- | HTTP transport for the public-namespace resolver. -- -- The Python REST resolver (see scripts/resolver/snrc-resolve.py) exposes -- -- GET /resolve/ -> 200 with a NameRecord JSON document --- 404 / 400 for unknown names / TLDs --- 502 for upstream RPC failures +-- 404 / 410 for names that do not resolve, the body +-- saying why (reserved, lapsed, never registered) +-- 400 for unknown TLDs, 502 for upstream RPC failures -- GET /health -> 200 when the resolver process is ready -- -- Boundary properties: @@ -27,7 +29,6 @@ module Simplex.Messaging.Server.Names.HttpResolver NameStatusResp (..), newResolverEnv, closeResolverEnv, - availabilityHttp, resolveHttp, healthHttp, ) @@ -128,21 +129,15 @@ authHeader = \case let encoded = BAE.convertToBase BAE.Base64 (encodeUtf8 u <> ":" <> encodeUtf8 p) :: ByteString in ("Authorization", "Basic " <> encoded) --- | GET /resolve/, decoding the 200 body --- directly into a NameRecord in one pass (no intermediate Aeson Value). The --- name is percent-encoded (every non-unreserved byte per RFC 3986): the --- resolver expects raw labels, so slashes/punctuation must not alter the path. -resolveHttp :: ResolverEnv -> Text -> IO (Either ResolverError NameRecord) -resolveHttp env name = - (>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict) - <$> httpGet env ("/resolve/" <> B.unpack (urlEncode True (encodeUtf8 name))) - --- | GET /resolve/, reading what the resolver says about the name --- rather than only whether it answered. The status code cannot tell an --- unregistered name from a reserved or lapsed one; that is in the body, under --- "status" on a 200 and "error" otherwise. -availabilityHttp :: ResolverEnv -> Text -> IO (Either ResolverError NameStatusResp) -availabilityHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseBytes} name = do +-- | GET /resolve/, returning the record when the +-- name resolves and what the resolver says about the name either way. The +-- status code cannot tell an unregistered name from a reserved or lapsed one; +-- that is in the body, under "status" on a 200 and "error" otherwise. Older +-- resolvers omit it, hence the Maybe. The name is percent-encoded (every +-- non-unreserved byte per RFC 3986): the resolver expects raw labels, so +-- slashes/punctuation must not alter the path. +resolveHttp :: ResolverEnv -> Text -> IO (Either ResolverError (Maybe NameRecord, Maybe NameStatusResp)) +resolveHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseBytes} name = do req0 <- parseRequest (baseUrl <> "/resolve/" <> B.unpack (urlEncode True (encodeUtf8 name))) let req = req0 @@ -152,25 +147,30 @@ availabilityHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxRespon } 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) (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 (statusResp t o) - _ -> Left (HttpStatusErr status) + Just v@(J.Object o) + | status < 400 -> (,statusResp o "status") . Just <$> first InvalidJson (JT.parseEither J.parseJSON v) + | otherwise -> maybe (Left $ HttpStatusErr status) (Right . (Nothing,) . Just) (statusResp o "error") + _ + | status < 400 -> Left (InvalidJson "not a JSON object") + | otherwise -> Left (HttpStatusErr status) pure (either (Left . HttpFailure) id result) where - statusResp t o = - NameStatusResp - { nsStatus = t, - nsExpires = jsonField o "expires", - nsGraceEnds = jsonField o "graceEnds", - nsAuctionEnds = jsonField o "auctionEnds", - nsPremium = jsonField o "premium" >>= decimalPrice, - nsReasonCode = jsonField o "reasonCode" - } + statusResp o field = mkResp <$> jsonField o field + where + mkResp t = + NameStatusResp + { nsStatus = t, + nsExpires = jsonField o "expires", + nsGraceEnds = jsonField o "graceEnds", + nsAuctionEnds = jsonField o "auctionEnds", + nsPremium = jsonField o "premium" >>= decimalPrice, + nsReasonCode = jsonField o "reasonCode" + } -- | A price is at most 78 decimal digits. The wire format length-prefixes it -- with one byte, which would wrap on anything longer, so drop it instead. diff --git a/src/Simplex/Messaging/Server/Prometheus.hs b/src/Simplex/Messaging/Server/Prometheus.hs index 421e0e5d9f..201f88426d 100644 --- a/src/Simplex/Messaging/Server/Prometheus.hs +++ b/src/Simplex/Messaging/Server/Prometheus.hs @@ -465,7 +465,7 @@ prometheusMetrics sm rtm ts = in "# Names\n\ \# -----\n\ \\n\ - \# HELP simplex_smp_names_reqs Total RSLV and NAVL requests forwarded to this server.\n\ + \# HELP simplex_smp_names_reqs Total RSLV requests forwarded to this server.\n\ \# TYPE simplex_smp_names_reqs counter\n\ \simplex_smp_names_reqs " <> mshow _rslvReqs <> "\n# rslvReqs\n\ \\n\ @@ -481,7 +481,7 @@ prometheusMetrics sm rtm ts = \# TYPE simplex_smp_names_resolver_errs counter\n\ \simplex_smp_names_resolver_errs " <> mshow _rslvResolverErrs <> "\n# rslvResolverErrs\n\ \\n\ - \# HELP simplex_smp_names_disabled RSLV and NAVL requests rejected because no resolver is configured (names role off).\n\ + \# HELP simplex_smp_names_disabled RSLV requests rejected because no resolver is configured (names role off).\n\ \# TYPE simplex_smp_names_disabled counter\n\ \simplex_smp_names_disabled " <> mshow _rslvDisabled <> "\n# rslvDisabled\n\ \\n" diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 4c905094f5..dc7f515aee 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -172,7 +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) +-- 22 - RNAME answers name availability as well as the record (7/25/2026) data SMPVersion @@ -209,7 +209,8 @@ namesSMPVersion = VersionSMP 20 serverInfoSMPVersion :: VersionSMP serverInfoSMPVersion = VersionSMP 21 --- | NAVL. A server below this does not know the command. +-- | RNAME carries availability. A server below this answers RSLV with the +-- record alone, and ERR NAME NOT_FOUND for a name that does not resolve. nameAvailSMPVersion :: VersionSMP nameAvailSMPVersion = VersionSMP 22 @@ -229,7 +230,7 @@ currentServerSMPRelayVersion = VersionSMP 22 -- client and server, as defined by SMP proxy. Normally set below the current -- version to prevent client version fingerprinting by the destination relays -- when clients upgrade at different times. Pinned to the current version (22) --- for this release because proxied name availability is gated on +-- for this release because a proxied RSLV only carries availability from -- nameAvailSMPVersion (22), so the one-version anti-fingerprinting buffer does -- not apply yet; it reappears once the current version advances past 22. proxiedSMPRelayVersion :: VersionSMP diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index eda0d064cf..f3899a4e40 100644 --- a/tests/AgentTests/ResolveNameTests.hs +++ b/tests/AgentTests/ResolveNameTests.hs @@ -23,7 +23,7 @@ import qualified NamesResolverServer as NRS import SMPAgentClient import SMPClient import SMPNamesTests (testNameRecord) -import Simplex.Messaging.Agent (resolveSimplexName, getSimplexNameAvailability) +import Simplex.Messaging.Agent (resolveSimplexName) import Simplex.Messaging.Agent.Client (AgentClient) import Simplex.Messaging.Agent.Env.SQLite (InitialAgentServers (..), ServerCfg, ServerRoles (..), presetServerCfg) import Simplex.Messaging.Agent.Protocol (AgentErrorType (..)) @@ -86,34 +86,16 @@ resolveNameTests = do it "surfaces as SMP host (NAME (RESOLVER ..))" testBackendError describe "success path" $ it "returns NameRecord" testDirectSuccess - describe "name availability" $ do - it "answers via agent server selection" testAvailSuccess - it "NAME NO_RESOLVER without a resolver" testAvailNoResolver - it "NO_NAME_SERVERS without a names server" testAvailNoNameServers + describe "name availability" $ + it "an unregistered name answers AVAILABLE" testAvailSuccess testAvailSuccess :: HasCallStack => IO () testAvailSuccess = withDirectResolver (status404, "{\"error\":\"unregistered\"}") $ \c -> do - r <- runExceptT $ getSimplexNameAvailability c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) - case r of - Right a -> a `shouldBe` SMP.NAVailable - _ -> expectationFailure $ "expected Right NAVailable, got: " <> show r - -testAvailNoResolver :: HasCallStack => IO () -testAvailNoResolver = - withNoResolver $ \c -> do - r <- runExceptT $ getSimplexNameAvailability c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) - case r of - Left (SMP _ (SMP.NAME SMP.NO_RESOLVER)) -> pure () - _ -> expectationFailure $ "expected Left (SMP _ (NAME NO_RESOLVER)), got: " <> show r - -testAvailNoNameServers :: HasCallStack => IO () -testAvailNoNameServers = - withNoNameServers $ \c -> do - r <- runExceptT $ getSimplexNameAvailability c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) + r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of - Left NO_NAME_SERVERS -> pure () - _ -> expectationFailure $ "expected Left NO_NAME_SERVERS, got: " <> show r + Right a -> a `shouldBe` SMP.NRNameAvailable + _ -> expectationFailure $ "expected Right NRNameAvailable, got: " <> show r testDirectNotFound :: HasCallStack => IO () testDirectNotFound = @@ -176,5 +158,5 @@ testDirectSuccess = withDirectResolver (status200, J.encode testNameRecord) $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of - Right nr -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right NameRecord, got: " <> show r + Right (SMP.NRNameRecord nr _) -> nr `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (NRNameRecord ..), got: " <> show r diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 0f45895bfe..9b39c3b490 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -34,7 +34,7 @@ import Simplex.Messaging.Protocol Command (..), CorrId (..), ErrorType (..), - NameAvailability (..), + NameResponse (..), NameErrorType (..), NameReservedReason (..), SParty (..), @@ -78,13 +78,6 @@ sendRslv h@THandle {params} corrId d = do r :| _ <- tGetClient h pure r -sendNavl :: Transport c => THandleSMP c 'TClient -> B.ByteString -> SimplexDomain -> IO (Transmission (Either ErrorType BrokerMsg)) -sendNavl h@THandle {params} corrId d = do - let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (NAVL d)) - [Right ()] <- tPut h (Right (Nothing, tToSend) :| []) - r :| _ <- tGetClient h - pure r - rslvTests :: Spec rslvTests = do describe "RSLV direct (non-forwarded)" $ do @@ -98,16 +91,16 @@ rslvTests = do it "PFWD-wrapped RSLV success returns RNAME (record JSON frames over the proxy)" testRslvForwardedSuccess describe "RSLV success path (RNAME response)" $ do it "returns RNAME with NameRecord" testRslvSuccess - describe "NAVL (availability)" $ do - it "unregistered comes back AVAILABLE" testNavlAvailable - it "auction comes back with premium" testNavlAuction - it "reserved comes back with the reason" testNavlReserved - it "no names config -> NAME NO_RESOLVER" testNavlDisabled - it "refuses NAVL below v22" testNavlVersion - it "PFWD-wrapped NAVL reaches the resolver" testNavlForwarded + describe "RSLV availability (RNAME response)" $ do + it "unregistered comes back AVAILABLE" testRslvAvailable + it "auction comes back with premium" testRslvAuction + it "reserved comes back with the reason" testRslvReserved + it "PFWD-wrapped auction reaches the resolver" testRslvForwardedAuction + describe "RSLV below v22" $ do + it "still resolves a name to its record" testRslvOldClientRecord + it "still answers NAME NOT_FOUND for a name that does not resolve" testRslvOldClientNotFound describe "hashed lookups" $ do it "RSLV sends the 2LD as its hash" testRslvSendsTheHash - it "NAVL sends the 2LD as its hash" testNavlSendsTheHash it "subname labels stay text" testSubnameKeepsItsLabels testRslvBackendNotFound :: IO () @@ -153,7 +146,7 @@ testRslvVersion = Left (PCETransportError TEVersion) -> pure () _ -> expectationFailure $ "expected Left (PCETransportError TEVersion), got: " <> show r -forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameRecord)) +forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameResponse)) forwardedResolveAlice = do g <- C.newRandom ts <- getCurrentTime @@ -176,8 +169,8 @@ testRslvForwardedSuccess :: IO () testRslvForwardedSuccess = withProxyAndResolver (status200, J.encode testNameRecord) $ forwardedResolveAlice >>= \r -> case r of - Right (Right nr) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (Right NameRecord), got: " <> show r + Right (Right (NRNameRecord nr _)) -> nr `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (Right (NRNameRecord ..)), got: " <> show r testRslvSuccess :: IO () testRslvSuccess = @@ -186,69 +179,66 @@ testRslvSuccess = (corrId, _entId, resp) <- sendRslv h "rs07" (domain "alice.simplex") corrId `shouldBe` CorrId "rs07" case resp of - Right (RNAME nr) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (RNAME ..), got: " <> show resp + Right (RNAME (NRNameRecord nr _)) -> nr `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (RNAME (NRNameRecord ..)), got: " <> show resp -testNavlAvailable :: IO () -testNavlAvailable = +testRslvAvailable :: IO () +testRslvAvailable = withResolverServer (status404, "{\"error\":\"unregistered\"}") $ testSMPClient @TLS $ \h -> do - (corrId, _entId, resp) <- sendNavl h "na01" (domain "ghost.simplex") + (corrId, _entId, resp) <- sendRslv h "na01" (domain "ghost.simplex") corrId `shouldBe` CorrId "na01" - resp `shouldBe` Right (NAVAIL NAVailable) + resp `shouldBe` Right (RNAME NRNameAvailable) -testNavlAuction :: IO () -testNavlAuction = +testRslvAuction :: IO () +testRslvAuction = withResolverServer (status410, auctionBody) $ testSMPClient @TLS $ \h -> do - (_, _, resp) <- sendNavl h "na02" (domain "lapsed.simplex") - resp `shouldBe` Right (NAVAIL (NAAuction "99999952316384526016153087" 1798191621)) + (_, _, resp) <- sendRslv h "na02" (domain "lapsed.simplex") + resp `shouldBe` Right (RNAME (NRNameAuction "99999952316384526016153087" 1798191621)) -testNavlReserved :: IO () -testNavlReserved = +testRslvReserved :: IO () +testRslvReserved = withResolverServer (status404, "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}") $ testSMPClient @TLS $ \h -> do - (_, _, resp) <- sendNavl h "na03" (domain "acme.simplex") - resp `shouldBe` Right (NAVAIL (NAReserved NRTrademark)) + (_, _, resp) <- sendRslv h "na03" (domain "acme.simplex") + resp `shouldBe` Right (RNAME (NRNameReserved NRTrademark)) -testNavlDisabled :: IO () -testNavlDisabled = - withSmpServerConfigOn (transport @TLS) memCfg testPort $ const $ - testSMPClient @TLS $ \h -> do - (_, _, resp) <- sendNavl h "na04" (domain "alice.simplex") - resp `shouldBe` Right (ERR (NAME NO_RESOLVER)) +-- | A client that predates v22 must see exactly what it saw before: the record +-- for a name that resolves, and NOT_FOUND for one that does not. +oldClient :: IO SMPClient +oldClient = do + g <- C.newRandom + ts <- getCurrentTime + let srv = SMPServer testHost testPort testKeyHash + -- the version just below the gate: a lower ceiling 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 ()) + either (fail . show) pure pcE + +testRslvOldClientRecord :: IO () +testRslvOldClientRecord = + withResolverServer (status200, J.encode testNameRecord) $ do + pc <- oldClient + r <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) + r `shouldBe` NRNameRecord testNameRecord Nothing -testNavlVersion :: IO () -testNavlVersion = +testRslvOldClientNotFound :: IO () +testRslvOldClientNotFound = withResolverServer (status404, "{\"error\":\"unregistered\"}") $ do - g <- C.newRandom - ts <- getCurrentTime - let srv = SMPServer testHost testPort testKeyHash - -- the version just below the gate: a lower ceiling 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")) + pc <- oldClient + r <- runExceptT (directResolveName pc NRMInteractive (domain "alice.simplex")) case r of - Left (PCETransportError TEVersion) -> pure () - _ -> expectationFailure $ "expected Left (PCETransportError TEVersion), got: " <> show r + Left (PCEProtocolError (SMP.NAME SMP.NOT_FOUND)) -> pure () + _ -> expectationFailure $ "expected Left (PCEProtocolError (NAME NOT_FOUND)), got: " <> show r -testNavlForwarded :: IO () -testNavlForwarded = - withProxyAndResolver (status410, auctionBody) $ do - g <- C.newRandom - ts <- getCurrentTime - let proxyServ = SMPServer testHost testPort testKeyHash - relayServ = SMPServer testHost2 testPort2 testKeyHash - cfg' = defaultSMPClientConfig {serverVRange = mkVersionRange minServerSMPRelayVersion currentClientSMPRelayVersion} - pcE <- getProtocolClient g NRMInteractive (1, proxyServ, Nothing) cfg' [] Nothing ts (\_ -> pure ()) - pc <- either (fail . show) pure pcE - sess <- runExceptT' (connectSMPProxiedRelay pc NRMInteractive relayServ Nothing) - r <- runExceptT (proxyNameAvailability pc NRMInteractive sess (domain "lapsed.simplex")) - case r of - Right (Right a) -> a `shouldBe` NAAuction "99999952316384526016153087" 1798191621 - _ -> expectationFailure $ "expected Right (Right NAAuction ..), got: " <> show r +testRslvForwardedAuction :: IO () +testRslvForwardedAuction = + withProxyAndResolver (status410, auctionBody) $ + forwardedResolveAlice >>= \r -> case r of + Right (Right a) -> a `shouldBe` NRNameAuction "99999952316384526016153087" 1798191621 + _ -> expectationFailure $ "expected Right (Right (NRNameAuction ..)), got: " <> show r -- a name one day past its grace period, priced by the .testing auction curve auctionBody :: LB.ByteString @@ -276,27 +266,21 @@ testRslvSendsTheHash :: IO () testRslvSendsTheHash = withResolverServerReqs (status200, J.encode echoed) $ \reqs -> do pc <- currentClient - nr <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) + r <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) resolvePaths reqs `shouldReturn` [["resolve", aliceHash <> ".simplex"]] -- the record names what the caller asked for - SMP.nrName nr `shouldBe` "alice.simplex" + case r of + NRNameRecord nr _ -> SMP.nrName nr `shouldBe` "alice.simplex" + _ -> expectationFailure $ "expected NRNameRecord, got: " <> show r where -- the resolver echoes what 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")) + _ <- runExceptT' (directResolveName pc NRMInteractive (domain "x.alice.simplex")) resolvePaths reqs `shouldReturn` [["resolve", "x." <> aliceHash <> ".simplex"]] runExceptT' :: Show e => ExceptT e IO a -> IO a diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index b041456194..25c912de72 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -17,12 +17,11 @@ import Network.HTTP.Types (status200, status400, status404, status410, status500 import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed) import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Encoding.String (strDecode, strEncode) -import Simplex.Messaging.Protocol (NameAvailability (..), ErrorType (..), NameErrorType (..), NameRecord (..), NameReservedReason (..)) +import Simplex.Messaging.Protocol (ErrorType (..), NameErrorType (..), NameRecord (..), NameReservedReason (..), NameResponse (..)) import Simplex.Messaging.Server.Main (validateUrl) import Simplex.Messaging.Server.Names ( NamesConfig (..), RpcAuth (..), - getNameAvailability, newNamesEnv, pingEndpoint, resolveName, @@ -105,89 +104,97 @@ errorWireSpec = availabilitySpec :: Spec availabilitySpec = do + -- one lookup answers both questions: what the name points to, and whether it + -- could be registered + it "a resolvable name answers with the record and its expiry" $ + answers status200 (recordWith "\"status\":\"registered\",\"expires\":1811232000") (NRNameRecord testNameRecord (Just 1811232000)) + it "a resolver that sends no status still answers with the record" $ + answers status200 (J.encode testNameRecord) (NRNameRecord testNameRecord Nothing) it "unregistered name is available" $ - answers status404 "{\"error\":\"unregistered\"}" NAVailable + answers status404 "{\"error\":\"unregistered\"}" NRNameAvailable it "expired name is available" $ - answers status410 "{\"error\":\"expired\"}" NAVailable + answers status410 "{\"error\":\"expired\"}" NRNameAvailable it "name in grace carries graceEnds" $ - answers status410 "{\"error\":\"grace\",\"graceEnds\":1796377221}" (NAInGrace 1796377221) + answers status410 "{\"error\":\"grace\",\"graceEnds\":1796377221}" (NRNameInGrace 1796377221) it "auction carries premium and auctionEnds" $ answers status410 "{\"error\":\"auction\",\"premium\":\"99999952316384526016153087\",\"auctionEnds\":1798191621}" - (NAAuction "99999952316384526016153087" 1798191621) + (NRNameAuction "99999952316384526016153087" 1798191621) it "reserved name carries the reason" $ - answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}" (NAReserved NRTrademark) + answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}" (NRNameReserved NRTrademark) -- an older resolver sends no reasonCode; that is not the chain saying "none" it "no reasonCode still reads as reserved" $ - answers status404 "{\"error\":\"reserved\"}" (NAReserved NRUnknown) + answers status404 "{\"error\":\"reserved\"}" (NRNameReserved NRUnknown) -- a later version may name reasons this one cannot; the reservation must -- survive that, or a client would offer a name it cannot register it "a reason from a later version still reads as reserved" $ - smpDecode "RESERVED SOMETHING_NEW" `shouldBe` Right (NAReserved NRUnknown) + smpDecode "RESERVED SOMETHING_NEW" `shouldBe` Right (NRNameReserved NRUnknown) -- the resolver names this one explicitly; it is not the same as not knowing it "unspecified reason reads as unspecified" $ - answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"unspecified\"}" (NAReserved NRUnspecified) + answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"unspecified\"}" (NRNameReserved NRUnspecified) it "unknown reason still reads as reserved" $ - answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"astrology\"}" (NAReserved NRUnknown) - it "registered name is taken, with expiry" $ - answers status200 "{\"status\":\"registered\",\"expires\":1811232000}" (NATaken (Just 1811232000)) - it "registered without expiry is taken" $ - answers status200 "{\"status\":\"registered\",\"expires\":null}" (NATaken Nothing) + answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"astrology\"}" (NRNameReserved NRUnknown) + it "registered without a resolver is taken, with expiry" $ + answers status404 "{\"error\":\"noResolver\",\"expires\":1811232000}" (NRNameTaken (Just 1811232000)) -- an answer missing its payload withholds the name: quoting the usual price -- for one that costs a premium is the wrong answer it "grace without graceEnds is taken" $ - answers status410 "{\"error\":\"grace\"}" (NATaken Nothing) + answers status410 "{\"error\":\"grace\"}" (NRNameTaken Nothing) it "auction without premium is taken" $ - answers status410 "{\"error\":\"auction\",\"auctionEnds\":1798191621}" (NATaken Nothing) - it "noResolver is taken" $ - answers status404 "{\"error\":\"noResolver\",\"expires\":1811232000}" (NATaken (Just 1811232000)) + answers status410 "{\"error\":\"auction\",\"auctionEnds\":1798191621}" (NRNameTaken Nothing) -- the wire length-prefixes the price with one byte, so a longer or -- non-numeric string is dropped rather than re-encoded it "over-long premium is dropped" $ - answers status410 (jsonBody ("{\"error\":\"auction\",\"premium\":\"" <> replicate 300 '9' <> "\",\"auctionEnds\":1798191621}")) (NATaken Nothing) + answers status410 (jsonBody ("{\"error\":\"auction\",\"premium\":\"" <> replicate 300 '9' <> "\",\"auctionEnds\":1798191621}")) (NRNameTaken Nothing) it "non-decimal premium is dropped" $ - answers status410 "{\"error\":\"auction\",\"premium\":\"1e26\",\"auctionEnds\":1798191621}" (NATaken Nothing) + answers status410 "{\"error\":\"auction\",\"premium\":\"1e26\",\"auctionEnds\":1798191621}" (NRNameTaken Nothing) -- a resolver that could not answer must not look like an answer: TAKEN would - -- assert a registration nobody read, NOT_FOUND would read as "free" + -- assert a registration nobody read, AVAILABLE would offer a name that is held it "upstream failure is a resolver error" $ refuses status502 "{\"error\":\"upstreamError\"}" (RESOLVER "upstreamError") it "unconfigured TLD is a resolver error" $ refuses status400 "{\"error\":\"tldNotConfigured\"}" (RESOLVER "tldNotConfigured") it "unreadable status is a resolver error" $ - refuses status200 "{\"status\":\"unknown\",\"expires\":null}" (RESOLVER "unknown") + refuses status404 "{\"error\":\"unknown\"}" (RESOLVER "unknown") it "long status is truncated" $ refuses status502 (jsonBody ("{\"error\":\"" <> replicate 400 'e' <> "\"}")) (RESOLVER (T.replicate 32 "e")) - it "non-JSON body is never NOT_FOUND" $ - refuses status404 "gateway" (RESOLVER "HTTP 404") + -- a body the router cannot read is the pre-v22 answer, unchanged: NOT_FOUND + -- says the router has nothing to say, never that the name is registrable + it "unreadable 404 body stays NOT_FOUND" $ + refuses status404 "gateway" NOT_FOUND it "over-cap body is a resolver error" $ withResolverServer (resolveResp status200 (jsonBody ("{\"status\":\"registered\",\"pad\":\"" <> replicate 400 'x' <> "\"}"))) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) {resolverMaxResponseBytes = 200} - getNameAvailability env navlDomain `shouldReturn` Left (RESOLVER "response too large") + resolveName env navlDomain `shouldReturn` Left (RESOLVER "response too large") it "every answer survives the wire" $ mapM_ (\a -> smpDecode (smpEncode a) `shouldBe` Right a) - [ NAVailable, - NATaken (Just 1811232000), - NATaken Nothing, - NAInGrace 1796377221, - NAAuction "99999952316384526016153087" 1798191621, - NAReserved NRUnspecified, - NAReserved NRTrademark, - NAReserved NRPublicInterest, - NAReserved NROffensive, - NAReserved NRInternal, - NAReserved NRPremium, - NAReserved NRUnknown + [ NRNameRecord testNameRecord (Just 1811232000), + NRNameRecord testNameRecord Nothing, + NRNameAvailable, + NRNameTaken (Just 1811232000), + NRNameTaken Nothing, + NRNameInGrace 1796377221, + NRNameAuction "99999952316384526016153087" 1798191621, + NRNameReserved NRUnspecified, + NRNameReserved NRTrademark, + NRNameReserved NRPublicInterest, + NRNameReserved NROffensive, + NRNameReserved NRInternal, + NRNameReserved NRPremium, + NRNameReserved NRUnknown ] where jsonBody = LB.fromStrict . B.pack + -- the resolver returns the record and the registration status in one body + recordWith extra = LB.init (J.encode testNameRecord) <> "," <> extra <> "}" answers st body a = resolverSays st body (Right a) refuses st body e = resolverSays st body (Left e) resolverSays st body expected = withResolverServer (resolveResp st body) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) - getNameAvailability env navlDomain `shouldReturn` expected + resolveName env navlDomain `shouldReturn` expected navlDomain = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} parseNameSpec :: Spec @@ -263,7 +270,7 @@ resolverSpec = do it "returns NameRecord on 200 OK" $ withResolverServer (resolveResp status200 (J.encode testNameRecord)) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) - resolveName env aliceDomain `shouldReturn` Right testNameRecord + resolveName env aliceDomain `shouldReturn` Right (NRNameRecord testNameRecord Nothing) it "returns NOT_FOUND on 404" $ withResolverServer (resolveResp status404 "{}") $ \port _ -> do From 08432383cdd1af283dbc6fad89b4f5ec7989aeb9 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 7 Sep 2026 11:11:11 +0200 Subject: [PATCH 11/27] doc fixes --- protocol/simplex-messaging.md | 13 +++++++------ src/Simplex/Messaging/Server/Names.hs | 5 +++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index ce484b7506..148d4c1c14 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1500,9 +1500,9 @@ several configured servers can act on distinctly: | Response | Condition | Client action | |---|---|---| | `RNAME` | the router read the registry | use it | -| `ERR NAME NOT_FOUND` | unknown TLD or malformed name; below v22 also every name that does not resolve | authoritative "no such name" — stop | +| `ERR NAME NOT_FOUND` | the router could not read any answer for the name; below v22 also every name that does not resolve | stop, and do not read it as registrable | | `ERR NAME NO_RESOLVER` | this router has no resolver (names role not enabled) | skip this server, try the next | -| `ERR NAME RESOLVER ` | transient failure: backing resolver error (upstream 5xx, transport, timeout, decode) | transient — retry or surface, do not treat as "not found" | +| `ERR NAME RESOLVER ` | the resolver answered something the router cannot act on: an unconfigured TLD, an unreachable chain, a transport failure, a timeout | surface ``; retry only if it reads as transient | A client SHOULD NOT broadcast a `name` to further servers after a name-capable router has answered (`NOT_FOUND` or `RESOLVER`), since that router has already @@ -1560,10 +1560,11 @@ A router that cannot read the payload for `GRACE` or `AUCTION` MUST answer `TAKEN` with no `expires`, never `AVAILABLE`. Quoting the ordinary price for a name that carries a premium is the harmful answer. -A router that cannot read the status at all MUST answer `ERR NAME RESOLVER -`. Not `TAKEN`, which asserts a registration it never read, and not -`AVAILABLE`, which offers a name that may be held. This covers an unreachable -chain, an unconfigured TLD, and any status the router does not recognise. +A router that reads a status it has no answer for MUST say so as `ERR NAME +RESOLVER `. Not `TAKEN`, which asserts a registration it never read, and +not `AVAILABLE`, which offers a name that may be held. An unreachable chain and +an unconfigured TLD arrive this way, as statuses of their own. When the response +carries no status the router can read at all, it answers `ERR NAME NOT_FOUND`. A client MUST read a `reason` it does not know as `UNKNOWN` and still treat the name as reserved: a later version may reserve names for reasons this one cannot diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 0436c4bb45..a1e0992022 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -113,11 +113,12 @@ mapStatus NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsAuctionEnds, nsPre -- rather than quote the ordinary price; its expiry is already past. lapsed = NRNameTaken Nothing +-- | Only reached when there was no status to read: a working resolver names one +-- on every 4xx, and a lapsed registration is GRACE or AUCTION. The 4xx codes +-- keep NOT_FOUND, which is what a client below v22 was told for them. mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case HttpStatusErr 404 -> NOT_FOUND - -- 410 is a lapsed registration: an answer about the name, not a resolver - -- failure, so it must not become RESOLVER. HttpStatusErr 410 -> NOT_FOUND HttpStatusErr 400 -> NOT_FOUND HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code)) From b8cc0196cac13663e25ab324c4b85f99368056ab Mon Sep 17 00:00:00 2001 From: brenzi Date: Mon, 7 Sep 2026 14:20:26 +0200 Subject: [PATCH 12/27] Update src/Simplex/Messaging/Server/Names.hs Co-authored-by: Evgeny --- src/Simplex/Messaging/Server/Names.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index a1e0992022..bccc07020b 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -80,7 +80,7 @@ mapReason = \case "internal" -> NRInternal "premium" -> NRPremium -- a code this router has no word for: still reserved, just unworded - _ -> NRUnknown + t -> NRUnknown t fetch :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResponse) fetch NamesEnv {resolverEnv} d = From 80765045b269f733fa21a099f1176c7575f53dc0 Mon Sep 17 00:00:00 2001 From: brenzi Date: Mon, 7 Sep 2026 14:20:46 +0200 Subject: [PATCH 13/27] Update src/Simplex/Messaging/Protocol.hs Co-authored-by: Evgeny --- src/Simplex/Messaging/Protocol.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 94956f521a..411a32e8e2 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -1681,7 +1681,7 @@ instance Encoding NameReservedReason where NROffensive -> "OFFENSIVE" NRInternal -> "INTERNAL" NRPremium -> "PREMIUM" - NRUnknown -> "UNKNOWN" + NRUnknown t -> t smpP = A.takeTill (== ' ') >>= \case "UNSPECIFIED" -> pure NRUnspecified From 1e5ddca661ce2bc34ab4eda3811c235d6607a52e Mon Sep 17 00:00:00 2001 From: brenzi Date: Mon, 7 Sep 2026 14:20:57 +0200 Subject: [PATCH 14/27] Update src/Simplex/Messaging/Protocol.hs Co-authored-by: Evgeny --- src/Simplex/Messaging/Protocol.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 411a32e8e2..1df64f4aea 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -2079,7 +2079,6 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where PONG -> e PONG_ RNAME r | v >= nameAvailSMPVersion -> e (RNAME_, ' ', r) - -- v20/v21 knows only the record, and had NOT_FOUND for every other answer | otherwise -> case r of NRNameRecord {nameRecord} -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode nameRecord) _ -> e (ERR_, ' ', NAME NOT_FOUND) From 13f8cce6828a251ede7a2e5ce299e8033a66ae9b Mon Sep 17 00:00:00 2001 From: brenzi Date: Mon, 7 Sep 2026 14:21:07 +0200 Subject: [PATCH 15/27] Update src/Simplex/Messaging/Protocol.hs Co-authored-by: Evgeny --- src/Simplex/Messaging/Protocol.hs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 1df64f4aea..157894e344 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -1592,9 +1592,6 @@ data ErrorType DUPLICATE_ -- not part of SMP protocol, used internally deriving (Eq, Show) --- | What the router knows about a name. Resolving a name and asking whether it --- can be registered are the same question to the registry, and a client that --- offers a taken name to register wants to show what took it. data NameResponse = -- | resolves, and the registration runs until this time NRNameRecord {nameRecord :: NameRecord, expires :: Maybe Int64} From 20ba72022476363bdf036b6ddbeb9ded7ba8e264 Mon Sep 17 00:00:00 2001 From: brenzi Date: Mon, 7 Sep 2026 14:21:17 +0200 Subject: [PATCH 16/27] Update src/Simplex/Messaging/Protocol.hs Co-authored-by: Evgeny --- src/Simplex/Messaging/Protocol.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 157894e344..35fe3afafc 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -1689,7 +1689,7 @@ instance Encoding NameReservedReason where "PREMIUM" -> pure NRPremium -- a later version may reserve names for reasons this one has no word for; -- losing "reserved" over that would be worse than losing the wording - _ -> pure NRUnknown + t -> pure $ NRUnknown t -- | Name resolution error data NameErrorType From e4fab46eb9027226bddaa57db6155de77c480bfc Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 7 Sep 2026 16:17:22 +0200 Subject: [PATCH 17/27] protocol types refactoring --- protocol/simplex-messaging.md | 140 +++++++---- scripts/resolver/service/snrc-resolve.py | 159 +++++------- src/Simplex/Messaging/Agent.hs | 6 +- src/Simplex/Messaging/Agent/Client.hs | 4 +- src/Simplex/Messaging/Client.hs | 14 +- src/Simplex/Messaging/Protocol.hs | 236 +++++++++++------- src/Simplex/Messaging/Server.hs | 2 +- src/Simplex/Messaging/Server/Names.hs | 91 ++++--- .../Messaging/Server/Names/HttpResolver.hs | 32 +-- tests/AgentTests/ResolveNameTests.hs | 10 +- tests/RSLVTests.hs | 48 ++-- tests/SMPNamesTests.hs | 156 +++++++----- 12 files changed, 511 insertions(+), 387 deletions(-) diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index 148d4c1c14..c9f699da85 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1512,64 +1512,109 @@ fact that this router cannot resolve, so iterating past it is safe. #### Name response Resolving a name and asking whether it can be registered are one question to the -registry, and one lookup answers both: a client offering a taken name to register -wants to show what took it. `RNAME` carries a tag saying which answer follows. +registry, and one lookup answers both: a client offering a taken name to +register wants to show what took it. `RNAME` carries three facts, from three +contracts - the controller, the registrar and the resolver - and any of them may +be absent. ```abnf -rname = %s"RNAME" SP answer -answer = %s"RECORD" SP optExpires json-bytes ; json-bytes consumes the remainder - / %s"TAKEN" SP optExpires - / %s"GRACE" SP grace-ends - / %s"AUCTION" SP premium auction-ends - / %s"RESERVED" SP reason - / %s"AVAILABLE" -optExpires = %s"0" / (%s"1" expires) ; absent when the router could not read the registration -expires = 8*8 OCTET ; as grace-ends -grace-ends = 8*8 OCTET ; Int64, network byte order (big-endian), seconds since the Unix epoch -auction-ends = 8*8 OCTET ; as grace-ends, and follows premium with no separator -premium = shortString ; ASCII decimal integer, in attoUSD (1e-18 USD) +rname = %s"RNAME" SP reserved SP registration SP json-bytes +reserved = %s"0" / (%s"1" reason) ; absent = not held back +registration = %s"0" / (%s"1" registered-or-not) ; absent = the router cannot say +registered-or-not = %s"REGISTERED" SP expires grace-until + / %s"UNREGISTERED" SP pricing +expires = 8*8 OCTET ; Int64, big-endian, seconds since the Unix epoch +grace-until = 8*8 OCTET ; as expires, and greater than it +pricing = %s"0" / (%s"1" rent-prices min-label-length premium-from + start-premium end-premium) +rent-prices = length *(8*8 OCTET) ; MicroUSD per year, by label length +min-label-length = 2*2 OCTET ; Word16, characters +premium-from = %s"0" / (%s"1" 8*8 OCTET) ; unix seconds the surcharge began +start-premium = 8*8 OCTET ; MicroUSD, Int64 +end-premium = 8*8 OCTET ; MicroUSD, Int64 reason = %s"UNSPECIFIED" / %s"TRADEMARK" / %s"PUBLIC_INTEREST" - / %s"OFFENSIVE" / %s"INTERNAL" / %s"PREMIUM" / %s"UNKNOWN" + / %s"OFFENSIVE" / %s"INTERNAL" / %s"PREMIUM" / word +word = 1*32(%x21-7E) ; a reason this version has no word for ``` -| Answer | Condition | Client action | -|---|---|---| -| `RECORD` | resolves, and the registration runs until `expires` | use the record | -| `TAKEN` | registered until `expires`, but its records point nowhere | 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` | -| `AVAILABLE` | registrable at the ordinary price | offer it | +`json-bytes` is the record as a UTF-8 JSON object, or `null` when the name does +not resolve. It consumes the remainder of the transmission. + +Money is MicroUSD, a millionth of a US dollar: the registry denominates in USD, +never in ETH, and the backing resolver converts before the value reaches the +protocol. Times are seconds since the Unix epoch. Lengths are characters. + +A client reads the three facts together: + +| The client sees | Meaning | +|---|---| +| a record | the name resolves; use it | +| `REGISTERED` | held by someone until `expires`, renewable by its owner alone until `grace-until` | +| `UNREGISTERED` | held by nobody; registrable unless it is also reserved | +| `pricing` | what registering it costs, computed locally | +| `reserved` | the registry holds it back, whether or not it is registered | +| no registration | a pre-v22 answer, which was only ever sent for a live registration | -Below v22, `RNAME` carries the bare record with no tag and no `expires`, and -every other answer is `ERR NAME NOT_FOUND`, as it was before this version. +Availability is the conjunction, not a state of its own: a name is registrable +when it is `UNREGISTERED` and carries no reservation, which is the registry's own +`available()`. An auction is not a state either - it is `UNREGISTERED` with a +premium that has not yet decayed to zero. -From v22 a client MUST NOT read `ERR NAME NOT_FOUND` as "registrable" — only -`AVAILABLE` says that. `NOT_FOUND` means the router has nothing to say about the -name, which includes a backing resolver whose answer it could not read. +A router MUST NOT send `pricing` for a reserved name. A name the registry holds +back is not for sale at the registry's price, and quoting one would be an offer +the registry will not honour. -`premium` is a decimal string because prices are 256-bit integers. It is the -surcharge only: the base price depends on the label's length, which a hashed -query does not carry. The client adds that. +The record travels while a name is registered, through its grace period, and +stops at the moment the name becomes registrable by anyone. Keeping it that far +lets whoever opens the name tell its owner that it is about to lapse; keeping it +past that would show a record whose owner no longer holds the name. How long a +client goes on opening an expiring name is its own decision. -Times are absolute, not durations, so a client can count down without -re-querying. A deadline is not permission to register; only the registry grants -that. +**Computing the price.** All amounts MicroUSD, all times seconds: + +``` +price len duration t + = rentPrices[min (len - 1) (length rentPrices - 1)] * duration / 31536000 + + max 0 (decayed startPremium (t - premiumFrom) - endPremium) +decayed s elapsed = s * 0.5 ** (elapsed / 86400) +``` -A router that cannot read the payload for `GRACE` or `AUCTION` MUST answer -`TAKEN` with no `expires`, never `AVAILABLE`. Quoting the ordinary price for a -name that carries a premium is the harmful answer. +The surcharge is charged once whatever the duration; only the rent scales with +it. `decayed` halves each day and interpolates within the day, and is the same +function for every deployment, so it is specified here rather than sent. A +client computing it in double precision lands within 0.01 MicroUSD of the +registry across the whole curve. Rounding may leave the surcharge just above +zero at the end of its window, so a client floors it at zero, as the registry +does. The minimum registration is 28 days, a contract constant rather than a +per-deployment value, so it is not sent either. + +`rentPrices` is indexed by label length, its last entry covering every longer +label. `minLabelLength` is sent because a hashed query carries no length: the +router cannot check it, so the client must, and a price quoted for a label the +registry will refuse is worse than no quote at all. + +Below v22, `RNAME` carries the bare record with no other field, and every answer +without a record is `ERR NAME NOT_FOUND`, as it was before this version. A name +in its grace period therefore resolves for those clients too, without the expiry +they have no field to carry. + +From v22 a client MUST NOT read `ERR NAME NOT_FOUND` as "registrable" - only +`UNREGISTERED` with no reservation says that. `NOT_FOUND` means the router has +nothing to say about the name, which includes a backing resolver whose answer it +could not read. A router that reads a status it has no answer for MUST say so as `ERR NAME -RESOLVER `. Not `TAKEN`, which asserts a registration it never read, and -not `AVAILABLE`, which offers a name that may be held. An unreachable chain and -an unconfigured TLD arrive this way, as statuses of their own. When the response -carries no status the router can read at all, it answers `ERR NAME NOT_FOUND`. +RESOLVER `. Not a registration, which asserts one it never read, and not +`UNREGISTERED`, which offers a name that may be held. An unreachable chain and an +unconfigured TLD arrive this way, as statuses of their own, and so does a +registration the router could not date. When the response carries no status the +router can read at all, it answers `ERR NAME NOT_FOUND`. -A client MUST read a `reason` it does not know as `UNKNOWN` and still treat the +A client MUST read a `reason` it does not know as unknown and still treat the name as reserved: a later version may reserve names for reasons this one cannot name, and losing the reservation over that would offer a name that cannot be -registered. A router sends `UNKNOWN` for a reason its own resolver did not name. +registered. The word itself travels so that a later client can use it; a router +sends at most one bounded token, since the field ends at a space. `json-bytes` MUST be a UTF-8 JSON object with the following schema: @@ -1595,10 +1640,11 @@ an empty string, not JSON `null` and not an absent key. Link fields empty array `[]` when unset. Coin fields (`eth`, `btc`, `xmr`, `dot`) use JSON `null` as the "unset" sentinel and MAY also be absent from the object entirely. -The backing resolver does not resolve a name whose registration has lapsed; the -router answers `GRACE` or `AUCTION` for those. The record carries no expiry -field of its own — `RECORD` carries it alongside. Testnet-vs-mainnet status is -derived from the queried TLD rather than an in-record flag. +The record carries no expiry field of its own: the registration alongside it +does. The backing resolver stops resolving a name once it is registrable by +anyone, so a record and an `UNREGISTERED` registration do not travel together. +Testnet-vs-mainnet status is derived from the queried TLD rather than an +in-record flag. Receivers MUST tolerate extra unknown fields (forward-compatibility for future field additions). Adding a required field is a breaking change requiring an diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 7cfb01f40d..66f32c40ba 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -41,8 +41,8 @@ (default: empty — TLD not yet deployed) SNRC_REGISTRAR_ BaseRegistrar (ERC-721) for the TLD; expiry and status (default: mainnet for .testing, empty for .simplex) - SNRC_CONTROLLER_ SimplexController (proxy) for the TLD; `reserved` status, - and through its `prices()` oracle the post-grace auction + SNRC_CONTROLLER_ SimplexController (proxy) for the TLD; reservations, + and through its `prices()` oracle what registering costs (default: mainnet for .testing, empty for .simplex) SNRC_PORT Listen port (default: 8000) SNRC_BIND Bind address (default: 0.0.0.0) @@ -126,6 +126,10 @@ ZERO_ADDR = "0x0000000000000000000000000000000000000000" +# The registry prices in attoUSD (1e-18 USD); the protocol carries MicroUSD. +ATTO_PER_MICRO = 10**12 +SECONDS_PER_YEAR = 31536000 + # ---------- RPC + ABI helpers (mirrors ens-lookup.py shape) ---------- @@ -236,49 +240,53 @@ def reservation_reason(tld: str, token: int) -> int: return decode_uint(raw) -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.""" - return cached(("auction", tld), lambda: read_auction_params(tld)) +def pricing_params(tld: str): + """What it costs to register a name under this TLD, in MicroUSD, or None + when no controller or price oracle is configured.""" + return cached(("pricing", tld), lambda: read_pricing_params(tld)) -def read_auction_params(tld: str): - params = (ZERO_ADDR, 0, 0, 0) +def read_pricing_params(tld: str): 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()"))), - ) - return params + if not controller: + return None + oracle = decode_address(eth_call(controller, selector("prices()"))) + if oracle == ZERO_ADDR: + return None + try: + return read_oracle_prices(controller, oracle) + except RuntimeError: + # An oracle that does not expose its curve cannot be quoted from. The + # name is still registrable; the price is simply not ours to state. + return None -def auction(tld: str, grace_ends: int, now: int): - """Past grace a name is registrable again, but at a premium decaying to zero - over the oracle's window. Returns when the premium reaches zero and what it - is now, in attoUSD, or (None, None) once prices are normal - which includes - an auction switched off with totalDays 0.""" - oracle, start, total_days, floor = auction_params(tld) - if oracle == ZERO_ADDR: - return None, None - ends = grace_ends + total_days * 86400 - if now >= ends: - return None, None - # decayedPremium is `pure`, so this is the oracle's own arithmetic rather - # than a second copy of its decay curve. - decayed = decode_uint( - eth_call( - oracle, - selector("decayedPremium(uint256,uint256)") - + encode_uint(start) - + encode_uint(now - grace_ends), - ) - ) - return ends, max(decayed - floor, 0) +def read_oracle_prices(controller: str, oracle: str): + # The oracle prices rent in attoUSD per second and the premium in attoUSD. + # Quotes round so they are never below what the registry charges: rents and + # the surcharge up, the floor that is subtracted from the surcharge down. + # An oracle built before the six-letter tier stops at five, and the contract + # itself then charges price5Letter for anything longer - which is what the + # last entry means here too. + rents = [] + for n in range(1, 7): + try: + rate = decode_uint(eth_call(oracle, selector(f"price{n}Letter()"))) + except RuntimeError: + if n <= 5: + raise + break + rents.append(ceil_div(rate * SECONDS_PER_YEAR, ATTO_PER_MICRO)) + return { + "rentPrices": rents, + "minLabelLength": decode_uint(eth_call(controller, selector("minCharLength()"))), + "startPremium": ceil_div(decode_uint(eth_call(oracle, selector("startPremium()"))), ATTO_PER_MICRO), + "endPremium": decode_uint(eth_call(oracle, selector("endValue()"))) // ATTO_PER_MICRO, + } + + +def ceil_div(a: int, b: int) -> int: + return -(-a // b) def name_status(name: str): @@ -290,10 +298,9 @@ def name_status(name: str): "status": "unknown", "expires": None, "graceEnds": None, - "auctionEnds": None, - "premium": None, "reasonCode": None, "reason": None, + "premiumFrom": None, } # nameExpires and reservedNames are keyed on uint256(keccak(label)). @@ -314,25 +321,24 @@ def name_status(name: str): now = chain_now() status = expiry_status(expires, grace, now) - auction_ends = premium = reason = None - if status in ("unregistered", "expired"): - code = reservation_reason(tld, token) - if code: - status, reason = "reserved", RESERVED_REASONS.get(code, UNKNOWN_REASON) - elif status == "expired": - auction_ends, premium = auction(tld, expires + grace, now) - if auction_ends: - status = "auction" + # A reservation is orthogonal to the registration: a registered name can be + # held back too, and that is why it will not free up when it expires. + code = reservation_reason(tld, token) + reason = RESERVED_REASONS.get(code, UNKNOWN_REASON) if code else None - return { + out = { "status": status, "expires": expires or None, "graceEnds": (expires + grace) if expires else None, - "auctionEnds": auction_ends, - "premium": None if premium is None else str(premium), "reasonCode": reason[0] if reason else None, "reason": reason[1] if reason else None, + # past grace the name is registrable again, at a surcharge decaying from + # the moment grace ended; the client computes it from the curve's ends + "premiumFrom": (expires + grace) if status == "expired" and expires else None, } + if status in ("unregistered", "expired"): + out.update(pricing_params(tld) or {}) + return out def selector(signature: str) -> str: @@ -642,53 +648,28 @@ def resolve(name: str): # Before the resolver lookup, so a lapsed name is not reported as noResolver. reg = name_status(name) - if reg["status"] in ("unregistered", "reserved"): + if reg["status"] in ("unregistered", "expired"): + # A name in grace is not here: its record still resolves, so that whoever + # opens it can tell the owner it is about to lapse. body = { "name": name, - "status": reg["status"], - "expires": reg["expires"], - "graceEnds": reg["graceEnds"], + **reg, "error": reg["status"], "message": ( - "this name is reserved and cannot be registered" - if reg["status"] == "reserved" - else "this name has never been registered" - ), - } - if reg["status"] == "reserved": - body["reasonCode"] = reg["reasonCode"] - body["reason"] = reg["reason"] - return 404, body - if reg["status"] in ("grace", "expired", "auction"): - body = { - "name": name, - "status": reg["status"], - "expires": reg["expires"], - "graceEnds": reg["graceEnds"], - "error": reg["status"], - "message": ( - "this registration expired and can be renewed by its owner" - if reg["status"] == "grace" + "this name has never been registered" + if reg["status"] == "unregistered" else "this registration expired and is open to anyone" ), } - if reg["status"] == "auction": - body["auctionEnds"] = reg["auctionEnds"] - body["premium"] = reg["premium"] - body["message"] = ( - "this registration expired and is open to anyone, at a premium " - "that decays to zero" - ) - return 410, body + return (404 if reg["status"] == "unregistered" else 410), body resolver_raw = eth_call(registry, selector("resolver(bytes32)") + node_hex) resolver_addr = decode_address(resolver_raw) if resolver_addr == ZERO_ADDR: return 404, { "name": name, + **reg, "status": "noResolver", - "expires": reg["expires"], - "graceEnds": reg["graceEnds"], "error": "noResolver", "message": "no resolver set for this name", } @@ -727,9 +708,7 @@ def resolve(name: str): "dot": addr_multicoin(resolver_addr, node, COIN_DOT), "owner": owner, "resolver": resolver_addr, - "status": reg["status"], - "expires": reg["expires"], - "graceEnds": reg["graceEnds"], + **reg, } diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index e0c509019a..6306f4045b 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -226,7 +226,7 @@ import Simplex.Messaging.Protocol ErrorType (AUTH), MsgBody, MsgFlags (..), - NameResponse, + NameResult, NtfServer, ProtoServerWithAuth (..), ProtocolServer (..), @@ -459,7 +459,7 @@ getConnShortLink c = withAgentEnv c .:. getConnShortLink' c -- | Resolve a SimpleX name (PFWD RSLV). The agent owns server selection: it -- picks a names-capable server (ServerRoles.names) from the user's nameSrvs, so -- chat clients just pass the parsed domain. -resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameResponse +resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameResult resolveSimplexName c nm userId domain = withAgentEnv c $ resolveSimplexName' c nm userId domain {-# INLINE resolveSimplexName #-} @@ -1268,7 +1268,7 @@ getConnShortLink' c nm userId = \case deleteLocalInvShortLink' :: AgentClient -> ConnShortLink 'CMInvitation -> AM () deleteLocalInvShortLink' c (CSLInvitation _ srv linkId _) = withStore' c $ \db -> deleteInvShortLink db srv linkId -resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResponse +resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResult resolveSimplexName' c nm userId domain = do resolverSrv <- getNextNameServer c userId resolveName c nm userId resolverSrv domain diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index e2f9df3288..c45a34c2d6 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -269,7 +269,7 @@ import Simplex.Messaging.Protocol NetworkError (..), MsgFlags (..), MsgId, - NameResponse, + NameResult, NtfServer, NtfServerWithAuth, ProtoServer, @@ -1993,7 +1993,7 @@ getQueueLink c nm userId server lnkId = -- resolver) and falls back to a direct send when the proxy is unavailable -- (faster but exposes the client IP). Mode selection is delegated to -- `sendOrProxySMPCommand`, which honours the network config (SPMNever etc.). -resolveName :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameResponse +resolveName :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameResult resolveName c nm userId server domain = snd <$> sendOrProxySMPCommand c nm userId server "" "RSLV" NoEntity resolveViaProxy resolveDirectly where diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 893525e063..94e91c0fa8 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -1060,16 +1060,14 @@ queryDomain :: VersionSMP -> SimplexDomain -> SimplexDomain queryDomain v d = if v >= nameAvailSMPVersion then hashedDomain d else d -- | A hashed query's record names the hash, so put back the name that was asked. -askedName :: SimplexDomain -> NameResponse -> NameResponse -askedName name = \case - r@NRNameRecord {nameRecord} -> r {nameRecord = nameRecord {nrName = fullDomainName name}} - r -> r +askedName :: SimplexDomain -> Maybe NameRecord -> Maybe NameRecord +askedName name = fmap $ \nr -> nr {nrName = fullDomainName name} -proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameResponse) +proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameResult) proxyResolveName c nm proxiedRelay name | v >= namesSMPVersion = proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (queryDomain v name)) >>= \case - Right (RNAME r) -> pure $ Right (askedName name r) + Right (RNAME reserved_ reg_ rec_) -> pure $ Right (reserved_, reg_, askedName name rec_) Right r -> throwE $ unexpectedResponse r Left e -> pure $ Left e | otherwise = throwE $ PCETransportError TEVersion @@ -1081,11 +1079,11 @@ proxyResolveName c nm proxiedRelay name -- proxy fallback in the agent. RSLV requires no entity ID or authorization -- (see `noAuthCmd` in Protocol.hs). Version-gated on the session here, not the -- encoder, so an old server never receives RSLV. -directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameResponse +directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameResult directResolveName c nm name | v >= namesSMPVersion = sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (queryDomain v name))) >>= \case - RNAME r -> pure (askedName name r) + RNAME reserved_ reg_ rec_ -> pure (reserved_, reg_, askedName name rec_) r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion where diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 35fe3afafc..fcd2f4c3e7 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -80,9 +80,12 @@ module Simplex.Messaging.Protocol ErrorType (..), CommandError (..), ProxyError (..), - NameResponse (..), - NRTag (..), + NameResult, + NameRegistration (..), + NamePricing (..), + MicroUSD (..), NameReservedReason (..), + reservedReason, NameErrorType (..), BrokerErrorType (..), NetworkError (..), @@ -251,7 +254,7 @@ import Data.Kind import Data.List (foldl') import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L -import Data.Maybe (isJust, isNothing) +import Data.Maybe (fromMaybe, isJust, isNothing) import Data.String import Data.Text (Text) import qualified Data.Text as T @@ -743,7 +746,7 @@ data BrokerMsg where ERR :: ErrorType -> BrokerMsg PONG :: BrokerMsg -- What the router knows about a SimpleX name. - RNAME :: NameResponse -> BrokerMsg + RNAME :: Maybe NameReservedReason -> Maybe NameRegistration -> Maybe NameRecord -> BrokerMsg deriving (Eq, Show) data RcvMessage = RcvMessage @@ -1592,104 +1595,133 @@ data ErrorType DUPLICATE_ -- not part of SMP protocol, used internally deriving (Eq, Show) -data NameResponse - = -- | resolves, and the registration runs until this time - NRNameRecord {nameRecord :: NameRecord, expires :: Maybe Int64} - | -- | registered, but its records point nowhere - NRNameTaken {expires :: Maybe Int64} - | -- | lapsed, renewable by its previous owner until this time - NRNameInGrace {graceEnds :: Int64} - | -- | registrable by anyone, at this premium in attoUSD until this time - NRNameAuction {premium :: Text, auctionEnds :: Int64} - | -- | held back by the registry - NRNameReserved {reason :: NameReservedReason} - | -- | registrable at the ordinary price - NRNameAvailable +-- | USD in millionths, converted by the resolver from the registry's attoUSD. +-- Int64 reaches ~9.2 trillion USD, and the smallest value in play - the last +-- step of a decaying premium - is a millionth of a dollar. +newtype MicroUSD = MicroUSD Int64 + deriving (Eq, Ord, Show) + deriving newtype (Encoding, ToJSON, FromJSON) + +-- | The three facts RSLV answers with, any of which the router may not have. +-- A plain tuple: nothing downstream needs a type of its own for it. +type NameResult = (Maybe NameReservedReason, Maybe NameRegistration, Maybe NameRecord) + +-- | What the registrar says about a name: held by someone, or registrable. +data NameRegistration + = -- | unix seconds; graceUntil > expires, and until it only the owner renews. + NRRegistered {expires :: Int64, graceUntil :: Int64} + | -- | Not held by anyone. Pricing is absent when the TLD has no controller or + -- price oracle configured, and when the name is reserved: a held-back name + -- is not for sale at the registry's price, only by arrangement with SimpleX. + -- An auction is not a separate state: it is this, with a premium that has + -- not decayed to zero yet. + NRUnregistered {pricing :: Maybe NamePricing} deriving (Eq, Show) -data NRTag - = NRNameRecord_ - | NRNameTaken_ - | NRNameInGrace_ - | NRNameAuction_ - | NRNameReserved_ - | NRNameAvailable_ - deriving (Show) - -instance Encoding NRTag where +instance Encoding NameRegistration where smpEncode = \case - NRNameRecord_ -> "RECORD" - NRNameTaken_ -> "TAKEN" - NRNameInGrace_ -> "GRACE" - NRNameAuction_ -> "AUCTION" - NRNameReserved_ -> "RESERVED" - NRNameAvailable_ -> "AVAILABLE" - smpP = messageTagP + NRRegistered {expires, graceUntil} -> "REGISTERED " <> smpEncode (expires, graceUntil) + NRUnregistered {pricing} -> "UNREGISTERED " <> smpEncode pricing + smpP = + A.takeTill (== ' ') >>= \case + "REGISTERED" -> NRRegistered <$> _smpP <*> smpP + "UNREGISTERED" -> NRUnregistered <$> _smpP + _ -> fail "bad NameRegistration" -instance ProtocolMsgTag NRTag where - decodeTag = \case - "RECORD" -> Just NRNameRecord_ - "TAKEN" -> Just NRNameTaken_ - "GRACE" -> Just NRNameInGrace_ - "AUCTION" -> Just NRNameAuction_ - "RESERVED" -> Just NRNameReserved_ - "AVAILABLE" -> Just NRNameAvailable_ - _ -> Nothing +-- | Enough to price the name locally, as often as the UI likes, without naming +-- it. All amounts MicroUSD, all times unix seconds: +-- +-- price len duration t +-- = rentPrices !! min (len - 1) (length rentPrices - 1) * duration `div` year +-- + max 0 (decayed startPremium (t - premiumFrom) - endPremium) +-- decayed s elapsed = s * 0.5 ** (elapsed / 86400) +-- +-- The surcharge is charged once whatever the duration - only the rent scales. +-- decayed is computed in Double and rounded: it lands within 0.01 MicroUSD of +-- the chain across the whole curve. Rounding startPremium and endPremium +-- separately means the premium may not reach exactly zero, so the client floors +-- at 0, as the chain does. The minimum registration is 28 days, a contract +-- constant rather than a per-deployment value, so it is not sent. +data NamePricing = NamePricing + { -- | MicroUSD per year by label length: first entry a one-letter label, last + -- covering every longer one. Rounded up, so a quote is never below the + -- charge; the exact figure is settled on chain at registration. + rentPrices :: [MicroUSD], + -- | characters: the registry refuses shorter, and a hash cannot be measured. + minLabelLength :: Int, + -- | unix seconds the surcharge began. Nothing when there is none. + premiumFrom :: Maybe Int64, + startPremium :: MicroUSD, -- before decay + endPremium :: MicroUSD -- floor subtracted from the decayed value + } + deriving (Eq, Show) -instance Encoding NameResponse where - smpEncode = \case - NRNameRecord {nameRecord, expires} -> e (NRNameRecord_, ' ', expires, Tail $ LB.toStrict $ J.encode nameRecord) - NRNameTaken {expires} -> e (NRNameTaken_, ' ', expires) - NRNameInGrace {graceEnds} -> e (NRNameInGrace_, ' ', graceEnds) - NRNameAuction {premium, auctionEnds} -> e (NRNameAuction_, ' ', premium, auctionEnds) - NRNameReserved {reason} -> e (NRNameReserved_, ' ', reason) - NRNameAvailable -> e NRNameAvailable_ +instance Encoding NamePricing where + smpEncode NamePricing {rentPrices, minLabelLength, premiumFrom, startPremium, endPremium} = + smpEncodeList rentPrices <> smpEncode (w16 minLabelLength, premiumFrom, startPremium, endPremium) where - e :: Encoding a => a -> ByteString - e = smpEncode - smpP = - smpP >>= \case - NRNameRecord_ -> do - expires <- smpP - nameRecord <- J.eitherDecodeStrict . unTail <$?> smpP - pure NRNameRecord {nameRecord, expires} - NRNameTaken_ -> NRNameTaken <$> smpP - NRNameInGrace_ -> NRNameInGrace <$> smpP - NRNameAuction_ -> NRNameAuction <$> smpP <*> smpP - NRNameReserved_ -> NRNameReserved <$> smpP - NRNameAvailable_ -> pure NRNameAvailable + w16 = fromIntegral :: Int -> Word16 + smpP = do + rentPrices <- smpListP + (minLen, premiumFrom, startPremium, endPremium) <- smpP + pure NamePricing {rentPrices, minLabelLength = fromIntegral (minLen :: Word16), premiumFrom, startPremium, endPremium} +-- | Why the registry holds a name back. A reason this version has no word for +-- keeps its own word rather than losing the reservation. data NameReservedReason - = NRUnspecified - | NRTrademark - | NRPublicInterest - | NROffensive - | NRInternal - | NRPremium - | -- | a reason this version cannot name - NRUnknown + = RRUnspecified + | RRTrademark + | RRPublicInterest + | RROffensive + | RRInternal + | RRPremium + | RRUnknown Text deriving (Eq, Show) instance Encoding NameReservedReason where smpEncode = \case - NRUnspecified -> "UNSPECIFIED" - NRTrademark -> "TRADEMARK" - NRPublicInterest -> "PUBLIC_INTEREST" - NROffensive -> "OFFENSIVE" - NRInternal -> "INTERNAL" - NRPremium -> "PREMIUM" - NRUnknown t -> t + RRUnspecified -> "UNSPECIFIED" + RRTrademark -> "TRADEMARK" + RRPublicInterest -> "PUBLIC_INTEREST" + RROffensive -> "OFFENSIVE" + RRInternal -> "INTERNAL" + RRPremium -> "PREMIUM" + RRUnknown t -> encodeUtf8 t smpP = A.takeTill (== ' ') >>= \case - "UNSPECIFIED" -> pure NRUnspecified - "TRADEMARK" -> pure NRTrademark - "PUBLIC_INTEREST" -> pure NRPublicInterest - "OFFENSIVE" -> pure NROffensive - "INTERNAL" -> pure NRInternal - "PREMIUM" -> pure NRPremium - -- a later version may reserve names for reasons this one has no word for; - -- losing "reserved" over that would be worse than losing the wording - t -> pure $ NRUnknown t + "UNSPECIFIED" -> pure RRUnspecified + "TRADEMARK" -> pure RRTrademark + "PUBLIC_INTEREST" -> pure RRPublicInterest + "OFFENSIVE" -> pure RROffensive + "INTERNAL" -> pure RRInternal + "PREMIUM" -> pure RRPremium + t -> pure $ RRUnknown (safeDecodeUtf8 t) + +-- | The vocabulary the backing resolver and the JSON API share, which is not +-- the wire vocabulary above. +instance TextEncoding NameReservedReason where + textEncode = \case + RRUnspecified -> "unspecified" + RRTrademark -> "trademark" + RRPublicInterest -> "publicInterest" + RROffensive -> "offensive" + RRInternal -> "internal" + RRPremium -> "premium" + RRUnknown t -> t + textDecode = \case + "unspecified" -> Just RRUnspecified + "trademark" -> Just RRTrademark + "publicInterest" -> Just RRPublicInterest + "offensive" -> Just RROffensive + "internal" -> Just RRInternal + "premium" -> Just RRPremium + "unknown" -> Just (RRUnknown "unknown") + _ -> Nothing + +-- | Keeps its word rather than losing the reservation. +reservedReason :: Text -> NameReservedReason +reservedReason t = fromMaybe (RRUnknown t) (textDecode t) + -- | Name resolution error data NameErrorType @@ -2074,11 +2106,11 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where | v < clientNoticesSMPVersion -> BLOCKED info {notice = Nothing} _ -> err PONG -> e PONG_ - RNAME r - | v >= nameAvailSMPVersion -> e (RNAME_, ' ', r) - | otherwise -> case r of - NRNameRecord {nameRecord} -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode nameRecord) - _ -> e (ERR_, ' ', NAME NOT_FOUND) + RNAME reserved_ reg_ rec_ + | v >= nameAvailSMPVersion -> e (RNAME_, ' ', reserved_, ' ', reg_, ' ', Tail $ LB.toStrict $ J.encode rec_) + | otherwise -> case rec_ of + Just rec -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode rec) + Nothing -> e (ERR_, ' ', NAME NOT_FOUND) where e :: Encoding a => a -> ByteString e = smpEncode @@ -2126,8 +2158,8 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where ERR_ -> ERR <$> _smpP PONG_ -> pure PONG RNAME_ - | v >= nameAvailSMPVersion -> RNAME <$> _smpP - | otherwise -> fmap (RNAME . (`NRNameRecord` Nothing)) . J.eitherDecodeStrict . unTail <$?> _smpP + | v >= nameAvailSMPVersion -> RNAME <$> _smpP <*> _smpP <*> (J.eitherDecodeStrict . unTail <$?> _smpP) + | otherwise -> RNAME Nothing Nothing . Just <$> (J.eitherDecodeStrict . unTail <$?> _smpP) where serviceRespP resp | v >= rcvServiceSMPVersion = resp <$> _smpP <*> smpP @@ -2150,7 +2182,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where PKEY {} -> noEntityMsg RRES _ -> noEntityMsg ALLS -> noEntityMsg - RNAME _ -> noEntityMsg + RNAME {} -> noEntityMsg -- other broker responses must have queue ID _ | B.null entId -> Left $ CMD NO_ENTITY @@ -2522,4 +2554,14 @@ $(J.deriveJSON defaultJSON ''BlockingInfo) $(concat <$> mapM @[] (J.deriveJSON (sumTypeJSON id)) [''ProxyError, ''NameErrorType, ''ErrorType]) -- clients report the reason to the user, so it has to reach their API as JSON -$(J.deriveJSON (enumJSON $ dropPrefix "NR") ''NameReservedReason) +-- | The JSON API keeps the closed set, so clients can localise it. An +-- unrecognised reason is "unknown" here; its word stays on the SMP wire for a +-- version that knows it. +instance ToJSON NameReservedReason where + toJSON = + J.String . \case + RRUnknown _ -> "unknown" + r -> textEncode r + +instance FromJSON NameReservedReason where + parseJSON = textParseJSON "NameReservedReason" diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index adf096a3a6..74697a070d 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -1499,7 +1499,7 @@ client st <- asks (rslvStats . serverStats) (selector, msg) <- liftIO (resolveName nenv d) <&> \case - Right r -> (rslvSucc, RNAME r) + Right (reserved_, reg_, rec_) -> (rslvSucc, RNAME reserved_ reg_ rec_) Left e@NOT_FOUND -> (rslvNotFound, ERR $ NAME e) Left e -> (rslvResolverErrs, ERR $ NAME e) incStat (selector st) $> msg diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index bccc07020b..9b23f3112c 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -3,6 +3,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} +{-# LANGUAGE TupleSections #-} module Simplex.Messaging.Server.Names ( NamesConfig (..), @@ -17,10 +18,10 @@ where import qualified Control.Exception as E import Control.Logger.Simple (logError) -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T -import Simplex.Messaging.Protocol (NameErrorType (..), NameRecord, NameReservedReason (..), NameResponse (..)) +import Simplex.Messaging.Protocol (NameErrorType (..), MicroUSD (..), NamePricing (..), NameRecord, NameRegistration (..), NameResult, NameReservedReason, reservedReason) import Simplex.Messaging.Server.Names.HttpResolver ( NameStatusResp (..), ResolverEnv, @@ -59,7 +60,7 @@ pingEndpoint :: NamesEnv -> IO (Either ResolverError ()) pingEndpoint NamesEnv {resolverEnv, config} = fromMaybe (Left ResolverTimeout) <$> timeout (resolverTimeoutMs config * 1000) (healthHttp resolverEnv) -resolveName :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResponse) +resolveName :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResult) resolveName env d = do r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetch env d)) case r of @@ -70,52 +71,62 @@ resolveName env d = do logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) pure (Left (RESOLVER "resolver error")) --- | The controller's reservation reasons, as the resolver spells them. -mapReason :: Text -> NameReservedReason -mapReason = \case - "unspecified" -> NRUnspecified - "trademark" -> NRTrademark - "publicInterest" -> NRPublicInterest - "offensive" -> NROffensive - "internal" -> NRInternal - "premium" -> NRPremium - -- a code this router has no word for: still reserved, just unworded - t -> NRUnknown t +-- | A code this router has no word for still reserves the name, and travels on +-- as itself. Bounded to one wire token: it is the resolver's text, and the slot +-- it goes into ends at a space. +resolverReason :: Text -> NameReservedReason +resolverReason = reservedReason . T.take 32 . T.takeWhile (/= ' ') -fetch :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResponse) +fetch :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResult) fetch NamesEnv {resolverEnv} d = - either (Left . mapResolverError) nameResponse <$> resolveHttp resolverEnv (fullDomainName d) + either (Left . mapResolverError) nameResult <$> resolveHttp resolverEnv (fullDomainName d) --- | A record answers on its own; the status only dates it. Without a record the --- status is the whole answer, and a status this router has no word for is not --- one - "taken" would assert a registration nobody read. -nameResponse :: (Maybe NameRecord, Maybe NameStatusResp) -> Either NameErrorType NameResponse -nameResponse = \case - (Just nameRecord, ns_) -> Right NRNameRecord {nameRecord, expires = nsExpires =<< ns_} - (Nothing, Just ns) -> mapStatus ns +-- | A record answers what the name points to; the status answers whether it can +-- be taken; a reservation is orthogonal to both. A resolver that reports no +-- status at all is an older one, and only ever returned a record for a live +-- registration - the client reads the absent status that way. +nameResult :: (Maybe NameRecord, Maybe NameStatusResp) -> Either NameErrorType NameResult +nameResult = \case + (rec_, Just ns) -> (\(reserved_, reg) -> (reserved_, Just reg, rec_)) <$> mapStatus ns + (Just rec, Nothing) -> Right (Nothing, Nothing, Just rec) (Nothing, Nothing) -> Left NOT_FOUND --- | The resolver's vocabulary for a name that does not resolve. -mapStatus :: NameStatusResp -> Either NameErrorType NameResponse -mapStatus NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsAuctionEnds, nsPremium, nsReasonCode} = - case nsStatus of - "unregistered" -> Right NRNameAvailable - "expired" -> Right NRNameAvailable - "grace" -> Right $ maybe lapsed NRNameInGrace nsGraceEnds - "auction" -> Right $ fromMaybe lapsed (NRNameAuction <$> nsPremium <*> nsAuctionEnds) - "reserved" -> Right $ NRNameReserved (maybe NRUnknown mapReason nsReasonCode) +-- | The resolver's vocabulary. A status this router has no word for is not an +-- answer: "registered" would assert a registration nobody read, and +-- "unregistered" would offer a name that may be held. +mapStatus :: NameStatusResp -> Either NameErrorType (Maybe NameReservedReason, NameRegistration) +mapStatus ns@NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsReasonCode} = + (reserved_,) <$> case nsStatus of + "registered" -> registered -- registered, but its records point nowhere - "noResolver" -> Right $ NRNameTaken nsExpires - -- the resolver's own words, bounded: they reach the client inside ERR + "noResolver" -> registered + "grace" -> registered + "unregistered" -> Right unregistered + "expired" -> Right unregistered + "auction" -> Right unregistered s -> Left (RESOLVER (T.take 32 s)) where - -- lapsed, but missing the deadline or price its status carries. Withhold it - -- rather than quote the ordinary price; its expiry is already past. - lapsed = NRNameTaken Nothing + reserved_ = resolverReason <$> nsReasonCode + -- a registration the router could not date is not one it can report + registered = maybe (Left $ RESOLVER "no expiry") Right $ do + expires <- nsExpires + graceUntil <- nsGraceEnds + pure NRRegistered {expires, graceUntil} + -- a held-back name is not for sale at the registry's price, so it is quoted + -- no price at all: what it costs is a conversation with SimpleX + unregistered = NRUnregistered {pricing = if isJust reserved_ then Nothing else namePricing ns} + +-- | Absent when the TLD has no controller or price oracle configured. The +-- surcharge start is absent for a name that never lapsed. +namePricing :: NameStatusResp -> Maybe NamePricing +namePricing NameStatusResp {nsRentPrices, nsMinLabelLength, nsPremiumFrom, nsStartPremium, nsEndPremium} = do + rentPrices <- map MicroUSD <$> nsRentPrices + minLabelLength <- nsMinLabelLength + startPremium <- MicroUSD <$> nsStartPremium + endPremium <- MicroUSD <$> nsEndPremium + pure NamePricing {rentPrices, minLabelLength, premiumFrom = nsPremiumFrom, startPremium, endPremium} + --- | Only reached when there was no status to read: a working resolver names one --- on every 4xx, and a lapsed registration is GRACE or AUCTION. The 4xx codes --- keep NOT_FOUND, which is what a client below v22 was told for them. mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case HttpStatusErr 404 -> NOT_FOUND diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index 4da4ce2df2..0e063c0809 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -42,11 +42,9 @@ 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 @@ -90,9 +88,17 @@ data NameStatusResp = NameStatusResp { nsStatus :: Text, nsExpires :: Maybe Int64, nsGraceEnds :: Maybe Int64, - nsAuctionEnds :: Maybe Int64, - nsPremium :: Maybe Text, - nsReasonCode :: Maybe Text + -- | reported alongside the status: a reservation is orthogonal to it + nsReasonCode :: Maybe Text, + -- | when the post-grace surcharge began + nsPremiumFrom :: Maybe Int64, + -- | the TLD's price oracle, in MicroUSD - per year for the rents. The + -- resolver converts from the registry's attoUSD, so nothing 256-bit gets + -- this far and every value fits a JSON number exactly. + nsRentPrices :: Maybe [Int64], + nsMinLabelLength :: Maybe Int, + nsStartPremium :: Maybe Int64, + nsEndPremium :: Maybe Int64 } deriving (Show) @@ -167,18 +173,14 @@ resolveHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseByt { nsStatus = t, nsExpires = jsonField o "expires", nsGraceEnds = jsonField o "graceEnds", - nsAuctionEnds = jsonField o "auctionEnds", - nsPremium = jsonField o "premium" >>= decimalPrice, - nsReasonCode = jsonField o "reasonCode" + nsReasonCode = jsonField o "reasonCode", + nsPremiumFrom = jsonField o "premiumFrom", + nsRentPrices = jsonField o "rentPrices", + nsMinLabelLength = jsonField o "minLabelLength", + nsStartPremium = jsonField o "startPremium", + nsEndPremium = jsonField o "endPremium" } --- | A price is at most 78 decimal digits. The wire format length-prefixes it --- with one byte, which would wrap on anything longer, so drop it instead. -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 nulls for statuses that do not carry it. jsonField :: J.FromJSON a => J.Object -> Key -> Maybe a jsonField o k = JT.parseMaybe J.parseJSON =<< JKM.lookup k o diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index f3899a4e40..5acb59b28f 100644 --- a/tests/AgentTests/ResolveNameTests.hs +++ b/tests/AgentTests/ResolveNameTests.hs @@ -87,15 +87,15 @@ resolveNameTests = do describe "success path" $ it "returns NameRecord" testDirectSuccess describe "name availability" $ - it "an unregistered name answers AVAILABLE" testAvailSuccess + it "an unregistered name answers as unregistered" testAvailSuccess testAvailSuccess :: HasCallStack => IO () testAvailSuccess = withDirectResolver (status404, "{\"error\":\"unregistered\"}") $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of - Right a -> a `shouldBe` SMP.NRNameAvailable - _ -> expectationFailure $ "expected Right NRNameAvailable, got: " <> show r + Right (Nothing, Just (SMP.NRUnregistered _), Nothing) -> pure () + _ -> expectationFailure $ "expected Right (_, NRUnregistered, _), got: " <> show r testDirectNotFound :: HasCallStack => IO () testDirectNotFound = @@ -158,5 +158,5 @@ testDirectSuccess = withDirectResolver (status200, J.encode testNameRecord) $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of - Right (SMP.NRNameRecord nr _) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (NRNameRecord ..), got: " <> show r + Right (_, _, Just nr) -> nr `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (_, _, Just record), got: " <> show r diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 9b39c3b490..c7cd54b213 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -34,7 +34,9 @@ import Simplex.Messaging.Protocol Command (..), CorrId (..), ErrorType (..), - NameResponse (..), + MicroUSD (..), + NamePricing (..), + NameRegistration (..), NameErrorType (..), NameReservedReason (..), SParty (..), @@ -146,7 +148,7 @@ testRslvVersion = Left (PCETransportError TEVersion) -> pure () _ -> expectationFailure $ "expected Left (PCETransportError TEVersion), got: " <> show r -forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameResponse)) +forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameResult)) forwardedResolveAlice = do g <- C.newRandom ts <- getCurrentTime @@ -169,8 +171,8 @@ testRslvForwardedSuccess :: IO () testRslvForwardedSuccess = withProxyAndResolver (status200, J.encode testNameRecord) $ forwardedResolveAlice >>= \r -> case r of - Right (Right (NRNameRecord nr _)) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (Right (NRNameRecord ..)), got: " <> show r + Right (Right (_, _, Just nr)) -> nr `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (Right (_, _, Just record)), got: " <> show r testRslvSuccess :: IO () testRslvSuccess = @@ -179,8 +181,8 @@ testRslvSuccess = (corrId, _entId, resp) <- sendRslv h "rs07" (domain "alice.simplex") corrId `shouldBe` CorrId "rs07" case resp of - Right (RNAME (NRNameRecord nr _)) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (RNAME (NRNameRecord ..)), got: " <> show resp + Right (RNAME Nothing Nothing (Just nr)) -> nr `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (RNAME _ _ (Just record)), got: " <> show resp testRslvAvailable :: IO () testRslvAvailable = @@ -188,21 +190,21 @@ testRslvAvailable = testSMPClient @TLS $ \h -> do (corrId, _entId, resp) <- sendRslv h "na01" (domain "ghost.simplex") corrId `shouldBe` CorrId "na01" - resp `shouldBe` Right (RNAME NRNameAvailable) + resp `shouldBe` Right (RNAME Nothing (Just (NRUnregistered Nothing)) Nothing) testRslvAuction :: IO () testRslvAuction = withResolverServer (status410, auctionBody) $ testSMPClient @TLS $ \h -> do (_, _, resp) <- sendRslv h "na02" (domain "lapsed.simplex") - resp `shouldBe` Right (RNAME (NRNameAuction "99999952316384526016153087" 1798191621)) + resp `shouldBe` Right (RNAME Nothing (Just (NRUnregistered (Just auctionPricing))) Nothing) testRslvReserved :: IO () testRslvReserved = - withResolverServer (status404, "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}") $ + withResolverServer (status404, "{\"error\":\"unregistered\",\"reasonCode\":\"trademark\"}") $ testSMPClient @TLS $ \h -> do (_, _, resp) <- sendRslv h "na03" (domain "acme.simplex") - resp `shouldBe` Right (RNAME (NRNameReserved NRTrademark)) + resp `shouldBe` Right (RNAME (Just RRTrademark) (Just (NRUnregistered Nothing)) Nothing) -- | A client that predates v22 must see exactly what it saw before: the record -- for a name that resolves, and NOT_FOUND for one that does not. @@ -222,7 +224,7 @@ testRslvOldClientRecord = withResolverServer (status200, J.encode testNameRecord) $ do pc <- oldClient r <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) - r `shouldBe` NRNameRecord testNameRecord Nothing + r `shouldBe` (Nothing, Nothing, Just testNameRecord) testRslvOldClientNotFound :: IO () testRslvOldClientNotFound = @@ -237,12 +239,24 @@ testRslvForwardedAuction :: IO () testRslvForwardedAuction = withProxyAndResolver (status410, auctionBody) $ forwardedResolveAlice >>= \r -> case r of - Right (Right a) -> a `shouldBe` NRNameAuction "99999952316384526016153087" 1798191621 - _ -> expectationFailure $ "expected Right (Right (NRNameAuction ..)), got: " <> show r + Right (Right (Nothing, Just (NRUnregistered (Just p)), Nothing)) -> premiumFrom p `shouldBe` Just 1788480000 + _ -> expectationFailure $ "expected Right (Right unregistered-with-premium), got: " <> show r --- a name one day past its grace period, priced by the .testing auction curve +-- a name three days past its grace period, priced by the .testing auction curve auctionBody :: LB.ByteString -auctionBody = "{\"error\":\"auction\",\"premium\":\"99999952316384526016153087\",\"auctionEnds\":1798191621}" +auctionBody = + "{\"error\":\"auction\",\"premiumFrom\":1788480000,\"rentPrices\":[0,0,127930000,31980000,999300],\ + \\"minLabelLength\":3,\"startPremium\":100000000000000,\"endPremium\":47683716}" + +auctionPricing :: NamePricing +auctionPricing = + NamePricing + { rentPrices = map MicroUSD [0, 0, 127930000, 31980000, 999300], + minLabelLength = 3, + premiumFrom = Just 1788480000, + startPremium = MicroUSD 100000000000000, + endPremium = MicroUSD 47683716 + } -- keccak-256("alice"), the registry key aliceHash :: Text @@ -270,8 +284,8 @@ testRslvSendsTheHash = resolvePaths reqs `shouldReturn` [["resolve", aliceHash <> ".simplex"]] -- the record names what the caller asked for case r of - NRNameRecord nr _ -> SMP.nrName nr `shouldBe` "alice.simplex" - _ -> expectationFailure $ "expected NRNameRecord, got: " <> show r + (_, _, Just nr) -> SMP.nrName nr `shouldBe` "alice.simplex" + _ -> expectationFailure $ "expected a record, got: " <> show r where -- the resolver echoes what it was asked about, which is the hash echoed = testNameRecord {SMP.nrName = aliceHash <> ".simplex"} diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 25c912de72..e2e5dc2b12 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -17,7 +17,7 @@ import Network.HTTP.Types (status200, status400, status404, status410, status500 import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed) import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Encoding.String (strDecode, strEncode) -import Simplex.Messaging.Protocol (ErrorType (..), NameErrorType (..), NameRecord (..), NameReservedReason (..), NameResponse (..)) +import Simplex.Messaging.Protocol (ErrorType (..), MicroUSD (..), NameErrorType (..), NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason (..)) import Simplex.Messaging.Server.Main (validateUrl) import Simplex.Messaging.Server.Names ( NamesConfig (..), @@ -104,53 +104,63 @@ errorWireSpec = availabilitySpec :: Spec availabilitySpec = do - -- one lookup answers both questions: what the name points to, and whether it - -- could be registered - it "a resolvable name answers with the record and its expiry" $ - answers status200 (recordWith "\"status\":\"registered\",\"expires\":1811232000") (NRNameRecord testNameRecord (Just 1811232000)) + -- one lookup answers all three questions: what the name points to, whether it + -- can be taken, and whether the registry holds it back + it "a resolvable name answers with the record and its registration" $ + answers status200 (recordWith "\"status\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483") $ + (Nothing, Just NRRegistered {expires = 1813853483, graceUntil = 1821629483}, Just testNameRecord) + -- an older resolver reports no status; the record is still the answer it "a resolver that sends no status still answers with the record" $ - answers status200 (J.encode testNameRecord) (NRNameRecord testNameRecord Nothing) - it "unregistered name is available" $ - answers status404 "{\"error\":\"unregistered\"}" NRNameAvailable - it "expired name is available" $ - answers status410 "{\"error\":\"expired\"}" NRNameAvailable - it "name in grace carries graceEnds" $ - answers status410 "{\"error\":\"grace\",\"graceEnds\":1796377221}" (NRNameInGrace 1796377221) - it "auction carries premium and auctionEnds" $ - answers - status410 - "{\"error\":\"auction\",\"premium\":\"99999952316384526016153087\",\"auctionEnds\":1798191621}" - (NRNameAuction "99999952316384526016153087" 1798191621) - it "reserved name carries the reason" $ - answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}" (NRNameReserved NRTrademark) + answers status200 (J.encode testNameRecord) (Nothing, Nothing, Just testNameRecord) + -- registered, but its records point nowhere + it "registered without a resolver is a registration with no record" $ + answers status404 "{\"error\":\"noResolver\",\"expires\":1813853483,\"graceEnds\":1821629483}" $ + (Nothing, Just NRRegistered {expires = 1813853483, graceUntil = 1821629483}, Nothing) + -- the record travels through grace: the UI decides how long to keep opening it + it "a name in grace keeps its record" $ + answers status200 (recordWith "\"status\":\"grace\",\"expires\":1785000000,\"graceEnds\":1792776000") $ + (Nothing, Just NRRegistered {expires = 1785000000, graceUntil = 1792776000}, Just testNameRecord) + -- a registration the router could not date is not one it can report + it "registered without expiry is a resolver error" $ + refuses status200 (recordWith "\"status\":\"registered\"") (RESOLVER "no expiry") + it "unregistered carries the price" $ + answers status404 (jsonBody ("{\"error\":\"unregistered\"," <> pricingJson <> "}")) $ + (Nothing, Just (NRUnregistered (Just testPricing)), Nothing) + it "past grace carries the premium start" $ + answers status410 (jsonBody ("{\"error\":\"auction\",\"premiumFrom\":1788480000," <> pricingJson <> "}")) $ + (Nothing, Just (NRUnregistered (Just testPricing {premiumFrom = Just 1788480000})), Nothing) + it "expired is unregistered" $ + answers status410 (jsonBody ("{\"error\":\"expired\"," <> pricingJson <> "}")) $ + (Nothing, Just (NRUnregistered (Just testPricing)), Nothing) + -- a TLD with no controller or price oracle: registrable, price unknown + it "no pricing from the resolver is no pricing on the wire" $ + answers status404 "{\"error\":\"unregistered\"}" (Nothing, Just (NRUnregistered Nothing), Nothing) + -- a held-back name is not for sale at the registry's price + it "reserved carries the reason and no price" $ + answers status404 (jsonBody ("{\"error\":\"unregistered\",\"reasonCode\":\"trademark\"," <> pricingJson <> "}")) $ + (Just RRTrademark, Just (NRUnregistered Nothing), Nothing) + -- reservation is orthogonal: it is why the name will not free up at expiry + it "reserved and registered keeps both" $ + answers status200 (recordWith "\"status\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483,\"reasonCode\":\"internal\"") $ + (Just RRInternal, Just NRRegistered {expires = 1813853483, graceUntil = 1821629483}, Just testNameRecord) -- an older resolver sends no reasonCode; that is not the chain saying "none" - it "no reasonCode still reads as reserved" $ - answers status404 "{\"error\":\"reserved\"}" (NRNameReserved NRUnknown) - -- a later version may name reasons this one cannot; the reservation must - -- survive that, or a client would offer a name it cannot register - it "a reason from a later version still reads as reserved" $ - smpDecode "RESERVED SOMETHING_NEW" `shouldBe` Right (NRNameReserved NRUnknown) - -- the resolver names this one explicitly; it is not the same as not knowing - it "unspecified reason reads as unspecified" $ - answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"unspecified\"}" (NRNameReserved NRUnspecified) - it "unknown reason still reads as reserved" $ - answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"astrology\"}" (NRNameReserved NRUnknown) - it "registered without a resolver is taken, with expiry" $ - answers status404 "{\"error\":\"noResolver\",\"expires\":1811232000}" (NRNameTaken (Just 1811232000)) - -- an answer missing its payload withholds the name: quoting the usual price - -- for one that costs a premium is the wrong answer - it "grace without graceEnds is taken" $ - answers status410 "{\"error\":\"grace\"}" (NRNameTaken Nothing) - it "auction without premium is taken" $ - answers status410 "{\"error\":\"auction\",\"auctionEnds\":1798191621}" (NRNameTaken Nothing) - -- the wire length-prefixes the price with one byte, so a longer or - -- non-numeric string is dropped rather than re-encoded - it "over-long premium is dropped" $ - answers status410 (jsonBody ("{\"error\":\"auction\",\"premium\":\"" <> replicate 300 '9' <> "\",\"auctionEnds\":1798191621}")) (NRNameTaken Nothing) - it "non-decimal premium is dropped" $ - answers status410 "{\"error\":\"auction\",\"premium\":\"1e26\",\"auctionEnds\":1798191621}" (NRNameTaken Nothing) - -- a resolver that could not answer must not look like an answer: TAKEN would - -- assert a registration nobody read, AVAILABLE would offer a name that is held + it "no reasonCode is not a reservation" $ + answers status404 "{\"error\":\"unregistered\"}" (Nothing, Just (NRUnregistered Nothing), Nothing) + -- a later version may reserve names for reasons this one cannot name; the + -- reservation must survive that, or a client would offer a name it cannot get + it "a reason from a later version still reserves the name" $ + answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"seasonal\"}" $ + (Just (RRUnknown "seasonal"), Just (NRUnregistered Nothing), Nothing) + -- the reason re-encodes into a slot that ends at a space, so the router keeps + -- it to one bounded token rather than trusting the resolver's text + it "a reason with a space is cut at the space" $ + answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"two words\"}" $ + (Just (RRUnknown "two"), Just (NRUnregistered Nothing), Nothing) + it "an over-long reason is truncated" $ + answers status404 (jsonBody ("{\"error\":\"unregistered\",\"reasonCode\":\"" <> replicate 100 'z' <> "\"}")) $ + (Just (RRUnknown (T.replicate 32 "z")), Just (NRUnregistered Nothing), Nothing) + -- a resolver that could not answer must not look like an answer: a registration + -- would assert one nobody read, and unregistered would offer a name that is held it "upstream failure is a resolver error" $ refuses status502 "{\"error\":\"upstreamError\"}" (RESOLVER "upstreamError") it "unconfigured TLD is a resolver error" $ @@ -167,24 +177,29 @@ availabilitySpec = do withResolverServer (resolveResp status200 (jsonBody ("{\"status\":\"registered\",\"pad\":\"" <> replicate 400 'x' <> "\"}"))) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) {resolverMaxResponseBytes = 200} resolveName env navlDomain `shouldReturn` Left (RESOLVER "response too large") - it "every answer survives the wire" $ + it "every registration survives the wire" $ mapM_ (\a -> smpDecode (smpEncode a) `shouldBe` Right a) - [ NRNameRecord testNameRecord (Just 1811232000), - NRNameRecord testNameRecord Nothing, - NRNameAvailable, - NRNameTaken (Just 1811232000), - NRNameTaken Nothing, - NRNameInGrace 1796377221, - NRNameAuction "99999952316384526016153087" 1798191621, - NRNameReserved NRUnspecified, - NRNameReserved NRTrademark, - NRNameReserved NRPublicInterest, - NRNameReserved NROffensive, - NRNameReserved NRInternal, - NRNameReserved NRPremium, - NRNameReserved NRUnknown + [ NRRegistered {expires = 1813853483, graceUntil = 1821629483}, + NRUnregistered Nothing, + NRUnregistered (Just testPricing), + NRUnregistered (Just testPricing {premiumFrom = Just 1788480000}) ] + it "every reason survives the wire" $ + mapM_ + (\a -> smpDecode (smpEncode a) `shouldBe` Right a) + [ RRUnspecified, + RRTrademark, + RRPublicInterest, + RROffensive, + RRInternal, + RRPremium, + RRUnknown "seasonal" + ] + -- the JSON API keeps a closed set so clients can localise it + it "an unknown reason is \"unknown\" in JSON" $ do + J.encode (RRUnknown "seasonal") `shouldBe` "\"unknown\"" + J.encode RRTrademark `shouldBe` "\"trademark\"" where jsonBody = LB.fromStrict . B.pack -- the resolver returns the record and the registration status in one body @@ -197,6 +212,23 @@ availabilitySpec = do resolveName env navlDomain `shouldReturn` expected navlDomain = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} +-- | The .testing oracle: MicroUSD per year by label length, and a premium that +-- halves daily from $100,000,000 down to a $47.68 floor. +testPricing :: NamePricing +testPricing = + NamePricing + { rentPrices = map MicroUSD [0, 0, 127930000, 31980000, 999300], + minLabelLength = 3, + premiumFrom = Nothing, + startPremium = MicroUSD 100000000000000, + endPremium = MicroUSD 47683716 + } + +pricingJson :: String +pricingJson = + "\"rentPrices\":[0,0,127930000,31980000,999300],\"minLabelLength\":3,\ + \\"startPremium\":100000000000000,\"endPremium\":47683716" + parseNameSpec :: Spec parseNameSpec = do -- asking by hash tells the client if a name is taken without naming it @@ -270,7 +302,7 @@ resolverSpec = do it "returns NameRecord on 200 OK" $ withResolverServer (resolveResp status200 (J.encode testNameRecord)) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) - resolveName env aliceDomain `shouldReturn` Right (NRNameRecord testNameRecord Nothing) + resolveName env aliceDomain `shouldReturn` Right (Nothing, Nothing, Just testNameRecord) it "returns NOT_FOUND on 404" $ withResolverServer (resolveResp status404 "{}") $ \port _ -> do From 1e3d6fa09b7059b238b2aa5647e4dd5ebc1570c7 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 7 Sep 2026 18:19:34 +0200 Subject: [PATCH 18/27] next iteration on types only --- src/Simplex/Messaging/Protocol.hs | 207 +++++++++++++++------------- src/Simplex/Messaging/SystemTime.hs | 3 +- 2 files changed, 111 insertions(+), 99 deletions(-) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index fcd2f4c3e7..a57f514d75 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -80,12 +80,11 @@ module Simplex.Messaging.Protocol ErrorType (..), CommandError (..), ProxyError (..), - NameResult, NameRegistration (..), NamePricing (..), - MicroUSD (..), + USDCents (..), NameReservedReason (..), - reservedReason, + parseReservedReason, NameErrorType (..), BrokerErrorType (..), NetworkError (..), @@ -276,6 +275,7 @@ import Simplex.Messaging.Parsers import Simplex.Messaging.Protocol.Types import Simplex.Messaging.Server.QueueStore.QueueInfo import Simplex.Messaging.ServiceScheme +import Simplex.Messaging.SystemTime (SystemSeconds) import Simplex.Messaging.SimplexName (SimplexDomain) import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (TransportHost, TransportHosts (..)) @@ -746,7 +746,7 @@ data BrokerMsg where ERR :: ErrorType -> BrokerMsg PONG :: BrokerMsg -- What the router knows about a SimpleX name. - RNAME :: Maybe NameReservedReason -> Maybe NameRegistration -> Maybe NameRecord -> BrokerMsg + RNAME :: NameRegistration -> BrokerMsg deriving (Eq, Show) data RcvMessage = RcvMessage @@ -1595,132 +1595,140 @@ data ErrorType DUPLICATE_ -- not part of SMP protocol, used internally deriving (Eq, Show) --- | USD in millionths, converted by the resolver from the registry's attoUSD. --- Int64 reaches ~9.2 trillion USD, and the smallest value in play - the last --- step of a decaying premium - is a millionth of a dollar. -newtype MicroUSD = MicroUSD Int64 +-- | US cents. Rounded up wherever the registry's unit does not divide evenly, +-- so a quote is never below what is charged; the exact figure is settled on +-- chain at registration. +newtype USDCents = USDCents Int64 deriving (Eq, Ord, Show) deriving newtype (Encoding, ToJSON, FromJSON) --- | The three facts RSLV answers with, any of which the router may not have. --- A plain tuple: nothing downstream needs a type of its own for it. -type NameResult = (Maybe NameReservedReason, Maybe NameRegistration, Maybe NameRecord) - --- | What the registrar says about a name: held by someone, or registrable. +-- | What the registry holds for a name. A TLD with no registrar or no price +-- oracle configured is not a case here - it is ERR NAME RESOLVER, because a +-- name that cannot be dated or priced is not one this router can report on. data NameRegistration - = -- | unix seconds; graceUntil > expires, and until it only the owner renews. - NRRegistered {expires :: Int64, graceUntil :: Int64} - | -- | Not held by anyone. Pricing is absent when the TLD has no controller or - -- price oracle configured, and when the name is reserved: a held-back name - -- is not for sale at the registry's price, only by arrangement with SimpleX. - -- An auction is not a separate state: it is this, with a premium that has - -- not decayed to zero yet. - NRUnregistered {pricing :: Maybe NamePricing} + = -- | Held by someone. A registered name always resolves: where its owner set + -- no records the record is still present, every field unset and nrResolver + -- the zero address, so "taken until " stays answerable. + NRRegistered + { -- | unix seconds the registration runs out. Absent only from a v20/v21 + -- router, whose answer carried the record and nothing else. + expires :: Maybe SystemSeconds, + -- | unix seconds, > expires: until here only the owner may renew + graceUntil :: Maybe SystemSeconds, + -- | held back as well, which is why it will not free up at expiry + reservedReason_ :: Maybe NameReservedReason, + nameRecord :: NameRecord + } + | -- | Held by nobody, and registrable now. + NRAvailable + { pricing :: NamePricing, + -- | while set, the name also costs a surcharge above `pricing` that + -- decays to nothing at this time. The surcharge itself is deliberately + -- not carried: it changes continuously, so it cannot be an in-app + -- purchase price. A client counts down to the ordinary price instead. + auctionUntil :: Maybe SystemSeconds + } + | -- | Held back by the registry and not registered. No price: what it costs, + -- and whether it can be had at all, is a conversation with SimpleX. This is + -- also why a reserved name never frees up on its own. + NRReserved {reservedReason :: NameReservedReason} deriving (Eq, Show) instance Encoding NameRegistration where smpEncode = \case - NRRegistered {expires, graceUntil} -> "REGISTERED " <> smpEncode (expires, graceUntil) - NRUnregistered {pricing} -> "UNREGISTERED " <> smpEncode pricing + NRRegistered {expires, graceUntil, reservedReason_, nameRecord} -> + "REGISTERED " <> smpEncode (expires, graceUntil, reservedReason_, ' ', Tail $ LB.toStrict $ J.encode nameRecord) + NRAvailable {pricing, auctionUntil} -> "AVAILABLE " <> smpEncode (auctionUntil, pricing) + NRReserved {reservedReason} -> "RESERVED " <> smpEncode reservedReason smpP = A.takeTill (== ' ') >>= \case - "REGISTERED" -> NRRegistered <$> _smpP <*> smpP - "UNREGISTERED" -> NRUnregistered <$> _smpP + "REGISTERED" -> do + (expires, graceUntil, reservedReason_) <- _smpP + nameRecord <- J.eitherDecodeStrict . unTail <$?> _smpP + pure NRRegistered {expires, graceUntil, reservedReason_, nameRecord} + "AVAILABLE" -> do + auctionUntil <- _smpP + pricing <- smpP + pure NRAvailable {pricing, auctionUntil} + "RESERVED" -> NRReserved <$> _smpP _ -> fail "bad NameRegistration" --- | Enough to price the name locally, as often as the UI likes, without naming --- it. All amounts MicroUSD, all times unix seconds: +-- | Enough to price the name locally. The client knows the label, so it knows +-- both which tier applies and whether the label is long enough - neither of +-- which the router can see behind a hash. -- --- price len duration t --- = rentPrices !! min (len - 1) (length rentPrices - 1) * duration `div` year --- + max 0 (decayed startPremium (t - premiumFrom) - endPremium) --- decayed s elapsed = s * 0.5 ** (elapsed / 86400) +-- price len duration = rentPrices !! min (len - 1) (length rentPrices - 1) +-- * duration `div` 31536000 -- --- The surcharge is charged once whatever the duration - only the rent scales. --- decayed is computed in Double and rounded: it lands within 0.01 MicroUSD of --- the chain across the whole curve. Rounding startPremium and endPremium --- separately means the premium may not reach exactly zero, so the client floors --- at 0, as the chain does. The minimum registration is 28 days, a contract --- constant rather than a per-deployment value, so it is not sent. +-- The registry's minimum registration is 28 days, a contract constant rather +-- than a per-deployment value, so it is specified rather than sent. data NamePricing = NamePricing - { -- | MicroUSD per year by label length: first entry a one-letter label, last - -- covering every longer one. Rounded up, so a quote is never below the - -- charge; the exact figure is settled on chain at registration. - rentPrices :: [MicroUSD], - -- | characters: the registry refuses shorter, and a hash cannot be measured. - minLabelLength :: Int, - -- | unix seconds the surcharge began. Nothing when there is none. - premiumFrom :: Maybe Int64, - startPremium :: MicroUSD, -- before decay - endPremium :: MicroUSD -- floor subtracted from the decayed value + { -- | US cents per year by label length: first entry a one-letter label, last + -- covering every longer one. + rentPrices :: [USDCents], + -- | characters: the registry refuses shorter, so the client must check it. + minLabelLength :: Int } deriving (Eq, Show) instance Encoding NamePricing where - smpEncode NamePricing {rentPrices, minLabelLength, premiumFrom, startPremium, endPremium} = - smpEncodeList rentPrices <> smpEncode (w16 minLabelLength, premiumFrom, startPremium, endPremium) - where - w16 = fromIntegral :: Int -> Word16 + smpEncode NamePricing {rentPrices, minLabelLength} = + smpEncodeList rentPrices <> smpEncode (fromIntegral minLabelLength :: Word16) smpP = do rentPrices <- smpListP - (minLen, premiumFrom, startPremium, endPremium) <- smpP - pure NamePricing {rentPrices, minLabelLength = fromIntegral (minLen :: Word16), premiumFrom, startPremium, endPremium} + minLen <- smpP + pure NamePricing {rentPrices, minLabelLength = fromIntegral (minLen :: Word16)} -- | Why the registry holds a name back. A reason this version has no word for -- keeps its own word rather than losing the reservation. data NameReservedReason - = RRUnspecified - | RRTrademark - | RRPublicInterest - | RROffensive - | RRInternal - | RRPremium - | RRUnknown Text + = -- | held for SimpleX. On chain this is 1, which is also what the boolean + -- reservedNames of the first .testing deployment set. + NRRInternal + | NRRTrademark + | NRRCommunity + | -- | a reason added to the registry after this version: still reserved, and + -- carrying its own word so a later version can name it + NRRUnknown Text deriving (Eq, Show) instance Encoding NameReservedReason where smpEncode = \case - RRUnspecified -> "UNSPECIFIED" - RRTrademark -> "TRADEMARK" - RRPublicInterest -> "PUBLIC_INTEREST" - RROffensive -> "OFFENSIVE" - RRInternal -> "INTERNAL" - RRPremium -> "PREMIUM" - RRUnknown t -> encodeUtf8 t + NRRInternal -> "INTERNAL" + NRRTrademark -> "TRADEMARK" + NRRCommunity -> "COMMUNITY" + NRRUnknown t -> encodeUtf8 t smpP = A.takeTill (== ' ') >>= \case - "UNSPECIFIED" -> pure RRUnspecified - "TRADEMARK" -> pure RRTrademark - "PUBLIC_INTEREST" -> pure RRPublicInterest - "OFFENSIVE" -> pure RROffensive - "INTERNAL" -> pure RRInternal - "PREMIUM" -> pure RRPremium - t -> pure $ RRUnknown (safeDecodeUtf8 t) + "INTERNAL" -> pure NRRInternal + "TRADEMARK" -> pure NRRTrademark + "COMMUNITY" -> pure NRRCommunity + t -> pure $ NRRUnknown (safeDecodeUtf8 t) -- | The vocabulary the backing resolver and the JSON API share, which is not -- the wire vocabulary above. instance TextEncoding NameReservedReason where textEncode = \case - RRUnspecified -> "unspecified" - RRTrademark -> "trademark" - RRPublicInterest -> "publicInterest" - RROffensive -> "offensive" - RRInternal -> "internal" - RRPremium -> "premium" - RRUnknown t -> t + NRRInternal -> "internal" + NRRTrademark -> "trademark" + NRRCommunity -> "community" + NRRUnknown t -> t textDecode = \case - "unspecified" -> Just RRUnspecified - "trademark" -> Just RRTrademark - "publicInterest" -> Just RRPublicInterest - "offensive" -> Just RROffensive - "internal" -> Just RRInternal - "premium" -> Just RRPremium - "unknown" -> Just (RRUnknown "unknown") + "internal" -> Just NRRInternal + "trademark" -> Just NRRTrademark + "community" -> Just NRRCommunity + "unknown" -> Just (NRRUnknown "unknown") _ -> Nothing -- | Keeps its word rather than losing the reservation. -reservedReason :: Text -> NameReservedReason -reservedReason t = fromMaybe (RRUnknown t) (textDecode t) +parseReservedReason :: Text -> NameReservedReason +parseReservedReason t = fromMaybe (NRRUnknown t) (textDecode t) + +-- | What a v20/v21 router's answer amounts to: it resolves, and nothing else +-- was said about it. +oldRegistration :: NameRecord -> NameRegistration +oldRegistration nameRecord = + NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord} -- | Name resolution error @@ -2106,11 +2114,12 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where | v < clientNoticesSMPVersion -> BLOCKED info {notice = Nothing} _ -> err PONG -> e PONG_ - RNAME reserved_ reg_ rec_ - | v >= nameAvailSMPVersion -> e (RNAME_, ' ', reserved_, ' ', reg_, ' ', Tail $ LB.toStrict $ J.encode rec_) - | otherwise -> case rec_ of - Just rec -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode rec) - Nothing -> e (ERR_, ' ', NAME NOT_FOUND) + RNAME reg + | v >= nameAvailSMPVersion -> e (RNAME_, ' ', reg) + -- v20/v21 knows only the record, and had NOT_FOUND for every other answer + | otherwise -> case reg of + NRRegistered {nameRecord} -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode nameRecord) + _ -> e (ERR_, ' ', NAME NOT_FOUND) where e :: Encoding a => a -> ByteString e = smpEncode @@ -2158,8 +2167,10 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where ERR_ -> ERR <$> _smpP PONG_ -> pure PONG RNAME_ - | v >= nameAvailSMPVersion -> RNAME <$> _smpP <*> _smpP <*> (J.eitherDecodeStrict . unTail <$?> _smpP) - | otherwise -> RNAME Nothing Nothing . Just <$> (J.eitherDecodeStrict . unTail <$?> _smpP) + | v >= nameAvailSMPVersion -> RNAME <$> _smpP + -- v20/v21 sent the record and nothing else; the dates it had no field for + -- are the only reason they are optional above + | otherwise -> fmap (RNAME . oldRegistration) . J.eitherDecodeStrict . unTail <$?> _smpP where serviceRespP resp | v >= rcvServiceSMPVersion = resp <$> _smpP <*> smpP @@ -2560,7 +2571,7 @@ $(concat <$> mapM @[] (J.deriveJSON (sumTypeJSON id)) [''ProxyError, ''NameError instance ToJSON NameReservedReason where toJSON = J.String . \case - RRUnknown _ -> "unknown" + NRRUnknown _ -> "unknown" r -> textEncode r instance FromJSON NameReservedReason where diff --git a/src/Simplex/Messaging/SystemTime.hs b/src/Simplex/Messaging/SystemTime.hs index d53a60aa1e..18d5ffb50d 100644 --- a/src/Simplex/Messaging/SystemTime.hs +++ b/src/Simplex/Messaging/SystemTime.hs @@ -23,11 +23,12 @@ import Data.Time.Clock.System (SystemTime (..), getSystemTime, systemToUTCTime) import Data.Typeable (Proxy (..)) import GHC.TypeLits (KnownNat, Nat, natVal) import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..)) +import Simplex.Messaging.Encoding (Encoding) import Simplex.Messaging.Encoding.String newtype RoundedSystemTime (t :: Nat) = RoundedSystemTime {roundedSeconds :: Int64} deriving (Eq, Ord, Show) - deriving newtype (FromJSON, ToJSON, FromField, ToField) + deriving newtype (Encoding, FromJSON, ToJSON, FromField, ToField) type SystemDate = RoundedSystemTime 86400 From 1dabe1dc5268bca2ea6a38c4c8ce2fd9a46145ba Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 8 Sep 2026 08:28:33 +0200 Subject: [PATCH 19/27] rentPrices map --- src/Simplex/Messaging/Protocol.hs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index a57f514d75..401c23c96a 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -85,6 +85,7 @@ module Simplex.Messaging.Protocol USDCents (..), NameReservedReason (..), parseReservedReason, + oldRegistration, NameErrorType (..), BrokerErrorType (..), NetworkError (..), @@ -253,6 +254,8 @@ import Data.Kind import Data.List (foldl') import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, isNothing) import Data.String import Data.Text (Text) @@ -1657,27 +1660,35 @@ instance Encoding NameRegistration where -- both which tier applies and whether the label is long enough - neither of -- which the router can see behind a hash. -- --- price len duration = rentPrices !! min (len - 1) (length rentPrices - 1) +-- price len duration = fromMaybe basePrice (M.lookup len rentPrices) -- * duration `div` 31536000 -- -- The registry's minimum registration is 28 days, a contract constant rather -- than a per-deployment value, so it is specified rather than sent. data NamePricing = NamePricing - { -- | US cents per year by label length: first entry a one-letter label, last - -- covering every longer one. - rentPrices :: [USDCents], + { -- | US cents per year for the label lengths the registry prices specially. + -- Lengths below minLabelLength are absent, being unregistrable. + rentPrices :: Map Int USDCents, + -- | US cents per year for every length not in rentPrices. + basePrice :: USDCents, -- | characters: the registry refuses shorter, so the client must check it. minLabelLength :: Int } deriving (Eq, Show) instance Encoding NamePricing where - smpEncode NamePricing {rentPrices, minLabelLength} = - smpEncodeList rentPrices <> smpEncode (fromIntegral minLabelLength :: Word16) + smpEncode NamePricing {rentPrices, basePrice, minLabelLength} = + smpEncodeList (map tier $ M.toList rentPrices) <> smpEncode (basePrice, w16 minLabelLength) + where + tier (len, price) = (w16 len, price) + w16 = fromIntegral :: Int -> Word16 smpP = do - rentPrices <- smpListP - minLen <- smpP - pure NamePricing {rentPrices, minLabelLength = fromIntegral (minLen :: Word16)} + tiers <- smpListP + (basePrice, minLen) <- smpP + pure NamePricing {rentPrices = tierMap tiers, basePrice, minLabelLength = fromIntegral (minLen :: Word16)} + where + tierMap :: [(Word16, USDCents)] -> Map Int USDCents + tierMap = M.fromList . map (\(len, price) -> (fromIntegral len, price)) -- | Why the registry holds a name back. A reason this version has no word for -- keeps its own word rather than losing the reservation. From 82ca4af3827dc5867c6ef9a03e92684f20d7ea9f Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 8 Sep 2026 08:44:35 +0200 Subject: [PATCH 20/27] claude answering ep review. to be continued... --- src/Simplex/Messaging/Protocol.hs | 79 ++++++++++++------------------- 1 file changed, 29 insertions(+), 50 deletions(-) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 401c23c96a..99762e2eea 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -256,7 +256,7 @@ import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe, isJust, isNothing) +import Data.Maybe (isJust, isNothing) import Data.String import Data.Text (Text) import qualified Data.Text as T @@ -1640,20 +1640,20 @@ data NameRegistration instance Encoding NameRegistration where smpEncode = \case NRRegistered {expires, graceUntil, reservedReason_, nameRecord} -> - "REGISTERED " <> smpEncode (expires, graceUntil, reservedReason_, ' ', Tail $ LB.toStrict $ J.encode nameRecord) - NRAvailable {pricing, auctionUntil} -> "AVAILABLE " <> smpEncode (auctionUntil, pricing) - NRReserved {reservedReason} -> "RESERVED " <> smpEncode reservedReason + smpEncode ('N', expires, graceUntil, reservedReason_, ' ', Tail $ LB.toStrict $ J.encode nameRecord) + NRAvailable {pricing, auctionUntil} -> smpEncode ('A', auctionUntil, pricing) + NRReserved {reservedReason} -> smpEncode ('R', reservedReason) smpP = - A.takeTill (== ' ') >>= \case - "REGISTERED" -> do - (expires, graceUntil, reservedReason_) <- _smpP + A.anyChar >>= \case + 'N' -> do + (expires, graceUntil, reservedReason_) <- smpP nameRecord <- J.eitherDecodeStrict . unTail <$?> _smpP pure NRRegistered {expires, graceUntil, reservedReason_, nameRecord} - "AVAILABLE" -> do - auctionUntil <- _smpP + 'A' -> do + auctionUntil <- smpP pricing <- smpP pure NRAvailable {pricing, auctionUntil} - "RESERVED" -> NRReserved <$> _smpP + 'R' -> NRReserved <$> smpP _ -> fail "bad NameRegistration" -- | Enough to price the name locally. The client knows the label, so it knows @@ -1678,13 +1678,12 @@ data NamePricing = NamePricing instance Encoding NamePricing where smpEncode NamePricing {rentPrices, basePrice, minLabelLength} = - smpEncodeList (map tier $ M.toList rentPrices) <> smpEncode (basePrice, w16 minLabelLength) + smpEncode (EncList $ map tier $ M.toList rentPrices, basePrice, w16 minLabelLength) where tier (len, price) = (w16 len, price) w16 = fromIntegral :: Int -> Word16 smpP = do - tiers <- smpListP - (basePrice, minLen) <- smpP + (EncList tiers, basePrice, minLen) <- smpP pure NamePricing {rentPrices = tierMap tiers, basePrice, minLabelLength = fromIntegral (minLen :: Word16)} where tierMap :: [(Word16, USDCents)] -> Map Int USDCents @@ -1703,37 +1702,26 @@ data NameReservedReason NRRUnknown Text deriving (Eq, Show) -instance Encoding NameReservedReason where - smpEncode = \case - NRRInternal -> "INTERNAL" - NRRTrademark -> "TRADEMARK" - NRRCommunity -> "COMMUNITY" - NRRUnknown t -> encodeUtf8 t - smpP = - A.takeTill (== ' ') >>= \case - "INTERNAL" -> pure NRRInternal - "TRADEMARK" -> pure NRRTrademark - "COMMUNITY" -> pure NRRCommunity - t -> pure $ NRRUnknown (safeDecodeUtf8 t) - --- | The vocabulary the backing resolver and the JSON API share, which is not --- the wire vocabulary above. -instance TextEncoding NameReservedReason where - textEncode = \case +-- | One vocabulary, shared by the wire, the backing resolver and the JSON API. +instance StrEncoding NameReservedReason where + strEncode = \case NRRInternal -> "internal" NRRTrademark -> "trademark" NRRCommunity -> "community" - NRRUnknown t -> t - textDecode = \case - "internal" -> Just NRRInternal - "trademark" -> Just NRRTrademark - "community" -> Just NRRCommunity - "unknown" -> Just (NRRUnknown "unknown") - _ -> Nothing + NRRUnknown t -> encodeUtf8 t + strP = parseReservedReason . safeDecodeUtf8 <$> A.takeTill (== ' ') + +instance Encoding NameReservedReason where + smpEncode = strEncode + smpP = strP -- | Keeps its word rather than losing the reservation. parseReservedReason :: Text -> NameReservedReason -parseReservedReason t = fromMaybe (NRRUnknown t) (textDecode t) +parseReservedReason = \case + "internal" -> NRRInternal + "trademark" -> NRRTrademark + "community" -> NRRCommunity + t -> NRRUnknown t -- | What a v20/v21 router's answer amounts to: it resolves, and nothing else -- was said about it. @@ -2127,7 +2115,6 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where PONG -> e PONG_ RNAME reg | v >= nameAvailSMPVersion -> e (RNAME_, ' ', reg) - -- v20/v21 knows only the record, and had NOT_FOUND for every other answer | otherwise -> case reg of NRRegistered {nameRecord} -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode nameRecord) _ -> e (ERR_, ' ', NAME NOT_FOUND) @@ -2179,8 +2166,6 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where PONG_ -> pure PONG RNAME_ | v >= nameAvailSMPVersion -> RNAME <$> _smpP - -- v20/v21 sent the record and nothing else; the dates it had no field for - -- are the only reason they are optional above | otherwise -> fmap (RNAME . oldRegistration) . J.eitherDecodeStrict . unTail <$?> _smpP where serviceRespP resp @@ -2575,15 +2560,9 @@ $(J.deriveJSON defaultJSON ''BlockingInfo) -- run deriveJSON in one TH splice to allow mutual instance $(concat <$> mapM @[] (J.deriveJSON (sumTypeJSON id)) [''ProxyError, ''NameErrorType, ''ErrorType]) --- clients report the reason to the user, so it has to reach their API as JSON --- | The JSON API keeps the closed set, so clients can localise it. An --- unrecognised reason is "unknown" here; its word stays on the SMP wire for a --- version that knows it. instance ToJSON NameReservedReason where - toJSON = - J.String . \case - NRRUnknown _ -> "unknown" - r -> textEncode r + toJSON = strToJSON + toEncoding = strToJEncoding instance FromJSON NameReservedReason where - parseJSON = textParseJSON "NameReservedReason" + parseJSON = strParseJSON "NameReservedReason" From 1a745982312a69427ddd1f10c67406b31ab019ea Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 8 Sep 2026 09:16:14 +0200 Subject: [PATCH 21/27] separate labelhash and plaintext names cleanly --- src/Simplex/Messaging/Protocol.hs | 40 ++++++++++++++++- src/Simplex/Messaging/SimplexName.hs | 67 ++++++++++++++++------------ 2 files changed, 77 insertions(+), 30 deletions(-) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 99762e2eea..d23ef15ec5 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -80,6 +80,8 @@ module Simplex.Messaging.Protocol ErrorType (..), CommandError (..), ProxyError (..), + NameQuery (..), + NameQueryLabel (..), NameRegistration (..), NamePricing (..), USDCents (..), @@ -279,7 +281,7 @@ import Simplex.Messaging.Protocol.Types import Simplex.Messaging.Server.QueueStore.QueueInfo import Simplex.Messaging.ServiceScheme import Simplex.Messaging.SystemTime (SystemSeconds) -import Simplex.Messaging.SimplexName (SimplexDomain) +import Simplex.Messaging.SimplexName (LabelHash, SimplexTLD) import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (TransportHost, TransportHosts (..)) import Simplex.Messaging.Util (bshow, eitherToMaybe, safeDecodeUtf8, (<$?>)) @@ -612,7 +614,7 @@ data Command (p :: Party) where -- - corrId: unique correlation ID between proxy and relay, also used as a nonce to encrypt forwarded transmission RFWD :: EncFwdTransmission -> Command ProxyService -- use CorrId as CbNonce, proxy to relay -- Resolve SimpleX name. - RSLV :: SimplexDomain -> Command Resolver + RSLV :: NameQuery -> Command Resolver deriving instance Show (Command p) @@ -1598,6 +1600,40 @@ data ErrorType DUPLICATE_ -- not part of SMP protocol, used internally deriving (Eq, Show) +-- | What RSLV asks about. Distinct from SimplexDomain, which stays a name a +-- person can type and a UI can show: only this may name a label by its hash. +data NameQuery = NameQuery + { queryTLD :: SimplexTLD, + -- | only the second-level label may be hashed - subnames are needed as text + -- to reach the record, so they are not part of this choice + queryLabel :: NameQueryLabel, + -- | parent to child, as in SimplexDomain + querySub :: [Text] + } + deriving (Eq, Show) + +data NameQueryLabel + = NQName Text + | NQHash LabelHash + deriving (Eq, Show) + +instance Encoding NameQueryLabel where + smpEncode = \case + NQName t -> smpEncode ('N', t) + NQHash h -> smpEncode ('H', h) + smpP = + A.anyChar >>= \case + 'N' -> NQName <$> smpP + 'H' -> NQHash <$> smpP + _ -> fail "bad NameQueryLabel" + +instance Encoding NameQuery where + smpEncode NameQuery {queryTLD, queryLabel, querySub} = + smpEncode (queryTLD, queryLabel, EncList querySub) + smpP = do + (queryTLD, queryLabel, EncList querySub) <- smpP + pure NameQuery {queryTLD, queryLabel, querySub} + -- | US cents. Rounded up wherever the registry's unit does not divide evenly, -- so a quote is never below what is charged; the exact figure is settled on -- chain at registration. diff --git a/src/Simplex/Messaging/SimplexName.hs b/src/Simplex/Messaging/SimplexName.hs index ee0d4a68f2..0a4ce79abf 100644 --- a/src/Simplex/Messaging/SimplexName.hs +++ b/src/Simplex/Messaging/SimplexName.hs @@ -10,7 +10,9 @@ module Simplex.Messaging.SimplexName SimplexTLD (..), SimplexNameType (..), fullDomainName, - hashedDomain, + LabelHash (..), + labelHash, + labelHashText, shortNameInfoStr, ) where @@ -21,6 +23,7 @@ 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 as BA import qualified Data.ByteArray.Encoding as BAE import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -77,23 +80,25 @@ nameLabelP = do -- | A second-level label sent as its keccak256 hash, so a router never learns -- the name. ENS's bracket form: brackets are outside the name character set, so -- it cannot collide with a real name. 66 chars, so exempt from the label limit. -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 == ']' - --- | Replace the second-level label with its keccak256 hash, the registry key. --- Subname labels stay text; a web TLD has no registry. -hashedDomain :: SimplexDomain -> SimplexDomain -hashedDomain d@SimplexDomain {nameTLD, domain} - | nameTLD == TLDWeb || isLabelHash domain = d - | otherwise = d {domain = "[" <> labelHash <> "]"} - where - keccak = hash (encodeUtf8 (T.toLower domain)) :: Digest Keccak_256 - labelHash = decodeLatin1 (BAE.convertToBase BAE.Base16 keccak) +-- | The registry's key for a label, and what BaseRegistrarImplementation.labelOf +-- is keyed on. Always 32 bytes, so it is never told from a name by its shape. +newtype LabelHash = LabelHash ByteString + deriving (Eq, Show) + +instance Encoding LabelHash where + smpEncode (LabelHash h) = h + smpP = LabelHash <$> A.take 32 + +-- | keccak-256 of the lowercased label. Only a second-level label is a registry +-- key: subname labels are needed as text to reach the record. +labelHash :: Text -> LabelHash +labelHash label = LabelHash $ BA.convert (hash (encodeUtf8 (T.toLower label)) :: Digest Keccak_256) + +-- | How the backing resolver is addressed for a hashed label: ENS's encoding +-- for a label whose text is unknown. The SMP protocol never parses this form - +-- it tags the choice instead - so the brackets live here alone. +labelHashText :: LabelHash -> Text +labelHashText (LabelHash h) = "[" <> decodeLatin1 (BAE.convertToBase BAE.Base16 h) <> "]" -- | Cap the name at 253 bytes (DNS full-domain limit) boundedNonSpace :: A.Parser ByteString @@ -118,26 +123,32 @@ instance StrEncoding SimplexDomain where strEncode = encodeUtf8 . fullDomainName strP = parseDomain . safeDecodeUtf8 <$?> boundedNonSpace where - parseDomain s = AT.parseOnly ((labelHashP <|> nameLabelP) `AT.sepBy1` AT.char '.' <* AT.endOfInput) s >>= mkDomain + parseDomain s = AT.parseOnly (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 -> 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) []) + "simplex" : name : sub -> Right (SimplexDomain TLDSimplex name sub) + "testing" : name : sub -> Right (SimplexDomain TLDTesting name sub) + _ -> 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 smpP = strP +instance Encoding SimplexTLD where + smpEncode = \case + TLDSimplex -> "s" + TLDTesting -> "t" + TLDWeb -> "w" + smpP = + A.anyChar >>= \case + 's' -> pure TLDSimplex + 't' -> pure TLDTesting + 'w' -> pure TLDWeb + _ -> fail "bad SimplexTLD" + fullDomainName :: SimplexDomain -> Text fullDomainName SimplexDomain {nameTLD, domain, subDomain} = T.intercalate "." (reverse subDomain ++ [domain] ++ tld') where From c0bf6203e2207fe1ae50d5dca56cfdf2e9fb620d Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 8 Sep 2026 09:36:00 +0200 Subject: [PATCH 22/27] align implementation with latest type changes to test against client --- protocol/simplex-messaging.md | 210 +++++++++--------- scripts/resolver/service/snrc-resolve.py | 121 +++++++--- src/Simplex/Messaging/Agent.hs | 6 +- src/Simplex/Messaging/Agent/Client.hs | 4 +- src/Simplex/Messaging/Client.hs | 23 +- src/Simplex/Messaging/Protocol.hs | 24 +- src/Simplex/Messaging/Server.hs | 4 +- src/Simplex/Messaging/Server/Names.hs | 100 ++++----- .../Messaging/Server/Names/HttpResolver.hs | 27 +-- src/Simplex/Messaging/SimplexName.hs | 9 +- tests/AgentTests/ResolveNameTests.hs | 10 +- tests/RSLVTests.hs | 68 +++--- tests/SMPNamesTests.hs | 171 ++++++-------- 13 files changed, 412 insertions(+), 365 deletions(-) diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index c9f699da85..37a47de28c 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1462,31 +1462,43 @@ The `RSLV` command carries the canonical fully-qualified name directly as the payload (not JSON): ```abnf -rslv = %s"RSLV" SP domain ; domain = canonical name as non-space bytes, consuming the remainder of the transmission +rslv = %s"RSLV" SP query +query = tld label sub +tld = %s"s" / %s"t" / %s"w" ; .simplex / .testing / a web name +label = %s"N" shortString ; the second-level label as text + / %s"H" 32*32 OCTET ; its keccak-256 +sub = length *shortString ; subname labels, parent to child ``` `domain` is the UTF-8 canonical fully-qualified name with the TLD always explicit (e.g. `privacy.simplex`, `test.testing`, `example.com`), bounded to 253 bytes. -**Hashed labels.** The second-level label MAY be given as `[` + 64 lowercase hex -+ `]`, the keccak-256 hash of that label, so a router can answer without being -told the name. This is ENS's encoding for an unknown preimage; brackets are -outside the name character set, so it cannot collide with a real name. A hashed -label is 66 characters and is exempt from the 63-byte label limit — it is a -registry key, not a DNS label. A bare `0x` hex string is an ordinary label, and -would be hashed again, keying a different name. - -Only the second-level label may be hashed; subname labels are needed as text to -reach the record. `[].simplex` and `sub.[].simplex` reach the nodes -their plain names do; a bracket label anywhere else is an ordinary label. Routers -MUST reject a name whose hashed label is not the second-level one. - -From v22 a client MUST hash the second-level label of every `RSLV`. -Older routers cannot parse the form, so a client on an older session sends the -name. A hashed query's record names the hash; the client restores the name it -used. A router answering a hashed query does not know the name's length, so it -cannot check a minimum-length policy either. +**Hashed labels.** `RSLV` does not carry a name. It carries a query, whose +second-level label is either the label itself or the keccak-256 of it, tagged so +that the two are told apart by the encoding rather than by their shape. Nothing +decides what a label is by counting characters or looking for punctuation. + +Only the second-level label may be hashed: subname labels are needed as text to +reach the record, and a web TLD has no registry to key on. `sub..simplex` +reaches the node `sub.name.simplex` does. + +From v22 a client MUST send the hash. Older routers can only read the name, so a +client on an older session sends it, and gets what it always got. A router +answering a hashed query does not know the name's length, so it cannot check a +minimum-length policy either — the client does that, from the pricing it is sent. + +The hash reaches the backing resolver as `[` + 64 lowercase hex + `]`, ENS's +encoding for a label whose text is unknown, because that is what its HTTP API +takes. That form appears nowhere in SMP. + +A hashed query still answers with the name. The registrar records the plaintext +label when a name is registered, keyed by the hash of that label, so a router can +look up what the hash stands for without ever being told. It is not the client's +word for it and needs no checking: the key is the hash of the value. A name +registered without that record answers `unknown`. What stays impossible is +learning a name that is *not* registered — there is nothing recorded to look up, +so a name someone is merely considering never becomes known. **Server-side validation.** The names router parses `domain` as a fully-qualified name (TLD required — bare labels are rejected) and forwards it @@ -1502,7 +1514,7 @@ several configured servers can act on distinctly: | `RNAME` | the router read the registry | use it | | `ERR NAME NOT_FOUND` | the router could not read any answer for the name; below v22 also every name that does not resolve | stop, and do not read it as registrable | | `ERR NAME NO_RESOLVER` | this router has no resolver (names role not enabled) | skip this server, try the next | -| `ERR NAME RESOLVER ` | the resolver answered something the router cannot act on: an unconfigured TLD, an unreachable chain, a transport failure, a timeout | surface ``; retry only if it reads as transient | +| `ERR NAME RESOLVER ` | the router cannot state an answer completely: no registrar or price oracle for the TLD, an unreachable chain, a transport failure, a timeout, a registration it could not date or resolve | surface ``; retry only if it reads as transient | A client SHOULD NOT broadcast a `name` to further servers after a name-capable router has answered (`NOT_FOUND` or `RESOLVER`), since that router has already @@ -1513,56 +1525,53 @@ fact that this router cannot resolve, so iterating past it is safe. Resolving a name and asking whether it can be registered are one question to the registry, and one lookup answers both: a client offering a taken name to -register wants to show what took it. `RNAME` carries three facts, from three -contracts - the controller, the registrar and the resolver - and any of them may -be absent. +register wants to show what took it. `RNAME` carries what the registry holds. ```abnf -rname = %s"RNAME" SP reserved SP registration SP json-bytes -reserved = %s"0" / (%s"1" reason) ; absent = not held back -registration = %s"0" / (%s"1" registered-or-not) ; absent = the router cannot say -registered-or-not = %s"REGISTERED" SP expires grace-until - / %s"UNREGISTERED" SP pricing -expires = 8*8 OCTET ; Int64, big-endian, seconds since the Unix epoch -grace-until = 8*8 OCTET ; as expires, and greater than it -pricing = %s"0" / (%s"1" rent-prices min-label-length premium-from - start-premium end-premium) -rent-prices = length *(8*8 OCTET) ; MicroUSD per year, by label length -min-label-length = 2*2 OCTET ; Word16, characters -premium-from = %s"0" / (%s"1" 8*8 OCTET) ; unix seconds the surcharge began -start-premium = 8*8 OCTET ; MicroUSD, Int64 -end-premium = 8*8 OCTET ; MicroUSD, Int64 -reason = %s"UNSPECIFIED" / %s"TRADEMARK" / %s"PUBLIC_INTEREST" - / %s"OFFENSIVE" / %s"INTERNAL" / %s"PREMIUM" / word +rname = %s"RNAME" SP registration +registration = %s"N" optTime optTime reserved SP json-bytes ; registered + / %s"A" optTime pricing ; available + / %s"R" reason ; reserved +optTime = %s"0" / (%s"1" 8*8 OCTET) ; Int64, big-endian, unix seconds +reserved = %s"0" / (%s"1" reason) ; absent = not held back +pricing = tiers basePrice minLabelLength +tiers = length *(2*2 OCTET 8*8 OCTET) ; label length -> US cents per year +basePrice = 8*8 OCTET ; US cents per year for every other length +minLabelLength = 2*2 OCTET ; characters +reason = %s"internal" / %s"trademark" / %s"community" / word word = 1*32(%x21-7E) ; a reason this version has no word for ``` -`json-bytes` is the record as a UTF-8 JSON object, or `null` when the name does -not resolve. It consumes the remainder of the transmission. - -Money is MicroUSD, a millionth of a US dollar: the registry denominates in USD, -never in ETH, and the backing resolver converts before the value reaches the -protocol. Times are seconds since the Unix epoch. Lengths are characters. +On `N` the two `optTime` fields are the expiry and the end of the grace period, +in that order, and `json-bytes` is the record, consuming the remainder of the +transmission. On `A` the `optTime` is when a post-grace surcharge decays to +nothing. The reason words are the same on the wire, in the backing resolver's +JSON and in a client's own API. -A client reads the three facts together: +Money is US cents; the registry denominates in USD, never in ETH, and the +backing resolver converts before the value reaches the protocol. Times are +seconds since the Unix epoch. Lengths are characters. -| The client sees | Meaning | +| Answer | Meaning | |---|---| -| a record | the name resolves; use it | -| `REGISTERED` | held by someone until `expires`, renewable by its owner alone until `grace-until` | -| `UNREGISTERED` | held by nobody; registrable unless it is also reserved | -| `pricing` | what registering it costs, computed locally | -| `reserved` | the registry holds it back, whether or not it is registered | -| no registration | a pre-v22 answer, which was only ever sent for a live registration | - -Availability is the conjunction, not a state of its own: a name is registrable -when it is `UNREGISTERED` and carries no reservation, which is the registry's own -`available()`. An auction is not a state either - it is `UNREGISTERED` with a -premium that has not yet decayed to zero. - -A router MUST NOT send `pricing` for a reserved name. A name the registry holds +| `N` | registered: held by someone until the expiry, renewable by its owner alone until the end of grace. It always carries a record: where the owner set none, every field is unset and the resolver address is zero | +| `A` | available: held by nobody and registrable now, at `pricing` | +| `R` | reserved: held back by the registry and not registered | + +Availability is not a state of its own but the conjunction the registry itself +computes: registrable means `A`, since a name that is held back answers `R` instead. A reservation on a name that *is* registered rides along in +the `reserved` field, and is why that name will not free up when it expires. + +An auction is not a state either. A name past its grace period answers `A` +with the ordinary price, plus the time its surcharge expires. The +surcharge itself is deliberately not carried: it decays continuously, so it +cannot be quoted as a purchase price. A client shows the ordinary price and +counts down to when it applies. + +A router MUST NOT quote a price for a reserved name. A name the registry holds back is not for sale at the registry's price, and quoting one would be an offer -the registry will not honour. +the registry will not honour - which is why `R` has no pricing field at all +rather than an empty one. The record travels while a name is registered, through its grace period, and stops at the moment the name becomes registrable by anyone. Keeping it that far @@ -1570,52 +1579,48 @@ lets whoever opens the name tell its owner that it is about to lapse; keeping it past that would show a record whose owner no longer holds the name. How long a client goes on opening an expiring name is its own decision. -**Computing the price.** All amounts MicroUSD, all times seconds: +**Computing the price.** In US cents, for a duration in seconds: ``` -price len duration t - = rentPrices[min (len - 1) (length rentPrices - 1)] * duration / 31536000 - + max 0 (decayed startPremium (t - premiumFrom) - endPremium) -decayed s elapsed = s * 0.5 ** (elapsed / 86400) +price len duration = tier len * duration / 31536000 +tier len = the entry for len in tiers, or basePrice when len is not in tiers ``` -The surcharge is charged once whatever the duration; only the rent scales with -it. `decayed` halves each day and interpolates within the day, and is the same -function for every deployment, so it is specified here rather than sent. A -client computing it in double precision lands within 0.01 MicroUSD of the -registry across the whole curve. Rounding may leave the surcharge just above -zero at the end of its window, so a client floors it at zero, as the registry -does. The minimum registration is 28 days, a contract constant rather than a -per-deployment value, so it is not sent either. - -`rentPrices` is indexed by label length, its last entry covering every longer -label. `minLabelLength` is sent because a hashed query carries no length: the -router cannot check it, so the client must, and a price quoted for a label the -registry will refuse is worse than no quote at all. - -Below v22, `RNAME` carries the bare record with no other field, and every answer -without a record is `ERR NAME NOT_FOUND`, as it was before this version. A name -in its grace period therefore resolves for those clients too, without the expiry -they have no field to carry. - -From v22 a client MUST NOT read `ERR NAME NOT_FOUND` as "registrable" - only -`UNREGISTERED` with no reservation says that. `NOT_FOUND` means the router has -nothing to say about the name, which includes a backing resolver whose answer it -could not read. - -A router that reads a status it has no answer for MUST say so as `ERR NAME -RESOLVER `. Not a registration, which asserts one it never read, and not -`UNREGISTERED`, which offers a name that may be held. An unreachable chain and an -unconfigured TLD arrive this way, as statuses of their own, and so does a -registration the router could not date. When the response carries no status the -router can read at all, it answers `ERR NAME NOT_FOUND`. +The registry's minimum registration is 28 days, a contract constant rather than +a per-deployment value, so it is specified here rather than sent. `tiers` omits +any length below `minLabelLength`, those being unregistrable. `minLabelLength` +is sent because a hashed query carries no length: the router cannot check it, so +the client must, and a price quoted for a label the registry will refuse is +worse than no quote at all. + +Below v22, `RNAME` carries the bare record and nothing else, and every answer +without one is `ERR NAME NOT_FOUND`, as it was before this version. A name in +its grace period therefore resolves for those clients too, without the expiry +they have no field to carry. In the other direction a v22 client reads such an +answer as `N` with no expiry, grace or reservation - which is the only +reason those three fields are optional. + +From v22 a client MUST NOT read `ERR NAME NOT_FOUND` as "registrable" - only `A` +says that. `NOT_FOUND` means the router has nothing to say about the +name, which includes a backing resolver whose answer it could not read. + +A router that cannot state an answer completely MUST say so as `ERR NAME +RESOLVER ` rather than answer partially. That covers a TLD with no +registrar or no price oracle configured, an unreachable chain, a timeout, a +registration it could not date, a registered name it could not resolve, and any +status word it does not recognise. Neither a registration nor availability may +be guessed: one would assert a registration nobody read, the other would offer a +name that may be held. A client MUST read a `reason` it does not know as unknown and still treat the name as reserved: a later version may reserve names for reasons this one cannot name, and losing the reservation over that would offer a name that cannot be -registered. The word itself travels so that a later client can use it; a router -sends at most one bounded token, since the field ends at a space. +registered. The word itself travels, unchanged, so that a later client can act +on it and a current one can show or log it - which is why the set is open rather +than an enumeration. A router sends at most one bounded token of printable +ASCII, since the field ends at a space. +`json-bytes` MUST be a UTF-8 JSON object with the following schema: `json-bytes` MUST be a UTF-8 JSON object with the following schema: | Field | JSON type | Constraints | @@ -1640,11 +1645,10 @@ an empty string, not JSON `null` and not an absent key. Link fields empty array `[]` when unset. Coin fields (`eth`, `btc`, `xmr`, `dot`) use JSON `null` as the "unset" sentinel and MAY also be absent from the object entirely. -The record carries no expiry field of its own: the registration alongside it -does. The backing resolver stops resolving a name once it is registrable by -anyone, so a record and an `UNREGISTERED` registration do not travel together. -Testnet-vs-mainnet status is derived from the queried TLD rather than an -in-record flag. +The record carries no expiry field of its own: `N` carries it alongside. +The backing resolver stops resolving a name once it is registrable by anyone, so +a record only ever accompanies `N`. Testnet-vs-mainnet status is derived +from the queried TLD rather than an in-record flag. Receivers MUST tolerate extra unknown fields (forward-compatibility for future field additions). Adding a required field is a breaking change requiring an diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 66f32c40ba..23f54f837c 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -107,13 +107,12 @@ # `reservedNames` holds a SimplexController.Reason; 0 means not reserved. A # controller from before the enum stores a bool, whose `true` decodes as 1. +# SimplexController.Reason. 1 is also what the boolean reservedNames of the +# first .testing deployment set, which is why it reads as "internal". RESERVED_REASONS = { - 1: ("unspecified", "reserved for a brand or public interest"), + 1: ("internal", "reserved for SimpleX"), 2: ("trademark", "reserved to protect a trademark"), - 3: ("publicInterest", "reserved in the public interest"), - 4: ("offensive", "reserved as an offensive name"), - 5: ("internal", "reserved for SimpleX"), - 6: ("premium", "reserved as a premium name"), + 3: ("community", "reserved for the community"), } # a Reason added to the contract after this resolver: still reserved, unworded UNKNOWN_REASON = ("unknown", "reserved") @@ -126,8 +125,8 @@ ZERO_ADDR = "0x0000000000000000000000000000000000000000" -# The registry prices in attoUSD (1e-18 USD); the protocol carries MicroUSD. -ATTO_PER_MICRO = 10**12 +# The registry prices in attoUSD (1e-18 USD); the protocol carries US cents. +ATTO_PER_CENT = 10**16 SECONDS_PER_YEAR = 31536000 @@ -262,13 +261,11 @@ def read_pricing_params(tld: str): def read_oracle_prices(controller: str, oracle: str): - # The oracle prices rent in attoUSD per second and the premium in attoUSD. - # Quotes round so they are never below what the registry charges: rents and - # the surcharge up, the floor that is subtracted from the surcharge down. - # An oracle built before the six-letter tier stops at five, and the contract - # itself then charges price5Letter for anything longer - which is what the - # last entry means here too. - rents = [] + # The oracle prices rent in attoUSD per second. Quotes round up, so one is + # never below what the registry charges. An oracle built before the + # six-letter tier stops at five, and the contract then charges price5Letter + # for anything longer - which is what basePrice means here. + tiers = {} for n in range(1, 7): try: rate = decode_uint(eth_call(oracle, selector(f"price{n}Letter()"))) @@ -276,15 +273,27 @@ def read_oracle_prices(controller: str, oracle: str): if n <= 5: raise break - rents.append(ceil_div(rate * SECONDS_PER_YEAR, ATTO_PER_MICRO)) + tiers[n] = ceil_div(rate * SECONDS_PER_YEAR, ATTO_PER_CENT) + base = tiers.pop(max(tiers)) + min_len = decode_uint(eth_call(controller, selector("minCharLength()"))) return { - "rentPrices": rents, - "minLabelLength": decode_uint(eth_call(controller, selector("minCharLength()"))), - "startPremium": ceil_div(decode_uint(eth_call(oracle, selector("startPremium()"))), ATTO_PER_MICRO), - "endPremium": decode_uint(eth_call(oracle, selector("endValue()"))) // ATTO_PER_MICRO, + # lengths the registry refuses are left out rather than priced at zero + "rentPrices": {n: c for n, c in tiers.items() if n >= min_len}, + "basePrice": base, + "minLabelLength": min_len, + # not sent: only used to date the end of the surcharge window + "_auctionDays": auction_days(oracle), } +def auction_days(oracle: str) -> int: + """The surcharge halves daily from startPremium until it falls below + endValue, so the window is log2(startPremium / endValue) days.""" + start = decode_uint(eth_call(oracle, selector("startPremium()"))) + end = decode_uint(eth_call(oracle, selector("endValue()"))) + return (start // end).bit_length() - 1 if end and start > end else 0 + + def ceil_div(a: int, b: int) -> int: return -(-a // b) @@ -300,17 +309,13 @@ def name_status(name: str): "graceEnds": None, "reasonCode": None, "reason": None, - "premiumFrom": None, + "auctionUntil": None, } # nameExpires and reservedNames are keyed on uint256(keccak(label)). # Only the 2LD's label is a registry key, wherever it sits - the same rule # node_of applies to the node. - label = labels[-2] - if is_encoded_labelhash(label): - token = int(label[1:-1], 16) - else: - token = int.from_bytes(keccak(label.encode()), "big") + token = label_token(labels[-2]) expires = decode_uint( eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token)) ) @@ -332,12 +337,17 @@ def name_status(name: str): "graceEnds": (expires + grace) if expires else None, "reasonCode": reason[0] if reason else None, "reason": reason[1] if reason else None, - # past grace the name is registrable again, at a surcharge decaying from - # the moment grace ended; the client computes it from the curve's ends - "premiumFrom": (expires + grace) if status == "expired" and expires else None, + "auctionUntil": None, } if status in ("unregistered", "expired"): - out.update(pricing_params(tld) or {}) + pricing = pricing_params(tld) + if pricing: + out.update({k: v for k, v in pricing.items() if not k.startswith("_")}) + # past grace the name is registrable again, but at a surcharge until + # the oracle's window closes; the surcharge itself never travels + ends = (expires + grace + pricing["_auctionDays"] * 86400) if expires else 0 + if status == "expired" and ends > now: + out["auctionUntil"] = ends return out @@ -364,6 +374,35 @@ def decode_bytes(hex_data: str) -> bytes: return raw[64:64 + length] +def registered_label(registrar: str, token: int) -> str: + """The registrar records the plaintext label at registration, keyed by its + own hash, so a hashed query still answers with the name it asked about. A + name registered without registerWithLabel has none, which is an error we + name rather than paper over.""" + raw = decode_bytes(eth_call(registrar, selector("labelOf(uint256)") + encode_uint(token))) + return raw.decode("utf-8", errors="replace") if raw else "unknown" + + +def canonical_name(name: str) -> str: + """The name the registry holds. A hashed query never told anyone the name, + so the registrar's own record of it is what comes back; a plaintext query + already carries it.""" + labels = name.split(".") + registrar = REGISTRARS.get(labels[-1]) + if not registrar or len(labels) < 2 or not is_encoded_labelhash(labels[-2]): + return name + label = registered_label(registrar, label_token(labels[-2])) + return ".".join(labels[:-2] + [label, labels[-1]]) + + +def label_token(label: str) -> int: + """The registry key for a second-level label, whether it arrived as text or + already hashed.""" + if is_encoded_labelhash(label): + return int(label[1:-1], 16) + return int.from_bytes(keccak(label.encode()), "big") + + def decode_uint(hex_data: str) -> int: raw = hex_data[2:] if hex_data.startswith("0x") else hex_data return int(raw[-64:], 16) if raw else 0 @@ -666,12 +705,24 @@ def resolve(name: str): resolver_raw = eth_call(registry, selector("resolver(bytes32)") + node_hex) resolver_addr = decode_address(resolver_raw) if resolver_addr == ZERO_ADDR: - return 404, { - "name": name, + # A registered name always resolves. With no resolver set the record is + # still returned, every field unset, so that "taken until " stays + # answerable for the name a would-be registrant is asking about. + owner = decode_address(eth_call(registry, selector("owner(bytes32)") + node_hex)) + return 200, { + "name": canonical_name(name), + "nickname": "", + "website": "", + "location": "", + "simplexContact": [], + "simplexChannel": [], + "eth": None, + "btc": None, + "xmr": None, + "dot": None, + "owner": owner, + "resolver": ZERO_ADDR, **reg, - "status": "noResolver", - "error": "noResolver", - "message": "no resolver set for this name", } owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex) @@ -696,7 +747,7 @@ def resolve(name: str): # use the ENSIP-5 dot convention (e.g. "simplex.contact") — only the # resolver's JSON surface camelCases them. return 200, { - "name": name, + "name": canonical_name(name), "nickname": nickname, "website": texts.get("url", ""), "location": texts.get("location", ""), diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 6306f4045b..06295948f6 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -226,7 +226,7 @@ import Simplex.Messaging.Protocol ErrorType (AUTH), MsgBody, MsgFlags (..), - NameResult, + NameRegistration (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer (..), @@ -459,7 +459,7 @@ getConnShortLink c = withAgentEnv c .:. getConnShortLink' c -- | Resolve a SimpleX name (PFWD RSLV). The agent owns server selection: it -- picks a names-capable server (ServerRoles.names) from the user's nameSrvs, so -- chat clients just pass the parsed domain. -resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameResult +resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameRegistration resolveSimplexName c nm userId domain = withAgentEnv c $ resolveSimplexName' c nm userId domain {-# INLINE resolveSimplexName #-} @@ -1268,7 +1268,7 @@ getConnShortLink' c nm userId = \case deleteLocalInvShortLink' :: AgentClient -> ConnShortLink 'CMInvitation -> AM () deleteLocalInvShortLink' c (CSLInvitation _ srv linkId _) = withStore' c $ \db -> deleteInvShortLink db srv linkId -resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResult +resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameRegistration resolveSimplexName' c nm userId domain = do resolverSrv <- getNextNameServer c userId resolveName c nm userId resolverSrv domain diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index c45a34c2d6..1890ac8bcb 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -269,7 +269,7 @@ import Simplex.Messaging.Protocol NetworkError (..), MsgFlags (..), MsgId, - NameResult, + NameRegistration (..), NtfServer, NtfServerWithAuth, ProtoServer, @@ -1993,7 +1993,7 @@ getQueueLink c nm userId server lnkId = -- resolver) and falls back to a direct send when the proxy is unavailable -- (faster but exposes the client IP). Mode selection is delegated to -- `sendOrProxySMPCommand`, which honours the network config (SPMNever etc.). -resolveName :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameResult +resolveName :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameRegistration resolveName c nm userId server domain = snd <$> sendOrProxySMPCommand c nm userId server "" "RSLV" NoEntity resolveViaProxy resolveDirectly where diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 94e91c0fa8..ea0db5257f 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -166,7 +166,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, fullDomainName, hashedDomain) +import Simplex.Messaging.SimplexName (SimplexDomain) import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport @@ -1054,20 +1054,11 @@ proxySMPMessage c nm proxiedRelay spKey sId flags msg = proxyOKSMPCommand c nm p -- through `proxySMPCommand` and pattern-matches the expected RNAME response. -- Version-gated on the destination relay (mirrors `connectSMPProxiedRelay`): -- the client never sends RSLV to a relay that predates names support. --- | How a name goes on the wire. From v22 the second-level label is sent as its --- hash; older routers can only parse the name. -queryDomain :: VersionSMP -> SimplexDomain -> SimplexDomain -queryDomain v d = if v >= nameAvailSMPVersion then hashedDomain d else d - --- | A hashed query's record names the hash, so put back the name that was asked. -askedName :: SimplexDomain -> Maybe NameRecord -> Maybe NameRecord -askedName name = fmap $ \nr -> nr {nrName = fullDomainName name} - -proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameResult) +proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameRegistration) proxyResolveName c nm proxiedRelay name | v >= namesSMPVersion = - proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (queryDomain v name)) >>= \case - Right (RNAME reserved_ reg_ rec_) -> pure $ Right (reserved_, reg_, askedName name rec_) + proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (nameQuery v name)) >>= \case + Right (RNAME reg) -> pure $ Right reg Right r -> throwE $ unexpectedResponse r Left e -> pure $ Left e | otherwise = throwE $ PCETransportError TEVersion @@ -1079,11 +1070,11 @@ proxyResolveName c nm proxiedRelay name -- proxy fallback in the agent. RSLV requires no entity ID or authorization -- (see `noAuthCmd` in Protocol.hs). Version-gated on the session here, not the -- encoder, so an old server never receives RSLV. -directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameResult +directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameRegistration directResolveName c nm name | v >= namesSMPVersion = - sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (queryDomain v name))) >>= \case - RNAME reserved_ reg_ rec_ -> pure (reserved_, reg_, askedName name rec_) + sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (nameQuery v name))) >>= \case + RNAME reg -> pure reg r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion where diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index d23ef15ec5..ae012c2c47 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -82,6 +82,8 @@ module Simplex.Messaging.Protocol ProxyError (..), NameQuery (..), NameQueryLabel (..), + nameQuery, + queryName, NameRegistration (..), NamePricing (..), USDCents (..), @@ -281,7 +283,7 @@ import Simplex.Messaging.Protocol.Types import Simplex.Messaging.Server.QueueStore.QueueInfo import Simplex.Messaging.ServiceScheme import Simplex.Messaging.SystemTime (SystemSeconds) -import Simplex.Messaging.SimplexName (LabelHash, SimplexTLD) +import Simplex.Messaging.SimplexName (LabelHash, SimplexDomain (..), SimplexTLD (..), domainName, labelHash, labelHashText) import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (TransportHost, TransportHosts (..)) import Simplex.Messaging.Util (bshow, eitherToMaybe, safeDecodeUtf8, (<$?>)) @@ -1627,6 +1629,26 @@ instance Encoding NameQueryLabel where 'H' -> NQHash <$> smpP _ -> fail "bad NameQueryLabel" +-- | How the backing resolver is addressed for this query. The only place a +-- hashed label is written as text, and it faces the resolver's HTTP API - the +-- SMP protocol tags the choice instead of spelling it. +queryName :: NameQuery -> Text +queryName NameQuery {queryTLD, queryLabel, querySub} = domainName queryTLD label querySub + where + label = case queryLabel of + NQName t -> t + NQHash h -> labelHashText h + +-- | The name a client asked about, hashed from v22 so the router is never told +-- what it is. A web TLD has no registry, so it is never hashed. +nameQuery :: VersionSMP -> SimplexDomain -> NameQuery +nameQuery v SimplexDomain {nameTLD, domain, subDomain} = + NameQuery {queryTLD = nameTLD, queryLabel = label, querySub = subDomain} + where + label + | v >= nameAvailSMPVersion && nameTLD /= TLDWeb = NQHash (labelHash domain) + | otherwise = NQName domain + instance Encoding NameQuery where smpEncode NameQuery {queryTLD, queryLabel, querySub} = smpEncode (queryTLD, queryLabel, EncList querySub) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 74697a070d..f50f694e2b 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -1494,12 +1494,12 @@ client Just nenv -> pure (Just nenv) -- Runs on a forked thread so RSLV does not block other commands; -- concurrency is limited by serverResolverConcurrency in forkCmd. - resolveNameMsg :: NamesEnv -> SimplexDomain -> M s BrokerMsg + resolveNameMsg :: NamesEnv -> NameQuery -> M s BrokerMsg resolveNameMsg nenv d = do st <- asks (rslvStats . serverStats) (selector, msg) <- liftIO (resolveName nenv d) <&> \case - Right (reserved_, reg_, rec_) -> (rslvSucc, RNAME reserved_ reg_ rec_) + Right reg -> (rslvSucc, RNAME reg) Left e@NOT_FOUND -> (rslvNotFound, ERR $ NAME e) Left e -> (rslvResolverErrs, ERR $ NAME e) incStat (selector st) $> msg diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 9b23f3112c..985cf21a3e 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -3,7 +3,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} -{-# LANGUAGE TupleSections #-} module Simplex.Messaging.Server.Names ( NamesConfig (..), @@ -18,10 +17,11 @@ where import qualified Control.Exception as E import Control.Logger.Simple (logError) -import Data.Maybe (fromMaybe, isJust) +import Data.Maybe (fromMaybe) import Data.Text (Text) +import qualified Data.Map.Strict as M import qualified Data.Text as T -import Simplex.Messaging.Protocol (NameErrorType (..), MicroUSD (..), NamePricing (..), NameRecord, NameRegistration (..), NameResult, NameReservedReason, reservedReason) +import Simplex.Messaging.Protocol (NameErrorType (..), NamePricing (..), NameRecord, NameQuery, NameRegistration (..), NameReservedReason, USDCents (..), oldRegistration, parseReservedReason, queryName) import Simplex.Messaging.Server.Names.HttpResolver ( NameStatusResp (..), ResolverEnv, @@ -32,7 +32,7 @@ import Simplex.Messaging.Server.Names.HttpResolver newResolverEnv, resolveHttp, ) -import Simplex.Messaging.SimplexName (SimplexDomain, fullDomainName) +import Simplex.Messaging.SystemTime (RoundedSystemTime (..)) import System.Timeout (timeout) data NamesConfig = NamesConfig @@ -60,9 +60,9 @@ pingEndpoint :: NamesEnv -> IO (Either ResolverError ()) pingEndpoint NamesEnv {resolverEnv, config} = fromMaybe (Left ResolverTimeout) <$> timeout (resolverTimeoutMs config * 1000) (healthHttp resolverEnv) -resolveName :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResult) -resolveName env d = do - r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetch env d)) +resolveName :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameRegistration) +resolveName env q = do + r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetch env q)) case r of Right result -> pure (fromMaybe (Left (RESOLVER "timeout")) result) Left e @@ -71,61 +71,59 @@ resolveName env d = do logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) pure (Left (RESOLVER "resolver error")) --- | A code this router has no word for still reserves the name, and travels on --- as itself. Bounded to one wire token: it is the resolver's text, and the slot --- it goes into ends at a space. -resolverReason :: Text -> NameReservedReason -resolverReason = reservedReason . T.take 32 . T.takeWhile (/= ' ') - -fetch :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResult) -fetch NamesEnv {resolverEnv} d = - either (Left . mapResolverError) nameResult <$> resolveHttp resolverEnv (fullDomainName d) +fetch :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameRegistration) +fetch NamesEnv {resolverEnv} q = + either (Left . mapResolverError) nameRegistration <$> resolveHttp resolverEnv (queryName q) --- | A record answers what the name points to; the status answers whether it can --- be taken; a reservation is orthogonal to both. A resolver that reports no --- status at all is an older one, and only ever returned a record for a live --- registration - the client reads the absent status that way. -nameResult :: (Maybe NameRecord, Maybe NameStatusResp) -> Either NameErrorType NameResult -nameResult = \case - (rec_, Just ns) -> (\(reserved_, reg) -> (reserved_, Just reg, rec_)) <$> mapStatus ns - (Just rec, Nothing) -> Right (Nothing, Nothing, Just rec) +-- | A resolver that reports no status at all is an older one, and only ever +-- returned a record for a live registration - which is what oldRegistration says. +nameRegistration :: (Maybe NameRecord, Maybe NameStatusResp) -> Either NameErrorType NameRegistration +nameRegistration = \case + (rec_, Just ns) -> mapStatus rec_ ns + (Just rec, Nothing) -> Right (oldRegistration rec) (Nothing, Nothing) -> Left NOT_FOUND -- | The resolver's vocabulary. A status this router has no word for is not an --- answer: "registered" would assert a registration nobody read, and --- "unregistered" would offer a name that may be held. -mapStatus :: NameStatusResp -> Either NameErrorType (Maybe NameReservedReason, NameRegistration) -mapStatus ns@NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsReasonCode} = - (reserved_,) <$> case nsStatus of +-- answer: a registration would assert one nobody read, and availability would +-- offer a name that may be held. +mapStatus :: Maybe NameRecord -> NameStatusResp -> Either NameErrorType NameRegistration +mapStatus rec_ ns@NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsReasonCode, nsAuctionUntil} = + case nsStatus of "registered" -> registered - -- registered, but its records point nowhere - "noResolver" -> registered "grace" -> registered - "unregistered" -> Right unregistered - "expired" -> Right unregistered - "auction" -> Right unregistered + "unregistered" -> available + "expired" -> available s -> Left (RESOLVER (T.take 32 s)) where - reserved_ = resolverReason <$> nsReasonCode - -- a registration the router could not date is not one it can report - registered = maybe (Left $ RESOLVER "no expiry") Right $ do - expires <- nsExpires - graceUntil <- nsGraceEnds - pure NRRegistered {expires, graceUntil} - -- a held-back name is not for sale at the registry's price, so it is quoted - -- no price at all: what it costs is a conversation with SimpleX - unregistered = NRUnregistered {pricing = if isJust reserved_ then Nothing else namePricing ns} + reservedReason_ = resolverReason <$> nsReasonCode + -- A registered name resolves: where the owner set no records the resolver + -- still returns one, every field unset. And a registration this router + -- could not date is not one it can report. + registered = case (rec_, nsExpires, nsGraceEnds) of + (Just nameRecord, Just expires, Just graceUntil) -> + Right NRRegistered {expires = Just (RoundedSystemTime expires), graceUntil = Just (RoundedSystemTime graceUntil), reservedReason_, nameRecord} + (Nothing, _, _) -> Left (RESOLVER "no record") + _ -> Left (RESOLVER "no expiry") + -- A held-back name is quoted no price: what it costs, and whether it can be + -- had at all, is a conversation with SimpleX. + available = case reservedReason_ of + Just r -> Right (NRReserved r) + Nothing -> case namePricing ns of + Just pricing -> Right NRAvailable {pricing, auctionUntil = RoundedSystemTime <$> nsAuctionUntil} + Nothing -> Left (RESOLVER "no price oracle") + +-- | A code this router has no word for still reserves the name, and travels on +-- as itself. Bounded to one wire token: it is the resolver's text, and the slot +-- it goes into ends at a space. +resolverReason :: Text -> NameReservedReason +resolverReason = parseReservedReason . T.take 32 . T.takeWhile (\c -> c > ' ' && c < '\DEL') --- | Absent when the TLD has no controller or price oracle configured. The --- surcharge start is absent for a name that never lapsed. namePricing :: NameStatusResp -> Maybe NamePricing -namePricing NameStatusResp {nsRentPrices, nsMinLabelLength, nsPremiumFrom, nsStartPremium, nsEndPremium} = do - rentPrices <- map MicroUSD <$> nsRentPrices +namePricing NameStatusResp {nsRentPrices, nsBasePrice, nsMinLabelLength} = do + rentPrices <- M.map USDCents <$> nsRentPrices + basePrice <- USDCents <$> nsBasePrice minLabelLength <- nsMinLabelLength - startPremium <- MicroUSD <$> nsStartPremium - endPremium <- MicroUSD <$> nsEndPremium - pure NamePricing {rentPrices, minLabelLength, premiumFrom = nsPremiumFrom, startPremium, endPremium} - + pure NamePricing {rentPrices, basePrice, minLabelLength} mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index 0e063c0809..e0106a3ad7 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -45,6 +45,7 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as BL import Data.Int (Int64) +import Data.Map.Strict (Map) import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import Network.HTTP.Client @@ -90,15 +91,16 @@ data NameStatusResp = NameStatusResp nsGraceEnds :: Maybe Int64, -- | reported alongside the status: a reservation is orthogonal to it nsReasonCode :: Maybe Text, - -- | when the post-grace surcharge began - nsPremiumFrom :: Maybe Int64, - -- | the TLD's price oracle, in MicroUSD - per year for the rents. The - -- resolver converts from the registry's attoUSD, so nothing 256-bit gets - -- this far and every value fits a JSON number exactly. - nsRentPrices :: Maybe [Int64], - nsMinLabelLength :: Maybe Int, - nsStartPremium :: Maybe Int64, - nsEndPremium :: Maybe Int64 + -- | when the post-grace surcharge decays to nothing, so the client can + -- count down to the ordinary price. The surcharge itself never travels. + nsAuctionUntil :: Maybe Int64, + -- | the TLD's price oracle, in US cents per year: the lengths it prices + -- specially, and the price for every other length. The resolver converts + -- from the registry's attoUSD, so nothing 256-bit gets this far and every + -- value fits a JSON number exactly. + nsRentPrices :: Maybe (Map Int Int64), + nsBasePrice :: Maybe Int64, + nsMinLabelLength :: Maybe Int } deriving (Show) @@ -174,11 +176,10 @@ resolveHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseByt nsExpires = jsonField o "expires", nsGraceEnds = jsonField o "graceEnds", nsReasonCode = jsonField o "reasonCode", - nsPremiumFrom = jsonField o "premiumFrom", + nsAuctionUntil = jsonField o "auctionUntil", nsRentPrices = jsonField o "rentPrices", - nsMinLabelLength = jsonField o "minLabelLength", - nsStartPremium = jsonField o "startPremium", - nsEndPremium = jsonField o "endPremium" + nsBasePrice = jsonField o "basePrice", + nsMinLabelLength = jsonField o "minLabelLength" } -- | A field the resolver omits or nulls for statuses that do not carry it. diff --git a/src/Simplex/Messaging/SimplexName.hs b/src/Simplex/Messaging/SimplexName.hs index 0a4ce79abf..7c9e380dfa 100644 --- a/src/Simplex/Messaging/SimplexName.hs +++ b/src/Simplex/Messaging/SimplexName.hs @@ -10,6 +10,7 @@ module Simplex.Messaging.SimplexName SimplexTLD (..), SimplexNameType (..), fullDomainName, + domainName, LabelHash (..), labelHash, labelHashText, @@ -150,9 +151,13 @@ instance Encoding SimplexTLD where _ -> fail "bad SimplexTLD" fullDomainName :: SimplexDomain -> Text -fullDomainName SimplexDomain {nameTLD, domain, subDomain} = T.intercalate "." (reverse subDomain ++ [domain] ++ tld') +fullDomainName SimplexDomain {nameTLD, domain, subDomain} = domainName nameTLD domain subDomain + +-- | A dotted name from its parts, whatever the second-level label is written as. +domainName :: SimplexTLD -> Text -> [Text] -> Text +domainName tld label sub = T.intercalate "." (reverse sub ++ [label] ++ tld') where - tld' = case nameTLD of + tld' = case tld of TLDSimplex -> ["simplex"] TLDTesting -> ["testing"] TLDWeb -> [] diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index 5acb59b28f..9a769ddf62 100644 --- a/tests/AgentTests/ResolveNameTests.hs +++ b/tests/AgentTests/ResolveNameTests.hs @@ -87,15 +87,15 @@ resolveNameTests = do describe "success path" $ it "returns NameRecord" testDirectSuccess describe "name availability" $ - it "an unregistered name answers as unregistered" testAvailSuccess + it "an unregistered name answers as available" testAvailSuccess testAvailSuccess :: HasCallStack => IO () testAvailSuccess = withDirectResolver (status404, "{\"error\":\"unregistered\"}") $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of - Right (Nothing, Just (SMP.NRUnregistered _), Nothing) -> pure () - _ -> expectationFailure $ "expected Right (_, NRUnregistered, _), got: " <> show r + Right (SMP.NRAvailable {}) -> pure () + _ -> expectationFailure $ "expected Right NRAvailable, got: " <> show r testDirectNotFound :: HasCallStack => IO () testDirectNotFound = @@ -158,5 +158,5 @@ testDirectSuccess = withDirectResolver (status200, J.encode testNameRecord) $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of - Right (_, _, Just nr) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (_, _, Just record), got: " <> show r + Right (SMP.NRRegistered {nameRecord}) -> nameRecord `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right NRRegistered, got: " <> show r diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index c7cd54b213..57467aeaf9 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -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 qualified Data.Map.Strict as M import Data.IORef (IORef, readIORef) import Data.List.NonEmpty (NonEmpty (..)) import Data.Text (Text) @@ -34,9 +35,10 @@ import Simplex.Messaging.Protocol Command (..), CorrId (..), ErrorType (..), - MicroUSD (..), NamePricing (..), NameRegistration (..), + NameReservedReason (..), + USDCents (..), NameErrorType (..), NameReservedReason (..), SParty (..), @@ -49,6 +51,7 @@ import Simplex.Messaging.Protocol ) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.SimplexName (SimplexDomain) +import Simplex.Messaging.SystemTime (RoundedSystemTime (..)) import Simplex.Messaging.Transport import Simplex.Messaging.Version (mkVersionRange) import Test.Hspec hiding (fit, it) @@ -75,7 +78,7 @@ withProxyAndResolver (st, body) runTest = sendRslv :: Transport c => THandleSMP c 'TClient -> B.ByteString -> SimplexDomain -> IO (Transmission (Either ErrorType BrokerMsg)) sendRslv h@THandle {params} corrId d = do - let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (RSLV d)) + let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (RSLV (SMP.nameQuery currentClientSMPRelayVersion d))) [Right ()] <- tPut h (Right (Nothing, tToSend) :| []) r :| _ <- tGetClient h pure r @@ -148,7 +151,7 @@ testRslvVersion = Left (PCETransportError TEVersion) -> pure () _ -> expectationFailure $ "expected Left (PCETransportError TEVersion), got: " <> show r -forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameResult)) +forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameRegistration)) forwardedResolveAlice = do g <- C.newRandom ts <- getCurrentTime @@ -171,8 +174,8 @@ testRslvForwardedSuccess :: IO () testRslvForwardedSuccess = withProxyAndResolver (status200, J.encode testNameRecord) $ forwardedResolveAlice >>= \r -> case r of - Right (Right (_, _, Just nr)) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (Right (_, _, Just record)), got: " <> show r + Right (Right NRRegistered {nameRecord}) -> nameRecord `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (Right NRRegistered), got: " <> show r testRslvSuccess :: IO () testRslvSuccess = @@ -181,30 +184,30 @@ testRslvSuccess = (corrId, _entId, resp) <- sendRslv h "rs07" (domain "alice.simplex") corrId `shouldBe` CorrId "rs07" case resp of - Right (RNAME Nothing Nothing (Just nr)) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (RNAME _ _ (Just record)), got: " <> show resp + Right (RNAME NRRegistered {nameRecord}) -> nameRecord `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (RNAME NRRegistered), got: " <> show resp testRslvAvailable :: IO () testRslvAvailable = - withResolverServer (status404, "{\"error\":\"unregistered\"}") $ + withResolverServer (status404, availableBody) $ testSMPClient @TLS $ \h -> do (corrId, _entId, resp) <- sendRslv h "na01" (domain "ghost.simplex") corrId `shouldBe` CorrId "na01" - resp `shouldBe` Right (RNAME Nothing (Just (NRUnregistered Nothing)) Nothing) + resp `shouldBe` Right (RNAME (NRAvailable auctionPricing Nothing)) testRslvAuction :: IO () testRslvAuction = withResolverServer (status410, auctionBody) $ testSMPClient @TLS $ \h -> do (_, _, resp) <- sendRslv h "na02" (domain "lapsed.simplex") - resp `shouldBe` Right (RNAME Nothing (Just (NRUnregistered (Just auctionPricing))) Nothing) + resp `shouldBe` Right (RNAME (NRAvailable auctionPricing (Just (RoundedSystemTime 1790294400)))) testRslvReserved :: IO () testRslvReserved = withResolverServer (status404, "{\"error\":\"unregistered\",\"reasonCode\":\"trademark\"}") $ testSMPClient @TLS $ \h -> do (_, _, resp) <- sendRslv h "na03" (domain "acme.simplex") - resp `shouldBe` Right (RNAME (Just RRTrademark) (Just (NRUnregistered Nothing)) Nothing) + resp `shouldBe` Right (RNAME (NRReserved NRRTrademark)) -- | A client that predates v22 must see exactly what it saw before: the record -- for a name that resolves, and NOT_FOUND for one that does not. @@ -224,11 +227,11 @@ testRslvOldClientRecord = withResolverServer (status200, J.encode testNameRecord) $ do pc <- oldClient r <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) - r `shouldBe` (Nothing, Nothing, Just testNameRecord) + r `shouldBe` NRRegistered Nothing Nothing Nothing testNameRecord testRslvOldClientNotFound :: IO () testRslvOldClientNotFound = - withResolverServer (status404, "{\"error\":\"unregistered\"}") $ do + withResolverServer (status404, availableBody) $ do pc <- oldClient r <- runExceptT (directResolveName pc NRMInteractive (domain "alice.simplex")) case r of @@ -239,23 +242,26 @@ testRslvForwardedAuction :: IO () testRslvForwardedAuction = withProxyAndResolver (status410, auctionBody) $ forwardedResolveAlice >>= \r -> case r of - Right (Right (Nothing, Just (NRUnregistered (Just p)), Nothing)) -> premiumFrom p `shouldBe` Just 1788480000 - _ -> expectationFailure $ "expected Right (Right unregistered-with-premium), got: " <> show r + Right (Right (NRAvailable _ auctionUntil)) -> auctionUntil `shouldBe` Just (RoundedSystemTime 1790294400) + _ -> expectationFailure $ "expected Right (Right NRAvailable), got: " <> show r + +pricingJson :: LB.ByteString +pricingJson = "\"rentPrices\":{\"3\":12793,\"4\":3198},\"basePrice\":100,\"minLabelLength\":3" --- a name three days past its grace period, priced by the .testing auction curve +availableBody :: LB.ByteString +availableBody = "{\"error\":\"unregistered\"," <> pricingJson <> "}" + +-- a name past its grace period, still inside the window where it costs a +-- surcharge above the ordinary price auctionBody :: LB.ByteString -auctionBody = - "{\"error\":\"auction\",\"premiumFrom\":1788480000,\"rentPrices\":[0,0,127930000,31980000,999300],\ - \\"minLabelLength\":3,\"startPremium\":100000000000000,\"endPremium\":47683716}" +auctionBody = "{\"error\":\"expired\",\"auctionUntil\":1790294400," <> pricingJson <> "}" auctionPricing :: NamePricing auctionPricing = NamePricing - { rentPrices = map MicroUSD [0, 0, 127930000, 31980000, 999300], - minLabelLength = 3, - premiumFrom = Just 1788480000, - startPremium = MicroUSD 100000000000000, - endPremium = MicroUSD 47683716 + { rentPrices = M.fromList [(3, USDCents 12793), (4, USDCents 3198)], + basePrice = USDCents 100, + minLabelLength = 3 } -- keccak-256("alice"), the registry key @@ -278,21 +284,19 @@ currentClient = do testRslvSendsTheHash :: IO () testRslvSendsTheHash = - withResolverServerReqs (status200, J.encode echoed) $ \reqs -> do + withResolverServerReqs (status200, J.encode testNameRecord) $ \reqs -> do pc <- currentClient r <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) resolvePaths reqs `shouldReturn` [["resolve", aliceHash <> ".simplex"]] - -- the record names what the caller asked for + -- the client never sent the name, and the record still names it: the + -- registrar records the label at registration, keyed by its own hash case r of - (_, _, Just nr) -> SMP.nrName nr `shouldBe` "alice.simplex" - _ -> expectationFailure $ "expected a record, got: " <> show r - where - -- the resolver echoes what it was asked about, which is the hash - echoed = testNameRecord {SMP.nrName = aliceHash <> ".simplex"} + NRRegistered {nameRecord} -> SMP.nrName nameRecord `shouldBe` "alice.simplex" + _ -> expectationFailure $ "expected NRRegistered, got: " <> show r testSubnameKeepsItsLabels :: IO () testSubnameKeepsItsLabels = - withResolverServerReqs (status404, "{\"error\":\"unregistered\"}") $ \reqs -> do + withResolverServerReqs (status404, availableBody) $ \reqs -> do pc <- currentClient _ <- runExceptT' (directResolveName pc NRMInteractive (domain "x.alice.simplex")) resolvePaths reqs `shouldReturn` [["resolve", "x." <> aliceHash <> ".simplex"]] diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index e2e5dc2b12..975938e550 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -11,13 +11,14 @@ import qualified Data.ByteString.Lazy as LB import Data.Either (isLeft, isRight) import Data.IORef (readIORef) import Data.List (sort) +import qualified Data.Map.Strict as M import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Network.HTTP.Types (status200, status400, status404, status410, status500, status502) import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed) import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Encoding.String (strDecode, strEncode) -import Simplex.Messaging.Protocol (ErrorType (..), MicroUSD (..), NameErrorType (..), NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason (..)) +import Simplex.Messaging.Protocol (ErrorType (..), NameErrorType (..), NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason (..), USDCents (..), nameQuery, queryName) import Simplex.Messaging.Server.Main (validateUrl) import Simplex.Messaging.Server.Names ( NamesConfig (..), @@ -27,7 +28,9 @@ import Simplex.Messaging.Server.Names resolveName, ) import Simplex.Messaging.Server.Names.HttpResolver (ResolverError (..)) -import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexTLD (..), fullDomainName, hashedDomain) +import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexTLD (..), fullDomainName) +import Simplex.Messaging.SystemTime (RoundedSystemTime (..)) +import Simplex.Messaging.Transport (currentClientSMPRelayVersion, namesSMPVersion) import Test.Hspec testNameRecord :: NameRecord @@ -104,63 +107,51 @@ errorWireSpec = availabilitySpec :: Spec availabilitySpec = do - -- one lookup answers all three questions: what the name points to, whether it - -- can be taken, and whether the registry holds it back - it "a resolvable name answers with the record and its registration" $ + -- one lookup answers what the name points to, whether it can be taken, and + -- whether the registry holds it back + it "a registered name answers with its record and dates" $ answers status200 (recordWith "\"status\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483") $ - (Nothing, Just NRRegistered {expires = 1813853483, graceUntil = 1821629483}, Just testNameRecord) - -- an older resolver reports no status; the record is still the answer - it "a resolver that sends no status still answers with the record" $ - answers status200 (J.encode testNameRecord) (Nothing, Nothing, Just testNameRecord) - -- registered, but its records point nowhere - it "registered without a resolver is a registration with no record" $ - answers status404 "{\"error\":\"noResolver\",\"expires\":1813853483,\"graceEnds\":1821629483}" $ - (Nothing, Just NRRegistered {expires = 1813853483, graceUntil = 1821629483}, Nothing) + NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Nothing, nameRecord = testNameRecord} -- the record travels through grace: the UI decides how long to keep opening it it "a name in grace keeps its record" $ answers status200 (recordWith "\"status\":\"grace\",\"expires\":1785000000,\"graceEnds\":1792776000") $ - (Nothing, Just NRRegistered {expires = 1785000000, graceUntil = 1792776000}, Just testNameRecord) - -- a registration the router could not date is not one it can report - it "registered without expiry is a resolver error" $ - refuses status200 (recordWith "\"status\":\"registered\"") (RESOLVER "no expiry") - it "unregistered carries the price" $ + NRRegistered {expires = Just (RoundedSystemTime 1785000000), graceUntil = Just (RoundedSystemTime 1792776000), reservedReason_ = Nothing, nameRecord = testNameRecord} + -- reservation is orthogonal: it is why the name will not free up at expiry + it "a registered name can be held back too" $ + answers status200 (recordWith "\"status\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483,\"reasonCode\":\"internal\"") $ + NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Just NRRInternal, nameRecord = testNameRecord} + -- an older resolver reports no status; the record is still the answer + it "a resolver that sends no status still answers with the record" $ + answers status200 (J.encode testNameRecord) $ + NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord = testNameRecord} + it "an unregistered name answers with the price" $ answers status404 (jsonBody ("{\"error\":\"unregistered\"," <> pricingJson <> "}")) $ - (Nothing, Just (NRUnregistered (Just testPricing)), Nothing) - it "past grace carries the premium start" $ - answers status410 (jsonBody ("{\"error\":\"auction\",\"premiumFrom\":1788480000," <> pricingJson <> "}")) $ - (Nothing, Just (NRUnregistered (Just testPricing {premiumFrom = Just 1788480000})), Nothing) - it "expired is unregistered" $ - answers status410 (jsonBody ("{\"error\":\"expired\"," <> pricingJson <> "}")) $ - (Nothing, Just (NRUnregistered (Just testPricing)), Nothing) - -- a TLD with no controller or price oracle: registrable, price unknown - it "no pricing from the resolver is no pricing on the wire" $ - answers status404 "{\"error\":\"unregistered\"}" (Nothing, Just (NRUnregistered Nothing), Nothing) + NRAvailable {pricing = testPricing, auctionUntil = Nothing} + it "expired is available, counting down to the ordinary price" $ + answers status410 (jsonBody ("{\"error\":\"expired\",\"auctionUntil\":1790294400," <> pricingJson <> "}")) $ + NRAvailable {pricing = testPricing, auctionUntil = Just (RoundedSystemTime 1790294400)} -- a held-back name is not for sale at the registry's price it "reserved carries the reason and no price" $ answers status404 (jsonBody ("{\"error\":\"unregistered\",\"reasonCode\":\"trademark\"," <> pricingJson <> "}")) $ - (Just RRTrademark, Just (NRUnregistered Nothing), Nothing) - -- reservation is orthogonal: it is why the name will not free up at expiry - it "reserved and registered keeps both" $ - answers status200 (recordWith "\"status\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483,\"reasonCode\":\"internal\"") $ - (Just RRInternal, Just NRRegistered {expires = 1813853483, graceUntil = 1821629483}, Just testNameRecord) - -- an older resolver sends no reasonCode; that is not the chain saying "none" - it "no reasonCode is not a reservation" $ - answers status404 "{\"error\":\"unregistered\"}" (Nothing, Just (NRUnregistered Nothing), Nothing) + NRReserved NRRTrademark -- a later version may reserve names for reasons this one cannot name; the -- reservation must survive that, or a client would offer a name it cannot get it "a reason from a later version still reserves the name" $ - answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"seasonal\"}" $ - (Just (RRUnknown "seasonal"), Just (NRUnregistered Nothing), Nothing) + answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"seasonal\"}" (NRReserved (NRRUnknown "seasonal")) -- the reason re-encodes into a slot that ends at a space, so the router keeps -- it to one bounded token rather than trusting the resolver's text it "a reason with a space is cut at the space" $ - answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"two words\"}" $ - (Just (RRUnknown "two"), Just (NRUnregistered Nothing), Nothing) + answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"two words\"}" (NRReserved (NRRUnknown "two")) it "an over-long reason is truncated" $ answers status404 (jsonBody ("{\"error\":\"unregistered\",\"reasonCode\":\"" <> replicate 100 'z' <> "\"}")) $ - (Just (RRUnknown (T.replicate 32 "z")), Just (NRUnregistered Nothing), Nothing) - -- a resolver that could not answer must not look like an answer: a registration - -- would assert one nobody read, and unregistered would offer a name that is held + NRReserved (NRRUnknown (T.replicate 32 "z")) + -- a name that cannot be dated or priced is not one this router reports on + it "a registration without expiry is a resolver error" $ + refuses status200 (recordWith "\"status\":\"registered\"") (RESOLVER "no expiry") + it "a registered name without a record is a resolver error" $ + refuses status404 "{\"error\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483}" (RESOLVER "no record") + it "no price oracle is a resolver error" $ + refuses status404 "{\"error\":\"unregistered\"}" (RESOLVER "no price oracle") it "upstream failure is a resolver error" $ refuses status502 "{\"error\":\"upstreamError\"}" (RESOLVER "upstreamError") it "unconfigured TLD is a resolver error" $ @@ -180,26 +171,19 @@ availabilitySpec = do it "every registration survives the wire" $ mapM_ (\a -> smpDecode (smpEncode a) `shouldBe` Right a) - [ NRRegistered {expires = 1813853483, graceUntil = 1821629483}, - NRUnregistered Nothing, - NRUnregistered (Just testPricing), - NRUnregistered (Just testPricing {premiumFrom = Just 1788480000}) - ] - it "every reason survives the wire" $ - mapM_ - (\a -> smpDecode (smpEncode a) `shouldBe` Right a) - [ RRUnspecified, - RRTrademark, - RRPublicInterest, - RROffensive, - RRInternal, - RRPremium, - RRUnknown "seasonal" + [ NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Nothing, nameRecord = testNameRecord}, + NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Just NRRInternal, nameRecord = testNameRecord}, + NRAvailable {pricing = testPricing, auctionUntil = Nothing}, + NRAvailable {pricing = testPricing, auctionUntil = Just (RoundedSystemTime 1790294400)}, + NRReserved NRRInternal, + NRReserved NRRTrademark, + NRReserved NRRCommunity, + NRReserved (NRRUnknown "seasonal") ] - -- the JSON API keeps a closed set so clients can localise it - it "an unknown reason is \"unknown\" in JSON" $ do - J.encode (RRUnknown "seasonal") `shouldBe` "\"unknown\"" - J.encode RRTrademark `shouldBe` "\"trademark\"" + -- one vocabulary: the same word on the wire, from the resolver, and in JSON + it "a reason reads the same in JSON as on the wire" $ do + J.encode (NRRUnknown "seasonal") `shouldBe` "\"seasonal\"" + J.encode NRRTrademark `shouldBe` "\"trademark\"" where jsonBody = LB.fromStrict . B.pack -- the resolver returns the record and the registration status in one body @@ -210,57 +194,43 @@ availabilitySpec = do withResolverServer (resolveResp st body) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) resolveName env navlDomain `shouldReturn` expected - navlDomain = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} + navlDomain = nameQuery namesSMPVersion SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} --- | The .testing oracle: MicroUSD per year by label length, and a premium that --- halves daily from $100,000,000 down to a $47.68 floor. +-- | The .testing oracle: US cents per year by label length. testPricing :: NamePricing testPricing = NamePricing - { rentPrices = map MicroUSD [0, 0, 127930000, 31980000, 999300], - minLabelLength = 3, - premiumFrom = Nothing, - startPremium = MicroUSD 100000000000000, - endPremium = MicroUSD 47683716 + { rentPrices = M.fromList [(3, USDCents 12793), (4, USDCents 3198)], + basePrice = USDCents 100, + minLabelLength = 3 } pricingJson :: String -pricingJson = - "\"rentPrices\":[0,0,127930000,31980000,999300],\"minLabelLength\":3,\ - \\"startPremium\":100000000000000,\"endPremium\":47683716" +pricingJson = "\"rentPrices\":{\"3\":12793,\"4\":3198},\"basePrice\":100,\"minLabelLength\":3" parseNameSpec :: Spec parseNameSpec = do - -- asking by hash tells the client if a name is taken without naming it - it "accepts a labelhash label" $ - parseN ("[" <> T.replicate 64 "b" <> "].simplex") `shouldSatisfy` isRight - it "refuses a hash of the wrong width" $ - parseN ("[" <> T.replicate 63 "b" <> "].simplex") `shouldSatisfy` isLeft - -- only the bracketed form is a key; a bare hex string would be hashed again - it "refuses a bare hex string" $ - parseN ("0x" <> T.replicate 64 "b" <> ".simplex") `shouldSatisfy` isLeft - it "keeps the brackets" $ - (strEncode <$> parseN ("[" <> T.replicate 64 "b" <> "].simplex")) - `shouldBe` Right (encodeUtf8 ("[" <> T.replicate 64 "b" <> "].simplex")) - -- only the 2LD is a registry key; subname labels are needed as text - it "accepts a hashed 2LD 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" $ - parseN ("[" <> T.replicate 64 "b" <> "].com") `shouldSatisfy` isLeft + -- a name is a name: the hashed form is a query, and has its own type + it "a name is never a hash" $ + parseN ("[" <> T.replicate 64 "b" <> "].simplex") `shouldSatisfy` isLeft -- keccak-256("alice"), the same constant the resolver's own tests use it "hashes the 2LD to the registry key" $ - (fullDomainName . hashedDomain <$> parseN "alice.simplex") + (queryName . nameQuery currentClientSMPRelayVersion <$> parseN "alice.simplex") `shouldBe` Right "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" it "leaves subname labels as text" $ - (fullDomainName . hashedDomain <$> parseN "x.alice.simplex") + (queryName . nameQuery currentClientSMPRelayVersion <$> parseN "x.alice.simplex") `shouldBe` Right "x.[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" - it "leaves a web name alone" $ - (fullDomainName . hashedDomain <$> parseN "example.com") `shouldBe` Right "example.com" - it "does not hash a hash" $ - (fullDomainName . hashedDomain . hashedDomain <$> parseN "alice.simplex") - `shouldBe` Right "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" + it "leaves a web name alone: no registry, nothing to key on" $ + (queryName . nameQuery currentClientSMPRelayVersion <$> parseN "example.com") `shouldBe` Right "example.com" + -- below v22 a router can only read the name + it "sends the name itself below v22" $ + (queryName . nameQuery namesSMPVersion <$> parseN "alice.simplex") `shouldBe` Right "alice.simplex" + it "a query survives the wire" $ + mapM_ + (\q -> smpDecode (smpEncode q) `shouldBe` Right q) + [ nameQuery currentClientSMPRelayVersion d, + nameQuery namesSMPVersion d + ] it "accepts a valid simplex-TLD name" $ case parseN "privacy.simplex" of Right d -> do @@ -296,13 +266,14 @@ parseNameSpec = do where parseN :: T.Text -> Either String SimplexDomain parseN = strDecode . encodeUtf8 + d = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = ["x"]} resolverSpec :: Spec resolverSpec = do it "returns NameRecord on 200 OK" $ withResolverServer (resolveResp status200 (J.encode testNameRecord)) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) - resolveName env aliceDomain `shouldReturn` Right (Nothing, Nothing, Just testNameRecord) + resolveName env aliceDomain `shouldReturn` Right (NRRegistered Nothing Nothing Nothing testNameRecord) it "returns NOT_FOUND on 404" $ withResolverServer (resolveResp status404 "{}") $ \port _ -> do @@ -358,7 +329,7 @@ resolverSpec = do readIORef reqs `shouldReturn` [["resolve", "alice.simplex"]] where - aliceDomain = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} + aliceDomain = nameQuery namesSMPVersion SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} healthSpec :: Spec healthSpec = do From 8b706331e220f949972dab6aa492cbd0348ae629 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 8 Sep 2026 17:04:20 +0200 Subject: [PATCH 23/27] fix adversarial review findings --- protocol/simplex-messaging.md | 28 ++-- scripts/resolver/service/test_snrc_resolve.py | 146 ++++++++++-------- src/Simplex/Messaging/Client.hs | 18 ++- src/Simplex/Messaging/Protocol.hs | 29 ++-- src/Simplex/Messaging/SimplexName.hs | 14 +- tests/AgentTests/ResolveNameTests.hs | 7 +- tests/RSLVTests.hs | 12 ++ tests/SMPNamesTests.hs | 17 +- 8 files changed, 163 insertions(+), 108 deletions(-) diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index 37a47de28c..9835b5d776 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1458,16 +1458,17 @@ while still returning a `NameRecord` matching the encoding below. #### Resolve name command -The `RSLV` command carries the canonical fully-qualified name directly as the -payload (not JSON): +From v22 the `RSLV` command carries a query; below v22 it carries the name +directly, as it always did (not JSON): ```abnf -rslv = %s"RSLV" SP query -query = tld label sub -tld = %s"s" / %s"t" / %s"w" ; .simplex / .testing / a web name -label = %s"N" shortString ; the second-level label as text - / %s"H" 32*32 OCTET ; its keccak-256 -sub = length *shortString ; subname labels, parent to child +rslv = %s"RSLV" SP (query / domain) ; query from v22, domain below it +query = tld label sub +tld = %s"s" / %s"t" / %s"w" ; .simplex / .testing / a web name +label = %s"N" shortString ; the second-level label as text + / %s"H" 32*32 OCTET ; its keccak-256 +sub = length *shortString ; subname labels, parent to child +domain = 1*253 OCTET ; the name as text ``` `domain` is the UTF-8 canonical fully-qualified name with the TLD always @@ -1494,11 +1495,12 @@ takes. That form appears nowhere in SMP. A hashed query still answers with the name. The registrar records the plaintext label when a name is registered, keyed by the hash of that label, so a router can -look up what the hash stands for without ever being told. It is not the client's -word for it and needs no checking: the key is the hash of the value. A name -registered without that record answers `unknown`. What stays impossible is -learning a name that is *not* registered — there is nothing recorded to look up, -so a name someone is merely considering never becomes known. +look up what the hash stands for without ever being told. The router is not +trusted for it: a client MUST check that the record names the name it asked +about, and reject the answer otherwise. A name registered without that record +answers `unknown`, which fails that check. What stays impossible is learning a +name that is *not* registered: there is nothing recorded to look up, so a name +someone is merely considering never becomes known. **Server-side validation.** The names router parses `domain` as a fully-qualified name (TLD required — bare labels are rejected) and forwards it diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index b764fef8f2..ab0132078f 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -217,10 +217,9 @@ def _keys(self, status, expires, grace_ends): "status": status, "expires": expires, "graceEnds": grace_ends, - "auctionEnds": None, - "premium": None, "reasonCode": None, "reason": None, + "auctionUntil": None, } def setUp(self): @@ -360,10 +359,9 @@ def test_every_branch_returns_the_same_keys(self): "status", "expires", "graceEnds", - "auctionEnds", - "premium", "reasonCode", "reason", + "auctionUntil", } snrc.eth_call = self._expiry(0) self.assertEqual(set(snrc.name_status("alice.testing")), keys) @@ -401,18 +399,22 @@ def eth_call(to, data): return eth_call - def test_unregistered_and_reserved_reads_reserved(self): + def test_unregistered_and_reserved_reports_the_reservation(self): snrc.eth_call = self._chain(0, True) - self.assertEqual(snrc.name_status("acme.testing")["status"], "reserved") + reg = snrc.name_status("acme.testing") + self.assertEqual(reg["status"], "unregistered") + self.assertEqual(reg["reasonCode"], "internal") def test_unregistered_and_not_reserved_reads_unregistered(self): snrc.eth_call = self._chain(0, False) self.assertEqual(snrc.name_status("acme.testing")["status"], "unregistered") - def test_a_lapsed_reserved_name_is_reserved_not_claimable(self): + def test_a_lapsed_reserved_name_keeps_its_reservation(self): past = int(time.time()) - 91 * 86400 snrc.eth_call = self._chain(past, True) - self.assertEqual(snrc.name_status("acme.testing")["status"], "reserved") + reg = snrc.name_status("acme.testing") + self.assertEqual(reg["status"], "expired") + self.assertEqual(reg["reasonCode"], "internal") def test_a_live_name_is_registered_even_if_reserved(self): snrc.eth_call = self._chain(int(time.time()) + 86400, True) @@ -431,7 +433,7 @@ def test_reserved_is_asked_by_labelhash_so_a_hashed_query_works(self): # keccak-256("acme") hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]" snrc.eth_call = self._chain(0, True) - self.assertEqual(snrc.name_status(hashed + ".testing")["status"], "reserved") + self.assertEqual(snrc.name_status(hashed + ".testing")["reasonCode"], "internal") class ReservedReasonTests(unittest.TestCase): @@ -490,7 +492,6 @@ 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) @@ -499,27 +500,27 @@ def test_a_trademark_reservation_says_so(self): _, body = snrc.resolve("acme.testing") self.assertEqual(body["reasonCode"], "trademark") - def test_a_controller_storing_a_bool_reads_as_unspecified(self): + def test_a_controller_storing_a_bool_reads_as_internal(self): """Before the enum `reservedNames` was a bool; its `true` decodes as 1.""" 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") + self.assertEqual(reg["reasonCode"], "internal") + self.assertEqual(reg["reason"], "reserved for SimpleX") def test_an_enum_value_this_resolver_predates_is_not_dropped(self): - """A new Reason still reads as reserved, and says it is unknown rather + """A new Reason still reserves the name, and says it is unknown rather than claiming the chain recorded none.""" snrc.eth_call = self._reserved_as(99) reg = snrc.name_status("acme.testing") - self.assertEqual(reg["status"], "reserved") self.assertEqual(reg["reasonCode"], "unknown") + self.assertEqual(reg["reason"], "reserved") def test_a_reserved_name_carries_the_reason(self): snrc.eth_call = self._chain(0, True) status, body = snrc.resolve("acme.testing") self.assertEqual(status, 404) - self.assertEqual(body["status"], "reserved") - self.assertEqual(body["reason"], "reserved for a brand or public interest") + self.assertEqual(body["status"], "unregistered") + self.assertEqual(body["reason"], "reserved for SimpleX") def test_the_message_does_not_claim_a_trademark(self): snrc.eth_call = self._chain(0, True) @@ -531,26 +532,27 @@ def test_an_unregistered_name_has_no_reason(self): status, body = snrc.resolve("acme.testing") self.assertEqual(status, 404) self.assertEqual(body["status"], "unregistered") - self.assertNotIn("reason", body) + self.assertIsNone(body["reason"]) def test_an_expired_name_has_no_reason(self): snrc.eth_call = self._chain(1, False) status, body = snrc.resolve("acme.testing") self.assertEqual(status, 410) self.assertEqual(body["status"], "expired") - self.assertNotIn("reason", body) + self.assertIsNone(body["reason"]) def test_a_hashed_query_gets_the_reason_too(self): snrc.eth_call = self._chain(0, True) # keccak-256("acme") hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]" _, body = snrc.resolve(hashed + ".testing") - self.assertEqual(body["reason"], "reserved for a brand or public interest") + self.assertEqual(body["reason"], "reserved for SimpleX") class AuctionTests(unittest.TestCase): - """Past grace anyone may register the name, but at a premium that halves - each day. Reporting it as plainly available would quote the normal price.""" + """Past grace anyone may register the name, but at a surcharge until the + oracle's window closes. `auctionUntil` dates that window; the surcharge + itself never travels.""" REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" @@ -561,6 +563,9 @@ class AuctionTests(unittest.TestCase): # The values .testing is deployed with: $100M, halving daily for 21 days. START_PREMIUM = 10 ** 26 TOTAL_DAYS = 21 + # what the oracle charges per year, in US cents, by label length + PRICES = {1: 64000, 2: 16000, 3: 1600, 4: 800, 5: 500, 6: 200} + MIN_LENGTH = 3 def setUp(self): self._saved = ( @@ -587,8 +592,8 @@ def tearDown(self): ) = self._saved def _chain(self, expires, total_days=TOTAL_DAYS, oracle=None, reserved=0): - """Answers as the controller and oracle do, including the oracle's own - `decayedPremium` shift, so the decay curve is not copied here.""" + """Answers as the controller and oracle do, quoting rent in attoUSD per + second as the oracle does.""" oracle = self.ORACLE if oracle is None else oracle self.oracle_calls = [] @@ -602,18 +607,19 @@ def eth_call(to, data): if data.startswith(snrc.selector("prices()")): self.assertEqual(to, self.CONTROLLER) return "0x" + snrc.encode_uint(int(oracle, 16)) + if data.startswith(snrc.selector("minCharLength()")): + self.assertEqual(to, self.CONTROLLER) + return "0x" + snrc.encode_uint(self.MIN_LENGTH) self.oracle_calls.append(data[:10]) self.assertEqual(to, oracle) - if data.startswith(snrc.selector("totalDays()")): - return "0x" + snrc.encode_uint(total_days) + for n, cents in self.PRICES.items(): + if data.startswith(snrc.selector(f"price{n}Letter()")): + rate = cents * snrc.ATTO_PER_CENT // snrc.SECONDS_PER_YEAR + return "0x" + snrc.encode_uint(rate) if data.startswith(snrc.selector("startPremium()")): return "0x" + snrc.encode_uint(self.START_PREMIUM) if data.startswith(snrc.selector("endValue()")): return "0x" + snrc.encode_uint(self.START_PREMIUM >> total_days) - if data.startswith(snrc.selector("decayedPremium(uint256,uint256)")): - start = int(data[10:74], 16) - elapsed = int(data[74:138], 16) - return "0x" + snrc.encode_uint(start >> (elapsed // 86400)) return self.fail("unexpected call " + data[:10]) return eth_call @@ -623,35 +629,39 @@ def _lapsed(self, days_into_auction): second clears the boundary, which counts as still in grace.""" return self.now - self.GRACE - 1 - days_into_auction * 86400 - def test_a_name_just_past_grace_is_in_auction_not_merely_expired(self): + def test_a_name_just_past_grace_is_expired_and_dates_the_auction(self): expires = self._lapsed(0) snrc.eth_call = self._chain(expires) reg = snrc.name_status("acme.testing") - self.assertEqual(reg["status"], "auction") - self.assertEqual( - reg["premium"], str(self.START_PREMIUM - (self.START_PREMIUM >> self.TOTAL_DAYS)) - ) + self.assertEqual(reg["status"], "expired") self.assertEqual(reg["graceEnds"], expires + self.GRACE) self.assertEqual( - reg["auctionEnds"], expires + self.GRACE + self.TOTAL_DAYS * 86400 + reg["auctionUntil"], expires + self.GRACE + self.TOTAL_DAYS * 86400 ) - def test_the_premium_halves_each_day(self): - snrc.eth_call = self._chain(self._lapsed(3)) + def test_the_window_lasts_as_long_as_the_premium_takes_to_decay(self): + expires = self._lapsed(0) + snrc.eth_call = self._chain(expires, total_days=10) reg = snrc.name_status("acme.testing") - floor = self.START_PREMIUM >> self.TOTAL_DAYS - self.assertEqual(reg["premium"], str((self.START_PREMIUM >> 3) - floor)) + self.assertEqual(reg["auctionUntil"], expires + self.GRACE + 10 * 86400) + + def test_the_prices_are_the_oracles_rates_in_cents_per_year(self): + snrc.eth_call = self._chain(self._lapsed(0)) + reg = snrc.name_status("acme.testing") + # 1 and 2 are below minCharLength; the 6-letter tier is the base price + self.assertEqual(reg["rentPrices"], {3: 1600, 4: 800, 5: 500}) + self.assertEqual(reg["basePrice"], 200) + self.assertEqual(reg["minLabelLength"], self.MIN_LENGTH) def test_past_the_window_prices_are_back_to_normal(self): snrc.eth_call = self._chain(self._lapsed(self.TOTAL_DAYS)) reg = snrc.name_status("acme.testing") self.assertEqual(reg["status"], "expired") - self.assertIsNone(reg["premium"]) - self.assertIsNone(reg["auctionEnds"]) + self.assertIsNone(reg["auctionUntil"]) def test_a_zero_day_window_switches_the_auction_off(self): snrc.eth_call = self._chain(self._lapsed(0), total_days=0) - self.assertEqual(snrc.name_status("acme.testing")["status"], "expired") + self.assertIsNone(snrc.name_status("acme.testing")["auctionUntil"]) def test_a_controller_with_no_oracle_leaves_the_name_merely_expired(self): snrc.eth_call = self._chain(self._lapsed(0), oracle=snrc.ZERO_ADDR) @@ -663,50 +673,45 @@ def test_a_name_in_grace_never_reaches_the_oracle(self): self.assertEqual(self.oracle_calls, []) def test_the_oracle_curve_is_read_once_not_per_query(self): - """The curve changes only on a retune, so only the decaying premium is - re-read; the rest would be four RPC calls per query.""" + """The curve changes only on a retune, so it is read once rather than + on every 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)")], - ) + self.assertEqual(self.oracle_calls[seen_first:], []) - def test_a_reserved_lapsed_name_stays_reserved_rather_than_auctioned(self): + def test_a_reserved_lapsed_name_keeps_its_reservation(self): snrc.eth_call = self._chain(self._lapsed(0), reserved=2) reg = snrc.name_status("acme.testing") - self.assertEqual(reg["status"], "reserved") - self.assertIsNone(reg["premium"]) + self.assertEqual(reg["status"], "expired") + self.assertEqual(reg["reasonCode"], "trademark") - def test_resolve_reports_the_auction_with_its_price_and_deadline(self): + def test_resolve_reports_the_prices_and_the_auction_deadline(self): expires = self._lapsed(1) snrc.eth_call = self._chain(expires) status, body = snrc.resolve("acme.testing") self.assertEqual(status, 410) - self.assertEqual(body["status"], "auction") - floor = self.START_PREMIUM >> self.TOTAL_DAYS - self.assertEqual(body["premium"], str((self.START_PREMIUM >> 1) - floor)) + self.assertEqual(body["status"], "expired") + self.assertEqual(body["basePrice"], 200) self.assertEqual( - body["auctionEnds"], expires + self.GRACE + self.TOTAL_DAYS * 86400 + body["auctionUntil"], expires + self.GRACE + self.TOTAL_DAYS * 86400 ) - def test_an_expired_name_past_the_window_carries_no_auction_fields(self): + def test_an_expired_name_past_the_window_has_no_auction_deadline(self): snrc.eth_call = self._chain(self._lapsed(self.TOTAL_DAYS)) status, body = snrc.resolve("acme.testing") self.assertEqual(status, 410) self.assertEqual(body["status"], "expired") - self.assertNotIn("premium", body) - self.assertNotIn("auctionEnds", body) + self.assertIsNone(body["auctionUntil"]) def test_a_hashed_query_is_priced_too(self): # keccak-256("acme") hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]" snrc.eth_call = self._chain(self._lapsed(0)) _, body = snrc.resolve(hashed + ".testing") - self.assertEqual(body["status"], "auction") - self.assertIsNotNone(body["premium"]) + self.assertEqual(body["status"], "expired") + self.assertEqual(body["basePrice"], 200) @@ -757,7 +762,6 @@ def test_an_unconfigured_tld_names_the_ones_that_are(self): def test_a_registration_problem_reports_the_status_as_the_code(self): for expires, code in ( (0, "unregistered"), - (int(time.time()) - 3600, "grace"), (int(time.time()) - 91 * 86400, "expired"), ): with self.subTest(code=code): @@ -766,12 +770,20 @@ def test_a_registration_problem_reports_the_status_as_the_code(self): self.assertEqual(body["error"], code) self.assertEqual(body["status"], code) - def test_a_registered_name_pointing_nowhere_is_noResolver(self): + def test_a_name_in_grace_still_resolves(self): + snrc.eth_call = self._chain(int(time.time()) - 3600) + status, body = snrc.resolve("alice.testing") + self.assertEqual(status, 200) + self.assertEqual(body["status"], "grace") + self.assertNotIn("error", body) + + def test_a_registered_name_pointing_nowhere_resolves_with_empty_records(self): snrc.eth_call = self._chain(int(time.time()) + 86400) status, body = snrc.resolve("alice.testing") - self.assertEqual(status, 404) - self.assertEqual(body["error"], "noResolver") - self.assertEqual(body["status"], "noResolver") + self.assertEqual(status, 200) + self.assertEqual(body["status"], "registered") + self.assertEqual(body["resolver"], snrc.ZERO_ADDR) + self.assertEqual(body["simplexContact"], []) def test_every_error_body_carries_both_fields(self): snrc.eth_call = self._chain(0) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index ea0db5257f..c5444f41f2 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -166,7 +166,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) import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport @@ -1058,7 +1058,7 @@ proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDo proxyResolveName c nm proxiedRelay name | v >= namesSMPVersion = proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (nameQuery v name)) >>= \case - Right (RNAME reg) -> pure $ Right reg + Right (RNAME reg) | resolvedName name reg -> pure $ Right reg Right r -> throwE $ unexpectedResponse r Left e -> pure $ Left e | otherwise = throwE $ PCETransportError TEVersion @@ -1068,18 +1068,26 @@ proxyResolveName c nm proxiedRelay name -- | Direct (non-PFWD) name resolution. Exposes the client IP to the resolver; -- callers that want anonymity should use `proxyResolveName` via the standard -- proxy fallback in the agent. RSLV requires no entity ID or authorization --- (see `noAuthCmd` in Protocol.hs). Version-gated on the session here, not the --- encoder, so an old server never receives RSLV. +-- (see `noAuthCmd` in Protocol.hs). Gated on the session version, below which +-- the server has no RSLV at all; the encoder gates the query format separately. directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameRegistration directResolveName c nm name | v >= namesSMPVersion = sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (nameQuery v name))) >>= \case - RNAME reg -> pure reg + RNAME reg | resolvedName name reg -> pure reg r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion where v = thVersion (thParams c) +-- | The record must name the name that was asked for. A hashed query does not +-- tell the router which name it is, so the record's own name is the router's +-- word until the client checks it here. +resolvedName :: SimplexDomain -> NameRegistration -> Bool +resolvedName d = \case + NRRegistered {nameRecord} -> T.toLower (nrName nameRecord) == fullDomainName d + _ -> True + -- | Acknowledge message delivery (server deletes the message). -- -- https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#acknowledge-message-delivery diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index ae012c2c47..467527e2a6 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -283,7 +283,7 @@ import Simplex.Messaging.Protocol.Types import Simplex.Messaging.Server.QueueStore.QueueInfo import Simplex.Messaging.ServiceScheme import Simplex.Messaging.SystemTime (SystemSeconds) -import Simplex.Messaging.SimplexName (LabelHash, SimplexDomain (..), SimplexTLD (..), domainName, labelHash, labelHashText) +import Simplex.Messaging.SimplexName (LabelHash, SimplexDomain (..), SimplexTLD (..), fullDomainName, labelHash, labelHashText) import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (TransportHost, TransportHosts (..)) import Simplex.Messaging.Util (bshow, eitherToMaybe, safeDecodeUtf8, (<$?>)) @@ -1629,18 +1629,25 @@ instance Encoding NameQueryLabel where 'H' -> NQHash <$> smpP _ -> fail "bad NameQueryLabel" --- | How the backing resolver is addressed for this query. The only place a --- hashed label is written as text, and it faces the resolver's HTTP API - the --- SMP protocol tags the choice instead of spelling it. +-- | How the backing resolver is addressed for this query. queryName :: NameQuery -> Text -queryName NameQuery {queryTLD, queryLabel, querySub} = domainName queryTLD label querySub +queryName = fullDomainName . queryDomain + +-- | The query as a name: what RSLV carries below v22, and what the resolver's +-- HTTP API takes. The only place a hashed label is written as text - the SMP +-- protocol tags the choice instead of spelling it. +queryDomain :: NameQuery -> SimplexDomain +queryDomain NameQuery {queryTLD, queryLabel, querySub} = + SimplexDomain {nameTLD = queryTLD, domain = label, subDomain = querySub} where label = case queryLabel of NQName t -> t NQHash h -> labelHashText h --- | The name a client asked about, hashed from v22 so the router is never told --- what it is. A web TLD has no registry, so it is never hashed. +-- | The name a client asked about, hashed from v22. The hash only hides an +-- unregistered name: a registered one comes back with its name in the record, +-- and a short label is guessable by hashing candidates. A web TLD has no +-- registry, so it is never hashed. nameQuery :: VersionSMP -> SimplexDomain -> NameQuery nameQuery v SimplexDomain {nameTLD, domain, subDomain} = NameQuery {queryTLD = nameTLD, queryLabel = label, querySub = subDomain} @@ -2021,7 +2028,9 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where PRXY host auth_ -> e (PRXY_, ' ', host, auth_) PFWD fwdV pubKey (EncTransmission s) -> e (PFWD_, ' ', fwdV, pubKey, Tail s) RFWD (EncFwdTransmission s) -> e (RFWD_, ' ', Tail s) - RSLV d -> e (RSLV_, ' ', d) + RSLV q + | v >= nameAvailSMPVersion -> e (RSLV_, ' ', q) + | otherwise -> e (RSLV_, ' ', queryDomain q) where e :: Encoding a => a -> ByteString e = smpEncode @@ -2128,7 +2137,9 @@ instance ProtocolEncoding SMPVersion ErrorType Cmd where CT SNotifierService NSUBS_ | v >= rcvServiceSMPVersion -> Cmd SNotifierService <$> (NSUBS <$> _smpP <*> smpP) | otherwise -> pure $ Cmd SNotifierService $ NSUBS (-1) mempty - CT SResolver RSLV_ -> Cmd SResolver . RSLV <$> _smpP <* A.takeByteString + CT SResolver RSLV_ + | v >= nameAvailSMPVersion -> Cmd SResolver . RSLV <$> _smpP <* A.takeByteString + | otherwise -> Cmd SResolver . RSLV . nameQuery v <$> _smpP <* A.takeByteString fromProtocolError = fromProtocolError @SMPVersion @ErrorType @BrokerMsg {-# INLINE fromProtocolError #-} diff --git a/src/Simplex/Messaging/SimplexName.hs b/src/Simplex/Messaging/SimplexName.hs index 7c9e380dfa..34e2421007 100644 --- a/src/Simplex/Messaging/SimplexName.hs +++ b/src/Simplex/Messaging/SimplexName.hs @@ -10,7 +10,6 @@ module Simplex.Messaging.SimplexName SimplexTLD (..), SimplexNameType (..), fullDomainName, - domainName, LabelHash (..), labelHash, labelHashText, @@ -78,11 +77,8 @@ nameLabelP = 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 sent as its keccak256 hash, so a router never learns --- the name. ENS's bracket form: brackets are outside the name character set, so --- it cannot collide with a real name. 66 chars, so exempt from the label limit. -- | The registry's key for a label, and what BaseRegistrarImplementation.labelOf --- is keyed on. Always 32 bytes, so it is never told from a name by its shape. +-- is keyed on. Always 32 bytes. newtype LabelHash = LabelHash ByteString deriving (Eq, Show) @@ -151,13 +147,9 @@ instance Encoding SimplexTLD where _ -> fail "bad SimplexTLD" fullDomainName :: SimplexDomain -> Text -fullDomainName SimplexDomain {nameTLD, domain, subDomain} = domainName nameTLD domain subDomain - --- | A dotted name from its parts, whatever the second-level label is written as. -domainName :: SimplexTLD -> Text -> [Text] -> Text -domainName tld label sub = T.intercalate "." (reverse sub ++ [label] ++ tld') +fullDomainName SimplexDomain {nameTLD, domain, subDomain} = T.intercalate "." (reverse subDomain ++ [domain] ++ tld') where - tld' = case tld of + tld' = case nameTLD of TLDSimplex -> ["simplex"] TLDTesting -> ["testing"] TLDWeb -> [] diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index 9a769ddf62..dd4e23c598 100644 --- a/tests/AgentTests/ResolveNameTests.hs +++ b/tests/AgentTests/ResolveNameTests.hs @@ -91,12 +91,17 @@ resolveNameTests = do testAvailSuccess :: HasCallStack => IO () testAvailSuccess = - withDirectResolver (status404, "{\"error\":\"unregistered\"}") $ \c -> do + withDirectResolver (status404, availableBody) $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of Right (SMP.NRAvailable {}) -> pure () _ -> expectationFailure $ "expected Right NRAvailable, got: " <> show r +-- an unregistered name is only available if the resolver also priced it +availableBody :: LB.ByteString +availableBody = + "{\"error\":\"unregistered\",\"rentPrices\":{\"3\":12793,\"4\":3198},\"basePrice\":100,\"minLabelLength\":3}" + testDirectNotFound :: HasCallStack => IO () testDirectNotFound = withDirectResolver (status404, "{}") $ \c -> do diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 57467aeaf9..1cd5d8cdea 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -107,6 +107,7 @@ rslvTests = do describe "hashed lookups" $ do it "RSLV sends the 2LD as its hash" testRslvSendsTheHash it "subname labels stay text" testSubnameKeepsItsLabels + it "a record naming a different name is rejected" testRslvWrongName testRslvBackendNotFound :: IO () testRslvBackendNotFound = @@ -301,5 +302,16 @@ testSubnameKeepsItsLabels = _ <- runExceptT' (directResolveName pc NRMInteractive (domain "x.alice.simplex")) resolvePaths reqs `shouldReturn` [["resolve", "x." <> aliceHash <> ".simplex"]] +-- a hashed query does not tell the router the name, so the record's own name is +-- checked against the one that was asked for +testRslvWrongName :: IO () +testRslvWrongName = + withResolverServer (status200, J.encode testNameRecord {SMP.nrName = "mallory.simplex"}) $ do + pc <- currentClient + r <- runExceptT (directResolveName pc NRMInteractive (domain "alice.simplex")) + case r of + Left (PCEUnexpectedResponse _) -> pure () + _ -> expectationFailure $ "expected Left (PCEUnexpectedResponse ..), got: " <> show r + runExceptT' :: Show e => ExceptT e IO a -> IO a runExceptT' a = runExceptT a >>= either (fail . show) pure diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 975938e550..5d7eb76b79 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -18,7 +18,7 @@ import Network.HTTP.Types (status200, status400, status404, status410, status500 import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed) import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Encoding.String (strDecode, strEncode) -import Simplex.Messaging.Protocol (ErrorType (..), NameErrorType (..), NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason (..), USDCents (..), nameQuery, queryName) +import Simplex.Messaging.Protocol (Command (..), ErrorType (..), NameErrorType (..), NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason (..), ProtocolEncoding (..), USDCents (..), nameQuery, queryName) import Simplex.Messaging.Server.Main (validateUrl) import Simplex.Messaging.Server.Names ( NamesConfig (..), @@ -30,7 +30,7 @@ import Simplex.Messaging.Server.Names import Simplex.Messaging.Server.Names.HttpResolver (ResolverError (..)) import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexTLD (..), fullDomainName) import Simplex.Messaging.SystemTime (RoundedSystemTime (..)) -import Simplex.Messaging.Transport (currentClientSMPRelayVersion, namesSMPVersion) +import Simplex.Messaging.Transport (currentClientSMPRelayVersion, nameAvailSMPVersion, namesSMPVersion, serverInfoSMPVersion) import Test.Hspec testNameRecord :: NameRecord @@ -54,6 +54,7 @@ smpNamesTests :: Spec smpNamesTests = do describe "NameRecord JSON (Protocol)" nameRecordEncodingSpec describe "ErrorType NAME wire encoding" errorWireSpec + describe "RSLV wire encoding" rslvWireSpec describe "Name parsing (SimplexDomain)" parseNameSpec describe "HTTP resolver" resolverSpec describe "name availability" availabilitySpec @@ -105,6 +106,18 @@ errorWireSpec = -- RESOLVER detail may contain spaces - must survive the round-trip smpDecode (smpEncode (NAME (RESOLVER "HTTP 502"))) `shouldBe` Right (NAME (RESOLVER "HTTP 502")) +-- the query format changed at v22, so an older session must still get the name +rslvWireSpec :: Spec +rslvWireSpec = do + it "below v22 carries the name, as it did before" $ + encodeProtocol v20 (RSLV (nameQuery v20 aliceDomain')) `shouldBe` "RSLV " <> smpEncode aliceDomain' + it "from v22 carries the query" $ + encodeProtocol v22 (RSLV (nameQuery v22 aliceDomain')) `shouldBe` "RSLV " <> smpEncode (nameQuery v22 aliceDomain') + where + v20 = serverInfoSMPVersion + v22 = nameAvailSMPVersion + aliceDomain' = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} + availabilitySpec :: Spec availabilitySpec = do -- one lookup answers what the name points to, whether it can be taken, and From f229e6158596ac62d17126376e2f2bde3c752ac2 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 8 Sep 2026 17:51:10 +0200 Subject: [PATCH 24/27] trim diff --- protocol/simplex-messaging.md | 76 +++++++------ scripts/resolver/service/snrc-resolve.py | 30 +++--- src/Simplex/Messaging/Client.hs | 5 +- src/Simplex/Messaging/Protocol.hs | 86 ++++++--------- src/Simplex/Messaging/Server/Names.hs | 30 +++--- .../Messaging/Server/Names/HttpResolver.hs | 100 +++++++----------- src/Simplex/Messaging/SimplexName.hs | 12 +-- tests/RSLVTests.hs | 9 +- tests/SMPNamesTests.hs | 29 +++-- 9 files changed, 160 insertions(+), 217 deletions(-) diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index 9835b5d776..f894f10478 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1475,19 +1475,18 @@ domain = 1*253 OCTET ; the name as text explicit (e.g. `privacy.simplex`, `test.testing`, `example.com`), bounded to 253 bytes. -**Hashed labels.** `RSLV` does not carry a name. It carries a query, whose -second-level label is either the label itself or the keccak-256 of it, tagged so -that the two are told apart by the encoding rather than by their shape. Nothing -decides what a label is by counting characters or looking for punctuation. +**Hashed labels.** The query's second-level label is either the label itself or +the keccak-256 of it, tagged, so the two are told apart by the tag and never by +the shape of the value. Only the second-level label may be hashed: subname labels are needed as text to reach the record, and a web TLD has no registry to key on. `sub..simplex` reaches the node `sub.name.simplex` does. From v22 a client MUST send the hash. Older routers can only read the name, so a -client on an older session sends it, and gets what it always got. A router -answering a hashed query does not know the name's length, so it cannot check a -minimum-length policy either — the client does that, from the pricing it is sent. +client on an older session sends the name. A router answering a hashed query +does not know the label's length, so it cannot check a minimum-length policy +either: the client does that, from the pricing it is sent. The hash reaches the backing resolver as `[` + 64 lowercase hex + `]`, ENS's encoding for a label whose text is unknown, because that is what its HTTP API @@ -1525,9 +1524,9 @@ fact that this router cannot resolve, so iterating past it is safe. #### Name response -Resolving a name and asking whether it can be registered are one question to the -registry, and one lookup answers both: a client offering a taken name to -register wants to show what took it. `RNAME` carries what the registry holds. +Resolving a name and asking whether it can be registered are one lookup in the +registry, and `RNAME` answers both: a client offering to register a name that +turns out to be taken can show what took it. ```abnf rname = %s"RNAME" SP registration @@ -1560,25 +1559,25 @@ seconds since the Unix epoch. Lengths are characters. | `A` | available: held by nobody and registrable now, at `pricing` | | `R` | reserved: held back by the registry and not registered | -Availability is not a state of its own but the conjunction the registry itself -computes: registrable means `A`, since a name that is held back answers `R` instead. A reservation on a name that *is* registered rides along in -the `reserved` field, and is why that name will not free up when it expires. - -An auction is not a state either. A name past its grace period answers `A` -with the ordinary price, plus the time its surcharge expires. The -surcharge itself is deliberately not carried: it decays continuously, so it -cannot be quoted as a purchase price. A client shows the ordinary price and -counts down to when it applies. - -A router MUST NOT quote a price for a reserved name. A name the registry holds -back is not for sale at the registry's price, and quoting one would be an offer -the registry will not honour - which is why `R` has no pricing field at all -rather than an empty one. - -The record travels while a name is registered, through its grace period, and -stops at the moment the name becomes registrable by anyone. Keeping it that far -lets whoever opens the name tell its owner that it is about to lapse; keeping it -past that would show a record whose owner no longer holds the name. How long a +`A` alone means registrable: a name the registry holds back answers `R` +instead, so a client has no flags to combine. A reservation on a name that *is* +registered is carried in the `reserved` field, and is why that name will not +free up when it expires. + +There is no separate answer for an auction. A name past its grace period answers +`A` with the ordinary price and the time its surcharge expires. The surcharge +itself is not carried: it decays continuously, so it cannot be quoted as a +purchase price. A client shows the ordinary price and counts down to when it +applies. + +A router MUST NOT quote a price for a reserved name: it is not for sale at the +registry's price, and quoting one would be an offer the registry will not +honour. That is why `R` has no pricing field. + +The record is carried while a name is registered and through its grace period, +and stops once the name is registrable by anyone. Keeping it through grace lets +whoever opens the name tell its owner that it is about to lapse; keeping it +longer would show a record whose owner no longer holds the name. How long a client goes on opening an expiring name is its own decision. **Computing the price.** In US cents, for a duration in seconds: @@ -1599,12 +1598,12 @@ Below v22, `RNAME` carries the bare record and nothing else, and every answer without one is `ERR NAME NOT_FOUND`, as it was before this version. A name in its grace period therefore resolves for those clients too, without the expiry they have no field to carry. In the other direction a v22 client reads such an -answer as `N` with no expiry, grace or reservation - which is the only -reason those three fields are optional. +answer as `N` with no expiry, grace or reservation, which is the only reason +those three fields are optional. -From v22 a client MUST NOT read `ERR NAME NOT_FOUND` as "registrable" - only `A` -says that. `NOT_FOUND` means the router has nothing to say about the -name, which includes a backing resolver whose answer it could not read. +From v22 a client MUST NOT read `ERR NAME NOT_FOUND` as "registrable": only `A` +says that. `NOT_FOUND` means the router has nothing to say about the name, which +includes a backing resolver whose answer it could not read. A router that cannot state an answer completely MUST say so as `ERR NAME RESOLVER ` rather than answer partially. That covers a TLD with no @@ -1617,12 +1616,11 @@ name that may be held. A client MUST read a `reason` it does not know as unknown and still treat the name as reserved: a later version may reserve names for reasons this one cannot name, and losing the reservation over that would offer a name that cannot be -registered. The word itself travels, unchanged, so that a later client can act -on it and a current one can show or log it - which is why the set is open rather -than an enumeration. A router sends at most one bounded token of printable -ASCII, since the field ends at a space. +registered. The word itself travels unchanged, so a later client can act on it +and a current one can show or log it, which is why the set is open rather than +an enumeration. A router sends at most one token of printable ASCII, since the +field ends at a space. -`json-bytes` MUST be a UTF-8 JSON object with the following schema: `json-bytes` MUST be a UTF-8 JSON object with the following schema: | Field | JSON type | Constraints | diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 23f54f837c..f83f773daa 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -106,9 +106,8 @@ } # `reservedNames` holds a SimplexController.Reason; 0 means not reserved. A -# controller from before the enum stores a bool, whose `true` decodes as 1. -# SimplexController.Reason. 1 is also what the boolean reservedNames of the -# first .testing deployment set, which is why it reads as "internal". +# controller from before the enum stores a bool, whose `true` decodes as 1, +# which is why 1 reads as "internal". RESERVED_REASONS = { 1: ("internal", "reserved for SimpleX"), 2: ("trademark", "reserved to protect a trademark"), @@ -240,7 +239,7 @@ def reservation_reason(tld: str, token: int) -> int: def pricing_params(tld: str): - """What it costs to register a name under this TLD, in MicroUSD, or None + """What it costs to register a name under this TLD, in US cents, or None when no controller or price oracle is configured.""" return cached(("pricing", tld), lambda: read_pricing_params(tld)) @@ -327,7 +326,7 @@ def name_status(name: str): status = expiry_status(expires, grace, now) # A reservation is orthogonal to the registration: a registered name can be - # held back too, and that is why it will not free up when it expires. + # held back too. code = reservation_reason(tld, token) reason = RESERVED_REASONS.get(code, UNKNOWN_REASON) if code else None @@ -375,18 +374,16 @@ def decode_bytes(hex_data: str) -> bytes: def registered_label(registrar: str, token: int) -> str: - """The registrar records the plaintext label at registration, keyed by its - own hash, so a hashed query still answers with the name it asked about. A - name registered without registerWithLabel has none, which is an error we - name rather than paper over.""" + """The plaintext label the registrar recorded at registration, keyed by the + hash of that label. A name registered without registerWithLabel has none, + and answers "unknown" instead.""" raw = decode_bytes(eth_call(registrar, selector("labelOf(uint256)") + encode_uint(token))) return raw.decode("utf-8", errors="replace") if raw else "unknown" def canonical_name(name: str) -> str: - """The name the registry holds. A hashed query never told anyone the name, - so the registrar's own record of it is what comes back; a plaintext query - already carries it.""" + """The name to answer with: a hashed query does not carry one, so the + registrar's record of the label fills it in.""" labels = name.split(".") registrar = REGISTRARS.get(labels[-1]) if not registrar or len(labels) < 2 or not is_encoded_labelhash(labels[-2]): @@ -688,8 +685,7 @@ def resolve(name: str): # Before the resolver lookup, so a lapsed name is not reported as noResolver. reg = name_status(name) if reg["status"] in ("unregistered", "expired"): - # A name in grace is not here: its record still resolves, so that whoever - # opens it can tell the owner it is about to lapse. + # A name in grace is not here: its record still resolves. body = { "name": name, **reg, @@ -705,9 +701,9 @@ def resolve(name: str): resolver_raw = eth_call(registry, selector("resolver(bytes32)") + node_hex) resolver_addr = decode_address(resolver_raw) if resolver_addr == ZERO_ADDR: - # A registered name always resolves. With no resolver set the record is - # still returned, every field unset, so that "taken until " stays - # answerable for the name a would-be registrant is asking about. + # A registered name always resolves: with no resolver set the record is + # still returned with every field unset, so "taken until " stays + # answerable. owner = decode_address(eth_call(registry, selector("owner(bytes32)") + node_hex)) return 200, { "name": canonical_name(name), diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index c5444f41f2..12c67e7251 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -1080,9 +1080,8 @@ directResolveName c nm name where v = thVersion (thParams c) --- | The record must name the name that was asked for. A hashed query does not --- tell the router which name it is, so the record's own name is the router's --- word until the client checks it here. +-- | The record must name the name that was asked for: a hashed query does not +-- tell the router which name it is, so the router is not trusted for it. resolvedName :: SimplexDomain -> NameRegistration -> Bool resolvedName d = \case NRRegistered {nameRecord} -> T.toLower (nrName nameRecord) == fullDomainName d diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 467527e2a6..3899d6b9c8 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -88,7 +88,6 @@ module Simplex.Messaging.Protocol NamePricing (..), USDCents (..), NameReservedReason (..), - parseReservedReason, oldRegistration, NameErrorType (..), BrokerErrorType (..), @@ -1602,12 +1601,12 @@ data ErrorType DUPLICATE_ -- not part of SMP protocol, used internally deriving (Eq, Show) --- | What RSLV asks about. Distinct from SimplexDomain, which stays a name a --- person can type and a UI can show: only this may name a label by its hash. +-- | What RSLV asks about. Unlike SimplexDomain, which is always text, this may +-- name a label by its hash. data NameQuery = NameQuery { queryTLD :: SimplexTLD, - -- | only the second-level label may be hashed - subnames are needed as text - -- to reach the record, so they are not part of this choice + -- | only the second-level label may be hashed: subname labels are needed as + -- text to reach the record queryLabel :: NameQueryLabel, -- | parent to child, as in SimplexDomain querySub :: [Text] @@ -1634,8 +1633,8 @@ queryName :: NameQuery -> Text queryName = fullDomainName . queryDomain -- | The query as a name: what RSLV carries below v22, and what the resolver's --- HTTP API takes. The only place a hashed label is written as text - the SMP --- protocol tags the choice instead of spelling it. +-- HTTP API takes. The only place a hashed label is written as text; SMP tags +-- the choice instead. queryDomain :: NameQuery -> SimplexDomain queryDomain NameQuery {queryTLD, queryLabel, querySub} = SimplexDomain {nameTLD = queryTLD, domain = label, subDomain = querySub} @@ -1663,23 +1662,20 @@ instance Encoding NameQuery where (queryTLD, queryLabel, EncList querySub) <- smpP pure NameQuery {queryTLD, queryLabel, querySub} --- | US cents. Rounded up wherever the registry's unit does not divide evenly, --- so a quote is never below what is charged; the exact figure is settled on --- chain at registration. +-- | US cents, rounded up where the registry's unit does not divide evenly, so +-- a quote is never below what is charged. The exact price is settled on chain. newtype USDCents = USDCents Int64 deriving (Eq, Ord, Show) - deriving newtype (Encoding, ToJSON, FromJSON) + deriving newtype (Encoding) --- | What the registry holds for a name. A TLD with no registrar or no price --- oracle configured is not a case here - it is ERR NAME RESOLVER, because a --- name that cannot be dated or priced is not one this router can report on. +-- | What the registry holds for a name. A name that cannot be dated or priced +-- is not a case here: the router answers ERR NAME RESOLVER instead. data NameRegistration - = -- | Held by someone. A registered name always resolves: where its owner set - -- no records the record is still present, every field unset and nrResolver - -- the zero address, so "taken until " stays answerable. + = -- | Held by someone. Always carries a record: where the owner set none, + -- every field is unset and the resolver address is zero. NRRegistered { -- | unix seconds the registration runs out. Absent only from a v20/v21 - -- router, whose answer carried the record and nothing else. + -- router, whose answer carried the record alone. expires :: Maybe SystemSeconds, -- | unix seconds, > expires: until here only the owner may renew graceUntil :: Maybe SystemSeconds, @@ -1691,14 +1687,12 @@ data NameRegistration NRAvailable { pricing :: NamePricing, -- | while set, the name also costs a surcharge above `pricing` that - -- decays to nothing at this time. The surcharge itself is deliberately - -- not carried: it changes continuously, so it cannot be an in-app - -- purchase price. A client counts down to the ordinary price instead. + -- decays to nothing at this time. The surcharge itself is not carried: + -- it changes continuously, so it cannot be quoted as a price. auctionUntil :: Maybe SystemSeconds } - | -- | Held back by the registry and not registered. No price: what it costs, - -- and whether it can be had at all, is a conversation with SimpleX. This is - -- also why a reserved name never frees up on its own. + | -- | Held back by the registry and not registered. No price: it is not for + -- sale at the registry's price. NRReserved {reservedReason :: NameReservedReason} deriving (Eq, Show) @@ -1722,21 +1716,14 @@ instance Encoding NameRegistration where _ -> fail "bad NameRegistration" -- | Enough to price the name locally. The client knows the label, so it knows --- both which tier applies and whether the label is long enough - neither of --- which the router can see behind a hash. --- --- price len duration = fromMaybe basePrice (M.lookup len rentPrices) --- * duration `div` 31536000 --- --- The registry's minimum registration is 28 days, a contract constant rather --- than a per-deployment value, so it is specified rather than sent. +-- which tier applies and whether the label is long enough; the router, behind a +-- hash, knows neither. The formula is in protocol/simplex-messaging.md. data NamePricing = NamePricing - { -- | US cents per year for the label lengths the registry prices specially. - -- Lengths below minLabelLength are absent, being unregistrable. + { -- | US cents per year, for the lengths the registry prices specially rentPrices :: Map Int USDCents, - -- | US cents per year for every length not in rentPrices. + -- | US cents per year for every other length basePrice :: USDCents, - -- | characters: the registry refuses shorter, so the client must check it. + -- | characters; the registry refuses shorter labels minLabelLength :: Int } deriving (Eq, Show) @@ -1754,16 +1741,14 @@ instance Encoding NamePricing where tierMap :: [(Word16, USDCents)] -> Map Int USDCents tierMap = M.fromList . map (\(len, price) -> (fromIntegral len, price)) --- | Why the registry holds a name back. A reason this version has no word for --- keeps its own word rather than losing the reservation. +-- | Why the registry holds a name back. data NameReservedReason - = -- | held for SimpleX. On chain this is 1, which is also what the boolean - -- reservedNames of the first .testing deployment set. + = -- | held for SimpleX NRRInternal | NRRTrademark | NRRCommunity | -- | a reason added to the registry after this version: still reserved, and - -- carrying its own word so a later version can name it + -- carries its own word so a later version can name it NRRUnknown Text deriving (Eq, Show) @@ -1774,22 +1759,19 @@ instance StrEncoding NameReservedReason where NRRTrademark -> "trademark" NRRCommunity -> "community" NRRUnknown t -> encodeUtf8 t - strP = parseReservedReason . safeDecodeUtf8 <$> A.takeTill (== ' ') + strP = reservedReasonOf . safeDecodeUtf8 <$> A.takeTill (== ' ') + where + reservedReasonOf = \case + "internal" -> NRRInternal + "trademark" -> NRRTrademark + "community" -> NRRCommunity + t -> NRRUnknown t instance Encoding NameReservedReason where smpEncode = strEncode smpP = strP --- | Keeps its word rather than losing the reservation. -parseReservedReason :: Text -> NameReservedReason -parseReservedReason = \case - "internal" -> NRRInternal - "trademark" -> NRRTrademark - "community" -> NRRCommunity - t -> NRRUnknown t - --- | What a v20/v21 router's answer amounts to: it resolves, and nothing else --- was said about it. +-- | A v20/v21 router's answer: the name resolves, and nothing else was said. oldRegistration :: NameRecord -> NameRegistration oldRegistration nameRecord = NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord} diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 985cf21a3e..f360789cfc 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -17,11 +17,13 @@ where import qualified Control.Exception as E import Control.Logger.Simple (logError) +import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.Text (Text) -import qualified Data.Map.Strict as M import qualified Data.Text as T -import Simplex.Messaging.Protocol (NameErrorType (..), NamePricing (..), NameRecord, NameQuery, NameRegistration (..), NameReservedReason, USDCents (..), oldRegistration, parseReservedReason, queryName) +import Data.Text.Encoding (encodeUtf8) +import Simplex.Messaging.Encoding.String (strDecode) +import Simplex.Messaging.Protocol (NameErrorType (..), NamePricing (..), NameQuery, NameRecord, NameRegistration (..), NameReservedReason (..), USDCents (..), oldRegistration, queryName) import Simplex.Messaging.Server.Names.HttpResolver ( NameStatusResp (..), ResolverEnv, @@ -75,17 +77,15 @@ fetch :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameRegistration) fetch NamesEnv {resolverEnv} q = either (Left . mapResolverError) nameRegistration <$> resolveHttp resolverEnv (queryName q) --- | A resolver that reports no status at all is an older one, and only ever --- returned a record for a live registration - which is what oldRegistration says. +-- | A resolver that reports no status is an older one, which returned a record +-- only for a live registration. nameRegistration :: (Maybe NameRecord, Maybe NameStatusResp) -> Either NameErrorType NameRegistration nameRegistration = \case (rec_, Just ns) -> mapStatus rec_ ns (Just rec, Nothing) -> Right (oldRegistration rec) (Nothing, Nothing) -> Left NOT_FOUND --- | The resolver's vocabulary. A status this router has no word for is not an --- answer: a registration would assert one nobody read, and availability would --- offer a name that may be held. +-- | The resolver's status words. An unknown status is not an answer. mapStatus :: Maybe NameRecord -> NameStatusResp -> Either NameErrorType NameRegistration mapStatus rec_ ns@NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsReasonCode, nsAuctionUntil} = case nsStatus of @@ -96,27 +96,25 @@ mapStatus rec_ ns@NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsReasonCode s -> Left (RESOLVER (T.take 32 s)) where reservedReason_ = resolverReason <$> nsReasonCode - -- A registered name resolves: where the owner set no records the resolver - -- still returns one, every field unset. And a registration this router - -- could not date is not one it can report. + -- A registered name always has a record, and a registration this router + -- cannot date is not one it can report. registered = case (rec_, nsExpires, nsGraceEnds) of (Just nameRecord, Just expires, Just graceUntil) -> Right NRRegistered {expires = Just (RoundedSystemTime expires), graceUntil = Just (RoundedSystemTime graceUntil), reservedReason_, nameRecord} (Nothing, _, _) -> Left (RESOLVER "no record") _ -> Left (RESOLVER "no expiry") - -- A held-back name is quoted no price: what it costs, and whether it can be - -- had at all, is a conversation with SimpleX. available = case reservedReason_ of Just r -> Right (NRReserved r) Nothing -> case namePricing ns of Just pricing -> Right NRAvailable {pricing, auctionUntil = RoundedSystemTime <$> nsAuctionUntil} Nothing -> Left (RESOLVER "no price oracle") --- | A code this router has no word for still reserves the name, and travels on --- as itself. Bounded to one wire token: it is the resolver's text, and the slot --- it goes into ends at a space. +-- | An unknown code still reserves the name, and travels on as itself. Cut to +-- one printable token: the wire slot it goes into ends at a space. resolverReason :: Text -> NameReservedReason -resolverReason = parseReservedReason . T.take 32 . T.takeWhile (\c -> c > ' ' && c < '\DEL') +resolverReason t = either (const (NRRUnknown t')) id (strDecode (encodeUtf8 t')) + where + t' = T.take 32 (T.takeWhile (\c -> c > ' ' && c < '\DEL') t) namePricing :: NameStatusResp -> Maybe NamePricing namePricing NameStatusResp {nsRentPrices, nsBasePrice, nsMinLabelLength} = do diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index e0106a3ad7..69d2c4e659 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -3,6 +3,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} -- | HTTP transport for the public-namespace resolver. @@ -37,8 +38,9 @@ where import qualified Control.Exception as E import qualified Data.Aeson as J import Data.Aeson.Key (Key) -import qualified Data.Aeson.Types as JT import qualified Data.Aeson.KeyMap as JKM +import qualified Data.Aeson.TH as JQ +import qualified Data.Aeson.Types as JT import Data.Bifunctor (first) import qualified Data.ByteArray.Encoding as BAE import Data.ByteString.Char8 (ByteString) @@ -66,6 +68,7 @@ import Network.HTTP.Client.TLS (tlsManagerSettings) import qualified Network.HTTP.Types as HT import Network.HTTP.Types.URI (urlEncode) import Simplex.Messaging.Names.Record (NameRecord) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix) data RpcAuth = AuthBearer Text | AuthBasic Text Text @@ -89,21 +92,19 @@ data NameStatusResp = NameStatusResp { nsStatus :: Text, nsExpires :: Maybe Int64, nsGraceEnds :: Maybe Int64, - -- | reported alongside the status: a reservation is orthogonal to it nsReasonCode :: Maybe Text, - -- | when the post-grace surcharge decays to nothing, so the client can - -- count down to the ordinary price. The surcharge itself never travels. + -- | when the post-grace surcharge decays to nothing nsAuctionUntil :: Maybe Int64, - -- | the TLD's price oracle, in US cents per year: the lengths it prices - -- specially, and the price for every other length. The resolver converts - -- from the registry's attoUSD, so nothing 256-bit gets this far and every - -- value fits a JSON number exactly. + -- | US cents per year, by label length nsRentPrices :: Maybe (Map Int Int64), + -- | US cents per year for every other length nsBasePrice :: Maybe Int64, nsMinLabelLength :: Maybe Int } deriving (Show) +$(JQ.deriveFromJSON defaultJSON {J.fieldLabelModifier = dropPrefix "ns"} ''NameStatusResp) + data ResolverError = HttpFailure HttpException | HttpStatusErr Int @@ -138,63 +139,41 @@ authHeader = \case in ("Authorization", "Basic " <> encoded) -- | GET /resolve/, returning the record when the --- name resolves and what the resolver says about the name either way. The --- status code cannot tell an unregistered name from a reserved or lapsed one; --- that is in the body, under "status" on a 200 and "error" otherwise. Older --- resolvers omit it, hence the Maybe. The name is percent-encoded (every --- non-unreserved byte per RFC 3986): the resolver expects raw labels, so --- slashes/punctuation must not alter the path. +-- name resolves and what the resolver says about the name either way. The status +-- code cannot tell an unregistered name from a reserved or lapsed one, so on the +-- two codes that carry availability the body is read as well. The name is +-- percent-encoded (every non-unreserved byte per RFC 3986): the resolver expects +-- raw labels, so slashes/punctuation must not alter the path. resolveHttp :: ResolverEnv -> Text -> IO (Either ResolverError (Maybe NameRecord, Maybe NameStatusResp)) -resolveHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseBytes} name = do - req0 <- parseRequest (baseUrl <> "/resolve/" <> B.unpack (urlEncode True (encodeUtf8 name))) - let req = - req0 - { redirectCount = 0, - requestHeaders = ("Accept", "application/json") : authHdr, - HC.responseTimeout = responseTimeoutMicro timeoutMicro - } - result <- E.try $ withResponse req manager $ \res -> do - let status = HT.statusCode (responseStatus res) - bs <- brReadSome (responseBody res) (maxResponseBytes + 1) - pure $ - if BL.length bs > fromIntegral maxResponseBytes - then Left BodyTooLarge - else case J.decode bs of - Just v@(J.Object o) - | status < 400 -> (,statusResp o "status") . Just <$> first InvalidJson (JT.parseEither J.parseJSON v) - | otherwise -> maybe (Left $ HttpStatusErr status) (Right . (Nothing,) . Just) (statusResp o "error") - _ - | status < 400 -> Left (InvalidJson "not a JSON object") - | otherwise -> Left (HttpStatusErr status) - pure (either (Left . HttpFailure) id result) +resolveHttp env name = + (>>= nameResp) <$> httpGet env ("/resolve/" <> B.unpack (urlEncode True (encodeUtf8 name))) where - statusResp o field = mkResp <$> jsonField o field - where - mkResp t = - NameStatusResp - { nsStatus = t, - nsExpires = jsonField o "expires", - nsGraceEnds = jsonField o "graceEnds", - nsReasonCode = jsonField o "reasonCode", - nsAuctionUntil = jsonField o "auctionUntil", - nsRentPrices = jsonField o "rentPrices", - nsBasePrice = jsonField o "basePrice", - nsMinLabelLength = jsonField o "minLabelLength" - } + nameResp (status, bs) + | status < 400 = (,statusResp bs "status") . Just <$> first InvalidJson (J.eitherDecode bs) + | status == 404 || status == 410 = + maybe (Left $ HttpStatusErr status) (Right . (Nothing,) . Just) (statusResp bs "error") + | otherwise = Left (HttpStatusErr status) --- | A field the resolver omits or nulls for statuses that do not carry it. -jsonField :: J.FromJSON a => J.Object -> Key -> Maybe a -jsonField o k = JT.parseMaybe J.parseJSON =<< JKM.lookup k o +-- | What the resolver says about the name, under "status" on a 200 and "error" +-- on the codes that carry availability. Older resolvers send neither. +statusResp :: BL.ByteString -> Key -> Maybe NameStatusResp +statusResp bs k = case J.decode bs of + Just (J.Object o) -> do + v <- JKM.lookup k o + JT.parseMaybe J.parseJSON (J.Object (JKM.insert "status" v o)) + _ -> Nothing -- | GET /health; success = reachable with status < 400. The body is -- size-capped but NOT decoded — the probe only checks reachability. healthHttp :: ResolverEnv -> IO (Either ResolverError ()) -healthHttp env = (() <$) <$> httpGet env "/health" +healthHttp env = (>>= statusOk . fst) <$> httpGet env "/health" + where + statusOk status = if status >= 400 then Left (HttpStatusErr status) else Right () --- | GET , returning the response body bytes on status < 400 --- within the size cap. Redirects are disabled and Authorization is attached --- only when configured. -httpGet :: ResolverEnv -> String -> IO (Either ResolverError BL.ByteString) +-- | GET , returning the response status and body bytes within the +-- size cap. Redirects are disabled and Authorization is attached only when +-- configured. +httpGet :: ResolverEnv -> String -> IO (Either ResolverError (Int, BL.ByteString)) httpGet ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseBytes} path = do req0 <- parseRequest (baseUrl <> path) let req = @@ -205,9 +184,6 @@ httpGet ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseBytes} } result <- E.try $ withResponse req manager $ \res -> do let status = HT.statusCode (responseStatus res) - if status >= 400 - then pure (Left (HttpStatusErr status)) - else do - bs <- brReadSome (responseBody res) (maxResponseBytes + 1) - pure $ if BL.length bs > fromIntegral maxResponseBytes then Left BodyTooLarge else Right bs + bs <- brReadSome (responseBody res) (maxResponseBytes + 1) + pure $ if BL.length bs > fromIntegral maxResponseBytes then Left BodyTooLarge else Right (status, bs) pure (either (Left . HttpFailure) id result) diff --git a/src/Simplex/Messaging/SimplexName.hs b/src/Simplex/Messaging/SimplexName.hs index 34e2421007..c81dbd4cbc 100644 --- a/src/Simplex/Messaging/SimplexName.hs +++ b/src/Simplex/Messaging/SimplexName.hs @@ -77,8 +77,8 @@ nameLabelP = do -- (Cyrillic а vs ASCII a hash to different on-chain records). isNameLetter c = c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' --- | The registry's key for a label, and what BaseRegistrarImplementation.labelOf --- is keyed on. Always 32 bytes. +-- | The registry's key for a label, and what +-- BaseRegistrarImplementation.labelOf takes. Always 32 bytes. newtype LabelHash = LabelHash ByteString deriving (Eq, Show) @@ -86,14 +86,12 @@ instance Encoding LabelHash where smpEncode (LabelHash h) = h smpP = LabelHash <$> A.take 32 --- | keccak-256 of the lowercased label. Only a second-level label is a registry --- key: subname labels are needed as text to reach the record. +-- | keccak-256 of the lowercased label, as the registry keys it. labelHash :: Text -> LabelHash labelHash label = LabelHash $ BA.convert (hash (encodeUtf8 (T.toLower label)) :: Digest Keccak_256) --- | How the backing resolver is addressed for a hashed label: ENS's encoding --- for a label whose text is unknown. The SMP protocol never parses this form - --- it tags the choice instead - so the brackets live here alone. +-- | ENS's encoding for a label whose text is unknown, which is what the +-- backing resolver's HTTP API takes. labelHashText :: LabelHash -> Text labelHashText (LabelHash h) = "[" <> decodeLatin1 (BAE.convertToBase BAE.Base16 h) <> "]" diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 1cd5d8cdea..a199dbb31f 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -217,8 +217,8 @@ oldClient = do g <- C.newRandom ts <- getCurrentTime let srv = SMPServer testHost testPort testKeyHash - -- the version just below the gate: a lower ceiling would also pass for a - -- gate at 20 or 21 and prove nothing about v22 + -- the version just below the gate: a lower ceiling would pass even if + -- the gate were at 20 or 21 oldCfg = defaultSMPClientConfig {serverVRange = mkVersionRange minServerSMPRelayVersion serverInfoSMPVersion} pcE <- getProtocolClient g NRMInteractive (1, srv, Nothing) oldCfg [] Nothing ts (\_ -> pure ()) either (fail . show) pure pcE @@ -269,7 +269,7 @@ auctionPricing = aliceHash :: Text aliceHash = "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]" --- | A current client must never put a registrable name on the wire. +-- | The paths the client asked the resolver for. resolvePaths :: IORef [[Text]] -> IO [[Text]] resolvePaths reqs = filter isResolve <$> readIORef reqs where @@ -289,8 +289,7 @@ testRslvSendsTheHash = pc <- currentClient r <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) resolvePaths reqs `shouldReturn` [["resolve", aliceHash <> ".simplex"]] - -- the client never sent the name, and the record still names it: the - -- registrar records the label at registration, keyed by its own hash + -- the client never sent the name, and the record still names it case r of NRRegistered {nameRecord} -> SMP.nrName nameRecord `shouldBe` "alice.simplex" _ -> expectationFailure $ "expected NRRegistered, got: " <> show r diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 5d7eb76b79..8c1795cfd6 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -121,19 +121,16 @@ rslvWireSpec = do availabilitySpec :: Spec availabilitySpec = do -- one lookup answers what the name points to, whether it can be taken, and - -- whether the registry holds it back + -- whether it is held back it "a registered name answers with its record and dates" $ answers status200 (recordWith "\"status\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483") $ NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Nothing, nameRecord = testNameRecord} - -- the record travels through grace: the UI decides how long to keep opening it it "a name in grace keeps its record" $ answers status200 (recordWith "\"status\":\"grace\",\"expires\":1785000000,\"graceEnds\":1792776000") $ NRRegistered {expires = Just (RoundedSystemTime 1785000000), graceUntil = Just (RoundedSystemTime 1792776000), reservedReason_ = Nothing, nameRecord = testNameRecord} - -- reservation is orthogonal: it is why the name will not free up at expiry it "a registered name can be held back too" $ answers status200 (recordWith "\"status\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483,\"reasonCode\":\"internal\"") $ NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Just NRRInternal, nameRecord = testNameRecord} - -- an older resolver reports no status; the record is still the answer it "a resolver that sends no status still answers with the record" $ answers status200 (J.encode testNameRecord) $ NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord = testNameRecord} @@ -143,16 +140,14 @@ availabilitySpec = do it "expired is available, counting down to the ordinary price" $ answers status410 (jsonBody ("{\"error\":\"expired\",\"auctionUntil\":1790294400," <> pricingJson <> "}")) $ NRAvailable {pricing = testPricing, auctionUntil = Just (RoundedSystemTime 1790294400)} - -- a held-back name is not for sale at the registry's price it "reserved carries the reason and no price" $ answers status404 (jsonBody ("{\"error\":\"unregistered\",\"reasonCode\":\"trademark\"," <> pricingJson <> "}")) $ NRReserved NRRTrademark - -- a later version may reserve names for reasons this one cannot name; the - -- reservation must survive that, or a client would offer a name it cannot get + -- losing the reservation would offer a name that cannot be registered it "a reason from a later version still reserves the name" $ answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"seasonal\"}" (NRReserved (NRRUnknown "seasonal")) - -- the reason re-encodes into a slot that ends at a space, so the router keeps - -- it to one bounded token rather than trusting the resolver's text + -- the reason re-encodes into a slot that ends at a space, so it is cut to one + -- token it "a reason with a space is cut at the space" $ answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"two words\"}" (NRReserved (NRRUnknown "two")) it "an over-long reason is truncated" $ @@ -165,16 +160,18 @@ availabilitySpec = do refuses status404 "{\"error\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483}" (RESOLVER "no record") it "no price oracle is a resolver error" $ refuses status404 "{\"error\":\"unregistered\"}" (RESOLVER "no price oracle") + -- only 404 and 410 carry availability, so only their bodies are read as a + -- status it "upstream failure is a resolver error" $ - refuses status502 "{\"error\":\"upstreamError\"}" (RESOLVER "upstreamError") - it "unconfigured TLD is a resolver error" $ - refuses status400 "{\"error\":\"tldNotConfigured\"}" (RESOLVER "tldNotConfigured") + refuses status502 "{\"error\":\"upstreamError\"}" (RESOLVER "HTTP 502") + it "unconfigured TLD is not found" $ + refuses status400 "{\"error\":\"tldNotConfigured\"}" NOT_FOUND it "unreadable status is a resolver error" $ refuses status404 "{\"error\":\"unknown\"}" (RESOLVER "unknown") it "long status is truncated" $ - refuses status502 (jsonBody ("{\"error\":\"" <> replicate 400 'e' <> "\"}")) (RESOLVER (T.replicate 32 "e")) - -- a body the router cannot read is the pre-v22 answer, unchanged: NOT_FOUND - -- says the router has nothing to say, never that the name is registrable + refuses status404 (jsonBody ("{\"error\":\"" <> replicate 400 'e' <> "\"}")) (RESOLVER (T.replicate 32 "e")) + -- NOT_FOUND says the router has nothing to say, never that the name is + -- registrable it "unreadable 404 body stays NOT_FOUND" $ refuses status404 "gateway" NOT_FOUND it "over-cap body is a resolver error" $ @@ -223,7 +220,7 @@ pricingJson = "\"rentPrices\":{\"3\":12793,\"4\":3198},\"basePrice\":100,\"minLa parseNameSpec :: Spec parseNameSpec = do - -- a name is a name: the hashed form is a query, and has its own type + -- the hashed form is a query, not a name: it has its own type it "a name is never a hash" $ parseN ("[" <> T.replicate 64 "b" <> "].simplex") `shouldSatisfy` isLeft -- keccak-256("alice"), the same constant the resolver's own tests use From 5ccbca9239f57486bbfdfe7b7a7e69d1c2cd2421 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 9 Sep 2026 09:07:26 +0200 Subject: [PATCH 25/27] align resolver with contract changes for names v2 --- scripts/resolver/service/snrc-resolve.py | 50 +++------ scripts/resolver/service/test_snrc_resolve.py | 101 ++++++------------ 2 files changed, 47 insertions(+), 104 deletions(-) diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index f83f773daa..00e9a6e38b 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -125,8 +125,6 @@ ZERO_ADDR = "0x0000000000000000000000000000000000000000" # The registry prices in attoUSD (1e-18 USD); the protocol carries US cents. -ATTO_PER_CENT = 10**16 -SECONDS_PER_YEAR = 31536000 # ---------- RPC + ABI helpers (mirrors ens-lookup.py shape) ---------- @@ -260,41 +258,30 @@ def read_pricing_params(tld: str): def read_oracle_prices(controller: str, oracle: str): - # The oracle prices rent in attoUSD per second. Quotes round up, so one is - # never below what the registry charges. An oracle built before the - # six-letter tier stops at five, and the contract then charges price5Letter - # for anything longer - which is what basePrice means here. - tiers = {} - for n in range(1, 7): - try: - rate = decode_uint(eth_call(oracle, selector(f"price{n}Letter()"))) - except RuntimeError: - if n <= 5: - raise - break - tiers[n] = ceil_div(rate * SECONDS_PER_YEAR, ATTO_PER_CENT) - base = tiers.pop(max(tiers)) + """The oracle keeps the curve in US cents per year, which is the unit the + SMP protocol carries, so nothing is converted here.""" + base, tiers = decode_prices(eth_call(oracle, selector("prices()"))) min_len = decode_uint(eth_call(controller, selector("minCharLength()"))) return { # lengths the registry refuses are left out rather than priced at zero "rentPrices": {n: c for n, c in tiers.items() if n >= min_len}, "basePrice": base, "minLabelLength": min_len, - # not sent: only used to date the end of the surcharge window - "_auctionDays": auction_days(oracle), } -def auction_days(oracle: str) -> int: - """The surcharge halves daily from startPremium until it falls below - endValue, so the window is log2(startPremium / endValue) days.""" - start = decode_uint(eth_call(oracle, selector("startPremium()"))) - end = decode_uint(eth_call(oracle, selector("endValue()"))) - return (start // end).bit_length() - 1 if end and start > end else 0 - - -def ceil_div(a: int, b: int) -> int: - return -(-a // b) +def decode_prices(hex_data: str): + """`prices()` returns the base price and the lengths priced differently.""" + raw = bytes.fromhex(hex_data[2:] if hex_data.startswith("0x") else hex_data) + base = int.from_bytes(raw[:32], "big") + at = int.from_bytes(raw[32:64], "big") + count = int.from_bytes(raw[at:at + 32], "big") + tiers = {} + for i in range(count): + item = at + 32 + i * 64 + length = int.from_bytes(raw[item:item + 32], "big") + tiers[length] = int.from_bytes(raw[item + 32:item + 64], "big") + return base, tiers def name_status(name: str): @@ -341,12 +328,7 @@ def name_status(name: str): if status in ("unregistered", "expired"): pricing = pricing_params(tld) if pricing: - out.update({k: v for k, v in pricing.items() if not k.startswith("_")}) - # past grace the name is registrable again, but at a surcharge until - # the oracle's window closes; the surcharge itself never travels - ends = (expires + grace + pricing["_auctionDays"] * 86400) if expires else 0 - if status == "expired" and ends > now: - out["auctionUntil"] = ends + out.update(pricing) return out diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index ab0132078f..cd4d060f84 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -549,10 +549,9 @@ def test_a_hashed_query_gets_the_reason_too(self): self.assertEqual(body["reason"], "reserved for SimpleX") -class AuctionTests(unittest.TestCase): - """Past grace anyone may register the name, but at a surcharge until the - oracle's window closes. `auctionUntil` dates that window; the surcharge - itself never travels.""" +class PricingTests(unittest.TestCase): + """The oracle keeps the curve in US cents per year, and a lapsed name costs + the ordinary price: this registry runs no auction.""" REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" @@ -560,11 +559,8 @@ class AuctionTests(unittest.TestCase): ORACLE = "0x1e0c9a2b9d1a4c8f7b3e5d6a9c2f4b8e1d7a3c50" GRACE = 90 * 86400 - # The values .testing is deployed with: $100M, halving daily for 21 days. - START_PREMIUM = 10 ** 26 - TOTAL_DAYS = 21 - # what the oracle charges per year, in US cents, by label length - PRICES = {1: 64000, 2: 16000, 3: 1600, 4: 800, 5: 500, 6: 200} + BASE = 200 + EXCEPTIONS = {1: 64000, 2: 16000, 3: 1600, 4: 800, 5: 500} MIN_LENGTH = 3 def setUp(self): @@ -591,9 +587,14 @@ def tearDown(self): snrc.chain_now, ) = self._saved - def _chain(self, expires, total_days=TOTAL_DAYS, oracle=None, reserved=0): - """Answers as the controller and oracle do, quoting rent in attoUSD per - second as the oracle does.""" + def _prices_return(self): + words = [snrc.encode_uint(self.BASE), snrc.encode_uint(0x40), + snrc.encode_uint(len(self.EXCEPTIONS))] + for length, cents in self.EXCEPTIONS.items(): + words += [snrc.encode_uint(length), snrc.encode_uint(cents)] + return "0x" + "".join(words) + + def _chain(self, expires, oracle=None, reserved=0): oracle = self.ORACLE if oracle is None else oracle self.oracle_calls = [] @@ -604,65 +605,38 @@ def eth_call(to, data): return "0x" + snrc.encode_uint(self.GRACE) if data.startswith(snrc.selector("reservedNames(bytes32)")): return "0x" + snrc.encode_uint(reserved) - if data.startswith(snrc.selector("prices()")): - self.assertEqual(to, self.CONTROLLER) - return "0x" + snrc.encode_uint(int(oracle, 16)) if data.startswith(snrc.selector("minCharLength()")): self.assertEqual(to, self.CONTROLLER) return "0x" + snrc.encode_uint(self.MIN_LENGTH) - self.oracle_calls.append(data[:10]) - self.assertEqual(to, oracle) - for n, cents in self.PRICES.items(): - if data.startswith(snrc.selector(f"price{n}Letter()")): - rate = cents * snrc.ATTO_PER_CENT // snrc.SECONDS_PER_YEAR - return "0x" + snrc.encode_uint(rate) - if data.startswith(snrc.selector("startPremium()")): - return "0x" + snrc.encode_uint(self.START_PREMIUM) - if data.startswith(snrc.selector("endValue()")): - return "0x" + snrc.encode_uint(self.START_PREMIUM >> total_days) + if data.startswith(snrc.selector("prices()")): + if to == self.CONTROLLER: + return "0x" + snrc.encode_uint(int(oracle, 16)) + self.oracle_calls.append(data[:10]) + self.assertEqual(to, oracle) + return self._prices_return() return self.fail("unexpected call " + data[:10]) return eth_call - def _lapsed(self, days_into_auction): - """An expiry whose grace ended `days_into_auction` days ago. The extra + def _lapsed(self, days_past_grace): + """An expiry whose grace ended `days_past_grace` days ago. The extra second clears the boundary, which counts as still in grace.""" - return self.now - self.GRACE - 1 - days_into_auction * 86400 - - def test_a_name_just_past_grace_is_expired_and_dates_the_auction(self): - expires = self._lapsed(0) - snrc.eth_call = self._chain(expires) - reg = snrc.name_status("acme.testing") - self.assertEqual(reg["status"], "expired") - self.assertEqual(reg["graceEnds"], expires + self.GRACE) - self.assertEqual( - reg["auctionUntil"], expires + self.GRACE + self.TOTAL_DAYS * 86400 - ) - - def test_the_window_lasts_as_long_as_the_premium_takes_to_decay(self): - expires = self._lapsed(0) - snrc.eth_call = self._chain(expires, total_days=10) - reg = snrc.name_status("acme.testing") - self.assertEqual(reg["auctionUntil"], expires + self.GRACE + 10 * 86400) + return self.now - self.GRACE - 1 - days_past_grace * 86400 - def test_the_prices_are_the_oracles_rates_in_cents_per_year(self): + def test_the_prices_are_the_oracles_cents_per_year(self): snrc.eth_call = self._chain(self._lapsed(0)) reg = snrc.name_status("acme.testing") - # 1 and 2 are below minCharLength; the 6-letter tier is the base price + # 1 and 2 are below minCharLength self.assertEqual(reg["rentPrices"], {3: 1600, 4: 800, 5: 500}) - self.assertEqual(reg["basePrice"], 200) + self.assertEqual(reg["basePrice"], self.BASE) self.assertEqual(reg["minLabelLength"], self.MIN_LENGTH) - def test_past_the_window_prices_are_back_to_normal(self): - snrc.eth_call = self._chain(self._lapsed(self.TOTAL_DAYS)) + def test_a_lapsed_name_costs_the_ordinary_price(self): + snrc.eth_call = self._chain(self._lapsed(0)) reg = snrc.name_status("acme.testing") self.assertEqual(reg["status"], "expired") self.assertIsNone(reg["auctionUntil"]) - def test_a_zero_day_window_switches_the_auction_off(self): - snrc.eth_call = self._chain(self._lapsed(0), total_days=0) - self.assertIsNone(snrc.name_status("acme.testing")["auctionUntil"]) - def test_a_controller_with_no_oracle_leaves_the_name_merely_expired(self): snrc.eth_call = self._chain(self._lapsed(0), oracle=snrc.ZERO_ADDR) self.assertEqual(snrc.name_status("acme.testing")["status"], "expired") @@ -673,8 +647,6 @@ def test_a_name_in_grace_never_reaches_the_oracle(self): self.assertEqual(self.oracle_calls, []) def test_the_oracle_curve_is_read_once_not_per_query(self): - """The curve changes only on a retune, so it is read once rather than - on every query.""" snrc.eth_call = self._chain(self._lapsed(1)) snrc.name_status("acme.testing") seen_first = len(self.oracle_calls) @@ -687,22 +659,12 @@ def test_a_reserved_lapsed_name_keeps_its_reservation(self): self.assertEqual(reg["status"], "expired") self.assertEqual(reg["reasonCode"], "trademark") - def test_resolve_reports_the_prices_and_the_auction_deadline(self): - expires = self._lapsed(1) - snrc.eth_call = self._chain(expires) - status, body = snrc.resolve("acme.testing") - self.assertEqual(status, 410) - self.assertEqual(body["status"], "expired") - self.assertEqual(body["basePrice"], 200) - self.assertEqual( - body["auctionUntil"], expires + self.GRACE + self.TOTAL_DAYS * 86400 - ) - - def test_an_expired_name_past_the_window_has_no_auction_deadline(self): - snrc.eth_call = self._chain(self._lapsed(self.TOTAL_DAYS)) + def test_resolve_reports_the_prices(self): + snrc.eth_call = self._chain(self._lapsed(1)) status, body = snrc.resolve("acme.testing") self.assertEqual(status, 410) self.assertEqual(body["status"], "expired") + self.assertEqual(body["basePrice"], self.BASE) self.assertIsNone(body["auctionUntil"]) def test_a_hashed_query_is_priced_too(self): @@ -711,8 +673,7 @@ def test_a_hashed_query_is_priced_too(self): snrc.eth_call = self._chain(self._lapsed(0)) _, body = snrc.resolve(hashed + ".testing") self.assertEqual(body["status"], "expired") - self.assertEqual(body["basePrice"], 200) - + self.assertEqual(body["basePrice"], self.BASE) class ErrorCodeTests(unittest.TestCase): From be35dbc9cb4ae87907fd0d148497d7d8163b25bb Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 9 Sep 2026 11:50:21 +0200 Subject: [PATCH 26/27] fix reverse compatibility with .testing mainnet --- scripts/resolver/service/snrc-resolve.py | 45 +++++++++++-- scripts/resolver/service/test_snrc_resolve.py | 63 +++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 00e9a6e38b..7261d0967e 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -257,22 +257,55 @@ def read_pricing_params(tld: str): return None +SECONDS_PER_YEAR = 31536000 +ATTO_PER_CENT = 10**16 + + def read_oracle_prices(controller: str, oracle: str): - """The oracle keeps the curve in US cents per year, which is the unit the - SMP protocol carries, so nothing is converted here.""" - base, tiers = decode_prices(eth_call(oracle, selector("prices()"))) + """SimplexPriceOracle keeps the curve in US cents per year, the unit the SMP + protocol carries. An ENS-shaped oracle prices in attoUSD per second and + charges a premium on lapsed names that it does not expose, so a quote from + it is only safe for a name that was never registered.""" + try: + base, tiers = decode_prices(eth_call(oracle, selector("prices()"))) + premium_unknown = False + except RuntimeError: + base, tiers = decode_letter_prices(oracle) + premium_unknown = True min_len = decode_uint(eth_call(controller, selector("minCharLength()"))) return { # lengths the registry refuses are left out rather than priced at zero "rentPrices": {n: c for n, c in tiers.items() if n >= min_len}, "basePrice": base, "minLabelLength": min_len, + "_premiumUnknown": premium_unknown, + } + + +def decode_letter_prices(oracle: str): + """`price1Letter()`..`price6Letter()`, in attoUSD per second. Quotes round + up, so one is never below what the registry charges. Six and above is the + base price, as StablePriceOracle charges it.""" + tiers = { + n: ceil_div( + decode_uint(eth_call(oracle, selector(f"price{n}Letter()"))) * SECONDS_PER_YEAR, + ATTO_PER_CENT, + ) + for n in range(1, 7) } + return tiers.pop(6), tiers + + +def ceil_div(a: int, b: int) -> int: + return -(-a // b) def decode_prices(hex_data: str): """`prices()` returns the base price and the lengths priced differently.""" raw = bytes.fromhex(hex_data[2:] if hex_data.startswith("0x") else hex_data) + # a short answer is not a curve: decoding it would quote every name as free + if len(raw) < 96: + raise RuntimeError("prices(): short response") base = int.from_bytes(raw[:32], "big") at = int.from_bytes(raw[32:64], "big") count = int.from_bytes(raw[at:at + 32], "big") @@ -327,8 +360,10 @@ def name_status(name: str): } if status in ("unregistered", "expired"): pricing = pricing_params(tld) - if pricing: - out.update(pricing) + # a lapsed name may carry a premium this resolver cannot read, and a + # quote without it would be below what the registry charges + if pricing and not (status == "expired" and pricing["_premiumUnknown"]): + out.update({k: v for k, v in pricing.items() if not k.startswith("_")}) return out diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index cd4d060f84..fdc25b9a5a 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -676,6 +676,69 @@ def test_a_hashed_query_is_priced_too(self): self.assertEqual(body["basePrice"], self.BASE) +class EnsOracleTests(unittest.TestCase): + """.testing runs an ENS-shaped oracle: it prices in attoUSD per second and + charges a premium on lapsed names that it does not expose.""" + + REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" + ORACLE = "0x1e0c9a2b9d1a4c8f7b3e5d6a9c2f4b8e1d7a3c50" + GRACE = 90 * 86400 + MIN_LENGTH = 6 + + 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} + self.now = int(time.time()) + snrc.chain_now = lambda: self.now + snrc._constants.clear() + + def tearDown(self): + (snrc.REGISTRIES, snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now) = self._saved + + def _chain(self, expires, letter_cents=0): + def eth_call(to, data): + if data.startswith(snrc.selector("nameExpires(uint256)")): + return "0x" + snrc.encode_uint(expires) + if data.startswith(snrc.selector("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(self.GRACE) + if data.startswith(snrc.selector("reservedNames(bytes32)")): + return "0x" + snrc.encode_uint(0) + if data.startswith(snrc.selector("minCharLength()")): + return "0x" + snrc.encode_uint(self.MIN_LENGTH) + if data.startswith(snrc.selector("prices()")): + if to == self.CONTROLLER: + return "0x" + snrc.encode_uint(int(self.ORACLE, 16)) + raise RuntimeError("eth_call returned 0x") # no prices() on this oracle + for n in range(1, 7): + if data.startswith(snrc.selector(f"price{n}Letter()")): + rate = letter_cents * snrc.ATTO_PER_CENT // snrc.SECONDS_PER_YEAR + return "0x" + snrc.encode_uint(rate) + return self.fail("unexpected call " + data[:10]) + + return eth_call + + def test_a_never_registered_name_is_priced_from_the_letter_curve(self): + snrc.eth_call = self._chain(0) + reg = snrc.name_status("ghost.testing") + self.assertEqual(reg["status"], "unregistered") + self.assertEqual(reg["basePrice"], 0) + self.assertEqual(reg["minLabelLength"], self.MIN_LENGTH) + + def test_a_non_zero_letter_curve_converts_to_cents_per_year(self): + snrc.eth_call = self._chain(0, letter_cents=1200) + self.assertEqual(snrc.name_status("ghost.testing")["basePrice"], 1200) + + def test_a_lapsed_name_is_not_priced_because_the_premium_is_unreadable(self): + snrc.eth_call = self._chain(self.now - self.GRACE - 1) + reg = snrc.name_status("acme.testing") + self.assertEqual(reg["status"], "expired") + self.assertNotIn("basePrice", reg) + + class ErrorCodeTests(unittest.TestCase): REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" From 9db84e1f286aeac08dd323da29a372216721c33b Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 9 Sep 2026 12:19:09 +0200 Subject: [PATCH 27/27] fix more robustly --- scripts/resolver/service/snrc-resolve.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 7261d0967e..8ffa6a1e51 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -284,16 +284,19 @@ def read_oracle_prices(controller: str, oracle: str): def decode_letter_prices(oracle: str): """`price1Letter()`..`price6Letter()`, in attoUSD per second. Quotes round - up, so one is never below what the registry charges. Six and above is the - base price, as StablePriceOracle charges it.""" - tiers = { - n: ceil_div( - decode_uint(eth_call(oracle, selector(f"price{n}Letter()"))) * SECONDS_PER_YEAR, - ATTO_PER_CENT, - ) - for n in range(1, 7) - } - return tiers.pop(6), tiers + up, so one is never below what the registry charges. An oracle built before + the six-letter tier stops at five, and charges its highest tier for anything + longer, which is what basePrice means here.""" + tiers = {} + for n in range(1, 7): + try: + rate = decode_uint(eth_call(oracle, selector(f"price{n}Letter()"))) + except RuntimeError: + if n <= 5: + raise + break + tiers[n] = ceil_div(rate * SECONDS_PER_YEAR, ATTO_PER_CENT) + return tiers.pop(max(tiers)), tiers def ceil_div(a: int, b: int) -> int: