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)