use crate::app::bond;
use crate::app::context::AppContext;
use crate::app::dispute::close_dispute_after_user_resolution;
use crate::db::{edit_pubkeys_order, update_order_to_initial_state};
use crate::lightning::LndConnector;
use crate::util::{enqueue_order_msg, get_order, update_order_event};
use mostro_core::prelude::*;
use nostr_sdk::prelude::*;
use sqlx::{Pool, Sqlite};
use sqlx_crud::Crud;
use std::str::FromStr;
use tracing::{info, warn};
pub trait CancelLightning {
fn cancel_hold_invoice<'a>(
&'a mut self,
hash: &'a str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), MostroError>> + Send + 'a>>;
}
impl CancelLightning for LndConnector {
fn cancel_hold_invoice<'a>(
&'a mut self,
hash: &'a str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), MostroError>> + Send + 'a>>
{
Box::pin(async move {
LndConnector::cancel_hold_invoice(self, hash)
.await
.map(|_| ())
})
}
}
fn reset_api_quotes(order: &mut Order) {
if order.price_from_api {
order.amount = 0;
order.fee = 0;
order.dev_fee = 0;
}
}
async fn notify_creator(order: &Order, request_id: Option<u64>) -> Result<(), MostroError> {
let creator_pubkey = order.get_creator_pubkey().map_err(MostroInternalErr)?;
enqueue_order_msg(
request_id,
Some(order.id),
Action::NewOrder,
Some(Payload::Order(SmallOrder::from(order.clone()))),
creator_pubkey,
None,
)
.await;
Ok(())
}
async fn cancel_cooperative_execution_step_2<L: CancelLightning + Send>(
ctx: &AppContext,
event: &UnwrappedMessage,
request_id: Option<u64>,
mut order: Order,
counterparty_pubkey: String,
my_keys: &Keys,
ln_client: &mut L,
) -> Result<(), MostroError> {
let pool = ctx.pool();
if let Some(initiator) = &order.cancel_initiator_pubkey {
if *initiator == event.sender.to_string() {
return Err(MostroCantDo(CantDoReason::InvalidPubkey));
}
}
if let Some(hash) = &order.hash {
ln_client.cancel_hold_invoice(hash).await?;
info!(
"Cooperative cancel: Order Id {}: Funds returned to seller",
&order.id
);
}
order.status = Status::CooperativelyCanceled.to_string();
let order = order
.clone()
.update(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
update_order_event(my_keys, Status::CooperativelyCanceled, &order)
.await
.map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?;
enqueue_order_msg(
request_id,
Some(order.id),
Action::CooperativeCancelAccepted,
None,
event.sender,
None,
)
.await;
let counterparty_pubkey = PublicKey::from_str(&counterparty_pubkey)
.map_err(|_| MostroInternalErr(ServiceError::InvalidPubkey))?;
enqueue_order_msg(
None,
Some(order.id),
Action::CooperativeCancelAccepted,
None,
counterparty_pubkey,
None,
)
.await;
info!("Cancel: Order Id {} canceled cooperatively!", order.id);
close_dispute_after_user_resolution(
ctx,
&order,
DisputeStatus::SellerRefunded,
my_keys,
"cooperative cancel",
)
.await;
bond::release_taker_bonds_for_order_or_warn(pool, order.id, "cooperative_cancel").await;
bond::resolve_range_maker_bond_at_close_or_warn(pool, &order, "cooperative_cancel").await;
Ok(())
}
async fn cancel_cooperative_execution_step_1(
pool: &Pool<Sqlite>,
event: &UnwrappedMessage,
mut order: Order,
counterparty_pubkey: String,
request_id: Option<u64>,
) -> Result<(), MostroError> {
order.cancel_initiator_pubkey = Some(event.sender.to_string());
let order = order
.update(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
enqueue_order_msg(
request_id,
Some(order.id),
Action::CooperativeCancelInitiatedByYou,
None,
event.sender,
None,
)
.await;
let counterparty_pubkey = PublicKey::from_str(&counterparty_pubkey)
.map_err(|_| MostroInternalErr(ServiceError::InvalidPubkey))?;
enqueue_order_msg(
None,
Some(order.id),
Action::CooperativeCancelInitiatedByPeer,
None,
counterparty_pubkey,
None,
)
.await;
Ok(())
}
async fn cancel_order_by_taker<L: CancelLightning + Send>(
pool: &Pool<Sqlite>,
event: &UnwrappedMessage,
order: Order,
my_keys: &Keys,
request_id: Option<u64>,
ln_client: &mut L,
taker_pubkey: PublicKey,
) -> Result<(), MostroError> {
let order_id = order.id;
let sender_str = event.sender.to_string();
let sender_bond =
crate::app::bond::db::find_active_bond_by_taker(pool, order_id, &sender_str).await?;
if let Some(bond) = sender_bond.as_ref() {
if let Err(e) = bond::release_bond(pool, bond).await {
warn!(
bond_id = %bond.id,
"taker_cancel: failed to release sender's bond: {}", e
);
}
}
let remaining = crate::app::bond::db::find_active_bonds_for_order(pool, order_id).await?;
let others_remain = remaining
.iter()
.any(|b| b.pubkey != sender_str && b.role == crate::app::bond::BondRole::Taker.to_string());
if others_remain {
enqueue_order_msg(
request_id,
Some(order_id),
Action::Canceled,
None,
event.sender,
None,
)
.await;
return Ok(());
}
cancel_order_by_taker_inner(
pool,
event,
order,
my_keys,
request_id,
ln_client,
taker_pubkey,
)
.await
}
async fn cancel_order_by_taker_inner<L: CancelLightning + Send>(
pool: &Pool<Sqlite>,
event: &UnwrappedMessage,
mut order: Order,
my_keys: &Keys,
request_id: Option<u64>,
ln_client: &mut L,
taker_pubkey: PublicKey,
) -> Result<(), MostroError> {
if let Some(hash) = &order.hash {
ln_client.cancel_hold_invoice(hash).await?;
info!("Order Id {}: Funds returned to seller", &order.id);
}
enqueue_order_msg(
request_id,
Some(order.id),
Action::Canceled,
None,
event.sender,
None,
)
.await;
reset_api_quotes(&mut order);
update_order_to_initial_state(pool, order.id, order.amount, order.fee, order.dev_fee)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
let order = edit_pubkeys_order(pool, &order)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
let order_updated = update_order_event(my_keys, Status::Pending, &order)
.await
.map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?;
info!(
"{}: Canceled order Id {} republishing order",
taker_pubkey, order.id
);
notify_creator(&order_updated, request_id).await?;
Ok(())
}
async fn cancel_order_by_maker<L: CancelLightning + Send>(
pool: &Pool<Sqlite>,
event: &UnwrappedMessage,
order: Order,
taker_pubkey: PublicKey,
my_keys: &Keys,
request_id: Option<u64>,
ln_client: &mut L,
) -> Result<(), MostroError> {
if let Ok(order_updated) = update_order_event(my_keys, Status::Canceled, &order).await {
order_updated
.update(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
}
if let Some(hash) = &order.hash {
ln_client.cancel_hold_invoice(hash).await?;
info!("Order Id {}: Funds returned to seller", &order.id);
}
enqueue_order_msg(
request_id,
Some(order.id),
Action::Canceled,
None,
event.sender,
None,
)
.await;
enqueue_order_msg(
None,
Some(order.id),
Action::Canceled,
None,
taker_pubkey,
None,
)
.await;
bond::release_taker_bonds_for_order_or_warn(pool, order.id, "maker_cancel").await;
bond::resolve_range_maker_bond_at_close_or_warn(pool, &order, "maker_cancel").await;
Ok(())
}
async fn cancel_pending_order_from_maker(
pool: &Pool<Sqlite>,
event: &UnwrappedMessage,
order: &mut Order,
my_keys: &Keys,
request_id: Option<u64>,
) -> Result<(), MostroError> {
order
.sent_from_maker(event.sender)
.map_err(|_| MostroCantDo(CantDoReason::IsNotYourOrder))?;
match update_order_event(my_keys, Status::Canceled, order).await {
Ok(order_updated) => {
order_updated
.update(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
}
Err(e) => {
return Err(MostroInternalErr(ServiceError::DbAccessError(
e.to_string(),
)));
}
}
enqueue_order_msg(
request_id,
Some(order.id),
Action::Canceled,
None,
event.sender,
None,
)
.await;
match crate::app::bond::db::find_active_bonds_for_order(pool, order.id).await {
Ok(active_bonds) => {
for active in active_bonds.iter() {
if let Ok(taker_pk) = PublicKey::from_str(&active.pubkey) {
if taker_pk != event.sender {
enqueue_order_msg(
None,
Some(order.id),
Action::Canceled,
None,
taker_pk,
None,
)
.await;
}
}
}
}
Err(err) => {
warn!(
order_id = %order.id,
"pending_maker_cancel: failed to look up active bonds for taker notification: {}",
err
);
}
}
bond::release_taker_bonds_for_order_or_warn(pool, order.id, "pending_maker_cancel").await;
bond::resolve_range_maker_bond_at_close_or_warn(pool, order, "pending_maker_cancel").await;
Ok(())
}
pub async fn cancel_action(
ctx: &AppContext,
msg: Message,
event: &UnwrappedMessage,
my_keys: &Keys,
ln_client: &mut LndConnector,
) -> Result<(), MostroError> {
cancel_action_generic(ctx, msg, event, my_keys, ln_client).await
}
async fn cancel_action_generic<L: CancelLightning + Send>(
ctx: &AppContext,
msg: Message,
event: &UnwrappedMessage,
my_keys: &Keys,
ln_client: &mut L,
) -> Result<(), MostroError> {
let pool = ctx.pool();
let request_id = msg.get_inner_message_kind().request_id;
let mut order = get_order(&msg, pool).await?;
if order.check_status(Status::Canceled).is_ok()
|| order.check_status(Status::CooperativelyCanceled).is_ok()
|| order.check_status(Status::CanceledByAdmin).is_ok()
{
return Err(MostroCantDo(CantDoReason::OrderAlreadyCanceled));
}
if order.check_status(Status::Pending).is_ok()
|| order.check_status(Status::WaitingTakerBond).is_ok()
{
if order.sent_from_maker(event.sender).is_ok() {
cancel_pending_order_from_maker(pool, event, &mut order, my_keys, request_id).await?;
return Ok(());
}
let sender_str = event.sender.to_string();
let bond_match =
match crate::app::bond::db::find_active_bonds_for_order(pool, order.id).await {
Ok(active_bonds) => active_bonds.iter().any(|b| b.pubkey == sender_str),
Err(e) => {
warn!(
order_id = %order.id,
"cancel: bond lookup failed for pending taker self-cancel: {}", e
);
false
}
};
let order_taker_match = order
.buyer_pubkey
.as_deref()
.is_some_and(|p| p == sender_str && p != order.creator_pubkey)
|| order
.seller_pubkey
.as_deref()
.is_some_and(|p| p == sender_str && p != order.creator_pubkey);
if bond_match || order_taker_match {
cancel_order_by_taker(
pool,
event,
order,
my_keys,
request_id,
ln_client,
event.sender,
)
.await?;
return Ok(());
}
return Err(MostroCantDo(CantDoReason::IsNotYourOrder));
}
match order.get_order_status().map_err(MostroInternalErr)? {
Status::WaitingPayment | Status::WaitingBuyerInvoice => {
cancel_not_active_order(pool, event, order, my_keys, request_id, ln_client).await?
}
Status::Active | Status::FiatSent | Status::Dispute => {
cancel_active_order(ctx, event, order, my_keys, request_id, ln_client).await?
}
_ => return Err(MostroCantDo(CantDoReason::NotAllowedByStatus)),
}
Ok(())
}
async fn cancel_active_order<L: CancelLightning + Send>(
ctx: &AppContext,
event: &UnwrappedMessage,
mut order: Order,
my_keys: &Keys,
request_id: Option<u64>,
ln_client: &mut L,
) -> Result<(), MostroError> {
let pool = ctx.pool();
let seller_pubkey = order.get_seller_pubkey().map_err(MostroInternalErr)?;
let buyer_pubkey = order.get_buyer_pubkey().map_err(MostroInternalErr)?;
let counterparty_pubkey: String;
if buyer_pubkey == event.sender {
order.buyer_cooperativecancel = true;
counterparty_pubkey = seller_pubkey.to_string();
} else {
order.seller_cooperativecancel = true;
counterparty_pubkey = buyer_pubkey.to_string();
}
match order.cancel_initiator_pubkey {
Some(_) => {
cancel_cooperative_execution_step_2(
ctx,
event,
request_id,
order,
counterparty_pubkey,
my_keys,
ln_client,
)
.await?;
}
None => {
cancel_cooperative_execution_step_1(
pool,
event,
order,
counterparty_pubkey,
request_id,
)
.await?;
}
}
Ok(())
}
async fn cancel_not_active_order<L: CancelLightning + Send>(
pool: &Pool<Sqlite>,
event: &UnwrappedMessage,
order: Order,
my_keys: &Keys,
request_id: Option<u64>,
ln_client: &mut L,
) -> Result<(), MostroError> {
let seller_pubkey = order.get_seller_pubkey().map_err(MostroInternalErr)?;
let buyer_pubkey = order.get_buyer_pubkey().map_err(MostroInternalErr)?;
let taker_pubkey = if order.creator_pubkey == seller_pubkey.to_string() {
buyer_pubkey
} else if order.creator_pubkey == buyer_pubkey.to_string() {
seller_pubkey
} else {
return Err(MostroInternalErr(ServiceError::InvalidPubkey));
};
if order.sent_from_maker(event.sender).is_ok() {
cancel_order_by_maker(
pool,
event,
order,
taker_pubkey,
my_keys,
request_id,
ln_client,
)
.await?;
} else if event.sender == taker_pubkey {
cancel_order_by_taker(
pool,
event,
order,
my_keys,
request_id,
ln_client,
taker_pubkey,
)
.await?;
} else {
return Err(MostroCantDo(CantDoReason::InvalidPubkey));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::context::test_utils::{test_settings, TestContextBuilder};
use nostr_sdk::{Keys, Timestamp};
use sqlx::SqlitePool;
use sqlx_crud::Crud;
use std::sync::Arc;
fn create_unwrapped_message_with_pubkey(pubkey: PublicKey) -> UnwrappedMessage {
UnwrappedMessage {
message: Message::Order(MessageKind::new(
Some(uuid::Uuid::new_v4()),
Some(1),
None,
Action::Cancel,
None,
)),
signature: None,
sender: pubkey,
identity: Keys::generate().public_key(),
created_at: Timestamp::now(),
}
}
fn create_pending_order(maker_pubkey: PublicKey, taker_pubkey: PublicKey) -> Order {
Order {
id: uuid::Uuid::new_v4(),
status: Status::Pending.to_string(),
kind: mostro_core::order::Kind::Sell.to_string(),
fiat_code: "USD".to_string(),
creator_pubkey: maker_pubkey.to_string(),
seller_pubkey: Some(maker_pubkey.to_string()),
buyer_pubkey: Some(taker_pubkey.to_string()),
amount: 21_000,
fee: 21,
dev_fee: 1,
..Default::default()
}
}
#[test]
fn reset_api_quotes_resets_amount_fee_and_dev_fee_only_when_api_priced() {
let maker = Keys::generate().public_key();
let taker = Keys::generate().public_key();
let mut api_order = create_pending_order(maker, taker);
api_order.price_from_api = true;
reset_api_quotes(&mut api_order);
assert_eq!(api_order.amount, 0);
assert_eq!(api_order.fee, 0);
assert_eq!(api_order.dev_fee, 0);
let mut fixed_price_order = create_pending_order(maker, taker);
fixed_price_order.price_from_api = false;
let original = (
fixed_price_order.amount,
fixed_price_order.fee,
fixed_price_order.dev_fee,
);
reset_api_quotes(&mut fixed_price_order);
assert_eq!(
(
fixed_price_order.amount,
fixed_price_order.fee,
fixed_price_order.dev_fee
),
original
);
}
struct StubLnClient;
impl CancelLightning for StubLnClient {
fn cancel_hold_invoice<'a>(
&'a mut self,
_hash: &'a str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), MostroError>> + Send + 'a>>
{
Box::pin(async move { Ok(()) })
}
}
#[tokio::test]
async fn cancel_action_with_ctx_rejects_non_creator_for_pending_order() {
let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap());
sqlx::migrate!("./migrations")
.run(pool.as_ref())
.await
.unwrap();
let ctx = TestContextBuilder::new()
.with_pool(pool)
.with_settings(test_settings())
.build();
let maker = Keys::generate().public_key();
let taker = Keys::generate().public_key();
let order = create_pending_order(maker, taker)
.create(ctx.pool())
.await
.unwrap();
let intruder = Keys::generate().public_key();
let event = create_unwrapped_message_with_pubkey(intruder);
let msg = Message::new_order(Some(order.id), Some(1), None, Action::Cancel, None);
let my_keys = Keys::generate();
let mut ln_client = StubLnClient;
let result = cancel_action_generic(&ctx, msg, &event, &my_keys, &mut ln_client).await;
assert!(matches!(
result,
Err(MostroCantDo(CantDoReason::IsNotYourOrder))
));
}
#[tokio::test]
async fn pending_taker_with_active_bond_is_not_routed_as_intruder() {
use crate::app::bond::db::find_active_bonds_for_order;
let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap());
sqlx::migrate!("./migrations")
.run(pool.as_ref())
.await
.unwrap();
let maker = Keys::generate().public_key();
let taker = Keys::generate().public_key();
let order = create_pending_order(maker, taker)
.create(pool.as_ref())
.await
.unwrap();
let mut bond = crate::app::bond::Bond::new_requested(
order.id,
taker.to_string(),
crate::app::bond::BondRole::Taker,
1_500,
);
bond.hash = None;
bond.create(pool.as_ref()).await.unwrap();
let active = find_active_bonds_for_order(pool.as_ref(), order.id)
.await
.unwrap();
let sender_str = taker.to_string();
assert!(
active.iter().any(|b| b.pubkey == sender_str),
"the taker must be recognised as a bonded sender"
);
let intruder = Keys::generate().public_key();
let intruder_str = intruder.to_string();
assert!(
!active.iter().any(|b| b.pubkey == intruder_str),
"an intruder with no bond row must not be routed to the taker-cancel path"
);
}
}