Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
47 changes: 21 additions & 26 deletions lib/features/disputes/notifiers/dispute_chat_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import 'package:mostro_mobile/shared/mixins/media_cache_mixin.dart';
import 'package:mostro_mobile/shared/utils/chat_keys.dart';
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';
import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart';
import 'package:mostro_mobile/features/subscriptions/subscription_manager_provider.dart';
import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart';
import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart';
import 'package:sembast/sembast.dart';
Expand Down Expand Up @@ -160,32 +161,16 @@ class DisputeChatNotifier extends StateNotifier<DisputeChatState> with MediaCach
_subscription = null;
}

// Subscribe to kind 14 chat events authored by K_sign. The spec requires
// filtering by authors, not #p, to prevent third-party flooding.
final chatKeys = _getChatKeys(session);
final nostrService = ref.read(nostrServiceProvider);

// Persisted per-conversation cursor (spec MUST); default lookback for
// conversations with no accepted events yet
final cursorSince =
await ref.read(disputeChatCursorStoreProvider).sinceFor(disputeId);
if (!mounted) return;
final since = cursorSince ??
DateTime.now().subtract(NostrEventExtensions.chatDefaultLookback);

final request = NostrRequest(
filters: [
NostrFilter(
kinds: [14],
authors: [chatKeys.sign.public],
since: since,
limit: NostrEventExtensions.chatDefaultLimit,
),
],
);

_subscription = nostrService.subscribeToEvents(request).listen(_onChatEvent);
logger.i('Subscribed to kind 14 chat via K_sign for dispute: $disputeId');
// Consume the app-wide dispute-chat stream: SubscriptionManager already
// maintains the kind-14 REQ (K_sign authors + shared persisted cursor)
// for every dispute session, and a private REQ here duplicated it on
// every relay. Events for other disputes are dropped by the K_sign
// pre-filter in _onChatEvent.
_subscription = ref
.read(subscriptionManagerProvider)
.disputeChat
.listen(_onChatEvent);
Comment thread
grunch marked this conversation as resolved.
Outdated
logger.i('Consuming shared dispute chat stream for dispute: $disputeId');
}

/// Listen for session changes and subscribe when admin shared key is ready
Expand Down Expand Up @@ -638,6 +623,16 @@ class DisputeChatNotifier extends StateNotifier<DisputeChatState> with MediaCach
try {
final sessions = ref.read(sessionNotifierProvider);

// Direct lookup: disputeId is persisted on the session when the
// dispute starts. Constant time, no side effects — the scan below
// instantiated an OrderNotifier per candidate on every event.
for (final session in sessions) {
if (session.disputeId == disputeId) {
return session;
}
}

// Fallback for sessions persisted before disputeId existed.
for (final session in sessions) {
if (session.orderId != null) {
try {
Expand Down
8 changes: 7 additions & 1 deletion lib/features/subscriptions/subscription_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,13 @@ class SubscriptionManager {
request: request,
streamSubscription: streamSubscription,
onCancel: () {
ref.read(nostrServiceProvider).unsubscribe(request.subscriptionId!);
// Tolerate teardown ordering: when the container is being
// disposed, the socket (and its REQs) dies with it anyway.
try {
ref.read(nostrServiceProvider).unsubscribe(request.subscriptionId!);
} catch (e) {
logger.w('Skipping relay CLOSE during teardown: $e');
}
},
);

Expand Down
132 changes: 132 additions & 0 deletions test/features/disputes/dispute_chat_single_req_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import 'dart:async';

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/nostr_event.dart';
import 'package:mostro_mobile/data/models/session.dart';
import 'package:mostro_mobile/data/repositories/event_storage.dart';
import 'package:mostro_mobile/features/disputes/notifiers/dispute_chat_notifier.dart';
import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart';
import 'package:mostro_mobile/features/subscriptions/subscription_manager_provider.dart';
import 'package:mostro_mobile/shared/notifiers/session_notifier.dart';
import 'package:mostro_mobile/shared/providers/mostro_service_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/chat_keys.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';

/// The dispute chat notifier used to open its OWN kind-14 REQ — a duplicate
/// of the one `SubscriptionManager` already maintains for
/// `SubscriptionType.disputeChat` (whose stream nobody consumed) — and
/// resolved its session by scanning every session and instantiating an
/// `OrderNotifier` (DB sync + subscriptions) per candidate, on EVERY
/// incoming event. It now consumes the manager's stream and resolves the
/// session through the persisted `session.disputeId`.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();

const disputeId = 'dispute-single-req';
const orderId = 'order-single-req';
final tradeKey = NostrKeyPairs(
private:
'0000000000000000000000000000000000000000000000000000000000000005',
);
final adminKey = NostrKeyPairs(
private:
'0000000000000000000000000000000000000000000000000000000000000006',
);

late Session session;
late StreamController<NostrEvent> disputeStream;
late MockSubscriptionManagerSpy manager;
late ProviderContainer container;

setUp(() async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();

session = Session(
masterKey: tradeKey,
tradeKey: tradeKey,
keyIndex: 1,
fullPrivacy: false,
startTime: DateTime.now(),
orderId: orderId,
)
..disputeId = disputeId
..setAdminPeer(adminKey.public);

disputeStream = StreamController<NostrEvent>.broadcast();
manager = MockSubscriptionManagerSpy();
when(manager.disputeChat).thenAnswer((_) => disputeStream.stream);

final db = await newDatabaseFactoryMemory()
.openDatabase('dispute_single_req.db');

container = ProviderContainer(overrides: [
sharedPreferencesProvider.overrideWithValue(SharedPreferencesAsync()),
eventStorageProvider.overrideWithValue(EventStorage(db: db)),
subscriptionManagerProvider.overrideWithValue(manager),
mostroServiceProvider
.overrideWith((ref) => throw UnimplementedError('unused')),
sessionNotifierProvider
.overrideWith((ref) => _FixedSessionNotifier(ref, [session])),
// The old resolution instantiated an OrderNotifier per session per
// event; the disputeId lookup must never need one.
orderNotifierProvider.overrideWith(
(ref, id) =>
throw StateError('session resolution must not build notifiers'),
),
]);
addTearDown(container.dispose);
addTearDown(disputeStream.close);
});

Future<NostrEvent> adminEnvelope(String text) {
final chatKeys = ChatKeys.fromSharedKey(session.adminSharedKey!);
final rumor = NostrEventExtensions.createChatRumor(
senderKeys: adminKey,
content: text,
);
return rumor.chatWrap(chatKeys);
}

test('events from the shared manager stream reach the notifier', () async {
container.read(disputeChatNotifierProvider(disputeId).notifier);
await pumpEventQueue(times: 50);

disputeStream.add(await adminEnvelope('hola admin'));

final deadline = DateTime.now().add(const Duration(seconds: 5));
while (container
.read(disputeChatNotifierProvider(disputeId))
.messages
.isEmpty &&
DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}

final messages =
container.read(disputeChatNotifierProvider(disputeId)).messages;
expect(messages, hasLength(1),
reason: 'the notifier must consume SubscriptionManager.disputeChat '
'instead of opening its own REQ, and resolve its session via '
'session.disputeId without touching order notifiers');
expect(messages.single.content, 'hola admin');
});
}

/// Session list the notifier can read without touching storage.
class _FixedSessionNotifier extends SessionNotifier {
_FixedSessionNotifier(Ref ref, List<Session> sessions)
: super(ref, MockSessionStorage(), MockSettings()) {
state = sessions;
}
}
Loading