mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-09-13 21:56:19 +00:00
report expiry and availability correctly
This commit is contained in:
+74
-12
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user