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