From 60196782d11e232b1cd267ad3996eb89ed3631a9 Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 20 Jul 2026 19:13:16 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(cashu):=20take=20flow=20escrow=20reque?= =?UTF-8?q?st=20+=20unblock=20creation=20=E2=80=94=20Track=20A=20TA-2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Cashu branch to `take_sell_action`/`take_buy_action` and the `show_cashu_escrow_request` helper (docs/cashu/02-track-a-lock.md §5), and unblock `NewOrder`/`TakeBuy`/`TakeSell` in `dispatch_cashu`. A taken order on a cashu node now asks the SELLER to lock a 2-of-3 escrow instead of paying a hold invoice, leaving the order in `WaitingPayment` where the TA-1 CAS expects it. Creatable and takeable ship together so the book never fills with untakeable orders (the orphan-order hazard CF-5 warns about). - `util::show_cashu_escrow_request`: advance to `WaitingPayment`, record both trade pubkeys, publish the order event, and enqueue an escrow request to the seller (`Action::WaitingSellerToPay` + `Payload::Order` carrying `amount`/`buyer_trade_pubkey`/`seller_trade_pubkey`) plus a bare "waiting for the seller" notice to the buyer. The buyer redeems ecash directly, so there is no buyer payout invoice. The escrow locks `order.amount` exactly (the fee is a separate token in TA-1f); the mint URL + locktime floor are node policy the daemon enforces authoritatively at lock validation (TA-1 §5/§7), so they are not carried in the request (the 0.14.0 protocol has no field for them). - `take_sell`/`take_buy`: early-return through `show_cashu_escrow_request` when `is_cashu_enabled()`, skipping the hold-invoice / buyer-invoice path. A supplied buyer invoice is ignored in Cashu mode. Lightning path unchanged. - `dispatch_cashu`: route `NewOrder`/`TakeBuy`/`TakeSell` to `handle_message_action_no_ln` (their real handlers); the gate test drops them from the `InvalidAction` list accordingly. Test: `show_cashu_escrow_request` advances the status, records both pubkeys, and enqueues the seller request (with the trade pubkeys + bare amount) and the buyer notice. Depends on CF-5 (+ TA-1 for the full e2e lock). Base: feat/cashu-ta1-lock-handler Refs: docs/cashu/02-track-a-lock.md (TA-2) --- src/app.rs | 28 ++++---- src/app/take_buy.rs | 21 +++++- src/app/take_sell.rs | 24 ++++++- src/util.rs | 148 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 14 deletions(-) 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..2f310713 100644 --- a/src/app/take_sell.rs +++ b/src/app/take_sell.rs @@ -1,11 +1,13 @@ 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::*; @@ -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?; diff --git a/src/util.rs b/src/util.rs index ecd367fc..56a19832 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1652,6 +1652,85 @@ 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> { + 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 and persist it. + 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 +3445,75 @@ 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()); + } + // ───────────────────────── nostr client plumbing ───────────────────────── #[tokio::test] From 49e7e8c507e11945ad708462dca28742fe7aaf8f Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 24 Jul 2026 17:58:37 -0300 Subject: [PATCH 2/4] fix(cashu): claim the take transition atomically before writing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the P1 review finding on the TA-2 take flow. show_cashu_escrow_request built a full row from an order copy read at the top of the take handler and wrote it unconditionally. Two concurrent TakeBuy/TakeSell for the same pending order both pass the caller's in-memory check_status, so the loser's write lands second — dragging the status back to WaitingPayment with its own trade keys and nulling every column its stale copy does not carry. If the seller had already submitted AddCashuEscrow in the window, that includes the persisted cashu_escrow_token: a funded escrow silently erased, with the lock notifications already sent. The helper now claims the Pending -> WaitingPayment transition through db::claim_order_status before touching anything, so only one taker reaches the write and the loser aborts with CantDo(NotAllowedByStatus) having changed nothing. The claim additionally refuses any order whose escrow is already funded, so a late take can never drag a funded order backwards whatever its current status. Note the Lightning path has the same shape — show_hold_invoice writes a full row just as unconditionally — but its stale write clears an invoice hash/preimage that the invoice-subscription flow reconciles, not an escrow token that nothing can recover. Worth a separate issue; not changed here. Track A §5 is updated to match. --- cashu-e2e-keys.json | 6 +++ docs/cashu/02-track-a-lock.md | 10 +++- src/db.rs | 36 ++++++++++++++ src/util.rs | 90 ++++++++++++++++++++++++++++++++++- 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 cashu-e2e-keys.json diff --git a/cashu-e2e-keys.json b/cashu-e2e-keys.json new file mode 100644 index 00000000..387665ce --- /dev/null +++ b/cashu-e2e-keys.json @@ -0,0 +1,6 @@ +{ + "note": "Track A TA-1 e2e harness. These keys redeem the escrow token — do not delete until the sats are swept.", + "buyer_trade_key": { "secret": "0386e6f387013afa38d3898f4b33bd44ec6d0556aa0cdf6f28e88395940974a3", "pubkey": "01c42757ad2c54e32d6eac4418ad5daa423a299ce505c19398715c416d8b500a" }, + "seller_trade_key": { "secret": "ac6a69bb9c0b3e9f9ac26d258a0c3a853ff0636345b833efdca9b7c399d91bfe", "pubkey": "411e87c8e32e3ac517220c62f60e49f28eed35b1c0f04099bf7bc0d14a01219e" }, + "mostro_key": { "secret": "ab50a599fc20ac4c832a16dce530657c8f6d0af4ab228867a5dad545c07e5430", "pubkey": "6ff598b082ce48bde87776dbf47a75b41ce98141ba991b0867d04f201c8a16a7" } +} diff --git a/docs/cashu/02-track-a-lock.md b/docs/cashu/02-track-a-lock.md index 2fc03e1b..2636ee16 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** + (`db::claim_order_status`) 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/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 56a19832..1532a5fd 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; @@ -1684,6 +1684,21 @@ pub async fn show_cashu_escrow_request( 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. + 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()); @@ -3514,6 +3529,79 @@ mod tests { 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] From d4e88f951edd79f330933e9ffe030c7942a6ea03 Mon Sep 17 00:00:00 2001 From: grunch Date: Thu, 20 Aug 2026 16:09:20 -0300 Subject: [PATCH 3/4] fix(cashu): review follow-ups on the TA-2 take flow Addresses the review on #830. - Drop `cashu-e2e-keys.json`: a local TA-1 e2e harness artifact that got swept into the branch. The keys are FakeWallet-only (the compose mint runs with `MINT_PRIVATE_KEY=TEST_PRIVATE_KEY`), so nothing redeemable is exposed, but `.gitignore` had no pattern to catch it and the next harness run would commit it again. Adds `*-keys.json`. - Document why `show_cashu_escrow_request`'s CAS hardcodes `Pending` while both callers also admit `WaitingTakerBond`: `WaitingTakerBond` is unreachable on a Cashu node because `validate_cashu_settings` makes `cashu.enabled` and `anti_abuse_bond.enabled` mutually exclusive at startup. That constraint lives in an unrelated module, so relaxing it would turn the claim into a refused-legitimate-take bug. - Document why publishing the order event before the full-row `update` is safe: the seller cannot build the 2-of-3 without the buyer trade pubkey, which only ships after the write, and the row carries no `buyer_pubkey` for the lock handler to validate against yet. Both are properties of ordering elsewhere, so the note warns against hoisting either enqueue above the write. --- .gitignore | 3 +++ cashu-e2e-keys.json | 6 ------ src/app/take_sell.rs | 5 ++--- src/util.rs | 23 ++++++++++++++++++++++- 4 files changed, 27 insertions(+), 10 deletions(-) delete mode 100644 cashu-e2e-keys.json 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/cashu-e2e-keys.json b/cashu-e2e-keys.json deleted file mode 100644 index 387665ce..00000000 --- a/cashu-e2e-keys.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "note": "Track A TA-1 e2e harness. These keys redeem the escrow token — do not delete until the sats are swept.", - "buyer_trade_key": { "secret": "0386e6f387013afa38d3898f4b33bd44ec6d0556aa0cdf6f28e88395940974a3", "pubkey": "01c42757ad2c54e32d6eac4418ad5daa423a299ce505c19398715c416d8b500a" }, - "seller_trade_key": { "secret": "ac6a69bb9c0b3e9f9ac26d258a0c3a853ff0636345b833efdca9b7c399d91bfe", "pubkey": "411e87c8e32e3ac517220c62f60e49f28eed35b1c0f04099bf7bc0d14a01219e" }, - "mostro_key": { "secret": "ab50a599fc20ac4c832a16dce530657c8f6d0af4ab228867a5dad545c07e5430", "pubkey": "6ff598b082ce48bde87776dbf47a75b41ce98141ba991b0867d04f201c8a16a7" } -} diff --git a/src/app/take_sell.rs b/src/app/take_sell.rs index 2f310713..0ffbd88c 100644 --- a/src/app/take_sell.rs +++ b/src/app/take_sell.rs @@ -5,9 +5,8 @@ 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_cashu_escrow_request, 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::*; diff --git a/src/util.rs b/src/util.rs index 1532a5fd..0e8c8736 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1691,6 +1691,14 @@ pub async fn show_cashu_escrow_request( // 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", @@ -1703,7 +1711,20 @@ pub async fn show_cashu_escrow_request( order.buyer_pubkey = Some(buyer_pubkey.to_string()); order.seller_pubkey = Some(seller_pubkey.to_string()); - // Publish the updated (WaitingPayment) order event and persist it. + // 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())))?; From d114f19194b47a52c2f212d1529a0bfb617e9ccd Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 21 Aug 2026 11:33:39 -0300 Subject: [PATCH 4/4] fix(cashu): ignore the buyer payout invoice on Cashu takes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cashu escrow mode never reads the buyer's payout invoice — the buyer redeems ecash directly from the 2-of-3 token — but `take_sell_action` ran `validate_invoice` before reaching the Cashu branch, so a stale or malformed BOLT11 payload rejected the take over a field the flow never uses. The inline comment already claimed a supplied invoice was ignored; now the code matches. The gate lives in `validate_buyer_invoice`, which takes the Cashu flag as an argument: `Settings::is_cashu_enabled()` reads the process-wide `MOSTRO_CONFIG` OnceLock, which a unit test cannot toggle, so the decision is only testable once it is separated from the read. Also fix a documentation citation to the repository style. --- docs/cashu/02-track-a-lock.md | 2 +- src/app/take_sell.rs | 66 ++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/docs/cashu/02-track-a-lock.md b/docs/cashu/02-track-a-lock.md index 2636ee16..7b255f5e 100644 --- a/docs/cashu/02-track-a-lock.md +++ b/docs/cashu/02-track-a-lock.md @@ -577,7 +577,7 @@ seller hold invoice: (This is the `show_cashu_escrow_request(...)` helper.) The order is left in `WaitingPayment`, exactly where the CAS in step 8 expects it. The helper **claims the `Pending → WaitingPayment` transition atomically** - (`db::claim_order_status`) before it writes anything: two concurrent takes + (`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 diff --git a/src/app/take_sell.rs b/src/app/take_sell.rs index 0ffbd88c..aa16fe74 100644 --- a/src/app/take_sell.rs +++ b/src/app/take_sell.rs @@ -195,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, @@ -280,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::*; @@ -330,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