Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions lib/core/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -61,8 +62,9 @@ class _MostroAppState extends ConsumerState<MostroApp> {
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()),
),
);
}
}
23 changes: 9 additions & 14 deletions lib/features/order/screens/my_order_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,15 @@ class _MyOrderScreenState extends ConsumerState<MyOrderScreen> {
'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,
};

Expand All @@ -162,15 +165,7 @@ class _MyOrderScreenState extends ConsumerState<MyOrderScreen> {
_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.
Expand Down
118 changes: 118 additions & 0 deletions lib/shared/widgets/trade_action_listener.dart
Original file line number Diff line number Diff line change
@@ -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<TradeRole?> 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<TradeActionListener> createState() =>
_TradeActionListenerState();
}

class _TradeActionListenerState extends ConsumerState<TradeActionListener> {
/// Updates whose role lookup is still in flight, keyed by
/// `orderId/status`, so a burst of identical emissions navigates once.
final Set<String> _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<String, OrderStatus> _latest = {};

static Future<TradeRole?> _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<void> _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<AsyncValue<TradeUpdate>>(tradeUpdatesProvider, (prev, next) {
final update = next.valueOrNull;
if (update == null) return;
_latest[update.orderId] = update.status;
_handle(update);
});
return widget.child;
}
}
35 changes: 25 additions & 10 deletions rust/src/api/orders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2002,14 +2002,15 @@ 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!(
"[orders] failed to sync status for order={order_id}: {e}"
);
}
}
emit_trade_update(&order_id, new_status);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
// Mostro asks the buyer for a Lightning invoice with AddInvoice. A
Expand Down Expand Up @@ -2052,14 +2053,19 @@ 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!(
"[orders] failed to sync add-invoice for order={order_id}: {e}"
);
}
}
// 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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2172,14 +2179,15 @@ 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!(
"[orders] failed to sync trade status for order={order_id}: {e}"
);
}
}
emit_trade_update(&order_id, status);
} else {
log::debug!(
"[orders] gift-wrap {:?}: order={order_id} (no status change)",
Expand Down Expand Up @@ -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<
Expand All @@ -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<TradeUpdatesStream> {
Ok(TradeUpdatesStream {
rx: trade_updates_tx().subscribe(),
Expand Down
9 changes: 6 additions & 3 deletions rust/src/api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 24 additions & 7 deletions specs/004-mostro-p2p-client/contracts/orders.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TradeUpdate>
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
}
```

Expand Down Expand Up @@ -252,7 +263,13 @@ what to listen to. Reference: <https://mostro.network/protocol/seller_pay_hold_i
| `HoldInvoicePaymentSettled` / `Released` / `PurchaseCompleted` | (status sync) | `status → SettledHoldInvoice` |
| `CooperativeCancelAccepted` | (status sync) | `status → CooperativelyCanceled` |
| `AdminSettled` / `AdminCanceled` | (status sync) | `status → SettledByAdmin` / `CanceledByAdmin` |
| `Canceled` | (none) | Never-active trade (pending/waiting): row + in-memory session **deleted**; otherwise `status → Canceled` (history kept). Emits `TradeUpdate` either way. See below. |
| `Canceled` | (none) | Never-active trade (pending/waiting): row + in-memory session **deleted**; otherwise `status → Canceled` (history kept). See below. |

Every arm above that syncs a status also emits a `TradeUpdate` (see
Comment thread
Catrya marked this conversation as resolved.
`on_trade_updated`) after the in-memory book update and the DB
persistence attempt — DB failures are logged, never suppress the
emission, and leave the row behind the book. `Canceled` included, which
emits whether it wiped the row or kept it as history.

### Daemon cancellation semantics

Expand Down
Loading
Loading