diff --git a/plans/names-codes/2026-10-21-store-api.md b/plans/names-codes/2026-10-21-store-api.md index 82dea377e7..c7257b0278 100644 --- a/plans/names-codes/2026-10-21-store-api.md +++ b/plans/names-codes/2026-10-21-store-api.md @@ -4,7 +4,7 @@ This is the HTTP API that StoreService serves to the names webapp at simplex.dom - 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). +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, 2026-10-21 and 2026-10-22, 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). @@ -44,11 +44,14 @@ Any other request that meets an existing order is refused with a tag that names ### 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); +- `POST /api/invoice` (a name), `POST /api/name/register`, `/reveal`, `/name`, `/spend` and `/retry` run one at a time per label hash (`/name` holds the old and the new label hash, taken in hash order; `/spend` holds the new name's); - 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. +- every hold, spending and refund of a balance runs one at a time per balance (section 8), after the label hash's turn and never with a code's; +- 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`, `/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 can still lead to a registration of its name: `unpaid` with an open invoice, `queued`, `committed`, revealing or registering, `failed` (retry is allowed), and a paid `taken` order until it is moved or turned into a code. A `registered` order is not live, so the same wallet can order the name again after it lapses; a code order that ends `taken` is not live. `live` is a stored flag, and every transition that changes it does so in the same transaction. +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, one holder per balance. A registration is live while it can still lead to a registration of its name: `unpaid` with an open invoice, `queued`, `committed`, revealing or registering, and `failed` (retry is allowed). A `registered` order is not live, so the same wallet can order the name again after it lapses; a `taken` order is not live. `live` is a stored flag, and every transition that changes it does so in the same transaction. + +In one transaction, rows are locked in this order: the invoice, then the registration, then a prepaid code or a balance. **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 with no money received (the poller may not have marked it yet; a funded invoice is never closed this way, as the sweep never expires one), 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. @@ -62,7 +65,7 @@ The database backs this with unique constraints: one live registration per commi | `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 | +| `price`, `amount`, `amountPaid`, `fromBalance`, `/spend`'s `balance`, a balance's `amount` and `refunded` | 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 | @@ -77,7 +80,7 @@ The database backs this with unique constraints: one live registration per commi | A link | one SimpleX short link: a contact address `https:///a#…` or `simplex:/a#…`, a channel `https:///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` | +| A request body | 16384 bytes on `/api/invoice`, `/api/name/register`, `/api/registration/:id/name`, `/spend` 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). @@ -104,15 +107,15 @@ The mock reads every body up to 16384 bytes. A body the page builds is never nea ### 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). +- It answers `503 provider_unavailable`, except in orders, `/api/name/register`, `/name` and `/spend`, where it answers `503 name_check_unavailable`. The page reads `provider_unavailable` from `/api/invoice` and `/spend` as "this payment method is unavailable", so orders need their own tag. +- In the registration view, a failed read of `intentNonce` 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. +- **EIP-191** (`"\x19Ethereum Signed Message:\n" ‖ decimal byte length ‖ message`) for orders (section 5), `find`, `/name` and `/spend` (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. @@ -128,7 +131,7 @@ Each limit counts requests per client per minute. It is taken after the route an | 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` | +| create (existing) | 5 | `POST /api/invoice`, `POST /api/invoice/:id/cancel`, `/name`, `/spend`, `/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. A search reads the chain within 10 s, then its profiles within one more fetch timeout, so it answers within about 13 s. @@ -163,7 +166,7 @@ A name is registered while `nameExpires + GRACE_PERIOD` is not past the latest b ``` 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. +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 payment kept as a balance (section 8). Answering otherwise would tell every searcher that the name is being bought before its commitment is revealed. **Registered, on the default resolver contract:** @@ -272,13 +275,13 @@ A code order keeps its code reserved while `failed`, for Try again. A daily swee ### The order fields -Both kinds of order, and the retarget, carry: +Both kinds of order, the retarget and the spend from a balance 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 | +| `owner` | the owner code's address; the retarget and the spend send none, as theirs is the order's | | `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) | @@ -353,7 +356,7 @@ A cancel that lost to settlement answers `200` with `status: paid`; the page re- | `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 | +| `/spend` | answered with the same order, in any phase: its invoice view, or its `registrationId` when it has no invoice | | `/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 | @@ -364,20 +367,21 @@ A cancel that lost to settlement answers `200` with `status: paid`; the page re- ```json {"id": "t2Jk8RmQ4vXw9NcP3yLb6g", "version": 7, "moves": 0, "linksRefused": false, "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, + "years": 2, "duration": 69292800, "price": 10000, "fromBalance": 0, "balance": 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. +- `version`: increases with every change the service makes to the registration (phase, label, intent, transactions, reveal times, error). The long polls hold on it. A change of the order's balance is not a change of the registration: it leaves `version` as it is (section 8, Balances). - `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. +- `price`: what the order is worth in cents: the price paid, any part from a balance included, or after `/name` the new name's price, which caps a further move. 0 for a code. A paid order that ends `taken` keeps it as its balance. +- `fromBalance`: the cents of `price` paid from another order's balance (`/spend`, section 8); 0 when none. +- `balance`: this order's balance (section 8), null when it has none: `{"amount": 10000, "reservedBy": null, "refunded": 0}`. `amount`: the cents left to spend. `reservedBy`: the id of the order whose open invoice holds the balance, else null; a holder whose invoice is past its expiry time with no money received reads null, as its invoice reads `expired`. `refunded`: the cents support refunded from it. - `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. +- `unread`: the fields left null because their chain read failed (`intentNonce`). 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). - `linksRefused`: true once a reveal was refused for its links (section 7), so every browser, not only the one that revealed, offers `/name` with corrected links; false again after a successful `/name`. - `expiresAt`: the name's `nameExpires`, once `registered`. @@ -393,6 +397,7 @@ The badge invoice's fields, with `invoiceId`, `kind: "name"` and the registratio "address": "bc1q…", "cryptoAmount": "0.00156", "cryptoCurrency": "btc", "requiredConfirmations": 1} ``` - Card: `clientSecret` instead of the crypto fields. +- For an order paid partly from a balance (`/spend`), `amount` is the difference only; the registration's `price` and `fromBalance` give the whole. - After a payment is read: `amountPaid`, `cryptoAmountPaid`, `cryptoAmountDue`, `paidInFull`, and `settledAt` once `paid`. - `status`: `open`, `paid` or `expired` (a cancel leaves `expired`). @@ -420,11 +425,10 @@ Both take the read limit. Errors: `404 not_found`, `429 rate_limited`. | `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 | +| `taken` | someone registered the name first | final. A paid order's `price` becomes its balance in the same write (section 8); a code order's code is released (section 4) | | `failed` | the service's retries gave up once none of its register transactions can still be mined (each mined, or its nonce taken by another mined transaction), 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. +The page follows `failed` too, since another browser can move it on. A `taken` order changes no more; its balance is read from its view. ## 7. The reveal @@ -456,13 +460,13 @@ A failed check changes nothing, with one exception: a reveal refused at check 7 ## 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. +These need the owner code, so a link alone cannot use them. Each takes its limit (find; create for `/name` and `/spend`) 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 `. -- `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. +- `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 an order without an invoice: a code order, or one paid wholly from a balance. Balances are read from each order's view, not from `find`. `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). @@ -474,23 +478,55 @@ Finds a name code's orders. Used by Check your code (the name code's verdict and - Message: `simplex.domains name `. 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. +- Allowed only (manager's ruling, 2026-10-21; `taken` removed by the owner's decisions of 2026-10-22, which keep a taken order's payment as its balance): 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, nor from `taken`. Else `bad_request`. The new name may be the same name with other links. - From `failed`, `/name` first runs `/retry`'s check 1 (section 9) on the current name: a register that went through makes the order `registered`, and `/name` answers `409 registration_changed` with that view, so one payment never registers two names. A failed chain read in that check answers `503 name_check_unavailable`. -- 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. +- 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`, 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": }`. A cheaper name refunds nothing, which the page says before the buyer chooses. +- A paid order (by invoice, from a balance, or both): the new name's chain price for the registration's `years` must be at most its `price`, else `409 {"error": "price_too_high", "price": }`. A cheaper name lowers `price` to its own and adds the difference to the order's balance (Balances, below), in the move's transaction, 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, it is kept, even if revoked or expired since (as `/retry` does); released and still unused, it is reserved again; spent elsewhere after the sweep released it is `409 code_used`; held by another order `409 code_reserved`; released and then revoked `400 code_invalid`, or 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 (`linksRefused` false). 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) +### Balances -- Message: `simplex.domains code `. -- The page derives the code from the owner code (payload from `HMAC-SHA256(seed, "simplex.domains taken code " + registrationId)`) as `SN-…`, 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": }` 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`. +A paid order whose name someone registered first keeps what it is worth as a balance, which buys another name (owner, 2026-10-22). There is no code for a taken order. + +- **What it is:** US cents, held per order and owned by that order's owner code. Balances are never merged, across orders or wallets: one order's balance pays for one new order at a time. A balance never expires. +- **Credited:** with the order's `price`, in the write that makes a paid order `taken` (paid by invoice, from a balance, or both), so a `taken` order with a payment always has a balance. A move to a cheaper name adds the difference (`/name`, above). A code order gets none: codes carry no money, and its code is released (section 4). +- **Spent:** only by `/spend` (below), signed by the owner, for a new order of the same owner. When the balance covers the new name's price, the price is spent at once and the rest stays as balance. When it does not, all of it pays, and an invoice is made for the difference. +- **Held:** while that invoice is open, the new order holds the balance: no other spend and no refund takes it. Settlement queues the new order and spends what it held, in one transaction. Whatever closes the invoice unpaid (a cancel, its expiry, the poller's sweep, a next order closing it as overdue, section 1) releases the hold in the transaction that closes the order. A payment that arrives after that is a late payment (section 1); the balance is not touched. A hold by an order whose invoice is past its expiry time with no money received reads as free (`reservedBy` null), and the next `/spend` or refund of that balance closes that order first, in its own transaction, as section 1 closes an overdue order. +- **Refunded:** only by support, when the buyer writes with the order's reference (its invoice id, or its registration id for an order without an invoice). The operator command `//refund ` (task plan, T26) sets the balance to 0 and records the cents; support then pays them back by hand. A held balance is not refunded. There is no refund endpoint, no automatic refund, and no refund address given to the service. +- **Concurrency:** each hold, spending and refund is one conditional write on the balance row (its `amount` as read, and no holder, or this holder), in the balance's turn (section 1). A writer outside the turn (settlement, the poller, a second process running the operator command) can only make the write refuse, never spend a balance twice. Every write is in the transaction of the order change it belongs to, so a restart finds both or neither. +- **Seen in** the view's `balance` (section 6). A balance change leaves the order's `version` unchanged: the page reads the view again, without `seenVersion`, to see it. + +### `POST /api/registration/:id/spend` (another name, paid from this order's balance) + +```json +{"labelHash": "0x…", "length": 9, "duration": 69292800, "commitment": "0x…", "sealed": "…", + "price": 20000, "balance": 10000, "method": "btc", "timestamp": 1792828800, "signature": "0x…"} +``` + +- `:id`: the order whose balance pays. Its owner is the new order's owner, so the same name code owns the new name; an `owner` in the body is ignored. +- Message: `simplex.domains spend `. +- The order fields are built as in section 5, for the service's `years`. `price`: the new name's price from the search answer. `balance`: the `amount` of the balance as the page read it. `method`: `card`, `btc` or `xmr`, present exactly when `price` is more than `balance`. + +It runs under the new name's label hash turn, then the balance's turn. Checks, in order: +1. The create limit; the body cap; formats, `sealed` present and at most 11095 characters, `price` and `balance` positive integers, and `method` present exactly when `price > balance`, else `bad_request`; `length` in 6–63, else `invalid_name`. +2. The order at `:id`, else `404 not_found`. +3. The signature recovers that order's owner, else `bad_request`. +4. Existing orders (section 5): an exact resend, by `order_hash` over the order fields, the owner, `:id`, `price`, `balance` and `method`, is answered with that order as it is now, in any phase; 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 service's `years`, else `bad_request`. +6. The balance: its `amount` equals `balance` and no open order holds it (an overdue holder counts as none), else `409 {"error": "balance_changed", "balance": {…the current balance, or null}}`. +7. The chain: `409 name_taken`, or `503 name_check_unavailable`. +8. The price: the chain price for `years` must equal `price`, else `409 catalog_changed` with the chain's `price`. +9. For a difference, the payment provider: unavailable is `503 provider_unavailable`. + +Answers: +- `price ≤ balance`: `200 {"registrationId": "…"}`. In one transaction, an overdue holder is closed, the new order is written `queued`, with `price` its name's and `fromBalance` equal to it, and `price` is spent from the balance; the rest stays. The page opens `?registration=`. +- `price > balance`: `200` the name invoice view of section 6, its `amount` the difference `price − balance`, its registration `unpaid` with `fromBalance` equal to `balance`. In one transaction, an overdue holder is closed, and the invoice, the new order and its hold are written. +- When the write refuses (another process changed the balance), the answer is `409 balance_changed`, and an invoice made at the provider is cancelled there, as for an order that lost (section 5). +- A paid order made this way is followed, retried, moved and ends like any paid order; ending `taken`, it gets a balance of its own, its whole `price`. + +Errors: `400 bad_request`, `400 invalid_name`, `404 not_found`, `409 order_exists`, `409 balance_changed`, `409 name_taken`, `409 catalog_changed`, `429 rate_limited`, `503 name_check_unavailable`, `503 provider_unavailable`. ## 9. Try again @@ -537,6 +573,7 @@ After `{tx}` the page signs the next edit with the next nonce; after `record_rev - **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. +- **Refunds.** A balance is refunded only by support, on request (owner, 2026-10-22): the page shows "write to with reference ", and support records it with the operator command `//refund` (section 8, Balances). No request of the page refunds anything. - **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`. @@ -551,7 +588,7 @@ Each line: the difference, the choice here, and what changes in the mock (`webap 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 `` after ``. 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`. +6. **A taken order's code.** Withdrawn by the owner's decisions of 2026-10-22: there is no code for a taken order (item 36). 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. @@ -567,44 +604,46 @@ Each line: the difference, the choice here, and what changes in the mock (`webap 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. +22. **`unread`.** Chosen: a failed `intentNonce` 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: 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`. No longer from `taken` (item 38). 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. +27. **`/name` bounds.** Chosen: from `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. +30. **`/code` carries `codeLength`.** Withdrawn with `/code` (item 36). 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 with no money received, in its own transaction; settlement queues only a live order; a payment for a closed order is a late payment. Mock: expire the old unfunded 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`. +33. **`/code` repeats.** Withdrawn with `/code` (item 36). 34. **`linksRefused`** in the registration view, set when the reveal records a links refusal and cleared by a successful `/name`. Mock: in the view. Page: offer `/name` with corrected links from the view, not only from its own record. 35. **A held code on `/name`.** A code still reserved by its order is accepted on `/name` even if revoked or expired since, as on `/retry`. Mock: in `retarget`. +36. **No code for a taken order** (owner, 2026-10-22). Chosen: `POST /api/registration/:id/code`, the phase `code_issued`, the view's `codeLength` and the tags `code_length_changed`, `code_unavailable` (and `code_conflict` on names) are gone. Mock: drop `takenCode`, its route, `codeLengthFor`, `issuedCodeLength` and the phase. Page: drop `takenOrderCode`, `takeCodeFor`, `takenCodeOf`, `api.takeCode`, the record's `takenCode`, Take a code, Here is a code instead, the `code-issued` kind and the "Code taken" chip, and their tests. +37. **Balances** (owner, 2026-10-22). Chosen: a paid order that ends `taken` keeps its `price` as a balance; `POST /api/registration/:id/spend` buys another name with it, paying only the difference; the view's `balance` and `fromBalance`; `409 balance_changed`; refunds by support only (section 8). Mock: a balance per order, credited when a paid order is taken; `spend`; settlement spends a hold and every close of the invoice releases it; a hold by an overdue order reads free; `POST /control/refund/:reference` for the tests. Page: Your names shows each balance with Use, and Refund, a popup to write to support with the reference; the search and Check your order show the balance and "Pay $X more" (or what stays); the taken page says the payment is kept as a balance; the payment page of a difference invoice says what the balance paid. +38. **`/name` not from `taken`; a cheaper move keeps the difference.** Chosen: a `taken` order is final and not live; a move to a cheaper name adds the difference to the order's balance. Mock: in `retarget`. Page: Choose another name leaves the taken page (Use the balance replaces it); for a failed order, "the rest is not refunded" becomes "the rest stays as your balance". ## Appendix B. Error tags | Tag | Status | Extra | Where | |---|---|---|---| | `bad_request` | 400 | | any | -| `invalid_name` | 400 | | search, orders, `/name` | +| `invalid_name` | 400 | | search, orders, `/name`, `/spend` | | `invalid_link` | 400 | | preview, reveal, records | | `code_invalid` | 400 | | code check, register, `/name` (only for a released code), retry | -| `not_found` | 404 | | unknown ids, `find` with no orders | +| `not_found` | 404 | | unknown ids (`/spend`'s order included), `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` | +| `order_exists` | 409 | | orders, register, `/name`, `/spend` | +| `name_taken` | 409 | | orders, register, `/name`, `/spend` | +| `catalog_changed` | 409 | `price` | `/api/invoice`, `/spend` | +| `balance_changed` | 409 | `balance` (the current balance, or null) | `/spend` | | `price_too_high` | 409 | `price` | `/name` | | `code_used`, `code_reserved`, `code_expired`, `payment_pending`, `code_not_covering` | 409 | | register, `/name` (a code order; `code_expired` only for a released code), 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` | +| `registration_changed` | 409 | `registration` (the current view) | reveal, `/name`, `/retry`, cancel | | `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` | +| `provider_unavailable` | 503 | | search, cancel, invoice and `/spend` (payment method), reveal, retry, records | +| `name_check_unavailable` | 503 | | orders, register, `/name`, `/spend` | | `edit_pending` | 504 | | records |