diff --git a/docs/cashu/02-track-a-lock.md b/docs/cashu/02-track-a-lock.md index 41e29741..2fc03e1b 100644 --- a/docs/cashu/02-track-a-lock.md +++ b/docs/cashu/02-track-a-lock.md @@ -133,6 +133,19 @@ to `release_action` (compute/verify first, persist second, notify last). `CantDo(InvalidPeer)`. (Same identity check shape as `release_action`.) 3. **Check status.** The order must be in the "waiting for seller to fund" status (`WaitingPayment`); otherwise `CantDo(NotAllowedByStatus)`. +3b. **Replay recovery (the one exception to step 3).** An order this handler + already locked — `cashu_escrow_locked_at` set **and** status still `Active` — + is a *replay*, not a stray request: step 10's notifications are sent after + the commit, so a crash (or a lost send queue) in between leaves the escrow + funded and the buyer never cued to send fiat, while step 3 would reject every + retry. If the submitted token equals the stored one, **re-send the step-10 + notifications and return `Ok(())`** — no mint round-trip, no second write. A + *different* token on an already-locked order ⇒ `CantDo(InvalidCashuToken)` + (a second funding the escrow can never honour). The exception is deliberately + scoped to `Active`: a locked order that has moved on (`FiatSent`, `Success`, + …) falls through to the step-3 rejection, so a stale "escrow locked, send + fiat" is never replayed onto an advanced trade. Only the seller trade key + (step 2) can reach this path. 4. **Extract the proof.** `Payload::CashuLockProof(proof)`; absent ⇒ `CantDo(InvalidCashuToken)`. (`MessageKind::verify()` already guarantees the payload shape, but the handler re-checks defensively.) @@ -149,6 +162,21 @@ to `release_action` (compute/verify first, persist second, notify last). Any mismatch ⇒ `CantDo(InvalidCashuToken)`. This is the security core: the 2-of-3 must lock to the keys Mostro already holds for this order, never attacker-chosen keys. +6b. **Reject a token already escrowed by another order.** The 2-of-3 commits to + `{P_B, P_S, P_M}` — trade keys, not an order id — so step 6 cannot tell this + order apart from another one that reuses the same trade keys, and the mint + reports the proofs unspent until the first redeem: without a guard both + orders validate the same token and go `Active` against a single redeemable + escrow. `db::cashu_escrow_token_in_use(pool, &proof.token, order.id)` ⇒ + `CantDo(InvalidCashuToken)`, checked here so the seller gets a clear reason + before the mint round-trip. The CF-4 CAS (step 8) repeats the predicate as a + `NOT EXISTS` leg, which is what actually closes the check-then-act race + between two concurrent submissions; a `false` CAS whose token turns out to be + held elsewhere is reported as `InvalidCashuToken` rather than a silent no-op. + This is *token-level* uniqueness — proof-level (`Y = hash_to_curve(secret)`) + uniqueness, which also catches a token re-serialised around the same proofs, + arrives with TA-1f's `cashu_fee_proofs` table (§4A) and should be extended to + the escrow token there. 7. **Validate the token against the mint.** `ctx.cashu_client()` → `verify_escrow_token(&proof.token, p_b, p_s, p_m, expected_amount, min_locktime)`. This composes: 2-of-3 condition (exactly 2 sigs @@ -164,7 +192,14 @@ to `release_action` (compute/verify first, persist second, notify last). now, /*expected*/ WaitingPayment, /*new*/ Active)`. If it returns `false` (status changed concurrently, or escrow already locked — replay), log and return `Ok(())` without notifying (idempotent; same pattern as the - `rows_affected() == 0` guard in `release_action`). + `rows_affected() == 0` guard in `release_action`) — **unless** the zero rows + are a lost race that left this submission's token unaccepted, in which case + it is a rejection. Two such races: the token was locked onto another order + (the §6b case), or a concurrent submission locked *this* order with a + **different** token. Both ⇒ `CantDo(InvalidCashuToken)`, because answering + `Ok(())` would leave the seller believing an untouched, still-unspent token + is escrowed. A duplicate delivery of the token that actually won, and a + status that simply moved on, stay no-ops. 9. **Publish the updated order event** (kind 38383) via `update_order_event`, as the LN funding path does, so the order's public state stays consistent. 10. **Notify the buyer.** Enqueue `Action::CashuEscrowLocked` to the buyer plus @@ -642,8 +677,11 @@ Tracks B/C/D** — it only edits its own files plus the pre-wired CF-5 stub. unchanged (wrong sender, wrong status, wrong mint, pubkey mismatch, malformed token, mint unavailable, double-spent, replay, **missing/mis-valued fee_token when `mostro.fee > 0`**). -3. The lock is atomic and idempotent (concurrent/replayed submission matches zero - rows and is a safe no-op; **the fee token is never redeemed twice**). +3. The lock is atomic and idempotent: a concurrent submission matches zero rows + and is a safe no-op, a sequential retry of an already-locked escrow replays + the notifications instead of being rejected (§4 step 3b), the same token is + never escrowed by two orders (§4 step 6b), and **the fee token is never + redeemed twice**. 3b. The seller-funded fee token of `2 * order.fee` is validated against the mint and redeemed on success; Mostro's total revenue equals the Lightning-mode `2 * order.fee`, and dev-fee accounting is unchanged (Option 2, §4A). The diff --git a/migrations/20260724120000_cashu_escrow_token_unique.sql b/migrations/20260724120000_cashu_escrow_token_unique.sql new file mode 100644 index 00000000..ae0fa9cd --- /dev/null +++ b/migrations/20260724120000_cashu_escrow_token_unique.sql @@ -0,0 +1,23 @@ +-- Track A TA-1: index the escrow token, and enforce "one order per escrow +-- token" at the schema level. +-- +-- Two lookups added by the lock handler scan this column: the `NOT EXISTS` +-- leg of `update_order_cashu_escrow`'s compare-and-set, and +-- `cashu_escrow_token_in_use`. Both run on every `AddCashuEscrow`, and without +-- an index both are full table scans of `orders`. +-- +-- Unique, not merely indexed, for the same reason as +-- `idx_bonds_parent_child_unique`: the application check already wins/loses +-- the TOCTOU race correctly (the loser sees `rows_affected = 0`), but the +-- index makes the invariant hold for ANY future caller and survives a code +-- regression that drops the `NOT EXISTS` predicate. It cannot change current +-- behaviour — the CAS never attempts a duplicate write — and it cannot fail on +-- existing data: `cashu_escrow_token` has had no writer in any released +-- version, since the only one is the handler this migration ships with. +-- +-- Partial so it constrains ONLY locked escrows and stays small: every +-- Lightning-mode order leaves the column NULL. (SQLite treats NULLs as +-- distinct, so the predicate is about size and intent, not correctness.) +CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_cashu_escrow_token + ON orders (cashu_escrow_token) + WHERE cashu_escrow_token IS NOT NULL; diff --git a/src/app.rs b/src/app.rs index 3c38ed7e..938641a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1052,10 +1052,11 @@ mod tests { } /// Every action that creates, advances, or settles an order — plus the - /// permanently-blocked buyer-invoice/bond actions and the not-yet- - /// implemented `AddCashuEscrow` (its CF-5 stub returns `InvalidAction` - /// too) — must be rejected with `CantDo(InvalidAction)` in Cashu - /// foundation mode. This is the DoD "no trade can complete yet" gate. + /// permanently-blocked buyer-invoice/bond actions — must be rejected + /// with `CantDo(InvalidAction)` in Cashu foundation mode. This is the + /// DoD "no trade can complete yet" gate. `AddCashuEscrow` is excluded: + /// Track A (TA-1) implements it, so it no longer routes to + /// `InvalidAction` — it runs the real lock handler. #[tokio::test] async fn blocks_every_order_lifecycle_action_with_invalid_action() { let _ = @@ -1075,7 +1076,6 @@ mod tests { Action::Cancel, Action::Dispute, Action::RateUser, - Action::AddCashuEscrow, Action::AdminCancel, Action::AdminSettle, Action::AddBondInvoice, diff --git a/src/app/add_cashu_escrow.rs b/src/app/add_cashu_escrow.rs index f114f20c..f457ada2 100644 --- a/src/app/add_cashu_escrow.rs +++ b/src/app/add_cashu_escrow.rs @@ -1,65 +1,721 @@ -//! Cashu escrow lock handler — Track A **TA-1** integration point. +//! Cashu escrow lock handler — Track A **TA-1** +//! (see `docs/cashu/02-track-a-lock.md` §4). //! -//! CF-5 ships this as a **stub**: `dispatch_cashu` already routes -//! `Action::AddCashuEscrow` here, but until Track A lands the handler simply -//! rejects the action with `CantDo(InvalidAction)` — the same "not implemented -//! yet" contract every other trade action gets in Cashu foundation mode -//! (see `docs/cashu/01-fundamentals.md` §6 and `docs/cashu/02-track-a-lock.md`). +//! The Cashu analogue of "the seller funds the escrow": accept the seller's +//! `AddCashuEscrow`, **fully validate** the 2-of-3 token against the mint and +//! the order's trade keys, **atomically** persist it and advance the order +//! (`WaitingPayment → Active`), then notify the buyer to send fiat. //! -//! Keeping the routing in `dispatch_cashu` frozen and the body in its own file -//! means Track A fills in the validation + atomic-lock algorithm **here**, -//! touching no shared file (the G-1 fix in `docs/cashu/02-track-a-lock.md` §11). +//! Validation ordering matters: **validate fully before mutating any state**, +//! then commit atomically — the same discipline `release_action` applies +//! (compute/verify first, persist second, notify last). All notifications +//! happen **after** the successful compare-and-set, so a validation or +//! persistence failure leaves the order exactly as it was and the seller can +//! retry. +//! +//! Fee collection (Option 2, §4A) is **not** handled here — it lands in TA-1f. use crate::app::context::AppContext; +use crate::cashu::{cashu_pubkey_from_xonly_hex, Error as CashuError}; +use crate::config::settings::Settings; +use crate::db::{cashu_escrow_token_in_use, update_order_cashu_escrow}; +use crate::util::{enqueue_order_msg, get_order, update_order_event}; +use chrono::Utc; +use mostro_core::db::Crud; use mostro_core::prelude::*; use nostr_sdk::prelude::*; +use sqlx::SqlitePool; + +/// Seconds in a day — the escrow locktime floor is configured in days +/// (`cashu.escrow_locktime_days`, §4B) and enforced here in seconds. +const SECONDS_PER_DAY: u64 = 86_400; + +/// Map a [`CashuError`] onto the wire-level [`CantDoReason`] the seller sees. +/// A mint that is unreachable/timing out is `CashuMintUnavailable` (retryable); +/// a malformed/mis-valued/wrong-condition token is `InvalidCashuToken`; a bad +/// mint URL is `InvalidMintUrl`. +fn cashu_reason(e: &CashuError) -> CantDoReason { + match e { + CashuError::InvalidMintUrl(_) => CantDoReason::InvalidMintUrl, + CashuError::MintConnection(_) | CashuError::Client(_) => CantDoReason::CashuMintUnavailable, + CashuError::Token(_) | CashuError::Condition(_) => CantDoReason::InvalidCashuToken, + } +} + +/// Tell both parties the escrow is live: the buyer learns it can send fiat, +/// the seller gets an ack carrying its request id. +/// +/// Shared by the happy path (step 10) and the replay-recovery path (step 3b), +/// which is the whole point of factoring it out — a retry after a lost +/// notification must produce exactly the same pair of messages as the original +/// lock. +async fn notify_escrow_locked( + order_id: uuid::Uuid, + buyer_pubkey: PublicKey, + seller_pubkey: PublicKey, + request_id: Option, +) { + enqueue_order_msg( + None, + Some(order_id), + Action::CashuEscrowLocked, + None, + buyer_pubkey, + None, + ) + .await; + enqueue_order_msg( + request_id, + Some(order_id), + Action::CashuEscrowLocked, + None, + seller_pubkey, + None, + ) + .await; +} + +/// Decide what a compare-and-set that matched zero rows actually means. +/// +/// Three causes reach here and they do **not** share an outcome: +/// - the token was locked onto a *different* order concurrently (the step-6b +/// race, lost) — this order can never be funded by it ⇒ reject; +/// - a concurrent submission locked *this* order with a **different** token in +/// the window since step 1 — this submission was not the one accepted, and +/// answering `Ok(())` would let the seller believe an untouched, unspent +/// token is escrowed ⇒ reject; +/// - anything else — the same token already locked (a duplicate delivery), or +/// the status simply moved on ⇒ benign no-op. +/// +/// `Ok(())` therefore means "safe idempotent no-op", never "accepted". +async fn classify_zero_row_cas( + pool: &SqlitePool, + order_id: uuid::Uuid, + token: &str, +) -> Result<(), MostroError> { + if cashu_escrow_token_in_use(pool, token, order_id).await? { + tracing::warn!( + "cashu lock: escrow token for order {order_id} was locked to another order concurrently — rejected" + ); + return Err(MostroCantDo(CantDoReason::InvalidCashuToken)); + } + + match Order::by_id(pool, order_id).await { + Ok(Some(fresh)) + if fresh.cashu_escrow_locked_at.is_some() + && fresh.cashu_escrow_token.as_deref() != Some(token) => + { + tracing::warn!( + "cashu lock: order {order_id} was locked with a different token concurrently — rejected" + ); + Err(MostroCantDo(CantDoReason::InvalidCashuToken)) + } + // Unclassifiable: nothing of ours was written, so fall back to the + // conservative no-op rather than inventing a rejection. + Err(e) => { + tracing::error!("cashu lock: refetch after a zero-row CAS failed for {order_id}: {e}"); + Ok(()) + } + _ => Ok(()), + } +} -/// Handle a seller's `AddCashuEscrow` submission. +/// Handle a seller's `AddCashuEscrow` submission (Track A §4). /// -/// **Foundation stub (CF-5):** returns `CantDo(InvalidAction)` unconditionally. -/// Track A (TA-1) replaces this body with the full lock algorithm — validate -/// the 2-of-3 token against the mint and the order's trade keys, atomically -/// compare-and-set `WaitingPayment → Active`, publish the order event, and -/// notify the buyer to send fiat. +/// On success the order advances `WaitingPayment → Active` in one atomic write +/// and both parties are notified with `CashuEscrowLocked` (the buyer's cue to +/// send fiat). Every rejection path returns the matching `CantDoReason` and +/// leaves the order unchanged. +/// +/// Replays never write twice. A concurrent submission matches zero rows in the +/// compare-and-set and is a safe no-op; a *sequential* retry of an escrow this +/// handler already locked re-sends the notifications and returns (step 3b), so +/// a crash between the commit and the send queue cannot strand the trade with +/// a funded escrow and an uninformed buyer. pub async fn add_cashu_escrow_action( - _ctx: &AppContext, - _msg: Message, - _event: &UnwrappedMessage, - _my_keys: &Keys, + ctx: &AppContext, + msg: Message, + event: &UnwrappedMessage, + my_keys: &Keys, ) -> Result<(), MostroError> { - Err(MostroError::MostroCantDo(CantDoReason::InvalidAction)) + let pool = ctx.pool(); + + // 1. Resolve the order (and the request id for the seller's ack). + let order = get_order(&msg, pool).await?; + let request_id = msg.get_inner_message_kind().request_id; + + // 2. Authorise the sender: only the order's seller trade key may fund the + // escrow (same identity-check shape as `release_action`). + let seller_pubkey = order.get_seller_pubkey().map_err(MostroInternalErr)?; + if seller_pubkey != event.sender { + return Err(MostroCantDo(CantDoReason::InvalidPeer)); + } + let buyer_pubkey = order.get_buyer_pubkey().map_err(MostroInternalErr)?; + + // 3. The order must be waiting for the seller to fund it — with exactly one + // exception, handled in 3b below: an order this handler already locked + // (escrow stored, status `Active`) is a *replay*, not a stray request. + // Rejecting it here would make the documented idempotent retry + // unreachable and strand a trade whose notifications were lost between + // the commit and the send queue. Any other status — including an + // `Active` order with no escrow, or a locked order that has since moved + // past `Active` — still falls through to the normal rejection, so a + // stale "escrow locked, send fiat" is never replayed onto a trade that + // has advanced. + let replayable = + order.cashu_escrow_locked_at.is_some() && order.status == Status::Active.to_string(); + if !replayable { + order + .check_status(Status::WaitingPayment) + .map_err(MostroCantDo)?; + } + + // 4. Extract the lock proof. `MessageKind::verify()` already guarantees the + // payload shape; re-check defensively. + let proof = match msg.get_inner_message_kind().get_payload() { + Some(Payload::CashuLockProof(p)) => p.clone(), + _ => return Err(MostroCantDo(CantDoReason::InvalidCashuToken)), + }; + + // 3b. Replay recovery. The order is already locked and `Active`: if this + // submission carries the very token we stored, re-send the post-commit + // notifications and stop — no mint round-trip, no second write, and the + // buyer finally gets its cue to send fiat. A *different* token on an + // already-locked order is a second funding attempt, which the escrow + // can never honour, so it is rejected outright. Only the seller trade + // key (checked in step 2) can reach this path. + if replayable { + if order.cashu_escrow_token.as_deref() != Some(proof.token.as_str()) { + return Err(MostroCantDo(CantDoReason::InvalidCashuToken)); + } + tracing::info!( + "cashu lock: replayed AddCashuEscrow for already-locked order {} — re-notifying both parties", + order.id + ); + notify_escrow_locked(order.id, buyer_pubkey, seller_pubkey, request_id).await; + return Ok(()); + } + + // 5. Bind the mint: the node only escrows on its own configured mint. This + // is a cheap field pre-check; `verify_escrow_token` (step 7) enforces + // the authoritative binding (the token's mint == the configured mint). + let configured_mint = Settings::get_cashu() + .map(|c| c.mint_url.clone()) + .ok_or_else(|| { + MostroInternalErr(ServiceError::UnexpectedError( + "cashu mode without [cashu] settings".to_string(), + )) + })?; + if proof.mint_url.trim_end_matches('/') != configured_mint.trim_end_matches('/') { + return Err(MostroCantDo(CantDoReason::InvalidMintUrl)); + } + + // 6. Bind the pubkeys to THIS order. The 2-of-3 must lock to the keys + // Mostro already holds for this order, never attacker-chosen keys. We + // both reject a proof whose stated keys disagree with the order (a cheap + // offline check) AND derive the authoritative `{P_B, P_S, P_M}` from the + // order — never from the proof — to hand to the mint validation. + let mostro_pubkey = my_keys.public_key(); + if proof.buyer_pubkey != buyer_pubkey.to_string() + || proof.seller_pubkey != seller_pubkey.to_string() + || proof.mostro_pubkey != mostro_pubkey.to_string() + { + return Err(MostroCantDo(CantDoReason::InvalidCashuToken)); + } + let to_cashu = |hex: String| { + cashu_pubkey_from_xonly_hex(&hex).map_err(|_| MostroCantDo(CantDoReason::InvalidCashuToken)) + }; + let p_b = to_cashu(buyer_pubkey.to_string())?; + let p_s = to_cashu(seller_pubkey.to_string())?; + let p_m = to_cashu(mostro_pubkey.to_string())?; + + // Amount: the escrow token locks the order amount exactly (Option 2 — the + // Mostro fee is a separate token handled in TA-1f). + let expected_amount = + u64::try_from(order.amount).map_err(|_| MostroCantDo(CantDoReason::InvalidAmount))?; + if expected_amount == 0 { + return Err(MostroCantDo(CantDoReason::InvalidAmount)); + } + + // 6b. Reject a token already escrowed by another order. The 2-of-3 commits + // to `{P_B, P_S, P_M}` — trade keys, not an order id — so the checks + // above cannot tell this order apart from another one sharing the same + // trade keys, and the mint reports the proofs unspent until the first + // redeem: without this guard both orders would validate the same token + // and go `Active` against a single redeemable escrow. Checked before + // the mint round-trip so the seller gets a clear reason cheaply; the + // CAS in step 8 repeats it atomically for the concurrent case. + if cashu_escrow_token_in_use(pool, &proof.token, order.id).await? { + tracing::warn!( + "cashu lock: escrow token for order {} is already locked to another order — rejected", + order.id + ); + return Err(MostroCantDo(CantDoReason::InvalidCashuToken)); + } + + // 7. Validate the token against the mint: 2-of-3 condition + seller-recovery + // locktime floor + mint binding + amount + DLEQ (NUT-12) + unspent + // (NUT-07). The floor is `now + cashu.escrow_locktime_days`; the seller + // may set a longer locktime, never a shorter one (§4B). + let cashu_client = ctx.cashu_client().ok_or_else(|| { + MostroInternalErr(ServiceError::UnexpectedError( + "cashu client not connected".to_string(), + )) + })?; + let now = Utc::now().timestamp(); + let locktime_days = Settings::get_cashu() + .map(|c| c.escrow_locktime_days) + .unwrap_or(15) as u64; + let min_locktime = (now as u64).saturating_add(locktime_days.saturating_mul(SECONDS_PER_DAY)); + cashu_client + .verify_escrow_token(&proof.token, p_b, p_s, p_m, expected_amount, min_locktime) + .await + .map_err(|e| MostroCantDo(cashu_reason(&e)))?; + + // 8. Atomically persist the escrow and advance the status in one write. A + // `false` return is classified by `classify_zero_row_cas`: a benign + // no-op returns `Ok(())` without notifying (idempotent; same shape as + // the `rows_affected() == 0` guard in `release_action`), while a race + // that left this submission's token unaccepted is rejected. + let locked = update_order_cashu_escrow( + pool, + order.id, + &configured_mint, + &proof.token, + now, + Status::WaitingPayment, + Status::Active, + ) + .await?; + if !locked { + classify_zero_row_cas(pool, order.id, &proof.token).await?; + tracing::info!( + "cashu lock: compare-and-set matched zero rows for order {} (replay or status moved on) — no-op", + order.id + ); + return Ok(()); + } + + // 9. Publish the updated (Active) order event so the public state stays + // consistent, mirroring the LN funding path. Best-effort: the lock is + // already committed, and a retry would hit the zero-row CAS guard, so a + // failure here is logged, never returned. + match Order::by_id(pool, order.id).await { + Ok(Some(fresh)) => match update_order_event(my_keys, Status::Active, &fresh).await { + Ok(updated) => { + if let Err(e) = updated.update(pool).await { + tracing::error!( + "cashu lock: failed to persist order event for {}: {e}", + order.id + ); + } + } + Err(e) => tracing::error!( + "cashu lock: failed to publish order event for {}: {e}", + order.id + ), + }, + Ok(None) => tracing::error!("cashu lock: order {} vanished after lock", order.id), + Err(e) => tracing::error!("cashu lock: refetch failed for {}: {e}", order.id), + } + + // 10. Notify both parties. The buyer learns the escrow is live and can send + // fiat; the seller gets an ack carrying the request id. If this send is + // lost (crash, dropped queue), the seller's retry replays it via 3b. + notify_escrow_locked(order.id, buyer_pubkey, seller_pubkey, request_id).await; + + Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::app::context::test_utils::{test_settings, TestContextBuilder}; + use nostr_sdk::{Keys, Timestamp}; use sqlx::SqlitePool; use std::sync::Arc; - /// CF-5 stub contract: until Track A lands, the handler rejects every - /// submission with `CantDo(InvalidAction)` — no panic, no state change. - #[tokio::test] - async fn stub_rejects_with_invalid_action() { - let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap()); - let ctx = TestContextBuilder::new() - .with_pool(pool) + async fn create_test_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::migrate!().run(&pool).await.unwrap(); + pool + } + + fn build_ctx(pool: &SqlitePool) -> AppContext { + TestContextBuilder::new() + .with_pool(Arc::new(pool.clone())) .with_settings(test_settings()) - .build(); - let my_keys = Keys::generate(); - let event = mostro_core::nip59::UnwrappedMessage { - message: Message::new_order(None, None, None, Action::AddCashuEscrow, None), + .build() + } + + /// A `WaitingPayment` sell order — the state a taken Cashu order sits in + /// while it waits for the seller to lock the escrow (§2). + fn waiting_payment_order(seller: PublicKey, buyer: PublicKey) -> Order { + Order { + id: uuid::Uuid::new_v4(), + status: Status::WaitingPayment.to_string(), + kind: mostro_core::order::Kind::Sell.to_string(), + fiat_code: "USD".to_string(), + creator_pubkey: seller.to_string(), + seller_pubkey: Some(seller.to_string()), + master_seller_pubkey: Some(seller.to_string()), + buyer_pubkey: Some(buyer.to_string()), + master_buyer_pubkey: Some(buyer.to_string()), + amount: 21_000, + fee: 21, + fiat_amount: 40, + ..Default::default() + } + } + + /// Stamp the escrow columns straight onto a row, standing in for a lock + /// this handler already committed (the state a replay arrives against). + async fn mark_locked(pool: &SqlitePool, id: uuid::Uuid, token: &str, status: Status) { + sqlx::query( + "UPDATE orders SET cashu_mint_url = ?1, cashu_escrow_token = ?2, + cashu_escrow_locked_at = ?3, status = ?4 WHERE id = ?5", + ) + .bind("https://mint.example.com") + .bind(token) + .bind(1700000100_i64) + .bind(status.to_string()) + .bind(id) + .execute(pool) + .await + .unwrap(); + } + + /// The escrow columns as stored, to assert a rejected or replayed + /// submission rewrote nothing. + async fn escrow_columns(pool: &SqlitePool, id: uuid::Uuid) -> (Option, Option) { + sqlx::query_as( + "SELECT cashu_escrow_token, cashu_escrow_locked_at FROM orders WHERE id = ?1", + ) + .bind(id) + .fetch_one(pool) + .await + .unwrap() + } + + fn lock_proof(token: &str, buyer: PublicKey, seller: PublicKey, mostro: PublicKey) -> Payload { + Payload::CashuLockProof(CashuLockProof::new( + token.to_string(), + "https://mint.example.com".to_string(), + buyer.to_string(), + seller.to_string(), + mostro.to_string(), + )) + } + + fn unwrapped_from(sender: PublicKey) -> UnwrappedMessage { + UnwrappedMessage { + message: Message::new_order(None, Some(1), None, Action::AddCashuEscrow, None), signature: None, - sender: Keys::generate().public_key(), + sender, identity: Keys::generate().public_key(), - created_at: nostr_sdk::Timestamp::now(), - }; - let msg = Message::new_order(None, None, None, Action::AddCashuEscrow, None); + created_at: Timestamp::now(), + } + } - let result = add_cashu_escrow_action(&ctx, msg, &event, &my_keys).await; + fn lock_message(order_id: uuid::Uuid, payload: Option) -> Message { + Message::new_order( + Some(order_id), + Some(1), + None, + Action::AddCashuEscrow, + payload, + ) + } + + /// `cashu_reason` maps each `CashuError` onto the reason the seller sees: + /// unreachable mint ⇒ retryable `CashuMintUnavailable`; bad token/condition + /// ⇒ `InvalidCashuToken`; bad URL ⇒ `InvalidMintUrl`. + #[test] + fn cashu_reason_maps_every_error_variant() { + assert_eq!( + cashu_reason(&CashuError::InvalidMintUrl("x".into())), + CantDoReason::InvalidMintUrl + ); + assert_eq!( + cashu_reason(&CashuError::MintConnection("x".into())), + CantDoReason::CashuMintUnavailable + ); assert_eq!( + cashu_reason(&CashuError::Token("x".into())), + CantDoReason::InvalidCashuToken + ); + assert_eq!( + cashu_reason(&CashuError::Condition("x".into())), + CantDoReason::InvalidCashuToken + ); + } + + /// Step 2: only the seller trade key may fund the escrow. A submission from + /// anyone else is rejected with `InvalidPeer` before any mint contact. + #[tokio::test] + async fn rejects_sender_that_is_not_the_seller() { + let pool = create_test_pool().await; + let ctx = build_ctx(&pool); + let seller = Keys::generate().public_key(); + let buyer = Keys::generate().public_key(); + let order = waiting_payment_order(seller, buyer) + .create(&pool) + .await + .unwrap(); + // The buyer (not the seller) tries to lock. + let event = unwrapped_from(buyer); + let msg = lock_message(order.id, None); + let my_keys = Keys::generate(); + + let result = add_cashu_escrow_action(&ctx, msg, &event, &my_keys).await; + assert!(matches!( + result, + Err(MostroCantDo(CantDoReason::InvalidPeer)) + )); + } + + /// Step 3: the order must be `WaitingPayment`. A lock against any other + /// status is rejected (the CAS would also refuse it, but we fail early). + #[tokio::test] + async fn rejects_order_that_is_not_waiting_payment() { + let pool = create_test_pool().await; + let ctx = build_ctx(&pool); + let seller = Keys::generate().public_key(); + let buyer = Keys::generate().public_key(); + let mut order = waiting_payment_order(seller, buyer); + order.status = Status::Active.to_string(); + let order = order.create(&pool).await.unwrap(); + let event = unwrapped_from(seller); + let msg = lock_message(order.id, None); + let my_keys = Keys::generate(); + + let result = add_cashu_escrow_action(&ctx, msg, &event, &my_keys).await; + assert!(matches!(result, Err(MostroCantDo(_)))); + } + + /// Step 4: a message without a `CashuLockProof` payload is rejected with + /// `InvalidCashuToken` (the seller sent no token to validate). + #[tokio::test] + async fn rejects_missing_lock_proof_payload() { + let pool = create_test_pool().await; + let ctx = build_ctx(&pool); + let seller = Keys::generate().public_key(); + let buyer = Keys::generate().public_key(); + let order = waiting_payment_order(seller, buyer) + .create(&pool) + .await + .unwrap(); + let event = unwrapped_from(seller); + // A non-CashuLockProof payload (rating) must not be accepted. + let msg = lock_message(order.id, Some(Payload::RatingUser(5))); + let my_keys = Keys::generate(); + + let result = add_cashu_escrow_action(&ctx, msg, &event, &my_keys).await; + assert!(matches!( + result, + Err(MostroCantDo(CantDoReason::InvalidCashuToken)) + )); + // The order is untouched. + let db = Order::by_id(&pool, order.id).await.unwrap().unwrap(); + assert_eq!(db.status, Status::WaitingPayment.to_string()); + assert!(db.cashu_escrow_token.is_none()); + } + + /// Step 3b: a seller retrying after the lock committed but the + /// notifications were lost gets the notifications replayed, not a status + /// rejection — otherwise the escrow is funded and the buyer never learns + /// to send fiat. The retry must not touch state or contact the mint (this + /// test runs with no mint and no `[cashu]` settings; reaching step 5 would + /// fail). + #[tokio::test] + async fn replayed_lock_on_locked_order_renotifies_without_rewriting_state() { + let pool = create_test_pool().await; + let ctx = build_ctx(&pool); + let seller = Keys::generate().public_key(); + let buyer = Keys::generate().public_key(); + let my_keys = Keys::generate(); + let order = waiting_payment_order(seller, buyer) + .create(&pool) + .await + .unwrap(); + mark_locked(&pool, order.id, "cashuAlockedtoken", Status::Active).await; + + let event = unwrapped_from(seller); + let msg = lock_message( + order.id, + Some(lock_proof( + "cashuAlockedtoken", + buyer, + seller, + my_keys.public_key(), + )), + ); + + let result = add_cashu_escrow_action(&ctx, msg, &event, &my_keys).await; + assert!(result.is_ok(), "a replay of our own lock must be a no-op"); + + let (token, locked_at) = escrow_columns(&pool, order.id).await; + assert_eq!(token.as_deref(), Some("cashuAlockedtoken")); + assert_eq!(locked_at, Some(1700000100), "the original lock must stand"); + } + + /// A *different* token on an already-locked order is a second funding + /// attempt the escrow can never honour — rejected, and the stored escrow + /// is left alone. + #[tokio::test] + async fn rejects_a_second_token_on_an_already_locked_order() { + let pool = create_test_pool().await; + let ctx = build_ctx(&pool); + let seller = Keys::generate().public_key(); + let buyer = Keys::generate().public_key(); + let my_keys = Keys::generate(); + let order = waiting_payment_order(seller, buyer) + .create(&pool) + .await + .unwrap(); + mark_locked(&pool, order.id, "cashuAlockedtoken", Status::Active).await; + + let event = unwrapped_from(seller); + let msg = lock_message( + order.id, + Some(lock_proof( + "cashuAdifferent", + buyer, + seller, + my_keys.public_key(), + )), + ); + + let result = add_cashu_escrow_action(&ctx, msg, &event, &my_keys).await; + assert!(matches!( result, - Err(MostroError::MostroCantDo(CantDoReason::InvalidAction)) + Err(MostroCantDo(CantDoReason::InvalidCashuToken)) + )); + + let (token, locked_at) = escrow_columns(&pool, order.id).await; + assert_eq!(token.as_deref(), Some("cashuAlockedtoken")); + assert_eq!(locked_at, Some(1700000100)); + } + + /// Step 8, cause (b): a concurrent submission locked THIS order with a + /// different token, so this submission was never accepted. Answering + /// `Ok(())` would let the seller believe an untouched, still-unspent token + /// is escrowed. + #[tokio::test] + async fn zero_row_cas_rejects_when_the_order_was_locked_with_another_token() { + let pool = create_test_pool().await; + let seller = Keys::generate().public_key(); + let buyer = Keys::generate().public_key(); + let order = waiting_payment_order(seller, buyer) + .create(&pool) + .await + .unwrap(); + mark_locked(&pool, order.id, "cashuAwinner", Status::Active).await; + + let result = classify_zero_row_cas(&pool, order.id, "cashuAloser").await; + assert!(matches!( + result, + Err(MostroCantDo(CantDoReason::InvalidCashuToken)) + )); + } + + /// Step 8, cause (a): the token itself went to a different order. + #[tokio::test] + async fn zero_row_cas_rejects_when_the_token_went_to_another_order() { + let pool = create_test_pool().await; + let seller = Keys::generate().public_key(); + let buyer = Keys::generate().public_key(); + let mine = waiting_payment_order(seller, buyer) + .create(&pool) + .await + .unwrap(); + let theirs = waiting_payment_order(seller, buyer) + .create(&pool) + .await + .unwrap(); + mark_locked(&pool, theirs.id, "cashuAtoken", Status::Active).await; + + let result = classify_zero_row_cas(&pool, mine.id, "cashuAtoken").await; + assert!(matches!( + result, + Err(MostroCantDo(CantDoReason::InvalidCashuToken)) + )); + } + + /// The benign causes stay no-ops: a duplicate delivery of the token that + /// actually won, and an order whose status simply moved on unlocked. + #[tokio::test] + async fn zero_row_cas_is_a_noop_for_duplicate_delivery_and_moved_status() { + let pool = create_test_pool().await; + let seller = Keys::generate().public_key(); + let buyer = Keys::generate().public_key(); + + // (b') Locked with the very token we submitted — a duplicate. + let duplicate = waiting_payment_order(seller, buyer) + .create(&pool) + .await + .unwrap(); + mark_locked(&pool, duplicate.id, "cashuAtoken", Status::Active).await; + assert!(classify_zero_row_cas(&pool, duplicate.id, "cashuAtoken") + .await + .is_ok()); + + // (c) Never locked, status moved on under us. A token of its own: the + // one above is legitimately taken, and reusing it here would trip the + // cross-order guard instead of exercising this branch. + let moved = waiting_payment_order(seller, buyer) + .create(&pool) + .await + .unwrap(); + sqlx::query("UPDATE orders SET status = ?1 WHERE id = ?2") + .bind(Status::Canceled.to_string()) + .bind(moved.id) + .execute(&pool) + .await + .unwrap(); + assert!(classify_zero_row_cas(&pool, moved.id, "cashuAmoved") + .await + .is_ok()); + + // A vanished order is unclassifiable, never a rejection. + assert!( + classify_zero_row_cas(&pool, uuid::Uuid::new_v4(), "cashuAvanished") + .await + .is_ok() + ); + } + + /// The replay path is scoped to `Active`: a locked order whose trade has + /// moved on must NOT get a stale "escrow locked, send fiat" replayed onto + /// it — it falls through to the ordinary status rejection. + #[tokio::test] + async fn does_not_replay_notifications_for_a_locked_order_past_active() { + let pool = create_test_pool().await; + let ctx = build_ctx(&pool); + let seller = Keys::generate().public_key(); + let buyer = Keys::generate().public_key(); + let my_keys = Keys::generate(); + let order = waiting_payment_order(seller, buyer) + .create(&pool) + .await + .unwrap(); + mark_locked(&pool, order.id, "cashuAlockedtoken", Status::FiatSent).await; + + let event = unwrapped_from(seller); + let msg = lock_message( + order.id, + Some(lock_proof( + "cashuAlockedtoken", + buyer, + seller, + my_keys.public_key(), + )), + ); + + let result = add_cashu_escrow_action(&ctx, msg, &event, &my_keys).await; + assert!( + matches!(result, Err(MostroCantDo(_))), + "a locked order past Active must be rejected, not re-notified" ); } } diff --git a/src/cashu/e2e_lock.rs b/src/cashu/e2e_lock.rs new file mode 100644 index 00000000..059d9c8b --- /dev/null +++ b/src/cashu/e2e_lock.rs @@ -0,0 +1,399 @@ +//! Mint-backed end-to-end harness for the Track A **TA-1** escrow lock +//! (`docs/cashu/02-track-a-lock.md` §8, Definition of Done item 1). +//! +//! This is the manual-test rig: it mints a **real** 2-of-3 escrow token at a +//! live mint and drives `add_cashu_escrow_action` with it, which is the only +//! way to exercise steps 5–8 of the handler. The in-module unit tests cannot — +//! they stop before the mint round-trip. +//! +//! `#[ignore]`d and env-gated, exactly like `tests/cashu_mint.rs`: a plain +//! `cargo test` reports it as *ignored*, and even under `--ignored` it returns +//! early when `CASHU_TEST_MINT_URL` is unset. +//! +//! ```text +//! # Throwaway mint (no real funds — start here): +//! docker compose -f docker-compose.cashu.yml up -d +//! CASHU_TEST_MINT_URL=http://127.0.0.1:3338 \ +//! cargo test --bin mostrod cashu::e2e_lock -- --ignored --nocapture +//! +//! # Real mint (REAL SATS — see the safety notes below): +//! CASHU_TEST_MINT_URL=https://mint.cubabitcoin.org CASHU_E2E_AMOUNT=4 \ +//! cargo test --bin mostrod cashu::e2e_lock -- --ignored --nocapture +//! ``` +//! +//! **Safety on a real mint.** The escrow token is locked to a 2-of-3 over three +//! freshly generated keys, so the sats are recoverable *only* with those keys. +//! The harness therefore writes them to `CASHU_E2E_KEYS_FILE` (default +//! `./cashu-e2e-keys.json`) **before** anything is minted, and configures the +//! locktime floor to `CASHU_E2E_LOCKTIME_DAYS` (default 1) so the +//! seller-recovery path (`refund = [P_S]`) opens a day later instead of the +//! 15-day production default. Keep the amount small. + +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +use cdk::amount::{FeeAndAmounts, SplitTarget}; +use cdk::mint_url::MintUrl; +use cdk::nuts::{ + Conditions, CurrencyUnit, MintQuoteBolt11Request, MintQuoteState, MintRequest, PaymentMethod, + PreMintSecrets, SigFlag, SpendingConditions, Token, +}; +use cdk::wallet::MintConnector; +use cdk::{Amount, HttpClient}; + +use crate::app::add_cashu_escrow::add_cashu_escrow_action; +use crate::app::context::test_utils::{test_settings, TestContextBuilder}; +use crate::app::context::AppContext; +use crate::cashu::{cashu_pubkey_from_xonly_hex, CashuClient}; +use crate::config::types::CashuSettings; +use chrono::Utc; +use mostro_core::db::Crud; +use mostro_core::prelude::*; +use nostr_sdk::prelude::*; +use sqlx::SqlitePool; + +/// Seconds in a day — the locktime floor is configured in days. +const SECONDS_PER_DAY: u64 = 86_400; + +/// How long to wait for a human to pay the mint quote on a real mint. The +/// throwaway FakeWallet mint settles instantly and never uses this. +const INVOICE_WAIT: Duration = Duration::from_secs(300); + +fn env_var(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.trim().is_empty()) +} + +fn amount_sats() -> u64 { + env_var("CASHU_E2E_AMOUNT") + .and_then(|v| v.parse().ok()) + .unwrap_or(4) +} + +fn locktime_days() -> u64 { + env_var("CASHU_E2E_LOCKTIME_DAYS") + .and_then(|v| v.parse().ok()) + .unwrap_or(1) +} + +/// The three parties' keys. Generated fresh per run and persisted before any +/// sats move, because they are the only way to redeem the escrow afterwards. +struct EscrowKeys { + buyer: Keys, + seller: Keys, + mostro: Keys, +} + +impl EscrowKeys { + fn generate() -> Self { + Self { + buyer: Keys::generate(), + seller: Keys::generate(), + mostro: Keys::generate(), + } + } + + /// Write the keys out **before** minting. On a real mint these are the + /// difference between "recoverable escrow" and "burnt sats". + fn persist(&self) -> String { + let path = + env_var("CASHU_E2E_KEYS_FILE").unwrap_or_else(|| "./cashu-e2e-keys.json".to_string()); + let json = format!( + r#"{{ + "note": "Track A TA-1 e2e harness. These keys redeem the escrow token — do not delete until the sats are swept.", + "buyer_trade_key": {{ "secret": "{}", "pubkey": "{}" }}, + "seller_trade_key": {{ "secret": "{}", "pubkey": "{}" }}, + "mostro_key": {{ "secret": "{}", "pubkey": "{}" }} +}} +"#, + self.buyer.secret_key().to_secret_hex(), + self.buyer.public_key(), + self.seller.secret_key().to_secret_hex(), + self.seller.public_key(), + self.mostro.secret_key().to_secret_hex(), + self.mostro.public_key(), + ); + std::fs::write(&path, json) + .expect("must be able to persist the escrow keys before minting"); + path + } +} + +/// Mint a token locked to the §4B escrow shape: `data = P_S`, +/// `pubkeys = [P_B, P_M]`, `n_sigs = 2`, `locktime` present, `refund = [P_S]`. +/// +/// Minting straight into the P2PK condition (rather than minting plain ecash +/// and swapping it) keeps the token value exact on a mint that charges an +/// input fee — the cubabitcoin keyset charges 100 ppk, and a swap would eat +/// into the escrow amount the handler checks for equality. +async fn mint_escrow_token( + mint_url: &str, + keys: &EscrowKeys, + amount: u64, + locktime: u64, +) -> String { + let url = MintUrl::from_str(mint_url).expect("mint url must parse"); + let client = HttpClient::new(url.clone(), None); + + let p_b = cashu_pubkey_from_xonly_hex(&keys.buyer.public_key().to_string()).unwrap(); + let p_s = cashu_pubkey_from_xonly_hex(&keys.seller.public_key().to_string()).unwrap(); + let p_m = cashu_pubkey_from_xonly_hex(&keys.mostro.public_key().to_string()).unwrap(); + + let conditions = SpendingConditions::P2PKConditions { + data: p_s, + conditions: Some(Conditions { + locktime: Some(locktime), + pubkeys: Some(vec![p_b, p_m]), + refund_keys: Some(vec![p_s]), + num_sigs: Some(2), + sig_flag: SigFlag::SigInputs, + num_sigs_refund: None, + }), + }; + + // The active sat keyset — the same one `CashuClient::connect` insists on. + let keysets = client + .get_mint_keysets() + .await + .expect("mint must serve /v1/keysets"); + let keyset = keysets + .keysets + .iter() + .find(|k| k.active && k.unit == CurrencyUnit::Sat) + .expect("mint must have an active sat keyset"); + let keyset_id = keyset.id; + + // 1. Ask for an invoice. + let quote = client + .post_mint_quote( + MintQuoteBolt11Request { + amount: Amount::from(amount), + unit: CurrencyUnit::Sat, + description: Some("mostro TA-1 escrow e2e".to_string()), + pubkey: None, + } + .into(), + ) + .await + .expect("mint must issue a quote"); + + println!("\n Pay this invoice to fund the escrow ({amount} sat):\n"); + println!(" {}\n", quote.request()); + + // 2. Wait for payment. FakeWallet settles instantly; a real mint waits for + // a human. + let deadline = std::time::Instant::now() + INVOICE_WAIT; + loop { + let status = client + .get_mint_quote_status(PaymentMethod::BOLT11, quote.quote()) + .await + .expect("mint must report quote status"); + if status.state() == Some(MintQuoteState::Paid) { + println!(" invoice paid ✓"); + break; + } + assert!( + std::time::Instant::now() < deadline, + "invoice was not paid within {INVOICE_WAIT:?}" + ); + tokio::time::sleep(Duration::from_secs(3)).await; + } + + // 3. Mint directly into the escrow condition. The allowed denominations + // come from the keyset itself — an empty list means "nothing to split + // into" and the amount cannot be represented. + let keyset_keys = client + .get_mint_keyset(keyset_id) + .await + .expect("mint must serve the keyset keys"); + // `KeySet.keys` is a wrapper; `.keys()` unwraps it to the amount->pubkey + // map, whose keys are the denominations the mint will sign. + let mut denominations: Vec = keyset_keys + .keys + .keys() + .keys() + .map(|amount| u64::from(*amount)) + .collect(); + denominations.sort_unstable(); + + let premint = PreMintSecrets::with_conditions( + keyset_id, + Amount::from(amount), + &SplitTarget::default(), + &conditions, + &FeeAndAmounts::from((keyset.input_fee_ppk, denominations)), + ) + .expect("premint secrets must build"); + + let response = client + .post_mint( + &PaymentMethod::BOLT11, + MintRequest { + quote: quote.quote().to_string(), + outputs: premint.blinded_messages(), + signature: None, + }, + ) + .await + .expect("mint must issue the blinded signatures"); + + let proofs = cdk::dhke::construct_proofs( + response.signatures, + premint.rs(), + premint.secrets(), + &keyset_keys.keys, + ) + .expect("proofs must reconstruct"); + + Token::new(url, proofs, None, CurrencyUnit::Sat).to_string() +} + +async fn temp_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::migrate!().run(&pool).await.unwrap(); + pool +} + +/// A taken Cashu order parked in `WaitingPayment` — the state TA-2 will +/// produce, seeded here by hand because `dispatch_cashu` still rejects +/// `NewOrder`/`TakeSell` until TA-2 lands. +async fn seed_waiting_payment_order(pool: &SqlitePool, keys: &EscrowKeys, amount: u64) -> Order { + Order { + id: uuid::Uuid::new_v4(), + status: Status::WaitingPayment.to_string(), + kind: mostro_core::order::Kind::Sell.to_string(), + fiat_code: "USD".to_string(), + creator_pubkey: keys.seller.public_key().to_string(), + seller_pubkey: Some(keys.seller.public_key().to_string()), + master_seller_pubkey: Some(keys.seller.public_key().to_string()), + buyer_pubkey: Some(keys.buyer.public_key().to_string()), + master_buyer_pubkey: Some(keys.buyer.public_key().to_string()), + amount: amount as i64, + fee: 0, + fiat_amount: 10, + ..Default::default() + } + .create(pool) + .await + .unwrap() +} + +fn lock_message(order_id: uuid::Uuid, token: &str, mint_url: &str, keys: &EscrowKeys) -> Message { + Message::new_order( + Some(order_id), + Some(1), + None, + Action::AddCashuEscrow, + Some(Payload::CashuLockProof(CashuLockProof::new( + token.to_string(), + mint_url.to_string(), + keys.buyer.public_key().to_string(), + keys.seller.public_key().to_string(), + keys.mostro.public_key().to_string(), + ))), + ) +} + +fn seller_event(keys: &EscrowKeys) -> UnwrappedMessage { + UnwrappedMessage { + message: Message::new_order(None, Some(1), None, Action::AddCashuEscrow, None), + signature: None, + sender: keys.seller.public_key(), + identity: keys.seller.public_key(), + created_at: Timestamp::now(), + } +} + +async fn build_ctx(pool: &SqlitePool, mint_url: &str) -> AppContext { + let client = CashuClient::connect(mint_url) + .await + .expect("the mint must satisfy NUT-07/11/12 and expose an active sat keyset"); + TestContextBuilder::new() + .with_pool(Arc::new(pool.clone())) + .with_settings(test_settings()) + .build() + .with_cashu_client(Arc::new(client)) +} + +/// The full TA-1 happy path against a live mint, followed by the two +/// review-driven guards this PR added. +#[tokio::test] +#[ignore = "requires a live mint and CASHU_TEST_MINT_URL; see the module docs"] +async fn escrow_lock_end_to_end_against_a_live_mint() { + let Some(mint_url) = env_var("CASHU_TEST_MINT_URL") else { + eprintln!("CASHU_TEST_MINT_URL unset; skipping the mint-backed e2e harness"); + return; + }; + let amount = amount_sats(); + let days = locktime_days(); + + // Settings first: the handler reads the configured mint and the locktime + // floor out of the process-global config. + let mut settings = test_settings(); + settings.cashu = Some(CashuSettings { + enabled: true, + mint_url: mint_url.clone(), + escrow_locktime_days: days as u32, + }); + crate::config::settings::init_mostro_settings(settings); + + let keys = EscrowKeys::generate(); + let keys_path = keys.persist(); + println!("\n=== TA-1 escrow lock e2e ==="); + println!(" mint: {mint_url}"); + println!(" amount: {amount} sat"); + println!(" keys file: {keys_path} <- these redeem the escrow, keep them"); + + // A locktime comfortably above the floor the handler enforces. + let locktime = (Utc::now().timestamp() as u64) + (days + 1) * SECONDS_PER_DAY; + let token = mint_escrow_token(&mint_url, &keys, amount, locktime).await; + println!(" escrow token minted ✓"); + + let pool = temp_pool().await; + let ctx = build_ctx(&pool, &mint_url).await; + let order = seed_waiting_payment_order(&pool, &keys, amount).await; + println!(" order {} seeded in WaitingPayment", order.id); + + // --- Happy path ------------------------------------------------------- + let event = seller_event(&keys); + let msg = lock_message(order.id, &token, &mint_url, &keys); + add_cashu_escrow_action(&ctx, msg, &event, &keys.mostro) + .await + .expect("a valid escrow token must lock the order"); + + let locked = Order::by_id(&pool, order.id).await.unwrap().unwrap(); + assert_eq!(locked.status, Status::Active.to_string()); + assert_eq!(locked.cashu_escrow_token.as_deref(), Some(token.as_str())); + assert!(locked.cashu_escrow_locked_at.is_some()); + println!(" ✓ happy path: WaitingPayment -> Active, escrow persisted"); + + // --- Replay recovery (step 3b, this PR) ------------------------------- + let msg = lock_message(order.id, &token, &mint_url, &keys); + add_cashu_escrow_action(&ctx, msg, &seller_event(&keys), &keys.mostro) + .await + .expect("replaying our own lock must be a no-op, not a rejection"); + let after = Order::by_id(&pool, order.id).await.unwrap().unwrap(); + assert_eq!( + after.cashu_escrow_locked_at, locked.cashu_escrow_locked_at, + "a replay must not rewrite the lock" + ); + println!(" ✓ replay re-notifies without rewriting state"); + + // --- Cross-order reuse (step 6b, this PR) ----------------------------- + let second = seed_waiting_payment_order(&pool, &keys, amount).await; + let msg = lock_message(second.id, &token, &mint_url, &keys); + let reused = add_cashu_escrow_action(&ctx, msg, &seller_event(&keys), &keys.mostro).await; + assert!( + matches!(reused, Err(MostroCantDo(CantDoReason::InvalidCashuToken))), + "the same token must not escrow a second order, got {reused:?}" + ); + let untouched = Order::by_id(&pool, second.id).await.unwrap().unwrap(); + assert_eq!(untouched.status, Status::WaitingPayment.to_string()); + assert!(untouched.cashu_escrow_token.is_none()); + println!(" ✓ the same token cannot escrow a second order"); + + println!("\n All checks passed. The escrow token is live at the mint:"); + println!(" {token}\n"); + println!(" Sweep it with the keys in {keys_path} (any 2 of the 3 sign),"); + println!(" or wait out the locktime and reclaim with the seller key alone.\n"); +} diff --git a/src/cashu/mod.rs b/src/cashu/mod.rs index 253b39ce..c12b8077 100644 --- a/src/cashu/mod.rs +++ b/src/cashu/mod.rs @@ -588,6 +588,11 @@ pub fn cashu_pubkey_from_xonly_hex(xonly_hex: &str) -> Result .map_err(|e| Error::Condition(format!("pubkey convert: {e}"))) } +/// Mint-backed end-to-end harness for the TA-1 escrow lock — `#[ignore]`d and +/// env-gated, so it never runs in a plain `cargo test`. +#[cfg(test)] +mod e2e_lock; + #[cfg(test)] mod tests { use super::*; diff --git a/src/db.rs b/src/db.rs index fac47c23..3bbdb57e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -814,6 +814,15 @@ pub async fn update_order_invoice_held_at_time( /// window, and a replayed or concurrent submission (already locked, or the /// status moved on) matches zero rows instead of double-writing. Returns /// whether a row matched. +/// +/// The `NOT EXISTS` leg makes the escrow token **unique across orders**: the +/// 2-of-3 condition binds the token to a pair of trade keys, not to an order +/// id, so two orders that reuse the same trade keys would otherwise both +/// accept the very same token and go `Active` against a single redeemable +/// escrow. Callers should reject the reuse up front with a clear +/// `CantDoReason` (see [`cashu_escrow_token_in_use`]); this predicate is the +/// atomic backstop that closes the check-then-act race between two concurrent +/// submissions. pub async fn update_order_cashu_escrow( pool: &SqlitePool, order_id: Uuid, @@ -832,6 +841,10 @@ pub async fn update_order_cashu_escrow( cashu_escrow_locked_at = ?3, status = ?4 WHERE id = ?5 AND status = ?6 AND cashu_escrow_locked_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM orders other + WHERE other.cashu_escrow_token = ?2 AND other.id <> ?5 + ) "#, ) .bind(mint_url) @@ -847,6 +860,45 @@ pub async fn update_order_cashu_escrow( Ok(result.rows_affected() > 0) } +/// Whether some **other** order already holds this exact escrow token. +/// +/// The escrow token's 2-of-3 condition commits to `{P_B, P_S, P_M}` — trade +/// keys, not an order id — so a client that reuses its trade keys across two +/// orders could submit the same token to both: the mint still reports the +/// proofs unspent until the first redeem, so both orders would pass validation +/// and go `Active` while only one escrow is redeemable. Used by +/// `add_cashu_escrow_action` to reject the reuse before touching the mint; +/// [`update_order_cashu_escrow`] repeats the predicate inside the CAS so two +/// concurrent submissions cannot both slip past this check. +/// +/// This is token-level (whole-token) uniqueness. Proof-level uniqueness — +/// recording each `Y = hash_to_curve(secret)`, which also catches a token +/// re-serialised or re-split around the same proofs — lands with the +/// `cashu_fee_proofs` table in TA-1f (`docs/cashu/02-track-a-lock.md` §4A). +pub async fn cashu_escrow_token_in_use( + pool: &SqlitePool, + token: &str, + exclude_order_id: Uuid, +) -> Result { + // `SELECT 1` rather than a column: existence is the whole question, and + // `orders.id` is stored as a BLOB uuid that would need decoding for nothing. + let found: Option<(i64,)> = sqlx::query_as( + r#" + SELECT 1 + FROM orders + WHERE cashu_escrow_token = ?1 AND id <> ?2 + LIMIT 1 + "#, + ) + .bind(token) + .bind(exclude_order_id) + .fetch_optional(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + Ok(found.is_some()) +} + /// Every order with a locked Cashu escrow, for restore/monitoring after a /// restart (CF-4). Deliberately **no** `status` predicate: /// [`update_order_cashu_escrow`] advances the status in the same write as @@ -3099,6 +3151,118 @@ mod tests { ); } + #[tokio::test] + async fn cashu_escrow_token_in_use_ignores_the_order_being_locked() { + // The reuse lookup answers "does SOME OTHER order hold this token?". + // It must never flag the order currently being locked, or a retry of + // its own escrow would be rejected as reuse. + let pool = setup_orders_db().await.unwrap(); + let first = uuid::Uuid::new_v4(); + let second = uuid::Uuid::new_v4(); + insert_cashu_test_order(&pool, first, &Status::WaitingPayment.to_string()).await; + insert_cashu_test_order(&pool, second, &Status::WaitingPayment.to_string()).await; + + // Nothing is locked yet. + assert!( + !super::cashu_escrow_token_in_use(&pool, "cashuAtoken", second) + .await + .unwrap() + ); + + assert!(super::update_order_cashu_escrow( + &pool, + first, + "https://mint.example.com", + "cashuAtoken", + 1700000100, + Status::WaitingPayment, + Status::Active, + ) + .await + .unwrap()); + + // Seen from another order the token is taken; seen from its own order + // it is not. + assert!( + super::cashu_escrow_token_in_use(&pool, "cashuAtoken", second) + .await + .unwrap() + ); + assert!( + !super::cashu_escrow_token_in_use(&pool, "cashuAtoken", first) + .await + .unwrap() + ); + // A different token is untouched by either lock. + assert!( + !super::cashu_escrow_token_in_use(&pool, "cashuAother", second) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn cashu_escrow_cas_refuses_a_token_locked_to_another_order() { + // The 2-of-3 condition commits to trade keys, not to an order id, so + // two orders sharing trade keys could submit the SAME token. The CAS + // must accept it exactly once: the second order matches zero rows and + // keeps its escrow columns empty, so only one order can be Active + // against one redeemable escrow. + let pool = setup_orders_db().await.unwrap(); + let first = uuid::Uuid::new_v4(); + let second = uuid::Uuid::new_v4(); + insert_cashu_test_order(&pool, first, &Status::WaitingPayment.to_string()).await; + insert_cashu_test_order(&pool, second, &Status::WaitingPayment.to_string()).await; + + assert!(super::update_order_cashu_escrow( + &pool, + first, + "https://mint.example.com", + "cashuAtoken", + 1700000100, + Status::WaitingPayment, + Status::Active, + ) + .await + .unwrap()); + + let reused = super::update_order_cashu_escrow( + &pool, + second, + "https://mint.example.com", + "cashuAtoken", + 1700000200, + Status::WaitingPayment, + Status::Active, + ) + .await + .unwrap(); + assert!(!reused, "a token already escrowed elsewhere must not lock"); + + let (mint, token, locked_at, status) = cashu_columns(&pool, second).await; + assert!(mint.is_none(), "escrow columns must stay untouched"); + assert!(token.is_none()); + assert!(locked_at.is_none()); + assert_eq!( + status, + Status::WaitingPayment.to_string(), + "a refused lock must not advance the status" + ); + + // A distinct token on the same order still locks normally. + assert!(super::update_order_cashu_escrow( + &pool, + second, + "https://mint.example.com", + "cashuAother", + 1700000300, + Status::WaitingPayment, + Status::Active, + ) + .await + .unwrap()); + } + #[tokio::test] async fn cashu_escrow_cas_on_missing_order_matches_zero_rows() { // A CAS against an id that isn't in the table must report no match