mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-10-12 02:22:45 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
075b1aa6e2 | ||
|
|
a40fdb8b36 | ||
|
|
685c3ff264 | ||
|
|
a3903887f5 | ||
|
|
e8652a2bd4 | ||
|
|
6175411616 |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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, "{}") $
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user