From 47bd1b2c470a38aa57e6f999c339366bd078d2af Mon Sep 17 00:00:00 2001 From: grunch Date: Thu, 9 Apr 2026 12:36:06 -0300 Subject: [PATCH 1/4] fix(trades): gift-wrap status sync, bridge logging, CantDo handling - Add global Kind 1059 subscription for all known trade keys so gift-wrap events are captured even after app restart - Replace local UUID with daemon UUID on NewOrder gift-wrap so subsequent actions (WaitingBuyerInvoice, etc.) update the correct order book entry - Update in-memory order book alongside DB on every gift-wrap status change so tradeStatusProvider reflects real status without waiting for Kind 38383 - Fix FiatSentOk mapping: was Active, now correctly maps to FiatSent - Handle Rate/RateReceived/PaymentFailed actions explicitly - Add bridge_log() to bypass log crate when another logger is installed, forward Rust logs to Flutter debugPrint via _forwardRustLogs() - Add detailed gift-wrap decryption logging ([gift-wrap] tag) - Deduplicate gift-wrap processing across per-trade and global subscriptions - Handle CantDo action: remove rejected order from order book, notify UI via OrderEvent stream, show snackbar with rejection reason - Add WaitingInvoice/WaitingPayment as distinct TradeStatusFilter values with spec-correct colors (#7C2D12/#FED7AA) - Remove noisy [orderBook] update log --- lib/core/app.dart | 40 ++ .../home/providers/home_order_providers.dart | 1 - .../trades/providers/trades_providers.dart | 6 +- .../trades/widgets/trades_list_item.dart | 2 + lib/main.dart | 33 ++ rust/src/api/logging.rs | 27 +- rust/src/api/orders.rs | 420 ++++++++++++++- rust/src/frb_generated.rs | 485 ++++++++++++++---- 8 files changed, 896 insertions(+), 118 deletions(-) diff --git a/lib/core/app.dart b/lib/core/app.dart index d09dd0a8..b828863b 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -7,6 +7,8 @@ import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; import 'package:mostro/features/notifications/services/push_notification_service.dart'; import 'package:mostro/features/settings/providers/settings_provider.dart'; +import 'package:mostro/features/trades/providers/trades_providers.dart'; +import 'package:mostro/src/rust/api/orders.dart' as orders_api; /// Root application widget. /// @@ -20,6 +22,9 @@ class MostroApp extends ConsumerStatefulWidget { ConsumerState createState() => _MostroAppState(); } +/// Global key for showing snackbars from anywhere (e.g. Rust order events). +final rootScaffoldMessengerKey = GlobalKey(); + class _MostroAppState extends ConsumerState { @override void initState() { @@ -32,6 +37,40 @@ class _MostroAppState extends ConsumerState { debugPrint('[app] push notification init failed: $e'); } }); + _listenOrderEvents(); + } + + /// Listen for daemon order rejection events and show a snackbar + refresh trades. + /// + /// Guarded by [_orderEventListenerActive] so hot reload/restart doesn't + /// create duplicate listeners on the same broadcast channel. + static bool _orderEventListenerActive = false; + + void _listenOrderEvents() { + if (_orderEventListenerActive) return; + _orderEventListenerActive = true; + Future.microtask(() async { + try { + final stream = await orders_api.onOrderEvent(); + while (mounted) { + final event = await stream.next(); + if (event == null || !mounted) break; + debugPrint('[order-event] ${event.reason}: ${event.message}'); + rootScaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text(event.message), + duration: const Duration(seconds: 4), + ), + ); + // Refresh the trades list so the removed/canceled order disappears. + ref.invalidate(rawTradesProvider); + } + } catch (e) { + debugPrint('[order-event] listener error: $e'); + } finally { + _orderEventListenerActive = false; + } + }); } @override @@ -47,6 +86,7 @@ class _MostroAppState extends ConsumerState { final themeMode = ref.watch(settingsProvider.select((s) => s.themeMode)); return MaterialApp.router( + scaffoldMessengerKey: rootScaffoldMessengerKey, title: 'Mostro', debugShowCheckedModeBanner: false, theme: buildLightTheme(), diff --git a/lib/features/home/providers/home_order_providers.dart b/lib/features/home/providers/home_order_providers.dart index fe4dfa87..c61f4eb8 100644 --- a/lib/features/home/providers/home_order_providers.dart +++ b/lib/features/home/providers/home_order_providers.dart @@ -157,7 +157,6 @@ final orderBookProvider = StreamProvider.autoDispose>((ref) asyn while (true) { final orders = await stream.next(); if (orders == null) break; - debugPrint('[orderBook] update: ${orders.length} orders'); yield orders.map(OrderItem.fromInfo).toList(); } }); diff --git a/lib/features/trades/providers/trades_providers.dart b/lib/features/trades/providers/trades_providers.dart index b40db68a..69b19ba7 100644 --- a/lib/features/trades/providers/trades_providers.dart +++ b/lib/features/trades/providers/trades_providers.dart @@ -12,6 +12,8 @@ import 'package:mostro/src/rust/api/types.dart' as rust_types; enum TradeStatusFilter { all('All'), pending('Pending'), + waitingInvoice('Waiting Invoice'), + waitingPayment('Waiting Payment'), active('Active'), fiatSent('Fiat Sent'), success('Success'), @@ -75,8 +77,8 @@ class TradeListItem { TradeStatusFilter orderStatusToFilter(rust_types.OrderStatus status) { return switch (status) { rust_types.OrderStatus.pending => TradeStatusFilter.pending, - rust_types.OrderStatus.waitingBuyerInvoice => TradeStatusFilter.pending, - rust_types.OrderStatus.waitingPayment => TradeStatusFilter.pending, + rust_types.OrderStatus.waitingBuyerInvoice => TradeStatusFilter.waitingInvoice, + rust_types.OrderStatus.waitingPayment => TradeStatusFilter.waitingPayment, rust_types.OrderStatus.active => TradeStatusFilter.active, rust_types.OrderStatus.inProgress => TradeStatusFilter.active, rust_types.OrderStatus.fiatSent => TradeStatusFilter.fiatSent, diff --git a/lib/features/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart index dc0a4706..2ee06fa6 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -158,6 +158,8 @@ class TradesListItem extends ConsumerWidget { static (Color, Color) _statusColors(TradeStatusFilter status) { return switch (status) { TradeStatusFilter.pending => AppColors.statusPending, + TradeStatusFilter.waitingInvoice => AppColors.statusWaiting, + TradeStatusFilter.waitingPayment => AppColors.statusWaiting, TradeStatusFilter.active => AppColors.statusActive, TradeStatusFilter.fiatSent => AppColors.statusActive, TradeStatusFilter.success => AppColors.statusSuccess, diff --git a/lib/main.dart b/lib/main.dart index c3d01182..5310ee63 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'package:mostro/src/rust/frb_generated.dart'; import 'package:mostro/src/rust/api.dart' as rust_api; import 'package:mostro/features/settings/providers/nwc_provider.dart'; import 'package:mostro/src/rust/api/nwc.dart' as nwc_api; +import 'package:mostro/src/rust/api/logging.dart' as logging_api; import 'package:mostro/src/rust/api/nostr.dart' as nostr_api; import 'package:mostro/src/rust/api/orders.dart' as orders_api; @@ -76,6 +77,9 @@ Future main() async { // Watch for connection state changes in background (logs appear in flutter output). _watchConnectionState(); + // Forward Rust log entries to debugPrint so they appear in `flutter run`. + _forwardRustLogs(); + final container = ProviderContainer( overrides: [ firstRunProvider.overrideWith( @@ -125,6 +129,35 @@ void _restoreNwcConnection(String nwcUri, ProviderContainer container) { }); } +/// Forward Rust log entries to debugPrint so they are visible in `flutter run`. +/// +/// Only active in debug builds. Filters to gift-wrap, orders, and trade-related +/// tags to avoid flooding the console. +/// Forward Rust log entries to debugPrint so they are visible in `flutter run`. +/// +/// Only active in debug builds. +void _forwardRustLogs() { + if (!kDebugMode) return; + debugPrint('[rust-log] starting Rust log forwarder...'); + Future.microtask(() async { + try { + debugPrint('[rust-log] subscribing to Rust log stream...'); + final stream = await logging_api.onLogEntry(); + debugPrint('[rust-log] subscribed — waiting for entries'); + while (true) { + final entry = await stream.next(); + if (entry == null) { + debugPrint('[rust-log] stream closed'); + break; + } + debugPrint('[rust/${entry.tag}] ${entry.message}'); + } + } catch (e, st) { + debugPrint('[rust-log] bridge error: $e\n$st'); + } + }); +} + /// Guards against overlapping diagnostic order polls on rapid reconnects. bool _isPollingOrders = false; diff --git a/rust/src/api/logging.rs b/rust/src/api/logging.rs index 2ba84264..47b2e752 100644 --- a/rust/src/api/logging.rs +++ b/rust/src/api/logging.rs @@ -53,8 +53,16 @@ pub fn install_log_bridge() { // Install a custom log::Log that forwards every record. // max_level is set to Debug so Info/Warn/Error all flow through. - let _ = log::set_logger(&BRIDGE_LOGGER); - log::set_max_level(log::LevelFilter::Debug); + // If a dependency already set a logger, this fails silently — + // the eprintln! fallback in BridgeLogger::log still works, but the + // Flutter stream won't receive entries via the log crate. + match log::set_logger(&BRIDGE_LOGGER) { + Ok(()) => log::set_max_level(log::LevelFilter::Debug), + Err(e) => eprintln!( + "[logging] WARN: set_logger failed ({e}), another logger is already active. \ + Using direct bridge for Flutter stream." + ), + } }); } @@ -113,6 +121,21 @@ pub(crate) fn forward_log(level: log::Level, target: &str, message: &str) { } } +/// Send a log entry directly to the Flutter stream, bypassing the `log` crate. +/// +/// Use this when the `log` crate logger may have been hijacked by a dependency. +/// The entry is also printed to stderr for terminal visibility. +pub(crate) fn bridge_log(level: log::Level, tag: &str, message: &str) { + eprintln!("[{level}] {tag}: {message}"); + forward_log(level, tag, message); +} + +/// Shorthand helpers — always reach both stderr and the Flutter log stream. +pub(crate) fn blog_info(tag: &str, msg: String) { bridge_log(log::Level::Info, tag, &msg); } +pub(crate) fn blog_warn(tag: &str, msg: String) { bridge_log(log::Level::Warn, tag, &msg); } +pub(crate) fn blog_debug(tag: &str, msg: String) { bridge_log(log::Level::Debug, tag, &msg); } + + // ── FRB stream ─────────────────────────────────────────────────────────────── /// Stream of log entries for consumption by Flutter. diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index e871956a..93b3a037 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -47,6 +47,14 @@ fn store_pending_maker_key(trade_pubkey_hex: &str, index: u32) { } } +/// Maps `trade_pubkey_hex` → `local_uuid` so the NewOrder gift-wrap handler +/// can replace the local UUID with the daemon's UUID in the order book and DB. +static PENDING_LOCAL_BY_TRADE: OnceLock>> = OnceLock::new(); + +fn pending_local_by_trade() -> &'static std::sync::RwLock> { + PENDING_LOCAL_BY_TRADE.get_or_init(|| std::sync::RwLock::new(HashMap::new())) +} + /// Maps `content_key` → `local_uuid` for newly created maker orders. /// /// At create time the order is added to the order book with a locally-generated @@ -232,6 +240,19 @@ impl OrderBook { let _ = self.tx.send(snapshot); } + /// Update the status of an existing cached order and notify listeners. + /// + /// No-op when the order is not in the cache (e.g. already removed). + pub async fn update_order_status(&self, order_id: &str, status: OrderStatus) { + let mut orders = self.orders.write().await; + if let Some(existing) = orders.iter_mut().find(|o| o.id == order_id) { + existing.status = status; + let snapshot = orders.clone(); + drop(orders); + let _ = self.tx.send(snapshot); + } + } + /// Get all cached orders, optionally filtered. pub async fn get_orders(&self, filters: Option) -> Vec { // Clone + filter under the read lock, then drop it before sorting. @@ -309,6 +330,31 @@ impl OrderBook { use tokio::sync::OnceCell; +// ── Gift-wrap deduplication ────────────────────────────────────────────────── + +/// Tracks recently processed gift-wrap event IDs to avoid duplicate processing +/// when both the per-trade and global subscriptions receive the same event. +static PROCESSED_GW: OnceLock>> = OnceLock::new(); + +/// Returns `true` if this event ID was already processed (duplicate). +/// Otherwise records it and returns `false`. +fn is_duplicate_gift_wrap(event_id: &str) -> bool { + const MAX_ENTRIES: usize = 128; + let deque = PROCESSED_GW.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new())); + let mut guard = match deque.lock() { + Ok(g) => g, + Err(_) => return false, + }; + if guard.iter().any(|id| id == event_id) { + return true; + } + guard.push_back(event_id.to_string()); + if guard.len() > MAX_ENTRIES { + guard.pop_front(); + } + false +} + static ORDER_BOOK: OnceCell = OnceCell::const_new(); fn order_book() -> &'static OrderBook { @@ -322,6 +368,52 @@ fn order_book() -> &'static OrderBook { ORDER_BOOK.get().expect("OrderBook not initialized") } +// ── Order event notifications (errors from daemon, etc.) ───────────────────── + +static ORDER_EVENT_TX: OnceLock> = OnceLock::new(); + +fn order_event_sender() -> &'static broadcast::Sender { + ORDER_EVENT_TX.get_or_init(|| { + let (tx, _) = broadcast::channel(32); + tx + }) +} + +/// An event notification pushed from the daemon gift-wrap handler to the UI. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct OrderEvent { + /// The order ID this event relates to (may be the local UUID). + pub order_id: String, + /// Machine-readable reason code (e.g. "out_of_range_sats_amount"). + pub reason: String, + /// Human-readable description for display in a snackbar. + pub message: String, +} + +/// Stream of order event notifications for consumption by Flutter. +pub struct OrderEventStream { + rx: broadcast::Receiver, +} + +impl OrderEventStream { + pub async fn next(&mut self) -> Option { + loop { + match self.rx.recv().await { + Ok(event) => return Some(event), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + } +} + +/// Subscribe to order event notifications (errors, rejections from daemon). +pub fn on_order_event() -> OrderEventStream { + OrderEventStream { + rx: order_event_sender().subscribe(), + } +} + /// Public API: get filtered orders. pub async fn get_orders(filters: Option) -> Result> { Ok(order_book().get_orders(filters).await) @@ -430,8 +522,12 @@ pub async fn create_order(params: NewOrderParams) -> Result { // the keys are written and miss the fingerprint match entirely. store_trade_key_index(&order.id, trade_index).await; // local UUID fallback store_trade_key_index(&ck, trade_index).await; // content fingerprint - store_pending_maker_key(&sender_keys.public_key().to_hex(), trade_index); + let trade_pk_hex = sender_keys.public_key().to_hex(); + store_pending_maker_key(&trade_pk_hex, trade_index); store_pending_local_id(&ck, &order.id); + if let Ok(mut map) = pending_local_by_trade().write() { + map.insert(trade_pk_hex, order.id.clone()); + } order_book().upsert_order(order.clone()).await; let mostro_pubkey = nostr_sdk::PublicKey::from_hex(&active_mostro_pubkey())?; @@ -811,7 +907,7 @@ pub(crate) async fn subscribe_gift_wraps(trade_pubkey: nostr_sdk::PublicKey, tra } let trade_pubkey_hex = trade_pubkey.to_hex(); - log::info!("[orders] gift-wrap subscription active for trade_pubkey={trade_pubkey_hex}"); + crate::api::logging::blog_info("orders", format!("gift-wrap subscription active for trade_pubkey={trade_pubkey_hex}")); use nostr_sdk::RelayPoolNotification; use tokio::time::{timeout, Duration}; @@ -842,10 +938,20 @@ pub(crate) async fn subscribe_gift_wraps(trade_pubkey: nostr_sdk::PublicKey, tra continue; } + let eid = event.id.to_hex(); + if is_duplicate_gift_wrap(&eid) { + continue; + } + crate::api::logging::blog_info("gift-wrap", format!( + "Kind 1059 received (per-trade) for trade={} from={} event_id={}", + &trade_pubkey_hex[..8], + &event.pubkey.to_hex()[..8], + &eid[..16], + )); let event_json = match serde_json::to_string(&*event) { Ok(j) => j, Err(e) => { - log::warn!("[orders] gift-wrap event serialize failed: {e}"); + crate::api::logging::blog_warn("gift-wrap", format!("event serialize failed: {e}")); continue; } }; @@ -854,7 +960,7 @@ pub(crate) async fn subscribe_gift_wraps(trade_pubkey: nostr_sdk::PublicKey, tra process_gift_wrap_rumor(&rumor_json, &trade_pubkey_hex).await; last_activity = tokio::time::Instant::now(); } - Err(e) => log::warn!("[orders] gift-wrap decrypt failed: {e}"), + Err(e) => crate::api::logging::blog_warn("gift-wrap", format!("decrypt failed for trade={}: {e}", &trade_pubkey_hex[..8])), } } Ok(Ok(RelayPoolNotification::Shutdown)) => break, @@ -876,46 +982,95 @@ pub(crate) async fn subscribe_gift_wraps(trade_pubkey: nostr_sdk::PublicKey, tra async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { use mostro_core::message::{Action, Message}; + crate::api::logging::blog_debug("gift-wrap", format!("raw rumor for trade={}: {}", &trade_pubkey_hex[..8], rumor_json)); + // The rumor is a serialised UnsignedEvent; extract its content string. let content = match serde_json::from_str::(rumor_json) { Ok(v) => match v.get("content").and_then(|c| c.as_str()) { Some(s) => s.to_string(), None => { - log::warn!("[orders] gift-wrap rumor has no content field"); + crate::api::logging::blog_warn("gift-wrap", format!("rumor has no content field")); return; } }, Err(e) => { - log::warn!("[orders] gift-wrap rumor JSON parse failed: {e}"); + crate::api::logging::blog_warn("gift-wrap", format!("rumor JSON parse failed: {e}")); return; } }; + crate::api::logging::blog_info("gift-wrap", format!("decrypted content for trade={}: {}", &trade_pubkey_hex[..8], &content)); + // Mostro wire format: [message, null_or_peer] let (msg, _peer): (Message, Option) = match serde_json::from_str(&content) { Ok(p) => p, Err(e) => { - log::warn!("[orders] gift-wrap content deserialize failed: {e}"); + crate::api::logging::blog_warn("gift-wrap", format!("content deserialize failed: {e}")); + crate::api::logging::blog_warn("gift-wrap", format!("raw content was: {content}")); return; } }; let kind = msg.get_inner_message_kind(); - log::info!( - "[orders] gift-wrap action={:?} order_id={:?} trade_pubkey={}", - kind.action, - kind.id, - trade_pubkey_hex - ); + let payload_desc = match &kind.payload { + Some(mostro_core::message::Payload::Order(o)) => format!( + "Order(status={:?}, amount={}, buyer_pk={}, seller_pk={})", + o.status, + o.amount, + o.buyer_trade_pubkey.as_deref().unwrap_or("-"), + o.seller_trade_pubkey.as_deref().unwrap_or("-"), + ), + Some(mostro_core::message::Payload::PaymentRequest(id, pr, amt)) => format!( + "PaymentRequest(id={id:?}, invoice_len={}, amount={amt:?})", + pr.len() + ), + Some(other) => format!("{other:?}"), + None => "None".to_string(), + }; + crate::api::logging::blog_info("gift-wrap", format!( + "action={:?} order_id={:?} trade_index={:?} trade_pubkey={} payload={}", + kind.action, kind.id, kind.trade_index, &trade_pubkey_hex[..8], payload_desc + )); match &kind.action { Action::NewOrder => { if let Some(order_id) = &kind.id { - let order_id_str = order_id.to_string(); - resolve_maker_order(&order_id_str, trade_pubkey_hex).await; - log::info!("[orders] gift-wrap NewOrder: daemon order={order_id_str} confirmed"); + let daemon_id = order_id.to_string(); + resolve_maker_order(&daemon_id, trade_pubkey_hex).await; + + // Replace the local UUID with the daemon's UUID in the order + // book and DB so subsequent gift-wrap actions (which use the + // daemon UUID) can find and update the entry. + let local_id = pending_local_by_trade() + .write() + .ok() + .and_then(|mut m| m.remove(trade_pubkey_hex)); + if let Some(local_id) = local_id { + if local_id != daemon_id { + // Update order book: remove old entry, upsert with new ID. + if let Some(mut info) = order_book().get_order(&local_id).await { + order_book().remove_order(&local_id).await; + info.id = daemon_id.clone(); + order_book().upsert_order(info).await; + } + // Update DB trade record. + if let Some(db) = crate::db::app_db::db() { + if let Err(e) = db.update_trade_order_id(&local_id, &daemon_id).await { + log::warn!( + "[orders] failed to update trade order_id {local_id} → {daemon_id}: {e}" + ); + } + } + crate::api::logging::blog_info("gift-wrap", format!( + "NewOrder: replaced local={local_id} with daemon={daemon_id}" + )); + } + } + crate::api::logging::blog_info("gift-wrap", format!( + "NewOrder: daemon order={daemon_id} confirmed" + )); } else { log::warn!("[orders] gift-wrap NewOrder has no order id"); } @@ -993,6 +1148,27 @@ async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { // Derive the ECDH shared key and store in session so the chat API // can encrypt/decrypt P2P messages and subscribe to the right p-tag. on_peer_pubkey_received(&order_id, trade_pubkey_hex, &peer_pubkey_hex).await; + + // Sync the order status from the payload so the trade doesn't stay + // stuck at Pending in the DB and in-memory order book. + if let Some(new_status) = small_order.status.and_then(map_core_status) { + log::info!( + "[orders] gift-wrap {:?}: syncing order={order_id} status={:?}", + kind.action, + new_status + ); + order_book().update_order_status(&order_id, new_status.clone()).await; + if let Some(db) = crate::db::app_db::db() { + if let Err(e) = db + .update_trade_fields(&order_id, Some(new_status), None, None) + .await + { + log::warn!( + "[orders] failed to sync status for order={order_id}: {e}" + ); + } + } + } } // Mostro sends PayInvoice to the seller with the hold invoice bolt11 // when a buyer takes a sell order (or a seller takes a buy order). @@ -1028,7 +1204,8 @@ async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { bolt11.len(), amount ); - // Save the hold invoice and update status to WaitingPayment in the DB. + // Save the hold invoice and update status to WaitingPayment. + order_book().update_order_status(&order_id, crate::api::types::OrderStatus::WaitingPayment).await; if let Some(db) = crate::db::app_db::db() { if let Err(e) = db .update_trade_fields( @@ -1061,7 +1238,13 @@ async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { | Action::DisputeInitiatedByYou | Action::DisputeInitiatedByPeer | Action::AdminSettled - | Action::AdminCanceled => { + | Action::AdminCanceled + // Rate/RateReceived/PaymentFailed do not change order status but are + // handled explicitly so they don't fall through to the catch-all. + | Action::Rate + | Action::RateUser + | Action::RateReceived + | Action::PaymentFailed => { let order_id = match &kind.id { Some(id) => id.to_string(), None => { @@ -1075,9 +1258,12 @@ async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { Action::WaitingBuyerInvoice => { Some(crate::api::types::OrderStatus::WaitingBuyerInvoice) } - Action::BuyerInvoiceAccepted | Action::FiatSentOk => { + Action::BuyerInvoiceAccepted => { Some(crate::api::types::OrderStatus::Active) } + Action::FiatSentOk => { + Some(crate::api::types::OrderStatus::FiatSent) + } Action::HoldInvoicePaymentSettled | Action::Released | Action::PurchaseCompleted => { Some(crate::api::types::OrderStatus::SettledHoldInvoice) } @@ -1089,6 +1275,8 @@ async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { } Action::CooperativeCancelInitiatedByPeer | Action::CooperativeCancelInitiatedByYou => None, // status doesn't change yet + Action::Rate | Action::RateUser | Action::RateReceived => None, + Action::PaymentFailed => None, // order stays at SettledHoldInvoice Action::DisputeInitiatedByYou | Action::DisputeInitiatedByPeer => { Some(crate::api::types::OrderStatus::Dispute) } @@ -1102,6 +1290,7 @@ async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { kind.action, status ); + order_book().update_order_status(&order_id, status.clone()).await; if let Some(db) = crate::db::app_db::db() { if let Err(e) = db .update_trade_fields(&order_id, Some(status), None, None) @@ -1119,12 +1308,83 @@ async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { ); } } + Action::CantDo => { + // Daemon rejected the action. Extract the reason from the payload + // and notify the UI so it can show a snackbar and clean up. + let reason = match &kind.payload { + Some(mostro_core::message::Payload::CantDo(Some(r))) => format!("{r:?}"), + Some(mostro_core::message::Payload::CantDo(None)) => "unknown".to_string(), + _ => "unknown".to_string(), + }; + // Build a user-friendly message from the reason code. + let message = match reason.as_str() { + "OutOfRangeSatsAmount" => "Order rejected: sats amount is out of the allowed range.".to_string(), + "OutOfRangeFiatAmount" => "Order rejected: fiat amount is out of the allowed range.".to_string(), + "InvalidAmount" => "Order rejected: invalid amount.".to_string(), + "InvalidInvoice" => "Order rejected: invalid Lightning invoice.".to_string(), + "IsNotYourOrder" => "Order rejected: this order does not belong to you.".to_string(), + "NotAllowedByStatus" => "Action rejected: not allowed in the current order status.".to_string(), + "OrderAlreadyCanceled" => "Order is already canceled.".to_string(), + other => format!("Order rejected by Mostro: {other}"), + }; + crate::api::logging::blog_warn("gift-wrap", format!( + "CantDo: reason={reason} order_id={:?}", kind.id + )); + // Remove the local order from the order book if we have an order_id. + // For NewOrder rejections, the local entry may be keyed by a local UUID + // tied to this trade key — look it up from PENDING_LOCAL_BY_TRADE. + let order_id = kind.id.map(|id| id.to_string()).or_else(|| { + pending_local_by_trade() + .write() + .ok() + .and_then(|mut m| m.remove(trade_pubkey_hex)) + }); + if let Some(oid) = &order_id { + order_book().remove_order(oid).await; + if let Some(db) = crate::db::app_db::db() { + let _ = db.update_trade_fields( + oid, + Some(crate::api::types::OrderStatus::Canceled), + None, + None, + ).await; + } + } + // Notify the UI. + let _ = order_event_sender().send(OrderEvent { + order_id: order_id.unwrap_or_default(), + reason, + message, + }); + } action => { log::debug!("[orders] gift-wrap unhandled action={action:?}"); } } } +/// Maps a `mostro_core::order::Status` to the local [`OrderStatus`] enum. +fn map_core_status(s: mostro_core::order::Status) -> Option { + use mostro_core::order::Status as S; + Some(match s { + S::Pending => OrderStatus::Pending, + S::WaitingBuyerInvoice => OrderStatus::WaitingBuyerInvoice, + S::WaitingPayment => OrderStatus::WaitingPayment, + S::Active => OrderStatus::Active, + S::InProgress => OrderStatus::InProgress, + S::FiatSent => OrderStatus::FiatSent, + S::SettledHoldInvoice => OrderStatus::SettledHoldInvoice, + S::Success => OrderStatus::Success, + S::Canceled => OrderStatus::Canceled, + S::CooperativelyCanceled => OrderStatus::CooperativelyCanceled, + S::Expired => OrderStatus::Expired, + S::CanceledByAdmin => OrderStatus::CanceledByAdmin, + S::SettledByAdmin => OrderStatus::SettledByAdmin, + S::CompletedByAdmin => OrderStatus::CompletedByAdmin, + S::Dispute => OrderStatus::Dispute, + }) +} + // ── Peer-pubkey resolution ──────────────────────────────────────────────────── /// Called when the daemon sends `BuyerTookOrder` or `HoldInvoicePaymentAccepted`. @@ -1371,6 +1631,85 @@ pub async fn restart_orders_subscription() { subscribe_orders().await; } +/// Build a map of `trade_pubkey_hex → (Keys, trade_index)` for all derived +/// trade keys so the global subscription can decrypt any gift-wrap. +async fn build_trade_key_map() -> HashMap { + let mut map = HashMap::new(); + let max_index = match crate::api::identity::get_identity().await { + Ok(Some(info)) => info.trade_key_index, + _ => return map, + }; + for idx in 1..=max_index { + match crate::api::identity::get_active_trade_keys(idx).await { + Ok(keys) => { + let hex = keys.public_key().to_hex(); + map.insert(hex, (keys, idx)); + } + Err(e) => log::warn!("[orders] failed to derive trade key {idx}: {e}"), + } + } + map +} + +/// Handle a Kind 1059 event received on the global subscription. +/// +/// Finds which trade key the event is addressed to (via `p` tag), decrypts, +/// logs the full content, and dispatches to `process_gift_wrap_rumor`. +async fn handle_global_gift_wrap( + event: &nostr_sdk::Event, + trade_key_map: &HashMap, +) { + // Find the p-tag that matches one of our trade keys. + let (recipient_hex, recipient_keys) = { + let mut found = None; + for tag in event.tags.iter() { + let s = tag.as_slice(); + if s.first().map(|v| v.as_str()) == Some("p") { + if let Some(pk_hex) = s.get(1).map(|v| v.as_str()) { + if let Some((keys, _idx)) = trade_key_map.get(pk_hex) { + found = Some((pk_hex.to_string(), keys.clone())); + break; + } + } + } + } + match found { + Some(f) => f, + None => { + // Not addressed to any of our known trade keys — skip silently. + return; + } + } + }; + + let eid = event.id.to_hex(); + if is_duplicate_gift_wrap(&eid) { + return; + } + crate::api::logging::blog_info("gift-wrap", format!( + "Kind 1059 received (global) for trade={} from={} event_id={}", + &recipient_hex[..8], + &event.pubkey.to_hex()[..8], + &eid[..16], + )); + + let event_json = match serde_json::to_string(event) { + Ok(j) => j, + Err(e) => { + crate::api::logging::blog_warn("gift-wrap", format!("event serialize failed: {e}")); + return; + } + }; + match crate::nostr::gift_wrap::unwrap(&recipient_keys, &event_json).await { + Ok(rumor_json) => { + process_gift_wrap_rumor(&rumor_json, &recipient_hex).await; + } + Err(e) => crate::api::logging::blog_warn("gift-wrap", format!( + "decrypt failed for trade={}: {e}", &recipient_hex[..8] + )), + } +} + async fn _run_order_subscription() { let Ok(pool) = crate::api::nostr::get_pool() else { log::error!("[orders] subscription failed: relay pool not initialized"); @@ -1388,10 +1727,16 @@ async fn _run_order_subscription() { return; } }; - log::info!( - "[orders] subscribing to Kind 38383 from mostro={}", - mostro_pubkey.to_hex() - ); + crate::api::logging::blog_info("orders", format!("subscribing to Kind 38383 from mostro={}", mostro_pubkey.to_hex())); + + // Build a map of all known trade keys so we can decrypt ANY Kind 1059 + // gift-wrap from Mostro, not just those from the current session. + let trade_key_map = build_trade_key_map().await; + let trade_pubkeys: Vec = trade_key_map + .keys() + .filter_map(|hex| nostr_sdk::PublicKey::from_hex(hex).ok()) + .collect(); + crate::api::logging::blog_info("orders", format!("trade key map: {} keys derived for gift-wrap decryption", trade_pubkeys.len())); // Get notifications receiver before subscribing to avoid missing // events that arrive between the subscribe call and receiver creation. @@ -1400,19 +1745,40 @@ async fn _run_order_subscription() { // Subscribe to ALL orders (no status restriction) so we receive status-change // events (e.g. pending → canceled) and can remove them from the order book. // Display-level filtering (show only pending) is handled in the Dart layer. - let filter = crate::nostr::order_events::all_orders_filter(&mostro_pubkey); - if let Err(e) = client.subscribe(filter, None).await { + let order_filter = crate::nostr::order_events::all_orders_filter(&mostro_pubkey); + if let Err(e) = client.subscribe(order_filter, None).await { log::error!("[orders] subscribe failed: {e}"); return; } - log::info!("[orders] Kind 38383 subscription active — waiting for events"); + + // Subscribe to Kind 1059 (gift-wrap) for ALL known trade pubkeys so we + // capture daemon responses even for trades started in previous sessions. + if !trade_pubkeys.is_empty() { + let gw_filter = nostr_sdk::Filter::new() + .kind(nostr_sdk::Kind::from(1059u16)) + .pubkeys(trade_pubkeys); + if let Err(e) = client.subscribe(gw_filter, None).await { + crate::api::logging::blog_warn("orders", format!("gift-wrap bulk subscribe failed: {e}")); + } else { + crate::api::logging::blog_info("orders", format!("Kind 1059 bulk subscription active for {} trade keys", trade_key_map.len())); + } + } + + crate::api::logging::blog_info("orders", format!("subscriptions active — waiting for events")); use nostr_sdk::RelayPoolNotification; loop { match rx.recv().await { Ok(RelayPoolNotification::Event { event, .. }) => { - log::info!( + // ── Kind 1059 gift-wrap: decrypt and dispatch ── + if event.kind == nostr_sdk::Kind::from(1059u16) { + handle_global_gift_wrap(&event, &trade_key_map).await; + continue; + } + + // ── Kind 38383 order book event ── + log::debug!( "[orders] event kind={} author={}", event.kind, &event.pubkey.to_hex()[..8] diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 921258ff..006c232d 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -45,7 +45,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 = 1165020131; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1025717147; // Section: executor @@ -651,6 +651,70 @@ fn wire__crate__api__orders__OrderBook_set_orders_impl( }, ) } +fn wire__crate__api__orders__OrderBook_update_order_status_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: "OrderBook_update_order_status", + 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); + let api_order_id = ::sse_decode(&mut deserializer); + let api_status = ::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, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::api::orders::OrderBook::update_order_status( + &*api_that_guard, + &api_order_id, + api_status, + ) + .await; + })?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__orders__OrderBook_upsert_order_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -713,6 +777,63 @@ fn wire__crate__api__orders__OrderBook_upsert_order_impl( }, ) } +fn wire__crate__api__orders__OrderEventStream_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: "OrderEventStream_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::OrderEventStream::next(&mut *api_that_guard).await, + )?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__orders__OrdersStream_next_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2859,6 +2980,38 @@ fn wire__crate__api__messages__on_new_message_impl( }, ) } +fn wire__crate__api__orders__on_order_event_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_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "on_order_event", + 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| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::orders::on_order_event())?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__orders__on_orders_updated_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3946,6 +4099,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 ); @@ -4035,6 +4191,16 @@ impl SseDecode for OrderBook { } } +impl SseDecode for OrderEventStream { + // 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 OrdersStream { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -4159,6 +4325,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> { @@ -4866,6 +5042,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 { @@ -5000,6 +5187,20 @@ impl SseDecode for Option> { } } +impl SseDecode for crate::api::orders::OrderEvent { + // 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_reason = ::sse_decode(deserializer); + let mut var_message = ::sse_decode(deserializer); + return crate::api::orders::OrderEvent { + order_id: var_orderId, + reason: var_reason, + message: var_message, + }; + } +} + impl SseDecode for crate::api::orders::OrderFilters { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -5396,201 +5597,211 @@ fn pde_ffi_dispatcher_primary_impl( 11 => { wire__crate__api__orders__OrderBook_set_orders_impl(port, ptr, rust_vec_len, data_len) } - 12 => { + 12 => wire__crate__api__orders__OrderBook_update_order_status_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 13 => { wire__crate__api__orders__OrderBook_upsert_order_impl(port, ptr, rust_vec_len, data_len) } - 13 => wire__crate__api__orders__OrdersStream_next_impl(port, ptr, rust_vec_len, data_len), 14 => { + wire__crate__api__orders__OrderEventStream_next_impl(port, ptr, rust_vec_len, data_len) + } + 15 => wire__crate__api__orders__OrdersStream_next_impl(port, ptr, rust_vec_len, data_len), + 16 => { wire__crate__api__reputation__RatingStream_next_impl(port, ptr, rust_vec_len, data_len) } - 15 => { + 17 => { wire__crate__api__nostr__RelayStatusStream_next_impl(port, ptr, rust_vec_len, data_len) } - 16 => { + 18 => { wire__crate__api__settings__SettingsStream_next_impl(port, ptr, rust_vec_len, data_len) } - 17 => wire__crate__api__messages__UnreadCountStream_next_impl( + 19 => wire__crate__api__messages__UnreadCountStream_next_impl( port, ptr, rust_vec_len, data_len, ), - 18 => { + 20 => { wire__crate__api__nwc__WalletStatusStream_next_impl(port, ptr, rust_vec_len, data_len) } - 19 => wire__crate__api__nostr__add_relay_impl(port, ptr, rust_vec_len, data_len), - 20 => wire__crate__api__orders__cancel_order_impl(port, ptr, rust_vec_len, data_len), - 21 => wire__crate__api__nwc__connect_wallet_impl(port, ptr, rust_vec_len, data_len), - 22 => wire__crate__api__identity__create_identity_impl(port, ptr, rust_vec_len, data_len), - 23 => wire__crate__api__orders__create_order_impl(port, ptr, rust_vec_len, data_len), - 24 => wire__crate__api__identity__delete_identity_impl(port, ptr, rust_vec_len, data_len), - 25 => wire__crate__api__identity__derive_trade_key_impl(port, ptr, rust_vec_len, data_len), - 26 => wire__crate__api__nwc__disconnect_wallet_impl(port, ptr, rust_vec_len, data_len), - 27 => { + 21 => wire__crate__api__nostr__add_relay_impl(port, ptr, rust_vec_len, data_len), + 22 => wire__crate__api__orders__cancel_order_impl(port, ptr, rust_vec_len, data_len), + 23 => wire__crate__api__nwc__connect_wallet_impl(port, ptr, rust_vec_len, data_len), + 24 => wire__crate__api__identity__create_identity_impl(port, ptr, rust_vec_len, data_len), + 25 => wire__crate__api__orders__create_order_impl(port, ptr, rust_vec_len, data_len), + 26 => wire__crate__api__identity__delete_identity_impl(port, ptr, rust_vec_len, data_len), + 27 => wire__crate__api__identity__derive_trade_key_impl(port, ptr, rust_vec_len, data_len), + 28 => wire__crate__api__nwc__disconnect_wallet_impl(port, ptr, rust_vec_len, data_len), + 29 => { wire__crate__api__messages__download_attachment_impl(port, ptr, rust_vec_len, data_len) } - 28 => wire__crate__api__identity__export_encrypted_backup_impl( + 30 => wire__crate__api__identity__export_encrypted_backup_impl( port, ptr, rust_vec_len, data_len, ), - 29 => wire__crate__api__nostr__fetch_mostro_instance_tags_impl( + 31 => wire__crate__api__nostr__fetch_mostro_instance_tags_impl( port, ptr, rust_vec_len, data_len, ), - 30 => wire__crate__api__nostr__flush_message_queue_impl(port, ptr, rust_vec_len, data_len), - 31 => wire__crate__api__get_app_version_impl(port, ptr, rust_vec_len, data_len), - 32 => wire__crate__api__messages__get_attachment_status_impl( + 32 => wire__crate__api__nostr__flush_message_queue_impl(port, ptr, rust_vec_len, data_len), + 33 => wire__crate__api__get_app_version_impl(port, ptr, rust_vec_len, data_len), + 34 => wire__crate__api__messages__get_attachment_status_impl( port, ptr, rust_vec_len, data_len, ), - 33 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), - 34 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), - 35 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), - 36 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), - 37 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), - 38 => wire__crate__api__settings__get_mostro_node_impl(port, ptr, rust_vec_len, data_len), - 39 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), - 40 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), - 41 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), - 42 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), - 43 => { + 35 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), + 36 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), + 37 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), + 38 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), + 39 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), + 40 => wire__crate__api__settings__get_mostro_node_impl(port, ptr, rust_vec_len, data_len), + 41 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), + 43 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), + 44 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), + 45 => { wire__crate__api__reputation__get_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 44 => wire__crate__api__reputation__get_rating_for_trade_impl( + 46 => wire__crate__api__reputation__get_rating_for_trade_impl( port, ptr, rust_vec_len, data_len, ), - 45 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), - 46 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), - 47 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), - 48 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), - 49 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), - 50 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), - 51 => wire__crate__api__disputes__handle_admin_canceled_impl( + 47 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), + 48 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), + 49 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), + 50 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), + 51 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), + 52 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), + 53 => wire__crate__api__disputes__handle_admin_canceled_impl( port, ptr, rust_vec_len, data_len, ), - 52 => { + 54 => { wire__crate__api__disputes__handle_admin_settled_impl(port, ptr, rust_vec_len, data_len) } - 53 => wire__crate__api__disputes__handle_admin_took_dispute_impl( + 55 => wire__crate__api__disputes__handle_admin_took_dispute_impl( port, ptr, rust_vec_len, data_len, ), - 54 => wire__crate__api__reputation__handle_rating_received_impl( + 56 => wire__crate__api__reputation__handle_rating_received_impl( port, ptr, rust_vec_len, data_len, ), - 55 => { + 57 => { wire__crate__api__identity__import_from_mnemonic_impl(port, ptr, rust_vec_len, data_len) } - 56 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), - 57 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), - 58 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), - 59 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), - 60 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), - 61 => wire__crate__api__identity__load_identity_from_mnemonic_impl( + 58 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), + 59 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), + 60 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), + 61 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), + 62 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), + 63 => wire__crate__api__identity__load_identity_from_mnemonic_impl( port, ptr, rust_vec_len, data_len, ), - 62 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), - 63 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), - 64 => wire__crate__api__messages__on_attachment_progress_impl( + 64 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), + 65 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), + 66 => wire__crate__api__messages__on_attachment_progress_impl( port, ptr, rust_vec_len, data_len, ), - 65 => wire__crate__api__nostr__on_connection_state_changed_impl( + 67 => wire__crate__api__nostr__on_connection_state_changed_impl( port, ptr, rust_vec_len, data_len, ), - 66 => { + 68 => { wire__crate__api__disputes__on_dispute_updated_impl(port, ptr, rust_vec_len, data_len) } - 67 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), - 68 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), - 69 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), - 70 => { + 69 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), + 70 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), + 71 => wire__crate__api__orders__on_order_event_impl(port, ptr, rust_vec_len, data_len), + 72 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), + 73 => { wire__crate__api__reputation__on_rating_received_impl(port, ptr, rust_vec_len, data_len) } - 71 => { + 74 => { wire__crate__api__nostr__on_relay_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 72 => { + 75 => { wire__crate__api__settings__on_settings_changed_impl(port, ptr, rust_vec_len, data_len) } - 73 => wire__crate__api__messages__on_unread_count_changed_impl( + 76 => wire__crate__api__messages__on_unread_count_changed_impl( port, ptr, rust_vec_len, data_len, ), - 74 => { + 77 => { wire__crate__api__nwc__on_wallet_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 75 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), - 76 => { + 78 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), + 79 => { wire__crate__api__orders__order_filters_default_impl(port, ptr, rust_vec_len, data_len) } - 77 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), - 78 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), - 79 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), - 80 => wire__crate__api__orders__resolve_maker_order_impl(port, ptr, rust_vec_len, data_len), - 81 => wire__crate__api__orders__restart_orders_subscription_impl( + 80 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), + 81 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), + 82 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), + 83 => wire__crate__api__orders__resolve_maker_order_impl(port, ptr, rust_vec_len, data_len), + 84 => wire__crate__api__orders__restart_orders_subscription_impl( port, ptr, rust_vec_len, data_len, ), - 82 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), - 83 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), - 84 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), - 85 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), - 86 => wire__crate__api__settings__set_default_fiat_code_impl( + 85 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), + 86 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), + 87 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), + 88 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), + 89 => wire__crate__api__settings__set_default_fiat_code_impl( port, ptr, rust_vec_len, data_len, ), - 87 => wire__crate__api__settings__set_default_lightning_address_impl( + 90 => wire__crate__api__settings__set_default_lightning_address_impl( port, ptr, rust_vec_len, data_len, ), - 88 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), - 89 => { + 91 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), + 92 => { wire__crate__api__settings__set_logging_enabled_impl(port, ptr, rust_vec_len, data_len) } - 90 => wire__crate__api__settings__set_mostro_node_impl(port, ptr, rust_vec_len, data_len), - 91 => wire__crate__api__settings__set_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), - 92 => { + 93 => wire__crate__api__settings__set_mostro_node_impl(port, ptr, rust_vec_len, data_len), + 94 => wire__crate__api__settings__set_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), + 95 => { wire__crate__api__reputation__set_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 93 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), - 94 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), - 95 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), - 96 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), - 97 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), + 96 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), + 97 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), + 98 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), + 99 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), + 100 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -5709,6 +5920,21 @@ impl flutter_rust_bridge::IntoIntoDart> for OrderBook { } } +// 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 OrderEventStream { + 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 { @@ -6294,6 +6520,28 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::orders::OrderEvent { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.order_id.into_into_dart().into_dart(), + self.reason.into_into_dart().into_dart(), + self.message.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::orders::OrderEvent +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::orders::OrderEvent +{ + fn into_into_dart(self) -> crate::api::orders::OrderEvent { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::orders::OrderFilters { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -6771,6 +7019,13 @@ impl SseEncode for OrderBook { } } +impl SseEncode for OrderEventStream { + // 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 OrdersStream { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -6883,6 +7138,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> { @@ -7491,6 +7757,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) { @@ -7611,6 +7887,15 @@ impl SseEncode for Option> { } } +impl SseEncode for crate::api::orders::OrderEvent { + // 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.reason, serializer); + ::sse_encode(self.message, serializer); + } +} + impl SseEncode for crate::api::orders::OrderFilters { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -8057,6 +8342,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_generatedRustAutoOpaqueInnerOrderEventStream( + 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_generatedRustAutoOpaqueInnerOrderEventStream( + 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_generatedRustAutoOpaqueInnerOrdersStream( ptr: *const std::ffi::c_void, @@ -8257,6 +8556,20 @@ mod web { MoiArc::>::decrement_strong_count(ptr as _); } + #[wasm_bindgen] + pub fn rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOrderEventStream( + 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_generatedRustAutoOpaqueInnerOrderEventStream( + 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_generatedRustAutoOpaqueInnerOrdersStream( ptr: *const std::ffi::c_void, From 28bddec89e079b4b2fd4b8620a5a84b8e8b46ec1 Mon Sep 17 00:00:00 2001 From: grunch Date: Thu, 9 Apr 2026 14:34:58 -0300 Subject: [PATCH 2/4] fix(orders): wait for daemon confirmation before creating local order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fire-and-forget order creation with a synchronous wait for the daemon's response via a oneshot channel. On CantDo rejection, create_order returns an error to Dart — no phantom pending order is created locally. - Add PENDING_CONFIRMATIONS oneshot channel map keyed by trade_pubkey - create_order registers channel before subscribing/publishing, waits up to 5s for NewOrder (confirmed) or CantDo (rejected) from daemon - On Confirmed: create order book + DB entry with daemon UUID - On Rejected: return Err(message) — Dart catch block shows snackbar - On timeout: create locally with local UUID (optimistic fallback) - Remove OrderEvent/OrderEventStream/on_order_event broadcast machinery - Remove PENDING_LOCAL_BY_TRADE map (no longer needed) - Remove rootScaffoldMessengerKey and _listenOrderEvents from Dart - Persist trade_key_index after successful order creation --- lib/core/app.dart | 40 -- .../order/screens/add_order_screen.dart | 14 +- rust/src/api/orders.rs | 237 +++++----- rust/src/frb_generated.rs | 415 ++++-------------- 4 files changed, 209 insertions(+), 497 deletions(-) diff --git a/lib/core/app.dart b/lib/core/app.dart index b828863b..d09dd0a8 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -7,8 +7,6 @@ import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; import 'package:mostro/features/notifications/services/push_notification_service.dart'; import 'package:mostro/features/settings/providers/settings_provider.dart'; -import 'package:mostro/features/trades/providers/trades_providers.dart'; -import 'package:mostro/src/rust/api/orders.dart' as orders_api; /// Root application widget. /// @@ -22,9 +20,6 @@ class MostroApp extends ConsumerStatefulWidget { ConsumerState createState() => _MostroAppState(); } -/// Global key for showing snackbars from anywhere (e.g. Rust order events). -final rootScaffoldMessengerKey = GlobalKey(); - class _MostroAppState extends ConsumerState { @override void initState() { @@ -37,40 +32,6 @@ class _MostroAppState extends ConsumerState { debugPrint('[app] push notification init failed: $e'); } }); - _listenOrderEvents(); - } - - /// Listen for daemon order rejection events and show a snackbar + refresh trades. - /// - /// Guarded by [_orderEventListenerActive] so hot reload/restart doesn't - /// create duplicate listeners on the same broadcast channel. - static bool _orderEventListenerActive = false; - - void _listenOrderEvents() { - if (_orderEventListenerActive) return; - _orderEventListenerActive = true; - Future.microtask(() async { - try { - final stream = await orders_api.onOrderEvent(); - while (mounted) { - final event = await stream.next(); - if (event == null || !mounted) break; - debugPrint('[order-event] ${event.reason}: ${event.message}'); - rootScaffoldMessengerKey.currentState?.showSnackBar( - SnackBar( - content: Text(event.message), - duration: const Duration(seconds: 4), - ), - ); - // Refresh the trades list so the removed/canceled order disappears. - ref.invalidate(rawTradesProvider); - } - } catch (e) { - debugPrint('[order-event] listener error: $e'); - } finally { - _orderEventListenerActive = false; - } - }); } @override @@ -86,7 +47,6 @@ class _MostroAppState extends ConsumerState { final themeMode = ref.watch(settingsProvider.select((s) => s.themeMode)); return MaterialApp.router( - scaffoldMessengerKey: rootScaffoldMessengerKey, title: 'Mostro', debugShowCheckedModeBanner: false, theme: buildLightTheme(), diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 772c7aaf..b66a31f1 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -8,8 +8,10 @@ import 'package:mostro/features/order/widgets/currency_section.dart'; import 'package:mostro/features/settings/providers/settings_provider.dart'; import 'package:mostro/features/order/widgets/payment_method_section.dart'; import 'package:mostro/features/order/widgets/price_section.dart'; +import 'package:mostro/core/services/identity_service.dart'; import 'package:mostro/features/trades/providers/trades_providers.dart' show refreshTrades; +import 'package:mostro/src/rust/api/identity.dart' as identity_api; import 'package:mostro/src/rust/api/orders.dart' as rust_orders; import 'package:mostro/src/rust/api/types.dart'; @@ -125,14 +127,24 @@ class _AddOrderScreenState extends ConsumerState { ); await rust_orders.createOrder(params: params); + + // Persist the updated trade key index so it survives app restarts. + final identity = await identity_api.getIdentity(); + if (identity != null) { + await IdentityService.saveTradeKeyIndex(identity.tradeKeyIndex); + } + refreshTrades(ref); if (!mounted) return; context.go(AppRoute.orderBook); } catch (e) { if (!mounted) return; + // CantDo rejections from Mostro arrive as errors from createOrder. + // Strip the Rust error prefix for a cleaner message. + final msg = e.toString().replaceFirst(RegExp(r'^.*?AnyhowException\('), '').replaceFirst(RegExp(r'\)$'), ''); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to create order: $e')), + SnackBar(content: Text(msg)), ); } finally { if (mounted) setState(() => _submitting = false); diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index 93b3a037..5aa7555e 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -47,12 +47,21 @@ fn store_pending_maker_key(trade_pubkey_hex: &str, index: u32) { } } -/// Maps `trade_pubkey_hex` → `local_uuid` so the NewOrder gift-wrap handler -/// can replace the local UUID with the daemon's UUID in the order book and DB. -static PENDING_LOCAL_BY_TRADE: OnceLock>> = OnceLock::new(); +// ── Daemon confirmation channel for create_order ───────────────────────────── + +/// Result sent by the gift-wrap handler to the waiting `create_order` call. +enum DaemonConfirmation { + /// Daemon accepted the order and assigned a UUID. + Confirmed { daemon_id: String }, + /// Daemon rejected the order with a CantDo reason. + Rejected { reason: String, message: String }, +} + +/// Maps `trade_pubkey_hex` → oneshot sender for the pending `create_order` call. +static PENDING_CONFIRMATIONS: OnceLock>>> = OnceLock::new(); -fn pending_local_by_trade() -> &'static std::sync::RwLock> { - PENDING_LOCAL_BY_TRADE.get_or_init(|| std::sync::RwLock::new(HashMap::new())) +fn pending_confirmations() -> &'static std::sync::Mutex>> { + PENDING_CONFIRMATIONS.get_or_init(|| std::sync::Mutex::new(HashMap::new())) } /// Maps `content_key` → `local_uuid` for newly created maker orders. @@ -368,52 +377,6 @@ fn order_book() -> &'static OrderBook { ORDER_BOOK.get().expect("OrderBook not initialized") } -// ── Order event notifications (errors from daemon, etc.) ───────────────────── - -static ORDER_EVENT_TX: OnceLock> = OnceLock::new(); - -fn order_event_sender() -> &'static broadcast::Sender { - ORDER_EVENT_TX.get_or_init(|| { - let (tx, _) = broadcast::channel(32); - tx - }) -} - -/// An event notification pushed from the daemon gift-wrap handler to the UI. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct OrderEvent { - /// The order ID this event relates to (may be the local UUID). - pub order_id: String, - /// Machine-readable reason code (e.g. "out_of_range_sats_amount"). - pub reason: String, - /// Human-readable description for display in a snackbar. - pub message: String, -} - -/// Stream of order event notifications for consumption by Flutter. -pub struct OrderEventStream { - rx: broadcast::Receiver, -} - -impl OrderEventStream { - pub async fn next(&mut self) -> Option { - loop { - match self.rx.recv().await { - Ok(event) => return Some(event), - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => return None, - } - } - } -} - -/// Subscribe to order event notifications (errors, rejections from daemon). -pub fn on_order_event() -> OrderEventStream { - OrderEventStream { - rx: order_event_sender().subscribe(), - } -} - /// Public API: get filtered orders. pub async fn get_orders(filters: Option) -> Result> { Ok(order_book().get_orders(filters).await) @@ -476,7 +439,7 @@ pub async fn create_order(params: NewOrderParams) -> Result { // Clone params before the struct takes ownership of its fields. let params_for_dispatch = params.clone(); - let order = OrderInfo { + let mut order = OrderInfo { id: uuid::Uuid::new_v4().to_string(), kind: params.kind, status: OrderStatus::Pending, @@ -499,11 +462,6 @@ pub async fn create_order(params: NewOrderParams) -> Result { let trade_index = trade_key_info.index; let sender_keys = crate::api::identity::get_active_trade_keys(trade_index).await?; - // Subscribe to gift-wrap (Kind 1059) responses from the daemon addressed to - // this trade key. This gives us the daemon-assigned order UUID faster and - // more reliably than waiting for the Kind 38383 content-fingerprint match. - subscribe_gift_wraps(sender_keys.public_key(), trade_index).await; - // Build the content fingerprint key BEFORE publishing so the subscription // loop never races against an empty TRADE_KEY_MAP when the daemon replies // faster than our post-publish bookkeeping runs. @@ -525,10 +483,9 @@ pub async fn create_order(params: NewOrderParams) -> Result { let trade_pk_hex = sender_keys.public_key().to_hex(); store_pending_maker_key(&trade_pk_hex, trade_index); store_pending_local_id(&ck, &order.id); - if let Ok(mut map) = pending_local_by_trade().write() { - map.insert(trade_pk_hex, order.id.clone()); - } - order_book().upsert_order(order.clone()).await; + + // DO NOT add to order book or DB yet — wait for daemon confirmation first. + // This avoids a phantom "pending" order when the daemon rejects (CantDo). let mostro_pubkey = nostr_sdk::PublicKey::from_hex(&active_mostro_pubkey())?; let event_json = actions::new_order( @@ -538,10 +495,65 @@ pub async fn create_order(params: NewOrderParams) -> Result { trade_index, ) .await?; + + // Set up the confirmation channel AFTER building the event but BEFORE + // publishing, so the entry is in the map before any response can arrive. + let (conf_tx, conf_rx) = tokio::sync::oneshot::channel::(); + if let Ok(mut map) = pending_confirmations().lock() { + map.insert(trade_pk_hex.clone(), conf_tx); + } + + // Subscribe to gift-wrap responses AFTER registering the confirmation + // channel so that any events (including stale ones replayed by relays) + // find the entry and notify us instead of being silently discarded. + subscribe_gift_wraps(sender_keys.public_key(), trade_index).await; + publish_event_json(&event_json).await?; - // Persist a TradeInfo for the maker so the order appears in My Trades - // immediately after creation with status Pending / OrderPublished. + crate::api::logging::blog_info("orders", format!( + "create_order published id={} trade_index={trade_index} — waiting for daemon", + order.id + )); + + // Wait for daemon confirmation. The daemon typically responds within 1s. + // The 5s timeout is a safety net for network issues. + let confirmation = tokio::time::timeout( + std::time::Duration::from_secs(5), + conf_rx, + ).await; + + // Clean up the pending entry regardless of outcome. + if let Ok(mut map) = pending_confirmations().lock() { + map.remove(&trade_pk_hex); + } + + // Determine the final order ID (daemon UUID or local fallback). + let final_order_id = match confirmation { + Ok(Ok(DaemonConfirmation::Confirmed { daemon_id })) => { + crate::api::logging::blog_info("orders", format!( + "create_order confirmed by daemon: {daemon_id}" + )); + daemon_id + } + Ok(Ok(DaemonConfirmation::Rejected { reason, message })) => { + crate::api::logging::blog_warn("orders", format!( + "create_order rejected: {reason} — {message}" + )); + return Err(anyhow::anyhow!("{message}")); + } + _ => { + // Timeout — optimistic: use local UUID and add to book/DB. + crate::api::logging::blog_info("orders", format!( + "create_order: no daemon response within 15s, using local id={}", order.id + )); + order.id.clone() + } + }; + + // Order confirmed (or timeout) — now create the local state. + order.id = final_order_id.clone(); + order_book().upsert_order(order.clone()).await; + let maker_role = match order.kind { OrderKind::Sell => crate::api::types::TradeRole::Seller, OrderKind::Buy => crate::api::types::TradeRole::Buyer, @@ -575,11 +587,6 @@ pub async fn create_order(params: NewOrderParams) -> Result { } } - log::info!( - "[orders] create_order dispatched id={} trade_index={trade_index} ck={ck}", - order.id - ); - Ok(order) } @@ -1040,37 +1047,28 @@ async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { let daemon_id = order_id.to_string(); resolve_maker_order(&daemon_id, trade_pubkey_hex).await; - // Replace the local UUID with the daemon's UUID in the order - // book and DB so subsequent gift-wrap actions (which use the - // daemon UUID) can find and update the entry. - let local_id = pending_local_by_trade() - .write() + // If create_order is waiting for confirmation, notify it. + // The caller handles UUID replacement. Otherwise (cold-start / + // reconnect), do the replacement here. + let conf_tx = pending_confirmations() + .lock() .ok() .and_then(|mut m| m.remove(trade_pubkey_hex)); - if let Some(local_id) = local_id { - if local_id != daemon_id { - // Update order book: remove old entry, upsert with new ID. - if let Some(mut info) = order_book().get_order(&local_id).await { - order_book().remove_order(&local_id).await; - info.id = daemon_id.clone(); - order_book().upsert_order(info).await; - } - // Update DB trade record. - if let Some(db) = crate::db::app_db::db() { - if let Err(e) = db.update_trade_order_id(&local_id, &daemon_id).await { - log::warn!( - "[orders] failed to update trade order_id {local_id} → {daemon_id}: {e}" - ); - } - } - crate::api::logging::blog_info("gift-wrap", format!( - "NewOrder: replaced local={local_id} with daemon={daemon_id}" - )); - } + if let Some(tx) = conf_tx { + let _ = tx.send(DaemonConfirmation::Confirmed { + daemon_id: daemon_id.clone(), + }); + crate::api::logging::blog_info("gift-wrap", format!( + "NewOrder: notified waiting create_order daemon={daemon_id}" + )); + } else { + // No waiting caller — cold start or reconnect. Replace UUID + // directly if the local order is still in the order book. + // Look up local UUID from PENDING_LOCAL_IDS via content key. + crate::api::logging::blog_info("gift-wrap", format!( + "NewOrder: daemon order={daemon_id} confirmed (no waiting caller)" + )); } - crate::api::logging::blog_info("gift-wrap", format!( - "NewOrder: daemon order={daemon_id} confirmed" - )); } else { log::warn!("[orders] gift-wrap NewOrder has no order id"); } @@ -1309,14 +1307,11 @@ async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { } } Action::CantDo => { - // Daemon rejected the action. Extract the reason from the payload - // and notify the UI so it can show a snackbar and clean up. let reason = match &kind.payload { Some(mostro_core::message::Payload::CantDo(Some(r))) => format!("{r:?}"), Some(mostro_core::message::Payload::CantDo(None)) => "unknown".to_string(), _ => "unknown".to_string(), }; - // Build a user-friendly message from the reason code. let message = match reason.as_str() { "OutOfRangeSatsAmount" => "Order rejected: sats amount is out of the allowed range.".to_string(), "OutOfRangeFiatAmount" => "Order rejected: fiat amount is out of the allowed range.".to_string(), @@ -1327,35 +1322,23 @@ async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { "OrderAlreadyCanceled" => "Order is already canceled.".to_string(), other => format!("Order rejected by Mostro: {other}"), }; - crate::api::logging::blog_warn("gift-wrap", format!( - "CantDo: reason={reason} order_id={:?}", kind.id - )); - // Remove the local order from the order book if we have an order_id. - // For NewOrder rejections, the local entry may be keyed by a local UUID - // tied to this trade key — look it up from PENDING_LOCAL_BY_TRADE. - let order_id = kind.id.map(|id| id.to_string()).or_else(|| { - pending_local_by_trade() - .write() - .ok() - .and_then(|mut m| m.remove(trade_pubkey_hex)) - }); - if let Some(oid) = &order_id { - order_book().remove_order(oid).await; - if let Some(db) = crate::db::app_db::db() { - let _ = db.update_trade_fields( - oid, - Some(crate::api::types::OrderStatus::Canceled), - None, - None, - ).await; - } + + // If create_order is waiting, notify it — the caller handles + // cleanup and returns an error to Dart. Otherwise ignore stale events. + let conf_tx = pending_confirmations() + .lock() + .ok() + .and_then(|mut m| m.remove(trade_pubkey_hex)); + if let Some(tx) = conf_tx { + crate::api::logging::blog_warn("gift-wrap", format!( + "CantDo: reason={reason} — notifying waiting create_order" + )); + let _ = tx.send(DaemonConfirmation::Rejected { reason, message }); + } else { + crate::api::logging::blog_debug("gift-wrap", format!( + "CantDo: reason={reason} — no waiting caller, ignoring stale event" + )); } - // Notify the UI. - let _ = order_event_sender().send(OrderEvent { - order_id: order_id.unwrap_or_default(), - reason, - message, - }); } action => { log::debug!("[orders] gift-wrap unhandled action={action:?}"); diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 006c232d..b7bc2cd8 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -45,7 +45,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 = -1025717147; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 424169504; // Section: executor @@ -777,63 +777,6 @@ fn wire__crate__api__orders__OrderBook_upsert_order_impl( }, ) } -fn wire__crate__api__orders__OrderEventStream_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: "OrderEventStream_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::OrderEventStream::next(&mut *api_that_guard).await, - )?; - Ok(output_ok) - })() - .await, - ) - } - }, - ) -} fn wire__crate__api__orders__OrdersStream_next_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2980,38 +2923,6 @@ fn wire__crate__api__messages__on_new_message_impl( }, ) } -fn wire__crate__api__orders__on_order_event_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_normal::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "on_order_event", - 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| { - transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_, ()>::Ok(crate::api::orders::on_order_event())?; - Ok(output_ok) - })()) - } - }, - ) -} fn wire__crate__api__orders__on_orders_updated_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -4099,9 +4010,6 @@ 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 ); @@ -4191,16 +4099,6 @@ impl SseDecode for OrderBook { } } -impl SseDecode for OrderEventStream { - // 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 OrdersStream { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -4325,16 +4223,6 @@ 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> { @@ -5042,17 +4930,6 @@ 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 { @@ -5187,20 +5064,6 @@ impl SseDecode for Option> { } } -impl SseDecode for crate::api::orders::OrderEvent { - // 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_reason = ::sse_decode(deserializer); - let mut var_message = ::sse_decode(deserializer); - return crate::api::orders::OrderEvent { - order_id: var_orderId, - reason: var_reason, - message: var_message, - }; - } -} - impl SseDecode for crate::api::orders::OrderFilters { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -5606,202 +5469,198 @@ fn pde_ffi_dispatcher_primary_impl( 13 => { wire__crate__api__orders__OrderBook_upsert_order_impl(port, ptr, rust_vec_len, data_len) } - 14 => { - wire__crate__api__orders__OrderEventStream_next_impl(port, ptr, rust_vec_len, data_len) - } - 15 => wire__crate__api__orders__OrdersStream_next_impl(port, ptr, rust_vec_len, data_len), - 16 => { + 14 => wire__crate__api__orders__OrdersStream_next_impl(port, ptr, rust_vec_len, data_len), + 15 => { wire__crate__api__reputation__RatingStream_next_impl(port, ptr, rust_vec_len, data_len) } - 17 => { + 16 => { wire__crate__api__nostr__RelayStatusStream_next_impl(port, ptr, rust_vec_len, data_len) } - 18 => { + 17 => { wire__crate__api__settings__SettingsStream_next_impl(port, ptr, rust_vec_len, data_len) } - 19 => wire__crate__api__messages__UnreadCountStream_next_impl( + 18 => wire__crate__api__messages__UnreadCountStream_next_impl( port, ptr, rust_vec_len, data_len, ), - 20 => { + 19 => { wire__crate__api__nwc__WalletStatusStream_next_impl(port, ptr, rust_vec_len, data_len) } - 21 => wire__crate__api__nostr__add_relay_impl(port, ptr, rust_vec_len, data_len), - 22 => wire__crate__api__orders__cancel_order_impl(port, ptr, rust_vec_len, data_len), - 23 => wire__crate__api__nwc__connect_wallet_impl(port, ptr, rust_vec_len, data_len), - 24 => wire__crate__api__identity__create_identity_impl(port, ptr, rust_vec_len, data_len), - 25 => wire__crate__api__orders__create_order_impl(port, ptr, rust_vec_len, data_len), - 26 => wire__crate__api__identity__delete_identity_impl(port, ptr, rust_vec_len, data_len), - 27 => wire__crate__api__identity__derive_trade_key_impl(port, ptr, rust_vec_len, data_len), - 28 => wire__crate__api__nwc__disconnect_wallet_impl(port, ptr, rust_vec_len, data_len), - 29 => { + 20 => wire__crate__api__nostr__add_relay_impl(port, ptr, rust_vec_len, data_len), + 21 => wire__crate__api__orders__cancel_order_impl(port, ptr, rust_vec_len, data_len), + 22 => wire__crate__api__nwc__connect_wallet_impl(port, ptr, rust_vec_len, data_len), + 23 => wire__crate__api__identity__create_identity_impl(port, ptr, rust_vec_len, data_len), + 24 => wire__crate__api__orders__create_order_impl(port, ptr, rust_vec_len, data_len), + 25 => wire__crate__api__identity__delete_identity_impl(port, ptr, rust_vec_len, data_len), + 26 => wire__crate__api__identity__derive_trade_key_impl(port, ptr, rust_vec_len, data_len), + 27 => wire__crate__api__nwc__disconnect_wallet_impl(port, ptr, rust_vec_len, data_len), + 28 => { wire__crate__api__messages__download_attachment_impl(port, ptr, rust_vec_len, data_len) } - 30 => wire__crate__api__identity__export_encrypted_backup_impl( + 29 => wire__crate__api__identity__export_encrypted_backup_impl( port, ptr, rust_vec_len, data_len, ), - 31 => wire__crate__api__nostr__fetch_mostro_instance_tags_impl( + 30 => wire__crate__api__nostr__fetch_mostro_instance_tags_impl( port, ptr, rust_vec_len, data_len, ), - 32 => wire__crate__api__nostr__flush_message_queue_impl(port, ptr, rust_vec_len, data_len), - 33 => wire__crate__api__get_app_version_impl(port, ptr, rust_vec_len, data_len), - 34 => wire__crate__api__messages__get_attachment_status_impl( + 31 => wire__crate__api__nostr__flush_message_queue_impl(port, ptr, rust_vec_len, data_len), + 32 => wire__crate__api__get_app_version_impl(port, ptr, rust_vec_len, data_len), + 33 => wire__crate__api__messages__get_attachment_status_impl( port, ptr, rust_vec_len, data_len, ), - 35 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), - 36 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), - 37 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), - 38 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), - 39 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), - 40 => wire__crate__api__settings__get_mostro_node_impl(port, ptr, rust_vec_len, data_len), - 41 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), - 42 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), - 44 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), - 45 => { + 34 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), + 35 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), + 36 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), + 37 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), + 38 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), + 39 => wire__crate__api__settings__get_mostro_node_impl(port, ptr, rust_vec_len, data_len), + 40 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), + 41 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), + 43 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), + 44 => { wire__crate__api__reputation__get_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 46 => wire__crate__api__reputation__get_rating_for_trade_impl( + 45 => wire__crate__api__reputation__get_rating_for_trade_impl( port, ptr, rust_vec_len, data_len, ), - 47 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), - 48 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), - 49 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), - 50 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), - 51 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), - 52 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), - 53 => wire__crate__api__disputes__handle_admin_canceled_impl( + 46 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), + 47 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), + 48 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), + 49 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), + 50 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), + 51 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), + 52 => wire__crate__api__disputes__handle_admin_canceled_impl( port, ptr, rust_vec_len, data_len, ), - 54 => { + 53 => { wire__crate__api__disputes__handle_admin_settled_impl(port, ptr, rust_vec_len, data_len) } - 55 => wire__crate__api__disputes__handle_admin_took_dispute_impl( + 54 => wire__crate__api__disputes__handle_admin_took_dispute_impl( port, ptr, rust_vec_len, data_len, ), - 56 => wire__crate__api__reputation__handle_rating_received_impl( + 55 => wire__crate__api__reputation__handle_rating_received_impl( port, ptr, rust_vec_len, data_len, ), - 57 => { + 56 => { wire__crate__api__identity__import_from_mnemonic_impl(port, ptr, rust_vec_len, data_len) } - 58 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), - 59 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), - 60 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), - 61 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), - 62 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), - 63 => wire__crate__api__identity__load_identity_from_mnemonic_impl( + 57 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), + 58 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), + 59 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), + 60 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), + 61 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), + 62 => wire__crate__api__identity__load_identity_from_mnemonic_impl( port, ptr, rust_vec_len, data_len, ), - 64 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), - 65 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), - 66 => wire__crate__api__messages__on_attachment_progress_impl( + 63 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), + 64 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), + 65 => wire__crate__api__messages__on_attachment_progress_impl( port, ptr, rust_vec_len, data_len, ), - 67 => wire__crate__api__nostr__on_connection_state_changed_impl( + 66 => wire__crate__api__nostr__on_connection_state_changed_impl( port, ptr, rust_vec_len, data_len, ), - 68 => { + 67 => { wire__crate__api__disputes__on_dispute_updated_impl(port, ptr, rust_vec_len, data_len) } - 69 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), - 70 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), - 71 => wire__crate__api__orders__on_order_event_impl(port, ptr, rust_vec_len, data_len), - 72 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), - 73 => { + 68 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), + 69 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), + 70 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), + 71 => { wire__crate__api__reputation__on_rating_received_impl(port, ptr, rust_vec_len, data_len) } - 74 => { + 72 => { wire__crate__api__nostr__on_relay_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 75 => { + 73 => { wire__crate__api__settings__on_settings_changed_impl(port, ptr, rust_vec_len, data_len) } - 76 => wire__crate__api__messages__on_unread_count_changed_impl( + 74 => wire__crate__api__messages__on_unread_count_changed_impl( port, ptr, rust_vec_len, data_len, ), - 77 => { + 75 => { wire__crate__api__nwc__on_wallet_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 78 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), - 79 => { + 76 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), + 77 => { wire__crate__api__orders__order_filters_default_impl(port, ptr, rust_vec_len, data_len) } - 80 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), - 81 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), - 82 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), - 83 => wire__crate__api__orders__resolve_maker_order_impl(port, ptr, rust_vec_len, data_len), - 84 => wire__crate__api__orders__restart_orders_subscription_impl( + 78 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), + 79 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), + 80 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), + 81 => wire__crate__api__orders__resolve_maker_order_impl(port, ptr, rust_vec_len, data_len), + 82 => wire__crate__api__orders__restart_orders_subscription_impl( port, ptr, rust_vec_len, data_len, ), - 85 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), - 86 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), - 87 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), - 88 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), - 89 => wire__crate__api__settings__set_default_fiat_code_impl( + 83 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), + 84 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), + 85 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), + 86 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), + 87 => wire__crate__api__settings__set_default_fiat_code_impl( port, ptr, rust_vec_len, data_len, ), - 90 => wire__crate__api__settings__set_default_lightning_address_impl( + 88 => wire__crate__api__settings__set_default_lightning_address_impl( port, ptr, rust_vec_len, data_len, ), - 91 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), - 92 => { + 89 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), + 90 => { wire__crate__api__settings__set_logging_enabled_impl(port, ptr, rust_vec_len, data_len) } - 93 => wire__crate__api__settings__set_mostro_node_impl(port, ptr, rust_vec_len, data_len), - 94 => wire__crate__api__settings__set_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), - 95 => { + 91 => wire__crate__api__settings__set_mostro_node_impl(port, ptr, rust_vec_len, data_len), + 92 => wire__crate__api__settings__set_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), + 93 => { wire__crate__api__reputation__set_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 96 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), - 97 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), - 98 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), - 99 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), - 100 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), + 94 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), + 95 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), + 96 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), + 97 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), + 98 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -5920,21 +5779,6 @@ impl flutter_rust_bridge::IntoIntoDart> for OrderBook { } } -// 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 OrderEventStream { - 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 { @@ -6520,28 +6364,6 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs -impl flutter_rust_bridge::IntoDart for crate::api::orders::OrderEvent { - fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { - [ - self.order_id.into_into_dart().into_dart(), - self.reason.into_into_dart().into_dart(), - self.message.into_into_dart().into_dart(), - ] - .into_dart() - } -} -impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive - for crate::api::orders::OrderEvent -{ -} -impl flutter_rust_bridge::IntoIntoDart - for crate::api::orders::OrderEvent -{ - fn into_into_dart(self) -> crate::api::orders::OrderEvent { - self - } -} -// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::orders::OrderFilters { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -7019,13 +6841,6 @@ impl SseEncode for OrderBook { } } -impl SseEncode for OrderEventStream { - // 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 OrdersStream { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -7138,17 +6953,6 @@ 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> { @@ -7757,16 +7561,6 @@ 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) { @@ -7887,15 +7681,6 @@ impl SseEncode for Option> { } } -impl SseEncode for crate::api::orders::OrderEvent { - // 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.reason, serializer); - ::sse_encode(self.message, serializer); - } -} - impl SseEncode for crate::api::orders::OrderFilters { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -8342,20 +8127,6 @@ 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_generatedRustAutoOpaqueInnerOrderEventStream( - 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_generatedRustAutoOpaqueInnerOrderEventStream( - 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_generatedRustAutoOpaqueInnerOrdersStream( ptr: *const std::ffi::c_void, @@ -8556,20 +8327,6 @@ mod web { MoiArc::>::decrement_strong_count(ptr as _); } - #[wasm_bindgen] - pub fn rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOrderEventStream( - 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_generatedRustAutoOpaqueInnerOrderEventStream( - 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_generatedRustAutoOpaqueInnerOrdersStream( ptr: *const std::ffi::c_void, From 2a1f42a80d7615b22c13907aa90a74521cdd1e91 Mon Sep 17 00:00:00 2001 From: grunch Date: Thu, 9 Apr 2026 14:38:00 -0300 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20error=20parsing,=20doc,=20persistence,=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use regex capture group for AnyhowException stripping so non-Anyhow errors are not corrupted - Wrap trade_key_index persistence in try-catch so failures don't mask successful order creation - Remove duplicate doc comment and inaccurate filtering mention - Fix timeout log message: 15s → 5s to match actual value --- lib/features/order/screens/add_order_screen.dart | 15 ++++++++++----- lib/main.dart | 4 ---- rust/src/api/orders.rs | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index b66a31f1..c8a61667 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -129,10 +129,13 @@ class _AddOrderScreenState extends ConsumerState { await rust_orders.createOrder(params: params); // Persist the updated trade key index so it survives app restarts. - final identity = await identity_api.getIdentity(); - if (identity != null) { - await IdentityService.saveTradeKeyIndex(identity.tradeKeyIndex); - } + // Failures here are non-fatal — the order was already created. + try { + final identity = await identity_api.getIdentity(); + if (identity != null) { + await IdentityService.saveTradeKeyIndex(identity.tradeKeyIndex); + } + } catch (_) {} refreshTrades(ref); @@ -142,7 +145,9 @@ class _AddOrderScreenState extends ConsumerState { if (!mounted) return; // CantDo rejections from Mostro arrive as errors from createOrder. // Strip the Rust error prefix for a cleaner message. - final msg = e.toString().replaceFirst(RegExp(r'^.*?AnyhowException\('), '').replaceFirst(RegExp(r'\)$'), ''); + final raw = e.toString(); + final anyhowMatch = RegExp(r'^.*?AnyhowException\((.+)\)$').firstMatch(raw); + final msg = anyhowMatch != null ? anyhowMatch.group(1)! : raw; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(msg)), ); diff --git a/lib/main.dart b/lib/main.dart index 5310ee63..baca8069 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -129,10 +129,6 @@ void _restoreNwcConnection(String nwcUri, ProviderContainer container) { }); } -/// Forward Rust log entries to debugPrint so they are visible in `flutter run`. -/// -/// Only active in debug builds. Filters to gift-wrap, orders, and trade-related -/// tags to avoid flooding the console. /// Forward Rust log entries to debugPrint so they are visible in `flutter run`. /// /// Only active in debug builds. diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index 5aa7555e..12dfb409 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -544,7 +544,7 @@ pub async fn create_order(params: NewOrderParams) -> Result { _ => { // Timeout — optimistic: use local UUID and add to book/DB. crate::api::logging::blog_info("orders", format!( - "create_order: no daemon response within 15s, using local id={}", order.id + "create_order: no daemon response within 5s, using local id={}", order.id )); order.id.clone() } From 7cb6c9899596bd990ac40ca1a86bc9b690889bab Mon Sep 17 00:00:00 2001 From: grunch Date: Thu, 9 Apr 2026 14:52:21 -0300 Subject: [PATCH 4/4] fix(orders): late NewOrder reconciliation, sensitive log redaction, subscription race, publish rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewOrder handler reconciles local UUID with daemon UUID when no caller is waiting (timeout/cold-start scenario) using PENDING_LOCAL_IDS lookup - Remove raw rumor JSON, decrypted content, and raw error content from gift-wrap logs — keep only action/kind with truncated trade pubkey - Await relay subscription setup synchronously in subscribe_gift_wraps before returning, then spawn event loop — prevents race where fast daemon response arrives before subscription is active - Rollback TRADE_KEY_MAP, PENDING_MAKER_KEYS, PENDING_LOCAL_IDS, and PENDING_CONFIRMATIONS entries if publish_event_json fails - Log identity persistence failures instead of silently swallowing --- .../order/screens/add_order_screen.dart | 4 +- rust/src/api/orders.rs | 132 +++++++++++------- 2 files changed, 88 insertions(+), 48 deletions(-) diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index c8a61667..7afc5ffe 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -135,7 +135,9 @@ class _AddOrderScreenState extends ConsumerState { if (identity != null) { await IdentityService.saveTradeKeyIndex(identity.tradeKeyIndex); } - } catch (_) {} + } catch (e) { + debugPrint('[orders] save tradeKeyIndex failed: $e'); + } refreshTrades(ref); diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index 12dfb409..632c92c8 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -508,7 +508,14 @@ pub async fn create_order(params: NewOrderParams) -> Result { // find the entry and notify us instead of being silently discarded. subscribe_gift_wraps(sender_keys.public_key(), trade_index).await; - publish_event_json(&event_json).await?; + if let Err(e) = publish_event_json(&event_json).await { + // Rollback all in-memory bookkeeping on publish failure. + if let Ok(mut m) = trade_key_map().write() { m.remove(&order.id); m.remove(&ck); } + if let Ok(mut m) = pending_maker_keys().write() { m.remove(&trade_pk_hex); } + if let Ok(mut m) = pending_local_ids().write() { m.remove(&ck); } + if let Ok(mut m) = pending_confirmations().lock() { m.remove(&trade_pk_hex); } + return Err(e.into()); + } crate::api::logging::blog_info("orders", format!( "create_order published id={} trade_index={trade_index} — waiting for daemon", @@ -883,39 +890,46 @@ pub async fn cancel_order(order_id: String) -> Result<()> { /// - `Action::NewOrder` — daemon confirmed the order; bridges daemon UUID into /// `TRADE_KEY_MAP` via `resolve_maker_order`. /// - All other actions are logged (full trade-session routing is Phase 7+). +/// +/// The relay subscription is established synchronously (awaited) before returning, +/// then the event loop is spawned as a background task. This guarantees the +/// subscription is active before the caller publishes the order event. pub(crate) async fn subscribe_gift_wraps(trade_pubkey: nostr_sdk::PublicKey, trade_index: u32) { - tokio::spawn(async move { - // Fetch keys first — if this fails there is no point subscribing and - // we avoid leaving an orphan relay subscription with no decryption path. - let recipient_keys = match crate::api::identity::get_active_trade_keys(trade_index).await { - Ok(k) => k, - Err(e) => { - log::error!("[orders] subscribe_gift_wraps: no trade keys: {e}"); - return; - } - }; - - let Ok(pool) = crate::api::nostr::get_pool() else { - log::warn!("[orders] subscribe_gift_wraps: relay pool not initialized"); + // ── Synchronous setup: awaited by the caller ── + let recipient_keys = match crate::api::identity::get_active_trade_keys(trade_index).await { + Ok(k) => k, + Err(e) => { + log::error!("[orders] subscribe_gift_wraps: no trade keys: {e}"); return; - }; - let client = pool.client(); + } + }; - // Obtain the notifications receiver BEFORE subscribing to avoid a - // window where daemon responses arrive but aren't captured. - let mut rx = client.notifications(); + let Ok(pool) = crate::api::nostr::get_pool() else { + log::warn!("[orders] subscribe_gift_wraps: relay pool not initialized"); + return; + }; + let client = pool.client(); - let filter = nostr_sdk::Filter::new() - .kind(nostr_sdk::Kind::from(1059u16)) - .pubkey(trade_pubkey); - if let Err(e) = client.subscribe(filter, None).await { - log::warn!("[orders] subscribe_gift_wraps subscribe failed: {e}"); - return; - } + // Obtain the notifications receiver BEFORE subscribing to avoid a + // window where daemon responses arrive but aren't captured. + let mut rx = client.notifications(); + + let filter = nostr_sdk::Filter::new() + .kind(nostr_sdk::Kind::from(1059u16)) + .pubkey(trade_pubkey); + if let Err(e) = client.subscribe(filter, None).await { + log::warn!("[orders] subscribe_gift_wraps subscribe failed: {e}"); + return; + } - let trade_pubkey_hex = trade_pubkey.to_hex(); - crate::api::logging::blog_info("orders", format!("gift-wrap subscription active for trade_pubkey={trade_pubkey_hex}")); + let trade_pubkey_hex = trade_pubkey.to_hex(); + crate::api::logging::blog_info("orders", format!( + "gift-wrap subscription active for trade={}", + &trade_pubkey_hex[..8] + )); + // ── Event loop: spawned as a background task ── + tokio::spawn(async move { use nostr_sdk::RelayPoolNotification; use tokio::time::{timeout, Duration}; @@ -926,7 +940,6 @@ pub(crate) async fn subscribe_gift_wraps(trade_pubkey: nostr_sdk::PublicKey, tra let remaining = Duration::from_secs(IDLE_TIMEOUT_SECS).saturating_sub(last_activity.elapsed()); if remaining.is_zero() { - log::debug!("[orders] gift-wrap idle timeout for trade={trade_pubkey_hex}"); break; } @@ -935,7 +948,6 @@ pub(crate) async fn subscribe_gift_wraps(trade_pubkey: nostr_sdk::PublicKey, tra if event.kind != nostr_sdk::Kind::from(1059u16) { continue; } - // Only process events addressed to our trade pubkey. let is_for_us = event.tags.iter().any(|t| { let s = t.as_slice(); s.first().map(|v| v.as_str()) == Some("p") @@ -967,7 +979,9 @@ pub(crate) async fn subscribe_gift_wraps(trade_pubkey: nostr_sdk::PublicKey, tra process_gift_wrap_rumor(&rumor_json, &trade_pubkey_hex).await; last_activity = tokio::time::Instant::now(); } - Err(e) => crate::api::logging::blog_warn("gift-wrap", format!("decrypt failed for trade={}: {e}", &trade_pubkey_hex[..8])), + Err(e) => crate::api::logging::blog_warn("gift-wrap", format!( + "decrypt failed for trade={}: {e}", &trade_pubkey_hex[..8] + )), } } Ok(Ok(RelayPoolNotification::Shutdown)) => break, @@ -980,8 +994,6 @@ pub(crate) async fn subscribe_gift_wraps(trade_pubkey: nostr_sdk::PublicKey, tra Ok(Ok(_)) => continue, } } - - log::debug!("[orders] gift-wrap subscription exiting for trade={trade_pubkey_hex}"); }); } @@ -989,32 +1001,33 @@ pub(crate) async fn subscribe_gift_wraps(trade_pubkey: nostr_sdk::PublicKey, tra async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { use mostro_core::message::{Action, Message}; - crate::api::logging::blog_debug("gift-wrap", format!("raw rumor for trade={}: {}", &trade_pubkey_hex[..8], rumor_json)); - // The rumor is a serialised UnsignedEvent; extract its content string. let content = match serde_json::from_str::(rumor_json) { Ok(v) => match v.get("content").and_then(|c| c.as_str()) { Some(s) => s.to_string(), None => { - crate::api::logging::blog_warn("gift-wrap", format!("rumor has no content field")); + crate::api::logging::blog_warn("gift-wrap", format!( + "rumor has no content field for trade={}", &trade_pubkey_hex[..8] + )); return; } }, Err(e) => { - crate::api::logging::blog_warn("gift-wrap", format!("rumor JSON parse failed: {e}")); + crate::api::logging::blog_warn("gift-wrap", format!( + "rumor JSON parse failed for trade={}: {e}", &trade_pubkey_hex[..8] + )); return; } }; - crate::api::logging::blog_info("gift-wrap", format!("decrypted content for trade={}: {}", &trade_pubkey_hex[..8], &content)); - // Mostro wire format: [message, null_or_peer] let (msg, _peer): (Message, Option) = match serde_json::from_str(&content) { Ok(p) => p, Err(e) => { - crate::api::logging::blog_warn("gift-wrap", format!("content deserialize failed: {e}")); - crate::api::logging::blog_warn("gift-wrap", format!("raw content was: {content}")); + crate::api::logging::blog_warn("gift-wrap", format!( + "content deserialize failed for trade={}: {e}", &trade_pubkey_hex[..8] + )); return; } }; @@ -1062,12 +1075,37 @@ async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) { "NewOrder: notified waiting create_order daemon={daemon_id}" )); } else { - // No waiting caller — cold start or reconnect. Replace UUID - // directly if the local order is still in the order book. - // Look up local UUID from PENDING_LOCAL_IDS via content key. - crate::api::logging::blog_info("gift-wrap", format!( - "NewOrder: daemon order={daemon_id} confirmed (no waiting caller)" - )); + // No waiting caller — either cold start, reconnect, or + // create_order timed out. Reconcile the local UUID with + // the daemon UUID if the order was persisted under a local ID. + let local_id = pending_local_ids() + .write() + .ok() + .and_then(|mut m| { + // Find the entry whose local_uuid is in the order book. + let key = m.iter() + .find(|(_, uuid)| uuid.as_str() != daemon_id) + .map(|(k, _)| k.clone()); + key.and_then(|k| m.remove(&k)) + }); + if let Some(local_id) = local_id { + if order_book().get_order(&local_id).await.is_some() { + let mut info = order_book().get_order(&local_id).await.unwrap(); + order_book().remove_order(&local_id).await; + info.id = daemon_id.clone(); + order_book().upsert_order(info).await; + if let Some(db) = crate::db::app_db::db() { + let _ = db.update_trade_order_id(&local_id, &daemon_id).await; + } + crate::api::logging::blog_info("gift-wrap", format!( + "NewOrder: late reconciliation local={local_id} → daemon={daemon_id}" + )); + } + } else { + crate::api::logging::blog_info("gift-wrap", format!( + "NewOrder: daemon order={daemon_id} confirmed (no local order to reconcile)" + )); + } } } else { log::warn!("[orders] gift-wrap NewOrder has no order id");