From 758281781b99e2cc2d9e03b2f47c8a8458858c41 Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 20 Jul 2026 18:51:57 -0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(cashu):=20add=5Fcashu=5Fescrow=5Factio?= =?UTF-8?q?n=20lock=20handler=20=E2=80=94=20Track=20A=20TA-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill in the CF-5 stub with the full escrow-lock algorithm (docs/cashu/02-track-a-lock.md §4). A `cashu`-mode node now accepts a seller's `AddCashuEscrow`, fully validates the 2-of-3 token against the mint and the order's trade keys, atomically advances `WaitingPayment → Active`, publishes the updated order event, and notifies the buyer to send fiat. Validate-fully-then-commit discipline (same as `release_action`): 1. resolve the order; 2. authorise the sender == the order's seller trade key (else `InvalidPeer`); 3. require `WaitingPayment` status; 4. extract the `CashuLockProof` (absent ⇒ `InvalidCashuToken`); 5. bind the mint: `proof.mint_url` must equal the configured mint (`InvalidMintUrl`) — a cheap pre-check; step 7 enforces the authoritative token↔mint binding; 6. bind `{P_B, P_S, P_M}` to THIS order — reject a proof whose stated keys disagree with the order, and derive the keys handed to the mint from the order (never from the proof), so the 2-of-3 can only lock to keys Mostro already holds; 7. `verify_escrow_token` (2-of-3 + seller-recovery locktime floor `now + escrow_locktime_days` + mint + amount + DLEQ + unspent); mint unreachable ⇒ `CashuMintUnavailable`, malformed ⇒ `InvalidCashuToken`; 8. `update_order_cashu_escrow` CAS (lock + status advance in one write); zero rows ⇒ idempotent no-op (replay/concurrent), no notification; 9. publish the Active order event (best-effort, logged on failure); 10. notify buyer + seller with `CashuEscrowLocked`. The escrow token locks `order.amount` exactly (Option 2 — the Mostro fee is a separate token added in TA-1f). Tests: the deterministic pre-mint rejection paths (wrong sender ⇒ `InvalidPeer`, wrong status, missing proof ⇒ `InvalidCashuToken`) and the `cashu_reason` error mapping. The mint-backed happy-path + replay tests are a follow-up: they need a 2-of-3 token builder in the CF-3 harness (which today ships only connectivity helpers). Depends on CF-5 (dispatch seam + cashu_client). Base: feat/cashu-cf5-boot-integration Refs: docs/cashu/02-track-a-lock.md (TA-1) --- src/app/add_cashu_escrow.rs | 378 ++++++++++++++++++++++++++++++++---- 1 file changed, 340 insertions(+), 38 deletions(-) diff --git a/src/app/add_cashu_escrow.rs b/src/app/add_cashu_escrow.rs index f114f20c..6f9e3ec9 100644 --- a/src/app/add_cashu_escrow.rs +++ b/src/app/add_cashu_escrow.rs @@ -1,65 +1,367 @@ -//! 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::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::*; -/// Handle a seller's `AddCashuEscrow` submission. +/// 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, + } +} + +/// 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; a replayed or concurrent submission matches zero +/// rows in the compare-and-set and is a safe idempotent no-op. 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)); + } + + // 3. The order must be waiting for the seller to fund it. + 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)), + }; + + // 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 buyer_pubkey = order.get_buyer_pubkey().map_err(MostroInternalErr)?; + 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)); + } + + // 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 means the status changed concurrently or the escrow is + // already locked (replay) — log and return `Ok(())` without notifying + // (idempotent; same shape as the `rows_affected() == 0` guard in + // `release_action`). + let locked = update_order_cashu_escrow( + pool, + order.id, + &configured_mint, + &proof.token, + now, + Status::WaitingPayment, + Status::Active, + ) + .await?; + if !locked { + 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. + 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; + + 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() + } + } + + 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!( - result, - Err(MostroError::MostroCantDo(CantDoReason::InvalidAction)) + 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()); + } } From 456e0fb2fdba076da9a8fe4790ad67067db38543 Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 20 Jul 2026 18:53:58 -0300 Subject: [PATCH 2/5] test(cashu): drop AddCashuEscrow from the InvalidAction gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TA-1 implements AddCashuEscrow, so dispatch_cashu no longer routes it to InvalidAction — it runs the real lock handler. Remove it from the 'blocks every order-lifecycle action' assertion list. --- src/app.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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, From 18cc7d698dbd16f226ca2c3f105de66a54ae14cb Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 24 Jul 2026 16:18:51 -0300 Subject: [PATCH 3/5] =?UTF-8?q?fix(cashu):=20address=20TA-1=20review=20?= =?UTF-8?q?=E2=80=94=20replay=20recovery=20+=20cross-order=20token=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the automated review of #829. Replay recovery (step 3b). The step-10 notifications are sent after the CAS commits, so a crash or a lost send queue in between left the escrow funded and the buyer never cued to send fiat — and the step-3 status check rejected every retry, making the documented idempotent retry path unreachable. An order this handler already locked (escrow stored, still Active) whose submitted token matches the stored one now replays the notifications and returns Ok(()): no mint round-trip, no second write. A different token on a locked order is a second funding the escrow can never honour, so it is rejected. The exception is scoped to Active, so a stale "escrow locked, send fiat" is never replayed onto a trade that has moved on. Cross-order token uniqueness (step 6b). The 2-of-3 condition commits to trade keys, not to an order id, and the mint reports proofs unspent until the first redeem — so two orders reusing the same trade keys could both validate the very same token and go Active against one redeemable escrow. The handler now rejects a token already escrowed elsewhere before the mint round-trip, and the CF-4 CAS carries a NOT EXISTS leg that closes the check-then-act race atomically. A CAS that matches zero rows because another order took the token is reported as InvalidCashuToken rather than a silent no-op. This is token-level uniqueness; proof-level (Y = hash_to_curve) uniqueness arrives with TA-1f. Track A §4 of the spec is updated to match, along with its Definition of Done. --- docs/cashu/02-track-a-lock.md | 39 ++++- src/app/add_cashu_escrow.rs | 286 ++++++++++++++++++++++++++++++---- src/db.rs | 164 +++++++++++++++++++ 3 files changed, 459 insertions(+), 30 deletions(-) diff --git a/docs/cashu/02-track-a-lock.md b/docs/cashu/02-track-a-lock.md index 41e29741..8846dda4 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,9 @@ 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 token is + meanwhile held by another order, the lost-race case of §6b, which is a + rejection rather than a no-op. 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 +672,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/src/app/add_cashu_escrow.rs b/src/app/add_cashu_escrow.rs index 6f9e3ec9..af45bad9 100644 --- a/src/app/add_cashu_escrow.rs +++ b/src/app/add_cashu_escrow.rs @@ -18,7 +18,7 @@ use crate::app::context::AppContext; use crate::cashu::{cashu_pubkey_from_xonly_hex, Error as CashuError}; use crate::config::settings::Settings; -use crate::db::update_order_cashu_escrow; +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; @@ -41,13 +41,51 @@ fn cashu_reason(e: &CashuError) -> CantDoReason { } } +/// 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; +} + /// Handle a seller's `AddCashuEscrow` submission (Track A §4). /// /// 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; a replayed or concurrent submission matches zero -/// rows in the compare-and-set and is a safe idempotent no-op. +/// 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, @@ -66,11 +104,25 @@ pub async fn add_cashu_escrow_action( 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. - order - .check_status(Status::WaitingPayment) - .map_err(MostroCantDo)?; + // 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. @@ -79,6 +131,25 @@ pub async fn add_cashu_escrow_action( _ => 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). @@ -98,7 +169,6 @@ pub async fn add_cashu_escrow_action( // 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 buyer_pubkey = order.get_buyer_pubkey().map_err(MostroInternalErr)?; let mostro_pubkey = my_keys.public_key(); if proof.buyer_pubkey != buyer_pubkey.to_string() || proof.seller_pubkey != seller_pubkey.to_string() @@ -121,6 +191,22 @@ pub async fn add_cashu_escrow_action( 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 @@ -156,6 +242,19 @@ pub async fn add_cashu_escrow_action( ) .await?; if !locked { + // Zero rows means one of three things. Two are benign (the status moved + // on, or a concurrent submission won the race and already locked this + // order) and stay idempotent no-ops. The third — a concurrent + // submission that locked this same token onto a *different* order, + // losing the step-6b race — must surface as a rejection so the seller + // is not left believing an escrow it can no longer fund is live. + if cashu_escrow_token_in_use(pool, &proof.token, order.id).await? { + tracing::warn!( + "cashu lock: escrow token for order {} was locked to another order concurrently — rejected", + order.id + ); + return Err(MostroCantDo(CantDoReason::InvalidCashuToken)); + } tracing::info!( "cashu lock: compare-and-set matched zero rows for order {} (replay or status moved on) — no-op", order.id @@ -187,25 +286,9 @@ pub async fn add_cashu_escrow_action( } // 10. Notify both parties. The buyer learns the escrow is live and can send - // fiat; the seller gets an ack carrying the request id. - 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; + // 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(()) } @@ -251,6 +334,45 @@ mod tests { } } + /// 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), @@ -364,4 +486,114 @@ mod tests { 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(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)); + } + + /// 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/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 From 102744d7558ccf0cebbdab5754425dbac47e29ec Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 24 Jul 2026 16:56:50 -0300 Subject: [PATCH 4/5] fix(cashu): classify zero-row CAS precisely + index the escrow token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up review findings on the TA-1 lock handler. A compare-and-set that matched zero rows was treated as a no-op whenever the token was not held by another order. That misses one case: a concurrent submission may have locked THIS order with a *different* token in the window since the order was fetched, which means this submission was never accepted — answering Ok(()) leaves the seller believing an untouched, still-unspent token is escrowed. The classification now lives in `classify_zero_row_cas`, which rejects both lost races (token taken by another order; this order locked with another token) and keeps the no-op for a duplicate delivery of the winning token or a status that simply moved on. Extracting it also makes the branch unit-testable — the call site sits past the mint round-trip and cannot be reached from tests. The handler's two token lookups — the CAS's NOT EXISTS leg and cashu_escrow_token_in_use — were full table scans of `orders`. Adds a partial index on cashu_escrow_token, unique for the same reason as idx_bonds_parent_child_unique: it cannot change current behaviour (the CAS never attempts a duplicate write) but makes the one-order-per-token invariant survive a regression that drops the NOT EXISTS predicate. Track A §4 step 8 is updated to match. --- docs/cashu/02-track-a-lock.md | 11 +- ...260724120000_cashu_escrow_token_unique.sql | 23 +++ src/app/add_cashu_escrow.rs | 156 ++++++++++++++++-- 3 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 migrations/20260724120000_cashu_escrow_token_unique.sql diff --git a/docs/cashu/02-track-a-lock.md b/docs/cashu/02-track-a-lock.md index 8846dda4..2fc03e1b 100644 --- a/docs/cashu/02-track-a-lock.md +++ b/docs/cashu/02-track-a-lock.md @@ -192,9 +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`) — **unless** the token is - meanwhile held by another order, the lost-race case of §6b, which is a - rejection rather than a no-op. + `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 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/add_cashu_escrow.rs b/src/app/add_cashu_escrow.rs index af45bad9..f457ada2 100644 --- a/src/app/add_cashu_escrow.rs +++ b/src/app/add_cashu_escrow.rs @@ -24,6 +24,7 @@ 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. @@ -74,6 +75,51 @@ async fn notify_escrow_locked( .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 (Track A §4). /// /// On success the order advances `WaitingPayment → Active` in one atomic write @@ -227,10 +273,10 @@ pub async fn add_cashu_escrow_action( .map_err(|e| MostroCantDo(cashu_reason(&e)))?; // 8. Atomically persist the escrow and advance the status in one write. A - // `false` return means the status changed concurrently or the escrow is - // already locked (replay) — log and return `Ok(())` without notifying - // (idempotent; same shape as the `rows_affected() == 0` guard in - // `release_action`). + // `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, @@ -242,19 +288,7 @@ pub async fn add_cashu_escrow_action( ) .await?; if !locked { - // Zero rows means one of three things. Two are benign (the status moved - // on, or a concurrent submission won the race and already locked this - // order) and stay idempotent no-ops. The third — a concurrent - // submission that locked this same token onto a *different* order, - // losing the step-6b race — must surface as a rejection so the seller - // is not left believing an escrow it can no longer fund is live. - if cashu_escrow_token_in_use(pool, &proof.token, order.id).await? { - tracing::warn!( - "cashu lock: escrow token for order {} was locked to another order concurrently — rejected", - order.id - ); - return Err(MostroCantDo(CantDoReason::InvalidCashuToken)); - } + 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 @@ -563,6 +597,94 @@ mod tests { 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. From 9987ba11c10fed67161f7c794c3758e40abb97b8 Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 24 Jul 2026 17:38:12 -0300 Subject: [PATCH 5/5] test(cashu): mint-backed e2e harness for the TA-1 escrow lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit tests stop before the mint round-trip, so steps 5-8 of the handler — mint binding, pubkey binding, verify_escrow_token, the CAS — had no coverage at all. tests/cashu_mint.rs cannot fill the gap either: mostro is a binary crate with no lib target, so an integration test cannot reach the handler. Adds an in-tree #[ignore]d, env-gated harness that mints a real 2-of-3 escrow token (data = P_S, pubkeys = [P_B, P_M], n_sigs = 2, locktime, refund = [P_S]) at a live mint and drives add_cashu_escrow_action with it end to end: happy path WaitingPayment -> Active, then the two guards this PR added — replay recovery and cross-order token reuse. It mints straight into the P2PK condition rather than minting plain ecash and swapping, so the token value stays exactly the order amount on a mint that charges an input fee (mint.cubabitcoin.org's sat keyset charges 100 ppk, and a swap would eat into the amount the handler checks for equality). Safety, since it can run against a real mint: the three keys are generated per run and written to disk BEFORE anything is minted — they are the only way to redeem the escrow — and the locktime floor defaults to 1 day rather than the 15-day production default, so the seller-recovery path opens quickly. Verified against the throwaway nutshell mint: all three checks pass. A plain cargo test still reports it as ignored. --- src/cashu/e2e_lock.rs | 399 ++++++++++++++++++++++++++++++++++++++++++ src/cashu/mod.rs | 5 + 2 files changed, 404 insertions(+) create mode 100644 src/cashu/e2e_lock.rs 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::*;