diff --git a/src/app.rs b/src/app.rs index 1efa4bb5..f3c6af09 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; // Handles Cashu 2-of-3 escrow lock (Track A) 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; @@ -229,6 +231,12 @@ async fn handle_message_action_no_ln( .await .map_err(|e| e.into()), Action::PayInvoice => Err(MostroError::MostroCantDo(CantDoReason::InvalidAction).into()), + // Cashu escrow lock only exists in Cashu mode (`run_cashu` routes it + // there). In Lightning mode it is invalid — reject explicitly rather + // than silently `Ok(())` via the wildcard, mirroring `PayInvoice`. + Action::AddCashuEscrow => { + Err(MostroError::MostroCantDo(CantDoReason::InvalidAction).into()) + } Action::LastTradeIndex => last_trade_index(ctx, msg, event, my_keys) .await .map_err(|e| e.into()), @@ -273,7 +281,7 @@ async fn handle_message_action( ctx: &AppContext, ) -> Result<()> { match action { - Action::Release => release_action(ctx, msg, event, my_keys, ln_client) + Action::Release => release_action(ctx, msg, event, my_keys) .await .map_err(|e| e.into()), Action::Cancel => cancel_action(ctx, msg, event, my_keys, ln_client) @@ -282,7 +290,7 @@ async fn handle_message_action( Action::AdminCancel => admin_cancel_action(ctx, msg, event, my_keys, ln_client) .await .map_err(|e| e.into()), - Action::AdminSettle => admin_settle_action(ctx, msg, event, my_keys, ln_client) + Action::AdminSettle => admin_settle_action(ctx, msg, event, my_keys) .await .map_err(|e| e.into()), _ => handle_message_action_no_ln(action, msg, event, my_keys, ctx).await, @@ -457,14 +465,32 @@ pub async fn run_cashu(ctx: AppContext) -> Result<()> { // Cashu mode (F4). Return CantDo so the peer gets // a clear error instead of a cryptic LND failure. 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. - Action::NewOrder - | Action::TakeSell - | Action::TakeBuy - | Action::AddInvoice + // Track A: the seller submits a 2-of-3 Cashu + // escrow token; Mostro validates it and advances + // the order to Active (the Cashu analogue of + // paying the hold invoice). + Action::AddCashuEscrow => add_cashu_escrow_action( + &ctx, + message.clone(), + &unwrapped, + my_keys, + ) + .await + .map_err(|e| e.into()), + // Order creation and the take flow are Cashu-aware: + // `take_*` branch on `is_cashu_enabled()` and route + // the lock through `show_cashu_escrow_request` + // (no hold invoice) instead of `escrow().lock()`, + // moving the order to `WaitingPayment` so the seller + // can submit the 2-of-3 token via `AddCashuEscrow`. + // They fall through to the no-LN dispatcher below. + // + // The remaining trade actions have no Cashu path yet + // and reject with a clear error: `AddInvoice` would + // call `escrow().lock()` on the Cashu backend (which + // is `unimplemented!()` and would panic), and + // release/cancel/dispute belong to Tracks B/C/D. + Action::AddInvoice | Action::Release | Action::Cancel | Action::AdminCancel diff --git a/src/app/add_cashu_escrow.rs b/src/app/add_cashu_escrow.rs new file mode 100644 index 00000000..d7014b74 --- /dev/null +++ b/src/app/add_cashu_escrow.rs @@ -0,0 +1,191 @@ +//! Track A — Cashu escrow lock (`Action::AddCashuEscrow`). +//! +//! This is the Cashu analogue of the seller paying the Lightning hold invoice. +//! Instead of Mostro taking custody, the seller swaps unencumbered ecash into a +//! NUT-11 2-of-3 P2PK token (locked to the buyer, seller and Mostro trade/arb +//! pubkeys) on the operator-configured mint and submits it here. Mostro only +//! *validates* it — it never holds more than its single key — then advances the +//! order to `Active` and tells the buyer to send fiat (box 2→3 of the sequence +//! diagram in `docs/CASHU_ESCROW_ARCHITECTURE.md`). + +use crate::app::context::AppContext; +use crate::cashu::cashu_pubkey_from_xonly_hex; +use crate::db; +use crate::util::{enqueue_order_msg, get_order, update_order_event}; +use mostro_core::prelude::*; +use nostr_sdk::prelude::*; +use sqlx_crud::Crud; + +/// Map a [`crate::cashu::Error`] from token validation onto the client-facing +/// [`CantDoReason`]. Mint-comms failures are surfaced distinctly from a bad +/// token so the seller can tell "retry later" from "your token is invalid". +fn cashu_reason(err: &crate::cashu::Error) -> CantDoReason { + use crate::cashu::Error::*; + match err { + InvalidMintUrl(_) => CantDoReason::InvalidMintUrl, + MintConnection(_) | Client(_) => CantDoReason::CashuMintUnavailable, + Token(_) | Condition(_) => CantDoReason::InvalidCashuToken, + } +} + +/// Handle a seller's Cashu escrow lock submission. +/// +/// Validates the submitted 2-of-3 token against the order and the configured +/// mint, then atomically records the escrow and advances `WaitingPayment → +/// Active`. The whole flow is coordinator-only: no funds move through Mostro. +pub async fn add_cashu_escrow_action( + ctx: &AppContext, + msg: Message, + event: &UnwrappedMessage, + my_keys: &Keys, +) -> Result<(), MostroError> { + let pool = ctx.pool(); + let order = get_order(&msg, pool).await?; + let request_id = msg.get_inner_message_kind().request_id; + + // The escrow is funded while the order waits for the seller, mirroring the + // Lightning "waiting for the seller to pay the hold invoice" stage. + if order.status != Status::WaitingPayment.to_string() { + return Err(MostroCantDo(CantDoReason::InvalidOrderStatus)); + } + + // Only the seller funds the escrow. Both trade pubkeys are set by the take + // flow before the order reaches WaitingPayment. + let seller_pubkey = order.get_seller_pubkey().map_err(MostroInternalErr)?; + if event.sender != seller_pubkey { + return Err(MostroCantDo(CantDoReason::InvalidPubkey)); + } + let buyer_pubkey = order.get_buyer_pubkey().map_err(MostroInternalErr)?; + + // The order amount must be resolved (range/market orders settle their + // amount before WaitingPayment); the escrow must lock a concrete value. + if order.amount <= 0 { + return Err(MostroCantDo(CantDoReason::InvalidParameters)); + } + + // Extract the seller's lock proof. `verify()` already guaranteed the + // payload shape for `AddCashuEscrow`, but match defensively. + let proof = match msg.get_inner_message_kind().get_payload() { + Some(Payload::CashuLockProof(p)) => p.clone(), + _ => return Err(MostroCantDo(CantDoReason::InvalidParameters)), + }; + + // The Cashu client exists only in Cashu mode; the dispatcher only routes + // this action there, so its absence is an internal misconfiguration. + let cashu_client = ctx.cashu_client().ok_or_else(|| { + MostroInternalErr(ServiceError::UnexpectedError( + "AddCashuEscrow dispatched without a Cashu client".to_string(), + )) + })?; + + // Derive the authoritative {P_B, P_S, P_M} from the order's trade keys and + // Mostro's identity key. We never trust the pubkeys the seller *states* in + // the proof — the token must be locked to the keys Mostro already holds. + // The order stores trade pubkeys as x-only hex; Mostro's key is its node + // identity pubkey (also x-only hex via `to_string`). + let to_cashu = |xonly_hex: &str| { + cashu_pubkey_from_xonly_hex(xonly_hex).map_err(|e| { + MostroInternalErr(ServiceError::UnexpectedError(format!( + "trade pubkey to cashu: {e}" + ))) + }) + }; + let buyer_hex = order + .buyer_pubkey + .clone() + .ok_or(MostroCantDo(CantDoReason::InvalidPubkey))?; + let seller_hex = order + .seller_pubkey + .clone() + .ok_or(MostroCantDo(CantDoReason::InvalidPubkey))?; + let p_b = to_cashu(&buyer_hex)?; + let p_s = to_cashu(&seller_hex)?; + let p_m = to_cashu(&my_keys.public_key().to_string())?; + + // Validate: 2-of-3 over {P_B,P_S,P_M}, hosted on our mint, exact amount, + // and every proof unspent at the mint. + cashu_client + .verify_escrow_token(&proof.token, p_b, p_s, p_m, order.amount as u64) + .await + .map_err(|e| MostroCantDo(cashu_reason(&e)))?; + + // Persist the escrow and advance to Active in one atomic compare-and-set so + // a locked token is never invisible (see `db::update_order_cashu_escrow`). + let locked_at = Timestamp::now().as_secs() as i64; + let token_mint = cashu_client.mint_url().to_string(); + let locked = db::update_order_cashu_escrow( + pool, + order.id, + &token_mint, + &proof.token, + locked_at, + &Status::WaitingPayment.to_string(), + &Status::Active.to_string(), + ) + .await?; + if !locked { + // Lost the CAS: the order moved off WaitingPayment, or an escrow was + // already locked (replay / concurrent submission). + return Err(MostroCantDo(CantDoReason::InvalidOrderStatus)); + } + + // From here on the escrow is durably locked and the order is `Active` in + // the DB. Everything below — re-publishing the replaceable event and + // notifying the parties — is best-effort: a failure must NOT be returned to + // the seller, because a retry would then hit the CAS guard above and get a + // confusing `InvalidOrderStatus` for an order that is already escrowed. + // Log loudly and still confirm the lock so the seller receives + // `CashuEscrowLocked` whenever the lock persisted. Notification context is + // taken from the pre-CAS `order` (id and trade indices are immutable + // through the lock), so the notices fire even if the re-fetch fails. + let order_id = order.id; + let trade_index_seller = order.trade_index_seller; + let trade_index_buyer = order.trade_index_buyer; + + match get_order(&msg, pool).await { + Ok(active_order) => { + match update_order_event(my_keys, Status::Active, &active_order).await { + Ok(order_updated) => { + if let Err(e) = order_updated.update(pool).await { + tracing::error!( + order_id = %order_id, + "AddCashuEscrow: escrow locked but persisting the updated order event failed: {e}" + ); + } + } + Err(e) => tracing::error!( + order_id = %order_id, + "AddCashuEscrow: escrow locked but publishing the order event failed: {e}" + ), + } + } + Err(e) => tracing::error!( + order_id = %order_id, + "AddCashuEscrow: escrow locked but re-fetching the order failed: {e}" + ), + } + + // Confirm the lock to the seller. + enqueue_order_msg( + request_id, + Some(order_id), + Action::CashuEscrowLocked, + None, + seller_pubkey, + trade_index_seller, + ) + .await; + + // Notify the buyer that escrow is locked — they can now send fiat. + enqueue_order_msg( + None, + Some(order_id), + Action::CashuEscrowLocked, + None, + buyer_pubkey, + trade_index_buyer, + ) + .await; + + Ok(()) +} diff --git a/src/app/add_invoice.rs b/src/app/add_invoice.rs index 3b721ec6..34a0d4e1 100644 --- a/src/app/add_invoice.rs +++ b/src/app/add_invoice.rs @@ -112,6 +112,7 @@ pub async fn add_invoice_action( ) .await; } else if let Err(cause) = show_hold_invoice( + ctx.escrow(), my_keys, None, &buyer_pubkey, diff --git a/src/app/admin_settle.rs b/src/app/admin_settle.rs index 49d5ccdc..058632e2 100644 --- a/src/app/admin_settle.rs +++ b/src/app/admin_settle.rs @@ -21,7 +21,6 @@ pub async fn admin_settle_action( msg: Message, event: &UnwrappedMessage, my_keys: &Keys, - ln_client: &mut LndConnector, ) -> Result<(), MostroError> { let pool = ctx.pool(); // Get request id @@ -92,7 +91,7 @@ pub async fn admin_settle_action( bond::validate_bond_resolution(pool, &order, &bond_resolution).await?; // Settle seller hold invoice - settle_seller_hold_invoice(event, ln_client, Action::AdminSettled, true, &order) + settle_seller_hold_invoice(event, ctx, Action::AdminSettled, true, &order) .await .map_err(|e| MostroInternalErr(ServiceError::LnNodeError(e.to_string())))?; // Update order event @@ -214,9 +213,16 @@ pub async fn admin_settle_action( // payout (asking the winning counterparty for a bolt11, // `send_payment`, retries, forfeiture on the long-stop window) is // still Phase 3's job. + // The anti-abuse bond is Lightning-only (mutually exclusive with Cashu + // mode), so settling slashed bonds always goes through an LND connector. + // The escrow seam is not involved here — bond hold invoices are distinct + // from the trade escrow. + let mut ln_client = LndConnector::new() + .await + .map_err(|e| MostroInternalErr(ServiceError::LnNodeError(e.to_string())))?; if let Err(e) = bond::apply_bond_resolution( pool, - ln_client, + &mut ln_client, &order_updated, &bond_resolution, BondSlashReason::LostDispute, diff --git a/src/app/bond/flow.rs b/src/app/bond/flow.rs index 6d3b01ad..71b30cac 100644 --- a/src/app/bond/flow.rs +++ b/src/app/bond/flow.rs @@ -968,11 +968,20 @@ async fn resume_take_after_bond( let buyer_pubkey = order.get_buyer_pubkey().map_err(MostroInternalErr)?; let seller_pubkey = order.get_seller_pubkey().map_err(MostroInternalErr)?; + // The bond feature is mutually exclusive with Cashu mode, so this resume + // path is always Lightning: wrap a fresh connector as the escrow backend + // for `show_hold_invoice` (which `escrow().lock()`s to create the hold + // invoice the seller pays). + let escrow: Arc = Arc::new( + crate::escrow::LightningBackend::new(LndConnector::new().await?), + ); + match kind { // Buy order → taker = seller, no buyer-invoice required up front: // mirror the post-take path in take_buy_action. mostro_core::order::Kind::Buy => { show_hold_invoice( + &escrow, my_keys, None, &buyer_pubkey, @@ -989,6 +998,7 @@ async fn resume_take_after_bond( if order.buyer_invoice.is_some() { let payment_request = order.buyer_invoice.clone(); show_hold_invoice( + &escrow, my_keys, payment_request, &buyer_pubkey, diff --git a/src/app/context.rs b/src/app/context.rs index 0eddef05..364be968 100644 --- a/src/app/context.rs +++ b/src/app/context.rs @@ -38,6 +38,11 @@ pub struct AppContext { settings: Arc, order_msg_queue: OrderMsgQueue, keys: Keys, + escrow: Arc, + /// Connected Cashu mint client, present only when the node boots in Cashu + /// escrow mode. The Track A lock handler reaches it via [`Self::cashu_client`] + /// to validate seller-submitted escrow tokens. `None` in Lightning mode. + cashu_client: Option>, } impl AppContext { @@ -48,6 +53,7 @@ impl AppContext { settings: Arc, order_msg_queue: OrderMsgQueue, keys: Keys, + escrow: Arc, ) -> Self { Self { pool, @@ -55,9 +61,21 @@ impl AppContext { settings, order_msg_queue, keys, + escrow, + cashu_client: None, } } + /// Attach a connected Cashu mint client (Cashu mode only). + /// + /// Builder-style so it composes with [`Self::new`] without widening the + /// constructor: Lightning-mode callers (and every existing test) keep the + /// `None` default, while the Cashu boot path in `main` sets the live client. + pub fn with_cashu_client(mut self, client: Arc) -> Self { + self.cashu_client = Some(client); + self + } + /// Database connection pool. pub fn pool(&self) -> &Pool { &self.pool @@ -90,6 +108,22 @@ impl AppContext { pub fn keys(&self) -> &Keys { &self.keys } + + /// Active escrow backend (Lightning today; Cashu opt-in later). + /// + /// Order-action handlers call `lock` / `release` / `cooperative_cancel` / + /// `dispute_*` through this instead of touching the LND connector directly. + pub fn escrow(&self) -> &Arc { + &self.escrow + } + + /// Connected Cashu mint client, if the node is in Cashu escrow mode. + /// + /// Returns `None` in Lightning mode. The Track A lock handler uses it to + /// validate seller-submitted 2-of-3 escrow tokens against the mint. + pub fn cashu_client(&self) -> Option<&Arc> { + self.cashu_client.as_ref() + } } #[cfg(test)] @@ -153,6 +187,7 @@ pub mod test_utils { order_msg_queue: Option, mock_order_msg_queue: Option, keys: Option, + escrow: Option>, } impl TestContextBuilder { @@ -164,6 +199,7 @@ pub mod test_utils { order_msg_queue: None, mock_order_msg_queue: None, keys: None, + escrow: None, } } @@ -205,6 +241,13 @@ pub mod test_utils { self } + /// Inject a specific escrow backend (e.g. a `MockEscrowBackend` whose + /// call counters the test later inspects). + pub fn with_escrow(mut self, escrow: Arc) -> Self { + self.escrow = Some(escrow); + self + } + /// Build the test context. /// /// This is synchronous: callers must provide dependencies explicitly. @@ -234,7 +277,13 @@ pub mod test_utils { .expect("TestContextBuilder: invalid nsec_privkey in settings") }); - AppContext::new(pool, nostr_client, settings, order_msg_queue, keys) + // Default to a recording mock backend so handler tests that don't + // care about escrow still get a working, asserting backend. + let escrow = self + .escrow + .unwrap_or_else(|| Arc::new(crate::escrow::test_utils::MockEscrowBackend::new())); + + AppContext::new(pool, nostr_client, settings, order_msg_queue, keys, escrow) } /// Build context plus mock handles used for assertions. diff --git a/src/app/release.rs b/src/app/release.rs index f52ac3d9..a415cac5 100644 --- a/src/app/release.rs +++ b/src/app/release.rs @@ -1,7 +1,6 @@ use crate::app::bond; use crate::app::context::AppContext; use crate::app::dispute::close_dispute_after_user_resolution; -use crate::escrow::EscrowBackend; use crate::lightning::LndConnector; use crate::lnurl::resolv_ln_address; use crate::nip33::{new_order_event, order_to_tags}; @@ -113,7 +112,6 @@ pub async fn check_failure_retries( /// * `msg` - The message containing the release request and associated metadata /// * `event` - The unwrapped gift event containing the seller's signature and verification data /// * `my_keys` - The Mostro node's keys used for signing events and messages -/// * `ln_client` - Lightning network client for invoice settlement /// /// # Returns /// @@ -160,7 +158,6 @@ pub async fn release_action( msg: Message, event: &UnwrappedMessage, my_keys: &Keys, - ln_client: &mut dyn EscrowBackend, ) -> Result<(), MostroError> { let pool = ctx.pool(); // Get request id @@ -190,7 +187,7 @@ pub async fn release_action( .map_err(MostroInternalErr)?; // Settle seller hold invoice - settle_seller_hold_invoice(event, ln_client, Action::Released, false, &order).await?; + settle_seller_hold_invoice(event, ctx, Action::Released, false, &order).await?; // Update order event with status SettledHoldInvoice order = update_order_event(my_keys, Status::SettledHoldInvoice, &order) .await diff --git a/src/app/take_buy.rs b/src/app/take_buy.rs index 0ce04b8b..6f880603 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}; @@ -180,8 +181,19 @@ pub async fn take_buy_action( order.trade_index_seller = Some(trade_index); order.set_timestamp_now(); + // Cashu escrow mode: no hold invoice. Move to `WaitingPayment` and prompt + // the seller (the taker, here) to fund the 2-of-3 escrow token; the order + // advances to `Active` only once they submit a valid token via + // `Action::AddCashuEscrow` (see `add_cashu_escrow_action`). + if Settings::is_cashu_enabled() { + show_cashu_escrow_request(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( + ctx.escrow(), my_keys, None, &buyer_pubkey, diff --git a/src/app/take_sell.rs b/src/app/take_sell.rs index 804915e1..a281dd72 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::prelude::*; use nostr_sdk::prelude::*; @@ -161,9 +163,19 @@ pub async fn take_sell_action( order.dev_fee = get_dev_fee(total_mostro_fee); } - // Validate invoice and get payment request if present - // NOW dev_fee is set correctly for proper validation - let payment_request = validate_invoice(&msg, &order).await?; + // Resolve the buyer payout invoice. In Cashu mode the buyer is paid in + // ecash (Track B), not over Lightning, so a buyer invoice is meaningless: + // reject one if provided rather than silently dropping it, and never run + // Lightning invoice validation (which could fail on an irrelevant invoice). + // On the Lightning path, validate as before (dev_fee is now set correctly). + let payment_request = if Settings::is_cashu_enabled() { + if msg.get_inner_message_kind().get_payment_request().is_some() { + return Err(MostroCantDo(CantDoReason::InvalidParameters)); + } + None + } else { + validate_invoice(&msg, &order).await? + }; let trade_index = match msg.get_inner_message_kind().trade_index { Some(trade_index) => trade_index, @@ -209,6 +221,16 @@ pub async fn take_sell_action( order.trade_index_buyer = Some(trade_index); order.set_timestamp_now(); + // Cashu escrow mode: there is no hold invoice. Move the order to + // `WaitingPayment` and prompt the seller to fund the 2-of-3 escrow token; + // the order advances to `Active` only once the seller submits a valid token + // via `Action::AddCashuEscrow` (see `add_cashu_escrow_action`). + if Settings::is_cashu_enabled() { + show_cashu_escrow_request(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?; @@ -216,6 +238,7 @@ pub async fn take_sell_action( // If payment request is present, show hold invoice else { show_hold_invoice( + ctx.escrow(), my_keys, payment_request, &event.sender, diff --git a/src/cashu/mod.rs b/src/cashu/mod.rs index 4d1e42dc..cb905eed 100644 --- a/src/cashu/mod.rs +++ b/src/cashu/mod.rs @@ -1,8 +1,13 @@ +use crate::escrow::{EscrowBackend, HoldInvoice}; +use async_trait::async_trait; 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, CurrencyUnit, 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 mostro_core::prelude::*; use std::str::FromStr; use std::sync::OnceLock; @@ -46,8 +51,7 @@ 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 { @@ -57,21 +61,48 @@ impl CashuClient { match cashu_client.client.get_mint_info().await { Ok(info) => { + // The Track A lock path needs NUT-11 (P2PK 2-of-3), NUT-07 + // (`/v1/checkstate` unspent check) and NUT-12 (DLEQ + // mint-authentication). A mint missing any of these would boot + // fine and then fail every `AddCashuEscrow`, stranding orders in + // `WaitingPayment` — so refuse to connect up front. 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(), + )); + } + if !info.nuts.nut07.supported { + CASHU_STATUS.get_or_init(|| false); + return Err(Error::MintConnection( + "Mint does not support NUT-07 token state check".into(), + )); + } + if !info.nuts.nut12.supported { + CASHU_STATUS.get_or_init(|| false); + return Err(Error::MintConnection( + "Mint does not support NUT-12 DLEQ proofs".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())) } } } + /// The mint URL this client is bound to. + /// + /// Track A's lock handler reads this to persist the order's + /// `cashu_mint_url` and to assert the seller's token was minted by the + /// operator-configured mint. + pub fn mint_url(&self) -> &MintUrl { + &self.mint_url + } + /// Verifies the 2-of-3 condition embedded in a token matches the expected pubkeys. /// It asserts that p_b, p_s, and p_m are present in the conditions. pub fn verify_2of3_condition( @@ -80,8 +111,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,25 +119,142 @@ 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(), + )); + } + } + + Ok(token) + } + + /// Full escrow-token validation for Track A's lock handler. + /// + /// Runs every check Mostro performs on a seller-submitted Cashu escrow + /// token before accepting it as the locked trade funds, returning the parsed + /// [`Token`] on success: + /// + /// 1. **2-of-3 condition.** [`Self::verify_2of3_condition`] asserts every + /// proof is P2PK-locked to a 2-of-3 over exactly `{p_b, p_s, p_m}` (the + /// order's buyer/seller trade pubkeys and Mostro's arbitrator key). + /// 2. **Mint binding.** The token's mint URL must match the node's + /// configured mint (`self.mint_url`); Mostro only escrows on its own mint. + /// 3. **Amount.** The token's total value must equal `expected_amount` + /// (sats); `check_state` only proves unspent-ness, not quantity. + /// 4. **Mint-issued (DLEQ).** Every proof must carry a valid NUT-12 DLEQ + /// proof verifying against the mint's keyset ([`Self::verify_token_dleq`]). + /// This authenticates the ecash as genuinely mint-signed — without it a + /// seller could fabricate unspent-but-worthless proofs (see step 4 in the + /// body), since `check_state` reports any unknown secret as `Unspent`. + /// 5. **Unspent.** Every proof must be `Unspent` at the mint (NUT-07 + /// `/v1/checkstate`). The checkstate `Y` points are derived directly from + /// each proof secret via `hash_to_curve`, so this needs no keyset fetch. + pub async fn verify_escrow_token( + &self, + token_str: &str, + p_b: PublicKey, + p_s: PublicKey, + p_m: PublicKey, + expected_amount: u64, + ) -> Result { + // 1: parse and verify the 2-of-3 spending condition. + let token = Self::verify_2of3_condition(token_str, p_b, p_s, p_m)?; + + // 2: the token must be hosted on the node's configured mint. + let token_mint = token + .mint_url() + .map_err(|e| Error::Token(format!("token mint url: {e}")))?; + if token_mint != self.mint_url { + return Err(Error::Token(format!( + "token mint {token_mint} does not match configured mint {}", + self.mint_url + ))); + } + + // 3: the locked amount must equal the order amount exactly, and be + // denominated in sats. `value()` is a bare integer, so without the + // unit guard a mint exposing multiple units would let a 100_000-msat + // token satisfy a 100_000-sat order (a 1000x-underfunded escrow). + match token.unit() { + Some(CurrencyUnit::Sat) => {} + other => { + return Err(Error::Token(format!("token unit {other:?} is not sat"))); } } + let value = token + .value() + .map_err(|e| Error::Token(format!("token value: {e}")))? + .to_u64(); + if value != expected_amount { + return Err(Error::Token(format!( + "token amount {value} does not match expected {expected_amount}" + ))); + } + + // 4: the proofs must be genuine, mint-issued ecash. `check_state` + // (step 5) only proves a secret is unspent — an honest mint reports any + // *unknown* secret as `Unspent`, so a seller could fabricate proofs with + // the right 2-of-3 condition and amount but no mint signature and still + // pass every other check, tricking the buyer into sending fiat against + // worthless ecash. DLEQ (NUT-12) authenticates each proof's blind + // signature against the mint's keyset offline, closing that hole. + self.verify_token_dleq(&token).await?; + + // 5: every proof must be unspent at the mint. Derive the checkstate Y + // points from the proof secrets (Y = hash_to_curve(secret)). + let secrets = token.token_secrets(); + if secrets.is_empty() { + return Err(Error::Token("token contains no proofs".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_states = ys.len(); + let states = self.check_state(ys).await?; + // Fail closed if the mint returns fewer states than proofs queried: a + // missing entry must never be treated as implicitly `Unspent`. + if states.states.len() != expected_states { + return Err(Error::Token(format!( + "checkstate returned {} states for {expected_states} proofs", + states.states.len() + ))); + } + if states + .states + .iter() + .any(|s| s.state != cdk::nuts::State::Unspent) + { + return Err(Error::Token("one or more proofs are not unspent".into())); + } Ok(token) } @@ -117,39 +264,62 @@ 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)))?; - + // TODO(track-b): `get_mint_keys` returns only ACTIVE keysets (NUT-01), + // so a proof minted under a since-rotated keyset is rejected here as + // "Unknown keyset" even though the mint still honours it. Track A only + // ever sees freshly-minted tokens so this is safe, but before Track B + // verifies older tokens this must fall back to fetching the specific + // keyset via `/v1/keys/{keyset_id}` for inactive keyset ids. + 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,8 +331,209 @@ 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(()) } } + +/// Convert a Nostr (BIP340 x-only) public key, given as 64-char hex, into a +/// Cashu (compressed secp256k1) [`PublicKey`]. +/// +/// Mostro's per-order trade keys and node identity key are x-only (32 bytes); +/// a NUT-11 P2PK spending condition needs the 33-byte compressed form. We +/// prepend the `0x02` (even-Y) parity byte, which is the same convention +/// `cdk::dhke::hash_to_curve` and the Cashu P2PK tooling use, so a signature +/// produced by the trade key validates against this derived pubkey. +/// +/// Track A uses this to derive the expected `{P_B, P_S, P_M}` from the order's +/// trade pubkeys and Mostro's key, rather than trusting the pubkeys the seller +/// states in the submitted [`mostro_core`] lock proof. +pub fn cashu_pubkey_from_xonly_hex(xonly_hex: &str) -> Result { + if xonly_hex.len() != 64 { + return Err(Error::Condition(format!( + "expected 64-char x-only hex, got {}", + xonly_hex.len() + ))); + } + PublicKey::from_hex(format!("02{xonly_hex}")) + .map_err(|e| Error::Condition(format!("pubkey convert: {e}"))) +} + +/// Cashu 2-of-3 multisig escrow backend. +/// +/// Implements the [`EscrowBackend`] seam for Cashu mode. Mostro is only a +/// coordinator here — it never takes custody — so the lock validates a +/// seller-submitted token against the configured mint and the order's trade +/// pubkeys, while release / cancel / dispute settlement are P2P or +/// arbitrator-signed by the feature tracks. +/// +/// The methods are filled in incrementally by the Cashu feature tracks +/// (Track A: [`EscrowBackend::lock`]; Tracks B/C/D: the rest). Until a track +/// lands, its method is `unimplemented!()`; the daemon only ever instantiates +/// this backend in Cashu mode, where `run_cashu` gates which actions dispatch. +#[derive(Debug, Default, Clone, Copy)] +pub struct CashuBackend; + +impl CashuBackend { + /// Create a new Cashu escrow backend. + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl EscrowBackend for CashuBackend { + async fn lock( + &self, + _order: &Order, + _description: &str, + _amount: i64, + ) -> Result { + unimplemented!("Cashu escrow lock is implemented in the Cashu lock track (Track A)") + } + + async fn release(&self, _order: &Order) -> Result<(), MostroError> { + unimplemented!("Cashu escrow release is implemented in the Cashu release track (Track B)") + } + + async fn cooperative_cancel(&self, _order: &Order) -> Result<(), MostroError> { + unimplemented!( + "Cashu cooperative cancel is implemented in the Cashu cancel track (Track C)" + ) + } + + async fn dispute_settle(&self, _order: &Order) -> Result<(), MostroError> { + unimplemented!("Cashu dispute settle is implemented in the Cashu dispute track (Track D)") + } + + async fn dispute_cancel(&self, _order: &Order) -> Result<(), MostroError> { + unimplemented!("Cashu dispute cancel is implemented in the Cashu dispute track (Track D)") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A valid 32-byte x-only hex (a known secp256k1 x coordinate). + const XONLY_HEX: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + #[test] + fn xonly_to_cashu_pubkey_prepends_even_parity() { + let pk = cashu_pubkey_from_xonly_hex(XONLY_HEX).expect("valid x-only converts"); + // The compressed form is the even-parity (0x02) point over the x-only. + assert_eq!(pk.to_hex(), format!("02{XONLY_HEX}")); + } + + #[test] + fn xonly_to_cashu_pubkey_rejects_wrong_length() { + // 63 chars (too short) and the already-prefixed 66-char form must both + // be rejected: the helper expects exactly the 64-char x-only. + assert!(cashu_pubkey_from_xonly_hex(&XONLY_HEX[..63]).is_err()); + assert!(cashu_pubkey_from_xonly_hex(&format!("02{XONLY_HEX}")).is_err()); + } + + #[test] + fn xonly_to_cashu_pubkey_rejects_non_hex() { + // Right length, but not valid hex / not a curve point. + let not_hex = "z".repeat(64); + assert!(cashu_pubkey_from_xonly_hex(¬_hex).is_err()); + } + + /// The derivation always prepends `02`, but a Nostr trade key's point may + /// have ODD-Y parity (`03`). NUT-11 P2PK uses BIP340 Schnorr, which verifies + /// against the x-only coordinate (the even-Y lift), so a signature by such a + /// key must still validate against the `02`-derived pubkey. If this failed, + /// the parity handling would be wrong and Track B (where Mostro signs with + /// its own key) would break. This is the sign/verify roundtrip proving it. + #[test] + fn odd_y_nostr_key_signs_for_derived_cashu_pubkey() { + use cdk::nuts::nut00::{Proof, Witness}; + use cdk::nuts::nut02::Id; + use cdk::nuts::nut11::P2PKWitness; + use cdk::secret::Secret; + use cdk::Amount; + use nostr_sdk::Keys; + + // Find a Nostr key whose secp256k1 point has odd-Y parity (`0x03`). + let (keys, nut_sk) = loop { + let keys = Keys::generate(); + let nut_sk = NutSecretKey::from_hex(keys.secret_key().to_secret_hex()) + .expect("nostr secret converts to a cdk secret key"); + if nut_sk.public_key().to_bytes()[0] == 0x03 { + break (keys, nut_sk); + } + }; + + let xonly_hex = keys.public_key().to_hex(); + let cashu_pk = cashu_pubkey_from_xonly_hex(&xonly_hex).expect("derive cashu pubkey"); + // Derivation forced even-Y even though the source key is odd-Y. + assert_eq!(cashu_pk.to_bytes()[0], 0x02); + + // Lock a proof to a 1-of-1 P2PK over the derived pubkey, sign with the + // odd-Y Nostr key, and verify. + let secret: Secret = SpendingConditions::new_p2pk(cashu_pk, None) + .try_into() + .expect("p2pk secret"); + let mut proof = Proof { + keyset_id: Id::from_str("009a1f293253e41e").unwrap(), + amount: Amount::ZERO, + secret, + c: PublicKey::from_str( + "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + ) + .unwrap(), + witness: Some(Witness::P2PKWitness(P2PKWitness { signatures: vec![] })), + dleq: None, + p2pk_e: None, + }; + proof.sign_p2pk(nut_sk).expect("sign with the odd-Y key"); + assert!( + proof.verify_p2pk().is_ok(), + "an odd-Y Nostr key must sign validly for the 02-derived cashu pubkey" + ); + } + + /// Mint-authentication (step 4 of `verify_escrow_token` → + /// `verify_token_dleq` → `Proof::verify_dleq`) closes the fabricated-token + /// hole only because cdk rejects a proof carrying NO DLEQ. Pin that pinned- + /// dependency behavior so a future cdk bump can't silently reopen it: an + /// absent DLEQ must error with `MissingDleqProof`. (The full + /// `verify_escrow_token` path needs a live mint and is covered by the + /// env-gated integration suite; this is the offline regression guard for + /// the exact attack primitive.) + #[test] + fn proof_without_dleq_is_rejected() { + use cdk::nuts::nut00::Proof; + use cdk::nuts::nut02::Id; + use cdk::secret::Secret; + use cdk::Amount; + + let proof = Proof { + keyset_id: Id::from_str("009a1f293253e41e").unwrap(), + amount: Amount::ZERO, + secret: Secret::generate(), + c: PublicKey::from_str( + "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + ) + .unwrap(), + witness: None, + dleq: None, + p2pk_e: None, + }; + let mint_pubkey = PublicKey::from_str( + "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + ) + .unwrap(); + assert!( + matches!( + proof.verify_dleq(mint_pubkey), + Err(cdk::nuts::nut12::Error::MissingDleqProof) + ), + "a proof with no DLEQ must be rejected (fabricated-token defense)" + ); + } +} diff --git a/src/escrow.rs b/src/escrow.rs index 453ed52e..d9292fb2 100644 --- a/src/escrow.rs +++ b/src/escrow.rs @@ -6,19 +6,25 @@ //! `docs/CASHU_ESCROW_ARCHITECTURE.md`). //! //! [`EscrowBackend`] is the seam between the action handlers and whichever escrow -//! mechanism a node is configured with. Handlers receive `&mut dyn EscrowBackend` -//! instead of a concrete connector, so the same `release` / `cancel` / `admin_*` -//! code paths drive either backend. The Lightning implementation lives on -//! [`LndConnector`] and is a thin pass-through to its inherent methods — -//! behaviour-preserving versus calling those methods directly. The -//! [`CashuBackend`] methods are intentionally stubbed (`unimplemented!()`); the -//! Cashu feature tracks fill them in (Track A: lock, Track B: release, Track C: -//! cooperative cancel, Track D: dispute resolution). +//! mechanism a node is configured with. Handlers reach the backend through +//! `AppContext::escrow()` (an `Arc`), so the same +//! `lock` / `release` / `cooperative_cancel` / `dispute_*` code paths drive +//! either backend. //! -//! This trait keeps today's hold-invoice primitives as method names. It is the -//! narrow set the threaded `ln_client` actually exercises in the escrow flow; -//! payout (`send_payment`) and invoice subscription are not part of the escrow -//! seam and remain on the concrete [`LndConnector`]. +//! The trait is *semantic*: its methods name escrow operations +//! (`lock`/`release`/`cooperative_cancel`/`dispute_settle`/`dispute_cancel`) +//! rather than the underlying Lightning primitives. [`LightningBackend`] maps +//! those operations onto today's hold-invoice calls — behaviour-preserving +//! versus calling them directly. The Cashu backend +//! ([`crate::cashu::CashuBackend`]) fills the same trait in incrementally; the +//! feature tracks implement one method at a time (Track A: lock, Track B: +//! release, Track C: cooperative cancel, Track D: dispute resolution). +//! +//! Methods take `&self` (interior mutability) and the trait is `Send + Sync`, so +//! a single `Arc` can live in `AppContext` and be shared +//! across handlers and `.await` points. Payout (`send_payment`) and invoice +//! subscription are not part of the escrow seam and remain on the concrete +//! [`LndConnector`]. use crate::lightning::LndConnector; use async_trait::async_trait; @@ -41,46 +47,78 @@ pub struct HoldInvoice { pub hash: Vec, } -/// The escrow operations the order-action handlers depend on. +/// The escrow operations the order-action handlers call instead of touching +/// LND (or, later, cdk) directly. /// -/// Implemented by [`LndConnector`] (Lightning) and [`CashuBackend`] (Cashu). -/// `Send` is a supertrait so `dyn EscrowBackend` is `Send` and can be held -/// across `.await` points inside the handlers. +/// `&self` + `Send + Sync` so a single `Arc` can live in +/// `AppContext` and be shared. Implemented by [`LightningBackend`] (Lightning) +/// and [`crate::cashu::CashuBackend`] (Cashu). #[async_trait] -pub trait EscrowBackend: Send { - /// Lock the trade amount in escrow. - /// - /// Lightning: create a hold invoice for `amount` sats with `description`, - /// returning the LND response plus `(preimage, hash)`. - async fn create_hold_invoice( - &mut self, +pub trait EscrowBackend: Send + Sync { + /// Open the escrow lock. Lightning: create a hold invoice for `amount` + /// sats described by `description`. The `order` is provided for backends + /// (Cashu) whose lock is derived from order state. + async fn lock( + &self, + order: &Order, description: &str, amount: i64, ) -> Result; - /// Release escrowed funds to the buyer. - /// - /// Lightning: settle the seller's hold invoice with `preimage`. - async fn settle_hold_invoice(&mut self, preimage: &str) -> Result<(), MostroError>; + /// Release escrowed funds to the buyer. Lightning: settle the hold invoice. + async fn release(&self, order: &Order) -> Result<(), MostroError>; + + /// Mutual / non-dispute cancel — return funds to seller. Lightning: cancel + /// the hold invoice. + async fn cooperative_cancel(&self, order: &Order) -> Result<(), MostroError>; + + /// Admin resolves a dispute in the buyer's favor. Lightning: settle the + /// hold invoice. + async fn dispute_settle(&self, order: &Order) -> Result<(), MostroError>; + + /// Admin resolves a dispute in the seller's favor. Lightning: cancel the + /// hold invoice. + async fn dispute_cancel(&self, order: &Order) -> Result<(), MostroError>; +} + +/// Lightning escrow backend: maps the semantic escrow operations onto +/// [`LndConnector`]'s inherent hold-invoice methods. +/// +/// Methods are `&self`, but the connector's methods are `&mut self`. The +/// connector's tonic client is cheap to clone, so we hold an owned +/// [`LndConnector`] and clone it per call to get the `&mut` each primitive +/// needs. This keeps the backend shareable behind an `Arc`. +#[derive(Clone)] +pub struct LightningBackend { + conn: LndConnector, +} - /// Return escrowed funds to the seller / void the escrow. +impl LightningBackend { + /// Wrap an existing [`LndConnector`] as an escrow backend. + pub fn new(conn: LndConnector) -> Self { + Self { conn } + } + + /// Borrow a fresh, cloned [`LndConnector`] for LN-only subsystems. /// - /// Lightning: cancel the hold invoice identified by `hash`. - async fn cancel_hold_invoice(&mut self, hash: &str) -> Result<(), MostroError>; + /// The anti-abuse bond and dev-fee payout are LN-only and not part of the + /// escrow seam; the handlers that drive them obtain a concrete connector + /// from here in Lightning mode. + pub fn connector(&self) -> LndConnector { + self.conn.clone() + } } -/// Lightning escrow backend: a thin pass-through to [`LndConnector`]'s inherent -/// hold-invoice methods. The settle/cancel responses LND returns are discarded -/// here because the escrow flow only cares whether the call succeeded. #[async_trait] -impl EscrowBackend for LndConnector { - async fn create_hold_invoice( - &mut self, +impl EscrowBackend for LightningBackend { + async fn lock( + &self, + _order: &Order, description: &str, amount: i64, ) -> Result { - let (resp, preimage, hash) = - LndConnector::create_hold_invoice(self, description, amount).await?; + let mut conn = self.conn.clone(); + let (resp, preimage, hash) = conn.create_hold_invoice(description, amount).await?; Ok(HoldInvoice { payment_request: resp.payment_request, preimage, @@ -88,42 +126,146 @@ impl EscrowBackend for LndConnector { }) } - async fn settle_hold_invoice(&mut self, preimage: &str) -> Result<(), MostroError> { - LndConnector::settle_hold_invoice(self, preimage).await?; - Ok(()) + async fn release(&self, order: &Order) -> Result<(), MostroError> { + settle_for_order(&self.conn, order).await + } + + async fn cooperative_cancel(&self, order: &Order) -> Result<(), MostroError> { + cancel_for_order(&self.conn, order).await } - async fn cancel_hold_invoice(&mut self, hash: &str) -> Result<(), MostroError> { - LndConnector::cancel_hold_invoice(self, hash).await?; - Ok(()) + async fn dispute_settle(&self, order: &Order) -> Result<(), MostroError> { + settle_for_order(&self.conn, order).await + } + + async fn dispute_cancel(&self, order: &Order) -> Result<(), MostroError> { + cancel_for_order(&self.conn, order).await } } -/// Cashu 2-of-3 multisig escrow backend. -/// -/// A placeholder for the opt-in Cashu mode. In Cashu mode Mostro is only a -/// coordinator and never takes custody, so these hold-invoice primitives do not -/// map onto Cashu directly — the feature tracks replace the escrow paths that -/// call them. Until then every method is `unimplemented!()` and the backend is -/// never instantiated (the daemon defaults to Lightning). -#[derive(Debug, Default, Clone, Copy)] -pub struct CashuBackend; +/// Settle the order's hold invoice using its stored preimage. Mirrors the +/// previous `settle_seller_hold_invoice` behaviour: a missing preimage is an +/// `InvalidInvoice` error. +async fn settle_for_order(conn: &LndConnector, order: &Order) -> Result<(), MostroError> { + let preimage = order + .preimage + .as_ref() + .ok_or(MostroCantDo(CantDoReason::InvalidInvoice))?; + let mut conn = conn.clone(); + conn.settle_hold_invoice(preimage).await?; + Ok(()) +} -#[async_trait] -impl EscrowBackend for CashuBackend { - async fn create_hold_invoice( - &mut self, - _description: &str, - _amount: i64, - ) -> Result { - unimplemented!("Cashu escrow lock is implemented in the Cashu lock track") +/// Cancel the order's hold invoice using its stored hash. Mirrors the previous +/// cancel-path behaviour: callers guard on `order.hash` being present, so a +/// missing hash here is treated as a no-op. +async fn cancel_for_order(conn: &LndConnector, order: &Order) -> Result<(), MostroError> { + if let Some(hash) = order.hash.as_ref() { + let mut conn = conn.clone(); + conn.cancel_hold_invoice(hash).await?; } + Ok(()) +} + +#[cfg(test)] +pub mod test_utils { + //! Test doubles for [`EscrowBackend`]. + + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; - async fn settle_hold_invoice(&mut self, _preimage: &str) -> Result<(), MostroError> { - unimplemented!("Cashu escrow release is implemented in the Cashu release track") + /// Recording mock for [`EscrowBackend`]. + /// + /// Returns success for every operation (a dummy [`HoldInvoice`] for `lock`) + /// and records which methods were called so tests can assert "settle was + /// called once" / "cancel was called", replacing the previous + /// `CancelLightning` / `SettleLightning` stubs. + #[derive(Debug, Default, Clone)] + pub struct MockEscrowBackend { + lock_calls: Arc, + release_calls: Arc, + cooperative_cancel_calls: Arc, + dispute_settle_calls: Arc, + dispute_cancel_calls: Arc, + /// Ordered log of method names invoked. + calls: Arc>>, } - async fn cancel_hold_invoice(&mut self, _hash: &str) -> Result<(), MostroError> { - unimplemented!("Cashu escrow cancel is implemented in the Cashu cancel track") + impl MockEscrowBackend { + pub fn new() -> Self { + Self::default() + } + + pub fn lock_count(&self) -> usize { + self.lock_calls.load(Ordering::SeqCst) + } + + pub fn release_count(&self) -> usize { + self.release_calls.load(Ordering::SeqCst) + } + + pub fn cooperative_cancel_count(&self) -> usize { + self.cooperative_cancel_calls.load(Ordering::SeqCst) + } + + pub fn dispute_settle_count(&self) -> usize { + self.dispute_settle_calls.load(Ordering::SeqCst) + } + + pub fn dispute_cancel_count(&self) -> usize { + self.dispute_cancel_calls.load(Ordering::SeqCst) + } + + /// Snapshot of the ordered method-call log. + pub fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + + fn record(&self, name: &str) { + self.calls.lock().unwrap().push(name.to_string()); + } + } + + #[async_trait] + impl EscrowBackend for MockEscrowBackend { + async fn lock( + &self, + _order: &Order, + _description: &str, + _amount: i64, + ) -> Result { + self.lock_calls.fetch_add(1, Ordering::SeqCst); + self.record("lock"); + Ok(HoldInvoice { + payment_request: "lnbc-mock".to_string(), + preimage: vec![0u8; 32], + hash: vec![0u8; 32], + }) + } + + async fn release(&self, _order: &Order) -> Result<(), MostroError> { + self.release_calls.fetch_add(1, Ordering::SeqCst); + self.record("release"); + Ok(()) + } + + async fn cooperative_cancel(&self, _order: &Order) -> Result<(), MostroError> { + self.cooperative_cancel_calls.fetch_add(1, Ordering::SeqCst); + self.record("cooperative_cancel"); + Ok(()) + } + + async fn dispute_settle(&self, _order: &Order) -> Result<(), MostroError> { + self.dispute_settle_calls.fetch_add(1, Ordering::SeqCst); + self.record("dispute_settle"); + Ok(()) + } + + async fn dispute_cancel(&self, _order: &Order) -> Result<(), MostroError> { + self.dispute_cancel_calls.fetch_add(1, Ordering::SeqCst); + self.record("dispute_cancel"); + Ok(()) + } } } diff --git a/src/main.rs b/src/main.rs index 87f9b85c..e66a9ccc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,18 +133,36 @@ async fn main() -> Result<()> { .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(), - ); - - start_scheduler(ctx.clone()).await; - if Settings::is_cashu_enabled() { tracing::info!("Starting in Cashu escrow mode (LND not required)"); + + // Connect to the operator-configured mint and verify it is reachable + // (and supports NUT-11 P2PK). The Track A lock handler needs a live + // client to validate seller-submitted escrow tokens — without it every + // `AddCashuEscrow` fails and no order can advance to `Active`. Refuse to + // boot if the mint is unavailable, mirroring how Lightning mode refuses + // to boot without LND. + let mint_url = Settings::get_cashu() + .map(|c| c.mint_url.clone()) + .expect("cashu enabled but [cashu] config missing"); + let cashu_client = match cashu::CashuClient::connect(&mint_url).await { + Ok(client) => Arc::new(client), + Err(e) => { + panic!("No connection to Cashu mint {mint_url} - shutting down Mostro! ({e})") + } + }; + + let escrow: Arc = Arc::new(cashu::CashuBackend::new()); + let ctx = AppContext::new( + get_db_pool(), + client.clone(), + settings, + MESSAGE_QUEUES.queue_order_msg.clone(), + mostro_keys.clone(), + escrow, + ) + .with_cashu_client(cashu_client); + start_scheduler(ctx.clone()).await; return run_cashu(ctx).await; } @@ -156,6 +174,21 @@ async fn main() -> Result<()> { panic!("No connection to LND node - shutting down Mostro!"); }; + // Lightning escrow backend wraps the connector; handlers reach it via + // `ctx.escrow()`. + let escrow: Arc = + Arc::new(escrow::LightningBackend::new(ln_client.clone())); + let ctx = AppContext::new( + get_db_pool(), + client.clone(), + settings, + MESSAGE_QUEUES.queue_order_msg.clone(), + mostro_keys.clone(), + escrow, + ); + + start_scheduler(ctx.clone()).await; + if let Ok(held_invoices) = find_held_invoices(get_db_pool().as_ref()).await { for invoice in held_invoices.iter() { if let Some(hash) = &invoice.hash { diff --git a/src/rpc/service.rs b/src/rpc/service.rs index f8c72c45..b5bcf04a 100644 --- a/src/rpc/service.rs +++ b/src/rpc/service.rs @@ -40,6 +40,14 @@ impl AdminServiceImpl { } } + /// Build the Lightning escrow backend the admin handlers reach through + /// `AppContext::escrow()`. The RPC service is Lightning-only (it owns an + /// `LndConnector`), so this always wraps a clone of that connector. + async fn escrow_backend(&self) -> Arc { + let conn = self.ln_client.lock().await.clone(); + Arc::new(crate::escrow::LightningBackend::new(conn)) + } + /// Convert admin actions to use existing handlers /// This creates the necessary structures to call existing admin handlers async fn call_admin_cancel( @@ -89,12 +97,14 @@ impl AdminServiceImpl { .ok_or_else(|| "MOSTRO_CONFIG not initialized".to_string())? .clone(), ); + let escrow = self.escrow_backend().await; let ctx = AppContext::new( self.pool.clone(), nostr_client, settings, MESSAGE_QUEUES.queue_order_msg.clone(), self.keys.clone(), + escrow, ); let mut ln_client = self.ln_client.lock().await; admin_cancel_action(&ctx, msg, &event, &self.keys, &mut ln_client) @@ -144,15 +154,16 @@ impl AdminServiceImpl { .ok_or_else(|| "MOSTRO_CONFIG not initialized".to_string())? .clone(), ); + let escrow = self.escrow_backend().await; let ctx = AppContext::new( self.pool.clone(), nostr_client, settings, MESSAGE_QUEUES.queue_order_msg.clone(), self.keys.clone(), + escrow, ); - let mut ln_client = self.ln_client.lock().await; - admin_settle_action(&ctx, msg, &event, &self.keys, &mut ln_client) + admin_settle_action(&ctx, msg, &event, &self.keys) .await .map_err(|e| format!("Admin settle failed: {}", e))?; @@ -198,12 +209,14 @@ impl AdminServiceImpl { .ok_or_else(|| "MOSTRO_CONFIG not initialized".to_string())? .clone(), ); + let escrow = self.escrow_backend().await; let ctx = AppContext::new( self.pool.clone(), nostr_client, settings, MESSAGE_QUEUES.queue_order_msg.clone(), self.keys.clone(), + escrow, ); admin_add_solver_action(&ctx, msg, &event, &self.keys) .await @@ -252,12 +265,14 @@ impl AdminServiceImpl { .ok_or_else(|| "MOSTRO_CONFIG not initialized".to_string())? .clone(), ); + let escrow = self.escrow_backend().await; let ctx = AppContext::new( self.pool.clone(), nostr_client, settings, MESSAGE_QUEUES.queue_order_msg.clone(), self.keys.clone(), + escrow, ); admin_take_dispute_action(&ctx, msg, &event, &self.keys) .await diff --git a/src/util.rs b/src/util.rs index 39050002..58ee18d0 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,3 +1,4 @@ +use crate::app::context::AppContext; use crate::bitcoin_price::BitcoinPriceManager; use crate::config::constants::{DEV_FEE_AUDIT_EVENT_KIND, DEV_FEE_LIGHTNING_ADDRESS}; use crate::config::settings::{get_db_pool, Settings}; @@ -26,6 +27,7 @@ use sqlx_crud::Crud; use std::collections::HashMap; use std::fmt::Write; use std::str::FromStr; +use std::sync::Arc; use std::thread; use tokio::sync::mpsc::channel; use tracing::info; @@ -789,6 +791,7 @@ pub async fn connect_nostr() -> Result { } pub async fn show_hold_invoice( + escrow: &Arc, my_keys: &Keys, payment_request: Option, buyer_pubkey: &PublicKey, @@ -796,24 +799,30 @@ pub async fn show_hold_invoice( mut order: Order, request_id: Option, ) -> Result<(), MostroError> { - let mut ln_client = lightning::LndConnector::new().await?; // Seller pays only the order amount and their Mostro fee // Dev fee is NOT charged to seller - it's paid by mostrod from its earnings let new_amount = order.amount + order.fee; - // Now we generate the hold invoice that seller should pay - let (invoice_response, preimage, hash) = ln_client - .create_hold_invoice( - &messages::hold_invoice_description( - &order.id.to_string(), - &order.fiat_code, - &order.fiat_amount.to_string(), - ) - .map_err(|e| MostroInternalErr(ServiceError::HoldInvoiceError(e.to_string())))?, - new_amount, - ) + // Open the escrow lock through the active backend. For Lightning this + // creates the hold invoice the seller pays. + let description = messages::hold_invoice_description( + &order.id.to_string(), + &order.fiat_code, + &order.fiat_amount.to_string(), + ) + .map_err(|e| MostroInternalErr(ServiceError::HoldInvoiceError(e.to_string())))?; + let invoice_response = escrow + .lock(&order, &description, new_amount) .await - .map_err(|e| MostroInternalErr(ServiceError::HoldInvoiceError(e.to_string())))?; + .map_err(|e| match e { + MostroError::MostroInternalErr(ServiceError::HoldInvoiceError(_)) => e, + MostroError::MostroInternalErr(inner) => { + MostroInternalErr(ServiceError::HoldInvoiceError(inner.to_string())) + } + other => other, + })?; + let preimage = invoice_response.preimage.clone(); + let hash = invoice_response.hash.clone(); if let Some(invoice) = payment_request { order.buyer_invoice = Some(invoice); }; @@ -874,6 +883,88 @@ pub async fn show_hold_invoice( Ok(()) } +/// Cashu-mode counterpart of [`show_hold_invoice`]. +/// +/// In Cashu escrow mode there is no hold invoice: instead of locking funds on +/// Mostro's node, the seller will swap unencumbered ecash into a 2-of-3 P2PK +/// token (locked to the buyer/seller trade keys and Mostro's key) and submit it +/// via `Action::AddCashuEscrow`. This helper performs the take-time transition: +/// it records both trade pubkeys, moves the order to `WaitingPayment`, publishes +/// the updated order event, and prompts the seller to fund the escrow — handing +/// them the buyer trade pubkey (`P_B`) they need to build the token (`P_M` is +/// Mostro's own node pubkey, already known to the client). No funds move through +/// Mostro; the order only advances to `Active` once the submitted token is +/// validated by `add_cashu_escrow_action`. +/// +/// Mirrors `show_hold_invoice`'s status/DB/event handling so the rest of the +/// trade lifecycle is identical to the Lightning flow from `WaitingPayment` on. +#[allow(clippy::too_many_arguments)] +pub async fn show_cashu_escrow_request( + 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 new status and persist. + let pool = db::connect() + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + 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())))?; + + // Prompt the seller to fund the escrow. The SmallOrder carries the buyer + // trade pubkey (P_B) the seller needs to construct the 2-of-3 token; P_M is + // Mostro's node pubkey, already known to the client. + let mut new_order = order.as_new_order(); + new_order.status = Some(Status::WaitingPayment); + // The seller locks exactly `order.amount` in the 2-of-3 token — this is the + // value `add_cashu_escrow_action` validates the submitted token against. We + // deliberately do NOT add the Mostro fee here: unlike a Lightning hold + // invoice (where Mostro settles the inbound HTLC and keeps the fee), Mostro + // never takes custody of the ecash and holds only 1 of 3 keys, so it cannot + // split a fee out of the redeemed token. Fee collection in Cashu mode is an + // open design question (see docs/CASHU_ESCROW_ARCHITECTURE.md, "Open + // Questions / Future Work"); until it is resolved the escrow locks the bare + // order amount. + 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()); + 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 (maker) to wait for the seller to 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?; @@ -1032,11 +1123,15 @@ pub async fn rate_counterpart( Ok(()) } -/// Settle a seller hold invoice -#[allow(clippy::too_many_arguments)] +/// Settle a seller hold invoice via the active escrow backend. +/// +/// Keeps the authorization check (sender must be the seller, unless `is_admin`) +/// and routes the settlement through `ctx.escrow()`: the normal path uses +/// `release`, the admin (dispute) path uses `dispute_settle`. For Lightning both +/// settle the hold invoice by preimage; a missing preimage is `InvalidInvoice`. pub async fn settle_seller_hold_invoice( event: &UnwrappedMessage, - ln_client: &mut dyn EscrowBackend, + ctx: &AppContext, action: Action, is_admin: bool, order: &Order, @@ -1053,13 +1148,13 @@ pub async fn settle_seller_hold_invoice( return Err(MostroCantDo(CantDoReason::InvalidPubkey)); } - // Settling the hold invoice - if let Some(preimage) = order.preimage.as_ref() { - ln_client.settle_hold_invoice(preimage).await?; - info!("{action}: Order Id {}: hold invoice settled", order.id); + // Settle the escrow lock through the active backend. + if is_admin { + ctx.escrow().dispute_settle(order).await?; } else { - return Err(MostroCantDo(CantDoReason::InvalidInvoice)); + ctx.escrow().release(order).await?; } + info!("{action}: Order Id {}: hold invoice settled", order.id); Ok(()) }