Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
255 changes: 246 additions & 9 deletions scripts/resolver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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

Expand Down Expand Up @@ -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
}
```

Expand All @@ -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>`

Every name an Ethereum address holds, across every configured TLD.

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.
```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 changes: 18 additions & 0 deletions scripts/resolver/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading