From b66d78e9a5ee5f202ab2b20e041bb55582306365 Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 31 Aug 2026 19:38:20 -0300 Subject: [PATCH 1/3] fix: stop one chat conversation from showing as two chat rooms KeyManager.getNextKeyIndex() read the stored trade key counter, stored currentIndex + 1 and also returned currentIndex + 1. The counter is the index deriveTradeKey() hands out next (it derives at the stored index, then increments), so the index reserved here was the very same one the next deriveTradeKey() call derived. getNextKeyIndex() is used only to reserve the trade key for a range order's child session, so after a range order release the next order the user created or took got a trade key identical to the child session's. Session.sharedKey is ECDH(tradeKey.private, peer.publicKey), so two such sessions sharing a counterparty derive the identical ChatKeys pair. Both ChatRoomNotifiers then pass the `event.pubkey == chatKeys.sign.public` ownership check for the same kind 14 envelopes, each stores them under its own orderId and each renders its own row: two chat rooms with the same peer, holding the same messages, both live, with a message typed in one appearing in the other. getNextKeyIndex() now returns currentIndex, reserving the index the counter points at and advancing past it. No collision and no gap: two consecutive calls hand out N and N+1. Sessions created before this fix are still on disk, so the chat list also collapses rows that share a conversation key, keeping the newest. An equal ECDH shared key means literally the same messages on both rows, and distinct orders always differ on at least one trade key, so this can never merge two legitimate conversations. Also hardens SessionNotifier against a related class of duplicate: Session.orderId is mutable and reachable through three maps of which only _sessions is keyed by orderId, while the promotion paths purged stale entries by identity alone. _emitState() now dedupes by orderId (the persisted _sessions entry wins) and _claimOrderId() drops other in-memory sessions carrying an orderId that has just been claimed. --- .../chat/notifiers/chat_rooms_notifier.dart | 74 +++++++++++------ lib/features/key_manager/key_manager.dart | 11 ++- lib/shared/notifiers/session_notifier.dart | 58 ++++++++++++- .../key_manager/key_manager_cache_test.dart | 20 +++++ test/notifiers/session_notifier_test.dart | 82 +++++++++++++++++++ 5 files changed, 214 insertions(+), 31 deletions(-) diff --git a/lib/features/chat/notifiers/chat_rooms_notifier.dart b/lib/features/chat/notifiers/chat_rooms_notifier.dart index 4f4583c0..49f751a4 100644 --- a/lib/features/chat/notifiers/chat_rooms_notifier.dart +++ b/lib/features/chat/notifiers/chat_rooms_notifier.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; +import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/services/logger_service.dart'; @@ -52,19 +53,7 @@ class ChatRoomsNotifier extends StateNotifier> { final now = DateTime.now(); try { - final chats = sessions - .where( - (s) => - s.orderId != null && - (s.peer != null || - s.startTime.isAfter(now.subtract(const Duration(hours: 1)))), - ) - .map((s) { - final chat = ref.read(chatRoomsProvider(s.orderId!)); - return chat; - }) - .where((chat) => chat.messages.isNotEmpty) - .toList(); + final chats = _chatsForSessions(sessions, now); state = chats; logger.i("Loaded ${chats.length} chats with messages"); @@ -83,20 +72,7 @@ class ChatRoomsNotifier extends StateNotifier> { final now = DateTime.now(); try { - final chats = sessions - .where( - (s) => - s.orderId != null && - (s.peer != null || - s.startTime.isAfter(now.subtract(const Duration(hours: 1)))), - ) - .map((s) { - // Force a fresh read of the chat state - final chat = ref.read(chatRoomsProvider(s.orderId!)); - return chat; - }) - .where((chat) => chat.messages.isNotEmpty) - .toList(); + final chats = _chatsForSessions(sessions, now); // Skip the emission when nothing visible changed: this runs after // every incoming chat event and a fresh list rebuilds the whole @@ -122,6 +98,50 @@ class ChatRoomsNotifier extends StateNotifier> { } } + /// Builds the visible chat rooms for [sessions], at most one row per + /// conversation. + /// + /// Two rows can otherwise describe a single conversation: + /// + /// - two sessions sharing an orderId resolve to the very same + /// [chatRoomsProvider], rendering the identical room twice; + /// - two sessions sharing a trade key *and* a peer derive the identical + /// ECDH shared key, so both accept the very same chat envelopes and each + /// stores them under its own orderId. `KeyManager.getNextKeyIndex` used + /// to hand out an already-reserved index, which produced exactly this. + /// + /// The key collision is fixed at the source, but sessions created before + /// the fix are still on disk, so collapse them here too and keep the newest. + List _chatsForSessions(List sessions, DateTime now) { + final cutoff = now.subtract(const Duration(hours: 1)); + final ordered = [...sessions] + ..sort((a, b) => b.startTime.compareTo(a.startTime)); + final seenOrderIds = {}; + final seenConversations = {}; + final chats = []; + for (final session in ordered) { + final orderId = session.orderId; + if (orderId == null) continue; + if (session.peer == null && !session.startTime.isAfter(cutoff)) continue; + if (!seenOrderIds.add(orderId)) continue; + // Identifies the conversation itself: the chat envelope keys are derived + // from this shared secret, so an equal value means literally the same + // messages on both rows. + final conversationId = session.sharedKey?.public; + if (conversationId != null && !seenConversations.add(conversationId)) { + logger.w( + 'Collapsing chat for order $orderId: it shares a conversation key ' + 'with another session (colliding trade keys).', + ); + continue; + } + final chat = ref.read(chatRoomsProvider(orderId)); + if (chat.messages.isEmpty) continue; + chats.add(chat); + } + return chats; + } + void _refreshAllSubscriptions() { // No need to manually refresh subscriptions // SubscriptionManager now handles this automatically based on SessionNotifier changes diff --git a/lib/features/key_manager/key_manager.dart b/lib/features/key_manager/key_manager.dart index 36452b78..7277b15b 100644 --- a/lib/features/key_manager/key_manager.dart +++ b/lib/features/key_manager/key_manager.dart @@ -132,11 +132,20 @@ class KeyManager { return _storage.hasPersistedTradeKeyIndex(); } + /// Reserve and return the next free trade key index, advancing the counter + /// past it. + /// + /// The stored counter is the index [deriveTradeKey] will hand out next, so + /// the reserved index must be that value — returning `currentIndex + 1` + /// while storing `currentIndex + 1` handed the very same index to the next + /// [deriveTradeKey] call. Two live sessions then shared a trade key, and + /// with a common counterparty also the ECDH shared key the chat envelope is + /// derived from, so one conversation surfaced as two chat rooms. Future getNextKeyIndex() async { final currentIndex = await getCurrentKeyIndex(); await setCurrentKeyIndex(currentIndex + 1); - return currentIndex + 1; + return currentIndex; } Future setCurrentKeyIndex(int index) async { diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index 768e5b02..efbab251 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -146,13 +146,61 @@ class SessionNotifier extends StateNotifier> { } void _emitState() { + // A session is reachable through three maps and only `_sessions` is keyed + // by orderId, so the same order can otherwise surface twice (e.g. a + // request-id session whose orderId was assigned by Mostro alongside the + // persisted session the restore flow rebuilt for that same order). Every + // consumer derived from this state — most visibly the chat list, which + // renders one row per session — would then show the order twice. + // `_sessions` holds the persisted session, so it wins. final combined = []; - combined.addAll(_sessions.values); - combined.addAll(_requestIdToSession.values); - combined.addAll(_pendingChildSessions.values); + final claimedOrderIds = {}; + for (final session in [ + ..._sessions.values, + ..._requestIdToSession.values, + ..._pendingChildSessions.values, + ]) { + final orderId = session.orderId; + if (orderId != null && !claimedOrderIds.add(orderId)) continue; + combined.add(session); + } state = combined; } + /// Drops every *other* in-memory session that carries [orderId] now that + /// [owner] is the session of record for it. Identity is not enough: the + /// restore flow rebuilds a brand new [Session] for an order that a pending + /// request-id or child session may already point at. + void _claimOrderId(String orderId, Session owner) { + bool isStale(Session session) => + !identical(session, owner) && session.orderId == orderId; + + void logEviction(Session session) { + // A differing trade key means the evicted session held key material no + // other map can resolve any more, so surface it rather than dropping it + // silently. + if (session.tradeKey.public != owner.tradeKey.public) { + logger.w( + 'Evicting session for order $orderId with a different trade key ' + '(${session.tradeKey.public}); it is no longer resolvable.', + ); + } else { + logger.d('Evicted a duplicate in-memory session for order $orderId'); + } + } + + _requestIdToSession.removeWhere((_, session) { + if (!isStale(session)) return false; + logEviction(session); + return true; + }); + _pendingChildSessions.removeWhere((_, session) { + if (!isStale(session)) return false; + logEviction(session); + return true; + }); + } + void _scheduleCleanup() { _cleanupTimer?.cancel(); _cleanupTimer = Timer.periodic( @@ -221,6 +269,7 @@ class SessionNotifier extends StateNotifier> { if (orderId != null) { _sessions[orderId] = session; + _claimOrderId(orderId, session); } else if (requestId != null) { _requestIdToSession[requestId] = session; } @@ -233,6 +282,7 @@ class SessionNotifier extends StateNotifier> { _sessions[session.orderId!] = session; _requestIdToSession.removeWhere((_, value) => identical(value, session)); _pendingChildSessions.remove(session.tradeKey.public); + _claimOrderId(session.orderId!, session); await _storage.putSession(session); _emitState(); @@ -250,6 +300,7 @@ class SessionNotifier extends StateNotifier> { if (orderId == null) return; _sessions[orderId] = session; _requestIdToSession.removeWhere((_, value) => identical(value, session)); + _claimOrderId(orderId, session); _emitState(); } @@ -410,6 +461,7 @@ class SessionNotifier extends StateNotifier> { session.orderId = childOrderId; _sessions[childOrderId] = session; + _claimOrderId(childOrderId, session); await _storage.putSession(session); _emitState(); diff --git a/test/features/key_manager/key_manager_cache_test.dart b/test/features/key_manager/key_manager_cache_test.dart index b59007c7..a1a64bb7 100644 --- a/test/features/key_manager/key_manager_cache_test.dart +++ b/test/features/key_manager/key_manager_cache_test.dart @@ -71,4 +71,24 @@ void main() { expect(derivator.privateToPublicKey(cached), derivator.privateToPublicKey(direct)); }); + + test('getNextKeyIndex reserves an index that deriveTradeKey cannot reuse', + () async { + // Arrange: the counter points at the next index to hand out. + await manager.setCurrentKeyIndex(5); + + // Act: reserve an index for a range order's child session, then derive + // the trade key for the next order the user creates or takes. + final reserved = await manager.getNextKeyIndex(); + final reservedKey = await manager.deriveTradeKeyFromIndex(reserved); + final nextKey = await manager.deriveTradeKey(); + + // Assert: reusing the reserved index would give two live sessions the + // same trade key, and with a shared counterparty the same ECDH shared + // key — i.e. one conversation surfacing as two chats. + expect(reserved, 5, reason: 'reserves the index deriveTradeKey was on'); + expect(reservedKey.public, isNot(nextKey.public)); + expect(await manager.getCurrentKeyIndex(), 7, + reason: 'both handed-out indices are consumed, leaving no gap'); + }); } diff --git a/test/notifiers/session_notifier_test.dart b/test/notifiers/session_notifier_test.dart index 2b0215d0..a4aceac2 100644 --- a/test/notifiers/session_notifier_test.dart +++ b/test/notifiers/session_notifier_test.dart @@ -4,6 +4,7 @@ import 'package:mockito/mockito.dart'; import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/features/key_manager/key_manager.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; @@ -25,6 +26,10 @@ void main() { private: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', ); + final derivedTradeKey = NostrKeyPairs( + private: + '0fedcba9876543210fedcba9876543210fedcba9876543210fedcba987654321', + ); setUpAll(() { provideDummy(MockKeyManager()); @@ -40,6 +45,9 @@ void main() { when(mockKeyManager.masterKeyPair).thenReturn(masterKey); when(mockPushService.registerToken(any)).thenAnswer((_) async => true); when(mockStorage.putSession(any)).thenAnswer((_) async {}); + when(mockKeyManager.getCurrentKeyIndex()).thenAnswer((_) async => 1); + when(mockKeyManager.deriveTradeKey()) + .thenAnswer((_) async => derivedTradeKey); notifier = SessionNotifier( mockRef, @@ -131,4 +139,78 @@ void main() { verifyNever(mockPushService.registerToken(any)); }); }); + + group('duplicate order sessions', () { + Session buildSession(String orderId, NostrKeyPairs tradeKey) => Session( + masterKey: masterKey, + tradeKey: tradeKey, + keyIndex: 7, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: orderId, + role: Role.seller, + ); + + test( + 'saveSession drops a stale request session that already carries the ' + 'same orderId', () async { + // Arrange: a pending create-order session (keyed by requestId) that has + // already been assigned its orderId by Mostro's newOrder response. + final pending = await notifier.newSession(requestId: 42, role: Role.seller); + pending.orderId = 'order-1'; + + // Act: a *different* Session object for the same order is persisted, + // as the restore flow does (it rebuilds sessions from scratch). + await notifier.saveSession(buildSession('order-1', childTradeKey)); + + // Assert: the order appears exactly once in the emitted state. + expect( + notifier.state.where((s) => s.orderId == 'order-1').length, + 1, + ); + }); + + test( + 'linkChildSessionToOrderId drops a stale session that already carries ' + 'the same orderId', () async { + // Arrange: an order already known by requestId that resolved to + // 'child-order-id', plus a pending child session for the same order. + final pending = await notifier.newSession(requestId: 7, role: Role.seller); + pending.orderId = 'child-order-id'; + await notifier.createChildOrderSession( + tradeKey: childTradeKey, + keyIndex: 5, + parentOrderId: 'parent-order-id', + role: Role.seller, + ); + + // Act + await notifier.linkChildSessionToOrderId( + 'child-order-id', + childTradeKey.public, + ); + + // Assert + expect( + notifier.state.where((s) => s.orderId == 'child-order-id').length, + 1, + ); + }); + + test('registerSessionInMemory never emits the same orderId twice', + () async { + // Arrange + final pending = await notifier.newSession(requestId: 9, role: Role.seller); + pending.orderId = 'order-2'; + + // Act + notifier.registerSessionInMemory(buildSession('order-2', childTradeKey)); + + // Assert + expect( + notifier.state.where((s) => s.orderId == 'order-2').length, + 1, + ); + }); + }); } From bf1f3547138b9ebb04cc526be22118775c124606 Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 31 Aug 2026 20:56:29 -0300 Subject: [PATCH 2/3] fix: do not hide a chat whose history lives on the older colliding session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversation-key dedup claimed the key before checking that the room actually held messages. Envelopes are stored globally once, under whichever orderId first handled them, and history is reloaded by that orderId, so after a restart only one of two colliding rooms holds the conversation — and it can be the older session's. The newest session's empty room then consumed the claim, was dropped as empty, and the room holding the messages was skipped as a duplicate, hiding the chat entirely. Resolve the room and require it non-empty before claiming the conversation key, so the empty candidate is passed over and the one with the history wins. --- .../chat/notifiers/chat_rooms_notifier.dart | 9 +- .../chat/chat_rooms_notifier_dedup_test.dart | 170 ++++++++++++++++++ 2 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 test/features/chat/chat_rooms_notifier_dedup_test.dart diff --git a/lib/features/chat/notifiers/chat_rooms_notifier.dart b/lib/features/chat/notifiers/chat_rooms_notifier.dart index 49f751a4..050bff49 100644 --- a/lib/features/chat/notifiers/chat_rooms_notifier.dart +++ b/lib/features/chat/notifiers/chat_rooms_notifier.dart @@ -124,6 +124,13 @@ class ChatRoomsNotifier extends StateNotifier> { if (orderId == null) continue; if (session.peer == null && !session.startTime.isAfter(cutoff)) continue; if (!seenOrderIds.add(orderId)) continue; + // Resolve the room before claiming anything. Envelopes are stored once + // globally, under whichever orderId handled them first, and history is + // reloaded by that orderId — so after a restart only one of two + // colliding rooms holds the conversation. Claiming for an empty room + // would drop the one that has the messages and hide the chat entirely. + final chat = ref.read(chatRoomsProvider(orderId)); + if (chat.messages.isEmpty) continue; // Identifies the conversation itself: the chat envelope keys are derived // from this shared secret, so an equal value means literally the same // messages on both rows. @@ -135,8 +142,6 @@ class ChatRoomsNotifier extends StateNotifier> { ); continue; } - final chat = ref.read(chatRoomsProvider(orderId)); - if (chat.messages.isEmpty) continue; chats.add(chat); } return chats; diff --git a/test/features/chat/chat_rooms_notifier_dedup_test.dart b/test/features/chat/chat_rooms_notifier_dedup_test.dart new file mode 100644 index 00000000..76633bfc --- /dev/null +++ b/test/features/chat/chat_rooms_notifier_dedup_test.dart @@ -0,0 +1,170 @@ +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/chat_room.dart'; +import 'package:mostro_mobile/data/models/peer.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_room_notifier.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../mocks.mocks.dart'; + +/// `KeyManager.getNextKeyIndex` used to hand out an already-reserved trade +/// key index, so two sessions could share a trade key. With a common +/// counterparty they also share the ECDH shared key the chat envelope keys +/// are derived from, and both chat rooms then accept the very same messages. +/// Sessions created before that fix are still on disk, so the list collapses +/// them into a single row. +void main() { + const olderOrderId = 'order-older'; + const newerOrderId = 'order-newer'; + // A real curve point: the shared key is a genuine ECDH computation. + final peerPubkey = NostrKeyPairs( + private: + '5566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344', + ).public; + // Both sessions carry this trade key, which is exactly the collision. + const sharedTradeKey = + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + + late ProviderContainer container; + late Map> messagesByOrderId; + + NostrEvent message(String id) => NostrEvent( + id: id, + kind: 14, + content: 'hola', + sig: '', + pubkey: 'peer-pubkey', + createdAt: DateTime.fromMillisecondsSinceEpoch(1000), + tags: const [], + ); + + Session session({ + required String orderId, + required DateTime startTime, + String tradeKeyPrivate = sharedTradeKey, + }) { + final s = Session( + masterKey: NostrKeyPairs( + private: + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'), + tradeKey: NostrKeyPairs(private: tradeKeyPrivate), + keyIndex: 0, + fullPrivacy: false, + startTime: startTime, + peer: Peer(publicKey: peerPubkey), + ); + s.orderId = orderId; + return s; + } + + void arrange(List sessions) { + container = ProviderContainer(overrides: [ + sessionNotifierProvider.overrideWith((ref) { + final notifier = _FakeSessionNotifier(ref); + notifier.emit(sessions); + return notifier; + }), + chatRoomsProvider.overrideWith((ref, id) => ChatRoomNotifier( + ChatRoom(orderId: id, messages: messagesByOrderId[id] ?? []), + id, + ref, + )), + ]); + addTearDown(container.dispose); + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + messagesByOrderId = {}; + }); + + test('two sessions sharing a conversation key render a single row', () { + // Arrange: both rooms hold the conversation (as they do while the app is + // running and both notifiers accept the same live envelopes). + messagesByOrderId = { + olderOrderId: [message('m1')], + newerOrderId: [message('m1')], + }; + arrange([ + session( + orderId: olderOrderId, + startTime: DateTime.now().subtract(const Duration(minutes: 30)), + ), + session(orderId: newerOrderId, startTime: DateTime.now()), + ]); + + // Act + final chats = container.read(chatRoomsNotifierProvider); + + // Assert: the newest session wins, and the conversation is shown once. + expect(chats.map((c) => c.orderId), [newerOrderId]); + }); + + test( + 'the conversation survives when only the older room holds the history', + () { + // Arrange: after a restart only one room reloads the history — envelopes + // are stored globally once, under whichever orderId first handled them + // (ChatRoomNotifier._onChatEvent), and _loadHistoricalMessages filters on + // that orderId. Here the newer session's room comes up empty. + messagesByOrderId = { + olderOrderId: [message('m1')], + newerOrderId: [], + }; + arrange([ + session( + orderId: olderOrderId, + startTime: DateTime.now().subtract(const Duration(minutes: 30)), + ), + session(orderId: newerOrderId, startTime: DateTime.now()), + ]); + + // Act + final chats = container.read(chatRoomsNotifierProvider); + + // Assert: claiming the conversation for the empty newer room would drop + // the older one too and make the chat vanish entirely. + expect(chats.map((c) => c.orderId), [olderOrderId]); + }); + + test('distinct conversations are both kept', () { + // Arrange: different trade keys, so different ECDH shared keys. + messagesByOrderId = { + olderOrderId: [message('m1')], + newerOrderId: [message('m2')], + }; + arrange([ + session( + orderId: olderOrderId, + startTime: DateTime.now().subtract(const Duration(minutes: 30)), + ), + session( + orderId: newerOrderId, + startTime: DateTime.now(), + tradeKeyPrivate: + '0fedcba9876543210fedcba9876543210fedcba9876543210fedcba987654321', + ), + ]); + + // Act + final chats = container.read(chatRoomsNotifierProvider); + + // Assert + expect(chats.map((c) => c.orderId), [newerOrderId, olderOrderId]); + }); +} + +/// Session list the provider can read without touching storage. +class _FakeSessionNotifier extends SessionNotifier { + _FakeSessionNotifier(Ref ref) + : super(ref, MockSessionStorage(), MockSettings()) { + state = const []; + } + + void emit(List sessions) => state = sessions; +} From 1f1b20308081b205bccb5390ae3b3011949bb3de Mon Sep 17 00:00:00 2001 From: grunch Date: Tue, 1 Sep 2026 07:31:44 -0300 Subject: [PATCH 3/3] test: wait for the isolate result instead of draining the event queue dispute_chat_duplicate_envelope_test failed on CI (and locally) because chatUnwrap verifies and decrypts on a worker isolate whose spawn takes real wall-clock time: pumpEventQueue can return before the valid envelope has been accepted, so the assertion read an empty message list. Poll for the message to land instead. Test-only, and identical to the fix on perf/orders-since-cursor, so whichever branch lands first the other rebases cleanly. --- .../dispute_chat_duplicate_envelope_test.dart | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/features/disputes/dispute_chat_duplicate_envelope_test.dart b/test/features/disputes/dispute_chat_duplicate_envelope_test.dart index 230b9708..34f08be6 100644 --- a/test/features/disputes/dispute_chat_duplicate_envelope_test.dart +++ b/test/features/disputes/dispute_chat_duplicate_envelope_test.dart @@ -174,7 +174,13 @@ void main() { nostrService.controller.add(forged); nostrService.controller.add(real); - await pumpEventQueue(times: 200); + // chatUnwrap verifies and decrypts on a worker isolate, whose spawn takes + // real wall-clock time: draining the event queue alone can return before + // the valid envelope has been accepted. Poll until it lands instead. + await _waitFor( + () => container.read(disputeChatNotifierProvider(disputeId)).messages + .isNotEmpty, + ); final messages = container.read(disputeChatNotifierProvider(disputeId)).messages; @@ -185,3 +191,15 @@ void main() { expect(notifier.mounted, isTrue); }); } + +/// Polls [condition] until it holds or [timeout] elapses, yielding to the +/// event loop between attempts so isolate results can be delivered. +Future _waitFor( + bool Function() condition, { + Duration timeout = const Duration(seconds: 10), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition() && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 10)); + } +}