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) $