mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 15:07:32 +00:00
plans: names store API contract and tasks
This commit is contained in:
1 parent
367ca8ac6e
commit
0f75c5e225
2 files changed
+959
No files matched your search
@@ -0,0 +1,604 @@
|
||||
# SimpleX names store: the API contract
|
||||
|
||||
This is the HTTP API that StoreService serves to the names webapp at simplex.domains, under `/api` on the page's own origin. It is authoritative for both implementations:
|
||||
- the Haskell service (`apps/simplex-store-service`, plan [`2026-10-09-names-web-store-plan.md`](2026-10-09-names-web-store-plan.md) stages 3 and 4, tasks in [`2026-10-21-store-service-tasks.md`](2026-10-21-store-service-tasks.md));
|
||||
- the webapp's mock (`simplex.domains/webapp/dev/mock/service.ts` and `dev/mock.ts`), which drives the real contracts on a local chain and is what the webapp is tested against.
|
||||
|
||||
It starts from what the mock implements (simplex.domains `sh/names-webapp` 2bea31b). Where the mock and the plan differed, the choice made here follows the owner's decisions of 2026-10-15, 2026-10-20 and 2026-10-21, the owner's answers of 2026-10-21 to the design questions, and the manager's rulings on the first review round. Each difference, with what must change in the mock and the page, is in [Appendix A](#appendix-a-mock-and-page-vs-plan). Error tags are indexed in [Appendix B](#appendix-b-error-tags).
|
||||
|
||||
The words used (owner code, commitment, intent, sponsor, sealed, phase, long poll) are defined in the spec, [Words used here](2026-10-09-name-codes.md#words-used-here).
|
||||
|
||||
## Contents
|
||||
|
||||
1. Conventions
|
||||
2. Search
|
||||
3. Link preview
|
||||
4. Codes
|
||||
5. Orders
|
||||
6. Following an order
|
||||
7. The reveal
|
||||
8. Owner-signed requests
|
||||
9. Try again
|
||||
10. Editing links
|
||||
11. Not in the API
|
||||
|
||||
## 1. Conventions
|
||||
|
||||
### Transport
|
||||
|
||||
- JSON bodies with `content-type: application/json`. Every answer has `cache-control: no-store` and a `Date` header. The page sets its clock from `Date`, and signs timestamps and deadlines by it.
|
||||
- An error is `{"error": "<tag>", …}` with the status in Appendix B. Some tags carry more fields (`price`, `revert`).
|
||||
- A rate limit answers `429 {"error": "rate_limited"}` with `Retry-After` in seconds.
|
||||
- An unknown path is `404 not_found`; a known path with another method is `405 method_not_allowed` with `Allow`.
|
||||
- Requests without a body (GET, cancel, retry) take none. A POST with a body that is not a JSON object is `bad_request`.
|
||||
|
||||
### Who may see an order
|
||||
|
||||
An order is disclosed (its ids, label hash, owner, `sealed`, payment address or `clientSecret`) only to a request that proves it made the order:
|
||||
- holding the order's id: `GET /api/invoice/:id`, `GET /api/registration/:id`, and the requests on `/api/registration/:id/…`;
|
||||
- sending every field of the order again exactly, with the owner's signature (a resend, section 5);
|
||||
- an owner signature with a fresh timestamp: `find` (section 8).
|
||||
|
||||
Any other request that meets an existing order is refused with a tag that names nothing (`409 order_exists`). Commitments are public on chain once committed, so a commitment alone proves nothing.
|
||||
|
||||
### One request at a time per name and per code
|
||||
|
||||
The service serializes the requests that create or change an order:
|
||||
- `POST /api/invoice` (a name), `POST /api/name/register`, `/reveal`, `/name`, `/code` and `/retry` run one at a time per label hash (`/name` holds the old and the new label hash, taken in hash order);
|
||||
- every reservation, release and spending of a prepaid code runs one at a time per code, and reserving is one conditional update (`unused` and not reserved → reserved by this registration) in the transaction that creates, moves or requeues the registration;
|
||||
- every write to a registration is a compare-and-set on the `version` it read (section 6). A request that loses the race changes nothing and answers `409 {"error": "registration_changed", "registration": {…the current view}}`, on every endpoint that writes a registration (the reveal, `/name`, `/code`, `/retry`, cancel), so the page always follows the current state.
|
||||
|
||||
The database backs this with unique constraints: one live registration per commitment, one live registration per owner and label hash, one registration per reserved code. A registration is live while it has an open invoice, or is past `unpaid` and not `taken` or `code_issued`; `live` is a stored flag, and every transition that changes it does so in the same transaction.
|
||||
|
||||
**An overdue order is closed by the next order.** When a new order from the same wallet and name meets a live order whose open invoice has passed its expiry time (the poller may not have marked it yet), the new order closes it inside its own transaction, under the label hash's turn: the old invoice becomes `expired`, its registration loses `live`, and only then is the new order inserted. The provider's cancel of the old invoice is sent afterwards, best effort, as the poller would. Settlement queues a registration only while it is still live; a payment that arrives for a closed order is a late payment (it stays `unpaid`, the operator is alerted, and support refunds it), never a second live order.
|
||||
|
||||
### Formats
|
||||
|
||||
| Field | Format |
|
||||
|---|---|
|
||||
| `labelHash`, `commitment`, `secret`, `:hash` | `0x` and 64 lowercase hex. Anything else is `bad_request` |
|
||||
| `owner` in order bodies | an EIP-55 checksummed address. In `find`, any case |
|
||||
| `signature` | `0x` and 130 lowercase hex: 65 bytes `r ‖ s ‖ v`, `v` 27 or 28, `s` at most half the curve order (low s). Else `bad_request` |
|
||||
| `codeHash` | unpadded base64url SHA-256 of the canonical code: 43 characters |
|
||||
| `length` | the label's length in characters, an integer |
|
||||
| `duration` | seconds, an integer (section 1, Term) |
|
||||
| `price`, `amount`, `amountPaid` | US cents, an integer |
|
||||
| `cryptoAmount…` | a decimal string |
|
||||
| `revealAfter`, `revealBefore`, `expiresAt` of a registration or a name, `termStart`, `timestamp`, `deadline` | Unix seconds, integers |
|
||||
| `gracePeriod` | seconds, an integer |
|
||||
| `expiresAt` of an invoice, `createdAt`, `settledAt` | ISO 8601 UTC, `2026-10-21T09:15:00Z` |
|
||||
| `version` | a registration's change counter, an integer |
|
||||
|
||||
### Sizes
|
||||
|
||||
| Limit | Value | Refused with |
|
||||
|---|---|---|
|
||||
| A label | 6–63 characters: `a-z 0-9 -`, each hyphen between letters or digits | `invalid_name` (length), `bad_request` (characters, at the reveal) |
|
||||
| A link | one SimpleX short link: a contact address `https://<host>/a#…` or `simplex:/a#…`, a channel `https://<host>/c#…` or `simplex:/c#…`; at most 4096 bytes of UTF-8 | `invalid_link` |
|
||||
| Links per key | at most one `simplex.contact` and one `simplex.channel` | `invalid_link` |
|
||||
| `sealed` | at most 11095 characters | `bad_request` |
|
||||
| A request body | 16384 bytes on `/api/invoice`, `/api/name/register`, `/api/registration/:id/name` and `/reveal`; 8192 bytes elsewhere | `bad_request` |
|
||||
|
||||
The mock reads every body up to 16384 bytes. A body the page builds is never near either cap. The page and the service check links against the same vectors (the webapp's `src/names.ts` cases, copied into the service's tests).
|
||||
|
||||
### Term
|
||||
|
||||
- A name is paid for `years` years. The search answer gives `years` (2 at launch, the service's setting) and, for a prepaid code, the code check gives its own `years`.
|
||||
- `years` is at least 2, for the setting and for every prepaid code: the controller refuses a duration under 730 days (`MIN_REGISTRATION_DURATION`), and the free days end on 1 January 2027. The service refuses to start with `years` below 2, and the operator's commands refuse to issue a code for fewer than 2 years.
|
||||
- The term starts on `termStart`, 1 January 2027 00:00 UTC, which the search answer gives. Until then the days are free.
|
||||
- The contract registers for a duration from the block's time, so the page builds: `duration = years × 31536000 + max(0, termStart − now)`, with `now` by the service's clock (`Date`). A year is 365 days, as the price oracle counts it.
|
||||
- The service checks it on every order, register and retarget: `duration − years × 31536000` must be within `term_slack` (1 hour) of `max(0, termStart − now)`, and never negative. Else `bad_request`.
|
||||
- The end date the page shows before registration is `max(termStart, now) + years × 31536000`. Once `registered`, it shows the view's `expiresAt`, which is later by the time from the order to the register: the duration is fixed in the commitment, and the buyer gains the delay.
|
||||
- Years of 365 days cross leap days: for an order before 1 January 2027, 2 years end on 31 December 2028 (`1861833600`), 5 years on 31 December 2031. The page shows these real dates, not calendar years (owner, 2026-10-21).
|
||||
- A new order for the same name builds a new commitment with the current `duration`. So Get a new invoice and Order the name again send a new commitment; a resend after a lost response sends the same one.
|
||||
|
||||
### Prices
|
||||
|
||||
- Prices come from the chain. The service quotes `controller.rentPriceUSD(label, years × 31536000).base`, in attoUSD, divided by 10^16 to cents.
|
||||
- The service never has the label at search, and the deployed `SimplexPriceOracle` prices by length only (`priceCentsPerYear(label.strlen())`, no premium). So it calls `rentPriceUSD` with a placeholder label of `length` letters `a`. The label never reaches the RPC node this way.
|
||||
- The launch list (owner, 2026-10-21) is $100, $200 and $400 for 2 years: `setPrices` with base 5000 cents a year, 7 letters 10000 and 6 letters 20000. There are no per-name prices.
|
||||
- Only the paid years are charged. The free days until `termStart` cost the buyer nothing; the sponsor's allowance pays their prorated price at registration.
|
||||
- An order states the price it saw. A different chain price answers `409 {"error": "catalog_changed", "price": <cents>}`.
|
||||
- An order's price is fixed when it is made (or retargeted). The reveal and the register compare no price.
|
||||
|
||||
### Chain reads
|
||||
|
||||
- A failed chain read is the RPC node not answering, answering an error, or timing out (10 s per call). It changes nothing.
|
||||
- It answers `503 provider_unavailable`, except in orders, `/api/name/register` and `/name`, where it answers `503 name_check_unavailable`. The page reads `provider_unavailable` from `/api/invoice` as "this payment method is unavailable", so orders need their own tag.
|
||||
- In the registration view, a failed read of `intentNonce` or `codeLength` does not fail the view: the field is null and named in `unread` (section 6).
|
||||
- A revert that is the answer is not a failure: `ownerOf` of a name nobody holds means "not held".
|
||||
|
||||
### Signatures
|
||||
|
||||
- **EIP-712 `RegisterName`**, domain `{name: "SimplexSponsor", version: "1", chainId, verifyingContract: sponsor}`: `RegisterName(string label,address owner,uint256 duration,address resolver,bytes[] data,uint256 nonce,uint256 deadline)`.
|
||||
- **EIP-712 `SetText` and `ClearRecords`**, domain `{name: "SimplexResolver", version: "1", chainId, verifyingContract: resolver}`: `SetText(bytes32 node,string key,string value,uint256 nonce,uint256 deadline)` and `ClearRecords(bytes32 node,uint256 nonce,uint256 deadline)`.
|
||||
- **EIP-191** (`"\x19Ethereum Signed Message:\n" ‖ decimal byte length ‖ message`) for orders (section 5), `find`, `/name` and `/code` (section 8). Their `timestamp` must be within 600 seconds of the service's clock, except on an exact resend.
|
||||
- All are signed with the owner code's key (`m/44'/60'/0'/0/0` of the BIP-39 seed from the code's entropy). The service recovers the signer and compares it with the owner. A high `s` is refused even though recovery accepts it, since the contracts' `SignatureChecker` refuses it on chain.
|
||||
- In messages, numbers are decimal; hashes and `sha256(sealed)` (the SHA-256 of `sealed`'s ASCII bytes) are lowercase `0x` hex.
|
||||
|
||||
### Rate limits
|
||||
|
||||
Each limit counts requests per client per minute. It is taken after the route and method match and before any other check, as `limited` does today, and every request counts, whatever its outcome. Values are the service's defaults (Decided, gap 29 of the sequence doc); the mock enforces none unless told to by `/control/search`.
|
||||
|
||||
| Limit | Per minute | Endpoints |
|
||||
|---|---|---|
|
||||
| search | 30 | `GET /api/name/:hash` |
|
||||
| preview | 20 | `POST /api/link/preview` |
|
||||
| code | 10 | `POST /api/code/check`, `POST /api/name/register` |
|
||||
| find | 5 | `POST /api/registration/find` |
|
||||
| records | 10 | `POST /api/name/records` |
|
||||
| read (existing) | 60 | `GET /api/invoice/:id`, `GET /api/registration/:id`, `POST /api/registration/:id/reveal` |
|
||||
| create (existing) | 5 | `POST /api/invoice`, `POST /api/invoice/:id/cancel`, `/name`, `/code`, `/retry` |
|
||||
|
||||
Beyond these:
|
||||
- **Link fetches** have two service-wide bounds, one for search answers (`searchFetchSlots`, 4 at a time) and one for previews (`previewFetchSlots`, 2 at a time), so previews cannot starve search. Each fetch has a 3-second timeout (`fetchTimeout`) and never waits for a slot longer than that: it gives up, and its profile is `unread` (section 2). Results are cached 10 minutes per link.
|
||||
- **Record edits** have a per-name cap of 10 relayed edits per hour (section 10). Refused requests do not count.
|
||||
|
||||
## 2. Search
|
||||
|
||||
### `GET /api/name/:hash?length=N`
|
||||
|
||||
The page validates and lower-cases the label, then sends its keccak256 and length. The service never gets the label here.
|
||||
|
||||
Checks, in order: the search limit; `:hash` format (`bad_request`); `length` an integer in 6–63 (`invalid_name`). Then the chain, by hash:
|
||||
- the base registrar's `nameExpires(uint256(hash))` and `GRACE_PERIOD`, and the controller's `reservedNames(hash)`;
|
||||
- for a registered name, the registry's `owner(node)` and `resolver(node)`, with `node = keccak256(namehash(tld) ‖ hash)`;
|
||||
- when that resolver is the default resolver contract: its `text(node, "simplex.contact")`, `text(node, "simplex.channel")` and `nonces(owner)`.
|
||||
|
||||
A name is registered while `nameExpires + GRACE_PERIOD` is not past the latest block. Otherwise it is reserved when `reservedNames` is not 0, and available when it is 0.
|
||||
|
||||
**Available:**
|
||||
|
||||
```json
|
||||
200 {"status": "available", "price": 10000, "currency": "usd", "years": 2, "termStart": 1798761600,
|
||||
"resolver": "0x…", "sponsor": "0x…", "chainId": 1, "tld": "simplex"}
|
||||
```
|
||||
- `price`: cents for `years` paid years, from the chain (section 1, Prices).
|
||||
- `resolver`: the default resolver contract the commitment must name. `sponsor` and `chainId`: the `RegisterName` domain. `tld`: for the node.
|
||||
|
||||
**Being registered here:** the chain says available while a registration here is `registering` (its register sent, not mined).
|
||||
|
||||
```json
|
||||
200 {"status": "registered", "entities": [], "tld": "simplex"}
|
||||
```
|
||||
No owner, records, nonce or chain id. Orders refuse this name with `name_taken`.
|
||||
|
||||
A name that is paid for here but not yet `registering` (`queued` to `committed`) still answers `available`, and another wallet may order it; the later of the two ends `taken` with its value kept. Answering otherwise would tell every searcher that the name is being bought before its commitment is revealed.
|
||||
|
||||
**Registered, on the default resolver contract:**
|
||||
|
||||
```json
|
||||
200 {"status": "registered",
|
||||
"entities": [
|
||||
{"type": "address", "kind": "contact", "displayName": "Alice's Bakery", "fullName": "Sourdough and pastries, Lisbon",
|
||||
"image": "data:image/jpeg;base64,…", "subscribers": null, "profile": "ok",
|
||||
"link": "https://smp8.simplex.im/a#Y8m3…", "deepLink": "simplex:/a#Y8m3…"},
|
||||
{"type": "channel", "kind": "channel", "displayName": "Bakery news", "fullName": "Daily bakes and opening hours",
|
||||
"image": null, "subscribers": 1204, "profile": "ok",
|
||||
"link": "https://smp9.simplex.im/c#Rk27…", "deepLink": "simplex:/c#Rk27…"}],
|
||||
"records": {"contact": "https://smp8.simplex.im/a#Y8m3…", "channel": "https://smp9.simplex.im/c#Rk27…"},
|
||||
"owner": "0x…", "resolver": "0x…", "recordsNonce": 0, "expiresAt": 1861833600, "gracePeriod": 7776000,
|
||||
"chainId": 1, "tld": "simplex"}
|
||||
```
|
||||
|
||||
- `records`: the raw text records, "" when unset. A record set elsewhere may hold several links joined with `;`.
|
||||
- `entities`: one per link in the records, split on `;`, that is a short link of its key's kind; other values make no entity. Contact entities come first.
|
||||
- An entity, as the app's connection-plan alert shows a chat:
|
||||
- `type`: the record it came from, `address` (`simplex.contact`) or `channel` (`simplex.channel`);
|
||||
- `kind`: what the link opens. For a contact address, `business` when the link data says so (`business`, or the profile's `peerType` business), `bot` for `peerType` bot, else `contact`. For a channel link, `channel`;
|
||||
- `displayName`, `fullName` (may be ""), `image` (a `data:image/…` URI or null);
|
||||
- `subscribers`: a channel's member count from its link data (`publicGroupData.publicMemberCount`), else null;
|
||||
- `link`: as recorded; `deepLink`: `simplex:/` and the path after the host.
|
||||
- `profile`: `ok` when the link data was read; `unread` when it could not be read now (no free slot, a timeout, a failed fetch), so searching again may fill it; `unavailable` when the service does not fetch it (section 3, the host rule). Without a profile the entity has `displayName` "", `fullName` "", `image` null, `subscribers` null, and `kind` from the link (`contact` or `channel`); the page shows the default icon.
|
||||
- The service opens each link with the chat core's connect plan (`APIConnectPlan`), which reads the link data without connecting, within the search fetch bound (section 1).
|
||||
- `owner`, `resolver`: the registry's `owner(node)` and `resolver(node)`. `recordsNonce`: `SimplexResolver.nonces(owner)`, for the next edit.
|
||||
- `expiresAt`: the registrar's `nameExpires`; past it the name is in its grace period. `gracePeriod`: the registrar's `GRACE_PERIOD` (90 days), so the name can be registered again from `expiresAt + gracePeriod`.
|
||||
|
||||
**Registered, on another resolver contract or none:** the service reads no records from a contract it does not know.
|
||||
|
||||
```json
|
||||
200 {"status": "registered", "entities": [], "records": null, "owner": "0x…", "resolver": "0x…",
|
||||
"recordsNonce": null, "expiresAt": 1861833600, "gracePeriod": 7776000, "chainId": 1, "tld": "simplex"}
|
||||
```
|
||||
|
||||
**Reserved:** `200 {"status": "reserved"}`.
|
||||
|
||||
**Errors:** `400 bad_request`, `400 invalid_name`, `429 rate_limited`, `503 provider_unavailable` (any failed chain read, including the record reads: a failed read is not "no links").
|
||||
|
||||
## 3. Link preview
|
||||
|
||||
### `POST /api/link/preview {"link": "…"}`
|
||||
|
||||
Shows a link's chat on the Links step and in Edit links.
|
||||
- Checks: the preview limit; the link is a short contact address or channel link within 4096 bytes, else `400 invalid_link`. Link validity is the shared vectors' alone; the host rule below never makes a link invalid.
|
||||
- **The host rule**, for previews and for the links of search answers alike, applied by the service before it asks the chat core to fetch:
|
||||
- The hosts are the link's servers: the authority of an `https://` link, or the server hosts carried inside a `simplex:/` link. A link with several servers has every host checked; it is fetched only if all pass.
|
||||
- An IP literal is checked as it is, a name by resolving it. A private, loopback, link-local, unique-local (`fc00::/7`), shared (CGNAT, `100.64.0.0/10`), unspecified or multicast address, among a literal or any resolved address, means no fetch. A public IP literal passes: SimpleX servers may be addressed by IP.
|
||||
- A `.onion` host and a name that does not resolve mean no fetch.
|
||||
- A link not fetched under this rule is still a valid link: its profile is `unavailable`.
|
||||
- The check is best effort: the chat core resolves the host again when it connects, so DNS rebinding between the two lookups remains possible. The guard against it is in the deployment: an egress rule denies the service's outbound traffic to the private ranges above (plan, stage 6).
|
||||
- The fetch runs within the preview bound of section 1 (`previewFetchSlots`, 3 seconds each, cached 10 minutes). With no free slot within the timeout, the profile is `unread`.
|
||||
- `200 {"type": "address", "kind": "contact", "displayName": "…", "fullName": "…", "image": null, "subscribers": null, "profile": "ok"}`: an entity without `link` and `deepLink`, fetched as in search.
|
||||
- A link whose data was not read is answered as in search, with an empty profile and `profile` `unread` or `unavailable`. The page accepts the link either way.
|
||||
- Errors: `400 bad_request`, `400 invalid_link`, `429 rate_limited`.
|
||||
|
||||
## 4. Codes
|
||||
|
||||
Codes are entered only on the Check your code screen, opened from the top bar. Two kinds parse from the same field (the core `NameCode` sum type, mirrored in the page's `code.ts`):
|
||||
- a **prepaid code**, `SN<length>-…`: the length in one or two ASCII digits (6–63; `SN6` to `SN9` in use), then 20 Crockford base32 characters with a Luhn mod-32 check. It pays for one name of at least that many letters, for its `years` (at least 2). The service's table, not the text, decides what it covers.
|
||||
- a **name code** (owner code), `SN-` and 30 characters: the key of a name bought here. It adds that name to Your names.
|
||||
|
||||
The page tells the kinds apart and catches a typo (a failed check character) without a request. Nothing about codes is sent at checkout: a prepaid code applied on Check your code makes the search answer's price $0 and pays at `/api/name/register`.
|
||||
|
||||
### `POST /api/code/check {"code": "SN8-25X6F-6JDNM-8C59E-HDHJD"}`
|
||||
|
||||
Says what a prepaid code covers, without spending it.
|
||||
- Checks: the code limit; the code parses as a prepaid code, else `400 code_invalid`; its hash is in the table, else `400 code_invalid`. Not 404, which the page reads as `not_found` whatever the body.
|
||||
- `200 {"minLength": 8, "years": 5, "state": "unused"}`, with `state` one of `unused`, `reserved` (held by an unfinished registration, including one `failed` and waiting for Try again), `used`, `revoked`, `expired`, `unpaid`.
|
||||
- The answer names no registration, even for `reserved`: a code may have changed hands, and holding it proves nothing about an order. The page links "Code in use" to an order only from its own storage.
|
||||
- Errors: `400 bad_request`, `400 code_invalid`, `429 rate_limited`.
|
||||
|
||||
### What Check your code shows
|
||||
|
||||
| Answer | How the page gets it | Verdict |
|
||||
|---|---|---|
|
||||
| Prepaid, usable | check: `state: unused` | "Prepaid code: it pays for a name of 8 or more letters, for 5 years." The code is kept in Your names and applied to searches |
|
||||
| Prepaid, reserved | check: `reserved` | "This code is being used for a name now", with a link to the order when this browser holds it |
|
||||
| Prepaid, used | check: `used` | "This code was already used" |
|
||||
| Prepaid, revoked | check: `revoked` | "This code is not valid: SimpleX withdrew it" |
|
||||
| Prepaid, expired | check: `expired` | "This code has expired" |
|
||||
| Prepaid, unpaid | check: `unpaid` | "This code is not paid for yet" |
|
||||
| Prepaid, unknown | check: `400 code_invalid` | "This code is not valid: SimpleX did not issue it" |
|
||||
| Name code, with its names | `find` (section 8): `200`, then each entry's `sealed` opened with the code | "Name code of alicebakery.simplex", and Add to Your names |
|
||||
| Name code, no orders | `find`: `404 not_found` | "No order was found for this code" |
|
||||
| Name code of another order | `find` lists an order whose `sealed` does not open with the code, or whose label does not hash to its `labelHash` | "This code does not open the order" |
|
||||
| Not a code | the page: no `SN` code parses | "This is not a SimpleX names code" |
|
||||
| Mistyped | the page: the check character fails | "Check it for a mistyped character" |
|
||||
| Rate limited | `429` with `Retry-After` | "Too many checks just now", with the wait |
|
||||
| Could not check | `503`, a network failure, or a timeout | "The code could not be checked. Try again in a moment." |
|
||||
|
||||
### Prepaid codes in orders
|
||||
|
||||
`/api/name/register` (section 5), `/name` for a code order (section 8) and `/retry` (section 9) check the code again, under the per-code serialization, and refuse one that cannot pay, with the same tags:
|
||||
- `400 code_invalid`: unknown or revoked;
|
||||
- `409 code_used` (spent, including by another order after the sweep released it), `409 code_reserved` (another registration holds it), `409 code_expired`, `409 payment_pending` (unpaid);
|
||||
- `409 code_not_covering`: the name is shorter than the code's `minLength`.
|
||||
|
||||
A code order keeps its code reserved while `failed`, for Try again. A daily sweep releases it once the registration has been `failed` for 30 days; a later Try again or `/name` reserves it again if it is still unused, else answers the tag above. A `taken` code order releases its code at once.
|
||||
|
||||
## 5. Orders
|
||||
|
||||
### The order fields
|
||||
|
||||
Both kinds of order, and the retarget, carry:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `labelHash`, `length` | keccak256 of the label, and its length |
|
||||
| `duration` | the term in seconds (section 1, Term) |
|
||||
| `owner` | the owner code's address |
|
||||
| `commitment` | `makeCommitment(Registration{label, owner, duration, secret, resolver, data, reverseRecord: 0, referrer: 0})`, built by the page |
|
||||
| `sealed` | the label and links encrypted under the owner code; required |
|
||||
| `timestamp`, `signature` | the owner's EIP-191 signature over the order (below) |
|
||||
|
||||
- `secret = HMAC-SHA256(BIP-39 seed of the owner code, "simplex.domains commitment " + label)`.
|
||||
- `resolver` is the search answer's.
|
||||
- `data` is one `setText(node, key, link)` call for each of `simplex.contact` and `simplex.channel` that has a link, contact first, none without links. The links are chosen on the Links step, before the order, and cannot change inside it except through `/name` (section 8).
|
||||
- `sealed`: the JSON `{"label", "contact", "channel"}` (each a link or ""), AES-256-GCM under `HMAC-SHA256(seed, "simplex.domains sealed")` with a random 12-byte IV, as unpadded `base64url(iv ‖ ciphertext ‖ tag)`. The service stores it unread and returns it.
|
||||
- The order signature, by the owner code's key, is over `simplex.domains order <labelHash> <length> <duration> <commitment> <sha256(sealed)> <timestamp>`. It proves the owner, so nobody can place orders in another wallet's name.
|
||||
|
||||
### Resends and existing orders
|
||||
|
||||
Every order looks for an existing live order (section 1) of the same commitment, or of the same `owner` and `labelHash`, before any chain read, holding the label hash's turn (section 1):
|
||||
1. **An exact resend:** a stored order, live or not, whose every field equals the request's (`labelHash`, `length`, `duration`, `owner`, `commitment`, `sealed`, and `method` and `price` for `/api/invoice`, or the code for `/api/name/register`), and the signature recovers the owner. The service finds it by `order_hash`, a hash of exactly those fields in a fixed encoding tagged by the kind of order (never the timestamp or the signature). It is answered with that order as it is now: the invoice view (an expired one included), or `{"registrationId": "…"}`, in any phase. The timestamp's age is not checked on an exact resend, so a lost answer is safe however late it is resent.
|
||||
2. **Anything else** that meets a live order is `409 {"error": "order_exists"}`, with no other field. The page then finds its own order with `find` (section 8), which proves the owner, and opens it there.
|
||||
|
||||
So one wallet never has two live orders for one name, a resend never meets its own registration as `name_taken`, and nobody learns about an order they did not make.
|
||||
|
||||
### `POST /api/invoice` (a paid order)
|
||||
|
||||
```json
|
||||
{"kind": "name", "labelHash": "0x…", "length": 12, "duration": 69292800, "owner": "0x014F…9b73",
|
||||
"commitment": "0x…", "sealed": "…", "method": "btc", "price": 10000, "timestamp": 1792828800, "signature": "0x…"}
|
||||
```
|
||||
|
||||
Checks, in order:
|
||||
1. The create limit; the body cap; `kind` is `name` (a badge order is unchanged); formats, `sealed` present and `method` (`card`, `btc`, `xmr`), else `bad_request`; `sealed` at most 11095 characters, else `bad_request`; `length` in 6–63, else `invalid_name`.
|
||||
2. The signature recovers `owner`, else `bad_request`.
|
||||
3. Existing orders (above): an exact resend is answered; an overdue order of the same wallet and name is closed first (section 1); anything else meeting a live order is `409 order_exists`.
|
||||
4. The timestamp within 600 seconds, else `bad_request`.
|
||||
5. The duration, within the slack (section 1, Term), else `bad_request`.
|
||||
6. The chain: a name registered, reserved, or being registered here is `409 name_taken`; a failed read is `503 name_check_unavailable`.
|
||||
7. The price: the chain price for `years` must equal `price`, else `409 catalog_changed` with the chain's `price`.
|
||||
8. The payment provider: unavailable is `503 provider_unavailable`.
|
||||
|
||||
`200`: the invoice view (section 6) with `registration.phase` `unpaid`. Settlement moves the registration to `queued` only while it is still live; otherwise the payment is a late payment (section 1).
|
||||
|
||||
Errors: `400 bad_request`, `400 invalid_name`, `409 order_exists`, `409 name_taken`, `409 catalog_changed`, `429 rate_limited`, `503 name_check_unavailable`, `503 provider_unavailable`.
|
||||
|
||||
### `POST /api/name/register` (paid with a prepaid code)
|
||||
|
||||
```json
|
||||
{"labelHash": "0x…", "length": 12, "duration": 163900800, "owner": "0x7a3C…e51D", "commitment": "0x…", "sealed": "…",
|
||||
"code": "SN8-25X6F-6JDNM-8C59E-HDHJD", "timestamp": 1792828800, "signature": "0x…"}
|
||||
```
|
||||
|
||||
The duration uses the code's `years`. Checks, in order:
|
||||
1. The code limit; the body cap; formats; `sealed` at most 11095 characters (`bad_request`); `length` in 6–63 (`invalid_name`).
|
||||
2. The code parses as a prepaid code and is in the table, else `400 code_invalid`.
|
||||
3. The signature recovers `owner`, else `bad_request`.
|
||||
4. Existing orders (above): an exact resend answers `200 {"registrationId": "…"}`; an overdue order of the same wallet and name is closed first (section 1); anything else meeting a live order is `409 order_exists`.
|
||||
5. The timestamp within 600 seconds, and the duration within the slack for the code's years, else `bad_request`.
|
||||
6. The chain: `409 name_taken`, or `503 name_check_unavailable`.
|
||||
7. The code's state (section 4), then its cover: `length ≥ minLength`, else `409 code_not_covering`. Reserving it is the conditional update of section 1, in the transaction that creates the registration.
|
||||
|
||||
`200 {"registrationId": "…"}`: the code is reserved for the registration, which is `queued`. The page opens `?registration=<id>`.
|
||||
|
||||
### `POST /api/invoice/:id/cancel`
|
||||
|
||||
As for badges, with the name's view:
|
||||
- `200` the invoice view, now `expired` (or still `open` when the provider took the cancel late: the page shows "still open" and keeps the order);
|
||||
- `409 not_open`, `409 funded` (the service has read a payment), `404 not_found`, `503 provider_unavailable` (also when Stripe refuses to cancel an intent already processing).
|
||||
|
||||
A cancel that lost to settlement answers `200` with `status: paid`; the page re-reads and opens the registration.
|
||||
|
||||
### Repeats
|
||||
|
||||
| Request | A repeat of the same body |
|
||||
|---|---|
|
||||
| `POST /api/invoice` | answered with the same order, in any phase |
|
||||
| `POST /api/name/register` | answered with the same `registrationId`, in any phase |
|
||||
| `POST /api/invoice/:id/cancel` | `409 not_open` once cancelled |
|
||||
| `/reveal` | `200 {}` once accepted, in that or any later phase |
|
||||
| `/name` | a body whose fields equal the registration's current ones answers the view unchanged, and counts no move |
|
||||
| `/code` | `200 {}` again |
|
||||
| `/retry` | `400 bad_request` once out of `failed`: the page re-reads the view |
|
||||
| `POST /api/name/records` | the same answer for an edit that was sent: it waits for the same transaction, or repeats its outcome; a refused one is checked anew |
|
||||
|
||||
## 6. Following an order
|
||||
|
||||
### The registration view
|
||||
|
||||
```json
|
||||
{"id": "t2Jk8RmQ4vXw9NcP3yLb6g", "version": 7, "moves": 0, "phase": "committed", "labelHash": "0x…", "label": null, "owner": "0x…",
|
||||
"commitment": "0x…", "sealed": "…", "resolver": "0x…", "sponsor": "0x…", "chainId": 1, "tld": "simplex",
|
||||
"years": 2, "duration": 69292800, "price": 10000, "codeLength": null, "commitTx": "0x…", "registerTx": null,
|
||||
"revealAfter": 1792828800, "revealBefore": 1792915140, "intentNonce": 0, "unread": [], "expiresAt": null, "error": null}
|
||||
```
|
||||
|
||||
- `id`: a random 128-bit token in base64url. Whoever holds it can follow the registration and send its reveal and retry.
|
||||
- `version`: increases with every change the service makes to the registration (phase, label, intent, transactions, reveal times, error). The long polls hold on it.
|
||||
- `label`: null until the reveal is accepted.
|
||||
- `years`, `duration`: the term paid for; `duration` rebuilds the commitment on another browser.
|
||||
- `price`: what the order is worth in cents: the price paid, or after `/name` the new name's price, which caps a further move. 0 for a code.
|
||||
- `codeLength`: the `minLength` of a code for this order (section 8). In `taken`, for a paid order, what `/code` would issue, null when no length qualifies; in `code_issued`, the issued code's `minLength`, so the name code recovers the code on any browser; null otherwise.
|
||||
- `commitTx`, `registerTx`: among the hashes sent since they were last cleared, the one mined with status 1, else the latest sent. Null when none, or when it was judged mined from chain state with no hash known.
|
||||
- `revealAfter`, `revealBefore`: the commit's block time plus the controller's `minCommitmentAge` and `maxCommitmentAge`.
|
||||
- `intentNonce`: `SimplexSponsor.nonces(owner)`, read only in `committed`, null otherwise.
|
||||
- `unread`: the fields left null because their chain read failed (`intentNonce`, `codeLength`). The view itself does not fail. The page reads the view again without `seenVersion` (a failed read changes no version), after 5 seconds, then 15, then every 30 seconds while `unread` is not empty.
|
||||
- `moves`: how many times `/name` has moved this order; at most `max_order_moves` (3).
|
||||
- `expiresAt`: the name's `nameExpires`, once `registered`.
|
||||
- `error`: null, or one sentence for the buyer. It never holds the RPC URL or a raw revert.
|
||||
|
||||
### The name invoice view
|
||||
|
||||
The badge invoice's fields, with `invoiceId`, `kind: "name"` and the registration, without `badgeType` and `months`:
|
||||
|
||||
```json
|
||||
{"invoiceId": "q7ZpL2dN9xWc4KfR8tYb1A", "status": "open", "amount": 10000, "currency": "usd",
|
||||
"expiresAt": "2026-10-21T10:15:00Z", "kind": "name", "registration": {…},
|
||||
"address": "bc1q…", "cryptoAmount": "0.00156", "cryptoCurrency": "btc", "requiredConfirmations": 1}
|
||||
```
|
||||
- Card: `clientSecret` instead of the crypto fields.
|
||||
- After a payment is read: `amountPaid`, `cryptoAmountPaid`, `cryptoAmountDue`, `paidInFull`, and `settledAt` once `paid`.
|
||||
- `status`: `open`, `paid` or `expired` (a cancel leaves `expired`).
|
||||
|
||||
### `GET /api/invoice/:id?wait=<status>&seenVersion=<version>&seenPaid=<cryptoAmountPaid>&seenFull=0|1`
|
||||
|
||||
The long poll for a paid order.
|
||||
- Without `wait`, it answers at once.
|
||||
- With `wait`, it holds while the status is `wait`, the payment marks are `seenPaid` and `seenFull` (each compared only when sent), and the registration's `version` is `seenVersion` (when sent). It answers on any change, or unchanged after at most 30 seconds (the mock: 20).
|
||||
- An invoice that reads `paid` while its registration is `unpaid` (paid after it closed) is answered at once and never held: there is nothing more to wait for. Support refunds it.
|
||||
- An open invoice past its expiry reads `expired`.
|
||||
|
||||
### `GET /api/registration/:id?seenVersion=<version>`
|
||||
|
||||
The long poll for any registration, and the only one for a code order. It holds while the `version` is `seenVersion`, up to 30 seconds. A `committed` registration with no reveal past `revealBefore` reads `failed` ("The name was not signed in time").
|
||||
|
||||
Both take the read limit. Errors: `404 not_found`, `429 rate_limited`.
|
||||
|
||||
### Phases
|
||||
|
||||
| Phase | Meaning | Next |
|
||||
|---|---|---|
|
||||
| `unpaid` | its invoice not settled while open | `queued` at settlement of an open invoice |
|
||||
| `queued` | paid, or its code reserved | `committing` |
|
||||
| `committing` | the commit sent | `committed` once mined, or once `commitments(commitment)` is live |
|
||||
| `committed` | waiting for `revealAfter` and the page's reveal | `registering` after the reveal; back to `committed` with `label` null when the sponsor nonce moved (`InvalidNonce`); `queued` after `/name`, only once a reveal was refused for its links; `failed` past `revealBefore` |
|
||||
| `registering` | the register sent | `registered`, `taken` or `failed` |
|
||||
| `registered` | the name is the owner's, `confirmations` blocks after the receipt | final |
|
||||
| `taken` | someone registered the name first | paid: `queued` after `/name`, or `code_issued` after `/code`. A code order ends here, its code released |
|
||||
| `failed` | the service's retries gave up, or the commitment lapsed | `queued` or `registered` after `/retry`; `queued` after `/name`. A code order keeps its code reserved for 30 days (section 4) |
|
||||
| `code_issued` | a paid taken order turned into a prepaid code | final |
|
||||
|
||||
The page follows `taken` and `failed` too, since another browser can move them on.
|
||||
|
||||
## 7. The reveal
|
||||
|
||||
### `POST /api/registration/:id/reveal`
|
||||
|
||||
```json
|
||||
{"label": "alicebakery", "secret": "0x…", "records": {"contact": ["https://smp8.simplex.im/a#…"], "channel": []},
|
||||
"nonce": 0, "deadline": 1792915140, "signature": "0x…"}
|
||||
```
|
||||
|
||||
Gives the service the name, the links and the owner's intent, once `revealAfter` has passed. The page sends it automatically while open, or when the order link is reopened (on another browser, with the name code).
|
||||
- `records`: at most one link per key, as chosen before the order.
|
||||
- `nonce`: the view's `intentNonce`. `deadline`: the view's `revealBefore`, after which the commitment lapses anyway.
|
||||
|
||||
It runs one at a time per label hash with orders, `/name` and `/retry` (section 1).
|
||||
|
||||
Checks:
|
||||
1. The read limit; the signature's format and low `s` (`bad_request`).
|
||||
2. With `label` already set: the same label and signature answer `200 {}`; anything else is `bad_request`.
|
||||
3. The phase is `committed`, else `bad_request`.
|
||||
4. The label is valid, hashes to `labelHash` and has `length`; at most one link per key.
|
||||
5. The service rebuilds `data` from the sent links and the commitment from the label, the owner, the stored `duration`, the secret and the default resolver; it must equal the stored commitment, else `bad_request`.
|
||||
6. The deadline is not past; the intent's signer is the owner; `nonce` equals `SimplexSponsor.nonces(owner)` on chain; else `bad_request`.
|
||||
7. Each link is a short link of its key's kind within the bound, else `invalid_link`.
|
||||
|
||||
`200 {}`: the registration keeps `committed` with its label set, and the worker registers. The write is a compare-and-set on the `version` the reveal read; a lost race writes nothing and answers `409 registration_changed` with the current view (section 1), from which the page signs again or follows the new state. A failed chain read is `503 provider_unavailable`. `404 not_found` for an unknown id.
|
||||
|
||||
A failed check changes nothing, with one exception: a reveal refused at check 7 records the refusal (`links_refused_at`) on the registration, as a compare-and-set write that bumps `version` and wakes the polls. It can only be recorded after checks 5 and 6 have passed, so the refused links are provably the committed ones and the owner signed them; nobody can trigger it on purpose with links that were never committed. Such links cannot be fixed by revealing again, since they are in the commitment; the page then offers `/name` with the same name and corrected links (section 8).
|
||||
|
||||
## 8. Owner-signed requests
|
||||
|
||||
These need the owner code, so a link alone cannot use them. Each takes its limit (find; create for `/name` and `/code`) and answers `404 not_found` for an unknown id.
|
||||
|
||||
### `POST /api/registration/find {"owner": "0x…", "timestamp": 1792828800, "signature": "0x…"}`
|
||||
|
||||
Finds a name code's orders. Used by Check your code (the name code's verdict and Add to Your names), by an order link opened where the code is not stored, and after `409 order_exists`.
|
||||
- Message: `simplex.domains find <owner as lowercase 0x hex> <timestamp>`.
|
||||
- `200 {"registrations": [{"registrationId": "…", "invoiceId": "…", "phase": "registered", "createdAt": "2026-10-21T09:15:00Z", "labelHash": "0x…", "sealed": "…"}]}`, newest first, one per registration of that owner, retargeted ones included, at most the 50 newest. `invoiceId` is null for a code order. `labelHash` and `sealed` let the page name every order with one request: it opens `sealed` with the code and checks the label's hash.
|
||||
- Orders are signed by their owner (section 5), so only the owner code's own orders are listed.
|
||||
- `404 not_found` when the owner has none. `400 bad_request` for a wrong signer or a stale timestamp. `429 rate_limited` (find limit).
|
||||
|
||||
### `POST /api/registration/:id/name` (another name, or other links)
|
||||
|
||||
```json
|
||||
{"labelHash": "0x…", "length": 9, "duration": 69292800, "commitment": "0x…", "sealed": "…", "timestamp": 1792828800, "signature": "0x…"}
|
||||
```
|
||||
|
||||
- Message: `simplex.domains name <registrationId> <labelHash> <length> <duration> <commitment> <sha256(sealed)> <timestamp>`. An `owner` in the body is ignored.
|
||||
- A body whose fields equal the registration's current ones is a repeat: it answers the view unchanged and counts no move.
|
||||
- Allowed only (manager's ruling, 2026-10-21): for a paid registration in `taken`; for any registration in `failed`; and for any registration in `committed` whose reveal was refused for its links (`invalid_link`, recorded by the reveal). Never from a `committed` registration that has had no such refusal. Else `bad_request`. The new name may be the same name with other links.
|
||||
- At most `max_order_moves` (3) successful moves per order over its life, counted on success; past that, `409 {"error": "move_limit", "registration": {…the current view}}`. The order stays as it is (`failed`, `taken`, or `committed` with its links refused); the page shows its state with a line to contact SimpleX support and the order's reference, and support settles it by hand, refunding a paid order.
|
||||
- The order checks run on the new fields: formats, `invalid_name`, existing live orders for the new commitment or this owner and the new hash, other than this registration (`409 order_exists`), the duration for the registration's `years`, `name_taken`, `name_check_unavailable`.
|
||||
- A paid order: the new name's chain price for the registration's `years` must be at most its `price`, else `409 {"error": "price_too_high", "price": <cents>}`. A cheaper name refunds nothing, which the page says before the buyer chooses.
|
||||
- A code order: the code is checked again and reserved again under the per-code serialization, as `/retry` does: still reserved by this order, or released and still unused, it is (re)reserved; spent elsewhere after the sweep released it is `409 code_used`; held by another order `409 code_reserved`; revoked `400 code_invalid`; expired `409 code_expired`. The new name must be covered by it (`409 code_not_covering`).
|
||||
- `200` the registration view: the new hash, length, duration, commitment and sealed, `price` the new name's (a paid order), `moves` one more, phase `queued`, with the label, intent, transactions, reveal times and the recorded link refusal cleared. An old commitment already on chain is left to lapse.
|
||||
|
||||
### `POST /api/registration/:id/code {"codeHash": "…", "codeLength": 8, "timestamp": 1792828800, "signature": "0x…"}` (a code for a taken order)
|
||||
|
||||
- Message: `simplex.domains code <registrationId> <codeHash> <codeLength> <timestamp>`.
|
||||
- The page derives the code from the owner code (payload from `HMAC-SHA256(seed, "simplex.domains taken code " + registrationId)`) as `SN<codeLength>-…`, with `codeLength` from the view, and sends its hash and the `codeLength` it used, so the name code recovers the code.
|
||||
- First, an identical repeat: when the registration's code was already issued with this hash, the answer is `200 {}`, whatever `codeLength` the request carries or the prices are now; the view keeps the issued code's `minLength` as `codeLength`. In `code_issued` the service compares only with the stored row, never with a fresh computation.
|
||||
- Otherwise only a paid registration in `taken`, else `bad_request`. The service computes `minLength` (below); when it differs from the sent `codeLength` (the prices moved since the view was read), the answer is `409 {"error": "code_length_changed", "codeLength": <current>}` and nothing is issued, and the page derives the code again with the new length. So the issued row's `minLength` always equals the length in the code's text. A hash another code has is `409 code_conflict`.
|
||||
- The code is unused, paid and without expiry, for the registration's `years`. Its `minLength` is the shortest length `L` from 6 such that every length from `L` to 63 has a chain price for those years at most the order's `price`: it pays for any name no dearer than what was paid (`SN6` to `SN8` with the launch prices). The service reads the whole list once from the oracle (`controller.prices()`, then its `prices()`), so a non-monotone list is handled. When no length qualifies (the prices rose above the order's for every length), `/code` is `409 code_unavailable`, and the view's `codeLength` is null; the page then offers only another name.
|
||||
- A failed chain read is `503 provider_unavailable`.
|
||||
- `200 {}`; the phase becomes `code_issued`, and the view keeps the code's `minLength` as `codeLength`.
|
||||
|
||||
## 9. Try again
|
||||
|
||||
### `POST /api/registration/:id/retry`
|
||||
|
||||
Only from `failed`, else `400 bad_request`. In order:
|
||||
1. A register that went through with only its confirmation lost (a mined register with status 1, or `ownerOf` is the owner) makes it `registered`.
|
||||
2. A code order whose code was released by the 30-day sweep and is now used, revoked, expired or held elsewhere answers that code error (`409 code_used`, `400 code_invalid`, `409 code_expired`, `409 code_reserved`); it stays `failed`.
|
||||
3. Otherwise it is `queued` again at no charge, re-reserving a released code. When the sponsor nonce moved since the intent, the label and intent are cleared, so the page reveals and signs again.
|
||||
|
||||
`200` the registration view. `503 provider_unavailable` on a failed chain read. Create limit.
|
||||
|
||||
## 10. Editing links
|
||||
|
||||
### `POST /api/name/records`
|
||||
|
||||
```json
|
||||
{"labelHash": "0x…", "key": "simplex.channel", "value": "https://smp9.simplex.im/c#…", "nonce": 0, "deadline": 1792832400, "signature": "0x…"}
|
||||
{"labelHash": "0x…", "clear": true, "nonce": 1, "deadline": 1792832400, "signature": "0x…"}
|
||||
```
|
||||
|
||||
Changes one record of a registered name; the service relays `setTextWithSig` (or `clearRecordsWithSig`) and pays the gas. Edit links in Your names sends one request per changed key, one at a time.
|
||||
- `key`: `simplex.contact` or `simplex.channel`. `value`: one short link of that kind, or "" to remove it. The whole record is replaced.
|
||||
- `nonce`: `recordsNonce` from the search answer, one counter for all of the owner's edits. `deadline`: signing time plus one hour.
|
||||
- The signature is `SetText` (or `ClearRecords`) with the name's key.
|
||||
|
||||
Checks, in order:
|
||||
1. The records limit; formats and the key; the link (`invalid_link`); the signature's format and low `s`; the signer recovered.
|
||||
2. An identical repeat of an edit that was sent (the same signed body): answered from that edit, as below. Only sent edits are stored; a request refused before sending leaves nothing, so its repeat is checked anew.
|
||||
3. The deadline after the latest block's time; the signer is the registry's `owner(node)`; the nonce equals `SimplexResolver.nonces(owner)`. Else `bad_request`.
|
||||
4. The per-name cap: 10 relayed edits in the last hour, else `429 rate_limited` with the real wait. Only edits that were sent count, so nobody but the owner can use up a name's cap.
|
||||
|
||||
Answers:
|
||||
- `200 {"tx": "0x…"}` once mined; `{"tx": null}` when it was found applied with the applying transaction unknown (another relayer's, or ours with its receipt pruned).
|
||||
- Whether an edit was **applied** is decided from its own transactions first: one of its sent hashes with a receipt of status 1 means applied, answered with that hash. Only when no receipt is known (pruned, or relayed by someone else) is it judged by chain state: the owner's nonce moved past its nonce and the record holds its value (both keys "" for a clear) means applied, `{"tx": null}`; the nonce moved but the record holds another value means `record_reverted` with `InvalidNonce`. A later edit of the same key can make this second judgment wrong; the page's next nonce re-read corrects what it shows.
|
||||
- `409 {"error": "record_reverted", "revert": "SignatureExpired"}`: reverted, with the revert's name or null. The nonce is free again for a new body.
|
||||
- `504 edit_pending`: not mined within 90 seconds, or the chain stopped answering after the send. The edit goes on; an identical repeat waits for it again. The page says the edit may still land and re-reads the name later.
|
||||
- `503 provider_unavailable`: the chain failed before anything was sent. Nothing is stored; the page may send it again.
|
||||
- `400 bad_request`, `400 invalid_link`, `429 rate_limited`.
|
||||
|
||||
After `{tx}` the page signs the next edit with the next nonce; after `record_reverted`, with the same one; after any `bad_request`, it re-reads `recordsNonce`.
|
||||
|
||||
## 11. Not in the API
|
||||
|
||||
- **The dApp hand-off.** Check your order, the page before payment, has "Paying with ETH? Register in the dApp", linking `https://app.simplex.domains` (host to be confirmed by the owner) with the name. Recorded in the owner decisions of 2026-10-21 (ep, 08:43: "remove dapp footer from here, it will be on invoice"), replacing the 2026-10-20 placement on the available answer. No order is made, and nothing reaches the service.
|
||||
- **Restoring an order** is Check your code with a name code: `find`, then the invoice or registration view, with nothing new on the service.
|
||||
- **Card payments** use Stripe.js on the page's own origin (owner, 2026-10-20), with the invoice's `clientSecret`.
|
||||
- **Redeeming a name code in the app** (`approveWithSig` and `claimWithSig` relays) comes with the app's redeem flow.
|
||||
- **No orders from the app.** The web flow opened from the SimpleX app is dropped (owner, 2026-10-21): the app will buy names itself (#7530 §13.6). Every order has an owner code and `sealed`.
|
||||
- **No resolver service.** The relays and the app read the chain from their own node (reth and nimbus), as the service does.
|
||||
|
||||
## Appendix A. Mock and page vs plan
|
||||
|
||||
Each line: the difference, the choice here, and what changes in the mock (`webapp/dev/mock/service.ts` unless said) and the page.
|
||||
|
||||
1. **Prices.** Mock: a table, `{6: 40000, 7: 20000, 8: 10000}` cents for 2 years. Plan: a service catalog (`name_prices`). Chosen: the chain's `rentPriceUSD` (owner, 2026-10-20). Mock: `priceOf` reads `controller.rentPriceUSD("a" × length, years × YEAR)`, divided by 10^16; `dev/deploy.ts` seeds the oracle with the launch list: base 5000 cents a year, 7 letters 10000, 6 letters 20000 (owner, 2026-10-21).
|
||||
2. **Term.** Mock and plan: years × 365 days plus a flat 30 days, built by the page alone. Chosen: `years × 365 days + max(0, termStart − now)`, `termStart` in the available answer, `duration` in every order body and in the view, checked within 1 hour. Mock: answer `termStart`; read, check and store `duration`; build `registrationTuple` and `controlTake` from it; drop `BONUS_DURATION` from `durationFor`'s callers. Page: `termStart` from the search answer, not `TERM_START_S` (`src/format.ts`); `termEnd` shows `max(termStart, now) + years × 365 days` before registration and the view's `expiresAt` after, not calendar years.
|
||||
3. **Get a new invoice.** Plan: reuses the commitment. Chosen: a new commitment with the current duration. Mock: the existing-order rule of item 18.
|
||||
4. **The retarget message** gains `<duration>` after `<length>`. Mock: in `retarget`. Page: in `src/flow.ts`'s `/name` signature.
|
||||
5. **A revoked code.** Mock: `400 code_invalid` from the check. Plan: `state: revoked`. Chosen: the plan's, so the page can say it was withdrawn. Mock: `checkCode` answers `revoked`.
|
||||
6. **A taken order's code.** Mock and page: 8+ letters at most (`codeTier`), 2 years. Chosen: the shortest length from which every length is no dearer than the order, for the order's years, given as the view's `codeLength` in `taken` and `code_issued`; `409 code_unavailable` when none. Mock: compute it in `takenCode` and the view. Page: `takenOrderCode` takes `codeLength`, and the code's years are the order's, not `PRICED_YEARS`.
|
||||
7. **`find` entries** gain `labelHash` and `sealed`, at most 50. Mock: in `find`.
|
||||
8. **Links.** Plan: several links per key joined with `;`, full links allowed, an edit keeps the other links. Mock and redesign: one short link per key. Chosen: the mock's (sequence doc gap 35). No mock change; the plan follows.
|
||||
9. **Record edit repeats.** Plan: an identical repeat is answered from the stored edit. Mock: it checks the nonce on chain again, so a repeat after `{tx}` is `bad_request`. Mock: keep each sent edit's outcome and answer repeats from it; store nothing for a refused edit.
|
||||
10. **`edit_pending`.** The plan answered only once mined. Chosen: `504 edit_pending` after 90 seconds or a chain that stops answering, as the mock already answers the second. No mock change.
|
||||
11. **An unfetchable profile.** Mock: every short link gets a profile. Chosen: an empty profile with the link's kind. Mock: optional, a fragment tag that fails the fetch.
|
||||
12. **Check order in `/api/name/register`.** Mock: the code's format before the body. Chosen: the order of section 5. Mock: reorder.
|
||||
13. **Resend match.** Mock: owner, label hash and method. Chosen: every field, including `sealed`, `method` and `price` (or the code), with the owner's signature. Mock: compare them all.
|
||||
14. **Long-poll hold.** Mock 20 seconds, service 30. The page must accept either. No change.
|
||||
15. **The checkout code.** Plan: "I have a code" at checkout and a header popup. Chosen: Check your code only (owner, 2026-10-21). No API change; the plan follows.
|
||||
16. **Restore.** Plan: its own screen. Chosen: Check your code with a name code (owner, 2026-10-21). No API change beyond item 7.
|
||||
17. **Orders from the app.** Mock and plan: an order opened from the app (`?name=&owner=`) with `sealed: null`. Chosen: dropped (owner, 2026-10-21). Mock: `createInvoice` requires `sealed`, as `registerWithCode` does; the page drops the app-opened routes and screens.
|
||||
18. **Existing orders.** Mock: an order meeting an open invoice is `400 bad_request` with its `invoiceId`, and one meeting a later registration is answered with that order, matched by commitment alone. Chosen: an exact resend is answered; anything else is `409 order_exists` with no fields (section 5). Mock: match by commitment or owner and label hash, answer only exact resends. Page: on `order_exists`, run `find` and open the order found.
|
||||
19. **Signed orders.** Chosen: `/api/invoice` (a name) and `/api/name/register` carry `timestamp` and the owner's `signature` over `simplex.domains order …`. Mock: check them. Page: `OrderRequest` gains `duration`, `timestamp` and `signature`.
|
||||
20. **Serialization.** Mock: one global turn for all orders (`oneOrderAtATime`). Chosen: per label hash and per code (section 1). No mock change needed; the mock's global turn is stricter.
|
||||
21. **Long polls on `version`.** Mock: holds on the phase, wakes on any change. Chosen: views carry `version`, polls take `seenVersion` instead of `seenPhase`. Mock: count changes per registration. Page: send `seenVersion`.
|
||||
22. **`unread`.** Chosen: a failed `intentNonce` or `codeLength` read leaves the field null and named in `unread`. Mock: the view never answers `503`. Page: read the view again when `unread` is not empty.
|
||||
23. **`/name` beyond `taken`.** Chosen: also from `failed`, and from `committed` only after a reveal refused for its links (item 27), for paid and code orders; a dearer name is `409 price_too_high`. Mock: in `retarget`. Page: offer other links or another name when a reveal is refused for its links.
|
||||
24. **A failed code order's code.** Mock: kept reserved, never released. Chosen: kept reserved for Try again, released by a daily sweep after 30 days (section 4). Mock: no sweep needed for tests.
|
||||
25. **`gracePeriod`** in registered answers. Page: use it instead of its 90-day constant.
|
||||
26. **Restoring an order** must rebuild the commitment from the view's `duration`. Page: `openOrder` and `revealOrder` in `src/flow.ts` use it, not `durationFor(years)`.
|
||||
27. **`/name` bounds.** Chosen: from `taken` (paid), `failed`, or a `committed` order whose reveal was refused for its links (recorded only after the commitment and intent checks pass), at most 3 moves, `409 move_limit` with the current view and a support line; a code order's code checked and reserved again; a body equal to the current fields is a repeat. Mock: in `retarget`, with a `moves` count and a recorded link refusal. Page: offer `/name` in `committed` only after `invalid_link` from the reveal.
|
||||
28. **Lost races.** Chosen: the reveal serialized per label hash, every registration write conditional on `version`, and `409 registration_changed` with the current view on every endpoint that writes a registration. Mock: compare the registration's version. Page: on `registration_changed`, follow the view.
|
||||
29. **Link profiles.** Chosen: entities and previews carry `profile` (`ok`, `unread`, `unavailable`); a refused, `.onion` or unresolvable host, or any refused host of several, is `unavailable`, never `invalid_link`; separate search and preview fetch bounds. Mock: answer `profile: "ok"`, and `unavailable` for a fragment tag. Page: accept the link whatever `profile` says, and show the default icon unless `ok`.
|
||||
30. **`/code` carries `codeLength`.** Chosen: in the body and the signed message, `409 code_length_changed` with the current value. Mock: in `takenCode`. Page: send it, and derive again on that answer.
|
||||
31. **`unread` pacing.** Page: re-read without `seenVersion` after 5, 15, then every 30 seconds.
|
||||
32. **Overdue orders.** Chosen: a new order from the same wallet and name closes an old order whose invoice is past its expiry time, in its own transaction; settlement queues only a live order; a payment for a closed order is a late payment. Mock: expire the old invoice in `createInvoice` and `registerWithCode` before the existing-order check, and settle only a live order.
|
||||
33. **`/code` repeats** answer the issued code whatever `codeLength` now. Mock: check the repeat first in `takenCode`.
|
||||
|
||||
## Appendix B. Error tags
|
||||
|
||||
| Tag | Status | Extra | Where |
|
||||
|---|---|---|---|
|
||||
| `bad_request` | 400 | | any |
|
||||
| `invalid_name` | 400 | | search, orders, `/name` |
|
||||
| `invalid_link` | 400 | | preview, reveal, records |
|
||||
| `code_invalid` | 400 | | code check, register, `/name`, retry |
|
||||
| `not_found` | 404 | | unknown ids, `find` with no orders |
|
||||
| `method_not_allowed` | 405 | `Allow` header | any |
|
||||
| `order_exists` | 409 | | orders, register, `/name` |
|
||||
| `name_taken` | 409 | | orders, register, `/name` |
|
||||
| `catalog_changed` | 409 | `price` | `/api/invoice` |
|
||||
| `price_too_high` | 409 | `price` | `/name` |
|
||||
| `code_used`, `code_reserved`, `code_expired`, `payment_pending`, `code_not_covering` | 409 | | register, `/name` (a code order), retry |
|
||||
| `move_limit` | 409 | `registration` (the current view) | `/name` |
|
||||
| `registration_changed` | 409 | `registration` (the current view) | reveal, `/name`, `/code`, `/retry`, cancel |
|
||||
| `code_length_changed` | 409 | `codeLength` | `/code` |
|
||||
| `code_conflict`, `code_unavailable` | 409 | | `/code` |
|
||||
| `not_open`, `funded` | 409 | | cancel |
|
||||
| `record_reverted` | 409 | `revert` | records |
|
||||
| `rate_limited` | 429 | `Retry-After` header | any limited endpoint |
|
||||
| `internal` | 500 | | any |
|
||||
| `provider_unavailable` | 503 | | search, cancel, invoice (payment method), reveal, `/code`, retry, records |
|
||||
| `name_check_unavailable` | 503 | | orders, register, `/name` |
|
||||
| `edit_pending` | 504 | | records |
|
||||
@@ -0,0 +1,355 @@
|
||||
# StoreService names backend: tasks for stages 2–4
|
||||
|
||||
This is the implementation plan for the names backend in StoreService: stages 2, 3 and 4 of [`2026-10-09-names-web-store-plan.md`](2026-10-09-names-web-store-plan.md), and the service side of stages 6 and 7. Every endpoint must answer as [`2026-10-21-store-api.md`](2026-10-21-store-api.md) says; the plan says how. Tasks are numbered T1–T21 (T7 split into T7a and T7b), each with its goal, files, acceptance, tests and dependencies. The owner's answers and the decisions taken here close the document.
|
||||
|
||||
## Where and how
|
||||
|
||||
- **Branch:** `sh/store-service` in the worktree `/project/git/simplex-chat-store`, from Alain's `origin/ab/names-registration-primitives-sponsored` (`f06910698`). PRs target `names`; the branch is rebased when Alain's lands there. Nothing is pushed without the owner.
|
||||
- **Stage 1 first.** The StoreService rename (workstream W3) lands before T1. Paths below use the new names:
|
||||
|
||||
| Before | After |
|
||||
|---|---|
|
||||
| `apps/simplex-badge-service/`, `BadgeService.*` | `apps/simplex-store-service/`, `StoreService.*` |
|
||||
| `Simplex.Chat.Badges.Service`, `BSC`/`BSP`/`BSE` | `Simplex.Chat.StoreService`, `STC`/`STP`/`STE` (wire tags unchanged) |
|
||||
| `tests/Bots/BadgeService/` | `tests/Bots/StoreService/` (as W3 names it) |
|
||||
| `scripts/badge-service/` | `scripts/store-service/` |
|
||||
|
||||
`src/Simplex/Chat/Badges/Code.hs` and `Types.hs` keep their names. Persisted names stay: the `sx_badge_service_` prefix, the migrations table, the database.
|
||||
- **simplexmq.** The branch pins simplexmq `4e6f5586` ("add EIP712 (#1941)") in `cabal.project` (`packages: .`), with its hash in `scripts/nix/sha256map.nix`. Verified 2026-10-21 in the store worktree's cabal source cache (`dist-newstyle/src/simplexmq-*`):
|
||||
- `Simplex.Messaging.Eth.Address` exports `Address` (no constructor), `unAddress`, `addressFromPublicKey`, `addressFromPrivateKey`, `recoverAddress`, `deriveAddress`. `checksumAddress` is not exported; it is `strEncode` of an `Address`. `strP` accepts all-lower or all-upper hex unchecked and checks EIP-55 only on mixed case.
|
||||
- `Eth.EIP712`: `hashTypedData`, `Eip712Value` (`VUint | VAddress | VBytes | VArray`), `serializeSignature`. A `bytes32` field is `VUint` of its 32 bytes.
|
||||
- `Eth.Transaction`: `signEip1559Tx` (raw bytes; the hash is keccak256 of them). `Eth.RLP`.
|
||||
- `Crypto.Secp256k1`: `recoverPublicKey`, which accepts a high `s`. `Crypto.BIP39`.
|
||||
- `SimplexName`: `SimplexDomain {nameTLD, domain, subDomain}`, `labelHash`. `nameLabelP` is not exported, and `strDecode` of a bare label fails ("domain requires TLD").
|
||||
- No EIP-191 helper; T3 adds it. No pin change is needed.
|
||||
- **One worktree per task.** Every task of a parallel wave works in its own worktree and branch, made from `sh/store-service` at the wave's start:
|
||||
|
||||
```
|
||||
git -C /project/git/simplex-chat-store worktree add /project/git/simplex-chat-store-t5 -b sh/store-t5 sh/store-service
|
||||
```
|
||||
|
||||
Each worktree has its own `dist-newstyle`. At the wave's start the manager builds `sh/store-service` (the merged head) once in `/project/git/simplex-chat-store`; each new task worktree then starts from a copy of that build, so a task rebuilds only what it changes:
|
||||
|
||||
```
|
||||
cp -a --reflink=auto /project/git/simplex-chat-store/dist-newstyle /project/git/simplex-chat-store-t5/
|
||||
```
|
||||
|
||||
The manager reviews each task branch and merges them into `sh/store-service` at the end of the wave, one at a time, building and testing after each merge.
|
||||
- **One cabal build at a time, across all worktrees.** The container runs out of memory with two. Every cabal command, by any agent, in any worktree, runs under one global lock:
|
||||
|
||||
```
|
||||
flock /project/git/.store-cabal.lock cabal build -j1 exe:simplex-store-service
|
||||
flock /project/git/.store-cabal.lock cabal build -j1 simplex-chat-test
|
||||
flock /project/git/.store-cabal.lock cabal test -j1 simplex-chat-test --test-options='-m "StoreService names"'
|
||||
```
|
||||
|
||||
Run it in the foreground with a timeout of at most 600000 ms, or in the background and wait for its exit notification. Never poll with `sleep`, `tail` or `ps` loops.
|
||||
- **Postgres.** The service's tests run on SQLite by default. The Postgres build uses its own build directory, so it does not reconfigure the SQLite one: `flock /project/git/.store-cabal.lock cabal build -j1 --flags=client_postgres --builddir dist-postgres simplex-chat-test`. Run it once per wave after the merges, where a Postgres server is available; else leave it to CI and say so.
|
||||
- **Test labels.** Each new spec sits under `describe "StoreService names: <part>"`, so `-m "StoreService names"` runs them all and `-m "StoreService names: <part>"` one task's. Badge specs keep their labels and must stay green.
|
||||
- **Shared files.**
|
||||
- *Registration lists*, where each task adds only its own lines: `simplex-chat.cabal` (each task adds its own modules to the executable's and the test suite's `other-modules` once they compile), `tests/Test.hs` (its specs), `StoreService/Web/Names.hs` (its handler module's routes), `StoreService/Names/Env.hs` (its part's field and its construction in `newNamesEnv`). The manager resolves the line conflicts at merge.
|
||||
- *Owned files*: within a wave, each of these has one owner task, given in the wave table; no other task of the wave edits it: `StoreService/Web/Server.hs`, `StoreService/Web/Http.hs`, `StoreService/Service.hs`, `StoreService/Names/Store.hs`, `StoreService/Names/Turns.hs`, the two migration modules, `StoreService/Names/Orders.hs`, `StoreService/Store/Invoices.hs`, `StoreService/Orders.hs`, `StoreService/Poller.hs`, `StoreService/Web/Names/Registration.hs`, `StoreService/Names/Worker.hs`, `tests/Bots/StoreService/NameRegistrationTests.hs`, `tests/Bots/StoreService/FakeEthNode.hs`, `apps/simplex-store-service/README.md`.
|
||||
- A task that needs a change in a file it does not own stops and tells the manager, who moves the change into the owner's task or the next wave.
|
||||
- **Fakes.** `tests/Bots/StoreService/FakeEthNode.hs` (96 lines on the branch) is extended by T5 into a scriptable JSON-RPC node. `FakeBTCPay` and `FakeStripe` are in-process test fixtures for paid orders. The local anvil chain and deploy script are the webapp's (`simplex.domains/webapp/dev/start.sh`, `dev/deploy.ts`; see memory "names local chain").
|
||||
|
||||
## Tasks
|
||||
|
||||
### Stage 2: core and primitives
|
||||
|
||||
#### T1. Name codes: one sum type for owner and prepaid codes
|
||||
|
||||
- **Goal:** a code's kind in its type, parsed from one text field, as Check your code needs.
|
||||
- **Files:** `src/Simplex/Chat/Badges/Code.hs`; `tests/BadgeTests.hs`; the owner-code users (`tests/Bots/StoreService/WebUIMock.hs`, and any other `parseNameCode`/`formatNameCode` caller, found with `rg`).
|
||||
- **Do:**
|
||||
- `data NameCode = NCOwner NameOwnerCode | NCPrepaid NamePrepaidCode`. The branch's `NameCode` becomes `NameOwnerCode` (`randomNameOwnerCode`, `nameOwnerCodeEntropy`, `parseNameOwnerCode`, `formatNameOwnerCode`).
|
||||
- `parseNameCode :: Text -> Maybe NameCode` dispatches on the body's length after `SN`, separators removed: 30 is an owner code, 21 or 22 a prepaid code. The 20-character body folds as badge codes do (case, `I`/`L` → `1`, `O` → `0`); the length must be ASCII digits, as the webapp's `parsePrepaid` requires.
|
||||
- `NamePrepaidCode`: the length digits (1–2, value 6–63) and 20 characters, the last a Luhn mod-32 check over the length digits' values and the 19 payload values. `randomNamePrepaidCode :: TVar ChaChaDRG -> Int -> IO NamePrepaidCode`, `namePrepaidCodeText` (canonical, `SN<len><20>`), `formatNameCode` (display, `SN<len>-XXXXX-…`), `namePrepaidCodeHash` (raw SHA-256 bytes of the canonical text, stored as a blob as `badgeCodeHash` is; unpadded base64url only on the wire), `namePrepaidCodeLength`.
|
||||
- **Acceptance:** an owner code never parses as prepaid and the reverse; a single-character change fails the check, and so does an adjacent swap except `0`↔`Z` (Luhn mod 32's blind spot, as for badge codes); the base64url of the hash equals the webapp's `codeHash` for the same code.
|
||||
- **Tests** (`describe "StoreService names: codes"` in `BadgeTests.hs`): the branch's owner-code vectors; prepaid vectors copied from `simplex.domains/webapp/test/crypto.test.ts` (parse, canonical, display, hash); the taken-order code derivation vector (`takenOrderCode`) as a parse vector; random codes round-trip; a letter in the length refused.
|
||||
- **Depends on:** stage 1.
|
||||
|
||||
#### T2. The label rule
|
||||
|
||||
- **Goal:** one label check, the relays' own.
|
||||
- **Files:** new `apps/simplex-store-service/src/StoreService/Names/Label.hs`; new `tests/Bots/StoreService/NamesLabelTests.hs`.
|
||||
- **Do:** `validLabel :: SimplexTLD -> Text -> Either LabelError Text`:
|
||||
1. the length, 6–63, first (`LELength`; search answers `invalid_name` for it), since `nameLabelP` also fails above 63;
|
||||
2. then `strDecode` of `label <> "." <> tld` as `SimplexDomain` (which runs `nameLabelP` on each part), requiring `subDomain == []`, `domain == label`, and the text already lower case (`LEChars`).
|
||||
- **Acceptance:** agrees with the webapp's `labelProblem` (`src/names.ts`) on its vectors.
|
||||
- **Tests:** `a-b-c-d-e-f` accepted; `--` anywhere, a leading or trailing hyphen, upper case, a dot, 5 and 64 characters refused (64 as `LELength`); 63 accepted.
|
||||
- **Depends on:** stage 1.
|
||||
|
||||
#### T3. Signatures and ABI helpers
|
||||
|
||||
- **Goal:** everything the service verifies from the page, with vectors from viem.
|
||||
- **Files:** new `StoreService/Names/Abi.hs` (the ABI helpers now private in `Registration.hs`, plus decoders for `uint256`, `address`, `bool`, `bytes32`, `string`, `bytes`, and the oracle's `(uint256, (uint256,uint256)[])`); changed `StoreService/Names/Registration.hs` (imports `Abi`; adds `resolverDomain`, `setTextDigest`, `clearRecordsDigest`; at most one link per key); new `StoreService/Names/Signatures.hs`; `tests/Bots/StoreService/NameRegistrationTests.hs` (its vectors regenerated with one link per key); new `tests/Bots/StoreService/NamesSignatureTests.hs`.
|
||||
- **Do (`Signatures`):**
|
||||
- `parseSignature :: Text -> Either SignatureError RecoverableSignature`: `0x` and 130 lowercase hex, `v` 27 or 28, `s` at most half the curve order. Recovery is simplexmq's `recoverAddress`.
|
||||
- `personalDigest :: ByteString -> ByteString` (EIP-191).
|
||||
- The messages of the contract, exactly: `orderMessage`, `findMessage`, `retargetMessage` (with `duration`), `takenCodeMessage` (with `codeLength`).
|
||||
- The address decoder goes through `strDecode` of the hex (there is no constructor). `parseChecksumAddress` (for order bodies) accepts only text equal to `strEncode` of the decoded address, since `strP` accepts one-case hex unchecked; `parseAnyCaseAddress` (for `find`) lower-cases first.
|
||||
- **Acceptance:** the digests and recovered signers equal viem's for the webapp's vectors.
|
||||
- **Tests:** `RegisterName`, `SetText`, `ClearRecords` digests and EIP-191 messages against `webapp/test/crypto.test.ts` and `flow.test.ts` vectors; a high-`s` signature refused though recovery accepts it; `v` 0, 1 and 29 refused; a wrong-case and an all-lowercase checksum refused by `parseChecksumAddress`; `NameRegistrationTests` green with its one-link vector.
|
||||
- **Depends on:** stage 1.
|
||||
|
||||
### Stage 3: the service's names endpoints
|
||||
|
||||
#### T4. Wiring: configuration, HTTP helpers, phases, the names router
|
||||
|
||||
- **Goal:** what every later task builds on, without an import cycle. Nothing answers differently until later tasks add routes.
|
||||
- **Files:** `StoreService/Config.hs`; the ini example; new `StoreService/Web/Http.hs`; `StoreService/Web/Server.hs`; `StoreService/Service.hs`; new `StoreService/Names/Env.hs`; new `StoreService/Names/Phases.hs`; new `StoreService/Names/Turns.hs`; new `StoreService/Web/Names.hs`; `simplex-chat.cabal` (its own new modules, and the branch's `Names.Chain` and `Names.Registration` in the executable); `tests/Test.hs`; `tests/Bots/StoreService/ConfigTests.hs`.
|
||||
- **Do:**
|
||||
- **The cycle:** move out of `Web/Server.hs` into `Web/Http.hs` everything a handler needs: `Respond`, `Limit`, `takeToken`, `limited`, `clientKey` and the bucket map, `readLimit`, `createLimit`, the new limits (`search` 30, `preview` 20, `code` 10, `find` 5, `records` 10 per minute), `maxBuckets` (now 32768), `jsonResponse`, `jsonError`, the error responses, `readBoundedBody`, `maxBodyBytes`, `nameBodyBytes` (16384), and `only` (today local to `webApp`'s `where`), so a known names path with another method answers `405 method_not_allowed` with `Allow`. The rate-limit state moves with them into a `HttpEnv` that `WebEnv` holds. `Web/Server.hs` and `Web/Names/*` import `Web/Http.hs`; `Web/Server.hs` imports `Web.Names.namesApp`, and no `Web/Names/*` module imports `Web/Server.hs`. Handlers that the invoice side shares (`invoiceView`, `createAtProvider`) move into `Web/Http.hs` or a module of their own when T11 needs them.
|
||||
- `[names]`: `tld`, `years` (2; startup refuses below 2), `term_start` (`2027-01-01T00:00:00Z`), `term_slack_seconds` (3600), `reveal_margin_seconds` (900), `max_order_moves` (3). `[chain]`: `rpc_url`, `chain_id`, `controller`, `sponsor`, `base_registrar`, `resolver`, `service_key`, `confirmations` (3), `gas_limit_cap` (8000000). `[listener]`: `names_static_dir`, `names_export_dir`, `names_hosts`. All in `knownSettings`. The names side is off when `[names]` is absent.
|
||||
- The RPC URL and the key never appear in a log line or an error.
|
||||
- `Names/Phases.hs`: a map keyed by registration id, outside `waitingCount`, holding each registration's `version`; `publish` after a commit, and `awaitChange id seenVersion micros`. Later tasks publish from the start.
|
||||
- `Names/Env.hs`: `NamesEnv` and `newNamesEnv cc userId config store`, called once from `Service.hs` with the `ChatController` and the service user's id, so no later task needs `Service.hs` to reach the chat core. Later tasks add their parts (chain client T5, profiles T7b, sender T16).
|
||||
- `Web/Names.hs`: `namesApp`, a route list that later tasks extend.
|
||||
- `Names/Turns.hs`: the per-name and per-code serialization of the contract's section 1, as its own module so no later task edits another's file to use it: `withLabelTurn :: NamesEnv -> LabelHash -> IO a -> IO a`, `withLabelTurns` (two label hashes, taken in hash order, for `/name`), `withCodeTurn` (a code hash). In-process locks keyed by hash, removed when unused. T9, T11, T12, T14, T15 and T18 import it.
|
||||
- **Acceptance:** badge endpoints and tests unchanged; the service starts with and without `[names]`; an unknown key in the new sections is warned about and ignored.
|
||||
- **Tests:** `ConfigTests`: the new sections parsed, defaults, a bad `term_start`, `years` 1 or a bad address refused at startup, the RPC URL absent from the rendered config error; `Phases`: publish wakes a waiter on the published version, not on an older one; a names route with the wrong method answers `405` with `Allow`; `Turns`: two holders of one label hash run one after the other, two of different hashes at once, and `withLabelTurns` A→B with B→A never deadlocks.
|
||||
- **Depends on:** stage 1.
|
||||
|
||||
#### T5. The chain client
|
||||
|
||||
- **Goal:** a JSON-RPC client the worker and every read can rely on.
|
||||
- **Files:** `StoreService/Names/Chain.hs`; `tests/Bots/StoreService/FakeEthNode.hs`; `tests/Bots/StoreService/NameRegistrationTests.hs` (its `ChainError` and `ChainClient` uses); new `tests/Bots/StoreService/NamesChainTests.hs`.
|
||||
- **Do:**
|
||||
- A 10-second timeout per call (`responseTimeout`), and a response size bound.
|
||||
- Errors keep `error.code` and `error.data`: a revert (code 3, or "execution reverted") is `ChainReverted (Maybe RevertName)`, decoded against the contracts' error selectors; anything else is `ChainUnavailable` with no URL in its text.
|
||||
- `ethCall`, `latestBlock` (number, timestamp, base fee), `transactionReceipt`, `transactionByHash`, `transactionCount`.
|
||||
- `sendTransaction` is split: `signTransaction` returns the raw bytes and the hash (keccak256 of the raw bytes); `sendRawTransaction` sends them. `estimateGas` is capped by `gas_limit_cap`.
|
||||
- `FakeEthNode` answers `eth_call` from a table keyed by contract and calldata, records raw transactions, mines them on demand with chosen statuses, and can fail, time out or revert with data per method. Later owners (wave table) extend it for dropped transactions, pruned receipts, a nonce used with no receipt and fee-bump replacements.
|
||||
- **Acceptance:** no call can hang past its timeout; a revert is never read as a node failure; `NameRegistrationTests` green.
|
||||
- **Tests:** a revert with data decoded by name; `ownerOf`'s revert read as a revert; a timeout and an RPC error read as unavailable without the URL; the signed hash equals the fake node's `keccak256(raw)`.
|
||||
- **Depends on:** T4.
|
||||
|
||||
#### T6. Contract reads
|
||||
|
||||
- **Goal:** every chain read the contract needs, typed.
|
||||
- **Files:** new `StoreService/Names/Contracts.hs`; new `tests/Bots/StoreService/NamesContractsTests.hs`.
|
||||
- **Do:** on T5 and T3's `Abi`:
|
||||
- `nameStatus :: LabelHash -> IO (Either ChainError NameStatus)`: registered while `nameExpires + GRACE_PERIOD ≥ latest block time`, with the registry's `owner(node)` and `resolver(node)` (registry from `baseRegistrar.ens()`, read once) and `GRACE_PERIOD`; else reserved when `reservedNames ≠ 0`; else available.
|
||||
- `nameRecords`: on the default resolver only, `text(node, "simplex.contact")`, `text(node, "simplex.channel")`, `nonces(owner)`.
|
||||
- `chainPriceCents :: Int -> Int -> IO …`: `rentPriceUSD(replicate length 'a', years × 31536000).base `div` 10^16`.
|
||||
- `codeLengthFor :: Int -> Int -> IO (Either ChainError (Maybe Int))`: reads the oracle (`controller.prices()`, then its `prices()`) once, and returns the shortest `L` from 6 such that every length from `L` to 63 costs at most the given cents for the years; `Nothing` when none does.
|
||||
- `sponsorNonce`, `resolverNonce`, `ownerOfToken` (a revert is `Nothing`), `nameExpires`, `commitmentTime`, `controllerAvailable label`, `commitmentAges` (read once at startup).
|
||||
- **Acceptance:** each read's calldata equals viem's `encodeFunctionData` for the same call (vectors computed once and pasted).
|
||||
- **Tests:** against `FakeEthNode`: the three statuses, grace period, another resolver, prices for 6, 7 and 12 letters at the launch list (40000, 20000, 10000 cents for 2 years), `codeLengthFor` 6, 7 and 8 for those prices, a non-monotone list (6 letters cheaper than 7), no qualifying length, a failed read.
|
||||
- **Depends on:** T3, T5.
|
||||
|
||||
#### T7a. Link kinds
|
||||
|
||||
- **Goal:** one link check for the reveal, the records relay and previews, landing early and on its own.
|
||||
- **Files:** new `StoreService/Names/Links.hs`; new `tests/Bots/StoreService/NamesLinksTests.hs`.
|
||||
- **Do:**
|
||||
- `linkKindOf :: Text -> Maybe LinkKind`: a short contact address (`/a#`) or channel (`/c#`) link, `https://<host>/…` or `simplex:/…`, within 4096 bytes. Its cases are the webapp's `src/names.ts` vectors.
|
||||
- `linkHosts :: Text -> [Host]`: the link's server hosts, the authority of an `https://` link or the server hosts carried inside a `simplex:/` link (parsed with the chat core's link parser, so the hosts are those the core will connect to).
|
||||
- **Tests:** the webapp's link vectors; full links, wrong kinds and a link one byte over the bound refused; hosts from an `https://` link and from a `simplex:/` link with one and with several servers.
|
||||
- **Depends on:** T4.
|
||||
|
||||
#### T7b. Link profiles
|
||||
|
||||
- **Goal:** the chats a name opens, as the app's connection-plan alert shows them, with outbound fetches bounded and private addresses refused.
|
||||
- **Files:** new `StoreService/Names/Profiles.hs`; new `tests/Bots/StoreService/NamesProfilesTests.hs`.
|
||||
- **Do:**
|
||||
- **The host rule** (contract section 3), best effort, before the chat core is asked to fetch: every host of `linkHosts` is checked; an IP literal as it is, a name by resolving it. A private, loopback, link-local, unique-local (`fc00::/7`), CGNAT (`100.64.0.0/10`), unspecified or multicast address, among a literal or any resolved address, or a `.onion` or unresolvable host, means no fetch and `profile: unavailable`; the link stays valid. A public IP literal passes. There is no address pinning: the pinned simplexmq resolves inside `connectTCPClient` with no hook, so DNS rebinding between the two lookups remains possible. The deployment's egress rule (T20) is the real guard.
|
||||
- `chatProfile`: runs `APIConnectPlan` (through the `ChatController` that `NamesEnv` holds) with resolve mode off and maps the plan: `CAPOk` with `ContactShortLinkData` → `business` when `business` or `peerType` business, `bot` for `peerType` bot, else `contact`; `GLPOk` (and `GLPNoRelays`, `GLPUpdateRequired`) with `GroupShortLinkData` → `channel`, `subscribers` from `publicGroupData.publicMemberCount`. `displayName`, `fullName`, `image` from the profile. A known chat (`CAPKnown`, `GLPKnown`, own link) uses the stored profile.
|
||||
- Bounds: `searchFetchSlots` (4) for search answers and `previewFetchSlots` (2) for previews, each service-wide; `fetchTimeout` (3 seconds) per fetch, which also bounds the wait for a slot; a 10-minute cache per link (bounded in size). No slot in time, a timeout or a failure gives the empty profile with `profile: unread`; the host rule gives `profile: unavailable`.
|
||||
- `entity`: the profile with `link` and `deepLink`.
|
||||
- **Acceptance:** the JSON equals the contract's entity and preview shapes.
|
||||
- **Tests:** the plan-to-profile mapping as a pure function over constructed plans (contact, business, bot, channel with a count, known chat, error); the cache, both concurrency bounds (previews filling theirs while search still fetches) and the timeout with a fake fetcher, including a fetch giving up when no slot frees in time (`unread`); a public IP literal passing; literals and names resolving to `127.0.0.1`, `10.0.0.1`, `100.64.0.1`, `fe80::1` and `fd00::1` refused (`unavailable`), with a stub resolver; a `.onion` and an unresolvable host `unavailable`; a link with one public and one private host `unavailable`; one integration test in the chat test harness: a test user's address short link and a channel link fetched through the service user's controller.
|
||||
- **Depends on:** T4, T7a.
|
||||
|
||||
#### T8. Storage
|
||||
|
||||
- **Goal:** the names tables and every query the endpoints, the sender and the worker use.
|
||||
- **Files:** `StoreService/Store/SQLite/Migrations.hs`, `StoreService/Store/Postgres/Migrations.hs` (migration `20261010_names`); new `StoreService/Names/Store.hs`; new `tests/Bots/StoreService/NamesStoreTests.hs`.
|
||||
- **Do:**
|
||||
- Tables `sx_badge_service_name_prepaid_codes`, `…_name_registrations` (with `duration`, `price`, `version`, `live`, `order_hash`, the order's `method`, `moves` and `links_refused_at`), `…_name_record_edits`, `…_name_invoices`, and `…_name_transactions` for every signed transaction (its registration or edit, kind, raw bytes, hash, registrar nonce, sent and mined blocks, status), as the plan's "Storage" lists them, without a price table.
|
||||
- Constraints: unique `commitment` and unique `(owner, label_hash)` among rows with `live` set; unique `reserved_by` on codes; edits unique on `(owner, nonce)` among pending and mined rows.
|
||||
- `order_hash`: SHA-256 of exactly the order's fields in a fixed encoding, tagged by kind: `name-invoice` with `labelHash, length, duration, owner, commitment, sealed, method, price`, or `name-code` with `labelHash, length, duration, owner, commitment, sealed, codeHash`. Never the timestamp or the signature.
|
||||
- Queries: create a paid or code registration (the code reserved by a conditional update in the same transaction); the existing-order lookups (exact match by `order_hash` among all stored orders; live by commitment, or by owner and label hash, telling an overdue one apart: its open invoice past its `expires_at`, whatever the poller has marked); `closeOverdue`, which in the caller's transaction marks that invoice `expired` and clears its registration's `live`; registering hashes; every registration write, phase changes and the reveal's fields included, as a compare-and-set on the `version` the caller read, bumping and returning it, and reporting a lost race; `live` kept in step with the phase and the invoice's status; reveal fields; clear-reveal; retarget; issue, reserve, release, spend and revoke codes; edits by body hash and by owner and nonce; relayed edits per name in a window; registrations by owner (find, the 50 newest); unpaid cleanup candidates; failed code orders past 30 days; transactions: insert a signed one before sending, record a receipt or block, list those without a receipt (resume), list by registration or edit.
|
||||
- **Acceptance:** migration up and down on SQLite and Postgres; badge tables untouched.
|
||||
- **Tests:** migration up/down; each query's effect; the unique constraints (a second live registration for one owner and hash, or one commitment, refused; a code reserved by one registration refused to another); `version` bumped on every change; a write with a stale `version` changing nothing; an overdue live order reported as overdue, and `closeOverdue` freeing the unique constraint for a new insert in the same transaction; `order_hash` equal for two requests differing only in timestamp and signature, and different for any other field.
|
||||
- **Depends on:** T4.
|
||||
|
||||
#### T9. Prepaid codes: check, issue, revoke
|
||||
|
||||
- **Goal:** the code check and the operator's codes.
|
||||
- **Files:** new `StoreService/Names/Codes.hs`; new `StoreService/Web/Names/Codes.hs`; `StoreService/Service.hs` (`//issue name`, `//revoke` of a prepaid code); `StoreService/Group/Command.hs` (parsing `/issue name`, `/bulk name`); `StoreService/Group.hs` (running them, beside `GCIssue`/`GCBulk`); new `tests/Bots/StoreService/NamesCodesTests.hs`.
|
||||
- **Do:**
|
||||
- `POST /api/code/check` as the contract's section 4, under the `code` limit; `revoked` is a state, an unknown code `400 code_invalid`; no registration named.
|
||||
- `//issue name <length> [years <Y>] [paid|unpaid|free]`, `/issue name <length> [years <Y>]`, `/bulk name <length> [years <Y>] count <N>`: length 6–63, years 2–10 (default 2; below 2 refused). `//revoke` takes a badge or a prepaid code.
|
||||
- `reserveCode`, `releaseCode`, `spendCode`, each under `withCodeTurn` (T4's `Turns`), for T12, T15, T17 and T18.
|
||||
- **Acceptance:** the check never changes a code.
|
||||
- **Tests:** each state; unknown and malformed codes; an owner code sent to the check refused as `code_invalid`; the operator commands in the existing bot and group test harnesses; years 1 refused; two concurrent reservations of one code, one winning.
|
||||
- **Depends on:** T1, T4, T8.
|
||||
|
||||
#### T10. Search and link preview
|
||||
|
||||
- **Goal:** `GET /api/name/:hash` and `POST /api/link/preview`.
|
||||
- **Files:** new `StoreService/Web/Names/Search.hs`; new `tests/Bots/StoreService/NamesSearchTests.hs`.
|
||||
- **Do:** the contract's sections 2 and 3: available with the chain price, `years` and `termStart`; registered with entities (each with `profile`), raw records, owner, resolver, nonce, expiry and `gracePeriod`; another resolver; being registered here; reserved; `provider_unavailable` for any failed read, records included. Previews through T7b's preview bound and host rule.
|
||||
- **Acceptance:** the JSON of every answer equals the contract's examples field for field.
|
||||
- **Tests:** each answer against `FakeEthNode` and a fake profile fetcher; length 5 and 64 `invalid_name`; a bad hash `bad_request`; the search limit; a record with two links joined by `;` giving two entities and a full link giving none; preview of each kind, an invalid and an over-long link (`invalid_link`), and a private-host link accepted with `profile: unavailable`.
|
||||
- **Depends on:** T6, T7b, T8.
|
||||
|
||||
#### T11. Paid orders, the registration view and the invoice's name side
|
||||
|
||||
- **Goal:** `POST /api/invoice` for names, the views it answers with, settlement, cancel and cleanup.
|
||||
- **Files:** new `StoreService/Names/Orders.hs` (the order checks shared with T12 and T15: formats, the order signature, existing orders, the timestamp, the duration, the chain, the price; one at a time per label hash); new `StoreService/Names/View.hs` (the registration view of the contract's section 6, with `version`, `codeLength` from T6 and `unread`); new `StoreService/Web/Names/Orders.hs`; `StoreService/Web/Server.hs` (`createInvoiceHandler` dispatches on `kind`; the name invoice view); `StoreService/Web/Http.hs` (the shared invoice view helpers); `StoreService/Store/Invoices.hs` (every invoice query gains the name side; provider-ref lookups across both tables, refusing a ref found in both); `StoreService/Orders.hs` (settling a name invoice queues its registration in the same transaction, only while open, then publishes; a late payment of a closed one alerts and leaves it `unpaid`); `StoreService/Poller.hs` (the 30-day cleanup with a provider read first; the overdue sweep for names); `StoreService/Providers/BTCPay.hs` (`InvoiceReceivedPayment`, `InvoicePaymentSettled` as read hints); `StoreService/Names/Store.hs` if a query is missing; new `tests/Bots/StoreService/NamesOrdersTests.hs`.
|
||||
- **Do:** the contract's section 5 for `/api/invoice` and cancel, in its order of checks; publish after every commit.
|
||||
- Orders run under `withLabelTurn` (T4's `Turns`).
|
||||
- **`live`:** every transition that changes it is in this task, in the same transaction as the change: settlement (past `unpaid`, live), cancel (`cancelOpenInvoice`), the poller's overdue sweep (`expireOverdue`) and its expiry of an invoice it reads, a late payment of a closed invoice (stays not live), the 30-day cleanup (deleted), and phase changes to `taken` or `code_issued` (not live).
|
||||
- **An overdue order is closed by the next one:** a new order from the same wallet and name that meets a live order whose invoice is past its expiry time calls `closeOverdue` in its own transaction, then inserts; after the commit it asks the provider to cancel the old invoice, best effort.
|
||||
- **Settlement queues only a live registration.** A settled signal for a closed one (cancelled, expired, or closed by a newer order) is a late payment: the invoice is marked paid, the registration stays `unpaid`, the operator is alerted, support refunds it.
|
||||
- **Acceptance:** badge invoices unchanged (their tests green); a name invoice's view, and the registration view inside it, as the contract's.
|
||||
- **Tests** (the plan's Verification, orders): a paid order; an exact resend answered with the same invoice, even after the timestamp window, with a new timestamp and signature, and after the registration went past `unpaid` (answered before the chain read); an exact resend of an expired order answered with its expired invoice; a new order of the same wallet and name accepted after a cancel, and after the old invoice's expiry time before the poller marks it (closing the old one); a payment of the old invoice arriving after a newer order replaced it, recorded as a late payment with an alert and the new order unaffected; a stale timestamp on a new order refused; a resend differing in any one field (including `sealed`) refused `409 order_exists` with no other field; an order for the same owner and hash with a new commitment refused the same way; an order carrying someone else's public commitment refused without disclosing anything; a bad or other signer's signature refused; two concurrent orders for one owner and hash, one winning; the duration slack before and after `termStart`; `catalog_changed` with the chain price; `name_taken` for registered, reserved and being registered here; `name_check_unavailable`; `sealed` missing or over 11095 and a body over 16384 refused, a longest order accepted; two orders for one name from two wallets both accepted (the later one ends `taken` in T17); cancel answers (`funded`, `not_open`, `200 paid`, `200 expired` with marks, still `open`); a late payment of a closed invoice leaving `unpaid` and alerting; the cleanup deleting an `unpaid` registration after 30 days, sparing one that received a payment, recording a payment found only at cleanup, keeping it when the provider does not answer; the view's `unread` on a failed `intentNonce` read, and on a failed `codeLength` read in `taken`.
|
||||
- **Depends on:** T3, T6, T8.
|
||||
|
||||
#### T12. Orders paid with a prepaid code
|
||||
|
||||
- **Goal:** `POST /api/name/register`.
|
||||
- **Files:** `StoreService/Names/Orders.hs`, `StoreService/Web/Names/Orders.hs` (owned in wave 5); `tests/Bots/StoreService/NamesOrdersTests.hs`.
|
||||
- **Do:** the contract's section 5, in its order of checks, under `withLabelTurn` and `withCodeTurn`; an overdue order of the same wallet and name closed first, as in T11; the code reserved by the conditional update in the transaction that creates the `queued` registration, then a publish.
|
||||
- **Tests:** a code order; an exact resend in any phase, the code spent or not, answered with its id, also with a new timestamp and signature; a resend differing in one field refused `order_exists`; a bad or another signer's signature refused; a stale timestamp on a new order refused; an open invoice for the name refused `order_exists`; each code error, `code_not_covering`; the duration for the code's years; `sealed` over 11095 refused; two concurrent orders spending one code for two names, one winning; the shared `code` limit with the check.
|
||||
- **Depends on:** T9, T11.
|
||||
|
||||
#### T13. The long polls
|
||||
|
||||
- **Goal:** pages follow a registration by its `version`.
|
||||
- **Files:** new `StoreService/Web/Names/Registration.hs` (`GET /api/registration/:id`); `StoreService/Web/Server.hs` (`readInvoiceHandler` takes `seenVersion`; a paid name invoice holds on the version alone; `paid` with `unpaid` answers at once); new `tests/Bots/StoreService/NamesPhasesTests.hs`.
|
||||
- **Do:** the holds of the contract's section 6 on T4's `Phases` and T11's view; a lapsed `committed` read as `failed`.
|
||||
- **Tests:** each change (phase, label set and cleared, reveal times after a re-commit, error) waking a held invoice poll and a held registration poll; an unpaid invoice held on its status waking on expiry; one paid after it closed answered at once; `waitingCount` unchanged by name polls.
|
||||
- **Depends on:** T11.
|
||||
|
||||
#### T14. The reveal
|
||||
|
||||
- **Goal:** `POST /api/registration/:id/reveal`.
|
||||
- **Files:** new `StoreService/Names/Reveal.hs`; `StoreService/Web/Names/Registration.hs` (owned in wave 6); new `tests/Bots/StoreService/NamesRevealTests.hs`.
|
||||
- **Do:** the contract's section 7, in its order of checks, under `withLabelTurn`: the commitment rebuilt locally with `makeCommitment` from the sent links and the stored duration (check 5); `verifyRegisterIntent` plus T3's low-`s` parse and the sponsor nonce from the chain (check 6); only then the links through T7a's `linkKindOf` (check 7), recording `links_refused_at` on `invalid_link` as a compare-and-set write that bumps `version` and publishes; the accepted reveal's write as a compare-and-set on the `version` read, a lost race answering `409 registration_changed` with the current view; bump `version` and publish after the commit. Every request takes one read token, whatever its outcome.
|
||||
- **Tests:** with and without links; an identical repeat in a later phase; a different repeat refused; a high-`s` intent refused; a wrong signer, a stale nonce, a past deadline, a label not matching the hash, a second link for one key (`invalid_link`), a link one byte over the bound (`invalid_link`), a wrong duration (commitment mismatch); a reveal with bad links that do not rebuild the commitment, or with a wrong signer, refused `bad_request` without recording a refusal; a refusal recorded only for the committed links with the owner's signature, waking a held poll; a reveal with two records at the bound passing the body cap; `provider_unavailable` on the nonce read; `invalid_link` recording the refusal; a reveal racing a `/name` (with a stubbed check pause), exactly one taking effect and the other answered `registration_changed` or `order_exists`/`bad_request`; a held poll woken by the label being set.
|
||||
- **Depends on:** T2, T3, T6, T7a, T13.
|
||||
|
||||
#### T15. Owner-signed requests: find, another name, a code instead
|
||||
|
||||
- **Goal:** `find`, `/name` and `/code`.
|
||||
- **Files:** new `StoreService/Web/Names/Owner.hs`; `StoreService/Names/Orders.hs` (the retarget and the taken code; owned in wave 6); new `tests/Bots/StoreService/NamesOwnerTests.hs`.
|
||||
- **Do:** the contract's section 8, each write a compare-and-set on `version`, then publish:
|
||||
- Every lost compare-and-set answers `409 registration_changed` with the current view.
|
||||
- `find` with `labelHash` and `sealed`, the 50 newest.
|
||||
- `/name`: under `withLabelTurns` (the old and the new label hash, in hash order); allowed from `taken` (paid), `failed`, and `committed` only with `links_refused_at` set; a body equal to the current fields answers the view unchanged; at most `max_order_moves` successful moves, counted on success, then `409 move_limit` with the current view (the page shows a support line and the reference); the duration in its message; the order checks for the new fields except this registration (`order_exists`); `price_too_high`; for a code order the code checked and reserved again through T9 under its per-code turn (`code_used`, `code_reserved`, `code_invalid`, `code_expired`, `code_not_covering`); the reveal, transactions and `links_refused_at` cleared.
|
||||
- `/code`: an identical repeat first (the issued code's hash) answers `200 {}` whatever `codeLength` the request carries; otherwise `codeLength` in the body and the signed message, `minLength` from `codeLengthFor`, `409 code_length_changed` with the current value when they differ; `code_unavailable` when none; the order's years; `code_conflict`; `503` on a failed read.
|
||||
- **Tests:** each with a wrong signer and a stale timestamp refused; `find` newest first with retargeted and `unpaid` entries, capped at 50, `not_found`; `/name` from `registered` and from a `committed` order with no refused reveal refused, from `failed` and from `committed` after a refused reveal accepted (the same name with other links too), a repeat of the current fields answered unchanged with no move counted, the fourth move `move_limit` with the current view, meeting another live order `order_exists`, a dearer name `price_too_high` with its price, a cheaper one accepted with the new `price`, a code order to a shorter name `code_not_covering`, a code order after the 30-day sweep re-reserving an unused code and answering `code_used` for one spent elsewhere, `sealed` over 11095 refused; `/name` A→B running concurrently with another order's B→A without deadlock; `/code` repeat answered 200 even after a price change that would move the length, its `minLength` for 6, 7 and 12 letters at the launch prices (6, 7 and 8), `code_length_changed` after a price change, `code_unavailable`, `503` on a failed read, the view's `codeLength` kept in `code_issued`; a held poll woken by `/name` and by `/code`.
|
||||
- **Depends on:** T3, T6, T9, T12, T13.
|
||||
|
||||
### Stage 4: registration on chain
|
||||
|
||||
#### T16. The registrar key's one sender
|
||||
|
||||
- **Goal:** every transaction of the registrar key goes out once, in nonce order, and survives a restart.
|
||||
- **Files:** new `StoreService/Names/Sender.hs`; new `tests/Bots/StoreService/NamesSenderTests.hs`.
|
||||
- **Do:**
|
||||
- One queue for commits, registers and record edits. Each transaction: dry run (`eth_estimateGas`, capped), signed, saved through T8's transaction queries with its raw bytes, hash and registrar nonce, then sent.
|
||||
- Receipts followed; a transaction the node does not know is rebroadcast while its registrar nonce is unused; after a timeout a fee-bumped replacement (same nonce, every hash kept).
|
||||
- Once the nonce is used with no receipt for any hash, the owner of the transaction (worker or relay) is asked to judge it from chain state; nothing is signed again before that.
|
||||
- On startup, every saved transaction without a receipt resumes this way.
|
||||
- **Tests:** two senders' work never sharing a nonce; a fee-bumped replacement mined under its new hash; a restart rebroadcasting a saved raw transaction; a dropped one whose nonce was used, judged from chain state before any new signature.
|
||||
- **Depends on:** T5, T8.
|
||||
|
||||
#### T17. The worker
|
||||
|
||||
- **Goal:** each registration from `queued` to `registered`, `taken` or `failed`, resumable.
|
||||
- **Files:** new `StoreService/Names/Worker.hs`; `StoreService/Service.hs` (start and resume the workers and the daily sweeps); new `tests/Bots/StoreService/NamesWorkerTests.hs`.
|
||||
- **Do:** the plan's stage 4 worker and phases: commit (reading `commitments` first; `UnexpiredCommitmentExists` → `committed`; a lapsed commitment cleared and committed again a second after `revealBefore`); `committed` until `revealAfter` and the reveal; register via `registerWithCredit`; `InvalidNonce` → `committed` with the reveal cleared; `registered` at a status-1 receipt with `ownerOf` the owner, `confirmations` blocks later, with `expires_at`, spending the code (T9); `taken` on `NameNotAvailable`/`NameReserved` or any revert while `available(label)` is false, unless `ownerOf` is the owner, releasing a code; `failed` otherwise, alerting on `InsufficientAllowance`, keeping a code reserved; a commitment not revealed by `revealBefore` → `failed`. Every chain read retried with the phase kept. Every change bumps `version` and publishes. The daily sweep releases codes of registrations `failed` for 30 days.
|
||||
- **Tests:** commit, wait, register; two wallets' paid orders for one name, the later ending `taken` with its value kept; a revert to taken (code released); a restart mid-phase; a commit reverting `UnexpiredCommitmentExists`; a commit out of gas sent again; a commit reverting at the lapse second, sent a second later; a pending commit waited for; `InvalidNonce` clearing the reveal; a lapse to `failed` keeping the code; the code sweep.
|
||||
- **Depends on:** T9, T13, T14, T16.
|
||||
|
||||
#### T18. Try again
|
||||
|
||||
- **Goal:** `POST /api/registration/:id/retry`.
|
||||
- **Files:** `StoreService/Web/Names/Registration.hs`, `StoreService/Names/Worker.hs` (owned in wave 8); `tests/Bots/StoreService/NamesWorkerTests.hs`.
|
||||
- **Do:** the contract's section 9 and the plan's retry steps under `withLabelTurn`, a lost compare-and-set answering `409 registration_changed`, `revealMargin` from `reveal_margin_seconds`, re-reserving a released code through T9; each write a compare-and-set on `version`, then publish.
|
||||
- **Tests** (the plan's `/retry` list): outside `failed` refused; a consumed commitment after a redeem → `registered`; a released code now used elsewhere (the error, still `failed`); a moved sponsor nonce clearing the reveal; a live commitment straight to `committed`; a third party's live re-commit clearing the reveal and `commitTx`; our own live commit after a moved nonce keeping its hashes; less than `revealMargin` left, committed again after `revealBefore`; a requeue clearing `failed_at`, `error` and the register's hashes; a held poll woken by the requeue.
|
||||
- **Depends on:** T9, T17.
|
||||
|
||||
#### T19. The records relay
|
||||
|
||||
- **Goal:** `POST /api/name/records`.
|
||||
- **Files:** new `StoreService/Names/Records.hs`; new `StoreService/Web/Names/Records.hs`; new `tests/Bots/StoreService/NamesRecordsTests.hs`.
|
||||
- **Do:** the contract's section 10 and the plan's relay, in the contract's order of checks: links through T7a's `linkKindOf`; repeats only of sent edits; the deadline, owner and nonce before the per-name cap; the cap counting relayed edits only; the pending row inserted before the edit joins the sender; `{tx}` once mined, `record_reverted`, `504 edit_pending` after 90 seconds or a chain that stops answering after the send, `503 provider_unavailable` before it with nothing stored; "applied" from the edit's own sent hashes first (a status-1 receipt answers that hash), and only without a known receipt from the nonce passed and the record holding the value, else `record_reverted` with `InvalidNonce`; expiry once a block `confirmations` deep is past the deadline.
|
||||
- **Tests** (the plan's `name_record_edits` list): a wrong signer, a high `s` or a passed deadline refused before any row or gas; ten edits signed by a non-owner leaving the owner's cap untouched; a refused edit's repeat checked anew; a pending edit resumed after a restart; one past its deadline freed while its transaction is still rebroadcast; `InvalidNonce` and `SignatureExpired` reverts of an edit another relayer applied answered `{tx: null}`; a nonce consumed by another body with a different value answered `record_reverted` `InvalidNonce`; a restart-expired edit found applied becoming `mined`; an identical repeat after a restart; a concurrent twin losing the unique insert before signing; a reverted edit's repeat answered `record_reverted` while a new edit with its nonce is pending; a revert freeing the nonce; a failed re-read retried with the edit pending; the cap; `edit_pending` and its repeat answered `{tx}` once mined; the link bound.
|
||||
- **Depends on:** T3, T6, T7a, T8, T16.
|
||||
|
||||
### Stages 6 and 7: the service side
|
||||
|
||||
#### T20. Both sites from one service
|
||||
|
||||
- **Goal:** the export and serving of stage 6.
|
||||
- **Files:** `StoreService/Web/Server.hs` (`exportWebapp` writes the names site with the Stripe key; all-in-one mode picks the site by `Host` against `names_hosts`; `contentTypeFor` gains `font/ttf` and `font/woff2` if missing); `scripts/store-service/compose.yml`; new `scripts/store-service/egress.sh`; `apps/simplex-store-service/README.md` (Caddy host blocks; the names CSP with `img-src 'self' data: https://*.stripe.com`; Stripe.js on the page's origin, accepted 2026-10-20); `tests/Bots/StoreService/WebTests.hs`.
|
||||
- **The egress rule** (the real guard behind T7b's best-effort host check): the service's outbound traffic to private, loopback, link-local, unique-local, CGNAT, unspecified and multicast ranges is denied, except the addresses it is configured to use (the RPC node, the database, BTCPay), which the rule lists from the ini. With the service on host networking, `egress.sh` installs it as `iptables`/`ip6tables` rules matching the service's user (`-m owner --uid-owner`); on a Docker bridge network, as rules on that network. The README says how to install and check it.
|
||||
- **Tests:** both sites exported with the key; `Host` picking the site; a names asset served with its type. The egress rule is checked by hand on the deployment: a connection to a private address refused, to the RPC node and the database allowed (README steps).
|
||||
- **Depends on:** T4.
|
||||
|
||||
#### T21. The service against the contract
|
||||
|
||||
- **Goal:** the Haskell service answers the webapp as the mock does.
|
||||
- **Files:** new `scripts/store-service/names-contract-run.sh`; `apps/simplex-store-service/README.md` (how to run it).
|
||||
- **Do:** start anvil and deploy with the webapp's `dev/deploy.ts`; write an ini for that deployment with no payment provider configured (the cases below need no paid order, and the fake providers are in-process test fixtures); seed prepaid codes with `//issue name`; serve the names build all-in-one; run the webapp's e2e cases that need no `/control` endpoint and no payment (search answers, Check your code, a code order to `registered` with links, the reveal from another browser with the name code, Edit links) with `BASE_URL` at the service.
|
||||
- **Acceptance:** those cases pass in Chromium against the service, unchanged from their run against the mock.
|
||||
- **Depends on:** T10–T20 merged (it runs in wave 9, after T18's merge), and the mock's Appendix A changes in the webapp.
|
||||
|
||||
## Waves, owners and merges
|
||||
|
||||
Each wave starts from `sh/store-service` as merged at the end of the last one. "Owns" lists the shared files of the section "Shared files" that the task alone edits in that wave.
|
||||
|
||||
| Wave | Tasks | Owns |
|
||||
|---|---|---|
|
||||
| 1 | T1, T2, T3, T4 | T3: `NameRegistrationTests.hs`. T4 (the wave's integration task): `Web/Server.hs`, `Web/Http.hs`, `Service.hs`, `Names/Turns.hs` |
|
||||
| 2 | T5, T7a, T7b, T8 | T5: `NameRegistrationTests.hs`, `FakeEthNode.hs`. T8: `Names/Store.hs`, the migrations |
|
||||
| 3 | T6, T9, T16 | T9: `Service.hs`. T16: `FakeEthNode.hs` (T6 uses T5's call table as it is) |
|
||||
| 4 | T10, T11, T19 | T11: `Web/Server.hs`, `Web/Http.hs`, `Names/Orders.hs`, `Names/Store.hs`, `Store/Invoices.hs`, `Orders.hs`, `Poller.hs`. T19: `FakeEthNode.hs` |
|
||||
| 5 | T12, T13 | T12: `Names/Orders.hs`, `Names/Store.hs`. T13: `Web/Server.hs`, `Web/Names/Registration.hs` |
|
||||
| 6 | T14, T15, T20 | T14: `Web/Names/Registration.hs`. T15: `Names/Orders.hs`. T20: `Web/Server.hs`, `README.md` |
|
||||
| 7 | T17 | `Service.hs`, `Names/Worker.hs`, `FakeEthNode.hs` |
|
||||
| 8 | T18 | `Web/Names/Registration.hs`, `Names/Worker.hs` |
|
||||
| 9 | T21 | `README.md` |
|
||||
|
||||
Each task is one or more atomic commits (`<scope>: <subject>`, no body) on its own branch. It ends with the build and its `-m "StoreService names: <part>"` tests green in its worktree, and the badge specs green. The manager merges the wave's branches one at a time and runs the build and all `StoreService` specs after each merge.
|
||||
|
||||
## Owner answers (2026-10-21, binding)
|
||||
|
||||
1. **Orders opened from the app** are dropped: the app will buy names itself (#7530 §13.6). Every web order has an owner code and `sealed`; there are no `?name=&owner=` links and no app-signed intents.
|
||||
2. **No resolver service** for the new deployment: the SMP relays and the app read the chain from their own node (reth with nimbus), as the service does. Sequence gap 31 is closed.
|
||||
3. **Launch prices:** $100, $200 and $400 for 2 years, set on chain with `setPrices`: base 5000 cents a year, 7 letters 10000, 6 letters 20000. No per-name prices.
|
||||
4. **365-day years**, showing the real end date: for an order before 1 January 2027, 2 years end on 31 December 2028, 5 years on 31 December 2031.
|
||||
|
||||
Manager rulings on the first review round: orders disclosed only to who made them (contract section 1); per-name and per-code serialization; the dApp line on Check your order, the page before payment (ep, 2026-10-21 08:43, recorded in the owner decisions); prepaid codes and the paid years at least 2; one worktree per task with one global build lock.
|
||||
|
||||
Manager rulings on the third round: an overdue order closed by the next order's transaction, settlement only of a live order, a payment for a closed order a late payment; no address pinning (the pinned simplexmq cannot), a best-effort host check before the core fetches and an egress rule in the deployment, every host of a link checked; T7 split so link kinds land alone in wave 2; a links refusal recorded only after the commitment and intent checks; a `/code` repeat answering the issued code; `move_limit` answering the order's state with a support line; `registration_changed` on every lost compare-and-set; the turns in their own module from the wave-1 integration task.
|
||||
|
||||
Manager rulings on the second round: `/name` only from `failed`, a reveal refused for its links, or `taken`, at most 3 moves per order; `/name` re-checks and re-reserves a code order's code; the reveal joins the per-name serialization with a version compare-and-set; the link host rule (public IP literals allowed, `.onion` and unresolvable hosts `unavailable`; its pinning replaced in the third round); separate search and preview fetch bounds that never wait past their timeout; T21 in its own wave; `live` transitions in one task (the expiry-time rule replaced in the third round by closing the overdue order); worktrees start from a copy of the wave's base build.
|
||||
|
||||
Still open and not backend: the dApp host, and the app's menu path in "Use in the app".
|
||||
|
||||
## Decided here
|
||||
|
||||
- **Label rule:** simplexmq's `SimplexDomain` parser on `label.tld`, not #7390's `validLabel`, which is unmerged and refuses `--` only at positions 3–4. One rule with the relays.
|
||||
- **Prices without the label:** `rentPriceUSD` with a placeholder label of the same length. The deployed oracle reads only the length, and the RPC node never sees the label before the reveal.
|
||||
- **Paid years:** 2, as a setting (`[names] years`, at least 2). No years picker: the mockup shows a fixed term.
|
||||
- **The term check:** `duration` in every order, register and retarget body, checked within 1 hour of `max(0, termStart − now)` and stored; returned in the view so the name code alone rebuilds the commitment; in the order and retarget signatures.
|
||||
- **A new commitment per new order**, with one live order per wallet and name (by commitment, or owner and label hash), enforced by the order lock and unique constraints.
|
||||
- **Signed orders:** the owner signs every order, which proves the owner (so nobody fills another wallet's `find` list) and lets the service refuse every non-identical order with a bare `order_exists`.
|
||||
- **A taken order's code** pays for any name no dearer than the order, for its years: the shortest length from which every length is no dearer, given as `codeLength`, kept after `code_issued`.
|
||||
- **A revoked code** answers `state: revoked`; a `reserved` code names no registration.
|
||||
- **`find` returns `labelHash` and `sealed`**, the 50 newest, so a name code's verdict names its orders in one request.
|
||||
- **The intent is signed at the reveal** (gap 34): the mock and the mockup's copy ("keep this page open, or reopen its link within 24 hours") assume it, and the service learns no name before the commit.
|
||||
- **`/name` from `failed`, `taken`, or a `committed` order whose reveal was refused for its links** (manager's ruling), at most 3 moves, so refused links can be corrected without support and no order can drive endless commits.
|
||||
- **Alain's prototype differences** (gaps 32, 33): the derived secret and `revealBefore` as the deadline stay; agreeing them with Alain is coordination, not an owner question.
|
||||
- **One short link per key** (gap 35), as the mock, the redesign and the app's `firstNameLink` already do.
|
||||
- **Limits** (gap 29): the values in the contract's section 1, every request counted, per-minute windows so `Limit` needs no change, `maxBuckets` 32768, an edit cap of 10 relayed edits an hour per name, `revealMargin` 900 seconds.
|
||||
- **An edit judged applied by nonce and record** (gap 30): kept, with "applied" defined as the nonce passed and the record holding the value.
|
||||
- **`edit_pending` after 90 seconds**, so a records request never outlives a proxy's idle timeout while the edit goes on.
|
||||
- **Long polls on `version`**, so changes inside a phase reach the page at once.
|
||||
- **A name paid for here but not yet `registering`** still searches as available: blocking it earlier would tell searchers it is being bought before the reveal. The later of two buyers ends `taken` with its value kept.
|
||||
Reference in new issue
Block a user