From e6dc6c884655029db4c44bd7b8f0d280130b7f2d Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 7 Sep 2026 08:09:43 +0200 Subject: [PATCH] eth_call guard and cache constants --- scripts/resolver/README.md | 10 +++-- scripts/resolver/service/snrc-resolve.py | 38 +++++++++++++------ scripts/resolver/service/test_snrc_resolve.py | 34 ++++++++++++++--- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index e4f0dfcfc..9d8f0785a 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -183,8 +183,9 @@ length, which a hash does not carry. The client adds that. The oracle comes from the controller's `prices()`, so no extra configuration is needed. Its window is read from the chain; zero days switches the auction off. -The curve is cached for `AUCTION_PARAMS_TTL` (5 minutes), so a `setPremium` -retune shows up within that; the decaying premium is read on every query. +Deployment constants - the grace period, the oracle and its curve - are cached +for `CONSTANTS_TTL` (5 minutes), so a retune shows up within that. Per-name +values and the decaying premium are read on every query. **Upgrade this service before the routers that query it.** An older resolver reports a name in its auction as plain `expired`, which routers read as @@ -280,7 +281,10 @@ 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. +and urlopen puts the URL it failed on into the message. It is also the answer +when a registrar, controller or oracle address has no contract behind it: the +empty reply is refused rather than read as zero, which would make every name +look free. ### Status codes diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 0bc9934ea..7cfb01f40 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -193,9 +193,26 @@ def chain_now() -> int: return decode_uint(block["timestamp"]) +# Deployment constants - the grace period, the oracle and its curve - change only +# when the owner retunes a contract, so they are read once per TTL rather than on +# every query. Per-name values and the decaying premium are never cached. +CONSTANTS_TTL = 300 +_constants: dict = {} + + +def cached(key, read): + """`read()` at most once per CONSTANTS_TTL for `key`.""" + hit = _constants.get(key) + if hit and time.time() - hit[0] < CONSTANTS_TTL: + return hit[1] + value = read() + _constants[key] = (time.time(), value) + return value + + 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()"))) + return cached(("grace", registrar), lambda: decode_uint(eth_call(registrar, selector("GRACE_PERIOD()")))) def expiry_status(expires: int, grace: int, now: int) -> str: @@ -219,18 +236,13 @@ def reservation_reason(tld: str, token: int) -> int: return decode_uint(raw) -# The oracle and its curve change only on a retune, so they are read once per -# TTL rather than per query. The premium itself decays, so it is never cached. -AUCTION_PARAMS_TTL = 300 -_auction_params: dict = {} - - def auction_params(tld: str): """(oracle, startPremium, totalDays, endValue) for the TLD's controller, or (ZERO_ADDR, 0, 0, 0) when no controller or no oracle is configured.""" - cached = _auction_params.get(tld) - if cached and time.time() - cached[0] < AUCTION_PARAMS_TTL: - return cached[1] + return cached(("auction", tld), lambda: read_auction_params(tld)) + + +def read_auction_params(tld: str): params = (ZERO_ADDR, 0, 0, 0) controller = CONTROLLERS.get(tld) if controller: @@ -242,7 +254,6 @@ def auction_params(tld: str): decode_uint(eth_call(oracle, selector("totalDays()"))), decode_uint(eth_call(oracle, selector("endValue()"))), ) - _auction_params[tld] = (time.time(), params) return params @@ -329,7 +340,10 @@ def selector(signature: str) -> str: def eth_call(to: str, data: str) -> str: - return rpc("eth_call", [{"to": to, "data": data}, "latest"]) + result = rpc("eth_call", [{"to": to, "data": data}, "latest"]) + if result == "0x": + raise RuntimeError(f"empty return from {to}: no contract at that address?") + return result def decode_address(hex_data: str) -> str: diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 0e7987d11..b764fef8f 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -97,7 +97,7 @@ class EncodedLabelhashTests(unittest.TestCase): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": ""} snrc.chain_now = lambda: int(time.time()) - snrc._auction_params.clear() + snrc._constants.clear() def tearDown(self): snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved @@ -235,7 +235,7 @@ class NameStatusTests(unittest.TestCase): # Expiry alone; ReservedTests covers a configured controller. snrc.CONTROLLERS = {"testing": ""} snrc.chain_now = lambda: int(time.time()) - snrc._auction_params.clear() + snrc._constants.clear() def tearDown(self): ( @@ -259,6 +259,28 @@ class NameStatusTests(unittest.TestCase): snrc.chain_now = lambda: future + 3650 * 86400 self.assertEqual(snrc.name_status("alice.testing")["status"], "expired") + def test_a_registrar_that_is_not_a_contract_is_an_error_not_a_free_name(self): + """An address with no code answers eth_call with empty data. Read as + zero, that would make every name look free.""" + snrc.eth_call = self._saved[2] # the real one, so its guard runs + snrc.rpc = lambda method, params: "0x" + with self.assertRaises(RuntimeError): + snrc.name_status("alice.testing") + + def test_the_grace_period_is_read_once_not_per_query(self): + seen = [] + + def eth_call(to, data): + seen.append(data[:10]) + if data.startswith(snrc.selector("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(self.GRACE) + return "0x" + snrc.encode_uint(int(time.time()) - 3600) + + snrc.eth_call = eth_call + snrc.name_status("alice.testing") + snrc.name_status("alice.testing") + self.assertEqual(seen.count(snrc.selector("GRACE_PERIOD()")), 1) + def test_zero_expiry_means_never_registered(self): snrc.eth_call = self._expiry(0) self.assertEqual( @@ -361,7 +383,7 @@ class ReservedTests(unittest.TestCase): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": self.CONTROLLER} snrc.chain_now = lambda: int(time.time()) - snrc._auction_params.clear() + snrc._constants.clear() def tearDown(self): snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved @@ -429,7 +451,7 @@ class ReservedReasonTests(unittest.TestCase): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": self.CONTROLLER} snrc.chain_now = lambda: int(time.time()) - snrc._auction_params.clear() + snrc._constants.clear() def tearDown(self): ( @@ -553,7 +575,7 @@ class AuctionTests(unittest.TestCase): snrc.CONTROLLERS = {"testing": self.CONTROLLER} self.now = int(time.time()) snrc.chain_now = lambda: self.now - snrc._auction_params.clear() + snrc._constants.clear() def tearDown(self): ( @@ -704,7 +726,7 @@ class ErrorCodeTests(unittest.TestCase): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": ""} snrc.chain_now = lambda: int(time.time()) - snrc._auction_params.clear() + snrc._constants.clear() def tearDown(self): (