From 07936897cdaa61d450008fd0036f9e3036f7f098 Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 20 Jul 2026 18:44:44 -0300 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20cashu=20boot=20+=20run=5Fcashu=20+?= =?UTF-8?q?=20dispatch=20seam=20=E2=80=94=20Cashu=20foundation=20CF-5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration PR of the Cashu foundation (docs/cashu/01-fundamentals.md §6). A node with `[cashu] enabled = true` now boots with NO Lightning node, connects its configured mint, and runs a Cashu event loop in which every trade action is rejected with `CantDo(InvalidAction)` — the feature tracks replace those arms one at a time. With Cashu disabled the daemon is behaviourally identical to `main`. - `main.rs`: branch on `Settings::is_cashu_enabled()`. Cashu mode skips `LndConnector::new()` and the LN status probe, connects the mint via `CashuClient::connect` (exit(1) if unreachable, mirroring LND-refusal), attaches it to the context, and returns `run_cashu(ctx)`. The spam-gate warm-up is extracted into `install_spam_gate()` and shared by both paths; the Lightning branch is otherwise unchanged. - `app.rs`: factor the transport/validation prologue and the post-dispatch error tail out of `run()` into `accept_event` / `finalize_dispatch`, shared VERBATIM by `run` and the new `run_cashu` so the two loops cannot drift. Add `dispatch_cashu` with the closed allow-list (`Orders`, `LastTradeIndex`, `RestoreSession`, `TradePubkey` → `handle_message_action_no_ln`; `AddCashuEscrow` → the TA-1 stub; everything else → `InvalidAction`). - `app/add_cashu_escrow.rs` (new): stub `add_cashu_escrow_action` returning `InvalidAction`; Track A fills the body in its own file (G-1). - `app/context.rs`: `AppContext` carries `Option>` with a `with_cashu_client` setter and `cashu_client()` accessor (`None` in Lightning mode). The `escrow: Arc` field the spec sketches is deferred: `run()` still threads `&mut LndConnector` and an unused `Arc` would be dead weight — added when Track B needs it. - `scheduler.rs`: gate the LN-only jobs (cancel-orders, retry-payments, dev-fee, bond payouts, stranded-bond reconcile) behind `!is_cashu_enabled()` so they never call `LndConnector::new()` on a node with no LND; make `job_info_event_send` self-skip when `LN_STATUS` is absent instead of panicking. Tests: `dispatch_cashu` blocks every order-lifecycle action with `InvalidAction` and routes the allow-list through the no-LN handler; the CF-5 stub returns `InvalidAction`. Full suite green (1023) with Cashu off. Refs: docs/cashu/01-fundamentals.md (CF-5) --- src/app.rs | 440 ++++++++++++++++++++++++++---------- src/app/add_cashu_escrow.rs | 65 ++++++ src/app/context.rs | 27 +++ src/main.rs | 102 +++++++-- src/scheduler.rs | 31 ++- 5 files changed, 523 insertions(+), 142 deletions(-) create mode 100644 src/app/add_cashu_escrow.rs diff --git a/src/app.rs b/src/app.rs index 089fc804..3c38ed7e 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,115 @@ 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 + } + // 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 creates, advances, or settles an order has no escrow + // behind it during the foundation milestone — reject it cleanly. + _ => Err(MostroError::MostroCantDo(CantDoReason::InvalidAction).into()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -898,6 +1025,91 @@ 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)) + ) + } + + /// 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. + #[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::NewOrder, + Action::TakeBuy, + Action::TakeSell, + Action::AddInvoice, + Action::FiatSent, + Action::Release, + Action::Cancel, + Action::Dispute, + Action::RateUser, + Action::AddCashuEscrow, + 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..f114f20c --- /dev/null +++ b/src/app/add_cashu_escrow.rs @@ -0,0 +1,65 @@ +//! Cashu escrow lock handler — Track A **TA-1** integration point. +//! +//! 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`). +//! +//! 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). + +use crate::app::context::AppContext; +use mostro_core::prelude::*; +use nostr_sdk::prelude::*; + +/// Handle a seller's `AddCashuEscrow` submission. +/// +/// **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. +pub async fn add_cashu_escrow_action( + _ctx: &AppContext, + _msg: Message, + _event: &UnwrappedMessage, + _my_keys: &Keys, +) -> Result<(), MostroError> { + Err(MostroError::MostroCantDo(CantDoReason::InvalidAction)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::context::test_utils::{test_settings, TestContextBuilder}; + 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) + .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), + signature: None, + sender: Keys::generate().public_key(), + identity: Keys::generate().public_key(), + created_at: nostr_sdk::Timestamp::now(), + }; + let msg = Message::new_order(None, None, None, Action::AddCashuEscrow, None); + + let result = add_cashu_escrow_action(&ctx, msg, &event, &my_keys).await; + assert_eq!( + result, + Err(MostroError::MostroCantDo(CantDoReason::InvalidAction)) + ); + } +} 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/main.rs b/src/main.rs index 30410241..3c10ee07 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,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 +151,56 @@ 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); + } + }; + + // Warm the anti-spam gate exactly as the Lightning path does. + install_spam_gate().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 +243,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 +269,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..22e13bb1 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"); From b8116e34c58ff092cbf23752c8a349e9b1e56ef9 Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 20 Jul 2026 22:30:48 -0300 Subject: [PATCH 2/6] =?UTF-8?q?fix(cashu):=20address=20CF-5=20review=20?= =?UTF-8?q?=E2=80=94=20gate=20bond=20expiry=20+=20warn=20on=20RPC=20in=20C?= =?UTF-8?q?ashu=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings from PR #828: - Bond-expiry paths in `job_expire_pending_older_orders` open `LndConnector::new()` via the bond-release helpers. Cashu mode has no LND, so gate all three release calls (WaitingMakerBond, taker-bond, range maker bond) behind `!Settings::is_cashu_enabled()`. Bonds are mutually exclusive with Cashu (CF-1), so a cashu node carries no bond rows by construction; this only matters defensively for a reused/mode-switched DB, where the order still expires but no doomed LND connection is attempted. - The Cashu boot branch early-returns before the RPC startup block, silently skipping the admin gRPC server. Warn when `[rpc].enabled = true` in Cashu mode so the missing API is visible instead of a surprise (the LN-independent RPC subset is a follow-up — the server currently requires an LND client). Skipped (with reasons posted on the PR): the `check_trade_index`-before-dispatch note (pre-existing shared-prologue behaviour, identical to the Lightning loop; the actions become valid in TA-2 where the check belongs) and the boot-init DRY hoist (it reorders the Lightning boot, violating the byte-identical guarantee). cargo fmt + clippy -D warnings + full suite (1023) green. --- src/main.rs | 13 ++++++++ src/scheduler.rs | 83 ++++++++++++++++++++++++++++-------------------- 2 files changed, 62 insertions(+), 34 deletions(-) diff --git a/src/main.rs b/src/main.rs index 3c10ee07..e0482464 100644 --- a/src/main.rs +++ b/src/main.rs @@ -177,6 +177,19 @@ async fn main() -> Result<()> { } }; + // 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; diff --git a/src/scheduler.rs b/src/scheduler.rs index 22e13bb1..7098f6e8 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -625,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!( @@ -657,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!( From 4c0780aa9e62763d4a2c55ee1d23cc10310b43be Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 20 Jul 2026 18:51:57 -0300 Subject: [PATCH 3/6] =?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 760467f23a2a3cd0389e80ff2c166afde0b5187a Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 20 Jul 2026 18:53:58 -0300 Subject: [PATCH 4/6] 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 a16c7459a832cb60c8c8e30353a5cb6e3273d531 Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 20 Jul 2026 19:13:16 -0300 Subject: [PATCH 5/6] =?UTF-8?q?feat(cashu):=20take=20flow=20escrow=20reque?= =?UTF-8?q?st=20+=20unblock=20creation=20=E2=80=94=20Track=20A=20TA-2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Cashu branch to `take_sell_action`/`take_buy_action` and the `show_cashu_escrow_request` helper (docs/cashu/02-track-a-lock.md §5), and unblock `NewOrder`/`TakeBuy`/`TakeSell` in `dispatch_cashu`. A taken order on a cashu node now asks the SELLER to lock a 2-of-3 escrow instead of paying a hold invoice, leaving the order in `WaitingPayment` where the TA-1 CAS expects it. Creatable and takeable ship together so the book never fills with untakeable orders (the orphan-order hazard CF-5 warns about). - `util::show_cashu_escrow_request`: advance to `WaitingPayment`, record both trade pubkeys, publish the order event, and enqueue an escrow request to the seller (`Action::WaitingSellerToPay` + `Payload::Order` carrying `amount`/`buyer_trade_pubkey`/`seller_trade_pubkey`) plus a bare "waiting for the seller" notice to the buyer. The buyer redeems ecash directly, so there is no buyer payout invoice. The escrow locks `order.amount` exactly (the fee is a separate token in TA-1f); the mint URL + locktime floor are node policy the daemon enforces authoritatively at lock validation (TA-1 §5/§7), so they are not carried in the request (the 0.14.0 protocol has no field for them). - `take_sell`/`take_buy`: early-return through `show_cashu_escrow_request` when `is_cashu_enabled()`, skipping the hold-invoice / buyer-invoice path. A supplied buyer invoice is ignored in Cashu mode. Lightning path unchanged. - `dispatch_cashu`: route `NewOrder`/`TakeBuy`/`TakeSell` to `handle_message_action_no_ln` (their real handlers); the gate test drops them from the `InvalidAction` list accordingly. Test: `show_cashu_escrow_request` advances the status, records both pubkeys, and enqueues the seller request (with the trade pubkeys + bare amount) and the buyer notice. Depends on CF-5 (+ TA-1 for the full e2e lock). Base: feat/cashu-ta1-lock-handler Refs: docs/cashu/02-track-a-lock.md (TA-2) --- src/app.rs | 28 ++++---- src/app/take_buy.rs | 20 +++++- src/app/take_sell.rs | 22 ++++++- src/util.rs | 148 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 13 deletions(-) diff --git a/src/app.rs b/src/app.rs index 938641a6..50daf0a4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -561,12 +561,21 @@ async fn dispatch_cashu( 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 creates, advances, or settles an order has no escrow - // behind it during the foundation milestone — reject it cleanly. + // 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()), } } @@ -1051,12 +1060,12 @@ mod tests { ) } - /// Every action that creates, advances, or settles an order — plus the - /// 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. + /// 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 _ = @@ -1067,9 +1076,6 @@ mod tests { let event = create_test_unwrapped_message(); for action in [ - Action::NewOrder, - Action::TakeBuy, - Action::TakeSell, Action::AddInvoice, Action::FiatSent, Action::Release, 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/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] From 456c3f4a6bb43dbd7a7ee98325942a0630130dfa Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 20 Jul 2026 19:17:23 -0300 Subject: [PATCH 6/6] =?UTF-8?q?feat(cashu):=20restore/monitor=20in-flight?= =?UTF-8?q?=20locked=20escrows=20=E2=80=94=20Track=20A=20TA-3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On boot in Cashu mode, re-hydrate in-flight locked escrows from the DB (the Cashu analogue of the Lightning `find_held_invoices` resubscribe) so a restart never loses sight of a locked-but-not-released escrow (docs/cashu/02-track-a-lock.md §6/§10). - `CashuClient::check_token_unspent`: whether every proof of a stored escrow token is still unspent at the mint (NUT-07), fail-closed on a short state reply. Reuses the Y-derivation the lock path already uses; does not re-run the full acceptance check (the token is immutable and was validated at lock). - `cashu_restore::restore_cashu_escrows`: list `find_locked_cashu_orders`, and for each re-check the token against the mint, logging a warning for any escrow that is spent/pending (redeemed or double-spent while the daemon was down). Best-effort, log-only — it never mutates order state (that belongs to the release/cancel/dispute tracks) and never blocks boot. - Wired into the `main.rs` Cashu boot branch after the mint connects, before the scheduler starts. Test: restore over an empty pool finds nothing and never contacts the mint. The mint-backed spent/live detection is exercised by the CF-3 integration harness. Depends on CF-4 + CF-5. Base: feat/cashu-ta2-take-flow Refs: docs/cashu/02-track-a-lock.md (TA-3) --- src/cashu/mod.rs | 30 ++++++++++++++++ src/cashu_restore.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 5 +++ 3 files changed, 117 insertions(+) create mode 100644 src/cashu_restore.rs 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 e0482464..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; @@ -193,6 +194,10 @@ async fn main() -> Result<()> { // 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()