diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 6de4bfcdb..2cbb0c52e 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -17,6 +17,7 @@ import 'package:mostro_mobile/features/order/models/order_state.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/order_cards.dart'; +import 'package:mostro_mobile/features/trades/state_message_finder.dart'; import 'package:mostro_mobile/features/trades/widgets/mostro_message_detail_widget.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; @@ -1137,38 +1138,6 @@ class _CountdownWidget extends ConsumerWidget { /// Find the message that triggered the current state /// Returns null if no valid message is found MostroMessage? _findMessageForState( - List messages, Status status) { - // Filter out messages with invalid timestamps - final validMessages = - messages.where((m) => m.timestamp != null && m.timestamp! > 0).toList(); - - if (validMessages.isEmpty) { - return null; - } - - // Sort messages by timestamp (newest first) - final sortedMessages = List.from(validMessages) - ..sort((a, b) => (b.timestamp ?? 0).compareTo(a.timestamp ?? 0)); - - // Find the message that caused this state - for (final message in sortedMessages) { - // Additional validation: ensure timestamp is not in the future - final messageTime = - DateTime.fromMillisecondsSinceEpoch(message.timestamp!); - if (messageTime.isAfter(DateTime.now().add(const Duration(hours: 1)))) { - continue; // Skip messages with future timestamps - } - - if (status == Status.waitingBuyerInvoice && - (message.action == actions.Action.addInvoice || - message.action == actions.Action.waitingBuyerInvoice)) { - return message; - } else if (status == Status.waitingPayment && - (message.action == actions.Action.payInvoice || - message.action == actions.Action.waitingSellerToPay)) { - return message; - } - } - return null; - } + List messages, Status status) => + StateMessageFinder.findMessageForState(messages, status); } diff --git a/lib/features/trades/state_message_finder.dart b/lib/features/trades/state_message_finder.dart new file mode 100644 index 000000000..b863dbb4e --- /dev/null +++ b/lib/features/trades/state_message_finder.dart @@ -0,0 +1,96 @@ +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; + +/// Finds the message that triggered a given order [Status], for the +/// trade-detail countdown. +/// +/// The countdown rebuilds once per second, so the copy+sort below used to run +/// every tick. Results are memoized per history-list *instance*: the storage +/// index emits a new list only on real changes, so ticks hit the memo and new +/// messages recompute. The [Expando] keys are weak, so entries are collected +/// with the list and nothing leaks. +/// +/// A result is only memoized when it does not depend on the current time: if +/// the future-timestamp guard skipped any message, the lookup is left uncached +/// so a later tick re-evaluates it once the clock catches up. +class StateMessageFinder { + StateMessageFinder._(); + + /// Messages timestamped further ahead than this are treated as bogus and + /// skipped, rather than driving a countdown from a nonsense start time. + static const futureTimestampTolerance = Duration(hours: 1); + + static final Expando> _memo = Expando(); + + /// Returns the newest valid message that produced [status], or null. + static MostroMessage? findMessageForState( + List messages, + Status status, { + DateTime? now, + }) { + final memo = _memo[messages] ??= {}; + if (memo.containsKey(status)) return memo[status]; + + final result = _lookup(messages, status, now ?? DateTime.now()); + if (!result.skippedFutureMessage) { + memo[status] = result.message; + } + return result.message; + } + + static _LookupResult _lookup( + List messages, + Status status, + DateTime now, + ) { + // Filter out messages with invalid timestamps + final validMessages = + messages.where((m) => m.timestamp != null && m.timestamp! > 0).toList(); + + if (validMessages.isEmpty) { + return const _LookupResult(null, skippedFutureMessage: false); + } + + // Sort messages by timestamp (newest first) + final sortedMessages = List.from(validMessages) + ..sort((a, b) => (b.timestamp ?? 0).compareTo(a.timestamp ?? 0)); + + final cutoff = now.add(futureTimestampTolerance); + var skippedFutureMessage = false; + + // Find the message that caused this state + for (final message in sortedMessages) { + // Additional validation: ensure timestamp is not in the future + final messageTime = + DateTime.fromMillisecondsSinceEpoch(message.timestamp!); + if (messageTime.isAfter(cutoff)) { + skippedFutureMessage = true; + continue; // Skip messages with future timestamps + } + + if (status == Status.waitingBuyerInvoice && + (message.action == actions.Action.addInvoice || + message.action == actions.Action.waitingBuyerInvoice)) { + return _LookupResult(message, + skippedFutureMessage: skippedFutureMessage); + } else if (status == Status.waitingPayment && + (message.action == actions.Action.payInvoice || + message.action == actions.Action.waitingSellerToPay)) { + return _LookupResult(message, + skippedFutureMessage: skippedFutureMessage); + } + } + return _LookupResult(null, skippedFutureMessage: skippedFutureMessage); + } +} + +class _LookupResult { + const _LookupResult(this.message, {required this.skippedFutureMessage}); + + final MostroMessage? message; + + /// True when the future-timestamp guard discarded a candidate, which makes + /// this result time-dependent and therefore not safe to memoize. + final bool skippedFutureMessage; +} diff --git a/lib/services/nwc/nwc_client.dart b/lib/services/nwc/nwc_client.dart index 2693066e4..bc12ffdae 100644 --- a/lib/services/nwc/nwc_client.dart +++ b/lib/services/nwc/nwc_client.dart @@ -33,6 +33,34 @@ class NwcNotification { /// app's architectural pattern of routing all Nostr communication through /// [NostrService] rather than accessing [Nostr.instance] directly. class NwcClient { + /// How far back the kind-23195 response subscription looks. + /// + /// Without a `since` the relay replays the wallet's entire response history + /// on every request. A response is always newer than its request in real + /// time, but `since` is computed from the *phone's* clock while the relay + /// filters on the `created_at` signed by the *wallet service* — two + /// independent machines. Too tight a window and a skewed wallet (or a phone + /// running ahead) has its live response silently dropped by the relay, which + /// times out every NWC operation, payments included. + /// + /// Ten minutes matches `ChatCursorStore.cursorOverlap`, the skew budget this + /// codebase already uses for relay-side `since` filters. It kills the replay + /// just as effectively while leaving a realistic clock margin. + static const responseReplayWindow = Duration(minutes: 10); + + /// Builds the subscription filter for the wallet's kind-23195 responses. + /// + /// Only filters by kind + author; some NWC relay implementations (e.g. + /// Primal) don't support #e / #p tag filters, so the e-tag match is verified + /// in the event handler. + static NostrFilter responseFilter(String walletPubkey, {DateTime? now}) { + return NostrFilter( + kinds: const [23195], + authors: [walletPubkey], + since: (now ?? DateTime.now()).subtract(responseReplayWindow), + ); + } + /// The parsed NWC connection. final NwcConnection connection; @@ -411,10 +439,10 @@ class NwcClient { // Only filter by kind + author; some NWC relay implementations // (e.g. Primal) don't support #e / #p tag filters, so we verify // the e-tag match in the event handler below. - final filter = NostrFilter( - kinds: const [23195], - authors: [connection.walletPubkey], - ); + // `since` bounds the replay: without it every request re-received the + // wallet's whole kind-23195 history from the relay (and the balance + // tick sends one request per minute). + final filter = responseFilter(connection.walletPubkey); final subId = 'nwc_${requestId.substring(0, 8)}'; final stream = _nostr.services.relays.startEventsSubscription( diff --git a/test/features/trades/state_message_finder_test.dart b/test/features/trades/state_message_finder_test.dart new file mode 100644 index 000000000..2519941db --- /dev/null +++ b/test/features/trades/state_message_finder_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/features/trades/state_message_finder.dart'; + +MostroMessage msg(actions.Action action, DateTime at) => + MostroMessage(action: action, timestamp: at.millisecondsSinceEpoch); + +void main() { + group('StateMessageFinder.findMessageForState', () { + test('returns the newest message that produced the status', () { + // Arrange + final now = DateTime.utc(2026, 1, 1, 12); + final older = msg(actions.Action.addInvoice, + now.subtract(const Duration(minutes: 30))); + final newer = msg(actions.Action.waitingBuyerInvoice, + now.subtract(const Duration(minutes: 5))); + final messages = [older, newer]; + + // Act + final found = StateMessageFinder.findMessageForState( + messages, Status.waitingBuyerInvoice, + now: now); + + // Assert + expect(found, same(newer)); + }); + + test('returns null when no message matches the status', () { + // Arrange + final now = DateTime.utc(2026, 1, 1, 12); + final messages = [ + msg(actions.Action.addInvoice, now.subtract(const Duration(minutes: 5))) + ]; + + // Act + final found = StateMessageFinder.findMessageForState( + messages, Status.waitingPayment, + now: now); + + // Assert + expect(found, isNull); + }); + + test('ignores messages with missing or non-positive timestamps', () { + // Arrange + final now = DateTime.utc(2026, 1, 1, 12); + final valid = msg(actions.Action.payInvoice, + now.subtract(const Duration(minutes: 5))); + final messages = [ + MostroMessage(action: actions.Action.payInvoice, timestamp: null), + MostroMessage(action: actions.Action.payInvoice, timestamp: 0), + valid, + ]; + + // Act + final found = StateMessageFinder.findMessageForState( + messages, Status.waitingPayment, + now: now); + + // Assert + expect(found, same(valid)); + }); + + test('memoizes per list instance so 1 s ticks skip the sort', () { + // Arrange + final now = DateTime.utc(2026, 1, 1, 12); + final expected = msg(actions.Action.addInvoice, + now.subtract(const Duration(minutes: 5))); + final messages = [expected]; + + // Act + final first = StateMessageFinder.findMessageForState( + messages, Status.waitingBuyerInvoice, + now: now); + // Mutating the same instance must not be observed: a real change emits a + // new list instance from the storage index. + messages.clear(); + final second = StateMessageFinder.findMessageForState( + messages, Status.waitingBuyerInvoice, + now: now); + + // Assert + expect(first, same(expected)); + expect(second, same(expected)); + }); + + test('a new list instance recomputes', () { + // Arrange + final now = DateTime.utc(2026, 1, 1, 12); + final first = msg(actions.Action.addInvoice, + now.subtract(const Duration(minutes: 5))); + final second = msg(actions.Action.waitingBuyerInvoice, + now.subtract(const Duration(minutes: 1))); + + // Act + final a = StateMessageFinder.findMessageForState( + [first], Status.waitingBuyerInvoice, + now: now); + final b = StateMessageFinder.findMessageForState( + [first, second], Status.waitingBuyerInvoice, + now: now); + + // Assert + expect(a, same(first)); + expect(b, same(second)); + }); + + test('skips messages timestamped far in the future', () { + // Arrange + final now = DateTime.utc(2026, 1, 1, 12); + final future = + msg(actions.Action.addInvoice, now.add(const Duration(hours: 2))); + final past = msg(actions.Action.addInvoice, + now.subtract(const Duration(minutes: 5))); + + // Act + final found = StateMessageFinder.findMessageForState( + [future, past], Status.waitingBuyerInvoice, + now: now); + + // Assert + expect(found, same(past)); + }); + + test('does not memoize a result the future guard made time-dependent', () { + // A skipped future message must be re-evaluated once the clock catches + // up, instead of being frozen for the lifetime of the list instance. + // Arrange + final now = DateTime.utc(2026, 1, 1, 12); + final future = + msg(actions.Action.addInvoice, now.add(const Duration(hours: 2))); + final messages = [future]; + + // Act + final beforeClockCatchesUp = StateMessageFinder.findMessageForState( + messages, Status.waitingBuyerInvoice, + now: now); + final afterClockCatchesUp = StateMessageFinder.findMessageForState( + messages, Status.waitingBuyerInvoice, + now: now.add(const Duration(hours: 3))); + + // Assert + expect(beforeClockCatchesUp, isNull); + expect(afterClockCatchesUp, same(future)); + }); + }); +} diff --git a/test/services/nwc/nwc_response_filter_test.dart b/test/services/nwc/nwc_response_filter_test.dart new file mode 100644 index 000000000..60dd3a1a3 --- /dev/null +++ b/test/services/nwc/nwc_response_filter_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/services/chat_cursor_store.dart'; +import 'package:mostro_mobile/services/nwc/nwc_client.dart'; + +/// Pins the `since` bound on the NWC kind-23195 response subscription. +/// +/// Without a `since` the relay replays the wallet's whole response history on +/// every request; with too tight a `since` a clock-skewed wallet has its live +/// response dropped by the relay and every NWC operation times out. +void main() { + const walletPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + group('NwcClient.responseFilter', () { + test('subscribes to kind 23195 from the wallet author only', () { + // Arrange + final now = DateTime.utc(2026, 1, 1, 12); + + // Act + final filter = NwcClient.responseFilter(walletPubkey, now: now); + + // Assert + expect(filter.kinds, [23195]); + expect(filter.authors, [walletPubkey]); + }); + + test('bounds the replay with a since cutoff', () { + // Arrange + final now = DateTime.utc(2026, 1, 1, 12); + + // Act + final filter = NwcClient.responseFilter(walletPubkey, now: now); + + // Assert + expect(filter.since, isNotNull); + expect(filter.since, now.subtract(NwcClient.responseReplayWindow)); + }); + + test('leaves at least ten minutes of wallet clock skew', () { + // A response signed by a wallet whose clock lags the phone must still + // pass the relay-side since filter. + // Arrange + final now = DateTime.utc(2026, 1, 1, 12); + final laggingWalletResponse = now.subtract(const Duration(minutes: 9)); + + // Act + final filter = NwcClient.responseFilter(walletPubkey, now: now); + + // Assert + expect(NwcClient.responseReplayWindow, + greaterThanOrEqualTo(const Duration(minutes: 10))); + expect(filter.since!.isBefore(laggingWalletResponse), isTrue); + }); + + test('matches the skew budget already used for relay since filters', () { + // Assert + expect(NwcClient.responseReplayWindow, ChatCursorStore.cursorOverlap); + }); + }); +}