From 89c8ba9c98e206d9599869832d5d404cbaa8bb42 Mon Sep 17 00:00:00 2001 From: brenzi-agent Date: Thu, 29 Oct 2026 13:13:37 +0100 Subject: [PATCH] resolver: read .simplex reservations from the name policy (#1993) * resolver: read .simplex reservations from the name policy simplex-network/ens-contracts#42 moved reservedNames from SimplexController to SimplexNamePolicy. SNRC_NAME_POLICY_ names it; where it is unset the controller is read as before, as on .testing. Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: keep the reserved read's shape, explicit policy in tests, describe contracts by behavior Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: pin the name policy in every test that reads reservations, one policy test that fails without the read, note setNamePolicy Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: no env pins the suite does not keep elsewhere, no comment repeating the docstring Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: name what the .testing controller keeps Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: an empty name policy reply is refused too Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: read each controller's name policy at startup in place of SNRC_NAME_POLICY_ Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: workers open their own RPC connections, docs of the startup read, test names and docstrings that say what holds Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: a controller with no contract behind it stops the startup read, so the container restarts until the node reaches it Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: an empty name policy reply falls back again, so a chain without .testing's controller still starts; new functions after the ones they join, compose lines added only Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: tests of the workers' own connections, an address without a contract and an unconfigured controller; docs of which TLDs answer 502 Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: a controller naming no policy is logged as the other fallbacks are, a test of the startup exit, no redundant default Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: the startup test above the worker tests' Linux guard, without a controller it does not need Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: the startup test runs one worker, a test of an unconfigured controller, the fallbacks described once Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: compose names the 502 of a policy the node has not reached, the worker test sets its own reservation, no repeated README clauses Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: every worker test reads the name's reservation from the policy, so its answer stays in setUp Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: docstrings that say where reservations are read from, no comment that does not hold Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: each reservation read picks the policy over the controller itself, no helper for two sites Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F * resolver: address review: the quick start's restart for a TLD with a name policy, the startup read's docstring Claude-Session: https://claude.ai/code/session_01BxsKd45qytTbangMqJs93F --------- Co-authored-by: brenzi-agent <346870783+brenzi-agent@users.noreply.github.com> --- scripts/resolver/README.md | 23 ++++++- scripts/resolver/docker-compose.yml | 4 ++ scripts/resolver/service/snrc-resolve.py | 46 ++++++++++++-- scripts/resolver/service/test_snrc_resolve.py | 60 ++++++++++++++++++- 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 6ba327c6a..995267837 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -46,7 +46,8 @@ docker compose logs --tail=20 reth ``` This is the long pole (~1 day on mainnet). Until reth is synced the resolver -returns `502`. +returns `502`. A TLD whose controller has a name policy then still answers `502` +until `docker compose restart resolver` (see Configuring addresses). ## Verify @@ -357,7 +358,8 @@ from before the enum stores a boolean, whose `true` decodes as 1, which is why ### Configuring addresses -The resolver reads three contracts, each configured per TLD. +The resolver reads three contracts, each configured per TLD, and the name +policy a controller names. The **registry** answers who owns a node, and `/resolve` reads the records from it. The **registrar** (ERC-721) holds `nameExpires` and `GRACE_PERIOD`, which @@ -372,6 +374,19 @@ held-back name reads as not reserved, and no name can be priced. All three default to the mainnet `.testing` deployment. `.simplex` is unset until it is deployed. +Newer contracts, which `.simplex` will run, keep `reservedNames` in a **name +policy** (`SimplexNamePolicy`), with the same `Reason`, and their controller +answers no `reservedNames` call. The resolver reads each controller's +`namePolicy()` once at startup, before its workers start, and reads reservations +from the policy it names. `.testing`'s controller is from before name policies, +so the read reverts. A read that reverts, answers nothing, names no policy or +fails with an RPC error is logged as `no_name_policy`, and reservations are read +from the controller. For a controller that has a name policy, every lookup under +its TLD then answers 502 until a restart: after an RPC error, or when the node +has not yet reached the controller or its `setNamePolicy`. A node that +does not answer stops the resolver, and the container restarts. After the +controller's owner calls `setNamePolicy`, restart the resolver. + 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: @@ -418,13 +433,15 @@ Each event is one line: UTC time, level, event name, then `key=value` fields. | Event | Level | Meaning | |---|---|---| -| `listening` | info | started: address, workers, RPC endpoint, registries, trusted proxies | +| `listening` | info | started: address, workers, RPC endpoint, registries, name policies, trusted proxies | | `request` | info | one answered request; `/health` only at debug | | `upstream_error` | warn | a read from the node failed; the caller got 502 | +| `no_name_policy` | warn | at startup, a controller's `namePolicy()` named no policy (see Configuring addresses); its reservations are read from the controller | | `multicall_unavailable` | warn | Multicall3 did not answer; rounds are sent as plain batches | | `client_gone` | warn | the caller hung up before the answer, usually an smp-server past its timeout | | `http_error` | warn | a malformed request; idle keep-alive timeouts only at debug | | `request_failed` | error | a request failed unexpectedly, with its traceback | +| `startup_failed` | error | the node did not answer the startup read of the name policies; the container restarts | | `worker_exited`, `worker_failed` | error | a worker stopped; the others stop too, so the container restarts | | `stopping` | info | stopped by a signal | diff --git a/scripts/resolver/docker-compose.yml b/scripts/resolver/docker-compose.yml index 1e716357b..a0978b6b6 100644 --- a/scripts/resolver/docker-compose.yml +++ b/scripts/resolver/docker-compose.yml @@ -141,6 +141,10 @@ services: # starting before reth has finished snapshot replay just yields a few # 502s until the chain is queryable. The upstream reth image doesn't # ship a HEALTHCHECK, so we can't gate on healthy. + # The startup read of the name policies is the exception: until reth + # answers, the resolver exits and restarts, and a TLD whose name policy + # reth has not yet reached answers 502 until the resolver restarts + # (README, "Configuring addresses"). reth: condition: service_started environment: diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 741e512f9..5ab92a4a4 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -42,6 +42,7 @@ Environment: 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; reservations, + read from the policy its `namePolicy()` names, if any, and through its `prices()` oracle what registering costs (default: mainnet for .testing, empty for .simplex) SNRC_PORT Listen port (default: 8000) @@ -140,6 +141,10 @@ CONTROLLERS = { "simplex": os.environ.get("SNRC_CONTROLLER_SIMPLEX", ""), # not deployed yet } +# The name policy each TLD's controller names, which holds its reservations in +# place of the controller; set at startup from `name_policies()`. +NAME_POLICIES = {} + # `reservedNames` holds a SimplexController.Reason; 0 means not reserved. A # controller from before the enum stores a bool, whose `true` decodes as 1, # which is why 1 reads as "internal". @@ -520,7 +525,27 @@ def reservation_reason(tld: str, token: int) -> int: controller = CONTROLLERS.get(tld) if not controller: return 0 - return decode_uint(eth_call(*reserved_call(controller, token))) + return decode_uint(eth_call(*reserved_call(NAME_POLICIES.get(tld, controller), token))) + + +def name_policies() -> dict: + """The name policy of each configured controller that names one. A controller + that reverts, answers nothing or names no policy is logged, and its reservations + are read from it; an RPC error is taken as a revert. Raises when the node does + not answer.""" + policies = {} + for tld, controller in CONTROLLERS.items(): + if not controller: + continue + try: + policy = decode_address(eth_call(*name_policy_call(controller))) + if policy == ZERO_ADDR: + raise RuntimeError("namePolicy() is unset") + except RuntimeError as e: + log_event(logging.WARNING, "no_name_policy", tld=tld, controller=controller, message=str(e), reservations="controller") + continue + policies[tld] = policy + return policies def pricing_params(tld: str): @@ -786,8 +811,12 @@ def label_call(registrar: str, token: int): return registrar, selector("labelOf(uint256)") + encode_uint(token) -def reserved_call(controller: str, token: int): - return controller, selector("reservedNames(bytes32)") + encode_uint(token) +def reserved_call(contract: str, token: int): + return contract, selector("reservedNames(bytes32)") + encode_uint(token) + + +def name_policy_call(controller: str): + return controller, selector("namePolicy()") def prices_call(contract: str): @@ -830,7 +859,7 @@ def lookup_reads(name: str): if len(labels) == 2 and is_encoded_labelhash(labels[0]): reads.append(eth_call_read(*label_call(registrar, token))) if CONTROLLERS.get(tld): - reads.append(eth_call_read(*reserved_call(CONTROLLERS[tld], token))) + reads.append(eth_call_read(*reserved_call(NAME_POLICIES.get(tld, CONTROLLERS[tld]), token))) registry = REGISTRIES.get(tld) if registry: node = node_of(name) @@ -1464,6 +1493,9 @@ def supervise(workers: int): stop() stop_on(signum, frame) + # each worker opens its own connections: one shared would mix their replies + while not _rpc_pool.empty(): + _rpc_pool.get_nowait().close() # before forking, so a signal during startup cannot orphan the workers signal.signal(signal.SIGTERM, stop_and_exit) signal.signal(signal.SIGINT, stop_and_exit) @@ -1488,6 +1520,11 @@ def supervise(workers: int): def main(): setup_logging() + try: + NAME_POLICIES.update(name_policies()) + except Exception: + log_event(logging.ERROR, "startup_failed", exc_info=True) + sys.exit(1) log_event( logging.INFO, "listening", @@ -1496,6 +1533,7 @@ def main(): workers=WORKERS, rpc=public_endpoint(RPC), registries=",".join(f"{tld}={addr or '-'}" for tld, addr in REGISTRIES.items()), + name_policies=",".join(f"{tld}={NAME_POLICIES.get(tld, '-')}" for tld in CONTROLLERS), trusted_proxies=",".join(map(str, TRUSTED_PROXIES)) or None, ) if WORKERS > 1: diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 55ea17e43..2cf6a792c 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -1141,6 +1141,7 @@ class FakeChain: REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" + NAME_POLICY = "0x4b7e0c1a9d2f3e5b6c8a7d9e0f1b2c3d4e5f6a7b" ORACLE = "0x1e0c9a2b9d1a4c8f7b3e5d6a9c2f4b8e1d7a3c50" OWNER = "0xd83bd7e0e6b8a4c1f2593a7b0c4e8d1a6f9b2c37" RESOLVER = "0x80fa2b1c3d4e5f60718293a4b5c6d7e8f9012345" @@ -1275,7 +1276,7 @@ class FakeNodeTestCase(unittest.TestCase): def setUp(self): self.node = FakeNode() - self._saved = (snrc.RPC, snrc.RPC_URL, snrc.REGISTRIES, snrc.REGISTRARS, snrc.CONTROLLERS) + self._saved = (snrc.RPC, snrc.RPC_URL, snrc.REGISTRIES, snrc.REGISTRARS, snrc.CONTROLLERS, snrc.NAME_POLICIES) snrc.RPC = self.node.url snrc.RPC_URL = urlparse(snrc.RPC) snrc.REGISTRIES = {"testing": FakeChain.REGISTRY, "simplex": ""} @@ -1286,7 +1287,7 @@ class FakeNodeTestCase(unittest.TestCase): def tearDown(self): self._drain_pool() - snrc.RPC, snrc.RPC_URL, snrc.REGISTRIES, snrc.REGISTRARS, snrc.CONTROLLERS = self._saved + snrc.RPC, snrc.RPC_URL, snrc.REGISTRIES, snrc.REGISTRARS, snrc.CONTROLLERS, snrc.NAME_POLICIES = self._saved self.node.stop() def _drain_pool(self): @@ -1398,6 +1399,12 @@ class BatchedReadsTests(FakeNodeTestCase): def test_v1_answers_the_same(self): self.assert_same_answer(snrc.resolve, "acme.testing", 2) + def test_reservations_held_by_a_name_policy_are_read_in_the_same_rounds(self): + snrc.NAME_POLICIES = {"testing": FakeChain.NAME_POLICY} + self.node.chain.answers[snrc.reserved_call(FakeChain.NAME_POLICY, snrc.label_token("free"))] = "0x" + snrc.encode_uint(1) + status, body = self.assert_same_answer(snrc.registration, "free.testing", 3) + self.assertEqual(body["registration"], {"type": "reserved", "reservedReason": "internal"}) + def test_without_multicall_a_round_is_still_one_batch(self): self.node.multicall = False with self.assertLogs("snrc_resolve", "WARNING") as logs: @@ -1436,6 +1443,37 @@ class BatchedReadsTests(FakeNodeTestCase): self.assertEqual(trips, 0) +class NamePoliciesTests(FakeNodeTestCase): + def test_a_controller_naming_a_policy_is_mapped_to_it(self): + self.node.chain.answers[snrc.name_policy_call(FakeChain.CONTROLLER)] = "0x" + snrc.encode_uint(int(FakeChain.NAME_POLICY, 16)) + self.assertEqual(snrc.name_policies(), {"testing": FakeChain.NAME_POLICY}) + + def test_a_controller_from_before_name_policies_keeps_the_reservations_and_is_logged(self): + with self.assertLogs("snrc_resolve", "WARNING") as logs: + self.assertEqual(snrc.name_policies(), {}) + [record] = logs.records + self.assertEqual((record.getMessage(), record.fields["tld"], record.fields["reservations"]), ("no_name_policy", "testing", "controller")) + + def test_an_unconfigured_controller_is_not_read(self): + snrc.CONTROLLERS = {"simplex": ""} + self.assertEqual(self.requests_made(snrc.name_policies), ({}, 0)) + + def test_an_address_without_a_contract_is_read_as_no_policy(self): + self.node.chain.answers[snrc.name_policy_call(FakeChain.CONTROLLER)] = "0x" + with self.assertLogs("snrc_resolve", "WARNING"): + self.assertEqual(snrc.name_policies(), {}) + + def test_a_controller_naming_no_policy_is_left_out_and_logged(self): + self.node.chain.answers[snrc.name_policy_call(FakeChain.CONTROLLER)] = "0x" + snrc.encode_uint(0) + with self.assertLogs("snrc_resolve", "WARNING"): + self.assertEqual(snrc.name_policies(), {}) + + def test_a_node_that_does_not_answer_stops_the_read(self): + self.node.status = 502 + with self.assertRaises(HTTPError): + snrc.name_policies() + + class Aggregate3Tests(unittest.TestCase): def test_calls_are_encoded_as_multicall3_reads_them(self): calls = [(FakeChain.REGISTRY, "0x0178b8bf" + "11" * 32), (FakeChain.RESOLVER, "0x59d1d43c" + "22" * 100)] @@ -1705,6 +1743,17 @@ class KeepAliveTests(unittest.TestCase): self.assertEqual(sock.recv(1), b"") +class StartupTests(unittest.TestCase): + def test_a_node_that_does_not_answer_stops_the_service(self): + node = FakeNode() + self.addCleanup(node.stop) + node.status = 502 + env = dict(os.environ, SNRC_RPC=node.url, SNRC_WORKERS="1") + service = subprocess.run([sys.executable, os.path.join(_HERE, "snrc-resolve.py")], env=env, stderr=subprocess.PIPE, text=True, timeout=10) + self.assertEqual(service.returncode, 1) + self.assertRegex(service.stderr, r"ERROR startup_failed") + + @unittest.skipUnless(sys.platform.startswith("linux"), "reads worker processes from /proc") class WorkerProcessesTests(unittest.TestCase): """Workers share the port, and the service stops as a whole, so the @@ -1715,6 +1764,8 @@ class WorkerProcessesTests(unittest.TestCase): with socket.socket() as s: s.bind(("127.0.0.1", 0)) self.port = s.getsockname()[1] + self.node.chain.answers[snrc.name_policy_call(FakeChain.CONTROLLER)] = "0x" + snrc.encode_uint(int(FakeChain.NAME_POLICY, 16)) + self.node.chain.answers[snrc.reserved_call(FakeChain.NAME_POLICY, snrc.label_token("acme"))] = "0x" + snrc.encode_uint(2) env = dict(os.environ, SNRC_RPC=self.node.url, SNRC_BIND="127.0.0.1", SNRC_PORT=str(self.port), SNRC_WORKERS="2", SNRC_REGISTRY_TESTING=FakeChain.REGISTRY, SNRC_REGISTRAR_TESTING=FakeChain.REGISTRAR, SNRC_CONTROLLER_TESTING=FakeChain.CONTROLLER) self.service = subprocess.Popen([sys.executable, os.path.join(_HERE, "snrc-resolve.py")], env=env, stderr=subprocess.PIPE, text=True) @@ -1767,6 +1818,11 @@ class WorkerProcessesTests(unittest.TestCase): with urlopen(f"http://127.0.0.1:{self.port}/v2/resolve/acme.testing", timeout=5) as res: self.assertEqual(json.loads(res.read())["registration"]["type"], "registered") + def test_workers_read_reservations_from_the_policy_on_their_own_connections(self): + with urlopen(f"http://127.0.0.1:{self.port}/v2/resolve/acme.testing", timeout=5) as res: + self.assertEqual(json.loads(res.read())["registration"]["reservedReason_"], "trademark") + self.assertEqual(self.node.connections, 2) + def test_stopping_the_service_stops_every_worker(self): self.service.send_signal(signal.SIGTERM) self.assertEqual(self.service.wait(timeout=5), 0)