Skip to content
Draft
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
413 changes: 411 additions & 2 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ reqwest = { version = "0.12.1", default-features = false, features = [
"rustls-tls",
] }
mostro-core = { version = "0.12.0", features = ["sqlx"] }
cashu = { version = "0.16", default-features = false }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
async-trait = "0.1.83"
Expand Down
127 changes: 127 additions & 0 deletions src/app/admin_cancel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,129 @@ use nostr_sdk::prelude::*;
use sqlx_crud::Crud;
use tracing::{error, info};

async fn cashu_admin_cancel(
ctx: &AppContext,
order: Order,
msg: &Message,
event: &UnwrappedMessage,
my_keys: &Keys,
request_id: Option<u64>,
) -> Result<(), MostroError> {
let pool = ctx.pool();

let token_str = order
.cashu_escrow_token
.as_deref()
.ok_or(MostroInternalErr(ServiceError::InvalidPayload))?;

let sigs = crate::cashu::sign_with_pm(token_str, ctx.keys().secret_key())
.map_err(|e| MostroInternalErr(ServiceError::UnexpectedError(e.to_string())))?;

let dispute = find_dispute_by_order_id(pool, order.id).await;

let dispute_initiator = match (order.seller_dispute, order.buyer_dispute) {
(true, false) => "seller",
(false, true) => "buyer",
(_, _) => return Err(MostroInternalErr(ServiceError::DisputeEventError)),
};

if let Ok(mut d) = dispute {
let dispute_id = d.id;
d.status = DisputeStatus::SellerRefunded.to_string();
d.update(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;

let tags: Tags = Tags::from_list(vec![
Tag::custom(
TagKind::Custom(std::borrow::Cow::Borrowed("s")),
vec![DisputeStatus::SellerRefunded.to_string()],
),
Tag::custom(
TagKind::Custom(std::borrow::Cow::Borrowed("initiator")),
vec![dispute_initiator],
),
Tag::custom(
TagKind::Custom(std::borrow::Cow::Borrowed("y")),
create_platform_tag_values(ctx.settings().mostro.name.as_deref()),
),
Tag::custom(
TagKind::Custom(std::borrow::Cow::Borrowed("z")),
vec!["dispute".to_string()],
),
]);

let dispute_event = new_dispute_event(my_keys, "", dispute_id.to_string(), tags)
.map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?;

info!("Dispute event to be published: {dispute_event:#?}");

let client = ctx.nostr_client();
if let Err(e) = client.send_event(&dispute_event).await {
error!("Failed to send dispute status event: {}", e);
}
}

let order_updated = update_order_event(my_keys, Status::CanceledByAdmin, &order)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
let order_updated_id = order_updated.id;
order_updated
.update(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
Comment on lines +85 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard Cashu cancel with the dispute status

When admin-cancel races with admin-settle for the same Cashu dispute, this unconditional update() can overwrite a terminal status that another resolver just wrote and then continue to enqueue Mostro's P_M signature for the seller. Since the Cashu settle branch uses WHERE status = Dispute before sending its signature, the cancel branch needs the same compare-and-swap behavior; otherwise a concurrent resolution can leak P_M signatures to both sides and make the disputed ecash redeemable by the wrong party or by whichever client reaches the mint first.

Useful? React with 👍 / 👎.


let (seller_pubkey, buyer_pubkey) = match (&order.seller_pubkey, &order.buyer_pubkey) {
(Some(seller), Some(buyer)) => (
PublicKey::from_str(seller.as_str())
.map_err(|_| MostroInternalErr(ServiceError::InvalidPubkey))?,
PublicKey::from_str(buyer.as_str())
.map_err(|_| MostroInternalErr(ServiceError::InvalidPubkey))?,
),
(None, _) => return Err(MostroInternalErr(ServiceError::InvalidPubkey)),
(_, None) => return Err(MostroInternalErr(ServiceError::InvalidPubkey)),
};

// Deliver P_M signatures to the seller so they can reclaim with seller_sig + pm_sig
enqueue_order_msg(
None,
Some(order_updated_id),
Action::CashuPmSignature,
Some(Payload::CashuSignatures(sigs)),
seller_pubkey,
msg.get_inner_message_kind().trade_index,
)
.await;

// Notify solver, seller, and buyer that the admin has canceled
let cancel_msg = Message::new_order(
Some(order.id),
request_id,
msg.get_inner_message_kind().trade_index,
Action::AdminCanceled,
None,
);
let cancel_json = cancel_msg
.as_json()
.map_err(|_| MostroInternalErr(ServiceError::MessageSerializationError))?;

send_dm(event.sender, my_keys, &cancel_json, None)
.await
.map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?;
send_dm(seller_pubkey, my_keys, &cancel_json, None)
.await
.map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?;
send_dm(buyer_pubkey, my_keys, &cancel_json, None)
.await
.map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?;

// ln_client.cancel_hold_invoice() and bond::apply_bond_resolution() are
// intentionally skipped: the seller redeems ecash directly at the mint
// using pm_sig + seller_sig, and bonds are mutually exclusive with Cashu
// mode (architecture §5).
Ok(())
}

/// Admin-initiated order cancellation.
///
/// Allows authorized dispute solvers or admins to cancel an order and refund
Expand Down Expand Up @@ -115,6 +238,10 @@ pub async fn admin_cancel_action(
let bond_resolution = bond::extract_bond_resolution(&msg);
bond::validate_bond_resolution(pool, &order, &bond_resolution).await?;

if order.cashu_escrow_token.is_some() {
return cashu_admin_cancel(ctx, order, &msg, event, my_keys, request_id).await;
}

if order.hash.is_some() {
// We return funds to seller
if let Some(hash) = order.hash.as_ref() {
Expand Down
131 changes: 131 additions & 0 deletions src/app/admin_settle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,133 @@ use tracing::error;

use super::release::do_payment;

async fn cashu_admin_settle(
ctx: &AppContext,
order: Order,
msg: &Message,
event: &UnwrappedMessage,
my_keys: &Keys,
request_id: Option<u64>,
) -> Result<(), MostroError> {
let pool = ctx.pool();

let token_str = order
.cashu_escrow_token
.as_deref()
.ok_or(MostroInternalErr(ServiceError::InvalidPayload))?;

let sigs = crate::cashu::sign_with_pm(token_str, ctx.keys().secret_key())
.map_err(|e| MostroInternalErr(ServiceError::UnexpectedError(e.to_string())))?;

let order_updated = update_order_event(my_keys, Status::SettledByAdmin, &order)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;

let result =
sqlx::query("UPDATE orders SET status = ?, event_id = ? WHERE id = ? AND status = ?")
.bind(&order_updated.status)
.bind(&order_updated.event_id)
.bind(order_updated.id)
.bind(Status::Dispute.to_string())
.execute(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;

if result.rows_affected() == 0 {
tracing::warn!(
"Order {} not transitioned to settled-by-admin: status changed concurrently",
order_updated.id
);
return Ok(());
}

let dispute = find_dispute_by_order_id(pool, order.id).await;

if let Ok(mut d) = dispute {
let dispute_id = d.id;
d.status = DisputeStatus::Settled.to_string();
d.update(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;

let dispute_initiator = match (order.seller_dispute, order.buyer_dispute) {
(true, false) => "seller",
(false, true) => "buyer",
(_, _) => return Err(MostroInternalErr(ServiceError::DisputeEventError)),
};

let tags: Tags = Tags::from_list(vec![
Tag::custom(
TagKind::Custom(std::borrow::Cow::Borrowed("s")),
vec![DisputeStatus::Settled.to_string()],
),
Tag::custom(
TagKind::Custom(std::borrow::Cow::Borrowed("initiator")),
vec![dispute_initiator],
),
Tag::custom(
TagKind::Custom(std::borrow::Cow::Borrowed("y")),
create_platform_tag_values(ctx.settings().mostro.name.as_deref()),
),
Tag::custom(
TagKind::Custom(std::borrow::Cow::Borrowed("z")),
vec!["dispute".to_string()],
),
]);

let dispute_event = new_dispute_event(my_keys, "", dispute_id.to_string(), tags)
.map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?;

let client = ctx.nostr_client();
if let Err(e) = client.send_event(&dispute_event).await {
error!("Failed to send dispute settlement event: {}", e);
}
}

// Deliver P_M signatures to the buyer so they can redeem with buyer_sig + pm_sig
if let Some(ref buyer_pubkey) = order_updated.buyer_pubkey {
enqueue_order_msg(
None,
Some(order_updated.id),
Action::CashuPmSignature,
Some(Payload::CashuSignatures(sigs)),
PublicKey::from_str(buyer_pubkey)
.map_err(|_| MostroInternalErr(ServiceError::InvalidPubkey))?,
msg.get_inner_message_kind().trade_index,
)
.await;
}

// Notify the solver and the seller that the dispute is settled
enqueue_order_msg(
request_id,
Some(order_updated.id),
Action::AdminSettled,
None,
event.sender,
msg.get_inner_message_kind().trade_index,
)
.await;

if let Some(ref seller_pubkey) = order_updated.seller_pubkey {
enqueue_order_msg(
None,
Some(order_updated.id),
Action::AdminSettled,
None,
PublicKey::from_str(seller_pubkey)
.map_err(|_| MostroInternalErr(ServiceError::InvalidPubkey))?,
msg.get_inner_message_kind().trade_index,
)
.await;
}

// do_payment() and bond::apply_bond_resolution() are intentionally skipped:
// the buyer redeems ecash directly at the mint using pm_sig + buyer_sig,
// and bonds are mutually exclusive with Cashu mode (architecture §5).
Ok(())
}

pub async fn admin_settle_action(
ctx: &AppContext,
msg: Message,
Expand Down Expand Up @@ -91,6 +218,10 @@ pub async fn admin_settle_action(
let bond_resolution = bond::extract_bond_resolution(&msg);
bond::validate_bond_resolution(pool, &order, &bond_resolution).await?;

if order.cashu_escrow_token.is_some() {
return cashu_admin_settle(ctx, order, &msg, event, my_keys, request_id).await;
}

// Settle seller hold invoice
settle_seller_hold_invoice(event, ln_client, Action::AdminSettled, true, &order)
.await
Expand Down
1 change: 1 addition & 0 deletions src/app/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ pub mod test_utils {
expiration: Some(ExpirationSettings::default()),
anti_abuse_bond: None,
price: None,
cashu: None,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/app/dev_fee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,7 @@ mod tests {
expiration: Some(Default::default()),
anti_abuse_bond: None,
price: None,
cashu: None,
});
}

Expand Down
1 change: 1 addition & 0 deletions src/app/rate_user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ mod tests {
expiration: Some(Default::default()),
anti_abuse_bond: None,
price: None,
cashu: None,
});
}

Expand Down
Loading
Loading