diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index e7e7629..ab7eb22 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -1142,14 +1142,17 @@ pub async fn take_order( // receive status changes (pending → in-progress → waiting-payment …). subscribe_single_order(&order_id).await; // Create a session so the chat API can look up keys immediately. - let _ = crate::mostro::session::session_manager() - .create_session( + if let Err(e) = crate::mostro::session::session_manager() + .install_session( order_id.clone(), trade.role.clone(), trade_index, trade.order.clone(), ) - .await; + .await + { + log::error!("[orders] no session for accepted take on order={order_id}: {e}"); + } Ok(trade) } @@ -1830,7 +1833,20 @@ async fn dispatch_mostro_message( log::info!("[orders] gift-wrap Canceled for trade={trade_pubkey_hex}"); if let Some(order_id) = &kind.id { let oid = order_id.to_string(); + if !is_current_generation(&oid, trade_index).await { + return; + } order_book().remove_order(&oid).await; + // Read before the sync below overwrites it with Canceled. + let prev_status = match crate::db::app_db::db() { + Some(db) => db + .get_trade_by_order_id(&oid) + .await + .ok() + .flatten() + .map(|t| t.order.status), + None => None, + }; // Sync the Canceled status into the trade DB so My Trades // reflects the cancellation immediately. if let Some(db) = crate::db::app_db::db() { @@ -1846,6 +1862,7 @@ async fn dispatch_mostro_message( log::warn!("[orders] failed to sync Canceled status for {oid}: {e}"); } } + apply_cancel_cleanup(&oid, prev_status.as_ref(), trade_index).await; } } // Seller receives BuyerTookOrder → peer is buyer_trade_pubkey. @@ -2116,6 +2133,9 @@ async fn dispatch_mostro_message( return; } }; + if !is_current_generation(&order_id, trade_index).await { + return; + } let small_order = match &kind.payload { Some(mostro_core::message::Payload::Order(so)) => so, _ => { @@ -2151,6 +2171,9 @@ async fn dispatch_mostro_message( log::info!( "[orders] gift-wrap BondSlashed: order={order_id} amount={amount_sats} cause={cause:?}" ); + crate::mostro::session::session_manager() + .resolve_deferred_removal(&order_id, trade_index) + .await; crate::api::bond::emit_bond_slashed(crate::api::types::BondSlashedEvent { event_id: event_id.to_string(), order_id, @@ -2167,6 +2190,46 @@ async fn dispatch_mostro_message( } } +/// Rejects a delivery addressed to a trade key the order has already moved on +/// from. A retake reuses the order id under a new trade key, so a delayed +/// `Canceled`/`BondSlashed` from the previous take would otherwise cancel the +/// order book entry, the persisted trade, and the session of the fresh one. +async fn is_current_generation(order_id: &str, trade_index: u32) -> bool { + let current = crate::mostro::session::session_manager() + .is_current_generation(order_id, trade_index) + .await; + if !current { + log::info!( + "[orders] gift-wrap for superseded trade key idx={trade_index} on order={order_id}, ignoring" + ); + } + current +} + +/// A cancel that may be followed by a `bond-slashed` keeps its session alive +/// for the grace period, so the trailing notice still decrypts. +async fn apply_cancel_cleanup( + order_id: &str, + prev_status: Option<&OrderStatus>, + trade_index: u32, +) { + use crate::mostro::session::{ + cancel_cleanup, defer_session_removal, session_manager, CancelCleanup, + BOND_SLASH_GRACE_SECS, + }; + match cancel_cleanup(prev_status) { + CancelCleanup::Keep => {} + CancelCleanup::Immediate => { + session_manager() + .remove_session_if_current(order_id, trade_index) + .await + } + CancelCleanup::Defer => { + defer_session_removal(order_id.to_string(), trade_index, BOND_SLASH_GRACE_SECS).await; + } + } +} + /// Maps a `mostro_core::order::Status` to the local [`OrderStatus`] enum. /// Map a daemon action to the order status it implies, for messages that /// carry no explicit status payload (action-only progression replies). @@ -3729,6 +3792,89 @@ mod tests { assert!(session.shared_key.is_none()); } + // ── Cancel cleanup ──────────────────────────────────────────────────────── + + /// Trade key indices for the take that gets canceled and a later retake. + const TAKE: u32 = 0; + const RETAKE: u32 = 7; + + async fn session_for_cleanup() -> String { + let order_id = uuid::Uuid::new_v4().to_string(); + session_manager() + .create_session( + order_id.clone(), + TradeRole::Buyer, + TAKE, + dummy_order_info(&order_id), + ) + .await + .expect("create_session"); + order_id + } + + #[tokio::test] + async fn a_committed_cancel_keeps_the_session_for_the_slash_notice() { + let order_id = session_for_cleanup().await; + + apply_cancel_cleanup(&order_id, Some(&OrderStatus::WaitingBuyerInvoice), TAKE).await; + + assert!(session_manager().get_session(&order_id).await.is_some()); + } + + #[tokio::test] + async fn a_pending_cancel_drops_the_session_at_once() { + let order_id = session_for_cleanup().await; + + apply_cancel_cleanup(&order_id, Some(&OrderStatus::Pending), TAKE).await; + + assert!(session_manager().get_session(&order_id).await.is_none()); + } + + #[tokio::test] + async fn a_disputed_cancel_keeps_the_session_for_the_admin_chat() { + let order_id = session_for_cleanup().await; + + apply_cancel_cleanup(&order_id, Some(&OrderStatus::Dispute), TAKE).await; + + assert!(session_manager().get_session(&order_id).await.is_some()); + } + + #[tokio::test] + async fn a_trailing_bond_slashed_settles_the_deferred_session() { + let order_id = session_for_cleanup().await; + + apply_cancel_cleanup(&order_id, Some(&OrderStatus::WaitingPayment), TAKE).await; + assert!(session_manager().get_session(&order_id).await.is_some()); + + assert!( + session_manager() + .resolve_deferred_removal(&order_id, TAKE) + .await, + "the cancel must have left a deferred removal for the notice to settle" + ); + assert!(session_manager().get_session(&order_id).await.is_none()); + } + + /// The dispatcher gate: a delivery to a superseded trade key must not reach + /// the order book, the persisted trade, or the session. + #[tokio::test] + async fn a_delivery_to_a_superseded_trade_key_is_rejected() { + let order_id = session_for_cleanup().await; + + assert!(is_current_generation(&order_id, TAKE).await); + assert!(!is_current_generation(&order_id, RETAKE).await); + } + + /// A cancel that survived the gate still cannot claim another generation. + #[tokio::test] + async fn a_cancel_cleanup_spares_a_session_of_another_generation() { + let order_id = session_for_cleanup().await; + + apply_cancel_cleanup(&order_id, Some(&OrderStatus::Pending), RETAKE).await; + + assert!(session_manager().get_session(&order_id).await.is_some()); + } + // ── Peer-pubkey resolution ──────────────────────────────────────────────── /// on_peer_pubkey_received with no session for the order is a graceful no-op. @@ -3791,6 +3937,154 @@ mod tests { // Clean up the leftover non-restore record so we don't leak global state. let _ = remove_pending_request(&other_key, 9); } + + // ── Dispatcher: canceled → bond-slashed ─────────────────────────────────── + + /// A daemon message as `dispatch_mostro_message` receives it, already + /// unwrapped. Authored by the active Mostro pubkey so it clears the + /// daemon-auth gate. + fn daemon_message( + action: mostro_core::message::Action, + order_id: uuid::Uuid, + payload: Option, + ) -> mostro_core::nip59::UnwrappedMessage { + let sender = nostr_sdk::PublicKey::from_hex(&crate::config::active_mostro_pubkey()) + .expect("active mostro pubkey"); + mostro_core::nip59::UnwrappedMessage { + message: mostro_core::message::Message::new_order( + Some(order_id), + None, + None, + action, + payload, + ), + signature: None, + sender, + identity: sender, + created_at: nostr_sdk::Timestamp::now(), + } + } + + /// The `bond-slashed` payload: a bond-sized amount and a null status. + fn slashed_bond_payload( + order_id: uuid::Uuid, + amount_sats: i64, + ) -> mostro_core::message::Payload { + mostro_core::message::Payload::Order(mostro_core::order::SmallOrder { + id: Some(order_id), + kind: None, + status: None, + amount: amount_sats, + fiat_code: "USD".to_string(), + min_amount: None, + max_amount: None, + fiat_amount: 100, + payment_method: "Bank".to_string(), + premium: 0, + buyer_trade_pubkey: None, + seller_trade_pubkey: None, + buyer_invoice: None, + created_at: None, + expires_at: None, + }) + } + + /// The sequence #197 is about, driven through the real dispatcher: the + /// session survives `canceled` so the trailing `bond-slashed` is still + /// handled, and the notice reaches the Dart-facing stream. + #[tokio::test] + async fn a_canceled_then_bond_slashed_sequence_is_handled_end_to_end() { + let order_id = uuid::Uuid::new_v4(); + let oid = order_id.to_string(); + let trade_pubkey = nostr_sdk::Keys::generate().public_key().to_hex(); + session_manager() + .create_session(oid.clone(), TradeRole::Buyer, TAKE, dummy_order_info(&oid)) + .await + .expect("create_session"); + + let mut notices = crate::api::bond::on_bond_slashed(); + + dispatch_mostro_message( + daemon_message(mostro_core::message::Action::Canceled, order_id, None), + "cancel-event", + &trade_pubkey, + TAKE, + ) + .await; + assert!( + session_manager().get_session(&oid).await.is_some(), + "the cancel must not drop the session while a slash may still trail it" + ); + + dispatch_mostro_message( + daemon_message( + mostro_core::message::Action::BondSlashed, + order_id, + Some(slashed_bond_payload(order_id, 21_000)), + ), + "slash-event", + &trade_pubkey, + TAKE, + ) + .await; + + let notice = await_bond_slashed(&mut notices, &oid).await; + assert_eq!(notice.amount_sats, 21_000); + assert!( + session_manager().get_session(&oid).await.is_none(), + "the notice settles the deferral" + ); + } + + /// A `canceled` delivered to the previous take's trade key must not reach + /// the order book entry or the session of the retake that replaced it. + #[tokio::test] + async fn a_canceled_from_a_superseded_trade_key_leaves_the_retake_alone() { + let order_id = uuid::Uuid::new_v4(); + let oid = order_id.to_string(); + let trade_pubkey = nostr_sdk::Keys::generate().public_key().to_hex(); + session_manager() + .create_session(oid.clone(), TradeRole::Seller, RETAKE, dummy_order_info(&oid)) + .await + .expect("create_session"); + order_book().upsert_order(dummy_order_info(&oid)).await; + + dispatch_mostro_message( + daemon_message(mostro_core::message::Action::Canceled, order_id, None), + "cancel-event", + &trade_pubkey, + TAKE, + ) + .await; + + assert!( + order_book().get_order(&oid).await.is_some(), + "an old key's cancel must not remove the retake's order book entry" + ); + assert!( + session_manager().get_session(&oid).await.is_some(), + "nor arm a deferral against its session" + ); + } + + /// Reads from the shared broadcast until the notice for `order_id` shows up, + /// so a concurrent test's notice cannot be mistaken for this one. + async fn await_bond_slashed( + stream: &mut crate::api::bond::BondSlashedStream, + order_id: &str, + ) -> crate::api::types::BondSlashedEvent { + let deadline = std::time::Duration::from_secs(5); + tokio::time::timeout(deadline, async { + loop { + let event = stream.next().await.expect("bond-slashed stream"); + if event.order_id == order_id { + return event; + } + } + }) + .await + .expect("bond-slashed notice never arrived") + } } #[cfg(test)] diff --git a/rust/src/mostro/session.rs b/rust/src/mostro/session.rs index 4c31e1a..fcd3831 100644 --- a/rust/src/mostro/session.rs +++ b/rust/src/mostro/session.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; -use crate::api::types::{OrderInfo, TradeRole}; +use crate::api::types::{OrderInfo, OrderStatus, TradeRole}; /// Per-trade session state. #[derive(Clone)] @@ -44,9 +44,59 @@ impl std::fmt::Debug for Session { } } +// ── Cancel cleanup policy ─────────────────────────────────────────────────── + +/// How long a session outlives a cancel a `bond-slashed` may still trail. +/// +/// Unlike v1, this is not what makes the notice arrive: the per-trade receiver +/// captures its own trade keys and `ensure_global_dm_coverage` retains them for +/// the life of the process, so neither reception nor decryption depends on the +/// session. It is a margin for handling that needs session state. +pub const BOND_SLASH_GRACE_SECS: i64 = 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CancelCleanup { + Immediate, + Defer, + /// Dispute and admin states still need the session's keys for the admin chat. + Keep, +} + +/// Decides a session's fate from the order status recorded *before* the cancel +/// was applied. +pub fn cancel_cleanup(status: Option<&OrderStatus>) -> CancelCleanup { + match status { + Some( + OrderStatus::Dispute + | OrderStatus::CanceledByAdmin + | OrderStatus::SettledByAdmin + | OrderStatus::CompletedByAdmin, + ) => CancelCleanup::Keep, + Some(OrderStatus::Pending) => CancelCleanup::Immediate, + _ => CancelCleanup::Defer, + } +} + +/// A pending removal, bound to the session that earned it. An order id is +/// reused across retakes, so the trade key index is what tells the canceled +/// take apart from the one that replaced it. +#[derive(Clone, Copy)] +struct DeferredRemoval { + deadline: i64, + trade_key_index: u32, +} + +/// Sessions and their pending removals share one lock: a retake racing the +/// grace deadline must never observe one map mid-update against the other. +#[derive(Default)] +struct SessionState { + sessions: HashMap, + deferred_removals: HashMap, +} + /// In-memory session store. pub struct SessionManager { - sessions: Arc>>, + state: Arc>, } impl Default for SessionManager { @@ -56,15 +106,12 @@ impl Default for SessionManager { impl SessionManager { pub fn new() -> Self { Self { - sessions: Arc::new(RwLock::new(HashMap::new())), + state: Arc::new(RwLock::new(SessionState::default())), } } - /// Create a new session for a trade. Returns an error if a session - /// already exists for this order (indicates duplicate processing). - pub async fn create_session( - &self, - order_id: String, + fn build_session( + order_id: &str, role: TradeRole, trade_key_index: u32, order: OrderInfo, @@ -76,25 +123,65 @@ impl SessionManager { order.id )); } - - let now = crate::rt::unix_now(); - - let session = Session { - order_id: order_id.clone(), + Ok(Session { + order_id: order_id.to_string(), role, trade_key_index, shared_key: None, admin_shared_key: None, peer_pubkey: None, order, - created_at: now, - }; + created_at: crate::rt::unix_now(), + }) + } - let mut sessions = self.sessions.write().await; - if sessions.contains_key(&order_id) { + /// Create a new session for a trade. Returns an error if a session + /// already exists for this order (indicates duplicate processing). + pub async fn create_session( + &self, + order_id: String, + role: TradeRole, + trade_key_index: u32, + order: OrderInfo, + ) -> Result { + let session = Self::build_session(&order_id, role, trade_key_index, order)?; + + let mut state = self.state.write().await; + // A session awaiting deferred removal belongs to the canceled take; + // this one supersedes it, deadline included. + if state.deferred_removals.remove(&order_id).is_some() { + state.sessions.remove(&order_id); + } + if state.sessions.contains_key(&order_id) { return Err(anyhow!("SessionAlreadyExists: {}", order_id)); } - sessions.insert(order_id, session.clone()); + state.sessions.insert(order_id, session.clone()); + Ok(session) + } + + /// Install the session for a take the daemon has already confirmed. + /// + /// Unlike [`Self::create_session`] this never yields to what it finds: the + /// take is accepted, so anything left under this order id belongs to an + /// earlier one and would otherwise leave the new trade session-less. + pub async fn install_session( + &self, + order_id: String, + role: TradeRole, + trade_key_index: u32, + order: OrderInfo, + ) -> Result { + let session = Self::build_session(&order_id, role, trade_key_index, order)?; + + let mut state = self.state.write().await; + state.deferred_removals.remove(&order_id); + if let Some(previous) = state.sessions.insert(order_id.clone(), session.clone()) { + log::warn!( + "[session] order={order_id}: replaced a stale session (trade key idx {} -> {})", + previous.trade_key_index, + trade_key_index + ); + } Ok(session) } @@ -107,22 +194,109 @@ impl SessionManager { session.order_id )); } - let mut sessions = self.sessions.write().await; - if !sessions.contains_key(order_id) { + let mut state = self.state.write().await; + if !state.sessions.contains_key(order_id) { return Err(anyhow!("SessionNotFound")); } - sessions.insert(order_id.to_string(), session); + state.sessions.insert(order_id.to_string(), session); Ok(()) } /// Get a session by order ID. pub async fn get_session(&self, order_id: &str) -> Option { - self.sessions.read().await.get(order_id).cloned() + self.state.read().await.sessions.get(order_id).cloned() } /// Remove a session (on completion, cancellation, or timeout). pub async fn remove_session(&self, order_id: &str) { - self.sessions.write().await.remove(order_id); + let mut state = self.state.write().await; + state.deferred_removals.remove(order_id); + state.sessions.remove(order_id); + } + + /// Whether `trade_key_index` still names the live session for this order. + /// + /// An order id outlives the take that used it: after a retake, a delivery + /// addressed to the previous trade key must not act on the fresh session. + /// An order with no session — a maker canceling their own listing — has no + /// generation to contradict. + pub async fn is_current_generation(&self, order_id: &str, trade_key_index: u32) -> bool { + match self.state.read().await.sessions.get(order_id) { + Some(session) => session.trade_key_index == trade_key_index, + None => true, + } + } + + /// Remove the session only while `trade_key_index` still names it. + pub async fn remove_session_if_current(&self, order_id: &str, trade_key_index: u32) { + let mut state = self.state.write().await; + if state + .sessions + .get(order_id) + .is_some_and(|s| s.trade_key_index == trade_key_index) + { + state.deferred_removals.remove(order_id); + state.sessions.remove(order_id); + } + } + + /// Defer this session's removal until `delay_secs` from now. + pub async fn defer_removal(&self, order_id: &str, trade_key_index: u32, delay_secs: i64) { + let deadline = crate::rt::unix_now() + delay_secs; + self.state.write().await.deferred_removals.insert( + order_id.to_string(), + DeferredRemoval { + deadline, + trade_key_index, + }, + ); + } + + /// Settle a deferred removal early. Reports whether one was pending for + /// this generation; anything else is left untouched. The session is only + /// dropped while it is still the one the deferral was armed against — a + /// retake in between keeps its own. + pub async fn resolve_deferred_removal(&self, order_id: &str, trade_key_index: u32) -> bool { + let mut state = self.state.write().await; + let matches = state + .deferred_removals + .get(order_id) + .is_some_and(|d| d.trade_key_index == trade_key_index); + if !matches { + return false; + } + state.deferred_removals.remove(order_id); + if state + .sessions + .get(order_id) + .is_some_and(|s| s.trade_key_index == trade_key_index) + { + state.sessions.remove(order_id); + } + true + } + + /// Drop every session whose deferred deadline has elapsed. A deadline whose + /// session has since been replaced is dropped without touching the new one. + pub async fn reconcile_deferred_removals(&self) { + let now = crate::rt::unix_now(); + let mut state = self.state.write().await; + let due: Vec<(String, u32)> = state + .deferred_removals + .iter() + .filter(|(_, d)| now >= d.deadline) + .map(|(order_id, d)| (order_id.clone(), d.trade_key_index)) + .collect(); + for (order_id, trade_key_index) in due { + state.deferred_removals.remove(&order_id); + if state + .sessions + .get(&order_id) + .is_some_and(|s| s.trade_key_index == trade_key_index) + { + state.sessions.remove(&order_id); + } + } } /// Store the ECDH admin shared key derived from `adminTookDispute`. @@ -135,8 +309,9 @@ impl SessionManager { order_id: &str, key: [u8; 32], ) -> Result<()> { - let mut sessions = self.sessions.write().await; - let session = sessions + let mut state = self.state.write().await; + let session = state + .sessions .get_mut(order_id) .ok_or_else(|| anyhow!("SessionNotFound: {order_id}"))?; session.admin_shared_key = Some(key); @@ -148,8 +323,8 @@ impl SessionManager { pub async fn cleanup_stale_sessions(&self, timeout_secs: i64) { let now = crate::rt::unix_now(); - let mut sessions = self.sessions.write().await; - sessions.retain(|_, s| { + let mut state = self.state.write().await; + state.sessions.retain(|_, s| { s.shared_key.is_some() || (now - s.created_at) < timeout_secs }); } @@ -165,3 +340,302 @@ static SESSION_MGR: OnceLock = OnceLock::new(); pub fn session_manager() -> &'static SessionManager { SESSION_MGR.get_or_init(SessionManager::new) } + +/// Register a deferred removal and arm the timer that enforces it. +/// +/// Registration is awaited so a `bond-slashed` arriving right after the cancel +/// always finds the deferral armed; only the deadline runs in the background. +pub async fn defer_session_removal(order_id: String, trade_key_index: u32, delay_secs: i64) { + session_manager() + .defer_removal(&order_id, trade_key_index, delay_secs) + .await; + crate::rt::spawn(async move { + crate::rt::time::sleep(crate::rt::time::Duration::from_secs( + delay_secs.max(0) as u64 + )) + .await; + session_manager().reconcile_deferred_removals().await; + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::types::OrderKind; + + fn dummy_order_info(id: &str) -> OrderInfo { + OrderInfo { + id: id.to_string(), + kind: OrderKind::Buy, + status: OrderStatus::Pending, + fiat_code: "USD".to_string(), + fiat_amount: Some(100.0), + fiat_amount_min: None, + fiat_amount_max: None, + payment_method: "Bank".to_string(), + premium: 0.0, + is_mine: false, + created_at: 0, + expires_at: None, + amount_sats: None, + creator_pubkey: String::new(), + rating: 0.0, + total_reviews: 0, + days_active: 0, + } + } + + /// Trade key indices standing in for the take that got canceled and the + /// retake that reused its order id. + const TAKE: u32 = 0; + const RETAKE: u32 = 7; + + async fn manager_with_session(order_id: &str) -> SessionManager { + let mgr = SessionManager::new(); + mgr.create_session( + order_id.to_string(), + TradeRole::Buyer, + TAKE, + dummy_order_info(order_id), + ) + .await + .expect("create_session"); + mgr + } + + #[test] + fn dispute_and_admin_states_keep_the_session() { + for status in [ + OrderStatus::Dispute, + OrderStatus::CanceledByAdmin, + OrderStatus::SettledByAdmin, + OrderStatus::CompletedByAdmin, + ] { + assert_eq!(cancel_cleanup(Some(&status)), CancelCleanup::Keep); + } + } + + #[test] + fn a_pending_cancel_returns_the_bond_and_drops_the_session() { + assert_eq!( + cancel_cleanup(Some(&OrderStatus::Pending)), + CancelCleanup::Immediate + ); + } + + #[test] + fn committed_and_unknown_states_defer() { + for status in [ + Some(OrderStatus::WaitingBuyerInvoice), + Some(OrderStatus::WaitingPayment), + Some(OrderStatus::Active), + Some(OrderStatus::FiatSent), + Some(OrderStatus::InProgress), + None, + ] { + assert_eq!(cancel_cleanup(status.as_ref()), CancelCleanup::Defer); + } + } + + #[tokio::test] + async fn a_deferred_session_survives_until_its_deadline() { + let order_id = "order-deferred"; + let mgr = manager_with_session(order_id).await; + + mgr.defer_removal(order_id, TAKE, BOND_SLASH_GRACE_SECS).await; + mgr.reconcile_deferred_removals().await; + + assert!( + mgr.get_session(order_id).await.is_some(), + "the session must outlive the cancel so a trailing bond-slashed can be decrypted" + ); + } + + #[tokio::test] + async fn a_deferred_session_is_dropped_once_the_deadline_passes() { + let order_id = "order-expired"; + let mgr = manager_with_session(order_id).await; + + mgr.defer_removal(order_id, TAKE, 0).await; + mgr.reconcile_deferred_removals().await; + + assert!(mgr.get_session(order_id).await.is_none()); + } + + #[tokio::test] + async fn resolving_a_deferral_drops_the_session_immediately() { + let order_id = "order-slashed"; + let mgr = manager_with_session(order_id).await; + + mgr.defer_removal(order_id, TAKE, BOND_SLASH_GRACE_SECS).await; + + assert!(mgr.resolve_deferred_removal(order_id, TAKE).await); + assert!(mgr.get_session(order_id).await.is_none()); + } + + #[tokio::test] + async fn resolving_without_a_deferral_leaves_the_session_alone() { + let order_id = "order-live"; + let mgr = manager_with_session(order_id).await; + + assert!(!mgr.resolve_deferred_removal(order_id, TAKE).await); + assert!( + mgr.get_session(order_id).await.is_some(), + "a live trade must not lose its session to an unrelated bond-slashed" + ); + } + + async fn retake(mgr: &SessionManager, order_id: &str) -> Result { + mgr.create_session( + order_id.to_string(), + TradeRole::Seller, + RETAKE, + dummy_order_info(order_id), + ) + .await + } + + /// Retaking the same order inside the grace window must not lose the fresh + /// session to the canceled take's timer. + #[tokio::test] + async fn a_retake_supersedes_the_deferred_session() { + let order_id = "order-retaken"; + let mgr = manager_with_session(order_id).await; + + mgr.defer_removal(order_id, TAKE, BOND_SLASH_GRACE_SECS).await; + retake(&mgr, order_id).await.expect("retake"); + + mgr.reconcile_deferred_removals().await; + + let session = mgr.get_session(order_id).await.expect("session kept"); + assert_eq!(session.trade_key_index, RETAKE); + } + + /// A retake landing on an already-elapsed deadline — the window a separate + /// deferral and session lock left open — still ends up with a live session. + #[tokio::test] + async fn a_retake_at_the_deadline_keeps_its_session() { + let order_id = "order-retaken-late"; + let mgr = manager_with_session(order_id).await; + + mgr.defer_removal(order_id, TAKE, 0).await; + retake(&mgr, order_id).await.expect("retake"); + + mgr.reconcile_deferred_removals().await; + + let session = mgr.get_session(order_id).await.expect("session kept"); + assert_eq!(session.trade_key_index, RETAKE); + } + + /// A live session is not a stale deferral: the duplicate guard still holds. + #[tokio::test] + async fn a_retake_over_a_live_session_is_still_refused() { + let order_id = "order-live-take"; + let mgr = manager_with_session(order_id).await; + + let err = retake(&mgr, order_id).await.expect_err("duplicate take"); + + assert!(err.to_string().contains("SessionAlreadyExists")); + } + + /// What `create_session` refuses above, an accepted take must not: the + /// daemon confirmed it, so it takes the order id over whatever it finds. + #[tokio::test] + async fn an_accepted_take_installs_its_session_over_a_live_one() { + let order_id = "order-installed"; + let mgr = manager_with_session(order_id).await; + + mgr.install_session( + order_id.to_string(), + TradeRole::Seller, + RETAKE, + dummy_order_info(order_id), + ) + .await + .expect("install"); + + let session = mgr.get_session(order_id).await.expect("session installed"); + assert_eq!(session.trade_key_index, RETAKE); + } + + #[tokio::test] + async fn an_installed_session_does_not_inherit_a_pending_deferral() { + let order_id = "order-installed-deferred"; + let mgr = manager_with_session(order_id).await; + + mgr.defer_removal(order_id, TAKE, 0).await; + mgr.install_session( + order_id.to_string(), + TradeRole::Seller, + RETAKE, + dummy_order_info(order_id), + ) + .await + .expect("install"); + + mgr.reconcile_deferred_removals().await; + + assert!(mgr.get_session(order_id).await.is_some()); + } + + #[tokio::test] + async fn removing_a_session_clears_its_pending_deferral() { + let order_id = "order-removed"; + let mgr = manager_with_session(order_id).await; + + mgr.defer_removal(order_id, TAKE, BOND_SLASH_GRACE_SECS).await; + mgr.remove_session(order_id).await; + + assert!(!mgr.resolve_deferred_removal(order_id, TAKE).await); + } + + // ── Generation binding ──────────────────────────────────────────────────── + + #[tokio::test] + async fn only_the_live_trade_key_is_the_current_generation() { + let order_id = "order-generation"; + let mgr = manager_with_session(order_id).await; + + assert!(mgr.is_current_generation(order_id, TAKE).await); + assert!(!mgr.is_current_generation(order_id, RETAKE).await); + assert!( + mgr.is_current_generation("order-never-taken", TAKE).await, + "an order we never took has no generation to contradict" + ); + } + + /// The full delayed-delivery sequence: the old take's `canceled` and its + /// trailing `bond-slashed` both land after the retake replaced the session. + #[tokio::test] + async fn a_delayed_cancel_from_the_old_key_spares_the_retaken_session() { + let order_id = "order-superseded"; + let mgr = manager_with_session(order_id).await; + + mgr.defer_removal(order_id, TAKE, BOND_SLASH_GRACE_SECS).await; + retake(&mgr, order_id).await.expect("retake"); + + // Delayed `canceled` from the old trade key: the dispatcher's gate + // rejects it before it can arm a deferral against the new session. + assert!(!mgr.is_current_generation(order_id, TAKE).await); + // Even if one were armed, neither the trailing notice nor the timer + // may claim a session of another generation. + mgr.defer_removal(order_id, TAKE, 0).await; + mgr.resolve_deferred_removal(order_id, TAKE).await; + mgr.reconcile_deferred_removals().await; + + let session = mgr.get_session(order_id).await.expect("retake survives"); + assert_eq!(session.trade_key_index, RETAKE); + } + + #[tokio::test] + async fn an_immediate_removal_spares_a_session_of_another_generation() { + let order_id = "order-immediate"; + let mgr = manager_with_session(order_id).await; + + mgr.remove_session_if_current(order_id, RETAKE).await; + assert!(mgr.get_session(order_id).await.is_some()); + + mgr.remove_session_if_current(order_id, TAKE).await; + assert!(mgr.get_session(order_id).await.is_none()); + } +} diff --git a/specs/004-mostro-p2p-client/contracts/orders.md b/specs/004-mostro-p2p-client/contracts/orders.md index 815315a..e7dc587 100644 --- a/specs/004-mostro-p2p-client/contracts/orders.md +++ b/specs/004-mostro-p2p-client/contracts/orders.md @@ -89,6 +89,9 @@ created: the TradeInfo is built from the reply's real data (status, calculated `amount_sats`, `hold_invoice`), persisted to My Trades, the order book entry is synced, and the trade session/subscriptions start. On rejection or timeout **nothing is persisted** — no phantom trade. +Retaking an order whose previous take was canceled inside the bond grace +window clears the pending session deletion first (see *Session lifecycle +on cancel*). **Errors**: `OrderNotFound`, `CannotTakeOwnOrder`, `OrderAlreadyTaken`, `InvalidRole`, `FiatAmountRequired`/`OutOfRange` (range orders), @@ -226,7 +229,7 @@ call — it arrives as a Kind 14 (NIP-44) message from mostrod. This section documents the full chain so Flutter providers and screens know what to listen to. Reference: . -### Inbound gift-wrap actions consumed by `process_gift_wrap_rumor` +### Inbound gift-wrap actions consumed by `dispatch_mostro_message` | Action | Payload variant | Effect on the seller's trade row | |------------------------------------|-----------------------------------------------------|----------------------------------------------------------------------------------| @@ -238,7 +241,7 @@ what to listen to. Reference: