From a3903887f537a2724b40eb58ddd80eefdba5d625 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 31 Aug 2026 10:44:37 +0200 Subject: [PATCH] some security hardening --- scripts/resolver/README.md | 79 ++++++-- scripts/resolver/docker-compose.yml | 8 + scripts/resolver/service/snrc-resolve.py | 182 +++++++++++++++--- scripts/resolver/service/test_snrc_resolve.py | 106 +++++++++- 4 files changed, 327 insertions(+), 48 deletions(-) diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 8be9c2a06..345e6b287 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -168,6 +168,22 @@ distinguish these: it is also true for a name nobody ever registered, since 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. +### Errors + +Every non-2xx body carries a stable `error` code to branch on and a human +`message`, alongside the subject (`name` or `address`): + +```jsonc +{"name": "alice.testing", "error": "unregistered", + "message": "this name has never been registered", + "status": "unregistered", "expires": null, "graceEnds": null} +``` + +Codes: `tldNotConfigured`, `notFullyQualified`, `unregistered`, `grace`, +`expired`, `noResolver`, `badAddress`, `badOffset`, `noRegistrarConfigured`, +`unauthorized`, `noSuchRoute`, `upstreamError`. For a name whose registration +is the problem, the code equals `status`. + ### Status codes | Status | Meaning | @@ -176,6 +192,7 @@ answer — a subname is only as valid as the name above it. | 400 | TLD not configured, or not a fully-qualified name | | 404 | never registered (`unregistered`), or registered with no resolver set (`noResolver`) | | 410 | registration has lapsed — `status` says whether it is still renewable | +| 401 | `Authorization` missing or wrong, when a secret is configured | | 502 | upstream RPC error / reth not synced | ### `GET /owned-by/
` @@ -215,19 +232,45 @@ thing to read. Requires `SNRC_REGISTRAR_`; with none configured the endpoint answers 400 rather than an empty list. -### Configuring registries +### Configuring addresses -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. +Two maps, both per TLD. The **registry** answers *who owns this node* and is +what `/resolve` reads; it defaults to mainnet `.testing`, with `.simplex` unset +until deployed. The **registrar** is the ERC-721 that can be asked the reverse +and when a name expires — it is what `/owned-by` and every expiry field are +read from. Without a registrar for a TLD, `/resolve` still works and reports +`"status": "unknown"`, and `/owned-by` answers 400. -`SNRC_REGISTRAR_` is the matching ERC-721 registrar, and is what `/owned-by` -and the expiry status are read from — the registry answers *who owns this node*, -the registrar is the NFT that can be asked the reverse and when it expires. -Without it `/resolve` still works and reports `"status": "unknown"`, and -`/owned-by` answers 400. `SNRC_MAX_OWNED` bounds one `/owned-by` response -(default 256). +| Variable | Purpose | +|---|---| +| `SNRC_REGISTRY_` | ENS registry; resolution | +| `SNRC_REGISTRAR_` | ERC-721 registrar; `/owned-by`, expiry and status | +| `SNRC_MAX_OWNED` | names per `/owned-by` page (default 256) | + +Set them on the `resolver` service in `docker-compose.yml`, or as env vars for +the standalone script. + +### Hardening + +The script binds `127.0.0.1` by default; `docker-compose.yml` sets `0.0.0.0` +because it must listen on the container bridge, and publishes the port to host +loopback only. Anything beyond loopback wants `SNRC_AUTH_BEARER` (or +`SNRC_AUTH_BASIC`, `user:password`) — the header is compared in constant time, +and it is the header the smp-server's `HttpResolver` already sends. Unset means +no check. + +`SNRC_CACHE_TTL` (default 15s) memoises `eth_call` by target and calldata, which +matters because one `/resolve` is 15 upstream calls and one `/owned-by` page can +be hundreds; set it to `0` to disable. `SNRC_MAX_RPC_BYTES` (default 2 MiB) +refuses an oversized JSON-RPC response rather than reading it. + +`/health` reports the RPC URL and both address maps, so **do not expose it** — +a hosted RPC URL usually carries the provider key in its path. 502 bodies name +the exception type only, and the detail goes to the log, for the same reason. + +`http.server` is a development server. This deployment is loopback-only and +that is the posture it is written for; anything public wants a real server in +front of it. ## Every case, and what comes back @@ -253,10 +296,10 @@ any lookup, and carry none of the three. | Lapsed, still in grace | 410 | `grace` | `expires` (when it lapsed), `graceEnds` (last moment its owner can renew) | | Lapsed, past grace | 410 | `expired` | same fields; anyone may register it now | | Never registered | 404 | `unregistered` | `expires` and `graceEnds` are `null` | -| TLD has no registry configured | 400 | — | `configured_tlds`, listing the ones that are | +| TLD has no registry configured | 400 | — | `error: tldNotConfigured`, plus `configuredTlds` | | TLD has no *registrar* configured | 200 / 404 | `unknown` | resolves as it otherwise would; expiry cannot be read, so `expires` and `graceEnds` are `null` | | Not fully qualified (`alice`) | 400 | — | `error` naming the expected form | -| RPC unreachable or node unsynced | 502 | — | `error` with the underlying exception type | +| RPC unreachable or node unsynced | 502 | — | `error: upstreamError`; the detail goes to the log, not the body | A name in grace still has its records on chain — expiry is lazy — but the resolver answers 410 rather than serving them, so a stale name cannot be @@ -274,11 +317,13 @@ and a registrar is configured; the interesting variation is per entry. | Address holds a name past grace | 200 | entry with `status` `expired`; still listed, because the holder is who needs to know | | Address holds nothing | 200 | `names: []` — an answer, not an error | | Token whose label was never recorded | 200 | entry with `"name": null` and its `labelhash`; the token is real, the name is not recoverable from chain state | -| Address holds more than `SNRC_MAX_OWNED` in a TLD | 200 | first 256, and `truncated: true` | +| Address holds more than `SNRC_MAX_OWNED` in a TLD | 200 | one page, `truncated: true` and `nextOffset` to resume from | | Several TLDs configured | 200 | all of them merged, sorted by TLD then name; `checkedTlds` says which were asked | -| Malformed address | 400 | `error`; no RPC call is made | -| No registrar configured for any TLD | 400 | `error` and `configured_tlds: []` — distinct from "holds nothing" | -| RPC unreachable or node unsynced | 502 | `error` with the underlying exception type | +| Malformed address | 400 | `error: badAddress`; no RPC call is made | +| Negative or non-numeric `?offset=` | 400 | `error: badOffset` | +| `?offset=` past the end | 200 | `names: []` and `nextOffset: null` | +| No registrar configured for any TLD | 400 | `error: noRegistrarConfigured`, `configuredTlds: []` — distinct from "holds nothing" | +| RPC unreachable or node unsynced | 502 | `error: upstreamError`; the detail goes to the log, not the body | Names are **not** filtered by expiry. Enumeration on the registrar is maintained on transfer, mint and burn but deliberately not on expiry, so a diff --git a/scripts/resolver/docker-compose.yml b/scripts/resolver/docker-compose.yml index f03cc2028..11bcd1318 100644 --- a/scripts/resolver/docker-compose.yml +++ b/scripts/resolver/docker-compose.yml @@ -144,6 +144,8 @@ services: condition: service_started environment: SNRC_RPC: http://reth:8545 + # The script defaults to 127.0.0.1; inside a container it has to listen + # on the bridge, and the port below is still published to loopback only. SNRC_BIND: 0.0.0.0 # Registry addresses cascade through the script's own defaults # (mainnet `.testing`; `.simplex` unconfigured). Set explicitly here @@ -155,6 +157,12 @@ services: # SNRC_REGISTRAR_TESTING: 0x... # SNRC_REGISTRAR_SIMPLEX: 0x... # SNRC_MAX_OWNED: 256 + # SNRC_CACHE_TTL: 15 # seconds to memoise eth_call; 0 disables + # SNRC_MAX_RPC_BYTES: 2097152 # refuse a larger JSON-RPC response + # Shared secret the caller must present. Unset = no check, which is + # right while the port is published to loopback only. + # SNRC_AUTH_BEARER: + # SNRC_AUTH_BASIC: : 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 6ed87fb02..4845a42c7 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -40,7 +40,7 @@ Environment: SNRC_REGISTRY_SIMPLEX ENSRegistry for the .simplex deployment (default: empty — TLD not yet deployed) SNRC_PORT Listen port (default: 8000) - SNRC_BIND Bind address (default: 0.0.0.0) + SNRC_BIND Bind address (default: 127.0.0.1; compose sets 0.0.0.0) Each TLD is a separate SNRC deployment with its own ENSRegistry; the resolver dispatches by the queried name's rightmost label. @@ -58,19 +58,21 @@ Addresses are returned in each chain's canonical presentation: Unrecognised payloads fall back to `0x`-prefixed raw hex. """ +import base64 import hashlib +import hmac import json import os import sys import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import unquote, urlparse +from urllib.parse import parse_qs, unquote, urlparse from urllib.request import Request, urlopen from eth_hash.auto import keccak RPC = os.environ.get("SNRC_RPC", "http://127.0.0.1:8545") -BIND = os.environ.get("SNRC_BIND", "0.0.0.0") +BIND = os.environ.get("SNRC_BIND", "127.0.0.1") PORT = int(os.environ.get("SNRC_PORT", "8000")) # Each TLD is its own SNRC deployment with its own ENSRegistry. Dispatch @@ -86,6 +88,15 @@ REGISTRIES = { "simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet } +# Shared secret the caller must present. Unset means no check - correct for a +# loopback deployment, and the reason the check exists at all is that the +# Haskell client has always been able to send `Authorization` and nothing here +# ever read it, so configuring auth protected nothing. +# SNRC_AUTH_BEARER= -> Authorization: Bearer +# SNRC_AUTH_BASIC=: -> Authorization: Basic base64(user:pass) +AUTH_BEARER = os.environ.get("SNRC_AUTH_BEARER", "") +AUTH_BASIC = os.environ.get("SNRC_AUTH_BASIC", "") + # The BaseRegistrar (ERC-721) per TLD, used for owner -> names. Separate from # the registry above: the registry answers "who owns this node", the registrar # is the NFT that can be asked the reverse. Not a proxy, so the address in @@ -112,6 +123,20 @@ ZERO_ADDR = "0x0000000000000000000000000000000000000000" # ---------- RPC + ABI helpers (mirrors ens-lookup.py shape) ---------- +# A JSON-RPC body larger than this is refused rather than read. The Haskell +# client that calls this resolver caps its own reads for the same reason; an +# upstream that is compromised or simply misconfigured must not be able to +# decide how much memory this process allocates. +MAX_RPC_BYTES = int(os.environ.get("SNRC_MAX_RPC_BYTES", str(2 * 1024 * 1024))) + +# eth_call answers change at block cadence, not per request, so repeating one +# within a few seconds asks the node a question it has already answered. One +# /resolve is 15 calls and one /owned-by can be hundreds, which makes this the +# difference between a warm resolver and a busy node. +CACHE_TTL = float(os.environ.get("SNRC_CACHE_TTL", "15")) +_CALL_CACHE = {} + + def rpc(method, params): body = json.dumps( {"jsonrpc": "2.0", "method": method, "params": params, "id": 1} @@ -126,7 +151,11 @@ def rpc(method, params): "User-Agent": "snrc-resolve/1.0", }, ) - res = json.loads(urlopen(req, timeout=15).read()) + with urlopen(req, timeout=15) as r: + raw = r.read(MAX_RPC_BYTES + 1) + if len(raw) > MAX_RPC_BYTES: + raise RuntimeError(f"RPC response exceeds {MAX_RPC_BYTES} bytes") + res = json.loads(raw) if "error" in res: raise RuntimeError(res["error"]) return res["result"] @@ -145,7 +174,28 @@ def selector(signature: str) -> str: def eth_call(to: str, data: str) -> str: - return rpc("eth_call", [{"to": to, "data": data}, "latest"]) + """A read against `latest`, memoised for CACHE_TTL seconds. + + Keyed on the call itself, so the cache is shared across endpoints: a name + resolved just after it was listed costs nothing the second time. Set + SNRC_CACHE_TTL=0 to disable. + """ + if CACHE_TTL <= 0: + return rpc("eth_call", [{"to": to, "data": data}, "latest"]) + key = (to, data) + now = time.monotonic() + hit = _CALL_CACHE.get(key) + if hit and hit[0] > now: + return hit[1] + result = rpc("eth_call", [{"to": to, "data": data}, "latest"]) + # Evict lazily: this only grows while requests are arriving, and a sweep + # on write keeps it proportional to traffic rather than to uptime. + if len(_CALL_CACHE) > 4096: + for k, v in list(_CALL_CACHE.items()): + if v[0] <= now: + del _CALL_CACHE[k] + _CALL_CACHE[key] = (now + CACHE_TTL, result) + return result def decode_address(hex_data: str) -> str: @@ -178,6 +228,37 @@ def decode_string(hex_data: str) -> str: return raw.decode("utf-8", errors="replace") if raw else "" +def expected_auth_header() -> str: + """The Authorization value this resolver requires, or "" for none.""" + if AUTH_BEARER: + return "Bearer " + AUTH_BEARER + if AUTH_BASIC: + return "Basic " + base64.b64encode(AUTH_BASIC.encode()).decode() + return "" + + +def auth_ok(header: str) -> bool: + """Constant-time compare, so a wrong token cannot be found a byte at a time.""" + expected = expected_auth_header() + if not expected: + return True + return hmac.compare_digest(header or "", expected) + + +def upstream_error(subject: dict, e: Exception) -> dict: + """A 502 body that names the failure without quoting the exception. + + urlopen puts the URL it failed on into its message, and SNRC_RPC may carry + a provider key, so the text goes to the log and a type goes 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 is_address(value: str) -> bool: return ( len(value) == 42 @@ -440,8 +521,9 @@ 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 = namehash(name) @@ -458,7 +540,8 @@ def resolve(name: str): "status": "unregistered", "expires": None, "graceEnds": None, - "error": "this name has never been registered", + "error": "unregistered", + "message": "this name has never been registered", } if reg["status"] in ("grace", "expired"): return 410, { @@ -466,7 +549,8 @@ def resolve(name: str): "status": reg["status"], "expires": reg["expires"], "graceEnds": reg["graceEnds"], - "error": ( + "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" @@ -481,7 +565,8 @@ def resolve(name: str): "status": "noResolver", "expires": reg["expires"], "graceEnds": reg["graceEnds"], - "error": "no resolver set for this name", + "error": "noResolver", + "message": "no resolver set for this name", } owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex) @@ -590,7 +675,7 @@ def name_status(name: str): } -def owned_by(address: str): +def owned_by(address: str, offset: int = 0): """Every live name an address holds, across every configured TLD. Read straight off the ERC-721 registrar rather than from logs: the token @@ -619,14 +704,19 @@ def owned_by(address: str): becomes the second. """ if not is_address(address): - return 400, {"address": address, "error": "expected a 0x-prefixed 20-byte address"} + return 400, { + "address": address, + "error": "badAddress", + "message": "expected a 0x-prefixed 20-byte address", + } configured = {t: r for t, r in REGISTRARS.items() if r} if not configured: return 400, { "address": address, - "error": "no registrar is configured on this resolver", - "configured_tlds": [], + "error": "noRegistrarConfigured", + "message": "no registrar is configured on this resolver", + "configuredTlds": [], } now = int(time.time()) @@ -636,10 +726,11 @@ def owned_by(address: str): held = decode_uint( eth_call(registrar, selector("balanceOf(address)") + encode_address(address)) ) - if held > MAX_OWNED: + first = min(offset, held) + last = min(first + MAX_OWNED, held) + if last < held: truncated = True - held = MAX_OWNED - for i in range(held): + for i in range(first, last): token = decode_uint( eth_call( registrar, @@ -662,7 +753,7 @@ def owned_by(address: str): { "name": (label + "." + tld) if label else None, "tld": tld, - "labelhash": hex(token), + "labelhash": "0x" + format(token, "064x"), "expires": expires, "graceEnds": expires + grace if expires else None, "status": expiry_status(expires, grace, now), @@ -673,6 +764,11 @@ def owned_by(address: str): return 200, { "address": address, "names": names, + "offset": offset, + # `nextOffset` is the cursor to resume from, or null when the listing + # is complete - so "there is more" and "here is how to get it" are the + # same answer rather than a flag with no way to act on it. + "nextOffset": offset + MAX_OWNED if truncated else None, "truncated": truncated, "checkedTlds": sorted(configured), } @@ -680,15 +776,36 @@ def owned_by(address: str): # ---------- HTTP layer ---------- +# Bumped when the response shape changes, so a client can tell "this resolver +# does not report that" from "that is not knowable for this name". +API_VERSION = 2 + + class Handler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 - http.server contract - path = urlparse(self.path).path - parts = [unquote(p) for p in path.split("/") if p] + parsed = urlparse(self.path) + parts = [unquote(p) for p in parsed.path.split("/") if p] + + if not auth_ok(self.headers.get("Authorization")): + self._respond( + 401, + {"error": "unauthorized", "message": "missing or invalid Authorization"}, + ) + return if parts == ["health"]: self._respond( 200, - {"ok": True, "rpc": RPC, "registries": REGISTRIES}, + { + "ok": True, + "version": API_VERSION, + "rpc": RPC, + "registries": REGISTRIES, + # /owned-by and every expiry field are read from these, so + # an operator who configured only the registries can see + # here why status reads "unknown". + "registrars": REGISTRARS, + }, ) return @@ -698,31 +815,42 @@ class Handler(BaseHTTPRequestHandler): 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 if len(parts) == 2 and parts[0] == "owned-by": address = parts[1].strip().lower() try: - status, body = owned_by(address) + offset = int(parse_qs(parsed.query).get("offset", ["0"])[0]) + if offset < 0: + raise ValueError("offset must not be negative") + except ValueError as e: + self._respond( + 400, {"address": address, "error": "badOffset", "message": str(e)} + ) + return + try: + status, body = owned_by(address, offset) except Exception as e: # surface upstream errors as 502 - status, body = 502, {"address": address, "error": f"{type(e).__name__}: {e}"} + status, body = 502, upstream_error({"address": address}, e) self._respond(status, body) return self._respond( 404, { - "error": "not found", + "error": "noSuchRoute", + "message": "not found", "routes": ["/health", "/resolve/", "/owned-by/
"], }, ) diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 4b58140db..f42157208 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -145,9 +145,10 @@ class OwnedByTests(unittest.TestCase): snrc.eth_call = self._fake_chain([(11, "", future)]) _, body = snrc.owned_by(self.OWNER) self.assertEqual(body["names"][0]["name"], None) - self.assertEqual(body["names"][0]["labelhash"], hex(11)) + # a labelhash is bytes32, not the shortest integer literal that fits + self.assertEqual(body["names"][0]["labelhash"], "0x" + "0" * 63 + "b") - def test_enumeration_is_bounded_and_says_so(self): + def test_enumeration_is_bounded_and_offers_a_cursor(self): future = int(time.time()) + 86400 snrc.MAX_OWNED, keep = 2, snrc.MAX_OWNED try: @@ -157,21 +158,50 @@ class OwnedByTests(unittest.TestCase): _, body = snrc.owned_by(self.OWNER) self.assertEqual(len(body["names"]), 2) self.assertTrue(body["truncated"]) + # a flag with no way to act on it is a dead end, so it carries one + self.assertEqual(body["nextOffset"], 2) finally: snrc.MAX_OWNED = keep + def test_the_cursor_walks_the_whole_list_without_repeats(self): + future = int(time.time()) + 86400 + snrc.MAX_OWNED, keep = 2, snrc.MAX_OWNED + try: + snrc.eth_call = self._fake_chain( + [(i, "n%d" % i, future) for i in range(1, 6)] + ) + seen, offset = [], 0 + while offset is not None: + _, body = snrc.owned_by(self.OWNER, offset) + seen += [n["name"] for n in body["names"]] + offset = body["nextOffset"] + self.assertEqual(sorted(seen), sorted("n%d.testing" % i for i in range(1, 6))) + self.assertEqual(len(seen), len(set(seen))) + finally: + snrc.MAX_OWNED = keep + + def test_an_offset_past_the_end_is_an_empty_page_not_an_error(self): + future = int(time.time()) + 86400 + snrc.eth_call = self._fake_chain([(1, "only", future)]) + status, body = snrc.owned_by(self.OWNER, 99) + self.assertEqual(status, 200) + self.assertEqual(body["names"], []) + self.assertIsNone(body["nextOffset"]) + def test_a_malformed_address_is_refused_before_any_rpc(self): snrc.eth_call = lambda *a: self.fail("must not reach the chain") status, body = snrc.owned_by("0xnope") self.assertEqual(status, 400) - self.assertIn("address", body["error"]) + self.assertEqual(body["error"], "badAddress") + self.assertIn("address", body["message"]) def test_no_configured_registrar_is_an_error_not_an_empty_list(self): snrc.REGISTRARS = {"testing": "", "simplex": ""} snrc.eth_call = lambda *a: self.fail("must not reach the chain") status, body = snrc.owned_by(self.OWNER) self.assertEqual(status, 400) - self.assertEqual(body["configured_tlds"], []) + self.assertEqual(body["error"], "noRegistrarConfigured") + self.assertEqual(body["configuredTlds"], []) class NameStatusTests(unittest.TestCase): @@ -277,6 +307,74 @@ class NameStatusTests(unittest.TestCase): self.assertEqual(set(snrc.name_status("alice.testing")), keys) +class AuthTests(unittest.TestCase): + """The Haskell client has always been able to send `Authorization`; until + now nothing here read it, so configuring auth protected nothing.""" + + def setUp(self): + self._saved = (snrc.AUTH_BEARER, snrc.AUTH_BASIC) + + def tearDown(self): + snrc.AUTH_BEARER, snrc.AUTH_BASIC = self._saved + + def test_no_secret_configured_accepts_anything(self): + snrc.AUTH_BEARER = snrc.AUTH_BASIC = "" + self.assertTrue(snrc.auth_ok("")) + self.assertTrue(snrc.auth_ok("Bearer whatever")) + + def test_bearer_accepts_only_the_configured_token(self): + snrc.AUTH_BEARER, snrc.AUTH_BASIC = "sekrit", "" + self.assertTrue(snrc.auth_ok("Bearer sekrit")) + self.assertFalse(snrc.auth_ok("Bearer sekri")) + self.assertFalse(snrc.auth_ok("Bearer sekrit2")) + self.assertFalse(snrc.auth_ok("")) + self.assertFalse(snrc.auth_ok(None)) + + def test_basic_matches_what_the_haskell_client_builds(self): + snrc.AUTH_BEARER, snrc.AUTH_BASIC = "", "user:pass" + # HttpResolver.hs: "Basic " <> base64(user <> ":" <> password) + self.assertEqual(snrc.expected_auth_header(), "Basic dXNlcjpwYXNz") + self.assertTrue(snrc.auth_ok("Basic dXNlcjpwYXNz")) + self.assertFalse(snrc.auth_ok("Basic bm9wZQ==")) + + +class CallCacheTests(unittest.TestCase): + """One /resolve is 15 upstream calls and one /owned-by can be hundreds, so + repeating a call the node just answered is the cost worth removing.""" + + def setUp(self): + self._saved = (snrc.eth_call, snrc.rpc, snrc.CACHE_TTL, dict(snrc._CALL_CACHE)) + snrc._CALL_CACHE.clear() + + def tearDown(self): + snrc.eth_call, snrc.rpc, snrc.CACHE_TTL, cache = self._saved + snrc._CALL_CACHE.clear() + snrc._CALL_CACHE.update(cache) + + def test_a_repeated_call_asks_the_node_once(self): + calls = [] + snrc.rpc = lambda method, params: calls.append(params) or "0x2a" + snrc.CACHE_TTL = 60 + self.assertEqual(snrc.eth_call("0xto", "0xdata"), "0x2a") + self.assertEqual(snrc.eth_call("0xto", "0xdata"), "0x2a") + self.assertEqual(len(calls), 1) + + def test_different_calls_are_not_confused(self): + snrc.rpc = lambda method, params: params[0]["data"] + snrc.CACHE_TTL = 60 + self.assertEqual(snrc.eth_call("0xto", "0xaa"), "0xaa") + self.assertEqual(snrc.eth_call("0xto", "0xbb"), "0xbb") + self.assertEqual(snrc.eth_call("0xother", "0xaa"), "0xaa") + + def test_zero_ttl_disables_it(self): + calls = [] + snrc.rpc = lambda method, params: calls.append(1) or "0x" + snrc.CACHE_TTL = 0 + snrc.eth_call("0xto", "0xdata") + snrc.eth_call("0xto", "0xdata") + self.assertEqual(len(calls), 2) + + class SplitLinksTests(unittest.TestCase): """`split_links` decodes the multi-URL convention for simplex.contact / simplex.channel text records. Reuses the same rule the dApp's