Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
10 changes: 9 additions & 1 deletion docs/cashu/02-track-a-lock.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
client then builds **two** tokens — the 2-of-3 escrow and the 1-of-1 `P_M` fee
token — and submits both in `AddCashuEscrow`.

Expand Down
28 changes: 17 additions & 11 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}
}
Expand Down Expand Up @@ -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 _ =
Expand All @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion src/app/take_buy.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 21 additions & 2 deletions src/app/take_sell.rs
Original file line number Diff line number Diff line change
@@ -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::*;
Expand Down Expand Up @@ -240,6 +241,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(());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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?;
Expand Down
36 changes: 36 additions & 0 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, MostroError> {
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
Expand Down
Loading