new owned_by endpoint and expiry status handling

This commit is contained in:
Alain Brenzikofer
2026-08-31 09:25:40 +02:00
parent ac4bf690b6
commit 6175411616
4 changed files with 439 additions and 3 deletions
+37 -1
View File
@@ -71,6 +71,42 @@ 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:
```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…, …}
```
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).
@@ -82,7 +118,7 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq
| reth p2p | `:30303` tcp/udp | Ethereum sync (open on firewall) |
| nimbus p2p | `:9000` tcp/udp | beacon sync (open on firewall) |
| nimbus REST | `127.0.0.1:5052` | beacon API |
| **resolver** | `127.0.0.1:8000` | SNRC REST (`/resolve`, `/health`) |
| **resolver** | `127.0.0.1:8000` | SNRC REST (`/resolve`, `/owned-by`, `/health`) |
## Caveats
+5
View File
@@ -150,6 +150,11 @@ services:
# only if you're deploying against a different network or contract.
# SNRC_REGISTRY_TESTING: 0x...
# SNRC_REGISTRY_SIMPLEX: 0x...
# Registrar (ERC-721) addresses, same cascade. These drive /owned-by and
# the expiry status on /resolve; without them status reads "unknown".
# SNRC_REGISTRAR_TESTING: 0x...
# SNRC_REGISTRAR_SIMPLEX: 0x...
# SNRC_MAX_OWNED: 256
ports:
- "127.0.0.1:8000:8000"
restart: unless-stopped
+192 -2
View File
@@ -29,6 +29,7 @@ Usage:
./snrc-resolve.py # serve on :8000
curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq .
curl -s http://127.0.0.1:8000/owned-by/0x69a6...2d32 | jq .
curl -s http://127.0.0.1:8000/health
Environment:
@@ -61,6 +62,7 @@ import hashlib
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
@@ -84,6 +86,21 @@ REGISTRIES = {
"simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet
}
# The BaseRegistrar (ERC-721) per TLD, used for owner -> names. Separate from
# the registry above: the registry answers "who owns this node", the registrar
# is the NFT that can be asked the reverse. Not a proxy, so the address in
# deployments is the one that answers.
REGISTRARS = {
"testing": os.environ.get("SNRC_REGISTRAR_TESTING", "")
or "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a", # mainnet .testing
"simplex": os.environ.get("SNRC_REGISTRAR_SIMPLEX", ""), # not deployed yet
}
# An address can hold any number of names, and enumeration costs one call per
# name. Bounded so a single request cannot pin the resolver to one caller's
# balance; the response says when it truncated rather than lying by omission.
MAX_OWNED = int(os.environ.get("SNRC_MAX_OWNED", "256"))
# SLIP-44 coin types (https://github.com/satoshilabs/slips/blob/master/slip-0044.md)
COIN_ETH = 60
COIN_BTC = 0
@@ -143,6 +160,32 @@ def decode_bytes(hex_data: str) -> bytes:
return raw[64:64 + length]
def decode_uint(hex_data: str) -> int:
raw = hex_data[2:] if hex_data.startswith("0x") else hex_data
return int(raw[-64:], 16) if raw else 0
def encode_uint(value: int) -> str:
return value.to_bytes(32, "big").hex()
def encode_address(addr: str) -> str:
return "00" * 12 + addr[2:].lower()
def decode_string(hex_data: str) -> str:
raw = decode_bytes(hex_data)
return raw.decode("utf-8", errors="replace") if raw else ""
def is_address(value: str) -> bool:
return (
len(value) == 42
and value.startswith("0x")
and all(c in "0123456789abcdefABCDEF" for c in value[2:])
)
def encode_text_call(node: bytes, key: str) -> str:
sel = selector("text(bytes32,string)")
head = node.hex() + (0x40).to_bytes(32, "big").hex()
@@ -404,10 +447,33 @@ def resolve(name: str):
node = namehash(name)
node_hex = node.hex()
# 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.
reg = name_status(name)
if reg["status"] == "unregistered":
return 404, {
"name": name,
"status": "unregistered",
"error": "this name has never been registered",
}
if reg["status"] == "expired":
return 410, {
"name": name,
"status": "expired",
"expires": reg["expires"],
"error": "this registration expired",
}
resolver_raw = eth_call(registry, selector("resolver(bytes32)") + node_hex)
resolver_addr = decode_address(resolver_raw)
if resolver_addr == ZERO_ADDR:
return 404, {"name": name, "error": "no resolver set for this name"}
return 404, {
"name": name,
"status": "noResolver",
"expires": reg["expires"],
"error": "no resolver set for this name",
}
owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex)
owner = decode_address(owner_raw)
@@ -443,6 +509,118 @@ def resolve(name: str):
"dot": addr_multicoin(resolver_addr, node, COIN_DOT),
"owner": owner,
"resolver": resolver_addr,
"status": reg["status"],
"expires": reg["expires"],
}
def name_status(name: str):
"""Registration status of the 2LD a name sits under.
Names expire lazily: the registrar keeps the record and simply stops
treating it as live, so "never registered" and "expired last Tuesday" are
both readable rather than both being absence. `nameExpires` returns 0 for a
label that was never registered, which is what separates the two.
Subnames are not registered here, so the status of `x.alice.testing` is the
status of `alice.testing` - which is the useful answer, since a subname is
only as valid as the 2LD above it.
"""
labels = name.split(".")
tld = labels[-1]
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}
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}
def owned_by(address: str):
"""Every live name an address holds, across every configured TLD.
Read straight off the ERC-721 registrar rather than from logs: the token
is the name, so `balanceOf` / `tokenOfOwnerByIndex` is the current answer
and it includes names acquired by transfer, which a scan of registration
events would miss. `labelOf` returns the plaintext label, recorded
write-once at registration, so no off-chain index is needed to turn a
token id back into a name.
Enumeration is deliberately not maintained on expiry - the registrar
documents this - so an expired name stays in the list until someone
re-registers it. That is reported rather than filtered: a caller scanning
for the names a key holds is exactly the caller who needs to be told one of
them has lapsed and can be renewed. Every entry carries `status`, using the
same vocabulary as /resolve, so "still yours" and "yours until you lose it"
are never confused for each other.
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.
"""
if not is_address(address):
return 400, {"address": address, "error": "expected a 0x-prefixed 20-byte address"}
configured = {t: r for t, r in REGISTRARS.items() if r}
if not configured:
return 400, {
"address": address,
"error": "no registrar is configured on this resolver",
"configured_tlds": [],
}
now = int(time.time())
names, truncated = [], False
for tld, registrar in configured.items():
held = decode_uint(
eth_call(registrar, selector("balanceOf(address)") + encode_address(address))
)
if held > MAX_OWNED:
truncated = True
held = MAX_OWNED
for i in range(held):
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))
)
label = decode_string(
eth_call(registrar, selector("labelOf(uint256)") + encode_uint(token))
)
# A label of "" means the token is real but its name is not
# recoverable from chain state - registered before labels were
# recorded, or by a path that does not record them. Reported
# without a name rather than silently dropped.
names.append(
{
"name": (label + "." + tld) if label else None,
"tld": tld,
"labelhash": hex(token),
"expires": expires,
"status": "registered" if expires > now else "expired",
}
)
names.sort(key=lambda n: (n["tld"], n["name"] or n["labelhash"]))
return 200, {
"address": address,
"names": names,
"truncated": truncated,
"checkedTlds": sorted(configured),
}
@@ -478,9 +656,21 @@ class Handler(BaseHTTPRequestHandler):
self._respond(status, body)
return
if len(parts) == 2 and parts[0] == "owned-by":
address = parts[1].strip().lower()
try:
status, body = owned_by(address)
except Exception as e: # surface upstream errors as 502
status, body = 502, {"address": address, "error": f"{type(e).__name__}: {e}"}
self._respond(status, body)
return
self._respond(
404,
{"error": "not found", "routes": ["/health", "/resolve/<name>"]},
{
"error": "not found",
"routes": ["/health", "/resolve/<name>", "/owned-by/<address>"],
},
)
def _respond(self, status: int, body: dict):
@@ -6,6 +6,7 @@ Run with `python3 -m unittest scripts/resolver/service/test_snrc_resolve.py`.
import importlib.util
import os
import time
import unittest
# snrc-resolve.py has a hyphen, so import it via importlib instead of `import`.
@@ -17,6 +18,210 @@ snrc = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(snrc)
class AbiCodecTests(unittest.TestCase):
"""The word-level codec the owner lookup is built from. Wrong padding here
is a silently empty answer rather than an error, so each direction is
pinned."""
def test_address_is_left_padded_to_a_word_and_lowercased(self):
self.assertEqual(
snrc.encode_address("0xEF47eb4384b46C89E4482a677c2cbcbd2a6fd85a"),
"00" * 12 + "ef47eb4384b46c89e4482a677c2cbcbd2a6fd85a",
)
def test_uint_round_trips_through_a_word(self):
for n in (0, 1, 42, 2**64, 2**255):
self.assertEqual(snrc.decode_uint("0x" + snrc.encode_uint(n)), n)
def test_decode_uint_of_empty_is_zero(self):
self.assertEqual(snrc.decode_uint(""), 0)
self.assertEqual(snrc.decode_uint("0x"), 0)
def test_decode_string_reads_the_dynamic_layout(self):
label = b"alice"
word = (32).to_bytes(32, "big") + len(label).to_bytes(32, "big")
padded = label + b"\x00" * (32 - len(label))
self.assertEqual(snrc.decode_string("0x" + (word + padded).hex()), "alice")
def test_decode_string_of_empty_return_is_empty(self):
self.assertEqual(snrc.decode_string("0x"), "")
def test_is_address_accepts_only_20_byte_hex(self):
self.assertTrue(snrc.is_address("0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"))
self.assertTrue(snrc.is_address("0xEF47EB4384B46C89E4482A677C2CBCBD2A6FD85A"))
self.assertFalse(snrc.is_address("ef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"))
self.assertFalse(snrc.is_address("0xef47eb"))
self.assertFalse(snrc.is_address("0x" + "g" * 40))
class OwnedByTests(unittest.TestCase):
"""owner -> names, read off the ERC-721 registrar.
The registrar's own invariant is that enumeration is maintained on
transfer/mint/burn and NOT on expiry, so an expired name stays enumerable
until it is re-registered. These pin the filter that follows from it.
"""
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
OWNER = "0x69a6000000000000000000000000000000002d32"
def _fake_chain(self, tokens):
"""tokens :: [(labelhash, label, expires)] held by OWNER."""
sel = snrc.selector
def eth_call(to, data):
self.assertEqual(to, self.REGISTRAR)
if data.startswith(sel("balanceOf(address)")):
return "0x" + snrc.encode_uint(len(tokens))
if data.startswith(sel("tokenOfOwnerByIndex(address,uint256)")):
i = int(data[-64:], 16)
return "0x" + snrc.encode_uint(tokens[i][0])
if data.startswith(sel("nameExpires(uint256)")):
tid = int(data[-64:], 16)
return "0x" + snrc.encode_uint(dict((t[0], t[2]) for t in tokens)[tid])
if data.startswith(sel("labelOf(uint256)")):
tid = int(data[-64:], 16)
label = dict((t[0], t[1]) for t in tokens)[tid].encode()
head = (32).to_bytes(32, "big") + len(label).to_bytes(32, "big")
pad = b"\x00" * ((-len(label)) % 32)
return "0x" + (head + label + pad).hex()
raise AssertionError("unexpected call " + data[:10])
return eth_call
def setUp(self):
self._registrars = snrc.REGISTRARS
self._eth_call = snrc.eth_call
snrc.REGISTRARS = {"testing": self.REGISTRAR, "simplex": ""}
def tearDown(self):
snrc.REGISTRARS = self._registrars
snrc.eth_call = self._eth_call
def test_lists_names_with_their_labels(self):
future = int(time.time()) + 86400
snrc.eth_call = self._fake_chain([(11, "alice", future), (22, "bob", future)])
status, body = snrc.owned_by(self.OWNER)
self.assertEqual(status, 200)
self.assertEqual([n["name"] for n in body["names"]], ["alice.testing", "bob.testing"])
self.assertFalse(body["truncated"])
self.assertEqual(body["checkedTlds"], ["testing"])
def test_expired_names_are_reported_not_dropped(self):
"""A scan is how a user finds out a name lapsed, so an expired name has
to come back labelled rather than vanish."""
now = int(time.time())
snrc.eth_call = self._fake_chain(
[(11, "live", now + 86400), (22, "lapsed", now - 1)]
)
_, body = snrc.owned_by(self.OWNER)
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")
self.assertEqual(by_name["lapsed.testing"]["expires"], now - 1)
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"))
def test_a_name_with_no_recorded_label_is_reported_by_labelhash(self):
future = int(time.time()) + 86400
snrc.eth_call = self._fake_chain([(11, "", future)])
_, body = snrc.owned_by(self.OWNER)
self.assertEqual(body["names"][0]["name"], None)
self.assertEqual(body["names"][0]["labelhash"], hex(11))
def test_enumeration_is_bounded_and_says_so(self):
future = int(time.time()) + 86400
snrc.MAX_OWNED, keep = 2, snrc.MAX_OWNED
try:
snrc.eth_call = self._fake_chain(
[(i, "n%d" % i, future) for i in range(1, 6)]
)
_, body = snrc.owned_by(self.OWNER)
self.assertEqual(len(body["names"]), 2)
self.assertTrue(body["truncated"])
finally:
snrc.MAX_OWNED = keep
def test_a_malformed_address_is_refused_before_any_rpc(self):
snrc.eth_call = lambda *a: self.fail("must not reach the chain")
status, body = snrc.owned_by("0xnope")
self.assertEqual(status, 400)
self.assertIn("address", body["error"])
def test_no_configured_registrar_is_an_error_not_an_empty_list(self):
snrc.REGISTRARS = {"testing": "", "simplex": ""}
snrc.eth_call = lambda *a: self.fail("must not reach the chain")
status, body = snrc.owned_by(self.OWNER)
self.assertEqual(status, 400)
self.assertEqual(body["configured_tlds"], [])
class NameStatusTests(unittest.TestCase):
"""simplexmq#1821: unresolvable has three causes and a caller has to tell
them apart. Names expire lazily, so the chain still holds the answer."""
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
def _expiry(self, value):
def eth_call(to, data):
self.assertTrue(data.startswith(snrc.selector("nameExpires(uint256)")))
return "0x" + snrc.encode_uint(value)
return eth_call
def setUp(self):
self._registrars, self._eth_call = snrc.REGISTRARS, snrc.eth_call
snrc.REGISTRARS = {"testing": self.REGISTRAR}
def tearDown(self):
snrc.REGISTRARS, snrc.eth_call = self._registrars, self._eth_call
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}
)
def test_past_expiry_is_expired_and_keeps_the_date(self):
past = int(time.time()) - 3600
snrc.eth_call = self._expiry(past)
self.assertEqual(
snrc.name_status("alice.testing"), {"status": "expired", "expires": past}
)
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}
)
def test_a_subname_reports_the_status_of_its_2ld(self):
future = int(time.time()) + 3600
seen = []
def eth_call(to, data):
seen.append(data)
return "0x" + snrc.encode_uint(future)
snrc.eth_call = eth_call
self.assertEqual(snrc.name_status("x.alice.testing")["status"], "registered")
# the token asked about is keccak("alice"), not keccak("x")
self.assertTrue(seen[0].endswith(snrc.keccak(b"alice").hex()))
def test_unconfigured_tld_is_unknown_rather_than_unregistered(self):
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}
)
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