From 9b9c1184f1353b2efccdbdec36cebfb471f93b1f Mon Sep 17 00:00:00 2001 From: ifuensan <5514150+ifuensan@users.noreply.github.com> Date: Sat, 30 May 2026 16:42:49 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(cashu):=20Track=20C=20=E2=80=94=20coop?= =?UTF-8?q?erative=20cancel=20in=20Cashu=20mode=20(no=20hold-invoice,=20P2?= =?UTF-8?q?P=202-of-3=20reclaim)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.rs | 38 +++++++----- src/app/cancel.rs | 155 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 160 insertions(+), 33 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1efa4bb5..f1873c97 100644 --- a/src/app.rs +++ b/src/app.rs @@ -32,7 +32,7 @@ use crate::app::admin_cancel::admin_cancel_action; use crate::app::admin_settle::admin_settle_action; use crate::app::admin_take_dispute::admin_take_dispute_action; use crate::app::bond::add_bond_invoice_action; -use crate::app::cancel::cancel_action; +use crate::app::cancel::{cancel_action, cancel_action_cashu}; use crate::app::context::AppContext; use crate::app::dispute::dispute_action; use crate::app::fiat_sent::fiat_sent_action; @@ -395,11 +395,13 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> { } } -/// Cashu-mode event loop. Mirrors `run()` but without an LND client — all -/// actions are routed through `handle_message_action_no_ln`. Actions that -/// require LND (Release, Cancel, AdminCancel, AdminSettle) fall through to -/// its `_` arm and are logged/ignored until the EscrowBackend abstraction -/// lands in F3 and wires up proper Cashu dispatch. +/// Cashu-mode event loop. Mirrors `run()` but without an LND client. Most +/// actions route through `handle_message_action_no_ln`; `Action::Cancel` is +/// handled by `cancel_action_cashu` (cooperative cancel needs no LND, since +/// Mostro holds no custody of Cashu funds). The remaining escrow-dependent +/// actions (`NewOrder`, `TakeSell`, `TakeBuy`, `AddInvoice`, `Release`, +/// `AdminCancel`, `AdminSettle`) are rejected with `CantDo` until their Cashu +/// tracks wire up the corresponding backend dispatch. pub async fn run_cashu(ctx: AppContext) -> Result<()> { let my_keys = ctx.keys(); let client = ctx.nostr_client(); @@ -453,20 +455,28 @@ pub async fn run_cashu(ctx: AppContext) -> Result<()> { if inner_message.verify() { if let Some(action) = message.inner_action() { - // Escrow-dependent actions are not yet wired for - // Cashu mode (F4). Return CantDo so the peer gets - // a clear error instead of a cryptic LND failure. + // Cancel is wired for Cashu (cooperative cancel); the + // other escrow-dependent actions are rejected with + // CantDo so the peer gets a clear error instead of a + // cryptic LND failure, until their tracks land. let result = match action { - // No escrow backend wired in F2: reject all - // trade actions so peers get a clear error - // rather than orders that can never be filled - // or cancelled. + // Track C: cooperative cancel works in Cashu mode + // without an LND client — Mostro never held custody, + // so it only records the cancel and transitions state + // while the parties reclaim the 2-of-3 token P2P. + Action::Cancel => { + cancel_action_cashu(&ctx, message.clone(), &unwrapped, my_keys) + .await + .map_err(|e| e.into()) + } + // Other trade actions have no escrow backend wired + // yet: reject them so peers get a clear error rather + // than orders that can never be filled or settled. Action::NewOrder | Action::TakeSell | Action::TakeBuy | Action::AddInvoice | Action::Release - | Action::Cancel | Action::AdminCancel | Action::AdminSettle => { Err(MostroError::MostroCantDo(CantDoReason::InvalidAction) diff --git a/src/app/cancel.rs b/src/app/cancel.rs index f1199ce6..f72a13d7 100644 --- a/src/app/cancel.rs +++ b/src/app/cancel.rs @@ -1,7 +1,10 @@ use crate::app::bond; use crate::app::context::AppContext; use crate::app::dispute::close_dispute_after_user_resolution; +use crate::config::settings::Settings; +use crate::config::types::EscrowMode; use crate::db::{edit_pubkeys_order, update_order_to_initial_state}; +use crate::escrow::CashuBackend; use crate::lightning::LndConnector; use crate::util::{enqueue_order_msg, get_order, update_order_event}; use mostro_core::prelude::*; @@ -32,6 +35,58 @@ impl CancelLightning for LndConnector { } } +/// Cashu escrow mode has no Lightning client. The cooperative-cancel fund +/// return is gated on [`Settings::escrow_mode`] and never reaches this method +/// in Cashu mode; it is a defensive no-op so an unexpected call can't try (and +/// fail) to cancel a hold invoice that, by construction, does not exist. +impl CancelLightning for CashuBackend { + fn cancel_hold_invoice<'a>( + &'a mut self, + _hash: &'a str, + ) -> std::pin::Pin> + Send + 'a>> + { + Box::pin(async move { + warn!("cancel_hold_invoice called on Cashu backend — no-op (Mostro holds no custody)"); + Ok(()) + }) + } +} + +/// Return the escrowed funds to the seller when a cancel voids the trade. +/// +/// The mechanism depends on the active escrow backend, so all cancel paths route +/// the fund return through here to keep one consistent rule: +/// - **Lightning:** cancel the seller's hold invoice — Mostro held custody, so it +/// must actively void the invoice for the funds to return. +/// - **Cashu:** a no-op. Mostro never took custody, so there is nothing to cancel: +/// the counterparty hands their signature to the seller over NIP-59 and the +/// seller reconstructs a 2-of-3 swap to reclaim the locked ecash, entirely P2P +/// (see `docs/CASHU_ESCROW_ARCHITECTURE.md`). +/// +/// `context` labels the log line (e.g. `"Cooperative cancel"`). +async fn return_escrow_to_seller( + order: &Order, + ln_client: &mut L, + context: &str, +) -> Result<(), MostroError> { + match Settings::escrow_mode() { + EscrowMode::Lightning => { + if let Some(hash) = &order.hash { + ln_client.cancel_hold_invoice(hash).await?; + info!("{context}: Order Id {}: Funds returned to seller", order.id); + } + } + EscrowMode::Cashu => { + info!( + "{context}: Order Id {}: Cashu escrow — no hold invoice to cancel; \ + seller reclaims the 2-of-3 token P2P", + order.id + ); + } + } + Ok(()) +} + /// Reset API-provided quote-derived amounts when republishing an order. /// /// When an order was created with `price_from_api`, its `amount` and `fee` @@ -92,15 +147,9 @@ async fn cancel_cooperative_execution_step_2( } } - // Cancel hold invoice if present; if funds were locked, this returns them to the seller. - if let Some(hash) = &order.hash { - // We return funds to seller - ln_client.cancel_hold_invoice(hash).await?; - info!( - "Cooperative cancel: Order Id {}: Funds returned to seller", - &order.id - ); - } + // Cooperative cancel voids the trade: return the escrowed funds to the seller + // (Lightning cancels the hold invoice; Cashu is a P2P no-op for Mostro). + return_escrow_to_seller(&order, ln_client, "Cooperative cancel").await?; order.status = Status::CooperativelyCanceled.to_string(); // update db let order = order @@ -273,11 +322,8 @@ async fn cancel_order_by_taker_inner( ln_client: &mut L, taker_pubkey: PublicKey, ) -> Result<(), MostroError> { - // Cancel hold invoice if present - if let Some(hash) = &order.hash { - ln_client.cancel_hold_invoice(hash).await?; - info!("Order Id {}: Funds returned to seller", &order.id); - } + // Void the escrow and return funds to the seller (backend-dependent). + return_escrow_to_seller(&order, ln_client, "Cancel").await?; //We notify the taker that the order is cancelled enqueue_order_msg( @@ -340,11 +386,8 @@ async fn cancel_order_by_maker( .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; } - // Cancel hold invoice if present - if let Some(hash) = &order.hash { - ln_client.cancel_hold_invoice(hash).await?; - info!("Order Id {}: Funds returned to seller", &order.id); - } + // Void the escrow and return funds to the seller (backend-dependent). + return_escrow_to_seller(&order, ln_client, "Cancel").await?; enqueue_order_msg( request_id, @@ -467,6 +510,34 @@ pub async fn cancel_action( cancel_action_generic(ctx, msg, event, my_keys, ln_client).await } +/// Cancel entry point for **Cashu escrow mode**, where the daemon runs without +/// an LND client (see `run_cashu`). Mostro never takes custody of Cashu funds, +/// so a cooperative cancel only records the cancel and transitions state — the +/// buyer hands their signature to the seller P2P (NIP-59 DM) so the seller +/// reconstructs a `P_S + P_B` 2-of-3 swap to reclaim the locked ecash. The same +/// cancel state machine drives both modes; here a no-op [`CashuBackend`] stands +/// in for the Lightning client and the cooperative-cancel path skips the +/// (non-existent) hold invoice via [`Settings::escrow_mode`]. +pub async fn cancel_action_cashu( + ctx: &AppContext, + msg: Message, + event: &UnwrappedMessage, + my_keys: &Keys, +) -> Result<(), MostroError> { + // Fail fast outside Cashu mode. This entry injects a no-op `CashuBackend`, + // so running it while a Lightning escrow is configured would silently skip + // the real hold-invoice cancellation (`return_escrow_to_seller` would take + // the Lightning arm and no-op) while still marking the order canceled. + // Only reachable from `run_cashu` today, but the invariant is enforced here + // because this function is `pub`. + if Settings::escrow_mode() != EscrowMode::Cashu { + return Err(MostroInternalErr(ServiceError::UnexpectedError( + "cancel_action_cashu invoked while not in Cashu escrow mode".to_string(), + ))); + } + cancel_action_generic(ctx, msg, event, my_keys, &mut CashuBackend).await +} + async fn cancel_action_generic( ctx: &AppContext, msg: Message, @@ -760,6 +831,52 @@ mod tests { } } + /// Track C: the Cashu cancel path drives the cancel state machine with a + /// no-op escrow client. Mostro holds no custody of Cashu funds, so even if + /// the hold-invoice primitive is reached it must not error — the seller + /// reclaims the 2-of-3 token P2P, off Mostro. This locks that contract. + #[tokio::test] + async fn cashu_backend_cancel_hold_invoice_is_noop() { + let mut backend = CashuBackend; + let result = backend.cancel_hold_invoice("deadbeef").await; + assert!(result.is_ok()); + } + + /// The Cashu cancel entry must refuse to run when the node is not in Cashu + /// escrow mode — otherwise it would inject the no-op CashuBackend and mark + /// an order canceled without cancelling the real Lightning hold invoice. The + /// unit-test binary's global escrow mode is Lightning, so this must error + /// before touching the order. + #[tokio::test] + async fn cancel_action_cashu_refuses_outside_cashu_mode() { + let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap()); + sqlx::migrate!("./migrations") + .run(pool.as_ref()) + .await + .unwrap(); + let ctx = TestContextBuilder::new() + .with_pool(pool) + .with_settings(test_settings()) + .build(); + + let caller = Keys::generate().public_key(); + let event = create_unwrapped_message_with_pubkey(caller); + let msg = Message::new_order( + Some(uuid::Uuid::new_v4()), + Some(1), + None, + Action::Cancel, + None, + ); + let my_keys = Keys::generate(); + + let result = cancel_action_cashu(&ctx, msg, &event, &my_keys).await; + assert!(matches!( + result, + Err(MostroInternalErr(ServiceError::UnexpectedError(_))) + )); + } + #[tokio::test] async fn cancel_action_with_ctx_rejects_non_creator_for_pending_order() { let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap()); From 0fecce29df9c0efedfabc2875c159748cec04729 Mon Sep 17 00:00:00 2001 From: ifuensan <5514150+ifuensan@users.noreply.github.com> Date: Sun, 31 May 2026 22:48:15 +0200 Subject: [PATCH 2/2] fix(cashu): clear clippy + fmt in cashu/mod.rs (F4 cleanup to unblock CI) --- src/cashu/mod.rs | 105 +++++++++++++++++++++++++++++------------------ 1 file changed, 66 insertions(+), 39 deletions(-) diff --git a/src/cashu/mod.rs b/src/cashu/mod.rs index 4d1e42dc..b5651e60 100644 --- a/src/cashu/mod.rs +++ b/src/cashu/mod.rs @@ -1,8 +1,8 @@ use cdk::error::Error as CdkClientError; use cdk::mint_url::MintUrl; +use cdk::nuts::{nut00::Proofs, nut01::SecretKey as NutSecretKey, nut10::SpendingConditions}; +use cdk::nuts::{nut02::ShortKeysetId, CheckStateRequest, CheckStateResponse, PublicKey, Token}; use cdk::wallet::MintConnector; -use cdk::nuts::{nut01::SecretKey as NutSecretKey, nut00::Proofs, nut10::SpendingConditions}; -use cdk::nuts::{CheckStateRequest, CheckStateResponse, PublicKey, Token, nut02::ShortKeysetId}; use std::str::FromStr; use std::sync::OnceLock; @@ -37,7 +37,6 @@ impl From for Error { /// A client for communicating with a Cashu mint. #[derive(Clone)] pub struct CashuClient { - mint_url: MintUrl, client: cdk::HttpClient, } @@ -46,28 +45,25 @@ pub static CASHU_STATUS: OnceLock = OnceLock::new(); impl CashuClient { /// Connects to a mint URL and verifies it is reachable. pub async fn connect(mint_url: &str) -> Result { - let url = MintUrl::from_str(mint_url) - .map_err(|e| Error::InvalidMintUrl(e.to_string()))?; + let url = MintUrl::from_str(mint_url).map_err(|e| Error::InvalidMintUrl(e.to_string()))?; - let client = cdk::HttpClient::new(url.clone(), None); - let cashu_client = Self { - mint_url: url.clone(), - client, - }; + let client = cdk::HttpClient::new(url, None); + let cashu_client = Self { client }; match cashu_client.client.get_mint_info().await { Ok(info) => { if !info.nuts.nut11.supported { CASHU_STATUS.get_or_init(|| false); - return Err(Error::MintConnection("Mint does not support NUT-11 P2PK".into())); + return Err(Error::MintConnection( + "Mint does not support NUT-11 P2PK".into(), + )); } CASHU_STATUS.get_or_init(|| true); Ok(cashu_client) } Err(e) => { CASHU_STATUS.get_or_init(|| false); - let err: cdk::error::Error = e.into(); - Err(Error::MintConnection(err.to_string())) + Err(Error::MintConnection(e.to_string())) } } } @@ -80,8 +76,7 @@ impl CashuClient { p_s: PublicKey, p_m: PublicKey, ) -> Result { - let token = Token::from_str(token) - .map_err(|e| Error::Token(e.to_string()))?; + let token = Token::from_str(token).map_err(|e| Error::Token(e.to_string()))?; let secrets = token.token_secrets(); if secrets.is_empty() { @@ -89,23 +84,36 @@ impl CashuClient { } for secret in secrets { - let spending_conditions = SpendingConditions::try_from(secret).map_err(|e| Error::Condition(e.to_string()))?; - + let spending_conditions = SpendingConditions::try_from(secret) + .map_err(|e| Error::Condition(e.to_string()))?; + if spending_conditions.num_sigs() != Some(2) { - return Err(Error::Condition("Spending condition must require exactly 2 signatures".into())); + return Err(Error::Condition( + "Spending condition must require exactly 2 signatures".into(), + )); } if spending_conditions.locktime().is_some() { - return Err(Error::Condition("Spending condition cannot have a locktime".into())); + return Err(Error::Condition( + "Spending condition cannot have a locktime".into(), + )); } if spending_conditions.refund_keys().is_some() { - return Err(Error::Condition("Spending condition cannot have refund keys".into())); + return Err(Error::Condition( + "Spending condition cannot have refund keys".into(), + )); } let pubkeys = spending_conditions.pubkeys().unwrap_or_default(); - if pubkeys.len() != 3 || !pubkeys.contains(&p_b) || !pubkeys.contains(&p_s) || !pubkeys.contains(&p_m) { - return Err(Error::Condition("Missing expected pubkeys in spending condition".into())); + if pubkeys.len() != 3 + || !pubkeys.contains(&p_b) + || !pubkeys.contains(&p_s) + || !pubkeys.contains(&p_m) + { + return Err(Error::Condition( + "Missing expected pubkeys in spending condition".into(), + )); } } @@ -117,39 +125,56 @@ impl CashuClient { /// that the proofs were signed by the mint. Use `verify_token_dleq` for that. pub async fn check_state(&self, ys: Vec) -> Result { let request = CheckStateRequest { ys }; - let response = self.client.post_check_state(request).await - .map_err(|e| { - Error::Client(cdk::error::Error::from(e)) - })?; + let response = self + .client + .post_check_state(request) + .await + .map_err(Error::Client)?; Ok(response) } /// Verifies the DLEQ proofs for all proofs in a token. /// This authenticates that the token was actually issued by the mint. pub async fn verify_token_dleq(&self, token: &Token) -> Result<(), Error> { - let keysets = self.client.get_mint_keys().await.map_err(|e| Error::Client(cdk::error::Error::from(e)))?; - + let keysets = self.client.get_mint_keys().await.map_err(Error::Client)?; + match token { Token::TokenV3(token_v3) => { - let proofs = token_v3.token.iter().flat_map(|t| t.proofs.clone()).collect::>(); + let proofs = token_v3 + .token + .iter() + .flat_map(|t| t.proofs.clone()) + .collect::>(); for proof in proofs { - let keyset = keysets.iter().find(|k| ShortKeysetId::from(k.id) == proof.keyset_id) + let keyset = keysets + .iter() + .find(|k| ShortKeysetId::from(k.id) == proof.keyset_id) .ok_or_else(|| Error::Token("Unknown keyset".into()))?; - let mint_pubkey = keyset.keys.get(&proof.amount).ok_or_else(|| Error::Token("Unknown amount for keyset".into()))?; - + let mint_pubkey = keyset + .keys + .get(&proof.amount) + .ok_or_else(|| Error::Token("Unknown amount for keyset".into()))?; + let p = proof.into_proof(&keyset.id); - p.verify_dleq(*mint_pubkey).map_err(|_| Error::Token("Invalid DLEQ proof".into()))?; + p.verify_dleq(*mint_pubkey) + .map_err(|_| Error::Token("Invalid DLEQ proof".into()))?; } - }, + } Token::TokenV4(token_v4) => { for token_entry in &token_v4.token { - let keyset = keysets.iter().find(|k| ShortKeysetId::from(k.id) == token_entry.keyset_id) + let keyset = keysets + .iter() + .find(|k| ShortKeysetId::from(k.id) == token_entry.keyset_id) .ok_or_else(|| Error::Token("Unknown keyset".into()))?; - + for proof_v4 in &token_entry.proofs { - let mint_pubkey = keyset.keys.get(&proof_v4.amount).ok_or_else(|| Error::Token("Unknown amount for keyset".into()))?; + let mint_pubkey = keyset + .keys + .get(&proof_v4.amount) + .ok_or_else(|| Error::Token("Unknown amount for keyset".into()))?; let p = proof_v4.into_proof(&keyset.id); - p.verify_dleq(*mint_pubkey).map_err(|_| Error::Token("Invalid DLEQ proof".into()))?; + p.verify_dleq(*mint_pubkey) + .map_err(|_| Error::Token("Invalid DLEQ proof".into()))?; } } } @@ -161,7 +186,9 @@ impl CashuClient { /// Signs proofs using the arbitrator's (Mostro) secret key. pub fn sign_with_pm(proofs: &mut Proofs, p_m_secret: NutSecretKey) -> Result<(), Error> { for proof in proofs.iter_mut() { - proof.sign_p2pk(p_m_secret.clone()).map_err(|e| Error::Client(cdk::error::Error::from(e)))?; + proof + .sign_p2pk(p_m_secret.clone()) + .map_err(|e| Error::Client(cdk::error::Error::from(e)))?; } Ok(()) }