mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 10:47:32 +00:00
plans: names balance contract after review
This commit is contained in:
1 parent
4b7a25373f
commit
d8f4ff5a73
1 file changed
+13
-12
@@ -51,7 +51,7 @@ The service serializes the requests that create or change an order:
|
||||
|
||||
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.
|
||||
In one transaction, rows are locked in this order: the invoice, then the registration, then a prepaid code or a balance. Rows a transaction inserts are new and wait on nobody, so `/spend` may insert its invoice and order after closing a holder (which releases that holder's balance row) and before writing the balance again.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -295,7 +295,7 @@ Both kinds of order, the retarget and the spend from a balance carry:
|
||||
### 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.
|
||||
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`, the code for `/api/name/register`, or the paying order's id, `price`, `balance` and `method` for `/spend`), 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.
|
||||
@@ -376,8 +376,8 @@ A cancel that lost to settlement answers `200` with `status: paid`; the page re-
|
||||
- `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, 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.
|
||||
- `fromBalance`: the cents paid from another order's balance when the order was made (`/spend`, section 8); 0 when none. A move does not change it, so after a move to a cheaper name it can exceed `price`.
|
||||
- `balance`: this order's balance (section 8), null when it has none: `{"amount": 10000, "held": false, "heldUntil": null, "refunded": 0}`. `amount`: the cents left to spend. `held`: an order's open invoice holds the balance; `heldUntil`: that invoice's `expiresAt`, else null. A holder whose invoice is past its expiry time with no money received reads as none (`held` false), as its invoice reads `expired`. `refunded`: the cents support refunded from it. The holder is not named: whoever holds this order's link gets no other order's id from it. The page knows the holder from its own records (the order it made by `/spend`), or finds it with `find`.
|
||||
- `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.
|
||||
@@ -466,7 +466,7 @@ These need the owner code, so a link alone cannot use them. Each takes its limit
|
||||
|
||||
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 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.
|
||||
- `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: the 50 newest, and after them every older one holding a balance above 0, since a balance never expires. `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).
|
||||
|
||||
@@ -482,7 +482,7 @@ Finds a name code's orders. Used by Check your code (the name code's verdict and
|
||||
- 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`, 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 (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": <cents>}`. 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 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": <cents>}`. A cheaper name lowers `price` to its own and adds the difference to the order's balance (Balances, below), in the move's transaction (manager's ruling, 2026-10-22), 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.
|
||||
|
||||
@@ -493,9 +493,10 @@ A paid order whose name someone registered first keeps what it is worth as a bal
|
||||
- **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 <reference> <cents>` (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.
|
||||
- **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 (`held` false), and the next `/spend` of that balance closes that order first, in its own transaction, as section 1 closes an overdue order (below). A refund does not: it waits for the poller's sweep to close the holder.
|
||||
- **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 <reference> <cents>` (task plan, T26) sets the balance to 0 and records the cents; support then pays them back by hand, by the methods that paid them, which the command lists back through every balance the money came from. A held balance is never refunded, overdue holder or not: the command names the holder and its invoice's expiry, and support runs it again once the poller has closed the holder. 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.
|
||||
- **Closing a holder** of another name: `/spend` holds the new name's turn, not the holder's. The close is the compare-and-set on the holder's `version` and the conditional expiry of its unfunded invoice (section 1), so it is safe without that turn. A holder no longer live when the transaction runs (cancelled, swept, or closed by its own name's next order meanwhile) is skipped, and the hold write decides; a holder still live that cannot be closed (its money arrived meanwhile) answers `409 balance_changed` with the current balance.
|
||||
- **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)
|
||||
@@ -523,7 +524,7 @@ It runs under the new name's label hash turn, then the balance's turn. Checks, i
|
||||
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=<id>`.
|
||||
- `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).
|
||||
- When the write refuses (another process changed the balance, or a holder still live could not be closed), 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`.
|
||||
@@ -573,7 +574,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 <support address> with reference <id>", and support records it with the operator command `//refund` (section 8, Balances). No request of the page refunds anything.
|
||||
- **Refunds.** A balance is refunded only by support, on request (owner, 2026-10-22): the page shows "write to chat@simplex.chat with reference <id>" (the page's `CONTACT_EMAIL`, as on every support line; manager's ruling, 2026-10-22), 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`.
|
||||
@@ -619,8 +620,8 @@ Each line: the difference, the choice here, and what changes in the mock (`webap
|
||||
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".
|
||||
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; a refund of a held balance refused; `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** (manager's ruling, 2026-10-22). 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
|
||||
|
||||
|
||||
Reference in new issue
Block a user