diff --git a/lib/core/app.dart b/lib/core/app.dart index d09dd0a8..dc2e3fa3 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -7,6 +7,7 @@ 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/shared/widgets/trade_action_listener.dart'; /// Root application widget. /// @@ -61,8 +62,9 @@ class _MostroAppState extends ConsumerState { GlobalCupertinoLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, - builder: (context, child) => - NotificationListenerWidget(child: child ?? const SizedBox.shrink()), + builder: (context, child) => NotificationListenerWidget( + child: TradeActionListener(child: child ?? const SizedBox.shrink()), + ), ); } } diff --git a/lib/features/order/screens/my_order_screen.dart b/lib/features/order/screens/my_order_screen.dart index 9bfd0b3e..ab0cd854 100644 --- a/lib/features/order/screens/my_order_screen.dart +++ b/lib/features/order/screens/my_order_screen.dart @@ -132,12 +132,15 @@ class _MyOrderScreenState extends ConsumerState { 'lastHandledStatus=$_lastHandledStatus orderStatus=${resolvedOrder.status}'); if (liveStatus != null && liveStatus != OrderStatus.pending && liveStatus != _lastHandledStatus) { - // For sellers: skip intermediate WaitingBuyerInvoice but still track it - // so we don't re-process it. Navigate to the appropriate screen when - // status reaches WaitingPayment or beyond. + // Invoice requests are not navigated from here: the app-wide + // TradeActionListener pushes the add/pay-invoice screen for the + // actionable role no matter which screen is open — including this + // one. The counterparty's copy of those statuses is informational + // (e.g. waiting-seller-to-pay persists WaitingPayment on the buyer + // side) and must not navigate either. Track them so they are not + // re-processed, and navigate to the trade detail from Active on. final shouldNavigate = switch (liveStatus) { - OrderStatus.waitingBuyerInvoice when isSelling => false, // skip — intermediate state - OrderStatus.waitingPayment when !isSelling => false, // skip — buyer doesn't see this + OrderStatus.waitingBuyerInvoice || OrderStatus.waitingPayment => false, _ => true, }; @@ -162,15 +165,7 @@ class _MyOrderScreenState extends ConsumerState { _lastHandledStatus = null; return; } - if (intendedStatus == OrderStatus.waitingPayment && isSelling) { - debugPrint('[MyOrderScreen] navigating to PayLightningInvoiceScreen'); - context.go(AppRoute.payInvoicePath(widget.orderId)); - } else if (intendedStatus == OrderStatus.waitingBuyerInvoice && - !isSelling) { - context.go(AppRoute.addInvoicePath(widget.orderId)); - } else { - context.go(AppRoute.tradeDetailPath(widget.orderId)); - } + context.go(AppRoute.tradeDetailPath(widget.orderId)); }); } else { // Mark this status as handled so we don't re-process it on next build. diff --git a/lib/shared/widgets/trade_action_listener.dart b/lib/shared/widgets/trade_action_listener.dart new file mode 100644 index 00000000..5768e770 --- /dev/null +++ b/lib/shared/widgets/trade_action_listener.dart @@ -0,0 +1,118 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:mostro/core/app_routes.dart'; +import 'package:mostro/features/order/providers/trade_state_provider.dart'; +import 'package:mostro/src/rust/api/orders.dart' as orders_api; +import 'package:mostro/src/rust/api/types.dart'; + +/// Auto-opens the invoice screens when the daemon requests action. +/// +/// Both `add-invoice` and `pay-invoice` carry expiration timeouts, so the +/// user must learn about them no matter which screen is open. This widget +/// wraps the app root and listens to [tradeUpdatesProvider], pushed by the +/// Rust ingest after the in-memory book update and the DB persistence +/// attempt (a DB failure never suppresses the emission): the trade row may +/// therefore be missing or stale, which the role lookup tolerates — no +/// role, no navigation. `WaitingBuyerInvoice` sends the buyer to the +/// add-invoice screen, `WaitingPayment` sends the seller to the +/// pay-invoice screen. +/// +/// Only makers ever reach this path — a taker's first reply is consumed by +/// the take waiter in Rust and produces no emission (TakeOrderScreen +/// navigates locally instead). +class TradeActionListener extends ConsumerStatefulWidget { + const TradeActionListener({ + super.key, + required this.child, + this.resolveRole, + this.navigate, + }); + + final Widget child; + + /// Test seam — production uses the bridge's trade-role lookup. + final Future Function(String orderId)? resolveRole; + + /// Test seam — production pushes on the global [appRouter] unless the + /// destination is already the current route. + final void Function(String destination)? navigate; + + @override + ConsumerState createState() => + _TradeActionListenerState(); +} + +class _TradeActionListenerState extends ConsumerState { + /// Updates whose role lookup is still in flight, keyed by + /// `orderId/status`, so a burst of identical emissions navigates once. + final Set _inFlight = {}; + + /// Latest status seen per order, recorded synchronously on every + /// emission. Emissions can arrive while a role lookup awaits (e.g. the + /// startup replay delivers WaitingPayment and Active milliseconds + /// apart); a handler whose status is no longer the latest must not + /// navigate to a screen the trade already left. + final Map _latest = {}; + + static Future _bridgeRole(String orderId) => + orders_api.getTradeRole(orderId: orderId); + + static void _routerNavigate(String destination) { + final current = + appRouter.routerDelegate.currentConfiguration.uri.toString(); + if (current == destination) return; + appRouter.push(destination); + } + + Future _handle(TradeUpdate update) async { + final destination = switch (update.status) { + OrderStatus.waitingBuyerInvoice => + AppRoute.addInvoicePath(update.orderId), + OrderStatus.waitingPayment => AppRoute.payInvoicePath(update.orderId), + _ => null, + }; + if (destination == null) return; + + final key = '${update.orderId}/${update.status}'; + if (!_inFlight.add(key)) return; + try { + final role = await (widget.resolveRole ?? _bridgeRole)(update.orderId); + // A newer emission superseded this one during the lookup. + if (_latest[update.orderId] != update.status) return; + // The daemon addresses add-invoice to the buyer and pay-invoice to + // the seller, but the same statuses also reach the counterparty as + // informational syncs (waiting-seller-to-pay persists WaitingPayment + // on the buyer side too) — those must not navigate. + final actionable = switch (update.status) { + OrderStatus.waitingBuyerInvoice => role == TradeRole.buyer, + OrderStatus.waitingPayment => role == TradeRole.seller, + _ => false, + }; + if (!actionable || !mounted) return; + // Screens expect their role in this map before being navigated to + // (see tradeRoleProvider docs). + ref.read(tradeRoleProvider.notifier).state = { + ...ref.read(tradeRoleProvider), + update.orderId: role == TradeRole.buyer, + }; + (widget.navigate ?? _routerNavigate)(destination); + } catch (e, st) { + debugPrint( + '[TradeActionListener] failed to handle ${update.orderId}: $e\n$st'); + } finally { + _inFlight.remove(key); + } + } + + @override + Widget build(BuildContext context) { + ref.listen>(tradeUpdatesProvider, (prev, next) { + final update = next.valueOrNull; + if (update == null) return; + _latest[update.orderId] = update.status; + _handle(update); + }); + return widget.child; + } +} diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index af8167a4..d2e2e16d 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -2002,7 +2002,7 @@ async fn dispatch_mostro_message( 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) + .update_trade_fields(&order_id, Some(new_status.clone()), None, None) .await { log::warn!( @@ -2010,6 +2010,7 @@ async fn dispatch_mostro_message( ); } } + emit_trade_update(&order_id, new_status); } } // Mostro asks the buyer for a Lightning invoice with AddInvoice. A @@ -2052,7 +2053,7 @@ async fn dispatch_mostro_message( } if let Some(db) = crate::db::app_db::db() { if let Err(e) = db - .update_trade_fields(&order_id, Some(new_status), None, amount) + .update_trade_fields(&order_id, Some(new_status.clone()), None, amount) .await { log::warn!( @@ -2060,6 +2061,11 @@ async fn dispatch_mostro_message( ); } } + // After the book update and the DB attempt, so a listener that + // reacts to the push (e.g. auto-opening the add-invoice screen) + // reads the freshest state available; a logged DB failure does + // not suppress the notification. + emit_trade_update(&order_id, new_status); } // 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). @@ -2126,6 +2132,7 @@ async fn dispatch_mostro_message( ); } } + emit_trade_update(&order_id, crate::api::types::OrderStatus::WaitingPayment); } // Handle remaining status-update actions from the daemon by syncing // the trade status in the DB so My Trades reflects the current state. @@ -2172,7 +2179,7 @@ async fn dispatch_mostro_message( 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) + .update_trade_fields(&order_id, Some(status.clone()), None, None) .await { log::warn!( @@ -2180,6 +2187,7 @@ async fn dispatch_mostro_message( ); } } + emit_trade_update(&order_id, status); } else { log::debug!( "[orders] gift-wrap {:?}: order={order_id} (no status change)", @@ -3598,8 +3606,9 @@ async fn _run_order_subscription() { } } -/// Buffered trade lifecycle updates; cancellations are rare, so a small -/// buffer is ample. +/// Buffered trade lifecycle updates. Every daemon-driven status sync emits +/// one, but they are per-trade progression steps — a handful per trade over +/// minutes — so a small buffer is still ample. const TRADE_UPDATES_CAPACITY: usize = 64; static TRADE_UPDATES: std::sync::OnceLock< @@ -3618,12 +3627,18 @@ pub(crate) fn emit_trade_update(order_id: &str, status: crate::api::types::Order }); } -/// Stream of trade lifecycle changes (daemon-driven cancellations). +/// Stream of trade lifecycle changes pushed by the daemon-message ingest. /// -/// Complements the 2s status polling: after a never-active trade is wiped -/// (see `cancellation_wipes_history`) there is no DB row left to poll, and -/// after a timeout republish the book shows `pending` again — in both cases -/// this push is the only signal the affected screens can react to. +/// Every status a Kind 14 dispatch arm syncs is emitted here, after the +/// in-memory book update and the DB persistence attempt. A DB write failure +/// (or a memory-only session with no DB at all) is logged and does not +/// suppress the emission — the stream means "the daemon moved this trade", +/// not "the DB commit succeeded", so listeners must tolerate a trade row +/// that is missing or behind the book. Complements the 2s status polling in +/// two ways: cancellations that polling cannot observe (a wiped +/// never-active trade has no DB row left, and after a timeout republish the +/// book shows `pending` again), and action requests the user must react to +/// promptly (add-invoice / pay-invoice) no matter which screen is open. pub async fn on_trade_updated() -> Result { Ok(TradeUpdatesStream { rx: trade_updates_tx().subscribe(), diff --git a/rust/src/api/types.rs b/rust/src/api/types.rs index 5d7e53fe..771389e7 100644 --- a/rust/src/api/types.rs +++ b/rust/src/api/types.rs @@ -265,9 +265,12 @@ pub struct TradeInfo { } /// A trade lifecycle change pushed from Rust so the UI does not have to poll -/// for it. Emitted on daemon-driven cancellation — including the wipe of a -/// never-active trade, whose DB row no longer exists by the time this -/// arrives, so polling could never observe the transition. +/// for it. Emitted on every daemon-driven status sync — cancellations +/// (including the wipe of a never-active trade, whose DB row no longer +/// exists by the time this arrives, so polling could never observe the +/// transition) as well as progression statuses like `WaitingBuyerInvoice` +/// and `WaitingPayment`, which screens use to react to the daemon's +/// add-invoice / pay-invoice requests. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct TradeUpdate { pub order_id: String, diff --git a/specs/004-mostro-p2p-client/contracts/orders.md b/specs/004-mostro-p2p-client/contracts/orders.md index a7284601..c513365a 100644 --- a/specs/004-mostro-p2p-client/contracts/orders.md +++ b/specs/004-mostro-p2p-client/contracts/orders.md @@ -196,16 +196,27 @@ Emits whenever the order list changes (new orders, status updates, expirations). Used to keep the UI order list in sync. ### on_trade_updated() → Stream -Push channel for trade lifecycle changes the 2s status polling cannot -observe: a never-active trade is **wiped** from the DB on the daemon's -`Canceled` (no row left to poll), and after a taker-timeout republish -the book reads `pending` again. Emitted by the `Canceled` gift-wrap -handler and the stale-state sweep. Screens filter by `order_id`. +Push channel for daemon-driven trade lifecycle changes. Every status a +Kind 14 dispatch arm syncs is emitted here after the in-memory book +update and the DB persistence **attempt** — a DB write failure (or a +memory-only session, where `db()` is `None`) is logged and does not +suppress the notification, so listeners must not assume the trade row +already reflects the status. Also emitted by the stale-state sweep's +maker resync. Two consumer needs: +changes the 2s status polling cannot observe (a never-active trade is +**wiped** from the DB on the daemon's `Canceled` — no row left to poll — +and after a taker-timeout republish the book reads `pending` again), and +action requests the user must react to promptly — `WaitingBuyerInvoice` / +`WaitingPayment` drive the app-wide auto-navigation to the add-invoice / +pay-invoice screens (`TradeActionListener`, which resolves the trade role +so the counterparty's informational copy of those statuses never +navigates). Take replies produce no emission: the take waiter consumes +them before the dispatch arms run. Screens filter by `order_id`. ```text TradeUpdate { order_id: String - status: OrderStatus # Canceled on wipe; Pending on maker resync + status: OrderStatus # the status just persisted; Pending on maker resync } ``` @@ -252,7 +263,13 @@ what to listen to. Reference: updates; + late List navigated; + + setUp(() { + updates = StreamController(); + navigated = []; + }); + + tearDown(() => updates.close()); + + Future pumpListener( + WidgetTester tester, { + required Future Function(String orderId) resolveRole, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + tradeUpdatesProvider.overrideWith((ref) => updates.stream), + ], + child: TradeActionListener( + resolveRole: resolveRole, + navigate: navigated.add, + child: const SizedBox.shrink(), + ), + ), + ); + return ProviderScope.containerOf( + tester.element(find.byType(TradeActionListener)), + listen: false, + ); + } + + testWidgets('actionable status navigates and records the role', + (tester) async { + final container = await pumpListener( + tester, + resolveRole: (_) async => TradeRole.seller, + ); + + updates.add(const TradeUpdate( + orderId: 'o1', status: OrderStatus.waitingPayment)); + await tester.pump(); + await tester.pump(); + + expect(navigated, [AppRoute.payInvoicePath('o1')]); + expect(container.read(tradeRoleProvider), {'o1': false}); + }); + + testWidgets('buyer is sent to add-invoice on WaitingBuyerInvoice', + (tester) async { + final container = await pumpListener( + tester, + resolveRole: (_) async => TradeRole.buyer, + ); + + updates.add(const TradeUpdate( + orderId: 'o1', status: OrderStatus.waitingBuyerInvoice)); + await tester.pump(); + await tester.pump(); + + expect(navigated, [AppRoute.addInvoicePath('o1')]); + expect(container.read(tradeRoleProvider), {'o1': true}); + }); + + testWidgets('informational copy for the counterparty does not navigate', + (tester) async { + // waiting-seller-to-pay persists WaitingPayment on the buyer side too. + await pumpListener(tester, resolveRole: (_) async => TradeRole.buyer); + + updates.add(const TradeUpdate( + orderId: 'o1', status: OrderStatus.waitingPayment)); + await tester.pump(); + await tester.pump(); + + expect(navigated, isEmpty); + }); + + testWidgets( + 'WaitingPayment superseded by Active during the role lookup ' + 'does not navigate', (tester) async { + // Startup replay delivers the historical statuses milliseconds apart: + // the WaitingPayment handler is still awaiting the role when Active + // lands, so it must drop its stale navigation. + final role = Completer(); + await pumpListener(tester, resolveRole: (_) => role.future); + + updates.add(const TradeUpdate( + orderId: 'o1', status: OrderStatus.waitingPayment)); + await tester.pump(); + updates.add( + const TradeUpdate(orderId: 'o1', status: OrderStatus.active)); + await tester.pump(); + + role.complete(TradeRole.seller); + await tester.pump(); + + expect(navigated, isEmpty); + }); +}