diff --git a/src/app.rs b/src/app.rs index 089fc804..50daf0a4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ pub mod context; // Submodules for different trading actions +pub mod add_cashu_escrow; // Cashu escrow lock handler (Track A / CF-5 stub) pub mod add_invoice; // Handles invoice creation pub mod admin_add_solver; // Admin functionality to add dispute solvers pub mod admin_cancel; // Admin order cancellation @@ -26,6 +27,7 @@ pub mod take_sell; // Taking sell orders pub mod trade_pubkey; // Trade pubkey action // Sync user trade index action // Import action handlers from submodules +use crate::app::add_cashu_escrow::add_cashu_escrow_action; use crate::app::add_invoice::add_invoice_action; use crate::app::admin_add_solver::admin_add_solver_action; use crate::app::admin_cancel::admin_cancel_action; @@ -290,6 +292,145 @@ async fn handle_message_action( } } +/// Decode and fully validate one relay event into a dispatchable +/// `(action, message, unwrapped)` triple, or `None` if it must be skipped +/// (failed PoW, wrong kind, spam-gate drop, decrypt failure, stale, missing +/// inner signature, failed trade-index, failed inner verify, no action). +/// +/// This is the transport + validation **prologue** shared VERBATIM by `run` +/// (Lightning) and `run_cashu` (Cashu) so the two event loops cannot drift +/// (CF-5, see `docs/cashu/01-fundamentals.md` §6). Its body is a literal cut of +/// the pre-dispatch logic `run` used to inline; each former `continue` becomes +/// `return None`. +async fn accept_event( + ctx: &AppContext, + event: &Event, + my_keys: &Keys, + pow: u8, + pow_first_contact: u8, + accepted_kind: Kind, + is_v2: bool, +) -> Option<(Action, Message, UnwrappedMessage)> { + // Verify proof of work + if !event.check_pow(pow) { + // Discard events that don't meet POW requirements + tracing::info!("Not POW verified event!"); + return None; + } + if event.kind != accepted_kind { + return None; + } + // Phase 2 anti-spam gate (protocol v2 / kind 14 only): + // cheap pre-validation BEFORE paying the NIP-44 decrypt + // cost. v1 gift wraps skip this — their outer key is a + // throwaway with no pre-validatable signal. + if is_v2 { + if let Some(gate) = crate::spam_gate::SpamGate::global() { + let now = chrono::Utc::now().timestamp(); + // Dedup: drop a re-sent identical event (defense in + // depth against replay floods). + if gate.is_replay(event.id, now) { + tracing::debug!("Dropping replayed event {}", event.id); + return None; + } + // Two lanes: a sender already in an active trade is + // fast-pathed (only the base `pow` already checked + // above applies); an unseen first-contact sender + // must clear the stiffer `pow_first_contact` before + // we decrypt. New orders/takes legitimately arrive + // here — so does spam, hence the PoW toll. + if !gate.is_known(&event.pubkey.to_string()) && !event.check_pow(pow_first_contact) { + tracing::info!( + "Dropping first-contact kind-14 event from unknown key {} below pow_first_contact ({} bits)", + event.pubkey, + pow_first_contact + ); + return None; + } + } + } + + // Validate event signature + if event.verify().is_err() { + tracing::warn!("Error in event verification") + }; + + // Mostro-core dispatches on the event kind: the gift wrap + // path handles the dual-key layout (identity key signs + // seal, trade key authors rumor), the kind-14 path the + // 3-element tuple with its in-ciphertext identity proof. + // Both decode and verify signatures in one shot and yield + // the same transport-agnostic `UnwrappedMessage`. + let unwrapped = match unwrap_incoming(event, my_keys).await { + Ok(Some(u)) => u, + // NIP-44 decrypt failed: not addressed to this node. + Ok(None) => return None, + Err(e) => { + tracing::warn!("Error unwrapping incoming message: {}", e); + return None; + } + }; + // Discard events older than 10 seconds to prevent replay attacks + let since_time = chrono::Utc::now() + .checked_sub_signed(chrono::Duration::seconds(10)) + .unwrap() + .timestamp() as u64; + if unwrapped.created_at.as_secs() < since_time { + return None; + } + let message = unwrapped.message.clone(); + + // Full-privacy clients reuse the trade key as identity and send + // unsigned rumors. Any other shape must carry a valid inner + // signature — unwrap_message already verified it, so if identity + // and sender differ here without a signature we bail out. + if unwrapped.identity != unwrapped.sender && unwrapped.signature.is_none() { + tracing::warn!( + "Missing inner signature: identity {} differs from trade key {}", + unwrapped.identity, + unwrapped.sender + ); + return None; + } + + // Get inner message kind + let inner_message = message.get_inner_message_kind(); + // Check if message is message with trade index + if let Err(e) = check_trade_index(ctx, &unwrapped, &message).await { + tracing::warn!("Error checking trade index: {}", e); + return None; + } + + if !inner_message.verify() { + return None; + } + let action = message.inner_action()?; + Some((action, message, unwrapped)) +} + +/// Shared post-dispatch error handling (identical in both loops). A handler +/// `Err` is downcast to a `MostroError` and turned into the right reply +/// (`manage_errors`) or logged (`warning_msg`); `Ok` is a no-op. Factored out +/// with [`accept_event`] so `run` and `run_cashu` share one error tail (CF-5). +async fn finalize_dispatch( + result: Result<()>, + message: Message, + unwrapped: UnwrappedMessage, + action: &Action, +) { + if let Err(e) = result { + match e.downcast::() { + Ok(err) => { + manage_errors(*err, message, unwrapped, action).await; + } + Err(e) => { + tracing::error!("Unexpected error type: {}", e); + warning_msg(action, ServiceError::UnexpectedError(e.to_string())); + } + } + } +} + /// Main event loop that processes incoming Nostr events. /// Handles message verification, POW checking, and routes valid messages to appropriate handlers. /// @@ -321,129 +462,124 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> { while let Ok(notification) = notifications.recv().await { if let RelayPoolNotification::Event { event, .. } = notification { - // Verify proof of work - if !event.check_pow(pow) { - // Discard events that don't meet POW requirements - tracing::info!("Not POW verified event!"); + let Some((action, message, unwrapped)) = accept_event( + &ctx, + &event, + my_keys, + pow, + pow_first_contact, + accepted_kind, + is_v2, + ) + .await + else { continue; - } - if event.kind == accepted_kind { - // Phase 2 anti-spam gate (protocol v2 / kind 14 only): - // cheap pre-validation BEFORE paying the NIP-44 decrypt - // cost. v1 gift wraps skip this — their outer key is a - // throwaway with no pre-validatable signal. - if is_v2 { - if let Some(gate) = crate::spam_gate::SpamGate::global() { - let now = chrono::Utc::now().timestamp(); - // Dedup: drop a re-sent identical event (defense in - // depth against replay floods). - if gate.is_replay(event.id, now) { - tracing::debug!("Dropping replayed event {}", event.id); - continue; - } - // Two lanes: a sender already in an active trade is - // fast-pathed (only the base `pow` already checked - // above applies); an unseen first-contact sender - // must clear the stiffer `pow_first_contact` before - // we decrypt. New orders/takes legitimately arrive - // here — so does spam, hence the PoW toll. - if !gate.is_known(&event.pubkey.to_string()) - && !event.check_pow(pow_first_contact) - { - tracing::info!( - "Dropping first-contact kind-14 event from unknown key {} below pow_first_contact ({} bits)", - event.pubkey, - pow_first_contact - ); - continue; - } - } - } - - // Validate event signature - if event.verify().is_err() { - tracing::warn!("Error in event verification") - }; + }; + let result = handle_message_action( + &action, + message.clone(), + &unwrapped, + my_keys, + ln_client, + &ctx, + ) + .await; + finalize_dispatch(result, message, unwrapped, &action).await; + } + } + } +} - // Mostro-core dispatches on the event kind: the gift wrap - // path handles the dual-key layout (identity key signs - // seal, trade key authors rumor), the kind-14 path the - // 3-element tuple with its in-ciphertext identity proof. - // Both decode and verify signatures in one shot and yield - // the same transport-agnostic `UnwrappedMessage`. - let unwrapped = match unwrap_incoming(&event, my_keys).await { - Ok(Some(u)) => u, - // NIP-44 decrypt failed: not addressed to this node. - Ok(None) => continue, - Err(e) => { - tracing::warn!("Error unwrapping incoming message: {}", e); - continue; - } - }; - // Discard events older than 10 seconds to prevent replay attacks - let since_time = chrono::Utc::now() - .checked_sub_signed(chrono::Duration::seconds(10)) - .unwrap() - .timestamp() as u64; - if unwrapped.created_at.as_secs() < since_time { - continue; - } - let message = unwrapped.message.clone(); - - // Full-privacy clients reuse the trade key as identity and send - // unsigned rumors. Any other shape must carry a valid inner - // signature — unwrap_message already verified it, so if identity - // and sender differ here without a signature we bail out. - if unwrapped.identity != unwrapped.sender && unwrapped.signature.is_none() { - tracing::warn!( - "Missing inner signature: identity {} differs from trade key {}", - unwrapped.identity, - unwrapped.sender - ); - continue; - } +/// Cashu-mode event loop (CF-5). Mirrors [`run`]'s transport/validation +/// pipeline through the shared [`accept_event`]/[`finalize_dispatch`] helpers, +/// but dispatches through [`dispatch_cashu`] instead of +/// [`handle_message_action`] — there is no `ln_client` in Cashu mode. It +/// differs from `run` in exactly one line: the dispatch call. +/// +/// During the foundation milestone every escrow/trade action is rejected with +/// `CantDo(InvalidAction)`; the feature tracks replace those arms one at a time +/// (see `docs/cashu/01-fundamentals.md` §6 action-ownership matrix). +pub async fn run_cashu(ctx: AppContext) -> Result<()> { + let my_keys = ctx.keys(); + let client = ctx.nostr_client(); + let pow = ctx.settings().mostro.pow; + #[allow(deprecated)] + let accepted_kind = ctx.settings().mostro.transport.event_kind(); + let pow_first_contact = ctx.settings().mostro.effective_pow_first_contact(); + let is_v2 = accepted_kind.as_u16() == crate::config::constants::DM_EVENT_KIND; - // Get inner message kind - let inner_message = message.get_inner_message_kind(); - // Check if message is message with trade index - if let Err(e) = check_trade_index(&ctx, &unwrapped, &message).await { - tracing::warn!("Error checking trade index: {}", e); - continue; - } + loop { + let mut notifications = client.notifications(); - if inner_message.verify() { - if let Some(action) = message.inner_action() { - if let Err(e) = handle_message_action( - &action, - message.clone(), - &unwrapped, - my_keys, - ln_client, - &ctx, - ) - .await - { - match e.downcast::() { - Ok(err) => { - manage_errors(*err, message, unwrapped, &action).await; - } - Err(e) => { - tracing::error!("Unexpected error type: {}", e); - warning_msg( - &action, - ServiceError::UnexpectedError(e.to_string()), - ); - } - } - } - } - } - } + while let Ok(notification) = notifications.recv().await { + if let RelayPoolNotification::Event { event, .. } = notification { + let Some((action, message, unwrapped)) = accept_event( + &ctx, + &event, + my_keys, + pow, + pow_first_contact, + accepted_kind, + is_v2, + ) + .await + else { + continue; + }; + let result = + dispatch_cashu(&action, message.clone(), &unwrapped, my_keys, &ctx).await; + finalize_dispatch(result, message, unwrapped, &action).await; } } } } +/// Route a validated action in Cashu mode (CF-5). +/// +/// The allow-list is drawn at *"escrow-independent actions that neither create +/// nor advance an order"* (`docs/cashu/01-fundamentals.md` §6, closed +/// decision): +/// +/// - **Allowed** → `handle_message_action_no_ln` (read-only / session; never +/// touch escrow, LND, or order lifecycle): `Orders`, `LastTradeIndex`, +/// `RestoreSession`, `TradePubkey`. +/// - **`AddCashuEscrow`** → `add_cashu_escrow_action` (a CF-5 stub Track A +/// fills in). Frozen here so Track A edits only its own file (G-1). +/// - **Blocked** → `CantDo(InvalidAction)` — everything that creates, advances, +/// or settles an order (there is no escrow behind it yet). The feature tracks +/// replace these arms one at a time; the action-ownership matrix in +/// fundamentals §6 guarantees every blocked action has an owner. +async fn dispatch_cashu( + action: &Action, + msg: Message, + event: &UnwrappedMessage, + my_keys: &Keys, + ctx: &AppContext, +) -> Result<()> { + match action { + // Escrow-independent, read-only / session actions — safe in Cashu mode. + Action::Orders | Action::LastTradeIndex | Action::RestoreSession | Action::TradePubkey => { + handle_message_action_no_ln(action, msg, event, my_keys, ctx).await + } + // Order creation + the take flow (Track A TA-2). Creating a pending + // order touches no escrow; the take handlers branch on cashu mode and + // emit the escrow request (`show_cashu_escrow_request`) instead of a + // hold invoice. Creatable and takeable ship together so the book never + // fills with untakeable orders. + Action::NewOrder | Action::TakeBuy | Action::TakeSell => { + handle_message_action_no_ln(action, msg, event, my_keys, ctx).await + } + // Cashu escrow lock — TA-1 fills the stub body; the routing is frozen. + Action::AddCashuEscrow => add_cashu_escrow_action(ctx, msg, event, my_keys) + .await + .map_err(|e| e.into()), + // Everything that advances or settles an order past the lock has no + // handler yet during Track A — reject it cleanly. Later tracks + // (release/cancel/dispute) replace these arms one at a time. + _ => Err(MostroError::MostroCantDo(CantDoReason::InvalidAction).into()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -898,6 +1034,88 @@ mod tests { } } + mod dispatch_cashu_tests { + use super::*; + use crate::app::context::test_utils::{test_settings, TestContextBuilder}; + use sqlx::SqlitePool; + use std::sync::Arc; + + async fn create_ctx() -> AppContext { + let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap()); + sqlx::migrate!("./migrations") + .run(pool.as_ref()) + .await + .unwrap(); + TestContextBuilder::new() + .with_pool(pool) + .with_settings(test_settings()) + .build() + } + + fn is_invalid_action(result: Result<()>) -> bool { + matches!( + result, + Err(e) if e.downcast_ref::() + == Some(&MostroError::MostroCantDo(CantDoReason::InvalidAction)) + ) + } + + /// Actions with no Cashu handler yet — release/cancel/dispute, the + /// permanently-blocked buyer-invoice/bond actions, and Track D admin + /// actions — must still be rejected with `CantDo(InvalidAction)`. The + /// Track A actions (`NewOrder`, `TakeBuy`, `TakeSell` in TA-2; + /// `AddCashuEscrow` in TA-1) are excluded: they now route to their real + /// handlers, not to `InvalidAction`. + #[tokio::test] + async fn blocks_every_order_lifecycle_action_with_invalid_action() { + let _ = + crate::config::MOSTRO_CONFIG.set(crate::app::context::test_utils::test_settings()); + let _ = crate::NOSTR_CLIENT.set(nostr_sdk::Client::default()); + let ctx = create_ctx().await; + let my_keys = create_test_keys(); + let event = create_test_unwrapped_message(); + + for action in [ + Action::AddInvoice, + Action::FiatSent, + Action::Release, + Action::Cancel, + Action::Dispute, + Action::RateUser, + Action::AdminCancel, + Action::AdminSettle, + Action::AddBondInvoice, + Action::AdminTakeDispute, + Action::AdminAddSolver, + ] { + let msg = create_test_message(action.clone(), None); + let result = dispatch_cashu(&action, msg, &event, &my_keys, &ctx).await; + assert!( + is_invalid_action(result), + "{action:?} must be blocked with InvalidAction in Cashu mode" + ); + } + } + + /// The allow-list (`Orders`, `LastTradeIndex`, `RestoreSession`, + /// `TradePubkey`) is routed to `handle_message_action_no_ln`. We assert + /// routing by observing that `RestoreSession` reaches its handler and + /// returns `Ok` — proving it was NOT short-circuited to `InvalidAction`. + #[tokio::test] + async fn allows_restore_session_through_no_ln_router() { + let ctx = create_ctx().await; + let my_keys = create_test_keys(); + let event = create_test_unwrapped_message(); + let msg = Message::new_restore(None); + + let result = dispatch_cashu(&Action::RestoreSession, msg, &event, &my_keys, &ctx).await; + assert!( + result.is_ok(), + "RestoreSession must route to the no-LN handler, got {result:?}" + ); + } + } + mod message_validation_tests { use super::*; diff --git a/src/app/add_cashu_escrow.rs b/src/app/add_cashu_escrow.rs new file mode 100644 index 00000000..6f9e3ec9 --- /dev/null +++ b/src/app/add_cashu_escrow.rs @@ -0,0 +1,367 @@ +//! Cashu escrow lock handler — Track A **TA-1** +//! (see `docs/cashu/02-track-a-lock.md` §4). +//! +//! 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. +//! +//! 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::*; + +/// 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). +/// +/// 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, +) -> Result<(), MostroError> { + 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; + + 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() + } + + /// 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, + identity: Keys::generate().public_key(), + created_at: Timestamp::now(), + } + } + + 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()); + } +} diff --git a/src/app/context.rs b/src/app/context.rs index 194b08d2..d447d607 100644 --- a/src/app/context.rs +++ b/src/app/context.rs @@ -6,6 +6,7 @@ //! //! This enables unit testing with mock implementations — see `TestContextBuilder`. +use crate::cashu::CashuClient; use crate::config::settings::Settings; use mostro_core::prelude::Message; use nostr_sdk::{Client, Keys, PublicKey}; @@ -38,10 +39,18 @@ pub struct AppContext { settings: Arc, order_msg_queue: OrderMsgQueue, keys: Keys, + /// Connected Cashu mint client (docs/cashu/, CF-5). `Some` only in Cashu + /// mode, where `main.rs` connects the configured mint at boot and attaches + /// it here; `None` in the default Lightning mode. Track A's lock handler + /// reads it through [`AppContext::cashu_client`]. + cashu_client: Option>, } impl AppContext { /// Create a new application context. + /// + /// The Cashu client defaults to `None` (Lightning mode). Cashu-mode boot + /// attaches a connected client with [`AppContext::with_cashu_client`]. pub fn new( pool: Arc>, nostr_client: Client, @@ -55,9 +64,18 @@ impl AppContext { settings, order_msg_queue, keys, + cashu_client: None, } } + /// Attach a connected Cashu mint client (Cashu mode, CF-5). Returns a new + /// context; the Lightning path never calls this, so `cashu_client()` stays + /// `None` there. + pub fn with_cashu_client(mut self, cashu_client: Arc) -> Self { + self.cashu_client = Some(cashu_client); + self + } + /// Database connection pool. pub fn pool(&self) -> &Pool { &self.pool @@ -90,6 +108,15 @@ impl AppContext { pub fn keys(&self) -> &Keys { &self.keys } + + /// The connected Cashu mint client, if this node runs in Cashu mode. + /// + /// `Some` only when `[cashu] enabled = true` and the mint connected at + /// boot (CF-5); `None` in Lightning mode. Track A's `add_cashu_escrow` + /// handler uses it to validate the seller's escrow token against the mint. + pub fn cashu_client(&self) -> Option<&Arc> { + self.cashu_client.as_ref() + } } #[cfg(test)] diff --git a/src/app/take_buy.rs b/src/app/take_buy.rs index 41f97f4a..c36031ea 100644 --- a/src/app/take_buy.rs +++ b/src/app/take_buy.rs @@ -1,9 +1,10 @@ use crate::app::bond; use crate::app::bond::TakerContext; use crate::app::context::AppContext; +use crate::config::settings::Settings; use crate::util::{ enqueue_order_msg, get_dev_fee, get_fiat_amount_requested, get_market_amount_and_fee, - get_order, show_hold_invoice, + get_order, show_cashu_escrow_request, show_hold_invoice, }; use crate::db::{seller_has_pending_order, update_user_trade_index}; @@ -188,6 +189,23 @@ pub async fn take_buy_action( order.trade_index_seller = Some(trade_index); order.set_timestamp_now(); + // Cashu escrow mode (Track A TA-2): the seller (taker) locks a 2-of-3 token + // instead of paying a hold invoice. Emit the escrow request and leave the + // order in WaitingPayment, where the CAS in `add_cashu_escrow_action` + // expects it. + if Settings::is_cashu_enabled() { + show_cashu_escrow_request( + pool, + my_keys, + &buyer_pubkey, + &seller_pubkey, + order, + request_id, + ) + .await?; + return Ok(()); + } + // Show hold invoice and return success or error if let Err(cause) = show_hold_invoice( my_keys, diff --git a/src/app/take_sell.rs b/src/app/take_sell.rs index 66eebdba..10094bc5 100644 --- a/src/app/take_sell.rs +++ b/src/app/take_sell.rs @@ -1,10 +1,12 @@ use crate::app::bond; use crate::app::bond::TakerContext; use crate::app::context::AppContext; +use crate::config::settings::Settings; use crate::db::{buyer_has_pending_order, update_user_trade_index}; use crate::util::{ enqueue_order_msg, get_dev_fee, get_fiat_amount_requested, get_market_amount_and_fee, - get_order, set_waiting_invoice_status, show_hold_invoice, update_order_event, validate_invoice, + get_order, set_waiting_invoice_status, show_cashu_escrow_request, show_hold_invoice, + update_order_event, validate_invoice, }; use mostro_core::db::Crud; use mostro_core::prelude::*; @@ -217,6 +219,24 @@ pub async fn take_sell_action( order.trade_index_buyer = Some(trade_index); order.set_timestamp_now(); + // Cashu escrow mode (Track A TA-2): the seller (maker) locks a 2-of-3 token + // instead of paying a hold invoice, and the buyer redeems ecash directly — + // so the buyer payout invoice is skipped entirely (a supplied one is + // ignored). Emit the escrow request to the seller and leave the order in + // WaitingPayment, where the CAS in `add_cashu_escrow_action` expects it. + if Settings::is_cashu_enabled() { + show_cashu_escrow_request( + pool, + my_keys, + &event.sender, + &seller_pubkey, + order, + request_id, + ) + .await?; + return Ok(()); + } + // If payment request is not present, update order status to waiting buyer invoice if payment_request.is_none() { update_order_status(&mut order, my_keys, pool, request_id).await?; diff --git a/src/cashu/mod.rs b/src/cashu/mod.rs index 253b39ce..d7538415 100644 --- a/src/cashu/mod.rs +++ b/src/cashu/mod.rs @@ -376,6 +376,36 @@ impl CashuClient { Ok(token) } + /// Whether **every** proof of a stored escrow token is still unspent at + /// the mint (NUT-07). Used by the restore/monitor path (Track A TA-3) to + /// re-hydrate in-flight locks after a restart and surface any escrow that + /// was redeemed or double-spent while the daemon was down. + /// + /// This intentionally does **not** re-run the full `verify_escrow_token` + /// acceptance check (condition/DLEQ/amount): those were validated when the + /// lock was accepted and the token is immutable. It only answers "is this + /// locked token still live?". Returns `false` if any proof is spent/pending + /// or the mint reports fewer states than proofs (fail-closed). + pub async fn check_token_unspent(&self, token_str: &str) -> Result { + let token = Token::from_str(token_str).map_err(|e| Error::Token(e.to_string()))?; + let secrets = token.token_secrets(); + if secrets.is_empty() { + return Err(Error::Token("Token contains no secrets".into())); + } + let ys = secrets + .iter() + .map(|s| cdk::dhke::hash_to_curve(s.as_bytes())) + .collect::, _>>() + .map_err(|e| Error::Token(format!("proof Y: {e}")))?; + let expected = ys.len(); + let states = self.check_state(ys).await?; + // Fail closed: a missing state entry must never read as unspent. + if states.states.len() != expected { + return Ok(false); + } + Ok(states.states.iter().all(|s| s.state == State::Unspent)) + } + /// Check the state of proofs against the mint's `/v1/checkstate` /// endpoint (NUT-07). This only proves the secrets are unspent — it /// does **not** authenticate that the proofs were signed by the mint; diff --git a/src/cashu_restore.rs b/src/cashu_restore.rs new file mode 100644 index 00000000..a6ffe3bd --- /dev/null +++ b/src/cashu_restore.rs @@ -0,0 +1,82 @@ +//! Cashu escrow restore / monitor — Track A **TA-3** +//! (see `docs/cashu/02-track-a-lock.md` §6/§10). +//! +//! On startup in Cashu mode the daemon re-hydrates in-flight locked escrows +//! from the DB — the Cashu analogue of the Lightning path's `find_held_invoices` +//! resubscribe — so a restart never loses sight of an escrow that is locked but +//! not yet released. Each locked token is re-checked against the mint (NUT-07) +//! to surface any escrow that was redeemed or double-spent while the daemon was +//! down. Best-effort and log-only: it never mutates order state (that belongs to +//! the release/cancel/dispute tracks) and never blocks boot on a mint hiccup. + +use crate::cashu::CashuClient; +use crate::db::find_locked_cashu_orders; +use sqlx::SqlitePool; + +/// Re-hydrate and re-check every in-flight locked Cashu escrow at boot. +pub async fn restore_cashu_escrows(pool: &SqlitePool, cashu_client: &CashuClient) { + let locked = match find_locked_cashu_orders(pool).await { + Ok(orders) => orders, + Err(e) => { + tracing::warn!("cashu restore: failed to load locked escrows: {e}"); + return; + } + }; + + if locked.is_empty() { + tracing::info!("cashu restore: no in-flight locked escrows"); + return; + } + + tracing::info!( + "cashu restore: re-hydrating {} in-flight locked escrow(s)", + locked.len() + ); + + for order in &locked { + let Some(token) = order.cashu_escrow_token.as_deref() else { + // The CAS writes token + locked_at together, so a locked row with + // no token is a data anomaly worth flagging. + tracing::warn!( + "cashu restore: order {} is locked but carries no escrow token", + order.id + ); + continue; + }; + + match cashu_client.check_token_unspent(token).await { + Ok(true) => tracing::info!( + "cashu restore: order {} escrow is live (status {})", + order.id, + order.status + ), + Ok(false) => tracing::warn!( + "cashu restore: order {} escrow token is spent/pending at the mint \ + (status {}) — needs attention", + order.id, + order.status + ), + Err(e) => tracing::warn!( + "cashu restore: order {} mint state check failed: {e}", + order.id + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// With no locked escrows the restore path is a clean no-op — it returns + /// before ever contacting the mint (so this runs offline) and never panics. + #[tokio::test] + async fn restore_over_empty_pool_finds_nothing() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + // `find_locked_cashu_orders` returning empty is the gate that makes + // `restore_cashu_escrows` return before constructing any mint request. + let locked = find_locked_cashu_orders(&pool).await.unwrap(); + assert!(locked.is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs index 30410241..ee566041 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ pub mod app; mod bitcoin_price; pub mod cashu; +pub mod cashu_restore; pub mod cli; pub mod config; pub mod db; @@ -17,7 +18,7 @@ pub mod spam_gate; pub mod util; use crate::app::context::AppContext; -use crate::app::run; +use crate::app::{run, run_cashu}; use crate::cli::settings_init; use crate::config::{ get_db_pool, Settings, DB_POOL, LN_STATUS, MESSAGE_QUEUES, MOSTRO_CONFIG, NOSTR_CLIENT, @@ -151,6 +152,73 @@ async fn main() -> Result<()> { } } + // Cashu escrow mode (docs/cashu/, CF-5): run the daemon with NO Lightning + // node. Skip `LndConnector::new()` and the LN status probe entirely, + // connect the configured mint instead (fail fast if unreachable, mirroring + // the LND-refusal behaviour), attach the client to the context, and hand + // off to the Cashu event loop. Every trade action is still rejected with + // `CantDo(InvalidAction)` until the feature tracks land. The default + // Lightning path below is left byte-for-byte unchanged. + if Settings::is_cashu_enabled() { + // `mint_url` non-emptiness + scheme were validated at config load + // (CF-1); this expect is unreachable for a validated config. + let mint_url = Settings::get_cashu() + .map(|c| c.mint_url.clone()) + .expect("cashu enabled but [cashu] settings missing after validation"); + tracing::info!( + "Starting in Cashu escrow mode — connecting mint {mint_url} (LND not initialised)" + ); + let cashu_client = match cashu::CashuClient::connect(&mint_url).await { + Ok(client) => Arc::new(client), + Err(e) => { + tracing::error!( + "No connection to Cashu mint {mint_url} - shutting down Mostro! ({e})" + ); + exit(1); + } + }; + + // The admin gRPC server takes a Lightning client that Cashu mode never + // initialises, so it is not started here. Warn (rather than silently + // skip) when an operator has RPC enabled, so the missing API is not a + // surprise. Starting the LN-independent RPC subset in Cashu mode is a + // follow-up (see PR #828 review). + if RpcServer::is_enabled() { + tracing::warn!( + "[rpc].enabled = true but the admin gRPC server is NOT started in Cashu mode: \ + it requires a Lightning client Cashu mode does not initialise. Disable [rpc], \ + or run in Lightning mode, if you need the RPC API." + ); + } + + // Warm the anti-spam gate exactly as the Lightning path does. + install_spam_gate().await; + + // Re-hydrate in-flight locked escrows after a restart (TA-3), the + // Cashu analogue of the Lightning `find_held_invoices` resubscribe. + cashu_restore::restore_cashu_escrows(get_db_pool().as_ref(), &cashu_client).await; + + let settings = Arc::new( + MOSTRO_CONFIG + .get() + .expect("MOSTRO_CONFIG not initialized") + .clone(), + ); + let ctx = AppContext::new( + get_db_pool(), + client.clone(), + settings, + MESSAGE_QUEUES.queue_order_msg.clone(), + mostro_keys.clone(), + ) + .with_cashu_client(cashu_client); + + start_scheduler(ctx.clone()).await; + + // Run the Mostro Cashu event loop and be happy!! + return run_cashu(ctx).await; + } + let mut ln_client = LndConnector::new().await?; let ln_status = ln_client.get_node_info().await?; let ln_status = LnStatus::from_get_info_response(ln_status); @@ -193,27 +261,9 @@ async fn main() -> Result<()> { } // Install the protocol-v2 anti-spam gate and warm its active-trade-pubkey - // cache before the event loop starts, so the very first kind-14 events are - // already pre-filtered against known keys (spec §6 Phase 2). The cache is - // kept fresh afterwards by `job_refresh_active_pubkeys`. Inert on the v1 - // (gift-wrap) transport, which never consults the gate. - { - use crate::spam_gate::{SpamGate, REPLAY_WINDOW_SECS}; - let gate = SpamGate::new(REPLAY_WINDOW_SECS); - match db::find_active_trade_pubkeys(get_db_pool().as_ref()).await { - Ok(keys) => { - tracing::info!( - "SpamGate: warming active-trade-pubkey cache ({} keys)", - keys.len() - ); - gate.set_known(keys); - } - Err(e) => tracing::warn!("SpamGate: initial cache warm failed: {e}"), - } - if gate.install_global().is_err() { - tracing::warn!("SpamGate already installed"); - } - } + // cache before the event loop starts (mode-agnostic — both `run` and + // `run_cashu` consult it for kind-14 events). + install_spam_gate().await; // Build AppContext explicitly with all dependencies let settings = Arc::new( @@ -237,6 +287,32 @@ async fn main() -> Result<()> { run(ctx, &mut ln_client).await } +/// Install the protocol-v2 anti-spam gate and warm its active-trade-pubkey +/// cache before the event loop starts, so the very first kind-14 events are +/// already pre-filtered against known keys (spec §6 Phase 2). The cache is +/// kept fresh afterwards by `job_refresh_active_pubkeys`. Inert on the v1 +/// (gift-wrap) transport, which never consults the gate. +/// +/// Shared by both boot paths (Lightning `run` and Cashu `run_cashu`, CF-5) so +/// the warm-up logic exists in exactly one place. +async fn install_spam_gate() { + use crate::spam_gate::{SpamGate, REPLAY_WINDOW_SECS}; + let gate = SpamGate::new(REPLAY_WINDOW_SECS); + match db::find_active_trade_pubkeys(get_db_pool().as_ref()).await { + Ok(keys) => { + tracing::info!( + "SpamGate: warming active-trade-pubkey cache ({} keys)", + keys.len() + ); + gate.set_known(keys); + } + Err(e) => tracing::warn!("SpamGate: initial cache warm failed: {e}"), + } + if gate.install_global().is_err() { + tracing::warn!("SpamGate already installed"); + } +} + /// Build the multi-source [`crate::price::PriceManager`] from settings and /// install it as the process-wide global. When `[price]` is absent in the /// settings file we synthesise it from the legacy `[mostro]` keys diff --git a/src/scheduler.rs b/src/scheduler.rs index 14ade795..7098f6e8 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -25,13 +25,26 @@ use util::{enqueue_order_msg, get_nostr_relays, send_dm, update_order_event}; pub async fn start_scheduler(ctx: AppContext) { info!("Creating scheduler"); + // Mode-agnostic jobs run in both Lightning and Cashu mode. job_expire_pending_older_orders(ctx.clone()).await; job_update_rate_events(ctx.clone()).await; - job_cancel_orders(ctx.clone()).await; - job_retry_failed_payments(ctx.clone()).await; - job_process_dev_fee_payment(ctx.clone()).await; - job_process_bond_payouts(ctx.clone()).await; - job_reconcile_stranded_maker_bonds(ctx.clone()).await; + + // Lightning-only jobs: they settle/cancel hold invoices, retry LN + // payments, pay the dev fee over LN, and service anti-abuse bonds — all of + // which require an LND node that Cashu mode never initialises (CF-5). + // Bonds are additionally mutually exclusive with Cashu mode (CF-1). Gating + // the spawns here keeps them from calling `LndConnector::new()` on a node + // that has no LND. Lightning mode is unaffected (`is_cashu_enabled()` is + // `false`, so every job below still starts exactly as before). + if !Settings::is_cashu_enabled() { + job_cancel_orders(ctx.clone()).await; + job_retry_failed_payments(ctx.clone()).await; + job_process_dev_fee_payment(ctx.clone()).await; + job_process_bond_payouts(ctx.clone()).await; + job_reconcile_stranded_maker_bonds(ctx.clone()).await; + } + + // Mode-agnostic jobs (the info event self-skips when LN status is absent). job_info_event_send(ctx.clone()).await; job_relay_list(ctx.clone()).await; job_update_bitcoin_prices().await; @@ -169,7 +182,13 @@ async fn job_info_event_send(ctx: AppContext) { let mostro_keys = ctx.keys().clone(); let client = ctx.nostr_client().clone(); let interval = ctx.settings().mostro.publish_mostro_info_interval as u64; - let ln_status = LN_STATUS.get().unwrap(); + // The info event embeds LN node stats (`info_to_tags`). In Cashu mode there + // is no LND, so `LN_STATUS` is never set — skip the job rather than panic + // on `unwrap()`. A Cashu-aware info event is future work (CF-5). + let Some(ln_status) = LN_STATUS.get() else { + info!("Skipping mostro info event: no LN status (Cashu mode)"); + return; + }; tokio::spawn(async move { loop { info!("Sending info about mostro"); @@ -606,12 +625,20 @@ async fn job_expire_pending_older_orders(ctx: AppContext) { expired.status = Status::Expired.to_string(); match expired.update(pool).await { Ok(_) => { - bond::release_bonds_for_order_or_warn( - pool, - order_id, - "maker_bond_expiry", - ) - .await; + // Bonds are Lightning-only and mutually exclusive + // with Cashu mode (CF-1), which has no LND — the + // release helpers open `LndConnector::new()`, so + // skip them here. A cashu node should carry no + // bond rows; any left over (e.g. a reused DB) are + // a misconfiguration, not this job's concern. + if !Settings::is_cashu_enabled() { + bond::release_bonds_for_order_or_warn( + pool, + order_id, + "maker_bond_expiry", + ) + .await; + } } Err(e) => { tracing::warn!( @@ -638,34 +665,41 @@ async fn job_expire_pending_older_orders(ctx: AppContext) { // expiry is the eventual safety net. match order_updated.update(pool).await { Ok(_) => { - // Phase 1: a Pending order may be - // carrying a still-active taker bond - // (Phase 1 keeps the order in `Pending` - // while the taker funds the bond hold - // invoice). Without this hook the bond - // stays in `Requested`/`Locked` and - // the HTLC sits in LND until CLTV - // expiry — Phase 1 promises "always - // release" on every exit path, - // expiry included. - bond::release_taker_bonds_for_order_or_warn( - pool, - order_id, - "pending_expiry", - ) - .await; - // Phase 6: an expiring Pending order may be a - // range remainder (or the range root) — resolve - // the maker bond at range close (release when no - // slice was slashed; settle-at-close otherwise). - // Also covers the non-range maker bond via the - // close helper's non-range release branch. - bond::resolve_range_maker_bond_at_close_or_warn( - pool, - &order_snapshot, - "pending_expiry", - ) - .await; + // Bonds are Lightning-only and mutually exclusive + // with Cashu mode (CF-1); the release helpers open + // `LndConnector::new()`, which a cashu node has + // not initialised. Skip them — a cashu node + // carries no bond rows by construction. + if !Settings::is_cashu_enabled() { + // Phase 1: a Pending order may be + // carrying a still-active taker bond + // (Phase 1 keeps the order in `Pending` + // while the taker funds the bond hold + // invoice). Without this hook the bond + // stays in `Requested`/`Locked` and + // the HTLC sits in LND until CLTV + // expiry — Phase 1 promises "always + // release" on every exit path, + // expiry included. + bond::release_taker_bonds_for_order_or_warn( + pool, + order_id, + "pending_expiry", + ) + .await; + // Phase 6: an expiring Pending order may be a + // range remainder (or the range root) — resolve + // the maker bond at range close (release when no + // slice was slashed; settle-at-close otherwise). + // Also covers the non-range maker bond via the + // close helper's non-range release branch. + bond::resolve_range_maker_bond_at_close_or_warn( + pool, + &order_snapshot, + "pending_expiry", + ) + .await; + } } Err(e) => { tracing::warn!( diff --git a/src/util.rs b/src/util.rs index cbe46a1e..0a667ed3 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1047,6 +1047,85 @@ pub async fn show_hold_invoice( Ok(()) } +/// Cashu analogue of [`show_hold_invoice`] (Track A **TA-2**, +/// `docs/cashu/02-track-a-lock.md` §5). +/// +/// Instead of creating a Lightning hold invoice, ask the **seller** to lock the +/// trade amount in a 2-of-3 Cashu token: advance the order to `WaitingPayment` +/// (where the CAS in `add_cashu_escrow_action` expects it), record both trade +/// pubkeys, publish the updated order event, and enqueue an escrow request to +/// the seller carrying everything the client needs to build the 2-of-3 +/// (`order.amount`, the buyer and seller trade pubkeys). The buyer (taker) gets +/// a "waiting for the seller" notice — the buyer redeems the ecash directly +/// later, so there is **no** buyer payout invoice in Cashu mode. +/// +/// The escrow token locks `order.amount` **exactly** — the Mostro fee is a +/// separate token (Option 2, added in TA-1f). The **mint URL** and the +/// **locktime floor** are node policy the daemon enforces authoritatively when +/// the seller submits (`add_cashu_escrow_action` §5/§7), so a client that funds +/// against the wrong mint or with too short a locktime is simply rejected and +/// retries — they are not carried in this request payload (the 0.14.0 protocol +/// has no field for them). +/// +/// The request is delivered as `Action::WaitingSellerToPay` carrying a +/// `Payload::Order` whose `buyer_trade_pubkey`/`seller_trade_pubkey` are set; +/// on a Cashu node the seller's client reads that as "lock the escrow" (the +/// Lightning path instead sends `Action::PayInvoice` with a bolt11). +pub async fn show_cashu_escrow_request( + pool: &Pool, + my_keys: &Keys, + buyer_pubkey: &PublicKey, + seller_pubkey: &PublicKey, + mut order: Order, + request_id: Option, +) -> Result<(), MostroError> { + order.status = Status::WaitingPayment.to_string(); + order.buyer_pubkey = Some(buyer_pubkey.to_string()); + order.seller_pubkey = Some(seller_pubkey.to_string()); + + // Publish the updated (WaitingPayment) order event and persist it. + let order_updated = update_order_event(my_keys, Status::WaitingPayment, &order) + .await + .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?; + order_updated + .update(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + // Build the escrow request for the seller. + let mut new_order = order.as_new_order(); + new_order.status = Some(Status::WaitingPayment); + new_order.amount = order.amount; + new_order.buyer_trade_pubkey = Some(buyer_pubkey.to_string()); + new_order.seller_trade_pubkey = Some(seller_pubkey.to_string()); + // No buyer invoice in Cashu mode. + new_order.buyer_invoice = None; + + enqueue_order_msg( + request_id, + Some(order.id), + Action::WaitingSellerToPay, + Some(Payload::Order(new_order)), + *seller_pubkey, + order.trade_index_seller, + ) + .await; + + // Notify the buyer (taker) that their order was taken and the seller must + // lock the escrow. + enqueue_order_msg( + request_id, + Some(order.id), + Action::WaitingSellerToPay, + None, + *buyer_pubkey, + order.trade_index_buyer, + ) + .await; + + Ok(()) +} + // Create function to reuse in case of resubscription pub async fn invoice_subscribe(hash: Vec, request_id: Option) -> Result<(), MostroError> { let mut ln_client_invoices = lightning::LndConnector::new().await?; @@ -2433,6 +2512,75 @@ mod tests { assert!(updated5.event_id.is_empty()); } + // ───────────────────────── cashu escrow request (TA-2) ───────────────────────── + + /// `show_cashu_escrow_request` advances the order to `WaitingPayment`, + /// records both trade pubkeys, and enqueues the escrow request to the + /// seller (carrying the trade pubkeys + bare amount) plus a "wait" notice + /// to the buyer — no buyer invoice, no Lightning. + #[tokio::test] + async fn show_cashu_escrow_request_advances_status_and_notifies_both_parties() { + init_globals(); + let pool = migrated_pool().await; + let keys = Keys::generate(); + let buyer = Keys::generate().public_key(); + let seller = Keys::generate().public_key(); + let mut order = base_order(OrderKind::Sell, Status::Pending); + order.trade_index_seller = Some(2); + order.trade_index_buyer = Some(3); + let order = order.create(&pool).await.unwrap(); + + show_cashu_escrow_request(&pool, &keys, &buyer, &seller, order.clone(), Some(7)) + .await + .expect("escrow request must succeed offline"); + + // Status advanced + both trade pubkeys recorded. + let db = Order::by_id(&pool, order.id).await.unwrap().unwrap(); + assert_eq!(db.status, Status::WaitingPayment.to_string()); + assert_eq!(db.buyer_pubkey, Some(buyer.to_string())); + assert_eq!(db.seller_pubkey, Some(seller.to_string())); + + // Collect our order's queued messages (the queue is shared across tests). + let msgs: Vec<(Message, PublicKey)> = MESSAGE_QUEUES + .queue_order_msg + .read() + .await + .iter() + .filter(|(m, _)| m.get_inner_message_kind().id == Some(order.id)) + .cloned() + .collect(); + + // The seller gets the escrow request carrying the 2-of-3 build inputs. + let (seller_msg, _) = msgs + .iter() + .find(|(_, pk)| *pk == seller) + .expect("seller escrow request"); + assert_eq!( + seller_msg.get_inner_message_kind().action, + Action::WaitingSellerToPay + ); + match seller_msg.get_inner_message_kind().get_payload() { + Some(Payload::Order(so)) => { + assert_eq!(so.buyer_trade_pubkey, Some(buyer.to_string())); + assert_eq!(so.seller_trade_pubkey, Some(seller.to_string())); + assert_eq!(so.amount, order.amount); + assert!(so.buyer_invoice.is_none()); + } + other => panic!("expected Order payload for the seller, got {other:?}"), + } + + // The buyer gets a bare "waiting for the seller" notice. + let (buyer_msg, _) = msgs + .iter() + .find(|(_, pk)| *pk == buyer) + .expect("buyer notice"); + assert_eq!( + buyer_msg.get_inner_message_kind().action, + Action::WaitingSellerToPay + ); + assert!(buyer_msg.get_inner_message_kind().get_payload().is_none()); + } + // ───────────────────────── nostr client plumbing ───────────────────────── #[tokio::test]