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
46 changes: 36 additions & 10 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
191 changes: 191 additions & 0 deletions src/app/add_cashu_escrow.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
1 change: 1 addition & 0 deletions src/app/add_invoice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions src/app/admin_settle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/app/bond/flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn crate::escrow::EscrowBackend> = 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,
Expand All @@ -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,
Expand Down
Loading