mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-11-04 10:47:51 +00:00
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_<TLD> 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_<TLD> 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>
This commit is contained in:
1 parent
2376adaf98
commit
89c8ba9c98
4 files changed
+124
-9
No files matched your search
@@ -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 |
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -42,6 +42,7 @@ Environment:
|
||||
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; 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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user