diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index abf84a273..0de5350c7 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1523,27 +1523,21 @@ fact that this router cannot resolve, so iterating past it is safe. #### Name response -Resolving a name and asking whether it can be registered are one lookup in the -registry, and `RNAME` answers both: a client offering to register a name that -turns out to be taken can show what took it. +`RNAME` answers both what a name resolves to and whether it can be registered. ```abnf rname = %s"RNAME" SP registration ``` `registration` is a UTF-8 JSON object consuming the remainder of the -transmission. Its `type` field says which of the three answers it is; the other -fields are the ones that answer carries. - -Money is US cents; the registry denominates in USD, never in ETH, and the -backing resolver converts before the value reaches the protocol. Times are -seconds since the Unix epoch. Lengths are characters. +transmission. Its `type` selects which of the three answers it is. Money is US +cents, times are seconds since the Unix epoch, and lengths are characters. | `type` | Meaning | |---|---| | `registered` | held by someone until `expires`, renewable by its owner alone until `graceUntil`. It always carries `nameRecord`: where the owner set none, every field is unset and the resolver address is zero | | `available` | held by nobody and registrable now, at `pricing` | -| `reserved` | held back by the registry and not registered | +| `reserved` | held back by the registry and not registered. It carries no price, and a router MUST NOT quote one | | Field | On | JSON type | Constraints | |---|---|---|---| @@ -1562,28 +1556,8 @@ seconds since the Unix epoch. Lengths are characters. A reason word is `internal`, `trademark`, `community`, or a word a later version reserves under, at most 32 printable ASCII characters. A router truncates an -unknown word to that, since it reaches the client as a word. The words are the same on the wire, in the backing resolver's -JSON and in a client's own API. - -`available` alone means registrable: a name the registry holds back answers -`reserved` instead, so a client has no flags to combine. A reservation on a name -that *is* registered is carried in `reservedReason`, and is why that name will -not free up when it expires. - -There is no separate answer for an auction. A name past its grace period answers -`available` at the ordinary price. Where the registry charges a post-grace -surcharge it is not carried: it decays continuously, so it cannot be quoted as a -purchase price, and a router that cannot read it answers `ERR NAME RESOLVER` -rather than a price below what the registry charges. - -A router MUST NOT quote a price for a reserved name: it is not for sale at the -registry's price, and quoting one would be an offer the registry will not -honour. That is why `reserved` has no pricing field. - -The record is carried while a name is registered and through its grace period, -and stops once the name is registrable by anyone. Keeping it through grace lets -whoever opens the name tell its owner that it is about to lapse; keeping it -longer would show a record whose owner no longer holds the name. +unknown word to that and otherwise passes it through unchanged. A client MUST +read a word it does not know as unknown and still treat the name as reserved. **Computing the price.** In US cents, for a duration in seconds: @@ -1592,37 +1566,22 @@ price len duration = tier len * duration / 31536000 tier len = the entry for len in registrationPrices, or basePrice when there is none ``` -The registry's minimum registration is 730 days, a contract constant rather -than a per-deployment value, so it is specified here rather than sent. -`registrationPrices` omits any length below `minLabelLength`, those being -unregistrable. `minLabelLength` is sent because a hashed query carries no -length: the router cannot check it, so the client must, and a quote for a label -the registry refuses must not be shown. +The registry's minimum registration is 730 days, a contract constant, so it is +specified here rather than sent. `registrationPrices` omits any length below +`minLabelLength`. A client MUST NOT show a quote for a label the registry +refuses: a hashed query carries no length, so only the client can check it. Below v22, `RNAME` carries the bare record and nothing else, and every answer -without one is `ERR NAME NOT_FOUND`, as it was before this version. A name in -its grace period therefore resolves for those clients too, without the expiry -they have no field to carry. In the other direction a v22 client reads such an -answer as `registered` with no expiry, grace or reservation, which is the only -reason those three fields are optional. +without one is `ERR NAME NOT_FOUND`. A v22 client reads such an answer as +`registered` with no expiry, grace or reservation. From v22 a client MUST NOT read `ERR NAME NOT_FOUND` as "registrable": only -`available` says that. `NOT_FOUND` means the router has nothing to say about the name, which -includes a backing resolver whose answer it could not read. +`available` says that. -A router that cannot state an answer completely MUST say so as `ERR NAME -RESOLVER ` rather than answer partially. That covers a TLD with no -registrar or no price oracle configured, an unreachable chain, a timeout, a -registration it could not date, a registered name it could not resolve, and any -status word it does not recognise. A router MUST NOT guess either. - -A client MUST read a `reason` it does not know as unknown and still treat the -name as reserved: a later version may reserve names for reasons this one cannot -name, and losing the reservation over that would offer a name that cannot be -registered. A router passes the word through unchanged, which is why the set is -open rather than an enumeration. A resolver can only send a word it has, though: -SNRC's registry records a numeric reason, so a code added after the resolver -arrives as `unknown` and only the reservation survives. +A router that cannot state an answer completely MUST send `ERR NAME RESOLVER +` rather than answer partially or guess. That covers a TLD with no +registrar or price oracle, an unreachable chain, a timeout, a registration it +could not date or resolve, and any status word it does not recognise. `nameRecord` MUST be a UTF-8 JSON object with the following schema: @@ -1641,34 +1600,16 @@ arrives as `unknown` and only the reservation survives. | `owner` | string | `"0x"` followed by 40 lowercase hex characters (20 raw bytes) | | `resolver` | string | `"0x"` followed by 40 lowercase hex characters; the resolver contract address that produced the record | -Text fields (`nickname`, `website`, `location`) use the empty string `""` as -the "unset" sentinel: a backing resolver with no value for the field MUST emit -an empty string, not JSON `null` and not an absent key. Link fields -(`simplexContact`, `simplexChannel`) are arrays, primary link first, and use the -empty array `[]` when unset. Coin fields (`eth`, `btc`, `xmr`, `dot`) use JSON -`null` as the "unset" sentinel and MAY also be absent from the object entirely. +Testnet-vs-mainnet status is derived from the queried TLD, not from the record. -The record carries no expiry field of its own: `registered` carries it -alongside. The backing resolver stops resolving a name once it is registrable by -anyone, so a record only ever accompanies `registered`. Testnet-vs-mainnet status is derived -from the queried TLD rather than an in-record flag. +Receivers MUST tolerate extra unknown fields; adding a required field is a +breaking change requiring an SMP version bump. Receivers parse by key name, so +peers MUST NOT rely on a byte-canonical form. -Receivers MUST tolerate extra unknown fields (forward-compatibility for future -field additions). Adding a required field is a breaking change requiring an -SMP version bump. - -**Field order is not significant.** Receivers parse JSON by key name, so object -key order, insignificant whitespace, and number formatting carry no meaning; -records are interpreted by decoded value, never compared byte-for-byte. Peers -MUST NOT rely on a byte-canonical form — a different resolver or server may emit -the same record with different key order or spacing. This order-independence is -what makes the format forward-compatible (see the unknown-field rule above). - -**Wire-size budget.** The names router caps the resolver response it will -accept (`resolver_max_response_bytes`, ≤ 16000 bytes, the default) so the -re-encoded `RNAME` stays within the SMP proxied transmission budget of 16224 -bytes; a response over the cap is rejected as `ERR NAME RESOLVER`. The link -arrays are bounded by this overall budget rather than a fixed per-field count. +The names router caps the resolver response it will accept +(`resolver_max_response_bytes`, at most 16000 bytes) so the re-encoded `RNAME` +stays within the SMP proxied transmission budget of 16224 bytes; a response over +the cap is `ERR NAME RESOLVER`. ## Transport connection with the SMP router diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 9678453d3..99e0df4ae 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -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. the route your router will call** (check 3 passes on an older resolver too): +```sh +curl -s http://127.0.0.1:8000/v2/resolve/foobar.testing | jq +# → {"type":"registered","expires":1780000000,"graceUntil":…,"nameRecord":{…}} +``` + **Wire your smp-server:** in its `[NAMES]` section set `resolver_endpoint: http://127.0.0.1:8000` (no auth needed for loopback). @@ -110,7 +116,52 @@ standalone for local dev (no Docker), via [`uv`](https://docs.astral.sh/uv/): uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + mainnet .testing ``` -### Response shape +Three routes, versioned separately from the protocol so each only changes when +its own shape does: + +| Route | Called by | Answers | +|---|---|---| +| `/v2/resolve/` | routers from SMP v22 | a `NameRegistration` | +| `/resolve/` | routers before SMP v22 | a name record, flat | +| `/health` | anyone | readiness | + +`/v1/resolve/` is an alias for `/resolve/`. + +### v2: `/v2/resolve/` + +The body is the SMP protocol's `NameRegistration`, which the router decodes as +is and forwards; translating the registry's model to it is this resolver's job. +Its `type` is `registered`, `available` or `reserved`, and the fields each one +carries are specified once, in the **Name response** section of +[`protocol/simplex-messaging.md`](../../protocol/simplex-messaging.md). It is +the wire format, so it is documented with the wire. + +Two things follow from that and are worth stating here. Expiry and grace belong +to the registration, not to the record: `expires` and `graceUntil` sit beside +`nameRecord`, not inside it. And a name nobody holds is not an error: it +answers 200 with `type: available` and its price, so no status code from this +route means "not registered". + +| Status | Meaning | +|---|---| +| 200 | a registration: `registered`, `available` or `reserved` | +| 400 | `tldNotConfigured`, `notFullyQualified` | +| 502 | `noPriceOracle`, `labelNotRecorded`, an unreadable status, or `upstreamError` | + +Error bodies carry `name` and a fixed `error` code to branch on. Only +`upstreamError` adds a `message`; the v1 route always adds one. + +`labelNotRecorded` means the registrar holds the name but never recorded its +label, so a hashed query cannot be answered with a name. See +[Querying by labelhash](#querying-by-labelhash). + +### v1: `/resolve/` + +What routers before SMP v22 call. Its shape is unrelated to v2's: the record is +flat, and `status`, `expires`, `graceEnds`, `reasonCode` and `reason` sit +alongside its fields. + +#### v1 response shape ```jsonc { @@ -134,7 +185,7 @@ 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 +#### v1 registration status and expiry A response carries `status`, `expires` and `graceEnds` whenever the resolver read them, a successful resolve included, so a client that has just resolved a @@ -164,6 +215,90 @@ released*. A subname reports the status of the 2LD above it, which is only as good as the name it sits under. +#### v1 errors + +Every non-2xx body carries two fields: `error` is a fixed code to branch on, +and `message` is a sentence for a human. Match on `error`, never on `message`, +which is free to change. + +```jsonc +{"name": "nope.testing", "error": "unregistered", + "message": "this name has never been registered", + "status": "unregistered", "expires": null, "graceEnds": null} +``` + +The codes are `tldNotConfigured`, `notFullyQualified`, `unregistered`, +`expired`, `noSuchRoute` and `upstreamError`. When the registration is what went +wrong, `error` and `status` hold the same value, so one field is enough to read. + +`upstreamError` says only which exception type the RPC call raised. The text +goes to the resolver's log instead, because `SNRC_RPC` can carry a provider key +and urlopen puts the URL it failed on into the message. It is also the answer +when a registrar, controller or oracle address has no contract behind it: the +empty reply is refused rather than read as zero, which would make every name +look free. + +#### v1 status codes + +| Status | Meaning | +|---|---| +| 200 | resolved (`status` is `registered` or `grace`, or `unknown` when no registrar is configured) | +| 400 | TLD not configured, or not a fully-qualified name | +| 404 | `unregistered` | +| 410 | `expired`: lapsed and past grace, so anyone may take it | +| 502 | upstream RPC error / reth not synced | + +### Querying by labelhash + +A client asking whether a name is free is usually about to register it, and +whoever runs the resolver could register it first. To avoid that, send the +keccak hash of the label in ENS's `[<64 hex>]` form instead of the label: + +```sh +# instead of /resolve/acme.testing +curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d' ' -f1)].testing" +``` + +namehash is `keccak(parent || keccak(label))`, so this reaches the same node and +returns the same record. The registrar keys `nameExpires` and `reservedNames` on +the labelhash too, so the status fields do not need the label either. The +resolver learns the name only by guessing the label and hashing it. + +Only the second-level label is a registry key, and `status` decodes a bracket +there at any depth. The record does not: a bracket is decoded only in a +two-label name, so `sub.[].testing` is not a supported query. Subname +labels stay text; a bracket label left of the 2LD is an ordinary label. Routers +from v22 send every 2LD this way, so a registrable name normally never reaches +this service. + +On v2, read `type`: only `available` means the name is free. On v1, read +`status`: a name is free on `unregistered` (404) and on `expired` (410), every +other status means somebody holds it, and a `reasonCode` means the registry will +refuse it whatever the status says. + +The hash must be keccak-256. `openssl dgst -sha3-256` and `sha3sum` compute +SHA3-256, a different function that returns 64 valid-looking hex characters +pointing at the wrong node. + +The resolver lowercases the query before matching, so uppercase hex works too. +Clients that refuse raw brackets in a path can percent-encode them as `%5B` and +`%5D`. + +Brackets cannot collide with a real name: they are invalid in a normalised ENS +name, and a `[<64 hex>]` label is 66 bytes against the registrar's +`maxLabelLength` of 63. A plain `0x…` label is not treated as a hash, since that +is an ordinary, registrable name. + +Only 2LDs can be queried this way, as only a 2LD can be raced for: subnames are +created by the 2LD's owner. A bracket label in a subname is hashed as written, +so it points at a node nobody can own. ENS tooling accepts the bracketed form at +any depth; this resolver does not, on purpose. + +This hides interest in a name and nothing else: the registration itself is +public, and commit-reveal covers that step. A short or well-known label is easy +to guess by hashing candidates, and the reveal publishes the labelhash, so an +operator who logged the query can match it to the name afterwards. + ### What a name costs The controller's `prices()` names the price oracle, so no extra configuration is @@ -189,9 +324,10 @@ answers `ERR NAME RESOLVER "HTTP 404"` until this service is upgraded, while ### Why a name is reserved -A held-back name carries `reasonCode`, the controller's reason, and `reason`, an -English sentence for a human reading this API. Clients should branch on -`reasonCode` and word it themselves, in the user's language. +v2 carries the controller's reason as `reservedReason`. v1 carries the same word +as `reasonCode`, plus `reason`, an English sentence for a human reading this API. +Clients should branch on the word and phrase it themselves, in the user's +language. | `reasonCode` | Meaning | |---|---| @@ -204,89 +340,6 @@ These are `SimplexController.Reason`, where 0 means not reserved. A controller from before the enum stores a boolean, whose `true` decodes as 1, which is why 1 reads as `internal`, so nothing needs migrating. -### Querying by labelhash - -A client asking whether a name is free is usually about to register it, and -whoever runs the resolver could register it first. To avoid that, send the -keccak hash of the label in ENS's `[<64 hex>]` form instead of the label: - -```sh -# instead of /resolve/acme.testing -curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d' ' -f1)].testing" -``` - -namehash is `keccak(parent || keccak(label))`, so this reaches the same node and -returns the same record. The registrar keys `nameExpires` and `reservedNames` on -the labelhash too, so the status fields do not need the label either. The -resolver learns the name only by guessing the label and hashing it. - -Only the second-level label is a registry key, and `status` decodes a bracket -there at any depth. The record does not: a bracket is decoded only in a -two-label name, so `sub.[].testing` is not a supported query. Subname -labels stay text; a bracket label left of the 2LD is an ordinary label. Routers -from v22 send every 2LD this way, so a registrable name normally never reaches -this service. - -Read the answer from `status`. A name is free on `unregistered` (404) and on -`expired` (410). Every other status means somebody holds the name. A `reasonCode` -means the registry will refuse it whatever the status says. - -The hash must be keccak-256. `openssl dgst -sha3-256` and `sha3sum` compute -SHA3-256, a different function that returns 64 valid-looking hex characters -pointing at the wrong node. - -The resolver lowercases the query before matching, so uppercase hex works too. -Clients that refuse raw brackets in a path can percent-encode them as `%5B` and -`%5D`. - -Brackets cannot collide with a real name: they are invalid in a normalised ENS -name, and a `[<64 hex>]` label is 66 bytes against the registrar's -`maxLabelLength` of 63. A plain `0x…` label is not treated as a hash, since that -is an ordinary, registrable name. - -Only 2LDs can be queried this way, as only a 2LD can be raced for: subnames are -created by the 2LD's owner. A bracket label in a subname is hashed as written, -so it points at a node nobody can own. ENS tooling accepts the bracketed form at -any depth; this resolver does not, on purpose. - -This hides interest in a name and nothing else: the registration itself is -public, and commit-reveal covers that step. A short or well-known label is easy -to guess by hashing candidates, and the reveal publishes the labelhash, so an -operator who logged the query can match it to the name afterwards. - -### Errors - -Every non-2xx body carries two fields: `error` is a fixed code to branch on, -and `message` is a sentence for a human. Match on `error`, never on `message`, -which is free to change. - -```jsonc -{"name": "nope.testing", "error": "unregistered", - "message": "this name has never been registered", - "status": "unregistered", "expires": null, "graceEnds": null} -``` - -The codes are `tldNotConfigured`, `notFullyQualified`, `unregistered`, -`expired`, `noSuchRoute` and `upstreamError`. When the registration is what went -wrong, `error` and `status` hold the same value, so one field is enough to read. - -`upstreamError` says only which exception type the RPC call raised. The text -goes to the resolver's log instead, because `SNRC_RPC` can carry a provider key -and urlopen puts the URL it failed on into the message. It is also the answer -when a registrar, controller or oracle address has no contract behind it: the -empty reply is refused rather than read as zero, which would make every name -look free. - -### Status codes - -| Status | Meaning | -|---|---| -| 200 | resolved (`status` is `registered` or `grace`, or `unknown` when no registrar is configured) | -| 400 | TLD not configured, or not a fully-qualified name | -| 404 | `unregistered` | -| 410 | `expired`: lapsed and past grace, so anyone may take it | -| 502 | upstream RPC error / reth not synced | - ### Configuring addresses The resolver reads three contracts, each configured per TLD.