diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index 52a3e90a0..a80d211b2 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1526,12 +1526,24 @@ fact that this router cannot resolve, so iterating past it is safe. `RNAME` answers both what a name resolves to and whether it can be registered. ```abnf -rname = %s"RNAME" SP registration +rname = %s"RNAME" SP nameResponse ``` -`registration` is a UTF-8 JSON object consuming the remainder of the -transmission. Its `type` selects which of the three answers it is. Money is US -cents, times are seconds since the Unix epoch, and lengths are characters. +`nameResponse` is a UTF-8 JSON object consuming the remainder of the +transmission: the `registration` below, and `lastBlockTs`, the block timestamp the +registry was read at. Money is US cents, times are seconds since the Unix epoch, and lengths are +characters. + +| Field | JSON type | Constraints | +|---|---|---| +| `lastBlockTs` | number | block timestamp the answer was read at. Absent only from a v20/v21 router, which sent the record alone | +| `registration` | object | below | + +A router reads the registry through a node of its own, which can lag. A name +registered or changed after `lastBlockTs` still reads as it did before, so a client +MUST NOT treat an answer as current without checking it. + +`registration`'s `type` selects which of the three answers it is. | `type` | Meaning | |---|---| diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index e1570d60a..f8f5dde8b 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -62,9 +62,14 @@ curl -s -X POST http://127.0.0.1:8545 \ **2. resolver is healthy:** ```sh curl -s http://127.0.0.1:8000/health | jq -# → {"ok": true, "rpc": "http://reth:8545", "registries": {"testing": "0x…", "simplex": ""}} +# → {"ok": true, "rpc": "http://reth:8545", "registries": {"testing": "0x…", "simplex": ""}, +# "blockNumber": 23400000, "chainLagSeconds": 12} ``` +`chainLagSeconds` is how far the node is behind the wall clock. A resolver that +is reachable and answering can still be hours behind, and every name it reports +is that stale. `null` means the node could not be reached. + **3. resolver resolves a live name** (`foobar.testing` is a populated test name): ```sh curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq @@ -129,8 +134,10 @@ its own shape does: ### v2: `/v2/resolve/` -The body is the SMP protocol's `NameRegistration`, which the router decodes as -is and forwards; translating the registry's model to it is this resolver's job. +The body is the SMP protocol's `NameResponse`, which the router decodes as is +and forwards; translating the registry's model to it is this resolver's job. It +is the registration below, plus `lastBlockTs`, the timestamp of the block it was read +at: a node that lags answers with names it has not seen registered yet. Its `type` is `registered`, `available` or `reserved`, and the fields each one carries are specified once, in the **Name response** section of [`protocol/simplex-messaging.md`](../../protocol/simplex-messaging.md). It is diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 7925c2ff3..42881a2a8 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -67,6 +67,7 @@ import json import os import sys +import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import unquote, urlparse from urllib.request import Request, urlopen @@ -183,6 +184,19 @@ def node_of(name: str) -> bytes: # ---------- Registration status ---------- +def head_block(): + """How far behind the node is. Unlike expiry, this is the one thing that has + to be measured against the host clock: a node that stops still has a block.""" + try: + block = rpc("eth_getBlockByNumber", ["latest", False]) + return { + "blockNumber": decode_uint(block["number"]), + "chainLagSeconds": int(time.time()) - decode_uint(block["timestamp"]), + } + except Exception: + return {"blockNumber": None, "chainLagSeconds": None} + + def chain_now() -> int: """Expiry is compared against the block timestamp, never the host clock.""" block = rpc("eth_getBlockByNumber", ["latest", False]) @@ -302,6 +316,8 @@ def name_status(name: str): if not registrar or len(labels) < 2: return { "status": "unknown", + # nothing was read, so there is no block to report + "lastBlockTs": None, "expires": None, "graceEnds": None, "reasonCode": None, @@ -315,12 +331,9 @@ def name_status(name: str): expires = decode_uint( eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token)) ) - if expires == 0: - status, grace, now = "unregistered", 0, 0 - else: - grace = grace_period(registrar) - now = chain_now() - status = expiry_status(expires, grace, now) + grace = grace_period(registrar) if expires else 0 + now = chain_now() + status = expiry_status(expires, grace, now) # A reservation is orthogonal to the registration: a registered name can be # held back too. @@ -329,6 +342,8 @@ def name_status(name: str): out = { "status": status, + # the block this was read at + "lastBlockTs": now, "expires": expires or None, "graceEnds": (expires + grace) if expires else None, "reasonCode": reason[0] if reason else None, @@ -708,6 +723,11 @@ def name_record(name: str): return rec +def name_response(reg, registration_body): + """The SMP protocol's NameResponse: the registration and the block read at.""" + return 200, {"lastBlockTs": reg["lastBlockTs"], "registration": registration_body} + + def registration(name: str): """The SMP protocol's NameRegistration, which the relay decodes as is. Translating the contract's model to it is this resolver's job.""" @@ -732,26 +752,26 @@ def registration(name: str): if pricing: reg.update({k: v for k, v in pricing.items() if not k.startswith("_")}) else: - return 200, { + return name_response(reg, { "type": "registered", "expires": reg["expires"], "graceUntil": reg["graceEnds"], "reservedReason_": reg["reasonCode"], "nameRecord": rec, - } + }) if reg["reasonCode"]: - return 200, {"type": "reserved", "reservedReason": reg["reasonCode"]} + return name_response(reg, {"type": "reserved", "reservedReason": reg["reasonCode"]}) if status in ("unregistered", "expired"): if "basePrice" not in reg: return 502, {"name": name, "error": "noPriceOracle"} - return 200, { + return name_response(reg, { "type": "available", "pricing": { "registrationPrices": reg["registrationPrices"], "basePrice": reg["basePrice"], "minLabelLength": reg["minLabelLength"], }, - } + }) return 502, {"name": name, "error": status} @@ -863,10 +883,7 @@ def do_GET(self): # noqa: N802 - http.server contract parts = [unquote(p) for p in path.split("/") if p] if parts == ["health"]: - self._respond( - 200, - {"ok": True, "rpc": RPC, "registries": REGISTRIES}, - ) + self._respond(200, {"ok": True, "rpc": RPC, "registries": REGISTRIES, **head_block()}) return if len(parts) == 3 and parts[0] == "v2" and parts[1] == "resolve": diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index ac142e266..4b0555ed1 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -20,6 +20,12 @@ _SPEC.loader.exec_module(snrc) +def registration(name): + """registration() answers a NameResponse; most tests assert what is in it.""" + status, body = snrc.registration(name) + return status, (body["registration"] if status == 200 else body) + + class SplitLinksTests(unittest.TestCase): """`split_links` decodes the multi-URL convention for simplex.contact / simplex.channel text records. Reuses the same rule the dApp's @@ -191,10 +197,11 @@ def eth_call(to, data): return eth_call - def _keys(self, status, expires, grace_ends): + def _keys(self, status, expires, grace_ends, read_at=-1): """Every branch answers with the same keys; only some carry values.""" return { "status": status, + "lastBlockTs": self.now if read_at == -1 else read_at, "expires": expires, "graceEnds": grace_ends, "reasonCode": None, @@ -212,7 +219,8 @@ def setUp(self): snrc.REGISTRARS = {"testing": self.REGISTRAR} # Expiry alone; ReservedTests covers a configured controller. snrc.CONTROLLERS = {"testing": ""} - snrc.chain_now = lambda: int(time.time()) + self.now = int(time.time()) + snrc.chain_now = lambda: self.now def tearDown(self): ( @@ -315,12 +323,13 @@ 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"), - self._keys("unknown", None, None), + self._keys("unknown", None, None, read_at=None), ) def test_every_branch_returns_the_same_keys(self): keys = { "status", + "lastBlockTs", "expires", "graceEnds", "reasonCode", @@ -871,7 +880,7 @@ def _lapsed(self, days_past_grace): 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") + status, body = registration("acme.testing") self.assertEqual(status, 200) self.assertEqual(body["type"], "registered") self.assertEqual(body["expires"], expires) @@ -882,19 +891,19 @@ def test_a_live_name_is_registered_and_carries_its_record(self): 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") + _, body = 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") + _, body = 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") + status, body = registration("acme.testing") self.assertEqual(status, 200) self.assertEqual(body["type"], "available") # lengths below minCharLength are unregistrable, so they are not priced @@ -904,13 +913,13 @@ def test_an_unregistered_name_is_available_with_its_pricing(self): 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") + _, body = 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") + status, body = registration("acme.testing") self.assertEqual(status, 200) self.assertEqual(body["type"], "reserved") self.assertEqual(body["reservedReason"], "trademark") @@ -920,27 +929,27 @@ 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") + _, by_name = registration("acme.testing") + _, by_hash = 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") + status, body = 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") + status, body = 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") + status, body = registration("acme.testing") self.assertEqual(status, 502) self.assertEqual(body["error"], "unknown") @@ -955,7 +964,7 @@ def test_each_answer_carries_exactly_its_own_fields(self): for expected_type, (chain, keys) in cases.items(): with self.subTest(type=expected_type): snrc.eth_call = chain - _, body = snrc.registration("acme.testing") + _, body = registration("acme.testing") self.assertEqual(body["type"], expected_type) self.assertEqual(set(body), keys) def test_a_hashed_query_the_registrar_cannot_name_is_refused(self): @@ -963,20 +972,20 @@ def test_a_hashed_query_the_registrar_cannot_name_is_refused(self): with a record the registrar could not name would only fail there.""" hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]" snrc.eth_call = self._chain(self.now + 3600, label=b"") - status, body = snrc.registration(hashed + ".testing") + status, body = registration(hashed + ".testing") self.assertEqual(status, 502) self.assertEqual(body["error"], "labelNotRecorded") def test_a_hashed_query_is_answered_with_the_name_the_registrar_recorded(self): hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]" snrc.eth_call = self._chain(self.now + 3600) - status, body = snrc.registration(hashed + ".testing") + status, body = registration(hashed + ".testing") self.assertEqual(status, 200) self.assertEqual(body["nameRecord"]["name"], "acme.testing") def test_a_subname_that_exists_is_registered_with_its_parents_dates(self): expires = self.now + 3600 snrc.eth_call = self._chain(expires) - status, body = snrc.registration("sub.acme.testing") + status, body = registration("sub.acme.testing") self.assertEqual(status, 200) self.assertEqual(body["type"], "registered") self.assertEqual(body["expires"], expires) @@ -986,15 +995,16 @@ def test_a_subname_nobody_created_is_not_registered(self): """The registrar only tracks 2LDs, so the parent's registration says nothing about a child that was never created: its node has no owner.""" snrc.eth_call = self._chain(self.now + 3600, owner=snrc.ZERO_ADDR) - status, body = snrc.registration("sub.acme.testing") + status, body = registration("sub.acme.testing") self.assertEqual(status, 200) self.assertEqual(body["type"], "available") def test_a_2ld_is_not_subject_to_the_owner_check(self): """Only a subname can be absent under a registered parent.""" snrc.eth_call = self._chain(self.now + 3600, owner=snrc.ZERO_ADDR) - _, body = snrc.registration("acme.testing") + _, body = registration("acme.testing") self.assertEqual(body["type"], "registered") + def test_v1_does_not_report_an_uncreated_subname_as_registered(self): """v1 has no availability, so the only honest answer is not-found. The 2LD case is untouched: a registered name with no resolver still resolves.""" @@ -1009,5 +1019,21 @@ def test_v1_still_resolves_a_2ld_with_no_resolver_set(self): self.assertEqual(status, 200) self.assertEqual(body["resolver"], snrc.ZERO_ADDR) + def test_the_answer_says_which_block_it_was_read_at(self): + """The resolver is only as current as its node. Without this a client + cannot tell an answer that predates its own registration.""" + snrc.eth_call = self._chain(self.now + 3600) + _, res = snrc.registration("acme.testing") + self.assertEqual(res["lastBlockTs"], self.now) + self.assertEqual(res["registration"]["type"], "registered") + + def test_an_available_name_says_so_too(self): + """This is the path that reads no block otherwise, and the one where + staleness matters most: the name may already be taken.""" + snrc.eth_call = self._chain(0) + _, res = snrc.registration("acme.testing") + self.assertEqual(res["lastBlockTs"], self.now) + self.assertEqual(res["registration"]["type"], "available") + if __name__ == "__main__": unittest.main() diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index b1e791d6d..e9a7379b4 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -228,7 +228,7 @@ import Simplex.Messaging.Protocol ErrorType (AUTH), MsgBody, MsgFlags (..), - NameRegistration, + NameResponse, NtfServer, ProtoServerWithAuth (..), ProtocolServer (..), @@ -461,7 +461,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 NameRegistration +resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameResponse resolveSimplexName c nm userId domain = withAgentEnv c $ resolveSimplexName' c nm userId domain {-# INLINE resolveSimplexName #-} @@ -1270,7 +1270,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 NameRegistration +resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResponse 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 d34fb55b9..8cbb4c17b 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -272,7 +272,7 @@ import Simplex.Messaging.Protocol NetworkError (..), MsgFlags (..), MsgId, - NameRegistration, + NameResponse, NtfServer, NtfServerWithAuth, ProtoServer, @@ -2022,7 +2022,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 NameRegistration +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 diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 95bad33a2..f8f1a4cb9 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -1054,7 +1054,7 @@ 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. -proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameRegistration) +proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameResponse) proxyResolveName c nm proxiedRelay name | prVersion proxiedRelay >= namesSMPVersion = proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (NQDomain name)) >>= \case @@ -1068,7 +1068,7 @@ proxyResolveName c nm proxiedRelay name -- proxy fallback in the agent. RSLV requires no entity ID or authorization -- (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 :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameResponse directResolveName c nm name | thVersion (thParams c) >= namesSMPVersion = sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (NQDomain name))) >>= \case @@ -1076,8 +1076,8 @@ directResolveName c nm name r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion -resolvedNameOrNotFound :: SimplexDomain -> NameRegistration -> Bool -resolvedNameOrNotFound d = \case +resolvedNameOrNotFound :: SimplexDomain -> NameResponse -> Bool +resolvedNameOrNotFound d NameResponse {registration} = case registration of NRRegistered {nameRecord} -> T.toLower (nrName nameRecord) == fullDomainName d _ -> True diff --git a/src/Simplex/Messaging/Names/Record.hs b/src/Simplex/Messaging/Names/Record.hs index 3d8d1e7c9..461bec16e 100644 --- a/src/Simplex/Messaging/Names/Record.hs +++ b/src/Simplex/Messaging/Names/Record.hs @@ -8,6 +8,7 @@ module Simplex.Messaging.Names.Record ( NameRecord (..), + NameResponse (..), NameRegistration (..), NamePricing (..), USDCents (..), @@ -63,6 +64,14 @@ newtype USDCents = USDCents Int64 deriving (Eq, Ord, Show) deriving newtype (ToJSON, FromJSON) +-- | What the registry holds for a name, and the block it was read at. +data NameResponse = NameResponse + { -- | absent only from a v20/v21 router, which sent the record alone + lastBlockTs :: Maybe SystemSeconds, + registration :: NameRegistration + } + deriving (Eq, Show) + -- | What the registry holds for a name. data NameRegistration = -- | Held by someone. Always carries a record, empty where none was set. @@ -131,3 +140,5 @@ $(JQ.deriveJSON defaultJSON ''NamePricing) -- taggedObjectJSON, not sumTypeJSON: this JSON is the RNAME payload and the -- resolver contract, so it must not vary with the swift build flag. $(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "NR") ''NameRegistration) + +$(JQ.deriveJSON defaultJSON ''NameResponse) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index afce76d8c..e2cbef3a4 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -81,6 +81,7 @@ module Simplex.Messaging.Protocol CommandError (..), ProxyError (..), NameQuery (..), + NameResponse (..), NameRegistration (..), NamePricing (..), USDCents (..), @@ -745,7 +746,7 @@ data BrokerMsg where ERR :: ErrorType -> BrokerMsg PONG :: BrokerMsg -- What the router knows about a SimpleX name. - RNAME :: NameRegistration -> BrokerMsg + RNAME :: NameResponse -> BrokerMsg deriving (Eq, Show) data RcvMessage = RcvMessage @@ -1994,9 +1995,9 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where | v < clientNoticesSMPVersion -> BLOCKED info {notice = Nothing} _ -> err PONG -> e PONG_ - RNAME reg - | v >= nameAvailSMPVersion -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode reg) - | otherwise -> case reg of + RNAME res + | v >= nameAvailSMPVersion -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode res) + | otherwise -> case registration res of NRRegistered {nameRecord} -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode nameRecord) _ -> e (ERR_, ' ', NAME NOT_FOUND) where @@ -2047,9 +2048,10 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where PONG_ -> pure PONG RNAME_ | v >= nameAvailSMPVersion -> fmap RNAME . J.eitherDecodeStrict . unTail <$?> _smpP - | otherwise -> fmap (RNAME . oldRegistration) . J.eitherDecodeStrict . unTail <$?> _smpP + | otherwise -> fmap (RNAME . oldResponse) . J.eitherDecodeStrict . unTail <$?> _smpP where - oldRegistration nameRecord = NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord} + oldResponse nameRecord = + NameResponse {lastBlockTs = Nothing, registration = NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord}} serviceRespP resp | v >= rcvServiceSMPVersion = resp <$> _smpP <*> smpP | otherwise = resp <$> _smpP <*> pure mempty diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index b6e73d48c..097cabbbe 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -1498,7 +1498,7 @@ client st <- asks (rslvStats . serverStats) (selector, msg) <- liftIO (resolveName nenv q) <&> \case - Right reg -> (if answered reg then rslvSucc else rslvNotFound, RNAME reg) + Right res -> (if answered (registration res) then rslvSucc else rslvNotFound, RNAME res) Left e -> (rslvResolverErrs, ERR $ NAME e) incStat (selector st) $> msg where diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 3ec1d7b5c..601519c56 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -22,7 +22,7 @@ import Data.Maybe (fromMaybe) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) import Simplex.Messaging.Encoding -import Simplex.Messaging.Protocol (NameErrorType (..), NameQuery, NameRegistration) +import Simplex.Messaging.Protocol (NameErrorType (..), NameQuery, NameResponse) import Simplex.Messaging.Server.Names.HttpResolver ( ResolverEnv, ResolverError (..), @@ -59,7 +59,7 @@ pingEndpoint :: NamesEnv -> IO (Either ResolverError ()) pingEndpoint NamesEnv {resolverEnv, config} = fromMaybe (Left ResolverTimeout) <$> timeout (resolverTimeoutMs config * 1000) (healthHttp resolverEnv) -resolveName :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameRegistration) +resolveName :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameResponse) resolveName env q = do r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetch env q)) case r of @@ -70,7 +70,7 @@ resolveName env q = do logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) pure (Left (RESOLVER "resolver error")) -fetch :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameRegistration) +fetch :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameResponse) fetch NamesEnv {resolverEnv} q = first mapResolverError <$> resolveHttp resolverEnv (decodeLatin1 $ smpEncode q) diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index 881fe664b..0f272bcf4 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -59,7 +59,7 @@ import qualified Network.HTTP.Client as HC import Network.HTTP.Client.TLS (tlsManagerSettings) import qualified Network.HTTP.Types as HT import Network.HTTP.Types.URI (urlEncode) -import Simplex.Messaging.Names.Record (NameRegistration) +import Simplex.Messaging.Names.Record (NameResponse) data RpcAuth = AuthBearer Text | AuthBasic Text Text @@ -112,7 +112,7 @@ authHeader = \case -- | The query is a name or a bracketed label hash, percent-encoded (every -- non-unreserved byte per RFC 3986) so it cannot alter the path. -resolveHttp :: ResolverEnv -> Text -> IO (Either ResolverError NameRegistration) +resolveHttp :: ResolverEnv -> Text -> IO (Either ResolverError NameResponse) resolveHttp env q = (>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict) <$> httpGet env ("/v2/resolve/" <> B.unpack (urlEncode True (encodeUtf8 q))) diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index b6f81a647..d8507194e 100644 --- a/tests/AgentTests/ResolveNameTests.hs +++ b/tests/AgentTests/ResolveNameTests.hs @@ -93,7 +93,7 @@ testAvailSuccess = withDirectResolver (status200, availableBody) $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of - Right (SMP.NRAvailable {}) -> pure () + Right SMP.NameResponse {registration = SMP.NRAvailable {}} -> pure () _ -> expectationFailure $ "expected Right NRAvailable, got: " <> show r -- | 404 is a resolver that predates /v2/resolve: no status from that endpoint @@ -159,5 +159,5 @@ testDirectSuccess = withDirectResolver (status200, registeredBody testNameRecord) $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of - Right (SMP.NRRegistered {nameRecord}) -> nameRecord `shouldBe` testNameRecord + Right SMP.NameResponse {registration = SMP.NRRegistered {nameRecord}} -> nameRecord `shouldBe` testNameRecord _ -> expectationFailure $ "expected Right NRRegistered, got: " <> show r diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index dbe188811..9263b6953 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -26,7 +26,7 @@ import SMPClient import Simplex.Messaging.Client import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (strDecode) -import SMPNamesTests (availableBody, registeredBody, reservedBody, testNameRecord, testPricing) +import SMPNamesTests (availableBody, registeredBody, reservedBody, resolved, testNameRecord, testPricing) import Simplex.Messaging.Protocol ( BrokerMsg (..), Cmd (..), @@ -35,6 +35,7 @@ import Simplex.Messaging.Protocol ErrorType (..), NameQuery (..), NameRegistration (..), + NameResponse (..), NameErrorType (..), NameReservedReason (..), SParty (..), @@ -140,7 +141,7 @@ testRslvVersion = Left (PCETransportError TEVersion) -> pure () _ -> expectationFailure $ "expected Left (PCETransportError TEVersion), got: " <> show r -forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameRegistration)) +forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameResponse)) forwardedResolveAlice = do g <- C.newRandom ts <- getCurrentTime @@ -163,7 +164,7 @@ testRslvForwardedSuccess :: IO () testRslvForwardedSuccess = withProxyAndResolver (status200, registeredBody testNameRecord) $ forwardedResolveAlice >>= \r -> case r of - Right (Right NRRegistered {nameRecord}) -> nameRecord `shouldBe` testNameRecord + Right (Right NameResponse {registration = NRRegistered {nameRecord}}) -> nameRecord `shouldBe` testNameRecord _ -> expectationFailure $ "expected Right (Right NRRegistered), got: " <> show r testRslvSuccess :: IO () @@ -173,7 +174,7 @@ testRslvSuccess = (corrId, _entId, resp) <- sendRslv h "rs07" (domain "alice.simplex") corrId `shouldBe` CorrId "rs07" case resp of - Right (RNAME NRRegistered {nameRecord}) -> nameRecord `shouldBe` testNameRecord + Right (RNAME NameResponse {registration = NRRegistered {nameRecord}}) -> nameRecord `shouldBe` testNameRecord _ -> expectationFailure $ "expected Right (RNAME NRRegistered), got: " <> show resp testRslvAvailable :: IO () @@ -182,14 +183,14 @@ testRslvAvailable = testSMPClient @TLS $ \h -> do (corrId, _entId, resp) <- sendRslv h "na01" (domain "ghost.simplex") corrId `shouldBe` CorrId "na01" - resp `shouldBe` Right (RNAME (NRAvailable testPricing)) + resp `shouldBe` Right (RNAME (resolved (NRAvailable testPricing))) testRslvReserved :: IO () testRslvReserved = withResolverServer (status200, reservedBody) $ testSMPClient @TLS $ \h -> do (_, _, resp) <- sendRslv h "na03" (domain "acme.simplex") - resp `shouldBe` Right (RNAME (NRReserved NRRTrademark)) + resp `shouldBe` Right (RNAME (resolved (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. @@ -209,7 +210,7 @@ testRslvOldClientRecord = withResolverServer (status200, registeredBody testNameRecord) $ do pc <- oldClient r <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) - r `shouldBe` NRRegistered Nothing Nothing Nothing testNameRecord + r `shouldBe` NameResponse Nothing (NRRegistered Nothing Nothing Nothing testNameRecord) testRslvOldClientNotFound :: IO () testRslvOldClientNotFound = @@ -224,7 +225,7 @@ testRslvForwardedAvailable :: IO () testRslvForwardedAvailable = withProxyAndResolver (status200, availableBody) $ forwardedResolveAlice >>= \r -> case r of - Right (Right (NRAvailable pricing)) -> pricing `shouldBe` testPricing + Right (Right NameResponse {registration = NRAvailable {pricing}}) -> pricing `shouldBe` testPricing _ -> expectationFailure $ "expected Right (Right NRAvailable), got: " <> show r -- keccak-256("alice"), the registry key @@ -253,7 +254,7 @@ testRslvSendsTheHash = resolvePaths reqs `shouldReturn` [["v2", "resolve", aliceHash <> ".simplex"]] -- the client never sent the name, and the record still names it case r of - NRRegistered {nameRecord} -> SMP.nrName nameRecord `shouldBe` "alice.simplex" + NameResponse {registration = NRRegistered {nameRecord}} -> SMP.nrName nameRecord `shouldBe` "alice.simplex" _ -> expectationFailure $ "expected NRRegistered, got: " <> show r testSubnameKeepsItsLabels :: IO () diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index d2d2a2c5d..7f28a0b34 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -3,7 +3,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} -module SMPNamesTests (smpNamesTests, testNameRecord, testPricing, registeredBody, availableBody, reservedBody) where +module SMPNamesTests (smpNamesTests, testNameRecord, testPricing, registeredBody, availableBody, reservedBody, responseBody, resolved) where import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B @@ -18,7 +18,7 @@ import Network.HTTP.Types (status200, status400, status404, status500, status502 import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed) import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Encoding.String (strDecode) -import Simplex.Messaging.Protocol (Command (..), ErrorType (..), NameErrorType (..), NamePricing (..), NameQuery (..), NameRecord (..), NameRegistration (..), NameReservedReason (..), ProtocolEncoding (..), USDCents (..)) +import Simplex.Messaging.Protocol (Command (..), ErrorType (..), NameErrorType (..), NamePricing (..), NameQuery (..), NameRecord (..), NameRegistration (..), NameResponse (..), NameReservedReason (..), ProtocolEncoding (..), USDCents (..)) import Simplex.Messaging.Server.Main (validateUrl) import Simplex.Messaging.Server.Names ( NamesConfig (..), @@ -54,13 +54,23 @@ testNameRecord = -- from the Haskell value: the literal JSON is the contract with the resolver. registeredBody :: NameRecord -> LB.ByteString registeredBody nameRec = - "{\"type\":\"registered\",\"expires\":1813853483,\"graceUntil\":1821629483,\"reservedReason_\":null,\"nameRecord\":" <> J.encode nameRec <> "}" + responseBody $ "{\"type\":\"registered\",\"expires\":1813853483,\"graceUntil\":1821629483,\"reservedReason_\":null,\"nameRecord\":" <> J.encode nameRec <> "}" availableBody :: LB.ByteString -availableBody = "{\"type\":\"available\",\"pricing\":{\"registrationPrices\":{\"3\":12793,\"4\":3198},\"basePrice\":100,\"minLabelLength\":3}}" +availableBody = responseBody "{\"type\":\"available\",\"pricing\":{\"registrationPrices\":{\"3\":12793,\"4\":3198},\"basePrice\":100,\"minLabelLength\":3}}" reservedBody :: LB.ByteString -reservedBody = "{\"type\":\"reserved\",\"reservedReason\":\"trademark\"}" +reservedBody = responseBody "{\"type\":\"reserved\",\"reservedReason\":\"trademark\"}" + +-- | The registration, and the block the resolver read it at. +responseBody :: LB.ByteString -> LB.ByteString +responseBody reg = "{\"lastBlockTs\":" <> testReadAt <> ",\"registration\":" <> reg <> "}" + +testReadAt :: LB.ByteString +testReadAt = "1813000000" + +resolved :: NameRegistration -> NameResponse +resolved registration = NameResponse {lastBlockTs = Just (RoundedSystemTime 1813000000), registration} -- | What `registeredBody testNameRecord` resolves to. registeredAlice :: NameRegistration @@ -148,17 +158,17 @@ availabilitySpec = do -- one lookup answers what the name points to, whether it can be taken, and -- whether it is held back it "a registered name answers with its record and dates" $ - answers (registeredBody testNameRecord) registeredAlice + answers (registeredBody testNameRecord) (resolved registeredAlice) it "a registered name can be held back too" $ - answers heldBackBody $ + answers heldBackBody . resolved $ NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Just NRRInternal, nameRecord = testNameRecord} it "an unregistered name answers with the price" $ - answers availableBody NRAvailable {pricing = testPricing} + answers availableBody (resolved NRAvailable {pricing = testPricing}) it "reserved carries the reason and no price" $ - answers reservedBody (NRReserved NRRTrademark) + answers reservedBody (resolved $ NRReserved NRRTrademark) -- losing the reservation would offer a name that cannot be registered it "a reason from a later version still reserves the name" $ - answers "{\"type\":\"reserved\",\"reservedReason\":\"seasonal\"}" (NRReserved (NRRUnknown "seasonal")) + answers (responseBody "{\"type\":\"reserved\",\"reservedReason\":\"seasonal\"}") (resolved $ NRReserved (NRRUnknown "seasonal")) -- RNAME carries the registration as JSON, so that is the encoding to hold it "every registration survives the wire" $ mapM_ @@ -177,7 +187,7 @@ availabilitySpec = do J.encode NRRTrademark `shouldBe` "\"trademark\"" where heldBackBody = - "{\"type\":\"registered\",\"expires\":1813853483,\"graceUntil\":1821629483,\"reservedReason_\":\"internal\",\"nameRecord\":" <> J.encode testNameRecord <> "}" + responseBody $ "{\"type\":\"registered\",\"expires\":1813853483,\"graceUntil\":1821629483,\"reservedReason_\":\"internal\",\"nameRecord\":" <> J.encode testNameRecord <> "}" answers body a = withResolverServer (resolveResp status200 body) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) @@ -246,7 +256,7 @@ resolverSpec = do it "returns the registration on 200 OK" $ withResolverServer (resolveResp status200 (registeredBody testNameRecord)) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) - resolveName env aliceDomain `shouldReturn` Right registeredAlice + resolveName env aliceDomain `shouldReturn` Right (resolved registeredAlice) -- /v2/resolve answers 200, 400 or 502 and never says "no such name": an -- unregistered name is NRAvailable. So no status maps to NOT_FOUND.