From f3de52b138434da0dd960d77add0d52b2d14c4f5 Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 1 Jun 2026 03:26:29 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(cashu):=20Track=20A=20=E2=80=94=20Cash?= =?UTF-8?q?u=202-of-3=20escrow=20lock=20end-to-end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Track A from docs/CASHU_ESCROW_ARCHITECTURE.md: the Cashu analogue of paying the Lightning hold invoice. Mostro stays a pure coordinator (1 of 3 keys, never custodial) — it only validates the seller-submitted token and advances the order. - add_cashu_escrow.rs: `AddCashuEscrow` handler. Requires WaitingPayment + sender==seller, derives the authoritative {P_B,P_S,P_M} from the order's trade pubkeys and Mostro's identity key (never trusting the pubkeys the seller states in the proof), validates the token via CashuClient::verify_escrow_token (2-of-3 over the right keys, bound to the configured mint, exact amount, all proofs unspent), then atomically CAS-advances WaitingPayment→Active and notifies both parties (CashuEscrowLocked) so the buyer can send fiat. - take_sell/take_buy: branch on is_cashu_enabled() before the hold-invoice path and call util::show_cashu_escrow_request, which sets WaitingPayment, persists, publishes the order event, and prompts the seller to fund the token (reusing WaitingSellerToPay + SmallOrder carrying the trade pubkeys). - app.rs run_cashu: route NewOrder/TakeSell/TakeBuy through the no-LN dispatcher (they are now Cashu-aware); keep AddInvoice/Release/Cancel/ AdminCancel/AdminSettle rejected (AddInvoice would hit the unimplemented CashuBackend::lock; release/cancel/dispute are Tracks B/C/D). - escrow seam plumbing: AppContext carries Arc + optional CashuClient; show_hold_invoice / settle_seller_hold_invoice go through the backend; main/rpc build the Lightning backend, main connects the mint and attaches the Cashu client in Cashu mode. - cashu/mod.rs: verify_escrow_token + cashu_pubkey_from_xonly_hex helpers. The escrow lock value is exactly order.amount (no Mostro fee added): Mostro holds only 1 of 3 keys and cannot split a fee out of the redeemed ecash, so Cashu-mode fee collection is left as an open question per the spec. Track A lock is implemented as a dedicated handler outside the EscrowBackend seam (seam stays LN-shaped for Tracks B/C/D), per design decision. cargo fmt + clippy --all-targets --all-features -D warnings clean; 388 unit tests + cashu mint integration test pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.rs | 40 +++-- src/app/add_cashu_escrow.rs | 164 ++++++++++++++++++++ src/app/add_invoice.rs | 1 + src/app/admin_settle.rs | 12 +- src/app/bond/flow.rs | 10 ++ src/app/context.rs | 51 ++++++- src/app/release.rs | 5 +- src/app/take_buy.rs | 14 +- src/app/take_sell.rs | 15 +- src/cashu/mod.rs | 289 ++++++++++++++++++++++++++++++++---- src/escrow.rs | 272 +++++++++++++++++++++++++-------- src/main.rs | 35 +++-- src/rpc/service.rs | 19 ++- src/util.rs | 139 ++++++++++++++--- 14 files changed, 915 insertions(+), 151 deletions(-) create mode 100644 src/app/add_cashu_escrow.rs diff --git a/src/app.rs b/src/app.rs index 1efa4bb5..b9c69a49 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; @@ -273,7 +275,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 +284,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 +459,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..59dfbbb8 --- /dev/null +++ b/src/app/add_cashu_escrow.rs @@ -0,0 +1,164 @@ +//! 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)); + } + + // Re-fetch the now-active, locked order and publish the replaceable event so + // clients observe the new status. + let order = get_order(&msg, pool).await?; + let order_updated = update_order_event(my_keys, Status::Active, &order).await?; + order_updated + .update(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + // Confirm the lock to the seller. + enqueue_order_msg( + request_id, + Some(order.id), + Action::CashuEscrowLocked, + None, + seller_pubkey, + order.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, + order.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..1ca798b6 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::*; @@ -209,6 +211,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 +228,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..a5623c32 100644 --- a/src/cashu/mod.rs +++ b/src/cashu/mod.rs @@ -1,8 +1,11 @@ +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, 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 +49,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 { @@ -59,19 +61,29 @@ impl CashuClient { 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())) } } } + /// 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 +92,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,67 +100,172 @@ 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. **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. + /// + /// Mint authenticity of the proofs (DLEQ) is intentionally out of scope here + /// (see [`Self::verify_token_dleq`]); the parties already agreed on the mint. + 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. + 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: 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 states = self.check_state(ys).await?; + 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) + } + /// Checks the state of proofs against the mint's `/v1/checkstate` endpoint. /// Note: This only checks if the secrets are unspent. It does not authenticate /// 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,8 +277,115 @@ 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()); + } +} 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..2b29ff53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,18 +133,18 @@ 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)"); + 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, + ); + start_scheduler(ctx.clone()).await; return run_cashu(ctx).await; } @@ -156,6 +156,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..89d47503 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,90 @@ 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> { + // 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. + let locked_amount = order.amount; + + 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); + new_order.amount = locked_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 +1125,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 +1150,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(()) } From f532ef062c5b99c867693483721658952a5fa4ef Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 1 Jun 2026 03:40:31 -0300 Subject: [PATCH 2/3] fix(cashu): wire mint client at boot + verify proof DLEQ (Codex P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P1 issues from the Codex review of #765: 1. Attach the Cashu client before the event loop. The Cashu boot branch built AppContext but never called CashuClient::connect / with_cashu_client, so cashu_client stayed None and every AddCashuEscrow failed with "dispatched without a Cashu client" — no order could reach Active. main now connects the configured mint and attaches the client, panicking if the mint is unreachable (mirroring how Lightning mode refuses to boot without LND). 2. Authenticate proofs as mint-issued before activating the order. verify_escrow_token relied on /v1/checkstate, which reports any unknown secret as Unspent — a seller could fabricate proofs with the right 2-of-3 condition and amount but no mint signature, pass every check, and trick the buyer into sending fiat against worthless ecash. Now verify the NUT-12 DLEQ proof of every proof (verify_token_dleq) before the unspent check. cargo fmt + clippy --all-targets --all-features -D warnings clean; 388 unit tests + cashu mint integration test pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cashu/mod.rs | 21 ++++++++++++++++----- src/main.rs | 20 +++++++++++++++++++- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/cashu/mod.rs b/src/cashu/mod.rs index a5623c32..56476fd1 100644 --- a/src/cashu/mod.rs +++ b/src/cashu/mod.rs @@ -149,12 +149,14 @@ impl CashuClient { /// 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. **Unspent.** Every proof must be `Unspent` at the mint (NUT-07 + /// 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. - /// - /// Mint authenticity of the proofs (DLEQ) is intentionally out of scope here - /// (see [`Self::verify_token_dleq`]); the parties already agreed on the mint. pub async fn verify_escrow_token( &self, token_str: &str, @@ -188,7 +190,16 @@ impl CashuClient { ))); } - // 4: every proof must be unspent at the mint. Derive the checkstate Y + // 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() { diff --git a/src/main.rs b/src/main.rs index 2b29ff53..e66a9ccc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -135,6 +135,23 @@ async fn main() -> Result<()> { ); 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(), @@ -143,7 +160,8 @@ async fn main() -> Result<()> { 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; } From 4756bfa2631a612861eb296c413c878f47d88fb6 Mon Sep 17 00:00:00 2001 From: grunch Date: Wed, 10 Jun 2026 20:12:42 -0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(cashu):=20Track=20A=20review=20?= =?UTF-8?q?=E2=80=94=20escrow-token=20fund-safety=20+=20flow=20correctness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security / fund-safety (verify_escrow_token, connect): - Assert the token's unit is `sat` before the amount check, so a mint exposing multiple units can't satisfy a sat order with a msat token. - Fail closed when `/v1/checkstate` returns fewer states than proofs queried — an omitted proof is never treated as implicitly Unspent. - Require NUT-07 and NUT-12 (alongside NUT-11) at connect, so a mint that can't serve the lock path is refused up front instead of stranding every AddCashuEscrow in WaitingPayment. (Absent-DLEQ rejection is already guaranteed by cdk's Proof::verify_dleq -> MissingDleqProof; added an offline regression test pinning it.) Flow correctness: - take_sell: run Lightning invoice validation only on the LN path; reject a Lightning invoice on the Cashu path with InvalidParameters instead of silently dropping it. - app.rs no-LN dispatcher: reject Action::AddCashuEscrow with InvalidAction (mirrors PayInvoice) rather than silently Ok(()). - add_cashu_escrow: make the post-CAS event publish / notifications best-effort (log, don't propagate) so a locked escrow always yields CashuEscrowLocked and a retry can't hit a confusing InvalidOrderStatus. Hardening / tests / cleanup: - Add a sign/verify roundtrip test proving an odd-Y Nostr key signs validly for the 02-derived cashu pubkey (BIP340 x-only verification). - TODO(track-b) note on inactive-keyset fallback in verify_token_dleq. - Inline locked_amount in show_cashu_escrow_request. cargo fmt + clippy --all-targets --all-features -D warnings + 390 unit tests green; cashu_mint integration test passes (skips without a mint). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.rs | 6 ++ src/app/add_cashu_escrow.rs | 51 ++++++++++--- src/app/take_sell.rs | 16 +++- src/cashu/mod.rs | 141 +++++++++++++++++++++++++++++++++++- src/util.rs | 22 +++--- 5 files changed, 207 insertions(+), 29 deletions(-) diff --git a/src/app.rs b/src/app.rs index b9c69a49..f3c6af09 100644 --- a/src/app.rs +++ b/src/app.rs @@ -231,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()), diff --git a/src/app/add_cashu_escrow.rs b/src/app/add_cashu_escrow.rs index 59dfbbb8..d7014b74 100644 --- a/src/app/add_cashu_escrow.rs +++ b/src/app/add_cashu_escrow.rs @@ -129,34 +129,61 @@ pub async fn add_cashu_escrow_action( return Err(MostroCantDo(CantDoReason::InvalidOrderStatus)); } - // Re-fetch the now-active, locked order and publish the replaceable event so - // clients observe the new status. - let order = get_order(&msg, pool).await?; - let order_updated = update_order_event(my_keys, Status::Active, &order).await?; - order_updated - .update(pool) - .await - .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + // 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), + Some(order_id), Action::CashuEscrowLocked, None, seller_pubkey, - order.trade_index_seller, + trade_index_seller, ) .await; // Notify the buyer that escrow is locked — they can now send fiat. enqueue_order_msg( None, - Some(order.id), + Some(order_id), Action::CashuEscrowLocked, None, buyer_pubkey, - order.trade_index_buyer, + trade_index_buyer, ) .await; diff --git a/src/app/take_sell.rs b/src/app/take_sell.rs index 1ca798b6..a281dd72 100644 --- a/src/app/take_sell.rs +++ b/src/app/take_sell.rs @@ -163,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, diff --git a/src/cashu/mod.rs b/src/cashu/mod.rs index 56476fd1..cb905eed 100644 --- a/src/cashu/mod.rs +++ b/src/cashu/mod.rs @@ -3,7 +3,9 @@ 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, PublicKey, Token}; +use cdk::nuts::{ + nut02::ShortKeysetId, CheckStateRequest, CheckStateResponse, CurrencyUnit, PublicKey, Token, +}; use cdk::wallet::MintConnector; use mostro_core::prelude::*; @@ -59,12 +61,29 @@ 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(), )); } + 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) } @@ -179,7 +198,16 @@ impl CashuClient { ))); } - // 3: the locked amount must equal the order amount exactly. + // 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}")))? @@ -210,7 +238,16 @@ impl CashuClient { .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() @@ -238,6 +275,12 @@ impl CashuClient { /// 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> { + // 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 { @@ -399,4 +442,98 @@ mod tests { 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/util.rs b/src/util.rs index 89d47503..58ee18d0 100644 --- a/src/util.rs +++ b/src/util.rs @@ -906,17 +906,6 @@ pub async fn show_cashu_escrow_request( mut order: Order, request_id: Option, ) -> Result<(), MostroError> { - // 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. - let locked_amount = order.amount; - order.status = Status::WaitingPayment.to_string(); order.buyer_pubkey = Some(buyer_pubkey.to_string()); order.seller_pubkey = Some(seller_pubkey.to_string()); @@ -938,7 +927,16 @@ pub async fn show_cashu_escrow_request( // Mostro's node pubkey, already known to the client. let mut new_order = order.as_new_order(); new_order.status = Some(Status::WaitingPayment); - new_order.amount = locked_amount; + // 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;