From 20f1b55c8c70c9876b207b4793b93316db51998d Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 17 Jun 2026 22:24:45 -0300 Subject: [PATCH 1/3] feat(transport): add dual-receive support for NIP-44 direct messages (Phase A) --- lib/features/mostro/transport.dart | 23 ++++++ .../subscriptions/subscription_manager.dart | 26 ++++++- lib/services/mostro_service.dart | 29 +++++-- lib/shared/utils/nostr_utils.dart | 76 +++++++++++++++++++ 4 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 lib/features/mostro/transport.dart diff --git a/lib/features/mostro/transport.dart b/lib/features/mostro/transport.dart new file mode 100644 index 000000000..2d9fdfb6f --- /dev/null +++ b/lib/features/mostro/transport.dart @@ -0,0 +1,23 @@ +import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; + +/// Wire transport a Mostro node speaks. +/// +/// - [giftWrap]: protocol v1, NIP-59 gift wrap (kind 1059). +/// - [nip44]: protocol v2, NIP-44 direct message signed by the trade key +/// (kind 14). +/// +/// Modelled as an enum (rather than a raw integer threaded through the code) so +/// the send path, the receive subscription filters and the message `version` +/// field cannot drift out of sync. See +/// `docs/architecture/TRANSPORT_V2_MIGRATION.md` (§4.1). +enum Transport { giftWrap, nip44 } + +/// Resolves the wire transport for a node from its advertised +/// `protocol_version` (§4.1). +/// +/// Phase A (dual receive) keeps the v1 path behaviourally unchanged, so this +/// always resolves to [Transport.giftWrap]. Phase C (auto-detection and wiring) +/// replaces the body with the real per-node resolution driven by +/// [MostroInstance.protocolVersion] and the explicit downgrade logging required +/// by the version-skew guard. +Transport resolveTransport(MostroInstance? instance) => Transport.giftWrap; diff --git a/lib/features/subscriptions/subscription_manager.dart b/lib/features/subscriptions/subscription_manager.dart index ea08f5f78..56c4524db 100644 --- a/lib/features/subscriptions/subscription_manager.dart +++ b/lib/features/subscriptions/subscription_manager.dart @@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/core/models/relay_list_event.dart'; import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/features/mostro/transport.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/features/subscriptions/subscription.dart'; import 'package:mostro_mobile/features/subscriptions/subscription_type.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; @@ -125,10 +127,26 @@ class SubscriptionManager { if (sessions.isEmpty) { return null; } - return NostrFilter( - kinds: [1059], - p: sessions.map((s) => s.tradeKey.public).toList(), - ); + final tradeKeys = sessions.map((s) => s.tradeKey.public).toList(); + // Transport selected per node (§4.1). Phase A always resolves to + // giftWrap, so the emitted filter is identical to the v1 behaviour; + // Phase C wires the real protocol_version resolution. + switch (resolveTransport(null)) { + case Transport.giftWrap: + return NostrFilter( + kinds: [1059], + p: tradeKeys, + ); + case Transport.nip44: + // v2 Mostro replies are kind 14 authored by the node and addressed + // (p) to the trade key; the authors pin disambiguates them from + // NIP-17 peer chat, which is also kind 14 (§3.4). + return NostrFilter( + kinds: [14], + authors: [ref.read(settingsProvider).mostroPublicKey], + p: tradeKeys, + ); + } case SubscriptionType.chat: if (sessions.isEmpty) { return null; diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index fb5216fcd..3337a8eab 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -127,11 +127,26 @@ class MostroService { final privateKey = matchingSession.tradeKey.private; try { - final decryptedEvent = await event.unWrap(privateKey); + // Transport branch (§5 Phase A): v1 gift wrap (kind 1059) yields an inner + // rumor whose content is the message tuple; v2 NIP-44 direct (kind 14) + // decrypts straight to the tuple. Both converge on jsonDecode below. + String? content; + String? decryptedId; + if (event.kind == 14) { + content = await NostrUtils.decryptNIP44DirectEvent( + event, + privateKey, + expectedAuthor: _settings.mostroPublicKey, + ); + } else { + final decryptedEvent = await event.unWrap(privateKey); + content = decryptedEvent.content; + decryptedId = decryptedEvent.id; + } - if (decryptedEvent.content == null) return; + if (content == null) return; - final result = jsonDecode(decryptedEvent.content!); + final result = jsonDecode(content); // Ensure result is a non-empty List before accessing elements if (result is! List || result.isEmpty) { @@ -156,15 +171,15 @@ class MostroService { final messageStorage = ref.read(mostroStorageProvider); - // Use decryptedEvent.id if available, otherwise fall back to original event.id - // This handles cases where admin messages might not have an id in the decrypted event + // Use the inner rumor id if available (v1), otherwise fall back to the + // original event id. v2 has no inner rumor, so it always falls back. final messageKey = - decryptedEvent.id ?? + decryptedId ?? event.id ?? 'msg_${DateTime.now().millisecondsSinceEpoch}'; await messageStorage.addMessage(messageKey, msg); logger.i( - 'Received DM, Event ID: ${decryptedEvent.id ?? event.id} with payload: ${decryptedEvent.content}', + 'Received DM, Event ID: ${decryptedId ?? event.id} with payload: $content', ); await _maybeLinkChildOrder(msg, matchingSession); diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 8130b9c00..36951c9c9 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -442,6 +442,82 @@ class NostrUtils { } } + /// Decrypts a protocol-v2 (NIP-44 direct) Mostro event. + /// + /// Sibling of [decryptNIP59Event] for the kind-14 transport (§3.3, §5 Phase + /// A). Unlike the gift-wrap path (rumor -> seal -> wrap, whose inner content + /// is the message tuple), the decrypted content here **is** the tuple + /// directly. Steps: + /// 1. Verify the event is a kind-14 authored by [expectedAuthor] (the node) + /// and that its signature is valid — for v2 the author signature is + /// load-bearing. + /// 2. NIP-44 decrypt `content` with [privateKey] (the trade key) and + /// `event.pubkey` (the node). + /// + /// Returns the decrypted tuple JSON string `[message, tradeSig?, identityProof?]`; + /// the caller decodes it and takes `tuple[0]`. + static Future decryptNIP44DirectEvent( + NostrEvent event, + String privateKey, { + required String expectedAuthor, + }) async { + if (event.kind != 14) { + throw ArgumentError('Wrong kind: ${event.kind}'); + } + if (event.content == null || event.content!.isEmpty) { + throw ArgumentError('Event content is empty'); + } + if (!isValidPrivateKey(privateKey)) { + throw ArgumentError('Invalid private key'); + } + if (event.pubkey != expectedAuthor) { + throw ArgumentError( + 'Unexpected author: expected $expectedAuthor, got ${event.pubkey}', + ); + } + if (!_isValidEventSignature(event)) { + throw ArgumentError('Invalid kind-14 event signature'); + } + + try { + return await decryptNIP44( + event.content!, + privateKey, + event.pubkey, + ); + } catch (e) { + throw Exception('Failed to decrypt NIP-44 direct event: $e'); + } + } + + /// Verifies a Nostr event's id and Schnorr signature (NIP-01): recomputes the + /// id from the serialized event and checks the signature over it. + static bool _isValidEventSignature(NostrEvent event) { + final id = event.id; + final sig = event.sig; + final createdAt = event.createdAt; + if (id == null || sig == null || createdAt == null) { + return false; + } + try { + final serialized = jsonEncode([ + 0, + event.pubkey, + createdAt.millisecondsSinceEpoch ~/ 1000, + event.kind, + event.tags ?? [], + event.content ?? '', + ]); + final computedId = sha256.convert(utf8.encode(serialized)).toString(); + if (computedId != id) { + return false; + } + return NostrKeyPairs.verify(event.pubkey, id, sig); + } catch (_) { + return false; + } + } + /// Validates the structure of a decrypted event static void _validateEventStructure(Map event) { final requiredFields = [ From d28250d5f2be8cb48716e259b4365f2078f4fd04 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 17 Jun 2026 23:21:16 -0300 Subject: [PATCH 2/3] feat(transport): add dual-receive support for NIP-44 direct messages in background isolate (Phase A) --- docs/architecture/TRANSPORT_V2_MIGRATION.md | 9 ++++ .../background_notification_service.dart | 45 +++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/docs/architecture/TRANSPORT_V2_MIGRATION.md b/docs/architecture/TRANSPORT_V2_MIGRATION.md index fdaaaa5d7..45ac2797b 100644 --- a/docs/architecture/TRANSPORT_V2_MIGRATION.md +++ b/docs/architecture/TRANSPORT_V2_MIGRATION.md @@ -251,6 +251,7 @@ for either transport without the two drifting apart. | Publish + PoW + recipient | `lib/services/mostro_service.dart:338-360` | | Receive subscription filters (kind 1059) | `lib/features/subscriptions/subscription_manager.dart:121-160` | | Receive decrypt (unWrap → `result[0]`) | `lib/services/mostro_service.dart:129-155` | +| Background receive decrypt (unWrap → `result[0]`) | `lib/features/notifications/services/background_notification_service.dart:199-322` | | NIP-59 unwrap | `lib/shared/utils/nostr_utils.dart:383-443` | | Node info / `protocol_version` parse | `lib/features/mostro/mostro_instance.dart:119-251` | | Global `version` constant | `lib/core/config.dart:56` | @@ -276,6 +277,14 @@ unchanged. - Branch `MostroService` receive (`lib/services/mostro_service.dart:129-155`): v1 yields an inner rumor whose content is the 2-tuple; v2's decrypted content **is** the tuple directly. Both converge on `MostroMessage.fromJson(tuple[0])`. +- Apply the **same receive branch to the background isolate** + (`lib/features/notifications/services/background_notification_service.dart`): + accept kind `14` in `_decryptAndProcessEvent` and branch `_handleTradeKeyEvent` + on `event.kind`. The background isolate has no Riverpod settings provider, so + the node pubkey (the v2 author to verify) is read from persisted settings + (`SharedPreferencesKeys.appSettings`). Without this, v2 replies received while + the app is backgrounded would be silently dropped once Phase C flips the + transport. ### Phase B — Dual send diff --git a/lib/features/notifications/services/background_notification_service.dart b/lib/features/notifications/services/background_notification_service.dart index 190ce8375..895486707 100644 --- a/lib/features/notifications/services/background_notification_service.dart +++ b/lib/features/notifications/services/background_notification_service.dart @@ -16,7 +16,9 @@ import 'package:mostro_mobile/data/models/peer.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as mostro_action; import 'package:mostro_mobile/data/models/enums/role.dart'; +import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; import 'package:mostro_mobile/data/repositories/session_storage.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; 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'; @@ -198,7 +200,7 @@ Future showLocalNotification(NostrEvent event) async { Future _decryptAndProcessEvent(NostrEvent event) async { try { - if (event.kind != 4 && event.kind != 1059) { + if (event.kind != 4 && event.kind != 1059 && event.kind != 14) { return null; } @@ -283,12 +285,29 @@ Future _processAdminDm(NostrEvent event, Session session) async /// Handle events matched by tradeKey (Mostro protocol + admin/dispute DMs) Future _handleTradeKeyEvent(NostrEvent event, Session session) async { - final decryptedEvent = await event.unWrap(session.tradeKey.private); - if (decryptedEvent.content == null) { + // Transport branch (§5 Phase A): v1 gift wrap (kind 1059) yields an inner + // rumor whose content is the message tuple; v2 NIP-44 direct (kind 14) + // decrypts straight to the tuple. Both converge on jsonDecode below. + final String? content; + if (event.kind == 14) { + final mostroPubkey = await _loadMostroPubkey(); + if (mostroPubkey == null) { + logger.w('No Mostro pubkey available, cannot decrypt kind-14 event'); + return null; + } + content = await NostrUtils.decryptNIP44DirectEvent( + event, + session.tradeKey.private, + expectedAuthor: mostroPubkey, + ); + } else { + content = (await event.unWrap(session.tradeKey.private)).content; + } + if (content == null) { return null; } - final result = jsonDecode(decryptedEvent.content!); + final result = jsonDecode(content); if (result is! List || result.isEmpty) { return null; } @@ -429,6 +448,24 @@ Future _handleP2PChatEvent(NostrEvent event, Session session) as } } +/// Reads the connected Mostro node pubkey from persisted settings. Needed in +/// the background isolate to verify the author of v2 (kind-14) replies, since +/// there is no Riverpod settings provider available here. +Future _loadMostroPubkey() async { + try { + final sharedPrefs = SharedPreferencesAsync(); + final settingsJson = + await sharedPrefs.getString(SharedPreferencesKeys.appSettings.value); + if (settingsJson == null) { + return null; + } + return Settings.fromJson(jsonDecode(settingsJson)).mostroPublicKey; + } catch (e) { + logger.e('Failed to load Mostro pubkey in background: $e'); + return null; + } +} + Future> _loadSessionsFromDatabase() async { try { final db = await openMostroDatabase('mostro.db'); From d8d09fc9cc606ea2add25eedb214ec37a6218a13 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 17 Jun 2026 23:44:49 -0300 Subject: [PATCH 3/3] feat(transport): implement protocol_version auto-detection and dual-receive for NIP-44 direct messages (Phase A) --- docs/architecture/TRANSPORT_V2_MIGRATION.md | 43 ++++++++---- .../repositories/open_orders_repository.dart | 14 ++++ lib/features/mostro/mostro_instance.dart | 18 +++++ lib/features/mostro/transport.dart | 33 ++++++--- .../subscriptions/subscription_manager.dart | 66 ++++++++++++++++-- .../features/mostro/mostro_instance_test.dart | 67 +++++++++++++++++++ test/features/mostro/transport_test.dart | 23 +++++++ 7 files changed, 237 insertions(+), 27 deletions(-) create mode 100644 test/features/mostro/transport_test.dart diff --git a/docs/architecture/TRANSPORT_V2_MIGRATION.md b/docs/architecture/TRANSPORT_V2_MIGRATION.md index 45ac2797b..abd35205d 100644 --- a/docs/architecture/TRANSPORT_V2_MIGRATION.md +++ b/docs/architecture/TRANSPORT_V2_MIGRATION.md @@ -18,8 +18,10 @@ during the migration window. > - **Reference client (CLI)**: `MostroP2P/mostro-cli` PRs #176, #177, #178 and > its `docs/TRANSPORT_V2_SPEC.md`. > -> **Status.** Design specification. No `lib/` code has been changed yet; this -> document drives the implementation phases (§5). +> **Status.** Living design specification. **Phase A (dual receive) is +> implemented** in this branch, including `protocol_version` auto-detection and +> per-node transport resolution on the receive path. The remaining phases (§5) +> are pending. --- @@ -264,12 +266,21 @@ The phases below define the code work for **subsequent branches**; this document does not execute them. Each phase keeps the v1 path behaviourally unchanged. -### Phase A — Dual receive +### Phase A — Dual receive (with receive-side auto-detection) +- Parse `protocol_version` into `MostroInstance.protocolVersion` (default v1 when + absent or unparseable) — `lib/features/mostro/mostro_instance.dart`. Resolve a + per-node `Transport` from it (`lib/features/mostro/transport.dart`), degrading + to v1 on an unsupported value and logging it (version-skew guard, §4.1). + > Receive-side detection lives here (not Phase C) so the dual-receive path is + > actually reachable and reviewable; Phase C only threads the resolved + > transport into the **send** path. - Make the subscription filters transport-aware - (`lib/features/subscriptions/subscription_manager.dart:121-160`): for a v2 - node, subscribe to kind `14` pinned to `authors = [mostroPubkey]` and - `p = [tradeKeys]`, instead of kind `1059`. + (`lib/features/subscriptions/subscription_manager.dart`): for a v2 node, + subscribe to kind `14` pinned to `authors = [mostroPubkey]` and + `p = [tradeKeys]`, instead of kind `1059`. The node info (kind 38385) arrives + asynchronously, so the manager listens to `OpenOrdersRepository`'s info-event + stream and re-subscribes when the resolved transport changes. - Add a v2 unwrap (sibling to `NostrUtils.decryptNIP59Event`, `lib/shared/utils/nostr_utils.dart:383-443`): verify the kind-14 event signature (author = node), NIP-44 decrypt `content` with `tradeKey.private` + @@ -283,8 +294,8 @@ unchanged. on `event.kind`. The background isolate has no Riverpod settings provider, so the node pubkey (the v2 author to verify) is read from persisted settings (`SharedPreferencesKeys.appSettings`). Without this, v2 replies received while - the app is backgrounded would be silently dropped once Phase C flips the - transport. + the app is backgrounded would be silently dropped once a node advertises + `protocol_version=2`. ### Phase B — Dual send @@ -300,13 +311,17 @@ unchanged. `lib/shared/utils/nostr_utils.dart:564-630`) for the first-contact lane — the daemon may still require PoW on the kind-14 event id. -### Phase C — Auto-detection and wiring +### Phase C — Send-side wiring -- Parse `protocol_version` into `MostroInstance.protocolVersion` - (default v1 when absent) — `lib/features/mostro/mostro_instance.dart`. -- Resolve transport per node (§4.1) and thread it into send and receive. -- Degrade to v1 on absent tag / unreachable node (version-skew guard), and - **log the downgrade explicitly** (`warn`) so the degraded state is observable. +> `protocol_version` parsing and per-node transport resolution already landed in +> Phase A (receive). This phase only threads that same resolved transport into +> the **send** path. + +- Thread the resolved `Transport` (§4.1) into `MostroService.publishOrder` so + outbound messages use the v2 `wrap` from Phase B against a v2 node, while v1 + nodes keep the gift-wrap path. +- Reuse the version-skew guard from Phase A: degrade to v1 on an unsupported / + unreachable node, keeping the existing explicit downgrade logging. ### Phase D — Tests diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index 9977f7130..090252f9a 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -19,11 +19,21 @@ class OpenOrdersRepository implements OrderRepository { final StreamController> _eventStreamController = StreamController.broadcast(); + + /// Emits the connected node's kind-38385 info event whenever it is (re)loaded. + /// Consumers (e.g. the transport resolver in [SubscriptionManager]) listen to + /// this to react when the node's `protocol_version` becomes known, since the + /// info event arrives asynchronously after the initial subscription. + final StreamController _mostroInstanceController = + StreamController.broadcast(); final Map _events = {}; StreamSubscription? _subscription; NostrEvent? get mostroInstance => _mostroInstance; + Stream get mostroInstanceStream => + _mostroInstanceController.stream; + OpenOrdersRepository(this._nostrService, this._settings) { // Subscribe to orders and initialize data _subscribeToOrders(); @@ -56,6 +66,9 @@ class OpenOrdersRepository implements OrderRepository { event.pubkey == _settings.mostroPublicKey) { logger.i('Mostro instance info loaded: $event'); _mostroInstance = event; + if (!_mostroInstanceController.isClosed) { + _mostroInstanceController.add(event); + } } }, onError: (error) { logger.e('Error in order subscription: $error'); @@ -76,6 +89,7 @@ class OpenOrdersRepository implements OrderRepository { void dispose() { _subscription?.cancel(); _eventStreamController.close(); + _mostroInstanceController.close(); _events.clear(); } diff --git a/lib/features/mostro/mostro_instance.dart b/lib/features/mostro/mostro_instance.dart index 193a3b233..8c5ebb2c6 100644 --- a/lib/features/mostro/mostro_instance.dart +++ b/lib/features/mostro/mostro_instance.dart @@ -38,6 +38,11 @@ class MostroInstance { final String fiatCurrenciesAccepted; final int maxOrdersPerResponse; + /// Wire transport advertised via the `protocol_version` tag (§2 of the + /// transport v2 migration). Defaults to `1` (NIP-59 gift wrap) when the tag + /// is absent or unparseable, matching the legacy-daemon behaviour. + final int protocolVersion; + /// Bond policy state. See [BondPolicy] for the three-state semantics. final BondPolicy bondPolicy; @@ -73,6 +78,7 @@ class MostroInstance { this.lndNodeUri, this.fiatCurrenciesAccepted, this.maxOrdersPerResponse, { + this.protocolVersion = 1, this.bondPolicy = BondPolicy.unsupported, this.bondApplyTo, this.bondSlashOnWaitingTimeout, @@ -105,6 +111,7 @@ class MostroInstance { event.lndNodeUri, event.fiatCurrenciesAccepted, event.maxOrdersPerResponse, + protocolVersion: event.protocolVersion ?? 1, bondPolicy: event.bondPolicy, bondApplyTo: event.bondApplyTo, bondSlashOnWaitingTimeout: event.bondSlashOnWaitingTimeout, @@ -170,6 +177,17 @@ extension MostroInstanceExtensions on NostrEvent { int get maxOrdersPerResponse => int.parse(_getTagValue('max_orders_per_response')); + /// Parses the wire transport version from the `protocol_version` tag (§2). + /// + /// Returns `null` when the tag is absent or unparseable. Callers treat + /// `null` as legacy v1 (NIP-59 gift wrap); the nullable form is preserved so + /// the transport resolver can distinguish "not advertised" from an explicit + /// version when deciding whether to log a version-skew downgrade. + int? get protocolVersion { + final raw = _getOptionalTagValue('protocol_version'); + return raw == null ? null : int.tryParse(raw); + } + /// Parses the anti-abuse bond policy from the `bond_enabled` tag. /// /// - Tag absent → [BondPolicy.unsupported] (legacy daemon). diff --git a/lib/features/mostro/transport.dart b/lib/features/mostro/transport.dart index 2d9fdfb6f..76c13efe0 100644 --- a/lib/features/mostro/transport.dart +++ b/lib/features/mostro/transport.dart @@ -1,4 +1,4 @@ -import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; /// Wire transport a Mostro node speaks. /// @@ -13,11 +13,28 @@ import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; enum Transport { giftWrap, nip44 } /// Resolves the wire transport for a node from its advertised -/// `protocol_version` (§4.1). +/// `protocol_version` (§2, §4.1). /// -/// Phase A (dual receive) keeps the v1 path behaviourally unchanged, so this -/// always resolves to [Transport.giftWrap]. Phase C (auto-detection and wiring) -/// replaces the body with the real per-node resolution driven by -/// [MostroInstance.protocolVersion] and the explicit downgrade logging required -/// by the version-skew guard. -Transport resolveTransport(MostroInstance? instance) => Transport.giftWrap; +/// - `2` → [Transport.nip44] (v2). +/// - `1` → [Transport.giftWrap] (v1, explicitly advertised). +/// - `null` → [Transport.giftWrap]. The tag is absent or the node info has not +/// been fetched yet; during the migration window this is the common legacy +/// case, so it resolves to v1 without noise. +/// - any other value → [Transport.giftWrap], logged at `warn`. We do not speak +/// that protocol, so we degrade to v1 (version-skew guard) and surface the +/// degraded state so a misconfigured node is not silently mis-paired. +Transport resolveTransport(int? protocolVersion) { + switch (protocolVersion) { + case 2: + return Transport.nip44; + case 1: + case null: + return Transport.giftWrap; + default: + logger.w( + 'Unsupported protocol_version $protocolVersion; ' + 'degrading to v1 gift wrap', + ); + return Transport.giftWrap; + } +} diff --git a/lib/features/subscriptions/subscription_manager.dart b/lib/features/subscriptions/subscription_manager.dart index 56c4524db..47bf5b658 100644 --- a/lib/features/subscriptions/subscription_manager.dart +++ b/lib/features/subscriptions/subscription_manager.dart @@ -5,11 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/core/models/relay_list_event.dart'; import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/features/mostro/transport.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/features/subscriptions/subscription.dart'; import 'package:mostro_mobile/features/subscriptions/subscription_type.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; /// Manages Nostr subscriptions across different parts of the application. @@ -20,9 +22,17 @@ import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; class SubscriptionManager { final Ref ref; final Map _subscriptions = {}; - + late final ProviderSubscription _sessionListener; + /// Listener for the connected node's kind-38385 info event, used to switch the + /// orders subscription transport once `protocol_version` becomes known. + StreamSubscription? _mostroInstanceListener; + + /// Transport currently applied to the orders subscription, tracked so the + /// info-event listener only re-subscribes when the resolved transport changes. + Transport? _appliedOrdersTransport; + final _ordersController = StreamController.broadcast(); final _chatController = StreamController.broadcast(); final _disputeChatController = StreamController.broadcast(); @@ -38,6 +48,49 @@ class SubscriptionManager { // Ensure resources are released with provider/container lifecycle ref.onDispose(dispose); _initializeExistingSessions(); + _initMostroInstanceListener(); + } + + /// Watches the connected node's info event so the orders subscription can + /// switch to the v2 (kind 14) transport once the node advertises + /// `protocol_version=2`. The info event arrives asynchronously after the + /// initial subscription, so without this the orders filter would stay pinned + /// to the transport resolved at subscription time (typically v1 at cold + /// start). Re-subscribes only when the resolved transport actually changes. + void _initMostroInstanceListener() { + try { + _mostroInstanceListener = + ref.read(orderRepositoryProvider).mostroInstanceStream.listen( + (_) { + final newTransport = _resolveOrdersTransport(); + if (newTransport == _appliedOrdersTransport) return; + final sessions = ref.read(sessionNotifierProvider); + if (sessions.isEmpty) return; + logger.i('Orders transport changed to $newTransport, re-subscribing'); + _updateSubscription(SubscriptionType.orders, sessions); + }, + onError: (error, stackTrace) { + logger.e('Error in mostro instance listener', + error: error, stackTrace: stackTrace); + }, + ); + } catch (e, stackTrace) { + logger.e('Failed to init mostro instance listener', + error: e, stackTrace: stackTrace); + } + } + + /// Resolves the transport for the orders subscription from the connected + /// node's advertised `protocol_version` (§2, §4.1). Defaults to v1 gift wrap + /// when the node info is not yet available or unreadable. + Transport _resolveOrdersTransport() { + try { + final infoEvent = ref.read(orderRepositoryProvider).mostroInstance; + return resolveTransport(infoEvent?.protocolVersion); + } catch (e) { + logger.w('Failed to resolve orders transport, defaulting to v1: $e'); + return Transport.giftWrap; + } } void _initSessionListener() { @@ -128,10 +181,12 @@ class SubscriptionManager { return null; } final tradeKeys = sessions.map((s) => s.tradeKey.public).toList(); - // Transport selected per node (§4.1). Phase A always resolves to - // giftWrap, so the emitted filter is identical to the v1 behaviour; - // Phase C wires the real protocol_version resolution. - switch (resolveTransport(null)) { + // Transport selected per node from its advertised protocol_version + // (§2, §4.1). Tracked so the info-event listener can detect a change + // and re-subscribe when the node info arrives after this subscription. + final transport = _resolveOrdersTransport(); + _appliedOrdersTransport = transport; + switch (transport) { case Transport.giftWrap: return NostrFilter( kinds: [1059], @@ -360,6 +415,7 @@ class SubscriptionManager { void dispose() { _sessionListener.close(); + _mostroInstanceListener?.cancel(); unsubscribeAll(); _ordersController.close(); _chatController.close(); diff --git a/test/features/mostro/mostro_instance_test.dart b/test/features/mostro/mostro_instance_test.dart index 82001ae1a..23fbc849f 100644 --- a/test/features/mostro/mostro_instance_test.dart +++ b/test/features/mostro/mostro_instance_test.dart @@ -289,4 +289,71 @@ void main() { } }); }); + + group('MostroInstance protocol_version tag', () { + NostrEvent buildEvent(List> extraTags) { + return NostrEvent( + id: 'a' * 64, + kind: 38385, + content: '', + sig: 'b' * 128, + pubkey: 'c' * 64, + createdAt: DateTime(2025), + tags: [ + ['d', 'c' * 64], + ['mostro_version', '0.13.0'], + ['mostro_commit_hash', 'deadbeef'], + ['max_order_amount', '1000000'], + ['min_order_amount', '1'], + ['expiration_hours', '24'], + ['expiration_seconds', '900'], + ['fee', '0.006'], + ['pow', '0'], + ['hold_invoice_expiration_window', '300'], + ['hold_invoice_cltv_delta', '144'], + ['invoice_expiration_window', '300'], + ['lnd_version', '0.18.0'], + ['lnd_node_pubkey', 'd' * 66], + ['lnd_commit_hash', 'cafebabe'], + ['lnd_node_alias', 'mostro-lnd'], + ['lnd_chains', 'bitcoin'], + ['lnd_networks', 'mainnet'], + ['lnd_uris', 'lnd://example'], + ['fiat_currencies_accepted', 'USD,EUR'], + ['max_orders_per_response', '100'], + ...extraTags, + ], + ); + } + + test('tag absent → getter null, model defaults to v1', () { + final event = buildEvent(const []); + expect(event.protocolVersion, isNull); + expect(MostroInstance.fromEvent(event).protocolVersion, 1); + }); + + test('protocol_version="2" → v2', () { + final event = buildEvent(const [ + ['protocol_version', '2'], + ]); + expect(event.protocolVersion, 2); + expect(MostroInstance.fromEvent(event).protocolVersion, 2); + }); + + test('protocol_version="1" → v1', () { + final event = buildEvent(const [ + ['protocol_version', '1'], + ]); + expect(event.protocolVersion, 1); + expect(MostroInstance.fromEvent(event).protocolVersion, 1); + }); + + test('unparseable value → getter null, model defaults to v1', () { + final event = buildEvent(const [ + ['protocol_version', 'abc'], + ]); + expect(event.protocolVersion, isNull); + expect(MostroInstance.fromEvent(event).protocolVersion, 1); + }); + }); } diff --git a/test/features/mostro/transport_test.dart b/test/features/mostro/transport_test.dart new file mode 100644 index 000000000..ebc518251 --- /dev/null +++ b/test/features/mostro/transport_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/features/mostro/transport.dart'; + +void main() { + group('resolveTransport', () { + test('protocol_version 2 → nip44', () { + expect(resolveTransport(2), Transport.nip44); + }); + + test('protocol_version 1 → giftWrap', () { + expect(resolveTransport(1), Transport.giftWrap); + }); + + test('null (tag absent / node info not yet fetched) → giftWrap', () { + expect(resolveTransport(null), Transport.giftWrap); + }); + + test('unsupported version → degrades to giftWrap', () { + expect(resolveTransport(3), Transport.giftWrap); + expect(resolveTransport(0), Transport.giftWrap); + }); + }); +}