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
37 changes: 3 additions & 34 deletions lib/features/trades/screens/trade_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<MostroMessage> 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<MostroMessage>.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<MostroMessage> messages, Status status) =>
StateMessageFinder.findMessageForState(messages, status);
}
96 changes: 96 additions & 0 deletions lib/features/trades/state_message_finder.dart
Original file line number Diff line number Diff line change
@@ -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<Map<Status, MostroMessage?>> _memo = Expando();

/// Returns the newest valid message that produced [status], or null.
static MostroMessage? findMessageForState(
List<MostroMessage> 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<MostroMessage> 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<MostroMessage>.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;
}
36 changes: 32 additions & 4 deletions lib/services/nwc/nwc_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Expand Down
149 changes: 149 additions & 0 deletions test/features/trades/state_message_finder_test.dart
Original file line number Diff line number Diff line change
@@ -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));
});
});
}
Loading
Loading