reused existing types

This commit is contained in:
Alain Brenzikofer committed 2026-09-23 09:51:32 +02:00
1 parent 4c8dc9f7d8
commit 78ebf1cb2e
7 files changed
+92 -98

No files matched your search

+8 -7
View File
@@ -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
+6 -3
View File
@@ -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:
+9 -7
View File
@@ -168,15 +168,17 @@ bounds its lifetime. A subname nobody created reports as not registered.
### v2: `/v2/owned-by/<address>?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
+18 -26
View File
@@ -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,
+37 -34
View File
@@ -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")
+4 -17
View File
@@ -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)
+10 -4
View File
@@ -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 ()