Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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