Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ Crypto:
no chain writes; the resolver path remains read-only. See
`plans/2026-08-05-eth-crypto-bindings.md`.

SMP protocol:
- `ROWN` command (v23): the names an address owns, and whether the account has
been used at all, so a recovered wallet seed can be scanned for the accounts
already in use. See `plans/2026-09-22-names-owned-by.md`.

# 6.5.1

Version 6.5.1.0
Expand Down
90 changes: 90 additions & 0 deletions plans/2026-09-22-names-owned-by.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Owned-by: the names an address owns

A recovered seed does not say how many of its accounts have been used. Until it
does, a restored device cannot take a fresh account without risking one its
owner already has, and cannot find the names it already holds. This adds the
question the protocol could not ask: what does this address own.

This is Workstream B of the SimpleX names v2 plan, after the Ethereum crypto
primitives. The client consumer is the wallet recovery scan in simplex-chat.

## What this delivers

```
GET /v2/owned-by/<address>?offset=N resolver, JSON OwnedNames
ROWN <address> <offset> -> ROWND SMP v23 (nameOwnedSMPVersion)
ownedSimplexNames agent, over ownedNames in the server
```

The agent returns the relay it used alongside the answer, so a caller can pass
it back as used and ask the next account elsewhere. A relay that cannot answer
at all - too old for ROWN, unreachable, or with no resolver of its own behind it
- is not the end of the lookup: it asks another, so a scan does not fail
wholesale on the one relay it happened to draw during a version rollout. Sending every account of a
seed to one relay would tell that relay the accounts belong to one wallet; the
scan therefore asks one account per relay, and `getNextNameServer` avoids the
hosts already used where the configured set allows. `proxiedSMPRelayVersion` is
pinned to the current version so ROWN can be proxied at all — a scan that falls
back to a direct session hands the relay its address with its IP. The pin is
necessary but not sufficient: under the default `SPMUnknown` proxy mode a
configured names server is a known host, so the scan reaches it directly, as
RSLV already does.

Enumeration is read off the ERC-721 registrar (`balanceOf`,
`tokenOfOwnerByIndex`, `labelOf`), not from registration logs: the token is the
name, so a name acquired by transfer counts. Each name is then answered with the
`NameResponse` resolving it gives, so the client that scans can list the names
and act on them without asking again — the reason the per-name cost is worth
paying. The registrar does not maintain enumeration on expiry, so a name past
its grace is still enumerated; it answers as available, naming nothing the
account holds, so it is left out while still counting towards `inUse`.

## What `inUse` actually answers

`inUse` is `holds a name now, or nonce > 0, or balance > 0`, on the one chain
the resolver is configured with. Holding a name is only one way for an account
to be in use: an account that was funded, or ever sent a transaction, is in use
with no name, and a scan that reads names alone hands out an account its owner
is already using. Within that scope the signal is sound — an EOA's balance can
only fall through its own transaction, which bumps the nonce, so "funded then
drained" still reads as used, and an EIP-7702 delegation leaves the authority's
nonce incremented, so it needs no separate code check.

It does not see:

- **Accounts holding only other tokens.** An EOA can hold ERC-20s or NFTs with
nonce 0 and no ether. ERC-20 has no reverse index, so answering this needs
`eth_getLogs` over Transfer topics for all history, which providers cap, or an
external indexer. Out of scope here.
- **Accounts used on another chain.** The same key is the same address on every
L2 and sidechain; this chain's nonce and balance stay 0.
- **History, as opposed to current state.** `balanceOf` is present ownership. A
name registered for an account by a controller — which leaves the owner at
nonce 0 — and later transferred away, or lapsed and re-registered by someone
else, leaves no trace.

The cost is bounded. A name-holding account is always found, so no account that
holds a name is ever mislabelled. A missed account matters only as a gap-limit
reset: the scan can stop up to `scanGapLimit` indexes early and not reach a name
beyond that. That is recovery incompleteness, recoverable by scanning again on a
better signal, not loss. In the other direction a dust transfer marks an account
used; the indexes come from hardened derivation with no published xpub, so they
cannot be enumerated to be dusted.

## What is deliberately absent

- **No token or indexer integration**, per the limits above.
- **Nonce and balance are not carried over SMP.** The resolver reports them, and
`OwnedNames` keeps only the names, the flag and the cursor, so a client cannot
yet see why an account is in use.
- **No caching, and no batching.** One owned-by is a `balanceOf` per configured
TLD, then for each name the same reads resolving it takes - roughly fifteen
`eth_call`s - plus the nonce and balance, all issued one at a time, so it is
forked on
the server the same way RSLV is and nothing is memoised. The page size trades
against both the relay's response cap and its timeout, and it bounds names per
registrar rather than per page, so a second TLD doubles a full page.
- **Enumeration is not atomic.** `balanceOf` and each `tokenOfOwnerByIndex` are
separate calls at `latest`, so a transfer between them can duplicate or skip an
entry, and successive pages can straddle blocks. Pinning every call to one
block number is the fix if it ever matters for a recovery scan.
53 changes: 51 additions & 2 deletions protocol/simplex-messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Version 21, 2026-07-05
- [Resolver commands](#resolver-commands)
- [Resolve name command](#resolve-name-command)
- [Name record response](#name-record-response)
- [Names owned by an address command](#names-owned-by-an-address-command)
- [Transport connection with the SMP router](#transport-connection-with-the-SMP-router)
- [General transport protocol considerations](#general-transport-protocol-considerations)
- [TLS transport encryption](#tls-transport-encryption)
Expand All @@ -86,7 +87,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 22. Versions 1-5 are discontinued. The version history:
This document describes SMP protocol version 23. Versions 1-5 are discontinued. The version history:

- v1: binary protocol encoding
- v2: message flags (used to control notifications)
Expand All @@ -109,6 +110,7 @@ This document describes SMP protocol version 22. Versions 1-5 are discontinued.
- 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
- v23: the names an address owns (ROWN command, ROWND response) — direct or forwarded via PFWD

## Introduction

Expand Down Expand Up @@ -1453,7 +1455,8 @@ companion REST resolver process (`scripts/resolver/snrc-resolve.py`) that
queries the SNRC contract on Ethereum; alternative backings (different chains,
DHT, etc.) are valid as long as they expose the documented HTTP shape (`GET
/v2/resolve/<query>` returning a `NameRegistration` on 200 for every
registration shape, 400 for unknown TLDs, 502 for upstream failures) or
registration shape, 400 for unknown TLDs, 502 for upstream failures, and from
SMP v23 `GET /v2/owned-by/<address>?offset=N` returning an `ownedNames`) or
substitute a different transport returning the same JSON. The resolver API is
versioned separately from this protocol: `/v1/resolve/<name>` returns a bare
`NameRecord` and is what relays before v22 call as `/resolve/<name>`.
Expand Down Expand Up @@ -1623,6 +1626,52 @@ The names router caps the resolver response it will accept
stays within the SMP proxied transmission budget of 16224 bytes; a response over
the cap is `ERR NAME RESOLVER`.

#### Names owned by an address command

`ROWN` asks which names an address holds, and whether the account has been used
at all, so a device restoring a wallet seed can find the accounts already in
use. It is unauthenticated and accepted direct or forwarded, as `RSLV` is, from
v23.

```abnf
rown = %s"ROWN" SP address offset
address = length "0x" 40HEXDIG ; EIP-55 checksummed, length-prefixed
length = 1*1 OCTET ; 42
offset = 4*4 OCTET ; Word32, network byte order: where to resume listing
```

```abnf
rownd = %s"ROWND" SP ownedNames
```

`ownedNames` is a UTF-8 JSON object consuming the remainder of the transmission.

| Field | JSON type | Constraints |
|---|---|---|
| `lastBlockTs` | number | the oldest block any read behind this answer saw |
| `names` | array | the names the address holds, each the `nameResponse` resolving it answers with |
| `inUse` | boolean | whether the account has been used at all |
| `nextOffset` | number | cursor to resume from, absent when the listing is complete |

Each name is answered in full, as `RSLV` answers it, so a client can list the
names and act on them without resolving each one again. Enumeration is not
maintained on expiry, so the registrar still enumerates a name past its grace;
it answers as `available` and names nothing the account holds, so it is left out
of `names` while still counting towards `inUse`.

As with `RNAME`, a router reads through a node of its own, which can lag. Every
name carries the block it was read at, and `lastBlockTs` is the oldest of those
and of the enumeration's own block, so a client can tell a badly lagging router
even from an answer that carries no names — which is the answer a recovery scan
acts on.

`inUse` is what the registry could see on its own chain — the account's nonce,
its balance, and every name it holds, which is not only the names listed here:
a later page of a held account still reports `inUse` true with an empty `names`. Holding a name is only one way for an account
to be in use, and an account used only for other tokens, or on another chain,
reads as unused, so a client MUST NOT treat `inUse` as false meaning the account
has never been used.

## Transport connection with the SMP router

### General transport protocol considerations
Expand Down
39 changes: 37 additions & 2 deletions scripts/resolver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ curl -s http://127.0.0.1:8000/v2/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 (`/v2/resolve`, `/resolve`, `/health`) |
| **resolver** | `127.0.0.1:8000` | SNRC REST (`/v2/resolve`, `/v2/owned-by`, `/resolve`, `/health`) |

## Caveats

Expand Down Expand Up @@ -121,12 +121,13 @@ 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
```

Three routes, versioned separately from the protocol so each only changes when
Four routes, versioned separately from the protocol so each only changes when
its own shape does:

| Route | Called by | Answers |
|---|---|---|
| `/v2/resolve/<query>` | routers from SMP v22 | a `NameRegistration` |
| `/v2/owned-by/<address>` | routers from SMP v23 | the names an address owns |
| `/resolve/<name>` | routers before SMP v22 | a name record, flat |
| `/health` | anyone | readiness |

Expand Down Expand Up @@ -165,6 +166,35 @@ label, so a hashed query cannot be answered with a name. See
A subname reports the expiry and grace of the 2LD above it, since that is what
bounds its lifetime. A subname nobody created reports as not registered.

### v2: `/v2/owned-by/<address>?offset=N`

The body is the SMP protocol's `OwnedNames`: the names the address holds, each
the same `NameResponse` `/v2/resolve` answers with, and `inUse`, whether the
account has been used at all. The router decodes it as is and forwards it, so
the fields are specified with the wire, in the **Names owned by an address
command** section of
[`protocol/simplex-messaging.md`](../../protocol/simplex-messaging.md).

Answering each name in full is what lets a caller list and act on them without a
second request for each. `lastBlockTs` is the oldest block any of the reads saw,
so an answer with no names still says how far behind the node was. Enumeration comes off the registrar's ERC-721 index, so
a name acquired by transfer counts; one past its grace is still enumerated but
answers as available, so it is left out while still counting towards `inUse`.

`offset` is the cursor to resume from, and it counts per registrar, so a page
holds up to `SNRC_MAX_OWNED` names for each configured TLD. `nextOffset` is the
next cursor, or null when the listing is complete. `inUse` covers every name the
address holds, not only the page, so a later page reports it true with an empty
`names`.

| Status | Meaning |
|---|---|
| 200 | the names the address holds, with `inUse` |
| 400 | `badAddress`, `badOffset`, `noRegistrarConfigured` |
| 502 | `upstreamError` |

Error bodies carry `address`, a fixed `error` code and a `message`.

### v1: `/resolve/<name>`

What routers before SMP v22 call. Its shape is unrelated to v2's: the record is
Expand Down Expand Up @@ -374,3 +404,8 @@ here.
To override any of them, set `SNRC_REGISTRY_<TLD>`, `SNRC_REGISTRAR_<TLD>` or
`SNRC_CONTROLLER_<TLD>` on the `resolver` service in `docker-compose.yml`, or
as env vars when you run the script directly.

`SNRC_MAX_OWNED` (default 16) caps the names `/v2/owned-by` returns per
registrar per page. A router will not read a body over 16000 bytes, and a client
cannot ask for a smaller page, so keep it times the number of configured TLDs
well under that.
Loading
Loading