Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e3f00b4
extend SMP protocol to support name availability queries with accurat…
brenzi Sep 5, 2026
69cce33
review fixes
brenzi Sep 5, 2026
783f0aa
more review fixes
brenzi Sep 5, 2026
45d314c
docs shortening and other review fixes
brenzi Sep 5, 2026
c99cc61
add catch all for furture variants
brenzi Sep 6, 2026
5c48027
adversarial review (against simplex-chat) fix
brenzi Sep 6, 2026
3b4a7f2
next iteration fixes
brenzi Sep 6, 2026
99adfe9
adapt house style
brenzi Sep 6, 2026
e6dc6c8
eth_call guard and cache constants
brenzi Sep 7, 2026
c2c3bbc
revert NAVL command and wrap it all into RSLV
brenzi Sep 7, 2026
0843238
doc fixes
brenzi Sep 7, 2026
b8cc019
Update src/Simplex/Messaging/Server/Names.hs
brenzi Sep 7, 2026
8076504
Update src/Simplex/Messaging/Protocol.hs
brenzi Sep 7, 2026
1e5ddca
Update src/Simplex/Messaging/Protocol.hs
brenzi Sep 7, 2026
13f8cce
Update src/Simplex/Messaging/Protocol.hs
brenzi Sep 7, 2026
20ba720
Update src/Simplex/Messaging/Protocol.hs
brenzi Sep 7, 2026
e4fab46
protocol types refactoring
brenzi Sep 7, 2026
1e3d6fa
next iteration on types only
brenzi Sep 7, 2026
1dabe1d
rentPrices map
brenzi Sep 8, 2026
82ca4af
claude answering ep review. to be continued...
brenzi Sep 8, 2026
1a74598
separate labelhash and plaintext names cleanly
brenzi Sep 8, 2026
c0bf620
align implementation with latest type changes to test against client
brenzi Sep 8, 2026
8b70633
fix adversarial review findings
brenzi Sep 8, 2026
f229e61
trim diff
brenzi Sep 8, 2026
7636c8e
Merge remote-tracking branch 'origin/master' into ab/snrc-resolver-SM…
brenzi Sep 9, 2026
5ccbca9
align resolver with contract changes for names v2
brenzi Sep 9, 2026
e03e4bd
Merge branch 'ab/snrc-resolver-expiry-and-availability' into ab/snrc-…
brenzi Sep 9, 2026
be35dbc
fix reverse compatibility with .testing mainnet
brenzi Sep 9, 2026
9db84e1
fix more robustly
brenzi Sep 9, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ cabal.project.local~
*.tix
.coverage

__pycache__/
158 changes: 141 additions & 17 deletions protocol/simplex-messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ It's designed with the focus on communication security and integrity, under the

It is designed as a low level protocol for other application protocols to solve the problem of secure and private message transmission, making [MITM attack][1] very difficult at any part of the message transmission system.

This document describes SMP protocol version 20. Versions 1-5 are discontinued. The version history:
This document describes SMP protocol version 22. Versions 1-5 are discontinued. The version history:

- v1: binary protocol encoding
- v2: message flags (used to control notifications)
Expand All @@ -108,6 +108,7 @@ This document describes SMP protocol version 20. Versions 1-5 are discontinued.
- v19: service subscriptions to messages (SUBS, NSUBS, SOKS, ENDS, ALLS commands)
- v20: public namespaces resolver (RSLV command, RNAME response) — direct or forwarded via PFWD
- v21: server public information in handshake
- v22: `RNAME` says whether a name can be registered, not only what it resolves to

## Introduction

Expand Down Expand Up @@ -1457,46 +1458,169 @@ while still returning a `NameRecord` matching the encoding below.

#### Resolve name command

The `RSLV` command carries the canonical fully-qualified name directly as the
payload (not JSON):
From v22 the `RSLV` command carries a query; below v22 it carries the name
directly, as it always did (not JSON):

```abnf
rslv = %s"RSLV" SP domain ; domain = canonical name as non-space bytes, consuming the remainder of the transmission
rslv = %s"RSLV" SP (query / domain) ; query from v22, domain below it
query = tld label sub
tld = %s"s" / %s"t" / %s"w" ; .simplex / .testing / a web name
label = %s"N" shortString ; the second-level label as text
/ %s"H" 32*32 OCTET ; its keccak-256
sub = length *shortString ; subname labels, parent to child
domain = 1*253 OCTET ; the name as text
```

`domain` is the UTF-8 canonical fully-qualified name with the TLD always
explicit (e.g. `privacy.simplex`, `test.testing`, `example.com`), bounded to
253 bytes.

**Hashed labels.** The query's second-level label is either the label itself or
the keccak-256 of it, tagged, so the two are told apart by the tag and never by
the shape of the value.

Only the second-level label may be hashed: subname labels are needed as text to
reach the record, and a web TLD has no registry to key on. `sub.<hash>.simplex`
reaches the node `sub.name.simplex` does.

From v22 a client MUST send the hash. Older routers can only read the name, so a
client on an older session sends the name. A router answering a hashed query
does not know the label's length, so it cannot check a minimum-length policy
either: the client does that, from the pricing it is sent.

The hash reaches the backing resolver as `[` + 64 lowercase hex + `]`, ENS's
encoding for a label whose text is unknown, because that is what its HTTP API
takes. That form appears nowhere in SMP.

A hashed query still answers with the name. The registrar records the plaintext
label when a name is registered, keyed by the hash of that label, so a router can
look up what the hash stands for without ever being told. The router is not
trusted for it: a client MUST check that the record names the name it asked
about, and reject the answer otherwise. A name registered without that record
answers `unknown`, which fails that check. What stays impossible is learning a
name that is *not* registered: there is nothing recorded to look up, so a name
someone is merely considering never becomes known.

**Server-side validation.** The names router parses `domain` as a
fully-qualified name (TLD required — bare labels are rejected) and forwards it
to the configured backing resolver, which is the source of truth for which
on-chain registry maps to each TLD.

The names router responds with either an `RNAME` response carrying the resolved
record, or an `ERR NAME` error whose subcode a client iterating across several
configured servers can act on distinctly:
The names router responds with either an `RNAME` response saying what it knows
about the name, or an `ERR NAME` error whose subcode a client iterating across
several configured servers can act on distinctly:

| Response | Condition | Client action |
|---|---|---|
| `RNAME` | record resolved | use it |
| `ERR NAME NOT_FOUND` | name not registered, unknown TLD, or malformed name | authoritative "no such name" — stop |
| `RNAME` | the router read the registry | use it |
| `ERR NAME NOT_FOUND` | the router could not read any answer for the name; below v22 also every name that does not resolve | stop, and do not read it as registrable |
| `ERR NAME NO_RESOLVER` | this router has no resolver (names role not enabled) | skip this server, try the next |
| `ERR NAME RESOLVER <detail>` | transient failure: backing resolver error (upstream 5xx, transport, timeout, decode) | transient — retry or surface, do not treat as "not found" |
| `ERR NAME RESOLVER <detail>` | the router cannot state an answer completely: no registrar or price oracle for the TLD, an unreachable chain, a transport failure, a timeout, a registration it could not date or resolve | surface `<detail>`; retry only if it reads as transient |

A client SHOULD NOT broadcast a `name` to further servers after a name-capable
router has answered (`NOT_FOUND` or `RESOLVER`), since that router has already
seen the lookup key; `NO_RESOLVER` discloses nothing about the name beyond the
fact that this router cannot resolve, so iterating past it is safe.

#### Name record response
#### Name response

The `RNAME` response carries a JSON-encoded record as the payload:
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.

```abnf
rname = %s"RNAME" SP json-bytes ; json-bytes consumes the remainder of the transmission
rname = %s"RNAME" SP registration
registration = %s"N" optTime optTime reserved SP json-bytes ; registered
/ %s"A" optTime pricing ; available
/ %s"R" reason ; reserved
optTime = %s"0" / (%s"1" 8*8 OCTET) ; Int64, big-endian, unix seconds
reserved = %s"0" / (%s"1" reason) ; absent = not held back
pricing = tiers basePrice minLabelLength
tiers = length *(2*2 OCTET 8*8 OCTET) ; label length -> US cents per year
basePrice = 8*8 OCTET ; US cents per year for every other length
minLabelLength = 2*2 OCTET ; characters
reason = %s"internal" / %s"trademark" / %s"community" / word
word = 1*32(%x21-7E) ; a reason this version has no word for
```

On `N` the two `optTime` fields are the expiry and the end of the grace period,
in that order, and `json-bytes` is the record, consuming the remainder of the
transmission. On `A` the `optTime` is when a post-grace surcharge decays to
nothing. The reason words are the same on the wire, in the backing resolver's
JSON and in a client's own API.

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.

| Answer | Meaning |
|---|---|
| `N` | registered: held by someone until the expiry, renewable by its owner alone until the end of grace. It always carries a record: where the owner set none, every field is unset and the resolver address is zero |
| `A` | available: held by nobody and registrable now, at `pricing` |
| `R` | reserved: held back by the registry and not registered |

`A` alone means registrable: a name the registry holds back answers `R`
instead, so a client has no flags to combine. A reservation on a name that *is*
registered is carried in the `reserved` field, 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
`A` with the ordinary price and the time its surcharge expires. The surcharge
itself is not carried: it decays continuously, so it cannot be quoted as a
purchase price. A client shows the ordinary price and counts down to when it
applies.

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 `R` 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. How long a
client goes on opening an expiring name is its own decision.

**Computing the price.** In US cents, for a duration in seconds:

```
price len duration = tier len * duration / 31536000
tier len = the entry for len in tiers, or basePrice when len is not in tiers
```

The registry's minimum registration is 28 days, a contract constant rather than
a per-deployment value, so it is specified here rather than sent. `tiers` 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 price quoted for a label the registry will refuse is
worse than no quote at all.

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 `N` with no expiry, grace or reservation, which is the only reason
those three fields are optional.

From v22 a client MUST NOT read `ERR NAME NOT_FOUND` as "registrable": only `A`
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.

A router that cannot state an answer completely MUST say so as `ERR NAME
RESOLVER <detail>` 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. Neither a registration nor availability may
be guessed: one would assert a registration nobody read, the other would offer a
name that may be held.

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. The word itself travels unchanged, so a later client can act on it
and a current one can show or log it, which is why the set is open rather than
an enumeration. A router sends at most one token of printable ASCII, since the
field ends at a space.

`json-bytes` MUST be a UTF-8 JSON object with the following schema:

| Field | JSON type | Constraints |
Expand All @@ -1521,10 +1645,10 @@ an empty string, not JSON `null` and not an absent key. Link fields
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.

The backing resolver filters records that are expired or otherwise unavailable
(the names router then returns `ERR NAME NOT_FOUND` to the client), so the wire
format carries no expiry field. Testnet-vs-mainnet status is derived from the
queried TLD rather than an in-record flag.
The record carries no expiry field of its own: `N` carries it alongside.
The backing resolver stops resolving a name once it is registrable by anyone, so
a record only ever accompanies `N`. Testnet-vs-mainnet status is derived
from the queried TLD rather than an in-record flag.

Receivers MUST tolerate extra unknown fields (forward-compatibility for future
field additions). Adding a required field is a breaking change requiring an
Expand Down
78 changes: 67 additions & 11 deletions scripts/resolver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,13 @@ uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + main
"simplexChannel": [],
"eth": null, "btc": "bc1q…", "xmr": "4ANz…", "dot": "139G…",
"owner": "0xd83b…", "resolver": "0x80fa…",
"status": "registered", // registered | grace | expired | unregistered | reserved | noResolver | unknown
"status": "registered", // registered | grace | auction | expired | unregistered | reserved | noResolver | unknown
"expires": 1780000000, // Unix seconds; when the registration ends
"graceEnds": 1787776000 // expires + GRACE_PERIOD; last moment the owner can renew
"graceEnds": 1787776000, // expires + GRACE_PERIOD; last moment the owner can renew
"auctionEnds": null, // when the premium reaches zero; only on `auction`
"premium": null, // decimal string, attoUSD; only on `auction`
"reasonCode": null, // only on `reserved`
"reason": null // only on `reserved`
}
```

Expand All @@ -147,9 +151,10 @@ name already knows when it expires. Both timestamps are Unix seconds, and
|---|---|
| `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 |
| `auction` | past grace, so anyone may register it — but at a premium, until `auctionEnds` |
| `expired` | lapsed, past grace, and past the auction — anyone may register it at the ordinary price |
| `unregistered` | never registered, and free to take |
| `reserved` | not registered, and held back — registration will be refused; the body carries a `reason` |
| `reserved` | not registered, and held back — registration will be refused; the body carries `reasonCode` and `reason` |
| `noResolver` | registered, but points nowhere |
| `unknown` | no `SNRC_REGISTRAR_<TLD>` configured, so status could not be read |

Expand All @@ -165,6 +170,49 @@ released*.
A subname reports the status of the 2LD above it, which is only as good as the
name it sits under.

### The post-grace auction

When grace ends anyone may register the name, but the price oracle adds a
premium that halves each day until it reaches zero. A name in that window
reports `auction` instead of `expired`, with `premium` (attoUSD as a decimal
string, since no JSON number holds a 256-bit integer) and `auctionEnds`.

`premium` is the surcharge alone: it depends only on when the registration
lapsed, so a labelhash query gets it, but the base price depends on the label's
length, which a hash does not carry. The client adds that.

The oracle comes from the controller's `prices()`, so no extra configuration is
needed. Its window is read from the chain; zero days switches the auction off.
Deployment constants - the grace period, the oracle and its curve - are cached
for `CONSTANTS_TTL` (5 minutes), so a retune shows up within that. Per-name
values and the decaying premium are read on every query.

**Upgrade this service before the routers that query it.** An older resolver
reports a name in its auction as plain `expired`, which routers read as
"available at the ordinary price" while the registrar charges the premium. It
also fails to decode a bracket label under a subname (`sub.[<hash>].tld`), which
routers from v22 send. The same wrong quote happens when the auction cannot be
read at all, so set `SNRC_CONTROLLER_<TLD>` wherever `SNRC_REGISTRAR_<TLD>` is.

### Why a name is reserved

`reserved` 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.

| `reasonCode` | Meaning |
|---|---|
| `unspecified` | reserved, with no reason recorded on chain |
| `trademark` | reserved to protect a trademark |
| `publicInterest` | reserved in the public interest |
| `offensive` | reserved as an offensive name |
| `internal` | reserved for SimpleX |
| `premium` | reserved as a premium name |
| `unknown` | a reason added to the contract after this resolver; still reserved |

A controller from before reasons existed stores a boolean; its `true` reads as
`unspecified`, so nothing needs migrating.

### Querying by labelhash

A client asking whether a name is free is usually about to register it, and
Expand All @@ -181,10 +229,15 @@ 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.

Read the answer from `status`. A name is free only when the body says
`unregistered`, which comes with a 404. Every other status means somebody holds
the name or held it recently. Watch out for `noResolver`: it is also a 404, but
the name is taken.
Only the second-level label is a registry key, and it is decoded wherever it
sits: `sub.[<hash>].testing` reaches the node `sub.name.testing` does. 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` or `auction` (410) — `auction` costs a premium on top. Every other
status means somebody holds the name. Watch `noResolver`: also a 404, but taken.

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
Expand Down Expand Up @@ -222,13 +275,16 @@ which is free to change.
```

The codes are `tldNotConfigured`, `notFullyQualified`, `unregistered`,
`reserved`, `grace`, `expired`, `noResolver`, `noSuchRoute` and
`reserved`, `grace`, `auction`, `expired`, `noResolver`, `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.
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

Expand All @@ -237,7 +293,7 @@ and urlopen puts the URL it failed on into the message.
| 200 | resolved (`status` is `registered`, or `unknown` when no registrar is configured) |
| 400 | TLD not configured, or not a fully-qualified name |
| 404 | `unregistered`, `reserved` or `noResolver` — the `status` field says which |
| 410 | registration lapsed — `status` says whether the owner can still renew (`grace`) or anyone may take it (`expired`) |
| 410 | registration lapsed — `status` says whether the owner can still renew (`grace`), anyone may take it at a premium (`auction`), or anyone may take it at the ordinary price (`expired`) |
| 502 | upstream RPC error / reth not synced |

### Configuring addresses
Expand Down
Loading
Loading