Skip to content
Open
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
76 changes: 69 additions & 7 deletions lib/features/order/providers/trade_state_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,47 @@ final tradeStatusLookupProvider =
},
);

/// Reads the local user's role in a trade through the bridge; injectable so
/// screens that must know whether they already participate can be tested
/// without the Rust side.
/// The local user's role in an order they still take part in
/// ([participatingRole]), read through the bridge; injectable so screens that
/// must know whether they already participate can be tested without the Rust
/// side.
final tradeRoleLookupProvider = Provider<Future<TradeRole?> Function(String)>(
(ref) => (orderId) => orders_api.getTradeRole(orderId: orderId),
(ref) =>
(orderId) async =>
participatingRole(await orders_api.listTrades(), orderId),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major

The role lookup now throws on a storage error; before it returned null. The take screen doesn't handle that.

The old implementation, orders_api.getTradeRole, never failed: get_trade_role logs a DB error and returns Ok(None). orders_api.listTrades() propagates it (db.list_trades().await?), so this closure can now complete with an error.

Both call sites in take_order_screen.dart assume it can't:

  • initState calls _redirectIfParticipant() unawaited with no catch, so the error surfaces as an unhandled async exception (reported to the zone / Crashlytics in release).
  • _onTakeOrder awaits it before setState(() => _cta = TakeOrderCta.loading) with no try. The future throws, _cta stays idle, and the Take button silently does nothing — no snackbar, no retry hint.

Suggested fix: keep the old contract at the seam (an unreadable store means "not a known participant", as getTradeRole did), and log it.

final tradeRoleLookupProvider = Provider<Future<TradeRole?> Function(String)>(
  (ref) => (orderId) async {
    try {
      return participatingRole(await orders_api.listTrades(), orderId);
    } catch (e, st) {
      debugPrint('[tradeRoleLookup] listTrades failed for $orderId: $e\n$st');
      return null;
    }
  },
);

Please add a take-screen test with a throwing lookup that asserts the take is still dispatched (or, if you prefer failing closed, that an error snackbar appears — but not a dead button).

🤖 Prompt for AI Agents
In lib/features/order/providers/trade_state_provider.dart, tradeRoleLookupProvider (line ~48):
wrap the listTrades() call in try/catch, log the error, and return null so the lookup keeps the
never-throws contract getTradeRole had. Add a test in
test/features/order/screens/take_order_screen_test.dart overriding tradeRoleLookupProvider with a
throwing function, and assert tapping "Take order" still dispatches takeOrderActionProvider.

);

/// The role of the user's trade on [orderId] among [trades], or null when
/// they no longer take part in it: no row at all, or only a take that has
/// ended ([isEndedTake]).
///
/// Every row for the order is read, not just one: a database from before
/// takes replaced their order's earlier row can hold two, and a live one
/// among them still makes the user a participant.
TradeRole? participatingRole(Iterable<TradeInfo> trades, String orderId) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚡ Performance | 🟡 Minor

Every take-screen lookup now deserialises the user's whole trade history — twice per take.

tradeRoleLookupProvider runs on initState and again on the Take tap. list_trades reads every trades row, serde_json-decodes each blob, sorts them, and FRB copies the full Vec<TradeInfo> into Dart, just to filter one order.id. get_trade_role hit the idx_trades_order_id expression index instead. For a long-lived account (hundreds of closed trades, each with bond / peer snapshots) that is a noticeable delay right when the user taps Take.

Not blocking for the fix, but since the rule lives in Dart only because Rust has no "all rows for an order" query, consider a narrow bridge call instead (e.g. list_trades_for_order(order_id) using the existing json_extract(data, '$.order.id') = ? index) and keep participatingRole as the pure rule over its result.

for (final trade in trades) {
if (trade.order.id == orderId && !isEndedTake(trade)) return trade.role;
}
return null;
}

/// Whether [trade] is a take whose row has ended, which leaves the user
/// nothing to follow on its order.
///
/// A trade that truly ended leaves its order in a status mostrod never takes
/// it out of: a take needs `Pending`, and only a waiting state goes back to
/// it. So once the order can be taken again, such a row is what a take that
/// never went active left behind: older builds marked it `Canceled` as soon
/// as its cancel went out. Holding on to it sent the user to that dead trade
/// instead of letting them take the order again (#434). Rust already takes
/// over such a row: the confirmed take replaces every earlier row of its
/// order.
///
/// Never a maker's row: its order is theirs, and the take screen is not
/// where they manage it.
bool isEndedTake(TradeInfo trade) =>
!trade.order.isMine && isTerminalTradeStatus(trade.order.status);

/// Takes an order through the bridge; injectable so the take screen's
/// outcomes (loading, already taken, rejected) can be tested without Rust.
final takeOrderActionProvider = Provider<
Expand Down Expand Up @@ -88,7 +122,7 @@ final tradeStatusProvider = StreamProvider.family
final status = await lookup(orderId);
if (status != null) {
yield status;
if (_isTerminal(status)) return;
if (isTerminalTradeStatus(status)) return;
}
await Future.delayed(const Duration(seconds: 2));
}
Expand All @@ -111,8 +145,9 @@ final tradeUpdatesProvider = StreamProvider.autoDispose<TradeUpdate>((
}
});

/// Whether the UI can stop polling. Escrow settlement still awaits payout.
bool _isTerminal(OrderStatus s) => const {
/// Whether a trade in [s] has ended: nothing moves it out again, so the UI
/// can stop polling. Escrow settlement still awaits payout.
bool isTerminalTradeStatus(OrderStatus s) => const {
OrderStatus.success,
OrderStatus.settledByAdmin,
OrderStatus.completedByAdmin,
Expand All @@ -122,6 +157,33 @@ bool _isTerminal(OrderStatus s) => const {
OrderStatus.canceledByAdmin,
}.contains(s);

/// The status a trade shows: its [row]'s persisted one, or the [live] one
/// from [tradeStatusProvider], which reads the order book first.
///
/// The row wins in two cases:
/// * **It has ended** ([isTerminalTradeStatus]). Whatever the book says about
/// the order later is no longer this trade. The one way such a row can be
/// wrong is the cancel's optimistic write on an active trade: a cooperative
/// cancel the peer never accepts, on a trade that then completes.
/// * **It is a take ([isTake]) and the book says `pending`.** A public
/// `pending` means nobody holds the order, so it is never a take's status.
/// Older builds marked a take `Canceled` as soon as its cancel went out,
/// even before it went active; once the daemon put the order back in the
/// book, that `pending` read as the user's own order, with a Cancel the
/// daemon refuses (`IsNotYourOrder`). A take parked at `WaitingTakerBond`
/// is another: publicly its order is still `pending`.
///
/// Otherwise the live status, or the row's while there is none yet.
OrderStatus shownTradeStatus({
required OrderStatus row,
required OrderStatus? live,
required bool isTake,
}) {
if (live == null || isTerminalTradeStatus(row)) return row;
if (isTake && live == OrderStatus.pending) return row;
return live;
}

/// Loads the buyer/seller role for a trade from the persistent DB.
///
/// Returns `true` when the local user is the buyer, `false` for seller, or
Expand Down
25 changes: 10 additions & 15 deletions lib/features/trades/providers/trade_rows_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ class TradeRow {

final String orderId;

/// Live when the order is still moving, else the persisted status.
/// Live when the order is still moving, else the persisted status — see
/// [shownTradeStatus], which the trade screen shares.
final rust_types.OrderStatus status;
final TradeRowState state;
final bool isSelling;
Expand All @@ -59,16 +60,6 @@ class TradeRow {
final String? peerHandle;
}

const _terminal = {
rust_types.OrderStatus.success,
rust_types.OrderStatus.settledByAdmin,
rust_types.OrderStatus.completedByAdmin,
rust_types.OrderStatus.canceled,
rust_types.OrderStatus.expired,
rust_types.OrderStatus.cooperativelyCanceled,
rust_types.OrderStatus.canceledByAdmin,
};

const _successes = {
rust_types.OrderStatus.success,
rust_types.OrderStatus.settledByAdmin,
Expand All @@ -89,10 +80,14 @@ final tradeRowsProvider = Provider<AsyncValue<List<TradeRow>>>((ref) {
TradeRow _row(Ref ref, rust_types.TradeInfo trade, {required bool canRate}) {
final order = trade.order;
final persisted = order.status;
final status =
_terminal.contains(persisted)
? persisted
: ref.watch(tradeStatusProvider(order.id)).valueOrNull ?? persisted;
final status = shownTradeStatus(
row: persisted,
live:
isTerminalTradeStatus(persisted)
? null
: ref.watch(tradeStatusProvider(order.id)).valueOrNull,
isTake: !order.isMine,
);
final ratedByMe =
trade.ratedAt != null ||
(_successes.contains(status) && ref.watch(ratedByMeProvider(order.id)));
Expand Down
35 changes: 31 additions & 4 deletions lib/features/trades/screens/trade_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import 'package:mostro/shared/widgets/mostro_reactive_button.dart';
import 'package:mostro/src/rust/api/disputes.dart' as disputes_api;
import 'package:mostro/src/rust/api/orders.dart' as orders_api;
import 'package:mostro/src/rust/api/reputation.dart' as reputation_api;
import 'package:mostro/src/rust/api/types.dart' show TradeInfo;

export 'package:mostro/features/trades/models/trade_status.dart';

Expand Down Expand Up @@ -189,9 +190,23 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
/// await: the seller's payment can land while the cancel dialog is open.
TradeStatus _liveStatus(TradeStatus fallback) {
final live = ref.read(tradeStatusProvider(widget.orderId)).valueOrNull;
return live == null ? fallback : tradeStatusFromOrderStatus(live);
if (live == null) return fallback;
final trade = ref.read(tradeInfoProvider(widget.orderId)).valueOrNull;
return tradeStatusFromOrderStatus(_shown(live, trade));
}

/// What [live] shows for this trade once its [trade] row is read in
/// ([shownTradeStatus]); [live] itself while the row has not loaded, or
/// when there is none.
static OrderStatus _shown(OrderStatus live, TradeInfo? trade) =>
trade == null
? live
: shownTradeStatus(
row: trade.order.status,
live: live,
isTake: !trade.order.isMine,
);

Future<void> _cancelOrder(TradeStatus status) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
Expand All @@ -207,8 +222,14 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
dialogRef
.watch(tradeStatusProvider(widget.orderId))
.valueOrNull;
final trade =
dialogRef
.watch(tradeInfoProvider(widget.orderId))
.valueOrNull;
final now =
live == null ? status : tradeStatusFromOrderStatus(live);
live == null
? status
: tradeStatusFromOrderStatus(_shown(live, trade));
return Text(_cancelDialogContent(l10n, now));
},
),
Expand Down Expand Up @@ -405,7 +426,8 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
debugPrint('[TradeDetailScreen] trade status failed: ${live.error}');
}
if (!live.hasValue) return TradeStatus.loading;
final status = tradeStatusFromOrderStatus(live.value!);
final trade = ref.watch(tradeInfoProvider(widget.orderId)).valueOrNull;
final status = tradeStatusFromOrderStatus(_shown(live.value!, trade));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major

The leftover take can still flash pending with Cancel while the row is loading — the exact bug this PR fixes.

_status() resolves as soon as tradeStatusProvider has a value, but trade comes from tradeInfoProvider, which awaits rawTradesProvider (a full listTrades() round trip). While that future is pending, _shown(live, null) returns the live status unchanged — the book's public pending.

Nothing else holds the screen in loading during that window: build() gates on _isBuyer(), which resolves from tradeRoleProvider or tradeRoleFromDbProvider (the indexed getTradeRole), independently of rawTradesProvider. For a leftover Canceled take opened cold (from a notification, the chat header, or after a restart), the role typically arrives first, so for one or more frames the screen renders TradeStatus.pendingTradeView offers trade.cancel → a tap publishes the cancel the daemon answers with CantDo(IsNotYourOrder) and the local cancel removes the live order from the book (#434, third bullet).

The same null-row fallback also applies to the haptic listener (L470): a pending → in-progress book change that lands before the row loads still nudges.

The tests don't catch this because _pumpRoutedTradeDetail resolves loadTrades immediately.

Suggested fix: don't let a live pending through until the row is known.

TradeStatus _status() {
  final live = ref.watch(tradeStatusProvider(widget.orderId));
  // …
  if (!live.hasValue) return TradeStatus.loading;
  final tradeAsync = ref.watch(tradeInfoProvider(widget.orderId));
  // A public `pending` may be a take's leftover (#434): only the row can
  // say, so hold `loading` rather than offer a Cancel the daemon refuses.
  if (live.value == OrderStatus.pending && tradeAsync.isLoading) {
    return TradeStatus.loading;
  }
  final status = tradeStatusFromOrderStatus(
    _shown(live.value!, tradeAsync.valueOrNull),
  );
  // …
}

and add a widget test where loadTrades completes after the status stream has emitted pending, asserting _cancelButton() is never found.

🤖 Prompt for AI Agents
In lib/features/trades/screens/trade_detail_screen.dart, _status() (around line 429): when the
live status is OrderStatus.pending and tradeInfoProvider(widget.orderId) is still loading, return
TradeStatus.loading instead of passing a null row to _shown(). Apply the same guard to the haptic
listener (around line 470): skip the nudge while the row is unresolved. Add a widget test in
test/features/trades/trade_detail_screen_test.dart where the status stream emits pending before
loadTrades completes with a Canceled take row, and assert the Cancel button never appears.

if (status != TradeStatus.pendingRating) return status;
final rating = ref.watch(tradeRatingProvider(widget.orderId));
if (rating.isLoading && !rating.hasValue) return TradeStatus.loading;
Expand Down Expand Up @@ -439,13 +461,18 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
// A step advanced by the relay: a nudge, the crossfades below, and a
// fresh deadline — every step has its own expiration, owned by a
// different party, so the clock loaded for the previous step is stale.
// Judged on what the screen shows: a book change the row outranks is
// not a step of this trade.
ref.listen<AsyncValue<OrderStatus>>(tradeStatusProvider(widget.orderId), (
previous,
next,
) {
final trade = ref.read(tradeInfoProvider(widget.orderId)).valueOrNull;
final before = previous?.valueOrNull;
final after = next.valueOrNull;
if (before != null && after != null && before != after) {
if (before != null &&
after != null &&
_shown(before, trade) != _shown(after, trade)) {
HapticFeedback.mediumImpact();
_loadExpiresAt();
}
Expand Down
21 changes: 21 additions & 0 deletions specs/004-mostro-p2p-client/contracts/orders.md
Original file line number Diff line number Diff line change
Expand Up @@ -749,3 +749,24 @@ The seller pay-invoice flow uses two complementary providers from
advancing past the pay-invoice screen; the NWC widget's local
`onPaymentSuccess` callback only flips a spinner flag and does not
navigate.

`tradeStatusProvider` reads the order book first, and the book holds the
order's public view. So the My Trades list and `TradeDetailScreen` show a
trade through `shownTradeStatus`, where the trade row wins in two cases:

- **The row has ended** (success or a cancelled family): whatever the book
says about the order afterwards is no longer this trade.
- **A take, and the book says `pending`**: a public `pending` means nobody
holds the order, so it is never a take's status. That covers a take left
`Canceled` by builds that wrote the status before the daemon answered
(the daemon later put the order back in the book), and a take parked at
`WaitingTakerBond`, whose order is still `pending` in public.

Every other live status wins over an open row.

The take screen sends a user who already takes part in the order to the
trade instead of offering to take it again (`tradeRoleLookupProvider`,
`participatingRole`). A take whose row has ended does not count: once its
order can be taken again, such a row can only be what a take that never
went active left behind, and `take_order` replaces it with the new take's
row. A maker's row always counts, and so does any row still open.
130 changes: 130 additions & 0 deletions test/features/order/providers/trade_state_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mostro/features/order/providers/trade_state_provider.dart';
import 'package:mostro/src/rust/api/types.dart';

import '../../../support/fake_trades.dart';
import '../../../support/provider_harness.dart';

void main() {
Expand Down Expand Up @@ -47,4 +48,133 @@ void main() {
expect(lookups, 2);
expect(requestedOrders, ['order-payout', 'order-payout']);
});

group('shownTradeStatus', () {
test('an ended row wins over any live status', () {
for (final row in OrderStatus.values.where(isTerminalTradeStatus)) {
for (final live in OrderStatus.values) {
for (final isTake in [true, false]) {
expect(
shownTradeStatus(row: row, live: live, isTake: isTake),
row,
reason: 'row=$row live=$live isTake=$isTake',
);
}
}
}
});

test('a public pending never shows over a take that is still open', () {
final open = OrderStatus.values.where((s) => !isTerminalTradeStatus(s));
for (final row in open) {
expect(
shownTradeStatus(row: row, live: OrderStatus.pending, isTake: true),
row,
reason: 'row=$row',
);
// The maker's own order is `pending` for real.
expect(
shownTradeStatus(row: row, live: OrderStatus.pending, isTake: false),
OrderStatus.pending,
reason: 'row=$row',
);
}
});

test('any other live status wins over an open row', () {
final open = OrderStatus.values.where((s) => !isTerminalTradeStatus(s));
for (final row in open) {
for (final live in OrderStatus.values) {
if (live == OrderStatus.pending) continue;
for (final isTake in [true, false]) {
expect(
shownTradeStatus(row: row, live: live, isTake: isTake),
live,
reason: 'row=$row live=$live isTake=$isTake',
);
}
}
}
});

test('the row stands in while there is no live status yet', () {
expect(
shownTradeStatus(
row: OrderStatus.waitingPayment,
live: null,
isTake: true,
),
OrderStatus.waitingPayment,
);
});
});

group('participatingRole', () {
const orderId = 'order-x';

test('no row on the order is no role', () {
expect(participatingRole(const [], orderId), isNull);
expect(
participatingRole([fakeTrade(id: 'other')], orderId),
isNull,
reason: 'another order\'s row',
);
});

test('a take still open is a participant', () {
for (final status in OrderStatus.values.where(
(s) => !isTerminalTradeStatus(s),
)) {
expect(
participatingRole([
fakeTrade(
id: 'x',
orderId: orderId,
status: status,
role: TradeRole.seller,
),
], orderId),
TradeRole.seller,
reason: '$status',
);
}
});

test('a take that has ended is not', () {
for (final status in OrderStatus.values.where(isTerminalTradeStatus)) {
final ended = fakeTrade(id: 'x', orderId: orderId, status: status);
expect(isEndedTake(ended), isTrue, reason: '$status');
expect(participatingRole([ended], orderId), isNull, reason: '$status');
}
});

test("a maker's row always is, ended or not", () {
final maker = fakeTrade(
id: 'x',
orderId: orderId,
status: OrderStatus.canceled,
isMine: true,
);
expect(isEndedTake(maker), isFalse);
expect(participatingRole([maker], orderId), TradeRole.buyer);
});

test('an open row wins over an ended one on the same order', () {
// Databases from before a take replaced its order's earlier row can
// hold both, in either order.
final ended = fakeTrade(
id: 'ended',
orderId: orderId,
status: OrderStatus.canceled,
);
final open = fakeTrade(
id: 'open',
orderId: orderId,
status: OrderStatus.active,
role: TradeRole.seller,
);
expect(participatingRole([ended, open], orderId), TradeRole.seller);
expect(participatingRole([open, ended], orderId), TradeRole.seller);
});
});
}
Loading