From 07936897cdaa61d450008fd0036f9e3036f7f098 Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 20 Jul 2026 18:44:44 -0300 Subject: [PATCH 1/2] =?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/2] =?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!(