Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import 'package:mostro_mobile/features/key_manager/key_derivator.dart';
import 'package:mostro_mobile/features/key_manager/key_manager.dart';
import 'package:mostro_mobile/features/key_manager/key_storage.dart';
import 'package:mostro_mobile/features/notifications/utils/notification_data_extractor.dart';
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';
import 'package:mostro_mobile/features/notifications/utils/notification_message_mapper.dart';
import 'package:mostro_mobile/generated/l10n.dart';
import 'package:mostro_mobile/generated/l10n_en.dart';
Expand Down Expand Up @@ -157,26 +158,70 @@ Future<MostroMessage?> _decryptAndProcessEvent(NostrEvent event) async {
orElse: () => null,
);

if (matchingSession == null) {
return null;
if (matchingSession != null) {
return _handleTradeKeyEvent(event, matchingSession);
}

final decryptedEvent = await event.unWrap(matchingSession.tradeKey.private);
if (decryptedEvent.content == null) {
return null;
// P2P chat: match by sharedKey.public
final chatMatch = sessions.cast<Session?>().firstWhere(
(s) => s?.sharedKey?.public == event.recipient,
orElse: () => null,
);

if (chatMatch != null) {
return _handleP2PChatEvent(event, chatMatch);
}

final result = jsonDecode(decryptedEvent.content!);
if (result is! List || result.isEmpty) {
return null;
} catch (e) {
logger.e('Decrypt error: $e');
return null;
}
}

/// Handle events matched by tradeKey (Mostro protocol + admin/dispute DMs)
Future<MostroMessage?> _handleTradeKeyEvent(NostrEvent event, Session session) async {
final decryptedEvent = await event.unWrap(session.tradeKey.private);
if (decryptedEvent.content == null) {
return null;
}

final result = jsonDecode(decryptedEvent.content!);
if (result is! List || result.isEmpty) {
return null;
}

// Detect admin/dispute DM format: [{"dm": {"action": "send-dm", ...}}]
final firstItem = result[0];
if (NostrUtils.isDmPayload(firstItem)) {
return MostroMessage(
action: mostro_action.Action.sendDm,
id: session.orderId,
timestamp: event.createdAt?.millisecondsSinceEpoch,
);
}

final mostroMessage = MostroMessage.fromJson(result[0]);
mostroMessage.timestamp = event.createdAt?.millisecondsSinceEpoch;

return mostroMessage;
}

/// Handle P2P chat events matched by sharedKey
Future<MostroMessage?> _handleP2PChatEvent(NostrEvent event, Session session) async {
try {
final decryptedEvent = await event.p2pUnwrap(session.sharedKey!);
if (decryptedEvent.content == null || decryptedEvent.content!.isEmpty) {
return null;
}

final mostroMessage = MostroMessage.fromJson(result[0]);
mostroMessage.timestamp = event.createdAt?.millisecondsSinceEpoch;

return mostroMessage;
return MostroMessage(
action: mostro_action.Action.sendDm,
id: session.orderId,
timestamp: event.createdAt?.millisecondsSinceEpoch,
);
} catch (e) {
logger.e('Decrypt error: $e');
logger.e('P2P chat decrypt error: $e');
return null;
}
}
Expand Down
14 changes: 11 additions & 3 deletions lib/features/notifications/utils/notification_data_extractor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -177,20 +177,28 @@ class NotificationDataExtractor {
// No additional values needed
break;

case Action.sendDm:
// Admin/dispute DM — no payload extraction needed, generic message
break;

case Action.cooperativeCancelAccepted:
// No additional values needed
break;

case Action.cantDo:
final cantDo = event.getPayload<CantDo>();
values['reason'] = cantDo?.cantDoReason.toString();
isTemporary = true; // cantDo notifications are temporary
break;

case Action.rate:
// No additional values needed
break;

case Action.rateReceived:
// This action doesn't generate notifications
return null;

default:
// Unknown actions generate temporary notifications
isTemporary = true;
Expand Down
7 changes: 7 additions & 0 deletions lib/services/mostro_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:mostro_mobile/shared/providers.dart';
import 'package:mostro_mobile/features/settings/settings_provider.dart';
import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart';
import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart';
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';

class MostroService {
final Ref ref;
Expand Down Expand Up @@ -129,6 +130,12 @@ class MostroService {
return;
}

// Skip dispute chat messages (they have "dm" key and are handled by DisputeChatNotifier)
if (NostrUtils.isDmPayload(result[0])) {
logger.i('Skipping dispute chat message (handled by DisputeChatNotifier)');
return;
}

// Skip restore-specific payloads that arrive as historical events due to temporary subscription
if (result[0] is Map && _isRestorePayload(result[0] as Map<String, dynamic>)) {
return;
Expand Down
6 changes: 6 additions & 0 deletions lib/shared/utils/nostr_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,12 @@ class NostrUtils {
}
}

/// Checks if a decoded Mostro payload item is a DM (dispute/admin chat) message.
/// DM messages use the format: {"dm": {"action": "send-dm", ...}}
static bool isDmPayload(dynamic item) {
return item is Map && item.containsKey('dm');
}

static Future<String> encryptNIP44(
String content, String privkey, String pubkey) async {
try {
Expand Down
8 changes: 8 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce
url: "https://pub.dev"
source: hosted
version: "7.2.0"
mockito:
dependency: "direct dev"
description:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import 'dart:convert';

import 'package:flutter_test/flutter_test.dart';
import 'package:mostro_mobile/data/models/enums/action.dart';
import 'package:mostro_mobile/data/models/mostro_message.dart';
import 'package:mostro_mobile/features/notifications/utils/notification_data_extractor.dart';
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';

/// Tests for the admin/dispute DM background notification pipeline (Phase 1).
///
/// Validates three layers:
/// 1. [NostrUtils.isDmPayload] — pure detection of the `{"dm": ...}` envelope
/// 2. [MostroMessage] construction — synthetic message with [Action.sendDm]
/// 3. [NotificationDataExtractor] — ensures sendDm is NOT marked temporary
void main() {
group('NostrUtils.isDmPayload', () {
test('detects dm wrapper key in decoded JSON', () {
final dmPayload = jsonDecode(
'[{"dm": {"action": "send-dm", "payload": {"text_message": "Hello"}}}]',
);

expect(dmPayload, isList);
expect(dmPayload, isNotEmpty);
expect(NostrUtils.isDmPayload(dmPayload[0]), isTrue);
});

test('does not detect dm key in standard Mostro message', () {
final orderPayload = jsonDecode(
'[{"order": {"action": "new-order", "id": "abc123", "payload": null}}]',
);

expect(NostrUtils.isDmPayload(orderPayload[0]), isFalse);
});

test('does not detect dm key in restore message', () {
final restorePayload = jsonDecode(
'[{"restore": {"action": "restore-session", "id": "abc123"}}]',
);

expect(NostrUtils.isDmPayload(restorePayload[0]), isFalse);
});

test('does not detect dm key in cant-do message', () {
final cantDoPayload = jsonDecode(
'[{"cant-do": {"action": "cant-do", "payload": null}}]',
);

expect(NostrUtils.isDmPayload(cantDoPayload[0]), isFalse);
});

test('handles dm payload with minimal content', () {
final dmPayload = jsonDecode('[{"dm": {}}]');

expect(NostrUtils.isDmPayload(dmPayload[0]), isTrue);
});

test('returns false for non-Map types', () {
expect(NostrUtils.isDmPayload('string'), isFalse);
expect(NostrUtils.isDmPayload(42), isFalse);
expect(NostrUtils.isDmPayload(null), isFalse);
expect(NostrUtils.isDmPayload([]), isFalse);
});
});

group('MostroMessage construction for DM', () {
test('preserves orderId and timestamp with sendDm action', () {
const testOrderId = 'test-order-123';
const testTimestamp = 1700000000000;

final message = MostroMessage(
action: Action.sendDm,
id: testOrderId,
timestamp: testTimestamp,
);

expect(message.action, Action.sendDm);
expect(message.id, testOrderId);
expect(message.timestamp, testTimestamp);
});
});

group('NotificationDataExtractor for sendDm', () {
test('produces non-temporary notification data', () async {
final message = MostroMessage(
action: Action.sendDm,
id: 'order-abc',
timestamp: 1700000000000,
);

final data = await NotificationDataExtractor.extractFromMostroMessage(
message,
null,
);

expect(data, isNotNull);
expect(data!.isTemporary, isFalse);
expect(data.action, Action.sendDm);
expect(data.orderId, 'order-abc');
});

test('returns empty values map (no payload extraction)', () async {
final message = MostroMessage(
action: Action.sendDm,
id: 'order-xyz',
);

final data = await NotificationDataExtractor.extractFromMostroMessage(
message,
null,
);

expect(data, isNotNull);
expect(data!.values, isEmpty);
});
});

group('NotificationDataExtractor for cooperativeCancelAccepted', () {
test('produces non-temporary notification data', () async {
final message = MostroMessage(
action: Action.cooperativeCancelAccepted,
id: 'order-cancel',
);

final data = await NotificationDataExtractor.extractFromMostroMessage(
message,
null,
);

expect(data, isNotNull);
expect(data!.isTemporary, isFalse);
expect(data.action, Action.cooperativeCancelAccepted);
});
});
}
Loading