diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 9be07c4e1..97126c7be 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -19,15 +19,15 @@ against **Ethereum mainnet** (where the `.testing` contracts live): ## 1. Configure -Edit `.env` — the defaults work as-is; override only if needed: +Edit `.env`. The defaults work as they are; change them only if you need to: ```sh NETWORK=mainnet # default TRUSTED_NODE_URL=https://mainnet-checkpoint-sync.attestant.io # default ``` -Everything else (NAT) has a working default baked into `docker-compose.yml`; -uncomment the hints in `.env` only to override. +Everything else (NAT) already has a working default in `docker-compose.yml`. +Uncomment the hints in `.env` only if you need to change one. ## 2. Run @@ -37,7 +37,7 @@ docker compose up -d docker compose logs -f reth resolver ``` -`depends_on` handles ordering automatically (start node → start resolver). +Compose starts the node before the resolver; `depends_on` takes care of that. ## 3. Wait for the node to sync @@ -45,12 +45,13 @@ docker compose logs -f reth resolver docker compose logs --tail=20 reth ``` -This is the long pole (~1 day on mainnet). Until reth is synced the resolver -returns `502`. +This is the slow step: about a day on mainnet. Until reth has synced, the +resolver returns `502`. ## Verify -Run these once the stack is up (the node-dependent ones pass after sync): +Run the three checks below once the stack is up. The ones that need chain data +pass only after the node has synced. **1. reth is reachable and reporting a block:** ```sh @@ -71,7 +72,7 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq # → {"name":"foobar.testing","nickname":"Foo","simplexContact":["https://smp16.simplex.im/a#…"], … } ``` -**Wire your smp-server:** in its `[NAMES]` section set +**Point your smp-server at it:** in its `[NAMES]` section set `resolver_endpoint: http://127.0.0.1:8000` (no auth needed for loopback). ## Ports (all loopback unless noted) @@ -86,9 +87,10 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq ## Caveats -- **All images track `:latest`** (reth, nimbus) — you get upstream fixes on each - `docker compose pull`; re-run the verify checks after pulling. -- All ports bind to loopback; expose only what you put behind a TLS reverse proxy. +- **All images track `:latest`** (reth, nimbus). Each `docker compose pull` + brings upstream fixes, so re-run the checks above afterwards. +- All ports bind to loopback. Expose only what you put behind a TLS reverse + proxy. ## Teardown @@ -103,8 +105,9 @@ docker compose down -v # also wipe volumes → full re-sync ## Resolver API reference -The resolver (`snrc-resolve.py`, host `127.0.0.1:8000`) is also runnable -standalone for local dev (no Docker), via [`uv`](https://docs.astral.sh/uv/): +You can also run the resolver (`snrc-resolve.py`, host `127.0.0.1:8000`) on its +own for local development, without Docker, using +[`uv`](https://docs.astral.sh/uv/): ```sh uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + mainnet .testing @@ -119,15 +122,48 @@ 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 } ``` -`simplexContact`/`simplexChannel` are arrays (a name can advertise multiple SMP -servers; clients try them in order). On-chain they're a single comma-separated -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`). +`simplexContact` and `simplexChannel` are arrays, because a name can advertise +several SMP servers; clients try them in order. On chain each one is a single +text record with the entries joined by `;`. The resolver splits that record, +trims each entry and drops the empty ones. Addresses come back in each chain's +usual format (EIP-55, bech32, SS58, Monero base58). Subnames work the same way +(`bar.foobar.testing`). + +### Registration status and expiry + +A response carries `status`, `expires` and `graceEnds` whenever the resolver +read them, a successful resolve included, so a client that has just resolved a +name already knows when it expires. Both timestamps are Unix seconds, and +`null` when they could not be read. + +| `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 | + +`grace` and `expired` are told apart by the registrar's own `available(id)` +rule, `expires + GRACE_PERIOD < now`. `GRACE_PERIOD` is read from the contract +rather than assumed, and `now` is the latest block's timestamp rather than the +host clock, which the registrar compares against too, so a machine with a wrong +clock cannot misreport a registration. That rule alone is not enough: it also +holds for a name nobody ever registered (`0 + GRACE_PERIOD < now`), so a zero +expiry is what separates *never registered* from *registered and since +released*. + +A subname reports the status of the 2LD above it, which is only as good as the +name it sits under. ### Querying by labelhash @@ -141,8 +177,22 @@ curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d' ``` namehash is `keccak(parent || keccak(label))`, so this reaches the same node and -returns the same record. The resolver learns the name only by guessing the label -and hashing it. +returns the same record. The registrar keys `nameExpires` and `reservedNames` on +the labelhash too, so the status fields do not need the label either. The +resolver learns the name only by guessing the label and hashing it. + +Read the answer from `status`. A name is free only when the body says +`unregistered`, which comes with a 404. Every other status means somebody holds +the name or held it recently. Watch out for `noResolver`: it is also a 404, but +the name is taken. + +The hash must be keccak-256. `openssl dgst -sha3-256` and `sha3sum` compute +SHA3-256, a different function that returns 64 valid-looking hex characters +pointing at the wrong node. + +The resolver lowercases the query before matching, so uppercase hex works too. +Clients that refuse raw brackets in a path can percent-encode them as `%5B` and +`%5D`. Brackets cannot collide with a real name: they are invalid in a normalised ENS name, and a `[<64 hex>]` label is 66 bytes against the registrar's @@ -151,23 +201,67 @@ is an ordinary, registrable name. Only 2LDs can be queried this way, as only a 2LD can be raced for: subnames are created by the 2LD's owner. A bracket label in a subname is hashed as written, -so it points at a node nobody can own. +so it points at a node nobody can own. ENS tooling accepts the bracketed form at +any depth; this resolver does not, on purpose. This hides interest in a name and nothing else: the registration itself is -public, and commit-reveal covers that step. +public, and commit-reveal covers that step. A short or well-known label is easy +to guess by hashing candidates, and the reveal publishes the labelhash, so an +operator who logged the query can match it to the name afterwards. + +### Errors + +Every non-2xx body carries two fields: `error` is a fixed code to branch on, +and `message` is a sentence for a human. Match on `error`, never on `message`, +which is free to change. + +```jsonc +{"name": "nope.testing", "error": "unregistered", + "message": "this name has never been registered", + "status": "unregistered", "expires": null, "graceEnds": null} +``` + +The codes are `tldNotConfigured`, `notFullyQualified`, `unregistered`, +`reserved`, `grace`, `expired`, `noResolver`, `noSuchRoute` and +`upstreamError`. When the registration is what went wrong, `error` and `status` +hold the same value, so one field is enough to read. + +`upstreamError` says only which exception type the RPC call raised. The text +goes to the resolver's log instead, because `SNRC_RPC` can carry a provider key +and urlopen puts the URL it failed on into the message. ### 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 + +The resolver reads three contracts, each configured per TLD. + +The **registry** answers who owns a node, and `/resolve` reads the records from +it. The **registrar** (ERC-721) holds `nameExpires` and `GRACE_PERIOD`, which +is where every expiry field comes from. With no registrar for a TLD, `/resolve` +still works and reports `"status": "unknown"`. The **controller** holds +`reservedNames`, which is where the `reserved` status comes from. With no +controller, a reserved name reads as `unregistered`. + +All three default to the mainnet `.testing` deployment. `.simplex` is unset +until it is deployed. + +The controller default is the **proxy**, not `SimplexControllerImpl`. Storage +lives in the proxy, so the implementation address answers nothing. The two +deployment files use different names for that proxy: +`deployments.mainnet.testing.json` records it under the ENS role name +`ETHRegistrarController`, and `verification.mainnet.testing.json` calls it +`SimplexControllerProxy`. Both are the same address, and it is the one used +here. -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. +To override any of them, set `SNRC_REGISTRY_`, `SNRC_REGISTRAR_` or +`SNRC_CONTROLLER_` on the `resolver` service in `docker-compose.yml`, or +as env vars when you run the script directly. \ No newline at end of file diff --git a/scripts/resolver/docker-compose.yml b/scripts/resolver/docker-compose.yml index 570b63df3..24a90488e 100644 --- a/scripts/resolver/docker-compose.yml +++ b/scripts/resolver/docker-compose.yml @@ -150,6 +150,12 @@ services: # only if you're deploying against a different network or contract. # SNRC_REGISTRY_TESTING: 0x... # SNRC_REGISTRY_SIMPLEX: 0x... + # Registrar and controller, same cascade. Without the registrar `status` + # is "unknown"; without the controller a reserved name is "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 8fc30b68d..cdadc1f48 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -39,6 +39,10 @@ 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) @@ -85,6 +89,23 @@ "simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet } +REGISTRARS = { + "testing": os.environ.get("SNRC_REGISTRAR_TESTING", "") + or "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a", # mainnet .testing + "simplex": os.environ.get("SNRC_REGISTRAR_SIMPLEX", ""), # not deployed yet +} + +CONTROLLERS = { + "testing": os.environ.get("SNRC_CONTROLLER_TESTING", "") + # Proxy address, not SimplexControllerImpl: storage is held by the proxy. + # Recorded in deployments.json as ETHRegistrarController. + or "0xeeb9b6bf5fb68fb726005f7ba549c2f4b32f2dad", # mainnet .testing + "simplex": os.environ.get("SNRC_CONTROLLER_SIMPLEX", ""), # not deployed yet +} + +# `reservedNames` stores the fact only, never a reason. +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 @@ -139,20 +160,81 @@ def is_encoded_labelhash(label: str) -> bool: def node_of(name: str) -> bytes: - """namehash, accepting an encoded labelhash in place of a 2LD's label. - - keccak(parent || keccak(label)) reaches the same node without the label, - so a caller can check a 2LD without disclosing which one they are about to - register. Subnames are excluded - only the 2LD's owner creates them, so - there is nothing to front-run - and a bracket label there is hashed as - written. - """ + """namehash, accepting an encoded labelhash in place of a 2LD's label. In a + subname a bracket label is hashed as written, not decoded.""" labels = name.split(".") if len(labels) == 2 and is_encoded_labelhash(labels[0]): return keccak(namehash(labels[1]) + bytes.fromhex(labels[0][1:-1])) return namehash(name) +# ---------- Registration status ---------- + + +def chain_now() -> int: + """Expiry is compared against the block timestamp, never the host clock.""" + block = rpc("eth_getBlockByNumber", ["latest", False]) + return decode_uint(block["timestamp"]) + + +def grace_period(registrar: str) -> int: + """A deployment can configure a different window, so it is read on chain.""" + return decode_uint(eth_call(registrar, selector("GRACE_PERIOD()"))) + + +def expiry_status(expires: int, grace: int, now: int) -> str: + """The registrar's `available(id)` is not enough on its own: it is also + true for a name nobody registered, since 0 + GRACE_PERIOD < now.""" + if expires == 0: + return "unregistered" + if expires > now: + return "registered" + if expires + grace >= now: + return "grace" + return "expired" + + +def is_reserved(tld: str, token: int) -> bool: + 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): + labels = name.split(".") + tld = labels[-1] + registrar = REGISTRARS.get(tld) + if not registrar or len(labels) < 2: + return {"status": "unknown", "expires": None, "graceEnds": None} + + # nameExpires and reservedNames are keyed on uint256(keccak(label)). + # Decoded for a 2LD only, the same rule node_of applies to the node. + 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, chain_now()) + + 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() @@ -173,6 +255,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() @@ -420,6 +511,17 @@ def split_links(value: str) -> list: return [item.strip() for item in value.split(LINK_SEPARATOR) if item.strip()] +def upstream_error(subject: dict, e: Exception) -> dict: + """urlopen puts the failing URL into its message and SNRC_RPC can carry a + provider key, so the text goes to the log and only the type to the caller.""" + print(f"upstream error: {type(e).__name__}: {e}", file=sys.stderr) + return { + **subject, + "error": "upstreamError", + "message": f"upstream RPC failed ({type(e).__name__})", + } + + def resolve(name: str): tld = name.rsplit(".", 1)[-1] registry = REGISTRIES.get(tld) @@ -427,17 +529,57 @@ def resolve(name: str): configured = [k for k, v in REGISTRIES.items() if v] return 400, { "name": name, - "error": f"TLD '{tld}' is not configured on this resolver", - "configured_tlds": configured, + "error": "tldNotConfigured", + "message": f"TLD '{tld}' is not configured on this resolver", + "configuredTlds": configured, } node = node_of(name) node_hex = node.hex() + # Before the resolver lookup, so a lapsed name is not reported as noResolver. + reg = name_status(name) + if reg["status"] in ("unregistered", "reserved"): + body = { + "name": name, + "status": reg["status"], + "expires": reg["expires"], + "graceEnds": reg["graceEnds"], + "error": reg["status"], + "message": ( + "this name is reserved and cannot be registered" + if reg["status"] == "reserved" + else "this name has never been registered" + ), + } + 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": reg["status"], + "message": ( + "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": "noResolver", + "message": "no resolver set for this name", + } owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex) owner = decode_address(owner_raw) @@ -473,6 +615,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"], } @@ -496,21 +641,26 @@ def do_GET(self): # noqa: N802 - http.server contract self._respond( 400, { - "error": "expected fully-qualified name, e.g. /resolve/alice.testing", - "got": name, + "name": name, + "error": "notFullyQualified", + "message": "expected a fully-qualified name, e.g. alice.testing", }, ) return try: status, body = resolve(name) except Exception as e: # surface upstream errors as 502 - status, body = 502, {"name": name, "error": f"{type(e).__name__}: {e}"} + status, body = 502, upstream_error({"name": name}, e) self._respond(status, body) return self._respond( 404, - {"error": "not found", "routes": ["/health", "/resolve/"]}, + { + "error": "noSuchRoute", + "message": "not found", + "routes": ["/health", "/resolve/"], + }, ) def _respond(self, status: int, body: dict): diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 6a56f5bef..16c82f610 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -4,8 +4,11 @@ Run with `python3 -m unittest scripts/resolver/service/test_snrc_resolve.py`. """ +import contextlib import importlib.util +import io import os +import time import unittest # snrc-resolve.py has a hyphen, so import it via importlib instead of `import`. @@ -83,12 +86,21 @@ def test_order_is_preserved(self): class EncodedLabelhashTests(unittest.TestCase): - """`node_of` accepts a 2LD's label as an encoded labelhash `[<64 hex>]`, - reaching the same node as the label itself.""" - # keccak-256("alice"), written out in full wherever a test needs it. # 9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501 + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + GRACE = 90 * 86400 + + def setUp(self): + self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now) + snrc.REGISTRARS = {"testing": self.REGISTRAR} + snrc.CONTROLLERS = {"testing": ""} + snrc.chain_now = lambda: int(time.time()) + + def tearDown(self): + snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved + def test_the_encoded_form_is_recognised(self): self.assertTrue( snrc.is_encoded_labelhash( @@ -149,6 +161,361 @@ def test_a_malformed_bracket_label_falls_back_to_a_literal_name(self): name = "[nothex].testing" self.assertEqual(snrc.node_of(name), snrc.namehash(name)) + def test_status_by_hash_matches_status_by_name(self): + 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): + 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.chain_now, + snrc.rpc, + ) + snrc.REGISTRARS = {"testing": self.REGISTRAR} + # Expiry alone; ReservedTests covers a configured controller. + snrc.CONTROLLERS = {"testing": ""} + snrc.chain_now = lambda: int(time.time()) + + def tearDown(self): + ( + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + snrc.chain_now, + snrc.rpc, + ) = self._saved + + def test_now_is_the_latest_blocks_timestamp(self): + # setUp replaced chain_now with the fixture clock; test the real one + real_chain_now = self._saved[3] + snrc.rpc = lambda method, params: {"timestamp": "0x65f1a2c0", "number": "0x123"} + self.assertEqual(real_chain_now(), 0x65F1A2C0) + + def test_status_reads_the_chain_clock_not_the_host_clock(self): + future = int(time.time()) + 3600 + snrc.eth_call = self._expiry(future) + self.assertEqual(snrc.name_status("alice.testing")["status"], "registered") + snrc.chain_now = lambda: future + 3650 * 86400 + self.assertEqual(snrc.name_status("alice.testing")["status"], "expired") + + 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): + 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 only when expires + GRACE < now.""" + 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.""" + 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): + 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): + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" + + def setUp(self): + self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now) + snrc.REGISTRARS = {"testing": self.REGISTRAR} + snrc.CONTROLLERS = {"testing": self.CONTROLLER} + snrc.chain_now = lambda: int(time.time()) + + def tearDown(self): + snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = 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): + 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) # reserved on chain, but unread + 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): + REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" + + def setUp(self): + self._saved = ( + snrc.REGISTRIES, + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + snrc.chain_now, + ) + snrc.REGISTRIES = {"testing": self.REGISTRY} + snrc.REGISTRARS = {"testing": self.REGISTRAR} + snrc.CONTROLLERS = {"testing": self.CONTROLLER} + snrc.chain_now = lambda: int(time.time()) + + def tearDown(self): + ( + snrc.REGISTRIES, + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + snrc.chain_now, + ) = 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["message"]) + + 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") + + +class ErrorCodeTests(unittest.TestCase): + REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" + REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" + + def setUp(self): + self._saved = ( + snrc.REGISTRIES, + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + snrc.chain_now, + ) + snrc.REGISTRIES = {"testing": self.REGISTRY, "simplex": ""} + snrc.REGISTRARS = {"testing": self.REGISTRAR} + snrc.CONTROLLERS = {"testing": ""} + snrc.chain_now = lambda: int(time.time()) + + def tearDown(self): + ( + snrc.REGISTRIES, + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + snrc.chain_now, + ) = self._saved + + def _chain(self, expires, resolver=None): + def eth_call(to, data): + if data.startswith(snrc.selector("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(90 * 86400) + if data.startswith(snrc.selector("resolver(bytes32)")): + return "0x" + "00" * 12 + (resolver or "00" * 20) + return "0x" + snrc.encode_uint(expires) + + return eth_call + + def test_an_unconfigured_tld_names_the_ones_that_are(self): + status, body = snrc.resolve("alice.nosuchtld") + self.assertEqual(status, 400) + self.assertEqual(body["error"], "tldNotConfigured") + self.assertEqual(body["configuredTlds"], ["testing"]) + self.assertIn("nosuchtld", body["message"]) + + def test_a_registration_problem_reports_the_status_as_the_code(self): + for expires, code in ( + (0, "unregistered"), + (int(time.time()) - 3600, "grace"), + (int(time.time()) - 91 * 86400, "expired"), + ): + with self.subTest(code=code): + snrc.eth_call = self._chain(expires) + _, body = snrc.resolve("alice.testing") + self.assertEqual(body["error"], code) + self.assertEqual(body["status"], code) + + def test_a_registered_name_pointing_nowhere_is_noResolver(self): + snrc.eth_call = self._chain(int(time.time()) + 86400) + status, body = snrc.resolve("alice.testing") + self.assertEqual(status, 404) + self.assertEqual(body["error"], "noResolver") + self.assertEqual(body["status"], "noResolver") + + def test_every_error_body_carries_both_fields(self): + snrc.eth_call = self._chain(0) + for name in ("alice.nosuchtld", "alice.testing"): + with self.subTest(name=name): + _, body = snrc.resolve(name) + self.assertIsInstance(body["error"], str) + self.assertIsInstance(body["message"], str) + self.assertNotEqual(body["error"], body["message"]) + + def test_an_upstream_failure_does_not_echo_the_exception(self): + with contextlib.redirect_stderr(io.StringIO()) as log: + body = snrc.upstream_error( + {"name": "alice.testing"}, + RuntimeError("http://user:secret@rpc.example/kEy8 refused"), + ) + # the operator still sees the detail in the log + self.assertIn("secret", log.getvalue()) + self.assertEqual(body["error"], "upstreamError") + self.assertIn("RuntimeError", body["message"]) + self.assertNotIn("secret", body["message"]) + self.assertNotIn("kEy8", body["message"]) + if __name__ == "__main__": unittest.main() diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 19bae15fc..856339bc8 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -76,6 +76,9 @@ fetch NamesEnv {resolverEnv} d = mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case HttpStatusErr 404 -> NOT_FOUND + -- 410 is a lapsed registration: an answer about the name, not a resolver + -- failure, so it must not become RESOLVER. + HttpStatusErr 410 -> NOT_FOUND HttpStatusErr 400 -> NOT_FOUND HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code)) HttpFailure _ -> RESOLVER "transport failure" diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 2416d851e..d453c5553 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -19,7 +19,7 @@ import Data.List.NonEmpty (NonEmpty (..)) import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (getCurrentTime) -import Network.HTTP.Types (Status, status200, status404, status502) +import Network.HTTP.Types (Status, status200, status404, status410, status502) import NamesResolverServer (memCfg, memCfg2, memProxyCfg, withNames) import qualified NamesResolverServer as NRS import SMPClient @@ -74,6 +74,7 @@ rslvTests :: Spec rslvTests = do describe "RSLV direct (non-forwarded)" $ do it "resolver replies 404 -> NAME NOT_FOUND (reached, not CMD PROHIBITED)" testRslvBackendNotFound + it "resolver replies 410 -> NAME NOT_FOUND (a lapsed name, not a resolver failure)" testRslvBackendGone it "resolver replies 502 -> NAME (RESOLVER ..)" testRslvBackendHttpErr it "no names config -> NAME NO_RESOLVER" testRslvDisabled it "refuses to send RSLV on a session below namesSMPVersion" testRslvVersion @@ -91,6 +92,13 @@ testRslvBackendNotFound = corrId `shouldBe` CorrId "rs01" resp `shouldBe` Right (ERR (NAME NOT_FOUND)) +testRslvBackendGone :: IO () +testRslvBackendGone = + withResolverServer (status410, "{}") $ + testSMPClient @TLS $ \h -> do + (_, _, resp) <- sendRslv h "rs08" (domain "lapsed.simplex") + resp `shouldBe` Right (ERR (NAME NOT_FOUND)) + testRslvBackendHttpErr :: IO () testRslvBackendHttpErr = withResolverServer (status502, "{}") $ diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 0101a40a7..16a332d5f 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -13,7 +13,7 @@ import Data.IORef (readIORef) import Data.List (sort) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) -import Network.HTTP.Types (status200, status400, status404, status500, status502) +import Network.HTTP.Types (status200, status400, status404, status410, status500, status502) import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed) import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Encoding.String (strDecode) @@ -156,6 +156,11 @@ resolverSpec = do env <- newNamesEnv (testNamesConfig port) resolveName env aliceDomain `shouldReturn` Left NOT_FOUND + it "returns NOT_FOUND on 410 (registration lapsed)" $ + withResolverServer (resolveResp status410 "{}") $ \port _ -> do + env <- newNamesEnv (testNamesConfig port) + resolveName env aliceDomain `shouldReturn` Left NOT_FOUND + it "returns RESOLVER on 502 (upstream failure)" $ withResolverServer (resolveResp status502 "{}") $ \port _ -> do env <- newNamesEnv (testNamesConfig port)