diff --git a/CLAUDE.md b/CLAUDE.md index 39ac586d..fb03e3dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,10 +50,11 @@ When implementing or debugging protocol-related features (order flows, actions, ### Nostr Integration - **NostrService** (`services/nostr_service.dart`) manages relay connections and messaging - All Nostr protocol interactions go through this service +- **Message ordering**: an order's history is ordered by `MostroMessage.eventCreatedAt` (the daemon's `created_at`, set in `MostroService._processEvent`) via `MostroMessage.compareByEventTime`; `timestamp` is the local receive time, used only as tie-break and for the recency gate for notifications. Relays replay pending events newest-first, decryption is concurrent and the wire `created_at` has one-second resolution, so receive order is not trade order. `OrderState.updateWith` additionally drops any message that would move the order back to an earlier lifecycle phase (`isStaleTransition` over `phaseRank`, the only allowed backwards move being the `new-order` republish to pending after a taker timeout), so a late copy can never move a trade back to a phase without fiat-sent/release/chat buttons or reopen a terminal one (regression tests: `test/features/order/notifiers/order_notifier_replay_order_test.dart`, `test/features/order/models/order_state_late_setup_message_test.dart`) - **MostroFSM** (`core/mostro_fsm.dart`) defines a transition matrix but is **not wired in** — nothing imports it - Order status is actually derived by `OrderState._getStatusFromAction` (`features/order/models/order_state.dart`), which maps actions to statuses without consulting the matrix - Do not read `mostro_fsm.dart` as an active validation layer. Its role axis models who performs an action, not the local user's role in the trade (the app never assigns `Role.admin` to a session), so wiring it as-is would reject legitimate admin resolutions - - The only transition guard that runs today is the dispute-evidence check on `admin-*` actions in `OrderState.updateWith` + - The only transition guards that run today live in `OrderState.updateWith`: the dispute-evidence check on `admin-*` actions and the backwards-phase drop (`isStaleTransition`) ### Navigation and UI - **GoRouter** for navigation (configured in `core/app_routes.dart`) diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 6ffb0b1f..0d16bd1a 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -15,8 +15,16 @@ class MostroMessage { final Action action; int? tradeIndex; T? _payload; + + /// Local receive time in milliseconds, stamped by storage on first write. + /// Drives the recency gate for notifications and navigation. int? timestamp; + /// The daemon's `created_at` for the event that carried this message, in + /// milliseconds. Null for messages written before it was recorded and for + /// locally synthesized ones (restore, outbound copies). + int? eventCreatedAt; + MostroMessage({ required this.action, this.requestId, @@ -24,8 +32,26 @@ class MostroMessage { T? payload, this.tradeIndex, this.timestamp, + this.eventCreatedAt, }) : _payload = payload; + /// Position of this message in the order's history. + /// + /// Mostrod's own event time is authoritative: relays replay pending events + /// newest-first and the decrypt pipeline is concurrent, so the receive time + /// alone puts an earlier message after a later one, and the order state + /// (and with it the trade buttons) ends up on a phase the trade already left. + /// The receive time is the fallback for legacy rows and the tie-break. The + /// wire `created_at` has one-second resolution, so two events from the same + /// second still fall back to receive order; `OrderState.isStaleTransition` + /// is what keeps such a tie from moving the order backwards. + static int compareByEventTime(MostroMessage a, MostroMessage b) { + final byEvent = (a.eventCreatedAt ?? a.timestamp ?? 0) + .compareTo(b.eventCreatedAt ?? b.timestamp ?? 0); + if (byEvent != 0) return byEvent; + return (a.timestamp ?? 0).compareTo(b.timestamp ?? 0); + } + Map toJson({int? version}) { Map json = { // The message version is derived from the wire transport: 1 for gift wrap @@ -47,6 +73,7 @@ class MostroMessage { factory MostroMessage.fromJson(Map json) { final timestamp = json['timestamp']; + final eventCreatedAt = json['event_created_at']; // IMPORTANT : Use 'order', 'restore' or 'cant-do' key as per protocol json = json['order'] ?? json['restore'] ?? json['cant-do'] ?? json; final num requestId = json['request_id'] ?? 0; @@ -60,6 +87,7 @@ class MostroMessage { ? Payload.fromJson(json['payload']) as T? : null, timestamp: timestamp, + eventCreatedAt: eventCreatedAt, ); } diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index f40b28e9..a656ad94 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -54,7 +54,7 @@ class MostroStorage extends BaseStorage { if (orderId == null) return; final list = _byOrder.putIfAbsent(orderId, () => []); list.add(message); - list.sort((a, b) => (b.timestamp ?? 0).compareTo(a.timestamp ?? 0)); + list.sort((a, b) => MostroMessage.compareByEventTime(b, a)); if (notify) _notifyOrder(orderId); } @@ -117,6 +117,7 @@ class MostroStorage extends BaseStorage { final Map dbMap = message.toJson(); message.timestamp ??= DateTime.now().millisecondsSinceEpoch; dbMap['timestamp'] = message.timestamp; + dbMap['event_created_at'] = message.eventCreatedAt; await store.record(id).put(db, dbMap); _indexAdd(message); @@ -197,15 +198,13 @@ class MostroStorage extends BaseStorage { .toList(); } - /// Filter messages by payload type + /// Latest message for [orderId] whose payload is a [T], by event time. Future getLatestMessageOfTypeById( String orderId, ) async { - final messages = await getMessagesForId(orderId); - for (final message in messages.reversed) { - if (message.payload is T) { - return message; - } + await _ensureIndex(); + for (final message in _byOrder[orderId] ?? const []) { + if (message.payload is T) return message; } return null; } diff --git a/lib/features/order/models/order_state.dart b/lib/features/order/models/order_state.dart index 673682bf..0482cf2d 100644 --- a/lib/features/order/models/order_state.dart +++ b/lib/features/order/models/order_state.dart @@ -232,6 +232,22 @@ class OrderState { // DEBUG: Log status mapping logger.d('Status mapping: $effectiveAction → $newStatus'); + // A message that would move the order back to a phase it already left is + // a late delivery, not a transition: relays replay pending events + // newest-first, decryption is concurrent and the wire `created_at` has + // one-second resolution, so `waiting-seller-to-pay` can be applied after + // `hold-invoice-payment-accepted`, or `fiat-sent-ok` after `released`. + // Taking it would land on a phase whose action table has no fiat-sent, + // release or chat button, which is how trades looked stuck until a + // dispute reset the row. + if (isStaleTransition(effectiveAction, newStatus)) { + logger.w( + 'Ignoring late ${message.action} for order ${message.id}: ' + 'it would move the order from $status back to $newStatus', + ); + return this; + } + // A pending order has no counterpart: when Mostro republishes it after the // taker times out, the previous taker's snapshot must not be carried into // the next take and shown as if it belonged to whoever takes it next. @@ -402,6 +418,48 @@ class OrderState { return newState; } + /// Position of a status along the trade lifecycle. Statuses that can + /// legitimately follow each other in either direction share a rank: a + /// cooperative cancel can be started from fiat-sent or during a dispute and + /// the trade can still reach fiat-sent while the cancel is pending; a + /// payout can fail and be retried while the hold invoice stays settled. + /// Every terminal status sits at the top so nothing reopens it. + static int phaseRank(Status status) => switch (status) { + Status.pending => 0, + Status.waitingTakerBond || + Status.waitingPayment || + Status.waitingBuyerInvoice || + Status.inProgress => + 1, + Status.active => 2, + Status.fiatSent || Status.cooperativelyCanceled || Status.dispute => 3, + Status.settledHoldInvoice || Status.paymentFailed => 4, + Status.success => 5, + Status.canceled || + Status.canceledByAdmin || + Status.settledByAdmin || + Status.completedByAdmin || + Status.expired => + 6, + }; + + /// Whether applying [action] with [newStatus] would move the order back to + /// a phase it already left. + /// + /// The one backwards move the protocol makes is the republish after a taker + /// timeout: Mostro sends `new-order` with a pending payload to the maker + /// while the order waits for the taker's invoice or payment. Nothing else + /// goes backwards, so any other such message is a late copy. + bool isStaleTransition(Action action, Status newStatus) { + final current = phaseRank(status); + final next = phaseRank(newStatus); + if (next >= current) return false; + final isRepublish = action == Action.newOrder && + newStatus == Status.pending && + current <= phaseRank(Status.waitingPayment); + return !isRepublish; + } + /// Maps actions to their corresponding statuses based on mostrod DM messages Status _getStatusFromAction(Action action, Status? payloadStatus) { switch (action) { diff --git a/lib/features/order/notifiers/order_notifier.dart b/lib/features/order/notifiers/order_notifier.dart index 3e4f0db8..bce7b2f4 100644 --- a/lib/features/order/notifiers/order_notifier.dart +++ b/lib/features/order/notifiers/order_notifier.dart @@ -79,19 +79,16 @@ class OrderNotifier extends AbstractMostroNotifier { _isSyncing = true; final storage = ref.read(mostroStorageProvider); - final messages = await storage.getAllMessagesForOrderId(orderId); + // The index hands out an unmodifiable view: sort a copy. + final messages = (await storage.getAllMessagesForOrderId(orderId)) + .toList() + ..sort(MostroMessage.compareByEventTime); if (messages.isEmpty) { logger.w('No messages found for order $orderId'); succeeded = true; return; } - messages.sort((a, b) { - final timestampA = a.timestamp ?? 0; - final timestampB = b.timestamp ?? 0; - return timestampA.compareTo(timestampB); - }); - OrderState currentState = state; for (final message in messages) { diff --git a/lib/features/trades/state_message_finder.dart b/lib/features/trades/state_message_finder.dart index b863dbb4..7a7a8e71 100644 --- a/lib/features/trades/state_message_finder.dart +++ b/lib/features/trades/state_message_finder.dart @@ -52,9 +52,9 @@ class StateMessageFinder { return const _LookupResult(null, skippedFutureMessage: false); } - // Sort messages by timestamp (newest first) + // Sort messages by event time (newest first) final sortedMessages = List.from(validMessages) - ..sort((a, b) => (b.timestamp ?? 0).compareTo(a.timestamp ?? 0)); + ..sort((a, b) => MostroMessage.compareByEventTime(b, a)); final cutoff = now.add(futureTimestampTolerance); var skippedFutureMessage = false; diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index 101c10c3..275c5024 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -91,9 +91,8 @@ class MostroMessageDetail extends ConsumerWidget { } actions.Action? _previousNonBondAction(List messages) { - final sorted = [...messages]..sort( - (a, b) => (b.timestamp ?? 0).compareTo(a.timestamp ?? 0), - ); + final sorted = [...messages] + ..sort((a, b) => MostroMessage.compareByEventTime(b, a)); for (final msg in sorted) { final a = msg.action; if (a == actions.Action.addBondInvoice) continue; diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 186dbe26..c59f939e 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -222,16 +222,20 @@ class MostroService { // decrypts straight to the tuple. Both converge on jsonDecode below. String? content; String? decryptedId; + DateTime? eventCreatedAt; if (event.kind == 14) { content = await NostrUtils.decryptNIP44DirectEvent( event, privateKey, expectedAuthor: _settings.mostroPublicKey, ); + eventCreatedAt = event.createdAt; } else { final decryptedEvent = await event.unWrap(privateKey); content = decryptedEvent.content; decryptedId = decryptedEvent.id; + // The wrap's created_at is randomized; the rumor's is the real one. + eventCreatedAt = decryptedEvent.createdAt ?? event.createdAt; } if (content == null) { @@ -264,6 +268,9 @@ class MostroService { } final msg = MostroMessage.fromJson(result[0]); + // Ordering key for the order's history: the daemon's event time, not + // the moment this client happened to decrypt it. + msg.eventCreatedAt = eventCreatedAt?.millisecondsSinceEpoch; final messageStorage = ref.read(mostroStorageProvider); diff --git a/test/data/repositories/mostro_storage_event_time_test.dart b/test/data/repositories/mostro_storage_event_time_test.dart new file mode 100644 index 00000000..cd4c39f4 --- /dev/null +++ b/test/data/repositories/mostro_storage_event_time_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; +import 'package:sembast/sembast_memory.dart'; + +/// The history used to be ordered by the local receive time stamped at write. +/// Relays replay pending events newest-first and the decrypt pipeline is +/// concurrent, so an earlier message could be written after a later one and +/// become the "latest" message the order state is derived from. +void main() { + late MostroStorage storage; + + setUp(() async { + final db = + await newDatabaseFactoryMemory().openDatabase('event_time_test.db'); + storage = MostroStorage(db: db); + }); + + MostroMessage message(Action action, {int? eventCreatedAt}) => MostroMessage( + action: action, + id: 'order-a', + eventCreatedAt: eventCreatedAt, + ); + + test('the latest message follows the event time, not the write order', + () async { + // Arrange: the newer event is decrypted and written first. + await storage.addMessage( + 'newer', + message(Action.holdInvoicePaymentAccepted, eventCreatedAt: 2000), + ); + await storage.addMessage( + 'older', + message(Action.waitingSellerToPay, eventCreatedAt: 1000), + ); + + // Act + final history = await storage.getAllMessagesForOrderId('order-a'); + final latest = await storage.getLatestMessageById('order-a'); + + // Assert + expect(history.map((m) => m.action), + [Action.holdInvoicePaymentAccepted, Action.waitingSellerToPay]); + expect(latest?.action, Action.holdInvoicePaymentAccepted); + }); + + test('legacy rows without an event time keep their receive-time order', + () async { + final first = message(Action.newOrder)..timestamp = 1000; + final second = message(Action.payInvoice)..timestamp = 2000; + await storage.addMessage('k1', first); + await storage.addMessage('k2', second); + + final history = await storage.getAllMessagesForOrderId('order-a'); + + expect(history.map((m) => m.action), [Action.payInvoice, Action.newOrder]); + }); + + _typedLookup(); + + test('the event time survives a round trip through the database', () async { + await storage.addMessage( + 'k1', + message(Action.waitingSellerToPay, eventCreatedAt: 1234), + ); + final reopened = MostroStorage(db: storage.db); + + final history = await reopened.getAllMessagesForOrderId('order-a'); + + expect(history.single.eventCreatedAt, 1234); + }); +} + +void _typedLookup() { + test('the typed latest lookup follows the event time too', () async { + final db = + await newDatabaseFactoryMemory().openDatabase('typed_lookup_test.db'); + final storage = MostroStorage(db: db); + Order order(Status status) => Order( + id: 'order-a', + kind: OrderType.sell, + status: status, + fiatCode: 'VES', + fiatAmount: 100, + paymentMethod: 'face to face', + ); + + await storage.addMessage( + 'newer', + MostroMessage( + action: Action.holdInvoicePaymentAccepted, + id: 'order-a', + eventCreatedAt: 2000, + payload: order(Status.active), + ), + ); + await storage.addMessage( + 'older', + MostroMessage( + action: Action.waitingSellerToPay, + id: 'order-a', + eventCreatedAt: 1000, + payload: order(Status.waitingPayment), + ), + ); + + final latest = await storage.getLatestMessageOfTypeById('order-a'); + + expect(latest?.getPayload()?.status, Status.active); + }); +} diff --git a/test/features/order/models/order_state_late_setup_message_test.dart b/test/features/order/models/order_state_late_setup_message_test.dart new file mode 100644 index 00000000..32248cb4 --- /dev/null +++ b/test/features/order/models/order_state_late_setup_message_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models.dart'; +import 'package:mostro_mobile/data/enums.dart'; +import 'package:mostro_mobile/features/order/models/order_state.dart'; + +/// Messages that land after the trade moved past their phase are late copies +/// (relays replay newest-first, decryption is concurrent, and the wire +/// created_at has one-second resolution). Applying them used to move the +/// status back to a phase whose action table has no fiat-sent, release or +/// chat button, leaving the trade looking stuck until a dispute reset the +/// row, or reopen a terminal order. +Order _order(Status status) => Order( + id: 'order-1', + kind: OrderType.sell, + status: status, + amount: 495, + fiatCode: 'CUP', + fiatAmount: 333, + paymentMethod: 'Saldo móvil', + ); + +OrderState _state(Status status, Action action) => OrderState( + status: status, + action: action, + order: _order(status), + ); + +MostroMessage _message(Action action, {Status? status}) => MostroMessage( + action: action, + id: 'order-1', + payload: status == null ? null : _order(status), + timestamp: 1, + ); + +void main() { + group('late setup messages on an active trade', () { + test('a late waiting-seller-to-pay keeps the buyer on active', () { + // Arrange + final active = _state(Status.active, Action.holdInvoicePaymentAccepted); + + // Act + final next = active.updateWith( + _message(Action.waitingSellerToPay, status: Status.waitingPayment), + ); + + // Assert + expect(next.status, Status.active); + expect(next.getActions(Role.buyer), contains(Action.fiatSent)); + expect(next.getActions(Role.buyer), contains(Action.sendDm)); + }); + + test('a late waiting-buyer-invoice keeps the seller on active', () { + final active = _state(Status.active, Action.buyerTookOrder); + + final next = active.updateWith( + _message(Action.waitingBuyerInvoice, + status: Status.waitingBuyerInvoice), + ); + + expect(next.status, Status.active); + expect(next.getActions(Role.seller), contains(Action.sendDm)); + }); + + test('a late buyer-took-order keeps the seller on fiat-sent', () { + final fiatSent = _state(Status.fiatSent, Action.fiatSentOk); + + final next = fiatSent.updateWith( + _message(Action.buyerTookOrder, status: Status.active), + ); + + expect(next.status, Status.fiatSent); + expect(next.getActions(Role.seller), contains(Action.release)); + }); + + test('a late hold-invoice-payment-accepted keeps a dispute open', () { + final disputed = _state(Status.dispute, Action.disputeInitiatedByYou); + + final next = disputed.updateWith( + _message(Action.holdInvoicePaymentAccepted, status: Status.active), + ); + + expect(next.status, Status.dispute); + expect(next.action, Action.disputeInitiatedByYou); + }); + }); + + _lateCopiesOnLaterPhases(); + + group('messages that are not late', () { + test('the active-entry message still opens the active phase', () { + final waiting = _state(Status.waitingPayment, Action.waitingSellerToPay); + + final next = waiting.updateWith( + _message(Action.holdInvoicePaymentAccepted, status: Status.active), + ); + + expect(next.status, Status.active); + }); + + test('add-invoice after a failed payout is still applied', () { + // Mostro reuses add-invoice to ask for a payout invoice, so it must not + // be treated as a setup-phase leftover. + final failed = _state(Status.paymentFailed, Action.paymentFailed); + + final next = failed.updateWith(_message(Action.addInvoice)); + + expect(next.action, Action.addInvoice); + expect(next.getActions(Role.buyer), contains(Action.addInvoice)); + }); + + test('a republished order still returns to pending', () { + final waiting = _state(Status.waitingPayment, Action.payInvoice); + + final next = waiting.updateWith( + _message(Action.newOrder, status: Status.pending), + ); + + expect(next.status, Status.pending); + }); + }); +} + +void _lateCopiesOnLaterPhases() { + group('late copies on later phases', () { + test('a same-second fiat-sent-ok does not undo a release', () { + // The wire created_at has one-second resolution, so the tie falls back + // to receive order; the transition guard must hold on its own. + final released = _state(Status.settledHoldInvoice, Action.released); + + final next = released.updateWith( + _message(Action.fiatSentOk, status: Status.fiatSent), + ); + + expect(next.status, Status.settledHoldInvoice); + expect(next.action, Action.released); + }); + + test('a same-second released does not undo purchase-completed', () { + final done = _state(Status.success, Action.purchaseCompleted); + + final next = done.updateWith( + _message(Action.released, status: Status.settledHoldInvoice), + ); + + expect(next.status, Status.success); + expect(next.getActions(Role.buyer), contains(Action.rate)); + }); + + test('a late hold-invoice-payment-accepted keeps a cooperative cancel', + () { + final canceling = _state( + Status.cooperativelyCanceled, Action.cooperativeCancelNoFiatByYou); + + final next = canceling.updateWith( + _message(Action.holdInvoicePaymentAccepted, status: Status.active), + ); + + expect(next.status, Status.cooperativelyCanceled); + }); + + test('fiat-sent-ok is still applied while a cooperative cancel is pending', + () { + // The buyer can still complete the trade: same rank, not a move back. + final canceling = _state( + Status.cooperativelyCanceled, Action.cooperativeCancelNoFiatByPeer); + + final next = canceling.updateWith( + _message(Action.fiatSentOk, status: Status.fiatSent), + ); + + expect(next.status, Status.fiatSent); + }); + }); + + group('late copies on terminal orders', () { + for (final status in [ + Status.expired, + Status.canceled, + Status.success, + Status.settledByAdmin, + ]) { + test('a late waiting-seller-to-pay does not reopen $status', () { + final terminal = _state(status, Action.canceled); + + final next = terminal.updateWith( + _message(Action.waitingSellerToPay, status: Status.waitingPayment), + ); + + expect(next.status, status); + }); + + test('a late hold-invoice-payment-accepted does not reopen $status', + () { + final terminal = _state(status, Action.canceled); + + final next = terminal.updateWith( + _message(Action.holdInvoicePaymentAccepted, status: Status.active), + ); + + expect(next.status, status); + }); + + test('a late new-order does not reopen $status', () { + final terminal = _state(status, Action.canceled); + + final next = terminal.updateWith( + _message(Action.newOrder, status: Status.pending), + ); + + expect(next.status, status); + }); + } + }); +} diff --git a/test/features/order/notifiers/order_notifier_replay_order_test.dart b/test/features/order/notifiers/order_notifier_replay_order_test.dart new file mode 100644 index 00000000..1e86a094 --- /dev/null +++ b/test/features/order/notifiers/order_notifier_replay_order_test.dart @@ -0,0 +1,146 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/features/order/notifiers/order_notifier.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/services/nostr_service.dart'; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; +import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; +import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; +import 'package:sembast/sembast_memory.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../../../mocks.mocks.dart'; + +/// `sync()` rebuilds the order state from the persisted history. That history +/// used to be replayed in receive order, so a `waiting-seller-to-pay` written +/// after `hold-invoice-payment-accepted` (relay replay newest-first, or two +/// concurrent decrypts) left the buyer on waiting-payment: no fiat-sent button +/// and no chat, until a dispute moved the state again. +class _SilentNostrService extends NostrService { + @override + bool get isInitialized => true; + + @override + Stream subscribeToEvents( + NostrRequest request, { + void Function(String)? onEose, + }) => + const Stream.empty(); +} + +class _IdleMostroService extends MostroService { + _IdleMostroService(super.ref); +} + +class _FixedSessionNotifier extends SessionNotifier { + _FixedSessionNotifier(Ref ref) + : super( + ref, + MockSessionStorage(), + Settings( + relays: [], + fullPrivacyMode: false, + mostroPublicKey: 'test', + ), + ) { + state = []; + } +} + +/// Real `sync()`, no live stream. +class _SyncOnlyOrderNotifier extends OrderNotifier { + _SyncOnlyOrderNotifier(super.orderId, super.ref); + + @override + void subscribe() {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const orderId = 'test-order-id'; + + late Database db; + late ProviderContainer container; + + setUp(() async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + db = await newDatabaseFactoryMemory().openDatabase('replay_order.db'); + + container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(SharedPreferencesAsync()), + mostroDatabaseProvider.overrideWithValue(db), + nostrServiceProvider.overrideWithValue(_SilentNostrService()), + mostroServiceProvider.overrideWith((ref) => _IdleMostroService(ref)), + sessionNotifierProvider + .overrideWith((ref) => _FixedSessionNotifier(ref)), + orderNotifierProvider.overrideWith( + (ref, id) => _SyncOnlyOrderNotifier(id, ref), + ), + ], + ); + }); + + tearDown(() async { + container.dispose(); + await db.close(); + }); + + MostroMessage message(Action action, Status status, + {required int eventCreatedAt}) => + MostroMessage( + action: action, + id: orderId, + eventCreatedAt: eventCreatedAt, + payload: Order( + id: orderId, + kind: OrderType.sell, + status: status, + fiatCode: 'VES', + fiatAmount: 100, + paymentMethod: 'face to face', + ), + ); + + test('an earlier message written last does not win the replay', () async { + // Arrange: the newer event was decrypted and persisted first. + final storage = container.read(mostroStorageProvider); + await storage.addMessage( + 'newer', + message(Action.holdInvoicePaymentAccepted, Status.active, + eventCreatedAt: 2000), + ); + await storage.addMessage( + 'older', + message(Action.waitingSellerToPay, Status.waitingPayment, + eventCreatedAt: 1000), + ); + + // Act + final notifier = container.read(orderNotifierProvider(orderId).notifier); + await notifier.sync(); + + // Assert + final state = container.read(orderNotifierProvider(orderId)); + expect(state.status, Status.active); + expect(state.action, Action.holdInvoicePaymentAccepted); + expect(state.getActions(Role.buyer), contains(Action.fiatSent)); + }); +} diff --git a/test/services/mostro_service_event_time_test.dart b/test/services/mostro_service_event_time_test.dart new file mode 100644 index 00000000..175b8463 --- /dev/null +++ b/test/services/mostro_service_event_time_test.dart @@ -0,0 +1,185 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/data/repositories/event_storage.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/settings/settings_notifier.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/features/subscriptions/subscription_manager_provider.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; +import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; +import 'package:sembast/sembast_memory.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../mocks.mocks.dart'; + +/// A stored message must carry the daemon's event time. Ordering the history +/// by the local receive time alone let an earlier message, replayed or +/// decrypted after a later one, become the order's "latest" message. +const _nodePriv = + '0000000000000000000000000000000000000000000000000000000000000003'; +const _tradePriv = + '0000000000000000000000000000000000000000000000000000000000000004'; +const _orderId = 'order-a'; + +void main() { + final nodeKeys = NostrKeyPairs(private: _nodePriv); + final tradeKeys = NostrKeyPairs(private: _tradePriv); + + late Database db; + late StreamController ordersController; + late MockSubscriptionManagerSpy subscriptionManager; + late _FakeSessionNotifier sessions; + late ProviderContainer container; + + Future nodeMessage(Action action, DateTime createdAt) async { + final tuple = [ + { + 'order': { + 'version': 2, + 'id': _orderId, + 'action': action.value, + 'payload': null, + }, + }, + null, + ]; + final encrypted = await NostrUtils.encryptNIP44( + jsonEncode(tuple), _nodePriv, tradeKeys.public); + return NostrEvent.fromPartialData( + kind: 14, + content: encrypted, + keyPairs: nodeKeys, + createdAt: createdAt, + tags: [ + ['p', tradeKeys.public], + ], + ); + } + + setUp(() async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + db = await newDatabaseFactoryMemory().openDatabase('event_time.db'); + ordersController = StreamController.broadcast(); + subscriptionManager = MockSubscriptionManagerSpy(); + when(subscriptionManager.orders) + .thenAnswer((_) => ordersController.stream); + + container = ProviderContainer(overrides: [ + sharedPreferencesProvider.overrideWithValue(SharedPreferencesAsync()), + mostroDatabaseProvider.overrideWithValue(db), + eventStorageProvider.overrideWithValue(EventStorage(db: db)), + subscriptionManagerProvider.overrideWithValue(subscriptionManager), + settingsProvider + .overrideWith((ref) => _FixedSettingsNotifier(nodeKeys.public)), + sessionNotifierProvider.overrideWith((ref) { + sessions = _FakeSessionNotifier(ref); + return sessions; + }), + mostroServiceProvider.overrideWith((ref) => MostroService(ref)..init()), + ]); + container.read(sessionNotifierProvider); + container.read(mostroServiceProvider); + + sessions.emit([ + Session( + masterKey: tradeKeys, + tradeKey: tradeKeys, + keyIndex: 0, + fullPrivacy: false, + startTime: DateTime.now(), + )..orderId = _orderId, + ]); + }); + + tearDown(() async { + await ordersController.close(); + container.dispose(); + await db.close(); + }); + + Future deliver(NostrEvent event) async { + ordersController.add(event); + final eventStore = EventStorage(db: db); + await _waitFor(() => eventStore.hasItem(event.id!)); + } + + test('a stored message carries the event created_at in milliseconds', + () async { + // Arrange + final createdAt = DateTime.fromMillisecondsSinceEpoch(1700000000 * 1000); + + // Act + await deliver(await nodeMessage(Action.waitingSellerToPay, createdAt)); + + // Assert + final history = await container + .read(mostroStorageProvider) + .getAllMessagesForOrderId(_orderId); + expect(history.single.eventCreatedAt, createdAt.millisecondsSinceEpoch); + }); + + test('an earlier event delivered second does not become the latest message', + () async { + // Arrange: the relay replays newest-first after a reconnect. + final base = DateTime.fromMillisecondsSinceEpoch(1700000000 * 1000); + final newer = await nodeMessage( + Action.holdInvoicePaymentAccepted, base.add(const Duration(seconds: 30))); + final older = await nodeMessage(Action.waitingSellerToPay, base); + + // Act + await deliver(newer); + await deliver(older); + + // Assert + final latest = await container + .read(mostroStorageProvider) + .getLatestMessageById(_orderId); + expect(latest?.action, Action.holdInvoicePaymentAccepted); + }); +} + +Future _waitFor( + Future Function() condition, { + Duration timeout = const Duration(seconds: 10), +}) async { + final deadline = DateTime.now().add(timeout); + while (!await condition() && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 10)); + } +} + +class _FixedSettingsNotifier extends SettingsNotifier { + _FixedSettingsNotifier(String mostroPublicKey) + : super(MockSharedPreferencesAsync()) { + state = Settings( + relays: const [], + fullPrivacyMode: false, + mostroPublicKey: mostroPublicKey, + ); + } +} + +class _FakeSessionNotifier extends SessionNotifier { + _FakeSessionNotifier(Ref ref) + : super(ref, MockSessionStorage(), MockSettings()) { + state = const []; + } + + void emit(List sessions) => state = sessions; +}