This commit is contained in:
Alain Brenzikofer
2026-08-31 09:59:09 +02:00
parent 6175411616
commit e8652a2bd4
3 changed files with 267 additions and 53 deletions
+141 -36
View File
@@ -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_<TLD>` 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_<TLD>` 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/<address>`
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_<TLD>`; 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_<TLD>` 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/<name>`
| 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/<address>`
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.
+64 -10
View File
@@ -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),
}
)
+62 -7
View File
@@ -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 /