diff --git a/.gitignore b/.gitignore index fd5316fb..144b3f92 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ lnurl-test-server/target # settings file settings.toml +# Local e2e harness key material (never a repo artifact) +*-keys.json + book/book/ bin/ diff --git a/docs/cashu/02-track-a-lock.md b/docs/cashu/02-track-a-lock.md index 2fc03e1b..7b255f5e 100644 --- a/docs/cashu/02-track-a-lock.md +++ b/docs/cashu/02-track-a-lock.md @@ -575,7 +575,15 @@ seller hold invoice: the **locktime horizon** (`cashu.escrow_locktime_days`, §4B) so the seller sets `locktime = now + days` with `refund = [P_S]`. (This is the `show_cashu_escrow_request(...)` helper.) The order is left in - `WaitingPayment`, exactly where the CAS in step 8 expects it. The seller's + `WaitingPayment`, exactly where the CAS in step 8 expects it. + The helper **claims the `Pending → WaitingPayment` transition atomically** + (`claim_order_status` in `src/db.rs`) before it writes anything: two concurrent takes + both pass the caller's in-memory `check_status`, and the loser's full-row + write is built from a copy read before either ran — it would drag the status + back with its own trade keys and null every column its stale copy does not + carry, including a `cashu_escrow_token` step 8 may already have persisted. + The claim also refuses any order whose escrow is already funded. The loser + gets `CantDo(NotAllowedByStatus)` and changes nothing. The seller's client then builds **two** tokens — the 2-of-3 escrow and the 1-of-1 `P_M` fee token — and submits both in `AddCashuEscrow`. diff --git a/src/app.rs b/src/app.rs index fd9a9b38..1f903842 100644 --- a/src/app.rs +++ b/src/app.rs @@ -562,12 +562,21 @@ async fn dispatch_cashu( Action::Orders | Action::LastTradeIndex | Action::RestoreSession | Action::TradePubkey => { handle_message_action_no_ln(action, msg, event, my_keys, ctx).await } + // Order creation + the take flow (Track A TA-2). Creating a pending + // order touches no escrow; the take handlers branch on cashu mode and + // emit the escrow request (`show_cashu_escrow_request`) instead of a + // hold invoice. Creatable and takeable ship together so the book never + // fills with untakeable orders. + Action::NewOrder | Action::TakeBuy | Action::TakeSell => { + handle_message_action_no_ln(action, msg, event, my_keys, ctx).await + } // Cashu escrow lock — TA-1 fills the stub body; the routing is frozen. Action::AddCashuEscrow => add_cashu_escrow_action(ctx, msg, event, my_keys) .await .map_err(|e| e.into()), - // Everything that creates, advances, or settles an order has no escrow - // behind it during the foundation milestone — reject it cleanly. + // Everything that advances or settles an order past the lock has no + // handler yet during Track A — reject it cleanly. Later tracks + // (release/cancel/dispute) replace these arms one at a time. _ => Err(MostroError::MostroCantDo(CantDoReason::InvalidAction).into()), } } @@ -1052,12 +1061,12 @@ mod tests { ) } - /// Every action that creates, advances, or settles an order — plus the - /// permanently-blocked buyer-invoice/bond actions — must be rejected - /// with `CantDo(InvalidAction)` in Cashu foundation mode. This is the - /// DoD "no trade can complete yet" gate. `AddCashuEscrow` is excluded: - /// Track A (TA-1) implements it, so it no longer routes to - /// `InvalidAction` — it runs the real lock handler. + /// Actions with no Cashu handler yet — release/cancel/dispute, the + /// permanently-blocked buyer-invoice/bond actions, and Track D admin + /// actions — must still be rejected with `CantDo(InvalidAction)`. The + /// Track A actions (`NewOrder`, `TakeBuy`, `TakeSell` in TA-2; + /// `AddCashuEscrow` in TA-1) are excluded: they now route to their real + /// handlers, not to `InvalidAction`. #[tokio::test] async fn blocks_every_order_lifecycle_action_with_invalid_action() { let _ = @@ -1068,9 +1077,6 @@ mod tests { let event = create_test_unwrapped_message(); for action in [ - Action::NewOrder, - Action::TakeBuy, - Action::TakeSell, Action::AddInvoice, Action::FiatSent, Action::Release, diff --git a/src/app/take_buy.rs b/src/app/take_buy.rs index 6f6c6533..4a5e25db 100644 --- a/src/app/take_buy.rs +++ b/src/app/take_buy.rs @@ -1,9 +1,11 @@ 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, is_order_take_window_closed, show_hold_invoice, HoldInvoiceOrigin, + get_order, is_order_take_window_closed, show_cashu_escrow_request, show_hold_invoice, + HoldInvoiceOrigin, }; use crate::db::{seller_has_pending_order, update_user_trade_index}; @@ -195,6 +197,23 @@ pub async fn take_buy_action( order.trade_index_seller = Some(trade_index); order.set_timestamp_now(); + // Cashu escrow mode (Track A TA-2): the seller (taker) locks a 2-of-3 token + // instead of paying a hold invoice. Emit the escrow request and leave the + // order in WaitingPayment, where the CAS in `add_cashu_escrow_action` + // expects it. + if Settings::is_cashu_enabled() { + show_cashu_escrow_request( + pool, + 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( my_keys, diff --git a/src/app/take_sell.rs b/src/app/take_sell.rs index 4c8e9674..aa16fe74 100644 --- a/src/app/take_sell.rs +++ b/src/app/take_sell.rs @@ -1,11 +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, is_order_take_window_closed, set_waiting_invoice_status, show_hold_invoice, - update_order_event, validate_invoice, HoldInvoiceOrigin, + get_order, is_order_take_window_closed, set_waiting_invoice_status, show_cashu_escrow_request, + show_hold_invoice, update_order_event, validate_invoice, HoldInvoiceOrigin, }; use mostro_core::prelude::*; use nostr_sdk::prelude::*; @@ -194,7 +195,8 @@ pub async fn take_sell_action( // 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?; + let payment_request = + validate_buyer_invoice(&msg, &order, Settings::is_cashu_enabled()).await?; let trade_index = match msg.get_inner_message_kind().trade_index { Some(trade_index) => trade_index, @@ -240,6 +242,24 @@ pub async fn take_sell_action( order.trade_index_buyer = Some(trade_index); order.set_timestamp_now(); + // Cashu escrow mode (Track A TA-2): the seller (maker) locks a 2-of-3 token + // instead of paying a hold invoice, and the buyer redeems ecash directly — + // so the buyer payout invoice is skipped entirely (a supplied one is + // ignored). Emit the escrow request to the seller and leave the order in + // WaitingPayment, where the CAS in `add_cashu_escrow_action` expects it. + if Settings::is_cashu_enabled() { + show_cashu_escrow_request( + pool, + 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?; @@ -261,6 +281,27 @@ pub async fn take_sell_action( Ok(()) } +/// Buyer payout invoice gate for a take. +/// +/// Cashu escrow mode never uses the buyer's payout invoice — the buyer redeems +/// ecash directly from the 2-of-3 token — so a supplied invoice is ignored +/// instead of validated. Without this gate a stale or malformed BOLT11 payload +/// would reject a Cashu take over a field the flow never reads. +/// +/// The Cashu flag is threaded in as an argument rather than read here because +/// `Settings::is_cashu_enabled()` reads the process-wide `MOSTRO_CONFIG` +/// `OnceLock`, which a unit test cannot toggle. +async fn validate_buyer_invoice( + msg: &Message, + order: &Order, + cashu_enabled: bool, +) -> Result, MostroError> { + if cashu_enabled { + return Ok(None); + } + validate_invoice(msg, order).await +} + #[cfg(test)] mod tests { use super::*; @@ -311,6 +352,48 @@ mod tests { } } + /// A garbage BOLT11 payload must not sink a Cashu take: the buyer payout + /// invoice is unused in escrow mode, so the gate drops it instead of + /// bubbling `InvalidInvoice`. The Lightning path keeps rejecting it. + #[tokio::test] + async fn cashu_take_ignores_a_malformed_buyer_invoice() { + // Arrange + let order = Order { + id: uuid::Uuid::new_v4(), + kind: OrderKind::Sell.to_string(), + status: Status::Pending.to_string(), + payment_method: "SEPA".to_string(), + amount: 1_000, + fee: 10, + fiat_code: "USD".to_string(), + fiat_amount: 100, + ..Default::default() + }; + let msg = Message::new_order( + Some(order.id), + None, + Some(1), + Action::TakeSell, + Some(Payload::PaymentRequest( + None, + "notaninvoice".to_string(), + None, + )), + ); + + // Act + Assert: Cashu escrow mode ignores the payload entirely. + assert_eq!( + validate_buyer_invoice(&msg, &order, true).await.unwrap(), + None + ); + + // Act + Assert: Lightning mode still refuses it. + let err = validate_buyer_invoice(&msg, &order, false) + .await + .unwrap_err(); + assert!(matches!(err, MostroCantDo(CantDoReason::InvalidInvoice))); + } + #[tokio::test] async fn test_update_order_status_structure() { // Test the structure of update_order_status function diff --git a/src/db.rs b/src/db.rs index b246f8bd..1b3fd117 100644 --- a/src/db.rs +++ b/src/db.rs @@ -874,6 +874,42 @@ pub async fn update_order_cashu_escrow( Ok(result.rows_affected() > 0) } +/// Atomically claim an order's status transition (Track A **TA-2**). +/// +/// Two concurrent `TakeBuy`/`TakeSell` events for the same pending order both +/// read a `Pending` copy and both pass the in-memory `check_status`, so without +/// this the loser goes on to write a full row built from its **stale** copy — +/// rewriting the status back to `WaitingPayment` and nulling every column it +/// does not know about, including a `cashu_escrow_token` the TA-1 CAS may have +/// persisted in the meantime. Claiming the transition first means only one +/// taker ever reaches the write, and the loser aborts having changed nothing. +/// +/// `cashu_escrow_locked_at IS NULL` is belt-and-braces: an order whose escrow +/// is already funded must never be dragged back to an earlier status, whatever +/// its current one. +pub async fn claim_order_status( + pool: &SqlitePool, + order_id: Uuid, + expected_status: Status, + new_status: Status, +) -> Result { + let result = sqlx::query( + r#" + UPDATE orders + SET status = ?1 + WHERE id = ?2 AND status = ?3 AND cashu_escrow_locked_at IS NULL + "#, + ) + .bind(new_status.to_string()) + .bind(order_id) + .bind(expected_status.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + Ok(result.rows_affected() > 0) +} + /// Whether some **other** order already holds this exact escrow token. /// /// The escrow token's 2-of-3 condition commits to `{P_B, P_S, P_M}` — trade diff --git a/src/util.rs b/src/util.rs index ecd367fc..0e8c8736 100644 --- a/src/util.rs +++ b/src/util.rs @@ -4,7 +4,7 @@ use crate::config::constants::{ use crate::config::settings::{get_db_pool, Settings}; use crate::config::*; use crate::db; -use crate::db::is_user_present; +use crate::db::{claim_order_status, is_user_present}; use crate::escrow::EscrowBackend; use crate::flow; use crate::lightning; @@ -1652,6 +1652,121 @@ pub async fn show_hold_invoice( Ok(()) } +/// Cashu analogue of [`show_hold_invoice`] (Track A **TA-2**, +/// `docs/cashu/02-track-a-lock.md` §5). +/// +/// Instead of creating a Lightning hold invoice, ask the **seller** to lock the +/// trade amount in a 2-of-3 Cashu token: advance the order to `WaitingPayment` +/// (where the CAS in `add_cashu_escrow_action` expects it), record both trade +/// pubkeys, publish the updated order event, and enqueue an escrow request to +/// the seller carrying everything the client needs to build the 2-of-3 +/// (`order.amount`, the buyer and seller trade pubkeys). The buyer (taker) gets +/// a "waiting for the seller" notice — the buyer redeems the ecash directly +/// later, so there is **no** buyer payout invoice in Cashu mode. +/// +/// The escrow token locks `order.amount` **exactly** — the Mostro fee is a +/// separate token (Option 2, added in TA-1f). The **mint URL** and the +/// **locktime floor** are node policy the daemon enforces authoritatively when +/// the seller submits (`add_cashu_escrow_action` §5/§7), so a client that funds +/// against the wrong mint or with too short a locktime is simply rejected and +/// retries — they are not carried in this request payload (the 0.14.0 protocol +/// has no field for them). +/// +/// The request is delivered as `Action::WaitingSellerToPay` carrying a +/// `Payload::Order` whose `buyer_trade_pubkey`/`seller_trade_pubkey` are set; +/// on a Cashu node the seller's client reads that as "lock the escrow" (the +/// Lightning path instead sends `Action::PayInvoice` with a bolt11). +pub async fn show_cashu_escrow_request( + pool: &Pool, + my_keys: &Keys, + buyer_pubkey: &PublicKey, + seller_pubkey: &PublicKey, + mut order: Order, + request_id: Option, +) -> Result<(), MostroError> { + // Claim the transition before writing anything. Two concurrent takes on the + // same pending order both pass the caller's in-memory `check_status`, and + // the full-row `update` below is built from a copy read before either ran: + // the loser would rewrite the status back to `WaitingPayment` with its own + // trade keys and null every column its stale copy does not carry — + // including a `cashu_escrow_token` the TA-1 CAS may already have persisted. + // Only the winner proceeds; the loser aborts having changed nothing. + // + // `Pending` is the only reachable pre-state here even though both callers + // also admit `WaitingTakerBond`: `validate_cashu_settings` + // (`src/config/util.rs`) rejects `cashu.enabled` together with + // `anti_abuse_bond.enabled` as a startup-fatal error (§4.5), so a Cashu node + // never mints a `WaitingTakerBond` order. If that exclusivity is ever + // relaxed, this claim must accept both statuses — otherwise a legitimate + // take on a bonded order is refused here with `NotAllowedByStatus`. + if !claim_order_status(pool, order.id, Status::Pending, Status::WaitingPayment).await? { + tracing::info!( + "cashu take: order {} was claimed concurrently or already funded — refusing the take", + order.id + ); + return Err(MostroCantDo(CantDoReason::NotAllowedByStatus)); + } + + order.status = Status::WaitingPayment.to_string(); + order.buyer_pubkey = Some(buyer_pubkey.to_string()); + order.seller_pubkey = Some(seller_pubkey.to_string()); + + // Publish the updated (WaitingPayment) order event, then persist the full + // row. The full-row write is built from `order`, the copy read before the + // CAS, so between the two TA-1's lock CAS — which fires on + // `WaitingPayment` — would be clobbered: the write would null + // `cashu_escrow_token` / `cashu_escrow_locked_at`, the exact damage the + // claim above exists to prevent, arriving via the seller instead of a + // second taker. + // + // That window is closed by ordering elsewhere, not by anything in this + // function: the seller cannot build the 2-of-3 without the buyer's trade + // pubkey, which only ships in the message enqueued *below* the write, and + // at this point the row carries no `buyer_pubkey` for the lock handler to + // validate against. Do not move either `enqueue_order_msg` above the + // `update` without re-reading the row (or narrowing the write) first. + 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())))?; + + // Build the escrow request for the seller. + let mut new_order = order.as_new_order(); + new_order.status = Some(Status::WaitingPayment); + 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()); + // No buyer invoice in Cashu mode. + 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 (taker) that their order was taken and the seller must + // 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?; @@ -3366,6 +3481,148 @@ mod tests { assert!(updated5.event_id.is_empty()); } + // ───────────────────────── cashu escrow request (TA-2) ───────────────────────── + + /// `show_cashu_escrow_request` advances the order to `WaitingPayment`, + /// records both trade pubkeys, and enqueues the escrow request to the + /// seller (carrying the trade pubkeys + bare amount) plus a "wait" notice + /// to the buyer — no buyer invoice, no Lightning. + #[tokio::test] + async fn show_cashu_escrow_request_advances_status_and_notifies_both_parties() { + init_globals(); + let pool = migrated_pool().await; + let keys = Keys::generate(); + let buyer = Keys::generate().public_key(); + let seller = Keys::generate().public_key(); + let mut order = base_order(OrderKind::Sell, Status::Pending); + order.trade_index_seller = Some(2); + order.trade_index_buyer = Some(3); + let order = order.create(&pool).await.unwrap(); + + show_cashu_escrow_request(&pool, &keys, &buyer, &seller, order.clone(), Some(7)) + .await + .expect("escrow request must succeed offline"); + + // Status advanced + both trade pubkeys recorded. + let db = Order::by_id(&pool, order.id).await.unwrap().unwrap(); + assert_eq!(db.status, Status::WaitingPayment.to_string()); + assert_eq!(db.buyer_pubkey, Some(buyer.to_string())); + assert_eq!(db.seller_pubkey, Some(seller.to_string())); + + // Collect our order's queued messages (the queue is shared across tests). + let msgs: Vec<(Message, PublicKey)> = MESSAGE_QUEUES + .queue_order_msg + .read() + .await + .iter() + .filter(|(m, _)| m.get_inner_message_kind().id == Some(order.id)) + .cloned() + .collect(); + + // The seller gets the escrow request carrying the 2-of-3 build inputs. + let (seller_msg, _) = msgs + .iter() + .find(|(_, pk)| *pk == seller) + .expect("seller escrow request"); + assert_eq!( + seller_msg.get_inner_message_kind().action, + Action::WaitingSellerToPay + ); + match seller_msg.get_inner_message_kind().get_payload() { + Some(Payload::Order(so)) => { + assert_eq!(so.buyer_trade_pubkey, Some(buyer.to_string())); + assert_eq!(so.seller_trade_pubkey, Some(seller.to_string())); + assert_eq!(so.amount, order.amount); + assert!(so.buyer_invoice.is_none()); + } + other => panic!("expected Order payload for the seller, got {other:?}"), + } + + // The buyer gets a bare "waiting for the seller" notice. + let (buyer_msg, _) = msgs + .iter() + .find(|(_, pk)| *pk == buyer) + .expect("buyer notice"); + assert_eq!( + buyer_msg.get_inner_message_kind().action, + Action::WaitingSellerToPay + ); + assert!(buyer_msg.get_inner_message_kind().get_payload().is_none()); + } + + /// Two concurrent takes on the same pending order: the second one holds a + /// stale `Pending` copy, and without the status claim its full-row write + /// would drag the order back and null the columns it does not carry. It + /// must be refused instead, leaving the first taker's state intact. + #[tokio::test] + async fn show_cashu_escrow_request_refuses_a_second_concurrent_take() { + init_globals(); + let pool = migrated_pool().await; + let keys = Keys::generate(); + let first_buyer = Keys::generate().public_key(); + let second_buyer = Keys::generate().public_key(); + let seller = Keys::generate().public_key(); + let order = base_order(OrderKind::Sell, Status::Pending) + .create(&pool) + .await + .unwrap(); + + // The stale copy the loser carries: still Pending, read before the + // winner ran. + let stale = order.clone(); + + show_cashu_escrow_request(&pool, &keys, &first_buyer, &seller, order.clone(), Some(1)) + .await + .expect("the first take must win"); + + let result = + show_cashu_escrow_request(&pool, &keys, &second_buyer, &seller, stale, Some(2)).await; + assert!( + matches!(result, Err(MostroCantDo(CantDoReason::NotAllowedByStatus))), + "the second take must be refused, got {result:?}" + ); + + // The winner's taker is still on the order. + let db = Order::by_id(&pool, order.id).await.unwrap().unwrap(); + assert_eq!(db.status, Status::WaitingPayment.to_string()); + assert_eq!(db.buyer_pubkey, Some(first_buyer.to_string())); + } + + /// An order whose escrow is already funded must never be dragged back by a + /// late take, whatever its status. + #[tokio::test] + async fn show_cashu_escrow_request_refuses_a_take_on_a_funded_order() { + init_globals(); + let pool = migrated_pool().await; + let keys = Keys::generate(); + let buyer = Keys::generate().public_key(); + let seller = Keys::generate().public_key(); + let order = base_order(OrderKind::Sell, Status::Pending) + .create(&pool) + .await + .unwrap(); + sqlx::query( + "UPDATE orders SET cashu_escrow_token = ?1, cashu_escrow_locked_at = ?2 WHERE id = ?3", + ) + .bind("cashuAtoken") + .bind(1700000100_i64) + .bind(order.id) + .execute(&pool) + .await + .unwrap(); + + let result = + show_cashu_escrow_request(&pool, &keys, &buyer, &seller, order.clone(), Some(3)).await; + assert!( + matches!(result, Err(MostroCantDo(CantDoReason::NotAllowedByStatus))), + "a funded order must not be re-taken, got {result:?}" + ); + + let db = Order::by_id(&pool, order.id).await.unwrap().unwrap(); + assert_eq!(db.cashu_escrow_token.as_deref(), Some("cashuAtoken")); + assert_eq!(db.cashu_escrow_locked_at, Some(1700000100)); + } + // ───────────────────────── nostr client plumbing ───────────────────────── #[tokio::test]