plans: names store without custody, codes and wallet

This commit is contained in:
shum committed 2026-10-10 15:11:10 +00:00
1 parent 91e19fb5b3
commit 050d516f0c
66 files changed
+1595 -1225

No files matched your search

+180 -189
View File
@@ -2,12 +2,18 @@
The names store sells SimpleX names (`<label>.simplex`) 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.
3. The service registers the name on chain, holds it, and hands the buyer a claim code that moves it into the app.
2. The buyer pays by card, Bitcoin or Monero, or redeems a code.
3. The browser makes the name's wallet, a 12-word recovery phrase, and the service registers the name to that wallet's address.
4. Nothing is held in custody. Imported as the master of an app with no wallet yet, the phrase gives the app the name's key; the app shows the name after `bind account=0` or the chain scan (out of #7475's scope). An app that already has a wallet needs account import, not built yet.
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). The app side of claiming and redemption is #7530's: `origin/ab/names-actions-api:plans/2026-09-28-name-registration-core-cli.md`.
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).
**The board**, every screen and how each leads to the next: [`names-flow.svg`](names-flow.svg). It is generated, see [§14](#14-regenerating-the-board). View it on GitHub from the file view, not "raw": the raw host's Content-Security-Policy blocks the embedded screens.
The app side:
- redeeming codes in the app is #7530's (`origin/ab/names-actions-api:plans/2026-09-28-name-registration-core-cli.md`);
- #7475's wallet (`origin/ab/wallet:docs/rfcs/2026-09-10-wallet-keys.md`) imports a name's phrase only as the device's single master; importing it beside an existing master is still to be built (#7475/#7530), since #7475 excludes keys its master does not derive;
- editing a name's records is later app work, out of #7530's scope.
**The board**, every screen and how each leads to the next: [`names-flow.svg`](names-flow.svg). It is generated, see [§13](#13-regenerating-the-board). View it on GitHub from the file view, not "raw": the raw host's Content-Security-Policy blocks the embedded screens.
[![names-flow.svg](names-flow.svg)](names-flow.svg)
@@ -17,138 +23,160 @@ Badges keep their own store at **badges.simplex.chat**. The implementation plan
2. Two stores
3. Search
4. Price and term
5. Registration
6. Claiming
7. A code for any name
5. The wallet
6. Registration
7. Codes
8. Service API
9. Storage
10. Configuration
11. Operator commands
12. The page
13. Deployment
14. Regenerating the board
15. Open questions
11. The page
12. Deployment
13. Regenerating the board
14. Open questions
## 1. Scope
**In:**
- The names store: search, checkout, payment, registration with its steps, and the claim code.
- The secondary "code for any name" flow.
- The StoreService service side: search with prices and what uses a taken name, name invoices, the registration worker, `claimName`, and operator issuing.
- The names store: search, checkout by payment or code, the wallet in the browser, registration with its steps, and the phrase.
- The flow opened from the app.
- History with "Check a code".
- The StoreService side: search, codes, orders, the registration worker, operator issuing.
- One webapp codebase building both stores.
- Deployment.
- The mock.
**Out:**
- The app and core side of claiming and redemption (#7530). The board draws its two screens in grey (A1, A2).
- Renewals, transfers between users, in-app purchase.
- Funding the registrar wallet.
- The app side (importing a phrase beside an existing master, records, codes in the app). The board draws two app screens in grey (A1, A2).
- Selling codes on the web.
- Renewals, transfers, in-app purchase.
- Refunds (manual, by support).
## 2. Two stores
| | Names | Badges |
|---|---|---|
| Host | simplex.domains | badges.simplex.chat (and embedded on simplex.chat, as today) |
| First screen | Search (N1) | The badge landing (B0, unchanged) |
| Cross-link | "SimpleX badges", under the main button and in the menu | "SimpleX names are at simplex.domains", under the landing |
| Host | simplex.domains | badges.simplex.chat (simplex.chat/badges redirects there) |
| First screen | Search (N1) | The badge landing (B0, unchanged but for the link) |
| Cross-link | "SimpleX badges" in the menu (N13) | "SimpleX names are at simplex.domains" under the landing |
| History | "Your names" (N12) | "Your codes", as today |
To the user these are two stores. One service (StoreService, the renamed badge service) and one webapp codebase run both. The payment, card and history screens are shared. Each store keeps its own `localStorage`, since the two hosts are different origins.
One service (StoreService, the renamed badge service) and one webapp codebase run both. The payment and card screens are shared; each store has its own history. Each store keeps its own `localStorage`, since the two hosts are different origins.
## 3. Search
**Typing (N1, N1a):** the page judges the label without asking anything (N1b, N1c). The rule is shared by `Simplex.Chat.Names.validNameLabel` and `web/src/names.ts`:
- `a-z`, `0-9` and `-` only, no leading or trailing hyphen, and no `--` at positions 3–4 (#7530's `validLabel`);
- at least 6 characters (the controller's `minCharLength`, which the resolver reads);
**The field (N1):** the name, a fixed `.simplex`, and a magnifying glass at the end. The glass, Enter and the Search button at the bottom all search. The name is judged when searching, not while typing, and the rules line appears only after a mistake (N1b, N1c). The rule is shared by `Simplex.Chat.Names.validNameLabel` and `web/src/names.ts`:
- `a-z`, `0-9` and `-`, no leading or trailing hyphen, and no `--` at positions 3–4 (#7530's `validLabel`);
- at least 6 characters (the controller's `minCharLength`);
- at most 63 (simplexmq's label limit).
Core rejects upper case, so the page folds it to lower case as the buyer types.
simplexmq lower-cases a name when parsing it (`SimplexDomain`'s `strP`, `labelHash`) and #7530's `validLabel` admits only a–z, so the page folds upper case as the buyer types.
**Searching:** `GET /api/name/<label>`. The service validates the label and hashes it, keccak256 over the ASCII bytes. It then asks `GET {resolver_url}/v2/resolve/[<hash hex>].<tld>`, so the resolver sees only the hash. The service never logs or stores the label.
**Searching:** `GET /api/name/:label`. The service validates the label and hashes it with keccak256, then asks `GET {resolver_url}/v2/resolve/[<hash hex>].<tld>`. The request carries only the hash; for a registered name the resolver still knows which name it was. The service never logs a searched label, and stores it only for an order.
| Resolver says | The page shows |
| Answer | The page shows |
|---|---|
| `available` | N2: "dynamis.simplex is available", the price for 2 years, and Register |
| `registered`, with links | N2a: "Used by", one card per `simplexContact`/`simplexChannel` link, each with picture, display name, the link and Open in SimpleX (deep link) |
| `registered`, no links | N2b: taken, and pointing nowhere (a name held for an unclaimed buyer reads like this) |
| `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 bought here reads like this until its owner sets records) |
| `reserved` | N2c: reserved, with "Contact the SimpleX team" |
| held by another open order | N2f: being registered, not offered until that order expires or finishes |
| no answer, or an error | N2d (503) |
| the read rate limit | N2e (429) |
| held by another open order | N2f: being registered |
| no answer, an error | N2d (503) |
| the rate limit | N2e (429) |
**What uses a taken name:** the service opens each link with its own chat core, as `APIConnectPlan` does, for the display name and picture, and builds the deep link. A short cache keeps one search to at most one fetch per link. Each fetch has a 3 s timeout and its own rate limit, since a name's records can point at any server. This mirrors the app's own search result (canvas 3b).
**Taken names:** the service opens each link with its own chat core. `APIConnectPlan` returns the link's display name and picture from its link data without connecting. Each fetch has a 3 s timeout, its own rate limit and a short cache.
**Under the main button:** the invest block, Wefunder's sentence, logo and link with a names amount (a placeholder for now), and **Check your code** (§7).
## 4. Price and term
- **Term:** 2 years, fixed: the registry's minimum (730 days, `protocol/simplex-messaging.md`), and nothing is charged for years whose price cannot be known yet. The term runs from registration, not from the claim.
- **Price:** the service's answer for the searched name. The page never shows a price list.
- The price rule is internal: a seeded per-length table, with per-name entries able to override it, both insert-only. A change is a new price id.
- The starting values are $100 for 8+ letters, $200 for 7 and $400 for 6, for the 2 years. They live in `Catalog.defaultNamePrices` and can change before launch.
- **Checkout** carries the price the page showed. The service prices again and answers `catalog_changed`, with the new price, if it moved (N3b).
- **Term:** 2 years for a paid name, fixed: the registry's minimum (730 days), and nothing is charged for years whose price cannot be known yet. A code registers for its own `years`; investor codes get 7, 5 or 3 by investment date, as simplex.domains says. 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: 9 October 2028 for a name registered on 10 October 2026.
- **Price:** the service's answer for the searched name; no price list is shown.
- The rule is internal: a seeded per-length table that per-name entries can override, insert-only.
- The starting values are $100 for 8+ letters, $200 for 7 and $400 for 6, for the 2 years, in `Catalog.defaultNamePrices`.
- **Checkout** carries the shown price. The service prices again and answers `catalog_changed`, with the new price, if it moved (N3b).
## 5. Registration
## 5. The wallet
1. Checkout (N3) → `POST /api/invoice` with `kind: "name"`.
- The page makes a claim code and sends only its hash.
- The service writes the invoice, an unpaid claim code row bound to the label, and a `name_registrations` row in phase `awaiting_payment`.
2. Payment uses the badge lane, unchanged (N4, N5, N10…). Settlement moves the registration to `paid`.
3. The registration worker (one per paid registration, resumed after a restart) moves it through these phases:
At checkout (N3), the browser makes the name's wallet:
1. 128 bits from `crypto.getRandomValues`.
2. A 12-word BIP-39 phrase, with its checksum.
3. The BIP-39 seed: PBKDF2-SHA512, 2048 rounds, no passphrase (WebCrypto).
4. BIP-32 to `m/44'/60'/0'/0/0`: HMAC-SHA512 from WebCrypto, secp256k1 from vendored `@noble/secp256k1`.
5. The EIP-55 address, with keccak-256 from vendored `@noble/hashes`.
How the phrase and address are handled:
- **The phrase** is stored in this browser before the order is created. Checkout warns when storage is unavailable ("This browser will not keep your recovery phrase", N3f), as the badge checkout does for codes; N7a then shows the phrase once.
- **The address** is the only thing sent to the service.
- **The path** is #7475's account 0. Tests pin the `abandon … about` vector to #7475's address for account 0, `0x9858EfFD…aEda94`.
N7 shows the phrase in numbered columns, with Copy and a QR for the app to scan, and says plainly that it owns the name and that nobody else has a copy. History keeps it, behind "Show phrase".
**Importing in the app** (A1, A2) is not built as drawn. #7475's device wallet is one master per device, generated with 24 words or imported from a 12–24-word phrase. On a device with no wallet, importing the name's phrase as the master gives it the name's key at account 0, and the app shows the name after an explicit `bind account=0` or the chain scan, which #7475 leaves out (its known limit 2); a device that already has a master cannot add it as an account. The phrase also works in any BIP-44 wallet at account 0.
**One wallet per order:** a taken order retargeted to another name (N8a) keeps its address, so the same phrase owns the new name.
**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. It makes no wallet, offers no "I have a code" (the app redeems codes itself), and ends with "Back to SimpleX".
## 6. Registration
1. **Orders:**
- Paid: `POST /api/invoice {kind: "name", label, price, method, owner}`; settlement moves the registration to `queued`.
- By code: `POST /api/name/register {label, owner, code}`, with no invoice. The code is reserved for the registration and spent when it succeeds.
2. **One order per name:** while an open invoice or unfinished registration holds a label, a second order is refused with `name_pending`.
3. **The worker**, one per registration, resumed after a restart. It uses #7530 §10's chain client (JSON-RPC to the resolver host's reth node, a dry run of every transaction, serial nonces, EIP-1559 fees, receipts with replacement):
| Phase | What happens | Page |
|---|---|---|
| `paid` → `committing` | Dry-run, then send `commit(makeCommitment(Registration{label, owner = registrar address, duration = 2 years, secret, …}))` | N6 |
| `committed` | The commit is mined; wait `minCommitmentAge`, read from the controller (60 s in the docs, set per deployment) | N6a |
| `registering` | Dry-run, then send the register call | N6b |
| `registered` | Receipt + 3 blocks; `expires_at` recorded; the claim code becomes claimable | N7 |
| `queued` (paid, or code reserved) → `committing` | Send `commit(makeCommitment(Registration{label, owner = buyer's address, duration, secret, …}))` from the service's key. `duration` is 2 years when paid and the code's `years` when redeemed; the secret is the service's own | N6 |
| `committed` | Mined; wait `minCommitmentAge`, read from the controller (60 s in the docs) | N6a |
| `registering` | Send `register`, paying the contract's fee | N6b |
| `registered` | Receipt + 3 blocks; `expires_at` recorded | N7 (or L3) |
| `taken` | The register dry run reverted `NameNotAvailable`; nothing was sent | N8 |
| `failed` | Any other failure after retries; the payment is kept and retrying charges nothing | N9 |
| `failed` | Any other failure after the service's retries; Try again, `POST /api/registration/:id/retry`, requeues it at no charge | N9 |
- **Progress:** each phase is published to the order's long poll. The page shows the steps as the app does (canvas 9), with transaction links, and a reload or another device shows the same state.
- **Taken (N8):** the order keeps its value. The buyer can register another name priced at or under what was paid (N8a, `POST /api/invoice/:id/name`); a cheaper name refunds nothing. Or they can turn the claim code into a code for any name of the original name's length (`POST /api/invoice/:id/unbind`); that is worth less if the name carried a per-name price above the length price.
- **One order per name:** while an open invoice or an unfinished registration holds a label, a second order for it is refused with `name_pending`, and search shows it as being registered. This makes N8 rare. It still covers names registered outside the store.
- **Refunds:** none in the service. A registration that fails for good is refunded manually by support.
- **The chain client** is #7530's (§10 there): JSON-RPC to the resolver host's reth node, a dry run of every transaction, serial nonces from the registrar key, EIP-1559 fees, receipts with replacement, and 3 confirmations.
- **Signing:** recoverable secp256k1 is in simplexmq #1843 (merged into `names`, not `master`); EIP-1559 transaction signing is on `ab/eth-tx`.
- **All phases:** `unpaid` (invoice open) → `queued` (paid, or code reserved) → the rows above. From `taken` a paid order goes back to `queued` with its new label, or ends `code_issued`; a code order ends at `taken` with its code released. A code is reserved from `queued` until it is `registered` (spent) or `taken` (released); `failed` keeps it for Try again.
- **Ownership from the start:** `register()` takes the owner as a struct field, and `makeCommitment` hashes the struct rather than `msg.sender` (names v2 RFC). So the name belongs to the buyer's address from the start, and the service's key never holds a name.
- **Records:** the name is registered with none. The owner sets them from the app after import.
- **Progress:** each phase is published to the long poll (`GET /api/invoice/:id` or `GET /api/registration/:id`).
- **Taken:** possible only for a name registered outside the store. The order keeps its value:
- paid: another name at most its price, searched on the order's own page, `POST /api/registration/:id/name` (N8a), where a cheaper one refunds nothing and a dearer one is `bad_request`; or a code for the original name's length (8+ for longer names) for 2 years, `POST /api/registration/:id/code` (N8b), which the service generates and returns once, storing only its hash;
- by code: the code is released, unspent, for another name.
- **Failed:** "Paid, not registered". Try again charges nothing, and refunds are manual.
- **Signing the service's transactions:** `signRecoverable` and `signEip1559Tx` in simplexmq #1887, on `names` (not `master`).
## 6. Claiming
N7 shows the claim code (`SC7-2Y-…`) with Copy, a QR and **Open in SimpleX** (`simplex:/name#code=<code>&label=<label>`). The link format is a proposal for the app team. The app (A1, #7530) sends a new service command:
```
claimName {code, owner, nameLinks} → name {registration}
```
The service handles it in four steps:
1. It checks that the code is paid, bound to a held name and unspent.
2. While it still owns the name, it sets the resolver records `nameLinks` asks for: the address and/or channel.
3. It transfers the registrar token and the registry node to `owner`.
4. It marks the code spent.
It refuses an unknown or spent code with `code_invalid`, and a name it no longer holds with `name_not_held`. Until the claim, the name points nowhere (N7's warning) and its 2 years run.
**A lost claim code (N7b):** the operator's `//reissue <reference>` issues a new claim code for a held, unclaimed name, after support confirms the order, and revokes the old one.
**Claim code prefix:** `SC<len>-2Y-…` for claim codes, `SN<len>-2Y-…` for length codes. The app knows from the text whether to send `claimName` or #7530's `redeemNameCode`.
## 7. A code for any name
The secondary link, "Buy a code for any name of 6+ letters":
- C1: choose the shortest length (6+, 7+, 8+), each with its price.
- C2: checkout, with `kind: "nameCode"`.
- C3: the code.
The app registers a name with it later, with the same steps (#7530's `redeemNameCode`).
## 7. Codes
| | |
|---|---|
| Shown | `SN7-2Y-9EEHX-PTHPY-NCH03-R54E6` |
| Grammar | `SN`, one digit for the minimum length (6–8), the years (`2`), `Y`, then 20 Crockford base32 characters |
| Check | Luhn mod 32 over the parameter characters and the 19 payload characters, so `SN7` → `SN6` fails |
| Shown | `SN7-BJTBP-29596-R3HJ8-25YM7`; `SN6-…`, `SN20-…`; `SN-…` with no length |
| Grammar | `SN`, an optional 1–2 digit length, then 20 Crockford base32 characters |
| Check | Luhn mod 32 over the length digits and the 19 payload characters |
| Reading | Case-insensitive, separators optional, `I`/`L` → `1`, `O` → `0` |
| Stored | SHA-256 of the canonical form (`SN72Y` + 20 characters), nothing else |
| Authority | The service row's `min_length` and `years`, not the text |
| Stored | SHA-256 of the canonical form, nothing else |
| Truth | The service's `name_codes` row: `min_length` (or none), `years`, status. The length in the text is a convention |
| Issued by | The operator (`//issue name [<length>] [years <Y>] [paid\|unpaid\|free]`, 2 years by default; 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 names v2 RFC has it buy a name directly), and the service for a taken order (N8b). Not sold on the web |
| Use | Single-use: reserved from `queued` until it is `registered` (spent) or `taken` (released); `failed` keeps it for Try again |
A claim code has the same grammar, with the service row bound to its label, under the `SC` prefix (§6). `Simplex.Chat.Badges.Code` gains `NameCode`, and `web/src/codes.ts` mirrors it.
**Check your code** (K1), under the invest block, opens a popup. `POST /api/code/check {code}` answers what the code covers (`minLength` or none, `years`) and whether it is unused, reserved by an unfinished registration, used (K5), revoked, expired or unpaid, without spending it. A typo fails the check character before any request (K6).
**Use this code** applies it: a covered name shows $0 and the code's term (K2, where Remove drops it), checkout goes to Register with code (K3), and the name registers for the code's years (K8). The board's example is an investor code, `SN-` for 7 years. A name the applied code does not cover shows its normal price, with a line saying what the code covers.
The popup's answer per state, drawn for unused, used and a typo:
| State | 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 |
**I have a code** at checkout (K4) does the same in place. A code that does not cover the name says so (K7), and the buyer can still pay.
**History:** "Check a code" (N12a) asks the same question and adds the code to the list, where Use applies it.
## 8. Service API
@@ -163,44 +191,38 @@ GET /api/name/bakery
{"type": "channel", "displayName": "Bakery news", "image": "data:image/jpg;base64,…",
"link": "https://smp9.simplex.im/c#…", "deepLink": "simplex:/c#…"}]}
GET /api/name/support
200 {"status": "reserved", "label": "support"}
GET /api/name/support 200 {"status": "reserved", "label": "support"}
GET /api/name/orbital 200 {"status": "pending", "label": "orbital"}
GET /api/name/orbital (held by another open order)
200 {"status": "pending", "label": "orbital"}
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 SN- code has "minLength": null
400 {"error": "invalid_name"} 503 {"error": "provider_unavailable"} 429 {"error": "rate_limited"}
POST /api/invoice {"kind": "name", "label": "dynamis", "price": 20000, "method": "btc", "owner": "0x5aE1…7cD3"}
POST /api/name/register {"label": "dynamis", "owner": "0x5aE1…7cD3", "code": "SN7-BJTBP-29596-R3HJ8-25YM7"}
GET /api/invoice/:id … plus "registration": {id, phase, label, owner, years, commitTx, registerTx, revealAfter, expiresAt, error}
GET /api/registration/:id the same registration object, long-polled
POST /api/registration/:id/name {"label": "dynamos"}
POST /api/registration/:id/code
POST /api/registration/:id/retry
```
```json
POST /api/invoice {"kind": "name", "label": "dynamis", "price": 20000, "method": "btc", "codeHash": "<43 base64url>"}
POST /api/invoice {"kind": "nameCode", "minLength": 7, "price": 20000, "method": "xmr", "codeHash": "<43 base64url>"}
```
- **The answer** has the badge answer's shape, with `kind`, `label` or `minLength`, and `years`.
- **Refusals:**
- `catalog_changed`, with the new `price`;
- `invalid_name`;
- `name_taken`, when the name was taken since the search;
- `name_pending`, when another open order holds it;
- `bad_request`, `code_conflict`, `provider_unavailable` and `rate_limited`, as today.
- **`GET /api/invoice/:id`** adds `registration {phase, label, commitTx, registerTx, revealAfter, expiresAt, claimed, error}`. The long poll also wakes on a phase change.
- **`POST /api/invoice/:id/name {label}`** retargets a `taken` registration. It is refused if the new price exceeds what was paid.
- **`POST /api/invoice/:id/unbind`** turns a `taken` order's claim code into a length code.
- **`claimName`** is a service request over SMP, beside the badge commands, at the next version (§6).
- **`invalid_name`, `name_taken` and `name_pending`** join `WIRE_ERROR_CODES`.
- **Errors:** new on HTTP: `invalid_name`, `name_taken`, `name_pending`, `code_not_covering`, `code_reserved`, and 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.
- **On the page:** `name_pending` and `name_taken` from an order go back to search (N2f, N2a); code errors from the service show where the code was entered, the popup (K5) or the checkout entry (K7), and a typo is caught on the page (K6); anything else is N3d.
- **The registration id** is a random 128-bit token, like order ids; whoever holds it can act on the registration.
- **Rate limits:** `/api/name` and `/api/code/check` each have their own.
- **`owner`** must be a checksummed address.
## 9. Storage
Migration `20261010_names`, in SQLite and Postgres:
Migration `20261010_names`, in SQLite and Postgres. The badge tables (`sx_badge_service_badge_codes`, `sx_badge_service_badge_code_invoices`, …) are untouched, so there is no table rebuild.
- **`sx_badge_service_badge_codes`:**
- adds `code_kind` (`badge`, `name`), `min_length`, `years` and `label`; the label is set for a claim code only;
- `badge_type` and `months` become nullable, with a CHECK per kind;
- SQLite rebuilds the table with `PRAGMA defer_foreign_keys = ON`, and an up/down test runs with referencing rows present.
- **`sx_badge_service_badge_code_invoices`:** adds `name_price_id`, and `price_id` (`NOT NULL` today) becomes nullable, with a CHECK that exactly one of the two is set. In SQLite this is a second rebuild.
- **`sx_badge_service_name_codes`:** `name_code_id`, `code_hash` (unique), `min_length` (null for `SN-`), `years`, `code_payment_status`, `spent_at`, `revoked_at`, `expires_at`, `created_at`.
- **`sx_badge_service_name_prices`:** `price_id`, `min_length`, `label` (null for a length rule), `price`, `currency`, `status`, `created_at`.
- **`sx_badge_service_name_registrations`:** `invoice_id`, `label`, `badge_code_id`, `secret`, `commitment`, `phase`, `commit_tx`, `commit_block`, `register_tx`, `register_block`, `expires_at`, `claimed_owner`, `claimed_at`, `error`, `created_at`, `updated_at`.
- **`sx_badge_service_name_registrations`:** `registration_id`, `label`, `owner`, `secret`, `commitment`, `phase`, transaction hashes and blocks, `years`, `expires_at`, `invoice_id` (null when paid by code), `name_code_id` (null when paid), `error`, `created_at`, `updated_at`.
- **`sx_badge_service_name_invoices`:** `invoice_id`, `registration_id`, `price_id`, `provider_ref`.
- The webhook and poller lookups by `provider_ref` cover both this table and `sx_badge_service_badge_code_invoices`. As in `Web/Server.hs` today, a row whose provider differs from the webhook's is ignored, since `provider_ref` is unique only within each table.
## 10. Configuration
@@ -218,89 +240,64 @@ tld = testing
rpc_url = http://127.0.0.1:8545
chain_id = 1
controller = 0x…
registrar = 0x…
; the resolver contract set in each Registration
resolver = 0x…
; the registrar's key: it pays gas and the registration fee, and holds names until they are claimed
registrar_key = replace-me
; pays gas and the registration fee; holds funds only, never names
service_key = replace-me
confirmations = 3
```
Without `[names]`, search answers `provider_unavailable`. Without `[chain]`, a paid registration waits in `paid`, and the service logs an error at startup.
## 11. Operator commands
| Where | Command |
|---|---|
| `--run-cli` | `//issue name <6\|7\|8> [paid\|unpaid\|free]` prints a length code once |
| `--run-cli` | `//revoke <code>`, for either kind |
| `--run-cli` | `//reissue <reference>`: a new claim code for a held, unclaimed name, revoking the old one; CLI only, as group replies reach every member |
| Managed group, moderators and above | `/issue name <6\|7\|8>`, `/bulk name <6\|7\|8> count <B>` |
## 12. The page
## 11. The page
**Routes on simplex.domains:**
- `/`: search;
- `#/checkout`;
- `#/code` and `#/code/checkout`: the secondary flow;
- `#/names`: history;
- `?order=<id>`: payment, registration and the claim code.
- `#/?name=&owner=`: opened from the app;
- `?order=<id>`: payment, registration, the phrase, and another name's search after `taken` (N8a);
- `?registration=<id>`: a registration paid with a code.
**Store record:** gains `kind` (`name`, `nameCode`), `label`, `minLength` and `years`.
**Modules:**
- `wallet.ts`: the crypto (§5);
- `names.ts`: rules and result types;
- `nameScreens.ts`: the screens;
- `namesMain.ts`: wiring.
**New rules in `styles.css`:** `.name-box`, `.name-status`, `.result`, `.entity`/`.avatar`, `.steps`/`.step-row`, `.store-links` and `.name-mark`. All of them use the existing tokens, so the dark theme needs nothing of its own (D2, D6a, D7). They are prototyped in `mockups/mockups.css`.
The shared `codes.ts`, `api.ts`, `domain.ts`, `store.ts`, `order.ts`, `screens.ts`, `main.ts` and `crowdfunding.ts` grow for names.
**The button's place:** the secondary links sit where the badge pages keep their invest block, so the main button stands at the same height on every screen.
### Registering a name
**New rules in `styles.css`:** the search field with its glass, results and entity cards, the steps, the phrase grid, the popup and the check-code line, from `mockups/mockups.css`, all on the existing tokens. The invest block keeps the main button at the same height as on the badge pages.
| | | |
|---|---|---|
| <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 |
| <img src="screens/N6.jpg" width="260"> | <img src="screens/N6a.jpg" width="260"> | <img src="screens/N7.jpg" width="260"> |
| **N6** committing | **N6a** waiting a minute | **N7** yours, with a claim code |
| **N1** search with the glass | **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 steps | **N7** yours, with the phrase | **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 |
### What a search can find
| | | |
|---|---|---|
| <img src="screens/N2a.jpg" width="260"> | <img src="screens/N2b.jpg" width="260"> | <img src="screens/N2c.jpg" width="260"> |
| **N2a** taken: what uses it | **N2b** taken, pointing nowhere | **N2c** reserved |
| <img src="screens/N1b.jpg" width="260"> | <img src="screens/N2d.jpg" width="260"> | <img src="screens/N2e.jpg" width="260"> |
| **N1b** too short, judged locally | **N2d** not checked | **N2e** too many searches |
### When registering does not go through, and the secondary code
| | | |
|---|---|---|
| <img src="screens/N8.jpg" width="260"> | <img src="screens/N8a.jpg" width="260"> | <img src="screens/N9.jpg" width="260"> |
| **N8** someone was faster | **N8a** another name, same payment | **N9** paid, not registered |
| <img src="screens/C1.jpg" width="260"> | <img src="screens/C3.jpg" width="260"> | <img src="screens/N12.jpg" width="260"> |
| **C1** a code for a length | **C3** the code | **N12** your names |
The refusals at checkout (N3a–N3e) and the payment endings (N4a–N4d, N10–N10c, N11) are the badge screens. The board shows each one with the name's content.
## 13. Deployment
## 12. Deployment
Production is the split deployment in `scripts/badge-service/` (`scripts/store-service/` after the rename):
- Caddy serves the webapp folder the service exports at boot and proxies `/api` and `/webhooks`.
- The service runs with `+client_postgres` and host networking, with its ini mounted read-only.
What changes:
- **Build:** `build.js` emits two site roots, `dist/badges/` and `dist/names/`, each with its own `index.html`, `sw.js` and `assets/<hash>/`. The Dockerfile's web stage builds both.
- **Export:** `exportWebapp` writes both, with the Stripe publishable key in each shell.
- **Build:** `build.js` emits two site roots, `dist/badges/` and `dist/names/`, each with its own `index.html`, `sw.js` and `assets/<hash>/`.
- **Export:** both are exported, with the Stripe key in each shell.
- **Caddy:**
- `badges.simplex.chat` → `web/badges`;
- `simplex.domains` → `web/names`;
- both proxy `/api/*`; `/webhooks/*` stays on one host.
The Caddyfile is not in this repository, so the deployment README gives both host blocks and their CSP: Stripe, and `frame-ancestors` for the badges embed.
- **Chain:** the resolver (`:8000`) and reth (`:8545`) on loopback when they share the host, a private network otherwise. The registrar key is in the mounted ini, which `Dockerfile.dockerignore` already keeps out of the image.
- **Unchanged:** the database `badge_service`, the `sx_badge_service_` prefix and the migrations table.
- both proxy `/api/*`; `/webhooks/*` stays on badges.simplex.chat, where BTCPay and Stripe already send it;
- `apps/simplex-badge-service/README.md` (`apps/simplex-store-service/` after the rename) gains the host blocks, and the CSP in its `web/README.md` gains simplex.domains.
- **Chain:** the resolver (`:8000`) and reth (`:8545`) on loopback when they share the host.
- **Unchanged:** the database `badge_service` and the `sx_badge_service_` prefix.
## 14. Regenerating the board
## 13. Regenerating the board
`mockups/screens.js` builds every screen in the browser on the webapp's compiled modules. Shared screens call the real `screens.js`, and name screens are prototypes for stage 5's `nameScreens.ts`. `mockups/layout.mjs` holds each frame's caption, position and incoming arrow. `mockups/board.mjs` renders each frame with Playwright into `screens/<tag>.jpg` (JPEG at quality 85, animations frozen, so runs are byte-identical) and writes `names-flow.svg` around them.
`mockups/screens.js` builds every screen in the browser on the webapp's compiled modules. Shared screens call the real `screens.js`, and name screens are prototypes for `nameScreens.ts`. `mockups/layout.mjs` holds the captions, positions and arrows. `mockups/board.mjs` reads the built assets from `apps/simplex-badge-service/web/dist/assets`, a path that follows the stage 1 rename and, after stage 5, the names site root. It renders each frame with Playwright into `screens/<tag>.jpg` (JPEG at quality 85, animations frozen) and writes `names-flow.svg`.
```
cd apps/simplex-badge-service/web && npm install && npm run build && cd ../../..
@@ -308,18 +305,12 @@ npm install --prefix /tmp/pw playwright && npx --prefix /tmp/pw playwright insta
PLAYWRIGHT=/tmp/pw/node_modules/playwright/index.mjs node plans/names-codes/mockups/board.mjs
```
## 15. Open questions
## 14. Open questions
1. **Funding:** the deployed `.testing` controller's `register` is payable in ETH, so the registrar key holds ETH. #7530's `registerWithCredit` (allowance-funded) replaces it once the `.simplex` contracts deploy.
2. **Claim mechanics (blocking stage 4):** no source here shows a registrar registering a name to itself, setting its records, then transferring it on the deployed contracts.
- The SNS contract source is not in these repositories.
- The names v2 RFC registers with the user as `owner` from the start.
- Only its remark about auto-reclaim suggests the registry node follows the token.
This has to be confirmed on the contract source first. If it does not hold, ownership falls back to the buyer giving an owner address.
3. **The open-in-app link format** (§6), for the app team.
4. **Coordination with #7530:** the StoreService rename (#7530 proposes `SHC`/`SHP`/`SHE` prefixes for ShopService; this plan uses `STC`/`STP`/`STE`), the code kind, the `Registration` encoder, the chain client, and the app side of `claimName`.
5. **Fetching link profiles** puts a load on the service that grows with searches. The cache bounds it per link, and a timeout and a rate limit of its own bound it per fetch and per client.
6. **Key custody:** the registrar key is a hot key in the ini, and it owns every unclaimed name. Should held names move to a separate holding address whose key is kept offline, with only registration on the hot key? Claim reminders keep the window short either way.
7. **Cost risk:** the buyer pays a fixed USD price; the contract's ETH fee and gas are paid at registration time. A gas spike is the service's loss.
8. **simplex.domains today** is a countdown page; the store replaces it at launch. Its heading and line ("Your SimpleX domain", "One name for your public channel and contact address") are kept.
1. **Importing the phrase in the app:** #7475 imports a phrase only as the device's single master, so an app that already has a wallet needs an account-import path (#7475/#7530). It decides whether a web-bought name reaches such an app.
2. **Funding and cost:** the `.testing` controller's `register` is payable in ETH. The service's key pays the fee and gas while the buyer paid a fixed USD price, so a gas spike is the service's loss. #7530 §9 sends `registerWithCredit` (a USD allowance, `setRegistrarAllowance`), and the names v2 RFC proposes credits for `.simplex` (`setRegistrarCredits`); the two differ and need coordinating.
3. **Signing:** simplexmq `names` (#1887) has to reach the simplexmq the service builds against.
4. **Coordination with #7530:** the StoreService rename (#7530 proposes `SHC`/`SHP`/`SHE`; this plan uses `STC`/`STP`/`STE`), the `Registration` encoder, the chain client, codes in the app, and 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, and the service's table rather than the text deciding what a code covers, so the app cannot check one offline.
5. **The investor amount** in the invest block, $500+ as on simplex.domains today, is a placeholder.
6. **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.
7. **simplex.domains today** is a countdown page; the store replaces it at launch. Its heading and line are kept.
@@ -2,254 +2,272 @@
## Context
SimpleX names (`<label>.simplex`, an ENS fork on Ethereum) get their own store at **simplex.domains**. The buyer searches a name. Then:
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.
3. The browser makes a wallet for the name, a 12-word recovery phrase, and the service registers the name to that wallet's address.
4. The buyer keeps the phrase. Imported as the master of an app with no wallet yet, it gives the app the name's key; the app shows the name after `bind account=0` or the chain scan (out of #7475's scope). An app that already has a wallet needs account import, not built yet.
- if it is free, the page shows its price, the buyer pays by card, Bitcoin or Monero, and the page shows the registration steps the app shows (Committing, Waiting, Registering);
- once registered, the buyer gets a claim code that moves the name into the app.
Nothing is held in custody: the name belongs to the browser's wallet from its first transaction. Badges keep their own store at **badges.simplex.chat**; to the user these are two stores, each linking the other as a secondary option. Both run on the badge service, renamed **StoreService**, and on one webapp codebase.
Badges keep their own store at **badges.simplex.chat**. To the user these are two stores, each with a secondary link to the other.
Both stores run on the badge service, renamed **StoreService**, and on one webapp codebase. Redemption in the app (core and UI) is #7530's: `origin/ab/names-actions-api:plans/2026-09-28-name-registration-core-cli.md`. That plan also specifies the service's chain client, which this work shares.
The app side belongs to #7530 and #7475 (`origin/ab/names-actions-api:plans/2026-09-28-name-registration-core-cli.md` and `origin/ab/wallet:docs/rfcs/2026-09-10-wallet-keys.md`): #7530 redeems codes in the app; #7475 imports a name's 12-word phrase only as the device's single master, so importing it beside an existing master is still to be built (#7475 excludes keys its master does not derive). Editing a name's records is later app work, out of #7530's scope. #7530 also specifies the service's chain client, which this work shares.
Facts that shape the design:
- Registering needs an owner address. Only the app's wallet has one, so the service registers each web-bought name to **its own registrar address** and holds it until the buyer claims it with the app. The name is held for real from the moment it is registered, so it cannot be taken while unclaimed.
- The term is fixed here at **2 years**, the registry's minimum (730 days, per `simplexmq: protocol/simplex-messaging.md`). It runs from the web registration, not from the claim.
- Availability and records come from the resolver (`simplexmq: scripts/resolver`): `GET /v2/resolve/[<keccak256(label)>].<tld>` → `available | registered{nameRecord} | reserved`. A registered name's `nameRecord` lists its SimpleX contact and channel links. The service opens those with its own chat core to show their display name and picture.
- Name rules:
- **Registering for any owner:** `register()` takes the owner as a field of the `Registration` struct, and `makeCommitment` hashes the struct rather than `msg.sender`. So the service can pay for and send both transactions while the owner is the browser's address (names v2 RFC, `origin/ab/names-purchase-and-management:docs/rfcs/2026-08-05-simplex-names-v2.md:172`).
- **The wallet:** 128 bits of entropy, shown as a 12-word BIP-39 phrase. The name's owner is account 0, `m/44'/60'/0'/0/0`, the path #7475 uses for account 0.
- #7475's device wallet is one master per device: generated with 24 words, or imported from a 12–24-word phrase.
- On a device with no wallet yet, importing the name's phrase as the master gives it the name's key at account 0; the app shows the name only after an explicit `bind account=0` or the chain scan, which #7475 leaves out (its known limit 2). A device that already has a master cannot add it as an account; that path is not built.
- **Term:** 2 years for a paid name, the registry's minimum (730 days, `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)>].<tld>` → `available | registered{nameRecord} | reserved`. `nameRecord` lists `simplexContact` and `simplexChannel` links.
- **Name rules:**
- characters and hyphens from #7530 `validLabel`: `a-z 0-9 -`, no leading or trailing hyphen, no `--` at positions 3–4;
- at least 6 characters from the controller's `minCharLength`, which the resolver reads;
- at least 6 characters from the controller's `minCharLength`;
- at most 63 from simplexmq's label limit;
- core rejects upper case, so the page folds it as the buyer types.
- simplexmq lower-cases a name when parsing it (`SimplexDomain`'s `strP`, `labelHash`) and #7530's `validLabel` admits only a–z, so the page folds upper case as typed.
- `.simplex` is not deployed; `.testing` is live on mainnet.
## Decisions (owner, 2026-10-09 and 2026-10-10)
| Topic | Decision |
|---|---|
| Stores | Names at simplex.domains, badges at badges.simplex.chat; separate to the user, with secondary cross-links |
| Code | One webapp codebase with two shells (names, badges) sharing payment, card and history screens; one service |
| Stores | Names at simplex.domains, badges at badges.simplex.chat; separate to the user, each linking the other as a secondary option |
| Code | One webapp codebase with two sites (names, badges) sharing payment and card screens; each site has its own history; one service |
| Service | The badge service is renamed **StoreService** (`apps/simplex-store-service`, `Simplex.Chat.StoreService`) |
| Search | The buyer types a name; the service answers availability and the price for that name |
| Taken | A taken name shows what uses it: address and/or channel, with display name, picture, link and deep link |
| Reserved | Shown as reserved, with a link to contact the SimpleX team |
| Price | Internal and changeable, possibly per name. The page never shows a price list, only the price of the name searched |
| Term | 2 years, fixed. No term screen |
| Registration | After payment the service registers the name to its registrar address. The page shows Committing → Waiting 60s → Registering, as in the app (canvas 9) |
| Ownership | The service holds the name until it is claimed: a claim code, and Open in SimpleX, move it to the app's wallet |
| Secondary | "Buy a code for any name of N+ letters", for registering later in the app (#7530's `SN<len>-2Y-…` code) |
| Code format | #7530 §6: `SN6-2Y-4K2P7-TQ9M1-ZX3RB-8HJ5W`, in core `Badges/Code.hs`, mirrored in `web/src/codes.ts`. A claim code (`SC…`) is bound to its name in the service |
| Storage | Name codes in the code table with `code_kind`; registrations in their own table |
| Search | A field with a magnifying glass, as dynadot.com draws it. Clicking the glass, pressing Enter, or the Search button at the bottom all search. 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"; being registered by another order |
| 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: 7, 5 or 3, by investment date, as simplex.domains says) |
| One order per name | A second order for a name with an open invoice or a registration in progress is refused (`name_pending`); search shows it as being registered |
| Wallet | The browser generates a wallet for each order, 128-bit entropy as a 12-word phrase; a taken order retargeted to another name keeps it. All key cryptography is in the browser |
| Registration | The browser sends the owner address; the service sends commit and register from its funded key, owner = that address. The page shows Committing → Waiting 60s → Registering, as in the app (canvas 9) |
| Codes | `SN6-…`, `SN7-…`, `SN20-…`: the number is the shortest name the code covers, a convention only. `SN-…` codes carry no length. The service's table is the source of truth, and the page asks the service what a code is for |
| Paying with a code | Checkout has "I have a code" under Card / Bitcoin / Monero and Pay. A code that covers the name makes its price 0, and redeeming it on the web yields the name's wallet |
| Check your code | Under the invest block, "Check your code" opens a popup that asks what a code covers. While it is applied, a covered name's price shows as $0 (investor codes) |
| Codes not sold | Codes are not sold on the web. They come from the app (an owner decision; #7530 defers in-app purchase, and the names v2 RFC has it buy a name directly), from the operator (CLI, group), or from the service for a taken order; each registers one name |
| History | "Your names" lists names and their phrases, and codes. "Check a code" adds a code to the list with what it covers |
| Invest block | The Wefunder sentence, logo and link, with a names amount, $500+ as on simplex.domains today. It is a placeholder for now (a constant, as `crowdfunding.ts`) |
| Storage | Name codes in their own table, not a kind column on the badge code table; registrations in their own table |
| Mockups | The board and screens generated from the real webapp modules (`plans/names-codes/mockups/`) |
| One order per name | A second order for a name with an open invoice or a registration in progress is refused (`name_pending`); search shows it as "being registered" until that order expires or finishes (2026-10-10) |
| Claim code prefix | Claim codes are `SC<len>-2Y-…`, length codes `SN<len>-2Y-…`, so the app knows from the text whether to claim or register (2026-10-10) |
| 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 PBKDF2-SHA512 and HMAC-SHA512. This ends the webapp's "no runtime dependencies" rule for these two |
| 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 phrase is made, there is no "I have a code" (the app redeems codes itself), and the page ends with "Back to SimpleX" |
## The names store, screen by screen
1. **Search** (the store's first screen): a name field with `.simplex`, validated locally as typed (length, characters). Search asks `GET /api/name/:label`, and the answer appears on the same screen:
- **available:** "Available for $X for 2 years" with Register enabled;
- **taken:** "Used by" and one card per address or channel, each with picture, display name, Open (deep link) and the link;
- **reserved:** "Reserved", with "Contact the SimpleX team";
- **being registered:** another buyer's order holds it;
- **check failed or rate limited:** shown as such.
1. **Search** (first screen): the field with `.simplex` and a magnifying glass. A mistake shows the rules line. Search asks `GET /api/name/:label`, 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;
- being registered;
- check failed or rate limited.
Secondary links: "Buy a code for any name of 6+ letters" and "Badges".
2. **Checkout:** summary (name, 2 years, total), Pay with Card / Bitcoin / Monero, and Pay.
3. **Payment:** the badge payment screens, unchanged.
4. **Registering:** the three steps, each ticked as it completes, with the transaction links. A reload resumes them; they are kept by the service, not the page.
5. **Registered:** "dynamis.simplex is yours until 10 Oct 2028". The claim code, Copy, Open in SimpleX and its QR. A warning that the name points nowhere until it is claimed, and that this browser keeps the only copy.
Under the main button: the invest block (Wefunder) and "Check your code".
2. **Checkout:** name, term (2 years, or the code's) and total; Pay with Card / Bitcoin / Monero; and "I have a code", which opens the code entry. When a code covers the name, the total is $0 and the button reads "Register with code".
3. **Payment:** the badge payment screens, their "Buy a new code" button reading "Get a new invoice", and the card screen with the name's rows; skipped when a code pays.
4. **Wallet and registration:**
- At checkout the browser generates the phrase, derives the address and stores the phrase locally.
- It sends the address with the order.
- After payment the page shows the three steps, ticked as they complete, with transaction links. A reload resumes them.
5. **Registered:** "Registered for 2 years, until 9 October 2028, to 0x5aE1…7cD3" (a code's own years when paid by code).
- The 12-word phrase, Copy, and a QR for the app to scan.
- "Import it in the app".
- Warnings: the phrase owns the name, this browser keeps the only copy, write it down.
**Outcomes off the path:**
- **Taken between search and registration:** the order keeps its value. The page offers two ways to use it:
- search another name to register with the same order, at most what was paid; a cheaper name refunds nothing;
- keep it as a code for any name of that length; that is worth less than what was paid if the name carried a per-name price above the length price.
The page says both.
- **Registration failed after paying:** "Paid, not registered". Retrying charges nothing. If it fails for good, refunds are handled manually by support. There is no refund path in the service.
- **Payment endings:** the badge screens (partial, expired, cancelled, card failures).
**The secondary flow:** choose the shortest length (6, 7, 8+) with its price, then checkout and payment as above. The result is an unbound `SN<len>-2Y` code for the app (#7530's redemption).
- **Taken between search and registration** (only for names registered outside the store, given one order per name): the order keeps its value.
- Paid: the buyer can 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 original name's length (8+ for longer names) for 2 years, issued by the service.
- By code: the code is released, unspent, for another name.
- **Registration failed after paying:** the service's own retries gave up: "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.
## Stages
Each stage is its own set of atomic commits. Stage 0 is reviewed before any code.
Each stage is its own set of atomic commits; stage 0 is reviewed before any code.
### 0. Design: board, spec and mockups (review gate)
Redo `plans/names-codes/`: the board `names-flow.svg`, `screens/*.jpg`, the spec `2026-10-09-name-codes.md` and the generator `mockups/`, for the store above. The board's sections:
Redo `plans/names-codes/` for the store above: `names-flow.svg`, `screens/*.jpg`, the spec `2026-10-09-name-codes.md`, and the generator inputs in `mockups/`. The board's sections, as `mockups/layout.mjs` lists them:
- the names store start to finish;
- what a search can find, including a name another order holds;
- registration steps and their outcomes;
- the secondary code flow;
- payment endings (shared);
- where it meets the app (claim; grey, #7530);
- the badges store at badges.simplex.chat, its landing and cross-link only;
- phone width;
- simplex.domains: registering a name start to finish, and what a search can find;
- 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 (import the phrase; grey, #7475/#7530);
- refused at checkout;
- the other ways a payment ends (shared), with the names store's phrase pages;
- your names, the badges store, and the dark theme;
- simplex.domains on a phone;
- the operator.
The generator stays as built, apart from more room for arrow lanes above each row. `screens.js`, `mockups.css` and `layout.mjs` change.
### 1. Rename to StoreService (pure refactor)
As #7530 §5, with StoreService for ShopService:
- A JSON test first pins every command, response and error tag.
- `Simplex.Chat.Badges.Service` → `Simplex.Chat.StoreService`, with constructor prefixes `BSC`/`BSP`/`BSE` → `STC`/`STP`/`STE` (`dropPrefix` updated in the same commit). #7530 proposes `SHC`/`SHP`/`SHE` for ShopService; nothing in `src`, `apps`, `tests` or `bots` starts with `STC`/`STP`/`STE`. `ChatConfig.badgeServiceAddress` → `storeServiceAddress`.
- `apps/simplex-badge-service` → `apps/simplex-store-service`, `BadgeService.*` → `StoreService.*`, executable `simplex-store-service`, scripts, tests and hspec labels.
- A JSON test first pins every command and response tag, as #7530 proposes, and also the hand-written `BSE` error tags.
- `Simplex.Chat.Badges.Service` → `Simplex.Chat.StoreService`, with constructor prefixes `BSC`/`BSP` → `STC`/`STP` (`dropPrefix` updated in the same commit), and `BSE` → `STE` in `Simplex.Chat.Badges.Types`, whose wire tags are written by hand and stay as they are.
- #7530 proposes `SHC`/`SHP`/`SHE`.
- `bots/src/API/Docs/Types.hs` builds the error docs from `consSep "BSE"` and changes with it; its `STEnum*` constructors start with `STE` but are unrelated.
- `ChatConfig.badgeServiceAddress` → `storeServiceAddress`.
- `apps/simplex-badge-service` → `apps/simplex-store-service`, `BadgeService.*` → `StoreService.*`, executable `simplex-store-service`, `scripts/store-service/`, tests and hspec labels.
- Persisted names stay: the `sx_badge_service_` prefix, the migrations table and the database. This is #7530's recommendation (D2), still open there.
- `plans/names-codes/mockups/board.mjs`, `mockups/layout.mjs` and spec §13 follow the new web path.
- Coordinate with #7530 so the rename lands once.
### 2. Core: name code kind, label rule, Registration encoding
### 2. Core: name codes, label rule, Registration encoding
- `src/Simplex/Chat/Badges/Code.hs` gains `NameCode` (`parseNameCode`, `randomNameCode`, `nameCodeParams`, `nameCodeHash`, `formatNameCode`, `nameCodeText`).
- The grammar is `SN` + minLength digit (6–8) + years + `Y` + 20 body characters, with a Luhn mod-32 check over the parameters and the payload.
- **`src/Simplex/Chat/Badges/Code.hs`** gains `NameCode` (`parseNameCode`, `randomNameCode`, `nameCodeHash`, `formatNameCode`, `nameCodeText`).
- The grammar is `SN`, an optional length (1–2 digits), then 20 Crockford base32 body characters, with 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.
- The same module reads `SC` claim codes, with an identical grammar.
- `src/Simplex/Chat/Names.hs`: `validNameLabel`, `nameTier`.
- The `Registration` ABI encoding and commitment, as one module both core and service use (#7530 D17). This is shared with #7530: build it once, where it lands first.
- Tests with vectors shared with `web/test`.
- **`src/Simplex/Chat/Names.hs`:** `validNameLabel`.
- **The `Registration` ABI encoding and commitment**, one module that core and service share (#7530 D17), built once where it lands first.
- **Tests:** vectors shared with `web/test`.
### 3. Service: search, prices, checkout
### 3. Service: search, codes, orders
- **`[names]`** in the ini: `resolver_url`, `tld` (`testing` until `.simplex` is deployed), and the internal price rule.
- **`[names]` in the ini:** `resolver_url`, and `tld` (`testing` until `.simplex` is deployed).
- **`GET /api/name/:label`:**
- validates and keccak-hashes the label, then asks the resolver;
- for a registered name, opens each `simplexContact`/`simplexChannel` link with the chat core (as `APIConnectPlan` does) for display name and picture, and adds a deep link;
- answers `available{price}`, `registered{entities}` or `reserved`;
- uses the read rate limit and never logs the label;
- caches link profiles briefly, so one search costs at most one fetch per link;
- fetches each link with a timeout (3 s) and under its own rate limit, since a name's records can point at any server;
- answers `pending` for a name held by another open order.
- **Prices:** `Catalog.namePrice :: [NamePrice] -> Text -> Either CatalogRefusal CurrencyAmount`, over the rows `readCatalogRows` reads, as `priceOffer` takes badge prices. It starts from a per-length table seeded insert-only (`name_prices`), with per-name overrides possible. The page shows what the service answers. Checkout reprices and refuses a changed price with `catalog_changed`.
- **Checkout:**
- `POST /api/invoice` with `kind: "name"` (label, the price shown, method, and the hash of a claim code made in the browser), or `kind: "nameCode"` (minLength, price, method, codeHash).
- A name invoice writes a `name_registrations` row (label, price, state `awaiting_payment`) and an unpaid claim code row bound to it.
- Settlement moves the registration to `paid`, which the registration worker picks up (stage 4).
- **Migration `20261010_names`** (SQLite and Postgres):
- `badge_codes` gains `code_kind`, `min_length` and `years`, and `badge_type`/`months` become nullable. SQLite needs a table rebuild with `PRAGMA defer_foreign_keys = ON` and an up/down test with referencing rows.
- `badge_code_invoices` gains `name_price_id`, and its `price_id` (`NOT NULL REFERENCES badge_prices` today) becomes nullable, with a CHECK that exactly one is set. That is a second SQLite table rebuild.
- New tables `name_prices` and `name_registrations`: invoice, label, code, secret, commitment, phase, tx hashes, block numbers, `expires_at`, claimed owner, timestamps.
- **`GET /api/invoice/:id`** adds `registration {phase, label, commitTx, registerTx, revealAfter, expiresAt, error}` for a name invoice. The long poll also wakes on phase changes, which extends what `Waiters` compares.
- **A `taken` order:** `POST /api/invoice/:id/name {label}` retargets it to another name at most its price, and `POST /api/invoice/:id/unbind` turns its claim code into a length code.
- **One order per name:** creating a name invoice refuses `name_pending` while another open invoice or unfinished registration holds the label. The hold ends when that invoice expires or the registration finishes.
- **The order view** carries `claimed`, so history can show a claimed name.
- **New wire errors:** `invalid_name`, `name_taken` and `name_pending`.
- for a registered name, opens each `simplexContact`/`simplexChannel` link with the chat core (as `APIConnectPlan` does, which returns the link's display name and picture without connecting), with a 3 s timeout, its own rate limit and a short cache, and adds a deep link;
- answers `available{price}`, `registered{entities}`, `reserved` or `pending`; a searched label is never logged, and is stored only when ordered.
- **`POST /api/code/check {code}`:** returns what a code covers (`minLength` or none, `years`) and whether it can still be used (`unused`, `reserved`, `used`, `revoked`, `expired`, `unpaid`), without spending it. It uses its own rate limit, since it confirms codes.
- **Prices:** `Catalog.namePrice :: [NamePrice] -> Text -> Either CatalogRefusal CurrencyAmount`, over a seeded insert-only `name_prices` table (`Catalog.defaultNamePrices`): per-length rules, overridable per name. Checkout reprices and refuses a changed price with `catalog_changed`, which now carries the new price.
- **Orders:**
- `POST /api/invoice {kind: "name", label, price, method, owner}` creates an invoice; settlement moves the registration to `queued`.
- `POST /api/name/register {label, owner, code}` registers with a code. It checks that the code covers the label, marks it reserved for this registration, and spends it when the registration succeeds; there is no invoice.
- `owner` is a checksummed address.
- One order per name: both refuse `name_pending` while another open order holds the label.
- **A `taken` order:**
- `POST /api/registration/:id/name {label}` retargets it to another name, at most what was paid;
- `POST /api/registration/:id/code` takes a code for the original name's length (8+ for longer names), for 2 years, instead. The service generates it and returns it once, and stores only its hash, as operator codes are stored.
- **`GET /api/registration/:id`**, the long-poll view a code registration uses, and the same object in `GET /api/invoice/:id` for a paid one: `{id, phase, label, owner, years, commitTx, registerTx, revealAfter, expiresAt, error}`. The id is a random 128-bit token, like order ids; whoever holds it can act on the registration.
- **Phases:** `unpaid` (invoice open) → `queued` (paid, or code reserved) → `committing` → `committed` → `registering` → `registered`; or `taken`, `failed`. From `taken` a paid order goes back to `queued` with its new label, or ends `code_issued`; a code order ends at `taken` with its code released. A code is reserved from `queued` until it is `registered` (spent) or `taken` (released); `failed` keeps it for Try again.
- **`POST /api/registration/:id/retry`:** from `failed`, back to `queued`, at no charge.
- **Migration `20261010_names`** (SQLite and Postgres), each table with the `sx_badge_service_` prefix:
- **`name_codes`:** `name_code_id`, `code_hash` (unique), `min_length` (null for an `SN-` code), `years`, `code_payment_status`, `spent_at`, `revoked_at`, `expires_at`, `created_at`. A code is single-use; it is reserved from `queued` until it is `registered` (spent) or `taken` (released); `failed` keeps it for Try again.
- **`name_prices`:** `price_id`, `min_length`, `label` (null for a length rule), `price`, `currency`, `status`, `created_at`.
- **`name_registrations`:** `registration_id`, `label`, `owner`, `secret`, `commitment`, `phase`, transaction hashes and blocks, `years`, `expires_at`, `invoice_id` (null when paid by code), `name_code_id` (null when paid), `error`, `created_at`, `updated_at`.
- **`name_invoices`:** `invoice_id`, `registration_id`, `price_id`, `provider_ref`.
- **The badge tables are untouched**, so there is no table rebuild.
- **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, since `provider_ref` is unique only within each table.
- **Wire errors:** new on HTTP and in `api.ts`'s `WIRE_ERROR_CODES`: `invalid_name`, `name_taken`, `name_pending`, `code_not_covering`, `code_reserved`, and 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`.
- **Operator:**
- `//issue name <6|7|8> [paid|unpaid|free]`, and group `/issue name` / `/bulk name`, for unbound length codes;
- `//revoke`, for either kind;
- `//reissue <reference>`: a new claim code for a held, unclaimed name whose code was lost (N7b), revoking the old one. It is CLI only, since a group posts its replies to every member.
- `//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 codes; without a length the code is `SN-…`, and without years it covers 2;
- `//revoke` takes either kind.
### 4. Service: registration on chain and claiming
### 4. Service: registration on chain
Shares #7530 §10's chain client: JSON-RPC to the resolver host's reth node, a dry run of every transaction, serial nonces, EIP-1559 fees, receipts with replacement, and 3 confirmations. `[chain]` holds the RPC URL, chain id, contract addresses, registrar key and confirmation depth.
Shares #7530 §10's chain client: JSON-RPC to the resolver host's reth node, a dry run of every transaction, serial nonces, EIP-1559 fees, receipts with replacement, and 3 confirmations. `[chain]` holds the RPC URL, chain id, the controller and resolver contract addresses, the service's key and the confirmation depth.
**Signing:**
- Recoverable secp256k1 is in #1843, merged into simplexmq's `names` branch, not `master`.
- EIP-1559 transaction signing (`signEip1559Tx`) is on the separate branch `ab/eth-tx`.
- Both must reach the simplexmq this service builds against.
**Prerequisite, before any of this stage is built:** confirm on the SNS contract source that a registrar can register a name to itself, set its records while it owns it, and then transfer the token with the registry node following it. No source in these repositories covers that path:
- the contract source is not here;
- the names v2 RFC has the user as `owner` from the start;
- its auto-reclaim remark suggests the registry node follows the token.
If the path does not hold, ownership falls back to the buyer giving an owner address.
`minCommitmentAge` is set per deployment (60 s in the docs), so the worker reads it from the controller rather than assuming it.
- **Registration worker**, one per paid registration, resumable after a restart:
1. `paid` → commit `makeCommitment(Registration{label, owner = registrar, duration = 2y, secret, …})` → `committing`
2. → `committed` once mined → `waiting` until `minCommitmentAge`
3. → `registering`: send the register call
4. → `registered` at receipt + 3 blocks, recording `expires_at`
- **Signing (the service's own transactions only):**
- recoverable secp256k1 (`signRecoverable`) and EIP-1559 transaction signing (`signEip1559Tx`) are in simplexmq #1887, on `names` with #1843, not `master`;
- `names` must reach the simplexmq the service builds against.
- **Worker**, one per registration, resumable after a restart:
1. `queued` (paid, or a code reserved) → commit `makeCommitment(Registration{label, owner = buyer's address, duration, secret (service-generated), resolver, …})` → `committing`. `duration` is 2 years for a paid name and the code's `years` for a code;
2. → `committed` once mined → wait `minCommitmentAge`, read from the controller;
3. → `registering`;
4. → `registered` at receipt + 3 blocks.
Each phase is published to the page's long poll.
- **Failure handling:**
- A dry run reverting with `NameNotAvailable` → `taken`. The order offers another name of at most its price, or conversion to an unbound length code.
- Other failures → `failed`, retried; still failing → "Paid, not registered", where a retry charges nothing.
- **Claiming:** a new service command `claimName {code, owner, nameLinks}`, at the next version.
- It checks that the code is paid, bound and unspent and that the name is held.
- It sets the resolver records the links ask for while the service still owns the name, then transfers the token and the registry node to `owner`, and marks the code spent.
- The core and app side are #7530's.
- **Funding:** the deployed `.testing` controller's `register` is payable in ETH, so the registrar key holds ETH. #7530's `registerWithCredit` replaces this when the `.simplex` contracts deploy. This is an open question in the spec.
- **Cost risk:** the buyer pays a fixed USD price; the service pays the contract's ETH fee plus gas when it registers. A gas spike is the service's loss.
- **Key custody risk:** the registrar key is a hot key in the ini, and it owns every unclaimed name, so whoever takes it can transfer them all.
- Keep the window short: claim reminders on the registered screen and in history.
- The spec asks whether held names should move to a separate holding address with a key kept offline.
Each phase is published to the long poll.
- **`taken`:** the register dry run reverted `NameNotAvailable`; nothing was sent, the payment or code is kept, and the outcomes above apply.
- **`failed`:** retried; still failing → "Paid, not registered".
- **Records:** the name is registered with no records. The owner sets them from the app after import, once the app can edit records (out of #7530's scope).
- **Funding and cost:** the deployed `.testing` controller's `register` is payable in ETH, so the service's key holds ETH and pays the fee and gas, while the buyer paid a fixed USD price. A gas spike is the service's loss. #7530 §9 sends `registerWithCredit`, funded by a USD allowance, and the names v2 RFC proposes credited registration for `.simplex` (with `setRegistrarCredits`, where #7530 has `setRegistrarAllowance`); either replaces ETH funding when deployed.
- **Key risk:** the service's key holds only funds, never names, so a stolen key loses ETH, not names.
### 5. Webapp (`web/`)
- **Two sites from one build:** `build.js` emits `dist/badges/` and `dist/names/`. Each is a complete site root (`index.html`, `sw.js`, `assets/<hash>/`), so each host has its own shell, service worker scope and precache, and Caddy needs no rewrites.
- **Two sites from one build:** `build.js` emits `dist/badges/` and `dist/names/`, each a complete site root (`index.html`, `sw.js`, `assets/<hash>/`).
- **Crypto** (`wallet.ts`):
1. 128-bit entropy from `crypto.getRandomValues`;
2. the BIP-39 phrase with its checksum;
3. PBKDF2-SHA512 seed (WebCrypto);
4. BIP-32 to `m/44'/60'/0'/0/0` (HMAC-SHA512 from WebCrypto, secp256k1 from `@noble/secp256k1`);
5. the EIP-55 address (keccak from `@noble/hashes`).
Tests pin the `abandon … about` vector against #7475's address for account 0.
- **New modules:**
- `names.ts` (pure): `validateLabel`, the search result types;
- `nameScreens.ts`: search, checkout, steps, registered, taken-meanwhile, secondary code flow;
- `namesMain.ts`: the names shell's wiring, reusing `flow.ts`, `order.ts`, `api.ts` and `store.ts`.
- `names.ts` (pure): `validateLabel` and the result types;
- `nameScreens.ts`: search with the glass, results, the code popup, checkout with "I have a code", the steps, the registered screen with the phrase, outcomes, history with "Check a code";
- `namesMain.ts`: wiring, including the flow opened from the app (`#/?name=&owner=`) and a code registration's page (`?registration=<id>`).
- **Extended modules:**
- `codes.ts` (name codes);
- `api.ts` (`checkName`, name and name-code invoices, registration in the invoice view, `invalid_name`);
- `domain.ts`/`store.ts` (`kind`, `label`, `minLength`, `years`);
- `order.ts` (registration phases select the steps, registered and taken screens);
- `main.ts` (the badges shell adds the "SimpleX names" cross-link).
- **Styles:** the name field, entity cards and step list, from `mockups.css`, into `styles.css`.
- **The open-in-app link:** `openInAppUrl(code, label)` → `simplex:/name#code=…&label=…`. This is a proposal for the app team to confirm.
- **Tests:** node:test in the existing style, plus the build-hash tripwire for both shells.
- `screens.ts` (exports the helpers `nameScreens.ts` builds on; the invest block in `invoiceFailure` becomes optional; `BUY_NEW_CODE` becomes a per-store label, "Get a new invoice" for names);
- `flow.ts` (`catalog_changed` with its new price);
- `api.ts` (`checkName`, `checkCode`, name orders, registration views, new errors);
- `domain.ts`/`store.ts` (names with phrase and address, and checked codes for history);
- `order.ts`;
- `main.ts` (the badges site links simplex.domains);
- `crowdfunding.ts` (the names amount, a placeholder).
- **Order errors on the page:** `name_pending` and `name_taken` go back to search showing N2f or N2a; code errors from the service show where the code was entered, the popup (K5) or the checkout entry (K7), and a typo is caught on the page (K6); `invalid_name` cannot come from a page that validates, and shows N3d.
- **Styles:** the search field with the glass, result and entity cards, the steps, the phrase grid and the popup, from `mockups.css`, into `styles.css`.
- **The phrase:** stored only in this browser. It is shown on the registered screen and in history, never sent anywhere; its QR is for the app to scan.
- **Tests:** node:test in the existing style, plus the build-hash tripwire for both sites.
### 6. Deployment (`scripts/badge-service/`, renamed `scripts/store-service/` in stage 1)
### 6. Deployment (`scripts/badge-service/`, renamed in stage 1)
Production is the split deployment in `scripts/badge-service/`:
- Caddy serves the folder the service exports at boot (`webapp_export_dir`, bind-mounted to `./web`) and proxies `/api` and `/webhooks` to the service.
- The service runs with `+client_postgres`, `network_mode: host`, and the ini mounted read-only.
- Caddy serves the folder the service exports at boot and proxies `/api` and `/webhooks`.
- The service runs with `+client_postgres` and host networking, with its ini mounted read-only.
Changes:
- **Dockerfile:** the web stage builds both sites; the runtime image carries `/srv/web/badges` and `/srv/web/names`.
- **Export:** `exportWebapp` copies both sites and injects the Stripe publishable key into both shells.
- **Caddy, per host:**
- **Build:** the Dockerfile's web stage builds both sites.
- **Export:** `exportWebapp` writes both, with the Stripe publishable key in each shell.
- **Caddy:**
- `badges.simplex.chat` → `web/badges`;
- `simplex.domains` → `web/names`;
- both proxy `/api/*` to the service; `/webhooks/*` stays on one host;
- a deployment note gives the per-host blocks and CSP (Stripe, `frame-ancestors`), since the Caddyfile is not in this repository.
- **All-in-one mode** (`serve_webapp = on`, development): the service picks the site by `Host` against `[listener] names_hosts` (default `simplex.domains`). Every other host gets badges.
- **Chain access:** with host networking, the resolver (`127.0.0.1:8000`) and reth (`127.0.0.1:8545`) are reached on loopback when they share the host, or over a private network otherwise. The registrar key lives in the mounted ini, which `Dockerfile.dockerignore` already keeps out of the image.
- **Persisted names stay** (database `badge_service`, the `sx_badge_service_` prefix). The compose service, image, binary and ini path move to the new name with the rename.
- both proxy `/api/*`; `/webhooks/*` stays on badges.simplex.chat, where BTCPay and Stripe already send it;
- `apps/simplex-badge-service/README.md` (renamed in stage 1) gains the host blocks, since the Caddyfile is not in this repository, and the CSP in `web/README.md` (Stripe, `frame-ancestors`) gains simplex.domains.
- **All-in-one mode** picks the site by `Host` against `[listener] names_hosts`.
- **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.
- **Unchanged:** the database `badge_service` and the `sx_badge_service_` prefix.
### 7. Mock (`web/mock/server.py`)
- `GET /api/name/<label>` with fixed available, taken (with entities and images) and reserved names, and per-name prices.
- Name and name-code invoices.
- `POST /control/registration/<id>/<phase>` to step a registration through committing, waiting, registering and registered, or into taken or failed. It also exposes the existing payment controls.
- Both sites: `/names/` and `/` (badges) by path, and by `Host` when it is `simplex.domains`, so either can be driven locally.
- `GET /api/name/<label>`: fixed available, taken (with entities and images), reserved and pending names, and per-name prices.
- `POST /api/code/check`: fixed codes covering 6+, 7+, 8+ and `SN-`, one used and one revoked.
- 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, by path (`/names/`) and by `Host`.
## Order of work
- Stages 1–3 and 5–7 do not depend on the chain.
- They are built and tested against the fake resolver, a fake JSON-RPC node and the mock.
- A paid name order waits in `paid` until stage 4 lands.
- Stage 4 waits for its prerequisite (the custodial path on the contract source) and for the two signing branches to reach the simplexmq the service builds against.
- Stages 1–3 and 5–7 do not depend on the chain. They are built and tested against the fake resolver, a fake JSON-RPC node and the mock; 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.
- Stage 4 waits for simplexmq `names` (#1887's signing) to reach the simplexmq the service builds against.
- **The app side:** importing a name's phrase beside an existing master (#7475/#7530) is needed before a web-bought name shows in an app that already has a wallet. Until then, it can be the master of a device with no wallet (the name shows after `bind account=0`), and it works in any BIP-44 wallet at account 0.
## Out of scope
- The app and core side of claiming and redemption (#7530).
- Funding the registrar wallet (operations).
- Renewals, transfers between users, in-app purchase.
- The app side: importing a phrase beside an existing master (to be added to #7475/#7530), codes in the app (#7530), record editing (later app work).
- Funding the service's key (operations).
- Renewals, transfers, in-app purchase.
- Refunds (manual, by support).
- Selling codes on the web.
## Verification
- **Haskell:** `cabal build exe:simplex-store-service` and the service and core test filters, on SQLite and Postgres. Covers:
- code vectors, `validNameLabel`, the migration up/down;
- name code vectors and `validNameLabel`;
- the migration up/down;
- `/api/name` against a fake resolver and fake link profiles;
- the registration worker against a fake JSON-RPC node (commit, wait, register, a revert to taken, a restart mid-phase);
- `claimName` (records, then transfer);
- badges unchanged, with wire tags pinned.
- **Web:** `npm run build` (both sites, hash committed) and `npm test`.
- **Docker:** `docker compose build` in `scripts/store-service/`, and a run against local Postgres that exports both sites.
- `/api/code/check`;
- orders by payment and by code, including one order per name;
- the worker against a fake JSON-RPC node (commit, wait, register, a revert to taken, a restart mid-phase);
- badges unchanged, wire tags pinned.
- **Web:** `npm run build` (both sites, hash committed) and `npm test`, including the wallet vectors.
- **Docker:** `docker compose build` and a run against local Postgres that exports both sites.
- **End to end on the mock with Playwright:**
1. search → available → Bitcoin payment → steps → registered
2. taken with entity cards, reserved, invalid
3. taken during registration → another name
4. the secondary code flow
5. card
6. both sites' cross-links
1. search (glass, Enter, button) → available → Bitcoin → steps → phrase
2. taken, reserved, pending, 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
5. taken during registration → another name, and → a code
6. failed → Try again
7. card
8. opened from the app
9. history with "Check a code"
10. both sites' cross-links
Light, dark and phone, compared against the stage-0 board.
Light, dark and phone, against the stage-0 board.
+6 -7
View File
@@ -16,7 +16,7 @@ const SCREENS_DIR = join(OUT, "screens");
const DIST = join(HERE, "../../../apps/simplex-badge-service/web/dist/assets");
// A made-up origin: module scripts do not load from file://, and intercepting requests needs no server.
const ORIGIN = "http://mockups.local";
// JPEG, not PNG: the page's background wash makes a PNG about four times larger, and the board embeds 49 of them.
// JPEG, not PNG: the page's background wash makes a PNG about four times larger, and the board embeds one per frame.
const JPEG_QUALITY = 85;
const CONTENT_TYPES = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".png": "image/png", ".svg": "image/svg+xml", ".woff2": "font/woff2" };
@@ -177,12 +177,12 @@ function drawFrame(f) {
out.push(`<rect x="${x + 0.5}" y="${y + 0.5}" width="${w - 1}" height="${DESKTOP_BAR}" rx="5" fill="#EEF0F3"/>`);
["#FF5F57", "#FEBC2E", "#28C840"].forEach((c, i) => out.push(`<circle cx="${x + 12 + i * 11}" cy="${y + 13}" r="3.6" fill="${c}"/>`));
out.push(`<rect x="${x + 52}" y="${y + 5}" width="${w - 64}" height="16" rx="8" fill="#ffffff"/>`);
if (f.url) out.push(text(x + 62, y + 16.5, fit(f.url, w - 84, 9.5), 9.5, `fill="#5F6368" font-family="ui-monospace, Menlo, Consolas, monospace"`));
out.push(text(x + 62, y + 16.5, fit(f.url, w - 84, 9.5), 9.5, `fill="#5F6368" font-family="ui-monospace, Menlo, Consolas, monospace"`));
} else if (f.kind === "phone" || f.kind === "app") {
const dashed = f.kind === "app" ? ` stroke-dasharray="6 5"` : "";
const stroke = f.kind === "app" ? COLORS.grey : "#1F2328";
out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="24" fill="#ffffff" stroke="${stroke}" stroke-width="2.5"${dashed}/>`);
const label = f.kind === "app" ? "SimpleX app" : fit(f.url ?? "", w - 24, 8.5);
const label = f.kind === "app" ? "SimpleX app" : fit(f.url, w - 24, 8.5);
out.push(text(x + w / 2, y + PHONE_PAD + 11, label, 8.5, `text-anchor="middle" fill="#5F6368" font-family="ui-monospace, Menlo, Consolas, monospace"`));
} else {
out.push(`<rect x="${x - 1}" y="${y - 1}" width="${w + 2}" height="${h + 2}" rx="10" fill="none" stroke="#D0D5DD"/>`);
@@ -251,7 +251,7 @@ function arrows(frames, byTag) {
const by = t.row.top + 24 + lane * LANE_STEP;
d = `M${sx},${sy} H${gx} V${by} H${cx} V${ty} H${t.box.x - 3}`;
}
const color = t.color ?? "grey";
const color = t.color;
const dash = t.dashed ? ` stroke-dasharray="9 6"` : "";
out.push(`<path d="${d}" fill="none" stroke="${COLORS[color]}" stroke-width="2"${dash} marker-end="url(#arrow-${color})"/>`);
}
@@ -260,8 +260,7 @@ function arrows(frames, byTag) {
function arrowLabels(frames) {
return frames.map((f) => {
if (f.label === undefined) return "";
const color = f.from === undefined ? "#8A8F98" : COLORS[f.color ?? "grey"];
const color = f.from === undefined ? "#8A8F98" : COLORS[f.color];
return text(f.box.x, f.box.y - 12, f.label, 13, `font-weight="700" fill="${color}"`);
}).join("\n");
}
@@ -274,7 +273,7 @@ function compose(frames) {
const total = notesTop + 40 + noteLines.length * 20 + 50;
const out = [];
out.push(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${total}" viewBox="0 0 ${width} ${total}" font-family="${FONT}">`);
out.push(`<defs>${Object.entries(COLORS).map(([name, c]) =>
out.push(`<defs>${["blue", "orange"].map((name) => [name, COLORS[name]]).map(([name, c]) =>
`<marker id="arrow-${name}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="${c}"/></marker>`).join("")}</defs>`);
out.push(`<rect width="${width}" height="${total}" fill="#ffffff"/>`);
out.push(`<rect width="${width}" height="120" fill="#1D2026"/>`);
+168 -132
View File
@@ -3,8 +3,8 @@
export const TITLE = "SimpleX names - the store at simplex.domains, every screen";
export const SUBTITLE =
"one tree per surface, left to right - blue is the normal path, orange a variant or a failure, grey a platform difference, " +
"and dashed is everything that crosses between the site and the app";
"one tree per surface, left to right - blue is the normal path, orange a variant or a failure, " +
"the dashed arrow hands off from the site to the app, and app screens are outlined in grey dashes";
export const CORNER = ["plans/names-codes/names-flow.svg", "generated by mockups/board.mjs"];
const HOST = "simplex.domains";
@@ -13,26 +13,34 @@ const ORDER = `${HOST}/?order=q7ZpL2dN9xWc4KfR8tYb1A`;
export const SECTIONS = [
{
title: "simplex.domains",
note: "the names store, separate from the badges store: search a name, pay, and the service registers it for you",
note: "the names store: search a name, pay, and the name is registered to a wallet made in your browser",
rows: [
{ label: "Registering a name, start to finish", kind: "desktop", cells: ["N1", "N1a", "N2", "N3", "N4", "N5", "N6", "N6a", "N6b", "N7"] },
{ label: "What a search can find", kind: "desktop", cells: [null, "N1b", "N1c", "N2a", "N2b", "N2c", "N2d", "N2e", "N2f"] },
{ label: "What a search can find", kind: "desktop", cells: [null, "N1b", "N1c", "N2a", "N2b", "N2c", "N2f", "N2d", "N2e"] },
],
},
{
title: "Paying with a code",
note: "the service's table says what a code covers, and a covered name costs $0",
rows: [
{ label: "Check your code, then register for $0", kind: "desktop", cells: [null, null, "K1", "K2", "K3", "K8"] },
{ label: "A code at checkout, and codes that do not work", kind: "desktop", cells: [null, null, "K5", "K6", "K4", "K7"] },
],
},
{
title: "When registering does not go through",
note: "the payment is never lost: it registers another name, becomes a code, or is retried at no charge",
rows: [{ label: "After the payment", kind: "desktop", cells: [null, null, null, null, null, null, "N9", null, "N8", "N8a"] }],
rows: [{ label: "After the payment", kind: "desktop", cells: [null, null, null, null, null, "N9", null, "N8", "N8a", "N8b"] }],
},
{
title: "Opened from the app",
note: "the app's link carries the name and its own owner address: no phrase, no \"I have a code\"",
rows: [{ label: "The app sends the buyer here to pay", kind: "desktop", cells: ["L1", "L2", "L3"] }],
},
{
title: "Where it meets the app",
note: "the service holds the name until the app claims it; drawn after canvas 10, built by #7530 and not here",
rows: [{ label: "Claiming moves the name to the app's wallet", kind: "app", cells: [null, null, null, null, null, null, null, null, null, "A1", "A2"] }],
},
{
title: "A code for any name",
note: "the secondary link: a code for a length, to register a name later in the app",
rows: [{ label: "Buying a code instead of a name", kind: "desktop", cells: [null, "C1", "C2", "C3"] }],
note: "importing the phrase makes the name an account of the app's wallet; drawn as the app might, to be added to #7475/#7530, not built here",
rows: [{ label: "Importing the recovery phrase", kind: "app", cells: [null, null, null, null, null, null, null, null, null, "A1", "A2"] }],
},
{
title: "Refused at checkout",
@@ -41,17 +49,17 @@ export const SECTIONS = [
},
{
title: "The other ways a payment ends",
note: "shared with the badges store: nothing about these screens depends on what is bought",
note: "the payment endings are the badges store's; the last row is the names store's own phrase pages, beside the shared unknown link",
rows: [
{ label: "Paying in Bitcoin or Monero", kind: "desktop", cells: [null, null, null, null, "N4a", "N4b", "N4c", "N4d"] },
{ label: "Paying by card", kind: "desktop", cells: [null, null, null, null, "N10", "N10a", "N10b", "N10c"] },
{ label: "A claim code this browser cannot show, and a link to nothing", kind: "desktop", cells: [null, null, null, null, null, null, null, "N7a", "N7b", "N11"] },
{ label: "A phrase this browser cannot show, and a link to nothing", kind: "desktop", cells: [null, null, null, null, null, null, "N3f", "N7a", "N7b", "N11"] },
],
},
{
title: "Your names, the badges store, and the dark theme",
note: "each store keeps its own history, on its own host; the badges store only gains a link here",
rows: [{ label: "History, the other store, then three screens dark", kind: "desktop", cells: ["N12", null, "B0", null, "D2", "D6a", "D7"] }],
rows: [{ label: "History, the other store, then three screens dark", kind: "desktop", cells: ["N13", "N12", "N12a", "B0", null, "D2", "D6a", "D7"] }],
},
{
title: "simplex.domains on a phone",
@@ -60,256 +68,284 @@ export const SECTIONS = [
},
{
title: "For the operator",
note: "codes for a length, issued by hand, as for badges",
note: "codes issued by hand, as for badges; they are not sold on the web",
rows: [{ label: "The terminal and the managed group", kind: "operator", cells: ["OP1", "OP2"] }],
},
];
export const FRAMES = {
N1: {
url: HOST, label: "simplex.domains, or a link from the app",
url: HOST, label: "simplex.domains",
title: "Search",
text: "The store's first screen. One field, with .simplex fixed after it; the length and characters are judged as typed. The secondary links sit where the badge pages keep their invest block, so the button stands at the same height.",
text: "The first screen. The field ends in .simplex and a magnifying glass: the glass, Enter and the Search button all search. The rules line stays hidden until a mistake. The invest block keeps the badge pages' place under the button, with Check your code.",
},
N1a: {
url: HOST, from: "N1", label: "types a name", color: "blue",
title: "Typed",
text: "Nothing is sent while typing. Search asks GET /api/name/<label>, which hashes the name for the resolver.",
text: "Nothing is sent while typing. Search asks GET /api/name/:label, which hashes the name for the resolver.",
},
N2: {
url: HOST, from: "N1a", label: "Search: available", color: "blue",
title: "Available, with its price",
text: "The service answers with the price of this name for 2 years, from its own price rule, which may differ by name. There is no price list and no term to choose: registration is 2 years, no more, no less.",
text: "The service's price for this name for 2 years; prices are internal and may differ by name. No price list, no term to choose.",
},
N3: {
url: `${HOST}/#/checkout`, from: "N2", label: "Register dynamis.simplex", color: "blue",
title: "Check your order, and pay",
text: "The badge checkout with the name's rows. POST /api/invoice carries kind name and the label; the service prices it again and refuses a changed price. The line under Pay says when the name is registered.",
text: "Here the browser makes the name's wallet: 128-bit entropy, a 12-word phrase, the address at m/44'/60'/0'/0/0, stored before the order is created. Only the address goes with the order. I have a code sits under Pay.",
},
N4: {
url: ORDER, from: "N3", label: "Pay with Bitcoin", color: "blue",
title: "Paying in crypto",
text: "Shared with badges, unchanged: the address, the amount, the QR and the held rate come from BTCPay.",
text: "Shared with badges.",
},
N5: {
url: ORDER, from: "N4", label: "the full amount arrives", color: "blue",
title: "Payment received",
text: "Shared: the network is confirming. Settlement hands the order to the registration worker.",
text: "Shared: settlement hands the order to the registration worker.",
},
N6: {
url: ORDER, from: "N5", label: "it confirms: registration starts", color: "blue",
title: "Committing",
text: "The app's steps (canvas 9), run by the service: it commits the registration, owner its own registrar address, and links the transaction.",
text: "The app's steps (canvas 9), run by the service from its own funded key, with the buyer's address as owner. The name belongs to that address from the start: nothing is held for anyone.",
},
N6a: {
url: ORDER, from: "N6", label: "the commit is mined", color: "blue",
title: "Waiting a minute",
text: "The contract makes a commitment wait before it can be used. The steps live in the service, so a reload or another device shows where it is.",
text: "The controller's minCommitmentAge. The steps live in the service, so a reload or another device shows where it is.",
},
N6b: {
url: ORDER, from: "N6a", label: "60 seconds later", color: "blue",
title: "Registering",
text: "The register transaction, dry-run first. It counts as registered at its receipt plus 3 blocks.",
text: "The register transaction, dry-run first; registered at its receipt plus 3 blocks.",
},
N7: {
url: ORDER, from: "N6b", label: "registered", color: "blue",
title: "Yours, with a claim code",
text: "The service holds the name for the buyer. The claim code (SC…, so the app knows to claim, not register), made in this browser, moves it to the app's wallet; Open in SimpleX carries it there. It points nowhere until claimed, and the 2 years have started.",
title: "Yours, with its recovery phrase",
text: "The 12 words own the name; the QR carries them to the app. The phrase never left this browser, so nobody can make another: the page says to write it down.",
},
N1b: {
url: HOST, from: "N1", label: "5 letters or fewer", color: "orange",
url: HOST, from: "N1a", label: "Search, with 5 letters", color: "orange",
title: "Too short",
text: "Judged on the page before any request.",
text: "Judged when searching, not while typing: the rules line appears only now, after the mistake. Editing the name and searching again is the way on.",
},
N1c: {
url: HOST, from: "N1", label: "a character outside a-z, 0-9, -", color: "orange",
url: HOST, from: "N1a", label: "Search, with a character outside a-z, 0-9, -", color: "orange",
title: "Not a name",
text: "Judged on the page: the rule the contract and the resolver share. Upper case is folded, not refused.",
text: "Judged on the page; upper case is folded as typed, not refused.",
},
N2a: {
url: HOST, from: "N1a", label: "taken, and linked", color: "orange",
title: "Taken: what uses it",
text: "The name's record lists an address and a channel. The service opens each link with its chat core for the display name and picture, and the page offers Open in SimpleX for each, as the app's search does (canvas 3b). Pictures are drawn as stand-ins here.",
text: "The record's address and channel, as canvas 3b. The display name and picture, read by the service's chat core (APIConnectPlan), and Open in SimpleX are this store's addition. Pictures are drawn as stand-ins.",
},
N2b: {
url: HOST, from: "N1a", label: "taken, linked to nothing", color: "orange",
title: "Taken, pointing nowhere",
text: "Registered without links, such as a name held for a buyer who has not claimed it yet.",
text: "Registered without links, such as a name bought here whose owner has not set records yet.",
},
N2c: {
url: HOST, from: "N1a", label: "reserved", color: "orange",
title: "Reserved",
text: "Internal, trademark and community reservations read alike, with a way to reach the SimpleX team, as the app does.",
},
N2d: {
url: HOST, from: "N1a", label: "503: the resolver is down", color: "orange",
title: "Not checked",
text: "The resolver did not answer. The buyer can search again; nothing goes forward on a guess.",
},
N2e: {
url: HOST, from: "N1a", label: "429", color: "orange",
title: "Too many searches",
text: "The read rate limit answered. Search stays off for Retry-After.",
text: "With a way to reach the SimpleX team, as the app does.",
},
N2f: {
url: HOST, from: "N1a", label: "held by another order", color: "orange",
title: "Being registered",
text: "Another buyer's order holds the name: an open invoice or a registration in progress. A second order is refused (name_pending), so two buyers never pay for one name.",
text: "Another order holds the name. A second order is refused (name_pending), so two buyers never pay for one name.",
},
N2d: {
url: HOST, from: "N1a", label: "503: the resolver is down", color: "orange",
title: "Not checked",
text: "Nothing goes forward on a guess.",
},
N2e: {
url: HOST, from: "N1a", label: "429", color: "orange",
title: "Too many searches",
text: "Search stays off for Retry-After.",
},
K1: {
url: HOST, from: "N2", label: "Check your code", color: "blue",
title: "Check your code",
text: "A popup over the search: POST /api/code/check says what the code covers and that it is unused, spending nothing. This is an investor code, SN- for any name and 7 years; the service's table, not the text, decides.",
},
K2: {
url: HOST, from: "K1", label: "Use this code", color: "blue",
title: "Covered: $0",
text: "While the code is applied, a name it covers shows $0 and the code's own term, 7 years here.",
},
K3: {
url: `${HOST}/#/checkout`, from: "K2", label: "Register with code", color: "blue",
title: "Checkout at $0",
text: "No payment step: POST /api/name/register reserves the code, and the steps start as at N6, on ?registration=<id>; the code is spent when the name registers.",
},
K8: {
url: `${HOST}/?registration=r4Hq9TzW2mXc7LpN5vBd8k`, from: "K3", label: "the steps finish, as N6-N7", color: "blue",
title: "Yours for the code's term",
text: "The registered screen with the code's years: 7 for this investor code. The phrase is handled as in N7.",
},
K5: {
url: HOST, from: "N2", label: "a used code", color: "orange",
title: "Already used",
text: "Each code registers one name.",
},
K6: {
url: HOST, from: "N2", label: "a mistyped code", color: "orange",
title: "Not valid",
text: "The check character catches a typo before any request.",
},
K4: {
url: `${HOST}/#/checkout`, from: "N3", label: "I have a code", color: "blue",
title: "A code at checkout",
text: "I have a code opens the entry in place; a covering code turns Pay into Register with code.",
},
K7: {
url: `${HOST}/#/checkout`, from: "N3", label: "a code for longer names", color: "orange",
title: "Does not cover this name",
text: "The code covers 8+ letters and the name has 7; the buyer can still pay.",
},
N9: {
url: ORDER, from: "N6", label: "the commit fails", color: "orange",
title: "Paid, not registered",
text: "Something on the chain failed and the name is still free. Try again charges nothing (canvas 10b); the service also retries by itself.",
text: "The name is still free. The service retried by itself and gave up; Try again (POST /api/registration/:id/retry) charges nothing (canvas 10b). Refunds are manual.",
},
N8: {
url: ORDER, from: "N6b", label: "someone registered it first", color: "orange",
url: ORDER, from: "N6b", label: "registered elsewhere first", color: "orange",
title: "Someone was faster",
text: "Taken between the search and the register call; the dry run caught it, so nothing was spent on it. The payment is kept for another name, or as a code for this length (canvas 10a).",
text: "Possible only for names registered outside the store. The commit was mined, but the register dry run caught it, so register was never sent. The payment is kept (canvas 10a). A code order ends here too, its code released unspent (not drawn).",
},
N8a: {
url: HOST, from: "N8", label: "Choose another name", color: "orange",
url: ORDER, from: "N8", label: "Choose another name", color: "orange",
title: "Another name, same payment",
text: "Search again with the order's value: any available name priced at or under what was paid registers with it.",
text: "Search on the order's own page: any name at or under what was paid, registered to the same wallet, so the phrase owns the new name. A cheaper one refunds nothing; a dearer one cannot be registered.",
},
N8b: {
url: ORDER, from: "N8", label: "Take a code", color: "orange",
title: "A code instead",
text: "The service issues a code for the original name's price tier (its length, 8+ for longer names), for 2 years. The browser keeps it under Your names; the service keeps only its hash.",
},
L1: {
url: `${HOST}/#/?name=dynamis&owner=0x2F0b…94aE`, label: "the app's link",
title: "Opened from the app",
text: "The app passes the name it searched and its own account's address, shown here and at checkout so a crafted link cannot pass unnoticed. The page makes no wallet.",
},
L2: {
url: `${HOST}/#/checkout`, from: "L1", label: "Register", color: "blue",
title: "Checkout, without I have a code",
text: "The app redeems codes itself, so this flow offers only card, Bitcoin and Monero.",
},
L3: {
url: ORDER, from: "L2", label: "paid, then the steps as N6", color: "blue",
title: "Back to SimpleX",
text: "Registered straight to the app's account: nothing to import.",
},
A1: {
from: "N7", label: "Open in SimpleX", color: "blue", dashed: true,
title: "Claiming in the app",
text: "Not built here (#7530): the app sends claimName with the code, its wallet address and where the name points. The service sets the records, then transfers the name.",
from: "N7", label: "scan the phrase with the app", color: "blue", dashed: true,
title: "Importing in the app",
text: "Not built here: #7475's wallet imports a phrase only as the device's single master, so adding a name's 12 words beside an existing master is new app work, to be added to #7475/#7530.",
},
A2: {
from: "A1", label: "Claim", color: "blue", dashed: true,
title: "The name is yours",
text: "As canvas 10: it points at the address or channel from now on.",
},
C1: {
url: `${HOST}/#/code`, from: "N1", label: "Buy a code for any name", color: "blue",
title: "A code for a length",
text: "The secondary link. A code covers any name of this length or longer for 2 years, chosen later in the app; prices are shown only here, one per length.",
},
C2: {
url: `${HOST}/#/code/checkout`, from: "C1", label: "Continue", color: "blue",
title: "Check the code order",
text: "The same checkout. POST /api/invoice carries kind nameCode, the length and the code's hash.",
},
C3: {
url: ORDER, from: "C2", label: "paid", color: "blue",
title: "The code",
text: "SN7-2Y-... says what it covers. The app registers a name with it (#7530), with the same steps.",
from: "A1", label: "Import", color: "blue",
title: "The name is in the app",
text: "After canvas 10's alert, without its \"points at\" line: a name bought here has no records yet.",
},
N3a: {
url: `${HOST}/#/checkout`, from: "N3", label: "503 provider_unavailable", color: "orange",
title: "A method is unavailable",
text: "Shared: the method is shown disabled and Pay relabels to the one now chosen.",
text: "Shared: shown disabled, Pay relabels.",
},
N3b: {
url: `${HOST}/#/checkout`, from: "N3", label: "catalog_changed", color: "orange",
title: "The price changed",
text: "The name's price changed while the buyer decided. Nothing was charged; Search again shows the new price.",
text: "Nothing was charged; Search again shows the new price.",
},
N3c: {
url: `${HOST}/#/checkout`, from: "N3", label: "429", color: "orange",
title: "Too many attempts",
text: "Shared: Pay is off for exactly Retry-After.",
text: "Shared: Pay is off for Retry-After.",
},
N3d: {
url: `${HOST}/#/checkout`, from: "N3", label: "network, 5xx, bad_request", color: "orange",
title: "That did not go through",
text: "The shared failure screen, without an invest block. Try again sends the same order again.",
text: "A prototype of the shared failure screen without its badge invest block; stage 5 makes that block optional.",
},
N3e: {
url: `${HOST}/#/checkout`, from: "N3", label: "a card payment awaits confirmation", color: "orange",
title: "An order is already waiting",
text: "The shared rule: a second order would be a second charge.",
},
N4a: {
url: ORDER, from: "N4", label: "less than the amount", color: "orange",
title: "Part of the amount",
text: "Shared: the page asks for the rest at the same address.",
},
N4b: {
url: ORDER, from: "N4", label: "Cancel this invoice", color: "orange",
title: "Cancelled",
text: "Shared: nothing was paid and the provider's invoice was invalidated.",
},
N4c: {
url: ORDER, from: "N4", label: "the window closes, nothing sent", color: "orange",
title: "Expired, nothing received",
text: "Shared: nothing was charged.",
},
N4d: {
url: ORDER, from: "N4a", label: "the window closes", color: "orange",
title: "Expired, part paid",
text: "Shared: the buyer quotes the reference.",
},
N4a: { url: ORDER, from: "N4", label: "less than the amount", color: "orange", title: "Part of the amount", text: "Shared." },
N4b: { url: ORDER, from: "N4", label: "Cancel this invoice", color: "orange", title: "Cancelled", text: "Shared; the new-invoice button reads Get a new invoice here, not Buy a new code." },
N4c: { url: ORDER, from: "N4", label: "the window closes, nothing sent", color: "orange", title: "Expired, nothing received", text: "Shared." },
N4d: { url: ORDER, from: "N4a", label: "the window closes", color: "orange", title: "Expired, part paid", text: "Shared: the buyer quotes the reference." },
N10: {
url: ORDER, from: "N3", label: "Pay with Card", color: "blue",
title: "Paying by card",
text: "The card lane with the name's rows over Stripe's Payment Element, drawn as a stand-in.",
},
N10a: {
url: ORDER, from: "N10", label: "Stripe.js does not load", color: "orange",
title: "The card form did not load",
text: "Shared: nothing was charged and the order is still open.",
},
N10b: {
url: ORDER, from: "N10", label: "the card is accepted", color: "blue",
title: "Card submitted",
text: "Shared: once the service says paid, registration starts at N6.",
},
N10c: {
url: ORDER, from: "N10b", label: "fifteen minutes, still open", color: "orange",
title: "Taking longer than expected",
text: "Shared: no new order is offered while a charge may be in flight.",
text: "The name's rows over Stripe's Payment Element, drawn as a stand-in.",
},
N10a: { url: ORDER, from: "N10", label: "Stripe.js does not load", color: "orange", title: "The card form did not load", text: "Shared, with Get a new invoice." },
N10b: { url: ORDER, from: "N10", label: "the card is accepted", color: "blue", title: "Card submitted", text: "Shared: once paid, registration starts at N6." },
N10c: { url: ORDER, from: "N10b", label: "fifteen minutes, still open", color: "orange", title: "Taking longer than expected", text: "Shared." },
N7a: {
url: ORDER, from: "N6b", label: "registered; this browser refuses the write", color: "orange",
title: "The claim code could not be saved",
text: "Storage is full or refused, so the code is shown once.",
url: ORDER, from: "N3f", label: "paid anyway, then registered", color: "orange",
title: "The phrase could not be saved",
text: "Storage was refused at checkout, which warned (\"This browser will not keep your recovery phrase\"); the phrase lived in memory and is shown once.",
},
N3f: {
url: `${HOST}/#/checkout`, from: "N3", label: "this browser refuses storage", color: "orange",
title: "The phrase will not be kept",
text: "Checked before the order is created. The buyer can still pay; the phrase then lives only in this page.",
},
N7b: {
url: ORDER, label: "a registered order opened elsewhere",
title: "Not on this device",
text: "The name is held, but its claim code exists only in the buying browser; support confirms the order by reference.",
},
N11: {
url: `${HOST}/?order=k2Vn8...`, label: "?order= names no invoice",
title: "The link does not work",
text: "Shared: an unknown or mistyped order.",
text: "The name is registered, but its phrase exists only in the buying browser. Nobody can make another, and the page says so.",
},
N11: { url: `${HOST}/?order=k2Vn8...`, label: "?order= names no invoice", title: "This link does not work", text: "Shared." },
N12: {
url: `${HOST}/#/names`, label: "the menu: Your names",
url: `${HOST}/#/names`, from: "N13", label: "Your names", color: "blue",
title: "Your names",
text: "Names registered, registering, and codes, from this browser's store. simplex.domains and badges.simplex.chat are different hosts, so each store keeps its own.",
text: "Seen a little after the dynamis order completed. Names with their phrases (shown on request), registrations in progress, and codes. Each store keeps its own history, on its own host.",
},
N13: {
url: HOST, label: "the menu",
title: "The menu",
text: "The shared menu, relabelled for names, with the badges store linked at its end.",
},
N12a: {
url: `${HOST}/#/names`, from: "N12", label: "Check a code", color: "blue",
title: "Check a code",
text: "Asks the service what a code covers and adds it to the list, where Use applies it to a search.",
},
B0: {
url: "badges.simplex.chat", label: "badges.simplex.chat",
url: "badges.simplex.chat", from: "N13", label: "SimpleX badges", color: "blue",
title: "The badges store",
text: "Unchanged but for one secondary link to simplex.domains; the names store links back from its search and its menu.",
text: "Unchanged but for one link to simplex.domains; the names store links back from its menu.",
},
D2: { screen: "N2", theme: "dark", url: HOST, label: "dark theme", title: "N2, dark", text: "Every new rule uses the existing tokens." },
D6a: { screen: "N6a", theme: "dark", url: ORDER, label: "dark theme", title: "N6a, dark", text: "The steps on the dark ground." },
D7: { screen: "N7", theme: "dark", url: ORDER, label: "dark theme", title: "N7, dark", text: "The QR keeps its white ground, so it still scans." },
M2: { screen: "N2", url: HOST, label: "a phone", title: "N2 at 390 px", text: "Field, answer and price in one column." },
M2: { screen: "N2", url: HOST, label: "a phone", title: "N2 at 390 px", text: "Field, glass, answer and price in one column." },
M3: { screen: "N3", url: `${HOST}/#/checkout`, from: "M2", label: "Register", color: "blue", title: "N3 at 390 px", text: "The methods stay side by side." },
M6a: { screen: "N6a", url: ORDER, from: "M3", label: "paid", color: "blue", title: "N6a at 390 px", text: "The steps at phone width." },
M7: { screen: "N7", url: ORDER, from: "M6a", label: "registered", color: "blue", title: "N7 at 390 px", text: "Code, buttons, QR, then the warnings." },
M7: { screen: "N7", url: ORDER, from: "M6a", label: "registered", color: "blue", title: "N7 at 390 px", text: "The phrase in two columns." },
M2a: { screen: "N2a", url: HOST, from: "M2", label: "another name: taken", color: "orange", title: "N2a at 390 px", text: "Links hide; pictures, names and Open stay." },
OP1: {
label: "the operator's terminal, --run-cli",
title: "Issuing by hand",
text: "//issue name <length> prints a code for a length, once. //revoke takes either kind.",
text: "//issue name [length] [years]: SN7- for a length, SN- for any name (investors, 7 years here), 2 years by default. //revoke takes either kind.",
},
OP2: {
label: "the managed group",
title: "Issuing in the group",
text: "/issue name and /bulk name for moderators and above. Every member reads what is posted there.",
text: "/issue name and /bulk name for moderators and above; every member reads what is posted.",
},
};
export const NOTES = [
"Design document, not screenshots of a build: every web screen is rendered from mockups/screens.js on the webapp's own compiled modules and styles.css, so the shared screens are the real ones and the name screens are prototypes for nameScreens.ts.",
"Two stores to the user: names at simplex.domains, badges at badges.simplex.chat, each linking the other as a secondary option; one service (StoreService) and one webapp codebase behind both.",
"Registration is 2 years, fixed. The price is the service's answer for the searched name; the price rule is internal and may change, or differ by name.",
"The service registers a paid name to its own registrar address and holds it; the claim code, made in the buyer's browser and stored by the service only as a hash, moves it to the app's wallet.",
"Two stores to the user: names at simplex.domains, badges at badges.simplex.chat; one service (StoreService) and one webapp codebase behind both.",
"Non-custodial: the browser makes each name's wallet (128-bit entropy, a 12-word BIP-39 phrase, account m/44'/60'/0'/0/0) with vendored @noble/secp256k1 and @noble/hashes, and sends only the address; the service pays for and sends commit and register with that owner.",
"A paid name registers for 2 years, a code for its own term; the price is the service's answer for the searched name. Codes (SN6-, SN-, ...) come from the operator, the app (not yet in #7530) or, for a taken order, the service; never sold here; the service's table is their truth.",
"Regenerate after npm run build in apps/simplex-badge-service/web with: PLAYWRIGHT=<playwright index.mjs> node plans/names-codes/mockups/board.mjs",
];
+86 -13
View File
@@ -1,8 +1,7 @@
/* The rules the names store adds to web/public/styles.css; they move there in stage 5. Below the
marked line are the board's own frames (the app, the operator), which never ship. */
marked line are board-only rules (frames, stand-ins, the screenshot wrapper), which never ship. */
/* the name field: the input and its fixed ".simplex" on one line, in the field card */
.name-field { margin-top: auto; padding-bottom: 14px; }
.name-box {
display: flex;
align-items: baseline;
@@ -27,8 +26,8 @@
.name-box.ok { border-color: var(--ok-line); }
.name-box.bad { border-color: var(--danger-line); }
/* the line under the field while typing: what the page can judge without asking */
.name-status { margin: 12px 0 0; font-size: .875rem; font-weight: 600; color: var(--ink-soft); text-align: left; }
/* the answer line under a field: the search rules, or what a code entered at checkout covers */
.name-status { margin: 12px 0 0; font-size: .875rem; font-weight: 600; text-align: left; }
.name-status.bad { color: var(--danger-fg); }
/* the search's answer, a card under the field */
@@ -102,12 +101,84 @@
.step-tx { font-size: .75rem; color: var(--accent); text-decoration: none; }
.step-note { margin: 4px 0 6px; font-size: .875rem; color: var(--danger-fg); }
/* the secondary links under a store's main button, as simplex.domains draws its own */
.store-links { margin: 0; font-size: .9375rem; font-weight: 500; }
.store-links a { color: var(--accent); text-decoration: none; white-space: nowrap; }
.store-links a:hover { text-decoration: underline; }
.store-links .dot { margin: 0 10px; color: var(--ink-soft); }
.panel > .store-links { margin-top: 18px; }
/* the magnifying glass at the end of the search field, which searches as Enter does */
.name-box .glass {
display: flex;
align-items: center;
justify-content: center;
flex: none;
align-self: center;
width: 36px;
height: 36px;
margin: -6px -6px -6px 6px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
}
.name-box .glass svg { width: 18px; height: 18px; }
.name-box input[type="search"]::-webkit-search-cancel-button { display: none; }
.name-status.ok { color: var(--ok-fg); }
.result-price .was { margin-right: 6px; font-size: 1rem; font-weight: 500; color: var(--ink-soft); }
/* a link line of its own, as the badges landing's link to simplex.domains; inside the invest block its rules win */
.check-code { margin: 10px 0 0; font-size: .9375rem; }
.check-code .link { font-size: inherit; font-weight: 600; }
/* entering a code at checkout */
.code-entry { margin: 20px 0 0; text-align: left; }
.code-entry .name-status { margin-top: 8px; }
/* the popup a code is checked in, over the screen it was opened from */
.popup-overlay {
position: fixed;
inset: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(13, 27, 58, .35);
}
.popup {
width: 100%;
max-width: 460px;
padding: 18px 22px 22px;
border: 1px solid var(--line);
border-radius: var(--r-card);
background: var(--menu);
box-shadow: var(--shadow-pop);
text-align: left;
}
.popup-head { display: flex; align-items: center; justify-content: space-between; }
.popup-title { font-family: var(--display); font-size: 1.375rem; font-weight: 700; color: var(--ink); }
.popup-close { padding: 4px; border: 0; background: none; color: var(--ink-soft); font-size: 1.125rem; cursor: pointer; }
.popup-lede { margin: 6px 0 14px; }
.popup .name-box input { font-size: 1.0625rem; }
.popup .primary { margin-top: 16px; }
/* the recovery phrase: twelve numbered words in three columns, to be written down in order */
.phrase {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 12px;
margin: 10px 0 0;
padding: 16px 20px;
border: 2px solid var(--code-line);
border-radius: var(--r-card);
background: var(--code-bg);
list-style: none;
counter-reset: word;
text-align: left;
}
.phrase li { counter-increment: word; font-size: 1.0625rem; font-weight: 600; color: var(--ink); }
.phrase li::before { content: counter(word) "."; display: inline-block; width: 30px; margin-right: 4px; text-align: right; color: var(--ink-soft); font-weight: 500; font-variant-numeric: tabular-nums; }
.phrase + .primary { margin-top: 14px; }
/* the owner under the steps: the address the name is registered to */
.step-owner { display: flex; justify-content: space-between; padding: 10px 0 4px; border-top: 1px solid var(--line); font-size: .875rem; color: var(--ink-soft); }
/* a name's mark in the history list, where a badge row has its badge art */
.name-mark {
@@ -129,11 +200,15 @@
@media (max-width: 560px) {
.name-box input, .name-box .tld { font-size: 1.125rem; }
.entity-link { display: none; }
.store-links .dot { margin: 0 6px; }
.phrase { grid-template-columns: repeat(2, 1fr); }
}
/* ---------------------------------------------- the board only, never shipped */
/* a full-page capture is taller than the viewport, so the overlay covers the document here; the page keeps it fixed */
body { position: relative; }
.popup-overlay { position: absolute; }
/* Stripe's Payment Element, drawn as a stand-in: on the real page it is Stripe's iframe */
.stripe-standin { display: block; padding: 4px 0 0; }
.stripe-standin .sl { display: block; margin: 10px 0 4px; font-size: .875rem; color: var(--ink); }
@@ -160,10 +235,8 @@ body.plain { display: block; padding: 0; min-height: 0; background: #ffffff; bac
.app-card { border-radius: 10px; background: #ffffff; }
.app-row { display: flex; justify-content: space-between; padding: 12px 14px; }
.app-ok { color: #34c759; font-weight: 600; }
.app-x { color: #8e8e93; }
.app-note { margin: 8px 14px 0; color: #6d6d72; font-size: 12px; }
.app-button { margin-top: 28px; padding: 13px; border-radius: 12px; background: #007aff; color: #ffffff; font-weight: 600; text-align: center; }
.app-link { margin-top: 14px; color: #007aff; text-align: center; }
.app-alert { margin: 120px 24px 0; padding: 18px 16px 0; border-radius: 14px; background: #ffffff; text-align: center; }
.app-alert p { color: #3c3c43; font-size: 13px; }
.app-alert-button { margin: 14px -16px 0; padding: 12px; border-top: 1px solid #d1d1d6; color: #007aff; font-weight: 600; }
+272 -178
View File
@@ -1,7 +1,7 @@
// Every screen of the names board, built in the browser on the compiled webapp modules.
// Shared screens call the real `screens.js`; name screens are prototypes for stage 5's `nameScreens.ts`.
import * as s from "./assets/screens.js";
import { chevronLeft, methodMark } from "./assets/icons.js";
import { chevronLeft, methodMark, wefunderMark } from "./assets/icons.js";
import { qrSvg } from "./assets/qr.js";
const { el, button } = s;
@@ -9,7 +9,15 @@ const noop = () => {};
const NOW = Date.parse("2026-10-10T12:00:00Z");
const HOLD_MS = (59 * 60 + 59) * 1000;
const ORDER_ID = "q7ZpL2dN9xWc4KfR8tYb1A";
const UNTIL = "10 October 2028";
// a year on chain is 365 days, so 730 days from 10 October 2026 cross 29 February 2028 and end on the 9th
const UNTIL = "9 October 2028";
const CODE_UNTIL = "8 October 2033";
const OWNER_SHORT = "0x5aE1…7cD3";
const APP_OWNER_SHORT = "0x2F0b…94aE";
// a stand-in: the page makes a fresh phrase, with its BIP-39 checksum, for every order
const PHRASE = ["orbit", "velvet", "canyon", "ripple", "harbor", "tiny", "lemon", "frost", "danger", "museum", "shield", "olive"];
// placeholder amount, as crowdfunding.ts keeps the badge ones
const NAME_INVEST = "$500";
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const BASE = ALPHABET.length;
@@ -26,17 +34,18 @@ function checkValue(values) {
return (BASE - (sum % BASE)) % BASE;
}
// A name code: "SN" for a length code or "SC" for a claim code, the shortest name, the years and "Y",
// then 19 payload characters and a check character taken over the parameters and the payload.
function nameCode(minLength, seed, kind = "SN") {
const params = [...`${minLength}2Y`].map((c) => ALPHABET.indexOf(c));
// A name code: "SN", the shortest name it covers when it has one (a convention: the service's table
// decides), then 19 payload characters and a check character over the length digits and the payload.
function nameCode(minLength, seed) {
const len = minLength === undefined ? "" : String(minLength);
const params = [...len].map((c) => ALPHABET.indexOf(c));
let x = seed;
const payload = Array.from({ length: PAYLOAD }, () => {
x = (x * 48271) % 2147483647;
return x % BASE;
});
const body = [...payload, checkValue([...params, ...payload])].map((v) => ALPHABET[v]).join("");
return `${kind}${minLength}-2Y-${body.match(/.{5}/g).join("-")}`;
return `SN${len}-${body.match(/.{5}/g).join("-")}`;
}
// The service's answer for a name; the page shows only this price, never a list.
@@ -77,50 +86,68 @@ function rows(pairs, total) {
el("div", { class: "row total" }, el("span", {}, "Total"), el("span", {}, total)));
}
function qr(payload, caption) {
const wrap = el("div", { class: "qr-wrap" }, qrSvg(payload, "code"));
if (caption) wrap.append(el("p", { class: "muted" }, caption));
return wrap;
function qr(payload, label, caption) {
return el("div", { class: "qr-wrap" }, qrSvg(payload, label), el("p", { class: "muted" }, caption));
}
// The invest block's place under the main button, so the button stands where it does on the badge screens.
function link(text) {
return el("a", { class: "link", href: "#" }, text);
}
// The invest block's place under the main button, so the button stands where it does on the badge pages.
function slot(...kids) {
return el("div", { class: "invest" }, ...kids);
}
function links(...items) {
const p = el("p", { class: "store-links" });
items.forEach((item, i) => {
if (i > 0) p.append(el("span", { class: "dot", "aria-hidden": "true" }, "·"));
p.append(el("a", { href: "#" }, item));
});
return p;
// The names store's invest block: the badge store's sentence and Wefunder link with the names amount,
// and "Check your code" under it.
function namesInvest() {
return slot(
el("p", {},
el("span", { class: "half" }, `Invest ${NAME_INVEST}+ in SimpleX Chat`), " ",
el("span", { class: "half" }, "and get a free SimpleX name.")),
el("a", { class: "invest-link", href: "#", "aria-label": s.INVEST_LABEL }, "Learn more on ", wefunderMark()),
el("p", { class: "check-code" }, link("Check your code")));
}
const SECONDARY = () => links("Buy a code for any name of 6+ letters", "SimpleX badges");
// ---------------------------------------------------------------- search
function nameBox(label, tone) {
const SVG_NS = "http://www.w3.org/2000/svg";
function magnifier() {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
const circle = document.createElementNS(SVG_NS, "circle");
for (const [k, v] of Object.entries({ cx: "10.5", cy: "10.5", r: "6.5", fill: "none", stroke: "currentColor", "stroke-width": "2.4" })) circle.setAttribute(k, v);
const line = document.createElementNS(SVG_NS, "line");
for (const [k, v] of Object.entries({ x1: "15.5", y1: "15.5", x2: "21", y2: "21", stroke: "currentColor", "stroke-width": "2.4", "stroke-linecap": "round" })) line.setAttribute(k, v);
svg.append(circle, line);
return svg;
}
function searchBox(label, tone) {
const input = el("input", {
class: "mono", type: "text", value: label, placeholder: "yourname",
"aria-label": "Name", autocomplete: "off", spellcheck: "false",
class: "mono", type: "search", value: label, placeholder: "yourname",
"aria-label": "Name", autocomplete: "off", spellcheck: "false", enterkeyhint: "search",
});
return el("div", { class: "rows field name-field" }, el("span", { class: "label" }, "Name"),
el("div", { class: `name-box${tone ? ` ${tone}` : ""}` }, input, el("span", { class: "tld mono" }, ".simplex")));
return el("div", { class: "rows field" },
el("div", { class: `name-box${tone ? ` ${tone}` : ""}` },
input, el("span", { class: "tld mono" }, ".simplex"),
el("button", { class: "glass", type: "button", "aria-label": "Search" }, magnifier())));
}
function avatar(initials, hue) {
return el("span", { class: "avatar", style: `--hue:${hue}`, "aria-hidden": "true" }, initials);
}
function entity({ initials, hue, name, kind, link }) {
function entity({ initials, hue, name, kind, link: url }) {
return el("div", { class: "entity" },
avatar(initials, hue),
el("div", { class: "entity-main" },
el("div", { class: "entity-name" }, name),
el("div", { class: "entity-kind" }, kind),
el("div", { class: "entity-link mono" }, link)),
el("div", { class: "entity-link mono" }, url)),
el("a", { class: "secondary inline", href: "#" }, "Open in SimpleX"));
}
@@ -129,9 +156,13 @@ const BAKERY = [
{ initials: "BN", hue: 200, name: "Bakery news", kind: "channel", link: "https://smp9.simplex.im/c#Rk27xN4pDe9…" },
];
// state: empty | typed | available | short | chars | taken | unlinked | reserved | pending | unchecked | limited | credit
// an investor code: any name, for 7 years
const APPLIED = nameCode(undefined, 101);
const APPLIED_YEARS = 7;
// state: empty | typed | available | coded | short | chars | taken | unlinked | reserved | pending | unchecked | limited | credit | fromApp
function search(state, label = "") {
const tone = { available: "ok", credit: "ok", short: "bad", chars: "bad", taken: "bad", unlinked: "bad", reserved: "bad", pending: "bad" }[state];
const tone = { available: "ok", coded: "ok", credit: "ok", fromApp: "ok", short: "bad", chars: "bad", taken: "bad", unlinked: "bad", reserved: "bad", pending: "bad" }[state];
const p = panel(el("h1", {}, "Your SimpleX domain"),
el("p", { class: "lede" },
el("span", { class: "half" }, "One name for your public channel"), " ",
@@ -140,56 +171,59 @@ function search(state, label = "") {
p.append(notice("notice", "Your payment is kept: $200.00",
"Choose another name of up to $200. It is registered with what you paid."));
}
p.append(nameBox(label, tone));
const status = (cls, text) => el("p", { class: `name-status${cls ? ` ${cls}` : ""}`, role: "status" }, text);
if (state === "fromApp") {
p.append(notice("notice", "Opened from SimpleX", `The name is registered to your app's wallet, ${APP_OWNER_SHORT}.`));
}
p.append(searchBox(label, tone));
const rule = (text) => el("p", { class: "name-status bad", role: "status" }, text);
const result = (cls, ...kids) => el("div", { class: `result${cls ? ` ${cls}` : ""}` }, ...kids);
let action = button("Search", noop);
switch (state) {
case "empty":
p.append(status(null, "6 letters or more: a–z, 0–9, and hyphens inside the name."));
action.setAttribute("disabled", "");
break;
case "typed":
p.append(status(null, `${label.length} letters. Nothing is looked up until you search.`));
break;
case "available":
case "credit":
p.append(el("div", { class: "result ok" },
case "fromApp":
p.append(result("ok",
el("div", { class: "result-title" }, `✓ ${label}.simplex is available`),
el("div", { class: "result-price" }, dollars(priceOf(label)), el("span", {}, " for 2 years")),
...(state === "credit" ? [el("div", { class: "result-note" }, "Covered by your payment.")] : [])));
action = button(state === "credit" ? "Register with your payment" : `Register ${label}.simplex`, noop);
break;
case "coded":
p.append(result("ok",
el("div", { class: "result-title" }, `✓ ${label}.simplex is available`),
el("div", { class: "result-price" }, el("s", { class: "was" }, dollars(priceOf(label))), " $0", el("span", {}, ` for ${APPLIED_YEARS} years`)),
el("div", { class: "result-note" }, `Covered by your code ${APPLIED}. `, link("Remove"))));
action = button("Register with code", noop);
break;
case "short":
p.append(status("bad", `${label.length} letters. A name has 6 letters or more.`));
action.setAttribute("disabled", "");
p.append(rule(`${label.length} letter${label.length === 1 ? "" : "s"}. A name has 6 letters or more: a–z, 0–9, and hyphens inside it.`));
break;
case "chars":
p.append(status("bad", "Use only a–z, 0–9, and single hyphens inside the name."));
action.setAttribute("disabled", "");
p.append(rule("Use only a–z, 0–9, and hyphens inside the name, not two at places 3 and 4."));
break;
case "taken":
p.append(el("div", { class: "result" },
el("div", { class: "result-title bad" }, `${label}.simplex is taken`),
el("span", { class: "label" }, "Used by"),
...BAKERY.map(entity)));
p.append(result("", el("div", { class: "result-title bad" }, `${label}.simplex is taken`),
el("span", { class: "label" }, "Used by"), ...BAKERY.map(entity)));
action.setAttribute("disabled", "");
break;
case "unlinked":
p.append(el("div", { class: "result" },
el("div", { class: "result-title bad" }, `${label}.simplex is taken`),
p.append(result("", el("div", { class: "result-title bad" }, `${label}.simplex is taken`),
el("div", { class: "result-note" }, "It is registered, and does not point at any address or channel yet.")));
action.setAttribute("disabled", "");
break;
case "reserved":
p.append(el("div", { class: "result" },
el("div", { class: "result-title bad" }, `${label}.simplex is reserved`),
p.append(result("", el("div", { class: "result-title bad" }, `${label}.simplex is reserved`),
el("div", { class: "result-note" }, "Reserved names are not sold here. If it is yours to use, contact the SimpleX team."),
el("a", { class: "secondary inline", href: "#" }, "Contact the SimpleX team")));
action.setAttribute("disabled", "");
break;
case "pending":
p.append(el("div", { class: "result" },
el("div", { class: "result-title bad" }, `${label}.simplex is being registered`),
p.append(result("", el("div", { class: "result-title bad" }, `${label}.simplex is being registered`),
el("div", { class: "result-note" }, "Another order holds it. If that order lapses, it can be searched again.")));
action.setAttribute("disabled", "");
break;
@@ -201,10 +235,37 @@ function search(state, label = "") {
action.setAttribute("disabled", "");
break;
}
p.append(action, slot(SECONDARY()));
p.append(action, state === "fromApp" || state === "credit" ? slot() : namesInvest());
return p;
}
// ---------------------------------------------------------------- the code popup
// state: valid | used | invalid | adding
function codePopup(state) {
const code = { valid: APPLIED, used: nameCode(6, 202), invalid: "SN7-4K2P7-TQ9M1-ZX3RB-8HJ5X", adding: nameCode(6, 505) }[state];
const ok = state === "valid" || state === "adding";
const title = state === "adding" ? "Check a code" : "Check your code";
const box = el("div", { class: "popup", role: "dialog", "aria-label": title },
el("div", { class: "popup-head" }, el("span", { class: "popup-title" }, title),
el("button", { class: "popup-close", type: "button", "aria-label": "Close" }, "✕")),
el("p", { class: "muted popup-lede" }, "A code from SimpleX, for example for investors. Nothing is spent until a name is registered with it."),
el("div", { class: `name-box${ok ? " ok" : " bad"}` },
el("input", { class: "mono", type: "text", value: code, "aria-label": "Code", spellcheck: "false" })));
const result = (good, title, note) => el("div", { class: `result${good ? " ok" : ""}` },
el("div", { class: `result-title${good ? "" : " bad"}` }, title), el("div", { class: "result-note" }, note));
const answer = {
valid: [result(true, "✓ Unused", `Covers any name, for ${APPLIED_YEARS} years.`), "Use this code"],
adding: [result(true, "✓ Unused", "Covers a name of 6 or more letters, for 2 years."), "Add to your codes"],
used: [result(false, "This code was already used", "Each code registers one name."), "Use this code"],
invalid: [result(false, "This code is not valid", "Check it for a mistyped character."), "Use this code"],
}[state];
const action = button(answer[1], noop);
if (!ok) action.setAttribute("disabled", "");
box.append(answer[0], action);
return box;
}
// ---------------------------------------------------------------- checkout
const METHOD_NAMES = { card: "Card", btc: "Bitcoin", xmr: "Monero" };
@@ -223,35 +284,51 @@ function methods(selected, unavailable) {
return choices;
}
function checkout({ what, total, selected, unavailable, openOrder, note }) {
const WALLET_NOTE = "The name is registered to a wallet made in this browser; you get its recovery phrase.";
// opts.code: none | entry | covering | short
function checkout(label, selected, opts = {}) {
const codeState = opts.code ?? "none";
// a code entered here that covers the name prices it at $0, as one applied from the popup does
const covered = codeState === "covering" || codeState === "entry";
const total = covered ? 0 : priceOf(label);
const p = panel(back(), el("h1", {}, "Check your order"));
if (openOrder) {
p.append(el("p", { class: "row-line" }, el("a", { class: "link", href: "#" }, "You have an order waiting to be confirmed")));
if (opts.openOrder) {
p.append(el("p", { class: "row-line" }, link("You have an order waiting to be confirmed")));
}
p.append(rows([...what, ["Term", "2 years from registration"]], dollars(total, true)));
if (openOrder) {
const term = covered ? `${APPLIED_YEARS} years from registration` : "2 years from registration";
p.append(rows([["Name", `${label}.simplex`], ["Term", term], ...(covered ? [["Code", APPLIED]] : [])], dollars(total, true)));
if (opts.openOrder) {
p.append(notice("notice", s.AWAITING_CARD_TITLE,
"A second order would be a second charge, so this one cannot be started yet.",
"Open the order above. When its invoice expires, a new one can be started there."));
return p;
}
if (unavailable) {
p.append(notice("warn", `${METHOD_NAMES[unavailable]} is temporarily unavailable`, "Try another method, or come back later."));
if (opts.noStorage) {
p.append(notice("warn", "This browser will not keep your recovery phrase", "It is shown once, when the name is registered. Have paper ready to write it down."));
}
p.append(el("span", { class: "label standalone" }, "Pay with"), methods(selected, unavailable),
el("div", { class: "notes slot" }, ...(selected === "card" ? [el("p", { class: "muted" }, "Card payments are processed by Stripe.")] : [])),
if (opts.unavailable) {
p.append(notice("warn", `${METHOD_NAMES[opts.unavailable]} is temporarily unavailable`, "Try another method, or come back later."));
}
if (codeState === "entry" || codeState === "short") {
const ok = codeState === "entry";
p.append(el("div", { class: "code-entry" },
el("span", { class: "label" }, "Your code"),
el("div", { class: `name-box${ok ? " ok" : " bad"}` }, el("input", { class: "mono", type: "text", value: ok ? APPLIED : nameCode(8, 404), "aria-label": "Code" })),
el("p", { class: `name-status${ok ? " ok" : " bad"}` }, ok ? `✓ Covers any name, for ${APPLIED_YEARS} years.` : `This code covers names of 8 or more letters; ${label} has ${label.length}.`)));
}
if (covered) {
p.append(button("Register with code", noop), slot(el("p", {}, WALLET_NOTE)));
return p;
}
p.append(el("span", { class: "label standalone" }, "Pay with"), methods(selected, opts.unavailable),
el("div", { class: "notes slot" }),
button(`Pay ${dollars(total, true)} with ${METHOD_NAMES[selected]}`, noop),
slot(el("p", { class: "muted" }, note)));
slot(...(opts.fromApp || codeState === "short" ? [] : [el("p", { class: "check-code" }, link("I have a code"))]),
el("p", {}, opts.fromApp ? `The name is registered to your app's wallet, ${APP_OWNER_SHORT}.` : WALLET_NOTE)));
return p;
}
function nameCheckout(label, selected, opts = {}) {
return checkout({
what: [["Name", `${label}.simplex`]], total: priceOf(label), selected, ...opts,
note: "The name is registered for you as soon as the payment confirms.",
});
}
function priceChanged() {
return panel(el("h1", { class: "tight" }, "The price changed"),
notice("notice", "dynamis.simplex costs $250.00 now", "The price changed while you were deciding.", "Nothing was charged."),
@@ -274,8 +351,8 @@ const CRYPTO = {
cryptoAmount: "0.00312", cryptoCurrency: "btc", expiresAt: new Date(NOW + HOLD_MS).toISOString(),
};
function awaiting(invoice) {
return s.awaitingPayment({ order: ORDER, invoice, method: "btc", nowMs: NOW, now: () => NOW, resumed: false, onCancel: async () => {} }).node;
function awaiting(invoice, now = NOW) {
return s.awaitingPayment({ order: ORDER, invoice, method: "btc", nowMs: now, now: () => now, resumed: false, onCancel: async () => {} }).node;
}
function cardForm(label) {
@@ -295,27 +372,28 @@ function cardForm(label) {
// ---------------------------------------------------------------- registration
// Each step: pending | active | done | failed
function steps(label, states, failedNote) {
function steps(label, states, failedNote, owner = OWNER_SHORT) {
const NAMES = [["Committing", "Committed"], ["Waiting 60s", "Waited 60s"], ["Registering", "Registered"]];
const TX = ["0x8f3a…c21d", null, "0x2b71…9e04"];
const list = el("div", { class: "steps" }, el("span", { class: "label" }, `${label}.simplex`));
states.forEach((st, i) => {
const name = st === "done" ? NAMES[i][1] : NAMES[i][0];
const mark = { pending: "", active: "", done: "✓", failed: "✕" }[st];
const mark = { pending: "", done: "✓", failed: "✕" }[st];
const row = el("div", { class: `step-row ${st}` },
el("span", { class: "step-mark", "aria-hidden": "true" }, ...(st === "active" ? [el("span", { class: "pulse" })] : [mark])),
el("span", { class: "step-name" }, name));
// a failed step sent nothing: its dry run stopped it
// a failed step links no transaction: N8's dry run sent none, and N9's commit never landed
if (TX[i] && (st === "done" || (st === "active" && i === 0))) row.append(el("a", { class: "step-tx mono", href: "#" }, `${TX[i]} ↗`));
list.append(row);
});
list.append(el("div", { class: "step-owner" }, el("span", {}, "Owner"), el("span", { class: "mono" }, owner)));
if (failedNote) list.append(el("p", { class: "step-note" }, failedNote));
return list;
}
function registering(label, states) {
const active = states.indexOf("active");
const lede = ["Paid. Committing the name on the blockchain.", "Committed. The contract makes every registration wait a minute.", "Registering the name to hold it for you."][active];
const lede = ["Committing the name on the blockchain.", "Committed. The contract makes every registration wait a minute.", "Registering the name to your wallet."][active];
return panel(el("h1", { class: "tight" }, `Registering ${label}.simplex`),
el("p", { class: "lede" }, lede),
steps(label, states),
@@ -323,124 +401,115 @@ function registering(label, states) {
reference());
}
function registered(label, saved) {
const code = nameCode(Math.min(label.length, 8), 7, "SC");
function phraseGrid() {
return el("ol", { class: "phrase" }, ...PHRASE.map((w) => el("li", {}, el("span", { class: "mono" }, w))));
}
function registered(label, saved, years = 2) {
const p = panel(el("div", { class: "tick" }, "✓"),
el("h1", { class: "tight center" }, `${label}.simplex is yours`),
el("p", { class: "lede center" }, `Registered for 2 years, until ${UNTIL}.`),
el("div", { class: "code" }, code),
button("Open in SimpleX", noop),
button("Copy code", noop, "secondary"));
el("p", { class: "lede center" }, `Registered for ${years} years, until ${years === 2 ? UNTIL : CODE_UNTIL}, to ${OWNER_SHORT}.`),
el("span", { class: "label standalone" }, "Recovery phrase"),
phraseGrid(),
button("Copy phrase", noop, "primary outline"));
const details = el("div", { class: "details" },
el("div", { class: "rows plain" }, el("span", { class: "label" }, "Claim it in the app"),
el("div", {}, `Open the link above, or search ${label}.simplex in the app and choose Claim with a code.`)),
notice("notice", "It points nowhere yet.", "Claiming moves it to your app and points it at your address or channel. The 2 years have started."),
el("div", { class: "rows plain" }, el("span", { class: "label" }, "Import it in the app"),
el("div", {}, "In the SimpleX app, import a name and scan this code.")),
notice("notice", "This phrase owns the name.", "Whoever has it controls the name. Nobody else has a copy, not even us: write it down."),
saved
? notice("warn", "This is the only copy.", "Saved in this browser and nowhere else.", "Anyone using this browser can read it, and clearing the browser loses it.")
: notice("warn", "This code could not be saved in this browser.", "Copy it now. It is shown here and nowhere else."));
p.append(el("div", { class: "split" }, qr(code, "scan to carry it to your phone"), details));
? notice("warn", "This is the only copy.", "Saved in this browser and nowhere else.", "Clearing the browser loses it.")
: notice("warn", "This phrase could not be saved in this browser.", "Write it down now. It is shown here and nowhere else."));
p.append(el("div", { class: "split" }, qr(PHRASE.join(" "), "recovery phrase", "scan it with the app"), details));
return p;
}
function registeredToApp(label) {
return panel(el("div", { class: "tick" }, "✓"),
el("h1", { class: "tight center" }, `${label}.simplex is yours`),
el("p", { class: "lede center" }, `Registered for 2 years, until ${UNTIL}, to your app's wallet.`),
steps(label, ["done", "done", "done"], undefined, APP_OWNER_SHORT),
button("Back to SimpleX", noop),
reference());
}
function takenMeanwhile(label) {
return panel(el("h1", { class: "tight" }, "Someone registered it first"),
el("p", { class: "lede" }, `${label}.simplex was registered just before ours. Your payment is kept.`),
steps(label, ["done", "done", "failed"], "Registering stopped before anything was spent on it: the name was no longer free."),
steps(label, ["done", "done", "failed"], "The register transaction was never sent: its dry run found the name no longer free."),
button("Choose another name", noop),
button(`Take a code for any name of ${Math.min(label.length, 8)}+ letters`, noop, "secondary"),
button(`Take a code for a name of ${Math.min(label.length, 8)}+ letters`, noop, "secondary"),
reference());
}
function takenCode(label) {
const code = nameCode(Math.min(label.length, 8), 909);
const p = panel(el("div", { class: "tick" }, "✓"),
el("h1", { class: "tight center" }, "Here is a code instead"),
el("p", { class: "lede center" }, `For any name of ${Math.min(label.length, 8)} or more letters, for 2 years: what ${label}.simplex was paid for.`),
el("div", { class: "code" }, code),
button("Copy code", noop, "primary outline"));
p.append(el("div", { class: "split" }, qr(code, "name code", "scan it with the app"),
el("div", { class: "details" },
el("div", { class: "rows plain" }, el("span", { class: "label" }, "Use it"),
el("div", {}, "Search another name here and choose I have a code, or redeem it in the app.")),
notice("warn", "Keep a copy of this code.", "The service keeps only its hash; this browser keeps the code, under Your names."))));
return p;
}
function failedAfterPaying(label) {
return panel(el("h1", { class: "tight" }, "Paid, not registered"),
el("p", { class: "lede" }, `Your payment is kept and ${label}.simplex is still free. Trying again charges nothing.`),
steps(label, ["failed", "pending", "pending"], "The blockchain did not take the commit."),
steps(label, ["failed", "pending", "pending"], "The blockchain did not take the commit, and the service's own retries gave up."),
button("Try again", noop),
reference());
}
function paidElsewhere(label) {
return panel(el("h1", { class: "tight" }, "The claim code is not on this device"),
notice("notice", `${label}.simplex was registered for this order until ${UNTIL}.`,
"Its claim code was made in the browser it was bought in, and is not stored anywhere else.",
"Quote the reference below and we will sort it out."),
function notOnThisDevice(label) {
return panel(el("h1", { class: "tight" }, "The recovery phrase is not on this device"),
notice("notice", `${label}.simplex was registered for this order to ${OWNER_SHORT}.`,
"Its recovery phrase was made in the browser it was bought in, and exists nowhere else. Nobody, not even us, can make another."),
reference());
}
// ---------------------------------------------------------------- the secondary code
function codeLength() {
const choices = el("div", { class: "choices" });
[[8, "8+ letters"], [7, "7+ letters"], [6, "6+ letters"]].forEach(([n, name]) => {
choices.append(el("button", { class: "choice term", type: "button", "aria-pressed": String(n === 7) },
el("div", { class: "name" }, name), el("div", { class: "price" }, dollars(PRICES[n]))));
});
return panel(back(), el("h1", {}, "A code for any name"),
el("p", { class: "lede" },
el("span", { class: "half" }, "For a name you choose later, in the app."), " ",
el("span", { class: "half" }, "It covers names of this length or longer, for 2 years.")),
choices,
button("Continue", noop),
slot(links("Search a name instead")));
}
function codeCheckout() {
return checkout({
what: [["A code for", "names of 7+ letters"]], total: PRICES[7], selected: "xmr",
note: "Register any name of 7 or more letters with it in the app.",
});
}
function codeIssued() {
const code = nameCode(7, 13);
const p = panel(el("div", { class: "tick" }, "✓"),
el("h1", { class: "tight center" }, "Paid. Here is your code."),
el("div", { class: "code" }, code),
button("Open in SimpleX", noop),
button("Copy code", noop, "secondary"));
const details = el("div", { class: "details" },
el("div", { class: "rows plain" }, el("span", { class: "label" }, "Register a name with it"),
el("div", {}, "In the app, search a name of 7 or more letters and choose Use a code.")),
notice("warn", "This is the only copy.", "Saved in this browser and nowhere else.", "Anyone using this browser can read it, and clearing the browser loses it."));
p.append(el("div", { class: "split" }, qr(code, "scan to carry it to your phone"), details));
return p;
}
// ---------------------------------------------------------------- history, and the badges store
function entry({ title, sub, status, tone, method, price, when, code, open }) {
const meta = el("div", { class: "meta" },
el("span", { class: "method" }, methodMark(method), METHOD_NAMES[method]),
el("span", {}, price), el("span", {}, when));
function entry({ title, sub, status, tone, method, price, when, phrase, code, open, use }) {
const meta = el("div", { class: "meta" });
if (method) meta.append(el("span", { class: "method" }, methodMark(method), METHOD_NAMES[method]));
if (price) meta.append(el("span", {}, price));
meta.append(el("span", {}, when));
const main = el("div", { class: "entry-main" },
el("div", { class: "entry-row" }, el("div", { class: "name" }, title), el("span", { class: `status ${tone}` }, status)));
if (sub) main.append(el("div", { class: "entry-sub" }, sub));
const metaRow = el("div", { class: "entry-row" }, meta);
if (open) metaRow.append(el("a", { class: "secondary", href: "#" }, "Open"));
if (use) metaRow.append(el("a", { class: "secondary", href: "#" }, "Use"));
main.append(metaRow);
const item = el("li", { class: "entry" }, el("div", { class: "entry-head" }, el("span", { class: "name-mark", "aria-hidden": "true" }, "#"), main));
const item = el("li", { class: "entry" }, el("div", { class: "entry-head" }, el("span", { class: "name-mark", "aria-hidden": "true" }, code ? "≡" : "#"), main));
if (phrase) item.append(el("div", { class: "code-row" }, el("code", { class: "mono" }, "•••• •••• •••• (12 words)"), button("Show phrase", noop, "secondary inline")));
if (code) item.append(el("div", { class: "code-row" }, el("code", { class: "mono" }, code), button("Copy", noop, "secondary inline")));
return item;
}
function history() {
return panel(el("h1", {}, "Your names"),
el("p", { class: "lede" }, "Every name and code you bought is in this browser, and nowhere else."),
el("p", { class: "lede" }, "Every name and code here is in this browser, and nowhere else."),
el("ul", { class: "entries" },
entry({ title: "dynamis.simplex", sub: `Registered until ${UNTIL} · claim code`, status: "registered", tone: "settled",
method: "btc", price: "$200.00", when: "10 October 2026, 12:18", code: nameCode(7, 7, "SC") }),
entry({ title: "dynamis.simplex", sub: `Until ${UNTIL} · owner ${OWNER_SHORT}`, status: "registered", tone: "settled",
method: "btc", price: "$200.00", when: "10 October 2026, 12:00", phrase: true }),
entry({ title: "nebula.simplex", sub: "Committed, waiting for the minute to pass", status: "registering", tone: "pending",
method: "xmr", price: "$400.00", when: "10 October 2026, 11:52", open: true }),
entry({ title: "A code for any name", sub: "7+ letters · 2 years", status: "paid", tone: "settled",
method: "card", price: "$200.00", when: "3 October 2026, 09:40", code: nameCode(7, 13) }),
method: "xmr", price: "$400.00", when: "10 October 2026, 12:17", open: true }),
entry({ title: "A code", sub: "Covers any name, for 7 years", status: "unused", tone: "settled",
when: "added 9 October 2026", code: nameCode(undefined, 303), use: true }),
entry({ title: "orbital.simplex", sub: "Nothing was charged", status: "this invoice expired", tone: "lost",
method: "btc", price: "$200.00", when: "28 September 2026, 18:03", open: true })),
button("Check a code", noop, "secondary"),
el("p", { class: "forget-line" }, button(s.FORGET_EVERYTHING, noop, "link danger")));
}
function badgesLanding() {
const p = s.landing({ onStart: noop });
p.append(links("SimpleX names are at simplex.domains"));
p.append(el("p", { class: "check-code" }, link("SimpleX names are at simplex.domains")));
return p;
}
@@ -450,23 +519,21 @@ function app(...kids) {
return el("div", { class: "app-screen" }, ...kids);
}
function appClaim() {
return app(el("div", { class: "app-nav" }, "‹ Back"), el("div", { class: "app-title" }, "Claim a name"),
el("div", { class: "app-label" }, "THE NAME"),
el("div", { class: "app-card" }, el("div", { class: "app-row" }, el("b", {}, "dynamis.simplex"), el("span", { class: "app-ok" }, "held for you"))),
el("div", { class: "app-label" }, "POINTS AT"),
el("div", { class: "app-card" }, el("div", { class: "app-row" }, el("span", {}, "Alice — your address"), el("span", { class: "app-x" }, "✕"))),
el("div", { class: "app-label" }, "CODE"),
el("div", { class: "app-card" }, el("div", { class: "app-row" }, el("span", {}, "Your claim code"), el("span", {}, "until 10 Oct 2028"))),
el("p", { class: "app-note" }, "Bought on simplex.domains. Claiming moves the name to this device's wallet and points it at Alice."),
el("div", { class: "app-button" }, "Claim"));
function appImport() {
return app(el("div", { class: "app-nav" }, "‹ Back"), el("div", { class: "app-title" }, "Import a name"),
el("div", { class: "app-label" }, "RECOVERY PHRASE"),
el("div", { class: "app-card" }, el("div", { class: "app-row" }, el("span", {}, "12 words, scanned"), el("span", { class: "app-ok" }, "✓"))),
el("div", { class: "app-label" }, "FOUND"),
el("div", { class: "app-card" }, el("div", { class: "app-row" }, el("b", {}, "dynamis.simplex"), el("span", {}, "until 9 Oct 2028"))),
el("p", { class: "app-note" }, "The phrase becomes an account of this device's wallet."),
el("div", { class: "app-button" }, "Import"));
}
function appClaimed() {
return app(el("div", { class: "app-nav" }, "‹ Back"), el("div", { class: "app-title" }, "Claim a name"),
function appImported() {
return app(el("div", { class: "app-nav" }, "‹ Back"), el("div", { class: "app-title" }, "Import a name"),
el("div", { class: "app-alert" },
el("b", {}, "The name is yours"),
el("p", {}, "dynamis.simplex points at Alice until 10 Oct 2028."),
el("b", {}, "The name is in your app"),
el("p", {}, "dynamis.simplex is yours until 9 Oct 2028, in this device's wallet."),
el("div", { class: "app-alert-button" }, "OK")));
}
@@ -475,8 +542,10 @@ function terminal() {
return el("pre", { class: "term-screen" },
"> //issue name 7\n",
el("span", { class: "out" }, `Code: ${code}\n`),
"> //issue name years 7\n",
el("span", { class: "out" }, `Code: ${nameCode(undefined, 17)}\n`),
"> //issue name 5\n",
el("span", { class: "out" }, "Usage: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free]\n //issue name 6|7|8 [paid|unpaid|free]\n //revoke <code>\n"),
el("span", { class: "out" }, "Usage: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free]\n //issue name [<length 6-63>] [years <Y>] [paid|unpaid|free]\n //revoke <code>\n"),
"> //revoke ", code, "\n",
el("span", { class: "out" }, "Revoked.\n"));
}
@@ -487,14 +556,15 @@ function groupChat() {
el("div", { class: "chat-title" }, "#SimpleX Store"),
msg("alice · moderator", "in", "/issue name 6"),
msg("SimpleX Store", "out", `Code: ${nameCode(6, 23)}`),
msg("alice · moderator", "in", "/bulk name 8 count 3"),
msg("SimpleX Store", "out", nameCode(8, 31), nameCode(8, 37), nameCode(8, 41)),
msg("alice · moderator", "in", "/bulk name years 7 count 3"),
msg("SimpleX Store", "out", nameCode(undefined, 31), nameCode(undefined, 37), nameCode(undefined, 41)),
msg("bob · member", "in", "/issue name 6"),
el("div", { class: "chat-note" }, "a member below moderator gets no reply, as for badge codes"));
}
// ---------------------------------------------------------------- the table
// A screen is a panel, or [panel, popup] for a popup drawn over it.
const SCREENS = {
N1: () => search("empty"),
N1a: () => search("typed", "dynamis"),
@@ -507,26 +577,36 @@ const SCREENS = {
N2d: () => search("unchecked", "dynamis"),
N2e: () => search("limited", "dynamis"),
N2f: () => search("pending", "orbital"),
N3: () => nameCheckout("dynamis", "btc"),
N3a: () => nameCheckout("dynamis", "xmr", { unavailable: "btc" }),
K1: () => [search("available", "dynamis"), codePopup("valid")],
K2: () => search("coded", "dynamis"),
K3: () => checkout("dynamis", "btc", { code: "covering" }),
K4: () => checkout("dynamis", "btc", { code: "entry" }),
K5: () => [search("available", "dynamis"), codePopup("used")],
K6: () => [search("available", "dynamis"), codePopup("invalid")],
K7: () => checkout("dynamis", "btc", { code: "short" }),
K8: () => registered("dynamis", true, 7),
N3: () => checkout("dynamis", "btc"),
N3a: () => checkout("dynamis", "xmr", { unavailable: "btc" }),
N3b: () => priceChanged(),
N3c: () => s.rateLimited({ total: "$200.00", method: "btc", seconds: 41, onBack: noop }, noop).node,
N3d: () => invoiceFailure(),
N3e: () => nameCheckout("dynamis", "btc", { openOrder: true }),
N3e: () => checkout("dynamis", "btc", { openOrder: true }),
N3f: () => checkout("dynamis", "btc", { noStorage: true }),
N4: () => awaiting(CRYPTO),
N4a: () => awaiting({ ...CRYPTO, cryptoAmountPaid: "0.00150", cryptoAmountDue: "0.00164" }),
N4a: () => awaiting({ ...CRYPTO, cryptoAmountPaid: "0.00150", cryptoAmountDue: "0.00162" }, NOW + 14 * 60_000),
N4b: () => s.windowClosed({ order: ORDER, invoice: { status: "expired" }, canceled: true, onNewInvoice: noop }),
N4c: () => s.windowClosed({ order: ORDER, invoice: { status: "expired" }, onNewInvoice: noop }),
N4d: () => s.windowClosed({ order: ORDER, invoice: { status: "expired", amount: 20000, currency: "usd", amountPaid: 9600, cryptoAmountPaid: "0.00150", cryptoCurrency: "btc" }, onNewInvoice: noop }),
N4d: () => s.windowClosed({ order: ORDER, invoice: { status: "expired", amount: 20000, currency: "usd", amountPaid: 9615, cryptoAmountPaid: "0.00150", cryptoCurrency: "btc" }, onNewInvoice: noop }),
N5: () => s.awaitingConfirmation({ order: ORDER, invoice: { status: "open", cryptoAmountPaid: "0.00312", requiredConfirmations: 1 }, method: "btc", gaveUp: false, onCheckAgain: noop }),
N6: () => registering("dynamis", ["active", "pending", "pending"]),
N6a: () => registering("dynamis", ["done", "active", "pending"]),
N6b: () => registering("dynamis", ["done", "done", "active"]),
N7: () => registered("dynamis", true),
N7a: () => registered("dynamis", false),
N7b: () => paidElsewhere("dynamis"),
N7b: () => notOnThisDevice("dynamis"),
N8: () => takenMeanwhile("dynamis"),
N8a: () => search("credit", "dynamos"),
N8b: () => takenCode("dynamis"),
N9: () => failedAfterPaying("dynamis"),
N10: () => cardForm("dynamis"),
N10a: () => s.cardUnavailable({ order: ORDER, reason: "script", onRetry: noop, onNewInvoice: noop }),
@@ -534,23 +614,32 @@ const SCREENS = {
N10c: () => s.awaitingConfirmation({ order: ORDER, invoice: undefined, method: "card", gaveUp: true, onCheckAgain: noop }),
N11: () => s.unknownOrder(noop),
N12: () => history(),
C1: () => codeLength(),
C2: () => codeCheckout(),
C3: () => codeIssued(),
N13: () => search("empty"),
N12a: () => [history(), codePopup("adding")],
L1: () => search("fromApp", "dynamis"),
L2: () => checkout("dynamis", "btc", { fromApp: true }),
L3: () => registeredToApp("dynamis"),
B0: () => badgesLanding(),
A1: () => appClaim(),
A2: () => appClaimed(),
A1: () => appImport(),
A2: () => appImported(),
OP1: () => terminal(),
OP2: () => groupChat(),
};
const NAMES_MENU = ["Register a name", "Your names"];
const NAMES_NEW_INVOICE = "Get a new invoice";
// The shared payment screens say "Buy a new code"; stage 5 gives screens.ts a per-store label, drawn here.
function relabelForNames(screen) {
screen.querySelectorAll("button").forEach((b) => { if (b.textContent === s.BUY_NEW_CODE) b.textContent = NAMES_NEW_INVOICE; });
return screen;
}
// The names store's header: the shared chrome, its menu relabelled for names, and the badges store linked from it.
function namesChrome(theme) {
const chrome = s.chrome({ theme: theme ?? "system", onNewPurchase: noop, onHistory: noop, onTheme: noop, onToggle: noop, onHome: noop });
chrome.node.querySelectorAll(".menu-item").forEach((item, i) => { item.textContent = NAMES_MENU[i] ?? item.textContent; });
chrome.node.querySelector(".menu-section:last-child")?.append(el("a", { class: "menu-item", href: "#" }, "SimpleX badges ↗"));
chrome.node.querySelectorAll(".menu-item").forEach((item, i) => { item.textContent = NAMES_MENU[i]; });
chrome.node.querySelector(".menu-section:last-child").append(el("a", { class: "menu-item", href: "#" }, "SimpleX badges ↗"));
return chrome;
}
@@ -558,10 +647,15 @@ export function render(id, theme) {
const build = SCREENS[id];
if (build === undefined) throw new Error(`mockups: no screen ${id}`);
const html = document.documentElement;
if (theme === "dark") { html.setAttribute("data-theme", "dark"); html.classList.add("dark"); html.style.colorScheme = "dark"; }
if (/^(N|C)/.test(id)) {
document.getElementById("chrome").replaceChildren(namesChrome(theme).node);
document.getElementById("app").replaceChildren(build());
if (theme === "dark") html.setAttribute("data-theme", "dark");
if (/^(N|K|L)/.test(id)) {
const built = build();
const [screen, popup] = Array.isArray(built) ? built : [built, undefined];
const chrome = namesChrome(theme);
document.getElementById("chrome").replaceChildren(chrome.node);
if (id === "N13") chrome.node.querySelector(".menu-button").click();
document.getElementById("app").replaceChildren(relabelForNames(screen));
if (popup) document.body.append(el("div", { class: "popup-overlay" }, popup));
} else if (id === "B0") {
const chrome = s.chrome({ theme: theme ?? "system", onNewPurchase: noop, onHistory: noop, onTheme: noop, onToggle: noop, onHome: noop });
document.getElementById("chrome").replaceChildren(chrome.node);
File diff suppressed because it is too large. Load diff

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB