From ccdb4c88f48b23cdadf6f05d06ded948705fca66 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 14:16:59 +0200 Subject: [PATCH] fix resolver boundary: status before body, cover /v2, drop dead field httpGet read the response body before checking the status, so an oversized error page surfaced as a transient "response too large" instead of the authoritative status. Reverting it to its previous shape restores that and removes the status test both callers had been re-deriving. registration() had no tests at all, though it is the endpoint SMP v22 consumes. RegistrationV2Tests covers the three answer shapes, the error paths and the exact key set of each, which is the wire contract. auctionUntil was always None with no consumer. The spec claimed a reason word travels unchanged; a resolver can only send a word it has, and SNRC's registry records a number. --- protocol/simplex-messaging.md | 7 +- scripts/resolver/service/snrc-resolve.py | 2 - scripts/resolver/service/test_snrc_resolve.py | 172 +++++++++++++++++- .../Messaging/Server/Names/HttpResolver.hs | 26 ++- 4 files changed, 184 insertions(+), 23 deletions(-) diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index fbff6566f..c864889fd 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1621,9 +1621,10 @@ 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 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. +registered. A router passes the word through unchanged, which is why the set is +open rather than an enumeration. A resolver can only send a word it has, though: +SNRC's registry records a numeric reason, so a code added after the resolver +arrives as `unknown` and only the reservation survives. `nameRecord` MUST be a UTF-8 JSON object with the following schema: diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 6ba95b85e..e9bafc66d 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -328,7 +328,6 @@ def name_status(name: str): "graceEnds": None, "reasonCode": None, "reason": None, - "auctionUntil": None, } # nameExpires and reservedNames are keyed on uint256(keccak(label)). @@ -356,7 +355,6 @@ 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, - "auctionUntil": None, } if status in ("unregistered", "expired"): pricing = pricing_params(tld) diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index eed40f0c0..7a5111785 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -200,7 +200,6 @@ class NameStatusTests(unittest.TestCase): "graceEnds": grace_ends, "reasonCode": None, "reason": None, - "auctionUntil": None, } def setUp(self): @@ -342,7 +341,6 @@ class NameStatusTests(unittest.TestCase): "graceEnds", "reasonCode", "reason", - "auctionUntil", } snrc.eth_call = self._expiry(0) self.assertEqual(set(snrc.name_status("alice.testing")), keys) @@ -616,7 +614,6 @@ class PricingTests(unittest.TestCase): 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_controller_with_no_oracle_leaves_the_name_merely_expired(self): snrc.eth_call = self._chain(self._lapsed(0), oracle=snrc.ZERO_ADDR) @@ -646,7 +643,6 @@ class PricingTests(unittest.TestCase): 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): # keccak-256("acme") @@ -813,5 +809,173 @@ class ErrorCodeTests(unittest.TestCase): self.assertNotIn("kEy8", body["message"]) +class RegistrationV2Tests(unittest.TestCase): + """`/v2/resolve` answers with the SMP protocol's NameRegistration, which the + relay decodes as is. The key names are the wire contract, so they are pinned + here: renaming one without the Haskell side is a silent break.""" + + REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" + ORACLE = "0x1e0c9a2b9d1a4c8f7b3e5d6a9c2f4b8e1d7a3c50" + OWNER = "0xd83bd7e0e6b8a4c1f2593a7b0c4e8d1a6f9b2c37" + + GRACE = 90 * 86400 + BASE = 200 + EXCEPTIONS = {1: 64000, 2: 16000, 3: 1600, 4: 800, 5: 500} + MIN_LENGTH = 3 + + 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 _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, reserved=0, oracle=None): + """The registry answers a zero resolver, so name_record returns the + empty record a registered name still has.""" + oracle = self.ORACLE if oracle is None else oracle + + 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("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(oracle, 16)) + return self._prices_return() + if data.startswith(snrc.selector("resolver(bytes32)")): + return "0x" + snrc.encode_uint(0) + if data.startswith(snrc.selector("owner(bytes32)")): + return "0x" + snrc.encode_uint(int(self.OWNER, 16)) + return self.fail("unexpected call " + data[:10]) + + return eth_call + + def _lapsed(self, days_past_grace): + return self.now - self.GRACE - 1 - days_past_grace * 86400 + + def test_a_live_name_is_registered_and_carries_its_record(self): + expires = self.now + 3600 + snrc.eth_call = self._chain(expires) + status, body = snrc.registration("acme.testing") + self.assertEqual(status, 200) + self.assertEqual(body["type"], "registered") + self.assertEqual(body["expires"], expires) + self.assertEqual(body["graceUntil"], expires + self.GRACE) + self.assertIsNone(body["reservedReason"]) + self.assertEqual(body["nameRecord"]["name"], "acme.testing") + + def test_a_name_in_grace_is_still_registered(self): + expires = self.now - 3600 + snrc.eth_call = self._chain(expires) + _, body = snrc.registration("acme.testing") + self.assertEqual(body["type"], "registered") + self.assertGreater(body["graceUntil"], self.now) + + def test_a_registered_name_that_is_held_back_says_so(self): + snrc.eth_call = self._chain(self.now + 3600, reserved=1) + _, body = snrc.registration("acme.testing") + self.assertEqual(body["type"], "registered") + self.assertEqual(body["reservedReason"], "internal") + + def test_an_unregistered_name_is_available_with_its_pricing(self): + snrc.eth_call = self._chain(0) + status, body = snrc.registration("acme.testing") + self.assertEqual(status, 200) + self.assertEqual(body["type"], "available") + # lengths below minCharLength are unregistrable, so they are not priced + self.assertEqual(body["pricing"]["registrationPrices"], {3: 1600, 4: 800, 5: 500}) + self.assertEqual(body["pricing"]["basePrice"], self.BASE) + self.assertEqual(body["pricing"]["minLabelLength"], self.MIN_LENGTH) + + def test_a_lapsed_name_is_available_at_the_ordinary_price(self): + snrc.eth_call = self._chain(self._lapsed(1)) + _, body = snrc.registration("acme.testing") + self.assertEqual(body["type"], "available") + self.assertEqual(body["pricing"]["basePrice"], self.BASE) + + def test_a_held_back_name_is_reserved_and_is_never_priced(self): + snrc.eth_call = self._chain(0, reserved=2) + status, body = snrc.registration("acme.testing") + self.assertEqual(status, 200) + self.assertEqual(body["type"], "reserved") + self.assertEqual(body["reservedReason"], "trademark") + self.assertNotIn("pricing", body) + + def test_a_hashed_query_answers_the_same_as_the_name(self): + # keccak-256("acme") + hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]" + snrc.eth_call = self._chain(0) + _, by_name = snrc.registration("acme.testing") + _, by_hash = snrc.registration(hashed + ".testing") + self.assertEqual(by_name, by_hash) + + def test_an_unconfigured_tld_is_refused_not_answered(self): + snrc.REGISTRIES = {"testing": ""} + snrc.eth_call = lambda *a: self.fail("must not reach the chain") + status, body = snrc.registration("acme.testing") + self.assertEqual(status, 400) + self.assertEqual(body["error"], "tldNotConfigured") + + def test_no_price_oracle_is_an_error_not_a_free_name(self): + snrc.eth_call = self._chain(0, oracle=snrc.ZERO_ADDR) + status, body = snrc.registration("acme.testing") + self.assertEqual(status, 502) + self.assertEqual(body["error"], "noPriceOracle") + + def test_a_status_it_cannot_read_is_an_error_not_a_registration(self): + snrc.REGISTRARS = {"testing": ""} + snrc.eth_call = self._chain(0) + status, body = snrc.registration("acme.testing") + self.assertEqual(status, 502) + self.assertEqual(body["error"], "unknown") + + def test_each_answer_carries_exactly_its_own_fields(self): + """The relay decodes by these names; an extra or missing one is a break.""" + cases = { + "registered": (self._chain(self.now + 3600), + {"type", "expires", "graceUntil", "reservedReason", "nameRecord"}), + "available": (self._chain(0), {"type", "pricing"}), + "reserved": (self._chain(0, reserved=1), {"type", "reservedReason"}), + } + for expected_type, (chain, keys) in cases.items(): + with self.subTest(type=expected_type): + snrc.eth_call = chain + snrc._constants.clear() + _, body = snrc.registration("acme.testing") + self.assertEqual(body["type"], expected_type) + self.assertEqual(set(body), keys) + if __name__ == "__main__": unittest.main() diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index d079fb081..1ed57a6b3 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -127,23 +127,18 @@ authHeader = \case -- 3986) so slashes and punctuation cannot alter the path. resolveHttp :: ResolverEnv -> Text -> IO (Either ResolverError NameRegistration) resolveHttp env q = - (>>= registration) <$> httpGet env ("/v2/resolve/" <> B.unpack (urlEncode True (encodeUtf8 q))) - where - registration (status, bs) - | status < 400 = first InvalidJson (J.eitherDecode bs) - | otherwise = Left (HttpStatusErr status) + (>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict) + <$> httpGet env ("/v2/resolve/" <> B.unpack (urlEncode True (encodeUtf8 q))) -- | 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 = (>>= statusOk . fst) <$> httpGet env "/health" - where - statusOk status = if status >= 400 then Left (HttpStatusErr status) else Right () +healthHttp env = (() <$) <$> httpGet env "/health" --- | 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)) +-- | 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) httpGet ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseBytes} path = do req0 <- parseRequest (baseUrl <> path) let req = @@ -154,6 +149,9 @@ httpGet ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseBytes} } 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 Right (status, bs) + 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 pure (either (Left . HttpFailure) id result)