some security hardening

This commit is contained in:
Alain Brenzikofer
2026-08-31 10:44:37 +02:00
parent e8652a2bd4
commit a3903887f5
4 changed files with 327 additions and 48 deletions
+62 -17
View File
@@ -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/<address>`
@@ -215,19 +232,45 @@ thing to read.
Requires `SNRC_REGISTRAR_<TLD>`; 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_<TLD>` 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_<TLD>` | ENS registry; resolution |
| `SNRC_REGISTRAR_<TLD>` | 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
+8
View File
@@ -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: <token>
# SNRC_AUTH_BASIC: <user>:<password>
ports:
- "127.0.0.1:8000:8000"
restart: unless-stopped
+155 -27
View File
@@ -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=<token> -> Authorization: Bearer <token>
# SNRC_AUTH_BASIC=<user>:<password> -> 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/<name>", "/owned-by/<address>"],
},
)
+102 -4
View File
@@ -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