report expiry and availability correctly

This commit is contained in:
Alain Brenzikofer
2026-09-04 08:42:48 +02:00
parent e670d9fa37
commit 9b2ff82cc7
4 changed files with 540 additions and 13 deletions
+74 -12
View File
@@ -119,7 +119,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 | reserved | noResolver | unknown
"expires": 1780000000, // Unix seconds; when the registration ends
"graceEnds": 1787776000 // expires + GRACE_PERIOD; last moment the owner can renew
}
```
@@ -129,6 +132,34 @@ 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, and free to take |
| `reserved` | not registered, and held back — registration will be refused; the body carries a `reason` |
| `noResolver` | registered, but points nowhere |
| `unknown` | no `SNRC_REGISTRAR_<TLD>` configured, so status could not be read |
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.
### Querying by labelhash
A client that asks whether a name is free is usually about to register it.
@@ -142,8 +173,20 @@ curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d'
```
This works because namehash is `keccak(parent || keccak(label))`. Passing
`keccak(label)` gives the same node, so the resolver reads the same record. It
learns which name you meant only if it guesses the label and hashes it.
`keccak(label)` gives the same node, so the resolver reads the same record
and the registrar also keys `nameExpires` and `reservedNames` on the labelhash,
so availability needs the label no more than the record does. It learns which
name you meant only if it guesses the label and hashes it.
Read the answer by `status`: a name is free exactly when the body says
`unregistered` (a 404). Every other answer is a name somebody holds or held
recently — note that `noResolver` is also a 404, and it is a taken name.
Two practical notes. The hash must be keccak-256: `openssl dgst -sha3-256` and
`sha3sum` compute SHA3-256, a different function, and produce 64 well-formed
hex of nothing you meant. And queries are lowercased before matching, so
uppercase hex works too; strict HTTP stacks that reject raw brackets in a path
can percent-encode them (`%5B`/`%5D`) — both forms load the same name.
Brackets keep the two forms from colliding. `[` and `]` are not valid in a
normalised ENS name, and the dApp normalises before it registers, so no name
@@ -157,23 +200,42 @@ Only 2LDs can be queried by hash. A 2LD is what a registration buys, so it is
the only name worth hiding. Subnames are left out because nobody can race you
for one: the owner of the 2LD creates them. In a subname the resolver hashes a
`[<64 hex>]` label as written instead of decoding it, so such a query points at
a node nobody can own.
a node nobody can own. (ENS tooling reads the bracketed form at any depth; this
resolver deliberately does not.)
This hides your interest in a name, and nothing more. The registration itself
is public, and the controller's commit-reveal protects that step.
is public, and the controller's commit-reveal protects that step. Guessing
stays cheap — for a short or brand-like label the hash is a speed bump, not
secrecy — and once the reveal makes the labelhash public, an operator who
logged the probe can link the two.
### Status codes
| Status | Meaning |
|---|---|
| 200 | resolved |
| 200 | resolved (`status` is `registered`, or `unknown` when no registrar is configured) |
| 400 | TLD not configured, or not a fully-qualified name |
| 404 | name has no resolver set on the registry |
| 404 | `unregistered`, `reserved` or `noResolver` — the `status` field says which |
| 410 | registration lapsed — `status` says whether the owner can still renew (`grace`) or anyone may take it (`expired`) |
| 502 | upstream RPC error / reth not synced |
### Configuring registries
### Configuring addresses
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.
Three maps, all per TLD. The **registry** answers *who owns this node* and is
what `/resolve` reads records from. The **registrar** (ERC-721) holds
`nameExpires` / `GRACE_PERIOD`, and is what every expiry field is read from —
without one for a TLD, `/resolve` still works and reports `"status": "unknown"`.
The **controller** holds `reservedNames`, and is what the `reserved` status is
read from; without one a reserved name reads as `unregistered`.
All three default to the mainnet `.testing` deployment; `.simplex` is unset
until deployed. Note that the controller default is the **proxy**, not
`SimplexControllerImpl`: storage lives in the proxy, so the implementation
answers nothing. `deployments.mainnet.testing.json` records it under the ENS
role name `ETHRegistrarController` and `verification.mainnet.testing.json`
names it `SimplexControllerProxy` — the same address, and the one defaulted to
here.
Override per TLD via env on the `resolver` service in `docker-compose.yml`
(`SNRC_REGISTRY_<TLD>` / `SNRC_REGISTRAR_<TLD>` / `SNRC_CONTROLLER_<TLD>`), or
as env vars for the standalone script.
+8
View File
@@ -150,6 +150,14 @@ services:
# only if you're deploying against a different network or contract.
# SNRC_REGISTRY_TESTING: 0x...
# SNRC_REGISTRY_SIMPLEX: 0x...
# Registrar and controller addresses, same cascade. The registrar
# drives the expiry status on /resolve; without it status reads
# "unknown". The controller drives the "reserved" status; without it a
# reserved name reads as "unregistered".
# SNRC_REGISTRAR_TESTING: 0x...
# SNRC_REGISTRAR_SIMPLEX: 0x...
# SNRC_CONTROLLER_TESTING: 0x...
# SNRC_CONTROLLER_SIMPLEX: 0x...
ports:
- "127.0.0.1:8000:8000"
restart: unless-stopped
+184 -1
View File
@@ -39,6 +39,10 @@ Environment:
0x58fc46996d975c57883564648bda5206d1a0102b)
SNRC_REGISTRY_SIMPLEX ENSRegistry for the .simplex deployment
(default: empty TLD not yet deployed)
SNRC_REGISTRAR_<TLD> BaseRegistrar (ERC-721) for the TLD; expiry and status
(default: mainnet for .testing, empty for .simplex)
SNRC_CONTROLLER_<TLD> SimplexController (proxy) for the TLD; `reserved` status
(default: mainnet for .testing, empty for .simplex)
SNRC_PORT Listen port (default: 8000)
SNRC_BIND Bind address (default: 0.0.0.0)
@@ -62,6 +66,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
@@ -85,6 +90,35 @@ REGISTRIES = {
"simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet
}
# The BaseRegistrar (ERC-721) per TLD, used for expiry and status. Separate
# from the registry above: the registry answers "who owns this node", the
# registrar holds nameExpires and GRACE_PERIOD. Not a proxy, so the address in
# deployments is the one that answers. Without one for a TLD, /resolve still
# works and reports "status": "unknown".
REGISTRARS = {
"testing": os.environ.get("SNRC_REGISTRAR_TESTING", "")
or "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a", # mainnet .testing
"simplex": os.environ.get("SNRC_REGISTRAR_SIMPLEX", ""), # not deployed yet
}
# The SimplexController per TLD, which holds `reservedNames`. Without one for a
# TLD, `reserved` is never reported and a reserved name reads as unregistered.
CONTROLLERS = {
"testing": os.environ.get("SNRC_CONTROLLER_TESTING", "")
# The proxy, not SimplexControllerImpl: storage and events live in the
# proxy, so the implementation address answers nothing. deployments.json
# records this one under the ENS role name ETHRegistrarController;
# verification.json names it SimplexControllerProxy. Same address.
or "0xeeb9b6bf5fb68fb726005f7ba549c2f4b32f2dad", # mainnet .testing
"simplex": os.environ.get("SNRC_CONTROLLER_SIMPLEX", ""), # not deployed yet
}
# Why a name is reserved. `reservedNames` stores only the fact, so every
# reserved name gets this same sentence; a per-name lookup (table or REST) is
# the intended replacement. Callers should render whatever this field holds
# rather than matching on its text.
RESERVED_REASON = "reserved for a brand or public interest"
# SLIP-44 coin types (https://github.com/satoshilabs/slips/blob/master/slip-0044.md)
COIN_ETH = 60
COIN_BTC = 0
@@ -165,6 +199,102 @@ def node_of(name: str) -> bytes:
return namehash(name)
# ---------- Registration status ----------
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`. 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 is_reserved(tld: str, token: int) -> bool:
"""Whether the controller holds this label for a brand.
Keyed by labelhash on chain, so this answers for a hashed query too.
"""
controller = CONTROLLERS.get(tld)
if not controller:
return False
raw = eth_call(controller, selector("reservedNames(bytes32)") + encode_uint(token))
return decode_uint(raw) != 0
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, "graceEnds": None}
# The registration facts (nameExpires, reservedNames) are keyed on
# uint256(keccak(label)), so a 2LD queried by its encoded labelhash gets
# the same answer without the label. The bracket form decodes only there -
# the same rule node_of applies to the node itself.
label = labels[-2]
if len(labels) == 2 and is_encoded_labelhash(label):
token = int(label[1:-1], 16)
else:
token = int.from_bytes(keccak(label.encode()), "big")
expires = decode_uint(
eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token))
)
if expires == 0:
status, grace = "unregistered", 0
else:
grace = grace_period(registrar)
status = expiry_status(expires, grace, int(time.time()))
# `reserved` only displaces the two states that read as "you could take
# this". A registered name is registered, and one in grace belongs to its
# owner either way - in both cases the reservation is not the answer to the
# question being asked.
if status in ("unregistered", "expired") and is_reserved(tld, token):
status = "reserved"
return {
"status": status,
"expires": expires or None,
"graceEnds": (expires + grace) if expires else None,
}
def selector(signature: str) -> str:
return "0x" + keccak(signature.encode())[:4].hex()
@@ -185,6 +315,15 @@ 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_text_call(node: bytes, key: str) -> str:
sel = selector("text(bytes32,string)")
head = node.hex() + (0x40).to_bytes(32, "big").hex()
@@ -446,10 +585,51 @@ def resolve(name: str):
node = node_of(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 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"] in ("unregistered", "reserved"):
body = {
"name": name,
"status": reg["status"],
"expires": reg["expires"],
"graceEnds": reg["graceEnds"],
"error": (
"this name is reserved and cannot be registered"
if reg["status"] == "reserved"
else "this name has never been registered"
),
}
# Only reserved names carry a reason, so its presence is the signal
# that one is known.
if reg["status"] == "reserved":
body["reason"] = RESERVED_REASON
return 404, body
if reg["status"] in ("grace", "expired"):
return 410, {
"name": name,
"status": reg["status"],
"expires": reg["expires"],
"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)
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"],
"graceEnds": reg["graceEnds"],
"error": "no resolver set for this name",
}
owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex)
owner = decode_address(owner_raw)
@@ -485,6 +665,9 @@ def resolve(name: str):
"dot": addr_multicoin(resolver_addr, node, COIN_DOT),
"owner": owner,
"resolver": resolver_addr,
"status": reg["status"],
"expires": reg["expires"],
"graceEnds": reg["graceEnds"],
}
@@ -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`.
@@ -96,6 +97,17 @@ class EncodedLabelhashTests(unittest.TestCase):
# keccak-256("alice") = 9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501
# - written out in full wherever a test needs a real labelhash.
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
GRACE = 90 * 86400
def setUp(self):
self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call)
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": ""}
def tearDown(self):
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved
def test_the_encoded_form_is_recognised(self):
self.assertTrue(
snrc.is_encoded_labelhash(
@@ -162,6 +174,268 @@ class EncodedLabelhashTests(unittest.TestCase):
name = "[nothex].testing"
self.assertEqual(snrc.node_of(name), snrc.namehash(name))
def test_status_by_hash_matches_status_by_name(self):
"""The registrar keys registration data on the labelhash too, so a
hashed query answers "is it free?" - not only "what does it say?" -
without the label."""
future = int(time.time()) + 86400
seen = []
def eth_call(to, data):
seen.append(data)
if data.startswith(snrc.selector("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(self.GRACE)
return "0x" + snrc.encode_uint(future)
snrc.eth_call = eth_call
by_name = snrc.name_status("alice.testing")
by_hash = snrc.name_status(
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
".testing"
)
self.assertEqual(by_name, by_hash)
self.assertEqual(by_name["status"], "registered")
# nothing in either request carried the label itself
self.assertTrue(all("alice".encode().hex() not in d for d in seen))
class NameStatusTests(unittest.TestCase):
"""unresolvable has three causes and a caller has to tell them apart.
Names expire lazily, so the chain still holds the answer."""
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)
return eth_call
def setUp(self):
self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call)
snrc.REGISTRARS = {"testing": self.REGISTRAR}
# These cases are about expiry alone. ReservedTests covers what a
# configured controller adds.
snrc.CONTROLLERS = {"testing": ""}
def tearDown(self):
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved
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, "graceEnds": None},
)
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": "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, "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 = []
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, "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 ReservedTests(unittest.TestCase):
"""A reserved name is unregistered and still unavailable, which a client
intending to register needs to know before it tries."""
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
def setUp(self):
self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call)
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
def tearDown(self):
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved
def _chain(self, expires, reserved):
def eth_call(to, data):
if data.startswith(snrc.selector("reservedNames(bytes32)")):
self.assertEqual(to, self.CONTROLLER)
return "0x" + snrc.encode_uint(1 if reserved else 0)
if data.startswith(snrc.selector("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(90 * 86400)
return "0x" + snrc.encode_uint(expires)
return eth_call
def test_unregistered_and_reserved_reads_reserved(self):
snrc.eth_call = self._chain(0, True)
self.assertEqual(snrc.name_status("acme.testing")["status"], "reserved")
def test_unregistered_and_not_reserved_reads_unregistered(self):
snrc.eth_call = self._chain(0, False)
self.assertEqual(snrc.name_status("acme.testing")["status"], "unregistered")
def test_a_lapsed_reserved_name_is_reserved_not_claimable(self):
past = int(time.time()) - 91 * 86400
snrc.eth_call = self._chain(past, True)
self.assertEqual(snrc.name_status("acme.testing")["status"], "reserved")
def test_a_live_name_is_registered_even_if_reserved(self):
"""It was handed to its brand; the reservation is no longer the answer."""
snrc.eth_call = self._chain(int(time.time()) + 86400, True)
self.assertEqual(snrc.name_status("acme.testing")["status"], "registered")
def test_a_name_in_grace_belongs_to_its_owner_not_the_reserved_set(self):
snrc.eth_call = self._chain(int(time.time()) - 3600, True)
self.assertEqual(snrc.name_status("acme.testing")["status"], "grace")
def test_no_controller_configured_means_reserved_is_never_reported(self):
snrc.CONTROLLERS = {"testing": ""}
snrc.eth_call = self._chain(0, True) # would say reserved if asked
self.assertEqual(snrc.name_status("acme.testing")["status"], "unregistered")
def test_reserved_is_asked_by_labelhash_so_a_hashed_query_works(self):
# keccak-256("acme")
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
snrc.eth_call = self._chain(0, True)
self.assertEqual(snrc.name_status(hashed + ".testing")["status"], "reserved")
class ReservedReasonTests(unittest.TestCase):
"""Why a name is reserved travels in its own field, so a client can show it
without parsing the message, and so a per-name reason can replace the fixed
one without moving anything."""
REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b"
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
def setUp(self):
self._saved = (
snrc.REGISTRIES,
snrc.REGISTRARS,
snrc.CONTROLLERS,
snrc.eth_call,
)
snrc.REGISTRIES = {"testing": self.REGISTRY}
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
def tearDown(self):
(
snrc.REGISTRIES,
snrc.REGISTRARS,
snrc.CONTROLLERS,
snrc.eth_call,
) = self._saved
def _chain(self, expires, reserved):
def eth_call(to, data):
if data.startswith(snrc.selector("reservedNames(bytes32)")):
return "0x" + snrc.encode_uint(1 if reserved else 0)
if data.startswith(snrc.selector("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(90 * 86400)
return "0x" + snrc.encode_uint(expires)
return eth_call
def test_a_reserved_name_carries_the_reason(self):
snrc.eth_call = self._chain(0, True)
status, body = snrc.resolve("acme.testing")
self.assertEqual(status, 404)
self.assertEqual(body["status"], "reserved")
self.assertEqual(body["reason"], "reserved for a brand or public interest")
def test_the_message_does_not_claim_a_trademark(self):
snrc.eth_call = self._chain(0, True)
_, body = snrc.resolve("acme.testing")
self.assertNotIn("trademark", body["error"])
def test_an_unregistered_name_has_no_reason(self):
snrc.eth_call = self._chain(0, False)
status, body = snrc.resolve("acme.testing")
self.assertEqual(status, 404)
self.assertEqual(body["status"], "unregistered")
self.assertNotIn("reason", body)
def test_an_expired_name_has_no_reason(self):
snrc.eth_call = self._chain(1, False)
status, body = snrc.resolve("acme.testing")
self.assertEqual(status, 410)
self.assertEqual(body["status"], "expired")
self.assertNotIn("reason", body)
def test_a_hashed_query_gets_the_reason_too(self):
snrc.eth_call = self._chain(0, True)
# keccak-256("acme")
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
_, body = snrc.resolve(hashed + ".testing")
self.assertEqual(body["reason"], "reserved for a brand or public interest")
if __name__ == "__main__":
unittest.main()