mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-11-04 08:37:49 +00:00
reused existing types
This commit is contained in:
1 parent
4c8dc9f7d8
commit
78ebf1cb2e
7 files changed
+92
-98
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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 ()
|
||||
|
||||
Reference in new issue
Block a user