From 5c480277d6bed73b65efe54c5cd2f2b5afe0046d Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sun, 6 Sep 2026 18:35:02 +0200 Subject: [PATCH] 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 94298a897..43da9a3b9 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 83eee825a..e4f0dfcfc 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 12917c0ae..0bc9934ea 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -115,6 +115,8 @@ RESERVED_REASONS = { 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 7bc7f9ad0..0e7987d11 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -485,11 +485,12 @@ class ReservedReasonTests(unittest.TestCase): 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 @@ class AuctionTests(unittest.TestCase): 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 d85c06bba..a533204ff 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 777662d2b..7ac3a0a18 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" $