mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 08:37:30 +00:00
plans: names docs follow the store API
This commit is contained in:
1 parent
0f75c5e225
commit
1917a3fe29
3 files changed
+717
-758
No files matched your search
@@ -1,8 +1,8 @@
|
||||
# SimpleX names store: simplex.domains
|
||||
|
||||
This is the spec of the names store. The store sells SimpleX names (`<label>.simplex`) at **simplex.domains**. In short:
|
||||
1. The buyer searches for a name. The page shows whether it is free, and at what price.
|
||||
2. The buyer pays by card, Bitcoin or Monero, or redeems a code.
|
||||
1. The buyer searches for a name. The page shows whether it is free, and at what price. The price is the chain's.
|
||||
2. The buyer pays by card, Bitcoin or Monero, or with a prepaid code entered on Check your code. An Ethereum buyer is sent to the dApp.
|
||||
3. The browser makes the name's wallet. The page shows it as an owner code ("your name code" on the page: `SN-` and 30 characters). The service registers the name to that wallet's address, and pays the fee and the gas.
|
||||
4. Nothing is held in custody. Redeeming the code in the app moves the name to the app's wallet.
|
||||
|
||||
@@ -10,6 +10,8 @@ Related documents:
|
||||
- Badges keep their own store at **badges.simplex.chat**.
|
||||
- The implementation plan is [`2026-10-09-names-web-store-plan.md`](2026-10-09-names-web-store-plan.md).
|
||||
- [`2026-10-13-names-sequence.md`](2026-10-13-names-sequence.md) shows every flow as a sequence of calls, with the gaps found.
|
||||
- [`2026-10-21-store-api.md`](2026-10-21-store-api.md) is the API contract, authoritative for every endpoint, field, error and limit.
|
||||
- ep's mockup of 2026-10-21 (artifact 86a64565) is the visual reference for every screen; the board below predates it.
|
||||
|
||||
The registration primitives (the building blocks for registering on chain) are Alain's `origin/ab/names-registration-primitives-sponsored` (`plans/2026-10-12-names-sponsored-registration.md`). They are written against ens-contracts PR #35 (`SimplexController`, `SimplexSponsor`, `SimplexResolver`). This store is the plumbing and the pages around them.
|
||||
|
||||
@@ -44,7 +46,7 @@ The app side is separate work:
|
||||
|
||||
- **Name**: `label.simplex`, a name on Ethereum, registered through the PR #35 contracts (an ENS fork).
|
||||
- **Owner code**: `SN-` and 30 characters. A 128-bit secret that only the buyer keeps. It is the only key to the name.
|
||||
- **Owner wallet** (owner address): the Ethereum key and address made from the owner code (BIP-39 seed, then `m/44'/60'/0'/0/0`). The name is registered to this address.
|
||||
- **Owner wallet** (owner address): the Ethereum key and address made from the owner code (BIP-39 seed from its entropy, then `m/44'/60'/0'/0/0`, as the app derives it). The name is registered to this address. The BIP-39 phrase is an internal step: the page never shows it, and there is no 12-word export.
|
||||
- **Commitment**: a hash of the name, the owner, a secret and the links. It hides the name while it is reserved, so nobody can see it and register it first.
|
||||
- **Commit, then reveal**: the commit puts the commitment on chain. At least 60 seconds later the page reveals the name, and the register can go through. A commitment lapses after 24 hours.
|
||||
- **Intent**: the owner's signature (`RegisterName`) that lets the service register the name for the owner.
|
||||
@@ -52,11 +54,12 @@ The app side is separate work:
|
||||
- **Registrar key**: the service's own Ethereum key. It sends every transaction and pays the gas (the network fee).
|
||||
- **Nonce**: a counter that stops a signature or transaction being used twice. The sponsor keeps one per owner for intents, the resolver contract one per owner for link edits, and the registrar key has its own transaction nonce.
|
||||
- **Sealed**: the name and links, encrypted with a key made from the owner code. The service stores it, so the owner code alone can restore an order in another browser.
|
||||
- **Resolver**: the lookup service (`snrc-resolve.py`) that answers whether a name is available, by its hash. Not to be confused with the **resolver contract** (`SimplexResolver`), which stores a name's links.
|
||||
- **RPC node**: the Ethereum JSON-RPC endpoint the service reads the chain from and sends its transactions to: a reth node with nimbus, or a hosted provider such as Alchemy. The service resolves names itself from the contracts, as the SMP relays and the app do from their own nodes. There is no resolver service.
|
||||
- **Resolver contract** (`SimplexResolver`, PR #35's `PublicResolver`): the contract that stores a name's links as text records.
|
||||
- **Links** (records): the SimpleX contact and channel links stored on a name.
|
||||
- **Prepaid code**: a code such as `SN7-…` that pays for a name of a given length or longer instead of money.
|
||||
- **Prepaid code**: a code such as `SN7-…` that pays for a name of a given length or longer, for its years, instead of money.
|
||||
- **Term start** (`termStart`): 1 January 2027. A name's paid years count from it; the days before it are free.
|
||||
- **Phase**: where a registration is: `unpaid` → `queued` → `committing` → `committed` → `registering` → `registered`, or `taken`, `failed`, `code_issued`.
|
||||
- **The lag**: right after a register, the resolver may not show the name yet; the service covers it for up to an hour.
|
||||
- **Long poll**: the page asks the service and the answer waits until something changes.
|
||||
|
||||
## 1. Scope
|
||||
@@ -66,8 +69,8 @@ This section lists what the store covers and what it leaves to other work.
|
||||
**In:**
|
||||
- The names store: search, checkout by payment or prepaid code, the wallet in the browser, registration with its steps, and the owner code.
|
||||
- Linking a contact address and channel to the name, at registration and later.
|
||||
- The flow opened from the app.
|
||||
- History, with "Add a code".
|
||||
- Check your code, for prepaid codes and name codes.
|
||||
- Your names, with the names and prepaid codes this browser holds.
|
||||
- The StoreService side: search, codes, orders, the registration worker, and issuing by the operator.
|
||||
- The names webapp, and the library it shares with the badges webapp. The webapp lives in the simplex.domains repo, which is a submodule here.
|
||||
- Deployment.
|
||||
@@ -75,6 +78,7 @@ This section lists what the store covers and what it leaves to other work.
|
||||
|
||||
**Out:**
|
||||
- The app side (redeeming codes, records in the app). The board draws two app screens in grey (A1, A2).
|
||||
- The web flow opened from the SimpleX app (`?name=&owner=`, L1–L3 on the board): dropped (owner, 2026-10-21). The app will buy names itself (#7530 §13.6).
|
||||
- Selling codes on the web.
|
||||
- Renewals, transfers, in-app purchase.
|
||||
- Refunds. Support handles them by hand.
|
||||
@@ -102,123 +106,117 @@ There are two webapps and one service:
|
||||
|
||||
Search tells the buyer whether a name is free and what it costs. The page sends only the name's hash, so the service does not learn the name at this point.
|
||||
|
||||
**The search bar (N1)** has a search icon, the name, a fixed `.simplex` tag and a Search button. Enter searches too.
|
||||
**The search field (N1)** holds the name, a fixed `.simplex` tag and a round search button. Enter searches too.
|
||||
- The page judges the name when the buyer searches, not while typing.
|
||||
- The rules line appears only after a mistake (N1b, N1c).
|
||||
|
||||
**The name rules.** The characters and hyphens follow #7390's `validLabel` (`Simplex.Chat.Names.Protocol` on `origin/ab/names-mvp-edits`). That code is not yet merged where the service builds. The names app's `names.ts` mirrors it. The length is checked separately (`invalid_name`). A name must have:
|
||||
**The name rules.** The characters and hyphens follow simplexmq's `nameLabelP`, which the service reaches by parsing a bare label as `SimplexDomain`. The names app's `names.ts` mirrors it. The length is checked separately (`invalid_name`). A name must have:
|
||||
- only `a-z`, `0-9` and `-`, after folding case;
|
||||
- each hyphen between letters or digits, so no leading, trailing or double hyphen;
|
||||
- at least 6 characters (the controller's `minCharLength`);
|
||||
- at most 63 characters (simplexmq's label limit).
|
||||
|
||||
The character and hyphen rules match simplexmq's `nameLabelP`. That parser also takes A–Z and lower-cases them after. #7390's `validLabel` refuses `--` only at positions 3–4. It is tightened to the rule above, because such a name would never resolve.
|
||||
|
||||
simplexmq lower-cases a name when parsing it (`SimplexDomain`'s `strP`, `labelHash`). `validLabel` admits only a–z. So the page folds upper case as the buyer types.
|
||||
`nameLabelP` also takes A–Z, and simplexmq lower-cases a name when parsing it (`SimplexDomain`'s `strP`, `labelHash`). The service takes only lower case, so the page folds upper case as the buyer types. #7390's `validLabel` (`origin/ab/names-mvp-edits`, unmerged) refuses `--` only at positions 3–4, so it is not used: such a name would sell but never resolve.
|
||||
|
||||
**Searching** works in three steps:
|
||||
1. The page validates the name and hashes it with keccak256.
|
||||
2. The page sends `GET /api/name/:hash?length=7`. The length sets the price. A length outside 6–63 is `invalid_name`.
|
||||
3. The service asks the resolver `GET {resolver_url}/v2/resolve/[<hash as 64 lowercase hex, no 0x>].<tld>`.
|
||||
3. The service reads the chain by the hash, over JSON-RPC to its RPC node:
|
||||
- the base registrar's `nameExpires(uint256(labelHash))` and `GRACE_PERIOD`, and the controller's `reservedNames(labelHash)`. The name is `registered` while its expiry plus the grace period is not past, else `reserved` when the reservation is set, else `available`;
|
||||
- for an available name, its price from the chain (§4);
|
||||
- for a registered name, the registry's `owner(node)` and `resolver(node)`, with `node = keccak256(namehash(tld) ‖ labelHash)` and the registry `baseRegistrar.ens()`;
|
||||
- when `resolver(node)` is the default resolver contract, its `text(node, "simplex.contact")`, `text(node, "simplex.channel")` and `nonces(owner)`.
|
||||
|
||||
What each side learns:
|
||||
- Neither request carries the name. For a registered name, the resolver still knows which name it was.
|
||||
- The request and the chain reads carry only the hash. The RPC node, or the hosted provider, sees which hashes are read. For a registered name the label is on chain anyway (`labelOf`).
|
||||
- The service learns a name only when a paid or code order reveals it (§6).
|
||||
|
||||
**The lag.** The resolver may report a hash as available while a registration here is:
|
||||
- `registering`, or
|
||||
- `registered`, with `registered_at` within the last hour.
|
||||
**Names being registered here.** The chain still reports a hash as available while a registration here is `registering`: its register is sent but not mined. Then search answers `registered{entities: [], tld}`, with no owner, and orders refuse the name with `name_taken`. The worker sets `registered` only `confirmations` blocks after the register's receipt, so from then on the service's own chain reads show the name. Otherwise the chain is believed, so an expired name is free again.
|
||||
|
||||
Then search answers `registered{entities: [], tld}`, with no owner, and orders refuse the name with `name_taken`. The hour is a margin for the resolver's node lagging the chain, since the resolver itself reads `latest` with no cache. After the hour the resolver is believed, so an expired name is free again.
|
||||
**Failed chain reads.** Search answers `provider_unavailable` when a chain read fails: the RPC node does not answer, or answers an error.
|
||||
|
||||
**Resolver failures.** Search answers `provider_unavailable` for each of these:
|
||||
- the resolver's 502 `upstreamError`, for a failed status, owner or resolver read, or a lost RPC connection (for a reverted text or address read, the resolver answers empty instead);
|
||||
- 400 `tldNotConfigured`;
|
||||
- 502 `unknown`, without the registrar address;
|
||||
- 502 `noPriceOracle`, without a readable price oracle or premium;
|
||||
- a resolver that does not answer;
|
||||
- a failed chain read.
|
||||
|
||||
The resolver's 502 `labelNotRecorded` means a label the base registrar never recorded. It cannot occur on PR #35, where every registration records its label. Search would answer it `provider_unavailable` too.
|
||||
|
||||
The order, `/api/name/register` and the `/name` retarget answer the same resolver failures with `name_check_unavailable` (503). They create or change nothing. They need their own error because `provider_unavailable` from invoice creation means an unavailable payment method (N3a).
|
||||
The order, `/api/name/register` and the `/name` retarget answer a failed chain read with `name_check_unavailable` (503). They create or change nothing. They need their own error because `provider_unavailable` from invoice creation means an unavailable payment method (N3a).
|
||||
|
||||
| Answer | The page shows |
|
||||
|---|---|
|
||||
| `available` | N2: available, its price for 2 years (or $0 when an applied code covers it, K2), and Register |
|
||||
| `registered`, with links | N2a: "Used by", one card per `simplexContact`/`simplexChannel` link, with picture, display name, link and Open in SimpleX (deep link) |
|
||||
| `registered`, no links | N2b: taken, pointing nowhere (a name registered without links reads like this until its owner sets them, and so does one this service is registering or registered within the last hour: for that one the answer has no records, owner, resolver, `recordsNonce` or chain id, and Edit links takes the nonce, chain id and resolver from the registration view, only once it is `registered`) |
|
||||
| `available` | one row: the name, "$100 / 2 years" and Register; $0 and the code's years when a kept prepaid code covers it, with a line when the name is shorter than the code allows |
|
||||
| `registered`, with links | one row: the chats the name opens as icons (the app's default icon for a contact, channel, business or bot, or the profile picture), then "Taken", or "Your name" when this browser holds it. Each icon opens a popup modelled on the app's connection-plan alert: picture, display name, `@name.simplex` or `#name.simplex`, full name, a channel's subscriber count, the link's QR on desktop, Open in SimpleX and Copy link |
|
||||
| `registered`, no links | "Taken" (a name registered without links reads like this until its owner sets them, and so does one this service is registering: for that one the answer has no records, owner, resolver, `recordsNonce` or chain id) |
|
||||
| `reserved` | N2c: reserved, with "Contact the SimpleX team" |
|
||||
| no answer, an error | N2d (503) |
|
||||
| the rate limit | N2e (429) |
|
||||
|
||||
**Taken names.** The service opens each link with its own chat core.
|
||||
- For short links, `APIConnectPlan` returns the link's display name and picture from its link data, without connecting.
|
||||
- A full link has no profile, so its card shows the link only.
|
||||
- Each fetch has a 3 s timeout, its own rate limit and a short cache.
|
||||
**Taken names.** The service opens each short link in the records with its own chat core (`APIConnectPlan`), which reads the link data without connecting. Each chat in the answer has:
|
||||
- `kind`: `contact`, `business` (the link data's `business`, or the profile's `peerType`), `bot` (`peerType` bot), or `channel` for a channel link;
|
||||
- the display name, the full name and the picture, if any;
|
||||
- a channel's subscriber count (`publicMemberCount`);
|
||||
- the link and its deep link.
|
||||
|
||||
**Below the search** the page shows:
|
||||
- how it works;
|
||||
- what a name does (a name pointing at an address and a channel);
|
||||
- the investor card: Wefunder's sentence and link with a names amount (a placeholder for now), and **Check your code** (§7).
|
||||
Search fetches and link-preview fetches have separate service-wide bounds (4 and 2 at a time), a 3 s timeout each that also bounds the wait for a slot, and a 10-minute cache. The hosts are the link's servers (the authority of an `https://` link, or the server hosts inside a `simplex:/` link). A public IP literal passes, since SimpleX servers may be addressed by IP. Every host of the link is checked before the chat core fetches: an IP literal as it is, a name by resolving it; a private, loopback, link-local, unique-local, CGNAT, unspecified or multicast address, a `.onion` host or an unresolvable name means no fetch. A link with several hosts is fetched only if all pass. A link not fetched stays valid: its profile is `unavailable`. The check is best effort, since the chat core resolves again when it connects (DNS rebinding remains possible); an egress rule in the deployment denies the service's traffic to private ranges. A fetch that fails or gets no slot in time gives `profile: unread`. Without a profile the chat shows with the link's kind and the default icon. The answer also carries `gracePeriod` (the registrar's 90 days), so the page says when an expired name can be registered again.
|
||||
|
||||
**The page around the search** is the simplex.domains landing: the headline "Your SimpleX domain" and its subtitle. Every page closes with "Invest in SimpleX Chat to get a name for 5 years." and a link to Wefunder.
|
||||
|
||||
## 4. Price and term
|
||||
|
||||
This section sets how long a name is registered and how its price is chosen.
|
||||
|
||||
- **Term.** A paid name is registered for 2 years, fixed.
|
||||
- This is the controller's minimum (730 days, `MIN_REGISTRATION_DURATION`).
|
||||
- Nothing is charged for years whose price cannot be known yet.
|
||||
- A code registers for its own `years`. Investor codes get 5 or 3, as simplex.domains says today.
|
||||
- A year on chain is 365 days (31,536,000 seconds, as simplexmq's `protocol/simplex-messaging.md` prices it). So the duration is `years × 365` days from registration. A name registered on 10 October 2026 runs to 9 October 2028.
|
||||
- **Price.** The price is the service's answer for the searched name. The page shows no price list.
|
||||
- The rule is internal. A seeded table sets a price per length. Per-name entries, keyed by the name's hash, can override it. Prices are insert-only.
|
||||
- The starting values, for the 2 years, are $100 for 8+ letters, $200 for 7 and $400 for 6. They are in `Catalog.defaultNamePrices`.
|
||||
- **Checkout** carries the shown price. The service prices the name again. If the price moved, it answers `catalog_changed` with the new price (N3b).
|
||||
- **Term.** A paid name is paid for 2 years (the service's `[names] years`), the controller's minimum (730 days, `MIN_REGISTRATION_DURATION`).
|
||||
- A prepaid code pays for its own `years`. Investor codes get 5.
|
||||
- `years` is at least 2, for the setting and for every code: below 730 days the controller refuses the commitment, and the free days end on 1 January 2027. The service refuses to start with less, and the operator's commands refuse to issue such a code.
|
||||
- The term starts on 1 January 2027 (owner, 2026-10-20), to give time to build the app's import. The contract registers for a duration from the block's time, so the page adds the days until then: `duration = years × 365 days + max(0, termStart − now)`. The flat 30 bonus days are gone.
|
||||
- A year on chain is 365 days (31,536,000 seconds, as simplexmq's `protocol/simplex-messaging.md` and the price oracle count it). The page shows the real end: for an order before 1 January 2027, 2 years end on 31 December 2028, 5 years on 31 December 2031 (owner, 2026-10-21).
|
||||
- The service owns `termStart` (`[names] term_start`), gives it in the search answer, and checks every order's duration within an hour of `max(0, termStart − now)`.
|
||||
- The duration is inside the commitment, so a name registered later than its order ends later by that delay.
|
||||
- Check your order shows the term, "Free until 1 January 2027" with the days left, and the end date `max(termStart, now) + years × 365 days`. Once registered, the page shows the view's `expiresAt`, later by the time from the order to the register.
|
||||
- **Price.** Prices come from the chain (owner, 2026-10-20). `SimplexPriceOracle` holds cents a year per label length, with a base for the lengths it does not list. The controller exposes it as `rentPriceUSD(label, duration)`, prorated by the second.
|
||||
- The service quotes `rentPriceUSD` for the paid years only, with a placeholder label of the name's length: the oracle reads only the length, and the RPC node never sees the label before the reveal.
|
||||
- The launch list (owner, 2026-10-21), set with `setPrices`: base 5000 cents a year, 7 letters 10000, 6 letters 20000. So $100 for 8+ letters, $200 for 7 and $400 for 6, for 2 years. There are no per-name prices.
|
||||
- The free days cost the buyer nothing. The sponsor's allowance pays their prorated price at registration.
|
||||
- **The order** carries the shown price. The service reads the chain's again. If it moved, it answers `catalog_changed` with the new price (N3b). Once the order exists, its price is fixed.
|
||||
|
||||
## 5. The wallet and its owner code
|
||||
|
||||
The browser makes a new wallet for each order. Its owner code is the only key to the name. This section says how the page builds the wallet, keeps the code safe, and what the service sees.
|
||||
|
||||
At checkout (N3), the browser makes the name's wallet:
|
||||
On the name code step, after Links and before Check your order, the browser makes the name's wallet:
|
||||
|
||||
1. It takes 128 bits from `crypto.getRandomValues`.
|
||||
2. It makes the owner code: `SN-` and 30 Crockford base32 characters. They hold the entropy and the first 22 bits of its SHA-256 (WebCrypto). The code is shown in six groups of five, e.g. `SN-DWPHS-6JBFR-ZR1MA-T5HQ9-P17QM-F5GM5`.
|
||||
3. It makes the BIP-39 phrase with that entropy, and its seed: PBKDF2-SHA512, 2048 rounds, no passphrase (WebCrypto).
|
||||
3. It makes the BIP-39 seed: the BIP-39 phrase with that entropy, then PBKDF2-SHA512, 2048 rounds, no passphrase (WebCrypto). The phrase is only this internal step and is never shown. The derivation must stay as it is, because the app derives the same wallet from the code.
|
||||
4. It derives the key by BIP-32 to `m/44'/60'/0'/0/0`. HMAC-SHA512 comes from WebCrypto, and secp256k1 from vendored `@noble/secp256k1`.
|
||||
5. It makes the EIP-55 address, with keccak-256 from vendored `@noble/hashes`.
|
||||
6. It derives the commitment secret, `secret = HMAC-SHA256(seed, "simplex.domains commitment " + label)` (WebCrypto). So the code and the name are enough to reveal.
|
||||
7. It derives the sealing key, `HMAC-SHA256(seed, "simplex.domains sealed")`.
|
||||
- The page encrypts the JSON `{label, contact, channel}` with AES-256-GCM. Each key holds its record value: its links joined with `;`, or "" without links.
|
||||
- The page encrypts the JSON `{label, contact, channel}` with AES-256-GCM. Each key holds its link, or "" without one.
|
||||
- The page sends the result with the order as `sealed` (unpadded base64url). So the code alone rebuilds the commitment in another browser.
|
||||
- The service refuses a `sealed` longer than 11095 characters with `bad_request`. That is the size of a 63-character label and two values at the link bound.
|
||||
- The order, register, retarget and reveal endpoints read bodies up to 16384 bytes, above today's 8192.
|
||||
|
||||
How the code and the address are handled:
|
||||
|
||||
- **One open invoice per commitment.**
|
||||
- An order whose commitment already has an open invoice gets that invoice when the order matches it. This covers a resend after a lost response.
|
||||
- Otherwise the order gets `400 {"error": "bad_request", "invoiceId": "…"}`, and the page opens that invoice.
|
||||
- The buyer uses Get a new invoice when the card form does not load or the payment details are unavailable. It cancels the old invoice first. The page orders again only after the cancel succeeds. The cancel answers lead to:
|
||||
- **One live order per wallet and name, disclosed only to its owner** (contract sections 1 and 5).
|
||||
- Every order is signed by the owner code's key (EIP-191 over `simplex.domains order <labelHash> <length> <duration> <commitment> <sha256(sealed)> <timestamp>`), so nobody orders in another wallet's name.
|
||||
- An exact resend, every field equal to the stored order (including `sealed`, the method and price, or the code), gets that order. This covers a lost response, however late.
|
||||
- Any other order that meets a live order of the same commitment, or of the same owner and label hash, gets `409 order_exists`, which names nothing. The page then finds its order with the signed `find` and opens it. A commitment is public on chain once committed, so it alone proves nothing.
|
||||
- A registration is live while it has an open invoice, or is past `unpaid` and not `taken` or `code_issued`. Orders run one at a time per label hash, and unique constraints back the rule.
|
||||
- The buyer uses Get a new invoice when the card form does not load or the payment details are unavailable. It cancels the old invoice first. The page orders again only after the cancel succeeds, with the current duration, so with a new commitment. The cancel answers lead to:
|
||||
- `funded`: the page keeps the old order.
|
||||
- `not_open`, a `200` `paid`, or a `200` `expired` with payment marks: the page re-reads the invoice. Paid, with its registration past `unpaid`: the page shows its order. Paid, with the registration still `unpaid`: the page orders again. Otherwise an invoice holding a payment is kept, and one expired with nothing received orders again.
|
||||
- a `200` still `open`: the page shows `CANCEL_STILL_OPEN` and keeps the old order.
|
||||
- `provider_unavailable`: the page keeps the old order. The service also answers this when Stripe refuses to cancel a card already processing.
|
||||
- A crypto payment the service has not yet read when the cancel goes through becomes a refund case. The poller alerts on it.
|
||||
- An order whose commitment already has a registration past `unpaid` (paid, or by code) gets that registration's order instead. For a code registration the answer has `invoiceId` null and the registration.
|
||||
- `/api/name/register` does the same. It also refuses with the same `bad_request` and `invoiceId` while the commitment has an open invoice.
|
||||
- Both checks run before the resolver. So a resend never meets its own registration as `name_taken`.
|
||||
- **One order at a time (N3e).** This browser's open order may have a submitted card payment awaiting confirmation. Then checkout's Continue is off until the payment settles or expires. The badge checkout refuses a second order in the same case (it shows no Pay button). An open invoice not yet paid only shows a link to the waiting order, and Continue stays on.
|
||||
- **Before paying (N3h).** Continue at checkout first shows the code, for paid and prepaid-code orders alike. It shows the code with Copy and a checkbox, "I have saved my code". The checkbox enables Continue. Only then is the order created. After this point, a crash or cleared storage loses nothing that the code cannot rebuild.
|
||||
- **The code** is also stored in this browser before the order is created. When storage is unavailable, checkout warns: "This browser will not keep your name code" (N3f). The badge checkout does the same for codes. N7a then reminds the buyer that the saved copy is the only one.
|
||||
- These checks run before the chain read. So a resend never meets its own registration as `name_taken`.
|
||||
- **One order at a time (N3e).** This browser's open order may have a submitted card payment awaiting confirmation. Then Check your order's Pay is off until the payment settles or expires. The badge checkout refuses a second order in the same case (it shows no Pay button). An open invoice not yet paid only shows a link to the waiting order, and Continue stays on.
|
||||
- **Before paying (N3h).** The name code step comes before Check your order, for paid and prepaid-code orders alike. It shows the code hidden, with Show and Copy, "Private windows and Tor Browser forget it when closed", and a checkbox, "I have saved my name code", which enables Continue. The order is created only from Check your order. After this point, a crash or cleared storage loses nothing that the code cannot rebuild.
|
||||
- **The code** is also stored in this browser before the order is created. When storage is unavailable, the name code step warns: "This browser will not keep your name code" (N3f). The badge checkout does the same for codes. N7a then reminds the buyer that the saved copy is the only one.
|
||||
- **Restoring (N7c).**
|
||||
- An order link opened where the code is not stored (N7b) asks for the code.
|
||||
- "Your names" offers "Restore with a name code". It finds the code's orders with `POST /api/registration/find`, signed by the code's key. It does not ask for the name.
|
||||
- Check your code takes a name code too. It finds the code's orders (the 50 newest) with `POST /api/registration/find`, signed by the code's key, names them ("Name code of alicebakery.simplex") and adds them to Your names. It does not ask for the name. There is no separate Restore screen.
|
||||
- The page then:
|
||||
1. derives the owner and the sealing key;
|
||||
2. checks that the owner matches the registration;
|
||||
3. opens `sealed` for the label and the links;
|
||||
4. derives the secret from the label, rebuilds the commitment and checks that it matches;
|
||||
4. derives the secret from the label, rebuilds the commitment with the view's `duration` and checks that it matches;
|
||||
5. carries on. It reveals if the registration waits for that. For a `code_issued` registration it re-derives and shows the taken code. Otherwise it shows the registered name.
|
||||
- **What the service gets.**
|
||||
- With the order: the address, the name's hash and length, the commitment, and `sealed` (which it cannot open).
|
||||
@@ -226,12 +224,9 @@ How the code and the address are handled:
|
||||
- Never the code.
|
||||
- **The path** is #7475's account 0. Tests pin the `abandon … about` vector to #7475's address for account 0, `0x9858EfFD…aEda94`. Tests also pin the branch's owner code vectors.
|
||||
|
||||
**After registration (N7)** the page shows the code again, with:
|
||||
- Copy;
|
||||
- a QR for the app to scan;
|
||||
- "Show it as 12 words, for another wallet" (its BIP-39 phrase).
|
||||
**After registration (N7)** the page shows "alicebakery.simplex is yours", the end date, and "Use in the app": the steps that work in the app today (set the name in Your SimpleX address, or in the channel's profile). Below them, the name code, hidden until shown, with Copy: it owns the name and edits its links.
|
||||
|
||||
N7 says plainly that the code owns the name and that nobody else has a copy. History keeps the code, behind "Show code".
|
||||
The page offers no other form of the code: no 12-word phrase, and no import into another wallet. Your names keeps the code, behind "Name code".
|
||||
|
||||
**Redeeming in the app** (A1, A2) is not built here. The app reads the code. The name then moves to the app's wallet on the base registrar (EIP-712 domain `SimplexNames`) in two steps:
|
||||
1. The code's wallet signs `ApproveName(from, to, tokenId, ephemeralPubKey, viewTag, nonce, deadline)` with `nonce = nonces(from)`, an empty key and a zero tag. The service relays `approveWithSig`.
|
||||
@@ -240,41 +235,37 @@ N7 says plainly that the code owns the name and that nobody else has a copy. His
|
||||
After the move:
|
||||
- The registry node follows the token. The records stay.
|
||||
- The app finds a code's names on chain. The registrar is enumerable (`balanceOf`, `tokenOfOwnerByIndex`, `labelOf`, `nameExpires`).
|
||||
- A BIP-44 wallet that imports the code's BIP-39 phrase holds the name. It can edit the name's records, paying gas.
|
||||
- Moving the name still needs the recipient to claim it. PR #35's `transferFrom` reverts `RecipientMustBeCaller` unless the recipient or a registrar controller calls.
|
||||
|
||||
**One wallet per order.**
|
||||
- A taken order retargeted to another name (N8a) keeps its address. So the same code owns the new name.
|
||||
- Get a new invoice after an expired one also reuses the address. So `find` can list that expired `unpaid` registration too. The page opens the registration that is not `unpaid`, or else the newest.
|
||||
|
||||
**Opened from the app (L1–L3).** The app's link carries the name and the app's own owner address: `simplex.domains/#/?name=<label>&owner=<address>`.
|
||||
- The page shows that address on the search and at checkout. So a crafted link cannot pass unnoticed.
|
||||
- The page makes no wallet. So the order carries no `sealed`, and the page offers no links and no "I have a code". The app sets links and redeems codes itself.
|
||||
- The flow ends with "Back to SimpleX".
|
||||
- Open: the owner must sign the `RegisterName` intent, and here the owner is the app's wallet (§14).
|
||||
- Get a new invoice after an expired one also reuses the address, with a new commitment. So `find` can list that expired `unpaid` registration too. The page opens the registration that is not `unpaid`, or else the newest.
|
||||
|
||||
## 6. Registration
|
||||
|
||||
This section follows a name from the order to the chain. The service commits, waits for the page to reveal the name, then registers it to the buyer's address. It also covers a name that is taken and a step that fails. The terms used are in [Words used here](#words-used-here).
|
||||
|
||||
1. **Orders.** The page sends the name's hash and length, never the name. With them it sends the owner and a commitment it builds itself: `makeCommitment(Registration{label, owner, duration, secret, resolver, data = one setText call per key that has links, its links joined with ";" (empty without links), reverseRecord = 0, referrer = 0})`.
|
||||
1. **Orders.** The page sends the name's hash and length, never the name. With them it sends the duration, the owner and a commitment it builds itself: `makeCommitment(Registration{label, owner, duration, secret, resolver, data = one setText call per key that has a link (empty without links), reverseRecord = 0, referrer = 0})`.
|
||||
- The secret is derived from the owner code (§5).
|
||||
- `duration` is 2 years, or the prepaid code's years, at 365 days a year.
|
||||
- `duration` is `years × 365 days + max(0, termStart − now)`, for the paid years or the prepaid code's (§4). The service checks it within an hour, stores it and returns it in the registration view.
|
||||
- The resolver contract comes from the search answer.
|
||||
- This is the contract's struct, as the branch's `registrationTuple` encodes it from its `Registration` (label, TLD, owner, duration, secret, resolver, contact and channel links).
|
||||
|
||||
The two kinds of order:
|
||||
- Paid: `POST /api/invoice {kind: "name", labelHash, length, price, method, owner, commitment, sealed}`. Settlement moves the registration to `queued`.
|
||||
- By prepaid code: `POST /api/name/register {labelHash, length, owner, commitment, sealed, code}`, with no invoice. A repeat answers the existing registration. The service finds it as a registration whose `name_prepaid_code_id` is this code, with the same owner and commitment, in any phase. The code is reserved for the registration and spent when the registration succeeds.
|
||||
- Paid: `POST /api/invoice {kind: "name", labelHash, length, duration, price, method, owner, commitment, sealed, timestamp, signature}`. Settlement moves the registration to `queued` only while it is still live. A new order from the same wallet and name closes an overdue one (its invoice past its expiry time) in its own transaction, and a payment that then arrives for the closed one is a late payment, refunded by support.
|
||||
- By prepaid code: `POST /api/name/register {labelHash, length, duration, owner, commitment, sealed, code}`, with no invoice. The page sends it from Check your order for a code kept on Check your code. A repeat answers the existing registration. The service finds it as a registration whose `name_prepaid_code_id` is this code, with the same owner and commitment, in any phase. The code is reserved for the registration and spent when the registration succeeds.
|
||||
|
||||
Both kinds refuse:
|
||||
- a length outside 6–63, with `invalid_name`;
|
||||
- with `name_taken`, a name the resolver reports registered or reserved;
|
||||
- with `name_taken`, a hash the resolver reports available while a registration here is `registering`, or `registered` with `registered_at` within the last hour. This is the lag (§3): a margin for the resolver's node lagging the chain, since the resolver itself reads `latest` with no cache. After the hour the resolver is believed, so an expired name is free again.
|
||||
- with `name_taken`, a name the chain shows registered or reserved;
|
||||
- with `name_taken`, a hash the chain shows available while a registration here is `registering` (§3);
|
||||
- with `name_check_unavailable`, a failed chain read;
|
||||
- with `bad_request`, a duration outside the hour's slack.
|
||||
|
||||
**Closed invoices.** An invoice that expires or is canceled leaves its registration `unpaid`.
|
||||
- A late payment of that invoice does not queue the registration. Support refunds it. This is because Get a new invoice reuses the commitment.
|
||||
- Get a new invoice makes a new order for the same name and wallet.
|
||||
- A late payment of that invoice does not queue the registration. Support refunds it. This is because a new order of the same wallet for the name may have been paid meanwhile.
|
||||
- Get a new invoice, on an expired invoice, makes a new order for the same name and wallet.
|
||||
- A canceled invoice offers Order the name again instead. It leads to the name's search result, where the buyer orders anew and chooses how to pay. Nothing is ordered until then.
|
||||
|
||||
**Cleanup.** 30 days after its invoice closed, an `unpaid` registration is deleted with its `name_invoices` and `invoices` rows, in one transaction.
|
||||
- A registration whose invoice received any payment, partial or late, is not deleted. It stays for support.
|
||||
@@ -283,7 +274,7 @@ This section follows a name from the order to the chain. The service commits, wa
|
||||
- The cleanup's read is the backstop. A payment whose webhook was lost is still recorded and alerted. If the provider does not answer, the registration is kept until the next daily run.
|
||||
2. **No hold.** The service cannot hold a name it has not seen, so it holds none. Two orders may name the same hash. Whichever registers first wins. The other ends `taken`, with its payment kept.
|
||||
3. **The worker.** There is one worker per registration, resumed after a restart. It uses the branch's chain client (`sendCommit`, `sendRegister`):
|
||||
- JSON-RPC to an RPC URL: the reth node on the resolver host, by #7530's decision D5;
|
||||
- JSON-RPC to the RPC node, `[chain] rpc_url`: a reth node, or a hosted provider such as Alchemy. The same node serves the service's chain reads (#7530's decision D5 named the reth node on the resolver host);
|
||||
- a dry run of every transaction;
|
||||
- EIP-1559 fees;
|
||||
- transactions signed by the registrar key.
|
||||
@@ -294,8 +285,8 @@ The worker's phases, what happens in each, and the page that shows it:
|
||||
|---|---|---|
|
||||
| `queued` (paid, or prepaid code reserved) → `committing` | Send `commit(commitment)`, the page's commitment, from the registrar key | N6 |
|
||||
| `committed` | Mined: a commit's outcome (its dry run, or its receipt's `status`) is checked against `commitments(commitment)`, since before the reveal there is no label: a commit that reverts, in its dry run (nothing sent) or mined with `status` 0, while its commitment is live (`UnexpiredCommitmentExists`) makes the registration `committed`, with the reveal times from `commitments()`. One that reverts with no live commitment (out of gas, or a commitment that lapsed by the read) is sent (again, if it was mined), a second later when the read falls at `commitments(commitment)` plus `maxCommitmentAge` itself, as the retry path waits at `revealBefore`; when the commitment had lapsed, `label`, `secret`, `records`, `intent_*`, `error`, the transaction hashes and blocks, `reveal_after` and `reveal_before` are also cleared before the new commit is signed, as retry does after `revealBefore`, so the page signs again. Then wait `minCommitmentAge`, read from the controller (60 s in PR #35's deployment), and the page's reveal, `POST /api/registration/:id/reveal {label, secret, records, nonce, deadline, signature}` | N6a |
|
||||
| `registering` | Send `registerWithCredit(registration, nonce, deadline, signature)` to the sponsor, which spends the registrar's allowance; from now search answers `registered` for this hash | N6b |
|
||||
| `registered` | A receipt with `status` 1 and `ownerOf(tokenId) == owner`, + 3 blocks; `registered_at` and `expires_at` recorded. On `status` 0 the worker reads `ownerOf(labelHash)` first (the owner holding it is `registered`), then `available(label)` to choose `taken` or `failed` | N7 (or L3) |
|
||||
| `registering` | Send `registerWithCredit(registration, nonce, deadline, signature)` to the sponsor, which spends the registrar's allowance; from now search answers `registered` for this hash. A register reverting `InvalidNonce` (another registration by the same owner spent the nonce first) clears `label`, `secret`, `records` and `intent_*` and returns to `committed`, so the page signs again with the next nonce | N6b |
|
||||
| `registered` | A receipt with `status` 1 and `ownerOf(tokenId) == owner`, + 3 blocks; `registered_at` and `expires_at` recorded. On `status` 0 the worker reads `ownerOf(labelHash)` first (the owner holding it is `registered`), then `available(label)` to choose `taken` or `failed` | N7 |
|
||||
| `taken` | The name is no longer available: a register that reverts, in its dry run (nothing sent) or mined with `status` 0, with `NameNotAvailable` or `NameReserved` decoded, or with any other revert while `available(label)` is false, unless `ownerOf(labelHash)` is the owner, which means this registration already went through (a resumed worker or a duplicate send) and is `registered`, recording `registered_at` and `expires_at` and spending its prepaid code (the sponsor checks the intent and the allowance before availability, so a taken name can revert with those first) | N8 |
|
||||
| `failed` | Any other failure after the service's retries (an `InsufficientAllowance` among them, which also alerts the operator), or a commitment not revealed in time; Try again, `POST /api/registration/:id/retry` (only from `failed`, else `bad_request`), goes in order: a lost confirmation (a mined `register_tx`, any of its hashes, with `status` 1, or `ownerOf(labelHash)` being the owner; a revert counts as not) makes it `registered`, recording `registered_at` and `expires_at` and spending its prepaid code (the operator is alerted if the sweep gave that code to another registration meanwhile); a released code now used, revoked, expired or held elsewhere answers that code error and leaves it `failed`; otherwise it requeues at no charge, re-reserving a released code, clearing `failed_at`, `error` and the register's transaction hashes and blocks, and `label`, `secret`, `records` and `intent_*` when the owner's sponsor nonce has moved, so the page reveals again. A commitment reading 0 is committed again (one still pending is waited for, and a commit reverting `UnexpiredCommitmentExists` means `committed`, with the reveal times from `commitments()`); otherwise, with `revealBefore` as `commitments(commitment)` plus `maxCommitmentAge` (`reveal_before` may be null), with at least `revealMargin` left before `revealBefore` (time for the reveal and the register) the commitment is reused, going to `committed` with the reveal times from `commitments(commitment)`, and `label`, `secret`, `records`, `intent_*` and the commit's transaction hashes and blocks are cleared when the commitment's time is not the block time of a commit of ours that mined with `status` 1, since anyone can commit a lapsed commitment again; with less, the worker waits until a second after `revealBefore` and goes on as after it; at it the worker waits a second; after it `label`, `secret`, `records`, `intent_*`, `error`, the transaction hashes and blocks, `reveal_after` and `reveal_before` are cleared and then the commitment is committed again, so the page signs again | N9 |
|
||||
|
||||
@@ -306,18 +297,16 @@ The worker's phases, what happens in each, and the page that shows it:
|
||||
- `nonce` is `SimplexSponsor.nonces(owner)`, which is 0 for a new owner code.
|
||||
- `deadline` is the view's `revealBefore`, when the commitment lapses anyway.
|
||||
|
||||
The service checks that:
|
||||
- the label is valid, hashes to `labelHash` and has `length`. So its price is the registration's `price_id` row, fixed at order or retarget (or the prepaid code covers it);
|
||||
- each link is a SimpleX link of its kind and within the link bound;
|
||||
- the resolver is the default one.
|
||||
|
||||
It rebuilds `data` and the commitment. Then it checks:
|
||||
The service checks, in order, that:
|
||||
- the label is valid, hashes to `labelHash` and has `length`, with at most one link per key. So its price is the registration's `price`, fixed at order or retarget (or the prepaid code covers it);
|
||||
- the resolver contract is the default one, and `data` rebuilt from the sent links and the commitment rebuilt with the stored duration equal the stored commitment;
|
||||
- `verifyRegisterIntent`: the owner signed, the deadline has not passed, the secret is 32 bytes;
|
||||
- low s, with a check of its own. `recoverPublicKey` accepts high s, and the sponsor's `SignatureChecker` rejects it. The signature is read as exactly 65 bytes `r ‖ s ‖ v`, with `v` 27 or 28, as OpenZeppelin requires. The recovery id is `v − 27`;
|
||||
- `nonce == SimplexSponsor.nonces(owner)`, read from the chain.
|
||||
- `nonce == SimplexSponsor.nonces(owner)`, read from the chain;
|
||||
- last, each link is a SimpleX short link of its kind within the link bound, else `invalid_link`.
|
||||
|
||||
Any failed check answers `bad_request`. A page that built the order itself never meets it.
|
||||
- The intent signs the duration (contracts PR #36). So the service checks that it is what was paid: 2 years, or the prepaid code's years, at 365 days a year.
|
||||
Any other failed check answers `bad_request` and changes nothing. A page that built the order itself never meets it. The one write a failed reveal makes is recording a links refusal at the last check: only the committed links, signed by the owner, can be refused there, so nobody records one on purpose to unlock `/name`. Recording it bumps `version` and wakes the polls.
|
||||
- The intent signs the duration (contracts PR #36). The service rebuilds the commitment with the registration's stored duration, checked against the paid years and `termStart` when the order was made.
|
||||
- The page must be open after the wait. Reopening the link within 24 hours (`maxCommitmentAge` in PR #35's deploy) finishes the reveal. In this browser the page uses storage. In another browser it uses the code (N7b).
|
||||
- A commitment not revealed within the controller's `maxCommitmentAge` lapses to `failed`.
|
||||
- **All phases.** `unpaid` (its invoice not settled while open) → `queued` (paid, or prepaid code reserved) → the rows above.
|
||||
@@ -332,64 +321,62 @@ The worker's phases, what happens in each, and the page that shows it:
|
||||
- `code_invalid`: "This code is not valid";
|
||||
- `code_expired`: "This code has expired".
|
||||
- **Ownership from the start.** `makeCommitment` hashes the `Registration` rather than `msg.sender`. The sponsor registers the name to the struct's owner once the owner has signed the intent (PR #35). So the name belongs to the buyer's address from the start, and the registrar key never holds a name.
|
||||
- **Links at registration (N3g).** Checkout offers "Point it at your SimpleX": a contact address and/or a channel link, one per key.
|
||||
- The page checks each link against the link bound: 4096 bytes of UTF-8 per record value, with a key's links joined with `;`.
|
||||
- The page previews each link (display name and picture, as on the taken-name cards) through `POST /api/link/preview`. That endpoint checks the bound too. So a typo shows before paying.
|
||||
- **Links at registration (N3g).** The Links step, before the name code, asks "What should your name open?": one field that takes a contact address or a channel link, its kind detected, and "Add a channel link" (or a contact address) for a second field of the other kind. Skip for now is a link.
|
||||
- One SimpleX short link per key: a contact address (`/a#`) or a channel (`/c#`). A full link is refused (`invalid_link`), since the app reads only the first short link of a key.
|
||||
- The page checks each link against the link bound, 4096 bytes of UTF-8.
|
||||
- The page previews each link (its chat, as on the taken-name answer) through `POST /api/link/preview`. That endpoint checks the bound too. So a typo shows before paying.
|
||||
- Check your order shows the links read-only. Nothing inside an order changes them.
|
||||
- The page puts `setText` calls for `simplex.contact` and `simplex.channel` on the name's node into the `Registration`'s `data`. So the commitment covers them.
|
||||
- The links reach the service only with the reveal, which carries them as `records`. The register transaction sets them.
|
||||
- Without links, the name is registered with none.
|
||||
- **Links later (N12b).** "Your names" lets the owner change the links after registration.
|
||||
- **When it is offered.** "Your names" has Edit links, except for a registered answer with an `owner` and null `records` (a name on another resolver or none).
|
||||
- **During the lag** the answer has neither. Edit links then takes:
|
||||
- the values from the order's links in `sealed`, or from this browser's last mined edit of that key (a mined clear sets both to "");
|
||||
- the nonce, chain id and resolver from the registration view.
|
||||
|
||||
It is offered only once the phase is `registered`.
|
||||
- **When it is offered.** "Your names" has Edit links once the phase is `registered`, except for a registered answer with an `owner` and null `records` (a name on another resolver contract or none).
|
||||
- **Signing.** For each changed key, the page signs EIP-712 `SetText(bytes32 node,string key,string value,uint256 nonce,uint256 deadline)` under the `SimplexResolver` domain, with the name's key. `POST /api/name/records` relays `SimplexResolver.setTextWithSig`, and the service pays the gas. A `ClearRecords` signature, relayed as `clearRecordsWithSig`, clears every record at once.
|
||||
- **The nonce** is `SimplexResolver.nonces(owner)`, one counter for all of the owner's edits. The registered search answer carries it with the chain id. While search lags, the registration view carries it.
|
||||
- **The nonce** is `SimplexResolver.nonces(owner)`, one counter for all of the owner's edits. The registered search answer carries it with the chain id.
|
||||
- **Checks.** The service:
|
||||
- recovers each edit's signer. It looks repeats up by signer and nonce, and matches them by `body_hash`;
|
||||
- requires low s, as `SignatureChecker` does. The signature is read as for the reveal: 65 bytes, `v` 27 or 28;
|
||||
- requires the signer to be the name's owner;
|
||||
- requires the deadline to be ahead of the latest block.
|
||||
|
||||
Otherwise it answers `bad_request`. Repeats are answered right after the signature check, before the deadline, cap, owner and nonce checks.
|
||||
- recovers each edit's signer, requiring low s as `SignatureChecker` does (65 bytes, `v` 27 or 28, as for the reveal);
|
||||
- answers an identical repeat of an edit it sent from that edit, by signer and nonce matched on `body_hash`. Only sent edits are stored, so a refused request's repeat is checked anew;
|
||||
- requires the deadline to be ahead of the latest block, the signer to be the name's owner, and the nonce to be the owner's on chain; otherwise `bad_request`;
|
||||
- then applies the per-name cap: 10 relayed edits an hour. Only sent edits count, so nobody but the owner can use up a name's cap.
|
||||
- **Reverts and restarts.** Three cases are re-checked against the nonce and the record: a revert, an expiry, and a restart that finds an edit's registrar nonce used with no receipt. For a clear, the record check is both keys reading "". The re-check is needed because anyone can relay a signed edit, and a pruned node can lose a receipt.
|
||||
- An edit found applied this way answers `{tx: null}`, since the applying transaction is unknown.
|
||||
- An edit is applied when one of its own sent transactions has a status-1 receipt; it answers that hash. Without a known receipt, it is applied when the owner's nonce has passed it and the record holds its value; it answers `{tx: null}`, since the applying transaction is unknown. A passed nonce with another value means another body took the nonce: `record_reverted` with `InvalidNonce`.
|
||||
- Otherwise a revert or an expiry answers `record_reverted`.
|
||||
- A chain that does not answer in time answers 504 `edit_pending`: the edit may still be mined, so the page says so and does not offer to send it again.
|
||||
- A chain that fails before the edit goes out (its dry run, gas estimate or nonce read) sends nothing and answers 503 `provider_unavailable`; the page offers to save again.
|
||||
- Otherwise the restart case is signed again under a new registrar nonce, while its deadline is ahead.
|
||||
- **One edit at a time.** The service accepts an edit only when its nonce is the on-chain one. It answers once the edit is mined. The page signs the next edit after that answer. It uses the next nonce after a mined edit, and the same nonce after `record_reverted`. After any `bad_request`, the page re-reads the nonce from where it first read it.
|
||||
- **Limits.** PR #35 has no edit credits. So the service bounds edits with its own rate limit and a per-name cap.
|
||||
- **One edit at a time.** The service accepts an edit only when its nonce is the on-chain one. It answers once the edit is mined. The page signs the next edit after that answer. It uses the next nonce after a mined edit, and the same nonce after `record_reverted`. After any `bad_request`, the page re-reads the nonce from the search answer.
|
||||
- **Limits.** PR #35 has no edit credits. So the service bounds edits with its own rate limit and the per-name cap.
|
||||
- **After redeeming**, the app's wallet owns the name and edits from the app. Your names hides Edit links once the search answer carries an owner other than the code's address.
|
||||
- **The record format.** A record holds its links joined with `;`. Removing one of several links rewrites the joined value. Clearing a key sets it to "".
|
||||
- **Progress.** Each phase is published to the long poll (`GET /api/invoice/:id` or `GET /api/registration/:id`).
|
||||
- The poll holds a name invoice or a registration while its phase is the page's `seenPhase`. It holds an invoice also while its status and payment marks are unchanged.
|
||||
- **The record format.** An edit sets a key to one short link, or to "" to remove it. A record set elsewhere may hold several links joined with `;`; readers split on it, and an edit replaces the whole record.
|
||||
- **Progress.** Each change of a registration is published to the long poll (`GET /api/invoice/:id` or `GET /api/registration/:id`).
|
||||
- The view carries `version`, which grows with every change: phase, label, intent, transactions, reveal times, error. The poll holds a name invoice or a registration while its `version` is the page's `seenVersion`. It holds an invoice also while its status and payment marks are unchanged.
|
||||
- A failed chain read for the view's `intentNonce` or `codeLength` leaves the field null and named in `unread`; the page reads again without `seenVersion` after 5 s, then 15 s, then every 30 s. The view itself never fails for them.
|
||||
- Every write to a registration is a compare-and-set on its `version`, and the reveal runs one at a time per label hash with orders, `/name` and `/retry`. A write that loses the race, on any endpoint, changes nothing and answers `409 registration_changed` with the current view.
|
||||
- An invoice paid after it closed (`paid` with phase `unpaid`) is answered at once.
|
||||
- The page stops at `registered`, `code_issued`, `taken`, `failed`, and at `unpaid` once the invoice is no longer open.
|
||||
- Today's long poll answers a paid invoice at once and tracks no phase. So a separate phase map tracks the phase. It is keyed by registration id and sits outside the poller's waiting count.
|
||||
- The page stops at `registered`, `code_issued`, and at `unpaid` once the invoice is paid after it closed. It keeps following an expired invoice, so a payment that arrives late shows at once instead of the page offering a new invoice. It also keeps following `taken` and `failed`, since another tab or browser can move the order, retry it or turn it into a code; each held poll answers only when the phase changes.
|
||||
- Today's long poll answers a paid invoice at once and tracks no registration. So a separate map tracks each registration's version. It is keyed by registration id and sits outside the poller's waiting count.
|
||||
- **Taken.** A name is taken by another registrar, or by another order here that registered first. The order keeps its value.
|
||||
- **A paid order** can take another name or a prepaid code:
|
||||
- **Another name (N8a)**, at most its price, searched on the order's own page: `POST /api/registration/:id/name {labelHash, length, commitment, sealed, timestamp, signature}`.
|
||||
- The service runs the order's checks on the new hash.
|
||||
- It replaces the registration's `price_id` with the new name's price row.
|
||||
- It replaces the hash, length, commitment and sealed order.
|
||||
- **Another name (N8a)**, at most its price at the chain's prices, searched on the order's own page: `POST /api/registration/:id/name {labelHash, length, duration, commitment, sealed, timestamp, signature}`.
|
||||
- The service runs the order's checks on the new hash and commitment. As for an order, another live order of the new commitment, or of this owner and the new hash, is refused with `order_exists`, so one wallet never pays twice for one name.
|
||||
- It sets the registration's `price` to the new name's price, which caps a further move.
|
||||
- It replaces the hash, length, duration, commitment and sealed order.
|
||||
- It clears `label`, `secret`, `records`, `intent_*`, the transaction hashes and blocks, `reveal_after`, `reveal_before` and `error`.
|
||||
- The registration is then committed and revealed again.
|
||||
- A cheaper name refunds nothing. A dearer one is `bad_request`.
|
||||
- **A prepaid code (N8b)** for the registration's current length (8+ for longer names), for 2 years, paid and without expiry: `POST /api/registration/:id/code {codeHash, timestamp, signature}`.
|
||||
- A cheaper name refunds nothing, which the page says before the buyer chooses. A dearer one is `409 price_too_high`, with its price.
|
||||
- **A prepaid code (N8b)** that pays for any name no dearer than the order, for the order's years, paid and without expiry: `POST /api/registration/:id/code {codeHash, codeLength, timestamp, signature}`. Its length is the shortest `L` from 6 such that every length from `L` to 63 costs at most the order's price (`SN6` to `SN8` at the launch prices); the service reads the oracle's whole list, so a non-monotone list is handled. When no length qualifies, `/code` is `409 code_unavailable`, and the page offers only another name. The registration view gives the length as `codeLength`, in `taken` and still in `code_issued`, and the page derives the code with it, so the owner code recovers the code on any browser.
|
||||
- **Signatures.** The owner signs both requests, as for `find`, because anyone holding the order link can read the registration id.
|
||||
- The signature is EIP-191, read as for the reveal (65 bytes, `v` 27 or 28).
|
||||
- It is over `simplex.domains name <registrationId> <labelHash> <length> <commitment> <sha256(sealed)> <timestamp>` or `simplex.domains code <registrationId> <codeHash> <timestamp>`.
|
||||
- `length` is in decimal.
|
||||
- It is over `simplex.domains name <registrationId> <labelHash> <length> <duration> <commitment> <sha256(sealed)> <timestamp>` or `simplex.domains code <registrationId> <codeHash> <codeLength> <timestamp>`. `/code` carries the `codeLength` the page used; when the service's own value differs (the prices moved), it answers `409 code_length_changed` with the current value and issues nothing.
|
||||
- `length` and `duration` are in decimal.
|
||||
- `labelHash`, `commitment` and `sha256(sealed)` are lowercase `0x` hex. `sha256(sealed)` is the SHA-256 of the `sealed` string's ASCII bytes.
|
||||
- `codeHash` is the badge codes' unpadded base64url SHA-256 of the canonical code.
|
||||
- `timestamp` is in decimal Unix seconds.
|
||||
- **When allowed.** Both are only for a paid registration in `taken`, else `bad_request`. The exception: an identical `/code` repeat is answered 200 again. A page that lost a `/name` answer re-reads the view.
|
||||
- **When allowed.** `/code` only for a paid registration in `taken`, else `bad_request`; an identical repeat is answered 200 again, whatever `codeLength` it carries or the prices are now. `/name` only for a paid registration in `taken`, for any registration in `failed`, and for a `committed` one whose reveal was refused for its links (`invalid_link`, which the reveal records); never from a healthy `committed` (manager's ruling, 2026-10-21). There it corrects the refused links, which are fixed in the commitment, with the same name or another. At most 3 successful moves per order; after the last, `409 move_limit` answers the order's state, and the page shows it with a line to contact SimpleX support and the reference (support refunds a paid order by hand); a body equal to the current fields is a repeat, answered unchanged. A code order's code is checked and reserved again (`code_used` when spent elsewhere after the sweep released it, `code_reserved`, `code_invalid`, `code_expired`), and must cover the new name (`code_not_covering`). A page that lost a `/name` answer re-reads the view.
|
||||
- **The taken code.** The page derives the code from the owner code's seed (payload from `HMAC-SHA256(seed, "simplex.domains taken code " + registrationId)`). It sends only the code's hash. So the owner code recovers the code, and a repeat sends the same hash. A hash another code has is `code_conflict`, as for badges. For a derived code this is unreachable in practice.
|
||||
- An order opened from the app can use neither `/name` nor `/code` (gap 10 in the sequence doc).
|
||||
- **A prepaid-code order:** the code is released, unspent, for another name.
|
||||
- **Failed.** The page says "Paid, not registered". Try again charges nothing. Refunds are manual.
|
||||
- **Signing.** `signRecoverable` and `signEip1559Tx` are in simplexmq #1887, on `names` (not `master`). EIP-712 hashing and signer recovery are in simplexmq `ab/name-registration-primitives-sponsored`, a PR into `names`.
|
||||
- **Failed.** The page says "Not registered yet". Try again charges nothing. A code order keeps its code reserved for Try again; a daily sweep releases it after 30 days. Refunds are manual.
|
||||
- **Signing.** simplexmq `4e6f5586` ("add EIP712 (#1941)"), which the primitives branch pins, has `signEip1559Tx`, EIP-712 hashing and signer recovery. It has no EIP-191 helper, so the service hashes the signed messages itself.
|
||||
|
||||
## 7. Codes
|
||||
|
||||
@@ -411,151 +398,93 @@ Digits after `SN` cannot decide it, since an owner code's body may start with on
|
||||
|
||||
| | Owner code | Prepaid code |
|
||||
|---|---|---|
|
||||
| Shown | `SN-DWPHS-6JBFR-ZR1MA-T5HQ9-P17QM-F5GM5` | `SN7-BJTBP-29596-R3HJ8-25YM7`; `SN6-…`, `SN20-…` |
|
||||
| Shown | `SN-DWPHS-6JBFR-ZR1MA-T5HQ9-P17QM-F5GM5` | `SN7-BJTBP-29596-R3HJ8-25YM7`; `SN6-…` to `SN9-…` in use |
|
||||
| Body | 30 characters: 128-bit entropy and 22 bits of its SHA-256 | a 1–2 digit length, then 20 characters, the last a Luhn mod-32 check over the length digits and the 19 payload characters |
|
||||
| Made by | the page, one per order | the operator (`//issue name <length> [years <Y>] [paid\|unpaid\|free]`, 2 years by default and at least 2; group `/issue name <length> [years <Y>]`, `/bulk name <length> [years <Y>] count <N>`), the app (an owner decision; #7530 defers in-app purchase), and the page for a taken order (N8b), derived from the owner code, sending only its hash. Not sold on the web |
|
||||
| Means | the name's key: whoever holds it owns the name until it is redeemed | the right to register one name of that length or more, for the code's years |
|
||||
| Made by | the page, one per order | the operator (`//issue name <length> [years <Y>] [paid\|unpaid\|free]`, 2 years by default and at least 2; group `/issue name <length> [years <Y>]`, `/bulk name <length> [years <Y>] count <N>`), and the page for a taken order (N8b), derived from the owner code, sending only its hash. Not sold on the web |
|
||||
| Means | the name's key: whoever holds it owns the name until it is redeemed | the right to register one name of that length or more, for the code's years from 1 January 2027 |
|
||||
| Stored by the service | never | SHA-256 of the canonical form; the `name_prepaid_codes` row (`min_length`, `years`, status) is the truth, the length in the text a convention |
|
||||
| Use | redeemed in the app, moving the name (§5) | single-use: reserved from `queued` until it is `registered` (spent) or `taken` (released); `failed` keeps it for Try again, for 30 days |
|
||||
|
||||
The rest of this section is about prepaid codes.
|
||||
**Check your code** is the one place a code is entered (owner, 2026-10-21). A text link in the top bar (in the menu on a phone) opens it as a whole screen with one code field. There is no code entry at checkout and no code popup.
|
||||
- The page tells the kinds apart with `parseNameCode`, and a typo fails the check character before any request.
|
||||
- A **prepaid code** is checked with `POST /api/code/check {code}`. It answers what the code covers (`minLength`, `years`) and whether it is unused, reserved by an unfinished registration, used, revoked, expired or unpaid. It does not spend the code.
|
||||
- A usable prepaid code is kept in Your names. While kept, a covered name's search answer shows $0 and the code's years, and Check your order offers "Register with your code", which sends `/api/name/register`. A name shorter than the code allows shows its normal price, with a line saying what the code covers. The investor codes on the board are `SN6-` for 5 years.
|
||||
- A **name code** is never sent. The page signs `find` with its key, opens each order's `sealed` and names them: "Name code of alicebakery.simplex", with Add to Your names (§5, Restoring).
|
||||
|
||||
**Check your code** (K1) opens a popup. It is on the investor card, and in the header as Check a code.
|
||||
- `POST /api/code/check {code}` answers what the code covers (`minLength`, `years`).
|
||||
- It also answers whether the code is unused, reserved by an unfinished registration, used (K5), revoked, expired or unpaid.
|
||||
- It does not spend the code.
|
||||
- A typo fails the check character before any request (K6).
|
||||
What the screen says for each answer:
|
||||
|
||||
**A name code where a prepaid code goes.** `parseNameCode` tells the kinds apart. So an owner code typed into the popup, the checkout entry or Add a code is answered on the page, sending nothing: "This is a name code. Restore it in Your names."
|
||||
|
||||
**Use this code** applies the code:
|
||||
- A covered name shows $0 and the code's term (K2). Remove drops the code there.
|
||||
- Register with code leads to a $0 checkout (K3).
|
||||
- The name registers for the code's years (K8).
|
||||
- A name the applied prepaid code does not cover shows its normal price, with a line saying what the code covers.
|
||||
|
||||
The board's example is an investor code, `SN6-` for 5 years.
|
||||
|
||||
The popup's answer for each state is below. The board draws unused, used and a typo.
|
||||
|
||||
| State | Line |
|
||||
| Answer | Line |
|
||||
|---|---|
|
||||
| `unused` | ✓ Unused, and what it covers (K1) |
|
||||
| `reserved` | This code is being used for a name now |
|
||||
| `used` | This code was already used (K5) |
|
||||
| `revoked`, unknown | This code is not valid |
|
||||
| `expired` | This code has expired |
|
||||
| `unpaid` | This code is not paid for yet |
|
||||
| typo | This code is not valid, check it for a mistyped character (K6): the check character fails before any request |
|
||||
| prepaid, `unused` | "Prepaid code: it pays for a name of 8 or more letters, for 5 years." |
|
||||
| prepaid, `reserved` | This code is being used for a name now |
|
||||
| prepaid, `used` | This code was already used |
|
||||
| prepaid, `revoked` | This code is not valid: SimpleX withdrew it |
|
||||
| prepaid, `expired` | This code has expired |
|
||||
| prepaid, `unpaid` | This code is not paid for yet |
|
||||
| prepaid, unknown (`code_invalid`) | This code is not valid: SimpleX did not issue it |
|
||||
| name code, with orders | Name code of alicebakery.simplex, and Add to Your names |
|
||||
| name code, no orders (`find` answers `not_found`) | No order was found for this code |
|
||||
| name code whose order's `sealed` does not open with it, or whose label does not hash to its `labelHash` | This code does not open the order |
|
||||
| not a code | This is not a SimpleX names code |
|
||||
| typo | Check it for a mistyped character |
|
||||
| rate limited | Too many checks just now, with the wait |
|
||||
| no answer | The code could not be checked. Try again in a moment. |
|
||||
|
||||
**I have a code** sits under Continue in the checkout's order card. It opens the code entry there (K4). A code that does not cover the name says so (K7), and the buyer can still pay.
|
||||
|
||||
**History.** "Add a code" (N12a) asks the same question. It adds the code to the list, where Use applies it.
|
||||
**When a kept code is spent.** An order or Try again may meet a code another tab or browser has spent meanwhile. The refusal (`code_used`, `code_reserved`, `code_invalid`, `code_expired`, `payment_pending`, `code_not_covering`) shows on that page, and the name shows its price again.
|
||||
|
||||
## 8. Service API
|
||||
|
||||
This section lists the service's HTTP endpoints, with example requests and answers. The rules that apply across endpoints follow the examples.
|
||||
The API contract [`2026-10-21-store-api.md`](2026-10-21-store-api.md) defines every endpoint, field, answer, error and limit, for the service and the webapp's mock. This section lists the endpoints and what the page does with their errors.
|
||||
|
||||
The examples show the launch configuration, `tld = simplex`. Until `.simplex` is deployed, the service answers `testing`.
|
||||
| Endpoint | Does | Limit |
|
||||
|---|---|---|
|
||||
| `GET /api/name/:hash?length=N` | search: `available` with the chain price, `years` and `termStart`; `registered` with its chats, raw records, owner, resolver contract, records nonce and expiry; `reserved` | search |
|
||||
| `POST /api/link/preview {link}` | a link's chat, as in search | preview |
|
||||
| `POST /api/code/check {code}` | what a prepaid code covers and its state | code |
|
||||
| `POST /api/invoice {kind: "name", …}` | a paid order, signed by the owner | create |
|
||||
| `POST /api/name/register {…, code}` | an order paid with a prepaid code, signed by the owner | code |
|
||||
| `POST /api/invoice/:id/cancel` | cancel an open invoice | create |
|
||||
| `GET /api/invoice/:id?wait=&seenVersion=&seenPaid=&seenFull=` | the paid order, long-polled | read |
|
||||
| `GET /api/registration/:id?seenVersion=` | the registration, long-polled | read |
|
||||
| `POST /api/registration/:id/reveal` | the name, links and signed intent | read |
|
||||
| `POST /api/registration/find` | a name code's orders, signed | find |
|
||||
| `POST /api/registration/:id/name` | another name, or other links, for a taken, failed or unrevealed order, signed | create |
|
||||
| `POST /api/registration/:id/code` | a prepaid code for a taken order, signed | create |
|
||||
| `POST /api/registration/:id/retry` | Try again | create |
|
||||
| `POST /api/name/records` | a signed link edit, relayed | records |
|
||||
|
||||
The examples use the launch configuration, `tld = simplex`. Until `.simplex` is deployed, the service answers `testing`.
|
||||
|
||||
```json
|
||||
GET /api/name/<keccak256("dynamis") as 0x and 64 lowercase hex>?length=7
|
||||
200 {"status": "available", "price": 20000, "currency": "usd", "years": 2,
|
||||
GET /api/name/<keccak256("alicebakery") as 0x and 64 lowercase hex>?length=11
|
||||
200 {"status": "available", "price": 10000, "currency": "usd", "years": 2, "termStart": 1798761600,
|
||||
"resolver": "0x…", "sponsor": "0x…", "chainId": 1, "tld": "simplex"}
|
||||
|
||||
GET /api/name/<keccak256("bakery") hex>?length=6
|
||||
200 {"status": "registered", "entities": [
|
||||
{"type": "address", "displayName": "Alice's Bakery", "image": "data:image/jpeg;base64,…",
|
||||
"link": "https://smp8.simplex.im/a#…", "deepLink": "simplex:/a#…"},
|
||||
{"type": "channel", "displayName": "Bakery news", "image": "data:image/jpeg;base64,…",
|
||||
"link": "https://smp9.simplex.im/c#…", "deepLink": "simplex:/c#…"}],
|
||||
"records": {"contact": "https://smp8.simplex.im/a#…", "channel": "https://smp9.simplex.im/c#…"}, (raw record values read from the default resolver on chain, links joined with ";", null with recordsNonce for a name on another resolver or none; the resolver's split links feed only the cards; the reveal's records are arrays)
|
||||
"owner": "0x…", "resolver": "0x…", "recordsNonce": 0, "chainId": 1, "tld": "simplex"}
|
||||
{"type": "address", "kind": "contact", "displayName": "Alice's Bakery", "fullName": "Sourdough and pastries, Lisbon",
|
||||
"image": "data:image/jpeg;base64,…", "subscribers": null, "link": "https://smp8.simplex.im/a#…", "deepLink": "simplex:/a#…"},
|
||||
{"type": "channel", "kind": "channel", "displayName": "Bakery news", "fullName": "Daily bakes and opening hours",
|
||||
"image": null, "subscribers": 1204, "link": "https://smp9.simplex.im/c#…", "deepLink": "simplex:/c#…"}],
|
||||
"records": {"contact": "https://smp8.simplex.im/a#…", "channel": "https://smp9.simplex.im/c#…"},
|
||||
"owner": "0x…", "resolver": "0x…", "recordsNonce": 0, "expiresAt": 1861833600, "gracePeriod": 7776000, "chainId": 1, "tld": "simplex"}
|
||||
|
||||
GET /api/name/<keccak256("support") hex>?length=7 200 {"status": "reserved"}
|
||||
GET /api/name/<hash>?length=5 400 {"error": "invalid_name"}
|
||||
GET /api/name/<hash of a name this service is registering>?length=7 200 {"status": "registered", "entities": [], "tld": "simplex"}
|
||||
|
||||
POST /api/code/check {"code": "SN7-BJTBP-29596-R3HJ8-25YM7"}
|
||||
200 {"minLength": 7, "years": 2, "state": "unused"}
|
||||
state: unused | reserved | used | revoked | expired | unpaid; an unknown code is 400 {"error": "code_invalid"} (not 404, which the transport reads as not_found whatever the body)
|
||||
|
||||
POST /api/invoice {"kind": "name", "labelHash": "0x…", "length": 7, "price": 20000, "method": "btc", "owner": "0x014f…9b73", "commitment": "0x…", "sealed": "…"} (sealed: null for an order opened from the app)
|
||||
200 {"invoiceId": "q7ZpL2dN9xWc4KfR8tYb1A", …the badge invoice's common fields (amount, currency, expiresAt, payment fields), flat, "kind": "name", "registration": {"id": "…", "phase": "unpaid", …}}
|
||||
POST /api/name/register {"labelHash": "0x…", "length": 7, "owner": "0x7a3C…e51D", "commitment": "0x…", "sealed": "…", "code": "SN7-BJTBP-29596-R3HJ8-25YM7"}
|
||||
200 {"registrationId": "t2Jk8RmQ4vXw9NcP3yLb6g"} (plus "invoiceId" when the commitment's registration was paid, so the page opens ?order=<invoiceId>)
|
||||
POST /api/invoice for a commitment whose registration is by code: 200 {"invoiceId": null, "kind": "name", "registration": {…the view}}, no payment fields; the page opens ?registration=<id>
|
||||
POST /api/invoice or /api/name/register for a commitment with a different open invoice: 400 {"error": "bad_request", "invoiceId": "q7ZpL2dN9xWc4KfR8tYb1A"}; the page opens that order
|
||||
|
||||
GET /api/invoice/:id?wait=<status>&seenPhase=<phase> … plus "registration": {id, phase, labelHash, label (null until the reveal), owner, commitment, sealed, resolver, sponsor, chainId, tld, years, commitTx, registerTx (each, among the hashes kept since they were last cleared, the one that mined with `status` 1, else the latest sent, and null when judged mined from chain state with no hash known to have mined with `status` 1), revealAfter, revealBefore, intentNonce, recordsNonce (once registered, for Edit links before search has the owner), expiresAt, error}
|
||||
GET /api/registration/:id?seenPhase=<phase> the same registration object, long-polled: the invoice holds while its status, payment marks and (for a name invoice) phase are the ones seen, except one paid after it closed, the registration while its phase is the one seen
|
||||
POST /api/registration/:id/reveal {"label": "dynamis", "secret": "0x…",
|
||||
"records": {"contact": ["https://smp8.simplex.im/a#…"], "channel": []},
|
||||
"nonce": 0, "deadline": 1791727200, "signature": "0x…"}
|
||||
200 {}
|
||||
POST /api/registration/:id/name {"labelHash": "0x…", "length": 7, "commitment": "0x…", "sealed": "…", "timestamp": 1791642000, "signature": "0x…"}
|
||||
200 the registration object, phase queued
|
||||
POST /api/registration/:id/retry
|
||||
200 the registration object, phase queued, or registered after a lost confirmation
|
||||
POST /api/registration/:id/code {"codeHash": "…", "timestamp": 1791642000, "signature": "0x…"}
|
||||
200 {}
|
||||
|
||||
POST /api/registration/find {"owner": "0x014f…9b73", "timestamp": 1791641400, "signature": "0x…"}
|
||||
signature: EIP-191 over "simplex.domains find <owner as lowercase 0x hex> <timestamp in decimal Unix seconds>", 65 bytes r ‖ s ‖ v with v 27 or 28
|
||||
200 {"registrations": [{"registrationId": "m8Tk3WqZ5nRc2YpJ7vLd4g", "invoiceId": "q7ZpL2dN9xWc4KfR8tYb1A", "phase": "committed", "createdAt": "2026-10-10T13:30:00Z"}]}
|
||||
newest first
|
||||
one per registration of that owner, retargeted ones included; invoiceId (the order link's id): null for a prepaid-code registration
|
||||
404 {"error": "not_found"} when the owner has no registration
|
||||
|
||||
POST /api/link/preview {"link": "https://smp8.simplex.im/a#…"}
|
||||
200 {"type": "address", "displayName": "Alice's Bakery", "image": "data:image/jpeg;base64,…"}
|
||||
|
||||
POST /api/name/records {"labelHash": "0x…", "key": "simplex.channel", "value": "https://smp9.simplex.im/c#…",
|
||||
"nonce": 0, "deadline": 1791727200, "signature": "0x…"}
|
||||
POST /api/name/records {"labelHash": "0x…", "clear": true, "nonce": 1, "deadline": 1791730800, "signature": "0x…"}
|
||||
key: simplex.contact | simplex.channel; deadline: signing time + 1 hour; a link not of the key's kind or a value over the link bound is invalid_link, a new edit past the per-name cap rate_limited
|
||||
200 {"tx": "0x…"} once mined ({"tx": null} when it was found applied on chain with the applying transaction unknown: another relayer's, or the service's own with its receipt pruned), or 409 {"error": "record_reverted", "revert": "SignatureExpired"} and the like (null for a mined revert other than an expired deadline, whose receipt carries no reason, or a dry-run revert whose node returns no data); an identical repeat (the same body on any row) waits for the same transaction while pending, answers its tx once mined (null again for an edit found applied that way), or record_reverted again for a reverted edit; only a body on no row is new, and with a nonce whose edit is pending or mined it is bad_request; after record_reverted the nonce is free again for a new body; the nonce must equal SimplexResolver.nonces(owner) on chain, else bad_request, so edits go one at a time
|
||||
POST /api/invoice {"kind": "name", "labelHash": "0x…", "length": 11, "duration": 69292800, "price": 10000, "method": "btc",
|
||||
"owner": "0x014F…9b73", "commitment": "0x…", "sealed": "…", "timestamp": 1792828800, "signature": "0x…"}
|
||||
200 {"invoiceId": "q7ZpL2dN9xWc4KfR8tYb1A", "status": "open", "amount": 10000, "currency": "usd", "expiresAt": "…",
|
||||
"kind": "name", "registration": {"id": "…", "version": 1, "phase": "unpaid", "years": 2, "duration": 69292800, "price": 10000, …},
|
||||
"address": "bc1q…", "cryptoAmount": "0.00156", "cryptoCurrency": "btc"}
|
||||
```
|
||||
|
||||
- **Errors.** These are new on HTTP:
|
||||
- `invalid_name`;
|
||||
- `name_taken`;
|
||||
- `name_check_unavailable`: the resolver failing during an order, register or retarget;
|
||||
- `invalid_link`;
|
||||
- `record_reverted`;
|
||||
- `code_not_covering`;
|
||||
- `code_reserved`;
|
||||
- the chat protocol's code errors: `code_invalid` (unknown or revoked), `code_used`, `code_expired` and `payment_pending` (an unpaid code).
|
||||
|
||||
`catalog_changed` carries the new price.
|
||||
- **Errors.** New on HTTP, with statuses in the contract's Appendix B: `invalid_name`, `name_taken`, `order_exists` (an order meeting another live order; it names nothing), `price_too_high`, `code_unavailable`, `name_check_unavailable` (a failed chain read during an order, register or retarget), `invalid_link`, `record_reverted` (with `revert`), `edit_pending`, `code_not_covering`, `code_reserved`, and the chat protocol's code errors `code_invalid` (unknown), `code_used`, `code_expired` and `payment_pending` (an unpaid code). `catalog_changed` carries the chain's price.
|
||||
- **On the page.**
|
||||
- `name_taken` from an order goes back to search and shows its new answer (N2a or N2b, or N2c when reserved).
|
||||
- Code errors from the service show where the code was entered: the popup (K5) or the checkout entry (K7). A typo is caught on the page (K6).
|
||||
- A `bad_request` carrying an `invoiceId` opens that order.
|
||||
- `name_check_unavailable` and anything else at checkout is N3d.
|
||||
- `name_taken` from an order goes back to search and shows its new answer.
|
||||
- Code errors show on Check your code, or on the page whose order or Try again spent the code.
|
||||
- `order_exists` runs the signed `find` and opens the order found.
|
||||
- `name_check_unavailable` and anything else before the order exists is N3d.
|
||||
- After the order exists, the order page shows the error with the reference, and the payment is kept.
|
||||
- **The registration id** is a random 128-bit token, like order ids. Whoever holds it can act on the registration.
|
||||
- **Chain reads.** A failed chain read answers `provider_unavailable` (503) and changes nothing. This applies in:
|
||||
- search;
|
||||
- the reveal (the failure is not counted as one of its `bad_request` answers);
|
||||
- the records relay (the failure is not counted toward the cap);
|
||||
- the registration view, whose `intentNonce` is read only in `committed` and `recordsNonce` only in `registered`, null otherwise;
|
||||
- `/retry`.
|
||||
|
||||
What counts as a failed read:
|
||||
- A revert that is the answer, as `ownerOf` for a name not held, is not a failure.
|
||||
- A null receipt counts as not mined (pending or pruned). Once its registrar nonce is used, the chain state decides: `commitments` for a commit, `ownerOf` for a register, the nonce and the record for an edit.
|
||||
- An RPC error is a failed read.
|
||||
|
||||
Retries:
|
||||
- The sender re-reads an edit after a dry-run or mined revert, an expiry or a used registrar nonce. Every such re-read is retried until it succeeds, with the edit kept `pending`.
|
||||
- The worker's reads are retried, with the registration kept in its phase.
|
||||
- **Rate limits.**
|
||||
- `/api/name`, `/api/link/preview`, `/api/code/check`, `/api/registration/find` and `/api/name/records` each have their own limit. It is taken after route and method matching and before any other check, as `limited` does today.
|
||||
- The profile fetch for a taken name's links has its own limit, applied per fetch.
|
||||
- `/api/name/register` shares the code check's limit, since it too reveals a code's state.
|
||||
- `GET /api/registration/:id` and the reveal take the read limit, like the invoice long poll. Each `bad_request` from the reveal counts against it.
|
||||
- `/name`, `/code` and `/retry` on a registration take the create limit.
|
||||
- **The registration id** is a random 128-bit token, like order ids. Whoever holds it can follow the registration and send its reveal and retry.
|
||||
- **Chain reads.** A failed chain read answers `provider_unavailable` (503) and changes nothing, except in orders, register and retarget, which answer `name_check_unavailable`. A revert that is the answer, as `ownerOf` for a name not held, is not a failure. A null receipt counts as not mined; once its registrar nonce is used, the chain state decides. The worker's and the sender's reads are retried, with the registration or edit kept as it is.
|
||||
- **Formats.** `owner` must be a checksummed address. `labelHash`, `commitment` and `secret`, in bodies and as `:hash`, are `0x` and 64 lowercase hex. Anything else is `bad_request`. `codeHash` stays the badge codes' unpadded base64url.
|
||||
|
||||
## 9. Storage
|
||||
@@ -569,13 +498,14 @@ Migration `20261010_names` runs in SQLite and Postgres. The badge tables (`sx_ba
|
||||
- `reserved_by` is the registration holding the code, unique when set. `reserved_at` says when, for the operator.
|
||||
- A daily sweep releases codes whose registration is still `failed`, with a `failed_at` 30 days old.
|
||||
- Each failure sets `failed_at`, and a requeue clears it.
|
||||
- **`sx_badge_service_name_prices`:** `price_id`, `min_length`, `label_hash` (null for a length rule), `price`, `currency`, `status`, `created_at`.
|
||||
- **`sx_badge_service_name_registrations`:**
|
||||
- Columns: `registration_id`, `label_hash`, `length`, `label`, `secret`, `records`, `intent_nonce`, `intent_deadline`, `intent_signature`, `sealed`, `owner`, `commitment`, `phase`, transaction hashes and blocks, `reveal_after`, `reveal_before`, `years`, `expires_at`, `price_id`, `name_prepaid_code_id`, `issued_code_id`, `registered_at`, `failed_at`, `error`, `created_at`, `updated_at`.
|
||||
- Columns: `registration_id`, `label_hash`, `length`, `label`, `secret`, `records`, `intent_nonce`, `intent_deadline`, `intent_signature`, `sealed`, `owner`, `commitment`, `phase`, `version`, `live`, `order_hash`, `moves`, `links_refused_at`, `reveal_after`, `reveal_before`, `years`, `duration`, `expires_at`, `price`, `name_prepaid_code_id`, `issued_code_id`, `registered_at`, `failed_at`, `error`, `created_at`, `updated_at`.
|
||||
- `intent_nonce`, `intent_deadline` and `intent_signature` are null until the reveal.
|
||||
- Transaction hashes: every one sent, fee-bumped replacements included. Each is saved with its raw signed transaction once signed and before it is sent.
|
||||
- Transactions: every one sent, fee-bumped replacements included, is a `sx_badge_service_name_transactions` row, saved with its raw signed transaction once signed and before it is sent.
|
||||
- `version` grows with every change; `live` marks an open invoice, or a phase past `unpaid` other than `taken` and `code_issued`; `order_hash` covers the order's fields, for exact resends. Unique `commitment` and unique `(owner, label_hash)` among live rows.
|
||||
- `reveal_after` and `reveal_before`: the commit's block time plus the controller's ages, read at the commit.
|
||||
- `price_id`: the price paid for, replaced at retarget. Null when paid by code.
|
||||
- `duration`: the term in seconds the commitment was built with.
|
||||
- `price`: what the order is worth in cents, the chain's price when ordered, replaced at retarget. 0 when paid by code. There is no price table.
|
||||
- `name_prepaid_code_id`: the code paying. Null when paid.
|
||||
- `issued_code_id`: the code taken instead of a taken name.
|
||||
- The invoice links through `name_invoices.registration_id`.
|
||||
@@ -587,12 +517,13 @@ Migration `20261010_names` runs in SQLite and Postgres. The badge tables (`sx_ba
|
||||
- **`sx_badge_service_name_record_edits`:**
|
||||
- Columns: `label_hash`, `owner`, `nonce`, `key`, `value`, `deadline`, `signature`, `body_hash`, transaction hashes, `state`, `created_at`.
|
||||
- `owner` is the recovered signer. `value` is null for the clear form.
|
||||
- Transaction hashes: every one sent, each saved with its raw signed transaction once signed and before it is sent.
|
||||
- Transactions: every one sent, each a `sx_badge_service_name_transactions` row, saved with its raw signed transaction once signed and before it is sent. Only sent edits are rows.
|
||||
- `state`: `pending`, `mined`, `reverted` and the revert.
|
||||
- Unique on owner and nonce among pending and mined rows.
|
||||
- A row found applied on chain with no receipt for any of its hashes is `mined`, with no hash known to have mined.
|
||||
- The service answers identical repeats from the table after a restart. The per-name cap counts its rows.
|
||||
- **`sx_badge_service_name_invoices`:** `invoice_id`, `registration_id`, `price_id`, `provider_ref`.
|
||||
- The service answers identical repeats from the table after a restart. The per-name cap counts its rows of the last hour.
|
||||
- **`sx_badge_service_name_transactions`:** the registration or edit, `kind` (commit, register, edit), `raw`, `tx_hash`, `registrar_nonce`, `sent_at`, `mined_block`, `status`. The sender resumes from the rows with no receipt.
|
||||
- **`sx_badge_service_name_invoices`:** `invoice_id`, `registration_id`, `provider_ref`.
|
||||
- Today's invoice queries are badge-only. So each of these gains this table: `GET /api/invoice/:id`, cancel, settlement, the poller's unpaid and overdue scans and its expiry update, and the lookups by `provider_ref`.
|
||||
- Settling a name invoice queues its registration, instead of requiring a code hash. This happens in the transaction that marks the invoice paid; the invoice's and the registration's changes are published to the long poll only after that commit. It applies only to an invoice still open.
|
||||
- An invoice settled after it expired or was canceled leaves its registration `unpaid` and alerts the operator to refund. Its order page and history row say "Paid after the invoice closed; support refunds it", with the reference.
|
||||
@@ -613,23 +544,35 @@ names_export_dir = /srv/webapp-names
|
||||
names_hosts = simplex.domains
|
||||
|
||||
[names]
|
||||
resolver_url = http://127.0.0.1:8000
|
||||
; simplex once that namespace is deployed
|
||||
tld = testing
|
||||
; the paid years of a name, and when every term starts (the days before are free)
|
||||
years = 2
|
||||
term_start = 2027-01-01T00:00:00Z
|
||||
; how far an order's duration may be from years × 365 days + max(0, term_start − now)
|
||||
term_slack_seconds = 3600
|
||||
; the least time before revealBefore for which Try again reuses a live commitment
|
||||
reveal_margin_seconds = 900
|
||||
; how many times one order may move to another name or other links (/name)
|
||||
max_order_moves = 3
|
||||
|
||||
[chain]
|
||||
; the RPC node for every chain read and transaction: a local reth node with nimbus, or a hosted provider such as Alchemy
|
||||
rpc_url = http://127.0.0.1:8545
|
||||
chain_id = 1
|
||||
; SimplexController: commit, the commitment ages, reservedNames and rentPriceUSD
|
||||
controller = 0x…
|
||||
; SimplexSponsor: the EIP-712 domain of RegisterName, and registerWithCredit
|
||||
sponsor = 0x…
|
||||
; BaseRegistrarImplementation: approveWithSig and claimWithSig when redeeming (the controller does not expose it), and the registry, as its ens()
|
||||
; BaseRegistrarImplementation: available and ownerOf, approveWithSig and claimWithSig when redeeming (the controller does not expose it), and the registry, as its ens()
|
||||
base_registrar = 0x…
|
||||
; the default resolver, the one each Registration may name
|
||||
; the default resolver contract (SimplexResolver), the one each Registration may name: text records, nonces and setTextWithSig
|
||||
resolver = 0x…
|
||||
; the registrar key: pays gas only, the sponsor's allowance pays the fee
|
||||
service_key = replace-me
|
||||
confirmations = 3
|
||||
; the highest gas limit a dry run may ask for: a register with two records at the link bound needs about 6.7M
|
||||
gas_limit_cap = 8000000
|
||||
```
|
||||
|
||||
## 11. The page
|
||||
@@ -638,9 +581,9 @@ This section describes the names webapp: its routes, its code, its wording and i
|
||||
|
||||
**Routes on simplex.domains:**
|
||||
- `/`: search;
|
||||
- `#/checkout`;
|
||||
- `#/names`: history;
|
||||
- `#/?name=&owner=`: opened from the app;
|
||||
- `#/checkout/links`, `#/checkout/code`, `#/checkout`: Links, Your name code, Check your order;
|
||||
- `#/names`: Your names;
|
||||
- `#/code`: Check your code;
|
||||
- `?order=<id>`: payment, registration, the owner code, and another name's search after `taken` (N8a);
|
||||
- `?registration=<id>`: a registration paid with a code.
|
||||
|
||||
@@ -652,29 +595,29 @@ This section describes the names webapp: its routes, its code, its wording and i
|
||||
Its parts:
|
||||
- `wallet.ts`: the crypto (§5);
|
||||
- `names.ts`: rules, the `Registration` encoding, the commitment, and the `RegisterName`, `SetText` and `ClearRecords` digests;
|
||||
- the views, from `mockups/domains.js` and `mockups/domains.css`;
|
||||
- the views, from ep's mockup of 2026-10-21 and the redesign spec ([`2026-10-19-webapp-redesign.md`](2026-10-19-webapp-redesign.md));
|
||||
- the Stripe publishable key, injected into the shell when the service exports the site.
|
||||
|
||||
**Naming.** The UI calls the owner code "your name code". It calls a prepaid code just "code" ("Check your code", "I have a code"). Prose and code say owner code and prepaid code.
|
||||
**Naming.** The UI calls the owner code "your name code" and a prepaid code a "prepaid code" ("Check your code" takes both). Prose and code say owner code and prepaid code.
|
||||
|
||||
**The look.**
|
||||
- It takes simplex.domains' fonts (GT Walsheim and Manrope), logos, brand blue, and a light/dark switch as simplex.domains has.
|
||||
- It adds the store's own icons and a palette of its own in the same spirit, light and dark, laid out as a modern registrar.
|
||||
- A frosted header holds the store's tabs (Search, Your names, SimpleX badges), Check a code and the toggle.
|
||||
- The first page is a large headline over one search bar with a `.simplex` tag and Search. Below it are a "how it works" grid and the investor card.
|
||||
- Checkout and the pages after it carry a five-step tracker and a sticky order card. The steps are search, details, name code, payment, registration. Opened from the app, the tracker has no name-code step. A prepaid code has no payment step.
|
||||
- Endings are single centred cards.
|
||||
**The look** follows ep's mockup (artifact 86a64565):
|
||||
- simplex.domains' fonts (GT Walsheim and Manrope), logos, brand blue, light and dark.
|
||||
- The landing's header: the logo, then "Check your code", "Your names" and the theme icon; on a phone, the symbol logo and a full-screen menu.
|
||||
- Home: the headline "Your SimpleX domain", its subtitle, and one search field. The answer is one 72 px row; the headline and field never move when it appears.
|
||||
- The purchase steps (Links, Your name code, Check your order, payment, Registering, is yours) share one frame: the heading row at the same height, the main button at the same place, Back to the left of the heading.
|
||||
- Cards are flat, with a 1 px line. Endings are one screen each, with one notice and one button.
|
||||
- Every page closes with the investor line and its Wefunder link.
|
||||
|
||||
The board's frames below predate the mockup: the checkout's "I have a code" (N3, K4) and the app-opened flow (L1–L3) are gone.
|
||||
|
||||
| | | |
|
||||
|---|---|---|
|
||||
| <img src="screens/N1.jpg" width="260"> | <img src="screens/N2.jpg" width="260"> | <img src="screens/N3.jpg" width="260"> |
|
||||
| **N1** search | **N2** available, with its price | **N3** checkout, I have a code |
|
||||
| <img src="screens/N6a.jpg" width="260"> | <img src="screens/N7.jpg" width="260"> | <img src="screens/N2a.jpg" width="260"> |
|
||||
| **N6a** the registration track | **N7** yours, with the owner code | **N2a** taken: what uses it |
|
||||
| <img src="screens/K1.jpg" width="260"> | <img src="screens/K2.jpg" width="260"> | <img src="screens/K4.jpg" width="260"> |
|
||||
| **K1** check your code | **K2** covered: $0 | **K4** a code at checkout |
|
||||
| <img src="screens/L1.jpg" width="260"> | <img src="screens/N12.jpg" width="260"> | <img src="screens/N8.jpg" width="260"> |
|
||||
| **L1** opened from the app | **N12** your names and codes | **N8** someone was faster |
|
||||
| <img src="screens/N1.jpg" width="260"> | <img src="screens/N2.jpg" width="260"> | <img src="screens/N2a.jpg" width="260"> |
|
||||
| **N1** search | **N2** available, with its price | **N2a** taken: what uses it |
|
||||
| <img src="screens/N6a.jpg" width="260"> | <img src="screens/N7.jpg" width="260"> | <img src="screens/N8.jpg" width="260"> |
|
||||
| **N6a** the registration track | **N7** yours, with the owner code | **N8** someone was faster |
|
||||
| <img src="screens/K1.jpg" width="260"> | <img src="screens/K2.jpg" width="260"> | <img src="screens/N12.jpg" width="260"> |
|
||||
| **K1** check your code | **K2** covered: $0 | **N12** your names and codes |
|
||||
|
||||
## 12. Deployment
|
||||
|
||||
@@ -693,9 +636,10 @@ What changes:
|
||||
- `/webhooks/*` (`/webhooks/btcpay`, `/webhooks/stripe`) stays on badges.simplex.chat only, as today. BTCPay and Stripe send every event there, names payments included.
|
||||
- `apps/simplex-badge-service/README.md` (`apps/simplex-store-service/` after the rename) gains the host blocks.
|
||||
- The names site gets a CSP like the badges one, with `data:` added to its `img-src` (`'self' data: https://*.stripe.com`) for the link profiles' pictures.
|
||||
- Stripe.js runs on simplex.domains itself, in the document that holds the codes in `localStorage`. Accepted by the owner (2026-10-20): no Checkout redirect and no separate card origin.
|
||||
- No inline styles are allowed. So the views move the mockups' `style` attributes into classes or set them through `node.style`.
|
||||
- **All-in-one mode** picks the site by `Host` against `names_hosts`.
|
||||
- **Chain:** the resolver (`:8000`) and reth (`:8545`) run on loopback when they share the host.
|
||||
- **Chain:** the service needs only the RPC node. A reth node with nimbus on the same host listens on loopback (`:8545`). The SMP relays and the app read the chain from their own nodes; there is no resolver service (owner, 2026-10-21). A hosted provider such as Alchemy is reached over HTTPS; its URL carries an API key, so it stays in the mounted ini.
|
||||
- **Unchanged:** the database `badge_service` and the `sx_badge_service_` prefix.
|
||||
|
||||
## 13. Regenerating the board
|
||||
@@ -720,29 +664,23 @@ PLAYWRIGHT=/tmp/pw/node_modules/playwright/index.mjs node plans/names-codes/mock
|
||||
|
||||
These points are not decided yet.
|
||||
|
||||
1. **Opened from the app (L1–L3).** The owner must sign the `RegisterName` intent, and here the owner is the app's wallet. There are two options:
|
||||
- The app signs it. The page hands the app the registration to sign, then reveals with the app's signature.
|
||||
- The flow waits until the app buys names itself.
|
||||
2. **Funding and cost.** `registerWithCredit` spends the registrar's allowance on `SimplexSponsor`, so the fee is not paid in ETH. The registrar key pays gas, while the buyer paid a fixed USD price. So a gas spike is the service's loss.
|
||||
3. **Signing and the primitives.**
|
||||
- simplexmq `names` (#1887) and `ab/name-registration-primitives-sponsored` (EIP-712, signer recovery) have to reach the simplexmq the service builds against.
|
||||
- The primitives branch (`ab/names-registration-primitives-sponsored`) has to land. Its `cabal.project` still points at a local simplexmq.
|
||||
4. **Coordination with #7530.**
|
||||
1. **Funding and cost.** `registerWithCredit` spends the registrar's allowance on `SimplexSponsor`, so the fee is not paid in ETH. The registrar key pays gas, while the buyer paid a fixed USD price. So a gas spike is the service's loss. The allowance also pays the free days until 1 January 2027.
|
||||
2. **The primitives branch** (`ab/names-registration-primitives-sponsored`) has to land in `names`. Its simplexmq pin, `4e6f5586`, already has EIP-712, signer recovery and transaction signing.
|
||||
3. **Coordination with #7530.**
|
||||
- The StoreService rename: #7530 proposes `SHC`/`SHP`/`SHE`, and this plan uses `STC`/`STP`/`STE`.
|
||||
- Codes in the app.
|
||||
- Codes in the app, and the app buying names itself (#7530 §13.6), which replaces the dropped web flow opened from the app.
|
||||
- Four code decisions that differ from #7530 §6/§9:
|
||||
- the `SN7-` format, without years in the text;
|
||||
- a table of their own instead of a kind column;
|
||||
- no codes sold on the web;
|
||||
- the service's table, not the text, decides what a code covers. So the app cannot check one offline.
|
||||
5. **Coordination with Alain's branch.**
|
||||
4. **Coordination with Alain's branch.**
|
||||
- Its `NameCode` becomes `NameOwnerCode` under the `NameCode` sum type (§7).
|
||||
- Its `RegisterName` intent carries PR #35's nonce since `965046fe1`, and the duration since `f85d53e91` (simplex-chat PR #7693, for ens-contracts PR #36). The service checks the nonce against `SimplexSponsor.nonces(owner)`. simplexmq's `EIP712` test vector (PR #1941) is a generic one, still the old type under a `SimpleX`/`2` domain; the live intent is checked against viem in `NameRegistrationTests`.
|
||||
- The StoreService rename moves its `BadgeService/Names/` modules.
|
||||
6. **The investor amount** on the investor card, $500+ as on simplex.domains today, is a placeholder.
|
||||
7. **The library's releases.** The names webapp pins a published `@simplex-chat/store`. A library change that names needs is published before the submodule bumps its pin.
|
||||
8. **Payment methods.** The current simplex.domains page lists In-app, Bitcoin and Monero. This design offers card, Bitcoin and Monero. In-app purchase is deferred in #7530.
|
||||
9. **Fetching link profiles** puts a load on the service that grows with searches. The cache, a timeout and a rate limit of its own bound it.
|
||||
10. **simplex.domains today** is a countdown page. The store replaces it at launch, in the same fonts and brand colour. The mockups' headline is "Own your name on SimpleX", where today's page says "Your SimpleX domain".
|
||||
11. **Editing links later** costs the service a relayed transaction per changed key. No on-chain credits meter it. PR #35 has `setTextWithSig` and `clearRecordsWithSig`, not the edit credits of the names v2 RFC (`origin/ab/names-gui:docs/rfcs/2026-08-05-simplex-names-v2.md`). The service's own cap bounds it.
|
||||
12. **The link separator** inside one `simplex.contact`/`simplex.channel` record is `;`. It is the `LINK_SEPARATOR` that `snrc-resolve.py` splits on, and it must match the dApp's `SIMPLEX_LINK_SEPARATOR`. The page and the service encode with it.
|
||||
5. **The library's releases.** The names webapp pins a published `@simplex-chat/store`. A library change that names needs is published before the submodule bumps its pin.
|
||||
6. **Payment methods.** The store offers card, Bitcoin and Monero. Ethereum buyers are sent to the dApp from Check your order; its host (`app.simplex.domains`) is not yet confirmed. In-app purchase is deferred in #7530.
|
||||
7. **Fetching link profiles** puts a load on the service that grows with searches. The cache, a timeout and a concurrency bound of its own limit it.
|
||||
8. **Editing links later** costs the service a relayed transaction per changed key. No on-chain credits meter it. PR #35 has `setTextWithSig` and `clearRecordsWithSig`, not the edit credits of the names v2 RFC (`origin/ab/names-gui:docs/rfcs/2026-08-05-simplex-names-v2.md`). The service's own cap bounds it.
|
||||
9. **The link separator** inside one `simplex.contact`/`simplex.channel` record is `;`. The page and the service write one link per key; readers (the service, the app's `firstNameLink`, the dApp's `SIMPLEX_LINK_SEPARATOR`) split on `;`, so a record written elsewhere with several links still reads.
|
||||
10. **"Use in the app"** describes the app's menu path for setting the name; the exact path is to be confirmed with the app owners.
|
||||
@@ -1,15 +1,19 @@
|
||||
# SimpleX names store: purchase and registration on the web
|
||||
|
||||
This plan builds the SimpleX names store at simplex.domains in stages 0–7, and says how each stage is verified.
|
||||
The words it uses (owner code, owner wallet, commitment, intent, sponsor, registrar key, nonce, sealed, resolver, phase, the lag, long poll) are defined in the spec, [Words used here](2026-10-09-name-codes.md#words-used-here).
|
||||
The words it uses (owner code, owner wallet, commitment, intent, sponsor, registrar key, nonce, sealed, RPC node, resolver contract, phase, long poll) are defined in the spec, [Words used here](2026-10-09-name-codes.md#words-used-here).
|
||||
|
||||
Two documents of 2026-10-21 go with it:
|
||||
- [`2026-10-21-store-api.md`](2026-10-21-store-api.md), the API contract. It is authoritative for every endpoint, field, error and limit, for the Haskell service and the webapp's mock alike. Where this plan and the contract differ, the contract wins.
|
||||
- [`2026-10-21-store-service-tasks.md`](2026-10-21-store-service-tasks.md), the backend tasks for stages 2–4 and the service side of 6–7.
|
||||
|
||||
## Context
|
||||
|
||||
This section says what the store does, what it builds on, and which facts about the contracts shape the design.
|
||||
|
||||
SimpleX names (`<label>.simplex`, an ENS fork on Ethereum) get their own store at **simplex.domains**:
|
||||
1. The buyer searches a name and sees whether it is free, and at what price.
|
||||
2. The buyer pays by card, Bitcoin or Monero, or redeems a code.
|
||||
1. The buyer searches a name and sees whether it is free, and at what price. The price is the chain's.
|
||||
2. The buyer pays by card, Bitcoin or Monero, or with a prepaid code entered on Check your code. An Ethereum buyer is sent to the dApp.
|
||||
3. The browser makes a wallet for the name, the owner wallet. The page shows it as an owner code ("your name code" on the page: `SN-` and 30 characters). The service registers the name to that wallet's address, paying the fee and gas.
|
||||
4. The buyer keeps the code. Redeeming it in the app moves the name to the app's wallet.
|
||||
|
||||
@@ -36,27 +40,33 @@ Facts that shape the design:
|
||||
- So the service pays and sends both transactions, while the owner is the browser's address.
|
||||
- The sponsor spends the registrar's allowance. So the service holds no ETH for fees, only gas.
|
||||
- **The owner code** is `SN-` and 30 Crockford base32 characters: the wallet's 128-bit entropy and the first 22 bits of its SHA-256.
|
||||
- The wallet's key is the BIP-44 key at `m/44'/60'/0'/0/0` of the 12-word BIP-39 phrase with that entropy.
|
||||
- A BIP-44 wallet importing the code's BIP-39 phrase holds the name and can edit its records, paying gas.
|
||||
- The wallet's key is the BIP-44 key at `m/44'/60'/0'/0/0` of the BIP-39 seed from that entropy, as the app derives it. That derivation must not change.
|
||||
- The BIP-39 phrase is only an internal step. No screen shows it, and there is no 12-word export to another wallet.
|
||||
- Moving the name still needs the recipient to claim it. PR #35's `transferFrom` reverts `RecipientMustBeCaller` unless the recipient or a registrar controller calls.
|
||||
- **Redeeming in the app** moves the name on `BaseRegistrarImplementation`, EIP-712 domain `SimplexNames` (PR #35). No phrase import is needed. The steps:
|
||||
- **Redeeming in the app** moves the name on `BaseRegistrarImplementation`, EIP-712 domain `SimplexNames` (PR #35). The steps:
|
||||
1. The code's wallet signs `ApproveName(from, to, tokenId, ephemeralPubKey, viewTag, nonce, deadline)` with `nonce = nonces(from)`. The key is empty and the tag zero, so there is no stealth announcement.
|
||||
2. The service relays `approveWithSig`.
|
||||
3. Once it is mined, the app's wallet signs `ClaimName(from, to, tokenId, offer, nonce, deadline)` with `offer = offers(tokenId)` read then, and `nonce = nonces(to)`. Each approval increments the offer, and `claimWithSig` reverts `NotOffered` on an older value.
|
||||
4. The service relays `claimWithSig`.
|
||||
5. The registry node follows the token. The records stay.
|
||||
- **Term:**
|
||||
- A paid name is registered for 2 years, the controller's minimum: 730 days, `MIN_REGISTRATION_DURATION` in PR #35's `SimplexController`, also in `simplexmq: protocol/simplex-messaging.md`.
|
||||
- A code registers for its own term.
|
||||
- A year on chain is 365 days (31,536,000 seconds, as simplexmq's `protocol/simplex-messaging.md` prices it). So the duration is `years × 365` days.
|
||||
- The term runs from registration.
|
||||
- **Availability and records** come from the resolver (`simplexmq: scripts/resolver`):
|
||||
- `GET /v2/resolve/[<keccak256(label) as 64 lowercase hex, no 0x>].<tld>` → `available | registered{nameRecord} | reserved`.
|
||||
- `nameRecord` lists `simplexContact` and `simplexChannel` links.
|
||||
- A paid name is paid for 2 years (the service's `[names] years`, at least 2: the service refuses to start below it), the controller's minimum: 730 days, `MIN_REGISTRATION_DURATION` in PR #35's `SimplexController`, also in `simplexmq: protocol/simplex-messaging.md`.
|
||||
- A prepaid code pays for its own `years`, at least 2.
|
||||
- A year on chain is 365 days (31,536,000 seconds, as simplexmq's `protocol/simplex-messaging.md` and `SimplexPriceOracle` count it).
|
||||
- The term starts on 1 January 2027 (owner, 2026-10-20). The contract registers for a duration from the block's time (`expiries = block.timestamp + duration`), so the page adds the days until then: `duration = years × 365 days + max(0, termStart − now)`. The service owns `termStart` (`[names] term_start`), gives it in the search answer, and checks each order's duration within `[names] term_slack` (1 hour).
|
||||
- The duration is inside the commitment, so a registration that goes through later than its order ends later by that delay.
|
||||
- **Prices** come from the chain (owner, 2026-10-20). `SimplexPriceOracle` holds cents a year per label length, with a base for the lengths it does not list (`priceCentsPerYear`, `setPrices`). The controller exposes it as `rentPriceUSD(label, duration)`, prorated by the second, with no premium. The sponsor spends the registrar's allowance by the same figure.
|
||||
- The service quotes `rentPriceUSD` for the paid years only. It calls it with a placeholder label of the name's length, since the oracle reads only the length and the service has only the hash until the reveal.
|
||||
- There is no service price table and no per-name price.
|
||||
- **Availability and records** come from the chain. The service reads them itself over JSON-RPC, from a reth node or a hosted provider such as Alchemy:
|
||||
- availability: `available(uint256(labelHash))` on `BaseRegistrarImplementation`, and `reservedNames(labelHash)` on `SimplexController`;
|
||||
- the owner and the resolver contract: the registry's `owner(node)` and `resolver(node)`, with `node = keccak256(namehash(tld) ‖ labelHash)` and the registry `baseRegistrar.ens()`;
|
||||
- the links and the edit nonce: `text(node, "simplex.contact")`, `text(node, "simplex.channel")` and `nonces(owner)` on `SimplexResolver`.
|
||||
- Every reader goes to the chain itself: the service, the SMP relays and the app each read from their own node (reth with nimbus, owner 2026-10-21). There is no resolver service.
|
||||
- **Name rules:**
|
||||
- Characters: `a-z 0-9 -` after folding case. simplexmq's `nameLabelP` (`SimplexName.hs`) also takes A–Z and lower-cases after parsing.
|
||||
- Hyphens: each hyphen has a letter or digit on both sides, as `nameLabelP` requires. So there is no leading, trailing or double hyphen anywhere.
|
||||
- #7390's `validLabel` (`origin/ab/names-mvp-edits`, `Names/Protocol.hs`, not merged into the branches this builds on) refuses `--` only at positions 3–4. It is tightened to the hyphen rule above with Alain, since a name with `--` elsewhere would sell but never resolve.
|
||||
- The service checks a label with simplexmq's own parser: `label.tld` decoded as `SimplexDomain` (`strDecode`, which runs `nameLabelP` on each part), with no subdomains, its `domain` equal to the label, already lower case, after a length check of 6–63. `nameLabelP` is not exported, and a bare label does not decode ("domain requires TLD"), so the TLD is appended. #7390's `validLabel` (`origin/ab/names-mvp-edits`, not merged into the branches this builds on) refuses `--` only at positions 3–4, so it is not used.
|
||||
- At least 6 characters, from the controller's `minCharLength`.
|
||||
- At most 63, from simplexmq's label limit.
|
||||
- simplexmq lower-cases a name when parsing it (`SimplexDomain`'s `strP`, `labelHash`), and `validLabel` admits only a–z. So the page folds upper case as typed.
|
||||
@@ -78,7 +88,7 @@ Facts that shape the design:
|
||||
- `setDefaultResolver`, `setBeneficiary`;
|
||||
- the guardian's allowance for the registrar.
|
||||
|
||||
## Decisions (owner, 2026-10-09 and 2026-10-10)
|
||||
## Decisions (owner, 2026-10-09, 2026-10-10, 2026-10-15, 2026-10-20 and 2026-10-21)
|
||||
|
||||
The owner's decisions on the store, one per topic. The stages below implement them.
|
||||
|
||||
@@ -89,56 +99,54 @@ The owner's decisions on the store, one per topic. The stages below implement th
|
||||
| Code | Two webapps sharing logic, not screens. Badges stay in this repo, built on the library. Names live in the simplex.domains repo in that site's look, and are a submodule of simplex-chat (`apps/simplex-domains`). The shared logic (API client, payments, storage, codes, QR) is `packages/simplex-chat-store`, published as `@simplex-chat/store`. One service exports both sites |
|
||||
| Service | The badge service is renamed **StoreService** (`apps/simplex-store-service`, `Simplex.Chat.StoreService`) |
|
||||
| Search | One search bar, as registrars draw it (dynadot.com): a search icon, the name, a `.simplex` tag and a Search button inside. Enter searches too. The name is judged when searching, not while typing. The rules line ("6 letters or more…") appears only after such a mistake |
|
||||
| Results | Available, with its price. Taken, with what uses it (address and/or channel: picture, display name, link, deep link). Reserved, with "Contact the SimpleX team" |
|
||||
| Price | Internal and changeable, possibly per name. The page shows only the searched name's price |
|
||||
| Term | 2 years for a paid name, fixed. No term screen. A code registers for its own `years` (investor codes: 5 or 3, as simplex.domains says today) |
|
||||
| Results | One row. Available, with its price for 2 years and Register. Taken, with the chats it opens as small icons (the app's default icon per kind, or the profile picture), each opening a popup modelled on the app's connection-plan alert: picture, display name, `@name.simplex` or `#name.simplex`, full name, a channel's subscriber count, the link's QR on desktop, Open in SimpleX and Copy link (ep's mockup, 2026-10-21). Taken with no links: "Taken". Reserved, with "Contact the SimpleX team". So the name answer carries, per linked chat, its kind (contact, channel, business, bot), display name, full name, picture if any and a channel's subscriber count |
|
||||
| Price | From the chain: `controller.rentPriceUSD` for the paid years, no service price table (2026-10-20). The page shows only the searched name's price. The launch list is $100, $200 and $400 for 2 years: `setPrices` with base 5000 cents a year, 7 letters 10000, 6 letters 20000, no per-name prices (2026-10-21) |
|
||||
| Term | 2 paid years for a paid name, no term screen. A prepaid code pays for its own `years` (investor codes: 5). The term starts on 1 January 2027: the page adds `max(0, termStart − now)` to the duration, and the service owns `termStart` and checks the duration within a slack (2026-10-20). The flat 30 bonus days are gone |
|
||||
| No hold, hash until reveal | The service sees only keccak(label) and its length until the register step, so it holds nothing. Two buyers may pay for one name, and the later one gets the taken outcome with the payment kept. Once its own register is sent, search answers registered |
|
||||
| Wallet | The browser generates a wallet for each order: 128-bit entropy, shown as an owner code (`SN-…`). A taken order retargeted to another name keeps it. All key cryptography is in the browser. The code is shown before paying and confirmed as saved. Everything else is derived from it, so the code alone finishes or restores an order if the browser loses its storage. For that, the order keeps the name and links sealed under a key from the code |
|
||||
| Wallet | The browser generates a wallet for each order: 128-bit entropy, shown as an owner code (`SN-…`). A taken order retargeted to another name keeps it. All key cryptography is in the browser. The code is shown before paying and confirmed as saved. Everything else is derived from it, so the code alone finishes or restores an order if the browser loses its storage. For that, the order keeps the name and links sealed under a key from the code. The code is the only form shown: no 12-word phrase, and no export to another wallet (2026-10-15) |
|
||||
| Chain access | The service talks to the Ethereum RPC directly: a reth node, or a hosted provider such as Alchemy. It reads availability, owners, resolver contracts, text records and nonces itself, and sends every transaction (2026-10-15). There is no resolver service: the SMP relays and the app read the chain from their own node, reth with nimbus (2026-10-21) |
|
||||
| Registration | The browser sends the owner address and the commitment. After the wait it reveals the registration and signs the owner's `RegisterName` intent. The service sends `commit` and `registerWithCredit` from its registrar key, owner = that address. The page shows Committing → Waiting 60s → Registering, as in the app (canvas 9) |
|
||||
| Codes | Two kinds, one sum type in core. The owner code (`SN-` and 30 characters) is a wallet: whoever holds it owns the name. The prepaid code (`SN6-…`, `SN7-…`, `SN20-…`, 20 characters) pays for one name of that many letters or more. The number is a convention; the service's table is the source of truth. The page asks the service what a prepaid code is for |
|
||||
| Paying with a code | Checkout's order card has "I have a code" under Continue. A prepaid code that covers the name makes its price 0 |
|
||||
| Check your code | The investor card on the first page and Check a code in the header open a popup that asks what a code covers. While it is applied, a covered name's price shows as $0 (investor codes) |
|
||||
| Codes | Two kinds, one sum type in core. The name code (owner code, `SN-` and 30 characters) is a wallet: whoever holds it owns the name. The prepaid code (`SN6-…` to `SN9-…`, 20 characters after the length) pays for one name of at least that many letters for its years. The number is a convention; the service's table is the source of truth. The page asks the service what a prepaid code is for |
|
||||
| Paying with a code | Codes are entered only on Check your code (2026-10-21). A prepaid code kept there makes a covered name's price $0 at search and on Check your order, with its years. There is no "I have a code" at checkout |
|
||||
| Check your code | A text link in the top bar opens a whole screen with one code field (2026-10-21). A prepaid code shows what it covers and is kept in Your names. A name code shows the name it owns and adds it to Your names. Anything else says it is not a SimpleX names code |
|
||||
| Codes not sold | Codes are not sold on the web. They come from the app, from the operator (CLI, group), or from the page for a taken order, which the service stores as a hash. Each registers one name. Codes from the app are an owner decision: #7530 defers in-app purchase, and the names v2 RFC (`origin/ab/names-gui:docs/rfcs/2026-08-05-simplex-names-v2.md`) has in-app purchase buy a name directly |
|
||||
| History | "Your names" lists names with Show code and Edit links, and prepaid codes. "Add a code" adds a prepaid code to the list with what it covers. "Restore with a name code" finds an order from its owner code alone (N7c) |
|
||||
| Investor card | "Investors get a name free": the Wefunder sentence and link, with a names amount ($500+, as on simplex.domains today) and Check your code. A short version sits in the checkout's order card. The amount is a placeholder for now (a constant, as `crowdfunding.ts`) |
|
||||
| History | "Your names" lists names with Name code and Edit links, and prepaid codes. Codes are added through Check your code. A name code there replaces Restore: it finds the code's orders with `find` and adds them (2026-10-21) |
|
||||
| Investors | A line closing every page, as badges: "Invest in SimpleX Chat to get a name for 5 years." and a link to Wefunder. A link, not a button (2026-10-20) |
|
||||
| Storage | Prepaid codes in their own table, not a kind column on the badge code table. Registrations in their own table. Owner codes are never stored by the service |
|
||||
| Mockups | The board and screens generated by `plans/names-codes/mockups/`. The names screens come from `domains.js` on simplex.domains' fonts, logos and brand blue. The badges landing comes from the real badges webapp |
|
||||
| Crypto in the browser | Vendor `@noble/secp256k1` and `@noble/hashes` (audited, MIT, no dependencies) at pinned versions, plus the BIP-39 English wordlist. WebCrypto covers SHA-256, PBKDF2-SHA512, HMAC-SHA512, HMAC-SHA256 and AES-256-GCM. They go into the names webapp in the simplex.domains repo. So the vendored crypto does not touch the badges webapp's rule (no dependencies beyond `typescript` and `@types/node`, `web/README.md`) |
|
||||
| Address links | Optional at checkout: a contact address and/or a channel link, one per key on the page. A record set elsewhere may hold several, joined with `;`, and the page keeps the others when it edits one. Links are previewed through the service, and set by the register transaction itself (the commitment's `data`). Later edits: the browser signs each changed key with the name's key (`SetText`), and the service relays it as PR #35's `SimplexResolver.setTextWithSig`, paying the gas |
|
||||
| Opened from the app | The app's link carries its own owner (`simplex.domains/#/?name=<label>&owner=<address>`), so the name is registered straight to the app's account. The page shows that address before paying. No owner code is made, so the order carries no `sealed`. There are no links and no "I have a code", since the app sets links and redeems codes itself. Open: the `RegisterName` intent must be signed by that owner, the app's wallet, so the app has to sign it |
|
||||
| Ethereum | Check your order, the page before payment, has one line, "Paying with ETH? Register in the dApp", linking the dApp (`https://app.simplex.domains`, host not yet confirmed). No order is made. ep, 2026-10-21 08:43: "remove dapp footer from here, it will be on invoice", recorded in the owner decisions of 2026-10-21, replacing the 2026-10-20 placement on the available answer |
|
||||
| Card | Stripe.js runs on the page's own origin, beside the stored codes. Accepted and documented; no Checkout redirect and no separate card origin (2026-10-20) |
|
||||
| Mockups | ep's mockup of 2026-10-21 (artifact 86a64565) is the visual reference for every screen. The board and screens generated by `plans/names-codes/mockups/` predate it. The names screens come from `domains.js` on simplex.domains' fonts, logos and brand blue. The badges landing comes from the real badges webapp |
|
||||
| Crypto in the browser | Vendor `@noble/secp256k1` and `@noble/hashes` (audited, MIT, no dependencies) at pinned versions, plus the BIP-39 English wordlist, which the seed needs (the phrase is never shown). WebCrypto covers SHA-256, PBKDF2-SHA512, HMAC-SHA512, HMAC-SHA256 and AES-256-GCM. They go into the names webapp in the simplex.domains repo. So the vendored crypto does not touch the badges webapp's rule (no dependencies beyond `typescript` and `@types/node`, `web/README.md`) |
|
||||
| Address links | Optional, on their own step before the name code: one field that takes a contact address or a channel link, its kind detected, and a second field for the other kind. One short link per key. They are set before paying and read-only inside an order (2026-10-20). Links are previewed through the service, and set by the register transaction itself (the commitment's `data`). Later edits in Your names: the browser signs each changed key with the name's key (`SetText`), and the service relays it as PR #35's `SimplexResolver.setTextWithSig`, paying the gas. A record set elsewhere may hold several links joined with `;`; an edit replaces the whole key |
|
||||
| Opened from the app | Dropped (2026-10-21). The app will buy names itself (#7530 §13.6). Every web order has an owner code |
|
||||
|
||||
## The names store, screen by screen
|
||||
|
||||
What the buyer sees, in order, and the outcomes off the main path.
|
||||
What the buyer sees, in order, and the outcomes off the main path. ep's mockup (2026-10-21) draws every step; the redesign spec is [`2026-10-19-webapp-redesign.md`](2026-10-19-webapp-redesign.md).
|
||||
|
||||
1. **Search** (first screen): the search bar with `.simplex` and Search. A mistake shows the rules line. Search sends keccak(label) and its length, `GET /api/name/:hash?length=7`, and the answer appears on the same screen:
|
||||
- available, with its price, or $0 when an applied code covers it;
|
||||
- taken, with "Used by" cards, or taken and pointing nowhere;
|
||||
- reserved;
|
||||
- check failed or rate limited.
|
||||
|
||||
Below it: how it works, what a name does, and the investor card with "Check your code".
|
||||
2. **Checkout:** the optional links (N3g) and the payment method, beside an order card. The order card has the term (2 years, or the code's), the total, Continue, and "I have a code", which opens the code entry. When a code covers the name, the total is $0 and the payment method reads "Paid with your code".
|
||||
3. **Payment:** the names app's own payment pages, on the shared payment logic: crypto with QR and the held rate, card, and their endings, with "Get a new invoice". Skipped when a code pays.
|
||||
4. **Wallet and registration:**
|
||||
- At checkout the browser generates the owner code. It derives the address and the commitment secret from it (`secret = HMAC-SHA256(BIP-39 seed, "simplex.domains commitment " + label)`), and stores them with the name locally.
|
||||
- Continue at checkout first shows the code (N3h), whether paid or by prepaid code. It must be saved, confirmed with a checkbox, before the order is created, since nothing else can recover the name.
|
||||
- The page sends the address, the name's hash and length, the commitment and `sealed` with the order.
|
||||
- After payment the page shows the three steps, ticked as they complete, with transaction links.
|
||||
- After the wait the page reveals the registration and signs the `RegisterName` intent, so it must be open then. Reopening the link finishes it, and another browser asks for the code (N7b).
|
||||
5. **Registered:** "Registered for 2 years, until 9 October 2028, to 0x014f…9b73" (a prepaid code's own years when paid by one).
|
||||
- The owner code, Copy, and a QR for the app to scan.
|
||||
- Next steps: redeem it in the app, point the name at an address, keep the code safe.
|
||||
1. **Search** (home): the simplex.domains landing with one search field, `.simplex` and a round search button inside it. A mistake shows the rules line. Search sends keccak(label) and its length, `GET /api/name/:hash?length=7`, and the answer is one row under the field:
|
||||
- available: "$100 / 2 years" and Register, or $0 and the code's years when a kept prepaid code covers it, with a line when the name is shorter than the code allows;
|
||||
- taken: the chats it opens as icons, each opening its popup, then "Taken"; or just "Taken";
|
||||
- yours: the icons and "Your name", linking Your names;
|
||||
- reserved, check failed, rate limited: one line each.
|
||||
2. **Links** (optional): one link field that takes a contact address or a channel link, previewed with `POST /api/link/preview`, and "Add a channel link" (or a contact address) for the other kind. Skip for now, or Continue.
|
||||
3. **Your name code:** the browser generates the owner code and shows it hidden, with Show and Copy, and "I have saved my name code", which enables Continue. Nothing else can recover the name.
|
||||
4. **Check your order:** the name with its chats, Term (2 years, or the code's), "Free until 1 January 2027" with the days, Registered until, the total; the links read-only; Pay with Card, Bitcoin or Monero; "After paying, keep this page open, or reopen its link within 24 hours"; "Paying with ETH? Register in the dApp". With a covering prepaid code: "Register with your code", total $0.
|
||||
- The page derives the address and the commitment secret from the code (`secret = HMAC-SHA256(BIP-39 seed, "simplex.domains commitment " + label)`), builds the duration and the commitment, and sends the address, the name's hash and length, the duration, the commitment and `sealed` with the order.
|
||||
5. **Payment:** Bitcoin or Monero with QR, amount, address and the held rate; or Stripe's card form. Cancel this invoice. Skipped when a code pays.
|
||||
6. **Registering:** Committed → Waiting → Registering, with the wait counting down. After the wait the page reveals the registration and signs the `RegisterName` intent, so it must be open then, or reopened within 24 hours. On another browser the order link asks for the name code.
|
||||
7. **Is yours:** "alicebakery.simplex is yours. Registered until 31 December 2028" (the view's `expiresAt`: 365-day years from 1 January 2027, plus the time from the order to the register), "Use in the app" with the steps that work in the app today, and the name code, hidden until shown.
|
||||
|
||||
**Outcomes off the path:**
|
||||
- **Taken between search and registration** (by another registrar, or by another order here that registered first): the order keeps its value.
|
||||
- Paid: the buyer has two choices.
|
||||
- Register another name at most its price, searched on the order's own page. A cheaper one refunds nothing.
|
||||
- Or take a code for the taken name's length (8+ for longer names) for 2 years. The page generates it, and the service stores its hash.
|
||||
- Register another name at most its price, searched on the order's own page. A cheaper one refunds nothing, which the page says first.
|
||||
- Or take a prepaid code that pays for any name no dearer than the order, for the order's years. The page derives it, and the service stores its hash.
|
||||
- By code: the code is released, unspent, for another name.
|
||||
- **Registration failed after paying:** the service's own retries gave up. The page shows "Paid, not registered", and Try again charges nothing. Refunds are manual, by support.
|
||||
- **A code that does not work:** invalid or revoked, already used, reserved by another unfinished registration, expired, not yet paid, or not covering this name (the name is shorter than the code's length).
|
||||
- **Payment endings:** the badge screens.
|
||||
- **Registration failed after paying:** the service's own retries gave up. The page shows "Not registered yet", and Try again charges nothing. Refunds are manual, by support.
|
||||
- **A code that does not work** (on Check your code, or when an order or Try again spends it): unknown, revoked, already used, held by another unfinished registration, expired, not yet paid, or not covering this name.
|
||||
- **Invoice closed:** expired, cancelled, partly or late paid: one page, with Get a new invoice, which keeps the name code and links.
|
||||
- **Payment endings:** the badge screens' cases, on the names pages.
|
||||
|
||||
## Stages
|
||||
|
||||
@@ -158,7 +166,6 @@ The board's sections, as `mockups/layout.mjs` lists them:
|
||||
- simplex.domains: registering a name start to finish, what a search can find, and before paying (links, then the owner code);
|
||||
- paying with a code: check, apply, and codes that do not work;
|
||||
- when registering does not go through;
|
||||
- opened from the app;
|
||||
- where it meets the app (redeeming the code; grey, not built here);
|
||||
- refused at checkout;
|
||||
- the other ways a payment ends (the badges store's endings, on the names store's pages), with the owner-code pages;
|
||||
@@ -207,22 +214,23 @@ It builds on `origin/ab/names-registration-primitives-sponsored`. That branch al
|
||||
- The branch's `parseNameCode` and `formatNameCode` handle owner codes only. They become `parseNameOwnerCode` and `formatNameOwnerCode`, under the sum type's dispatching ones.
|
||||
- Their uses in `tests/BadgeTests.hs` and `WebUIMock.hs` follow.
|
||||
- Agree the rename with Alain.
|
||||
- **Prepaid codes** (`randomNamePrepaidCode`, `namePrepaidCodeHash`):
|
||||
- **Prepaid codes** (`randomNamePrepaidCode`, `namePrepaidCodeHash`), `SN6-…` to `SN9-…` in use:
|
||||
- the length (1–2 digits), then 20 Crockford base32 characters;
|
||||
- a Luhn mod-32 check over the length digits and the payload;
|
||||
- the length is shown, not trusted: the table row decides;
|
||||
- the canonical form is hashed with SHA-256.
|
||||
- **`Simplex.Chat.Names.Protocol`:** #7390's `validLabel`, tightened to simplexmq's hyphen rule, is the one character and hyphen check. The length is checked separately.
|
||||
- Its branch is not merged into #7530's or Alain's sponsored branch. So this stage ports the module, or lands after #7390.
|
||||
- Its comment there forbids a second copy, so the service reuses it rather than adding its own.
|
||||
- **The label rule** (`StoreService.Names.Label`): the length, 6–63, first (`invalid_name`); then simplexmq's `SimplexDomain` parser checks the characters and hyphens (`nameLabelP`, reached through `strDecode` of `label.tld`, which must give no subdomains, `domain` equal to the label, and already be lower case).
|
||||
- #7390's `validLabel` is not merged where this builds, and refuses `--` only at positions 3–4. Reusing simplexmq's parser keeps one rule with the relays and the app, with no second copy.
|
||||
- **The `Registration` encoding** stays where the branch has it, `BadgeService/Names/Registration.hs`. The names webapp mirrors it in TypeScript with the branch's vectors.
|
||||
- **Tests:** owner and prepaid code vectors, including that neither parses as the other; vectors shared with the names webapp.
|
||||
- **Tests:** owner and prepaid code vectors, including that neither parses as the other; vectors shared with the names webapp (`src/crypto/code.ts`); the label rule against simplexmq's parser (`--` anywhere refused).
|
||||
|
||||
### 3. Service: search, codes, orders
|
||||
|
||||
This stage adds the service's names endpoints: search, link previews, code checks, orders, the reveal, finding an order, link edits, the taken outcomes and retry. It also adds the tables, the wire errors and the operator commands.
|
||||
|
||||
- **`[names]` in the ini:** `resolver_url`, and `tld` (`testing` until `.simplex` is deployed).
|
||||
The contract [`2026-10-21-store-api.md`](2026-10-21-store-api.md) defines every endpoint below to the field; this section says how the service builds them.
|
||||
|
||||
- **`[names]` in the ini:** `tld` (`testing` until `.simplex` is deployed), `years` (2), `term_start` (`2027-01-01T00:00:00Z`), `term_slack_seconds` (3600), `reveal_margin_seconds` (900), `max_order_moves` (3).
|
||||
- `knownSettings` in `Config.hs` gains `[names]`, `[chain]` and the `[listener]` keys `names_static_dir`, `names_export_dir` and `names_hosts`.
|
||||
- The parser warns about and ignores any key not listed.
|
||||
|
||||
@@ -232,45 +240,44 @@ This stage adds the service's names endpoints: search, link previews, code check
|
||||
- **Input:**
|
||||
- It takes keccak256(label) and the label's length, never the label.
|
||||
- It refuses a length outside 6–63 with `invalid_name`.
|
||||
- It asks the resolver by hash, under its own rate limit, not the read limit the invoice long poll uses.
|
||||
- It reads the chain by hash (the reads under Context, "Availability and records"), under its own rate limit, not the read limit the invoice long poll uses.
|
||||
- The name is `registered` while the registrar's `nameExpires` plus `GRACE_PERIOD` is not past the latest block, else `reserved` when `reservedNames` is not 0, else `available`.
|
||||
- **Link cards** for a registered name:
|
||||
- It opens each `simplexContact`/`simplexChannel` link with the chat core's `APIConnectPlan`.
|
||||
- It splits the `simplex.contact` and `simplex.channel` records on `;` and opens each short link of the key's kind with the chat core's `APIConnectPlan`. A value that is not one makes no card.
|
||||
- `WebEnv` has no chat controller today, since `serviceLanes` builds it without `cc`. So `cc` and the service user's id are threaded into it.
|
||||
- `APIConnectPlan` returns a short link's display name and picture without connecting. A full link has no profile, so its card shows the link only.
|
||||
- The fetch has a 3 s timeout, its own rate limit and a short cache. It adds a deep link.
|
||||
- `APIConnectPlan` reads the link data without connecting. Each card gets, as the app's connection-plan alert shows it:
|
||||
- `kind`: from a contact address's `ContactShortLinkData`, `business` when `business` is set or the profile's `peerType` is business, `bot` for `peerType` bot, else `contact`; from a channel link (`/c#`), `channel`;
|
||||
- `displayName`, `fullName` and `image` (a data URI) from the profile or the group profile;
|
||||
- `subscribers`: a channel's `publicGroupData.publicMemberCount`, else null.
|
||||
- The fetch has a 3 s timeout, which also bounds its wait for one of the search answers' own slots (`searchFetchSlots`, 4 service-wide), and a 10-minute cache per link. It adds a deep link. A fetch that fails or gets no slot gives a card with an empty profile, the link's kind and `profile: unread`; the host rule gives `profile: unavailable`.
|
||||
- The hosts are the link's servers (the authority of an `https://` link, or the server hosts inside a `simplex:/` link). A public IP literal passes, since SimpleX servers may be addressed by IP. Every host of the link is checked before the chat core fetches: an IP literal as it is, a name by resolving it; a private, loopback, link-local, unique-local, CGNAT, unspecified or multicast address, a `.onion` host or an unresolvable name means no fetch. A link with several hosts is fetched only if all pass. A link not fetched stays valid: its profile is `unavailable`. The check is best effort, since the chat core resolves again when it connects (DNS rebinding remains possible); an egress rule in the deployment denies the service's traffic to private ranges.
|
||||
- **Answers:**
|
||||
- `available{price, currency, years, resolver, sponsor, chainId, tld}`:
|
||||
- `available{price, currency, years, termStart, resolver, sponsor, chainId, tld}`:
|
||||
- `price` is the chain's, in cents, for `years` (Prices, below); `termStart` is `[names] term_start` in Unix seconds;
|
||||
- `resolver` is the resolver contract the page puts in its commitment;
|
||||
- `sponsor` and `chainId` are for the intent's EIP-712 domain;
|
||||
- `tld` is for the records' node.
|
||||
- `registered{entities, records, owner, resolver, recordsNonce, chainId, tld}`:
|
||||
- `registered{entities, records, owner, resolver, recordsNonce, expiresAt, gracePeriod, chainId, tld}`:
|
||||
- `gracePeriod` is the registrar's `GRACE_PERIOD`, so the page shows when an expired name can be registered again;
|
||||
- `records` are the raw `{contact, channel}` values. They are read from the chain as `text(node, key)` on the configured default resolver contract, with `node = keccak256(namehash(tld) ‖ labelHash)`.
|
||||
- They are read raw because the resolver splits and trims values, and answers `""` for a reverted text read. That would make Edit links drop links. Reading them raw also keeps a link whose card could not be fetched.
|
||||
- They are answered raw, so Edit links keeps every link, including one whose card could not be fetched.
|
||||
- They are read only when the answer's `resolver` is the default one. A name on another resolver contract or on none (PR #35 allows either without data) answers `records` and `recordsNonce` null, with its `owner`. Your names offers no Edit links for it.
|
||||
- The lag answer (below), with no `owner`, is not this case. There, Edit links takes the values from the order's links in `sealed`, or from this browser's own last mined edit of a key when it has one. A mined clear counts as an edit of both keys to "". So a second edit in the lag does not bring back removed links.
|
||||
- `owner` and `resolver` come from the resolver's answer, which reads them for the hashed node.
|
||||
- `owner` and `resolver` are the registry's `owner(node)` and `resolver(node)`.
|
||||
- `recordsNonce` is `SimplexResolver.nonces(owner)`, read from the default resolver contract under the same condition, for edits by that owner.
|
||||
- `reserved`.
|
||||
- **Resolver failures in search** answer `provider_unavailable`:
|
||||
- The resolver's 502 `labelNotRecorded` is a registered name whose label the base registrar never recorded. It cannot occur on PR #35, where every registration records its label. Search would answer it `provider_unavailable`, as it answers the resolver's other errors.
|
||||
- Those other errors:
|
||||
- 502 `upstreamError`, for a failed status, owner or resolver read, or a lost RPC connection. A reverted text or address read it answers empty instead;
|
||||
- 400 `tldNotConfigured`;
|
||||
- 502 `unknown`, without the registrar address;
|
||||
- 502 `noPriceOracle`, without a readable price oracle or premium.
|
||||
- Also a resolver that does not answer, and a failed chain read.
|
||||
- **Resolver failures elsewhere** answer `name_check_unavailable` (503):
|
||||
- The order, `/api/name/register` and the `/name` retarget also ask the resolver by hash. On the same resolver failures they answer `name_check_unavailable` and create or change nothing.
|
||||
- **Failed chain reads in search** answer `provider_unavailable`: the RPC node does not answer, or answers an error.
|
||||
- **Failed chain reads elsewhere** answer `name_check_unavailable` (503):
|
||||
- The order, `/api/name/register` and the `/name` retarget also read the chain by hash. On a failed read they answer `name_check_unavailable` and create or change nothing.
|
||||
- They do not answer `provider_unavailable`, since the badges client reads `provider_unavailable` from invoice creation as an unavailable payment method (N3a).
|
||||
- The page shows N3d at checkout, and the error with the reference on the order page.
|
||||
- **The lag:** a name registered here may not show in the resolver yet.
|
||||
- This covers a hash the resolver reports available while a registration here is `registering`, or `registered` with `registered_at` within the last hour.
|
||||
- The hour is a margin for the resolver's node lagging the chain, since the resolver itself reads `latest` with no cache. After that the resolver is believed, so an expired name is free again.
|
||||
- Search then answers `registered{entities: [], tld}`, with no owner.
|
||||
- Edit links then takes the nonce, chain id and resolver from the registration view instead. It is offered only once the phase is `registered`; the view's `recordsNonce` is null before.
|
||||
- Once the resolver reports the name registered, it is answered like any other registered name. Its links show, and Edit links has the owner's nonce.
|
||||
- **The link bound:** each record value (a key's links joined with `;`) is at most 4096 bytes of UTF-8. The service pays for each record's storage, so the value is bounded.
|
||||
- Checked by the page, by `/api/link/preview` for one link and by `/api/name/records` (`invalid_link`), and by the reveal (`bad_request`).
|
||||
- **Names being registered here:** the chain still shows a name available while its register here is sent but not mined.
|
||||
- This covers a hash the chain reports available while a registration here is `registering`.
|
||||
- Search then answers `registered{entities: [], tld}`, with no owner. Edit links is offered only once the phase is `registered`.
|
||||
- The worker sets `registered` only `confirmations` blocks after the register's receipt. From then on the service's own reads show the name like any other registered name: its links show, and Edit links has the owner's nonce.
|
||||
- Otherwise the chain is believed, so an expired name is free again.
|
||||
- **Links:** one short link per key: a contact address (`/a#`) in `simplex.contact`, a channel (`/c#`) in `simplex.channel` (sequence doc gap 35; the app reads only the first short link of each key). A full link is `invalid_link`.
|
||||
- **The link bound:** each record value is at most 4096 bytes of UTF-8. The service pays for each record's storage, so the value is bounded. With one short link per key (about 70 bytes) the bound is never near; it guards a hand-built request.
|
||||
- Checked by the page, by `/api/link/preview`, by `/api/name/records` and by the reveal (`invalid_link`).
|
||||
- A new 4096-byte value takes about 129 storage slots, some 2.85M gas, against about 65k for its calldata and 33k for the `TextChanged` event.
|
||||
- So the register's gas limit reaches about 6.7M for two records at the bound. The branch's client takes that limit from `eth_estimateGas`, and its cap must allow it.
|
||||
- Estimated on the local anvil deployment: 446k with no records, 3.55M with one, 6.67M with two, plus about 30k when the owner's intent is checked. The gas used is about 5% lower near the bound, and 10–14% lower for short or no records.
|
||||
@@ -278,30 +285,40 @@ This stage adds the service's names endpoints: search, link previews, code check
|
||||
- **`POST /api/link/preview {link}`** shows a link's profile at checkout.
|
||||
- It checks the link's kind and the link bound. The page also checks the bound before building the commitment, since the links are fixed in it.
|
||||
- It then opens a SimpleX contact or channel link with the chat core, as for taken names.
|
||||
- It answers `{type, displayName, image}` or `invalid_link`.
|
||||
- It answers `{type, kind, displayName, fullName, image, subscribers, profile}`, a search card without `link` and `deepLink`, or `invalid_link` for a link that is not a short link of its kind within the bound. The host rule never makes a link invalid. Previews have their own slots (`previewFetchSlots`, 2 service-wide), so they cannot starve search.
|
||||
- Same timeout and cache as the link cards, and its own rate limit.
|
||||
- **`POST /api/code/check {code}`** says what a prepaid code covers, without spending it.
|
||||
- An unknown code is `code_invalid`, with status 400, since the transport reads every 404 as `not_found`.
|
||||
- Otherwise it returns what the code covers (`minLength`, `years`), and whether it can still be used (`unused`, `reserved`, `used`, `revoked`, `expired`, `unpaid`).
|
||||
- It uses its own rate limit, since it confirms codes.
|
||||
- **Prices:** `Catalog.namePrice :: [NamePrice] -> LabelHash -> Int -> Either CatalogRefusal CurrencyAmount` prices a name by the label's hash and length.
|
||||
- It reads a seeded insert-only `name_prices` table (`Catalog.defaultNamePrices`): per-length rules, overridable per name by the label's hash.
|
||||
- Checkout reprices, and refuses a changed price with `catalog_changed`, which now carries the new price.
|
||||
- It is called only from Check your code. A name code typed there is never sent: the page answers it with `find` (below).
|
||||
- **Prices** come from the chain (Context, "Prices"). `rentPriceUSD(<length × "a">, years × 365 days).base / 10^16` is the price in cents.
|
||||
- Search, orders and the retarget read it. A failed read is `provider_unavailable` in search and `name_check_unavailable` elsewhere.
|
||||
- An order states the price it saw. A different chain price is `catalog_changed` with the new `price`.
|
||||
- The order's price is stored on the registration (`price`, cents) and fixed there: the reveal and the register compare none. The sponsor spends the chain's price for the whole duration, the free days included, from the registrar's allowance.
|
||||
- **Term:** each order, register and retarget carries `duration`. The service checks `duration − years × 365 days` against `max(0, termStart − now)` within `term_slack_seconds`, for the paid years or the code's, else `bad_request`. It stores the duration, rebuilds the commitment with it at the reveal, and returns it in the registration view, so the name code alone rebuilds the commitment on another browser.
|
||||
|
||||
#### Orders
|
||||
|
||||
An order carries the name's hash, the owner address, the commitment and `sealed`. It is paid by invoice or by prepaid code. Nothing is held: any number of orders may name the same hash.
|
||||
An order carries the name's hash and length, the duration, the owner address, the commitment and `sealed`. It is paid by invoice or by prepaid code. Nothing is held: any number of orders from different wallets may name the same hash, but one wallet has one live order per name.
|
||||
|
||||
- **`POST /api/invoice {kind: "name", labelHash, length, price, method, owner, commitment, sealed}`** creates a paid order. `sealed` is null for an order opened from the app, and so null in its view.
|
||||
- **Orders are signed and disclosed only to who made them** (manager's ruling, 2026-10-21; contract sections 1 and 5).
|
||||
- Each order carries `timestamp` and the owner's EIP-191 signature over `simplex.domains order <labelHash> <length> <duration> <commitment> <sha256(sealed)> <timestamp>`. So nobody places orders in another wallet's name.
|
||||
- An **exact resend** (every field equal to a stored order, including `sealed`, the method and price or the code, and the signature recovering the owner) is answered with that order, in any phase, whatever the timestamp's age.
|
||||
- Anything else that meets a live order of the same commitment, or of the same owner and label hash, is `409 order_exists`, with no other field. The page then finds its order with the signed `find`. The commitment is public on chain once committed, so it proves nothing on its own.
|
||||
- A registration is live while it has an open invoice, or is past `unpaid` and not `taken` or `code_issued`.
|
||||
- **One request at a time per name and per code.** Orders, the reveal, `/name`, `/code` and `/retry` run one at a time per label hash; every reservation, release and spending of a prepaid code one at a time per code, reserving by one conditional update in the transaction that creates, moves or requeues the registration. The turns live in their own module (`StoreService.Names.Turns`). Every registration write is a compare-and-set on its `version`; a lost race on any endpoint answers `409 registration_changed` with the current view. Unique constraints back it: one live registration per commitment, one per owner and label hash, one registration per reserved code.
|
||||
- **An overdue order is closed by the next one.** A new order from the same wallet and name that meets a live order whose invoice is past its expiry time (the poller may not have marked it yet) marks that invoice `expired` and clears its registration's `live` in its own transaction, then inserts; the provider's cancel follows, best effort. Settlement queues a registration only while it is still live; a payment for a closed order is a late payment (stays `unpaid`, alert, support refunds).
|
||||
- **`POST /api/invoice {kind: "name", labelHash, length, duration, price, method, owner, commitment, sealed, timestamp, signature}`** creates a paid order. `sealed` is required.
|
||||
- Its checks, in this order:
|
||||
1. The length: outside 6–63 it is `invalid_name`. Then the `sealed` check (below).
|
||||
2. An open invoice for the same commitment. If its owner, hash, length, price and method match, it answers with that invoice (a resend after a lost response). Otherwise it refuses with `400 {"error": "bad_request", "invoiceId": "…"}`. So one commitment never has two invoices open, and the page opens the existing order. A buyer who changes the method after a lost response cancels it there.
|
||||
3. A registration past `unpaid` for the same commitment (paid, or by code). It answers with that registration's order instead of a new one. So an invoice that settles while its cancel runs never gets a twin.
|
||||
- For a code registration that is `{invoiceId: null, kind: "name", registration}`, with no payment fields. The page opens it at `?registration=<id>`.
|
||||
- The library's `createInvoice` accepts it and skips its payment-field check.
|
||||
4. The resolver. It refuses with `name_taken` a name the resolver reports registered or reserved, or a hash in the lag: available at the resolver while a registration here is `registering`, or `registered` with `registered_at` within the last hour (the margin explained under search).
|
||||
- The two commitment checks run before the resolver, so a resend never meets its own registration as `name_taken`.
|
||||
- Then it creates an invoice. Settlement moves the registration to `queued`.
|
||||
1. The formats, the length (outside 6–63 it is `invalid_name`), and the `sealed` check (below).
|
||||
2. The signature recovers `owner`, else `bad_request`.
|
||||
3. Existing orders: an exact resend is answered with its order; anything else meeting a live order is `order_exists`.
|
||||
4. The timestamp within 600 seconds, and the duration within the slack (Term, above), else `bad_request`.
|
||||
5. The chain. It refuses with `name_taken` a name the chain shows registered or reserved, or a hash available on chain while a registration here is `registering` (explained under search).
|
||||
6. The price: the chain's price for `years` must equal `price`, else `catalog_changed` with the chain's `price`.
|
||||
- Check 3 runs before the chain read, so a resend never meets its own registration as `name_taken`.
|
||||
- Then it creates an invoice. Settlement moves the registration to `queued` while it is still live; otherwise the payment is a late payment.
|
||||
- **Get a new invoice** cancels the old invoice first when it may still be open, and makes the new order only after the cancel succeeds.
|
||||
- It applies from an ending where the old invoice may still be open: the card form not loading, or the payment details unavailable. A declined card stays in the form on the open invoice.
|
||||
- The cancel is `POST /api/invoice/:id/cancel` (create limit). What the page does with each answer:
|
||||
@@ -322,78 +339,78 @@ An order carries the name's hash, the owner address, the commitment and `sealed`
|
||||
- So a crypto payment not yet read is invalidated with the invoice and becomes a refund case.
|
||||
- The poller already alerts on it ("expired holding … needs a refund"), and no twin registration results.
|
||||
- The badges webapp's `newInvoice()` only clears the session today.
|
||||
- Get a new invoice makes a new order for the same name and wallet, so it reuses the commitment.
|
||||
- Get a new invoice makes a new order for the same name and wallet with the current duration, so with a new commitment, once the old invoice is closed. A cancel that lost to settlement leaves a live order: the new order meets it as `order_exists`, and the page opens it through `find`.
|
||||
- **Closed invoices and cleanup:**
|
||||
- An invoice that expires or is canceled leaves its registration `unpaid`.
|
||||
- A late payment of it does not queue it, since Get a new invoice reuses the commitment. It is kept for support to refund.
|
||||
- A late payment of it does not queue it, since a new order for the same name and wallet may have been paid meanwhile. It is kept for support to refund.
|
||||
- An `unpaid` registration is deleted, with its `name_invoices` and `invoices` rows, in one transaction 30 days after its invoice closed. One whose invoice received any payment, partial or late, stays for support.
|
||||
- The cleanup first reads the invoice from the provider itself (`pReadInvoice`, then settlement as for any read). The poller and the provider list pass read an invoice only within about 72 hours of its creation (`settleWindow`). After that only a webhook brings a payment.
|
||||
- Today's BTCPay handler acts only on `InvoiceProcessing`, `InvoiceSettled`, `InvoiceExpired` and `InvoiceInvalid`. It gains `InvoiceReceivedPayment` and `InvoicePaymentSettled`, as read hints for a payment into a closed invoice.
|
||||
- The cleanup's read is the backstop. A payment whose webhook was lost is still recorded and alerted. If the provider does not answer, the registration is kept until the next daily run.
|
||||
- **`POST /api/name/register {labelHash, length, owner, commitment, sealed, code}`** registers a name paid by a prepaid code. There is no invoice.
|
||||
- **`POST /api/name/register {labelHash, length, duration, owner, commitment, sealed, code, timestamp, signature}`** registers a name paid by a prepaid code. There is no invoice. The page sends it from Check your order, for a code kept on Check your code.
|
||||
- It takes the code check's rate limit, since it too reveals a code's state.
|
||||
- Right after the rate limit, it checks for a repeat. A registration whose `name_prepaid_code_id` is this code, with the same owner and commitment, answers that `registrationId`. This holds in any phase, the code spent or not. So a lost response is safe even once its own registration makes the name look taken.
|
||||
- It applies the invoice's two commitment checks, at the same point (after the length and `sealed` checks, before the resolver):
|
||||
- a registration past `unpaid` for the commitment answers that registration's id, with its `invoiceId` when it was paid, so the page opens `?order=<id>`;
|
||||
- an open invoice for the commitment is refused with `400 {"error": "bad_request", "invoiceId": "…"}`. The page cancels it first, as above.
|
||||
- It refuses a length and a name as the invoice does (`invalid_name`, `name_taken`).
|
||||
- It checks that the code covers the length, and marks the code reserved for this registration.
|
||||
- After the formats, the code's parse and the owner's signature, it applies the existing-order rule: an exact resend answers its `registrationId` in any phase, the code spent or not, so a lost response is safe even once its own registration makes the name look taken; anything else meeting a live order is `order_exists`.
|
||||
- It checks the timestamp and the duration for the code's years, and refuses a length and a name as the invoice does (`invalid_name`, `name_taken`).
|
||||
- It checks that the code covers the length, and reserves the code by the conditional update, in the transaction that creates the registration.
|
||||
- It spends the code when the registration succeeds.
|
||||
- **Formats:**
|
||||
- `owner` is a checksummed address.
|
||||
- `labelHash`, `commitment` and `secret`, in bodies and as `:hash`, are `0x` and 64 lowercase hex. Anything else is `bad_request`.
|
||||
- `codeHash` stays the badge codes' unpadded base64url.
|
||||
- **The commitment** is built by the page:
|
||||
- `commitment = makeCommitment(Registration{label, owner, duration, secret, resolver, data = one setText call per key that has links, its links joined with ";" (empty without links), reverseRecord = 0, referrer = 0})`;
|
||||
- `commitment = makeCommitment(Registration{label, owner, duration, secret, resolver, data = one setText call per key that has a link (empty without links), reverseRecord = 0, referrer = 0})`;
|
||||
- `secret = HMAC-SHA256(BIP-39 seed of the owner code, "simplex.domains commitment " + label)`.
|
||||
- This is the contract's struct, as the branch's `registrationTuple` encodes it from its `Registration` (label, TLD, owner, duration, secret, resolver, contact and channel links).
|
||||
- `duration` is 2 years, or the prepaid code's years, at 365 days a year.
|
||||
- `duration` is `years × 365 days + max(0, termStart − now)`: the paid years, or the prepaid code's, and the free days until 1 January 2027.
|
||||
- Its `data` is the records' `setText` calls.
|
||||
- **`sealed`** lets a browser that has only the owner code rebuild the commitment and reveal.
|
||||
- It is the JSON `{label, contact, channel}`: each key's record value, its links joined with `;`, "" without links.
|
||||
- It is the JSON `{label, contact, channel}`: each key's link, or "" without one.
|
||||
- The page encrypts it with AES-256-GCM under `HMAC-SHA256(seed, "simplex.domains sealed")` and a random 12-byte IV. The result is unpadded `base64url(iv ‖ ciphertext ‖ 16-byte tag)`; WebCrypto's output carries the tag.
|
||||
- The service stores it unread, and returns it in the registration view.
|
||||
- It refuses one longer than 11095 characters (`bad_request`). That is the base64url of the 12-byte IV, 8293 bytes of JSON for a 63-character label and two 4096-byte values, and the 16-byte tag. SimpleX links need no JSON escaping.
|
||||
- It refuses one longer than 11095 characters (`bad_request`). That is the base64url of the 12-byte IV, 8293 bytes of JSON for a 63-character label and two 4096-byte values (the link bound), and the 16-byte tag. SimpleX links need no JSON escaping.
|
||||
- Today's body cap, `maxBodyBytes = 8192` in `Web/Server.hs`, would refuse such an order before any name check. So `/api/invoice`, `/api/name/register`, `/api/registration/:id/name` and the reveal read bodies up to a new `nameBodyBytes = 16384`. A longest order is about 11.4 KB, and a reveal with two records at the link bound under 9 KB.
|
||||
- **`POST /api/registration/:id/reveal {label, secret, records, nonce, deadline, signature}`** gives the service the name, the links and the owner's signed intent, once the commitment is old enough.
|
||||
- It is accepted in `committed` while `label` is null. The page sends it once `minCommitmentAge` has passed.
|
||||
- It runs one at a time per label hash with orders, `/name` and `/retry`, and writes by compare-and-set on the `version` it read; a lost race answers `409 registration_changed` with the current view.
|
||||
- Refused for its links (`invalid_link`, after the commitment and intent checks passed), it records `links_refused_at`, which opens `/name` for the order.
|
||||
- Once `label` is set, an identical repeat answers `200`, in that or any later phase. A different body is `bad_request`.
|
||||
- The body:
|
||||
- `records`: the links chosen at checkout, `{contact: [...], channel: [...]}`, empty when none;
|
||||
- `records`: the links chosen on the Links step, `{contact: [...], channel: [...]}`, at most one each, empty when none;
|
||||
- `signature`: the owner's EIP-712 `RegisterName(string label,address owner,uint256 duration,address resolver,bytes[] data,uint256 nonce,uint256 deadline)` signature, under the `SimplexSponsor` domain;
|
||||
- `nonce`: `SimplexSponsor.nonces(owner)`, which the registration view carries as `intentNonce`. It is 0 for a new owner code;
|
||||
- `deadline`: the view's `revealBefore`. The controller refuses the commitment from then on, so a later deadline gains nothing.
|
||||
- The checks:
|
||||
1. The label is valid, hashes to `labelHash` and has `length`. So its price is the registration's `price_id` row, fixed at order or retarget, or it is covered by the prepaid code.
|
||||
2. Each link is a SimpleX link of its kind, within the link bound.
|
||||
3. The resolver is the default resolver contract.
|
||||
4. The service rebuilds `data` and the commitment.
|
||||
5. `verifyRegisterIntent`: owner signed, deadline not passed, secret 32 bytes.
|
||||
6. A low-s check of its own, since `recoverPublicKey` accepts high s and the sponsor's `SignatureChecker` rejects it. The signature is read as exactly 65 bytes `r ‖ s ‖ v`, with `v` 27 or 28, as OpenZeppelin requires. The recovery id is `v − 27`.
|
||||
7. `nonce == SimplexSponsor.nonces(owner)`, read from the chain.
|
||||
- A failed check is `bad_request`, and the registration stays `committed`.
|
||||
- The intent signs the duration (contracts PR #36), so the service checks that the signed duration is what was paid: 2 years, or the prepaid code's years, at 365 days a year.
|
||||
- The checks, in order:
|
||||
1. The label is valid, hashes to `labelHash` and has `length`, with at most one link per key. So its price is the registration's `price`, fixed at order or retarget, or it is covered by the prepaid code.
|
||||
2. The resolver is the default resolver contract. The service rebuilds `data` from the sent links and the commitment, with the registration's stored `duration`; it must equal the stored commitment.
|
||||
3. `verifyRegisterIntent`: owner signed, deadline not passed, secret 32 bytes.
|
||||
4. A low-s check of its own, since `recoverPublicKey` accepts high s and the sponsor's `SignatureChecker` rejects it. The signature is read as exactly 65 bytes `r ‖ s ‖ v`, with `v` 27 or 28, as OpenZeppelin requires. The recovery id is `v − 27`.
|
||||
5. `nonce == SimplexSponsor.nonces(owner)`, read from the chain.
|
||||
6. Each link is a SimpleX short link of its kind, within the link bound, else `invalid_link`.
|
||||
- A failed check is `bad_request` (or `invalid_link`), and the registration stays `committed`. The one write a failed reveal makes is recording `links_refused_at` at check 6, after checks 2–5 passed: the refused links are provably the committed ones and the owner signed them, so nobody can record a refusal on purpose to unlock `/name`. Recording it is a compare-and-set write that bumps `version` and wakes the polls.
|
||||
- The intent signs the duration (contracts PR #36). The service checks it against the registration's stored duration, which was checked against the paid years and `termStart` at the order or the retarget.
|
||||
|
||||
#### Finding an order and editing links
|
||||
|
||||
- **`POST /api/registration/find {owner, timestamp, signature}`** finds orders for a buyer who has the owner code but not the order link.
|
||||
- **`POST /api/registration/find {owner, timestamp, signature}`** finds a name code's orders. Check your code calls it for a name code (its verdict names the code's names, and Add to Your names restores them), and so does an order link opened where the code is not stored. There is no separate Restore screen.
|
||||
- The page derives the owner from the code and signs `simplex.domains find <owner> <timestamp>` (EIP-191) with its key.
|
||||
- `owner` is lowercase `0x` hex, and `timestamp` decimal Unix seconds.
|
||||
- The signature is read as the reveal's: 65 bytes, with `v` 27 or 28.
|
||||
- The service checks that the signer is `owner` and the timestamp is within 10 minutes.
|
||||
- It returns `{registrations: [{registrationId, invoiceId, phase, createdAt}]}`, newest first, or `not_found` when there is none.
|
||||
- It returns `{registrations: [{registrationId, invoiceId, phase, createdAt, labelHash, sealed}]}`, newest first, or `not_found` when there is none.
|
||||
- One entry for each registration of that owner, retargeted ones included.
|
||||
- `invoiceId` is the order link's id, and null for a prepaid-code registration.
|
||||
- `labelHash` and `sealed` let the page name every order in one request: it opens `sealed` with the code and checks the label's hash.
|
||||
- At most the 50 newest. Orders are signed by their owner, so only the code's own orders are listed.
|
||||
- Get a new invoice reuses the wallet, so an expired order's `unpaid` registration may be listed too. The page opens the one that is not `unpaid`, or the newest.
|
||||
- It has its own rate limit.
|
||||
- **`POST /api/name/records {labelHash, key, value, nonce, deadline, signature}`** (or `{labelHash, clear: true, nonce, deadline, signature}`) changes one link record later. The page signs the edit with the name's key, and the service relays it and pays the gas.
|
||||
- **The request:**
|
||||
- `key` is `simplex.contact` or `simplex.channel`. Each link must be a SimpleX link of that kind (`invalid_link`).
|
||||
- `key` is `simplex.contact` or `simplex.channel`. `value` is one short link of that kind, or "" to remove it (`invalid_link`).
|
||||
- `deadline` is when the page signs, plus one hour.
|
||||
- A record holds its links joined with `;`. So removing one of several links rewrites the joined value, and clearing a key sets it to "".
|
||||
- The edit replaces the whole record, including links joined with `;` that were set elsewhere.
|
||||
- **What the page signs:**
|
||||
- EIP-712 `SetText(bytes32 node,string key,string value,uint256 nonce,uint256 deadline)`, under the `SimplexResolver` domain, with the name's key.
|
||||
- `nonce` is `SimplexResolver.nonces(owner)`, one counter for all of the owner's edits. `GET /api/name/:hash` returns it with the chain id for a registered name, and the registration view returns it while search lags.
|
||||
- `nonce` is `SimplexResolver.nonces(owner)`, one counter for all of the owner's edits. `GET /api/name/:hash` returns it with the chain id for a registered name.
|
||||
- For the clear form, EIP-712 `ClearRecords(bytes32 node,uint256 nonce,uint256 deadline)` instead, under the same domain and nonce. It wipes every record of the name at once, through `clearRecordsWithSig`.
|
||||
- **Sending:** the service relays `setTextWithSig` (or `clearRecordsWithSig`), one transaction each, paying the gas. It sends through the registrar key's one sender, the worker's, so an edit and a commit never collide on the key's nonce.
|
||||
- **The checks, in order:**
|
||||
@@ -402,26 +419,25 @@ An order carries the name's hash, the owner address, the commitment and `sealed`
|
||||
3. The signer. The service recovers the `SetText`/`ClearRecords` signer (`recoverAddress`, no chain read).
|
||||
- The digest encodes `node` as `bytes32`. simplexmq's `Eip712Value` on its branch has no constructor for that, so it goes through `VUint`, not the hashing `VBytes`.
|
||||
4. Low s, as PR #35's OpenZeppelin `SignatureChecker` requires. simplexmq's `recoverPublicKey` on its branch accepts high s, which would pass here and revert on chain. The signature is read as the reveal reads it. Else `bad_request`.
|
||||
5. An identical repeat, looked up by signer and nonce and matched by `body_hash` (below), is answered now, before any deadline check.
|
||||
5. An identical repeat of an edit that was sent, looked up by signer and nonce and matched by `body_hash` (below), is answered now from that edit, before any deadline check. Only sent edits are stored: a request refused before sending leaves nothing, so its repeat is checked anew.
|
||||
6. For a new edit:
|
||||
- the deadline must be ahead of the latest block's time, else `bad_request`;
|
||||
- past the per-name cap it answers `rate_limited`;
|
||||
- the deadline must be ahead of the latest block's time;
|
||||
- the signer must be the registry's `owner(node)`, read from the chain with `node = keccak256(namehash(tld) ‖ labelHash)` and the registry `baseRegistrar.ens()`;
|
||||
- the edit's nonce must equal `SimplexResolver.nonces(owner)` on chain;
|
||||
- else `bad_request`.
|
||||
- No refusal counts toward the cap, which counts `name_record_edits` rows.
|
||||
7. The per-name cap, after the owner and nonce checks: 10 relayed edits an hour, else `rate_limited`. Only edits that were sent count, so nobody but the owner can use up a name's cap.
|
||||
- **A new edit:**
|
||||
1. The service inserts the pending row before the edit joins the sender. So a concurrent twin loses the unique insert before any signing.
|
||||
2. It sends the edit.
|
||||
3. It answers `{tx}` once it is mined, or `record_reverted` with the revert's name when known.
|
||||
- **Naming a revert:**
|
||||
- A dry run's revert carries its data where the node returns it. Anvil and Hardhat 3.19.0 do; reth is unchecked (gap 5).
|
||||
- A mined revert's receipt has no data, and the resolver host's reth offers no `debug_traceTransaction`.
|
||||
- A dry run's revert carries its data where the node returns it. Anvil and Hardhat 3.19.0 do; reth and hosted providers are unchecked (gap 5).
|
||||
- A mined revert's receipt has no data, and the service does not assume `debug_traceTransaction` on the RPC node.
|
||||
- So a mined revert is named `SignatureExpired` when its block's timestamp is past the deadline, and is unnamed (`revert` null) otherwise.
|
||||
- **The re-read after a revert:** anyone can relay a signed edit, so a revert may mean the edit was already applied.
|
||||
- An applied edit reverts `InvalidNonce`, or `SignatureExpired` once its deadline has passed.
|
||||
- So on any revert the service first re-reads the nonce and the record.
|
||||
- An edit whose nonce the chain has passed, with the record holding its value, is answered as mined, with `{tx: null}`. For a clear, both keys must read "".
|
||||
- One of the edit's own sent hashes with a status-1 receipt means applied, answered with that hash. Only without a known receipt: an edit whose nonce the chain has passed, with the record holding its value, is applied, and answered as mined, with `{tx: null}`. For a clear, both keys must read "". When the nonce has passed but the record holds another value, the edit was not applied: `record_reverted` with `InvalidNonce`.
|
||||
- `{tx: null}` means the applying transaction is unknown: another relayer's, or the service's own with its receipt pruned.
|
||||
- **After a restart**, each pending edit resumes:
|
||||
- With no transaction yet and its deadline ahead, it is sent.
|
||||
@@ -439,7 +455,7 @@ An order carries the name's hash, the owner address, the commitment and `sealed`
|
||||
- After `record_reverted` the nonce is free again for a new body.
|
||||
- **The page's order of edits:**
|
||||
- The page signs the next edit only after the answer: with the next nonce after `{tx}`, and with the same nonce after `record_reverted`, since a reverted edit leaves the on-chain nonce unchanged.
|
||||
- After any `bad_request` it re-reads `recordsNonce` from `GET /api/name/:hash`, or from the registration view while search lags.
|
||||
- After any `bad_request` it re-reads `recordsNonce` from `GET /api/name/:hash`.
|
||||
- So edits go one at a time and in order, and a failed one never strands later ones. The contract does not queue intents: a higher nonce reverts `InvalidNonce`.
|
||||
- Your names hides Edit links once the registered answer carries an `owner` other than the code's address (after redeeming).
|
||||
- PR #35 has no edit credits. So the service bounds edits with its own rate limit and a per-name cap.
|
||||
@@ -449,35 +465,40 @@ An order carries the name's hash, the owner address, the commitment and `sealed`
|
||||
- **A `taken` order** has two ways on, both signed by the owner, as `find` is.
|
||||
- The signature is read the same way, and the timestamp must be within 10 minutes. The owner signs because the registration id is readable by anyone holding the order link.
|
||||
- The signed messages (EIP-191):
|
||||
- `simplex.domains name <registrationId> <labelHash> <length> <commitment> <sha256(sealed)> <timestamp>`;
|
||||
- `simplex.domains code <registrationId> <codeHash> <timestamp>`.
|
||||
- `simplex.domains name <registrationId> <labelHash> <length> <duration> <commitment> <sha256(sealed)> <timestamp>`;
|
||||
- `simplex.domains code <registrationId> <codeHash> <codeLength> <timestamp>`.
|
||||
- Their fields:
|
||||
- `length` in decimal;
|
||||
- `length` and `duration` in decimal;
|
||||
- `labelHash`, `commitment` and `sha256(sealed)` as lowercase `0x` hex. `sha256(sealed)` is the SHA-256 of the `sealed` string's ASCII bytes;
|
||||
- `codeHash` as the badge codes' unpadded base64url SHA-256 of the canonical code;
|
||||
- `timestamp` in decimal Unix seconds.
|
||||
- An order opened from the app has no owner key on the page, so it cannot use them (gap 10).
|
||||
- **`POST /api/registration/:id/name {labelHash, length, commitment, sealed, timestamp, signature}`** retargets the order to another name.
|
||||
- Only a paid registration in `taken` may, else `bad_request`. A page whose response was lost re-reads the view, which shows the retarget.
|
||||
- It runs the order's checks on the new hash (`invalid_name`, `name_taken`).
|
||||
- It requires `namePrice` at most what was paid, else `bad_request`.
|
||||
- It replaces the registration's `price_id` with that price row, so the reveal takes that row as the new label's price.
|
||||
- It replaces the hash, length, commitment and sealed order.
|
||||
- **`POST /api/registration/:id/name {labelHash, length, duration, commitment, sealed, timestamp, signature}`** retargets the order to another name, or to the same name with other links.
|
||||
- Allowed only for a paid registration in `taken`, for any registration in `failed`, and for a registration in `committed` whose reveal was refused for its links (`invalid_link`, recorded as `links_refused_at`); never from a healthy `committed` (manager's ruling, 2026-10-21). Else `bad_request`. So links a reveal refuses, which are fixed in the commitment, can be corrected without support.
|
||||
- At most `[names] max_order_moves` (3) successful moves per order over its life, counted on success; past that `409 move_limit` with the current view. Each move costs the service a new commit, so the cap bounds what one order can spend. The order then stays as it is (`failed`, `taken`, or `committed` with its links refused); the page shows its state with a line to contact SimpleX support and the reference, and support settles it by hand, refunding a paid order.
|
||||
- A body equal to the registration's current fields is a repeat: it answers the view unchanged and counts no move. A page whose response was lost re-reads the view, which shows the retarget.
|
||||
- It holds the old and the new label hash's turn, taken in hash order, and writes by compare-and-set on `version`.
|
||||
- A code order's code is checked and reserved again under its per-code turn, as `/retry` does (`code_used` when the sweep released it and it was spent elsewhere, `code_reserved`, `code_invalid`, `code_expired`, `code_not_covering`).
|
||||
- It runs the order's checks on the new fields: `invalid_name`, the existing-order rule for the new commitment or this owner and the new hash, other than this registration (`order_exists`), the duration for the registration's `years`, `name_taken`.
|
||||
- A paid order: the new name's chain price for those years must be at most the registration's `price`, else `409 price_too_high` with that price. A cheaper name refunds nothing; the page says so before the buyer chooses. A code order: the code must cover the new name (`code_not_covering`), and stays reserved.
|
||||
- It sets a paid registration's `price` to the new name's price, which caps a further move.
|
||||
- It replaces the hash, length, duration, commitment and sealed order.
|
||||
- It clears `label`, `secret`, `records`, `intent_*`, the transaction hashes and blocks, `reveal_after`, `reveal_before` and `error`. So the worker cannot register with the old intent, and the view shows no old transaction.
|
||||
- It commits again, and the page reveals again.
|
||||
- **`POST /api/registration/:id/code {codeHash, timestamp, signature}`** takes a prepaid code instead.
|
||||
- An identical repeat (the registration's `issued_code_id` has this hash) is answered 200 again. This is checked after the rate limit and the owner's signature.
|
||||
- Otherwise only a paid registration in `taken` gets a code: for its current `length` (8+ for longer names), for 2 years, paid and without expiry.
|
||||
- **`POST /api/registration/:id/code {codeHash, codeLength, timestamp, signature}`** takes a prepaid code instead. `codeLength` is the length the page put in the code's text; a different current value is `409 code_length_changed` with it, and nothing is issued.
|
||||
- An identical repeat (the registration's `issued_code_id` has this hash) is answered 200 again, whatever `codeLength` it carries and whatever the prices are now. This is checked after the rate limit and the owner's signature, before any length check; in `code_issued` the stored row is the only reference.
|
||||
- Otherwise only a paid registration in `taken` gets a code, for the registration's `years`, paid and without expiry. Its `min_length` is the shortest length `L` from 6 such that every length from `L` to 63 has a chain price for those years at most the registration's `price`: the code pays for any name no dearer than the order (`SN6` to `SN8` with the launch prices). The service reads the oracle's whole list once (`controller.prices()`, then its `prices()`), so a non-monotone list is handled. When no length qualifies, `/code` is `409 code_unavailable`. A failed read is `503 provider_unavailable`.
|
||||
- The registration view carries that length as `codeLength` in `taken`, and the issued code's `min_length` in `code_issued`, so the owner code recovers the code on any browser. The page puts it in the code's text (1–2 digits) before hashing it.
|
||||
- The page derives the code from the owner code's seed. Its payload comes from `HMAC-SHA256(seed, "simplex.domains taken code " + registrationId)`. So the owner code recovers it, and a repeat sends the same hash. The page sends only the hash.
|
||||
- `code_conflict` keeps its badge meaning: a hash another code already has. A derived code cannot be regenerated. But with the registration id in it and a 95-bit payload, a conflict is a practical impossibility, and the order page would show the error with the reference.
|
||||
- An order opened from the app cannot use this endpoint (gap 10).
|
||||
- The service inserts a `name_prepaid_codes` row for the hash and sets `issued_code_id` to it. The service sets `min_length` and `years` from the registration, not from the code's text.
|
||||
- **`GET /api/registration/:id`** is the long-poll view a code registration uses. `GET /api/invoice/:id` carries the same object for a paid one.
|
||||
- Its fields: `{id, phase, labelHash, label (null until the reveal), owner, commitment, sealed, resolver, sponsor, chainId, tld, years, commitTx, registerTx, revealAfter, revealBefore, intentNonce, recordsNonce, expiresAt, error}`.
|
||||
- Its fields: `{id, version, phase, labelHash, label (null until the reveal), owner, commitment, sealed, resolver, sponsor, chainId, tld, years, duration, price, codeLength, commitTx, registerTx, revealAfter, revealBefore, intentNonce, unread, expiresAt, error}`. `codeLength` is null except for a paid registration in `taken` or `code_issued`.
|
||||
- `version` grows with every change to the registration; the long polls hold on it.
|
||||
- `unread` names the fields (`intentNonce`, `codeLength`) left null because their chain read failed; the view itself never fails for them. The page reads the view again without `seenVersion` after 5 s, then 15 s, then every 30 s while `unread` is not empty.
|
||||
- `moves` counts the order's successful `/name` moves.
|
||||
- `commitTx` and `registerTx`: each is chosen among the hashes kept since they were last cleared. It is the one that mined with `status` 1, else the latest sent. It is null when judged mined from chain state with no hash known to have mined with `status` 1.
|
||||
- `revealBefore` is the commit's block time + `maxCommitmentAge` (24 h in PR #35's deploy).
|
||||
- `intentNonce` is `SimplexSponsor.nonces(owner)`.
|
||||
- `recordsNonce` is set once registered, for Edit links before search has the owner.
|
||||
- The id is a random 128-bit token, like order ids. Whoever holds it can act on the registration.
|
||||
- **Phases:** `unpaid` → `queued` → `committing` → `committed` → `registering` → `registered`; or `taken`, `failed`.
|
||||
- `unpaid`: its invoice not settled while open.
|
||||
@@ -487,8 +508,8 @@ An order carries the name's hash, the owner address, the commitment and `sealed`
|
||||
- From `taken`, a paid order goes back to `queued` with its new label hash, or ends `code_issued`. A code order ends at `taken`, with its code released.
|
||||
- A code's reservation is described with its table below.
|
||||
- **Chain reads:** a chain read that fails answers `provider_unavailable` (503) and changes nothing.
|
||||
- This applies in search, the reveal (not counted as its `bad_request`), the records relay (not counted toward the cap), the registration view's nonces and `/retry`.
|
||||
- The view reads nonces only in `committed` (for `intentNonce`) and in `registered` (for `recordsNonce`), null otherwise. So a chain outage never blocks an unpaid order's payment page.
|
||||
- This applies in search, the reveal, the records relay (nothing sent, nothing stored), `/code` and `/retry`.
|
||||
- The view reads `intentNonce` only in `committed` and `codeLength` only in `taken`, and on a failed read leaves them null and named in `unread`. So a chain outage never blocks an order's page.
|
||||
- A failed read is the node unreachable or answering an error. An RPC error is a failed read like any other.
|
||||
- These are not failed reads:
|
||||
- A revert that is itself the answer, as `ownerOf` for a name not held, counts as not this owner's.
|
||||
@@ -496,11 +517,13 @@ An order carries the name's hash, the owner address, the commitment and `sealed`
|
||||
- Retries:
|
||||
- For edits, every re-read the sender makes (after a dry-run or mined revert, an expiry, or a used registrar nonce) is retried until it succeeds. The edit is kept `pending`, and its repeats wait.
|
||||
- For registrations, the worker's reads (`ownerOf`, `available`, `commitments`, receipts) are retried, with the registration kept in its phase.
|
||||
- **Rate limits:**
|
||||
- Search, link preview, code check, find and records each have their own. Each is taken after route and method matching and before any other check, as `limited` does today.
|
||||
- The profile fetch for a taken name's links has its own, applied per fetch.
|
||||
- **Rate limits** (values in the contract, section 1; gap 29 decided):
|
||||
- Search (30 a minute), link preview (20), code check (10), find (5) and records (10) each have their own. Each is taken after route and method matching and before any other check, as `limited` does today, and every request counts, whatever its outcome. All count per 60-second window, so `Limit` and `takeToken` need no change.
|
||||
- `maxBuckets` rises from 8192 to 32768, since five limits join the two of today in one bucket map.
|
||||
- Link fetches have two service-wide bounds, `searchFetchSlots` (4) and `previewFetchSlots` (2), a 3-second `fetchTimeout` that also bounds the wait for a slot, and a 10-minute cache per link. A fetch that gets no slot in time gives `profile: unread`. The host rule (search, above) gives `profile: unavailable`, never `invalid_link`.
|
||||
- Record edits have a per-name cap of 10 relayed edits an hour, counted over sent `name_record_edits` rows; its `Retry-After` is the real wait, which the page clamps to 300 seconds and asks again.
|
||||
- `/api/name/register` shares the code check's.
|
||||
- `GET /api/registration/:id` and the reveal take the read limit, like the invoice long poll. Each `bad_request` from the reveal counts against it.
|
||||
- `GET /api/registration/:id` and the reveal take the read limit, like the invoice long poll.
|
||||
- `/name`, `/code` and `/retry` on a registration take the create limit.
|
||||
- **`POST /api/registration/:id/retry`** is Try again. It works from `failed` only (else `bad_request`), in this order:
|
||||
1. A register of this registration that went through, with only its confirmation lost.
|
||||
@@ -524,7 +547,7 @@ An order carries the name's hash, the owner address, the commitment and `sealed`
|
||||
- Its `revealBefore` is `commitments(commitment)` plus `maxCommitmentAge`, since `reveal_before` may be null.
|
||||
- A live commitment (read before that time) is reused. The registration goes straight to `committed`, with `reveal_after` and `reveal_before` from `commitments(commitment)` and the controller's ages.
|
||||
- Anyone can commit a public commitment again after it lapses. So the commitment's time may not be the block time of a commit of ours that mined with `status` 1 (a third party committed it again). Then `label`, `secret`, `records`, `intent_*` and the commit's transaction hashes and blocks are also cleared. The page signs again, and the view shows no commit of ours.
|
||||
- With less than `revealMargin` left before `revealBefore`, the worker does not reuse it. `revealMargin` is time for the page's reveal and the register to mine; its value is open (gap 29). The worker waits until a second after `revealBefore`, and goes on as below.
|
||||
- With less than `revealMargin` left before `revealBefore`, the worker does not reuse it. `revealMargin` is time for the page's reveal and the register to mine: `[names] reveal_margin_seconds`, 900 by default (gap 29, decided). The worker waits until a second after `revealBefore`, and goes on as below.
|
||||
- At `revealBefore` itself, the commitment can neither register (`CommitmentTooOld`) nor be committed again (`UnexpiredCommitmentExists`). So the worker waits a second.
|
||||
- After `revealBefore`, the worker clears `label`, `secret`, `records`, `intent_*`, `error`, the transaction hashes and blocks, `reveal_after` and `reveal_before`. Then it commits the commitment again with the same secret, since the old intent's deadline has passed. The view's null `label` makes the page reveal and sign again.
|
||||
|
||||
@@ -536,12 +559,14 @@ An order carries the name's hash, the owner address, the commitment and `sealed`
|
||||
- `failed` keeps it for Try again, for 30 days. Each failure sets `failed_at`, and a requeue clears it.
|
||||
- A daily sweep releases codes whose registration is still `failed` with a `failed_at` 30 days old.
|
||||
- A later retry reserves the code again if it is still unused. Otherwise it answers `code_used`, `code_invalid` (revoked) or `code_expired`, or `code_reserved` when another registration holds it.
|
||||
- **`name_prices`:** `price_id`, `min_length`, `label_hash` (null for a length rule), `price`, `currency`, `status`, `created_at`.
|
||||
- **`name_registrations`:**
|
||||
- Columns: `registration_id`, `label_hash`, `length`, `label`, `secret`, `records`, `intent_nonce`, `intent_deadline` and `intent_signature` (null until the reveal), `sealed`, `owner`, `commitment`, `phase`, transaction hashes and blocks, `reveal_after`, `reveal_before`, `years`, `expires_at`, `price_id`, `name_prepaid_code_id`, `issued_code_id`, `registered_at`, `failed_at`, `error`, `created_at`, `updated_at`.
|
||||
- Transaction hashes: every one sent, fee-bumped replacements included. Each is saved with its raw signed transaction once signed and before it is sent.
|
||||
- Columns: `registration_id`, `label_hash`, `length`, `label`, `secret`, `records`, `intent_nonce`, `intent_deadline` and `intent_signature` (null until the reveal), `sealed`, `owner`, `commitment`, `phase`, transaction hashes and blocks, `reveal_after`, `reveal_before`, `years`, `duration`, `expires_at`, `price`, `name_prepaid_code_id`, `issued_code_id`, `registered_at`, `failed_at`, `error`, `created_at`, `updated_at`.
|
||||
- Transactions: every one sent, fee-bumped replacements included, is a `name_transactions` row (below), saved with its raw signed transaction once signed and before it is sent.
|
||||
- `version`, `live` (an open invoice, or past `unpaid` and not `taken` or `code_issued`; a new order from the same wallet and name closes an overdue one, clearing it in the same transaction), `order_hash` (the order's fields, never the timestamp or signature, for exact resends), the order's method, `moves` (successful `/name` moves) and `links_refused_at` (a reveal refused for its links).
|
||||
- Unique `commitment` and unique `(owner, label_hash)` among rows with `live` set.
|
||||
- `reveal_after` and `reveal_before`: the commit's block time plus the controller's ages, read at the commit.
|
||||
- `price_id`: the price paid for, replaced at retarget; null when paid by code.
|
||||
- `duration`: the term in seconds the commitment was built with, checked at the order or the retarget.
|
||||
- `price`: what the order is worth in cents, the chain's price when ordered, replaced at retarget; 0 when paid by code. There is no price table: prices are the chain's.
|
||||
- `name_prepaid_code_id`: the code paying; null when paid.
|
||||
- `issued_code_id`: the code taken instead of a taken name.
|
||||
- The invoice links through `name_invoices.registration_id`.
|
||||
@@ -552,12 +577,13 @@ An order carries the name's hash, the owner address, the commitment and `sealed`
|
||||
- A register counts as mined when `ownerOf` is the owner.
|
||||
- **`name_record_edits`:**
|
||||
- Columns: `label_hash`, `owner` (the recovered signer), `nonce`, `key`, `value` (null for the clear form), `deadline`, `signature`, `body_hash` (of the signed request), transaction hashes, `state`, `created_at`.
|
||||
- Transaction hashes: every one sent, each saved with its raw signed transaction once signed and before it is sent.
|
||||
- Transactions: every one sent, each a `name_transactions` row saved with its raw signed transaction once signed and before it is sent. Only sent edits are rows: a refused request leaves none.
|
||||
- `state`: `pending`, `mined`, or `reverted` with the revert's name.
|
||||
- Unique on owner and nonce among pending and mined rows. So a reverted row stays for its repeat while the nonce is reused.
|
||||
- A row found applied on chain with no receipt for any of its hashes is `mined`, with no hash known to have mined.
|
||||
- The service answers identical repeats from the table after a restart, and the per-name cap counts its rows.
|
||||
- **`name_invoices`:** `invoice_id`, `registration_id`, `price_id`, `provider_ref`.
|
||||
- **`name_transactions`:** `registration_id` or the edit's id, `kind` (commit, register, edit), `raw`, `tx_hash`, `registrar_nonce`, `sent_at`, `mined_block`, `status`. The sender resumes from the rows with no receipt.
|
||||
- **`name_invoices`:** `invoice_id`, `registration_id`, `provider_ref`. The amount is the invoice's own.
|
||||
- **The badge tables are untouched**, so there is no table rebuild.
|
||||
- **Invoice queries:** today's are badge-only, so each gains the name side.
|
||||
- The queries: the shared `invoiceRowSelect` (inner-joined to `badge_code_invoices` and `badge_codes`); `qGetInvoice`, behind `GET /api/invoice/:id`, cancel and settlement (`settlementInvoice`); the poller's `qUnpaidRefs`, `qOverdueInvoices` and `qExpireOverdue` (whose update requires a `badge_code_invoices` row); and the lookups by `provider_ref`.
|
||||
@@ -567,24 +593,28 @@ An order carries the name's hash, the owner address, the commitment and `sealed`
|
||||
- in the same database transaction that marks the invoice paid;
|
||||
- with both publishes after it commits, so a long poll never reads `paid` with a stale `unpaid`.
|
||||
- Today a settled signal marks even an expired invoice paid (`Orders.hs` `decide`, the poller's "an expired invoice can still be marked paid"), with no alert.
|
||||
- For a name invoice already expired or canceled, settlement marks it paid, leaves the registration `unpaid`, and alerts the operator to refund, since Get a new invoice reuses the commitment. The order page and history then show "Paid after the invoice closed; support refunds it" with the reference (not drawn).
|
||||
- For a name invoice already expired or canceled, settlement marks it paid, leaves the registration `unpaid`, and alerts the operator to refund, since a new order of the same wallet for the name may have been paid meanwhile. The order page and history then show "Paid after the invoice closed; support refunds it" with the reference (not drawn).
|
||||
- `InvoiceRow`'s `irBadgeType` and `irMonths` become the product: a badge's type and months, or a name's registration id.
|
||||
- **Provider-ref lookups:** the webhook and poller queries that find an invoice by `provider_ref` cover both `badge_code_invoices` and `name_invoices`.
|
||||
- As in `Web/Server.hs` today, a row whose provider differs from the webhook's is ignored, as a guard against a collision between providers.
|
||||
- A ref must also be unique across the two tables. The lookup checks both, and refuses one found in both.
|
||||
- **Wire errors:** new on HTTP and in `api.ts`'s `WIRE_ERROR_CODES`:
|
||||
- **Wire errors:** new on HTTP, with their statuses in the contract's Appendix B:
|
||||
- `invalid_name`;
|
||||
- `name_taken`;
|
||||
- `name_check_unavailable`: the resolver failing during an order, register or retarget (N3d at checkout);
|
||||
- `name_check_unavailable`: a failed chain read during an order, register or retarget (N3d at checkout);
|
||||
- `invalid_link`;
|
||||
- `record_reverted`;
|
||||
- `record_reverted`, with `revert`;
|
||||
- `edit_pending` (504): an edit not mined within 90 seconds, or a chain that stopped answering after the send;
|
||||
- `code_not_covering`;
|
||||
- `code_reserved`;
|
||||
- the chat protocol's code errors `code_invalid` (unknown or revoked), `code_used`, `code_expired` and `payment_pending` (an unpaid code).
|
||||
- A retarget to a name dearer than the payment is `bad_request`.
|
||||
- `catalog_changed` now carries the chain's `price`.
|
||||
- `order_exists` (409): an order meeting another live order that is not its exact resend; it names nothing.
|
||||
- `price_too_high` (409, with `price`): a retarget to a name dearer than the order.
|
||||
- `code_unavailable` (409): `/code` when no length is as cheap as the order.
|
||||
- **Operator:**
|
||||
- `//issue name <length> [years <Y>] [paid|unpaid|free]`, and group `/issue name <length> [years <Y>]` / `/bulk name <length> [years <Y>] count <N>`, issue single-use prepaid codes.
|
||||
- Without years, a code covers 2.
|
||||
- `//issue name <length> [years <Y>] [paid|unpaid|free]`, and group `/issue name <length> [years <Y>]` / `/bulk name <length> [years <Y>] count <N>`, issue single-use prepaid codes. The length is 6–9 in practice (`SN6` to `SN9`); 6–63 is accepted.
|
||||
- Without years, a code covers 2. Codes and the paid years are at least 2.
|
||||
- Fewer than 2 is refused, since the controller reverts `DurationTooShort` below 730 days.
|
||||
- `//revoke` takes a badge code or a prepaid code.
|
||||
|
||||
@@ -593,16 +623,15 @@ An order carries the name's hash, the owner address, the commitment and `sealed`
|
||||
This stage makes the service send the commit and the register from its registrar key, and track each registration through its phases.
|
||||
|
||||
It uses the branch's chain client (`BadgeService/Names/Chain.hs`, `sendCommit`, `sendRegister`):
|
||||
- JSON-RPC to an RPC URL. By #7530's decision D5 that is the reth node on the resolver host. Its compose file runs reth `--minimal`, which likely prunes the hash-to-transaction index and old receipts the worker and `/retry` need (gap 25 in the sequence doc).
|
||||
- JSON-RPC to the RPC node, `[chain] rpc_url`: a reth node, or a hosted provider such as Alchemy. The same node serves every chain read of stage 3. A reth node run `--minimal` likely prunes the hash-to-transaction index and old receipts the worker and `/retry` need, so the service's node keeps them (gap 25 in the sequence doc).
|
||||
- A dry run (`eth_estimateGas`) of every transaction.
|
||||
- EIP-1559 fees.
|
||||
- Signed by the registrar key.
|
||||
|
||||
`[chain]` holds the RPC URL, chain id, the controller, sponsor, base registrar and resolver addresses, the registrar key and the confirmation depth.
|
||||
`[chain]` holds the RPC URL, chain id, the controller, sponsor, base registrar and default resolver contract addresses, the registrar key and the confirmation depth.
|
||||
|
||||
- **Signing:** `signRecoverable` and `signEip1559Tx` come from simplexmq #1887, on `names`. EIP-712 hashing and signer recovery come from simplexmq `ab/name-registration-primitives-sponsored`, a PR into `names`.
|
||||
- Both must reach the simplexmq the service builds against.
|
||||
- The branch's `cabal.project` still points at a local checkout.
|
||||
- The branch's `cabal.project` pins simplexmq `4e6f5586`, which has both (Order of work, "simplexmq dependence").
|
||||
- **Worker**, one per registration, resumable after a restart:
|
||||
1. `queued` (paid, or a prepaid code reserved) → `sendCommit` with the page's commitment → `committing`.
|
||||
2. → `committed` once the commit's receipt is in. It records `reveal_after` and `reveal_before` from its block time and the controller's `minCommitmentAge` and `maxCommitmentAge`. Then it waits for `reveal_after` and the page's reveal.
|
||||
@@ -626,14 +655,14 @@ It uses the branch's chain client (`BadgeService/Names/Chain.hs`, `sendCommit`,
|
||||
- It is keyed by registration id, since a prepaid-code registration has no invoice.
|
||||
- It is left out of `waitingCount`, which sets the payment poller's cadence (`waiting_seconds` against `idle_seconds`).
|
||||
- The phase is published on every phase change, by the worker or the service (settlement, `/retry`, `/name`, `/code`), after its database transaction commits.
|
||||
- **`GET /api/invoice/:id`** takes a `seenPhase` parameter beside `wait`.
|
||||
- It holds while its status and payment marks are the ones seen and, for a name invoice, its phase is `seenPhase`. It wakes on a change to any of them.
|
||||
- **`GET /api/invoice/:id`** takes a `seenVersion` parameter beside `wait`.
|
||||
- It holds while its status and payment marks are the ones seen and, for a name invoice, its registration's `version` is `seenVersion`. It wakes on a change to any of them, so a label, intent or reveal-time change inside a phase reaches the page at once.
|
||||
- A paid name invoice, whose status cannot change, holds on the phase map alone. So it adds nothing to `waitingCount`.
|
||||
- One that reads `paid` with phase `unpaid` (paid after it closed) is not held.
|
||||
- **`GET /api/registration/:id`** takes `seenPhase`, and holds while the phase is the one seen.
|
||||
- **`GET /api/registration/:id`** takes `seenVersion`, and holds while the version is the one seen.
|
||||
- **The library:**
|
||||
- `waitForChange` sends `seenPhase` for an invoice with a registration. It returns when its status, payment mark or phase changes. It treats `paid` as terminal only for an invoice without a registration.
|
||||
- A new `waitForRegistration(id, seenPhase)` long-polls `GET /api/registration/:id` the same way.
|
||||
- `waitForChange` sends `seenVersion` for an invoice with a registration. It returns when its status, payment mark or registration changes. It treats `paid` as terminal only for an invoice without a registration.
|
||||
- A new `waitForRegistration(id, seenVersion)` long-polls `GET /api/registration/:id` the same way.
|
||||
- Both stop at `registered`, `code_issued`, `taken` and `failed`. `waitForChange` also stops at `unpaid` once the invoice is no longer `open` (expired, canceled, or paid after it closed).
|
||||
- The page polls again after the buyer's own action (a retarget, a code, Try again).
|
||||
- **The branch's "left to the plumbing PR"** is this stage's checklist:
|
||||
@@ -662,9 +691,9 @@ It uses the branch's chain client (`BadgeService/Names/Chain.hs`, `sendCommit`,
|
||||
- The exception: `ownerOf(labelHash)` is the owner. That means this registration already went through (a resumed worker or a duplicate send). It is `registered`, recording `registered_at` and `expires_at` and spending its prepaid code.
|
||||
- The payment or prepaid code is kept, and the outcomes above apply.
|
||||
- **`failed`:** retried; still failing → "Paid, not registered".
|
||||
- **Records:** the links chosen at checkout, if any, are set by the register transaction.
|
||||
- Its `data` holds one `setText` call for each of `simplex.contact` and `simplex.channel` that has links, on the name's node.
|
||||
- The links are joined by `;` (`LINK_SEPARATOR` in `snrc-resolve.py`).
|
||||
- **Records:** the links chosen on the Links step, if any, are set by the register transaction.
|
||||
- Its `data` holds one `setText` call for each of `simplex.contact` and `simplex.channel` that has a link, on the name's node, contact first.
|
||||
- Each key holds one short link. Readers split a record on `;` (`LINK_SEPARATOR`), so a record set elsewhere with several links still reads.
|
||||
- Without links the name is registered with none.
|
||||
- **Funding:** `registerWithCredit` spends the registrar's allowance on the sponsor, so the registration fee is not paid in ETH. The registrar key pays only gas. A gas spike is the service's loss.
|
||||
- **Key risk:** the registrar key holds gas and spends an allowance. By policy it holds no names.
|
||||
@@ -674,7 +703,7 @@ It uses the branch's chain client (`BadgeService/Names/Chain.hs`, `sendCommit`,
|
||||
- `renewWithCredit` needs none.
|
||||
- So the allowance is kept small, and the guardian zeroes it as the kill switch.
|
||||
- **Allowance:** only the guardian (`controller.beneficiary()`) sets it, with `setRegistrarAllowance(registrar, allowanceUSD)` in attoUSD. It replaces, not adds.
|
||||
- Each registration spends the name's list price for its whole duration (5 years for a 5-year code).
|
||||
- Each registration spends the name's chain price for its whole duration: the paid years and the free days until 1 January 2027 (5 years and the days for a 5-year code).
|
||||
- `InsufficientAllowance` alerts the operator. While `available(label)` is true it is a `failed`, never `taken`.
|
||||
- **Redeeming in the app (later):** the service relays the code wallet's `approveWithSig` and the app wallet's `claimWithSig` on the base registrar. An endpoint for it comes with the app's redeem flow.
|
||||
|
||||
@@ -689,7 +718,7 @@ This stage builds two webapps that share logic, not screens.
|
||||
- **Moved out of the badges webapp:**
|
||||
- From `api.ts`, split at the product:
|
||||
- The transport moves: `ApiError`, the wire error codes, the long poll and its backoff, `waitForChange`.
|
||||
- `ApiError` gains the error body's other fields, since `decodeError` keeps only `error` today. `catalog_changed` carries the new price, `record_reverted` the revert's name, and a commitment's `bad_request` the open `invoiceId`.
|
||||
- `ApiError` gains the error body's other fields, since `decodeError` keeps only `error` today. `catalog_changed` carries the new price, `record_reverted` the revert's name, and `price_too_high` the new name's price.
|
||||
- `waitForChange` stops on `bad_request`. Today it retries that with backoff, like any non-OK status except 404 (thrown) and 429 (retried after `Retry-After`).
|
||||
- The invoice's common fields move: status, amounts, crypto, expiry.
|
||||
- Each app passes in its product:
|
||||
@@ -704,7 +733,7 @@ This stage builds two webapps that share logic, not screens.
|
||||
- The QR encoder from `qr.ts`: `encodeQr`, `paymentUri`, the `QrSymbol` type, and the steps its tests check (`generatorPoly`, `eccOf`, `rawDataModules`, `numDataCodewords`, `byteCapacity`, `alignmentPositions`, `dataCodewords`, `interleave`, `formatBits`, `versionBits`). `qrSvg` builds DOM nodes and stays in each webapp.
|
||||
- From `crowdfunding.ts`: the Wefunder link, taking its `utm_source` (`badges` today, `domains` as simplex.domains links it), and `dollars`. The perks are keyed by badge type and stay. The names app has its own placeholder amount.
|
||||
- **New for names:**
|
||||
- `api.ts` gains `checkName`, `checkCode`, `previewLink`, name orders, the reveal, `find`, the retarget (`/name`), the taken code (`/code`), `/retry`, `records`, registration views with `waitForRegistration`, and the new errors;
|
||||
- `api.ts` gains `checkName`, `checkCode`, `previewLink`, name orders, the reveal, `find`, the retarget (`/name`), the taken code (`/code`), `/retry`, `records`, registration views with `waitForRegistration`, and the new errors, as the contract defines them;
|
||||
- `catalog_changed` carries the new price.
|
||||
- **Shape:**
|
||||
- ES modules compiled by `tsc` with the webapp's strictness, but with its own `tsconfig.json`:
|
||||
@@ -738,13 +767,14 @@ This stage builds two webapps that share logic, not screens.
|
||||
- **Views:** `screens.ts` gains the link to simplex.domains, with `target: "_blank"` like the Wefunder link. The app supports running inside an iframe on simplex.chat (drafted on `sh/website-iframe`; simplex.chat/badges redirects today). The badges screens are otherwise unchanged.
|
||||
|
||||
**5c. The names webapp, in the simplex.domains repo**, replacing the countdown page at launch:
|
||||
- **One order at a time:** as the badges webapp refuses a second order then (it shows no Pay button), a submitted card payment awaiting confirmation turns checkout's Continue off (N3e) until it settles or expires. An unpaid open invoice only shows a link to that order.
|
||||
- **One order at a time:** as the badges webapp refuses a second order then (it shows no Pay button), a submitted card payment awaiting confirmation turns checkout's Continue off (N3e) until it settles or expires. An unpaid open invoice only shows a link to that order. The service refuses a second live order of one wallet for one name anyway (contract, section 5).
|
||||
- **Term and prices:** the page shows the service's price and `years`, "Free until 1 January 2027" with the days left, and Registered until `max(termStart, now) + years × 365 days` before registration (31 December 2028 for 2 years, 31 December 2031 for 5, for an order before 2027), then the view's `expiresAt` (owner, 2026-10-21: 365-day years, the real date). It builds `duration` from the search answer's `termStart` by the service's clock (`Date`), and builds a new commitment for every new order.
|
||||
- **Build:** a TypeScript build emitting a static site (`index.html`, `assets/<hash>/`, `sw.js`), the same way the badges webapp builds.
|
||||
- It depends on `@simplex-chat/store` at a pinned version from npm, and on the vendored `@noble/secp256k1` and `@noble/hashes`.
|
||||
- It copies the library into its assets and rewrites the specifiers, as 5b does.
|
||||
- **Crypto** (`wallet.ts`):
|
||||
1. 128-bit entropy from `crypto.getRandomValues`, formatted and parsed as the owner code by the library's `codes.ts` (SHA-256 checksum, WebCrypto);
|
||||
2. the BIP-39 phrase with that entropy, and its PBKDF2-SHA512 seed (WebCrypto);
|
||||
2. the BIP-39 seed: the phrase with that entropy, then PBKDF2-SHA512 (WebCrypto). The phrase stays inside `wallet.ts` and is never shown;
|
||||
3. BIP-32 to `m/44'/60'/0'/0/0` (HMAC-SHA512 from WebCrypto, secp256k1 from `@noble/secp256k1`);
|
||||
4. the EIP-55 address (keccak from `@noble/hashes`);
|
||||
5. the commitment secret from the seed, and signatures over the `RegisterName` intent, `SetText` and `ClearRecords` record edits, and the EIP-191 `find`, `name` and `code` messages; the sealed label and links.
|
||||
@@ -759,22 +789,22 @@ This stage builds two webapps that share logic, not screens.
|
||||
- the `RegisterName`, `SetText` and `ClearRecords` digests, computed with viem and accepted by PR #35's `SimplexSponsor` and `SimplexResolver`, as `e2e/StoreE2E.test.ts` signs them.
|
||||
- The branch's `registerWithCredit` calldata vectors pin that digest indirectly, through the viem signatures they embed.
|
||||
- simplexmq's `EthCryptoTests.hs` on `ab/name-registration-primitives-sponsored` still hashes the type without the nonce, under a `SimpleX`/`2` domain.
|
||||
- **Views**, from `plans/names-codes/mockups/domains.js` and `domains.css`, using simplex.domains' fonts, logos and brand blue with the mockups' palette, light and dark:
|
||||
- the header, the search and its results, the code popup;
|
||||
- checkout with links and "I have a code", and the owner code before paying;
|
||||
- **Views**, from ep's mockup of 2026-10-21 (artifact 86a64565) and the redesign spec, using simplex.domains' fonts, logos and brand blue, light and dark:
|
||||
- the header with Check your code and Your names (a menu on a phone), the search and its one-row answers, the chat popup;
|
||||
- Check your code: one field for a prepaid code or a name code;
|
||||
- the Links step, the name code, Check your order with the dApp line;
|
||||
- the payment pages and their endings;
|
||||
- on the registered page, "Show it as 12 words, for another wallet": the owner code's BIP-39 phrase, for importing elsewhere;
|
||||
- the registration track and the registered page;
|
||||
- restore and the outcomes;
|
||||
- history and the link editor.
|
||||
- **Wiring:** routes `/`, `#/checkout`, `#/names`, `#/?name=&owner=` (opened from the app), `?order=<id>` and `?registration=<id>`.
|
||||
- the outcomes;
|
||||
- Your names and Edit links.
|
||||
- **Wiring:** routes `/`, `#/checkout`, `#/names`, `#/code` (Check your code), `?order=<id>` and `?registration=<id>`.
|
||||
- **Order errors on the page:**
|
||||
- `name_taken` goes back to search and shows its new answer (N2a or N2b, or N2c for a reserved name).
|
||||
- Code errors from the service show where the code was entered: the popup (K5) or the checkout entry (K7). A typo is caught on the page (K6).
|
||||
- A `bad_request` carrying an `invoiceId` opens that order.
|
||||
- Code errors from the service show on Check your code, or, when an order or Try again spends the code, on that page. A typo is caught on the page.
|
||||
- `order_exists` runs the signed `find` and opens the order found.
|
||||
- Anything else at checkout is N3d. After the order exists, the order page shows the error with the reference, and the payment is kept.
|
||||
- **The owner code:** stored only in this browser. It is shown before paying, on the registered page and in history, and never sent anywhere. Its QR is for the app to scan.
|
||||
- **The Stripe publishable key:** injected into the shell at export, as for badges (stage 6).
|
||||
- **The Stripe publishable key:** injected into the shell at export, as for badges (stage 6). Stripe.js loads on the page's own origin, beside the stored codes (accepted, 2026-10-20).
|
||||
- **Tests:** node:test, as the badges webapp has them.
|
||||
|
||||
**5d. simplex.domains as a submodule of simplex-chat, at `apps/simplex-domains`:**
|
||||
@@ -801,24 +831,24 @@ Changes:
|
||||
- `/webhooks/*` (`/webhooks/btcpay`, `/webhooks/stripe`) stays on badges.simplex.chat only, as today. BTCPay and Stripe send every event there, names payments included.
|
||||
- `apps/simplex-badge-service/README.md` (renamed in stage 1) gains the host blocks, since the Caddyfile is not in this repository.
|
||||
- The names site's CSP follows the badges one in `web/README.md`, with `data:` added to its `img-src` (`'self' data: https://*.stripe.com`) for the link profiles' pictures.
|
||||
- Stripe.js runs on simplex.domains itself, in the document that holds the owner codes and prepaid codes in `localStorage` (owner, 2026-10-20: accepted). The README's hosting section says so, with the CSP's `script-src` and `frame-src` limited to `https://js.stripe.com` and `https://*.js.stripe.com`.
|
||||
- The CSP allows no inline styles. So the views port the mockups' `style` attributes to classes, or set them through `node.style` (the avatar hue).
|
||||
- **All-in-one mode** (`serve_webapp = on`) picks the site by `Host` against `[listener] names_hosts`. `contentTypeFor` gains `font/ttf` for simplex.domains' Manrope `.ttf` files, or the names build converts them to `.woff2`.
|
||||
- **Chain:** the resolver and reth on loopback when they share the host. The service's key is in the mounted ini, which `Dockerfile.dockerignore` already keeps out of the image.
|
||||
- **Egress:** `scripts/store-service/egress.sh` denies the service's outbound traffic to private, loopback, link-local, unique-local, CGNAT, unspecified and multicast ranges, except the addresses it is configured to use (the RPC node, the database, BTCPay). It is the real guard behind the link previews' best-effort host check, which DNS rebinding could pass. With host networking it matches the service's user (`iptables -m owner --uid-owner`); on a Docker bridge, that network. The README has the steps to install and check it.
|
||||
- **Chain:** the RPC node only: a reth node on loopback when it shares the host, or a hosted provider such as Alchemy over HTTPS. The service's key, and a provider URL with its API key, are in the mounted ini, which `Dockerfile.dockerignore` already keeps out of the image.
|
||||
- **Unchanged:** the database `badge_service` and the `sx_badge_service_` prefix.
|
||||
|
||||
### 7. Mock (`web/mock/server.py`)
|
||||
### 7. The mock and the contract
|
||||
|
||||
This stage extends the mock server so both webapps can be tested without the service.
|
||||
This stage keeps the names webapp's mock and the service on one API.
|
||||
|
||||
- `GET /api/name/<hash>?length=<n>`: fixed available, taken (with entities and images) and reserved names by hash, and per-name prices.
|
||||
- `POST /api/registration/<id>/reveal`: checks as the service does.
|
||||
- `POST /api/code/check`: fixed codes covering 6+, 7+ and 8+, one used and one revoked.
|
||||
- `POST /api/link/preview`: fixed address and channel profiles, and an invalid link.
|
||||
- `POST /api/name/records`: checks the signature against the stored owner and answers as the relay would.
|
||||
- `POST /api/registration/find`: checks the signature and returns the mock's registrations for that owner.
|
||||
- Name orders by payment and by code.
|
||||
- `POST /control/registration/<id>/<phase>` steps a registration, or sends it to taken or failed; `retry`, `name` and `code` follow the service.
|
||||
- Both sites, the badges webapp and the names submodule, by path.
|
||||
- **The names mock** lives with the webapp, `simplex.domains/webapp/dev/mock/` (`service.ts`, `chain.ts`, served by `dev/mock.ts`, deployed by `dev/deploy.ts`). It drives the real contracts on a local anvil chain, reads them directly, and keeps orders in memory. `/control/*` endpoints settle, expire and fail orders, take names, and inject errors for tests.
|
||||
- **It follows the contract** [`2026-10-21-store-api.md`](2026-10-21-store-api.md). Its Appendix A lists what the mock and the page change: chain prices, `termStart` and `duration`, signed orders and `order_exists`, the retarget message, the revoked code state, the taken code's length, `find`'s new fields, edit repeats, `version` and `unread`, `/name` bounds, the reveal's race, link `profile` states, `/code`'s `codeLength`, `gracePeriod`.
|
||||
- **The badges mock** (`web/mock/server.py`) gains no names endpoints. Both sites' webapps are tested against their own mocks.
|
||||
- **The service is checked against the same contract:**
|
||||
- Haskell API tests walk every endpoint and outcome of the contract against a fake JSON-RPC node.
|
||||
- A contract run starts the service on the local anvil chain the mock uses (`dev/deploy.ts`), with fake BTCPay and Stripe, and runs the webapp's e2e specs that need no `/control` endpoint against it: search, code check, register with a code to registered, the reveal, find, edit links.
|
||||
- **Both sites** are served by the service in all-in-one mode for these runs (stage 6).
|
||||
|
||||
## Order of work
|
||||
|
||||
@@ -827,25 +857,25 @@ What each stage waits for, and what to agree with others first.
|
||||
- **Chain dependence:**
|
||||
- Stages 1, 2 and 5–7 do not depend on the chain.
|
||||
- Stage 3 reads the chain:
|
||||
- search's records nonce and raw records;
|
||||
- search's availability, reservation, `owner(node)`, `resolver(node)`, records nonce, raw records and price (`rentPriceUSD`), and the same availability and price reads in orders, `/api/name/register` and `/name`;
|
||||
- the reveal's sponsor nonce;
|
||||
- `/retry`'s `ownerOf`, `nameExpires`, register receipts and sponsor nonce;
|
||||
- the registration view's `intentNonce` and `recordsNonce`;
|
||||
- the registration view's `intentNonce`;
|
||||
- the records relay's latest block time, `owner(node)`, nonce, and, after a revert, expiry or lost receipt, the record values.
|
||||
- Stage 3 also sends to the chain (the records relay). So it is built and tested against the fake resolver and a fake JSON-RPC node.
|
||||
- Stage 3 also sends to the chain (the records relay). So it is built and tested against a fake JSON-RPC node.
|
||||
- Its relay sends through stage 4's one sender, so the relay ships with stage 4.
|
||||
- **simplexmq dependence:**
|
||||
- These wait for simplexmq `names` (#1843, #1887's signing) and for the EIP-712 hashing and `recoverAddress` of `ab/name-registration-primitives-sponsored` to reach the simplexmq the service builds against:
|
||||
- stage 2 (on Alain's branch, whose owner code uses simplexmq's BIP-39 from #1843, and whose `Registration` encoding uses its EIP-712);
|
||||
- stage 3's relay, reveal intent check, and signed `find`, `/name` and `/code`;
|
||||
- stage 4.
|
||||
- Neither this branch's pin, `e24d537e`, nor master's, `cba13823`, has them. PR #7693 pins simplexmq `3bd48320` (simplexmq PR #1941 into `names`), which has the EIP-712, `recoverAddress` and transaction modules.
|
||||
- Alain's branch (`f06910698`, merged with `names`) pins simplexmq `4e6f5586` ("add EIP712 (#1941)") in `cabal.project`, with its hash in `scripts/nix/sha256map.nix`. That commit has `Simplex.Messaging.Eth.Address` (`recoverAddress`, `addressFromPrivateKey`; the EIP-55 form is `strEncode`, since `checksumAddress` is not exported), `Eth.EIP712` (`hashTypedData`, `Eip712Value`), `Eth.Transaction` (`signEip1559Tx`) and `Eth.RLP` (checked 2026-10-21 in the store worktree's cabal source cache). It has no EIP-191 helper, so the service hashes `"\x19Ethereum Signed Message:\n" ‖ length ‖ message` itself. The backend branch `sh/store-service` starts from it.
|
||||
- A paid order waits in `queued` until stage 4 lands.
|
||||
- **Codes differ from #7530 §6/§9:** the `SN7-` format without years in the text, their own table, none sold on the web, and the service's table rather than the text deciding what a code covers. So the app cannot check one offline. Agree with #7530 before stage 2.
|
||||
- **The mock first:** the contract's Appendix A changes to the mock (chain prices, `termStart` and `duration`) land in the webapp before stage 3's endpoints are built, so both are tested against the same answers.
|
||||
- **With Alain:** agree the `NameCode` sum type (his `NameCode` becomes `NameOwnerCode`), land his primitives branch with a pinned simplexmq, and do the StoreService rename after it, since the rename moves his `BadgeService/Names/` modules.
|
||||
- **Stage 5 in order:** the library (5a) and the badges webapp on it (5b) first, then a published release the names webapp (5c) pins, then the submodule (5d).
|
||||
- **The app side:** redeeming an owner code in the app (the service relaying `approveWithSig` and `claimWithSig`) is needed before a web-bought name moves to the app.
|
||||
- A BIP-44 wallet importing the code's BIP-39 phrase holds the name and can edit its records, paying gas.
|
||||
- Moving it still needs the recipient to claim it, since PR #35's `transferFrom` reverts `RecipientMustBeCaller` unless the recipient or a registrar controller calls.
|
||||
|
||||
## Out of scope
|
||||
@@ -853,6 +883,7 @@ What each stage waits for, and what to agree with others first.
|
||||
- The app side: redeeming owner codes and prepaid codes in the app (#7530), record editing in the app (later app work).
|
||||
- Funding the service's key (operations).
|
||||
- Renewals, transfers, in-app purchase.
|
||||
- The web flow opened from the SimpleX app (`?name=&owner=`): dropped; the app will buy names itself (#7530 §13.6).
|
||||
- Refunds (manual, by support).
|
||||
- Selling codes on the web.
|
||||
|
||||
@@ -861,22 +892,42 @@ What each stage waits for, and what to agree with others first.
|
||||
How each part is checked before release: Haskell tests, web tests, the Docker build, and end-to-end runs on the mock.
|
||||
|
||||
- **Haskell:** `cabal build exe:simplex-store-service` and the service and core test filters, on SQLite and Postgres. Covers:
|
||||
- name code vectors and `validLabel` (a `--` anywhere refused, as simplexmq parses it);
|
||||
- name code vectors and the label rule (a `--` anywhere refused, as simplexmq parses it);
|
||||
- the migration up/down;
|
||||
- `/api/name` against a fake resolver, fake link profiles and a fake JSON-RPC node:
|
||||
- `/api/name` against fake link profiles and a fake JSON-RPC node:
|
||||
- available, registered and reserved from the base registrar's `nameExpires` plus `GRACE_PERIOD` against the latest block and the controller's `reservedNames`, with `gracePeriod` in the answer;
|
||||
- the owner and resolver contract from the registry, and a hash `registering` here answered `registered` with no owner;
|
||||
- nonces and the two text records on the default resolver contract;
|
||||
- a name on another resolver contract or none, answered with `records` and `recordsNonce` null;
|
||||
- `/api/code/check`;
|
||||
- `/api/code/check`, each state including `revoked`;
|
||||
- prices and the term:
|
||||
- the available answer's `price` from `rentPriceUSD` for the paid years, and `termStart`;
|
||||
- an order whose price differs from the chain's refused with `catalog_changed` and the chain's price;
|
||||
- a duration within the slack accepted, one outside it (too long, too short, or negative free days) refused, before and after `termStart`;
|
||||
- the reveal rebuilding the commitment with the stored duration;
|
||||
- a second order of one wallet for one name with a new commitment refused `order_exists`; accepted after the old invoice's cancel, or after its expiry time even before the poller marks it;
|
||||
- a taken order's `codeLength` from the chain prices, a non-monotone list, none qualifying, and `codeLength` kept in `code_issued`;
|
||||
- exact resends answered, any other order meeting a live one `order_exists` with no fields, an unsigned or wrongly signed order refused;
|
||||
- two concurrent orders for one owner and name, and two for one code, one winning;
|
||||
- `/name` from `failed`, `taken` and after a reveal refused for its links, refused from a healthy `committed`, `move_limit` on the fourth move, a repeat counting no move, a code order's code re-reserved, and `price_too_high`;
|
||||
- a reveal racing a `/name`, one taking effect and the other answered `registration_changed`;
|
||||
- `/code` answering `code_length_changed` after a price change;
|
||||
- link previews: a public IP literal fetched, private, CGNAT, unique-local, `.onion` and unresolvable hosts `unavailable`, a link with one private host of several `unavailable`, previews unable to starve search;
|
||||
- a new order closing an overdue order of the same wallet and name, and a payment of the closed one recorded as a late payment;
|
||||
- a reveal with uncommitted links or a wrong signer recording no links refusal;
|
||||
- a `/code` repeat answered 200 after a price change;
|
||||
- a failed `intentNonce` or `codeLength` read leaving the view with `unread`;
|
||||
- orders, the reveal and the records relay:
|
||||
- orders by payment and by code;
|
||||
- the reveal and its checks: with and without links, a high-s intent refused, the sponsor nonce from a fake JSON-RPC node;
|
||||
- link previews;
|
||||
- the records relay against a fake JSON-RPC node;
|
||||
- a second order for a commitment with an open invoice, refused with its id;
|
||||
- `/api/name/register` for a commitment with an open invoice, refused, and for one with a paid registration, answered with its `registrationId` and `invoiceId`;
|
||||
- a resend of the same order, answered with the existing invoice;
|
||||
- a second, different order for a commitment or for an owner and name with an open invoice, refused `order_exists` with no other field;
|
||||
- an order carrying another wallet's public commitment, refused without disclosing anything;
|
||||
- `/api/name/register` meeting an open invoice or a paid registration, refused `order_exists`;
|
||||
- an exact resend of an order (every field, `sealed` included), answered with the existing invoice or `registrationId`, also after the timestamp window;
|
||||
- Get a new invoice after the card form failed to load, cancelling the old one first (and re-reading on `not_open`);
|
||||
- an order for a commitment with a registration past `unpaid`, answered with that registration's order (a code one with `invoiceId` null), checked before the resolver so a resend after its own registration is not `name_taken`;
|
||||
- an exact resend after its own registration went past `unpaid`, answered with its order before the chain read, so it is not `name_taken`;
|
||||
- a cancel answering `200` `paid`, or `200` `expired` with payment marks, re-read; and `200` `open` shown as `CANCEL_STILL_OPEN`;
|
||||
- two orders for one name (the later one taken);
|
||||
- closed invoices:
|
||||
@@ -895,15 +946,15 @@ How each part is checked before release: Haskell tests, web tests, the Docker bu
|
||||
- a reverted edit's repeat answered `record_reverted` while a new edit with its nonce is pending;
|
||||
- a revert freeing the nonce for a new body;
|
||||
- a failed re-read after a dry-run or mined revert, retried with the edit kept `pending`;
|
||||
- the per-name cap;
|
||||
- the per-name cap, counting relayed edits only, so a non-owner's refused edits leave it untouched;
|
||||
- sizes:
|
||||
- the link bound at preview, reveal and records;
|
||||
- `sealed` over 11095 characters refused at `/api/invoice`, `/api/name/register` and `/name`;
|
||||
- a longest order and a reveal with two records at the link bound passing the body cap;
|
||||
- `find`, `/name` and `/code` with their owner signatures and 10-minute window (a wrong signer and a stale timestamp refused), and `/retry` (re-reserving a released code, the lost-confirmation path);
|
||||
- the worker against a fake JSON-RPC node:
|
||||
- each phase change waking a long poll held on a paid invoice with `seenPhase`, and one held on `GET /api/registration/:id?seenPhase`;
|
||||
- an unpaid name invoice held with `seenPhase=unpaid` waking on its expiry, and one paid after it closed answered at once and not polled again;
|
||||
- each change (phase, label, reveal times, error) waking a long poll held on a paid invoice with `seenVersion`, and one held on `GET /api/registration/:id?seenVersion`;
|
||||
- an unpaid name invoice held on its status waking on its expiry, and one paid after it closed answered at once and not polled again;
|
||||
- commit, wait, register;
|
||||
- a revert to taken;
|
||||
- a restart mid-phase;
|
||||
@@ -933,15 +984,14 @@ How each part is checked before release: Haskell tests, web tests, the Docker bu
|
||||
- **End to end on the mock with Playwright:**
|
||||
1. search (Enter, button) → available → the owner code, confirmed as saved → Bitcoin → steps → registered
|
||||
2. taken, reserved, invalid
|
||||
3. "Check your code" → $0 → register with code → registered for the code's years
|
||||
4. "I have a code" at checkout, and codes that do not work
|
||||
3. Check your code with a prepaid code → $0 at search → register with code → registered for the code's years
|
||||
4. codes that do not work on Check your code (used, reserved, revoked, expired, unpaid, unknown, not a code, a typo), and a code that no longer covers or was spent when the order is sent
|
||||
5. links at checkout → the name points at them; Edit links
|
||||
6. storage cleared after paying → the order link asks for the owner code and finishes; Restore with a name code from Your names
|
||||
6. storage cleared after paying → the order link asks for the owner code and finishes; Check your code with a name code names the name and adds it to Your names
|
||||
7. taken during registration → another name, and → a code
|
||||
8. failed → Try again
|
||||
9. card
|
||||
10. opened from the app
|
||||
11. history with "Add a code"
|
||||
12. both stores' cross-links
|
||||
10. Your names with names and prepaid codes
|
||||
11. both stores' cross-links
|
||||
|
||||
Light, dark and phone, against the stage-0 board.
|
||||
Light, dark and phone, against ep's mockup of 2026-10-21.
|
||||
@@ -3,14 +3,15 @@
|
||||
This document shows every flow of the names store as a sequence of calls. Its purpose is to check each step and find what is missing.
|
||||
|
||||
- The spec is [`2026-10-09-name-codes.md`](2026-10-09-name-codes.md), and the plan is [`2026-10-09-names-web-store-plan.md`](2026-10-09-names-web-store-plan.md).
|
||||
- The words used here (owner code, commitment, intent, sponsor, the lag and others) are defined in the spec, under [Words used here](2026-10-09-name-codes.md#words-used-here).
|
||||
- The words used here (owner code, commitment, intent, sponsor, RPC node, resolver contract and others) are defined in the spec, under [Words used here](2026-10-09-name-codes.md#words-used-here).
|
||||
- The API contract [`2026-10-21-store-api.md`](2026-10-21-store-api.md) is authoritative for every request and answer shown here.
|
||||
|
||||
## The whole flow at a glance
|
||||
|
||||
Three parts talk to each other:
|
||||
- the **webapp** in the browser;
|
||||
- the **service** (StoreService);
|
||||
- the **chain host**, where the resolver reads names from the Ethereum node (reth, with nimbus as its consensus client).
|
||||
- the **RPC node**: the Ethereum JSON-RPC endpoint, a reth node (with nimbus as its consensus client) or a hosted provider such as Alchemy. The service reads names and sends transactions through it directly.
|
||||
|
||||
The payment provider only takes the money. Sections 1–13 below show every step in detail. This diagram is the happy path for one paid name.
|
||||
|
||||
@@ -20,21 +21,17 @@ sequenceDiagram
|
||||
participant W as Webapp
|
||||
participant S as Service
|
||||
participant P as BTCPay or Stripe
|
||||
box Chain host
|
||||
participant R as Resolver
|
||||
participant E as Ethereum node<br/>reth + nimbus
|
||||
end
|
||||
participant E as RPC node<br/>reth or Alchemy
|
||||
|
||||
Note over W,E: Search
|
||||
W->>S: GET /api/name/:hash
|
||||
S->>R: GET /v2/resolve/[hash].simplex
|
||||
R->>E: eth_call (registry, resolver)
|
||||
R-->>S: available
|
||||
S-->>W: available, price
|
||||
S->>E: eth_call: nameExpires(labelHash), reservedNames(labelHash), rentPriceUSD
|
||||
E-->>S: available, price
|
||||
S-->>W: available, price, termStart
|
||||
|
||||
Note over W,E: Order
|
||||
W->>W: owner code → wallet, makeCommitment
|
||||
W->>S: POST /api/invoice (hash, owner, commitment)
|
||||
W->>W: links, owner code → wallet, duration, makeCommitment
|
||||
W->>S: POST /api/invoice (hash, duration, owner, commitment)
|
||||
S->>P: create invoice
|
||||
S-->>W: invoice to pay
|
||||
|
||||
@@ -78,9 +75,8 @@ Participants, the same in every diagram:
|
||||
- **Webapp**: the names webapp in the browser (`wallet.ts`, `names.ts`, the library's `api.ts` and `store.ts`).
|
||||
- **Service**: StoreService. This is its web API, its registration worker with the one transaction sender, and its database.
|
||||
- **BTCPay or Stripe**: the payment provider.
|
||||
- **Resolver**: `snrc-resolve.py` on the chain host. It reads names by hash from the Ethereum node.
|
||||
- **Ethereum node**: reth (with nimbus) and the PR #35 contracts (`SimplexController`, `SimplexSponsor`, `SimplexResolver`, the base registrar).
|
||||
- **SimpleX app**: the app. It redeems names, and it opens orders for its own wallet.
|
||||
- **RPC node**: reth (with nimbus) or a hosted provider such as Alchemy, and the PR #35 contracts behind it (`SimplexController`, `SimplexSponsor`, `SimplexResolver`, the base registrar, the registry). The service reads every name and sends every transaction through it. The SMP relays and the app read the chain from their own nodes too; there is no resolver service (owner, 2026-10-21).
|
||||
- **SimpleX app**: the app. It redeems names. The web flow opened from the app is dropped (owner, 2026-10-21): the app will buy names itself (#7530 §13.6).
|
||||
|
||||
Each section shows the calls first. The bullets under it hold the exact rules.
|
||||
|
||||
@@ -96,11 +92,10 @@ Each section shows the calls first. The bullets under it hold the exact rules.
|
||||
7. A prepaid code
|
||||
8. Taken: another name, or a prepaid code
|
||||
9. Failed, retried, lapsed
|
||||
10. Restore with the owner code
|
||||
11. Links: preview at checkout, edit later
|
||||
10. A name code on Check your code
|
||||
11. Links: preview before the order, edit later
|
||||
12. Redeem in the app
|
||||
13. Opened from the app
|
||||
14. Gaps found
|
||||
13. Gaps found
|
||||
|
||||
## 1. Search
|
||||
|
||||
@@ -111,67 +106,64 @@ sequenceDiagram
|
||||
autonumber
|
||||
participant W as Webapp
|
||||
participant S as Service
|
||||
participant R as Resolver
|
||||
participant E as Ethereum node
|
||||
W->>W: lower-case, validLabel, keccak256 → labelHash
|
||||
participant E as RPC node
|
||||
W->>W: lower-case, validateLabel, keccak256 → labelHash
|
||||
W->>S: GET /api/name/:labelHash?length=n
|
||||
S->>R: GET /v2/resolve/[labelHash].simplex
|
||||
R->>E: eth_call: registry, registrar, controller
|
||||
R-->>S: available, registered or reserved
|
||||
S->>E: eth_call: registrar nameExpires(labelHash), GRACE_PERIOD, controller reservedNames(labelHash)
|
||||
E-->>S: available, registered or reserved
|
||||
alt available
|
||||
S->>S: namePrice(labelHash, n)
|
||||
S-->>W: available (price, sponsor, chainId)
|
||||
S->>E: eth_call: controller rentPriceUSD("a" × n, years × 365 days)
|
||||
S-->>W: available (price, years, termStart, resolver, sponsor, chainId)
|
||||
else registered
|
||||
S->>S: linkProfile(link) for each link
|
||||
S->>E: registry owner(node), resolver(node)
|
||||
S->>E: nonces(owner), text(node, contact), text(node, channel)
|
||||
S-->>W: registered (cards, owner, records)
|
||||
S->>S: chatProfile(link) for each link
|
||||
S-->>W: registered (chats, owner, records)
|
||||
else reserved
|
||||
S-->>W: reserved
|
||||
end
|
||||
```
|
||||
|
||||
- **The page's check:** the page folds the name to lower case first. Then `validateLabel` in `names.ts` checks it, mirroring `validLabel`.
|
||||
- **The page's check:** the page folds the name to lower case first. Then `validateLabel` in `names.ts` checks it, mirroring simplexmq's `nameLabelP`, which the service reaches by parsing the label as `SimplexDomain`.
|
||||
- **Checks first:** the service checks the search rate limit, then the length. The length must be 6–63, else the answer is `invalid_name`.
|
||||
- **The price:** `namePrice` is `Catalog.namePrice`.
|
||||
- **Hash formats:**
|
||||
- `:labelHash` is `0x` + 64 lowercase hex.
|
||||
- The resolver path takes 64 lowercase hex without `0x`.
|
||||
- **The lag:** the resolver may say available while our own registration (`registrationByHash(labelHash)`) is `registering`, or `registered` within the last hour.
|
||||
- **The price:** the chain's, `rentPriceUSD(…).base / 10^16` in cents for the paid `years`. The placeholder label of the same length works because the oracle prices by length only, and keeps the label from the RPC node. Launch list: $100 for 8+ letters, $200 for 7, $400 for 6, for 2 years.
|
||||
- **Hash formats:** `:labelHash` is `0x` + 64 lowercase hex.
|
||||
- **The status:** `registered` while `nameExpires + GRACE_PERIOD` is not past the latest block; else `reserved` when `reservedNames(labelHash)` is set; else `available`.
|
||||
- **Being registered here:** the chain still says available while our own registration (`registrationByHash(labelHash)`) is `registering`, its register sent but not mined.
|
||||
- Then the answer is `registered{entities: [], tld}`, with no owner.
|
||||
- The page then takes the nonce, chain id and resolver from the registration view.
|
||||
- The worker sets `registered` only `confirmations` blocks after the receipt, so from then on the chain reads show the name.
|
||||
- **The answers:**
|
||||
- `available{price, currency, years, resolver, sponsor, chainId, tld}`;
|
||||
- `registered{entities, records, owner, resolver, recordsNonce, chainId, tld}`.
|
||||
- `available{price, currency, years, termStart, resolver, sponsor, chainId, tld}`;
|
||||
- `registered{entities, records, owner, resolver, recordsNonce, expiresAt, chainId, tld}`.
|
||||
- **Owner and records:**
|
||||
- `owner` and `resolver` come from the resolver's answer.
|
||||
- `records` and `recordsNonce` are read from the chain, on the default resolver, with `node = keccak256(namehash(tld) ‖ labelHash)`.
|
||||
- They are read only when the answer names the default resolver. Otherwise they are null.
|
||||
- **Link cards:** for each `simplexContact` and `simplexChannel` link, `linkProfile` uses `APIConnectPlan` ‡. It has its own rate limit, a cache and a 3 s timeout.
|
||||
- **Failures:** any resolver error answers `provider_unavailable`. A failed chain read answers the same. The resolver errors are:
|
||||
- `upstreamError`, `tldNotConfigured`, `noPriceOracle`, `unknown`;
|
||||
- no answer;
|
||||
- `labelNotRecorded`, which cannot happen on PR #35.
|
||||
- `node = keccak256(namehash(tld) ‖ labelHash)`, and the registry is `baseRegistrar.ens()`.
|
||||
- `owner` and `resolver` are the registry's `owner(node)` and `resolver(node)`.
|
||||
- `records` and `recordsNonce` are read on the default resolver contract.
|
||||
- They are read only when `resolver(node)` is the default resolver contract. Otherwise they are null.
|
||||
- **The chats:** for each short link of its key's kind in the `simplex.contact` and `simplex.channel` records, split on ";", `chatProfile` uses `APIConnectPlan` ‡. Each chat has its `kind` (contact, business, bot or channel), display name, full name, picture if any, a channel's subscriber count, the link and its deep link. A fetch has a 3 s timeout, at most 4 run at once, and a 10-minute cache; a failed one gives the chat with no profile.
|
||||
- **Failures:** a failed chain read answers `provider_unavailable`: the RPC node does not answer, or answers an error.
|
||||
|
||||
## 2. Checkout, owner code and order
|
||||
|
||||
The page makes the owner code, the owner wallet and the commitment in the browser, then places the order. The order carries the name's hash, the commitment and the sealed copy, not the name itself.
|
||||
The buyer chooses the links, saves the name code, then checks the order and pays. The page makes the owner code, the owner wallet and the commitment in the browser. The order carries the name's hash, the duration, the commitment and the sealed copy, not the name itself.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant W as Webapp
|
||||
participant S as Service
|
||||
opt Links step
|
||||
W->>S: POST /api/link/preview (section 11)
|
||||
S-->>W: the link's chat
|
||||
end
|
||||
W->>W: newOwnerCode() → owner code
|
||||
W->>W: show the owner code, wait for "I have saved my name code"
|
||||
W->>W: walletFromCode(code) → key, owner address
|
||||
W->>W: secret = HMAC(seed, name)
|
||||
opt links chosen
|
||||
W->>S: POST /api/link/preview (section 11)
|
||||
S-->>W: link card
|
||||
end
|
||||
W->>W: duration = years × 365 days + max(0, termStart − now)
|
||||
W->>W: commitment = makeCommitment(registration)
|
||||
W->>W: sealed = encrypt(name, links) with the seed
|
||||
W->>W: saveOrder(code, name, links)
|
||||
W->>W: show the owner code, wait for "I have saved my code"
|
||||
```
|
||||
|
||||
Then the order itself:
|
||||
@@ -181,13 +173,14 @@ sequenceDiagram
|
||||
autonumber
|
||||
participant W as Webapp
|
||||
participant S as Service
|
||||
participant R as Resolver
|
||||
participant E as RPC node
|
||||
participant P as BTCPay or Stripe
|
||||
W->>S: POST /api/invoice (labelHash, length, price, method, owner, commitment, sealed)
|
||||
S->>S: validate, check this commitment's other orders
|
||||
S->>R: is the name still free?
|
||||
R-->>S: available
|
||||
S->>S: namePrice = price?
|
||||
W->>W: sign "simplex.domains order …" with the owner key
|
||||
W->>S: POST /api/invoice (labelHash, length, duration, price, method, owner, commitment, sealed, timestamp, signature)
|
||||
S->>S: validate, check the signature, existing orders for the name, the duration (one at a time per label hash)
|
||||
S->>E: is the name still free? nameExpires, reservedNames; rentPriceUSD
|
||||
E-->>S: available, price
|
||||
S->>S: chain price = price?
|
||||
S->>P: create invoice
|
||||
P-->>S: invoice
|
||||
S->>S: save registration (unpaid) and invoice
|
||||
@@ -195,29 +188,30 @@ sequenceDiagram
|
||||
W->>W: saveOrder(orderId = invoiceId, registrationId)
|
||||
```
|
||||
|
||||
- **Before checkout:** if this browser has an open order whose card payment awaits confirmation, Continue is off (N3e).
|
||||
- **Before paying:** if this browser has an open order whose card payment awaits confirmation, Pay is off (N3e). Check your order, the page before payment, shows the links read-only, the term, "Free until 1 January 2027" and the end date (`max(termStart, now) + years × 365 days`: 31 December 2028 for 2 years ordered before 2027), and the dApp line for Ethereum buyers (ep, 2026-10-21 08:43, recorded in the owner decisions).
|
||||
- **Page-side functions:**
|
||||
- `newOwnerCode` works as `randomNameCode` †. It takes 128 bits of entropy. The code is `SN-` + the entropy + 22 bits of SHA-256.
|
||||
- `walletFromCode` works as the test mock †. It makes a BIP-39 seed, the key at `m/44'/60'/0'/0/0`, and its address.
|
||||
- `walletFromCode` works as the test mock †. It makes a BIP-39 seed from the entropy, the key at `m/44'/60'/0'/0/0`, and its address, as the app derives them. The BIP-39 phrase is an internal step and is never shown.
|
||||
- **Registration fields:**
|
||||
- `secret = HMAC-SHA256(seed, "simplex.domains commitment " + label)`.
|
||||
- The links of one key are joined with ";". Each value is within 4096 bytes.
|
||||
- `registration = {label, owner, duration (2 years × 365 days), secret, resolver, data = recordCalls(registration) †}`.
|
||||
- One short link per key, within 4096 bytes.
|
||||
- `registration = {label, owner, duration (years × 365 days + max(0, termStart − now)), secret, resolver, data = recordCalls(registration) †}`.
|
||||
- `makeCommitment` is †.
|
||||
- **Sealing:** `sealed = AES-256-GCM(HMAC-SHA256(seed, "simplex.domains sealed"), {label, contact, channel})`.
|
||||
- **The service's checks, in order:**
|
||||
1. the create rate limit;
|
||||
2. the owner address;
|
||||
3. the length (`invalid_name`);
|
||||
4. `sealed` within its bound (`bad_request`);
|
||||
5. this commitment's other orders (below);
|
||||
6. the resolver: registered or reserved is `name_taken`. A resolver failure is `name_check_unavailable`, and nothing is created;
|
||||
7. the lag: our own registration `registering`, or `registered` within an hour, is `name_taken`;
|
||||
8. `namePrice` must equal the price, else `catalog_changed{price}`.
|
||||
- **This commitment's other orders:**
|
||||
- An open invoice for the same order is answered with that invoice.
|
||||
- A different order gets `400 {"error": "bad_request", "invoiceId"}`, and the page opens that order.
|
||||
- A registration past `unpaid` is answered with its order. For a code registration, `invoiceId` is null, and the page opens `?registration=<id>`.
|
||||
2. the owner address, the length (`invalid_name`), `sealed` present and within its bound (`bad_request`);
|
||||
3. the order signature, EIP-191 over `simplex.domains order <labelHash> <length> <duration> <commitment> <sha256(sealed)> <timestamp>`, recovering the owner (`bad_request`);
|
||||
4. existing orders for the name (below);
|
||||
5. the timestamp within 600 s, and the duration within an hour of `years × 365 days + max(0, termStart − now)`, else `bad_request`;
|
||||
6. the chain: registered or reserved is `name_taken`. A failed chain read is `name_check_unavailable`, and nothing is created;
|
||||
7. being registered here: our own registration `registering` is `name_taken`;
|
||||
8. the chain's price must equal the price, else `catalog_changed{price}`.
|
||||
- **Existing orders for the name** (a live order of the same commitment, or of the same owner and label hash; manager's ruling, 2026-10-21):
|
||||
- An exact resend, every field equal to the stored order including `sealed`, the method and the price, is answered with that order, in any phase and however late.
|
||||
- A live order of the same wallet and name whose invoice has passed its expiry time is closed inside the new order's transaction (invoice `expired`, `live` cleared, the provider's cancel after, best effort); the new order then goes on.
|
||||
- Anything else gets `409 order_exists`, which names nothing. The page then runs the signed `find` (section 10) and opens its order. A commitment is public on chain once committed, so it alone proves nothing.
|
||||
- Orders, `/name`, `/code` and `/retry` run one at a time per label hash; unique constraints back it.
|
||||
- **Storage:** the service inserts `name_registrations` (phase `unpaid`), `name_invoices` and `invoices` ‡.
|
||||
|
||||
## 3. Payment and settlement
|
||||
@@ -245,10 +239,10 @@ sequenceDiagram
|
||||
- **Name invoices in today's code:** `getInvoiceByProviderRef` ‡, the poller's scans, its expiry update and `GET /api/invoice/:id` all gain a name side.
|
||||
- **Settlement** is one database transaction:
|
||||
- the invoice becomes paid;
|
||||
- the registration goes `unpaid` → `queued`.
|
||||
- the registration goes `unpaid` → `queued`, only while it is still live. A payment for an order that was closed (cancelled, expired, or closed by a newer order of the same wallet and name) is a late payment.
|
||||
- Both changes are published to the long poll after the commit.
|
||||
- **Late payment:** an invoice paid after it expired or was canceled leaves the registration `unpaid`. The operator is alerted to refund it (gap 26).
|
||||
- **The long poll** carries `wait` and `seenPhase`, so it also wakes on phase changes.
|
||||
- **The long poll** carries `wait` and `seenVersion`, so it also wakes on every change of the registration (its `version`).
|
||||
|
||||
**Get a new invoice.** The old invoice may still be open when the card form does not load, or the payment details are unavailable. The page then cancels it before ordering again:
|
||||
|
||||
@@ -259,7 +253,7 @@ sequenceDiagram
|
||||
participant S as Service
|
||||
W->>S: POST /api/invoice/:id/cancel
|
||||
alt cancelled, nothing received
|
||||
W->>S: POST /api/invoice, same commitment (section 2)
|
||||
W->>S: POST /api/invoice, a new commitment with the current duration (section 2)
|
||||
else 409 funded, 503, or still open
|
||||
W->>W: keep the old order
|
||||
else 409 not_open, paid, or holding a payment
|
||||
@@ -276,6 +270,7 @@ sequenceDiagram
|
||||
- paid, with the registration still `unpaid` (paid after it closed): order again;
|
||||
- otherwise holding a payment: keep the order;
|
||||
- expired with nothing received: order again.
|
||||
- **The new order** keeps the wallet and the links, and builds a new commitment, since the free days until 1 January 2027 shrink each day. The service finds the old order by owner and label hash.
|
||||
|
||||
## 4. Commit
|
||||
|
||||
@@ -286,7 +281,7 @@ sequenceDiagram
|
||||
autonumber
|
||||
participant W as Webapp
|
||||
participant S as Service
|
||||
participant E as Ethereum node
|
||||
participant E as RPC node
|
||||
S->>S: next queued registration
|
||||
S->>E: eth_estimateGas: SimplexController.commit(commitment)
|
||||
S->>S: sign, save the tx hash and raw tx
|
||||
@@ -322,7 +317,7 @@ sequenceDiagram
|
||||
autonumber
|
||||
participant W as Webapp
|
||||
participant S as Service
|
||||
participant E as Ethereum node
|
||||
participant E as RPC node
|
||||
W->>W: wait until revealAfter
|
||||
W->>W: sign RegisterName(label, owner, duration, resolver, data, nonce, deadline)
|
||||
W->>S: POST /api/registration/:id/reveal (label, secret, records, nonce, deadline, signature)
|
||||
@@ -341,24 +336,24 @@ sequenceDiagram
|
||||
- `deadline` is `revealBefore`.
|
||||
- The digest uses `sponsorDomain(chainId, sponsor)` †. The page signs it with `@noble/secp256k1`.
|
||||
- **The service's checks:**
|
||||
- **Rate limit:** the read limit. Each `bad_request` counts against it.
|
||||
- **Rate limit:** the read limit. Every request counts, whatever its outcome.
|
||||
- **Phase:** the registration must be `committed`, with `label` null.
|
||||
- Once `label` is set, an identical repeat answers 200 in any phase.
|
||||
- A different body is `bad_request`.
|
||||
- **The name:**
|
||||
- `validLabel` (#7390, ported and tightened to simplexmq's hyphen rule);
|
||||
- `validLabel` (simplexmq's `SimplexDomain` parser, lower case, 6–63);
|
||||
- `keccak256(label)` = `labelHash`, and the length matches;
|
||||
- the price is the registration's `price_id` row, or the prepaid code covers the length.
|
||||
- **Links and resolver:**
|
||||
- each link is a SimpleX link of its kind;
|
||||
- each record is within 4096 bytes;
|
||||
- the resolver is the default one.
|
||||
- **The commitment:** `data = recordCalls(registration)` †, and `makeCommitment(registration)` † must equal the stored commitment.
|
||||
- the price is the registration's `price`, fixed at the order, or the prepaid code covers the length.
|
||||
- **Links and resolver contract:** at most one link per key; the resolver contract is the default one. The links' kind and bound are checked last (below).
|
||||
- **The commitment:** `data = recordCalls(registration)` †, the registration's stored `duration`, and `makeCommitment(registration)` † must equal the stored commitment. The intent signs that duration (PR #36).
|
||||
- **The intent:**
|
||||
- `verifyRegisterIntent` † checks the 32-byte secret, the deadline, and that the signer is the owner;
|
||||
- the service then checks for low s itself, because the sponsor's `SignatureChecker` rejects high s;
|
||||
- the nonce must equal `SimplexSponsor.nonces(owner)`.
|
||||
- **A failed chain read** answers `provider_unavailable`. It does not count as a `bad_request`.
|
||||
- **The links, last:** each is a SimpleX short link of its kind within 4096 bytes, else `invalid_link`.
|
||||
- **A failed chain read** answers `provider_unavailable`.
|
||||
- **Links refused** (`invalid_link`) cannot be revealed again, since they are in the commitment. The service records the refusal, the one write a failed reveal makes, and only after the commitment and intent checks passed, so the refused links are the committed ones and the owner signed them. Recording it bumps `version` and wakes the polls. The page offers `/name` with the same name and corrected links (section 8), allowed only after such a refusal.
|
||||
- **Serialization:** the reveal runs one at a time per label hash with orders, `/name` and `/retry`, and writes by compare-and-set on the `version` it read. A write that loses the race, here or on any endpoint, changes nothing and answers `409 registration_changed` with the current view.
|
||||
|
||||
## 6. Register and confirm
|
||||
|
||||
@@ -369,7 +364,7 @@ sequenceDiagram
|
||||
autonumber
|
||||
participant W as Webapp
|
||||
participant S as Service
|
||||
participant E as Ethereum node
|
||||
participant E as RPC node
|
||||
S->>S: revealed and now ≥ revealAfter, verifyRegisterIntent again
|
||||
S->>E: dry run: SimplexSponsor.registerWithCredit(registration, nonce, deadline, signature)
|
||||
E-->>S: ok
|
||||
@@ -390,7 +385,7 @@ When the dry run or the mined transaction reverts:
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant S as Service
|
||||
participant E as Ethereum node
|
||||
participant E as RPC node
|
||||
S->>E: ownerOf(labelHash)
|
||||
alt the owner already holds it
|
||||
S->>S: phase registered (a resumed or duplicate send)
|
||||
@@ -421,55 +416,55 @@ sequenceDiagram
|
||||
|
||||
## 7. A prepaid code
|
||||
|
||||
The buyer pays with a prepaid code instead of money. The page checks the code first, then registers without an invoice.
|
||||
The buyer pays with a prepaid code instead of money. Codes are entered only on Check your code, in the top bar (owner, 2026-10-21). The page checks the code there, keeps it, and registers without an invoice from Check your order.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant W as Webapp
|
||||
participant S as Service
|
||||
W->>W: parseNameCode(text) → prepaid code
|
||||
W->>W: Check your code: parseNameCode(text) → prepaid code
|
||||
W->>S: POST /api/code/check (code)
|
||||
S-->>W: minLength, years, state
|
||||
W->>W: covered names show $0 and the code's years
|
||||
W->>W: checkout as section 2, duration = years × 365 days
|
||||
W->>W: keep the code in Your names; covered names show $0 and the code's years
|
||||
W->>W: Links, name code and commitment as section 2, duration = code years × 365 days + max(0, termStart − now)
|
||||
```
|
||||
|
||||
Then the registration:
|
||||
Then the registration, from Check your order's "Register with your code":
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant W as Webapp
|
||||
participant S as Service
|
||||
participant R as Resolver
|
||||
W->>S: POST /api/name/register (labelHash, length, owner, commitment, sealed, code)
|
||||
S->>S: a repeat of this code's registration? answer it
|
||||
S->>S: validate, check this commitment's other orders
|
||||
S->>R: is the name still free?
|
||||
R-->>S: available
|
||||
S->>S: code usable and covers the length? reserve it
|
||||
participant E as RPC node
|
||||
W->>S: POST /api/name/register (labelHash, length, duration, owner, commitment, sealed, code, timestamp, signature)
|
||||
S->>S: validate, check the signature; an exact resend? answer it
|
||||
S->>S: other live orders for the name? order_exists; check the duration
|
||||
S->>E: is the name still free? nameExpires, reservedNames
|
||||
E-->>S: available
|
||||
S->>S: code usable and covers the length? reserve it (one at a time per code)
|
||||
S->>S: registration queued
|
||||
S-->>W: registrationId
|
||||
Note over W,S: then sections 4 to 6, on ?registration=id
|
||||
```
|
||||
|
||||
- **Parsing:** `parseNameCode` returns `NCPrepaid` or `NCOwner`. The sum type is proposed. The page catches a typo in the check character (K6).
|
||||
- **An owner code entered here** sends nothing. The page tells the buyer it is a name code, to restore under Your names.
|
||||
- **Parsing:** `parseNameCode` returns `NCPrepaid` or `NCOwner`. The sum type is proposed. The page catches a typo in the check character before any request.
|
||||
- **A name code entered on Check your code** is not sent to the code check: section 10.
|
||||
- **The code check:**
|
||||
- `POST /api/code/check` looks the code up in `name_prepaid_codes` by `namePrepaidCodeHash`.
|
||||
- It answers `minLength`, `years` and `state`: `unused`, `reserved`, `used`, `revoked`, `expired` or `unpaid`.
|
||||
- The code covers a name when its length ≥ `min_length`.
|
||||
- An unknown code answers `400 code_invalid`.
|
||||
- **Rate limits:** `/api/name/register` uses the code-check rate limit.
|
||||
- **The service's checks, in order:**
|
||||
1. **The repeat check:** a registration paying with this code, with the same owner and commitment, answers its `registrationId`. This keeps a lost response safe.
|
||||
2. **Validation:** the owner, `sealed` within its bound (`bad_request`), and the length (`invalid_name`).
|
||||
3. **This commitment's other orders:**
|
||||
- an open invoice answers `bad_request` with its `invoiceId`. The page cancels that invoice first.
|
||||
- a registration past `unpaid` answers its `registrationId`, plus its `invoiceId` when it was paid. The page opens `?order=`.
|
||||
4. **The resolver:** registered or reserved is `name_taken`. A failure is `name_check_unavailable`.
|
||||
5. **The lag:** our own registration `registering`, or `registered` within the hour, is `name_taken`.
|
||||
- **Code errors:** `code_invalid`, `code_used`, `code_reserved`, `code_expired`, `payment_pending` or `code_not_covering`. They are shown at checkout (K7), or in the popup (K5).
|
||||
1. **Validation:** the owner, `sealed` within its bound (`bad_request`), and the length (`invalid_name`); the code parses and is known (`code_invalid`); the order signature recovers the owner (`bad_request`).
|
||||
2. **Existing orders:** an exact resend (every field, the code included) answers its `registrationId`, in any phase, so a lost response is safe. Anything else meeting a live order for the name is `order_exists`.
|
||||
3. **The timestamp and the duration**, for the code's years, within their windows (`bad_request`).
|
||||
5. **The chain:** registered or reserved is `name_taken`. A failed chain read is `name_check_unavailable`.
|
||||
6. **Being registered here:** our own registration `registering` is `name_taken`.
|
||||
7. **The code:** its state, then its cover. Reserving is one conditional update in the transaction that creates the registration, one at a time per code.
|
||||
- **Code errors:** `code_invalid`, `code_used`, `code_reserved`, `code_expired`, `payment_pending` or `code_not_covering`. They show on the page that sent the order, and the name shows its price again.
|
||||
|
||||
## 8. Taken: another name, or a prepaid code
|
||||
|
||||
@@ -482,16 +477,16 @@ sequenceDiagram
|
||||
participant S as Service
|
||||
S-->>W: phase taken (long poll)
|
||||
alt another name (N8a)
|
||||
W->>S: GET /api/name/:hash (section 1), price ≤ paid
|
||||
W->>W: new commitment and sealed for the same wallet
|
||||
W->>S: GET /api/name/:hash (section 1), chain price ≤ the order's price
|
||||
W->>W: new duration, commitment and sealed for the same wallet
|
||||
W->>W: sign "simplex.domains name …" with the owner key
|
||||
W->>S: POST /api/registration/:id/name (labelHash, length, commitment, sealed, timestamp, signature)
|
||||
W->>S: POST /api/registration/:id/name (labelHash, length, duration, commitment, sealed, timestamp, signature)
|
||||
S->>S: checks, then queued with the new name
|
||||
Note over W,S: then sections 4 to 6 again
|
||||
else a prepaid code (N8b)
|
||||
W->>W: derive the code from the seed and registrationId
|
||||
W->>W: derive the code (SN + codeLength) from the seed and registrationId
|
||||
W->>W: sign "simplex.domains code …" with the owner key
|
||||
W->>S: POST /api/registration/:id/code (codeHash, timestamp, signature)
|
||||
W->>S: POST /api/registration/:id/code (codeHash, codeLength, timestamp, signature)
|
||||
S->>S: save the code, phase code_issued
|
||||
S-->>W: 200
|
||||
end
|
||||
@@ -500,18 +495,21 @@ sequenceDiagram
|
||||
- **Another name (`/name`):** it uses the create rate limit.
|
||||
- **Checks, in order:**
|
||||
1. the owner's signature and timestamp;
|
||||
2. a paid registration in `taken`;
|
||||
3. the order's checks: `sealed` within its bound, `invalid_name`, and `name_taken`. A resolver failure is `name_check_unavailable`, and nothing changes;
|
||||
4. `namePrice` must be ≤ the amount paid, else `bad_request`.
|
||||
2. a paid registration in `taken`, or any registration in `failed`, or one in `committed` whose reveal was refused for its links (to correct them; the same name is allowed); never a healthy `committed`. At most 3 successful moves per order; after the last, `409 move_limit` answers the order's state, shown with a support line and the reference; a body equal to the current fields answers the view unchanged. Both label hashes' turns are taken in hash order;
|
||||
3. the order's checks: `sealed` within its bound, `invalid_name`, other live orders for the new commitment or this owner and the new hash (`order_exists`), the duration, and `name_taken`. A failed chain read is `name_check_unavailable`, and nothing changes;
|
||||
4. a paid order: the new name's chain price must be ≤ the order's `price`, else `409 price_too_high` with that price. A cheaper name refunds nothing, which the page says first. A code order: the code is checked and reserved again under its per-code turn (`code_used` when spent elsewhere after the sweep released it, `code_reserved`, `code_invalid`, `code_expired`) and must cover the new name (`code_not_covering`).
|
||||
- **The signed message** includes the duration: `simplex.domains name <registrationId> <labelHash> <length> <duration> <commitment> <sha256(sealed)> <timestamp>`.
|
||||
- **On success:**
|
||||
- `price_id` becomes the new price row.
|
||||
- The registration goes to `queued` with the new hash, length, commitment and `sealed`.
|
||||
- `price` becomes the new name's price, which caps a further move.
|
||||
- The registration goes to `queued` with the new hash, length, duration, commitment and `sealed`.
|
||||
- `label`, `secret`, `records`, `intent_*`, the tx hashes and blocks, `reveal_after`, `reveal_before` and `error` are cleared.
|
||||
- **A prepaid code instead (`/code`):** it uses the create rate limit, then checks the owner's signature and timestamp.
|
||||
- **The code:** `SN<length tier>` + a payload from `HMAC-SHA256(seed, "simplex.domains taken code " + registrationId)`. The page keeps it under Your names.
|
||||
- **A repeat:** if `issued_code_id` already has this hash, the answer is 200 again.
|
||||
- **The code:** `SN<codeLength>` + a payload from `HMAC-SHA256(seed, "simplex.domains taken code " + registrationId)`. `codeLength` is the shortest length `L` from 6 such that every length from `L` to 63 costs at most the order's price, from the oracle's whole list: the code pays for any name no dearer than the order. The view carries it in `taken` and still in `code_issued`, so the name code recovers the code on any browser. The page keeps it under Your names.
|
||||
- **The length moved** since the view was read (a price change): `409 code_length_changed` with the current value, and nothing is issued; the page derives the code again. So the issued row's length always equals the code's text.
|
||||
- **No length qualifies** (the prices rose above the order's): `409 code_unavailable`; the view's `codeLength` is null, and the page offers only another name. A failed read is `503 provider_unavailable`.
|
||||
- **A repeat:** if `issued_code_id` already has this hash, the answer is 200 again, whatever `codeLength` it carries; this is checked before the length.
|
||||
- **Otherwise:** only a paid registration in `taken` qualifies. The service then:
|
||||
- inserts the code (current length tier, 2 years, paid, no expiry);
|
||||
- inserts the code (`codeLength`, the order's years, paid, no expiry);
|
||||
- sets `issued_code_id`;
|
||||
- moves the phase to `code_issued`.
|
||||
- **Collisions:** a hash that another code already has is `code_conflict`. For a derived code this is unreachable in practice.
|
||||
@@ -526,7 +524,7 @@ sequenceDiagram
|
||||
autonumber
|
||||
participant W as Webapp
|
||||
participant S as Service
|
||||
participant E as Ethereum node
|
||||
participant E as RPC node
|
||||
S->>S: phase failed (retries exhausted, or not revealed in time)
|
||||
W->>S: POST /api/registration/:id/retry
|
||||
S->>E: did it register after all? receipts, ownerOf(labelHash)
|
||||
@@ -547,7 +545,7 @@ From `queued`, the worker reads the commitment to decide whether to commit again
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant S as Service
|
||||
participant E as Ethereum node
|
||||
participant E as RPC node
|
||||
S->>E: commitments(commitment)
|
||||
alt 0, a commit of ours pending
|
||||
S->>S: wait for it
|
||||
@@ -586,9 +584,9 @@ sequenceDiagram
|
||||
- **After `revealBefore`:** the worker first clears `label`, `secret`, `records`, `intent_*`, `error`, the tx hashes and blocks, `reveal_after` and `reveal_before`. Then it commits again.
|
||||
- **Failure causes:** `InsufficientAllowance` also alerts the operator.
|
||||
|
||||
## 10. Restore with the owner code
|
||||
## 10. A name code on Check your code
|
||||
|
||||
In another browser, or after losing the order link, the owner code alone finds and reopens the order.
|
||||
In another browser, or after losing the order link, the owner code alone finds and reopens the order. Check your code takes it (owner, 2026-10-21); there is no separate Restore screen.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -597,11 +595,12 @@ sequenceDiagram
|
||||
participant S as Service
|
||||
alt the order link is known (N7b)
|
||||
W->>S: GET /api/invoice/:id or /api/registration/:id
|
||||
else only the code (Your names)
|
||||
W->>W: walletFromCode(code)
|
||||
else only the code (Check your code)
|
||||
W->>W: parseNameCode → NCOwner, walletFromCode(code)
|
||||
W->>W: sign "simplex.domains find owner timestamp"
|
||||
W->>S: POST /api/registration/find (owner, timestamp, signature)
|
||||
S-->>W: registrations, newest first
|
||||
S-->>W: registrations with labelHash and sealed, newest first
|
||||
W->>W: open each sealed → "Name code of alicebakery.simplex", Add to Your names
|
||||
W->>S: GET the newest that is not unpaid
|
||||
end
|
||||
S-->>W: the registration
|
||||
@@ -620,18 +619,19 @@ sequenceDiagram
|
||||
- `timestamp` is in Unix seconds.
|
||||
- **`find` checks:** its own rate limit. The signer must be `owner`, and the timestamp must be within 10 minutes.
|
||||
- **`find` answers:**
|
||||
- `{registrations: [{registrationId, invoiceId or null, phase, createdAt}]}`, including retargeted ones;
|
||||
- `404 not_found` when there are none.
|
||||
- `{registrations: [{registrationId, invoiceId or null, phase, createdAt, labelHash, sealed}]}`, including retargeted ones, the 50 newest. `labelHash` and `sealed` let the page name every order in one request. Orders are signed by their owner, so only the code's own orders are listed;
|
||||
- `404 not_found` when there are none: "No order was found for this code".
|
||||
- **After `409 order_exists`** the page runs `find` the same way to open the order it already has.
|
||||
- **Opening a registration:** with the read limit, the page opens:
|
||||
- `GET /api/invoice/:invoiceId` when it is set (the order page, with what was paid);
|
||||
- else `GET /api/registration/:id`.
|
||||
- **Checking the code:** the page rebuilds the commitment from `sealed` and the seed. It must equal the registration's commitment.
|
||||
- **Checking the code:** the page rebuilds the commitment from `sealed`, the seed and the view's `duration`. It must equal the registration's commitment, else "This code does not open the order".
|
||||
|
||||
## 11. Links: preview at checkout, edit later
|
||||
## 11. Links: preview before the order, edit later
|
||||
|
||||
After registration, the owner changes links by signing each edit, and the service relays it and pays the gas.
|
||||
|
||||
At checkout, a link is previewed before it goes into the registration:
|
||||
On the Links step, before the name code, a link is previewed before it goes into the registration. One field takes a contact address or a channel link; its kind is detected, and a second field takes the other kind:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -640,8 +640,8 @@ sequenceDiagram
|
||||
participant S as Service
|
||||
W->>S: POST /api/link/preview (link)
|
||||
S->>S: SimpleX contact or channel link? APIConnectPlan
|
||||
S-->>W: type, displayName, image (or invalid_link)
|
||||
Note over W: the links go into the registration's data (section 2)
|
||||
S-->>W: type, kind, displayName, fullName, image, subscribers, profile (or invalid_link)
|
||||
Note over W: the links go into the registration's data (section 2), read-only inside the order
|
||||
```
|
||||
|
||||
Later, editing a registered name's links:
|
||||
@@ -651,7 +651,7 @@ sequenceDiagram
|
||||
autonumber
|
||||
participant W as Webapp
|
||||
participant S as Service
|
||||
participant E as Ethereum node
|
||||
participant E as RPC node
|
||||
W->>W: sign SetText(node, key, value, nonce, deadline) with the owner key
|
||||
W->>S: POST /api/name/records (labelHash, key, value, nonce, deadline, signature)
|
||||
S->>S: recover the signer, check the link and a repeat
|
||||
@@ -659,7 +659,7 @@ sequenceDiagram
|
||||
S->>S: save the pending edit
|
||||
S->>E: SimplexResolver.setTextWithSig(…), the service pays gas
|
||||
E-->>S: receipt
|
||||
S-->>W: tx, or record_reverted
|
||||
S-->>W: tx, record_reverted, or edit_pending after 90 s
|
||||
```
|
||||
|
||||
When an edit reverts, or a restart finds it unresolved:
|
||||
@@ -668,7 +668,7 @@ When an edit reverts, or a restart finds it unresolved:
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant S as Service
|
||||
participant E as Ethereum node
|
||||
participant E as RPC node
|
||||
S->>E: nonces(signer), text(node, key)
|
||||
alt nonce passed and the record holds the value
|
||||
S->>S: mined (applied by another transaction): answer tx null
|
||||
@@ -679,38 +679,38 @@ sequenceDiagram
|
||||
end
|
||||
```
|
||||
|
||||
- **Preview:** its own rate limit. The link must be within 4096 bytes.
|
||||
- **Preview:** its own rate limit. The link must be one SimpleX short link of a contact address (`/a#`) or a channel (`/c#`), within 4096 bytes. The hosts are the link's servers (the authority of an `https://` link, or the server hosts inside a `simplex:/` link). A public IP literal passes, since SimpleX servers may be addressed by IP. Every host of the link is checked before the chat core fetches: an IP literal as it is, a name by resolving it; a private, loopback, link-local, unique-local, CGNAT, unspecified or multicast address, a `.onion` host or an unresolvable name means no fetch. A link with several hosts is fetched only if all pass. A link not fetched stays valid: its profile is `unavailable`. The check is best effort, since the chat core resolves again when it connects (DNS rebinding remains possible); an egress rule in the deployment denies the service's traffic to private ranges.
|
||||
- **How records are stored:**
|
||||
- A key holds its links joined with ";".
|
||||
- Removing one of several links rewrites the whole value.
|
||||
- Clearing a key sets it to "".
|
||||
- A key holds one short link (gap 35).
|
||||
- A record set elsewhere may hold several joined with ";"; readers split on it, and an edit replaces the whole value.
|
||||
- Removing a key's link sets it to "".
|
||||
- **Clearing all records:** the page signs `ClearRecords` instead, and the service relays `clearRecordsWithSig`.
|
||||
- **When the page offers Edit links:**
|
||||
- **Hidden** when the search answer has an owner other than the code's address (the name was redeemed).
|
||||
- **Hidden** when the answer has an owner with null records (another resolver, or none).
|
||||
- **Hidden** when the answer has an owner with null records (another resolver contract, or none).
|
||||
- **The current values** come from the answer's records.
|
||||
- **During the lag**, the values come from the order's links in `sealed`, or from this browser's last mined edit of that key. A mined clear sets both keys to "". The chain id and resolver come from the view. Edit links is offered only once the phase is `registered`.
|
||||
- **The page's nonce:**
|
||||
- **Start:** `recordsNonce` from the search answer or the view.
|
||||
- **Start:** `recordsNonce` from the search answer.
|
||||
- **Next nonce:** +1 after each `{tx}`. The same after `record_reverted`.
|
||||
- **Recovery:** re-read it after any `bad_request`.
|
||||
- **Deadline:** now + 1 hour.
|
||||
- **The service's checks, in order:**
|
||||
1. **Rate limit:** its own, for every request.
|
||||
2. **The record:** the key is `simplex.contact` or `simplex.channel`. Its links are of that kind and within 4096 bytes, else `invalid_link`.
|
||||
3. **The signature:** recover the signer (`recoverAddress` †) and require low s. Otherwise `bad_request`, not counted toward the cap.
|
||||
4. **A repeat:** found by signer and nonce, and matched by `body_hash`. This runs before any deadline check.
|
||||
- An identical repeat waits for the same tx. It answers the tx once mined (tx null when another transaction applied it), or answers `record_reverted` again.
|
||||
2. **The record:** the key is `simplex.contact` or `simplex.channel`. The value is one short link of that kind within 4096 bytes, or "", else `invalid_link`.
|
||||
3. **The signature:** recover the signer (`recoverAddress` †) and require low s. Otherwise `bad_request`.
|
||||
4. **A repeat of a sent edit:** found by signer and nonce, and matched by `body_hash`. This runs before any deadline check. Only sent edits are stored; a refused request leaves nothing, so its repeat is checked anew.
|
||||
- An identical repeat waits for the same tx. It answers the tx once mined (tx null when found applied), or answers `record_reverted` again.
|
||||
- A different body with a nonce whose edit is pending or mined is `bad_request`.
|
||||
- After `record_reverted`, the nonce is free again for a new body.
|
||||
5. **For a new edit only:** the deadline must be ahead of the latest block, else `bad_request`. Then the per-name cap applies (`rate_limited`).
|
||||
6. **On chain:** the signer must be `ENSRegistry.owner(node)`, and the nonce must equal `SimplexResolver.nonces(signer)`, else `bad_request`. A failed read is `provider_unavailable`.
|
||||
5. **For a new edit only:** the deadline must be ahead of the latest block; the signer must be `ENSRegistry.owner(node)`; the nonce must equal `SimplexResolver.nonces(signer)`. Else `bad_request`. A failed read is `provider_unavailable`, and nothing is stored.
|
||||
6. **The per-name cap**, after those checks: 10 relayed edits an hour, else `rate_limited`. Only sent edits count, so a non-owner cannot use up a name's cap.
|
||||
7. **Concurrency:** the pending row (signer, nonce, body hash) is inserted before signing. Of two concurrent identical requests, one loses the unique insert.
|
||||
8. **Sending:** through the registrar's one sender. The hash and raw tx are saved before sending.
|
||||
- **The revert re-read:** the service re-reads the nonce and the record in these cases:
|
||||
- after a revert: an applied edit reverts `InvalidNonce`, or `SignatureExpired` once past its deadline;
|
||||
- after a restart's expiry.
|
||||
- **The revert's name:** a mined revert's receipt has no data. It is named `SignatureExpired` when past the deadline, else null.
|
||||
- **Applied** is decided from the edit's own sent hashes first: a status-1 receipt answers that hash. Only without a known receipt: the owner's nonce passed the edit's and the record holds its value means applied (`{tx: null}`); a passed nonce with another value means `record_reverted` with `InvalidNonce`.
|
||||
|
||||
## 12. Redeem in the app
|
||||
|
||||
@@ -721,7 +721,7 @@ sequenceDiagram
|
||||
autonumber
|
||||
participant A as SimpleX app
|
||||
participant S as Service
|
||||
participant E as Ethereum node
|
||||
participant E as RPC node
|
||||
A->>A: parseNameCode → NCOwner, walletFromCode → key, address
|
||||
A->>E: balanceOf, tokenOfOwnerByIndex, labelOf, nameExpires
|
||||
A->>A: sign ApproveName with the code key
|
||||
@@ -739,32 +739,14 @@ sequenceDiagram
|
||||
- **The relay endpoints** are not specified yet (gap 9).
|
||||
- **Afterwards** the owner code no longer controls the name.
|
||||
|
||||
## 13. Opened from the app
|
||||
|
||||
The app opens the store for a name that its own wallet will own, so there is no owner code. The link is `simplex.domains/#/?name=<label>&owner=<appAddress>`.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant A as SimpleX app
|
||||
participant W as Webapp
|
||||
participant S as Service
|
||||
A->>W: open simplex.domains with name and owner = appAddress
|
||||
W->>W: owner = appAddress, shown before paying
|
||||
W->>W: random secret, kept in this page
|
||||
W->>S: order as section 2, without sealed
|
||||
Note over W,S: sections 3 and 4 as usual
|
||||
W->>A: the RegisterName intent must be signed by appAddress (gap 10)
|
||||
```
|
||||
|
||||
## 14. Gaps found
|
||||
## 13. Gaps found
|
||||
|
||||
Each gap says what the problem is, why it matters, and what is decided, or that it is still open. Gaps marked "Resolved" are settled and kept for the record.
|
||||
|
||||
1. **The page cannot build the sponsor's EIP-712 domain.**
|
||||
- Problem: `sponsorDomain(chainId, sponsor)` † needs the chain id and the `SimplexSponsor` address. The search answer carries only `resolver`.
|
||||
- Why it matters: the intent's digest uses that domain (section 5), so the page cannot sign.
|
||||
- Decided: the answer is `available{…, resolver, sponsor, chainId, tld}`. The TLD (`[names] tld`) builds the records' node.
|
||||
- Decided: the answer is `available{…, years, termStart, resolver, sponsor, chainId, tld}`. The TLD (`[names] tld`) builds the records' node; `termStart` builds the duration.
|
||||
2. **The intent can expire before it is sent.**
|
||||
- Problem: the page signs with a deadline. The register may be sent after it, because of a gas spike, retries or a queue.
|
||||
- Why it matters: then `verifyRegisterIntent` fails and the sponsor reverts.
|
||||
@@ -790,17 +772,15 @@ Each gap says what the problem is, why it matters, and what is decided, or that
|
||||
1. Read `ownerOf(labelHash)` first. The owner holding it means this registration went through.
|
||||
2. Otherwise, a decoded `NameNotAvailable` or `NameReserved` means taken or reserved. So does any other revert while `available(label)` is false.
|
||||
- The sponsor checks the intent's signature, nonce and deadline, and spends the allowance, before the controller checks availability. So a taken name can first revert `SignatureExpired`, `InvalidNonce`, `InvalidSignature` or `InsufficientAllowance`.
|
||||
- Open: whether reth's `error.data` carries the revert data is unchecked.
|
||||
6. **The commitment fixes the links and the duration at checkout.**
|
||||
- Open: whether reth's or a hosted provider's `error.data` carries the revert data is unchecked.
|
||||
6. **The commitment fixes the links and the duration at the order.**
|
||||
- Problem: links changed after the order need a new commitment before the commit, or an edit after registration.
|
||||
- Why it matters: a change after the order no longer matches the commitment the service holds.
|
||||
- Decided: allow editing links only until the order is created.
|
||||
- Decided: links are set on the Links step before paying and are read-only inside an order (owner, 2026-10-20). The duration is fixed in the commitment too, so a new order of the same wallet builds a new commitment with the current free days.
|
||||
7. **Resolved: the price may change between order and reveal.**
|
||||
- Problem: the catalog price may change after the order.
|
||||
- Why it matters: the reveal checks the price (section 5).
|
||||
- Decided: the registration's `price_id` fixes the price at order time, or at retarget after its check against what was paid.
|
||||
- `name_prices` is insert-only, so that row stays.
|
||||
- The reveal compares no price: the label's hash and length make that row its price.
|
||||
- Problem: the chain's price (`rentPriceUSD`, owner 2026-10-20) may change after the order, with `setPrices`.
|
||||
- Why it matters: the buyer paid the price shown at the order.
|
||||
- Decided: the registration's `price` (cents) fixes the price at order time, or at retarget after its check against what was paid. The reveal and the register compare no price. The sponsor spends the chain's price at registration from the allowance, so a rise between order and register is the service's cost.
|
||||
8. **Resolved: a lapsed commitment can be committed again.**
|
||||
- Problem: a retry after a lapse must know when the same commitment can go on chain again.
|
||||
- The contract's rules:
|
||||
@@ -816,14 +796,11 @@ Each gap says what the problem is, why it matters, and what is decided, or that
|
||||
- The app reads the code's names on chain. The registrar is ERC-721 enumerable, with `labelOf` and `nameExpires`.
|
||||
- Add relay endpoints for `approveWithSig`, and then `claimWithSig`.
|
||||
- The claim signs `offer = offers(tokenId)`, read after the approval.
|
||||
10. **Opened from the app: the intent's signer is the app.**
|
||||
- Problem: the page cannot sign for `appAddress`. The page's random secret is lost if the page closes. A taken order cannot sign `/name` or `/code` without the app.
|
||||
- Why it matters: the flow in section 13 cannot finish from the page alone.
|
||||
- Open, two options:
|
||||
- the app builds and keeps the registration and signs the intent, and the page only takes the payment;
|
||||
- or defer the flow.
|
||||
10. **Resolved: opened from the app.**
|
||||
- Problem: an order opened from the app would need the app's wallet to sign the intent, `/name` and `/code`.
|
||||
- Decided: the web flow opened from the app is dropped (owner, 2026-10-21). The app will buy names itself (#7530 §13.6). Every web order has an owner code and `sealed`.
|
||||
11. **Resolved: editing links later.**
|
||||
- Problem: the commitment fixes the links at checkout (gap 6), so later changes need a signed edit on chain.
|
||||
- Problem: the commitment fixes the links at the order (gap 6), so later changes need a signed edit on chain.
|
||||
- The contract: PR #35's `SimplexResolver` has:
|
||||
- `setTextWithSig` (EIP-712 `SetText`, domain `SimplexResolver`) and `clearRecordsWithSig` (`ClearRecords`);
|
||||
- one nonce counter per owner, shared by both;
|
||||
@@ -831,18 +808,18 @@ Each gap says what the problem is, why it matters, and what is decided, or that
|
||||
- a `SetText` intent covers one record, and a `ClearRecords` intent all of the name's records;
|
||||
- `Multicallable`, which would let the relayer send several signed edits in one transaction.
|
||||
- Decided:
|
||||
- The page needs that nonce and the chain id. So the registered search answer carries them with the resolver. The registration view carries the nonce while search lags.
|
||||
- The page needs that nonce and the chain id. So the registered search answer carries them with the resolver.
|
||||
- Several edits go one at a time. The service accepts an edit only when its nonce equals `nonces(owner)` on chain, and answers once it is mined. The page signs the next edit after that answer.
|
||||
- A higher nonce sent early would revert `InvalidNonce`, because the contract does not queue intents.
|
||||
- The service relays one transaction per edit, by its own choice, and bounds the cost itself.
|
||||
- Tested: two consecutive edits, relayed in order, are in `e2e/StoreE2E.test.ts`.
|
||||
12. **Resolved: search during our own registration.**
|
||||
- Problem: during the lag, the resolver may still say available for a name we are registering.
|
||||
- Problem: while our register is sent but not mined, the chain still says available for a name we are registering.
|
||||
- Why it matters: search would offer a name that is already ours.
|
||||
- Decided: search uses the index on `name_registrations.label_hash` with `phase`, now in both schemas, and `registered_at` for the resolver's hour of lag.
|
||||
- Decided: search uses the index on `name_registrations.label_hash` with `phase`, now in both schemas, and answers `registered` for a hash in `registering`. The service reads the chain itself, so it needs no margin after `registered`.
|
||||
13. **Resolved: the reveal's rate limit.**
|
||||
- Problem: the reveal needed a rate limit.
|
||||
- Decided: it takes the read limit, and each `bad_request` from it counts against that limit.
|
||||
- Decided: it takes the read limit, and every request counts against it, whatever its outcome.
|
||||
14. **Resolved: Alain's encoder carries the intent nonce.**
|
||||
- Problem: the encoder must match PR #35's register call and intent.
|
||||
- Decided: since `965046fe1` ("adapt contract updates"), `Registration.hs` has:
|
||||
@@ -891,7 +868,7 @@ Each gap says what the problem is, why it matters, and what is decided, or that
|
||||
- Decided: the view carries:
|
||||
- `intentNonce` (`SimplexSponsor.nonces(owner)`);
|
||||
- `revealBefore` (commit block time + `maxCommitmentAge`, 24 h in PR #35's deploy);
|
||||
- for a browser with only the owner code: `sealed` (the label and links), `commitment`, `years` (the duration), `resolver`, `sponsor`, `chainId` and `tld`.
|
||||
- for a browser with only the owner code: `sealed` (the label and links), `commitment`, `years` and `duration`, `resolver`, `sponsor`, `chainId` and `tld`.
|
||||
- That is enough to rebuild the commitment and the intent.
|
||||
- Tested: `e2e/StoreE2E.test.ts` registers from only the code and those fields.
|
||||
22. **Resolved: a consumed commitment also reads 0.**
|
||||
@@ -923,25 +900,25 @@ Each gap says what the problem is, why it matters, and what is decided, or that
|
||||
- So an identical repeat is answered after a restart with its `{tx}`, or `record_reverted` again.
|
||||
- The per-name cap is counted from it.
|
||||
- The relay sends through the worker's one sender.
|
||||
25. **The resolver host's reth runs `--minimal`** (simplexmq `scripts/resolver/docker-compose.yml`).
|
||||
25. **A reth node run `--minimal`**, as simplexmq's `scripts/resolver/docker-compose.yml` ran it for the resolver service, which is no longer used.
|
||||
- Problem: as far as known, and not yet checked against reth's source, that mode prunes the hash-to-transaction index fully. It keeps receipts only for about the last 10,000 blocks.
|
||||
- Why it matters:
|
||||
- `eth_getTransactionReceipt` could fail even for a recent transaction.
|
||||
- `/retry`'s step 1 could not read a receipt older than about 10,000 blocks, some 33 hours.
|
||||
- It falls back on `ownerOf` when the node answers a pruned receipt with null. An RPC error there is a failed read, so `/retry` answers `provider_unavailable`.
|
||||
- On such a node a mined transaction also looks unknown. This is why the rebroadcast rule (gap 23) reads the effect on chain rather than re-signing blindly.
|
||||
- Open: check reth's minimal pruning before relying on that node, or point the worker at a node that keeps the index and receipts.
|
||||
- Open: check reth's minimal pruning before relying on such a node, or run the service's reth (with nimbus) without `--minimal`, or use a provider that keeps the index and receipts.
|
||||
26. **A closed name invoice paid late.**
|
||||
- Problem: today's settlement marks even an expired invoice paid on a settled signal, with no alert (`Orders.hs`, `Poller.hs`). Get a new invoice reuses the commitment.
|
||||
- Why it matters: a late payment of the closed invoice could queue a second paid registration for the same commitment.
|
||||
- Problem: today's settlement marks even an expired invoice paid on a settled signal, with no alert (`Orders.hs`, `Poller.hs`). Get a new invoice orders the same name for the same wallet.
|
||||
- Why it matters: a late payment of the closed invoice could queue a second paid registration of one wallet for one name.
|
||||
- Decided:
|
||||
- The name side checks the invoice's prior state.
|
||||
- A late payment of a closed invoice does not queue its registration. That registration stays `unpaid`.
|
||||
- The 30-day cleanup never deletes it, because the cleanup spares any invoice that received a payment.
|
||||
- The late payment is kept for support to refund.
|
||||
- So one commitment never has two paid registrations. The other guards for this:
|
||||
- a different order for a commitment with an open invoice is refused, and a matching resend gets that invoice;
|
||||
- an order or a code register for a commitment with a registration past unpaid gets that registration;
|
||||
- So one wallet never has two paid registrations for one name. The other guards for this, by commitment or by owner and label hash:
|
||||
- an exact resend gets its own order; any other order while a live order holds the name is refused with `order_exists`, which names nothing;
|
||||
- orders run one at a time per label hash, and unique constraints allow one live registration per owner and label hash;
|
||||
- Get a new invoice cancels an open invoice first, and orders again only after the cancel succeeds.
|
||||
- The page shows "Paid after the invoice closed; support refunds it" with the reference, in the order and in history.
|
||||
- The poller and the provider list pass read an invoice only within about 72 hours of its creation (`settleWindow` ‡). After that, only a webhook brings a payment.
|
||||
@@ -951,8 +928,8 @@ Each gap says what the problem is, why it matters, and what is decided, or that
|
||||
- Problem: the answer is lost, but the code may already be reserved for a `queued` registration.
|
||||
- Why it matters: the buyer does not have the `registrationId` of a registration that holds the code.
|
||||
- Decided:
|
||||
- Try again resends the same body. The repeat check, run right after the rate limit, answers that `registrationId`.
|
||||
- A buyer who leaves finds it through Restore with a name code.
|
||||
- Try again resends the same body. The exact-resend check (every field equal, the signature recovering the owner, the timestamp's age not checked) answers that `registrationId`.
|
||||
- A buyer who leaves finds it through Check your code with the name code.
|
||||
28. **Resolved: concurrent edits with one nonce.**
|
||||
- Problem: two requests can arrive at once for the same nonce.
|
||||
- Why it matters: both could be signed and sent.
|
||||
@@ -980,25 +957,21 @@ Each gap says what the problem is, why it matters, and what is decided, or that
|
||||
- The cap needs a count and a window over a name's `name_record_edits` rows.
|
||||
- All limits share one bucket map capped at `maxBuckets = 8192`, with one bucket per limit and client (`Web/Server.hs`). When full, it first drops buckets older than the window. Then, if more than half are still live, it drops the lowest-count half. So about six new limits may need the cap raised.
|
||||
- The client clamps `Retry-After` at 300 s (`RETRY_AFTER_MAX_SECONDS` in `api.ts`). So a per-name cap window over 5 minutes needs the page to show when edits reopen rather than retry, or a larger clamp for it.
|
||||
- Open: decide them before the service is built.
|
||||
- Decided (2026-10-21, `2026-10-21-store-api.md` section 1): per client per minute, search 30, preview 20, code check 10, find 5, records 10, with the existing read 60 and create 5; one 60-second window for all, so `Limit` needs no change; `maxBuckets` 32768; profile fetches with separate bounds for search (4) and previews (2), a 3 s timeout that also bounds the wait for a slot, and a 10-minute cache; an edit cap of 10 an hour per name, whose real wait the page clamps to 300 s and asks again; `revealMargin` 900 s.
|
||||
30. **An edit judged applied by its nonce and record can be misjudged.**
|
||||
- Problem: the re-read after a revert, an expiry or a lost receipt treats an edit as applied when the signer's nonce has passed it and the record holds its value.
|
||||
- A later edit that overwrote the key makes an applied edit answer `record_reverted`.
|
||||
- A record that already held the value, while another edit took the nonce, makes an unapplied edit answer `{tx: null}`.
|
||||
- Why it matters: the page recovers through its next `bad_request` and nonce re-read, but the outcome it shows is wrong.
|
||||
- Open: matching the resolver's `TextChanged`/`VersionChanged` logs for the node to a transaction carrying this edit's signature would decide it exactly.
|
||||
- Decided (2026-10-21): keep the nonce-and-record judgment; the page's next nonce re-read corrects a misjudged outcome. Matching the resolver contract's `TextChanged`/`VersionChanged` logs to a transaction carrying this edit's signature would decide it exactly, and can come later.
|
||||
- PR #35's `SimplexResolver` emits `TextChanged` from `setTextWithSig` and `VersionChanged` from `clearRecordsWithSig`.
|
||||
- Whether a pruned node keeps those logs is not checked.
|
||||
31. **Which resolver serves the fresh deployment.**
|
||||
- Problem: the resolver knows two TLDs, with one deployment each. Its `testing` defaults to the live mainnet `.testing` contracts (simplexmq `scripts/resolver/service/snrc-resolve.py`, `REGISTRIES`, `REGISTRARS`, `CONTROLLERS`). The SMP relays resolve through it.
|
||||
- Why it matters: pointing the shared resolver at the fresh deployment would take the live names from the relays.
|
||||
- Options:
|
||||
- a fresh PR #35 deployment under `tld = testing` gets its own resolver instance, with `SNRC_REGISTRY_TESTING`, `SNRC_REGISTRAR_TESTING` and `SNRC_CONTROLLER_TESTING` set to it and `resolver_url` pointing there;
|
||||
- or `.testing` itself is redeployed.
|
||||
- Open: an owner decision, with Alain.
|
||||
31. **Resolved: which resolver serves the fresh deployment to the relays.**
|
||||
- Problem: the SMP relays resolved names through simplexmq's resolver service, whose `testing` pointed at the live `.testing` contracts, not the fresh deployment.
|
||||
- Decided: there is no resolver service (owner, 2026-10-21). The relays and the app connect to their own node (reth with nimbus) and read the deployment's contracts directly, as the service does.
|
||||
32. **Alain's prototype makes the commitment secret random.**
|
||||
- Problem: simplexmq PR #1942's `names-web` draws a random 32-byte secret. This design derives it: `HMAC-SHA256(seed, "simplex.domains commitment " + label)` (section 2).
|
||||
- Why it matters: with a derived secret, the same code and name always give the same commitment, which Get a new invoice and retry reuse, and the owner code alone rebuilds it on another browser. A random secret would have to be stored too, for example inside `sealed`.
|
||||
- Why it matters: with a derived secret, the same code, name, links and duration always give the same commitment, which retry reuses, and the owner code alone rebuilds it on another browser with the view's `duration`. A random secret would have to be stored too, for example inside `sealed`.
|
||||
- Recommended: keep the derived secret. A random one gains nothing here; the prototype has no restore flow.
|
||||
- Open: agree with Alain.
|
||||
33. **Alain's prototype sets the intent's deadline to now + 1 hour.**
|
||||
@@ -1011,10 +984,8 @@ Each gap says what the problem is, why it matters, and what is decided, or that
|
||||
- Why it matters:
|
||||
- Signing at checkout lets the buyer pay and close the tab; the service then registers alone, which suits slow Bitcoin payments. But the intent carries the name, so the service learns it at checkout.
|
||||
- Signing at the reveal keeps the service to the name's hash until after the commit, as decided on 2026-10-12, and as Alain's checklist asks for payment metadata and logs. But the page must be open, or reopened within 24 hours, after the 60-second wait.
|
||||
- Options: keep signing at the reveal; or sign at checkout with the deadline at the invoice's expiry + `maxCommitmentAge`, not now + 1 hour, so a slow payment does not expire it.
|
||||
- Open: an owner decision.
|
||||
- Decided (2026-10-21): keep signing at the reveal. The mockup's copy ("keep this page open, or reopen its link within 24 hours") and the mock assume it, and the service learns no name before the commit.
|
||||
35. **One link per key, short links only.**
|
||||
- Problem: this design lets a key hold several links joined with `;` (sections 2 and 11). `names-web` takes one link per key. The app reads a name with `firstNameLink` (`src/Simplex/Chat/Library/Commands.hs`), which takes the first link that parses as a short link of the right type and ignores the rest. Alain's checklist accepts only a contact short link in `simplex.contact` and a channel short link in `simplex.channel`.
|
||||
- Why it matters: extra links do nothing in the app, and a full link never works through a name, while several links per key cost the design editing one link of several, the `;` joining, and the lag rules that keep removed links from coming back (section 11).
|
||||
- Recommended: one short link per key, refusing full links with `invalid_link`.
|
||||
- Open: confirm, then simplify sections 2 and 11, the spec and the plan.
|
||||
- Problem: the earlier design let a key hold several links joined with `;`. `names-web` takes one link per key. The app reads a name with `firstNameLink` (`src/Simplex/Chat/Library/Commands.hs`), which takes the first link that parses as a short link of the right type and ignores the rest. Alain's checklist accepts only a contact short link in `simplex.contact` and a channel short link in `simplex.channel`.
|
||||
- Why it matters: extra links do nothing in the app, and a full link never works through a name, while several links per key cost the design editing one link of several and the `;` joining (section 11).
|
||||
- Decided: one short link per key, refusing full links with `invalid_link`, as the mock, the redesign (one link field, a second for the other kind) and the app already do. Sections 2 and 11, the spec and the plan follow.
|
||||
Reference in new issue
Block a user