Skip to content
Open
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
6 changes: 6 additions & 0 deletions cashu-e2e-keys.json
Original file line number Diff line number Diff line change
@@ -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" }
}
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
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 @@ -561,12 +561,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 @@ -1051,12 +1060,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 @@ -1067,9 +1076,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
20 changes: 19 additions & 1 deletion src/app/take_buy.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
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, show_hold_invoice,
get_order, show_cashu_escrow_request, show_hold_invoice,
};

use crate::db::{seller_has_pending_order, update_user_trade_index};
Expand Down Expand Up @@ -188,6 +189,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
22 changes: 21 additions & 1 deletion src/app/take_sell.rs
Original file line number Diff line number Diff line change
@@ -1,10 +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, set_waiting_invoice_status, show_hold_invoice, update_order_event, validate_invoice,
get_order, set_waiting_invoice_status, show_cashu_escrow_request, show_hold_invoice,
update_order_event, validate_invoice,
};
use mostro_core::db::Crud;
use mostro_core::prelude::*;
Expand Down Expand Up @@ -217,6 +219,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?;
Expand Down
36 changes: 36 additions & 0 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,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