From 78ebf1cb2eae5c5012829336e4df1b25e8314551 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 23 Sep 2026 09:51:32 +0200 Subject: [PATCH] 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 | 71 ++++++++++--------- src/Simplex/Messaging/Names/Record.hs | 21 ++---- tests/RSLVTests.hs | 14 ++-- 7 files changed, 92 insertions(+), 98 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 @@ COIN_DOT = 354 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 _chain(self, held, expires, label=b"alice"): + def response(self, name, type_): + reg = {"type": type_} + if type_ == "registered": + reg["nameRecord"] = {"name": name} + return {"lastBlockTs": 1813000000, "registration": reg} + + 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") + self.assertEqual(self.names(body), ["alice.testing"]) + self.assertEqual(body["names"][0]["registration"]["type"], "registered") + self.assertIn("lastBlockTs", body["names"][0]) - 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) + 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 @@ class OwnedByTests(unittest.TestCase): 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 @@ class OwnedByTests(unittest.TestCase): 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 @@ class OwnedByTests(unittest.TestCase): 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 ()