diff --git a/plans/2026-09-22-names-owned-by.md b/plans/2026-09-22-names-owned-by.md index 92a939d4b..80ae7c94a 100644 --- a/plans/2026-09-22-names-owned-by.md +++ b/plans/2026-09-22-names-owned-by.md @@ -13,7 +13,7 @@ primitives. The client consumer is the wallet recovery scan in simplex-chat. ``` GET /v2/owned-by/
?offset=N resolver, JSON OwnedNames ROWN
-> ROWND SMP v23 (nameOwnedSMPVersion) -ownedNames server, agent client, agent +ownedSimplexNames agent, over ownedNames in the server ``` The agent returns the relay it used alongside the answer, so a caller can pass @@ -22,7 +22,10 @@ seed to one relay would tell that relay the accounts belong to one wallet; the scan therefore asks one account per relay, and `getNextNameServer` avoids the hosts already used where the configured set allows. `proxiedSMPRelayVersion` is pinned to the current version so ROWN can be proxied at all — a scan that falls -back to a direct session hands the relay its address with its IP. +back to a direct session hands the relay its address with its IP. The pin is +necessary but not sufficient: under the default `SPMUnknown` proxy mode a +configured names server is a known host, so the scan reaches it directly, as +RSLV already does. Enumeration is read off the ERC-721 registrar (`balanceOf`, `tokenOfOwnerByIndex`, `labelOf`), not from registration logs: the token is the @@ -70,8 +73,13 @@ cannot be enumerated to be dusted. - **Nonce and balance are not carried over SMP.** The resolver reports them, and `OwnedNames` keeps only the names, the flag and the cursor, so a client cannot yet see why an account is in use. -- **No caching.** One owned-by is many `eth_call`s, so it is forked on the - server the same way RSLV is, and nothing is memoised. +- **No caching, and no batching.** One owned-by is a `balanceOf` per configured + TLD, a grace period for each that lists a name, three `eth_call`s per name + listed, and the block, nonce and balance, all issued one at a time, so it is + forked on + the server the same way RSLV is and nothing is memoised. The page size trades + against both the relay's response cap and its timeout, and it bounds names per + registrar rather than per page, so a second TLD doubles a full page. - **Enumeration is not atomic.** `balanceOf` and each `tokenOfOwnerByIndex` are separate calls at `latest`, so a transfer between them can duplicate or skip an entry, and successive pages can straddle blocks. Pinning every call to one diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index a80d211b2..768ddac06 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -70,6 +70,7 @@ Version 21, 2026-07-05 - [Resolver commands](#resolver-commands) - [Resolve name command](#resolve-name-command) - [Name record response](#name-record-response) + - [Names owned by an address command](#names-owned-by-an-address-command) - [Transport connection with the SMP router](#transport-connection-with-the-SMP-router) - [General transport protocol considerations](#general-transport-protocol-considerations) - [TLS transport encryption](#tls-transport-encryption) @@ -86,7 +87,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 22. Versions 1-5 are discontinued. The version history: +This document describes SMP protocol version 23. Versions 1-5 are discontinued. The version history: - v1: binary protocol encoding - v2: message flags (used to control notifications) @@ -109,6 +110,7 @@ This document describes SMP protocol version 22. Versions 1-5 are discontinued. - v20: public namespaces resolver (RSLV command, RNAME response) — direct or forwarded via PFWD - v21: server public information in handshake - v22: `RNAME` says whether a name can be registered, not only what it resolves to +- v23: the names an address owns (ROWN command, ROWND response) — direct or forwarded via PFWD ## Introduction @@ -1453,7 +1455,8 @@ companion REST resolver process (`scripts/resolver/snrc-resolve.py`) that queries the SNRC contract on Ethereum; alternative backings (different chains, DHT, etc.) are valid as long as they expose the documented HTTP shape (`GET /v2/resolve/` returning a `NameRegistration` on 200 for every -registration shape, 400 for unknown TLDs, 502 for upstream failures) or +registration shape, 400 for unknown TLDs, 502 for upstream failures, and from +SMP v23 `GET /v2/owned-by/
?offset=N` returning an `ownedNames`) or substitute a different transport returning the same JSON. The resolver API is versioned separately from this protocol: `/v1/resolve/` returns a bare `NameRecord` and is what relays before v22 call as `/resolve/`. @@ -1623,6 +1626,42 @@ The names router caps the resolver response it will accept stays within the SMP proxied transmission budget of 16224 bytes; a response over the cap is `ERR NAME RESOLVER`. +#### Names owned by an address command + +`ROWN` asks which names an address holds, and whether the account has been used +at all, so a device restoring a wallet seed can find the accounts already in +use. It is unauthenticated and accepted direct or forwarded, as `RSLV` is, from +v23. + +```abnf +rown = %s"ROWN" SP address offset +address = length "0x" 40HEXDIG ; EIP-55 checksummed, length-prefixed +length = 1*1 OCTET ; 42 +offset = 4*4 OCTET ; Word32, network byte order: where to resume listing +``` + +```abnf +rownd = %s"ROWND" SP ownedNames +``` + +`ownedNames` is a UTF-8 JSON object consuming the remainder of the transmission. + +| Field | JSON type | Constraints | +|---|---|---| +| `names` | array | the names the address holds, each with `name` (absent when the registrar recorded no label), `labelhash`, `expires` and `status` | +| `inUse` | boolean | whether the account has been used at all | +| `nextOffset` | number | cursor to resume from, absent when the listing is complete | + +Enumeration is not maintained on expiry, so a lapsed name stays listed and is +told apart by its `status`. + +`inUse` is what the registry could see on its own chain — the account's nonce, +its balance, and every name it holds, which is not only the names listed here: +a later page of a held account still reports `inUse` true with an empty `names`. Holding a name is only one way for an account +to be in use, and an account used only for other tokens, or on another chain, +reads as unused, so a client MUST NOT treat `inUse` as false meaning the account +has never been used. + ## Transport connection with the SMP router ### General transport protocol considerations diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index f8f5dde8b..7231887af 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -93,7 +93,7 @@ curl -s http://127.0.0.1:8000/v2/resolve/foobar.testing | jq | reth p2p | `:30303` tcp/udp | Ethereum sync (open on firewall) | | nimbus p2p | `:9000` tcp/udp | beacon sync (open on firewall) | | nimbus REST | `127.0.0.1:5052` | beacon API | -| **resolver** | `127.0.0.1:8000` | SNRC REST (`/v2/resolve`, `/resolve`, `/health`) | +| **resolver** | `127.0.0.1:8000` | SNRC REST (`/v2/resolve`, `/v2/owned-by`, `/resolve`, `/health`) | ## Caveats @@ -121,12 +121,13 @@ standalone for local dev (no Docker), via [`uv`](https://docs.astral.sh/uv/): uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + mainnet .testing ``` -Three routes, versioned separately from the protocol so each only changes when +Four routes, versioned separately from the protocol so each only changes when its own shape does: | Route | Called by | Answers | |---|---|---| | `/v2/resolve/` | routers from SMP v22 | a `NameRegistration` | +| `/v2/owned-by/
` | routers from SMP v23 | the names an address owns | | `/resolve/` | routers before SMP v22 | a name record, flat | | `/health` | anyone | readiness | @@ -165,6 +166,32 @@ label, so a hashed query cannot be answered with a name. See A subname reports the expiry and grace of the 2LD above it, since that is what bounds its lifetime. A subname nobody created reports as not registered. +### v2: `/v2/owned-by/
?offset=N` + +The body is the SMP protocol's `OwnedNames`: the names the address holds, and +`inUse`, whether the account has been used at all. The router decodes it as is +and forwards it, so the fields are specified with the wire, in the **Names owned +by an address command** section of +[`protocol/simplex-messaging.md`](../../protocol/simplex-messaging.md). + +Enumeration comes off the registrar's ERC-721 index, so a name acquired by +transfer counts, and a lapsed one stays listed until someone re-registers it: +every entry carries its `status` rather than being filtered out. + +`offset` is the cursor to resume from, and it counts per registrar, so a page +holds up to `SNRC_MAX_OWNED` names for each configured TLD. `nextOffset` is the +next cursor, or null when the listing is complete. `inUse` covers every name the +address holds, not only the page, so a later page reports it true with an empty +`names`. + +| Status | Meaning | +|---|---| +| 200 | the names the address holds, with `inUse` | +| 400 | `badAddress`, `badOffset`, `noRegistrarConfigured` | +| 502 | `upstreamError` | + +Error bodies carry `address`, a fixed `error` code and a `message`. + ### v1: `/resolve/` What routers before SMP v22 call. Its shape is unrelated to v2's: the record is @@ -374,3 +401,8 @@ here. To override any of them, set `SNRC_REGISTRY_`, `SNRC_REGISTRAR_` or `SNRC_CONTROLLER_` on the `resolver` service in `docker-compose.yml`, or as env vars when you run the script directly. + +`SNRC_MAX_OWNED` (default 16) caps the names `/v2/owned-by` returns per +registrar per page. A router will not read a body over 16000 bytes, and a client +cannot ask for a smaller page, so keep it times the number of configured TLDs +well under that. diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 0abc54ad7..bee038f78 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -44,6 +44,8 @@ Environment: SNRC_CONTROLLER_ SimplexController (proxy) for the TLD; reservations, and through its `prices()` oracle what registering costs (default: mainnet for .testing, empty for .simplex) + SNRC_MAX_OWNED Names /v2/owned-by returns per registrar per page + (default: 16) SNRC_PORT Listen port (default: 8000) SNRC_BIND Bind address (default: 0.0.0.0) @@ -124,8 +126,10 @@ COIN_DOT = 354 ZERO_ADDR = "0x0000000000000000000000000000000000000000" -# Names returned per /owned-by page; a scan follows `nextOffset` for the rest. -MAX_OWNED = int(os.environ.get("SNRC_MAX_OWNED", "256")) +# Names per registrar per /owned-by page, so a page holds this many times the +# number of configured TLDs. A relay caps the body it reads at 16000 bytes and a +# client cannot ask for a smaller page, so keep the product well under it. +MAX_OWNED = max(1, int(os.environ.get("SNRC_MAX_OWNED", "16"))) # The registry prices in attoUSD (1e-18 USD); the protocol carries US cents. @@ -893,7 +897,8 @@ def owned_by(address: str, offset: int = 0): Enumeration is read off the ERC-721 registrar, so a name acquired by transfer counts, and a lapsed one stays listed until re-registered - it is reported with its `status`, not filtered. `inUse` is what a recovery scan - asks, derived from this chain's nonce and balance and the names above: an + asks, derived from this chain's nonce and balance and every name it holds, + not only the page returned: an account holding only other tokens is not seen, and neither is one used on another chain. """ @@ -921,23 +926,23 @@ def owned_by(address: str, offset: int = 0): } now = chain_now() - names, truncated = [], False + names, truncated, total_held = [], False, 0 for tld, registrar in configured.items(): - grace = grace_period(registrar) held = decode_uint(eth_call(registrar, selector("balanceOf(address)") + encode_address(address))) - first = min(offset, held) - last = min(first + MAX_OWNED, held) + total_held += held + last = min(offset + MAX_OWNED, held) if last < held: truncated = True - for i in range(first, last): + grace = grace_period(registrar) if last > offset else 0 + for i in range(offset, last): token = decode_uint( eth_call(registrar, selector("tokenOfOwnerByIndex(address,uint256)") + encode_address(address) + encode_uint(i)) ) expires = decode_uint(eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token))) - # A label of "" means the token is real but its name is not - # recoverable from chain state - registered before labels were - # recorded. Reported without a name rather than silently dropped. - label = decode_bytes(eth_call(registrar, selector("labelOf(uint256)") + encode_uint(token))).decode() + # No label means the token is real but its name is not recoverable + # from chain state - registered before labels were recorded. + # Reported without a name rather than silently dropped. + label = registered_label(registrar, token) names.append( { "name": (label + "." + tld) if label else None, @@ -957,12 +962,13 @@ def owned_by(address: str, offset: int = 0): "names": names, "nonce": nonce, "balance": str(balance), - "inUse": bool(names) or nonce > 0 or balance > 0, + # every name the address holds, not just this page: a later page of a + # held name must not read as an account that owns nothing + "inUse": total_held > 0 or nonce > 0 or balance > 0, "offset": offset, # `nextOffset` is the cursor to resume from, or null when the listing is # complete, so "there is more" and "how to get it" are one answer. "nextOffset": offset + MAX_OWNED if truncated else None, - "truncated": truncated, "checkedTlds": sorted(configured), } @@ -995,7 +1001,7 @@ class Handler(BaseHTTPRequestHandler): try: offset = int(parse_qs(parsed.query).get("offset", ["0"])[0]) except ValueError: - self._respond(400, {"address": address, "error": "badOffset"}) + self._respond(400, {"address": address, "error": "badOffset", "message": "offset is a decimal position in the listing"}) return try: status, body = owned_by(address, offset) @@ -1058,7 +1064,7 @@ def main(): ) for tld, addr in REGISTRIES.items(): sys.stderr.write(f" .{tld:<8s} = {addr or '(not configured)'}\n") - sys.stderr.write(" GET /v2/resolve/ GET /v1/resolve/ GET /health\n") + sys.stderr.write(" GET /v2/resolve/ GET /v2/owned-by/
GET /v1/resolve/ GET /health\n") try: server.serve_forever() except KeyboardInterrupt: diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 3d036b84b..e9f65c300 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -1117,7 +1117,6 @@ class OwnedByTests(unittest.TestCase): def test_more_names_than_a_page_carry_the_cursor_to_resume_from(self): snrc.eth_call = self._chain(snrc.MAX_OWNED + 1, int(time.time()) + 86400) _, body = snrc.owned_by(self.ADDR) - self.assertTrue(body["truncated"]) self.assertEqual(body["nextOffset"], snrc.MAX_OWNED) self.assertEqual(len(body["names"]), snrc.MAX_OWNED) @@ -1135,6 +1134,22 @@ class OwnedByTests(unittest.TestCase): _, body = snrc.owned_by(self.ADDR) self.assertEqual(body["names"][0]["status"], "grace") + def test_a_page_past_the_end_still_reports_the_account_in_use(self): + """inUse is a property of the account, not of the page: an empty later + page must not read as an account that owns nothing.""" + snrc.eth_call = self._chain(1, int(time.time()) + 86400) + _, body = snrc.owned_by(self.ADDR, snrc.MAX_OWNED) + self.assertEqual(body["names"], []) + self.assertTrue(body["inUse"]) + + def test_a_label_that_is_not_utf8_is_reported_not_fatal(self): + """The registrar stores the label bytes unchecked, so one bad label + must not take down the whole listing.""" + snrc.eth_call = self._chain(1, int(time.time()) + 86400, label=b"\xff\xfe") + status, body = snrc.owned_by(self.ADDR) + self.assertEqual(status, 200) + self.assertEqual(len(body["names"]), 1) + def test_a_negative_offset_is_refused(self): snrc.eth_call = self._chain(1, int(time.time()) + 86400) status, body = snrc.owned_by(self.ADDR, -1) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 60776b53d..5000008f7 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -2044,17 +2044,15 @@ ownedNames c nm userId server addr offset = -- | 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. --- Servers already used are avoided where the set allows: one relay asked about --- every account of a scan would learn they belong to one wallet. +-- Servers already used are avoided where the set allows, operator first: one +-- operator asked about every account of a scan learns they are one wallet. getNextNameServer :: AgentClient -> UserId -> [SMPServer] -> AM SMPServer getNextNameServer c userId usedSrvs = liftIO (TM.lookupIO userId (userServers c :: TMap UserId (UserServers 'PSMP))) >>= \case Just UserServers {nameSrvs} -> case L.nonEmpty nameSrvs of - Just srvs -> protoServer <$> pickServer (fromMaybe srvs $ L.nonEmpty $ L.filter (isUnusedServer usedHosts) srvs) + Just srvs -> protoServer . snd <$> getNextServer_ srvs (usedOperatorsHosts srvs usedSrvs) Nothing -> throwE NO_NAME_SERVERS Nothing -> throwE $ INTERNAL "unknown userId - no user servers" - where - usedHosts = S.unions $ map serverHosts usedSrvs enableQueueNotifications :: AgentClient -> RcvQueue -> SMP.NtfPublicAuthKey -> SMP.RcvNtfPublicDhKey -> AM (SMP.NotifierId, SMP.RcvNtfPublicDhKey) enableQueueNotifications c rq@RcvQueue {rcvId, rcvPrivateKey} notifierKey rcvNtfPublicDhKey = diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index ff94fe2c0..2edc817e9 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -1081,8 +1081,9 @@ directResolveName c nm name r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion --- | Names an address owns, via PFWD. A scan reveals which accounts belong to --- one wallet, so hiding the client IP matters more here than for one name. +-- | Names an address owns, via PFWD, when the network config selects a proxy. A +-- scan reveals which accounts belong to one wallet, so hiding the client IP +-- matters more here than for one name. proxyOwnedNames :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> Address -> Word32 -> ExceptT SMPClientError IO (Either ProxyClientError OwnedNames) proxyOwnedNames c nm proxiedRelay addr offset | prVersion proxiedRelay >= nameOwnedSMPVersion = diff --git a/src/Simplex/Messaging/Names/Record.hs b/src/Simplex/Messaging/Names/Record.hs index 51c20e28b..c483f233f 100644 --- a/src/Simplex/Messaging/Names/Record.hs +++ b/src/Simplex/Messaging/Names/Record.hs @@ -94,9 +94,9 @@ data NameRegistration -- | What the registry holds for an address: the names it owns, and whether the -- account is in use, which holding a name is only one way to be. `ownInUse` is --- what the resolver could see on its own chain - the nonce, the balance and the --- names - so an account used only for other tokens, or on another chain, reads --- as unused. +-- what the resolver could see on its own chain - the nonce, the balance and +-- every name the address holds, not only the ones listed here - so an account +-- used only for other tokens, or on another chain, reads as unused. data OwnedNames = OwnedNames { ownNames :: [OwnedName], ownInUse :: Bool, diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 2c519898f..df71acd4b 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -1509,6 +1509,10 @@ client answered = \case NRRegistered {} -> True _ -> v >= nameAvailSMPVersion + resolverMsg :: VersionSMP -> NamesEnv -> Command Resolver -> M s BrokerMsg + resolverMsg v nenv = \case + RSLV d -> resolveNameMsg v nenv d + ROWN addr offset -> ownedNamesMsg nenv addr offset -- Forked for the same reason as RSLV: one owned-by is many eth_calls. ownedNamesMsg :: NamesEnv -> Address -> Word32 -> M s BrokerMsg ownedNamesMsg nenv addr offset = do @@ -1532,12 +1536,9 @@ client SEND flags msgBody -> response <$> withQueue_ False err (sendMessage flags msgBody) Cmd SIdleClient PING -> pure $ response (corrId, NoEntity, PONG) Cmd SProxyService (RFWD encBlock) -> (response . (corrId, NoEntity,) =<<) <$> processForwardedCommand encBlock - Cmd SResolver (RSLV d) -> rslvNamesEnv >>= \case + Cmd SResolver command -> rslvNamesEnv >>= \case Nothing -> pure $ response (corrId, NoEntity, ERR (NAME NO_RESOLVER)) - Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity (resolveNameMsg (thVersion thParams') nenv d) - Cmd SResolver (ROWN addr offset) -> rslvNamesEnv >>= \case - Nothing -> pure $ response (corrId, NoEntity, ERR (NAME NO_RESOLVER)) - Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity (ownedNamesMsg nenv addr offset) + Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity (resolverMsg (thVersion thParams') nenv command) 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 @@ -2165,10 +2166,10 @@ client -- rejectOrVerify filters allowed commands, no need to repeat it here. Left r -> pure $ Just r Right t''@(_, (corrId', entId', cmd')) -> case cmd' of - Cmd SResolver (RSLV d) -> lift $ rslvNamesEnv >>= \case + Cmd SResolver command -> lift $ rslvNamesEnv >>= \case Nothing -> pure $ Just (corrId', entId', ERR (NAME NO_RESOLVER)) Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity $ do - msg <- resolveNameMsg (thVersion clntTHParams) nenv d + msg <- resolverMsg (thVersion clntTHParams) nenv command 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. @@ -2190,6 +2191,7 @@ client Cmd SSenderLink (LKEY _) -> True Cmd SSenderLink LGET -> True Cmd SResolver (RSLV _) -> True + Cmd SResolver (ROWN _ _) -> True _ -> False verified = \case VRVerified q -> Right (q, t'') diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index d3872fff5..b9442906e 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -43,8 +43,8 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as BL import Data.Text (Text) -import Data.Word (Word32) import Data.Text.Encoding (encodeUtf8) +import Data.Word (Word32) import Network.HTTP.Client ( HttpException, Manager, diff --git a/src/Simplex/Messaging/Server/Prometheus.hs b/src/Simplex/Messaging/Server/Prometheus.hs index 85d2624fd..9eb6edde3 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 resolver requests (RSLV and ROWN) 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 resolved, or availability answered.\n\ + \# HELP simplex_smp_names_success NameRecord resolved, availability answered, or names owned listed.\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 Resolver 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/tests/NamesResolverServer.hs b/tests/NamesResolverServer.hs index 14b6bc551..2ec26d056 100644 --- a/tests/NamesResolverServer.hs +++ b/tests/NamesResolverServer.hs @@ -47,8 +47,8 @@ withResolverServerDelayed delayMs handler action = do let (st, body) = handler (pathInfo req) send $ responseLBS st [(hContentType, "application/json")] body --- | The resolver API is versioned on its own: v2 answers with NameRegistration --- JSON, which is the only shape the server asks for. +-- | The resolver API is versioned on its own: v2 answers a name with +-- NameRegistration JSON, and an address with OwnedNames. resolveResp :: Status -> LB.ByteString -> [Text] -> (Status, LB.ByteString) resolveResp st body = \case ["health"] -> (ok200, "{}") diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 525d1eef0..6b274593b 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -103,6 +103,7 @@ rslvTests = do describe "RSLV forwarded (PFWD)" $ do it "PFWD-wrapped RSLV reaches resolver via proxy (PCEProtocolError (NAME RESOLVER))" testRslvForwarded it "PFWD-wrapped RSLV success returns RNAME (record JSON frames over the proxy)" testRslvForwardedSuccess + it "PFWD-wrapped ROWN reaches the resolver, so a scan need not go direct" testRownForwarded describe "RSLV success path (RNAME response)" $ do it "returns RNAME with NameRecord" testRslvSuccess describe "RSLV availability (RNAME response)" $ do @@ -182,7 +183,11 @@ testRslvVersion = _ -> expectationFailure $ "expected Left (PCETransportError TEVersion), got: " <> show r forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameResponse)) -forwardedResolveAlice = do +forwardedResolveAlice = forwardedToRelay $ \pc sess -> proxyResolveName pc NRMInteractive sess (domain "alice.simplex") + +-- | Run one proxied resolver command over a PFWD session to the second relay. +forwardedToRelay :: (SMPClient -> ProxiedRelay -> ExceptT SMPClientError IO a) -> IO (Either SMPClientError a) +forwardedToRelay proxiedCmd = do g <- C.newRandom ts <- getCurrentTime let proxyServ = SMPServer testHost testPort testKeyHash @@ -191,7 +196,7 @@ forwardedResolveAlice = do pcE <- getProtocolClient g NRMInteractive (1, proxyServ, Nothing) cfg' [] Nothing ts (\_ -> pure ()) pc <- either (fail . show) pure pcE sess <- runExceptT' (connectSMPProxiedRelay pc NRMInteractive relayServ Nothing) - runExceptT (proxyResolveName pc NRMInteractive sess (domain "alice.simplex")) + runExceptT (proxiedCmd pc sess) testRslvForwarded :: IO () testRslvForwarded = @@ -207,6 +212,15 @@ testRslvForwardedSuccess = Right (Right NameResponse {registration = NRRegistered {nameRecord}}) -> nameRecord `shouldBe` testNameRecord _ -> expectationFailure $ "expected Right (Right NRRegistered), got: " <> show r +-- | Without this the scan has to fall back to a direct session, handing the +-- relay the address together with the client IP. +testRownForwarded :: IO () +testRownForwarded = + withProxyAndResolver (status200, ownedBody) $ + forwardedToRelay (\pc sess -> proxyOwnedNames pc NRMInteractive sess testAddr 0) >>= \r -> case r of + Right (Right owned) -> map onName (ownNames owned) `shouldBe` [Just "alice.testing"] + _ -> expectationFailure $ "expected Right (Right OwnedNames), got: " <> show r + testRslvSuccess :: IO () testRslvSuccess = withResolverServer (status200, registeredBody testNameRecord) $