diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index ae6e406da..8be9c2a06 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -71,41 +71,11 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq # → {"name":"foobar.testing","nickname":"Foo","simplexContact":["https://smp16.simplex.im/a#…"], … } ``` -**4. resolver distinguishes the three ways a name fails to resolve.** Names -expire lazily, so the chain still holds the answer and the resolver reports it -rather than returning a bare 404 for every case: +**4. resolver answers the reverse lookup:** ```sh -curl -s http://127.0.0.1:8000/resolve/never-taken.testing | jq -# 404 → {"status":"unregistered", …} never registered -curl -s http://127.0.0.1:8000/resolve/lapsed.testing | jq -# 410 → {"status":"expired","expires":1750…} registered, then lapsed -curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq -# 200 → {"status":"registered","expires":1780…, …} +curl -s http://127.0.0.1:8000/owned-by/0x69a6000000000000000000000000000000002d32 | jq '.names' +# → [{"name":"foobar.testing","status":"registered","expires":1780…, …}] ``` -A held name that points nowhere answers 404 with `"status":"noResolver"`, which -is a different problem from either of the above. Status needs -`SNRC_REGISTRAR_` configured; without it the field reads `"unknown"` and -the endpoint behaves as it did before. - -**5. resolver lists the names an address holds:** -```sh -curl -s http://127.0.0.1:8000/owned-by/0x69a6000000000000000000000000000000002d32 | jq -# → {"address":"0x69a6…","names":[ -# {"name":"foobar.testing","tld":"testing","labelhash":"0x…", -# "expires":1780…,"status":"registered"}, -# {"name":"lapsed.testing","tld":"testing","labelhash":"0x…", -# "expires":1750…,"status":"expired"}], -# "truncated":false,"checkedTlds":["testing"]} -``` -Read from the ERC-721 registrar (`balanceOf` / `tokenOfOwnerByIndex` / -`labelOf`), so it reflects names acquired by transfer as well as by -registration, and needs no log scan. - -**Expired names are listed, not filtered**, each carrying the same `status` -vocabulary `/resolve` uses. A wallet scanning for the names a key holds is -precisely the caller who needs to be told one has lapsed, so it can offer to -renew it. Filter on `status == "registered"` for the live set only. Bounded by -`SNRC_MAX_OWNED` (default 256), and the response says when it truncated. **Wire your smp-server:** in its `[NAMES]` section set `resolver_endpoint: http://127.0.0.1:8000` (no auth needed for loopback). @@ -155,7 +125,10 @@ uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + main "simplexContact": ["https://smp16.simplex.im/a#…", "https://smp11…"], // primary first, fallbacks after "simplexChannel": [], "eth": null, "btc": "bc1q…", "xmr": "4ANz…", "dot": "139G…", - "owner": "0xd83b…", "resolver": "0x80fa…" + "owner": "0xd83b…", "resolver": "0x80fa…", + "status": "registered", // registered | grace | expired | unregistered | noResolver | unknown + "expires": 1780000000, // Unix seconds; when the registration ends + "graceEnds": 1787776000 // expires + GRACE_PERIOD; last moment the owner can renew } ``` @@ -165,18 +138,150 @@ text record; the resolver splits/trims/drops-empties. Address encodings are canonical per chain (EIP-55 / bech32 / SS58 / Monero-base58). Subnames work identically (`bar.foobar.testing`). +### Registration status and expiry + +`status`, `expires` and `graceEnds` are on every response that got far enough to +know them, including a successful resolve — so a client that has just resolved a +name already holds its expiry and needs no second request to warn about it. +`expires` and `graceEnds` are Unix timestamps in seconds; both are `null` when +unknown. + +| `status` | Meaning | +|---|---| +| `registered` | live; `expires` is when that ends | +| `grace` | lapsed, but only the previous owner may renew it, until `graceEnds` | +| `expired` | lapsed and past grace — anyone may register it now | +| `unregistered` | never registered | +| `noResolver` | registered, but points nowhere | +| `unknown` | no `SNRC_REGISTRAR_` configured, so status could not be read | + +Which HTTP code carries each, and what every other input does, is in +[Every case](#every-case-and-what-comes-back) at the end. + +The split between `grace` and `expired` mirrors the registrar's own +`available(id)` rule (`expires + GRACE_PERIOD < now`), with `GRACE_PERIOD` read +from the contract rather than assumed. Note that `available(id)` alone cannot +distinguish these: it is also true for a name nobody ever registered, since +`0 + GRACE_PERIOD < now`. A zero expiry is what separates *never taken* from +*taken and since released*. + +Subnames report the status of the 2LD they sit under, which is the useful +answer — a subname is only as valid as the name above it. + ### Status codes | Status | Meaning | |---|---| -| 200 | resolved | +| 200 | resolved; `status` is `registered` | | 400 | TLD not configured, or not a fully-qualified name | -| 404 | name has no resolver set on the registry | +| 404 | never registered (`unregistered`), or registered with no resolver set (`noResolver`) | +| 410 | registration has lapsed — `status` says whether it is still renewable | | 502 | upstream RPC error / reth not synced | +### `GET /owned-by/
` + +Every name an Ethereum address holds, across every configured TLD. + +```jsonc +{ + "address": "0x69a6…", + "names": [ + {"name": "foobar.testing", "tld": "testing", "labelhash": "0x…", + "expires": 1780000000, "graceEnds": 1787776000, "status": "registered"}, + {"name": "lapsed.testing", "tld": "testing", "labelhash": "0x…", + "expires": 1750000000, "graceEnds": 1757776000, "status": "grace"} + ], + "truncated": false, + "checkedTlds": ["testing"] +} +``` + +Read from the ERC-721 registrar (`balanceOf` → `tokenOfOwnerByIndex` → +`nameExpires` → `labelOf`), so it needs no log scan and includes names acquired +by transfer as well as by registration. `labelOf` is the plaintext label +recorded write-once at registration, so a token id turns back into a name +without an off-chain index; a token whose label was never recorded is returned +with `"name": null` and its `labelhash`, rather than being dropped. + +**Lapsed names are listed, not filtered**, with the same `status` vocabulary as +`/resolve` — a wallet scanning a key is exactly the caller who needs to be told +a name has lapsed and can still be renewed. Filter on `status == "registered"` +for the live set only. Enumeration is deliberately not maintained on expiry (the +registrar documents this), which is why `status` rather than presence is the +thing to read. + +`truncated` is `true` when an address holds more than `SNRC_MAX_OWNED` names +(default 256) in one TLD, so a caller can tell a short list from a complete one. +Requires `SNRC_REGISTRAR_`; with none configured the endpoint answers 400 +rather than an empty list. + ### Configuring registries Defaults to mainnet `.testing` (`0x03f438…`); `.simplex` is unset until deployed. Override per TLD via env on the `resolver` service in `docker-compose.yml` (`SNRC_REGISTRY_TESTING` / `SNRC_REGISTRY_SIMPLEX`), or as env vars for the standalone script. + +`SNRC_REGISTRAR_` is the matching ERC-721 registrar, and is what `/owned-by` +and the expiry status are read from — the registry answers *who owns this node*, +the registrar is the NFT that can be asked the reverse and when it expires. +Without it `/resolve` still works and reports `"status": "unknown"`, and +`/owned-by` answers 400. `SNRC_MAX_OWNED` bounds one `/owned-by` response +(default 256). + +## Every case, and what comes back + +Every input either endpoint can be given, and the exact answer. Written out +because the interesting cases are the ones that are hard to reach on purpose — +a name in its grace period, a token whose label predates label recording — and +a caller has to handle them without having seen one. + +Timestamps are Unix seconds. `status`, `expires` and `graceEnds` are present on +every `/resolve` response that got as far as looking the name up — `null` where +not knowable — so a client can read them without checking for the key first. +The two 400s below are the exception: they fail on the request itself, before +any lookup, and carry none of the three. + +### `GET /resolve/` + +| Situation | HTTP | `status` | Body | +|---|---|---|---| +| Live name with records | 200 | `registered` | full record; `expires` is when it ends, `graceEnds` when it would stop being renewable | +| Live name, no text records set | 200 | `registered` | full record; text fields `""`, link arrays `[]`, coin fields `null` | +| Live subname (`bar.foo.testing`) | 200 | `registered` | its own records, with the expiry of the 2LD `foo.testing` above it | +| Registered, resolver never set | 404 | `noResolver` | `expires`, `graceEnds`, `error` — held, but points nowhere | +| Lapsed, still in grace | 410 | `grace` | `expires` (when it lapsed), `graceEnds` (last moment its owner can renew) | +| Lapsed, past grace | 410 | `expired` | same fields; anyone may register it now | +| Never registered | 404 | `unregistered` | `expires` and `graceEnds` are `null` | +| TLD has no registry configured | 400 | — | `configured_tlds`, listing the ones that are | +| TLD has no *registrar* configured | 200 / 404 | `unknown` | resolves as it otherwise would; expiry cannot be read, so `expires` and `graceEnds` are `null` | +| Not fully qualified (`alice`) | 400 | — | `error` naming the expected form | +| RPC unreachable or node unsynced | 502 | — | `error` with the underlying exception type | + +A name in grace still has its records on chain — expiry is lazy — but the +resolver answers 410 rather than serving them, so a stale name cannot be +resolved by accident. Read `expires` from that response to say when it lapsed. + +### `GET /owned-by/
` + +Answers 200 with a `names` array in every case where the address is well formed +and a registrar is configured; the interesting variation is per entry. + +| Situation | HTTP | Result | +|---|---|---| +| Address holds live names | 200 | one entry each, `status` `registered` | +| Address holds a name in grace | 200 | entry with `status` `grace` and `graceEnds` — the renewal reminder case | +| Address holds a name past grace | 200 | entry with `status` `expired`; still listed, because the holder is who needs to know | +| Address holds nothing | 200 | `names: []` — an answer, not an error | +| Token whose label was never recorded | 200 | entry with `"name": null` and its `labelhash`; the token is real, the name is not recoverable from chain state | +| Address holds more than `SNRC_MAX_OWNED` in a TLD | 200 | first 256, and `truncated: true` | +| Several TLDs configured | 200 | all of them merged, sorted by TLD then name; `checkedTlds` says which were asked | +| Malformed address | 400 | `error`; no RPC call is made | +| No registrar configured for any TLD | 400 | `error` and `configured_tlds: []` — distinct from "holds nothing" | +| RPC unreachable or node unsynced | 502 | `error` with the underlying exception type | + +Names are **not** filtered by expiry. Enumeration on the registrar is +maintained on transfer, mint and burn but deliberately not on expiry, so a +lapsed name stays enumerable until someone re-registers it — and that is +exactly the name its holder needs to be told about. Filter on +`status == "registered"` for the live set. diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 1711aa553..6ed87fb02 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -449,20 +449,28 @@ def resolve(name: str): # Registration first, because it is the fact that separates the failures a # caller has to tell apart: a name nobody has taken, one whose registration - # lapsed, and one that is held but not pointed anywhere. + # lapsed and may still be renewed, one that lapsed and is now open to + # anyone, and one that is held but not pointed anywhere. reg = name_status(name) if reg["status"] == "unregistered": return 404, { "name": name, "status": "unregistered", + "expires": None, + "graceEnds": None, "error": "this name has never been registered", } - if reg["status"] == "expired": + if reg["status"] in ("grace", "expired"): return 410, { "name": name, - "status": "expired", + "status": reg["status"], "expires": reg["expires"], - "error": "this registration expired", + "graceEnds": reg["graceEnds"], + "error": ( + "this registration expired and can be renewed by its owner" + if reg["status"] == "grace" + else "this registration expired and is open to anyone" + ), } resolver_raw = eth_call(registry, selector("resolver(bytes32)") + node_hex) @@ -472,6 +480,7 @@ def resolve(name: str): "name": name, "status": "noResolver", "expires": reg["expires"], + "graceEnds": reg["graceEnds"], "error": "no resolver set for this name", } @@ -511,9 +520,43 @@ def resolve(name: str): "resolver": resolver_addr, "status": reg["status"], "expires": reg["expires"], + "graceEnds": reg["graceEnds"], } +def grace_period(registrar: str) -> int: + """The registrar's own GRACE_PERIOD, in seconds. + + Read from the chain rather than hardcoded, so a deployment that chooses a + different window is reported correctly instead of confidently wrongly. One + call per request, not per name. + """ + return decode_uint(eth_call(registrar, selector("GRACE_PERIOD()"))) + + +def expiry_status(expires: int, grace: int, now: int) -> str: + """Registration state from an expiry timestamp. + + Mirrors the registrar's `available(id)`, which is + `expiries[id] + GRACE_PERIOD < block.timestamp`. It is computed here rather + than called per name because the answer is needed for every token in a + listing and the inputs are one constant plus a value already fetched. + + Note that `available` alone cannot be used for this: it is also true for a + name nobody ever registered, since `0 + GRACE_PERIOD < now`. The zero + expiry is what separates "never taken" from "lapsed and now free". + """ + if expires == 0: + return "unregistered" + if expires > now: + return "registered" + if expires + grace >= now: + # Expired, but only the previous owner may renew it - nobody else can + # take it yet. + return "grace" + return "expired" + + def name_status(name: str): """Registration status of the 2LD a name sits under. @@ -531,17 +574,20 @@ def name_status(name: str): registrar = REGISTRARS.get(tld) if not registrar or len(labels) < 2: # No registrar configured for this TLD: say so rather than guess. - return {"status": "unknown", "expires": None} + return {"status": "unknown", "expires": None, "graceEnds": None} token = int.from_bytes(keccak(labels[-2].encode()), "big") expires = decode_uint( eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token)) ) if expires == 0: - return {"status": "unregistered", "expires": None} - if expires <= int(time.time()): - return {"status": "expired", "expires": expires} - return {"status": "registered", "expires": expires} + return {"status": "unregistered", "expires": None, "graceEnds": None} + grace = grace_period(registrar) + return { + "status": expiry_status(expires, grace, int(time.time())), + "expires": expires, + "graceEnds": expires + grace, + } def owned_by(address: str): @@ -565,6 +611,12 @@ def owned_by(address: str): Callers wanting only the live set filter on `status == "registered"`, which is the check the registrar's invariant asks of readers - applied by whoever knows whether expired names matter to them, rather than here. + + A lapsed name is reported as `grace` while only its previous owner may + renew it, and `expired` once anyone can take it. The difference is the + whole content of a renewal reminder: one is "renew this", the other is + "this is gone unless you are quick", and `graceEnds` says when the first + becomes the second. """ if not is_address(address): return 400, {"address": address, "error": "expected a 0x-prefixed 20-byte address"} @@ -580,6 +632,7 @@ def owned_by(address: str): 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)) ) @@ -611,7 +664,8 @@ def owned_by(address: str): "tld": tld, "labelhash": hex(token), "expires": expires, - "status": "registered" if expires > now else "expired", + "graceEnds": expires + grace if expires else None, + "status": expiry_status(expires, grace, now), } ) diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index adf3d8404..4b58140db 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -64,6 +64,7 @@ class OwnedByTests(unittest.TestCase): REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" OWNER = "0x69a6000000000000000000000000000000002d32" + GRACE = 90 * 86400 def _fake_chain(self, tokens): """tokens :: [(labelhash, label, expires)] held by OWNER.""" @@ -71,6 +72,8 @@ class OwnedByTests(unittest.TestCase): def eth_call(to, data): self.assertEqual(to, self.REGISTRAR) + if data.startswith(sel("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(self.GRACE) if data.startswith(sel("balanceOf(address)")): return "0x" + snrc.encode_uint(len(tokens)) if data.startswith(sel("tokenOfOwnerByIndex(address,uint256)")): @@ -118,14 +121,24 @@ class OwnedByTests(unittest.TestCase): self.assertEqual([n["name"] for n in body["names"]], ["lapsed.testing", "live.testing"]) by_name = {n["name"]: n for n in body["names"]} self.assertEqual(by_name["live.testing"]["status"], "registered") - self.assertEqual(by_name["lapsed.testing"]["status"], "expired") + # lapsed an hour ago, so still renewable by its owner + self.assertEqual(by_name["lapsed.testing"]["status"], "grace") self.assertEqual(by_name["lapsed.testing"]["expires"], now - 1) + self.assertEqual(by_name["lapsed.testing"]["graceEnds"], now - 1 + self.GRACE) + + def test_a_name_past_grace_is_reported_as_claimable(self): + now = int(time.time()) + snrc.eth_call = self._fake_chain([(11, "gone", now - self.GRACE - 3600)]) + _, body = snrc.owned_by(self.OWNER) + self.assertEqual(body["names"][0]["status"], "expired") def test_status_uses_the_same_vocabulary_as_resolve(self): now = int(time.time()) snrc.eth_call = self._fake_chain([(11, "live", now + 86400)]) _, body = snrc.owned_by(self.OWNER) - self.assertIn(body["names"][0]["status"], ("registered", "expired")) + self.assertIn( + body["names"][0]["status"], ("registered", "grace", "expired", "unregistered") + ) def test_a_name_with_no_recorded_label_is_reported_by_labelhash(self): future = int(time.time()) + 86400 @@ -167,8 +180,12 @@ class NameStatusTests(unittest.TestCase): REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + GRACE = 90 * 86400 + def _expiry(self, value): def eth_call(to, data): + if data.startswith(snrc.selector("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(self.GRACE) self.assertTrue(data.startswith(snrc.selector("nameExpires(uint256)"))) return "0x" + snrc.encode_uint(value) @@ -184,23 +201,47 @@ class NameStatusTests(unittest.TestCase): def test_zero_expiry_means_never_registered(self): snrc.eth_call = self._expiry(0) self.assertEqual( - snrc.name_status("alice.testing"), {"status": "unregistered", "expires": None} + snrc.name_status("alice.testing"), + {"status": "unregistered", "expires": None, "graceEnds": None}, ) - def test_past_expiry_is_expired_and_keeps_the_date(self): + def test_recently_expired_is_in_grace_and_says_when_it_ends(self): + """Only the previous owner may renew during grace - nobody else can + take the name yet, so this is a different answer from `expired`.""" past = int(time.time()) - 3600 snrc.eth_call = self._expiry(past) self.assertEqual( - snrc.name_status("alice.testing"), {"status": "expired", "expires": past} + snrc.name_status("alice.testing"), + {"status": "grace", "expires": past, "graceEnds": past + self.GRACE}, ) + def test_past_the_grace_window_it_is_expired_and_claimable(self): + past = int(time.time()) - self.GRACE - 3600 + snrc.eth_call = self._expiry(past) + self.assertEqual(snrc.name_status("alice.testing")["status"], "expired") + + def test_the_boundary_belongs_to_grace(self): + """The registrar frees a name when expires + GRACE < now, so the last + second of the window is still the owner's.""" + now = int(time.time()) + snrc.eth_call = self._expiry(now - self.GRACE) + self.assertEqual(snrc.name_status("alice.testing")["status"], "grace") + def test_future_expiry_is_registered(self): future = int(time.time()) + 3600 snrc.eth_call = self._expiry(future) self.assertEqual( - snrc.name_status("alice.testing"), {"status": "registered", "expires": future} + snrc.name_status("alice.testing"), + {"status": "registered", "expires": future, "graceEnds": future + self.GRACE}, ) + def test_never_registered_is_not_confused_with_claimable(self): + """`available(id)` is true for both, since 0 + GRACE < now. The zero + expiry is the only thing that separates them.""" + snrc.eth_call = self._expiry(0) + self.assertEqual(snrc.name_status("alice.testing")["status"], "unregistered") + self.assertNotEqual(snrc.name_status("alice.testing")["status"], "expired") + def test_a_subname_reports_the_status_of_its_2ld(self): future = int(time.time()) + 3600 seen = [] @@ -218,9 +259,23 @@ class NameStatusTests(unittest.TestCase): snrc.REGISTRARS = {"testing": ""} snrc.eth_call = lambda *a: self.fail("must not reach the chain") self.assertEqual( - snrc.name_status("alice.testing"), {"status": "unknown", "expires": None} + snrc.name_status("alice.testing"), + {"status": "unknown", "expires": None, "graceEnds": None}, ) + def test_every_branch_returns_the_same_keys(self): + """Callers read status/expires/graceEnds unconditionally, so a branch + that omits one is a KeyError in the caller rather than a missing field + in the JSON.""" + keys = {"status", "expires", "graceEnds"} + snrc.eth_call = self._expiry(0) + self.assertEqual(set(snrc.name_status("alice.testing")), keys) + snrc.eth_call = self._expiry(int(time.time()) + 3600) + self.assertEqual(set(snrc.name_status("alice.testing")), keys) + snrc.REGISTRARS = {"testing": ""} + snrc.eth_call = lambda *a: self.fail("must not reach the chain") + self.assertEqual(set(snrc.name_status("alice.testing")), keys) + class SplitLinksTests(unittest.TestCase): """`split_links` decodes the multi-URL convention for simplex.contact /