Compare commits

...
Author SHA1 Message Date
Alain Brenzikofer 075b1aa6e2 fix a possible breaking 2026-08-31 11:48:37 +02:00
Alain Brenzikofer a40fdb8b36 add tests and CI 2026-08-31 11:25:40 +02:00
Alain Brenzikofer 685c3ff264 support query name by labelhash 2026-08-31 11:03:44 +02:00
Alain Brenzikofer a3903887f5 some security hardening 2026-08-31 10:44:37 +02:00
Alain Brenzikofer e8652a2bd4 fixes 2026-08-31 09:59:09 +02:00
Alain Brenzikofer 6175411616 new owned_by endpoint and expiry status handling 2026-08-31 09:25:40 +02:00
8 changed files with 1581 additions and 27 deletions
+28
View File
@@ -301,3 +301,31 @@ jobs:
echo "All "$attempts" attempts failed."
exit 1
fi
# =============================
# Resolver test job
# =============================
# The SNRC resolver is Python and stdlib-only apart from keccak, so it needs
# none of the Haskell toolchain above and runs independently of it.
resolver-test:
name: "resolver (python)"
runs-on: ubuntu-latest
steps:
- name: Clone project
uses: actions/checkout@v3
- name: Set up Python
# Matches the runtime stage of scripts/resolver/service/Dockerfile.
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install resolver dependencies
# The only runtime dependency; declared in
# scripts/resolver/service/pyproject.toml.
run: python -m pip install "eth-hash[pycryptodome]>=0.7"
- name: Test
run: python -m unittest discover -s scripts/resolver/service -v
+246 -9
View File
@@ -71,6 +71,12 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq
# → {"name":"foobar.testing","nickname":"Foo","simplexContact":["https://smp16.simplex.im/a#…"], … }
```
**4. resolver answers the reverse lookup:**
```sh
curl -s http://127.0.0.1:8000/owned-by/0x69a6000000000000000000000000000000002d32 | jq '.names'
# → [{"name":"foobar.testing","status":"registered","expires":1780…, …}]
```
**Wire your smp-server:** in its `[NAMES]` section set
`resolver_endpoint: http://127.0.0.1:8000` (no auth needed for loopback).
@@ -82,7 +88,7 @@ curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq
| reth p2p | `:30303` tcp/udp | Ethereum sync (open on firewall) |
| nimbus p2p | `:9000` tcp/udp | beacon sync (open on firewall) |
| nimbus REST | `127.0.0.1:5052` | beacon API |
| **resolver** | `127.0.0.1:8000` | SNRC REST (`/resolve`, `/health`) |
| **resolver** | `127.0.0.1:8000` | SNRC REST (`/resolve`, `/owned-by`, `/health`) |
## Caveats
@@ -119,7 +125,10 @@ uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + main
"simplexContact": ["https://smp16.simplex.im/a#…", "https://smp11…"], // primary first, fallbacks after
"simplexChannel": [],
"eth": null, "btc": "bc1q…", "xmr": "4ANz…", "dot": "139G…",
"owner": "0xd83b…", "resolver": "0x80fa…"
"owner": "0xd83b…", "resolver": "0x80fa…",
"status": "registered", // registered | grace | expired | unregistered | noResolver | unknown
"expires": 1780000000, // Unix seconds; when the registration ends
"graceEnds": 1787776000 // expires + GRACE_PERIOD; last moment the owner can renew
}
```
@@ -129,18 +138,246 @@ text record; the resolver splits/trims/drops-empties. Address encodings are
canonical per chain (EIP-55 / bech32 / SS58 / Monero-base58). Subnames work
identically (`bar.foobar.testing`).
### Registration status and expiry
`status`, `expires` and `graceEnds` are on every response that got far enough to
know them, including a successful resolve — so a client that has just resolved a
name already holds its expiry and needs no second request to warn about it.
`expires` and `graceEnds` are Unix timestamps in seconds; both are `null` when
unknown.
| `status` | Meaning |
|---|---|
| `registered` | live; `expires` is when that ends |
| `grace` | lapsed, but only the previous owner may renew it, until `graceEnds` |
| `expired` | lapsed and past grace — anyone may register it now |
| `unregistered` | never registered, and free to take |
| `reserved` | not registered, and held back — registration will be refused; the body carries a `reason` |
| `noResolver` | registered, but points nowhere |
| `unknown` | no `SNRC_REGISTRAR_<TLD>` configured, so status could not be read |
Which HTTP code carries each, and what every other input does, is in
[Every case](#every-case-and-what-comes-back) at the end.
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*.
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.
### Asking without naming the name
A client checking whether a name is free is usually about to register it, so
the question itself is worth front-running. Substitute the label's keccak hash
for the label and the answer is identical:
```sh
# instead of /resolve/acme.testing
curl -s http://127.0.0.1:8000/resolve/0x$(printf acme | keccak-256sum | cut -d' ' -f1).testing
```
namehash is defined as `keccak(parent || keccak(label))`, so supplying
`keccak(label)` yields the same node — and the registrar keys both
`nameExpires` and `reservedNames` on the labelhash, so status needs nothing
else. Whoever runs the resolver sees a hash and learns which name you are
interested in only if they already guessed it.
The two forms cannot be confused: a hashed label is `0x` and 64 hex characters,
66 in total, and the registrar caps real labels well below that. Only the
leftmost label may be hashed, and only for a 2LD.
Registration is still a public act — this hides the *interest*, not the
eventual registration, and the commit-reveal in the controller is what protects
the registration itself.
### 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}
```
A `reserved` body carries one extra field, `reason`, explaining why the name is
held back:
```jsonc
{"name": "support.testing", "error": "reserved",
"message": "this name is reserved and cannot be registered",
"reason": "reserved for a brand or public interest",
"status": "reserved", "expires": null, "graceEnds": null}
```
The contract records only that a name is reserved, not why, so today every
reserved name gets that same sentence; a per-name lookup is expected to replace
it. `reason` appears on no other status, so its presence is the signal that one
is known — render it rather than matching on its text.
Codes: `tldNotConfigured`, `notFullyQualified`, `unregistered`, `reserved`,
`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 |
|---|---|
| 200 | resolved |
| 200 | resolved; `status` is `registered` |
| 400 | TLD not configured, or not a fully-qualified name |
| 404 | name has no resolver set on the registry |
| 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 |
### Configuring registries
### `GET /owned-by/<address>`
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.
Every name an Ethereum address holds, across every configured TLD.
```jsonc
{
"address": "0x69a6…",
"names": [
{"name": "foobar.testing", "tld": "testing", "labelhash": "0x…",
"expires": 1780000000, "graceEnds": 1787776000, "status": "registered"},
{"name": "lapsed.testing", "tld": "testing", "labelhash": "0x…",
"expires": 1750000000, "graceEnds": 1757776000, "status": "grace"}
],
"truncated": false,
"checkedTlds": ["testing"]
}
```
Read from the ERC-721 registrar (`balanceOf` → `tokenOfOwnerByIndex` →
`nameExpires` → `labelOf`), so it needs no log scan and includes names acquired
by transfer as well as by registration. `labelOf` is the plaintext label
recorded write-once at registration, so a token id turns back into a name
without an off-chain index; a token whose label was never recorded is returned
with `"name": null` and its `labelhash`, rather than being dropped.
**Lapsed names are listed, not filtered**, with the same `status` vocabulary as
`/resolve` — a wallet scanning a key is exactly the caller who needs to be told
a name has lapsed and can still be renewed. Filter on `status == "registered"`
for the live set only. Enumeration is deliberately not maintained on expiry (the
registrar documents this), which is why `status` rather than presence is the
thing to read.
`truncated` is `true` when an address holds more than `SNRC_MAX_OWNED` names
(default 256) in one TLD, so a caller can tell a short list from a complete one.
Requires `SNRC_REGISTRAR_<TLD>`; with none configured the endpoint answers 400
rather than an empty list.
### Configuring addresses
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. The **controller** holds `reservedNames`, and is what the `reserved` status is
read from; without one for a TLD, a reserved name reads as `unregistered`.
Note that the controller address is the **proxy**, not `SimplexControllerImpl`:
storage lives in the proxy, so the implementation answers nothing.
`deployments.mainnet.testing.json` records it under the ENS role name
`ETHRegistrarController` and `verification.mainnet.testing.json` names it
`SimplexControllerProxy` — the same address, and the one defaulted to here.
| Variable | Purpose |
|---|---|
| `SNRC_REGISTRY_<TLD>` | ENS registry; resolution |
| `SNRC_REGISTRAR_<TLD>` | ERC-721 registrar; `/owned-by`, expiry and status |
| `SNRC_CONTROLLER_<TLD>` | SimplexController; the `reserved` 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
Every input either endpoint can be given, and the exact answer. Written out
because the interesting cases are the ones that are hard to reach on purpose —
a name in its grace period, a token whose label predates label recording — and
a caller has to handle them without having seen one.
Timestamps are Unix seconds. `status`, `expires` and `graceEnds` are present on
every `/resolve` response that got as far as looking the name up — `null` where
not knowable — so a client can read them without checking for the key first.
The two 400s below are the exception: they fail on the request itself, before
any lookup, and carry none of the three.
### `GET /resolve/<name>`
| Situation | HTTP | `status` | Body |
|---|---|---|---|
| Live name with records | 200 | `registered` | full record; `expires` is when it ends, `graceEnds` when it would stop being renewable |
| Live name, no text records set | 200 | `registered` | full record; text fields `""`, link arrays `[]`, coin fields `null` |
| Live subname (`bar.foo.testing`) | 200 | `registered` | its own records, with the expiry of the 2LD `foo.testing` above it |
| Registered, resolver never set | 404 | `noResolver` | `expires`, `graceEnds`, `error` — held, but points nowhere |
| 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` |
| Reserved | 404 | `reserved` | not registered and not registrable; adds `reason`; overrides `unregistered` and `expired` |
| Queried by labelhash (`0x…64hex.testing`) | as the label | as the label | identical answer; the label is never sent |
| 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: 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
resolved by accident. Read `expires` from that response to say when it lapsed.
### `GET /owned-by/<address>`
Answers 200 with a `names` array in every case where the address is well formed
and a registrar is configured; the interesting variation is per entry.
| Situation | HTTP | Result |
|---|---|---|
| Address holds live names | 200 | one entry each, `status` `registered` |
| Address holds a name in grace | 200 | entry with `status` `grace` and `graceEnds` — the renewal reminder case |
| 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 | 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: 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
lapsed name stays enumerable until someone re-registers it — and that is
exactly the name its holder needs to be told about. Filter on
`status == "registered"` for the live set.
+18
View File
@@ -144,12 +144,30 @@ 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
# only if you're deploying against a different network or contract.
# SNRC_REGISTRY_TESTING: 0x...
# SNRC_REGISTRY_SIMPLEX: 0x...
# Registrar (ERC-721) addresses, same cascade. These drive /owned-by and
# the expiry status on /resolve; without them status reads "unknown".
# SNRC_REGISTRAR_TESTING: 0x...
# SNRC_REGISTRAR_SIMPLEX: 0x...
# SimplexController, which holds reservedNames. No default: it is behind
# a UUPS proxy and deployments records the implementation, so the address
# has to be the proxy and has to be given.
# SNRC_CONTROLLER_TESTING: 0x...
# SNRC_CONTROLLER_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
+476 -16
View File
@@ -29,6 +29,7 @@ Usage:
./snrc-resolve.py # serve on :8000
curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq .
curl -s http://127.0.0.1:8000/owned-by/0x69a6...2d32 | jq .
curl -s http://127.0.0.1:8000/health
Environment:
@@ -39,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.
@@ -57,18 +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
@@ -84,6 +88,48 @@ REGISTRIES = {
"simplex": os.environ.get("SNRC_REGISTRY_SIMPLEX", ""), # not deployed yet
}
# The SimplexController per TLD, which holds `reservedNames`. Without one for a
# TLD, `reserved` is never reported and a reserved name reads as unregistered.
CONTROLLERS = {
"testing": os.environ.get("SNRC_CONTROLLER_TESTING", "")
# The proxy, not SimplexControllerImpl: storage and events live in the
# proxy, so the implementation address answers nothing. deployments.json
# records this one under the ENS role name ETHRegistrarController;
# verification.json names it SimplexControllerProxy. Same address.
or "0xeeb9b6bf5fb68fb726005f7ba549c2f4b32f2dad", # mainnet .testing
"simplex": os.environ.get("SNRC_CONTROLLER_SIMPLEX", ""), # not deployed yet
}
# Why a name is reserved. `reservedNames` stores only the fact, so every
# reserved name gets this same sentence; a per-name lookup (table or REST) is
# the intended replacement. Callers should render whatever this field holds
# rather than matching on its text.
RESERVED_REASON = "reserved for a brand or public interest"
# 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
# deployments is the one that answers.
REGISTRARS = {
"testing": os.environ.get("SNRC_REGISTRAR_TESTING", "")
or "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a", # mainnet .testing
"simplex": os.environ.get("SNRC_REGISTRAR_SIMPLEX", ""), # not deployed yet
}
# An address can hold any number of names, and enumeration costs one call per
# name. Bounded so a single request cannot pin the resolver to one caller's
# balance; the response says when it truncated rather than lying by omission.
MAX_OWNED = int(os.environ.get("SNRC_MAX_OWNED", "256"))
# SLIP-44 coin types (https://github.com/satoshilabs/slips/blob/master/slip-0044.md)
COIN_ETH = 60
COIN_BTC = 0
@@ -95,6 +141,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}
@@ -109,7 +169,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"]
@@ -128,7 +192,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:
@@ -143,6 +228,63 @@ def decode_bytes(hex_data: str) -> bytes:
return raw[64:64 + length]
def decode_uint(hex_data: str) -> int:
raw = hex_data[2:] if hex_data.startswith("0x") else hex_data
return int(raw[-64:], 16) if raw else 0
def encode_uint(value: int) -> str:
return value.to_bytes(32, "big").hex()
def encode_address(addr: str) -> str:
return "00" * 12 + addr[2:].lower()
def decode_string(hex_data: str) -> str:
raw = decode_bytes(hex_data)
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
and value.startswith("0x")
and all(c in "0123456789abcdefABCDEF" for c in value[2:])
)
def encode_text_call(node: bytes, key: str) -> str:
sel = selector("text(bytes32,string)")
head = node.hex() + (0x40).to_bytes(32, "big").hex()
@@ -397,17 +539,62 @@ 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)
node = node_of(name)
node_hex = node.hex()
# Registration first, because it is the fact that separates the failures a
# caller has to tell apart: a name nobody has taken, one whose registration
# lapsed and may still be renewed, one that lapsed and is now open to
# anyone, and one that is held but not pointed anywhere.
reg = name_status(name)
if reg["status"] in ("unregistered", "reserved"):
body = {
"name": name,
"status": reg["status"],
"expires": reg["expires"],
"graceEnds": reg["graceEnds"],
"error": reg["status"],
"message": (
"this name is reserved and cannot be registered"
if reg["status"] == "reserved"
else "this name has never been registered"
),
}
# Only reserved names carry a reason, so its presence is the signal
# that one is known.
if reg["status"] == "reserved":
body["reason"] = RESERVED_REASON
return 404, body
if reg["status"] in ("grace", "expired"):
return 410, {
"name": name,
"status": reg["status"],
"expires": reg["expires"],
"graceEnds": reg["graceEnds"],
"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"
),
}
resolver_raw = eth_call(registry, selector("resolver(bytes32)") + node_hex)
resolver_addr = decode_address(resolver_raw)
if resolver_addr == ZERO_ADDR:
return 404, {"name": name, "error": "no resolver set for this name"}
return 404, {
"name": name,
"status": "noResolver",
"expires": reg["expires"],
"graceEnds": reg["graceEnds"],
"error": "noResolver",
"message": "no resolver set for this name",
}
owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex)
owner = decode_address(owner_raw)
@@ -443,20 +630,270 @@ def resolve(name: str):
"dot": addr_multicoin(resolver_addr, node, COIN_DOT),
"owner": owner,
"resolver": resolver_addr,
"status": reg["status"],
"expires": reg["expires"],
"graceEnds": reg["graceEnds"],
}
def grace_period(registrar: str) -> int:
"""The registrar's own GRACE_PERIOD, in seconds.
Read from the chain rather than hardcoded, so a deployment that chooses a
different window is reported correctly instead of confidently wrongly. One
call per request, not per name.
"""
return decode_uint(eth_call(registrar, selector("GRACE_PERIOD()")))
# A labelhash standing in for a label: "0x" and 32 bytes of hex. A real label
# cannot collide with this, because the registrar caps labels well below the 66
# characters this takes - so the two forms are distinguishable without a flag.
HASHED_LABEL_LEN = 66
def is_labelhash(label: str) -> bool:
return (
len(label) == HASHED_LABEL_LEN
and label.startswith("0x")
and all(c in "0123456789abcdef" for c in label[2:])
)
def label_token(label: str) -> int:
"""The registrar token id for a label, given either the label or its hash.
Querying by hash is what lets a client ask "is this name free?" without
telling the resolver which name it is about to register - the answer is
the same, and the intent does not leak to whoever runs the resolver.
"""
return int(label, 16) if is_labelhash(label) else int.from_bytes(keccak(label.encode()), "big")
def node_of(name: str) -> bytes:
"""namehash, accepting a hashed leftmost label.
namehash is defined recursively as keccak(parent || keccak(label)), so a
caller who supplies keccak(label) directly gets the same node without ever
sending the label.
"""
labels = name.split(".")
if len(labels) == 2 and is_labelhash(labels[0]):
return keccak(namehash(labels[1]) + bytes.fromhex(labels[0][2:]))
return namehash(name)
def is_reserved(tld: str, token: int) -> bool:
"""Whether the controller holds this label for a brand.
Keyed by labelhash on chain, so this answers for a hashed query too.
"""
controller = CONTROLLERS.get(tld)
if not controller:
return False
raw = eth_call(
controller, selector("reservedNames(bytes32)") + encode_uint(token)
)
return decode_uint(raw) != 0
def expiry_status(expires: int, grace: int, now: int) -> str:
"""Registration state from an expiry timestamp.
Mirrors the registrar's `available(id)`, which is
`expiries[id] + GRACE_PERIOD < block.timestamp`. It is computed here rather
than called per name because the answer is needed for every token in a
listing and the inputs are one constant plus a value already fetched.
Note that `available` alone cannot be used for this: it is also true for a
name nobody ever registered, since `0 + GRACE_PERIOD < now`. The zero
expiry is what separates "never taken" from "lapsed and now free".
"""
if expires == 0:
return "unregistered"
if expires > now:
return "registered"
if expires + grace >= now:
# Expired, but only the previous owner may renew it - nobody else can
# take it yet.
return "grace"
return "expired"
def name_status(name: str):
"""Registration status of the 2LD a name sits under.
Names expire lazily: the registrar keeps the record and simply stops
treating it as live, so "never registered" and "expired last Tuesday" are
both readable rather than both being absence. `nameExpires` returns 0 for a
label that was never registered, which is what separates the two.
Subnames are not registered here, so the status of `x.alice.testing` is the
status of `alice.testing` - which is the useful answer, since a subname is
only as valid as the 2LD above it.
"""
labels = name.split(".")
tld = labels[-1]
registrar = REGISTRARS.get(tld)
if not registrar or len(labels) < 2:
# No registrar configured for this TLD: say so rather than guess.
return {"status": "unknown", "expires": None, "graceEnds": None}
token = label_token(labels[-2])
expires = decode_uint(
eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token))
)
if expires == 0:
status, grace = "unregistered", 0
else:
grace = grace_period(registrar)
status = expiry_status(expires, grace, int(time.time()))
# `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
# owner either way - in both cases the reservation is not the answer to the
# question being asked.
if status in ("unregistered", "expired") and is_reserved(tld, token):
status = "reserved"
return {
"status": status,
"expires": expires or None,
"graceEnds": (expires + grace) if expires else None,
}
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
is the name, so `balanceOf` / `tokenOfOwnerByIndex` is the current answer
and it includes names acquired by transfer, which a scan of registration
events would miss. `labelOf` returns the plaintext label, recorded
write-once at registration, so no off-chain index is needed to turn a
token id back into a name.
Enumeration is deliberately not maintained on expiry - the registrar
documents this - so an expired name stays in the list until someone
re-registers it. That is reported rather than filtered: a caller scanning
for the names a key holds is exactly the caller who needs to be told one of
them has lapsed and can be renewed. Every entry carries `status`, using the
same vocabulary as /resolve, so "still yours" and "yours until you lose it"
are never confused for each other.
Callers wanting only the live set filter on `status == "registered"`, which
is the check the registrar's invariant asks of readers - applied by whoever
knows whether expired names matter to them, rather than here.
A lapsed name is reported as `grace` while only its previous owner may
renew it, and `expired` once anyone can take it. The difference is the
whole content of a renewal reminder: one is "renew this", the other is
"this is gone unless you are quick", and `graceEnds` says when the first
becomes the second.
"""
if not is_address(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": "noRegistrarConfigured",
"message": "no registrar is configured on this resolver",
"configuredTlds": [],
}
now = int(time.time())
names, truncated = [], False
for tld, registrar in configured.items():
grace = grace_period(registrar)
held = decode_uint(
eth_call(registrar, selector("balanceOf(address)") + encode_address(address))
)
first = min(offset, held)
last = min(first + MAX_OWNED, held)
if last < held:
truncated = True
for i in range(first, last):
token = decode_uint(
eth_call(
registrar,
selector("tokenOfOwnerByIndex(address,uint256)")
+ encode_address(address)
+ encode_uint(i),
)
)
expires = decode_uint(
eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token))
)
label = decode_string(
eth_call(registrar, selector("labelOf(uint256)") + encode_uint(token))
)
# A label of "" means the token is real but its name is not
# recoverable from chain state - registered before labels were
# recorded, or by a path that does not record them. Reported
# without a name rather than silently dropped.
names.append(
{
"name": (label + "." + tld) if label else None,
"tld": tld,
"labelhash": "0x" + format(token, "064x"),
"expires": expires,
"graceEnds": expires + grace if expires else None,
"status": expiry_status(expires, grace, now),
}
)
names.sort(key=lambda n: (n["tld"], n["name"] or n["labelhash"]))
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),
}
# ---------- 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
@@ -466,21 +903,44 @@ 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:
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, upstream_error({"address": address}, e)
self._respond(status, body)
return
self._respond(
404,
{"error": "not found", "routes": ["/health", "/resolve/<name>"]},
{
"error": "noSuchRoute",
"message": "not found",
"routes": ["/health", "/resolve/<name>", "/owned-by/<address>"],
},
)
def _respond(self, status: int, body: dict):
@@ -5,8 +5,14 @@ Run with `python3 -m unittest scripts/resolver/service/test_snrc_resolve.py`.
"""
import importlib.util
import json
import os
import threading
import time
import unittest
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
# snrc-resolve.py has a hyphen, so import it via importlib instead of `import`.
_HERE = os.path.dirname(os.path.abspath(__file__))
@@ -17,6 +23,548 @@ snrc = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(snrc)
class AbiCodecTests(unittest.TestCase):
"""The word-level codec the owner lookup is built from. Wrong padding here
is a silently empty answer rather than an error, so each direction is
pinned."""
def test_address_is_left_padded_to_a_word_and_lowercased(self):
self.assertEqual(
snrc.encode_address("0xEF47eb4384b46C89E4482a677c2cbcbd2a6fd85a"),
"00" * 12 + "ef47eb4384b46c89e4482a677c2cbcbd2a6fd85a",
)
def test_uint_round_trips_through_a_word(self):
for n in (0, 1, 42, 2**64, 2**255):
self.assertEqual(snrc.decode_uint("0x" + snrc.encode_uint(n)), n)
def test_decode_uint_of_empty_is_zero(self):
self.assertEqual(snrc.decode_uint(""), 0)
self.assertEqual(snrc.decode_uint("0x"), 0)
def test_decode_string_reads_the_dynamic_layout(self):
label = b"alice"
word = (32).to_bytes(32, "big") + len(label).to_bytes(32, "big")
padded = label + b"\x00" * (32 - len(label))
self.assertEqual(snrc.decode_string("0x" + (word + padded).hex()), "alice")
def test_decode_string_of_empty_return_is_empty(self):
self.assertEqual(snrc.decode_string("0x"), "")
def test_is_address_accepts_only_20_byte_hex(self):
self.assertTrue(snrc.is_address("0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"))
self.assertTrue(snrc.is_address("0xEF47EB4384B46C89E4482A677C2CBCBD2A6FD85A"))
self.assertFalse(snrc.is_address("ef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"))
self.assertFalse(snrc.is_address("0xef47eb"))
self.assertFalse(snrc.is_address("0x" + "g" * 40))
class OwnedByTests(unittest.TestCase):
"""owner -> names, read off the ERC-721 registrar.
The registrar's own invariant is that enumeration is maintained on
transfer/mint/burn and NOT on expiry, so an expired name stays enumerable
until it is re-registered. These pin the filter that follows from it.
"""
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
OWNER = "0x69a6000000000000000000000000000000002d32"
GRACE = 90 * 86400
def _fake_chain(self, tokens):
"""tokens :: [(labelhash, label, expires)] held by OWNER."""
sel = snrc.selector
def eth_call(to, data):
self.assertEqual(to, self.REGISTRAR)
if data.startswith(sel("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(self.GRACE)
if data.startswith(sel("balanceOf(address)")):
return "0x" + snrc.encode_uint(len(tokens))
if data.startswith(sel("tokenOfOwnerByIndex(address,uint256)")):
i = int(data[-64:], 16)
return "0x" + snrc.encode_uint(tokens[i][0])
if data.startswith(sel("nameExpires(uint256)")):
tid = int(data[-64:], 16)
return "0x" + snrc.encode_uint(dict((t[0], t[2]) for t in tokens)[tid])
if data.startswith(sel("labelOf(uint256)")):
tid = int(data[-64:], 16)
label = dict((t[0], t[1]) for t in tokens)[tid].encode()
head = (32).to_bytes(32, "big") + len(label).to_bytes(32, "big")
pad = b"\x00" * ((-len(label)) % 32)
return "0x" + (head + label + pad).hex()
raise AssertionError("unexpected call " + data[:10])
return eth_call
def setUp(self):
self._registrars = snrc.REGISTRARS
self._eth_call = snrc.eth_call
snrc.REGISTRARS = {"testing": self.REGISTRAR, "simplex": ""}
def tearDown(self):
snrc.REGISTRARS = self._registrars
snrc.eth_call = self._eth_call
def test_lists_names_with_their_labels(self):
future = int(time.time()) + 86400
snrc.eth_call = self._fake_chain([(11, "alice", future), (22, "bob", future)])
status, body = snrc.owned_by(self.OWNER)
self.assertEqual(status, 200)
self.assertEqual([n["name"] for n in body["names"]], ["alice.testing", "bob.testing"])
self.assertFalse(body["truncated"])
self.assertEqual(body["checkedTlds"], ["testing"])
def test_expired_names_are_reported_not_dropped(self):
"""A scan is how a user finds out a name lapsed, so an expired name has
to come back labelled rather than vanish."""
now = int(time.time())
snrc.eth_call = self._fake_chain(
[(11, "live", now + 86400), (22, "lapsed", now - 1)]
)
_, body = snrc.owned_by(self.OWNER)
self.assertEqual([n["name"] for n in body["names"]], ["lapsed.testing", "live.testing"])
by_name = {n["name"]: n for n in body["names"]}
self.assertEqual(by_name["live.testing"]["status"], "registered")
# lapsed an hour ago, so still renewable by its owner
self.assertEqual(by_name["lapsed.testing"]["status"], "grace")
self.assertEqual(by_name["lapsed.testing"]["expires"], now - 1)
self.assertEqual(by_name["lapsed.testing"]["graceEnds"], now - 1 + self.GRACE)
def test_a_name_past_grace_is_reported_as_claimable(self):
now = int(time.time())
snrc.eth_call = self._fake_chain([(11, "gone", now - self.GRACE - 3600)])
_, body = snrc.owned_by(self.OWNER)
self.assertEqual(body["names"][0]["status"], "expired")
def test_status_uses_the_same_vocabulary_as_resolve(self):
now = int(time.time())
snrc.eth_call = self._fake_chain([(11, "live", now + 86400)])
_, body = snrc.owned_by(self.OWNER)
self.assertIn(
body["names"][0]["status"], ("registered", "grace", "expired", "unregistered")
)
def test_a_name_with_no_recorded_label_is_reported_by_labelhash(self):
future = int(time.time()) + 86400
snrc.eth_call = self._fake_chain([(11, "", future)])
_, body = snrc.owned_by(self.OWNER)
self.assertEqual(body["names"][0]["name"], None)
# 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_offers_a_cursor(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)]
)
_, 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.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["error"], "noRegistrarConfigured")
self.assertEqual(body["configuredTlds"], [])
class LabelhashQueryTests(unittest.TestCase):
"""Asking by labelhash instead of by label.
A client checking whether a name is free is about to register it, so
telling the resolver which name that is hands whoever runs it a
front-running opportunity. namehash is keccak(parent || keccak(label)), so
a caller who supplies keccak(label) gets an identical answer having said
nothing about the name."""
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
GRACE = 90 * 86400
def setUp(self):
self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call)
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": ""}
def tearDown(self):
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved
def test_a_hashed_label_is_recognised_and_a_real_one_is_not(self):
self.assertTrue(snrc.is_labelhash("0x" + snrc.keccak(b"alice").hex()))
self.assertFalse(snrc.is_labelhash("alice"))
self.assertFalse(snrc.is_labelhash("0x" + "z" * 64))
# a label cannot be this long, which is what keeps the forms apart
self.assertFalse(snrc.is_labelhash("0x" + "a" * 62))
def test_hash_and_label_give_the_same_token_and_node(self):
h = "0x" + snrc.keccak(b"alice").hex()
self.assertEqual(snrc.label_token("alice"), snrc.label_token(h))
self.assertEqual(snrc.node_of("alice.testing"), snrc.node_of(h + ".testing"))
def test_status_by_hash_matches_status_by_name(self):
future = int(time.time()) + 86400
seen = []
def eth_call(to, data):
seen.append(data)
if data.startswith(snrc.selector("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(self.GRACE)
return "0x" + snrc.encode_uint(future)
snrc.eth_call = eth_call
h = "0x" + snrc.keccak(b"alice").hex()
by_name = snrc.name_status("alice.testing")
by_hash = snrc.name_status(h + ".testing")
self.assertEqual(by_name, by_hash)
self.assertEqual(by_name["status"], "registered")
# nothing in either request carried the label itself
self.assertTrue(all("alice".encode().hex() not in d for d in seen))
class ReservedTests(unittest.TestCase):
"""A reserved name is unregistered and still unavailable, which a client
intending to register needs to know before it tries."""
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
def setUp(self):
self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call)
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
def tearDown(self):
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved
def _chain(self, expires, reserved):
def eth_call(to, data):
if data.startswith(snrc.selector("reservedNames(bytes32)")):
self.assertEqual(to, self.CONTROLLER)
return "0x" + snrc.encode_uint(1 if reserved else 0)
if data.startswith(snrc.selector("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(90 * 86400)
return "0x" + snrc.encode_uint(expires)
return eth_call
def test_unregistered_and_reserved_reads_reserved(self):
snrc.eth_call = self._chain(0, True)
self.assertEqual(snrc.name_status("acme.testing")["status"], "reserved")
def test_unregistered_and_not_reserved_reads_unregistered(self):
snrc.eth_call = self._chain(0, False)
self.assertEqual(snrc.name_status("acme.testing")["status"], "unregistered")
def test_a_lapsed_reserved_name_is_reserved_not_claimable(self):
past = int(time.time()) - 91 * 86400
snrc.eth_call = self._chain(past, True)
self.assertEqual(snrc.name_status("acme.testing")["status"], "reserved")
def test_a_live_name_is_registered_even_if_reserved(self):
"""It was handed to its brand; the reservation is no longer the answer."""
snrc.eth_call = self._chain(int(time.time()) + 86400, True)
self.assertEqual(snrc.name_status("acme.testing")["status"], "registered")
def test_a_name_in_grace_belongs_to_its_owner_not_the_reserved_set(self):
snrc.eth_call = self._chain(int(time.time()) - 3600, True)
self.assertEqual(snrc.name_status("acme.testing")["status"], "grace")
def test_no_controller_configured_means_reserved_is_never_reported(self):
snrc.CONTROLLERS = {"testing": ""}
snrc.eth_call = self._chain(0, True) # would say reserved if asked
self.assertEqual(snrc.name_status("acme.testing")["status"], "unregistered")
def test_reserved_is_asked_by_labelhash_so_a_hashed_query_works(self):
h = "0x" + snrc.keccak(b"acme").hex()
snrc.eth_call = self._chain(0, True)
self.assertEqual(snrc.name_status(h + ".testing")["status"], "reserved")
class ReservedReasonTests(unittest.TestCase):
"""Why a name is reserved travels in its own field, so a client can show it
without parsing the message, and so a per-name reason can replace the fixed
one without moving anything."""
REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b"
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
def setUp(self):
self._saved = (
snrc.REGISTRIES,
snrc.REGISTRARS,
snrc.CONTROLLERS,
snrc.eth_call,
)
snrc.REGISTRIES = {"testing": self.REGISTRY}
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
def tearDown(self):
(
snrc.REGISTRIES,
snrc.REGISTRARS,
snrc.CONTROLLERS,
snrc.eth_call,
) = self._saved
def _chain(self, expires, reserved):
def eth_call(to, data):
if data.startswith(snrc.selector("reservedNames(bytes32)")):
return "0x" + snrc.encode_uint(1 if reserved else 0)
if data.startswith(snrc.selector("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(90 * 86400)
return "0x" + snrc.encode_uint(expires)
return eth_call
def test_a_reserved_name_carries_the_reason(self):
snrc.eth_call = self._chain(0, True)
status, body = snrc.resolve("acme.testing")
self.assertEqual(status, 404)
self.assertEqual(body["status"], "reserved")
self.assertEqual(body["reason"], "reserved for a brand or public interest")
def test_the_message_does_not_claim_a_trademark(self):
snrc.eth_call = self._chain(0, True)
_, body = snrc.resolve("acme.testing")
self.assertNotIn("trademark", body["message"])
def test_an_unregistered_name_has_no_reason(self):
snrc.eth_call = self._chain(0, False)
status, body = snrc.resolve("acme.testing")
self.assertEqual(status, 404)
self.assertEqual(body["status"], "unregistered")
self.assertNotIn("reason", body)
def test_an_expired_name_has_no_reason(self):
snrc.eth_call = self._chain(1, False)
status, body = snrc.resolve("acme.testing")
self.assertEqual(status, 410)
self.assertEqual(body["status"], "expired")
self.assertNotIn("reason", body)
def test_a_hashed_query_gets_the_reason_too(self):
snrc.eth_call = self._chain(0, True)
h = "0x" + snrc.keccak(b"acme").hex()
_, body = snrc.resolve(h + ".testing")
self.assertEqual(body["reason"], "reserved for a brand or public interest")
class NameStatusTests(unittest.TestCase):
"""simplexmq#1821: unresolvable has three causes and a caller has to tell
them apart. Names expire lazily, so the chain still holds the answer."""
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
GRACE = 90 * 86400
def _expiry(self, value):
def eth_call(to, data):
if data.startswith(snrc.selector("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(self.GRACE)
self.assertTrue(data.startswith(snrc.selector("nameExpires(uint256)")))
return "0x" + snrc.encode_uint(value)
return eth_call
def setUp(self):
self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call)
snrc.REGISTRARS = {"testing": self.REGISTRAR}
# These cases are about expiry alone. ReservedTests covers what a
# configured controller adds.
snrc.CONTROLLERS = {"testing": ""}
def tearDown(self):
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call = self._saved
def test_zero_expiry_means_never_registered(self):
snrc.eth_call = self._expiry(0)
self.assertEqual(
snrc.name_status("alice.testing"),
{"status": "unregistered", "expires": None, "graceEnds": None},
)
def test_recently_expired_is_in_grace_and_says_when_it_ends(self):
"""Only the previous owner may renew during grace - nobody else can
take the name yet, so this is a different answer from `expired`."""
past = int(time.time()) - 3600
snrc.eth_call = self._expiry(past)
self.assertEqual(
snrc.name_status("alice.testing"),
{"status": "grace", "expires": past, "graceEnds": past + self.GRACE},
)
def test_past_the_grace_window_it_is_expired_and_claimable(self):
past = int(time.time()) - self.GRACE - 3600
snrc.eth_call = self._expiry(past)
self.assertEqual(snrc.name_status("alice.testing")["status"], "expired")
def test_the_boundary_belongs_to_grace(self):
"""The registrar frees a name when expires + GRACE < now, so the last
second of the window is still the owner's."""
now = int(time.time())
snrc.eth_call = self._expiry(now - self.GRACE)
self.assertEqual(snrc.name_status("alice.testing")["status"], "grace")
def test_future_expiry_is_registered(self):
future = int(time.time()) + 3600
snrc.eth_call = self._expiry(future)
self.assertEqual(
snrc.name_status("alice.testing"),
{"status": "registered", "expires": future, "graceEnds": future + self.GRACE},
)
def test_never_registered_is_not_confused_with_claimable(self):
"""`available(id)` is true for both, since 0 + GRACE < now. The zero
expiry is the only thing that separates them."""
snrc.eth_call = self._expiry(0)
self.assertEqual(snrc.name_status("alice.testing")["status"], "unregistered")
self.assertNotEqual(snrc.name_status("alice.testing")["status"], "expired")
def test_a_subname_reports_the_status_of_its_2ld(self):
future = int(time.time()) + 3600
seen = []
def eth_call(to, data):
seen.append(data)
return "0x" + snrc.encode_uint(future)
snrc.eth_call = eth_call
self.assertEqual(snrc.name_status("x.alice.testing")["status"], "registered")
# the token asked about is keccak("alice"), not keccak("x")
self.assertTrue(seen[0].endswith(snrc.keccak(b"alice").hex()))
def test_unconfigured_tld_is_unknown_rather_than_unregistered(self):
snrc.REGISTRARS = {"testing": ""}
snrc.eth_call = lambda *a: self.fail("must not reach the chain")
self.assertEqual(
snrc.name_status("alice.testing"),
{"status": "unknown", "expires": None, "graceEnds": None},
)
def test_every_branch_returns_the_same_keys(self):
"""Callers read status/expires/graceEnds unconditionally, so a branch
that omits one is a KeyError in the caller rather than a missing field
in the JSON."""
keys = {"status", "expires", "graceEnds"}
snrc.eth_call = self._expiry(0)
self.assertEqual(set(snrc.name_status("alice.testing")), keys)
snrc.eth_call = self._expiry(int(time.time()) + 3600)
self.assertEqual(set(snrc.name_status("alice.testing")), keys)
snrc.REGISTRARS = {"testing": ""}
snrc.eth_call = lambda *a: self.fail("must not reach the chain")
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
@@ -82,5 +630,244 @@ class SplitLinksTests(unittest.TestCase):
)
class HandlerTests(unittest.TestCase):
"""The HTTP layer: routing, auth, query parsing, and the mapping from a
(status, body) pair to a response.
These go over a real socket because that is the only way to reach them —
every branch here lives in `do_GET`, which no function-level test calls.
"""
REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b"
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
RESOLVER = "0x1111111111111111111111111111111111111111"
OWNER = "0x69a6000000000000000000000000000000002d32"
FUTURE = 4102444800 # 2100-01-01
def setUp(self):
self._saved = {
k: getattr(snrc, k)
for k in (
"REGISTRIES",
"REGISTRARS",
"CONTROLLERS",
"AUTH_BEARER",
"AUTH_BASIC",
"eth_call",
"text",
"addr_multicoin",
)
}
snrc.REGISTRIES = {"testing": self.REGISTRY}
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
snrc.AUTH_BEARER = ""
snrc.AUTH_BASIC = ""
self.chain(expires=self.FUTURE)
class Quiet(snrc.Handler):
def log_message(self, fmt, *args):
pass
self.srv = ThreadingHTTPServer(("127.0.0.1", 0), Quiet)
# Default poll_interval is 0.5s and shutdown() waits for it, which
# would cost half a second per test in this class alone.
threading.Thread(
target=self.srv.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True
).start()
self.base = "http://127.0.0.1:%d" % self.srv.server_address[1]
def tearDown(self):
self.srv.shutdown()
self.srv.server_close()
for k, v in self._saved.items():
setattr(snrc, k, v)
# -- fixtures ---------------------------------------------------------
def chain(self, expires, reserved=False, resolver=None, raises=None):
"""Install a fake chain. `raises` makes every call fail, which is how
the 502 path is reached."""
resolver = self.RESOLVER if resolver is None else resolver
sel = snrc.selector
def eth_call(to, data):
if raises is not None:
raise raises
if data.startswith(sel("reservedNames(bytes32)")):
return "0x" + snrc.encode_uint(1 if reserved else 0)
if data.startswith(sel("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(90 * 86400)
if data.startswith(sel("nameExpires(uint256)")):
return "0x" + snrc.encode_uint(expires)
if data.startswith(sel("resolver(bytes32)")):
return "0x" + snrc.encode_uint(int(resolver, 16))
if data.startswith(sel("owner(bytes32)")):
return "0x" + snrc.encode_uint(int(self.OWNER, 16))
if data.startswith(sel("balanceOf(address)")):
return "0x" + snrc.encode_uint(1)
if data.startswith(sel("tokenOfOwnerByIndex(address,uint256)")):
return "0x" + snrc.encode_uint(int.from_bytes(snrc.keccak(b"acme"), "big"))
if data.startswith(sel("labelOf(uint256)")):
label = b"acme"
head = (32).to_bytes(32, "big") + len(label).to_bytes(32, "big")
return "0x" + (head + label + b"\x00" * 28).hex()
raise AssertionError("unexpected call " + data[:10])
snrc.eth_call = eth_call
snrc.text = lambda r, node, key: {"name": "Acme", "url": "https://acme.example"}.get(key, "")
snrc.addr_multicoin = lambda r, node, coin: (
self.OWNER if coin == snrc.COIN_ETH else None
)
def get(self, path, auth=None):
req = urllib.request.Request(self.base + path)
if auth is not None:
req.add_header("Authorization", auth)
try:
with urllib.request.urlopen(req, timeout=5) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
with e:
return e.code, json.loads(e.read())
# -- routing ----------------------------------------------------------
def test_health_reports_the_version_and_the_registrars(self):
status, body = self.get("/health")
self.assertEqual(status, 200)
self.assertTrue(body["ok"])
self.assertEqual(body["version"], snrc.API_VERSION)
# Present so an operator can see why status would read "unknown".
self.assertEqual(body["registrars"], {"testing": self.REGISTRAR})
def test_an_unknown_route_names_the_routes_that_exist(self):
status, body = self.get("/nope")
self.assertEqual(status, 404)
self.assertEqual(body["error"], "noSuchRoute")
self.assertIn("/resolve/<name>", body["routes"])
def test_the_root_path_is_not_a_route(self):
status, body = self.get("/")
self.assertEqual(status, 404)
self.assertEqual(body["error"], "noSuchRoute")
# -- auth -------------------------------------------------------------
def test_no_auth_configured_means_no_header_is_needed(self):
self.assertEqual(self.get("/health")[0], 200)
def test_a_configured_token_is_required(self):
snrc.AUTH_BEARER = "s3cret"
status, body = self.get("/health")
self.assertEqual(status, 401)
self.assertEqual(body["error"], "unauthorized")
def test_the_right_token_is_accepted(self):
snrc.AUTH_BEARER = "s3cret"
self.assertEqual(self.get("/health", auth="Bearer s3cret")[0], 200)
def test_a_wrong_token_is_refused(self):
snrc.AUTH_BEARER = "s3cret"
self.assertEqual(self.get("/health", auth="Bearer nope")[0], 401)
def test_auth_is_checked_before_the_route_exists(self):
# An unauthenticated caller learns nothing about which routes exist.
snrc.AUTH_BEARER = "s3cret"
status, body = self.get("/nope")
self.assertEqual(status, 401)
self.assertNotIn("routes", body)
# -- /resolve ---------------------------------------------------------
def test_a_live_name_returns_its_record(self):
status, body = self.get("/resolve/acme.testing")
self.assertEqual(status, 200)
self.assertEqual(body["name"], "acme.testing")
self.assertEqual(body["nickname"], "Acme")
self.assertEqual(body["website"], "https://acme.example")
self.assertEqual(body["owner"], self.OWNER)
self.assertEqual(body["status"], "registered")
self.assertEqual(body["expires"], self.FUTURE)
def test_a_bare_label_is_rejected_before_any_rpc(self):
self.chain(expires=0, raises=AssertionError("must not reach the chain"))
status, body = self.get("/resolve/acme")
self.assertEqual(status, 400)
self.assertEqual(body["error"], "notFullyQualified")
def test_a_name_is_lowercased(self):
status, body = self.get("/resolve/ACME.TESTING")
self.assertEqual(status, 200)
self.assertEqual(body["name"], "acme.testing")
def test_a_reserved_name_is_404_with_its_reason(self):
self.chain(expires=0, reserved=True)
status, body = self.get("/resolve/acme.testing")
self.assertEqual(status, 404)
self.assertEqual(body["status"], "reserved")
self.assertEqual(body["reason"], "reserved for a brand or public interest")
def test_an_expired_name_is_410(self):
self.chain(expires=1)
status, body = self.get("/resolve/acme.testing")
self.assertEqual(status, 410)
self.assertEqual(body["status"], "expired")
def test_a_name_with_no_resolver_is_404(self):
self.chain(expires=self.FUTURE, resolver=snrc.ZERO_ADDR)
status, body = self.get("/resolve/acme.testing")
self.assertEqual(status, 404)
self.assertEqual(body["error"], "noResolver")
def test_an_unconfigured_tld_is_400(self):
status, body = self.get("/resolve/acme.example")
self.assertEqual(status, 400)
self.assertEqual(body["error"], "tldNotConfigured")
self.assertEqual(body["configuredTlds"], ["testing"])
# -- /owned-by --------------------------------------------------------
def test_owned_by_lists_the_names_held(self):
status, body = self.get("/owned-by/" + self.OWNER)
self.assertEqual(status, 200)
self.assertEqual([n["name"] for n in body["names"]], ["acme.testing"])
self.assertEqual(body["offset"], 0)
def test_a_negative_offset_is_rejected(self):
status, body = self.get("/owned-by/%s?offset=-1" % self.OWNER)
self.assertEqual(status, 400)
self.assertEqual(body["error"], "badOffset")
def test_a_non_numeric_offset_is_rejected(self):
status, body = self.get("/owned-by/%s?offset=abc" % self.OWNER)
self.assertEqual(status, 400)
self.assertEqual(body["error"], "badOffset")
def test_a_bad_address_is_rejected(self):
status, body = self.get("/owned-by/not-an-address")
self.assertEqual(status, 400)
self.assertEqual(body["error"], "badAddress")
# -- upstream failure -------------------------------------------------
def test_an_rpc_failure_is_502_and_does_not_leak_the_rpc_url(self):
# SNRC_RPC can carry a key, and urlopen puts the URL it failed on into
# the exception message, so the body must not quote the exception.
self.chain(expires=0, raises=RuntimeError("failed on http://user:[email protected]:8545"))
status, body = self.get("/resolve/acme.testing")
self.assertEqual(status, 502)
self.assertEqual(body["error"], "upstreamError")
self.assertNotIn("rpc.internal", json.dumps(body))
self.assertNotIn("key", json.dumps(body))
def test_an_rpc_failure_on_owned_by_is_also_502(self):
self.chain(expires=0, raises=RuntimeError("boom"))
status, body = self.get("/owned-by/" + self.OWNER)
self.assertEqual(status, 502)
self.assertEqual(body["error"], "upstreamError")
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -76,6 +76,10 @@ fetch NamesEnv {resolverEnv} d =
mapResolverError :: ResolverError -> NameErrorType
mapResolverError = \case
HttpStatusErr 404 -> NOT_FOUND
-- 410 is a lapsed registration (past expiry, in grace or beyond): a correct
-- answer about the name, not a resolver failure, so it must not become
-- RESOLVER - that is reserved for the backing resolver/RPC breaking.
HttpStatusErr 410 -> NOT_FOUND
HttpStatusErr 400 -> NOT_FOUND
HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code))
HttpFailure _ -> RESOLVER "transport failure"
+13 -1
View File
@@ -19,7 +19,7 @@ import Data.List.NonEmpty (NonEmpty (..))
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Clock (getCurrentTime)
import Network.HTTP.Types (Status, status200, status404, status502)
import Network.HTTP.Types (Status, status200, status404, status410, status502)
import NamesResolverServer (memCfg, memCfg2, memProxyCfg, withNames)
import qualified NamesResolverServer as NRS
import SMPClient
@@ -74,6 +74,7 @@ rslvTests :: Spec
rslvTests = do
describe "RSLV direct (non-forwarded)" $ do
it "resolver replies 404 -> NAME NOT_FOUND (reached, not CMD PROHIBITED)" testRslvBackendNotFound
it "resolver replies 410 -> NAME NOT_FOUND (a lapsed name, not a resolver failure)" testRslvBackendGone
it "resolver replies 502 -> NAME (RESOLVER ..)" testRslvBackendHttpErr
it "no names config -> NAME NO_RESOLVER" testRslvDisabled
it "refuses to send RSLV on a session below namesSMPVersion" testRslvVersion
@@ -91,6 +92,17 @@ testRslvBackendNotFound =
corrId `shouldBe` CorrId "rs01"
resp `shouldBe` Right (ERR (NAME NOT_FOUND))
-- The resolver answers 410 for a registration that has lapsed (in grace or
-- past it). That is a correct answer about the name, so it has to arrive as
-- NOT_FOUND; RESOLVER would make the client treat it as a broken resolver and
-- abort domain verification instead of reporting the name as unverified.
testRslvBackendGone :: IO ()
testRslvBackendGone =
withResolverServer (status410, "{}") $
testSMPClient @TLS $ \h -> do
(_, _, resp) <- sendRslv h "rs08" (domain "lapsed.simplex")
resp `shouldBe` Right (ERR (NAME NOT_FOUND))
testRslvBackendHttpErr :: IO ()
testRslvBackendHttpErr =
withResolverServer (status502, "{}") $
+9 -1
View File
@@ -13,7 +13,7 @@ import Data.IORef (readIORef)
import Data.List (sort)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Network.HTTP.Types (status200, status400, status404, status500, status502)
import Network.HTTP.Types (status200, status400, status404, status410, status500, status502)
import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed)
import Simplex.Messaging.Encoding (smpDecode, smpEncode)
import Simplex.Messaging.Encoding.String (strDecode)
@@ -156,6 +156,14 @@ resolverSpec = do
env <- newNamesEnv (testNamesConfig port)
resolveName env aliceDomain `shouldReturn` Left NOT_FOUND
it "returns NOT_FOUND on 410 (registration lapsed)" $
-- A lapsed name is a correct answer, not a resolver failure: RESOLVER
-- would make the client abort domain verification instead of reporting
-- the name as unverified.
withResolverServer (resolveResp status410 "{}") $ \port _ -> do
env <- newNamesEnv (testNamesConfig port)
resolveName env aliceDomain `shouldReturn` Left NOT_FOUND
it "returns RESOLVER on 502 (upstream failure)" $
withResolverServer (resolveResp status502 "{}") $ \port _ -> do
env <- newNamesEnv (testNamesConfig port)