diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 7d6808e60..9893af5c4 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -152,10 +152,12 @@ about it. `expires` and `graceEnds` are Unix timestamps in seconds; both are 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*. +from the contract rather than assumed and `now` taken from the latest block's +timestamp rather than the host clock — the registrar compares against that same +clock, so a skewed machine cannot misstate a registration. 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. diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 3971f5011..c44b2e9db 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -66,7 +66,6 @@ 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 @@ -202,6 +201,16 @@ def node_of(name: str) -> bytes: # ---------- Registration status ---------- +def chain_now() -> int: + """The latest block's timestamp - the same clock the registrar reads. + + Asked explicitly rather than taken from the host clock, so a skewed clock + on this machine cannot misstate a registration. + """ + block = rpc("eth_getBlockByNumber", ["latest", False]) + return decode_uint(block["timestamp"]) + + def grace_period(registrar: str) -> int: """The registrar's own GRACE_PERIOD, in seconds. @@ -279,7 +288,7 @@ def name_status(name: str): status, grace = "unregistered", 0 else: grace = grace_period(registrar) - status = expiry_status(expires, grace, int(time.time())) + status = expiry_status(expires, grace, chain_now()) # `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 diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 857a2bf5c..02374831f 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -101,12 +101,13 @@ class EncodedLabelhashTests(unittest.TestCase): GRACE = 90 * 86400 def setUp(self): - self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call) + 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 = self._saved + snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved def test_the_encoded_form_is_recognised(self): self.assertTrue( @@ -217,14 +218,44 @@ class NameStatusTests(unittest.TestCase): return eth_call def setUp(self): - self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call) + self._saved = ( + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + snrc.chain_now, + snrc.rpc, + ) snrc.REGISTRARS = {"testing": self.REGISTRAR} # These cases are about expiry alone. ReservedTests covers what a # configured controller adds. snrc.CONTROLLERS = {"testing": ""} + snrc.chain_now = lambda: int(time.time()) def tearDown(self): - snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved + ( + snrc.REGISTRARS, + snrc.CONTROLLERS, + snrc.eth_call, + snrc.chain_now, + snrc.rpc, + ) = self._saved + + def test_now_is_the_latest_blocks_timestamp(self): + # setUp replaces chain_now with the fixture clock; this is about the + # real one, saved as the 4th element of the setUp snapshot + 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): + """The registrar compares expiry to block.timestamp, so the resolver + must too - a host clock years ahead must not turn a live name into a + claimable one.""" + 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) @@ -313,12 +344,13 @@ class ReservedTests(unittest.TestCase): CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" def setUp(self): - self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call) + 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 = self._saved + snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved def _chain(self, expires, reserved): def eth_call(to, data): @@ -380,10 +412,12 @@ class ReservedReasonTests(unittest.TestCase): 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): ( @@ -391,6 +425,7 @@ class ReservedReasonTests(unittest.TestCase): snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, + snrc.chain_now, ) = self._saved def _chain(self, expires, reserved):