diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 1930b3345..7d6808e60 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -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_` 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_` / `SNRC_REGISTRAR_` / `SNRC_CONTROLLER_`), or +as env vars for the standalone script. diff --git a/scripts/resolver/docker-compose.yml b/scripts/resolver/docker-compose.yml index 570b63df3..84d64e2c7 100644 --- a/scripts/resolver/docker-compose.yml +++ b/scripts/resolver/docker-compose.yml @@ -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 diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index b72cc9a1d..3971f5011 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -39,6 +39,10 @@ Environment: 0x58fc46996d975c57883564648bda5206d1a0102b) SNRC_REGISTRY_SIMPLEX ENSRegistry for the .simplex deployment (default: empty — TLD not yet deployed) + SNRC_REGISTRAR_ BaseRegistrar (ERC-721) for the TLD; expiry and status + (default: mainnet for .testing, empty for .simplex) + SNRC_CONTROLLER_ 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"], } diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index aea000a04..857a2bf5c 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -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()