From 46d8cf9ff6345370ad2c7365d6ea4e057184db16 Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 3 Apr 2026 04:09:55 -0300 Subject: [PATCH 1/3] Create orders --- .../order/screens/add_order_screen.dart | 52 +++++++++++++++++-- rust/src/api/orders.rs | 34 ++++++++---- rust/src/mostro/actions.rs | 3 +- 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 5bada3f3..b15e463c 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -7,6 +7,8 @@ import 'package:mostro/core/app_theme.dart'; import 'package:mostro/features/order/widgets/currency_section.dart'; import 'package:mostro/features/order/widgets/payment_method_section.dart'; import 'package:mostro/features/order/widgets/price_section.dart'; +import 'package:mostro/src/rust/api/orders.dart' as rust_orders; +import 'package:mostro/src/rust/api/types.dart'; /// Create order screen — Route `/add_order`. /// @@ -56,6 +58,13 @@ class _AddOrderScreenState extends ConsumerState { final hasPayment = selectedMethods.isNotEmpty || customMethod.isNotEmpty; if (!hasPayment) return false; + final isMarket = ref.read(isMarketPriceProvider); + if (!isMarket) { + final fixedSatsStr = ref.read(fixedSatsProvider); + final sats = BigInt.tryParse(fixedSatsStr); + if (sats == null || sats <= BigInt.zero) return false; + } + if (_isRange) { final min = double.tryParse(_minController.text); final max = double.tryParse(_maxController.text); @@ -73,14 +82,47 @@ class _AddOrderScreenState extends ConsumerState { setState(() => _submitting = true); try { - // TODO (Phase 7): Call create_order() via Rust bridge. - // For now, simulate a short delay and navigate to My Trades. - await Future.delayed(const Duration(milliseconds: 300)); + final fiatCode = ref.read(selectedFiatCodeProvider); + final isMarket = ref.read(isMarketPriceProvider); + final premium = isMarket ? ref.read(premiumValueProvider) : 0.0; + final fixedSatsStr = ref.read(fixedSatsProvider); - if (!mounted) return; + // Sanitize and join payment methods (comma-separated, no special chars). + final sanitized = customMethod + .trim() + .replaceAll(RegExp(r'[,"\\\[\]{}]'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + final allMethods = [ + ...selectedMethods, + if (sanitized.isNotEmpty) sanitized, + ]; + final paymentMethod = allMethods.join(','); + + final params = NewOrderParams( + kind: _isBuy ? OrderKind.buy : OrderKind.sell, + fiatAmount: _isRange ? null : double.tryParse(_amountController.text), + fiatAmountMin: + _isRange ? double.tryParse(_minController.text) : null, + fiatAmountMax: + _isRange ? double.tryParse(_maxController.text) : null, + fiatCode: fiatCode, + paymentMethod: paymentMethod, + premium: premium, + amountSats: (!isMarket && fixedSatsStr.isNotEmpty) + ? BigInt.tryParse(fixedSatsStr) + : null, + ); - // Navigate to My Trades tab (order book / trades screen). + await rust_orders.createOrder(params: params); + + if (!mounted) return; context.go(AppRoute.orderBook); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to create order: $e')), + ); } finally { if (mounted) setState(() => _submitting = false); } diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index 8e3b7e67..ee8f6f2c 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -288,20 +288,34 @@ pub async fn create_order(params: NewOrderParams) -> Result { // Cache locally (optimistic — daemon will publish the real Kind 38383). order_book().upsert_order(order.clone()).await; - // Dispatch new_order to Mostro using the identity key (index 0). - if let Ok(sender_keys) = crate::api::identity::get_active_trade_keys(0).await { - if let Ok(mostro_pubkey) = nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY) { - match actions::new_order(&sender_keys, &mostro_pubkey, ¶ms_for_dispatch).await { - Ok(event_json) => { - if let Err(e) = publish_event_json(&event_json).await { - log::warn!("[orders] create_order publish failed: {e}"); - } else { - log::info!("[orders] create_order dispatched for id={}", order.id); + // Derive a fresh trade key for this order — each order must use a unique + // derived key index so the daemon can verify the trade index in the message. + match crate::api::identity::derive_trade_key().await { + Ok(trade_key_info) => { + let trade_index = trade_key_info.index; + match crate::api::identity::get_active_trade_keys(trade_index).await { + Ok(sender_keys) => { + if let Ok(mostro_pubkey) = nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY) { + match actions::new_order(&sender_keys, &mostro_pubkey, ¶ms_for_dispatch, trade_index).await { + Ok(event_json) => { + if let Err(e) = publish_event_json(&event_json).await { + log::warn!("[orders] create_order publish failed: {e}"); + } else { + store_trade_key_index(&order.id, trade_index).await; + log::info!( + "[orders] create_order dispatched id={} trade_index={trade_index}", + order.id + ); + } + } + Err(e) => log::warn!("[orders] create_order action build failed: {e}"), + } } } - Err(e) => log::warn!("[orders] create_order action build failed: {e}"), + Err(e) => log::warn!("[orders] create_order: could not get trade keys: {e}"), } } + Err(e) => log::warn!("[orders] create_order: could not derive trade key: {e}"), } Ok(order) diff --git a/rust/src/mostro/actions.rs b/rust/src/mostro/actions.rs index abf57e59..feb072e0 100644 --- a/rust/src/mostro/actions.rs +++ b/rust/src/mostro/actions.rs @@ -25,6 +25,7 @@ pub async fn new_order( sender_keys: &Keys, mostro_pubkey: &PublicKey, params: &NewOrderParams, + trade_index: u32, ) -> Result { use mostro_core::order::{Kind, SmallOrder, Status}; @@ -57,7 +58,7 @@ pub async fn new_order( ); let payload = Some(Payload::Order(small_order)); - let msg = Message::new_order(None, None, Some(0), Action::NewOrder, payload); + let msg = Message::new_order(None, None, Some(trade_index as i64), Action::NewOrder, payload); wrap_message(sender_keys, mostro_pubkey, msg).await } From 2e7b4b99d7645462133b40a3536a9abf0a0d9314 Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 3 Apr 2026 04:15:52 -0300 Subject: [PATCH 2/3] Both create_order and take_order now log error if DEFAULT_MOSTRO_PUBKEY is ever malformed, instead of silently swallowing the failure --- rust/src/api/orders.rs | 136 +++++++++++++++++++++-------------------- 1 file changed, 71 insertions(+), 65 deletions(-) diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index ee8f6f2c..43a87676 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -295,21 +295,24 @@ pub async fn create_order(params: NewOrderParams) -> Result { let trade_index = trade_key_info.index; match crate::api::identity::get_active_trade_keys(trade_index).await { Ok(sender_keys) => { - if let Ok(mostro_pubkey) = nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY) { - match actions::new_order(&sender_keys, &mostro_pubkey, ¶ms_for_dispatch, trade_index).await { - Ok(event_json) => { - if let Err(e) = publish_event_json(&event_json).await { - log::warn!("[orders] create_order publish failed: {e}"); - } else { - store_trade_key_index(&order.id, trade_index).await; - log::info!( - "[orders] create_order dispatched id={} trade_index={trade_index}", - order.id - ); + match nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY) { + Ok(mostro_pubkey) => { + match actions::new_order(&sender_keys, &mostro_pubkey, ¶ms_for_dispatch, trade_index).await { + Ok(event_json) => { + if let Err(e) = publish_event_json(&event_json).await { + log::warn!("[orders] create_order publish failed: {e}"); + } else { + store_trade_key_index(&order.id, trade_index).await; + log::info!( + "[orders] create_order dispatched id={} trade_index={trade_index}", + order.id + ); + } } + Err(e) => log::warn!("[orders] create_order action build failed: {e}"), } - Err(e) => log::warn!("[orders] create_order action build failed: {e}"), } + Err(e) => log::error!("[orders] create_order: invalid mostro pubkey: {e}"), } } Err(e) => log::warn!("[orders] create_order: could not get trade keys: {e}"), @@ -406,64 +409,67 @@ pub async fn take_order( // Dispatch the take action to Mostro using the trade key for signing. match crate::api::identity::get_active_trade_keys(trade_index).await { Ok(sender_keys) => { - if let Ok(mostro_pubkey) = nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY) { - // Read default LN address from settings (take-sell-ln-address flow). - let ln_address: Option = - crate::api::settings::get_settings() - .await - .ok() - .and_then(|s| s.default_lightning_address); - let ln_address_ref = ln_address.as_deref(); - - let action_result = match dispatch_role { - TradeRole::Buyer => { - actions::take_sell( - &sender_keys, - &mostro_pubkey, - &order_id, - trade_index, - fiat_amount, - ln_address_ref, - ) - .await - } - TradeRole::Seller => { - actions::take_buy( - &sender_keys, - &mostro_pubkey, - &order_id, - trade_index, - fiat_amount, - ) - .await - } - }; - - match action_result { - Ok(event_json) => { - if let Err(e) = publish_event_json(&event_json).await { - log::warn!("[orders] take_order publish failed: {e}"); - } else { - // Persist trade-key mapping and trade record only after a - // successful publish so failures leave no stale state. - store_trade_key_index(&order_id, trade_index).await; - if let Some(db) = crate::db::app_db::db() { - if let Err(e) = db.save_trade(&trade).await { - log::warn!("[orders] failed to persist trade: {e}"); + match nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY) { + Ok(mostro_pubkey) => { + // Read default LN address from settings (take-sell-ln-address flow). + let ln_address: Option = + crate::api::settings::get_settings() + .await + .ok() + .and_then(|s| s.default_lightning_address); + let ln_address_ref = ln_address.as_deref(); + + let action_result = match dispatch_role { + TradeRole::Buyer => { + actions::take_sell( + &sender_keys, + &mostro_pubkey, + &order_id, + trade_index, + fiat_amount, + ln_address_ref, + ) + .await + } + TradeRole::Seller => { + actions::take_buy( + &sender_keys, + &mostro_pubkey, + &order_id, + trade_index, + fiat_amount, + ) + .await + } + }; + + match action_result { + Ok(event_json) => { + if let Err(e) = publish_event_json(&event_json).await { + log::warn!("[orders] take_order publish failed: {e}"); + } else { + // Persist trade-key mapping and trade record only after a + // successful publish so failures leave no stale state. + store_trade_key_index(&order_id, trade_index).await; + if let Some(db) = crate::db::app_db::db() { + if let Err(e) = db.save_trade(&trade).await { + log::warn!("[orders] failed to persist trade: {e}"); + } } + log::info!( + "[orders] take_order dispatched order={order_id} \ + trade_index={trade_index} ln_address={}", + if ln_address_ref.is_some() { "present" } else { "none" } + ); + // Subscribe to d-tag K38383 updates for this specific order so we + // receive status changes (pending → in-progress → waiting-payment …). + subscribe_single_order(&order_id).await; } - log::info!( - "[orders] take_order dispatched order={order_id} \ - trade_index={trade_index} ln_address={}", - if ln_address_ref.is_some() { "present" } else { "none" } - ); - // Subscribe to d-tag K38383 updates for this specific order so we - // receive status changes (pending → in-progress → waiting-payment …). - subscribe_single_order(&order_id).await; } + Err(e) => log::warn!("[orders] take_order action build failed: {e}"), } - Err(e) => log::warn!("[orders] take_order action build failed: {e}"), } + Err(e) => log::error!("[orders] take_order: invalid mostro pubkey: {e}"), } } Err(e) => log::warn!("[orders] take_order: could not get trade keys: {e}"), From f2e3357af7323f29edf9b662954e0a88afd55a5a Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 3 Apr 2026 04:43:05 -0300 Subject: [PATCH 3/3] fix(orders): correct trade key handling for order creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Derive a fresh trade key (index ≥ 1) when creating an order instead of using the identity key (index 0), fixing the InvalidTradeIndex error returned by the Mostro node - Propagate all dispatch errors so create_order returns Err on failure instead of silently swallowing them and returning Ok - Move optimistic order book upsert to after a successful publish to avoid surfacing failed orders in the UI - Replace the broken store_trade_key_index(local_uuid) call with a PENDING_MAKER_KEYS map keyed by trade pubkey; expose resolve_maker_order() to transfer the entry once the daemon assigns a real order ID - Watch isMarketPriceProvider and fixedSatsProvider in build() so the Submit button re-evaluates reactively when price type or fixed sats change --- .../order/screens/add_order_screen.dart | 17 +++- rust/src/api/orders.rs | 96 ++++++++++++------- 2 files changed, 74 insertions(+), 39 deletions(-) diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index b15e463c..da92d275 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -54,13 +54,16 @@ class _AddOrderScreenState extends ConsumerState { super.dispose(); } - bool _checkValid(List selectedMethods, String customMethod) { + bool _checkValid( + List selectedMethods, + String customMethod, + bool isMarket, + String fixedSatsStr, + ) { final hasPayment = selectedMethods.isNotEmpty || customMethod.isNotEmpty; if (!hasPayment) return false; - final isMarket = ref.read(isMarketPriceProvider); if (!isMarket) { - final fixedSatsStr = ref.read(fixedSatsProvider); final sats = BigInt.tryParse(fixedSatsStr); if (sats == null || sats <= BigInt.zero) return false; } @@ -78,7 +81,9 @@ class _AddOrderScreenState extends ConsumerState { Future _submit() async { final selectedMethods = ref.read(selectedPaymentMethodsProvider); final customMethod = ref.read(customPaymentMethodProvider); - if (_submitting || !_checkValid(selectedMethods, customMethod)) return; + final isMarket = ref.read(isMarketPriceProvider); + final fixedSatsStr = ref.read(fixedSatsProvider); + if (_submitting || !_checkValid(selectedMethods, customMethod, isMarket, fixedSatsStr)) return; setState(() => _submitting = true); try { @@ -137,7 +142,9 @@ class _AddOrderScreenState extends ConsumerState { final inputBg = colors?.backgroundInput ?? const Color(0xFF252A3A); final selectedMethods = ref.watch(selectedPaymentMethodsProvider); final customMethod = ref.watch(customPaymentMethodProvider); - final isValid = _checkValid(selectedMethods, customMethod); + final isMarket = ref.watch(isMarketPriceProvider); + final fixedSatsStr = ref.watch(fixedSatsProvider); + final isValid = _checkValid(selectedMethods, customMethod, isMarket, fixedSatsStr); return Scaffold( appBar: AppBar(title: const Text('CREATING NEW ORDER')), diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index 43a87676..8347d44a 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -27,6 +27,50 @@ fn trade_key_map() -> &'static std::sync::RwLock> { TRADE_KEY_MAP.get_or_init(|| std::sync::RwLock::new(HashMap::new())) } +/// Maps `trade_pubkey_hex` → `trade_key_index` for newly created maker orders. +/// +/// The daemon assigns its own UUID to a new order and publishes it as a Kind +/// 38383 event signed by the daemon (not the maker), so there is no way to +/// derive the real order ID from the Kind 38383 event itself. Instead we keep +/// a short-lived entry keyed by the trade public key and call +/// `resolve_maker_order` once the daemon's gift-wrapped acknowledgement arrives +/// and the real order ID is known. +static PENDING_MAKER_KEYS: OnceLock>> = OnceLock::new(); + +fn pending_maker_keys() -> &'static std::sync::RwLock> { + PENDING_MAKER_KEYS.get_or_init(|| std::sync::RwLock::new(HashMap::new())) +} + +fn store_pending_maker_key(trade_pubkey_hex: &str, index: u32) { + if let Ok(mut map) = pending_maker_keys().write() { + map.insert(trade_pubkey_hex.to_string(), index); + } +} + +/// Resolve a pending maker order once the daemon's gift-wrapped acknowledgement +/// is processed and the real order ID becomes known. +/// +/// Moves the entry from `PENDING_MAKER_KEYS` into the regular `TRADE_KEY_MAP` +/// so that subsequent maker actions (e.g. cancel) can find the trade key via +/// `get_trade_key_index(real_order_id)`. +pub async fn resolve_maker_order(order_id: &str, trade_pubkey_hex: &str) { + let index = pending_maker_keys() + .read() + .ok() + .and_then(|m| m.get(trade_pubkey_hex).copied()); + if let Some(idx) = index { + if let Ok(mut map) = pending_maker_keys().write() { + map.remove(trade_pubkey_hex); + } + store_trade_key_index(order_id, idx).await; + log::info!("[orders] resolved maker order={order_id} trade_index={idx}"); + } else { + log::warn!( + "[orders] resolve_maker_order: no pending entry for pubkey={trade_pubkey_hex}" + ); + } +} + /// Persist `index` for `order_id` in both the in-memory cache and the DB. /// /// The in-memory write is synchronous and always succeeds. The DB write is @@ -285,41 +329,25 @@ pub async fn create_order(params: NewOrderParams) -> Result { is_mine: true, }; - // Cache locally (optimistic — daemon will publish the real Kind 38383). - order_book().upsert_order(order.clone()).await; + // Derive a fresh trade key — each order must use a unique derived key index + // so the daemon can verify the trade index in the message. + let trade_key_info = crate::api::identity::derive_trade_key().await?; + let trade_index = trade_key_info.index; + let sender_keys = crate::api::identity::get_active_trade_keys(trade_index).await?; + let mostro_pubkey = nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY)?; + let event_json = + actions::new_order(&sender_keys, &mostro_pubkey, ¶ms_for_dispatch, trade_index) + .await?; + publish_event_json(&event_json).await?; - // Derive a fresh trade key for this order — each order must use a unique - // derived key index so the daemon can verify the trade index in the message. - match crate::api::identity::derive_trade_key().await { - Ok(trade_key_info) => { - let trade_index = trade_key_info.index; - match crate::api::identity::get_active_trade_keys(trade_index).await { - Ok(sender_keys) => { - match nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY) { - Ok(mostro_pubkey) => { - match actions::new_order(&sender_keys, &mostro_pubkey, ¶ms_for_dispatch, trade_index).await { - Ok(event_json) => { - if let Err(e) = publish_event_json(&event_json).await { - log::warn!("[orders] create_order publish failed: {e}"); - } else { - store_trade_key_index(&order.id, trade_index).await; - log::info!( - "[orders] create_order dispatched id={} trade_index={trade_index}", - order.id - ); - } - } - Err(e) => log::warn!("[orders] create_order action build failed: {e}"), - } - } - Err(e) => log::error!("[orders] create_order: invalid mostro pubkey: {e}"), - } - } - Err(e) => log::warn!("[orders] create_order: could not get trade keys: {e}"), - } - } - Err(e) => log::warn!("[orders] create_order: could not derive trade key: {e}"), - } + // Cache locally and persist the pending trade-key entry only after a + // successful publish so a failure doesn't surface as a successful response. + order_book().upsert_order(order.clone()).await; + store_pending_maker_key(&sender_keys.public_key().to_hex(), trade_index); + log::info!( + "[orders] create_order dispatched id={} trade_index={trade_index}", + order.id + ); Ok(order) }