Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CASHU_ESCROW_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ Runs alongside the tracks and closes the milestone:

- End-to-end happy-path and dispute trades on the F6 mint; cross-track wiring once A+B (and then C, D) land.
- Mint-unavailable handling and retries; idempotency on resubmitted tokens; `restore_session` for in-flight Cashu orders; expiry/timeout for un-locked escrows.
- **Daemon-driven timeout cancel through the escrow seam.** `scheduler.rs`'s `job_cancel_orders` currently returns locked trade funds to the seller by calling `LndConnector::cancel_hold_invoice` directly. That bypasses `EscrowBackend` and is Lightning-only, so a Cashu order hitting the same timeout would not be handled correctly. Route this path through `EscrowBackend::cooperative_cancel` so daemon-initiated timeout refunds work for either backend. Deliberately left out of F3 (which is behavior-preserving and scoped to the user-facing `take_*`/`add_invoice`/`release`/`cancel`/`admin_*` call sites) and distinct from Track C, which only owns `cancel.rs`'s user-initiated branch.
- Enforce and test the bonds-vs-Cashu mutual exclusion end to end.
- Operator docs: enabling Cashu mode, choosing a mint, the trust model, and migration/runbook notes.

Expand Down
3 changes: 2 additions & 1 deletion src/app/release.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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};
Expand Down Expand Up @@ -159,7 +160,7 @@ pub async fn release_action(
msg: Message,
event: &UnwrappedMessage,
my_keys: &Keys,
ln_client: &mut LndConnector,
ln_client: &mut dyn EscrowBackend,
) -> Result<(), MostroError> {
let pool = ctx.pool();
// Get request id
Expand Down
129 changes: 129 additions & 0 deletions src/escrow.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//! Escrow backend abstraction.
//!
//! Mostro can hold trade funds in escrow in more than one way. Today the only
//! production backend is **Lightning** (hold invoices on an LND node). A second,
//! opt-in **Cashu** 2-of-3 multisig backend is being added incrementally (see
//! `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).
//!
//! 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`].

use crate::lightning::LndConnector;
use async_trait::async_trait;
use mostro_core::prelude::*;

/// Backend-agnostic result of opening an escrow "lock".
///
/// For Lightning this carries the hold invoice: `payment_request` is the BOLT11
/// string the counterparty pays, and `(preimage, hash)` identify and later
/// settle it. Keeping this struct (rather than LND's `AddHoldInvoiceResp`) out
/// of the trait keeps `fedimint-tonic-lnd` out of the escrow contract, so a
/// non-Lightning backend can implement `EscrowBackend` without depending on it.
#[derive(Debug, Clone)]
pub struct HoldInvoice {
/// BOLT11 invoice the counterparty pays (Lightning).
pub payment_request: String,
/// Preimage that releases the locked funds.
pub preimage: Vec<u8>,
/// Payment hash identifying the escrow.
pub hash: Vec<u8>,
}

/// The escrow operations the order-action handlers depend on.
///
/// 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.
#[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,
description: &str,
amount: i64,
) -> Result<HoldInvoice, MostroError>;

/// 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>;

/// Return escrowed funds to the seller / void the escrow.
///
/// Lightning: cancel the hold invoice identified by `hash`.
async fn cancel_hold_invoice(&mut self, hash: &str) -> Result<(), MostroError>;
}

/// 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,
description: &str,
amount: i64,
) -> Result<HoldInvoice, MostroError> {
let (resp, preimage, hash) =
LndConnector::create_hold_invoice(self, description, amount).await?;
Ok(HoldInvoice {
payment_request: resp.payment_request,
preimage,
hash,
})
}

async fn settle_hold_invoice(&mut self, preimage: &str) -> Result<(), MostroError> {
LndConnector::settle_hold_invoice(self, preimage).await?;
Ok(())
}

async fn cancel_hold_invoice(&mut self, hash: &str) -> Result<(), MostroError> {
LndConnector::cancel_hold_invoice(self, hash).await?;
Ok(())
}
}

/// 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;

#[async_trait]
impl EscrowBackend for CashuBackend {
async fn create_hold_invoice(
&mut self,
_description: &str,
_amount: i64,
) -> Result<HoldInvoice, MostroError> {
unimplemented!("Cashu escrow lock is implemented in the Cashu lock track")
}

async fn settle_hold_invoice(&mut self, _preimage: &str) -> Result<(), MostroError> {
unimplemented!("Cashu escrow release is implemented in the Cashu release track")
}

async fn cancel_hold_invoice(&mut self, _hash: &str) -> Result<(), MostroError> {
unimplemented!("Cashu escrow cancel is implemented in the Cashu cancel track")
}
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod bitcoin_price;
pub mod cli;
pub mod config;
pub mod db;
pub mod escrow;
pub mod flow;
pub mod lightning;
pub mod lnurl;
Expand Down
4 changes: 2 additions & 2 deletions src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ use crate::config::settings::{get_db_pool, Settings};
use crate::config::*;
use crate::db;
use crate::db::is_user_present;
use crate::escrow::EscrowBackend;
use crate::flow;
use crate::lightning;
use crate::lightning::invoice::is_valid_invoice;
use crate::lightning::LndConnector;
use crate::lnurl::HTTP_CLIENT;
use crate::messages;
use crate::models::Yadio;
Expand Down Expand Up @@ -1036,7 +1036,7 @@ pub async fn rate_counterpart(
#[allow(clippy::too_many_arguments)]
pub async fn settle_seller_hold_invoice(
event: &UnwrappedMessage,
ln_client: &mut LndConnector,
ln_client: &mut dyn EscrowBackend,
action: Action,
is_admin: bool,
order: &Order,
Expand Down