From 5dc64f1cf48044c461e875445790b0e24b419843 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 22 Sep 2026 08:53:51 +0000 Subject: [PATCH 01/13] resolver: owned-by endpoint for recovery scans /v2/owned-by/
lists the names an address holds, read off the registrar's ERC-721 index so a name acquired by transfer counts too, and reports whether the account has been used at all. inUse is what a recovery scan asks: holding a name is only one way to be in use, so the nonce and balance it is derived from are reported too. A scan that gets this wrong hands out an account its owner already uses. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WHaVjENni2mwBUL77SwFvr --- scripts/resolver/service/snrc-resolve.py | 119 +++++++++++++++++- scripts/resolver/service/test_snrc_resolve.py | 99 +++++++++++++++ 2 files changed, 214 insertions(+), 4 deletions(-) diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 42881a2a8..997d5d329 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -69,7 +69,7 @@ import sys import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import unquote, urlparse +from urllib.parse import parse_qs, unquote, urlparse from urllib.request import Request, urlopen from eth_hash.auto import keccak @@ -124,6 +124,9 @@ 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")) + # The registry prices in attoUSD (1e-18 USD); the protocol carries US cents. @@ -417,6 +420,14 @@ def encode_uint(value: int) -> str: return value.to_bytes(32, "big").hex() +def encode_address(address: str) -> str: + return (12 * "00") + address[2:].lower() + + +def is_address(address: str) -> bool: + return len(address) == 42 and address.startswith("0x") and all(c in "0123456789abcdef" for c in address[2:].lower()) + + def encode_text_call(node: bytes, key: str) -> str: sel = selector("text(bytes32,string)") head = node.hex() + (0x40).to_bytes(32, "big").hex() @@ -875,12 +886,98 @@ def resolve(name: str): } +def owned_by(address: str, offset: int = 0): + """Every name an address holds, across every configured TLD, with the + account's on-chain footprint. + + Read off the ERC-721 registrar rather than from logs: the token is the + name, so `balanceOf` / `tokenOfOwnerByIndex` is the current answer and it + includes names acquired by transfer, which a scan of registration events + would miss. `labelOf` returns the plaintext label, recorded write-once at + registration, so no off-chain index is needed to turn a token id back into + a name. + + Enumeration is deliberately not maintained on expiry, so a lapsed name + stays in the list until someone re-registers it. That is reported rather + than filtered: every entry carries `status`, using the same vocabulary as + /resolve, and a caller scanning a recovered key is exactly the caller who + needs to be told one of its names can still be renewed. + + `inUse` answers the question a recovery scan actually asks - has this + account ever been used - which holding a name is only one way to be. An + account that was funded or ever sent a transaction is in use even with no + name, so the nonce and balance it is derived from are reported too: a scan + that gets this wrong hands out an account its owner is already using. + """ + if not is_address(address): + return 400, { + "address": address, + "error": "badAddress", + "message": "expected a 0x-prefixed 20-byte address", + } + + configured = {t: r for t, r in REGISTRARS.items() if r} + if not configured: + return 400, { + "address": address, + "error": "noRegistrarConfigured", + "message": "no registrar is configured on this resolver", + "configuredTlds": [], + } + + now = int(time.time()) + names, truncated = [], False + 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) + if last < held: + truncated = True + for i in range(first, 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() + names.append( + { + "name": (label + "." + tld) if label else None, + "tld": tld, + "labelhash": "0x" + format(token, "064x"), + "expires": expires, + "graceEnds": expires + grace if expires else None, + "status": expiry_status(expires, grace, now), + } + ) + + nonce = decode_uint(rpc("eth_getTransactionCount", [address, "latest"])) + balance = decode_uint(rpc("eth_getBalance", [address, "latest"])) + names.sort(key=lambda n: (n["tld"], n["name"] or n["labelhash"])) + return 200, { + "address": address, + "names": names, + "nonce": nonce, + "balance": str(balance), + "inUse": bool(names) 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), + } + + # ---------- HTTP layer ---------- class Handler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 - http.server contract - path = urlparse(self.path).path - parts = [unquote(p) for p in path.split("/") if p] + parsed = urlparse(self.path) + parts = [unquote(p) for p in parsed.path.split("/") if p] if parts == ["health"]: self._respond(200, {"ok": True, "rpc": RPC, "registries": REGISTRIES, **head_block()}) @@ -898,6 +995,20 @@ def do_GET(self): # noqa: N802 - http.server contract self._respond(status, body) return + if len(parts) == 3 and parts[0] == "v2" and parts[1] == "owned-by": + address = parts[2].strip().lower() + try: + offset = int(parse_qs(parsed.query).get("offset", ["0"])[0]) + except ValueError: + self._respond(400, {"address": address, "error": "badOffset"}) + return + try: + status, body = owned_by(address, offset) + except Exception as e: # surface upstream errors as 502 + status, body = 502, upstream_error({"address": address}, e) + self._respond(status, body) + return + # /v1/resolve is an alias: relays before SMP v22 call /resolve if parts[:2] == ["v1", "resolve"] and len(parts) == 3: parts = ["resolve", parts[2]] @@ -926,7 +1037,7 @@ def do_GET(self): # noqa: N802 - http.server contract { "error": "noSuchRoute", "message": "not found", - "routes": ["/health", "/v2/resolve/", "/v1/resolve/", "/resolve/"], + "routes": ["/health", "/v2/resolve/", "/v2/owned-by/
", "/v1/resolve/", "/resolve/"], }, ) diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 4b0555ed1..92bd7ce8a 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -1035,5 +1035,104 @@ def test_an_available_name_says_so_too(self): self.assertEqual(res["lastBlockTs"], self.now) self.assertEqual(res["registration"]["type"], "available") +class OwnedByTests(unittest.TestCase): + """`/v2/owned-by` answers what a recovery scan asks: which names an account + holds, and whether the account has been used at all. Enumeration is read + off the registrar's ERC-721 index, so a name acquired by transfer counts + the same as one registered here.""" + + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + ADDR = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8" + GRACE = 90 * 86400 + # keccak-256("alice") + ALICE = 0x9C0257114EB9399A2985F8E75DAD7600C5D89FE3824FFA99EC1C3EB8BF3B0501 + + def setUp(self): + self._saved = (snrc.REGISTRARS, snrc.eth_call, snrc.rpc) + snrc.REGISTRARS = {"testing": self.REGISTRAR, "simplex": ""} + snrc.rpc = lambda method, params: "0x0" + + def tearDown(self): + snrc.REGISTRARS, snrc.eth_call, snrc.rpc = self._saved + + def _chain(self, held, expires, label=b"alice"): + def call(to, data): + sel = data[:10] + if sel == snrc.selector("GRACE_PERIOD()"): + return snrc.encode_uint(self.GRACE) + if sel == snrc.selector("balanceOf(address)"): + return snrc.encode_uint(held) + if sel == snrc.selector("tokenOfOwnerByIndex(address,uint256)"): + return snrc.encode_uint(self.ALICE) + if sel == snrc.selector("nameExpires(uint256)"): + return snrc.encode_uint(expires) + if sel == snrc.selector("labelOf(uint256)"): + return "0x" + snrc.encode_uint(32) + snrc.encode_uint(len(label)) + label.hex() + "00" * ((-len(label)) % 32) + raise AssertionError("unexpected call " + sel) + return call + + def test_a_held_name_is_listed_with_its_status(self): + snrc.eth_call = self._chain(1, int(time.time()) + 86400) + status, body = snrc.owned_by(self.ADDR) + self.assertEqual(status, 200) + self.assertEqual([n["name"] for n in body["names"]], ["alice.testing"]) + self.assertEqual(body["names"][0]["status"], "registered") + + def test_a_lapsed_name_is_reported_not_filtered(self): + """A scan of a recovered key is exactly the caller who needs to be told + a name can still be renewed.""" + snrc.eth_call = self._chain(1, int(time.time()) - 86400) + _, body = snrc.owned_by(self.ADDR) + self.assertEqual(body["names"][0]["status"], "grace") + + def test_holding_a_name_is_in_use(self): + snrc.eth_call = self._chain(1, int(time.time()) + 86400) + _, body = snrc.owned_by(self.ADDR) + self.assertTrue(body["inUse"]) + + def test_an_account_that_sent_a_transaction_is_in_use_with_no_name(self): + """The case a names-only scan gets wrong: an account in use, holding + nothing, would be handed out again.""" + snrc.eth_call = self._chain(0, 0) + snrc.rpc = lambda method, params: "0x3" if method == "eth_getTransactionCount" else "0x0" + _, body = snrc.owned_by(self.ADDR) + self.assertEqual(body["names"], []) + self.assertEqual(body["nonce"], 3) + self.assertTrue(body["inUse"]) + + def test_a_funded_account_is_in_use_with_no_name_and_no_nonce(self): + snrc.eth_call = self._chain(0, 0) + snrc.rpc = lambda method, params: "0x0" if method == "eth_getTransactionCount" else "0xde0b6b3a7640000" + _, body = snrc.owned_by(self.ADDR) + self.assertEqual(body["balance"], "1000000000000000000") + self.assertTrue(body["inUse"]) + + def test_an_untouched_account_is_not_in_use(self): + snrc.eth_call = self._chain(0, 0) + _, body = snrc.owned_by(self.ADDR) + self.assertFalse(body["inUse"]) + self.assertIsNone(body["nextOffset"]) + + 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) + + def test_a_malformed_address_is_refused(self): + status, body = snrc.owned_by("0xnothex") + self.assertEqual(status, 400) + self.assertEqual(body["error"], "badAddress") + + def test_no_configured_registrar_is_an_error_not_an_empty_answer(self): + """An empty list would read as "this key owns nothing", which is the + one answer a scan must not invent.""" + snrc.REGISTRARS = {"testing": "", "simplex": ""} + status, body = snrc.owned_by(self.ADDR) + self.assertEqual(status, 400) + self.assertEqual(body["error"], "noRegistrarConfigured") + + if __name__ == "__main__": unittest.main() From f53a6f3364c1103231d737fa925fa99120ed4ecd Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 22 Sep 2026 09:19:47 +0000 Subject: [PATCH 02/13] names: ROWN, the names an address owns A recovered key has to be scanned for the accounts it already uses, which needs a question the protocol could not ask: what does this address own. ROWN carries one address and a page offset and is answered with ROWND, from nameOwnedSMPVersion. The agent returns the relay it used so a scan can ask the next account elsewhere: sending every account to one relay would tell it they belong to one wallet. inUse is the answer the scan acts on. Holding a name is only one way for an account to be in use, so the resolver reports the nonce and balance it is derived from; a scan that reads names alone hands out an account its owner is already using. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WHaVjENni2mwBUL77SwFvr --- src/Simplex/Messaging/Agent.hs | 14 +++++++ src/Simplex/Messaging/Agent/Client.hs | 29 ++++++++++++++ src/Simplex/Messaging/Client.hs | 25 ++++++++++++ src/Simplex/Messaging/Eth/Address.hs | 8 ++++ src/Simplex/Messaging/Names/Record.hs | 29 ++++++++++++++ src/Simplex/Messaging/Protocol.hs | 19 ++++++++- src/Simplex/Messaging/Server.hs | 17 +++++++- src/Simplex/Messaging/Server/Names.hs | 24 +++++++++++ .../Messaging/Server/Names/HttpResolver.hs | 10 ++++- src/Simplex/Messaging/Transport.hs | 20 ++++++---- tests/NamesResolverServer.hs | 1 + tests/RSLVTests.hs | 40 +++++++++++++++++++ 12 files changed, 226 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index e9a7379b4..3e1e707ef 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -67,6 +67,7 @@ module Simplex.Messaging.Agent deleteConnShortLink, getConnShortLink, resolveSimplexName, + ownedSimplexNames, getConnLinkPrivKey, deleteLocalInvShortLink, changeConnectionUser, @@ -222,6 +223,8 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken, NtfRegCode (NtfRegCode), NtfTknStatus (..), NtfTokenId, PNMessageData (..), pnMessagesP) import Simplex.Messaging.Notifications.Types import Simplex.Messaging.Parsers (defaultJSON, parse) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Names.Record (OwnedNames) import Simplex.Messaging.Protocol ( BrokerMsg, Cmd (..), @@ -465,6 +468,12 @@ resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDoma resolveSimplexName c nm userId domain = withAgentEnv c $ resolveSimplexName' c nm userId domain {-# INLINE resolveSimplexName #-} +-- | Names an address owns. The relay used is returned so a scan can pass it +-- back as used and ask the next account elsewhere. +ownedSimplexNames :: AgentClient -> NetworkRequestMode -> UserId -> [SMPServer] -> Address -> Word32 -> AE (SMPServer, OwnedNames) +ownedSimplexNames c nm userId used addr offset = withAgentEnv c $ ownedSimplexNames' c nm userId used addr offset +{-# INLINE ownedSimplexNames #-} + getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) getConnLinkPrivKey c = withAgentEnv c . getConnLinkPrivKey' c {-# INLINE getConnLinkPrivKey #-} @@ -1270,6 +1279,11 @@ getConnShortLink' c nm userId = \case deleteLocalInvShortLink' :: AgentClient -> ConnShortLink 'CMInvitation -> AM () deleteLocalInvShortLink' c (CSLInvitation _ srv linkId _) = withStore' c $ \db -> deleteInvShortLink db srv linkId +ownedSimplexNames' :: AgentClient -> NetworkRequestMode -> UserId -> [SMPServer] -> Address -> Word32 -> AM (SMPServer, OwnedNames) +ownedSimplexNames' c nm userId used addr offset = do + srv <- getNextNameServerAvoiding c userId used + (srv,) <$> ownedNames c nm userId srv addr offset + resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResponse resolveSimplexName' c nm userId domain = do resolverSrv <- getNextNameServer c userId diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 8cbb4c17b..7fa17cd08 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -70,6 +70,8 @@ module Simplex.Messaging.Agent.Client secureGetQueueLink, getQueueLink, resolveName, + ownedNames, + getNextNameServerAvoiding, getNextNameServer, enableQueueNotifications, EnableQueueNtfReq (..), @@ -263,6 +265,8 @@ import Simplex.Messaging.Notifications.Protocol import Simplex.Messaging.Notifications.Transport (NTFVersion) import Simplex.Messaging.Notifications.Types import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parse, sumTypeJSON) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Names.Record (OwnedNames) import Simplex.Messaging.Protocol ( AProtocolType (..), BrokerMsg, @@ -2029,6 +2033,31 @@ resolveName c nm userId server domain = resolveViaProxy smp proxySess = proxyResolveName smp nm proxySess domain resolveDirectly smp = directResolveName smp nm domain +-- | Names an address owns, from one names-capable relay. Mirrors resolveName: +-- proxied where the network config allows it, direct otherwise. +ownedNames :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> Address -> Word32 -> AM OwnedNames +ownedNames c nm userId server addr offset = + snd <$> sendOrProxySMPCommand c nm userId server "" "ROWN" NoEntity ownedViaProxy ownedDirectly + where + ownedViaProxy smp proxySess = proxyOwnedNames smp nm proxySess addr offset + ownedDirectly smp = directOwnedNames smp nm addr offset + +-- | A names-capable server the caller has not used yet. A scan asks about one +-- account per relay, because sending the whole list to one relay would tell it +-- those accounts belong to one wallet. With fewer relays than accounts the +-- choice wraps onto used ones, which is the best the configured set allows. +getNextNameServerAvoiding :: AgentClient -> UserId -> [SMPServer] -> AM SMPServer +getNextNameServerAvoiding c userId used = + liftIO (TM.lookupIO userId (userServers c :: TMap UserId (UserServers 'PSMP))) >>= \case + Just UserServers {nameSrvs} -> + let unused = filter ((`notElem` used) . protoServer . snd) nameSrvs + in case L.nonEmpty unused of + Just srvs -> protoServer <$> pickServer srvs + Nothing -> case L.nonEmpty nameSrvs of + Just srvs -> protoServer <$> pickServer srvs + Nothing -> throwE NO_NAME_SERVERS + Nothing -> throwE $ INTERNAL "unknown userId - no user servers" + -- | 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. diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index f8f1a4cb9..fd9e72434 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -75,6 +75,8 @@ module Simplex.Messaging.Client proxySMPMessage, proxyResolveName, directResolveName, + proxyOwnedNames, + directOwnedNames, forwardSMPTransmission, getSMPQueueInfo, sendProtocolCommand, @@ -163,6 +165,9 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) +import Data.Word (Word32) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Names.Record (OwnedNames) import Simplex.Messaging.Protocol import Simplex.Messaging.Protocol.Types import Simplex.Messaging.Server.QueueStore.QueueInfo @@ -1076,6 +1081,26 @@ 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. +proxyOwnedNames :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> Address -> Word32 -> ExceptT SMPClientError IO (Either ProxyClientError OwnedNames) +proxyOwnedNames c nm proxiedRelay addr offset + | prVersion proxiedRelay >= nameOwnedSMPVersion = + proxySMPCommand c nm proxiedRelay Nothing NoEntity (ROWN addr offset) >>= \case + Right (ROWND owned) -> pure $ Right owned + Right r -> throwE $ unexpectedResponse r + Left e -> pure $ Left e + | otherwise = throwE $ PCETransportError TEVersion + +-- | Direct (non-PFWD) owned-names lookup, exposing the client IP. +directOwnedNames :: SMPClient -> NetworkRequestMode -> Address -> Word32 -> ExceptT SMPClientError IO OwnedNames +directOwnedNames c nm addr offset + | thVersion (thParams c) >= nameOwnedSMPVersion = + sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (ROWN addr offset)) >>= \case + ROWND owned -> pure owned + r -> throwE $ unexpectedResponse r + | otherwise = throwE $ PCETransportError TEVersion + resolvedNameOrNotFound :: SimplexDomain -> NameResponse -> Bool resolvedNameOrNotFound d NameResponse {registration} = case registration of NRRegistered {nameRecord} -> T.toLower (nrName nameRecord) == fullDomainName d diff --git a/src/Simplex/Messaging/Eth/Address.hs b/src/Simplex/Messaging/Eth/Address.hs index f0d9a5ef9..699938fb4 100644 --- a/src/Simplex/Messaging/Eth/Address.hs +++ b/src/Simplex/Messaging/Eth/Address.hs @@ -19,8 +19,10 @@ import Data.Char (isDigit, isHexDigit, isLower, isUpper, toLower) import Data.Word (Word32, Word8) import Simplex.Messaging.Crypto.BIP32 (hardened) import qualified Simplex.Messaging.Crypto.Secp256k1 as S +import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Eth.Keccak (keccak256) +import Simplex.Messaging.Util ((<$?>)) -- | A 20-byte Ethereum address. 'Show' renders the EIP-55 checksummed form, as pasted into a block explorer. newtype Address = Address ByteString @@ -30,6 +32,12 @@ instance Show Address where show = BC.unpack . checksumAddress -- | EIP-55 checksummed hex. Parsing accepts bare or @0x@-prefixed hex and verifies a mixed-case checksum. +-- | On the wire the address is its EIP-55 hex, length-prefixed, so a field +-- following it is not swallowed by the hex parser. +instance Encoding Address where + smpEncode = smpEncode . strEncode + smpP = strDecode <$?> smpP + instance StrEncoding Address where strEncode = checksumAddress strP = do diff --git a/src/Simplex/Messaging/Names/Record.hs b/src/Simplex/Messaging/Names/Record.hs index 461bec16e..5f8feecd8 100644 --- a/src/Simplex/Messaging/Names/Record.hs +++ b/src/Simplex/Messaging/Names/Record.hs @@ -13,6 +13,8 @@ module Simplex.Messaging.Names.Record NamePricing (..), USDCents (..), NameReservedReason (..), + OwnedNames (..), + OwnedName (..), ) where @@ -90,6 +92,29 @@ data NameRegistration NRReserved {reservedReason :: NameReservedReason} deriving (Eq, Show) +-- | What the registry holds for an address: the names it owns, and whether +-- the account has been used at all. Holding a name is only one way to be in +-- use, so a recovery scan that reads only `ownNames` hands out an account its +-- owner is already using. +data OwnedNames = OwnedNames + { ownNames :: [OwnedName], + ownInUse :: Bool, + -- | the cursor to resume from, absent when the listing is complete + ownNextOffset :: Maybe Int + } + deriving (Eq, Show) + +-- | One name an address holds. Enumeration is not maintained on expiry, so a +-- lapsed name stays listed; `onStatus` is how a caller tells it apart. +data OwnedName = OwnedName + { -- | absent when the registrar never recorded the label + onName :: Maybe Text, + onLabelhash :: Text, + onExpires :: SystemSeconds, + onStatus :: Text + } + deriving (Eq, Show) + -- | Enough to price the name locally, which the router cannot do behind a hash. data NamePricing = NamePricing { -- | US cents per year, for the lengths the registry prices specially @@ -135,6 +160,10 @@ instance ToJSON NameReservedReason where instance FromJSON NameReservedReason where parseJSON = textParseJSON "NameReservedReason" +$(JQ.deriveJSON defaultJSON {J.fieldLabelModifier = dropPrefix "on"} ''OwnedName) + +$(JQ.deriveJSON defaultJSON {J.fieldLabelModifier = dropPrefix "own"} ''OwnedNames) + $(JQ.deriveJSON defaultJSON ''NamePricing) -- taggedObjectJSON, not sumTypeJSON: this JSON is the RNAME payload and the diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index e2cbef3a4..854bd7fae 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -261,7 +261,7 @@ import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Data.Type.Equality -import Data.Word (Word8, Word16) +import Data.Word (Word8, Word16, Word32) import GHC.TypeLits (ErrorMessage (..), TypeError, type (+)) import qualified GHC.TypeLits as TE import qualified GHC.TypeLits as Type @@ -271,6 +271,7 @@ import Simplex.Messaging.Agent.Store.DB (Binary (..), FromField (..), ToField (. import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Eth.Address (Address) import Simplex.Messaging.Names.Record import Simplex.Messaging.Parsers import Simplex.Messaging.Protocol.Types @@ -610,6 +611,8 @@ data Command (p :: Party) where RFWD :: EncFwdTransmission -> Command ProxyService -- use CorrId as CbNonce, proxy to relay -- Resolve SimpleX name. RSLV :: NameQuery -> Command Resolver + -- Names an address owns, and whether the account has been used at all. + ROWN :: Address -> Word32 -> Command Resolver deriving instance Show (Command p) @@ -747,6 +750,8 @@ data BrokerMsg where PONG :: BrokerMsg -- What the router knows about a SimpleX name. RNAME :: NameResponse -> BrokerMsg + -- What the router knows about the names an address owns. + ROWND :: OwnedNames -> BrokerMsg deriving (Eq, Show) data RcvMessage = RcvMessage @@ -958,6 +963,7 @@ data CommandTag (p :: Party) where NSUB_ :: CommandTag Notifier NSUBS_ :: CommandTag NotifierService RSLV_ :: CommandTag Resolver + ROWN_ :: CommandTag Resolver data CmdTag = forall p. PartyI p => CT (SParty p) (CommandTag p) @@ -985,6 +991,7 @@ data BrokerMsgTag | ERR_ | PONG_ | RNAME_ + | ROWND_ deriving (Show) class ProtocolMsgTag t where @@ -1022,6 +1029,7 @@ instance PartyI p => Encoding (CommandTag p) where NSUB_ -> "NSUB" NSUBS_ -> "NSUBS" RSLV_ -> "RSLV" + ROWN_ -> "ROWN" smpP = messageTagP instance ProtocolMsgTag CmdTag where @@ -1051,6 +1059,7 @@ instance ProtocolMsgTag CmdTag where "NSUB" -> Just $ CT SNotifier NSUB_ "NSUBS" -> Just $ CT SNotifierService NSUBS_ "RSLV" -> Just $ CT SResolver RSLV_ + "ROWN" -> Just $ CT SResolver ROWN_ _ -> Nothing instance Encoding CmdTag where @@ -1081,6 +1090,7 @@ instance Encoding BrokerMsgTag where ERR_ -> "ERR" PONG_ -> "PONG" RNAME_ -> "RNAME" + ROWND_ -> "ROWND" smpP = messageTagP instance ProtocolMsgTag BrokerMsgTag where @@ -1104,6 +1114,7 @@ instance ProtocolMsgTag BrokerMsgTag where "ERR" -> Just ERR_ "PONG" -> Just PONG_ "RNAME" -> Just RNAME_ + "ROWND" -> Just ROWND_ _ -> Nothing -- | SMP message body format @@ -1846,6 +1857,7 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where PFWD fwdV pubKey (EncTransmission s) -> e (PFWD_, ' ', fwdV, pubKey, Tail s) RFWD (EncFwdTransmission s) -> e (RFWD_, ' ', Tail s) RSLV q -> e (RSLV_, ' ', if v >= nameAvailSMPVersion then hashedQuery q else q) + ROWN addr offset -> e (ROWN_, ' ', addr, offset) where e :: Encoding a => a -> ByteString e = smpEncode @@ -1871,6 +1883,7 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where PFWD {} -> entityCmd RFWD _ -> noAuthCmd RSLV _ -> noAuthCmd + ROWN _ _ -> noAuthCmd SUB -> serviceCmd NSUB -> serviceCmd -- other client commands must have both signature and queue ID @@ -1953,6 +1966,7 @@ instance ProtocolEncoding SMPVersion ErrorType Cmd where | v >= rcvServiceSMPVersion -> Cmd SNotifierService <$> (NSUBS <$> _smpP <*> smpP) | otherwise -> pure $ Cmd SNotifierService $ NSUBS (-1) mempty CT SResolver RSLV_ -> Cmd SResolver . RSLV <$> _smpP + CT SResolver ROWN_ -> Cmd SResolver <$> (ROWN <$> _smpP <*> smpP) fromProtocolError = fromProtocolError @SMPVersion @ErrorType @BrokerMsg {-# INLINE fromProtocolError #-} @@ -1995,6 +2009,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where | v < clientNoticesSMPVersion -> BLOCKED info {notice = Nothing} _ -> err PONG -> e PONG_ + ROWND owned -> e (ROWND_, ' ', Tail $ LB.toStrict $ J.encode owned) RNAME res | v >= nameAvailSMPVersion -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode res) | otherwise -> case registration res of @@ -2046,6 +2061,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where OK_ -> pure OK ERR_ -> ERR <$> _smpP PONG_ -> pure PONG + ROWND_ -> fmap ROWND . J.eitherDecodeStrict . unTail <$?> _smpP RNAME_ | v >= nameAvailSMPVersion -> fmap RNAME . J.eitherDecodeStrict . unTail <$?> _smpP | otherwise -> fmap (RNAME . oldResponse) . J.eitherDecodeStrict . unTail <$?> _smpP @@ -2074,6 +2090,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where RRES _ -> noEntityMsg ALLS -> noEntityMsg RNAME {} -> noEntityMsg + ROWND {} -> noEntityMsg -- other broker responses must have queue ID _ | B.null entId -> Left $ CMD NO_ENTITY diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 097cabbbe..051c4bac8 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -109,7 +109,9 @@ import Simplex.Messaging.Server.Env.STM as Env import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Server.MsgStore import Simplex.Messaging.Server.MsgStore.Journal (JournalMsgStore, JournalQueue (..), getJournalQueueMessages) -import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, resolveName) +import Data.Word (Word32) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, ownedNames, resolveName) import Simplex.Messaging.Server.MsgStore.STM import Simplex.Messaging.Server.MsgStore.Types import Simplex.Messaging.Server.NtfStore @@ -1276,6 +1278,7 @@ verifyQueueTransmission service thAuth (tAuth, authorized, (corrId, entId, comma vc SProxiedClient _ = VRVerified Nothing vc SProxyService (RFWD _) = VRVerified Nothing vc SResolver (RSLV _) = VRVerified Nothing + vc SResolver (ROWN _ _) = VRVerified Nothing checkRole = case (service, partyClientRole p) of (Just THClientService {serviceRole}, Just role) -> serviceRole == role _ -> True @@ -1506,6 +1509,15 @@ client answered = \case NRRegistered {} -> True _ -> v >= nameAvailSMPVersion + -- 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 + st <- asks (rslvStats . serverStats) + (selector, msg) <- + liftIO (ownedNames nenv addr offset) <&> \case + Right owned -> (rslvSucc, ROWND owned) + Left e -> (rslvResolverErrs, ERR $ NAME e) + incStat (selector st) $> msg transportErr :: TransportError -> ErrorType transportErr = PROXY . BROKER . TRANSPORT mkIncProxyStats :: MonadIO m => ProxyStats -> ProxyStats -> OwnServer -> (ProxyStats -> IORef Int) -> m () @@ -1523,6 +1535,9 @@ client Cmd SResolver (RSLV d) -> 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) 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 diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 601519c56..2633ed0ee 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -12,6 +12,7 @@ module Simplex.Messaging.Server.Names closeNamesEnv, pingEndpoint, resolveName, + ownedNames, ) where @@ -19,10 +20,14 @@ import qualified Control.Exception as E import Control.Logger.Simple (logError) import Data.Bifunctor (first) import Data.Maybe (fromMaybe) +import Data.Word (Word32) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) import Simplex.Messaging.Encoding +import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Protocol (NameErrorType (..), NameQuery, NameResponse) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Names.Record (OwnedNames) import Simplex.Messaging.Server.Names.HttpResolver ( ResolverEnv, ResolverError (..), @@ -30,6 +35,7 @@ import Simplex.Messaging.Server.Names.HttpResolver closeResolverEnv, healthHttp, newResolverEnv, + ownedByHttp, resolveHttp, ) import System.Timeout (timeout) @@ -74,6 +80,24 @@ fetch :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameResponse) fetch NamesEnv {resolverEnv} q = first mapResolverError <$> resolveHttp resolverEnv (decodeLatin1 $ smpEncode q) +-- | The names an address owns. A resolver without the endpoint answers 404, +-- which mapResolverError reports as RESOLVER, so a scan cannot read "this +-- resolver cannot say" as "this address owns nothing". +ownedNames :: NamesEnv -> Address -> Word32 -> IO (Either NameErrorType OwnedNames) +ownedNames env addr offset = do + r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetchOwned env addr offset)) + case r of + Right result -> pure (fromMaybe (Left (RESOLVER "timeout")) result) + Left e + | Just (_ :: E.SomeAsyncException) <- E.fromException e -> E.throwIO e + | otherwise -> do + logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) + pure (Left (RESOLVER "resolver error")) + +fetchOwned :: NamesEnv -> Address -> Word32 -> IO (Either NameErrorType OwnedNames) +fetchOwned NamesEnv {resolverEnv} addr offset = + first mapResolverError <$> ownedByHttp resolverEnv (decodeLatin1 $ strEncode addr) (fromIntegral offset) + mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code)) diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index 0f272bcf4..c9355459b 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -29,6 +29,7 @@ module Simplex.Messaging.Server.Names.HttpResolver newResolverEnv, closeResolverEnv, resolveHttp, + ownedByHttp, healthHttp, ) where @@ -59,7 +60,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 (NameResponse) +import Simplex.Messaging.Names.Record (NameResponse, OwnedNames) data RpcAuth = AuthBearer Text | AuthBasic Text Text @@ -117,6 +118,13 @@ resolveHttp env q = (>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict) <$> httpGet env ("/v2/resolve/" <> B.unpack (urlEncode True (encodeUtf8 q))) +-- | The address is EIP-55 hex, which is already URL-safe, but it is encoded +-- for the same reason the name is: nothing from a client reaches the path raw. +ownedByHttp :: ResolverEnv -> Text -> Int -> IO (Either ResolverError OwnedNames) +ownedByHttp env addr offset = + (>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict) + <$> httpGet env ("/v2/owned-by/" <> B.unpack (urlEncode True (encodeUtf8 addr)) <> "?offset=" <> show offset) + -- | GET /health; success = reachable with status < 400. The body is -- size-capped but NOT decoded — the probe only checks reachability. healthHttp :: ResolverEnv -> IO (Either ResolverError ()) diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index d2c30d25a..6d4f9951f 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -54,6 +54,7 @@ module Simplex.Messaging.Transport namesSMPVersion, serverInfoSMPVersion, nameAvailSMPVersion, + nameOwnedSMPVersion, simplexMQVersion, smpBlockSize, TransportConfig (..), @@ -218,6 +219,11 @@ serverInfoSMPVersion = VersionSMP 21 nameAvailSMPVersion :: VersionSMP nameAvailSMPVersion = VersionSMP 22 +-- | ROWN lists the names an address owns. A server below this does not answer +-- it, which a recovery scan must not read as the address owning nothing. +nameOwnedSMPVersion :: VersionSMP +nameOwnedSMPVersion = VersionSMP 23 + minClientSMPRelayVersion :: VersionSMP minClientSMPRelayVersion = VersionSMP 14 @@ -225,20 +231,20 @@ minServerSMPRelayVersion :: VersionSMP minServerSMPRelayVersion = VersionSMP 14 currentClientSMPRelayVersion :: VersionSMP -currentClientSMPRelayVersion = VersionSMP 22 +currentClientSMPRelayVersion = VersionSMP 23 currentServerSMPRelayVersion :: VersionSMP -currentServerSMPRelayVersion = VersionSMP 22 +currentServerSMPRelayVersion = VersionSMP 23 -- Max SMP protocol version to be used in e2e encrypted connection between -- client and server, as defined by SMP proxy. Normally set below the current -- version to prevent client version fingerprinting by the destination relays --- when clients upgrade at different times. Pinned to the current version (22) --- for this release because a proxied RSLV only carries availability from --- nameAvailSMPVersion (22), so the one-version anti-fingerprinting buffer does --- not apply yet; it reappears once the current version advances past 22. +-- when clients upgrade at different times. Pinned to the current version (23) +-- for this release because a proxied ROWN is only answered from +-- nameOwnedSMPVersion (23), and a scan that cannot proxy exposes its address +-- to the relay; the buffer reappears once the current version advances past 23. proxiedSMPRelayVersion :: VersionSMP -proxiedSMPRelayVersion = VersionSMP 22 +proxiedSMPRelayVersion = VersionSMP 23 -- minimal supported protocol version is 14 supportedClientSMPRelayVRange :: VersionRangeSMP diff --git a/tests/NamesResolverServer.hs b/tests/NamesResolverServer.hs index 054d55e40..14b6bc551 100644 --- a/tests/NamesResolverServer.hs +++ b/tests/NamesResolverServer.hs @@ -53,6 +53,7 @@ resolveResp :: Status -> LB.ByteString -> [Text] -> (Status, LB.ByteString) resolveResp st body = \case ["health"] -> (ok200, "{}") ("v2" : "resolve" : _) -> (st, body) + ("v2" : "owned-by" : _) -> (st, body) _ -> (notFound404, "{}") testNamesConfig :: Int -> NamesConfig diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 9263b6953..e67aa6b33 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -27,6 +27,8 @@ import Simplex.Messaging.Client import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (strDecode) import SMPNamesTests (availableBody, registeredBody, reservedBody, resolved, testNameRecord, testPricing) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Names.Record (OwnedName (..), OwnedNames (..)) import Simplex.Messaging.Protocol ( BrokerMsg (..), Cmd (..), @@ -72,6 +74,16 @@ withProxyAndResolver (st, body) runTest = withSmpServerConfigOn (transport @TLS) memProxyCfg testPort $ \_ -> withSmpServerConfigOn (transport @TLS) (withNames port memCfg2) testPort2 (const runTest) +sendRown :: Transport c => THandleSMP c 'TClient -> B.ByteString -> Address -> IO (Transmission (Either ErrorType BrokerMsg)) +sendRown h@THandle {params} corrId addr = do + let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (ROWN addr 0)) + [Right ()] <- tPut h (Right (Nothing, tToSend) :| []) + r :| _ <- tGetClient h + pure r + +testAddr :: Address +testAddr = either error id $ strDecode "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + sendRslv :: Transport c => THandleSMP c 'TClient -> B.ByteString -> SimplexDomain -> IO (Transmission (Either ErrorType BrokerMsg)) sendRslv h@THandle {params} corrId d = do let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (RSLV (NQDomain d))) @@ -84,6 +96,8 @@ rslvTests = do describe "RSLV direct (non-forwarded)" $ do it "resolver without the v2 route (404) -> NAME RESOLVER, not NOT_FOUND" testRslvBackendNotFound it "resolver replies 502 -> NAME (RESOLVER ..)" testRslvBackendHttpErr + it "ROWN returns the names an address owns" testRownOwned + it "ROWN on a resolver without the endpoint is a resolver error, not an empty answer" testRownUnsupported it "no names config -> NAME NO_RESOLVER" testRslvDisabled it "refuses to send RSLV on a session below namesSMPVersion" testRslvVersion describe "RSLV forwarded (PFWD)" $ do @@ -105,6 +119,32 @@ rslvTests = do -- | /v2/resolve answers 200, 400 or 502, so a 404 is a resolver that predates -- the route, not a name that does not exist. +-- The scan reads inUse, so an account in use with no names must not arrive +-- looking the same as one that owns nothing. +testRownOwned :: IO () +testRownOwned = + withResolverServer (status200, ownedBody) $ + testSMPClient @TLS $ \h -> do + (corrId, _entId, resp) <- sendRown h "ro01" testAddr + corrId `shouldBe` CorrId "ro01" + case resp of + Right (ROWND owned) -> do + map onName (ownNames owned) `shouldBe` [Just "alice.testing"] + ownInUse owned `shouldBe` True + r -> expectationFailure $ "unexpected " <> show r + +-- A resolver that does not serve owned-by answers 404, which must reach the +-- client as a resolver error: read as "owns nothing" it would end a scan early. +testRownUnsupported :: IO () +testRownUnsupported = + withResolverServer (status404, "{}") $ + testSMPClient @TLS $ \h -> do + (_, _, resp) <- sendRown h "ro02" testAddr + resp `shouldBe` Right (ERR (NAME (RESOLVER "HTTP 404"))) + +ownedBody :: LB.ByteString +ownedBody = "{\"address\":\"0x70997970c51812dc3a010c7d01b50e0d17dc79c8\",\"names\":[{\"name\":\"alice.testing\",\"labelhash\":\"0x9c02\",\"expires\":1821603121,\"status\":\"registered\"}],\"inUse\":true,\"nextOffset\":null}" + testRslvBackendNotFound :: IO () testRslvBackendNotFound = withResolverServer (status404, "{}") $ From 3ae71f06b44e70d25b7eb7c604c252bd9b7afdf5 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 22 Sep 2026 13:18:30 +0200 Subject: [PATCH 03/13] implement scanning names by owner --- CHANGELOG.md | 5 +++ scripts/resolver/service/snrc-resolve.py | 7 ++++ scripts/resolver/service/test_snrc_resolve.py | 6 ++++ src/Simplex/Messaging/Agent.hs | 4 +-- src/Simplex/Messaging/Agent/Client.hs | 4 +-- src/Simplex/Messaging/Client.hs | 4 +-- src/Simplex/Messaging/Eth/Address.hs | 11 +++--- src/Simplex/Messaging/Server.hs | 4 +-- src/Simplex/Messaging/Server/Names.hs | 35 ++++++++----------- .../Messaging/Server/Names/HttpResolver.hs | 4 ++- tests/RSLVTests.hs | 32 ++++++++--------- 11 files changed, 64 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d336c92c9..15f3eafd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ Crypto: no chain writes; the resolver path remains read-only. See `plans/2026-08-05-eth-crypto-bindings.md`. +SMP protocol: +- `ROWN` command (v23): the names an address owns, and whether the account has + been used at all, so a recovered wallet seed can be scanned for the accounts + already in use. + # 6.5.1 Version 6.5.1.0 diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 997d5d329..c332d4090 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -916,6 +916,13 @@ def owned_by(address: str, offset: int = 0): "message": "expected a 0x-prefixed 20-byte address", } + if offset < 0: + return 400, { + "address": address, + "error": "badOffset", + "message": "offset is a position in the listing", + } + configured = {t: r for t, r in REGISTRARS.items() if r} if not configured: return 400, { diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 92bd7ce8a..52912d682 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -1125,6 +1125,12 @@ def test_a_malformed_address_is_refused(self): self.assertEqual(status, 400) self.assertEqual(body["error"], "badAddress") + 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) + self.assertEqual(status, 400) + self.assertEqual(body["error"], "badOffset") + def test_no_configured_registrar_is_an_error_not_an_empty_answer(self): """An empty list would read as "this key owns nothing", which is the one answer a scan must not invent.""" diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 3e1e707ef..ac6f1806e 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -220,11 +220,11 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import qualified Simplex.Messaging.Crypto.ShortLink as SL import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Names.Record (OwnedNames) import Simplex.Messaging.Notifications.Protocol (DeviceToken, NtfRegCode (NtfRegCode), NtfTknStatus (..), NtfTokenId, PNMessageData (..), pnMessagesP) import Simplex.Messaging.Notifications.Types import Simplex.Messaging.Parsers (defaultJSON, parse) -import Simplex.Messaging.Eth.Address (Address) -import Simplex.Messaging.Names.Record (OwnedNames) import Simplex.Messaging.Protocol ( BrokerMsg, Cmd (..), diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 7fa17cd08..3786bf811 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -261,12 +261,12 @@ import Simplex.Messaging.Crypto.Entitlement (EntitlementCredential, EntitlementP import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Client +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Names.Record (OwnedNames) import Simplex.Messaging.Notifications.Protocol import Simplex.Messaging.Notifications.Transport (NTFVersion) import Simplex.Messaging.Notifications.Types import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parse, sumTypeJSON) -import Simplex.Messaging.Eth.Address (Address) -import Simplex.Messaging.Names.Record (OwnedNames) import Simplex.Messaging.Protocol ( AProtocolType (..), BrokerMsg, diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index fd9e72434..ff94fe2c0 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -156,6 +156,7 @@ import Data.Maybe (catMaybes, fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), diffUTCTime, getCurrentTime) +import Data.Word (Word32) import qualified Data.X509 as X import qualified Data.X509.Validation as XV import Network.Socket (HostName, ServiceName) @@ -164,10 +165,9 @@ import Numeric.Natural import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) -import Data.Word (Word32) import Simplex.Messaging.Eth.Address (Address) import Simplex.Messaging.Names.Record (OwnedNames) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol import Simplex.Messaging.Protocol.Types import Simplex.Messaging.Server.QueueStore.QueueInfo diff --git a/src/Simplex/Messaging/Eth/Address.hs b/src/Simplex/Messaging/Eth/Address.hs index 699938fb4..fd95d5e6d 100644 --- a/src/Simplex/Messaging/Eth/Address.hs +++ b/src/Simplex/Messaging/Eth/Address.hs @@ -32,12 +32,6 @@ instance Show Address where show = BC.unpack . checksumAddress -- | EIP-55 checksummed hex. Parsing accepts bare or @0x@-prefixed hex and verifies a mixed-case checksum. --- | On the wire the address is its EIP-55 hex, length-prefixed, so a field --- following it is not swallowed by the hex parser. -instance Encoding Address where - smpEncode = smpEncode . strEncode - smpP = strDecode <$?> smpP - instance StrEncoding Address where strEncode = checksumAddress strP = do @@ -56,6 +50,11 @@ instance StrEncoding Address where where letters = BC.filter (not . isDigit) body +-- | The EIP-55 hex, length-prefixed, so a field following it is not swallowed by the hex parser. +instance Encoding Address where + smpEncode = smpEncode . strEncode + smpP = strDecode <$?> smpP + addressSize :: Int addressSize = 20 diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 051c4bac8..2c519898f 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -88,6 +88,7 @@ import Data.Time.Clock.System (SystemTime (..), getSystemTime) import Data.Time.Format.ISO8601 (iso8601Show) import Data.Type.Equality import Data.Typeable (cast) +import Data.Word (Word32) import qualified Data.X509 as X import qualified Data.X509.Validation as XV import GHC.Conc.Signal @@ -103,14 +104,13 @@ import Simplex.Messaging.Client.Agent (OwnServer, SMPClientAgent (..), SMPClient import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Eth.Address (Address) import Simplex.Messaging.Protocol import Simplex.Messaging.Server.Control import Simplex.Messaging.Server.Env.STM as Env import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Server.MsgStore import Simplex.Messaging.Server.MsgStore.Journal (JournalMsgStore, JournalQueue (..), getJournalQueueMessages) -import Data.Word (Word32) -import Simplex.Messaging.Eth.Address (Address) import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, ownedNames, resolveName) import Simplex.Messaging.Server.MsgStore.STM import Simplex.Messaging.Server.MsgStore.Types diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 2633ed0ee..703294723 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -20,14 +20,14 @@ import qualified Control.Exception as E import Control.Logger.Simple (logError) import Data.Bifunctor (first) import Data.Maybe (fromMaybe) -import Data.Word (Word32) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) +import Data.Word (Word32) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Protocol (NameErrorType (..), NameQuery, NameResponse) import Simplex.Messaging.Eth.Address (Address) import Simplex.Messaging.Names.Record (OwnedNames) +import Simplex.Messaging.Protocol (NameErrorType (..), NameQuery, NameResponse) import Simplex.Messaging.Server.Names.HttpResolver ( ResolverEnv, ResolverError (..), @@ -66,27 +66,24 @@ pingEndpoint NamesEnv {resolverEnv, config} = fromMaybe (Left ResolverTimeout) <$> timeout (resolverTimeoutMs config * 1000) (healthHttp resolverEnv) 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 - Right result -> pure (fromMaybe (Left (RESOLVER "timeout")) result) - Left e - | Just (_ :: E.SomeAsyncException) <- E.fromException e -> E.throwIO e - | otherwise -> do - logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) - pure (Left (RESOLVER "resolver error")) +resolveName env q = resolverCall env $ fetch env q fetch :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameResponse) fetch NamesEnv {resolverEnv} q = first mapResolverError <$> resolveHttp resolverEnv (decodeLatin1 $ smpEncode q) --- | The names an address owns. A resolver without the endpoint answers 404, --- which mapResolverError reports as RESOLVER, so a scan cannot read "this --- resolver cannot say" as "this address owns nothing". +-- | The names an address owns. A resolver without the endpoint answers 404, which is a RESOLVER error, not an empty list. ownedNames :: NamesEnv -> Address -> Word32 -> IO (Either NameErrorType OwnedNames) -ownedNames env addr offset = do - r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetchOwned env addr offset)) - case r of +ownedNames env addr offset = resolverCall env $ fetchOwned env addr offset + +fetchOwned :: NamesEnv -> Address -> Word32 -> IO (Either NameErrorType OwnedNames) +fetchOwned NamesEnv {resolverEnv} addr offset = + first mapResolverError <$> ownedByHttp resolverEnv (decodeLatin1 $ strEncode addr) offset + +-- | Bound the call by the configured timeout, and report a raised exception as an error, leaving async exceptions alone. +resolverCall :: NamesEnv -> IO (Either NameErrorType a) -> IO (Either NameErrorType a) +resolverCall env a = + E.try (timeout (resolverTimeoutMs (config env) * 1000) a) >>= \case Right result -> pure (fromMaybe (Left (RESOLVER "timeout")) result) Left e | Just (_ :: E.SomeAsyncException) <- E.fromException e -> E.throwIO e @@ -94,10 +91,6 @@ ownedNames env addr offset = do logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) pure (Left (RESOLVER "resolver error")) -fetchOwned :: NamesEnv -> Address -> Word32 -> IO (Either NameErrorType OwnedNames) -fetchOwned NamesEnv {resolverEnv} addr offset = - first mapResolverError <$> ownedByHttp resolverEnv (decodeLatin1 $ strEncode addr) (fromIntegral offset) - mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code)) diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index c9355459b..d3872fff5 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -11,6 +11,7 @@ -- GET /v2/resolve/ -> 200 with a NameRegistration JSON document, for -- all three registration shapes; 400 for unknown -- TLDs, 502 for upstream RPC failures +-- GET /v2/owned-by/ -> 200 with an OwnedNames JSON document -- GET /v1/resolve/ -> 200 with a NameRecord, what relays before SMP -- v22 call as /resolve -- GET /health -> 200 when the resolver process is ready @@ -42,6 +43,7 @@ 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 Network.HTTP.Client ( HttpException, @@ -120,7 +122,7 @@ resolveHttp env q = -- | The address is EIP-55 hex, which is already URL-safe, but it is encoded -- for the same reason the name is: nothing from a client reaches the path raw. -ownedByHttp :: ResolverEnv -> Text -> Int -> IO (Either ResolverError OwnedNames) +ownedByHttp :: ResolverEnv -> Text -> Word32 -> IO (Either ResolverError OwnedNames) ownedByHttp env addr offset = (>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict) <$> httpGet env ("/v2/owned-by/" <> B.unpack (urlEncode True (encodeUtf8 addr)) <> "?offset=" <> show offset) diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index e67aa6b33..525d1eef0 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -119,7 +119,22 @@ rslvTests = do -- | /v2/resolve answers 200, 400 or 502, so a 404 is a resolver that predates -- the route, not a name that does not exist. --- The scan reads inUse, so an account in use with no names must not arrive +testRslvBackendNotFound :: IO () +testRslvBackendNotFound = + withResolverServer (status404, "{}") $ + testSMPClient @TLS $ \h -> do + (corrId, _entId, resp) <- sendRslv h "rs01" (domain "ghost.simplex") + corrId `shouldBe` CorrId "rs01" + resp `shouldBe` Right (ERR (NAME (RESOLVER "HTTP 404"))) + +testRslvBackendHttpErr :: IO () +testRslvBackendHttpErr = + withResolverServer (status502, "{}") $ + testSMPClient @TLS $ \h -> do + (_, _, resp) <- sendRslv h "rs05" (domain "alice.simplex") + resp `shouldBe` Right (ERR (NAME (RESOLVER "HTTP 502"))) + +-- | The scan reads inUse, so an account in use with no names must not arrive -- looking the same as one that owns nothing. testRownOwned :: IO () testRownOwned = @@ -145,21 +160,6 @@ testRownUnsupported = ownedBody :: LB.ByteString ownedBody = "{\"address\":\"0x70997970c51812dc3a010c7d01b50e0d17dc79c8\",\"names\":[{\"name\":\"alice.testing\",\"labelhash\":\"0x9c02\",\"expires\":1821603121,\"status\":\"registered\"}],\"inUse\":true,\"nextOffset\":null}" -testRslvBackendNotFound :: IO () -testRslvBackendNotFound = - withResolverServer (status404, "{}") $ - testSMPClient @TLS $ \h -> do - (corrId, _entId, resp) <- sendRslv h "rs01" (domain "ghost.simplex") - corrId `shouldBe` CorrId "rs01" - resp `shouldBe` Right (ERR (NAME (RESOLVER "HTTP 404"))) - -testRslvBackendHttpErr :: IO () -testRslvBackendHttpErr = - withResolverServer (status502, "{}") $ - testSMPClient @TLS $ \h -> do - (_, _, resp) <- sendRslv h "rs05" (domain "alice.simplex") - resp `shouldBe` Right (ERR (NAME (RESOLVER "HTTP 502"))) - testRslvDisabled :: IO () testRslvDisabled = withSmpServerConfigOn (transport @TLS) memCfg testPort $ const $ From 2ffec2aad6385e179f1f7437b7865ae8ff474244 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 22 Sep 2026 16:40:59 +0200 Subject: [PATCH 04/13] shrink diff --- scripts/resolver/service/snrc-resolve.py | 23 ++++--------------- src/Simplex/Messaging/Agent.hs | 4 ++-- src/Simplex/Messaging/Agent/Client.hs | 29 +++++++----------------- src/Simplex/Messaging/Server/Names.hs | 22 +++++++++--------- 4 files changed, 26 insertions(+), 52 deletions(-) diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index c332d4090..4d33d3d73 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -890,24 +890,11 @@ def owned_by(address: str, offset: int = 0): """Every name an address holds, across every configured TLD, with the account's on-chain footprint. - Read off the ERC-721 registrar rather than from logs: the token is the - name, so `balanceOf` / `tokenOfOwnerByIndex` is the current answer and it - includes names acquired by transfer, which a scan of registration events - would miss. `labelOf` returns the plaintext label, recorded write-once at - registration, so no off-chain index is needed to turn a token id back into - a name. - - Enumeration is deliberately not maintained on expiry, so a lapsed name - stays in the list until someone re-registers it. That is reported rather - than filtered: every entry carries `status`, using the same vocabulary as - /resolve, and a caller scanning a recovered key is exactly the caller who - needs to be told one of its names can still be renewed. - - `inUse` answers the question a recovery scan actually asks - has this - account ever been used - which holding a name is only one way to be. An - account that was funded or ever sent a transaction is in use even with no - name, so the nonce and balance it is derived from are reported too: a scan - that gets this wrong hands out an account its owner is already using. + 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; holding a name is only one way to be in use, so the nonce and balance + it is derived from are reported too. """ if not is_address(address): return 400, { diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index ac6f1806e..c9f35995b 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -1281,12 +1281,12 @@ deleteLocalInvShortLink' c (CSLInvitation _ srv linkId _) = withStore' c $ \db - ownedSimplexNames' :: AgentClient -> NetworkRequestMode -> UserId -> [SMPServer] -> Address -> Word32 -> AM (SMPServer, OwnedNames) ownedSimplexNames' c nm userId used addr offset = do - srv <- getNextNameServerAvoiding c userId used + srv <- getNextNameServer c userId used (srv,) <$> ownedNames c nm userId srv addr offset resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResponse resolveSimplexName' c nm userId domain = do - resolverSrv <- getNextNameServer c userId + resolverSrv <- getNextNameServer c userId [] resolveName c nm userId resolverSrv domain changeConnectionUser' :: AgentClient -> UserId -> ConnId -> UserId -> AM () diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 3786bf811..60776b53d 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -71,7 +71,6 @@ module Simplex.Messaging.Agent.Client getQueueLink, resolveName, ownedNames, - getNextNameServerAvoiding, getNextNameServer, enableQueueNotifications, EnableQueueNtfReq (..), @@ -260,9 +259,9 @@ import Simplex.Messaging.Crypto.BBS (BBSPresHeader (..), BBSPublicKey) import Simplex.Messaging.Crypto.Entitlement (EntitlementCredential, EntitlementProof, generateEntitlementProof) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Notifications.Client import Simplex.Messaging.Eth.Address (Address) import Simplex.Messaging.Names.Record (OwnedNames) +import Simplex.Messaging.Notifications.Client import Simplex.Messaging.Notifications.Protocol import Simplex.Messaging.Notifications.Transport (NTFVersion) import Simplex.Messaging.Notifications.Types @@ -2042,32 +2041,20 @@ ownedNames c nm userId server addr offset = ownedViaProxy smp proxySess = proxyOwnedNames smp nm proxySess addr offset ownedDirectly smp = directOwnedNames smp nm addr offset --- | A names-capable server the caller has not used yet. A scan asks about one --- account per relay, because sending the whole list to one relay would tell it --- those accounts belong to one wallet. With fewer relays than accounts the --- choice wraps onto used ones, which is the best the configured set allows. -getNextNameServerAvoiding :: AgentClient -> UserId -> [SMPServer] -> AM SMPServer -getNextNameServerAvoiding c userId used = - liftIO (TM.lookupIO userId (userServers c :: TMap UserId (UserServers 'PSMP))) >>= \case - Just UserServers {nameSrvs} -> - let unused = filter ((`notElem` used) . protoServer . snd) nameSrvs - in case L.nonEmpty unused of - Just srvs -> protoServer <$> pickServer srvs - Nothing -> case L.nonEmpty nameSrvs of - Just srvs -> protoServer <$> pickServer srvs - Nothing -> throwE NO_NAME_SERVERS - Nothing -> throwE $ INTERNAL "unknown userId - no user servers" - -- | 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. -getNextNameServer :: AgentClient -> UserId -> AM SMPServer -getNextNameServer c userId = +-- 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. +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 srvs + Just srvs -> protoServer <$> pickServer (fromMaybe srvs $ L.nonEmpty $ L.filter (isUnusedServer usedHosts) srvs) 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/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 703294723..06e59098d 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -68,6 +68,17 @@ pingEndpoint NamesEnv {resolverEnv, config} = resolveName :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameResponse) resolveName env q = resolverCall env $ fetch env q +-- | Bound the call by the configured timeout, and report a raised exception as an error, leaving async exceptions alone. +resolverCall :: NamesEnv -> IO (Either NameErrorType a) -> IO (Either NameErrorType a) +resolverCall env a = + E.try (timeout (resolverTimeoutMs (config env) * 1000) a) >>= \case + Right result -> pure (fromMaybe (Left (RESOLVER "timeout")) result) + Left e + | Just (_ :: E.SomeAsyncException) <- E.fromException e -> E.throwIO e + | otherwise -> do + logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) + pure (Left (RESOLVER "resolver error")) + fetch :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameResponse) fetch NamesEnv {resolverEnv} q = first mapResolverError <$> resolveHttp resolverEnv (decodeLatin1 $ smpEncode q) @@ -80,17 +91,6 @@ fetchOwned :: NamesEnv -> Address -> Word32 -> IO (Either NameErrorType OwnedNam fetchOwned NamesEnv {resolverEnv} addr offset = first mapResolverError <$> ownedByHttp resolverEnv (decodeLatin1 $ strEncode addr) offset --- | Bound the call by the configured timeout, and report a raised exception as an error, leaving async exceptions alone. -resolverCall :: NamesEnv -> IO (Either NameErrorType a) -> IO (Either NameErrorType a) -resolverCall env a = - E.try (timeout (resolverTimeoutMs (config env) * 1000) a) >>= \case - Right result -> pure (fromMaybe (Left (RESOLVER "timeout")) result) - Left e - | Just (_ :: E.SomeAsyncException) <- E.fromException e -> E.throwIO e - | otherwise -> do - logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) - pure (Left (RESOLVER "resolver error")) - mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code)) From adcdb54c021fefd78e5131c61d8973d49933bdf0 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 22 Sep 2026 16:50:19 +0200 Subject: [PATCH 05/13] use chain time, not host time --- scripts/resolver/service/snrc-resolve.py | 7 ++++--- scripts/resolver/service/test_snrc_resolve.py | 14 ++++++++++++-- src/Simplex/Messaging/Names/Record.hs | 9 +++++---- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 4d33d3d73..0abc54ad7 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -893,8 +893,9 @@ 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; holding a name is only one way to be in use, so the nonce and balance - it is derived from are reported too. + asks, derived from this chain's nonce and balance and the names above: an + account holding only other tokens is not seen, and neither is one used on + another chain. """ if not is_address(address): return 400, { @@ -919,7 +920,7 @@ def owned_by(address: str, offset: int = 0): "configuredTlds": [], } - now = int(time.time()) + now = chain_now() names, truncated = [], False for tld, registrar in configured.items(): grace = grace_period(registrar) diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 52912d682..3d036b84b 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -1048,12 +1048,13 @@ class OwnedByTests(unittest.TestCase): ALICE = 0x9C0257114EB9399A2985F8E75DAD7600C5D89FE3824FFA99EC1C3EB8BF3B0501 def setUp(self): - self._saved = (snrc.REGISTRARS, snrc.eth_call, snrc.rpc) + self._saved = (snrc.REGISTRARS, snrc.eth_call, snrc.rpc, snrc.chain_now) snrc.REGISTRARS = {"testing": self.REGISTRAR, "simplex": ""} snrc.rpc = lambda method, params: "0x0" + snrc.chain_now = lambda: int(time.time()) def tearDown(self): - snrc.REGISTRARS, snrc.eth_call, snrc.rpc = self._saved + snrc.REGISTRARS, snrc.eth_call, snrc.rpc, snrc.chain_now = self._saved def _chain(self, held, expires, label=b"alice"): def call(to, data): @@ -1125,6 +1126,15 @@ def test_a_malformed_address_is_refused(self): self.assertEqual(status, 400) self.assertEqual(body["error"], "badAddress") + def test_status_follows_the_block_clock_not_the_host(self): + """A name the host clock still calls registered is in grace once the + chain has passed its expiry.""" + expires = int(time.time()) + 86400 + snrc.eth_call = self._chain(1, expires) + snrc.chain_now = lambda: expires + 1 + _, body = snrc.owned_by(self.ADDR) + self.assertEqual(body["names"][0]["status"], "grace") + 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/Names/Record.hs b/src/Simplex/Messaging/Names/Record.hs index 5f8feecd8..51c20e28b 100644 --- a/src/Simplex/Messaging/Names/Record.hs +++ b/src/Simplex/Messaging/Names/Record.hs @@ -92,10 +92,11 @@ data NameRegistration NRReserved {reservedReason :: NameReservedReason} deriving (Eq, Show) --- | What the registry holds for an address: the names it owns, and whether --- the account has been used at all. Holding a name is only one way to be in --- use, so a recovery scan that reads only `ownNames` hands out an account its --- owner is already using. +-- | 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. data OwnedNames = OwnedNames { ownNames :: [OwnedName], ownInUse :: Bool, From 861e9890c606cfb7d0cea13a6206071446ec9977 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 22 Sep 2026 16:59:50 +0200 Subject: [PATCH 06/13] RFC and fixes --- CHANGELOG.md | 2 +- plans/2026-09-22-names-owned-by.md | 78 ++++++++++++++++++++++++++++++ src/Simplex/Messaging/Transport.hs | 1 + 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 plans/2026-09-22-names-owned-by.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f3eafd7..758cf8758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Crypto: SMP protocol: - `ROWN` command (v23): the names an address owns, and whether the account has been used at all, so a recovered wallet seed can be scanned for the accounts - already in use. + already in use. See `plans/2026-09-22-names-owned-by.md`. # 6.5.1 diff --git a/plans/2026-09-22-names-owned-by.md b/plans/2026-09-22-names-owned-by.md new file mode 100644 index 000000000..92a939d4b --- /dev/null +++ b/plans/2026-09-22-names-owned-by.md @@ -0,0 +1,78 @@ +# Owned-by: the names an address owns + +A recovered seed does not say how many of its accounts have been used. Until it +does, a restored device cannot take a fresh account without risking one its +owner already has, and cannot find the names it already holds. This adds the +question the protocol could not ask: what does this address own. + +This is Workstream B of the SimpleX names v2 plan, after the Ethereum crypto +primitives. The client consumer is the wallet recovery scan in simplex-chat. + +## What this delivers + +``` +GET /v2/owned-by/
?offset=N resolver, JSON OwnedNames +ROWN
-> ROWND SMP v23 (nameOwnedSMPVersion) +ownedNames server, agent client, agent +``` + +The agent returns the relay it used alongside the answer, so a caller can pass +it back as used and ask the next account elsewhere. Sending every account of a +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. + +Enumeration is read off the ERC-721 registrar (`balanceOf`, +`tokenOfOwnerByIndex`, `labelOf`), not from registration logs: the token is the +name, so a name acquired by transfer counts. The registrar does not maintain +enumeration on expiry — it states this as an invariant, and burns a token only +when the name is re-registered — so a lapsed name stays listed. It is reported +with its `status` rather than filtered, because a caller scanning a recovered +seed is exactly the caller who needs to be told a name can still be renewed. + +## What `inUse` actually answers + +`inUse` is `holds a name now, or nonce > 0, or balance > 0`, on the one chain +the resolver is configured with. Holding a name is only one way for an account +to be in use: an account that was funded, or ever sent a transaction, is in use +with no name, and a scan that reads names alone hands out an account its owner +is already using. Within that scope the signal is sound — an EOA's balance can +only fall through its own transaction, which bumps the nonce, so "funded then +drained" still reads as used, and an EIP-7702 delegation leaves the authority's +nonce incremented, so it needs no separate code check. + +It does not see: + +- **Accounts holding only other tokens.** An EOA can hold ERC-20s or NFTs with + nonce 0 and no ether. ERC-20 has no reverse index, so answering this needs + `eth_getLogs` over Transfer topics for all history, which providers cap, or an + external indexer. Out of scope here. +- **Accounts used on another chain.** The same key is the same address on every + L2 and sidechain; this chain's nonce and balance stay 0. +- **History, as opposed to current state.** `balanceOf` is present ownership. A + name registered for an account by a controller — which leaves the owner at + nonce 0 — and later transferred away, or lapsed and re-registered by someone + else, leaves no trace. + +The cost is bounded. A name-holding account is always found, so no account that +holds a name is ever mislabelled. A missed account matters only as a gap-limit +reset: the scan can stop up to `scanGapLimit` indexes early and not reach a name +beyond that. That is recovery incompleteness, recoverable by scanning again on a +better signal, not loss. In the other direction a dust transfer marks an account +used; the indexes come from hardened derivation with no published xpub, so they +cannot be enumerated to be dusted. + +## What is deliberately absent + +- **No token or indexer integration**, per the limits above. +- **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. +- **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 + block number is the fix if it ever matters for a recovery scan. diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 6d4f9951f..d3f32ec61 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -178,6 +178,7 @@ smpBlockSize = 16384 -- 20 - public namespaces resolver, RSLV command (6/20/2026) -- 21 - server public information in handshake (7/5/2026) -- 22 - RNAME answers name availability as well as the record (7/25/2026) +-- 23 - ROWN command, the names an address owns (9/22/2026) data SMPVersion From 1d82c0815dcf005f4bbb84c307bae13cb51cdd6b Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 23 Sep 2026 07:58:53 +0200 Subject: [PATCH 07/13] iterate /goal --- plans/2026-09-22-names-owned-by.md | 16 +++++-- protocol/simplex-messaging.md | 43 ++++++++++++++++++- scripts/resolver/README.md | 36 +++++++++++++++- scripts/resolver/service/snrc-resolve.py | 38 +++++++++------- scripts/resolver/service/test_snrc_resolve.py | 17 +++++++- src/Simplex/Messaging/Agent/Client.hs | 8 ++-- src/Simplex/Messaging/Client.hs | 5 ++- src/Simplex/Messaging/Names/Record.hs | 6 +-- src/Simplex/Messaging/Server.hs | 16 ++++--- .../Messaging/Server/Names/HttpResolver.hs | 2 +- src/Simplex/Messaging/Server/Prometheus.hs | 6 +-- tests/NamesResolverServer.hs | 4 +- tests/RSLVTests.hs | 18 +++++++- 13 files changed, 165 insertions(+), 50 deletions(-) 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 @@ 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 @@ 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 @@ def do_GET(self): # noqa: N802 - http.server contract 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 @@ def test_an_untouched_account_is_not_in_use(self): 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 @@ def test_status_follows_the_block_clock_not_the_host(self): _, 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) $ From 4c8dc9f7d86b2572be29e6a603e4d8d65415177f Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 23 Sep 2026 08:54:35 +0200 Subject: [PATCH 08/13] cut some comments --- src/Simplex/Messaging/Names/Record.hs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/Simplex/Messaging/Names/Record.hs b/src/Simplex/Messaging/Names/Record.hs index c483f233f..60e5500a7 100644 --- a/src/Simplex/Messaging/Names/Record.hs +++ b/src/Simplex/Messaging/Names/Record.hs @@ -92,11 +92,7 @@ data NameRegistration NRReserved {reservedReason :: NameReservedReason} deriving (Eq, Show) --- | 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 --- 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. +-- | What the registry holds for an address: the names it owns, and whether the account is in use data OwnedNames = OwnedNames { ownNames :: [OwnedName], ownInUse :: Bool, @@ -105,8 +101,7 @@ data OwnedNames = OwnedNames } deriving (Eq, Show) --- | One name an address holds. Enumeration is not maintained on expiry, so a --- lapsed name stays listed; `onStatus` is how a caller tells it apart. +-- | One name an address holds. A lapsed name stays listed; `onStatus` is how a caller tells it apart. data OwnedName = OwnedName { -- | absent when the registrar never recorded the label onName :: Maybe Text, From 78ebf1cb2eae5c5012829336e4df1b25e8314551 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 23 Sep 2026 09:51:32 +0200 Subject: [PATCH 09/13] reused existing types --- plans/2026-09-22-names-owned-by.md | 15 ++-- protocol/simplex-messaging.md | 9 ++- scripts/resolver/README.md | 16 ++-- scripts/resolver/service/snrc-resolve.py | 44 +++++------ scripts/resolver/service/test_snrc_resolve.py | 73 ++++++++++--------- src/Simplex/Messaging/Names/Record.hs | 21 +----- tests/RSLVTests.hs | 14 +++- 7 files changed, 93 insertions(+), 99 deletions(-) diff --git a/plans/2026-09-22-names-owned-by.md b/plans/2026-09-22-names-owned-by.md index 80ae7c94a..26902613e 100644 --- a/plans/2026-09-22-names-owned-by.md +++ b/plans/2026-09-22-names-owned-by.md @@ -29,11 +29,12 @@ RSLV already does. Enumeration is read off the ERC-721 registrar (`balanceOf`, `tokenOfOwnerByIndex`, `labelOf`), not from registration logs: the token is the -name, so a name acquired by transfer counts. The registrar does not maintain -enumeration on expiry — it states this as an invariant, and burns a token only -when the name is re-registered — so a lapsed name stays listed. It is reported -with its `status` rather than filtered, because a caller scanning a recovered -seed is exactly the caller who needs to be told a name can still be renewed. +name, so a name acquired by transfer counts. Each name is then answered with the +`NameResponse` resolving it gives, so the client that scans can list the names +and act on them without asking again — the reason the per-name cost is worth +paying. The registrar does not maintain enumeration on expiry, so a name past +its grace is still enumerated; it answers as available, naming nothing the +account holds, so it is left out while still counting towards `inUse`. ## What `inUse` actually answers @@ -74,8 +75,8 @@ cannot be enumerated to be dusted. `OwnedNames` keeps only the names, the flag and the cursor, so a client cannot yet see why an account is in use. - **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 + TLD, then for each name the same reads resolving it takes - roughly fifteen + `eth_call`s - plus the 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 diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index 768ddac06..ac70308e4 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1648,12 +1648,15 @@ rownd = %s"ROWND" SP ownedNames | 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` | +| `names` | array | the names the address holds, each the `nameResponse` resolving it answers with | | `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`. +Each name is answered in full, as `RSLV` answers it, so a client can list the +names and act on them without resolving each one again. Enumeration is not +maintained on expiry, so the registrar still enumerates a name past its grace; +it answers as `available` and names nothing the account holds, so it is left out +of `names` while still counting towards `inUse`. `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: diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 7231887af..209beecb5 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -168,15 +168,17 @@ 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 +The body is the SMP protocol's `OwnedNames`: the names the address holds, each +the same `NameResponse` `/v2/resolve` answers with, 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. +Answering each name in full is what lets a caller list and act on them without a +second request for each. Enumeration comes off the registrar's ERC-721 index, so +a name acquired by transfer counts; one past its grace is still enumerated but +answers as available, so it is left out while still counting towards `inUse`. `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 diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index bee038f78..f4e9348c3 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -127,9 +127,10 @@ ZERO_ADDR = "0x0000000000000000000000000000000000000000" # 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"))) +# number of configured TLDs. Each carries a full NameResponse, and a relay caps +# the body it reads at 16000 bytes while 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", "8"))) # The registry prices in attoUSD (1e-18 USD); the protocol carries US cents. @@ -895,12 +896,12 @@ def owned_by(address: str, offset: int = 0): account's on-chain footprint. 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 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. + transfer counts. Each name is answered with the NameResponse /v2/resolve + gives for it, so a caller can list and act on them without asking again. + `inUse` is what a recovery scan 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. """ if not is_address(address): return 400, { @@ -925,7 +926,6 @@ def owned_by(address: str, offset: int = 0): "configuredTlds": [], } - now = chain_now() names, truncated, total_held = [], False, 0 for tld, registrar in configured.items(): held = decode_uint(eth_call(registrar, selector("balanceOf(address)") + encode_address(address))) @@ -933,30 +933,22 @@ def owned_by(address: str, offset: int = 0): last = min(offset + MAX_OWNED, held) if last < held: truncated = True - 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))) - # 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, - "tld": tld, - "labelhash": "0x" + format(token, "064x"), - "expires": expires, - "graceEnds": expires + grace if expires else None, - "status": expiry_status(expires, grace, now), - } - ) + if not label: + continue + status, body = registration(label + "." + tld) + # only a name still held names itself; one past its grace answers as + # available, which says nothing about the token it is enumerated on + if status == 200 and body["registration"]["type"] == "registered": + names.append(body) nonce = decode_uint(rpc("eth_getTransactionCount", [address, "latest"])) balance = decode_uint(rpc("eth_getBalance", [address, "latest"])) - names.sort(key=lambda n: (n["tld"], n["name"] or n["labelhash"])) + names.sort(key=lambda n: n["registration"]["nameRecord"]["name"]) return 200, { "address": address, "names": names, diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index e9f65c300..88c4d6c3a 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -1048,53 +1048,65 @@ class OwnedByTests(unittest.TestCase): ALICE = 0x9C0257114EB9399A2985F8E75DAD7600C5D89FE3824FFA99EC1C3EB8BF3B0501 def setUp(self): - self._saved = (snrc.REGISTRARS, snrc.eth_call, snrc.rpc, snrc.chain_now) + self._saved = (snrc.REGISTRARS, snrc.eth_call, snrc.rpc, snrc.registration) snrc.REGISTRARS = {"testing": self.REGISTRAR, "simplex": ""} snrc.rpc = lambda method, params: "0x0" - snrc.chain_now = lambda: int(time.time()) + # resolving one name is RegistrationV2Tests' subject; what owned-by adds + # is which names to resolve, so the registration itself is stubbed + snrc.registration = lambda name: (200, self.response(name, "registered")) def tearDown(self): - snrc.REGISTRARS, snrc.eth_call, snrc.rpc, snrc.chain_now = self._saved + snrc.REGISTRARS, snrc.eth_call, snrc.rpc, snrc.registration = self._saved + + def response(self, name, type_): + reg = {"type": type_} + if type_ == "registered": + reg["nameRecord"] = {"name": name} + return {"lastBlockTs": 1813000000, "registration": reg} - def _chain(self, held, expires, label=b"alice"): + def _chain(self, held, label=b"alice"): def call(to, data): sel = data[:10] - if sel == snrc.selector("GRACE_PERIOD()"): - return snrc.encode_uint(self.GRACE) if sel == snrc.selector("balanceOf(address)"): return snrc.encode_uint(held) if sel == snrc.selector("tokenOfOwnerByIndex(address,uint256)"): return snrc.encode_uint(self.ALICE) - if sel == snrc.selector("nameExpires(uint256)"): - return snrc.encode_uint(expires) if sel == snrc.selector("labelOf(uint256)"): return "0x" + snrc.encode_uint(32) + snrc.encode_uint(len(label)) + label.hex() + "00" * ((-len(label)) % 32) raise AssertionError("unexpected call " + sel) return call - def test_a_held_name_is_listed_with_its_status(self): - snrc.eth_call = self._chain(1, int(time.time()) + 86400) + def names(self, body): + return [n["registration"]["nameRecord"]["name"] for n in body["names"]] + + def test_a_held_name_is_answered_as_a_full_registration(self): + """The caller acts on the names straight away, so each is the same + NameResponse resolving it would give.""" + snrc.eth_call = self._chain(1) status, body = snrc.owned_by(self.ADDR) self.assertEqual(status, 200) - self.assertEqual([n["name"] for n in body["names"]], ["alice.testing"]) - self.assertEqual(body["names"][0]["status"], "registered") - - def test_a_lapsed_name_is_reported_not_filtered(self): - """A scan of a recovered key is exactly the caller who needs to be told - a name can still be renewed.""" - snrc.eth_call = self._chain(1, int(time.time()) - 86400) + self.assertEqual(self.names(body), ["alice.testing"]) + self.assertEqual(body["names"][0]["registration"]["type"], "registered") + self.assertIn("lastBlockTs", body["names"][0]) + + def test_a_name_past_its_grace_is_not_listed_as_held(self): + """Enumeration keeps it until someone re-registers, but it answers as + available, which names nothing the account still holds.""" + snrc.eth_call = self._chain(1) + snrc.registration = lambda name: (200, self.response(name, "available")) _, body = snrc.owned_by(self.ADDR) - self.assertEqual(body["names"][0]["status"], "grace") + self.assertEqual(body["names"], []) + self.assertTrue(body["inUse"]) def test_holding_a_name_is_in_use(self): - snrc.eth_call = self._chain(1, int(time.time()) + 86400) + snrc.eth_call = self._chain(1) _, body = snrc.owned_by(self.ADDR) self.assertTrue(body["inUse"]) def test_an_account_that_sent_a_transaction_is_in_use_with_no_name(self): """The case a names-only scan gets wrong: an account in use, holding nothing, would be handed out again.""" - snrc.eth_call = self._chain(0, 0) + snrc.eth_call = self._chain(0) snrc.rpc = lambda method, params: "0x3" if method == "eth_getTransactionCount" else "0x0" _, body = snrc.owned_by(self.ADDR) self.assertEqual(body["names"], []) @@ -1102,20 +1114,20 @@ def test_an_account_that_sent_a_transaction_is_in_use_with_no_name(self): self.assertTrue(body["inUse"]) def test_a_funded_account_is_in_use_with_no_name_and_no_nonce(self): - snrc.eth_call = self._chain(0, 0) + snrc.eth_call = self._chain(0) snrc.rpc = lambda method, params: "0x0" if method == "eth_getTransactionCount" else "0xde0b6b3a7640000" _, body = snrc.owned_by(self.ADDR) self.assertEqual(body["balance"], "1000000000000000000") self.assertTrue(body["inUse"]) def test_an_untouched_account_is_not_in_use(self): - snrc.eth_call = self._chain(0, 0) + snrc.eth_call = self._chain(0) _, body = snrc.owned_by(self.ADDR) self.assertFalse(body["inUse"]) self.assertIsNone(body["nextOffset"]) 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) + snrc.eth_call = self._chain(snrc.MAX_OWNED + 1) _, body = snrc.owned_by(self.ADDR) self.assertEqual(body["nextOffset"], snrc.MAX_OWNED) self.assertEqual(len(body["names"]), snrc.MAX_OWNED) @@ -1125,19 +1137,10 @@ def test_a_malformed_address_is_refused(self): self.assertEqual(status, 400) self.assertEqual(body["error"], "badAddress") - def test_status_follows_the_block_clock_not_the_host(self): - """A name the host clock still calls registered is in grace once the - chain has passed its expiry.""" - expires = int(time.time()) + 86400 - snrc.eth_call = self._chain(1, expires) - snrc.chain_now = lambda: expires + 1 - _, 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) + snrc.eth_call = self._chain(1) _, body = snrc.owned_by(self.ADDR, snrc.MAX_OWNED) self.assertEqual(body["names"], []) self.assertTrue(body["inUse"]) @@ -1145,13 +1148,13 @@ def test_a_page_past_the_end_still_reports_the_account_in_use(self): 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") + snrc.eth_call = self._chain(1, 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) + snrc.eth_call = self._chain(1) status, body = snrc.owned_by(self.ADDR, -1) self.assertEqual(status, 400) self.assertEqual(body["error"], "badOffset") diff --git a/src/Simplex/Messaging/Names/Record.hs b/src/Simplex/Messaging/Names/Record.hs index 60e5500a7..03a23acf3 100644 --- a/src/Simplex/Messaging/Names/Record.hs +++ b/src/Simplex/Messaging/Names/Record.hs @@ -14,7 +14,6 @@ module Simplex.Messaging.Names.Record USDCents (..), NameReservedReason (..), OwnedNames (..), - OwnedName (..), ) where @@ -92,25 +91,15 @@ data NameRegistration NRReserved {reservedReason :: NameReservedReason} deriving (Eq, Show) --- | What the registry holds for an address: the names it owns, and whether the account is in use +-- | What the registry holds for an address: the names it owns, each as the 'NameResponse' resolving it gives, and whether the account is in use data OwnedNames = OwnedNames - { ownNames :: [OwnedName], + { ownNames :: [NameResponse], ownInUse :: Bool, -- | the cursor to resume from, absent when the listing is complete ownNextOffset :: Maybe Int } deriving (Eq, Show) --- | One name an address holds. A lapsed name stays listed; `onStatus` is how a caller tells it apart. -data OwnedName = OwnedName - { -- | absent when the registrar never recorded the label - onName :: Maybe Text, - onLabelhash :: Text, - onExpires :: SystemSeconds, - onStatus :: Text - } - deriving (Eq, Show) - -- | Enough to price the name locally, which the router cannot do behind a hash. data NamePricing = NamePricing { -- | US cents per year, for the lengths the registry prices specially @@ -156,10 +145,6 @@ instance ToJSON NameReservedReason where instance FromJSON NameReservedReason where parseJSON = textParseJSON "NameReservedReason" -$(JQ.deriveJSON defaultJSON {J.fieldLabelModifier = dropPrefix "on"} ''OwnedName) - -$(JQ.deriveJSON defaultJSON {J.fieldLabelModifier = dropPrefix "own"} ''OwnedNames) - $(JQ.deriveJSON defaultJSON ''NamePricing) -- taggedObjectJSON, not sumTypeJSON: this JSON is the RNAME payload and the @@ -167,3 +152,5 @@ $(JQ.deriveJSON defaultJSON ''NamePricing) $(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "NR") ''NameRegistration) $(JQ.deriveJSON defaultJSON ''NameResponse) + +$(JQ.deriveJSON defaultJSON {J.fieldLabelModifier = dropPrefix "own"} ''OwnedNames) diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 6b274593b..6471fa8e3 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -28,7 +28,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (strDecode) import SMPNamesTests (availableBody, registeredBody, reservedBody, resolved, testNameRecord, testPricing) import Simplex.Messaging.Eth.Address (Address) -import Simplex.Messaging.Names.Record (OwnedName (..), OwnedNames (..)) +import Simplex.Messaging.Names.Record (OwnedNames (..)) import Simplex.Messaging.Protocol ( BrokerMsg (..), Cmd (..), @@ -145,7 +145,7 @@ testRownOwned = corrId `shouldBe` CorrId "ro01" case resp of Right (ROWND owned) -> do - map onName (ownNames owned) `shouldBe` [Just "alice.testing"] + map ownedName (ownNames owned) `shouldBe` ["alice.simplex"] ownInUse owned `shouldBe` True r -> expectationFailure $ "unexpected " <> show r @@ -158,8 +158,14 @@ testRownUnsupported = (_, _, resp) <- sendRown h "ro02" testAddr resp `shouldBe` Right (ERR (NAME (RESOLVER "HTTP 404"))) +-- | Each owned name is the same NameResponse /v2/resolve answers with. ownedBody :: LB.ByteString -ownedBody = "{\"address\":\"0x70997970c51812dc3a010c7d01b50e0d17dc79c8\",\"names\":[{\"name\":\"alice.testing\",\"labelhash\":\"0x9c02\",\"expires\":1821603121,\"status\":\"registered\"}],\"inUse\":true,\"nextOffset\":null}" +ownedBody = "{\"names\":[" <> registeredBody testNameRecord <> "],\"inUse\":true,\"nextOffset\":null}" + +ownedName :: NameResponse -> Text +ownedName = \case + NameResponse {registration = NRRegistered {nameRecord}} -> SMP.nrName nameRecord + r -> error $ "expected a registered name, got: " <> show r testRslvDisabled :: IO () testRslvDisabled = @@ -218,7 +224,7 @@ 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"] + Right (Right owned) -> map ownedName (ownNames owned) `shouldBe` ["alice.simplex"] _ -> expectationFailure $ "expected Right (Right OwnedNames), got: " <> show r testRslvSuccess :: IO () From b974dbd8c8b7e13f0f6381dfeeb5434bf19a3795 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 23 Sep 2026 10:36:54 +0200 Subject: [PATCH 10/13] add ownLastBlockTs also in case no names were found --- protocol/simplex-messaging.md | 7 +++++ scripts/resolver/README.md | 3 ++- scripts/resolver/service/snrc-resolve.py | 8 ++++++ scripts/resolver/service/test_snrc_resolve.py | 27 ++++++++++++++++--- src/Simplex/Messaging/Names/Record.hs | 4 ++- tests/RSLVTests.hs | 2 +- 6 files changed, 44 insertions(+), 7 deletions(-) diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index ac70308e4..3350b391e 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1648,6 +1648,7 @@ rownd = %s"ROWND" SP ownedNames | Field | JSON type | Constraints | |---|---|---| +| `lastBlockTs` | number | the oldest block any read behind this answer saw | | `names` | array | the names the address holds, each the `nameResponse` resolving it answers with | | `inUse` | boolean | whether the account has been used at all | | `nextOffset` | number | cursor to resume from, absent when the listing is complete | @@ -1658,6 +1659,12 @@ maintained on expiry, so the registrar still enumerates a name past its grace; it answers as `available` and names nothing the account holds, so it is left out of `names` while still counting towards `inUse`. +As with `RNAME`, a router reads through a node of its own, which can lag. Every +name carries the block it was read at, and `lastBlockTs` is the oldest of those +and of the enumeration's own block, so a client can tell a badly lagging router +even from an answer that carries no names — which is the answer a recovery scan +acts on. + `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 diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 209beecb5..dafe139c5 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -176,7 +176,8 @@ command** section of [`protocol/simplex-messaging.md`](../../protocol/simplex-messaging.md). Answering each name in full is what lets a caller list and act on them without a -second request for each. Enumeration comes off the registrar's ERC-721 index, so +second request for each. `lastBlockTs` is the oldest block any of the reads saw, +so an answer with no names still says how far behind the node was. Enumeration comes off the registrar's ERC-721 index, so a name acquired by transfer counts; one past its grace is still enumerated but answers as available, so it is left out while still counting towards `inUse`. diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index f4e9348c3..1c3880d09 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -898,6 +898,8 @@ def owned_by(address: str, offset: int = 0): Enumeration is read off the ERC-721 registrar, so a name acquired by transfer counts. Each name is answered with the NameResponse /v2/resolve gives for it, so a caller can list and act on them without asking again. + `lastBlockTs` is the oldest block any of those reads saw, so a caller can + tell a resolver lagging critically behind even when it holds no names. `inUse` is what a recovery scan 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 @@ -926,6 +928,9 @@ def owned_by(address: str, offset: int = 0): "configuredTlds": [], } + # the block the enumeration below is read at; each name resolved after it + # reports its own, and the oldest of them all is what the answer carries + last_block_ts = chain_now() names, truncated, total_held = [], False, 0 for tld, registrar in configured.items(): held = decode_uint(eth_call(registrar, selector("balanceOf(address)") + encode_address(address))) @@ -945,12 +950,15 @@ def owned_by(address: str, offset: int = 0): # available, which says nothing about the token it is enumerated on if status == 200 and body["registration"]["type"] == "registered": names.append(body) + if body["lastBlockTs"] is not None: + last_block_ts = min(last_block_ts, body["lastBlockTs"]) nonce = decode_uint(rpc("eth_getTransactionCount", [address, "latest"])) balance = decode_uint(rpc("eth_getBalance", [address, "latest"])) names.sort(key=lambda n: n["registration"]["nameRecord"]["name"]) return 200, { "address": address, + "lastBlockTs": last_block_ts, "names": names, "nonce": nonce, "balance": str(balance), diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 88c4d6c3a..49d8c3415 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -1047,22 +1047,25 @@ class OwnedByTests(unittest.TestCase): # keccak-256("alice") ALICE = 0x9C0257114EB9399A2985F8E75DAD7600C5D89FE3824FFA99EC1C3EB8BF3B0501 + BLOCK_TS = 1813000000 + def setUp(self): - self._saved = (snrc.REGISTRARS, snrc.eth_call, snrc.rpc, snrc.registration) + self._saved = (snrc.REGISTRARS, snrc.eth_call, snrc.rpc, snrc.registration, snrc.chain_now) snrc.REGISTRARS = {"testing": self.REGISTRAR, "simplex": ""} snrc.rpc = lambda method, params: "0x0" + snrc.chain_now = lambda: self.BLOCK_TS # resolving one name is RegistrationV2Tests' subject; what owned-by adds # is which names to resolve, so the registration itself is stubbed snrc.registration = lambda name: (200, self.response(name, "registered")) def tearDown(self): - snrc.REGISTRARS, snrc.eth_call, snrc.rpc, snrc.registration = self._saved + snrc.REGISTRARS, snrc.eth_call, snrc.rpc, snrc.registration, snrc.chain_now = self._saved - def response(self, name, type_): + def response(self, name, type_, block_ts=None): reg = {"type": type_} if type_ == "registered": reg["nameRecord"] = {"name": name} - return {"lastBlockTs": 1813000000, "registration": reg} + return {"lastBlockTs": self.BLOCK_TS if block_ts is None else block_ts, "registration": reg} def _chain(self, held, label=b"alice"): def call(to, data): @@ -1153,6 +1156,22 @@ def test_a_label_that_is_not_utf8_is_reported_not_fatal(self): self.assertEqual(status, 200) self.assertEqual(len(body["names"]), 1) + def test_an_account_with_no_names_still_reports_the_block_it_was_read_at(self): + """inUse false is the answer a scan acts on, so it has to carry how + stale the registry it came from is.""" + snrc.eth_call = self._chain(0) + _, body = snrc.owned_by(self.ADDR) + self.assertEqual(body["names"], []) + self.assertEqual(body["lastBlockTs"], self.BLOCK_TS) + + def test_the_oldest_block_of_every_read_is_the_one_reported(self): + """A name resolved against a node further behind is what the caller + has to judge the whole answer by.""" + snrc.eth_call = self._chain(1) + snrc.registration = lambda name: (200, self.response(name, "registered", self.BLOCK_TS - 600)) + _, body = snrc.owned_by(self.ADDR) + self.assertEqual(body["lastBlockTs"], self.BLOCK_TS - 600) + def test_a_negative_offset_is_refused(self): snrc.eth_call = self._chain(1) status, body = snrc.owned_by(self.ADDR, -1) diff --git a/src/Simplex/Messaging/Names/Record.hs b/src/Simplex/Messaging/Names/Record.hs index 03a23acf3..f62ea8ab4 100644 --- a/src/Simplex/Messaging/Names/Record.hs +++ b/src/Simplex/Messaging/Names/Record.hs @@ -93,7 +93,9 @@ data NameRegistration -- | What the registry holds for an address: the names it owns, each as the 'NameResponse' resolving it gives, and whether the account is in use data OwnedNames = OwnedNames - { ownNames :: [NameResponse], + { -- | the oldest block any of the reads behind this answer saw, so a lagging registry shows even where no name does + ownLastBlockTs :: Maybe SystemSeconds, + ownNames :: [NameResponse], ownInUse :: Bool, -- | the cursor to resume from, absent when the listing is complete ownNextOffset :: Maybe Int diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 6471fa8e3..b28d38c15 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -160,7 +160,7 @@ testRownUnsupported = -- | Each owned name is the same NameResponse /v2/resolve answers with. ownedBody :: LB.ByteString -ownedBody = "{\"names\":[" <> registeredBody testNameRecord <> "],\"inUse\":true,\"nextOffset\":null}" +ownedBody = "{\"lastBlockTs\":1813000000,\"names\":[" <> registeredBody testNameRecord <> "],\"inUse\":true,\"nextOffset\":null}" ownedName :: NameResponse -> Text ownedName = \case From 21164fce3c163c20891b21da7dd0608c677ede74 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 23 Sep 2026 10:57:17 +0200 Subject: [PATCH 11/13] retry different relay upon failure --- plans/2026-09-22-names-owned-by.md | 5 ++++- src/Simplex/Messaging/Agent.hs | 20 +++++++++++++++---- tests/AgentTests/ResolveNameTests.hs | 30 +++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/plans/2026-09-22-names-owned-by.md b/plans/2026-09-22-names-owned-by.md index 26902613e..432f42120 100644 --- a/plans/2026-09-22-names-owned-by.md +++ b/plans/2026-09-22-names-owned-by.md @@ -17,7 +17,10 @@ ownedSimplexNames agent, over ownedNames in the server ``` The agent returns the relay it used alongside the answer, so a caller can pass -it back as used and ask the next account elsewhere. Sending every account of a +it back as used and ask the next account elsewhere. A relay that cannot answer +at all - too old for ROWN, unreachable, or with no resolver of its own behind it +- is not the end of the lookup: it asks another, so a scan does not fail +wholesale on the one relay it happened to draw during a version rollout. Sending every account of a 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 diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index c9f35995b..d922f645b 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -228,7 +228,7 @@ import Simplex.Messaging.Parsers (defaultJSON, parse) import Simplex.Messaging.Protocol ( BrokerMsg, Cmd (..), - ErrorType (AUTH), + ErrorType (AUTH, NAME), MsgBody, MsgFlags (..), NameResponse, @@ -1280,9 +1280,21 @@ deleteLocalInvShortLink' :: AgentClient -> ConnShortLink 'CMInvitation -> AM () deleteLocalInvShortLink' c (CSLInvitation _ srv linkId _) = withStore' c $ \db -> deleteInvShortLink db srv linkId ownedSimplexNames' :: AgentClient -> NetworkRequestMode -> UserId -> [SMPServer] -> Address -> Word32 -> AM (SMPServer, OwnedNames) -ownedSimplexNames' c nm userId used addr offset = do - srv <- getNextNameServer c userId used - (srv,) <$> ownedNames c nm userId srv addr offset +ownedSimplexNames' c nm userId used addr offset = tryRelays ownedNamesRelays used + where + tryRelays attempts tried = do + srv <- getNextNameServer c userId tried + ((srv,) <$> ownedNames c nm userId srv addr offset) `catchError` \e -> + if attempts > 1 && cannotAnswer e then tryRelays (attempts - 1) (srv : tried) else throwE e + -- a relay that cannot answer, as against one that answers: too old for ROWN, + -- unreachable, or with no resolver of its own behind it + cannotAnswer e = temporaryOrHostError e || case e of + SMP _ (NAME _) -> True + _ -> False + +-- | Relays one owned-names lookup asks before it gives up: during a version rollout the first one picked may have no ROWN. +ownedNamesRelays :: Int +ownedNamesRelays = 3 resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResponse resolveSimplexName' c nm userId domain = do diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index d8507194e..bea2c1727 100644 --- a/tests/AgentTests/ResolveNameTests.hs +++ b/tests/AgentTests/ResolveNameTests.hs @@ -15,6 +15,7 @@ module AgentTests.ResolveNameTests (resolveNameTests) where import AgentTests.FunctionalAPITests (withAgent) import Control.Monad.Except (runExceptT) import qualified Data.ByteString.Lazy as LB +import Data.IORef (IORef, readIORef) import Data.List (isInfixOf) import Network.HTTP.Types (Status, status200, status404, status502) import NamesResolverServer (memCfg, memCfg2, memProxyCfg, withNames) @@ -22,7 +23,10 @@ import qualified NamesResolverServer as NRS import SMPAgentClient import SMPClient import SMPNamesTests (availableBody, registeredBody, testNameRecord) -import Simplex.Messaging.Agent (resolveSimplexName) +import Data.Text (Text) +import Simplex.Messaging.Agent (ownedSimplexNames, resolveSimplexName) +import Simplex.Messaging.Encoding.String (strDecode) +import Simplex.Messaging.Eth.Address (Address) import Simplex.Messaging.Agent.Client (AgentClient) import Simplex.Messaging.Agent.Env.SQLite (InitialAgentServers (..), ServerCfg, ServerRoles (..), presetServerCfg) import Simplex.Messaging.Agent.Protocol (AgentErrorType (..)) @@ -49,6 +53,13 @@ withDirectResolver (st, body) k = withSmpServerConfigOn (transport @TLS) (withNames port memCfg) testPort $ \_ -> withAgent 1 agentCfg (oneSrv (nameSrvCfg testSMPServer)) testDB k +-- | As 'withDirectResolver', with the requests the resolver was asked for. +withDirectResolverReqs :: (Status, LB.ByteString) -> (AgentClient -> IORef [[Text]] -> IO a) -> IO a +withDirectResolverReqs (st, body) k = + NRS.withResolverServer (NRS.resolveResp st body) $ \port reqs -> + withSmpServerConfigOn (transport @TLS) (withNames port memCfg) testPort $ \_ -> + withAgent 1 agentCfg (oneSrv (nameSrvCfg testSMPServer)) testDB $ \c -> k c reqs + withProxyAndResolver :: (Status, LB.ByteString) -> (AgentClient -> IO a) -> IO a withProxyAndResolver (st, body) k = NRS.withResolverServer (NRS.resolveResp st body) $ \port _ -> @@ -87,6 +98,23 @@ resolveNameTests = do it "returns NameRecord" testDirectSuccess describe "name availability" $ it "an unregistered name answers as available" testAvailSuccess + describe "owned names" $ + it "a relay that cannot answer is not the end of the lookup" testOwnedRetriesRelays + +-- | A relay too old for ROWN, unreachable, or with no resolver behind it must +-- not end a recovery scan at the account it was asked about. +testOwnedRetriesRelays :: HasCallStack => IO () +testOwnedRetriesRelays = + withDirectResolverReqs (status502, "{}") $ \c reqs -> do + r <- runExceptT $ ownedSimplexNames c NRMInteractive 1 [] testAddr 0 + case r of + Left (SMP _ (SMP.NAME _)) -> pure () + _ -> expectationFailure $ "expected Left (SMP _ (NAME ..)), got: " <> show r + asked <- readIORef reqs + length [q | q@("v2" : "owned-by" : _) <- asked] `shouldBe` 3 + +testAddr :: Address +testAddr = either error id $ strDecode "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" testAvailSuccess :: HasCallStack => IO () testAvailSuccess = From c1e062ba123373f5615820dbf3d51e01dac61d35 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 23 Sep 2026 11:42:03 +0200 Subject: [PATCH 12/13] cut comments --- scripts/resolver/service/snrc-resolve.py | 16 ++++------------ scripts/resolver/service/test_snrc_resolve.py | 3 +-- src/Simplex/Messaging/Agent.hs | 6 ++---- src/Simplex/Messaging/Agent/Client.hs | 6 ++---- src/Simplex/Messaging/Client.hs | 4 +--- src/Simplex/Messaging/Server.hs | 1 - .../Messaging/Server/Names/HttpResolver.hs | 3 +-- src/Simplex/Messaging/Transport.hs | 8 ++------ tests/AgentTests/ResolveNameTests.hs | 3 +-- tests/NamesResolverServer.hs | 3 +-- tests/RSLVTests.hs | 9 +++------ 11 files changed, 18 insertions(+), 44 deletions(-) diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 1c3880d09..be61ce1f0 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -126,10 +126,7 @@ ZERO_ADDR = "0x0000000000000000000000000000000000000000" -# Names per registrar per /owned-by page, so a page holds this many times the -# number of configured TLDs. Each carries a full NameResponse, and a relay caps -# the body it reads at 16000 bytes while a client cannot ask for a smaller page, -# so keep the product well under it. +# Names per registrar per /owned-by page; times the configured TLDs, a full page of NameResponses must fit the 16000 bytes a relay reads. MAX_OWNED = max(1, int(os.environ.get("SNRC_MAX_OWNED", "8"))) # The registry prices in attoUSD (1e-18 USD); the protocol carries US cents. @@ -928,8 +925,7 @@ def owned_by(address: str, offset: int = 0): "configuredTlds": [], } - # the block the enumeration below is read at; each name resolved after it - # reports its own, and the oldest of them all is what the answer carries + # the oldest block behind the answer: this one, or any name's own last_block_ts = chain_now() names, truncated, total_held = [], False, 0 for tld, registrar in configured.items(): @@ -946,8 +942,7 @@ def owned_by(address: str, offset: int = 0): if not label: continue status, body = registration(label + "." + tld) - # only a name still held names itself; one past its grace answers as - # available, which says nothing about the token it is enumerated on + # one past its grace answers as available, naming nothing it holds if status == 200 and body["registration"]["type"] == "registered": names.append(body) if body["lastBlockTs"] is not None: @@ -962,12 +957,9 @@ def owned_by(address: str, offset: int = 0): "names": names, "nonce": nonce, "balance": str(balance), - # 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 + # every name held, not just this page, or page two reads as owning 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, "checkedTlds": sorted(configured), } diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 49d8c3415..fd417e57c 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -1054,8 +1054,7 @@ def setUp(self): snrc.REGISTRARS = {"testing": self.REGISTRAR, "simplex": ""} snrc.rpc = lambda method, params: "0x0" snrc.chain_now = lambda: self.BLOCK_TS - # resolving one name is RegistrationV2Tests' subject; what owned-by adds - # is which names to resolve, so the registration itself is stubbed + # RegistrationV2Tests covers resolving one; owned-by picks which snrc.registration = lambda name: (200, self.response(name, "registered")) def tearDown(self): diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index d922f645b..e886b8fe5 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -468,8 +468,7 @@ resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDoma resolveSimplexName c nm userId domain = withAgentEnv c $ resolveSimplexName' c nm userId domain {-# INLINE resolveSimplexName #-} --- | Names an address owns. The relay used is returned so a scan can pass it --- back as used and ask the next account elsewhere. +-- | Names an address owns, with the relay used, so a scan can ask the next account elsewhere. ownedSimplexNames :: AgentClient -> NetworkRequestMode -> UserId -> [SMPServer] -> Address -> Word32 -> AE (SMPServer, OwnedNames) ownedSimplexNames c nm userId used addr offset = withAgentEnv c $ ownedSimplexNames' c nm userId used addr offset {-# INLINE ownedSimplexNames #-} @@ -1286,8 +1285,7 @@ ownedSimplexNames' c nm userId used addr offset = tryRelays ownedNamesRelays use srv <- getNextNameServer c userId tried ((srv,) <$> ownedNames c nm userId srv addr offset) `catchError` \e -> if attempts > 1 && cannotAnswer e then tryRelays (attempts - 1) (srv : tried) else throwE e - -- a relay that cannot answer, as against one that answers: too old for ROWN, - -- unreachable, or with no resolver of its own behind it + -- cannot answer, as against answers: too old for ROWN, unreachable, or with no resolver cannotAnswer e = temporaryOrHostError e || case e of SMP _ (NAME _) -> True _ -> False diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 5000008f7..637ef0c16 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -2032,8 +2032,7 @@ resolveName c nm userId server domain = resolveViaProxy smp proxySess = proxyResolveName smp nm proxySess domain resolveDirectly smp = directResolveName smp nm domain --- | Names an address owns, from one names-capable relay. Mirrors resolveName: --- proxied where the network config allows it, direct otherwise. +-- | Names an address owns, from one names-capable relay, proxied as resolveName is. ownedNames :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> Address -> Word32 -> AM OwnedNames ownedNames c nm userId server addr offset = snd <$> sendOrProxySMPCommand c nm userId server "" "ROWN" NoEntity ownedViaProxy ownedDirectly @@ -2044,8 +2043,7 @@ 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, operator first: one --- operator asked about every account of a scan learns they are one wallet. +-- Used servers are avoided where the set allows, operator first: one operator asked about every account learns the wallet. getNextNameServer :: AgentClient -> UserId -> [SMPServer] -> AM SMPServer getNextNameServer c userId usedSrvs = liftIO (TM.lookupIO userId (userServers c :: TMap UserId (UserServers 'PSMP))) >>= \case diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 2edc817e9..833a9500e 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -1081,9 +1081,7 @@ directResolveName c nm name r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion --- | 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. +-- | Names an address owns, via PFWD: a scan links accounts, 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/Server.hs b/src/Simplex/Messaging/Server.hs index df71acd4b..55e542851 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -1513,7 +1513,6 @@ client 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 st <- asks (rslvStats . serverStats) diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index b9442906e..faa76d329 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -120,8 +120,7 @@ resolveHttp env q = (>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict) <$> httpGet env ("/v2/resolve/" <> B.unpack (urlEncode True (encodeUtf8 q))) --- | The address is EIP-55 hex, which is already URL-safe, but it is encoded --- for the same reason the name is: nothing from a client reaches the path raw. +-- | The address is encoded for the same reason the name is: nothing from a client reaches the path raw. ownedByHttp :: ResolverEnv -> Text -> Word32 -> IO (Either ResolverError OwnedNames) ownedByHttp env addr offset = (>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict) diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index d3f32ec61..a3ef423fd 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -220,8 +220,7 @@ serverInfoSMPVersion = VersionSMP 21 nameAvailSMPVersion :: VersionSMP nameAvailSMPVersion = VersionSMP 22 --- | ROWN lists the names an address owns. A server below this does not answer --- it, which a recovery scan must not read as the address owning nothing. +-- | ROWN lists the names an address owns; below this a server does not answer it, which is not the same as owning nothing. nameOwnedSMPVersion :: VersionSMP nameOwnedSMPVersion = VersionSMP 23 @@ -240,10 +239,7 @@ currentServerSMPRelayVersion = VersionSMP 23 -- Max SMP protocol version to be used in e2e encrypted connection between -- client and server, as defined by SMP proxy. Normally set below the current -- version to prevent client version fingerprinting by the destination relays --- when clients upgrade at different times. Pinned to the current version (23) --- for this release because a proxied ROWN is only answered from --- nameOwnedSMPVersion (23), and a scan that cannot proxy exposes its address --- to the relay; the buffer reappears once the current version advances past 23. +-- when clients upgrade at different times. Pinned to the current version (23) because a proxied ROWN needs it, and the buffer returns once current passes 23. proxiedSMPRelayVersion :: VersionSMP proxiedSMPRelayVersion = VersionSMP 23 diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index bea2c1727..82b4e6f5a 100644 --- a/tests/AgentTests/ResolveNameTests.hs +++ b/tests/AgentTests/ResolveNameTests.hs @@ -101,8 +101,7 @@ resolveNameTests = do describe "owned names" $ it "a relay that cannot answer is not the end of the lookup" testOwnedRetriesRelays --- | A relay too old for ROWN, unreachable, or with no resolver behind it must --- not end a recovery scan at the account it was asked about. +-- | A relay that cannot answer must not end a scan at the account it was asked about. testOwnedRetriesRelays :: HasCallStack => IO () testOwnedRetriesRelays = withDirectResolverReqs (status502, "{}") $ \c reqs -> do diff --git a/tests/NamesResolverServer.hs b/tests/NamesResolverServer.hs index 2ec26d056..a21c9df1c 100644 --- a/tests/NamesResolverServer.hs +++ b/tests/NamesResolverServer.hs @@ -47,8 +47,7 @@ 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 a name with --- NameRegistration JSON, and an address with OwnedNames. +-- | The resolver API is versioned on its own: v2 answers a name with NameRegistration, 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 b28d38c15..40f7c21ee 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -135,8 +135,7 @@ testRslvBackendHttpErr = (_, _, resp) <- sendRslv h "rs05" (domain "alice.simplex") resp `shouldBe` Right (ERR (NAME (RESOLVER "HTTP 502"))) --- | The scan reads inUse, so an account in use with no names must not arrive --- looking the same as one that owns nothing. +-- | The scan reads inUse, so in use with no names must not look like owning nothing. testRownOwned :: IO () testRownOwned = withResolverServer (status200, ownedBody) $ @@ -149,8 +148,7 @@ testRownOwned = ownInUse owned `shouldBe` True r -> expectationFailure $ "unexpected " <> show r --- A resolver that does not serve owned-by answers 404, which must reach the --- client as a resolver error: read as "owns nothing" it would end a scan early. +-- 404 must reach the client as a resolver error; read as "owns nothing" it would end a scan early. testRownUnsupported :: IO () testRownUnsupported = withResolverServer (status404, "{}") $ @@ -218,8 +216,7 @@ 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. +-- | Without this a scan falls back to a direct session, handing the relay the address with the IP. testRownForwarded :: IO () testRownForwarded = withProxyAndResolver (status200, ownedBody) $ From 6275e7ee5e5e83c3d03ea613b4ad7eea2511c879 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 23 Sep 2026 11:50:37 +0200 Subject: [PATCH 13/13] iterate /goal --- src/Simplex/Messaging/Agent.hs | 10 +++++----- tests/AgentTests/ResolveNameTests.hs | 5 +---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index e886b8fe5..c6d68e20e 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -1278,6 +1278,11 @@ 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 NameResponse +resolveSimplexName' c nm userId domain = do + resolverSrv <- getNextNameServer c userId [] + resolveName c nm userId resolverSrv domain + ownedSimplexNames' :: AgentClient -> NetworkRequestMode -> UserId -> [SMPServer] -> Address -> Word32 -> AM (SMPServer, OwnedNames) ownedSimplexNames' c nm userId used addr offset = tryRelays ownedNamesRelays used where @@ -1294,11 +1299,6 @@ ownedSimplexNames' c nm userId used addr offset = tryRelays ownedNamesRelays use ownedNamesRelays :: Int ownedNamesRelays = 3 -resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResponse -resolveSimplexName' c nm userId domain = do - resolverSrv <- getNextNameServer c userId [] - resolveName c nm userId resolverSrv domain - changeConnectionUser' :: AgentClient -> UserId -> ConnId -> UserId -> AM () changeConnectionUser' c oldUserId connId newUserId = do SomeConn _ conn <- withStore c (`getConn` connId) diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index 82b4e6f5a..0a17eae55 100644 --- a/tests/AgentTests/ResolveNameTests.hs +++ b/tests/AgentTests/ResolveNameTests.hs @@ -48,10 +48,7 @@ oneSrv :: ServerCfg 'SMP.PSMP -> InitialAgentServers oneSrv cfg_ = (initAgentServersProxy_ SPMNever SPFProhibit) {smp = [(1, [cfg_])]} withDirectResolver :: (Status, LB.ByteString) -> (AgentClient -> IO a) -> IO a -withDirectResolver (st, body) k = - NRS.withResolverServer (NRS.resolveResp st body) $ \port _ -> - withSmpServerConfigOn (transport @TLS) (withNames port memCfg) testPort $ \_ -> - withAgent 1 agentCfg (oneSrv (nameSrvCfg testSMPServer)) testDB k +withDirectResolver resp k = withDirectResolverReqs resp $ \c _ -> k c -- | As 'withDirectResolver', with the requests the resolver was asked for. withDirectResolverReqs :: (Status, LB.ByteString) -> (AgentClient -> IORef [[Text]] -> IO a) -> IO a