diff --git a/docs/cashu/03-track-b-release.md b/docs/cashu/03-track-b-release.md new file mode 100644 index 00000000..3db35843 --- /dev/null +++ b/docs/cashu/03-track-b-release.md @@ -0,0 +1,293 @@ +# Cashu Escrow — Track B: Release (Happy Path) + +**Status:** Draft for review · **Target:** `main` (**requires `mostro-core ≥ 0.14.0`**) · +**Depends on:** Fundamentals **CF-1, CF-2, CF-5** + **Track A** (the escrow must be +locked before it can be released) · **Feature flag:** `[cashu].enabled` + +Track B is the **happy-path settlement** of a Cashu trade: the buyer confirms +fiat sent, the seller releases, and the buyer redeems the locked 2-of-3 token +with **two signatures** (its own + the seller's). It is "box 3" of the sequence +diagram in [`../CASHU_ESCROW_ARCHITECTURE.md`](../CASHU_ESCROW_ARCHITECTURE.md). + +This document assumes Fundamentals and Track A are merged. It only adds behaviour +*inside the Cashu branch*; the Lightning path is never changed. + +--- + +## 1. Goal and scope + +### Goal +Complete a locked Cashu trade without Mostro ever touching the funds: +1. The **buyer** signals `FiatSent` (gated by the remaining-locktime guard, §4B). +2. The **seller** signals `Release`, and delivers its **Cashu release signature** + directly to the buyer (P2P NIP-59 DM) so the buyer can build a valid 2-of-3 + `SwapRequest` and redeem the ecash **itself**. +3. Mostro **advances the order state** (`Active → FiatSent → SettledHoldInvoice + → Success`), where the final step is driven by a **daemon-verifiable fact** + — the escrow proofs are observed **spent at the mint** before the locktime — + not by the seller's word. It then makes the trade **rateable** and, because + the fee was collected at lock (Track A §4A), takes **no** settlement action + on the funds. + +### In scope +- The Cashu branch of `fiat_sent_action` carrying the **§4B remaining-locktime + guard** (`escrow_settlement_margin_days`, default 3), with its startup + validation. +- The Cashu branch of `release_action`: the seller marks release, the daemon + advances `FiatSent → SettledHoldInvoice` and notifies the buyer. +- The **release watcher** (§5C): a cashu-mode scheduler job that confirms the + redeem at the mint and drives `SettledHoldInvoice → Success`, making the + order rateable. +- Unblocking `FiatSent`, `Release`, and `RateUser` in `dispatch_cashu`. + +### Out of scope (other tracks) +- **Cooperative cancel** → Track C. **Dispute resolution** → Track D (including + the buyer's recourse when a seller "releases" but never delivers a usable + signature — §9 raises it). +- The **live fee redeem** (Track A TA-1f follow-up) — orthogonal to release. +- Any change to the Lightning release/settle path. + +--- + +## 2. Where Track B sits — flow and state transitions + +```mermaid +sequenceDiagram + participant B as Buyer + participant M as Mostro (cashu mode) + participant S as Seller + participant Mint as Cashu Mint + + Note over B,S: Escrow already locked (Track A) — order is Active + B->>M: FiatSent + M->>M: guard: remaining locktime >= escrow_settlement_margin_days + M->>S: FiatSentOk (buyer paid — release when ready) + S->>B: Release signature (NIP-59 DM, seller signs SIG_INPUTS) + S->>M: Release + M->>M: advance FiatSent -> SettledHoldInvoice + M->>B: Released (seller released — redeem now) + B->>Mint: SwapRequest {2-of-3 proofs, buyer_sig + seller_sig, buyer outputs} + Mint-->>B: fresh ecash (buyer holds the funds) + M->>Mint: check_state(escrow proof Ys) [release watcher, periodic] + Mint-->>M: SPENT (before locktime) + M->>M: advance SettledHoldInvoice -> Success; make rateable +``` + +**State transitions Track B performs:** +`Active → FiatSent` (buyer), `FiatSent → SettledHoldInvoice` (seller release), +then `SettledHoldInvoice → Success` (watcher observes the redeem). This is the +exact status sequence of the Lightning flow (`release_action` already moves +`FiatSent → SettledHoldInvoice` and the payout moves it to `Success`), so the +public NIP-33 mapping and every status consumer are unchanged — but **no hold +invoice is settled and no payment is made**: the buyer redeems the token +itself, off Mostro's servers. + +> **Why the seller's signature goes P2P, not through Mostro — and why Mostro +> must not relay it.** With `SIG_INPUTS` the seller's signature authorises a +> spend of the proofs with *any* outputs. Mostro already holds `P_M`; if it also +> held the seller's `P_S` signature it would have **two of three** and could swap +> the escrow to itself. Keeping the seller signature strictly seller→buyer +> (NIP-59 DM over the trade keys, the same channel the `release`/`cancel` +> messages already use) is what keeps Mostro non-custodial on the happy path. +> Consequently Mostro cannot *validate the signature*; what it can validate is +> the **outcome** — the proofs' NUT-07 state at the mint — which is how `Success` +> is reached (§5C). + +--- + +## 3. What Track B consumes from Fundamentals + Track A + +| Needs | From | Exact item | +|-------|------|------------| +| Mode gate | CF-1 | `Settings::is_cashu_enabled()`, `escrow_mode()` | +| Locktime floor / margin | CF-1 | `get_cashu().escrow_locktime_days`; **new** `escrow_settlement_margin_days` (default 3, §4) + `validate_cashu_settings` check | +| Locked escrow row | Track A | `Order.{cashu_escrow_token, cashu_escrow_locked_at}` populated, status `Active` | +| Token locktime read | CF-2 | parse the stored token's `locktime` (the guard compares it to now) | +| Proof state at the mint | CF-2 | `CashuClient::check_state(ys)` (NUT-07) — the release watcher's source of truth | +| Dispatch seam | CF-5 | `FiatSent`/`Release`/`RateUser` arms in `dispatch_cashu` | +| Notifications | existing | `enqueue_order_msg`, `update_order_event` | +| Cashu-mode scheduler slot | TA-1f / TA-3 | the cashu-gated job family in `scheduler.rs` (fee-redeem retry, lock monitor); the watcher is one more member | + +Protocol (already on `main`, `mostro-core ≥ 0.14.0`): `Action::{FiatSent, +FiatSentOk, Release, Released, RateUser}` and the existing rating payloads — +**no new protocol variant is required**. The seller's Cashu release signature is +carried in the existing P2P release message shape (trade-key-signed NIP-59 DM); +Mostro validates the *state transition* and the *redeem outcome*, never the +swap itself. + +--- + +## 4. The `escrow_settlement_margin_days` guard (§4B — Track B executes it) + +Track A §4B defines the attack: a seller stalls the fiat phase until little +locktime remains, lets the buyer send fiat on day 13 of 15, goes silent, and +reclaims via the refund path on day 15 — keeping both fiat and sats without +failing a single protocol check. **Track B closes it at the `FiatSent` gate.** + +- New `#[serde(default)]` key on `CashuSettings`: **`escrow_settlement_margin_days`, + default 3**. Added by Track B (not needed during foundation). +- **Startup validation** (extend `validate_cashu_settings`, `src/config/util.rs`, + which already rejects invalid `escrow_locktime_days`): when Cashu is enabled, + require `1 <= escrow_settlement_margin_days < escrow_locktime_days`. Anything + else is a **startup-fatal** config error — a margin at or above the locktime + floor would reject *every* `FiatSent` on a minimum-locktime token, silently + disabling the marketplace. Because the seller MAY set a longer locktime than + the floor (Track A §4B), the guard always compares against the **token's + actual** locktime, never the configured floor. +- In the Cashu branch of `fiat_sent_action`: read the stored escrow token's + `locktime` and evaluate, in **unsigned seconds with saturating arithmetic**: + + ```text + remaining = locktime.saturating_sub(now) // 0 when already expired + reject if remaining < escrow_settlement_margin_days * 86_400 + ``` + + - token or `locktime` missing/unparseable ⇒ `CantDo(CashuEscrowNotLocked)` + (an `Active` Cashu order always has both; their absence is a data bug, logged + at `error`); + - `remaining == 0` (locktime already passed — the seller can reclaim alone) or + `remaining < margin` ⇒ reject. TB-1 ships this with the existing + `CantDoReason::NotAllowedByStatus` so no protocol bump is needed; a dedicated + `CantDoReason::CashuSettlementWindowClosed` is recorded as an **additive** + `mostro-core` follow-up (clients already have to handle an unknown reason + generically). + + Saturating subtraction means an expired locktime can never underflow into a + huge "remaining" value that lets fiat through; the expired case collapses into + the rejected branch by construction. Fiat can never be sent inside the danger + window, so the seller cannot weaponise the locktime. + +With the defaults (15-day floor, 3-day margin) the usable fiat-settlement window +on a minimum-locktime token is ≈12 days. + +--- + +## 5. Handlers — the Cashu branches + +### 5A · `fiat_sent_action` (Cashu branch) +Same identity/status checks as today (only the **buyer** may send fiat; order +must be `Active`). Then, in Cashu mode only: apply the §4 guard, advance +`Active → FiatSent`, publish the order event, and notify both parties +(`FiatSentOk`) — no LND, no hold-invoice interaction. + +### 5B · `release_action` (Cashu branch) +The submitter must be the **seller** (same identity check as the Lightning +release); the order must be `FiatSent` (or `Dispute`, exactly as today). In +Cashu mode: **do not** settle a hold invoice (there is none); instead advance +`FiatSent → SettledHoldInvoice` with the **same conditional `UPDATE … WHERE +status IN (FiatSent, Dispute)`** the Lightning branch already uses +(`release.rs`), publish the order event, and notify the buyer with `Released` +("the seller released — redeem now"). Mostro MAY additionally relay the +seller's *message* as a reliability aid, but it **never carries or stores the +seller's Cashu signature** (§2 callout). + +`SettledHoldInvoice` here means exactly what it means on Lightning: *the seller +has done their part; the funds have not yet reached the buyer.* The order is +**not** terminal and **not** rateable yet. + +### 5C · Release watcher — `SettledHoldInvoice → Success` on observed redeem +A cashu-gated scheduler job (same family as the TA-1f fee-redeem retry and the +TA-3 lock monitor) that, every tick, selects orders with +`status = SettledHoldInvoice AND cashu_escrow_token IS NOT NULL`, computes the +escrow proofs' `Y = hash_to_curve(secret)` from the stored token, and calls +`CashuClient::check_state(ys)` (NUT-07): + +- **All proofs `SPENT` and `now < locktime`** ⇒ the only spend path open before + the locktime is the 2-of-3, and Mostro did not sign, so the spender is the + buyer (with the seller's signature). Advance `SettledHoldInvoice → Success` + with a conditional `UPDATE … WHERE status = SettledHoldInvoice`, publish the + order event, notify both parties (`PurchaseCompleted`), and send the `Rate` + requests — the trade is now rateable. +- **Any proof `UNSPENT`** ⇒ nothing to do; the buyer has not redeemed yet. The + TA-3 monitor's locktime warnings to the buyer keep firing. +- **`SPENT` observed only after the locktime** ⇒ ambiguous (a late buyer redeem + or the seller's §4B reclaim are indistinguishable from the mint's answer). + Never auto-`Success`; log at `warn`, leave the order in `SettledHoldInvoice` + for the dispute path (§9 → Track D). +- **Mint unreachable** ⇒ skip, retry next tick (the order stays eligible). + +The watcher is the **only** path to `Success` in Cashu mode. A seller who sends +`Release` but never hands the buyer a usable signature therefore leaves the +order in `SettledHoldInvoice`, where the buyer can still open a dispute (Track D +admits `Dispute` from `SettledHoldInvoice` in Cashu mode — raised in §9) and a +solver can hand the buyer a `P_M` signature instead. Mostro never marks a +trade successful that the buyer could not complete. + +### 5D · `dispatch_cashu` unblocks +Replace the `InvalidAction` arms for `FiatSent`, `Release`, and `RateUser` +(route through the existing `handle_message_action_no_ln`, whose branches now +carry the Cashu logic). `RateUser` keeps its existing `Success`-only gate, which +in Cashu mode is reachable only through the watcher. No other action changes. + +--- + +## 6. PR breakdown (atomic, backwards-compatible) + +### TB-1 · `escrow_settlement_margin_days` + `FiatSent` guard +Add the `CashuSettings` key, its `validate_cashu_settings` check, and the Cashu +branch of `fiat_sent_action` with the §4 remaining-locktime guard (saturating +arithmetic; expired ⇒ rejected). Unblock `FiatSent` in `dispatch_cashu`. +*Depends on CF-1, Track A. Conflict surface: `config/*`, `fiat_sent.rs`, +`app.rs` (one dispatch arm).* + +### TB-2 · `release_action` Cashu branch + release watcher + rating +Add the Cashu branch of `release_action` (`FiatSent → SettledHoldInvoice`, +notify the buyer, no signature relay), the §5C release watcher +(`SettledHoldInvoice → Success` on observed spend before locktime), and unblock +`Release` + `RateUser`. Completes the happy path end-to-end with TB-1 and +Track A. +*Depends on CF-2 (`check_state`), CF-5, Track A, TB-1 (for a full e2e test). +Conflict surface: `release.rs`, `scheduler.rs` (additive, cashu-gated), +`rate_user.rs` (if touched), `app.rs` (two dispatch arms).* + +--- + +## 7. Issues table — sequential vs parallel + +| ID | Title | Depends on | Parallel with | Conflict surface | Risk | +|----|-------|-----------|---------------|------------------|------| +| **TB-1** | `escrow_settlement_margin_days` + `FiatSent` §4B guard | CF-1, Track A | Tracks C/D | `config/*`, `fiat_sent.rs` | Low-Medium | +| **TB-2** | `release_action` Cashu branch + release watcher + unblock `Release`/`RateUser` | CF-2, CF-5, Track A, TB-1 | Tracks C/D | `release.rs`, `scheduler.rs`, `app.rs` | Medium | + +**Ordering.** Track B has **no merge-order dependency** on Tracks C or D in +either direction: nothing in B calls the Track C refund helper, and nothing in +C/D needs the watcher. "Parallel" here means *disjoint handler files*; the +shared touch points are the `dispatch_cashu` allow-list (edited one arm at a +time) and the cashu-gated job list in `scheduler.rs` (additive). Track D does +take one **behavioural** input from B — `Dispute` must be admitted from +`SettledHoldInvoice` in Cashu mode (§9) — which D implements whether it lands +before or after B. + +--- + +## 8. Definition of Done + +1. A locked Cashu order can be driven `Active → FiatSent → SettledHoldInvoice → + Success` end-to-end against the CF-3 mint, with the buyer redeeming the 2-of-3 + token itself and the watcher observing the spend. +2. `FiatSent` is rejected inside the settlement-margin window (§4) **and** when + the locktime has already passed, and accepted outside it; the exact + `CantDoReason` is asserted. A margin `>= escrow_locktime_days` (or `0`) is + rejected at startup by `validate_cashu_settings`. +3. `Release` alone never produces `Success`: with the proofs still `UNSPENT` the + order stays in `SettledHoldInvoice` and is not rateable; a `SPENT` answer + observed only after the locktime is logged and does **not** advance the + order. +4. Every identity/status rejection path returns the correct reason and leaves the + order unchanged (wrong sender, wrong status, guard tripped). +5. The trade becomes rateable only after the watcher reaches `Success`; + `RateUser` works then and is rejected before. +6. With Cashu disabled, behaviour is identical to `main`; existing tests pass + unmodified. `fmt`/`clippy -D warnings`/`test` green. + +--- + +## 9. Cross-track obligations satisfied / raised + +| Obligation | Defined in | Track B does | +|------------|-----------|--------------| +| `FiatSent` rejected when remaining locktime < `escrow_settlement_margin_days` (incl. already-expired) | Track A §4B | **Executed** (TB-1) | +| `RateUser` unblocked once terminal state reachable | CF-5 §6 | **Executed** (TB-2) | +| Buyer locktime warnings as expiry approaches | Track A §4B | Surfaced by TA-3 monitor; TB may add a nudge on `FiatSent` | +| `Dispute` admitted from `SettledHoldInvoice` in Cashu mode — the buyer's recourse when the seller releases but never delivers a usable signature (`dispute_action` today admits only `Active`/`FiatSent`) | **Raised here** (§5C) | **Executed by Track D** (TD-2, `dispute_action` Cashu branch) | +| `Success` is reached only on an observed mint spend before locktime, never on the seller's message alone | **Raised here** (§5C) | **Executed** (TB-2 watcher); Track D reuses the same watcher for `SettledByAdmin → CompletedByAdmin` | diff --git a/docs/cashu/04-track-c-coop-cancel.md b/docs/cashu/04-track-c-coop-cancel.md new file mode 100644 index 00000000..d15ef567 --- /dev/null +++ b/docs/cashu/04-track-c-coop-cancel.md @@ -0,0 +1,407 @@ +# Cashu Escrow — Track C: Cooperative Cancel + +**Status:** Draft for review · **Target:** `main` (**requires `mostro-core ≥ 0.14.0`**) · +**Depends on:** Fundamentals **CF-1, CF-2, CF-5** + **Track A** (the escrow must be +locked before it can be cancelled) · **Feature flag:** `[cashu].enabled` + +Track C is the **cooperative unwind**: both parties agree to abandon a locked +trade, the buyer hands the seller the signature needed to reclaim the escrow, and +Mostro records the cancellation and **refunds the seller the fee** it collected at +lock. No dispute, no arbitrator signature on the *escrow*. + +This document assumes Fundamentals and Track A are merged. It only adds behaviour +*inside the Cashu branch*; the Lightning path is never changed. + +--- + +## 1. Goal and scope + +### Goal +Let a locked Cashu trade be cancelled by mutual consent without Mostro moving the +escrow: +1. Either party requests `Cancel`; when **both** have requested it (the existing + cooperative-cancel handshake), the trade is cancelled. +2. The **buyer** delivers its **Cashu signature** directly to the **seller** (P2P + NIP-59 DM) so the seller can build a 2-of-3 `SwapRequest` + (`buyer_sig + seller_sig`) and **reclaim** the locked ecash itself. +3. Mostro advances the order to **`CooperativelyCanceled`** — the same internal + terminal status the Lightning handshake persists today + (`cancel_cooperative_execution_step_2`, `src/app/cancel.rs`), which NIP-33 + already publishes as `canceled` — and, because the fee was collected at lock + (Track A §4A), **refunds the seller** the whole Mostro fee (`2 * order.fee`) + through the single-shot contract in §4. + +### In scope +- The Cashu branch of `cancel_action` (cooperative handshake → + `CooperativelyCanceled`). +- The **fee-refund obligation** (Track A §4A), executed through one shared, + crash-safe helper (§4) that Track D reuses. This is the first track to execute + the refund obligation. +- The **unfunded-take timeout** (§5a, TC-2): recovery for a take whose escrow is + never locked. +- Unblocking `Cancel` in `dispatch_cashu`. + +### Out of scope (other tracks) +- **Unilateral / dispute-driven** cancellation of a **locked** escrow → Track D + (`admin_cancel`). A take that was **never locked** has no escrow to arbitrate, + so it belongs to neither Track D nor the cooperative handshake above — its + recovery is the TC-2 timeout job (§5a, raised by the TA-2 review). +- **Release** happy path → Track B. +- The **ecash revenue store** (Track A TA-1f follow-up) — needed only for the + *already-redeemed* fee path (§4); Track C ships the *unredeemed* path, which + needs no store. + +--- + +## 2. Where Track C sits — flow and state transitions + +```mermaid +sequenceDiagram + participant B as Buyer + participant M as Mostro (cashu mode) + participant S as Seller + participant Mint as Cashu Mint + + Note over B,S: Escrow already locked (Track A) + B->>M: Cancel + S->>M: Cancel + M->>M: both requested -> cooperative cancel + B->>S: Buyer signature (NIP-59 DM) + M->>M: advance -> CooperativelyCanceled (CAS) + M->>M: refund_cashu_fee: claim (CAS) -> sign_with_pm(fee token) + M->>S: CashuPmSignature { fee-token proofs } (fee refund) + S->>Mint: SwapRequest {2-of-3 proofs, buyer_sig + seller_sig, seller outputs} + Mint-->>S: reclaimed escrow + S->>Mint: SwapRequest {fee proofs, P_M sig, seller outputs} + Mint-->>S: reclaimed fee +``` + +**State transition Track C performs:** the existing cooperative-cancel handshake +drives the order to `CooperativelyCanceled` (internal), published as `canceled` +by the existing NIP-33 mapping (`src/nip33.rs`). `Status::Canceled` is reserved, +as today, for the non-cooperative paths (maker cancel of a pending order, the +timeout job) and `CanceledByAdmin` for Track D — code that distinguishes a +completed handshake from an ordinary cancellation (`admin_settle`, +`admin_cancel`, the bond slash tables) keeps working unchanged. No hold invoice +is cancelled — the seller reclaims the token itself with the buyer's signature. + +> **Why the buyer signs for the seller here.** On the happy path the *seller* +> signs so the *buyer* redeems; on a cancel the roles invert — the funds return to +> the seller, so the *buyer* provides the second signature that lets the *seller* +> reclaim. Same 2-of-3 token, opposite redeemer. + +--- + +## 3. What Track C consumes from Fundamentals + Track A + +| Needs | From | Exact item | +|-------|------|------------| +| Mode gate | CF-1 | `Settings::is_cashu_enabled()`, `escrow_mode()` | +| Locked escrow row | Track A | `Order.{cashu_escrow_token, cashu_escrow_locked_at}` populated | +| Fee to refund | Track A §4A (TA-1f) | `order.fee` → refund value `2 * order.fee`; `cashu_fee_token` / `cashu_fee_redeemed_at` state; the `cashu_fee_proofs` table | +| `P_M` signing | TA-1f (CF-2 surface; Track D TD-1 is the same primitive) | `sign_with_pm(token) -> Vec` — used here on the **fee** token only | +| Seller trade pubkey | existing | `order.get_seller_pubkey()` (refund recipient) | +| Mostro-sends-ecash (redeemed-fee path only) | TA-1f follow-up (ecash revenue store) | mint/send a fresh token to `P_S` | +| Claim-status CAS | TA-2 | `claim_order_status` (`src/db.rs`) — TC-2 extends it | +| Dispatch seam | CF-5 | `Cancel` arm in `dispatch_cashu` | + +Protocol (already on `main`, `mostro-core ≥ 0.14.0`): the existing +cooperative-cancel messages (`Action::Cancel`, +`CooperativeCancelInitiatedByYou/ByPeer`, `CooperativeCancelAccepted`). The +buyer's Cashu signature travels in the existing P2P cancel message shape. The +fee refund is delivered with `Action::CashuPmSignature` + +`Payload::CashuSignatures` — the frozen surface Track D uses — carrying `P_M` +signatures over the **fee-token** proofs; **no new protocol variant is +required**. The `mostro-core` doc-comment on `CashuPmSignature` ("emitted only +during dispute resolution") is widened to "whenever Mostro hands a party its +`P_M` signatures over proofs that party holds" — a documentation-only change, +no wire change. + +--- + +## 4. The fee refund — one crash-safe, single-shot contract (Track A §4A) + +Because the fee is realised at **lock**, not at success, the daemon **owes the +seller a refund** on every non-success path. A cooperative cancel is one such +path; a seller-wins dispute (Track D) is another. Both call the **same helper**, +`refund_cashu_fee(order)`, with the contract below. Nothing else in the daemon +may refund a fee. + +### 4.1 Why "not collected" is not a refund +The TA-1f fee token is P2PK **1-of-1 to `P_M`**. Before Mostro redeems it the +seller's ecash is not "still the seller's" — it is locked to Mostro's key and the +seller **cannot spend it**. So an unredeemed fee is *exactly as owed* as a +redeemed one; the difference is only in *how* it is returned. + +### 4.2 The two refund paths + +| Fee state at refund time | How the seller gets it back | Needs | +|--------------------------|-----------------------------|-------| +| **Unredeemed** (`cashu_fee_redeemed_at IS NULL`) — the only state TA-1f produces until the revenue store lands | Mostro runs `sign_with_pm(fee_token)` and delivers the per-proof `P_M` signatures to `P_S` via `CashuPmSignature`; the seller already holds the fee proofs and swaps them at the mint itself. Value returned = the full fee token = `2 * order.fee`. | TA-1f only (token persisted, `sign_with_pm`). **No ecash store, no minting.** | +| **Redeemed** (`cashu_fee_redeemed_at IS NOT NULL`) | Mostro mints/sends a fresh token of `2 * order.fee` to `P_S` from its ecash revenue store. | The **ecash revenue store** follow-up Track A TA-1f scopes (Mostro persisting swapped fee proofs). Not scheduled yet — see §7. | + +The refund helper dispatches on `cashu_fee_redeemed_at`; the handler does not +care which path ran. If the fee was **never charged** (`mostro.fee == 0`, +`cashu_fee_token IS NULL`), the helper is a no-op. + +> **Interaction with the self-service-refund refinement (Track A §4A).** If a +> future revision locks the fee token with `locktime` + `refund = [P_S]`, the +> seller can reclaim the fee unilaterally after locktime and the unredeemed path +> becomes a fast-path optimisation rather than the sole recovery route. Track C +> ships against the simple 1-of-1 fee token TA-1f defines. + +### 4.3 Crash-safety and idempotency — the contract +1. **Atomic claim.** The helper first claims the refund with one conditional + `UPDATE`: `SET cashu_fee_refund_claimed_at = now WHERE id = ? AND + cashu_fee_token IS NOT NULL AND cashu_fee_refund_claimed_at IS NULL`. + `rows_affected == 0` ⇒ already claimed (replayed cancel, concurrent terminal + transition, or a previous crash mid-refund) ⇒ return without side effects. + This is the **single-shot** guarantee; it does not depend on the caller's + status check. +2. **The claim also fences the fee-redeem job.** TA-1f's pending-redeem retry + selects `cashu_fee_token IS NOT NULL AND cashu_fee_redeemed_at IS NULL`; TC-1 + adds `AND cashu_fee_refund_claimed_at IS NULL`, and the redeem's own stamp + becomes a CAS on the same predicate. A fee is therefore either redeemed or + refunded, never both, and Mostro never races the seller at the mint for the + same proofs. +3. **Side effect is replayable from persisted state.** Unredeemed path: `P_M` + signatures are deterministic over the persisted `cashu_fee_token`, so + re-signing and re-sending is harmless (the seller swaps once; a second + `CashuPmSignature` for already-spent proofs is noise). Redeemed path: the + minted refund token MUST be persisted *before* it is sent, so a crash after + minting never loses ecash — the revenue-store follow-up owns that column. +4. **Durable delivery.** After the claim: sign/mint → `enqueue_order_msg` → + stamp `cashu_fee_refunded_at`. A cashu-mode scheduler job re-runs the side + effect for rows with `cashu_fee_refund_claimed_at IS NOT NULL AND + cashu_fee_refunded_at IS NULL` (crash between claim and stamp). Because of + (3) the retry is safe. +5. **Terminal-state races.** The helper is invoked from exactly one place per + terminal transition (`cancel_cooperative_execution_step_2` here; + `admin_cancel_action` in Track D), and only **after** that transition's own + status CAS succeeded — the Cashu branch of step 2 persists + `CooperativelyCanceled` with `UPDATE … WHERE id = ? AND status IN (Active, + FiatSent)` rather than the unconditional full-row write, so a cooperative + cancel and an `AdminCancel` landing together produce exactly one terminal + status and one refund. The claim in (1) is the backstop if both still reach + the helper. +6. **Bookkeeping columns** (TC-1 migration, additive): + `cashu_fee_refund_claimed_at`, `cashu_fee_refunded_at` on `orders`. Reusing + `cashu_fee_redeemed_at` for refund state is **not** allowed — the two paths + in §4.2 need to tell "redeemed" from "refunded". + +--- + +## 5. Handler — the Cashu branch of `cancel_action` + +Keep the existing cooperative-cancel handshake (both parties must request +`Cancel`; the initiator guard in step 2 is unchanged). In Cashu mode, at the +point the Lightning path would cancel the hold invoice: +- **Do not** touch LND (there is no hold invoice). +- Advance the order to `CooperativelyCanceled` with the conditional status + `UPDATE` of §4.3(5), then publish the order event (NIP-33 maps it to + `canceled`) and send `CooperativeCancelAccepted` to both parties, as today. +- Acknowledge/relay the buyer's Cashu signature to the seller so the seller can + reclaim the escrow (Mostro never stores that signature — the §2 callout in + Track B applies symmetrically: `P_B` sig + `P_M` would be a 2-of-3). +- Call `refund_cashu_fee(order)` (§4) — once, if a fee was collected. + +`dispatch_cashu` replaces the `InvalidAction` arm for `Cancel` (routing through +the cashu-aware `cancel` handler). A *unilateral* cancel of a locked escrow (no +peer consent) is **not** a Track C concern — that path is a dispute (Track D). +A cancel of a **pending** (never-taken) order keeps today's maker-cancel path +and `Status::Canceled`. + +--- + +## 5a. The unfunded-take timeout (gap raised by the TA-2 review) + +Track A TA-2 (`show_cashu_escrow_request` in `src/util.rs`, merged in #830) +claims the `Pending → WaitingPayment` transition atomically +(`claim_order_status`) and only then publishes the order event and persists the +row. Two abandonment cases leave an order sitting in `WaitingPayment` **with no +escrow locked**: + +1. **The seller never submits `AddCashuEscrow`** (gone, or their client never + retries a rejected lock). Nothing is locked and no fiat has moved — but the + order is taken off the book from the maker's perspective, indefinitely. +2. **A partial failure after the claim** (the Nostr publish or the full-row + persist fails). The claim has already committed, so the order is + `WaitingPayment` with the taker's data unpersisted and no party notified. + +On a Lightning node the equivalent state self-heals: `job_cancel_orders` +(`src/scheduler.rs`) re-selects stale `WaitingPayment` rows every tick +(`find_order_by_seconds` against `taken_at`), cancels the hold invoice, and +republishes or cancels the order. **That job is Lightning-only** (skipped when +`Settings::is_cashu_enabled()`), and until Track C lands, `Cancel` itself is +rejected with `InvalidAction` in `dispatch_cashu` — so today neither recovery +path exists in Cashu mode. + +No funds are ever at risk here (nothing was locked), so this is a +liveness/book-keeping hole, not a safety hole — but it must be closed before +Cashu mode is production-usable. + +### 5a.1 What is durable after the claim — the recovery record +`claim_order_status` commits **only** `status = WaitingPayment` (one-column +`UPDATE … WHERE status = Pending AND cashu_escrow_locked_at IS NULL`). Every +other take-side field — `taken_at` (`set_timestamp_now()` in `take_buy`/ +`take_sell`), the taker's trade pubkey (`buyer_pubkey`/`seller_pubkey`), its +`master_*_pubkey` and `trade_index_*` — lives only in the in-memory copy until +the full-row `update` that runs **after** `update_order_event`. Between the two, +the row is `WaitingPayment` with `taken_at = 0` and the maker's columns exactly +as they were at creation. + +TC-2 therefore: +- **Extends the claim** to stamp `taken_at = now` in the same `UPDATE` + (`claim_order_status` gains a `taken_at` parameter; one-line, additive). The + durable recovery record is then **`{status = WaitingPayment, taken_at}` plus + the maker-side columns that pre-date the take**. Nothing about the taker is + guaranteed durable, and TC-2 never relies on it. +- **Selects on that record only:** `status = WaitingPayment AND + cashu_escrow_locked_at IS NULL AND taken_at > 0 AND taken_at <= now - + expiration_seconds`. Rows with `taken_at = 0` cannot exist once the claim + stamps it; for rows claimed by a pre-TC-2 daemon (upgrade window) the job logs + them once and treats `created_at` as the age. +- **Notification fallback:** the maker is always reachable (its trade pubkey is + on the row from creation) and is notified on republish/cancel exactly as the + Lightning job does. The taker is notified **only if** its trade pubkey was + persisted (`buyer_pubkey`/`seller_pubkey` both non-null); if the full-row + write never landed there is nobody to notify — the taker's client sees the + order back in the book / gone, and its own `WaitingSellerToPay` timeout + handles the UX. No reconciliation against Nostr is attempted. + +### 5a.2 The transition is a CAS, serialised with `AddCashuEscrow` +The Lightning job mutates with `update_order_to_initial_state` — an +**unconditional** `UPDATE … WHERE id = ?`. TC-2 must not reuse it: a seller's +`AddCashuEscrow` that succeeds between the job's `SELECT` and its write would +have its lock (`cashu_escrow_token`, `cashu_escrow_locked_at`, `Active`) +overwritten by a republish or cancel. Instead TC-2 mutates with one conditional +statement that re-checks the full predicate at the mutation point: + +```sql +UPDATE orders +SET status = ?new, taken_at = 0, buyer_pubkey = ?maker_or_null, + seller_pubkey = ?maker_or_null, /* + the LN job's amount/fee resets */ +WHERE id = ? AND status = 'waiting-payment' + AND cashu_escrow_locked_at IS NULL + AND taken_at <= ?deadline +``` + +`rows_affected == 0` ⇒ something else won the row (a late lock, or a concurrent +tick) ⇒ **no event is published and nobody is notified**. The TA-1 lock CAS +(`update_order_cashu_escrow`) requires `status = WaitingPayment AND +cashu_escrow_locked_at IS NULL`; the two predicates are mutually exclusive on +the same row, so SQLite's single-writer semantics guarantee **exactly one** +wins. A seller locking *after* the timeout won is rejected cleanly by the TA-1 +handler's `WaitingPayment` status check and keeps their token; a seller locking +*before* keeps the order `Active` and the job does nothing. A **locked** escrow +is never touched by this job (locked escrows are Track C/D territory). + +**TC-2 closes the gap:** a cashu-gated scheduler job that selects on the +§5a.1 record and, via the §5a.2 CAS, republishes the order as `Pending` when the +taker stalled (mirroring the Lightning `(WaitingPayment, Buy)` republish arm) +or cancels it (`Status::Canceled`) when the maker stalled — without touching LND. + +--- + +## 6. PR breakdown (atomic, backwards-compatible) + +### TC-1 · `cancel_action` Cashu branch + `refund_cashu_fee` +Add the Cashu branch to `cancel_action` (cooperative handshake → +`CooperativelyCanceled` via conditional status CAS, no LND), the +`refund_cashu_fee` helper with the §4.3 contract (claim CAS, unredeemed path via +`sign_with_pm` + `CashuPmSignature`, redeem-job fence, retry job), its +migration (`cashu_fee_refund_claimed_at`, `cashu_fee_refunded_at`), and unblock +`Cancel` in `dispatch_cashu`. Unit-tested against the CF-3 mint: both-parties +cancel → `CooperativelyCanceled` + seller reclaims the escrow + seller swaps +the fee token with the delivered `P_M` signatures; single-party cancel does not +finalise; replayed cancel does not re-claim; a refund claimed after a simulated +crash is re-delivered by the retry job; a claimed refund is skipped by the +fee-redeem job; a concurrent `AdminCancel`-style terminal write produces one +terminal status and one refund. +*Depends on CF-5, Track A **including TA-1f** (fee token persisted + +`sign_with_pm`). Conflict surface: `cancel.rs`, `db.rs` (additive), +`migrations/`, `scheduler.rs` (additive, cashu-gated), `app.rs` (one dispatch +arm).* + +### TC-2 · Unfunded-take timeout job (cashu-mode `job_cancel_orders` analogue) +A cashu-gated scheduler job that recovers orders stuck in `WaitingPayment` with +no locked escrow (§5a): stamp `taken_at` inside `claim_order_status`, select on +the durable record (§5a.1), mutate with the conditional CAS (§5a.2), +republish as `Pending` when the taker (buy-order seller) stalled, cancel when +the maker (sell-order seller) stalled, notify the maker always and the taker +when reachable — never touching LND. Unit-tested: a stale unfunded take is +republished/cancelled; a late-arriving `AddCashuEscrow` is rejected by the TA-1 +status check; a **concurrent** late lock (lock CAS and timeout CAS racing on +the same row) leaves exactly one winner and the loser publishes nothing; a row +with no taker pubkey notifies the maker only; a *locked* escrow is never +touched. +*Depends on CF-1, CF-5, TA-2. Conflict surface: `db.rs` +(`claim_order_status`, additive param) + `util.rs` (one call site) + +`scheduler.rs` (additive, cashu-gated) + tests.* + +--- + +## 7. Issues table — sequential vs parallel + +| ID | Title | Depends on | Parallel with | Conflict surface | Risk | +|----|-------|-----------|---------------|------------------|------| +| **TC-1** | `cancel_action` Cashu branch + `refund_cashu_fee` + unblock `Cancel` | CF-5, Track A, **TA-1f** | Track B; Track D TD-1/TD-2 | `cancel.rs`, `db.rs`, `migrations/`, `scheduler.rs`, `app.rs` | Medium (funds return + refund) | +| **TC-2** | Unfunded-take timeout job (§5a) | CF-1, CF-5, TA-2 | TC-1, Tracks B/D | `db.rs`, `util.rs`, `scheduler.rs` (additive, cashu-gated) | Low | + +**Ordering.** +- Track C has **no dependency on Track B** (nothing here needs the release + watcher) and Track B has none on C. +- **Track D depends on TC-1:** TD-3's seller-wins refund calls + `refund_cashu_fee` and relies on its migration; TD-3 lands after TC-1 (or + carries the helper itself if it lands first — in which case TC-1 consumes it). + TD-1/TD-2 are independent of Track C. +- **TC-1 depends on TA-1f**, which supplies the persisted fee token and + `sign_with_pm`. The **unredeemed** refund path (§4.2) is fully implementable + with TA-1f alone; the **redeemed** path needs the ecash revenue store, which + TA-1f scopes as a follow-up with **no PR, issue, or track yet**. TC-1 ships + the unredeemed path only; the redeemed branch is unreachable while TA-1f + defers live redeem, and TC-1 makes that explicit (an `error`-level log and a + claim left open for the store's retry job, never a silent no-op) — the moment + the store lands, the redeemed path is its first consumer. This is recorded in + the DoD so the gap is not a surprise for whoever picks up C. +- TC-2 is independent of the cancel handshake and can land before or after + TC-1 — until one of them does, a take whose seller never locks leaves the + order stranded in `WaitingPayment` (§5a). + +--- + +## 8. Definition of Done + +1. A locked Cashu order, cancelled cooperatively by both parties, reaches + `CooperativelyCanceled` (published as `canceled`), the seller can reclaim the + escrow with the buyer's signature, and the seller receives `P_M` signatures + over the fee-token proofs and can swap them for `2 * order.fee` — verified + end-to-end against the CF-3 mint. +2. A single-party `Cancel` does **not** finalise the cancellation (the handshake + still requires both). +3. The fee refund is **single-shot and crash-safe** (§4.3): a replayed/duplicate + cancel never re-claims; a crash between claim and delivery is recovered by + the retry job; a claimed refund is never redeemed by the fee-redeem job; a + fee-free order refunds nothing. +4. The redeemed-fee path is **explicitly scoped out** of TC-1 pending the ecash + revenue store, and the code makes that state unreachable (TA-1f defers live + redeem) rather than silently "not refunding". +5. A take whose escrow was never locked does not strand the order: the TC-2 + timeout republishes or cancels it through the §5a.2 CAS, a concurrent or late + lock leaves exactly one winner, the maker is always notified, and a locked + escrow is never touched by the job. +6. With Cashu disabled, behaviour is identical to `main`; existing tests pass + unmodified. `fmt`/`clippy -D warnings`/`test` green. + +--- + +## 9. Cross-track obligations satisfied / raised + +| Obligation | Defined in | Track C does | +|------------|-----------|--------------| +| Fee refund on non-success (coop cancel after lock), `2 * order.fee` to `P_S` | Track A §4A / §10 | **Executed** (TC-1, unredeemed path via `P_M` signatures) | +| Single-shot, crash-safe refund bookkeeping shared by every refund caller | Track A §4A | **Executed** (`refund_cashu_fee`, §4.3) — **Track D must call it** (TD-3) | +| Refund of an *already-redeemed* fee (needs Mostro-held ecash) | Track A §4A (TA-1f follow-up: ecash revenue store) | **Scoped out, named** (§7) — first consumer of the store when it lands | +| Fee-redeem retry job must skip refund-claimed rows | **Raised here** (§4.3(2)) | **Executed** (TC-1 amends the TA-1f job predicate) | +| `claim_order_status` stamps `taken_at` in the claim | **Raised here** (§5a.1) | **Executed** (TC-2) | +| Unfunded-take timeout — no `job_cancel_orders` analogue in Cashu mode, so a never-locked take strands the order in `WaitingPayment` | TA-2 review (MostroP2P/mostro#830) | **Raised → TC-2** (§5a) | diff --git a/docs/cashu/05-track-d-dispute.md b/docs/cashu/05-track-d-dispute.md new file mode 100644 index 00000000..b1f1a333 --- /dev/null +++ b/docs/cashu/05-track-d-dispute.md @@ -0,0 +1,339 @@ +# Cashu Escrow — Track D: Dispute Resolution (`P_M` signs) + +**Status:** Draft for review · **Target:** `main` (**requires `mostro-core ≥ 0.14.0`**) · +**Depends on:** Fundamentals **CF-1, CF-2, CF-5** + **Track A** (the escrow must be +locked before it can be disputed) + **Track C TC-1** (the shared fee-refund +helper, for TD-3) · **Feature flag:** `[cashu].enabled` + +Track D is the **only** track where Mostro's arbitrator key `P_M` produces a +signature over the **escrow**. When a locked Cashu trade cannot be resolved +cooperatively, a solver decides the outcome and Mostro hands the **winner** its +`P_M` signature so the winner can complete a 2-of-3 swap and take the funds. +This closes the trust model: the 2-of-3 exists precisely so that a single honest +arbitrator can break a buyer↔seller deadlock without ever holding the funds. + +This document assumes Fundamentals and Track A are merged. It only adds behaviour +*inside the Cashu branch*; the Lightning path is never changed. + +--- + +## 1. Goal and scope + +### Goal +Resolve a disputed locked Cashu trade by arbitrator signature: +1. A party opens a `Dispute`; a solver takes it (`AdminTakeDispute`). +2. The solver rules for one side: + - **`AdminSettle`** (buyer wins) → Mostro signs with `P_M` and delivers the + signature to the **buyer**, who redeems with `P_M + P_B`. + - **`AdminCancel`** (seller wins) → Mostro signs with `P_M` and delivers the + signature to the **seller**, who reclaims with `P_M + P_S`, and Mostro + **refunds the seller the fee** (Track A §4A, via Track C's helper). +3. Mostro advances the order to its terminal dispute state **only when the + outcome is enforceable** (§4B) and makes the outcome auditable. + +### In scope +- `sign_with_pm` — the `P_M` signing primitive (NUT-11 P2PK) in `CashuClient` + (shared with TA-1f's fee redeem and Track C's fee refund; whichever lands + first carries it). +- The Cashu branches of `dispute_action`, `admin_take_dispute_action`, + `admin_settle_action`, `admin_cancel_action`, delivering `CashuPmSignature`. +- Unblocking `Dispute`, `AdminTakeDispute`, `AdminSettle`, `AdminCancel`, + `AdminAddSolver` in `dispatch_cashu`. +- The **near-locktime guard** (Track A §4B): solver alert on take, a hard + pre-sign check on settle, and finalisation only on an observed spend. +- The **fee refund** on a seller-wins resolution (Track A §4A) through + `refund_cashu_fee` (Track C §4). + +### Out of scope (other tracks) +- Happy-path **release** → Track B. **Cooperative cancel** → Track C. +- Bond-related admin flows — bonds are mutually exclusive with Cashu (CF-1 §4.5), + so `AddBondInvoice` stays permanently `InvalidAction`. + +--- + +## 2. Where Track D sits — flow and state transitions + +```mermaid +sequenceDiagram + participant P as Party (buyer or seller) + participant M as Mostro (cashu mode) + participant Solver as Solver + participant W as Winner + participant Mint as Cashu Mint + + Note over P,M: Escrow locked (Track A); trade stalled + P->>M: Dispute + Solver->>M: AdminTakeDispute + Note over M,Solver: solver alerted + deadline shown if remaining locktime < margin (§4B) + Solver->>M: AdminSettle (buyer wins) | AdminCancel (seller wins) + M->>Mint: check_state(escrow proof Ys) [hard guard: must be UNSPENT] + M->>M: sign_with_pm(escrow proofs) + M->>W: CashuPmSignature { per-proof {secret, signature} } + W->>Mint: SwapRequest {2-of-3 proofs, P_M sig + own sig, own outputs} + Mint-->>W: funds to the winner + M->>Mint: check_state [release watcher, Track B §5C] + Mint-->>M: SPENT before locktime -> CompletedByAdmin + Note over M: if seller wins, refund_cashu_fee (Track C §4) +``` + +**State transitions Track D performs:** `Dispute` opens the dispute +(`Active/FiatSent/SettledHoldInvoice → Dispute`). `AdminSettle` drives +`Dispute → SettledByAdmin` (signature delivered to the buyer) and the Track B +release watcher drives `SettledByAdmin → CompletedByAdmin` once the escrow is +observed spent before the locktime — the exact Lightning sequence +(`admin_settle` settles, the payout completes). `AdminCancel` drives +`Dispute → CanceledByAdmin` directly: the seller can always reclaim (with `P_M` +before the locktime, alone after it), so nothing remains to observe. In every +case there is **no hold-invoice settle/cancel** — Mostro emits a signature +instead. + +--- + +## 3. What Track D consumes from Fundamentals + Track A + +| Needs | From | Exact item | +|-------|------|------------| +| Mode gate | CF-1 | `Settings::is_cashu_enabled()` | +| Locked escrow row | Track A | `Order.{cashu_escrow_token}` populated; the token's `locktime` | +| Settlement margin | Track B TB-1 | `escrow_settlement_margin_days` — reused as the resolution SLA (§4B) | +| `P_M` signing | CF-2 surface (TA-1f / TD-1) | `CashuClient::sign_with_pm(token \| proofs) -> Vec` | +| Proof state at the mint | CF-2 | `CashuClient::check_state(ys)` (NUT-07) — the pre-sign guard and the watcher's source of truth | +| Release watcher | Track B TB-2 | `SettledHoldInvoice → Success` job, extended to `SettledByAdmin → CompletedByAdmin` | +| Winner pubkey | existing | `order.get_buyer_pubkey()` / `get_seller_pubkey()` | +| Fee refund (seller wins) | Track C TC-1 | `refund_cashu_fee(order)` — the single-shot contract of Track C §4.3 | +| Solver management | existing | `admin_add_solver`, `admin_take_dispute` | +| Dispatch seam | CF-5 | `Dispute`/`AdminTakeDispute`/`AdminSettle`/`AdminCancel`/`AdminAddSolver` arms | + +Protocol (already on `main`, `mostro-core ≥ 0.14.0`, frozen): +- `Action::CashuPmSignature` (Mostro → winner) carrying + `Payload::CashuSignatures(Vec)`, where + `CashuProofSignature = { secret, signature }` — **one entry per escrow proof**. +- `Action::{Dispute, AdminTakeDispute, AdminSettle, AdminCancel, AdminAddSolver}` + and the dispute payloads. +- `CantDoReason::CashuEscrowNotLocked` (settle/cancel a never-locked escrow), + `NotAllowedByStatus` (escrow already spent at the mint, §4B), plus the + dispute-status reasons. + +**No new protocol variant is required** — the `CashuPmSignature` / +`CashuSignatures` surface was landed in the 0.13.0 baseline exactly for this +track. The only new **daemon-side** capability is `sign_with_pm` in `CashuClient` +(if TA-1f has not already landed it). + +--- + +## 4. `sign_with_pm` — the arbitrator signing primitive (CF-2 surface) + +The one place the daemon uses its `P_M` key on **escrow** funds: + +```rust +/// Produce Mostro's NUT-11 P2PK signature over every proof of the escrow +/// token, so the dispute winner can assemble a 2-of-3 SwapRequest +/// (P_M + winner). Returns one {secret, signature} per proof. +fn sign_with_pm(token: &Token, p_m_secret: &SecretKey) + -> Result, Error>; +``` + +- It signs the escrow token's proofs only when a solver has ruled — never on + the happy path, never unilaterally. The non-custodial guarantee holds: + Mostro's one signature is worthless without the winner's second signature. +- The same primitive signs the **fee** token for TA-1f's redeem and Track C's + refund; those are Mostro's own revenue, not escrow, and are out of this + track's scope. +- The signature is delivered to the winner in `CashuPmSignature`; the winner (not + Mostro) chooses the swap outputs and submits to the mint. + +--- + +## 4B. Near-locktime — making the §4B obligation enforceable + +Track A §4B: a `P_M` signature delivered after the seller has reclaimed via the +refund path is worthless. A log line and a priority flag do not prevent Mostro +from finalising a dispute and handing the buyer an unusable signature, so Track +D enforces the window at the three points it controls: + +1. **Resolution SLA = `escrow_settlement_margin_days`** (Track B TB-1; default + 3). No second knob: the `FiatSent` guard already guarantees at least this + much locktime remained when fiat moved, so it is the natural budget for a + human resolution. `remaining = locktime.saturating_sub(now)` (unsigned, + saturating — same arithmetic as Track B §4). +2. **On `AdminTakeDispute`** — alert, with a deadline. If + `remaining < margin`, the solver's `AdminTookDispute` notification and the + log carry the absolute locktime and the remaining time, and the dispute is + priority-flagged. Informational, but now concrete. +3. **On `AdminSettle` (buyer wins)** — hard pre-sign guard, then finalise only + on an observed spend: + - `check_state` the escrow proofs **before** `sign_with_pm`. Any proof + `SPENT` ⇒ **refuse** with `CantDo(NotAllowedByStatus)`, log at `error` + with the mint answer, and notify the solver: the escrow has already moved + (seller reclaim after locktime, or a buyer redeem the watcher has not seen + yet). No signature is produced; the order does **not** change status. The + solver's recovery path is to rule the way the funds actually went — + `AdminCancel` if the seller reclaimed (which closes the order and refunds + the fee), or wait one watcher tick if the buyer redeemed (the watcher then + takes `SettledHoldInvoice/Dispute → Success` on its own evidence). + - `UNSPENT` and `remaining == 0` ⇒ the seller *can* reclaim at any moment; + signing is still the buyer's only chance, so sign and deliver, but the + `CashuPmSignature` is accompanied by an explicit warning to the buyer + ("swap immediately — the seller's refund path is open") and the log + records the race. This residual window exists only if a dispute outlived + the full margin; the §4B `FiatSent` guard and step 2 are what keep it rare. + - Advance `Dispute → SettledByAdmin` with a conditional `UPDATE … WHERE + status = Dispute`. **`CompletedByAdmin` is reached only by the release + watcher** (Track B §5C) on `SPENT` observed before the locktime — never by + the settle handler itself. A buyer who cannot use the signature therefore + leaves the order in `SettledByAdmin`, visible to the solver, rather than + in a terminal state that claims the buyer was paid. + - **Re-delivery.** A repeated `AdminSettle` on a `SettledByAdmin` order + re-signs (deterministic over the stored token) and re-sends the + `CashuPmSignature` instead of being rejected — the recovery for a lost DM. +4. **On `AdminCancel` (seller wins)** — no time guard is needed: before the + locktime the `P_M` signature lets the seller reclaim now; after it the + seller reclaims alone. Sign, deliver, advance `Dispute → CanceledByAdmin` + (conditional `UPDATE`), then `refund_cashu_fee`. `check_state` is still run + first: `SPENT` before the locktime means the buyer redeemed (the seller + released P2P during the dispute) — refuse with `NotAllowedByStatus` and let + the watcher close it as `Success`; `SPENT` after the locktime means the + seller already reclaimed — proceed with the status change and the refund, + skip the (useless) signature. + +--- + +## 5. Handlers — the Cashu branches + +### 5A · `dispute_action` (Cashu branch) +Same identity rules as today. Status rule: today `dispute_action` admits only +`Active`/`FiatSent`; in Cashu mode it **also admits `SettledHoldInvoice`** — +the state Track B leaves an order in when the seller sent `Release` but the +buyer never received a usable signature (Track B §5C). Without this the buyer +has no recourse. In Cashu mode: advance to `Dispute`, no LND. + +### 5B · `admin_take_dispute_action` (Cashu branch) +Assign the solver as today; additionally apply §4B(2): compute the remaining +locktime and, when below the margin, include the deadline in the solver's +notification and log, and priority-flag the dispute. + +### 5C · `admin_settle_action` (buyer wins) / `admin_cancel_action` (seller wins) +In Cashu mode, replace the hold-invoice settle/cancel with §4B(3)/(4): +- `check_state` → refuse on an inconsistent spend state. +- `sign_with_pm(escrow_token)` → `CashuPmSignature` to the winner (buyer for + settle, seller for cancel). +- Conditional status `UPDATE` to `SettledByAdmin` / `CanceledByAdmin`, publish + the order event. +- **`admin_cancel` (seller wins) additionally calls `refund_cashu_fee`** + (Track C §4) **after** its own status CAS succeeded — the helper's claim CAS + is the backstop against a cooperative cancel landing at the same time (Track + C §4.3(5)). +- Map a settle/cancel against a never-locked escrow to + `CantDo(CashuEscrowNotLocked)`. + +### 5D · Release watcher extension +Extend the Track B §5C watcher's selection to `status IN (SettledHoldInvoice, +SettledByAdmin)`; the `SPENT`-before-locktime arm maps `SettledHoldInvoice → +Success` and `SettledByAdmin → CompletedByAdmin` (and sends the `Rate` +requests in both cases). All other arms are unchanged. + +### 5E · `dispatch_cashu` unblocks +Replace the `InvalidAction` arms for `Dispute`, `AdminTakeDispute`, +`AdminSettle`, `AdminCancel`, and route `AdminAddSolver` to +`handle_message_action_no_ln` (solver management touches no escrow/LND). + +--- + +## 6. PR breakdown (atomic, backwards-compatible) + +### TD-1 · `sign_with_pm` + `CashuClient` surface +Add `sign_with_pm` (NUT-11 P2PK) to `CashuClient`, unit-tested against the CF-3 +mint (a `P_M`-signed proof + a winner signature satisfies the 2-of-3; a wrong key +does not; a `P_M` signature alone satisfies a 1-of-1 `P_M` token — the fee +case). Pure library; no daemon wiring. **Folds into TA-1f if TA-1f lands +first** — it is the same primitive; the tests above then live there. +*Depends on CF-2. Conflict surface: `cashu/mod.rs` (additive). Parallel with all.* + +### TD-2 · `dispute` + `admin_take_dispute` Cashu branches + solver alert +Cashu branches for opening (incl. from `SettledHoldInvoice`) and taking a +dispute, plus the §4B(2) near-locktime solver alert with deadline. Unblock +`Dispute`, `AdminTakeDispute`, `AdminAddSolver`. +*Depends on CF-5, Track A, Track B TB-1 (the margin key). Conflict surface: +`dispute.rs`, `admin_take_dispute.rs`, `admin_add_solver.rs` (if touched), +`app.rs`.* + +### TD-3 · `admin_settle` / `admin_cancel` Cashu branches + `P_M` delivery + fee refund +The §4B(3)/(4) guards, `CashuPmSignature` delivery to the winner, re-delivery on +repeat, the watcher extension (§5D), and the seller-wins call to +`refund_cashu_fee`. Unblock `AdminSettle`, `AdminCancel`. Completes dispute +resolution end-to-end. +*Depends on TD-1, TD-2, Track A, Track B TB-2 (the watcher), **Track C TC-1** +(the refund helper + its migration). Conflict surface: `admin_settle.rs`, +`admin_cancel.rs`, `scheduler.rs` (watcher arm, additive), `app.rs`.* + +--- + +## 7. Issues table — sequential vs parallel + +| ID | Title | Depends on | Parallel with | Conflict surface | Risk | +|----|-------|-----------|---------------|------------------|------| +| **TD-1** | `sign_with_pm` + CF-2 surface | CF-2 | everything (or folded into TA-1f) | `cashu/mod.rs` | Medium (crypto) | +| **TD-2** | `dispute`/`admin_take_dispute` Cashu + solver alert | CF-5, Track A, TB-1 | Track C; TB-2 | `dispute.rs`, `admin_take_dispute.rs`, `app.rs` | Medium | +| **TD-3** | `admin_settle`/`admin_cancel` + `P_M` delivery + watcher ext. + fee refund | TD-1, TD-2, Track A, **TB-2**, **TC-1** | — (last) | `admin_settle.rs`, `admin_cancel.rs`, `scheduler.rs`, `app.rs` | Medium-High (funds + revenue) | + +**Sequencing.** TD-1 (library) can land first and in parallel with everything. +TD-2 needs only the TB-1 config key. **TD-3 is the integration point and lands +last:** it needs the signing primitive, the dispute-open path, Track B's +release watcher (to finalise `SettledByAdmin`) and Track C's `refund_cashu_fee` +(to refund on seller-wins). Track D is therefore parallel with Tracks B/C in +*code* (disjoint handler files) but **not in merge order** — TD-3 follows TB-2 +and TC-1. If TD-3 must land before one of them, it carries the missing piece +itself (the watcher arm or the helper) and the other track consumes it. + +--- + +## 8. Definition of Done + +1. A disputed locked Cashu order can be resolved either way against the CF-3 mint: + `AdminSettle` delivers a `P_M` signature the **buyer** uses to redeem, and the + order reaches `CompletedByAdmin` only once the watcher observes the spend; + `AdminCancel` delivers a `P_M` signature the **seller** uses to reclaim and + the order reaches `CanceledByAdmin`. +2. Mostro's `P_M` signature over the escrow is produced **only** during dispute + resolution, is worthless alone (the winner must add its own signature), and + is delivered via `CashuPmSignature`; a repeated `AdminSettle` re-delivers it. +3. A seller-wins resolution refunds `2 * order.fee` to `P_S` through + `refund_cashu_fee`, single-shot; a cooperative cancel racing the admin cancel + yields one terminal status and one refund. +4. **Near-locktime is enforced, not just logged (§4B):** the solver is alerted + with a deadline; `AdminSettle` against `SPENT` proofs is refused without + producing a signature or changing status; a settle after the locktime + delivers the signature with the explicit race warning; `SettledByAdmin` + never becomes `CompletedByAdmin` without an observed spend before the + locktime. Each case is asserted. +5. A settle/cancel against a never-locked escrow returns + `CashuEscrowNotLocked`. `Dispute` is accepted from `SettledHoldInvoice` in + Cashu mode and rejected there with Cashu disabled. +6. With Cashu disabled, behaviour is identical to `main`; existing tests pass + unmodified. `fmt`/`clippy -D warnings`/`test` green. + +--- + +## 9. Cross-track obligations satisfied / raised + +| Obligation | Defined in | Track D does | +|------------|-----------|--------------| +| Dispute-near-locktime solver alert | Track A §4B | **Executed** (TD-2, with deadline) | +| Late `P_M` signature must not finalise a dispute the winner cannot complete | Track A §4B (made concrete here, §4B) | **Executed** (TD-3: pre-sign `check_state`, finalisation by observed spend) | +| Fee refund on dispute-resolved-for-seller | Track A §4A / §10; contract in Track C §4.3 | **Executed** (TD-3 calls `refund_cashu_fee`) | +| `Dispute` admitted from `SettledHoldInvoice` in Cashu mode | Track B §9 | **Executed** (TD-2) | +| Watcher finalises `SettledByAdmin → CompletedByAdmin` | Track B §9 | **Executed** (TD-3, §5D) | +| Every blocked admin/dispute action has an owner | CF-5 §6 matrix | **Executed** (TD-2/TD-3 unblock all dispute actions; `AddBondInvoice` stays permanently blocked) | + +--- + +## 10. After Track D — the feature is complete + +With Tracks A–D merged, a `[cashu] enabled = true` node can run a full trade +lifecycle — create, take, lock, release, cooperatively cancel, and resolve +disputes — entirely on ecash, with Mostro as a non-custodial coordinator that +signs only to arbitrate (and to hand back its own fee). The remaining open items +are the two scoped follow-ups Track A raised — the live fee-token redeem with its +**ecash revenue store** (which also unlocks the redeemed-fee refund path, Track +C §4.2) and the self-service-refund locktime refinement — neither of which +blocks a functioning Cashu marketplace. diff --git a/docs/cashu/README.md b/docs/cashu/README.md index 2e6af828..0927f50d 100644 --- a/docs/cashu/README.md +++ b/docs/cashu/README.md @@ -30,9 +30,9 @@ untouched while the feature is off, and lets several developers work in parallel | 00 | [`../CASHU_ESCROW_ARCHITECTURE.md`](../CASHU_ESCROW_ARCHITECTURE.md) | Architecture, motivation, crypto model, trust model | Reference | | 01 | [`01-fundamentals.md`](./01-fundamentals.md) | **Foundation milestone** — config, mint client, DB helpers, test harness, boot wiring | **Draft (this PR)** | | 02 | [`02-track-a-lock.md`](./02-track-a-lock.md) | Escrow lock / setup (`AddCashuEscrow`) | **Draft** | -| 03 | `03-track-b-release.md` | Release happy path | Planned | -| 04 | `04-track-c-coop-cancel.md` | Cooperative cancel | Planned | -| 05 | `05-track-d-dispute.md` | Dispute resolution (`P_M` signs) | Planned | +| 03 | [`03-track-b-release.md`](./03-track-b-release.md) | Release happy path (`FiatSent` + `Release`) | **Draft** | +| 04 | [`04-track-c-coop-cancel.md`](./04-track-c-coop-cancel.md) | Cooperative cancel + fee refund | **Draft** | +| 05 | [`05-track-d-dispute.md`](./05-track-d-dispute.md) | Dispute resolution (`P_M` signs) | **Draft** | The **fundamentals** document (01) is the only one that touches shared, conflict-prone files. Once it has merged, the feature tracks (02–05) edit