Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions protocol/simplex-messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -1526,12 +1526,24 @@ fact that this router cannot resolve, so iterating past it is safe.
`RNAME` answers both what a name resolves to and whether it can be registered.

```abnf
rname = %s"RNAME" SP registration
rname = %s"RNAME" SP nameResponse
```

`registration` is a UTF-8 JSON object consuming the remainder of the
transmission. Its `type` selects which of the three answers it is. Money is US
cents, times are seconds since the Unix epoch, and lengths are characters.
`nameResponse` is a UTF-8 JSON object consuming the remainder of the
transmission: the `registration` below, and `lastBlockTs`, the block timestamp the
registry was read at. Money is US cents, times are seconds since the Unix epoch, and lengths are
characters.

| Field | JSON type | Constraints |
|---|---|---|
| `lastBlockTs` | number | block timestamp the answer was read at. Absent only from a v20/v21 router, which sent the record alone |
| `registration` | object | below |

A router reads the registry through a node of its own, which can lag. A name
registered or changed after `lastBlockTs` still reads as it did before, so a client
MUST NOT treat an answer as current without checking it.

`registration`'s `type` selects which of the three answers it is.

| `type` | Meaning |
|---|---|
Expand Down
13 changes: 10 additions & 3 deletions scripts/resolver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,14 @@ curl -s -X POST http://127.0.0.1:8545 \
**2. resolver is healthy:**
```sh
curl -s http://127.0.0.1:8000/health | jq
# → {"ok": true, "rpc": "http://reth:8545", "registries": {"testing": "0x…", "simplex": ""}}
# → {"ok": true, "rpc": "http://reth:8545", "registries": {"testing": "0x…", "simplex": ""},
# "blockNumber": 23400000, "chainLagSeconds": 12}
```

`chainLagSeconds` is how far the node is behind the wall clock. A resolver that
is reachable and answering can still be hours behind, and every name it reports
is that stale. `null` means the node could not be reached.

**3. resolver resolves a live name** (`foobar.testing` is a populated test name):
```sh
curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq
Expand Down Expand Up @@ -129,8 +134,10 @@ its own shape does:

### v2: `/v2/resolve/<query>`

The body is the SMP protocol's `NameRegistration`, which the router decodes as
is and forwards; translating the registry's model to it is this resolver's job.
The body is the SMP protocol's `NameResponse`, which the router decodes as is
and forwards; translating the registry's model to it is this resolver's job. It
is the registration below, plus `lastBlockTs`, the timestamp of the block it was read
at: a node that lags answers with names it has not seen registered yet.
Its `type` is `registered`, `available` or `reserved`, and the fields each one
carries are specified once, in the **Name response** section of
[`protocol/simplex-messaging.md`](../../protocol/simplex-messaging.md). It is
Expand Down
47 changes: 32 additions & 15 deletions scripts/resolver/service/snrc-resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
import json
import os
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote, urlparse
from urllib.request import Request, urlopen
Expand Down Expand Up @@ -183,6 +184,19 @@ def node_of(name: str) -> bytes:
# ---------- Registration status ----------


def head_block():
"""How far behind the node is. Unlike expiry, this is the one thing that has
to be measured against the host clock: a node that stops still has a block."""
try:
block = rpc("eth_getBlockByNumber", ["latest", False])
return {
"blockNumber": decode_uint(block["number"]),
"chainLagSeconds": int(time.time()) - decode_uint(block["timestamp"]),
}
except Exception:
return {"blockNumber": None, "chainLagSeconds": None}


def chain_now() -> int:
"""Expiry is compared against the block timestamp, never the host clock."""
block = rpc("eth_getBlockByNumber", ["latest", False])
Expand Down Expand Up @@ -302,6 +316,8 @@ def name_status(name: str):
if not registrar or len(labels) < 2:
return {
"status": "unknown",
# nothing was read, so there is no block to report
"lastBlockTs": None,
"expires": None,
"graceEnds": None,
"reasonCode": None,
Expand All @@ -315,12 +331,9 @@ def name_status(name: str):
expires = decode_uint(
eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token))
)
if expires == 0:
status, grace, now = "unregistered", 0, 0
else:
grace = grace_period(registrar)
now = chain_now()
status = expiry_status(expires, grace, now)
grace = grace_period(registrar) if expires else 0
now = chain_now()
status = expiry_status(expires, grace, now)

# A reservation is orthogonal to the registration: a registered name can be
# held back too.
Expand All @@ -329,6 +342,8 @@ def name_status(name: str):

out = {
"status": status,
# the block this was read at
"lastBlockTs": now,
"expires": expires or None,
"graceEnds": (expires + grace) if expires else None,
"reasonCode": reason[0] if reason else None,
Expand Down Expand Up @@ -708,6 +723,11 @@ def name_record(name: str):
return rec


def name_response(reg, registration_body):
"""The SMP protocol's NameResponse: the registration and the block read at."""
return 200, {"lastBlockTs": reg["lastBlockTs"], "registration": registration_body}


def registration(name: str):
"""The SMP protocol's NameRegistration, which the relay decodes as is.
Translating the contract's model to it is this resolver's job."""
Expand All @@ -732,26 +752,26 @@ def registration(name: str):
if pricing:
reg.update({k: v for k, v in pricing.items() if not k.startswith("_")})
else:
return 200, {
return name_response(reg, {
"type": "registered",
"expires": reg["expires"],
"graceUntil": reg["graceEnds"],
"reservedReason_": reg["reasonCode"],
"nameRecord": rec,
}
})
if reg["reasonCode"]:
return 200, {"type": "reserved", "reservedReason": reg["reasonCode"]}
return name_response(reg, {"type": "reserved", "reservedReason": reg["reasonCode"]})
if status in ("unregistered", "expired"):
if "basePrice" not in reg:
return 502, {"name": name, "error": "noPriceOracle"}
return 200, {
return name_response(reg, {
"type": "available",
"pricing": {
"registrationPrices": reg["registrationPrices"],
"basePrice": reg["basePrice"],
"minLabelLength": reg["minLabelLength"],
},
}
})
return 502, {"name": name, "error": status}


Expand Down Expand Up @@ -863,10 +883,7 @@ def do_GET(self): # noqa: N802 - http.server contract
parts = [unquote(p) for p in path.split("/") if p]

if parts == ["health"]:
self._respond(
200,
{"ok": True, "rpc": RPC, "registries": REGISTRIES},
)
self._respond(200, {"ok": True, "rpc": RPC, "registries": REGISTRIES, **head_block()})
return

if len(parts) == 3 and parts[0] == "v2" and parts[1] == "resolve":
Expand Down
66 changes: 46 additions & 20 deletions scripts/resolver/service/test_snrc_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
_SPEC.loader.exec_module(snrc)


def registration(name):
"""registration() answers a NameResponse; most tests assert what is in it."""
status, body = snrc.registration(name)
return status, (body["registration"] if status == 200 else body)


class SplitLinksTests(unittest.TestCase):
"""`split_links` decodes the multi-URL convention for simplex.contact /
simplex.channel text records. Reuses the same rule the dApp's
Expand Down Expand Up @@ -191,10 +197,11 @@ def eth_call(to, data):

return eth_call

def _keys(self, status, expires, grace_ends):
def _keys(self, status, expires, grace_ends, read_at=-1):
"""Every branch answers with the same keys; only some carry values."""
return {
"status": status,
"lastBlockTs": self.now if read_at == -1 else read_at,
"expires": expires,
"graceEnds": grace_ends,
"reasonCode": None,
Expand All @@ -212,7 +219,8 @@ def setUp(self):
snrc.REGISTRARS = {"testing": self.REGISTRAR}
# Expiry alone; ReservedTests covers a configured controller.
snrc.CONTROLLERS = {"testing": ""}
snrc.chain_now = lambda: int(time.time())
self.now = int(time.time())
snrc.chain_now = lambda: self.now

def tearDown(self):
(
Expand Down Expand Up @@ -315,12 +323,13 @@ def test_unconfigured_tld_is_unknown_rather_than_unregistered(self):
snrc.eth_call = lambda *a: self.fail("must not reach the chain")
self.assertEqual(
snrc.name_status("alice.testing"),
self._keys("unknown", None, None),
self._keys("unknown", None, None, read_at=None),
)

def test_every_branch_returns_the_same_keys(self):
keys = {
"status",
"lastBlockTs",
"expires",
"graceEnds",
"reasonCode",
Expand Down Expand Up @@ -871,7 +880,7 @@ def _lapsed(self, days_past_grace):
def test_a_live_name_is_registered_and_carries_its_record(self):
expires = self.now + 3600
snrc.eth_call = self._chain(expires)
status, body = snrc.registration("acme.testing")
status, body = registration("acme.testing")
self.assertEqual(status, 200)
self.assertEqual(body["type"], "registered")
self.assertEqual(body["expires"], expires)
Expand All @@ -882,19 +891,19 @@ def test_a_live_name_is_registered_and_carries_its_record(self):
def test_a_name_in_grace_is_still_registered(self):
expires = self.now - 3600
snrc.eth_call = self._chain(expires)
_, body = snrc.registration("acme.testing")
_, body = registration("acme.testing")
self.assertEqual(body["type"], "registered")
self.assertGreater(body["graceUntil"], self.now)

def test_a_registered_name_that_is_held_back_says_so(self):
snrc.eth_call = self._chain(self.now + 3600, reserved=1)
_, body = snrc.registration("acme.testing")
_, body = registration("acme.testing")
self.assertEqual(body["type"], "registered")
self.assertEqual(body["reservedReason_"], "internal")

def test_an_unregistered_name_is_available_with_its_pricing(self):
snrc.eth_call = self._chain(0)
status, body = snrc.registration("acme.testing")
status, body = registration("acme.testing")
self.assertEqual(status, 200)
self.assertEqual(body["type"], "available")
# lengths below minCharLength are unregistrable, so they are not priced
Expand All @@ -904,13 +913,13 @@ def test_an_unregistered_name_is_available_with_its_pricing(self):

def test_a_lapsed_name_is_available_at_the_ordinary_price(self):
snrc.eth_call = self._chain(self._lapsed(1))
_, body = snrc.registration("acme.testing")
_, body = registration("acme.testing")
self.assertEqual(body["type"], "available")
self.assertEqual(body["pricing"]["basePrice"], self.BASE)

def test_a_held_back_name_is_reserved_and_is_never_priced(self):
snrc.eth_call = self._chain(0, reserved=2)
status, body = snrc.registration("acme.testing")
status, body = registration("acme.testing")
self.assertEqual(status, 200)
self.assertEqual(body["type"], "reserved")
self.assertEqual(body["reservedReason"], "trademark")
Expand All @@ -920,27 +929,27 @@ def test_a_hashed_query_answers_the_same_as_the_name(self):
# keccak-256("acme")
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
snrc.eth_call = self._chain(0)
_, by_name = snrc.registration("acme.testing")
_, by_hash = snrc.registration(hashed + ".testing")
_, by_name = registration("acme.testing")
_, by_hash = registration(hashed + ".testing")
self.assertEqual(by_name, by_hash)

def test_an_unconfigured_tld_is_refused_not_answered(self):
snrc.REGISTRIES = {"testing": ""}
snrc.eth_call = lambda *a: self.fail("must not reach the chain")
status, body = snrc.registration("acme.testing")
status, body = registration("acme.testing")
self.assertEqual(status, 400)
self.assertEqual(body["error"], "tldNotConfigured")

def test_no_price_oracle_is_an_error_not_a_free_name(self):
snrc.eth_call = self._chain(0, oracle=snrc.ZERO_ADDR)
status, body = snrc.registration("acme.testing")
status, body = registration("acme.testing")
self.assertEqual(status, 502)
self.assertEqual(body["error"], "noPriceOracle")

def test_a_status_it_cannot_read_is_an_error_not_a_registration(self):
snrc.REGISTRARS = {"testing": ""}
snrc.eth_call = self._chain(0)
status, body = snrc.registration("acme.testing")
status, body = registration("acme.testing")
self.assertEqual(status, 502)
self.assertEqual(body["error"], "unknown")

Expand All @@ -955,28 +964,28 @@ def test_each_answer_carries_exactly_its_own_fields(self):
for expected_type, (chain, keys) in cases.items():
with self.subTest(type=expected_type):
snrc.eth_call = chain
_, body = snrc.registration("acme.testing")
_, body = registration("acme.testing")
self.assertEqual(body["type"], expected_type)
self.assertEqual(set(body), keys)
def test_a_hashed_query_the_registrar_cannot_name_is_refused(self):
"""The client checks the record names what it asked about, so answering
with a record the registrar could not name would only fail there."""
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
snrc.eth_call = self._chain(self.now + 3600, label=b"")
status, body = snrc.registration(hashed + ".testing")
status, body = registration(hashed + ".testing")
self.assertEqual(status, 502)
self.assertEqual(body["error"], "labelNotRecorded")

def test_a_hashed_query_is_answered_with_the_name_the_registrar_recorded(self):
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
snrc.eth_call = self._chain(self.now + 3600)
status, body = snrc.registration(hashed + ".testing")
status, body = registration(hashed + ".testing")
self.assertEqual(status, 200)
self.assertEqual(body["nameRecord"]["name"], "acme.testing")
def test_a_subname_that_exists_is_registered_with_its_parents_dates(self):
expires = self.now + 3600
snrc.eth_call = self._chain(expires)
status, body = snrc.registration("sub.acme.testing")
status, body = registration("sub.acme.testing")
self.assertEqual(status, 200)
self.assertEqual(body["type"], "registered")
self.assertEqual(body["expires"], expires)
Expand All @@ -986,15 +995,16 @@ def test_a_subname_nobody_created_is_not_registered(self):
"""The registrar only tracks 2LDs, so the parent's registration says
nothing about a child that was never created: its node has no owner."""
snrc.eth_call = self._chain(self.now + 3600, owner=snrc.ZERO_ADDR)
status, body = snrc.registration("sub.acme.testing")
status, body = registration("sub.acme.testing")
self.assertEqual(status, 200)
self.assertEqual(body["type"], "available")

def test_a_2ld_is_not_subject_to_the_owner_check(self):
"""Only a subname can be absent under a registered parent."""
snrc.eth_call = self._chain(self.now + 3600, owner=snrc.ZERO_ADDR)
_, body = snrc.registration("acme.testing")
_, body = registration("acme.testing")
self.assertEqual(body["type"], "registered")

def test_v1_does_not_report_an_uncreated_subname_as_registered(self):
"""v1 has no availability, so the only honest answer is not-found. The
2LD case is untouched: a registered name with no resolver still resolves."""
Expand All @@ -1009,5 +1019,21 @@ def test_v1_still_resolves_a_2ld_with_no_resolver_set(self):
self.assertEqual(status, 200)
self.assertEqual(body["resolver"], snrc.ZERO_ADDR)

def test_the_answer_says_which_block_it_was_read_at(self):
"""The resolver is only as current as its node. Without this a client
cannot tell an answer that predates its own registration."""
snrc.eth_call = self._chain(self.now + 3600)
_, res = snrc.registration("acme.testing")
self.assertEqual(res["lastBlockTs"], self.now)
self.assertEqual(res["registration"]["type"], "registered")

def test_an_available_name_says_so_too(self):
"""This is the path that reads no block otherwise, and the one where
staleness matters most: the name may already be taken."""
snrc.eth_call = self._chain(0)
_, res = snrc.registration("acme.testing")
self.assertEqual(res["lastBlockTs"], self.now)
self.assertEqual(res["registration"]["type"], "available")

if __name__ == "__main__":
unittest.main()
Loading
Loading