From c012525608f2dbbe58b217fb73005549197c0a14 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:26:25 -0600 Subject: [PATCH 1/9] feat(db): add delete_trade_by_order_id operation - Adds the trait method with SQLite and IndexedDB implementations. - SQLite deletes via the nested JSON order id, mirroring get_trade_by_order_id, because trades.id is a fresh UUID for takers. - IndexedDB is a no-op since trades are not persisted on web yet (#233). - Chat messages are deliberately untouched; groundwork for the timeout cleanup. --- rust/src/db/indexeddb.rs | 4 +++ rust/src/db/mod.rs | 7 ++++ rust/src/db/sqlite.rs | 77 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/rust/src/db/indexeddb.rs b/rust/src/db/indexeddb.rs index 221bbec..b7dccc3 100644 --- a/rust/src/db/indexeddb.rs +++ b/rust/src/db/indexeddb.rs @@ -275,6 +275,10 @@ impl Storage for IndexedDbStorage { Ok(None) // no persisted trade: role lookup returns None (#233) } + async fn delete_trade_by_order_id(&self, _order_id: &str) -> Result<()> { + Ok(()) // no persisted trades on web: nothing to delete (#233) + } + async fn update_trade_order_id( &self, _old_order_id: &str, diff --git a/rust/src/db/mod.rs b/rust/src/db/mod.rs index bbc024f..daa439d 100644 --- a/rust/src/db/mod.rs +++ b/rust/src/db/mod.rs @@ -161,6 +161,13 @@ pub trait Storage: Send + Sync { order_id: &str, ) -> Result>; + /// Delete a persisted trade by the order ID it is associated with. + /// + /// Chat messages are keyed separately (`messages.trade_id` holds the + /// order id, no FK) and are deliberately NOT touched here. No-op when + /// no matching trade exists. + async fn delete_trade_by_order_id(&self, order_id: &str) -> Result<()>; + /// Update the order ID inside a persisted trade (e.g. local UUID → daemon UUID). /// /// Loads the trade whose `order.id == old_order_id`, replaces `order.id` diff --git a/rust/src/db/sqlite.rs b/rust/src/db/sqlite.rs index 7ae59ad..42eb8f5 100644 --- a/rust/src/db/sqlite.rs +++ b/rust/src/db/sqlite.rs @@ -510,6 +510,19 @@ impl Storage for SqliteStorage { Ok(row.map(|(data,)| serde_json::from_str(&data)).transpose()?) } + async fn delete_trade_by_order_id(&self, order_id: &str) -> Result<()> { + // Same nested-id filter as `get_trade_by_order_id`: `trades.id` is a + // fresh UUID for takers, so the row must be found via the order id + // stored inside the JSON blob. + sqlx::query( + "DELETE FROM trades WHERE json_extract(data, '$.order.id') = ?", + ) + .bind(order_id) + .execute(&self.pool) + .await?; + Ok(()) + } + async fn update_trade_order_id( &self, old_order_id: &str, @@ -635,6 +648,70 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[tokio::test] + async fn delete_trade_by_order_id_removes_only_the_matching_row() { + use crate::api::types::*; + + let path = temp_db_path(); + let storage = SqliteStorage::open(path.to_str().unwrap()).await.unwrap(); + + // Taker-shaped rows: trades.id is a fresh UUID, distinct from the + // order id — deletion must go through the nested JSON order id. + let trade = |row_id: &str, order_id: &str| TradeInfo { + id: row_id.into(), + order: OrderInfo { + id: order_id.into(), + kind: OrderKind::Sell, + status: OrderStatus::WaitingBuyerInvoice, + amount_sats: None, + fiat_amount: Some(100.0), + fiat_amount_min: None, + fiat_amount_max: None, + fiat_code: "CUP".into(), + payment_method: "bank".into(), + premium: 0.0, + creator_pubkey: "maker".into(), + created_at: 1, + expires_at: None, + is_mine: false, + rating: 0.0, + total_reviews: 0, + days_active: 0, + }, + role: TradeRole::Buyer, + counterparty_pubkey: String::new(), + current_step: TradeStep::Buyer(BuyerStep::OrderTaken), + hold_invoice: None, + buyer_invoice: None, + trade_key_index: 1, + cooperative_cancel_state: None, + timeout_at: None, + started_at: 1, + completed_at: None, + outcome: None, + }; + storage.save_trade(&trade("row-a", "order-a")).await.unwrap(); + storage.save_trade(&trade("row-b", "order-b")).await.unwrap(); + + storage.delete_trade_by_order_id("order-a").await.unwrap(); + + assert!(storage + .get_trade_by_order_id("order-a") + .await + .unwrap() + .is_none()); + let remaining = storage.list_trades().await.unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].order.id, "order-b"); + + // Unknown order id: no-op, not an error. + storage.delete_trade_by_order_id("order-missing").await.unwrap(); + assert_eq!(storage.list_trades().await.unwrap().len(), 1); + + drop(storage); + let _ = std::fs::remove_file(&path); + } + #[tokio::test] async fn mark_messages_read_survives_rehydration() { use crate::api::types::*; From 7cce81bd7fb6b4f4e89c96590c10536aad427d7b Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:41:17 -0600 Subject: [PATCH 2/9] fix(orders): wipe local state when the daemon cancels a not-yet-active trade - On Canceled, a trade still in pending/waiting states is deleted together with its in-memory session instead of lingering as a Canceled history row. - Mirrors v1, which deletes pending/waiting sessions on cancel; trades that progressed keep their row and chat as history. - InProgress rows are kept: it only enters via the Kind 38383 sync, where it masks both waiting and active phases, so it is ambiguous. --- rust/src/api/orders.rs | 95 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 84 insertions(+), 11 deletions(-) diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index e7e7629..6ffcd9a 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -1831,19 +1831,49 @@ async fn dispatch_mostro_message( if let Some(order_id) = &kind.id { let oid = order_id.to_string(); order_book().remove_order(&oid).await; - // Sync the Canceled status into the trade DB so My Trades - // reflects the cancellation immediately. if let Some(db) = crate::db::app_db::db() { - if let Err(e) = db - .update_trade_fields( - &oid, - Some(crate::api::types::OrderStatus::Canceled), - None, - None, - ) - .await + let local_status = match db.get_trade_by_order_id(&oid).await { + Ok(Some(trade)) => Some(trade.order.status), + Ok(None) => None, + Err(e) => { + log::warn!("[orders] Canceled: trade lookup failed for {oid}: {e}"); + None + } + }; + if local_status + .as_ref() + .is_some_and(cancellation_wipes_history) { - log::warn!("[orders] failed to sync Canceled status for {oid}: {e}"); + // The trade never went active (no peer, no chat, no + // exchange — typically a waiting-state timeout): + // wipe it instead of keeping a meaningless + // Canceled history row. Mirrors v1, which deletes + // pending/waiting sessions on cancel. + match db.delete_trade_by_order_id(&oid).await { + Ok(()) => log::info!( + "[orders] Canceled before active — removed trade for order={oid}" + ), + Err(e) => log::warn!( + "[orders] failed to remove canceled trade for {oid}: {e}" + ), + } + crate::mostro::session::session_manager() + .remove_session(&oid) + .await; + } else { + // Sync the Canceled status into the trade DB so My + // Trades reflects the cancellation immediately. + if let Err(e) = db + .update_trade_fields( + &oid, + Some(crate::api::types::OrderStatus::Canceled), + None, + None, + ) + .await + { + log::warn!("[orders] failed to sync Canceled status for {oid}: {e}"); + } } } } @@ -2204,6 +2234,25 @@ fn status_for_action(action: &mostro_core::message::Action) -> Option bool { + matches!( + status, + OrderStatus::Pending + | OrderStatus::WaitingBuyerInvoice + | OrderStatus::WaitingPayment + ) +} + fn map_core_status(s: mostro_core::order::Status) -> Option { use mostro_core::order::Status as S; Some(match s { @@ -3662,6 +3711,30 @@ mod tests { ); } + // ── Cancellation cleanup ────────────────────────────────────────────────── + + /// Only never-active trades are wiped on a daemon `canceled`; anything + /// that progressed (or is ambiguous, like InProgress) keeps its history row. + #[test] + fn cancellation_wipes_history_only_for_never_active_trades() { + use crate::api::types::OrderStatus as S; + for s in [S::Pending, S::WaitingBuyerInvoice, S::WaitingPayment] { + assert!(cancellation_wipes_history(&s), "{s:?} must be wiped"); + } + for s in [ + S::InProgress, + S::Active, + S::FiatSent, + S::Dispute, + S::Success, + S::Canceled, + S::CooperativelyCanceled, + S::CanceledByAdmin, + ] { + assert!(!cancellation_wipes_history(&s), "{s:?} must keep history"); + } + } + // ── Helper ──────────────────────────────────────────────────────────────── fn dummy_order_info(id: &str) -> crate::api::types::OrderInfo { From 72396b4f0bd98ccfd78be5bf5acb9fea31126364 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:46:11 -0600 Subject: [PATCH 3/9] fix(orders): stop removing the order from the book on a daemon Canceled - mostrod republishes the order as pending BEFORE sending Canceled on a taker-responsible timeout, so the blind remove raced the republish and left the order missing from the book until an app restart. - The book is fed only by Kind 38383 events: a genuine cancel arrives as a status update and the UI already filters non-pending orders. --- rust/src/api/orders.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index 6ffcd9a..abd1473 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -1830,7 +1830,14 @@ 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(); - order_book().remove_order(&oid).await; + // Deliberately NOT removed from the order book. The book is + // fed only by the daemon's Kind 38383 events, and on a + // taker-responsible timeout mostrod republishes the order as + // `pending` BEFORE sending this Canceled (scheduler.rs: + // update_order_event, then notify) — a blind remove here + // races that republish and leaves the order missing from the + // book until restart. A genuine cancel arrives as a 38383 + // status update and the UI already filters non-pending. if let Some(db) = crate::db::app_db::db() { let local_status = match db.get_trade_by_order_id(&oid).await { Ok(Some(trade)) => Some(trade.order.status), From 521496954a6038a802f1d07986de2865166c8d44 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:57:54 -0600 Subject: [PATCH 4/9] feat(api): push trade lifecycle updates to Dart via on_trade_updated stream - New broadcast channel + TradeUpdatesStream mirroring the bond-slashed pattern; the Canceled arm emits for both the wipe and mark-canceled paths. - Needed because polling cannot observe a cancellation anymore: the wiped trade has no DB row left, and a timeout republish reads pending again. - Regenerates frb bindings. --- rust/src/api/orders.rs | 70 +++++++ rust/src/api/types.rs | 10 + rust/src/frb_generated.rs | 427 ++++++++++++++++++++++++++++++-------- 3 files changed, 418 insertions(+), 89 deletions(-) diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index abd1473..85418c6 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -1883,6 +1883,10 @@ async fn dispatch_mostro_message( } } } + // Push the cancellation to Dart: after a wipe there is no DB + // row left to poll, and after a timeout republish the book + // reads `pending` — screens need this signal either way. + emit_trade_update(&oid, crate::api::types::OrderStatus::Canceled); } } // Seller receives BuyerTookOrder → peer is buyer_trade_pubkey. @@ -3055,6 +3059,55 @@ async fn _run_order_subscription() { } } +/// Buffered trade lifecycle updates; cancellations are rare, so a small +/// buffer is ample. +const TRADE_UPDATES_CAPACITY: usize = 64; + +static TRADE_UPDATES: std::sync::OnceLock< + broadcast::Sender, +> = std::sync::OnceLock::new(); + +fn trade_updates_tx() -> &'static broadcast::Sender { + TRADE_UPDATES.get_or_init(|| broadcast::channel(TRADE_UPDATES_CAPACITY).0) +} + +/// Broadcasts a trade lifecycle change to any active [`TradeUpdatesStream`]. +pub(crate) fn emit_trade_update(order_id: &str, status: crate::api::types::OrderStatus) { + let _ = trade_updates_tx().send(crate::api::types::TradeUpdate { + order_id: order_id.to_string(), + status, + }); +} + +/// Stream of trade lifecycle changes (daemon-driven cancellations). +/// +/// Complements the 2s status polling: after a never-active trade is wiped +/// (see `cancellation_wipes_history`) there is no DB row left to poll, and +/// after a timeout republish the book shows `pending` again — in both cases +/// this push is the only signal the affected screens can react to. +pub async fn on_trade_updated() -> Result { + Ok(TradeUpdatesStream { + rx: trade_updates_tx().subscribe(), + }) +} + +/// Wrapper for flutter_rust_bridge Dart Stream generation. +pub struct TradeUpdatesStream { + rx: broadcast::Receiver, +} + +impl TradeUpdatesStream { + pub async fn next(&mut self) -> Option { + loop { + match self.rx.recv().await { + Ok(update) => return Some(update), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + } +} + /// Stream that emits whenever the order list changes. pub async fn on_orders_updated() -> Result { let rx = order_book().subscribe(); @@ -3742,6 +3795,23 @@ mod tests { } } + /// A subscriber created before the emit receives the update; emitting + /// with no subscribers must not error or panic. + #[tokio::test] + async fn trade_updates_reach_subscribers() { + // No subscriber yet: emit is a silent no-op. + emit_trade_update("order-nobody", crate::api::types::OrderStatus::Canceled); + + let mut stream = on_trade_updated().await.unwrap(); + emit_trade_update("order-x", crate::api::types::OrderStatus::Canceled); + let update = stream.next().await.expect("subscriber must receive the update"); + assert_eq!(update.order_id, "order-x"); + assert!(matches!( + update.status, + crate::api::types::OrderStatus::Canceled + )); + } + // ── Helper ──────────────────────────────────────────────────────────────── fn dummy_order_info(id: &str) -> crate::api::types::OrderInfo { diff --git a/rust/src/api/types.rs b/rust/src/api/types.rs index 12af0c1..5d7e53f 100644 --- a/rust/src/api/types.rs +++ b/rust/src/api/types.rs @@ -264,6 +264,16 @@ pub struct TradeInfo { pub outcome: Option, } +/// A trade lifecycle change pushed from Rust so the UI does not have to poll +/// for it. Emitted on daemon-driven cancellation — including the wipe of a +/// never-active trade, whose DB row no longer exists by the time this +/// arrives, so polling could never observe the transition. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TradeUpdate { + pub order_id: String, + pub status: OrderStatus, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AttachmentInfo { pub file_name: String, diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 17b6edb..29c59db 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -48,7 +48,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1417387575; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 659438006; // Section: executor @@ -1235,6 +1235,64 @@ fn wire__crate__api__identity__TradeKeyIndexStream_next_impl( }, ) } +fn wire__crate__api__orders__TradeUpdatesStream_next_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "TradeUpdatesStream_next", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, ()>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, true, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref_mut().await) + } + _ => unreachable!(), + } + } + let mut api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok( + crate::api::orders::TradeUpdatesStream::next(&mut *api_that_guard) + .await, + )?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__messages__UnreadCountStream_next_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3418,6 +3476,41 @@ fn wire__crate__api__identity__on_trade_key_index_changed_impl( }, ) } +fn wire__crate__api__orders__on_trade_updated_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "on_trade_updated", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::orders::on_trade_updated().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__messages__on_unread_count_changed_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -4490,6 +4583,9 @@ flutter_rust_bridge::frb_generated_moi_arc_impl_value!( flutter_rust_bridge::frb_generated_moi_arc_impl_value!( flutter_rust_bridge::for_generated::RustAutoOpaqueInner ); +flutter_rust_bridge::frb_generated_moi_arc_impl_value!( + flutter_rust_bridge::for_generated::RustAutoOpaqueInner +); flutter_rust_bridge::frb_generated_moi_arc_impl_value!( flutter_rust_bridge::for_generated::RustAutoOpaqueInner ); @@ -4637,6 +4733,16 @@ impl SseDecode for TradeKeyIndexStream { } } +impl SseDecode for TradeUpdatesStream { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = , + >>::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::rust_auto_opaque_decode_owned(inner); + } +} + impl SseDecode for UnreadCountStream { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -4791,6 +4897,16 @@ impl SseDecode } } +impl SseDecode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return decode_rust_opaque_moi(inner); + } +} + impl SseDecode for RustOpaqueMoi> { @@ -5561,6 +5677,17 @@ impl SseDecode for Option { } } +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + impl SseDecode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -5944,6 +6071,18 @@ impl SseDecode for crate::api::types::TradeStep { } } +impl SseDecode for crate::api::types::TradeUpdate { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_orderId = ::sse_decode(deserializer); + let mut var_status = ::sse_decode(deserializer); + return crate::api::types::TradeUpdate { + order_id: var_orderId, + status: var_status, + }; + } +} + impl SseDecode for u16 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -6064,227 +6203,234 @@ fn pde_ffi_dispatcher_primary_impl( rust_vec_len, data_len, ), - 22 => wire__crate__api__messages__UnreadCountStream_next_impl( + 22 => wire__crate__api__orders__TradeUpdatesStream_next_impl( port, ptr, rust_vec_len, data_len, ), - 23 => { + 23 => wire__crate__api__messages__UnreadCountStream_next_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 24 => { wire__crate__api__nwc__WalletStatusStream_next_impl(port, ptr, rust_vec_len, data_len) } - 24 => wire__crate__api__nostr__add_relay_impl(port, ptr, rust_vec_len, data_len), - 25 => wire__crate__api__orders__cancel_order_impl(port, ptr, rust_vec_len, data_len), - 26 => wire__crate__api__logging__clear_logs_impl(port, ptr, rust_vec_len, data_len), - 27 => wire__crate__api__nwc__connect_wallet_impl(port, ptr, rust_vec_len, data_len), - 28 => wire__crate__api__identity__create_identity_impl(port, ptr, rust_vec_len, data_len), - 29 => wire__crate__api__orders__create_order_impl(port, ptr, rust_vec_len, data_len), - 30 => wire__crate__api__identity__delete_identity_impl(port, ptr, rust_vec_len, data_len), - 31 => wire__crate__api__identity__derive_trade_key_impl(port, ptr, rust_vec_len, data_len), - 32 => wire__crate__api__nwc__disconnect_wallet_impl(port, ptr, rust_vec_len, data_len), - 33 => { + 25 => wire__crate__api__nostr__add_relay_impl(port, ptr, rust_vec_len, data_len), + 26 => wire__crate__api__orders__cancel_order_impl(port, ptr, rust_vec_len, data_len), + 27 => wire__crate__api__logging__clear_logs_impl(port, ptr, rust_vec_len, data_len), + 28 => wire__crate__api__nwc__connect_wallet_impl(port, ptr, rust_vec_len, data_len), + 29 => wire__crate__api__identity__create_identity_impl(port, ptr, rust_vec_len, data_len), + 30 => wire__crate__api__orders__create_order_impl(port, ptr, rust_vec_len, data_len), + 31 => wire__crate__api__identity__delete_identity_impl(port, ptr, rust_vec_len, data_len), + 32 => wire__crate__api__identity__derive_trade_key_impl(port, ptr, rust_vec_len, data_len), + 33 => wire__crate__api__nwc__disconnect_wallet_impl(port, ptr, rust_vec_len, data_len), + 34 => { wire__crate__api__messages__download_attachment_impl(port, ptr, rust_vec_len, data_len) } - 34 => wire__crate__api__identity__export_encrypted_backup_impl( + 35 => wire__crate__api__identity__export_encrypted_backup_impl( port, ptr, rust_vec_len, data_len, ), - 35 => wire__crate__api__nostr__fetch_mostro_instance_tags_impl( + 36 => wire__crate__api__nostr__fetch_mostro_instance_tags_impl( port, ptr, rust_vec_len, data_len, ), - 36 => wire__crate__api__nostr__flush_message_queue_impl(port, ptr, rust_vec_len, data_len), - 37 => wire__crate__api__get_app_version_impl(port, ptr, rust_vec_len, data_len), - 38 => wire__crate__api__messages__get_attachment_status_impl( + 37 => wire__crate__api__nostr__flush_message_queue_impl(port, ptr, rust_vec_len, data_len), + 38 => wire__crate__api__get_app_version_impl(port, ptr, rust_vec_len, data_len), + 39 => wire__crate__api__messages__get_attachment_status_impl( port, ptr, rust_vec_len, data_len, ), - 39 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), - 40 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), - 41 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), - 42 => wire__crate__api__escrow__get_escrow_mode_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), - 44 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), - 45 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), - 46 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), - 47 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), - 48 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), - 49 => { + 40 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), + 41 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), + 43 => wire__crate__api__escrow__get_escrow_mode_impl(port, ptr, rust_vec_len, data_len), + 44 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), + 45 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), + 46 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), + 47 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), + 48 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), + 49 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), + 50 => { wire__crate__api__reputation__get_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 50 => wire__crate__api__reputation__get_rating_for_trade_impl( + 51 => wire__crate__api__reputation__get_rating_for_trade_impl( port, ptr, rust_vec_len, data_len, ), - 51 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), - 52 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), - 53 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), - 54 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), - 55 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), - 56 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), - 57 => wire__crate__api__disputes__handle_admin_canceled_impl( + 52 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), + 53 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), + 54 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), + 55 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), + 56 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), + 57 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), + 58 => wire__crate__api__disputes__handle_admin_canceled_impl( port, ptr, rust_vec_len, data_len, ), - 58 => { + 59 => { wire__crate__api__disputes__handle_admin_settled_impl(port, ptr, rust_vec_len, data_len) } - 59 => wire__crate__api__disputes__handle_admin_took_dispute_impl( + 60 => wire__crate__api__disputes__handle_admin_took_dispute_impl( port, ptr, rust_vec_len, data_len, ), - 60 => wire__crate__api__reputation__handle_rating_received_impl( + 61 => wire__crate__api__reputation__handle_rating_received_impl( port, ptr, rust_vec_len, data_len, ), - 61 => { + 62 => { wire__crate__api__identity__import_from_mnemonic_impl(port, ptr, rust_vec_len, data_len) } - 62 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), - 63 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), - 64 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), - 65 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), - 66 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), - 67 => wire__crate__api__identity__load_identity_from_mnemonic_impl( + 63 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), + 64 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), + 65 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), + 66 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), + 67 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), + 68 => wire__crate__api__identity__load_identity_from_mnemonic_impl( port, ptr, rust_vec_len, data_len, ), - 68 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), - 69 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), - 70 => wire__crate__api__messages__on_attachment_progress_impl( + 69 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), + 70 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), + 71 => wire__crate__api__messages__on_attachment_progress_impl( port, ptr, rust_vec_len, data_len, ), - 71 => wire__crate__api__bond__on_bond_slashed_impl(port, ptr, rust_vec_len, data_len), - 72 => wire__crate__api__nostr__on_connection_state_changed_impl( + 72 => wire__crate__api__bond__on_bond_slashed_impl(port, ptr, rust_vec_len, data_len), + 73 => wire__crate__api__nostr__on_connection_state_changed_impl( port, ptr, rust_vec_len, data_len, ), - 73 => { + 74 => { wire__crate__api__disputes__on_dispute_updated_impl(port, ptr, rust_vec_len, data_len) } - 74 => { + 75 => { wire__crate__api__escrow__on_escrow_mode_changed_impl(port, ptr, rust_vec_len, data_len) } - 75 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), - 76 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), - 77 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), - 78 => { + 76 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), + 77 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), + 78 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), + 79 => { wire__crate__api__reputation__on_rating_received_impl(port, ptr, rust_vec_len, data_len) } - 79 => { + 80 => { wire__crate__api__nostr__on_relay_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 80 => { + 81 => { wire__crate__api__settings__on_settings_changed_impl(port, ptr, rust_vec_len, data_len) } - 81 => wire__crate__api__identity__on_trade_key_index_changed_impl( + 82 => wire__crate__api__identity__on_trade_key_index_changed_impl( port, ptr, rust_vec_len, data_len, ), - 82 => wire__crate__api__messages__on_unread_count_changed_impl( + 83 => wire__crate__api__orders__on_trade_updated_impl(port, ptr, rust_vec_len, data_len), + 84 => wire__crate__api__messages__on_unread_count_changed_impl( port, ptr, rust_vec_len, data_len, ), - 83 => { + 85 => { wire__crate__api__nwc__on_wallet_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 84 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), - 85 => { + 86 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), + 87 => { wire__crate__api__orders__order_filters_default_impl(port, ptr, rust_vec_len, data_len) } - 86 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), - 87 => wire__crate__api__logging__recent_logs_impl(port, ptr, rust_vec_len, data_len), - 88 => wire__crate__api__settings__rehydrate_active_mostro_node_impl( + 88 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), + 89 => wire__crate__api__logging__recent_logs_impl(port, ptr, rust_vec_len, data_len), + 90 => wire__crate__api__settings__rehydrate_active_mostro_node_impl( port, ptr, rust_vec_len, data_len, ), - 89 => wire__crate__api__escrow__rehydrate_escrow_overrides_impl( + 91 => wire__crate__api__escrow__rehydrate_escrow_overrides_impl( port, ptr, rust_vec_len, data_len, ), - 90 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), - 91 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), - 92 => wire__crate__api__orders__restart_orders_subscription_impl( + 92 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), + 93 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), + 94 => wire__crate__api__orders__restart_orders_subscription_impl( port, ptr, rust_vec_len, data_len, ), - 93 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), - 94 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), - 95 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), - 96 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), - 97 => wire__crate__api__settings__set_active_mostro_node_impl( + 95 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), + 96 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), + 97 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), + 98 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), + 99 => wire__crate__api__settings__set_active_mostro_node_impl( port, ptr, rust_vec_len, data_len, ), - 98 => wire__crate__api__escrow__set_cashu_mint_url_override_impl( + 100 => wire__crate__api__escrow__set_cashu_mint_url_override_impl( port, ptr, rust_vec_len, data_len, ), - 99 => wire__crate__api__settings__set_default_fiat_code_impl( + 101 => wire__crate__api__settings__set_default_fiat_code_impl( port, ptr, rust_vec_len, data_len, ), - 100 => wire__crate__api__settings__set_default_lightning_address_impl( + 102 => wire__crate__api__settings__set_default_lightning_address_impl( port, ptr, rust_vec_len, data_len, ), - 101 => wire__crate__api__escrow__set_escrow_mode_override_impl( + 103 => wire__crate__api__escrow__set_escrow_mode_override_impl( port, ptr, rust_vec_len, data_len, ), - 102 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), - 103 => { + 104 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), + 105 => { wire__crate__api__settings__set_logging_enabled_impl(port, ptr, rust_vec_len, data_len) } - 104 => { + 106 => { wire__crate__api__reputation__set_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 105 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), - 106 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), - 107 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), - 108 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), - 109 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), + 107 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), + 108 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), + 109 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), + 110 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), + 111 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -6511,6 +6657,24 @@ impl flutter_rust_bridge::IntoIntoDart> for Trad } } +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for FrbWrapper { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self.0) + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for FrbWrapper +{ +} + +impl flutter_rust_bridge::IntoIntoDart> for TradeUpdatesStream { + fn into_into_dart(self) -> FrbWrapper { + self.into() + } +} + // Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for FrbWrapper { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { @@ -7474,6 +7638,27 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::types::TradeUpdate { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.order_id.into_into_dart().into_dart(), + self.status.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::types::TradeUpdate +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::types::TradeUpdate +{ + fn into_into_dart(self) -> crate::api::types::TradeUpdate { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::types::WalletStatus { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { match self { @@ -7605,6 +7790,13 @@ impl SseEncode for TradeKeyIndexStream { } } +impl SseEncode for TradeUpdatesStream { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >>::sse_encode(flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self), serializer); + } +} + impl SseEncode for UnreadCountStream { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -7766,6 +7958,17 @@ impl SseEncode } } +impl SseEncode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + let (ptr, size) = self.sse_encode_raw(); + ::sse_encode(ptr, serializer); + ::sse_encode(size, serializer); + } +} + impl SseEncode for RustOpaqueMoi> { @@ -8419,6 +8622,16 @@ impl SseEncode for Option { } } +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -8763,6 +8976,14 @@ impl SseEncode for crate::api::types::TradeStep { } } +impl SseEncode for crate::api::types::TradeUpdate { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.order_id, serializer); + ::sse_encode(self.status, serializer); + } +} + impl SseEncode for u16 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -9035,6 +9256,20 @@ mod io { MoiArc::>::decrement_strong_count(ptr as _); } + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_mostro_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTradeUpdatesStream( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_mostro_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTradeUpdatesStream( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } + #[unsafe(no_mangle)] pub extern "C" fn frbgen_mostro_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerUnreadCountStream( ptr: *const std::ffi::c_void, @@ -9280,6 +9515,20 @@ mod web { MoiArc::>::decrement_strong_count(ptr as _); } + #[wasm_bindgen] + pub fn rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTradeUpdatesStream( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[wasm_bindgen] + pub fn rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTradeUpdatesStream( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } + #[wasm_bindgen] pub fn rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerUnreadCountStream( ptr: *const std::ffi::c_void, From a458828cef51a765b300d7efe80285efec7ba299 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:04:05 -0600 Subject: [PATCH 5/9] ix(order): react to daemon cancellation on the invoice screens - add-invoice had no status listener at all: after a waiting-state timeout the form stayed up and every submit died with a 10s NoDaemonResponse. - pay-invoice only watched polled terminal statuses, which a wipe or a timeout republish never produces; both screens now also listen to the on_trade_updated push, show a notice, refresh My Trades and leave. --- .../order/providers/trade_state_provider.dart | 16 ++++++++++ .../screens/add_lightning_invoice_screen.dart | 30 +++++++++++++++++++ .../screens/pay_lightning_invoice_screen.dart | 28 ++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/lib/features/order/providers/trade_state_provider.dart b/lib/features/order/providers/trade_state_provider.dart index 5cd871c..0afd344 100644 --- a/lib/features/order/providers/trade_state_provider.dart +++ b/lib/features/order/providers/trade_state_provider.dart @@ -52,6 +52,22 @@ final tradeStatusProvider = } }); +/// Trade lifecycle updates pushed from Rust (daemon-driven cancellations). +/// +/// Complements [tradeStatusProvider]'s polling, which cannot observe a +/// cancellation anymore: a never-active trade is wiped from the DB on the +/// daemon's Canceled, and after a timeout republish the order book reads +/// `pending` again. Screens filter by `orderId`. +final tradeUpdatesProvider = + StreamProvider.autoDispose((ref) async* { + final stream = await orders_api.onTradeUpdated(); + while (true) { + final update = await stream.next(); + if (update == null) break; + yield update; + } +}); + /// Whether a status is terminal (no further changes possible). bool _isTerminal(OrderStatus s) => const { OrderStatus.success, diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index c36f675..5ce20d5 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -8,8 +8,11 @@ import 'package:mostro/core/daemon_errors.dart'; import 'package:mostro/l10n/app_localizations.dart'; import 'package:mostro/features/order/providers/trade_state_provider.dart'; import 'package:mostro/features/settings/providers/nwc_provider.dart'; +import 'package:mostro/features/trades/providers/trades_providers.dart' + show refreshTrades; import 'package:mostro/shared/widgets/nwc_invoice_widget.dart'; import 'package:mostro/src/rust/api/orders.dart' as orders_api; +import 'package:mostro/src/rust/api/types.dart' show OrderStatus, TradeUpdate; /// Add Lightning Invoice screen — Route `/add_invoice/:orderId`. /// @@ -37,6 +40,8 @@ class _AddLightningInvoiceScreenState bool _submitting = false; /// `true` when NWC is connected but generation failed → show manual form. bool _manualMode = false; + /// One-shot guard so we don't navigate twice as further updates stream in. + bool _navigated = false; @override void dispose() { @@ -115,6 +120,31 @@ class _AddLightningInvoiceScreenState final isWalletConnected = ref.watch(isWalletConnectedProvider); + // Leave the screen when mostrod cancels the order (e.g. the buyer let the + // waiting-state window expire): the daemon ignores messages for a + // canceled order, so without this the form just sits here and every + // submit dies with a 10s NoDaemonResponse. + ref.listen>(tradeUpdatesProvider, (prev, next) { + final update = next.valueOrNull; + if (update == null || _navigated || !mounted) return; + if (update.orderId != widget.orderId) return; + switch (update.status) { + case OrderStatus.canceled: + case OrderStatus.cooperativelyCanceled: + case OrderStatus.canceledByAdmin: + case OrderStatus.expired: + _navigated = true; + // The wiped trade must also disappear from the My Trades cache. + refreshTrades(ref); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.orderNoLongerActive)), + ); + context.go(AppRoute.home); + default: + break; + } + }); + // Resolve sats: provider first (live polling), fall back to constructor param. final sats = _resolvedSats(ref); diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 2e579ae..d4de5e4 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -10,8 +10,10 @@ import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; import 'package:mostro/features/order/providers/trade_state_provider.dart'; import 'package:mostro/features/settings/providers/nwc_provider.dart'; +import 'package:mostro/features/trades/providers/trades_providers.dart' + show refreshTrades; import 'package:mostro/l10n/app_localizations.dart'; -import 'package:mostro/src/rust/api/types.dart' show OrderStatus; +import 'package:mostro/src/rust/api/types.dart' show OrderStatus, TradeUpdate; import 'package:mostro/shared/widgets/nwc_payment_widget.dart'; /// Pay Lightning Invoice screen — Route `/pay_invoice/:orderId`. @@ -95,6 +97,30 @@ class _PayLightningInvoiceScreenState }, ); + // Push-based cancellation signal. The polling listener above cannot see + // a daemon cancel anymore: the wiped trade has no DB row left, and after + // a timeout republish the book reads `pending` — a status the switch + // above deliberately ignores. + ref.listen>(tradeUpdatesProvider, (prev, next) { + final update = next.valueOrNull; + if (update == null || _navigated || !mounted) return; + if (update.orderId != widget.orderId) return; + switch (update.status) { + case OrderStatus.canceled: + case OrderStatus.cooperativelyCanceled: + case OrderStatus.canceledByAdmin: + case OrderStatus.expired: + _navigated = true; + refreshTrades(ref); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.orderNoLongerActive)), + ); + context.go(AppRoute.home); + default: + break; + } + }); + return tradeAsync.when( loading: () => Scaffold( appBar: AppBar(title: Text(l10n.payLightningInvoiceTitle)), From 11f79955c956ef5b8bd011a5f011498d65cfb4f7 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:13:10 -0600 Subject: [PATCH 6/9] feat(orders): periodic sweep reconciling stale waiting trades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Runs 60s after subscribing and every 30 min (v1's cadence): waiting trades past their window are checked against the public book — pending republish wipes taker rows and resyncs maker rows, an outright cancel wipes; absence or ambiguous statuses change nothing, the daemon stays the authority. - Also drops keyless in-memory sessions older than 24h (first real caller of cleanup_stale_sessions) and logs sweep counters. --- rust/src/api/orders.rs | 177 +++++++++++++++++++++++++++++++++++++ rust/src/mostro/session.rs | 6 +- 2 files changed, 181 insertions(+), 2 deletions(-) diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index 85418c6..2754bff 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -2534,6 +2534,165 @@ pub async fn subscribe_orders() { let _guard = ResetGuard; _run_order_subscription().await; }); + + // Reconciles state the gift-wrap channel missed (e.g. a waiting-state + // timeout that fired while the app was closed). Idempotent across + // re-subscribes — at most one sweep loop per process. + spawn_stale_sweep(); +} + +// ── Stale-state sweep ───────────────────────────────────────────────────────── + +/// Delay before the first sweep so the initial Kind 38383 fetch can populate +/// the book — the sweep only acts on positive book signals, so it must not +/// run against an empty cache. +const SWEEP_INITIAL_DELAY_SECS: u64 = 60; +/// Cadence mirrors v1's 30-minute cleanup job. +const SWEEP_INTERVAL_SECS: u64 = 30 * 60; +/// Waiting trades younger than this are never touched: the daemon's own +/// waiting window (default `expiration_seconds`) has not elapsed yet. +const SWEEP_MIN_AGE_SECS: i64 = 900; +/// Keyless in-memory sessions older than this are dropped. Any order that +/// can still activate does so long before; a missing session self-heals in +/// the peer-pubkey handler anyway. +const SWEEP_SESSION_TTL_SECS: i64 = 24 * 3600; + +static SWEEP_ACTIVE: AtomicBool = AtomicBool::new(false); + +/// What the sweep does with one stale waiting trade, given the daemon's +/// current public (Kind 38383) status for that order. +#[derive(Debug, PartialEq)] +enum SweepAction { + /// The trade never went active and the daemon moved on — republished as + /// pending (taker side) or canceled outright: wipe row + session, same + /// as the live `Canceled` gift-wrap path. + Wipe, + /// Own maker order republished as pending: the order is alive again, + /// sync the row back so My Trades reflects it. + SyncPending, + /// No positive daemon signal — absent from the book, or the ambiguous + /// `in-progress` public marker: leave untouched. + Keep, +} + +fn sweep_action( + is_mine: bool, + book_status: Option<&crate::api::types::OrderStatus>, +) -> SweepAction { + use crate::api::types::OrderStatus as S; + match book_status { + Some(S::Pending) if is_mine => SweepAction::SyncPending, + Some(S::Pending) => SweepAction::Wipe, + Some(S::Canceled | S::Expired | S::CanceledByAdmin) => SweepAction::Wipe, + _ => SweepAction::Keep, + } +} + +fn spawn_stale_sweep() { + if SWEEP_ACTIVE + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + crate::rt::spawn(async { + crate::rt::time::sleep(crate::rt::time::Duration::from_secs( + SWEEP_INITIAL_DELAY_SECS, + )) + .await; + loop { + run_stale_sweep_once().await; + crate::rt::time::sleep(crate::rt::time::Duration::from_secs( + SWEEP_INTERVAL_SECS, + )) + .await; + } + }); +} + +/// Reconcile trades stuck in waiting states with the daemon's public book. +/// +/// Covers cancellations whose gift wrap the app never received (closed or +/// offline when the daemon's waiting window expired). The clock only +/// *triggers* the check — every decision needs a positive daemon signal +/// (see [`sweep_action`]); the daemon stays the authority on order state. +async fn run_stale_sweep_once() { + let Some(db) = crate::db::app_db::db() else { + return; + }; + let trades = match db.list_trades().await { + Ok(trades) => trades, + Err(e) => { + log::warn!("[orders] sweep: list_trades failed: {e}"); + return; + } + }; + let now = crate::rt::unix_now(); + let (mut examined, mut wiped, mut resynced) = (0usize, 0usize, 0usize); + for trade in trades { + if !matches!( + trade.order.status, + crate::api::types::OrderStatus::WaitingBuyerInvoice + | crate::api::types::OrderStatus::WaitingPayment + ) { + continue; + } + // Age gate: never race the take/propagation window of a live trade. + let deadline = trade + .timeout_at + .unwrap_or(trade.started_at + SWEEP_MIN_AGE_SECS); + if now <= deadline { + continue; + } + examined += 1; + let oid = trade.order.id.clone(); + let book_status = order_book().get_order(&oid).await.map(|o| o.status); + match sweep_action(trade.order.is_mine, book_status.as_ref()) { + SweepAction::Wipe => match db.delete_trade_by_order_id(&oid).await { + Ok(()) => { + crate::mostro::session::session_manager() + .remove_session(&oid) + .await; + emit_trade_update(&oid, crate::api::types::OrderStatus::Canceled); + log::info!("[orders] sweep: wiped stale waiting trade order={oid}"); + wiped += 1; + } + Err(e) => log::warn!("[orders] sweep: failed to wipe {oid}: {e}"), + }, + SweepAction::SyncPending => { + match db + .update_trade_fields( + &oid, + Some(crate::api::types::OrderStatus::Pending), + None, + None, + ) + .await + { + Ok(()) => { + emit_trade_update(&oid, crate::api::types::OrderStatus::Pending); + log::info!( + "[orders] sweep: resynced republished maker order={oid} to pending" + ); + resynced += 1; + } + Err(e) => log::warn!("[orders] sweep: failed to resync {oid}: {e}"), + } + } + SweepAction::Keep => {} + } + } + let sessions_dropped = crate::mostro::session::session_manager() + .cleanup_stale_sessions(SWEEP_SESSION_TTL_SECS) + .await; + if examined > 0 || sessions_dropped > 0 { + crate::api::logging::blog_info( + "orders", + format!( + "stale sweep: examined={examined} wiped={wiped} resynced={resynced} sessions_dropped={sessions_dropped}" + ), + ); + } } /// Refresh the order book on demand (UI "Refresh" action). @@ -3812,6 +3971,24 @@ mod tests { )); } + /// The sweep only acts on positive daemon signals: pending republish + /// (wipe for takers, resync for makers) and outright cancellation; + /// absence from the book or ambiguous statuses leave the trade alone. + #[test] + fn sweep_action_requires_a_positive_book_signal() { + use crate::api::types::OrderStatus as S; + assert_eq!(sweep_action(true, Some(&S::Pending)), SweepAction::SyncPending); + assert_eq!(sweep_action(false, Some(&S::Pending)), SweepAction::Wipe); + for s in [S::Canceled, S::Expired, S::CanceledByAdmin] { + assert_eq!(sweep_action(false, Some(&s)), SweepAction::Wipe); + assert_eq!(sweep_action(true, Some(&s)), SweepAction::Wipe); + } + assert_eq!(sweep_action(false, None), SweepAction::Keep); + for s in [S::InProgress, S::Active, S::Success] { + assert_eq!(sweep_action(false, Some(&s)), SweepAction::Keep); + } + } + // ── Helper ──────────────────────────────────────────────────────────────── fn dummy_order_info(id: &str) -> crate::api::types::OrderInfo { diff --git a/rust/src/mostro/session.rs b/rust/src/mostro/session.rs index 4c31e1a..077681a 100644 --- a/rust/src/mostro/session.rs +++ b/rust/src/mostro/session.rs @@ -144,14 +144,16 @@ impl SessionManager { } /// Remove sessions older than `timeout_secs` that have no shared key - /// (i.e., the take action was never acknowledged by Mostro). - pub async fn cleanup_stale_sessions(&self, timeout_secs: i64) { + /// (i.e., the trade never went active). Returns how many were dropped. + pub async fn cleanup_stale_sessions(&self, timeout_secs: i64) -> usize { let now = crate::rt::unix_now(); let mut sessions = self.sessions.write().await; + let before = sessions.len(); sessions.retain(|_, s| { s.shared_key.is_some() || (now - s.created_at) < timeout_secs }); + before - sessions.len() } } From 3012f2b2e55bbf564e116090857e969d7d5ba5ff Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:17:16 -0600 Subject: [PATCH 7/9] docs(specs): sync order contracts with cancellation cleanup and sweep - Documents the on_trade_updated stream, the differentiated Canceled handling (wipe never-active trades vs keep history), the no-book-removal rule and its race rationale, and the stale-state sweep. - Notes timeout_at's real semantics and the trade-row deletion exception in the data model. --- .../004-mostro-p2p-client/contracts/orders.md | 43 +++++++++++++++++++ specs/004-mostro-p2p-client/data-model.md | 7 ++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/specs/004-mostro-p2p-client/contracts/orders.md b/specs/004-mostro-p2p-client/contracts/orders.md index 815315a..873d7a1 100644 --- a/specs/004-mostro-p2p-client/contracts/orders.md +++ b/specs/004-mostro-p2p-client/contracts/orders.md @@ -195,6 +195,20 @@ Returns null if URI is not a valid Mostro deep link. Emits whenever the order list changes (new orders, status updates, expirations). Used to keep the UI order list in sync. +### on_trade_updated() → Stream +Push channel for trade lifecycle changes the 2s status polling cannot +observe: a never-active trade is **wiped** from the DB on the daemon's +`Canceled` (no row left to poll), and after a taker-timeout republish +the book reads `pending` again. Emitted by the `Canceled` gift-wrap +handler and the stale-state sweep. Screens filter by `order_id`. + +```text +TradeUpdate { + order_id: String + status: OrderStatus # Canceled on wipe; Pending on maker resync +} +``` + ### on_order_status_changed(order_id: String) → Stream Emits when a specific order's status changes. @@ -237,6 +251,35 @@ what to listen to. Reference: Date: Wed, 5 Aug 2026 09:58:03 -0600 Subject: [PATCH 8/9] fix(trades): drop wiped trades from My Trades as soon as Rust pushes the update - rawTradesProvider now invalidates itself on every on_trade_updated push, so a daemon cancel wipe (or a sweep resync) refreshes the list from any screen instead of only via pull-to-refresh or the invoice screens. - The wipe log moves to the bridge logger so it is visible while testing. --- lib/features/trades/providers/trades_providers.dart | 5 +++++ rust/src/api/orders.rs | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/features/trades/providers/trades_providers.dart b/lib/features/trades/providers/trades_providers.dart index b10028d..308a804 100644 --- a/lib/features/trades/providers/trades_providers.dart +++ b/lib/features/trades/providers/trades_providers.dart @@ -156,6 +156,11 @@ String _formatFiat(double? amount, double? min, double? max) { /// Exposed so callers (e.g. [refreshTrades]) can invalidate it when new trades /// are added. Per-row live status comes from [tradeStatusProvider]. final rawTradesProvider = FutureProvider>((ref) { + // Refetch whenever Rust pushes a trade lifecycle change: a daemon cancel + // wipes the row (it must leave My Trades no matter which screen is open) + // and a sweep resync rewrites its status — pull-to-refresh must not be + // the only way to observe either. + ref.listen(tradeUpdatesProvider, (_, __) => ref.invalidateSelf()); return orders_api.listTrades(); }); diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index 2754bff..2c0ea55 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -1857,8 +1857,11 @@ async fn dispatch_mostro_message( // Canceled history row. Mirrors v1, which deletes // pending/waiting sessions on cancel. match db.delete_trade_by_order_id(&oid).await { - Ok(()) => log::info!( - "[orders] Canceled before active — removed trade for order={oid}" + Ok(()) => crate::api::logging::blog_info( + "orders", + format!( + "Canceled before active — removed trade for order={oid}" + ), ), Err(e) => log::warn!( "[orders] failed to remove canceled trade for {oid}: {e}" From e1d71fc2d4929901f36717cc56797b6828c3f524 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:06:42 -0600 Subject: [PATCH 9/9] fix(review): address CodeRabbit round 1 - Log dropped trade-updates on broadcast lag instead of skipping silently; the Dart-facing lag API was declined as disproportionate (see PR thread). - Fix the data-model persistence wording: trade rows mutate in place (status, hold_invoice, amount_sats), they are not status-only. --- rust/src/api/orders.rs | 9 ++++++++- specs/004-mostro-p2p-client/data-model.md | 7 ++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index 2c0ea55..936a4c8 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -3263,7 +3263,14 @@ impl TradeUpdatesStream { loop { match self.rx.recv().await { Ok(update) => return Some(update), - Err(broadcast::error::RecvError::Lagged(_)) => continue, + // Dropped updates degrade, not corrupt: the trades list + // refetches on any later emission, kept-history trades are + // covered by the 2s status poll, and the sweep re-emits + // within 30 min. Log so the (unlikely) case is observable. + Err(broadcast::error::RecvError::Lagged(n)) => { + log::warn!("[orders] trade-updates stream lagged, dropped {n} updates"); + continue; + } Err(broadcast::error::RecvError::Closed) => return None, } } diff --git a/specs/004-mostro-p2p-client/data-model.md b/specs/004-mostro-p2p-client/data-model.md index 4a7ebf0..ef57492 100644 --- a/specs/004-mostro-p2p-client/data-model.md +++ b/specs/004-mostro-p2p-client/data-model.md @@ -111,9 +111,10 @@ trade at a time (v2.0 scope constraint). | completed_at | Timestamp? | When trade finished (null if active) | | outcome | Enum? | `Success`, `Canceled`, `Expired`, `DisputeWon`, `DisputeLost` | -Trade rows are history and normally only mutate `status` — with one -exception: a trade canceled by the daemon while still in -pending/waiting states (never active) is **deleted** rather than kept +Trade rows are history: they are updated in place (`status`, +`hold_invoice`, `amount_sats` — see `update_trade_fields`) but never +deleted, with one exception: a trade canceled by the daemon while still +in pending/waiting states (never active) is **deleted** rather than kept (see `contracts/orders.md` — Daemon cancellation semantics). **Buyer progress steps**: `OrderTaken`, `PayInvoice`, `PaymentLocked`,