diff --git a/docs/architecture/TRANSPORT_V2_MIGRATION.md b/docs/architecture/TRANSPORT_V2_MIGRATION.md index 093349825..05a7efb7c 100644 --- a/docs/architecture/TRANSPORT_V2_MIGRATION.md +++ b/docs/architecture/TRANSPORT_V2_MIGRATION.md @@ -18,10 +18,12 @@ during the migration window. > - **Reference client (CLI)**: `MostroP2P/mostro-cli` PRs #176, #177, #178 and > its `docs/TRANSPORT_V2_SPEC.md`. > -> **Status.** Living design specification. **Phase A (dual receive)** — -> including `protocol_version` auto-detection and per-node transport resolution -> on the receive path — is merged to `main`. **Phase B (dual send)** is -> implemented in this branch. Phases C–D (§5) are pending. +> **Status.** **Migration complete.** All phases (§5) are implemented and +> merged: dual receive (A), dual send (B), per-node `protocol_version` +> auto-detection and transport wiring (folded into A/B), the `version`-field +> cleanup (C), and the test suite (D). The app speaks both transports and +> selects per node; the v1 gift-wrap path is unchanged and the v2 NIP-44 path +> engages automatically against a node advertising `protocol_version=2`. --- @@ -231,16 +233,14 @@ this is the version-skew guard. The downgrade must be **logged explicitly** (at `warn`) so a misconfigured or unreachable node cannot silently leave the app in a degraded transport without anyone noticing. -### 4.2 `version` field stops being a global constant +### 4.2 `version` field is derived from the transport -`Config.mostroVersion` is currently a hardcoded `1` -(`lib/core/config.dart:56`) read by `MostroMessage.toJson()` -(`lib/data/models/mostro_message.dart:30`). The message `version` must instead -be **derived from the resolved `Transport`** (1 for `giftWrap`, 2 for `nip44` — -decision #2). The cleanest seam is to pass the `Transport` (not a raw int) into -the serialize/wrap path rather than reading a global, so the version number is -always a function of the transport and a single `MostroMessage` can be wrapped -for either transport without the two drifting apart. +The global `Config.mostroVersion` constant has been **removed**. The message +`version` is now a function of the wire transport: `MostroMessage.toJson({int? +version})` defaults to `1` (gift wrap — used by storage, logging and the v1 send +path), and `wrapNip44` passes `version: 2` explicitly. The same serialized +message JSON is reused for the tuple, the trade signature and the identity-proof +payload, so element 0, element 1 and element 2 can never drift apart. ### 4.3 Touch points (current v1 code → where v2 plugs in) @@ -256,7 +256,7 @@ for either transport without the two drifting apart. | 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` | +| `version` derived from transport (no global constant) | `lib/data/models/mostro_message.dart` (`toJson`) | --- @@ -321,25 +321,35 @@ unchanged. - The NIP-40 `expiration` tag is **omitted** (the daemon treats it as optional / caller-supplied), avoiding any risk of a message expiring before processing. -### Phase C — Send-side wiring - -> `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 - -- v2 `wrap` → `unwrap` round-trip: produces a kind-14 event authored by the - trade key; decodes back to the same `MostroMessage`. -- `protocol_version` tag parse → transport mapping (including absent → v1). -- Identity proof: present in normal mode, `null` in full-privacy; domain string - is exactly `mostro-transport-v2-identity::`. +### Phase C — Wiring & finalization ✅ + +> Send-side transport wiring landed with Phase B: every outbound Mostro send +> already routes through `MostroMessage.wrapForTransport(protocolVersion: …)`, +> and receive-side detection landed with Phase A. This phase finalized the +> remaining loose ends. + +- Removed the global `Config.mostroVersion`; the message `version` is derived + from the transport (§4.2). +- Extracted the transport → orders-filter mapping into the testable top-level + `buildOrdersFilter` (`subscription_manager.dart`). +- The version-skew guard (`resolveTransport`) degrades to v1 on an unsupported + protocol version and logs it (§4.1). + +### Phase D — Tests ✅ + +- v2 `wrap` → `unwrap` round-trip, reputation and full-privacy + (`test/data/mostro_message_nip44_test.dart`): produces a kind-14 event + authored by the trade key and decodes back to the same message; cryptographically + verifies the trade signature and the identity proof, including the exact domain + string `mostro-transport-v2-identity::` (`null` in + full-privacy). +- `protocol_version` tag parse → transport mapping, including absent → v1 + (`test/features/mostro/transport_test.dart`, + `test/features/mostro/mostro_instance_test.dart`). +- Orders subscription filter is transport-aware + (`test/features/subscriptions/orders_filter_test.dart`). +- Restore decode handles both transports and yields identical results + (`test/features/restore/restore_decode_test.dart`). - Regression: the v1 gift-wrap path is byte-for-byte unchanged (existing serialization tests stay green). diff --git a/lib/core/config.dart b/lib/core/config.dart index c655ff360..a6b4d9d4c 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -52,9 +52,6 @@ class Config { // Debug mode static bool get isDebug => !kReleaseMode; - // Mostro version - static int mostroVersion = 1; - // Key derivation configuration static const String keyDerivationPath = "m/44'/1237'/38383'/0"; diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 705f6cd23..0c6ca21f4 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -3,7 +3,6 @@ import 'package:convert/convert.dart'; import 'package:crypto/crypto.dart'; import 'package:dart_nostr/nostr/core/key_pairs.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; -import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/payload.dart'; import 'package:mostro_mobile/features/mostro/transport.dart'; @@ -28,7 +27,11 @@ class MostroMessage { Map toJson({int? version}) { Map json = { - 'version': version ?? Config.mostroVersion, + // The message version is derived from the wire transport: 1 for gift wrap + // (the default here, used by storage/logging and the v1 send path) and 2 + // for NIP-44 direct (passed explicitly by `wrapNip44`). See §4.2 of + // docs/architecture/TRANSPORT_V2_MIGRATION.md. + 'version': version ?? 1, 'request_id': requestId, 'trade_index': tradeIndex, }; diff --git a/lib/features/settings/about_screen.dart b/lib/features/settings/about_screen.dart index ac1aa8251..1b8c304c3 100644 --- a/lib/features/settings/about_screen.dart +++ b/lib/features/settings/about_screen.dart @@ -335,6 +335,14 @@ class AboutScreen extends ConsumerWidget { ), const SizedBox(height: 16), + _buildInfoRowWithDialog( + context, + S.of(context)!.protocolVersion, + instance.protocolVersion.toString(), + S.of(context)!.protocolVersionExplanation, + ), + const SizedBox(height: 16), + _buildInfoRowWithDialog( context, S.of(context)!.mostroCommitId, diff --git a/lib/features/subscriptions/subscription_manager.dart b/lib/features/subscriptions/subscription_manager.dart index 47bf5b658..d554c0c93 100644 --- a/lib/features/subscriptions/subscription_manager.dart +++ b/lib/features/subscriptions/subscription_manager.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/services/logger_service.dart'; @@ -186,22 +187,11 @@ class SubscriptionManager { // 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], - 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, - ); - } + return buildOrdersFilter( + transport, + tradeKeys, + ref.read(settingsProvider).mostroPublicKey, + ); case SubscriptionType.chat: if (sessions.isEmpty) { return null; @@ -423,3 +413,33 @@ class SubscriptionManager { _relayListController.close(); } } + +/// Builds the orders subscription filter for the resolved [transport] (§4.1). +/// +/// - v1 ([Transport.giftWrap]): kind 1059 addressed (`p`) to the trade keys. +/// - v2 ([Transport.nip44]): kind 14 authored by the node ([mostroPubkey]) and +/// addressed to the trade keys — the `authors` pin disambiguates Mostro v2 +/// replies from NIP-17 peer chat, which is also kind 14 (§3.4). +/// +/// Top-level so the transport → filter mapping can be unit-tested without the +/// full [SubscriptionManager] / Riverpod orchestration. +@visibleForTesting +NostrFilter buildOrdersFilter( + Transport transport, + List tradeKeys, + String mostroPubkey, +) { + switch (transport) { + case Transport.giftWrap: + return NostrFilter( + kinds: [1059], + p: tradeKeys, + ); + case Transport.nip44: + return NostrFilter( + kinds: [14], + authors: [mostroPubkey], + p: tradeKeys, + ); + } +} diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 6c7689d9c..bd91d46f3 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -975,6 +975,8 @@ "read": "Lesen", "technicalDetails": "Technische Details", "mostroDaemonVersion": "Mostro-Version", + "protocolVersion": "Protokollversion", + "protocolVersionExplanation": "Das Transportprotokoll dieses Knotens: 1 = NIP-59 Gift Wrap, 2 = NIP-44 Direktnachrichten. Die App erkennt dies automatisch und verwendet den passenden Transport.", "mostroCommitId": "Mostro Commit-ID", "orderExpiration": "Order-Ablaufzeit", "holdInvoiceExpiration": "Hold-Invoice Ablaufzeit", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 13a58550b..e68dd7f64 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -975,6 +975,8 @@ "read": "Read", "technicalDetails": "Technical Details", "mostroDaemonVersion": "Mostro Version", + "protocolVersion": "Protocol Version", + "protocolVersionExplanation": "The wire transport protocol this node speaks: 1 = NIP-59 gift wrap, 2 = NIP-44 direct messages. The app detects this automatically and uses the matching transport.", "mostroCommitId": "Mostro Commit ID", "orderExpiration": "Order Expiration", "holdInvoiceExpiration": "Hold Invoice Expiration", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 4eb118eb7..5d77e12c1 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -952,6 +952,8 @@ "read": "Leer", "technicalDetails": "Detalles Técnicos", "mostroDaemonVersion": "Versión de Mostro", + "protocolVersion": "Versión del Protocolo", + "protocolVersionExplanation": "El protocolo de transporte que habla este nodo: 1 = gift wrap NIP-59, 2 = mensajes directos NIP-44. La app lo detecta automáticamente y usa el transporte correspondiente.", "mostroCommitId": "ID de Commit Mostro", "orderExpiration": "Expiración de Orden", "holdInvoiceExpiration": "Expiración de Factura de Retención", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 21cafb184..161f07b9a 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -975,6 +975,8 @@ "read": "Lire", "technicalDetails": "Détails techniques", "mostroDaemonVersion": "Version de Mostro", + "protocolVersion": "Version du Protocole", + "protocolVersionExplanation": "Le protocole de transport utilisé par ce nœud : 1 = gift wrap NIP-59, 2 = messages directs NIP-44. L'application le détecte automatiquement et utilise le transport correspondant.", "mostroCommitId": "ID de commit Mostro", "orderExpiration": "Expiration de commande", "holdInvoiceExpiration": "Expiration hold invoice", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 46e809a1d..03aed6d68 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1014,6 +1014,8 @@ "read": "Leggi", "technicalDetails": "Dettagli Tecnici", "mostroDaemonVersion": "Versione di Mostro", + "protocolVersion": "Versione del Protocollo", + "protocolVersionExplanation": "Il protocollo di trasporto utilizzato da questo nodo: 1 = gift wrap NIP-59, 2 = messaggi diretti NIP-44. L'app lo rileva automaticamente e usa il trasporto corrispondente.", "mostroCommitId": "ID Commit Mostro", "orderExpiration": "Scadenza Ordine", "holdInvoiceExpiration": "Scadenza Fattura di Blocco", diff --git a/test/features/subscriptions/orders_filter_test.dart b/test/features/subscriptions/orders_filter_test.dart new file mode 100644 index 000000000..3d715eac4 --- /dev/null +++ b/test/features/subscriptions/orders_filter_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/features/mostro/transport.dart'; +import 'package:mostro_mobile/features/subscriptions/subscription_manager.dart'; + +void main() { + group('buildOrdersFilter (transport-aware orders subscription)', () { + final mostroPubkey = 'a' * 64; + final tradeKeys = ['b' * 64, 'c' * 64]; + + test('v1 (giftWrap) → kind 1059, no authors pin', () { + final filter = buildOrdersFilter( + Transport.giftWrap, + tradeKeys, + mostroPubkey, + ); + + expect(filter.kinds, [1059]); + expect(filter.p, tradeKeys); + expect(filter.authors, isNull); + }); + + test('v2 (nip44) → kind 14 authored by the node, addressed to trade keys', + () { + final filter = buildOrdersFilter( + Transport.nip44, + tradeKeys, + mostroPubkey, + ); + + expect(filter.kinds, [14]); + expect(filter.authors, [mostroPubkey]); + expect(filter.p, tradeKeys); + }); + }); +} diff --git a/test/services/mostro_service_test.dart b/test/services/mostro_service_test.dart index 73375a9f9..ebd671744 100644 --- a/test/services/mostro_service_test.dart +++ b/test/services/mostro_service_test.dart @@ -228,7 +228,7 @@ void main() { final messageContent = { 'order': { - 'version': Config.mostroVersion, + 'version': 1, 'id': orderId, 'action': 'take-sell', 'payload': { @@ -289,7 +289,7 @@ void main() { userPubKey: userPubKey, messageContent: { 'order': { - 'version': Config.mostroVersion, + 'version': 1, 'id': orderId, 'action': 'take-sell', 'payload': { @@ -349,7 +349,7 @@ void main() { userPubKey: userPubKey, messageContent: { 'order': { - 'version': Config.mostroVersion, + 'version': 1, 'id': orderId, 'action': 'take-sell', 'payload': { @@ -407,7 +407,7 @@ void main() { // Simulate server-side verification final messageContent = { 'order': { - 'version': Config.mostroVersion, + 'version': 1, 'id': orderId, 'action': 'take-sell', 'payload': {