From 90723bda6cf5e0ef35de9b85d47d9485e6a5b834 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Date: Fri, 19 Jun 2026 18:18:22 -0300 Subject: [PATCH 1/8] feat: add dual send for transport v2 (Phase B) (#624) * feat(transport): implement protocol v2 (NIP-44 direct) send path with identity proof (Phase B) * feat(transport): route all outbound Mostro sends through wrapForTransport (Phase B) * feat(restore): add dual-receive support for transport v2 (NIP-44 direct) in restore flow * test(restore): add dual-receive transport v2 regression tests for restore flow * fix(transport): bind dispute identity proof and guard restore tuple - DisputeRepository.createDispute now passes masterKey/keyIndex to wrapForTransport so reputation-mode disputes carry the v2 identity proof instead of being silently downgraded to full-privacy, matching publishOrder. - decodeRestoreMessage guards against an empty JSON tuple before indexing [0] to avoid a RangeError. - Refresh stale NIP-59-only comments in the dispute send path. --------- Co-authored-by: grunch --- .../SESSION_RECOVERY_ARCHITECTURE.md | 26 +++- docs/architecture/TRANSPORT_V2_MIGRATION.md | 30 ++-- lib/data/models/mostro_message.dart | 130 ++++++++++++++++-- lib/data/repositories/dispute_repository.dart | 12 +- lib/features/restore/restore_manager.dart | 122 +++++++++------- lib/services/mostro_service.dart | 8 +- test/data/mostro_message_nip44_test.dart | 116 ++++++++++++++++ .../features/restore/restore_decode_test.dart | 120 ++++++++++++++++ 8 files changed, 489 insertions(+), 75 deletions(-) create mode 100644 test/data/mostro_message_nip44_test.dart create mode 100644 test/features/restore/restore_decode_test.dart diff --git a/docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md b/docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md index fcd4d7a12..3f6e6f0dc 100644 --- a/docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md +++ b/docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md @@ -151,7 +151,13 @@ Future importMnemonicAndRestore(String mnemonic) async { **File**: `lib/features/restore/restore_manager.dart:116-141` -Creates a temporary subscription using trade key index 1 to receive Mostro responses: +Creates a temporary subscription using trade key index 1 to receive Mostro +responses. To interoperate across the transport v2 migration it listens on +**both** wire transports at once — v1 gift wrap (kind 1059) and v2 NIP-44 direct +(kind 14) — because the node info (kind 38385) that advertises `protocol_version` +may not have loaded yet when restore starts. The node answers on whichever +transport it speaks; the v2 filter pins `authors = [mostroPubkey]` to disambiguate +from NIP-17 chat (also kind 14). See `TRANSPORT_V2_MIGRATION.md`. ```dart Future> _createTempSubscription() async { @@ -159,19 +165,31 @@ Future> _createTempSubscription() async { throw Exception('Temp trade key not initialized'); } - final filter = NostrFilter( + final mostroPubkey = ref.read(settingsProvider).mostroPublicKey; + final v1Filter = NostrFilter( kinds: [1059], p: [_tempTradeKey!.public], limit: 0, // No historical events, only new ones ); + final v2Filter = NostrFilter( + kinds: [14], + authors: [mostroPubkey], + p: [_tempTradeKey!.public], + limit: 0, + ); - final request = NostrRequest(filters: [filter]); + final request = NostrRequest(filters: [v1Filter, v2Filter]); final stream = ref.read(nostrServiceProvider).subscribeToEvents(request); - + return stream.listen(_handleTempSubscriptionsResponse); } ``` +The response is decoded by the top-level `decodeRestoreMessage(event, tempTradeKey, +mostroPubkey)`, which branches on `event.kind`: kind 14 is NIP-44 decrypted (and +the node signature verified) straight to the tuple, while kind 1059 is gift-wrap +unwrapped to a rumor whose content is the tuple. Both converge on `tuple[0]`. + ### Stage 3: Data Request Sequence The recovery process follows a structured request sequence: diff --git a/docs/architecture/TRANSPORT_V2_MIGRATION.md b/docs/architecture/TRANSPORT_V2_MIGRATION.md index abd35205d..093349825 100644 --- a/docs/architecture/TRANSPORT_V2_MIGRATION.md +++ b/docs/architecture/TRANSPORT_V2_MIGRATION.md @@ -18,10 +18,10 @@ 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) 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. +> **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. --- @@ -304,12 +304,22 @@ unchanged. `version: 2`; computes the trade signature; computes the identity proof (domain-tagged string signed with the master key, `null` in full-privacy); NIP-44 encrypts the 3-tuple toward the node; emits a kind-`14` event **signed - by the trade key** with `p` and NIP-40 `expiration` tags. -- Route `MostroService.publishOrder` - (`lib/services/mostro_service.dart:338-360`) through the resolved transport. - **Preserve PoW** (`NostrUtils.mineProofOfWork`, - `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. + by the trade key** with a `p` tag (the NIP-40 `expiration` tag is omitted; see + the note below). +- Route **every** outbound Mostro send through the resolved transport via a + single `MostroMessage.wrapForTransport(protocolVersion: …)` entry point — not + just `MostroService.publishOrder`, but also the `RestoreManager` requests + (restore, order-details, last-trade-index) and + `DisputeRepository.createDispute`, so a v2 node never receives a stray v1 gift + wrap. **Preserve PoW** (`NostrUtils.mineProofOfWork`) for the first-contact + lane — the daemon may still require PoW on the kind-14 event id. +- **Identity proof signature** mirrors `mostro-core`'s `transport.rs`: the + trade-key signature (tuple element 1) is the existing `MostroMessage.sign` + (SHA-256 hex digest then Schnorr), and the identity proof (element 2) is the + master key signing `mostro-transport-v2-identity::` + with the same scheme. Both are `null` in full-privacy mode. +- 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 diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 7a9d9bc7c..705f6cd23 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -6,6 +6,7 @@ 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'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class MostroMessage { @@ -25,9 +26,9 @@ class MostroMessage { this.timestamp, }) : _payload = payload; - Map toJson() { + Map toJson({int? version}) { Map json = { - 'version': Config.mostroVersion, + 'version': version ?? Config.mostroVersion, 'request_id': requestId, 'trade_index': tradeIndex, }; @@ -97,30 +98,37 @@ class MostroMessage { return null; } - String sign(NostrKeyPairs keyPair) { + String sign(NostrKeyPairs keyPair, {int? version}) { //IMPORTANT : Use 'restore' key for restore and last-trade-index actions, 'order' for everything else, as per protocol final wrapperKey = action == Action.restore || action == Action.lastTradeIndex ? 'restore' : 'order'; - final message = {wrapperKey: toJson()}; + final message = {wrapperKey: toJson(version: version)}; final serializedEvent = jsonEncode(message); - final bytes = utf8.encode(serializedEvent); + return _mostroSign(serializedEvent, keyPair); + } + + /// Signs a UTF-8 string the Mostro way: SHA-256 digest, hex-encoded, then + /// Schnorr-signed. Shared by the message [sign] and the protocol-v2 identity + /// proof so both produce signatures the daemon can verify identically. + String _mostroSign(String message, NostrKeyPairs keyPair) { + final bytes = utf8.encode(message); final digest = sha256.convert(bytes); final hash = hex.encode(digest.bytes); - final signature = keyPair.sign(hash); - return signature; + return keyPair.sign(hash); } - String serialize({NostrKeyPairs? keyPair}) { + String serialize({NostrKeyPairs? keyPair, int? version}) { //IMPORTANT : Use 'restore' key for restore and last-trade-index actions, 'order' for everything else, as per protocol final wrapperKey = action == Action.restore || action == Action.lastTradeIndex ? 'restore' : 'order'; - final message = {wrapperKey: toJson()}; + final message = {wrapperKey: toJson(version: version)}; final serializedEvent = jsonEncode(message); - final signature = (keyPair != null) ? '"${sign(keyPair)}"' : null; + final signature = + (keyPair != null) ? '"${sign(keyPair, version: version)}"' : null; final content = '[$serializedEvent, $signature]'; return content; } @@ -159,4 +167,106 @@ class MostroMessage { difficulty: difficulty, ); } + + /// Wraps the message for protocol v2 (NIP-44 direct, kind 14). + /// + /// Produces the 3-tuple `[message, tradeSig, identityProof]` (§3.3), NIP-44 + /// encrypts it toward [recipientPubKey] with the trade key, and emits a + /// kind-14 event **signed by the trade key** carrying a `p` tag. Mirrors + /// `mostro-core`'s `transport.rs` wrap: + /// - the message JSON carries `version: 2`; + /// - in reputation mode (master key present) `tradeSig` is the trade-key + /// signature over the message and `identityProof` is + /// `[identityPubkey, sig]` where the signature is over + /// `mostro-transport-v2-identity::` made with the + /// master key; + /// - in full-privacy mode (no master key) both are `null`. + /// + /// PoW (NIP-13), when [difficulty] > 0, is mined on the kind-14 event id and + /// signed by the trade key — the first-contact lane is preserved. + Future wrapNip44({ + required NostrKeyPairs tradeKey, + required String recipientPubKey, + NostrKeyPairs? masterKey, + int? keyIndex, + int difficulty = 0, + }) async { + tradeIndex = keyIndex; + + final wrapperKey = + action == Action.restore || action == Action.lastTradeIndex + ? 'restore' + : 'order'; + final messageMap = {wrapperKey: toJson(version: 2)}; + final messageJson = jsonEncode(messageMap); + + // Reputation mode binds the identity; full privacy omits both signatures. + final String? tradeSig = + masterKey != null ? _mostroSign(messageJson, tradeKey) : null; + + List? identityProof; + if (masterKey != null) { + final payload = + 'mostro-transport-v2-identity:${tradeKey.public}:$messageJson'; + identityProof = [masterKey.public, _mostroSign(payload, masterKey)]; + } + + final tuple = jsonEncode([messageMap, tradeSig, identityProof]); + + final encrypted = await NostrUtils.encryptNIP44( + tuple, + tradeKey.private, + recipientPubKey, + ); + + final event = NostrEvent.fromPartialData( + kind: 14, + content: encrypted, + keyPairs: tradeKey, + tags: [ + ['p', recipientPubKey], + ], + createdAt: DateTime.now(), + ); + + if (difficulty > 0) { + return NostrUtils.mineProofOfWork(event, difficulty, tradeKey); + } + return event; + } + + /// Wraps the message for the transport advertised by the node's + /// [protocolVersion] (§5 Phase B): v2 (NIP-44 direct, kind 14) via + /// [wrapNip44] or v1 (gift wrap, kind 1059) via [wrap]. + /// + /// Single entry point so every outbound Mostro send — order actions, restore + /// requests, dispute creation — selects the transport consistently from the + /// connected node, instead of hard-coding the v1 path. + Future wrapForTransport({ + required int? protocolVersion, + required NostrKeyPairs tradeKey, + required String recipientPubKey, + NostrKeyPairs? masterKey, + int? keyIndex, + int difficulty = 0, + }) { + switch (resolveTransport(protocolVersion)) { + case Transport.nip44: + return wrapNip44( + tradeKey: tradeKey, + recipientPubKey: recipientPubKey, + masterKey: masterKey, + keyIndex: keyIndex, + difficulty: difficulty, + ); + case Transport.giftWrap: + return wrap( + tradeKey: tradeKey, + recipientPubKey: recipientPubKey, + masterKey: masterKey, + keyIndex: keyIndex, + difficulty: difficulty, + ); + } + } } diff --git a/lib/data/repositories/dispute_repository.dart b/lib/data/repositories/dispute_repository.dart index 9705ba499..c2611f602 100644 --- a/lib/data/repositories/dispute_repository.dart +++ b/lib/data/repositories/dispute_repository.dart @@ -40,10 +40,13 @@ class DisputeRepository { return false; } - // Create dispute message using Gift Wrap protocol (NIP-59) + // Create dispute message final disputeMessage = MostroMessage(action: Action.dispute, id: orderId); - // Wrap message using Gift Wrap protocol (NIP-59) with PoW from Mostro instance + // Wrap the message for the transport advertised by the connected node + // (v1 gift wrap kind 1059 / v2 NIP-44 direct kind 14), with PoW from the + // Mostro instance. In reputation mode the master key and key index bind + // the identity proof; full privacy omits both. final mostroInstance = _ref.read(orderRepositoryProvider).mostroInstance; if (mostroInstance == null) { logger.w( @@ -52,9 +55,12 @@ class DisputeRepository { ); } final mostroPow = mostroInstance?.pow ?? 0; - final event = await disputeMessage.wrap( + final event = await disputeMessage.wrapForTransport( + protocolVersion: mostroInstance?.protocolVersion, tradeKey: session.tradeKey, recipientPubKey: _mostroPubkey, + masterKey: session.fullPrivacy ? null : session.masterKey, + keyIndex: session.fullPrivacy ? null : session.keyIndex, difficulty: mostroPow, ); diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index d98763dfa..d4ca7828a 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:dart_nostr/nostr/core/key_pairs.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:dart_nostr/nostr/model/request/filter.dart'; @@ -24,6 +25,7 @@ import 'package:mostro_mobile/data/models/payload.dart'; import 'package:mostro_mobile/data/models/peer.dart'; import 'package:mostro_mobile/data/models/restore_response.dart'; import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/restore/restore_progress_notifier.dart'; import 'package:mostro_mobile/features/restore/restore_progress_state.dart'; @@ -196,20 +198,45 @@ class RestoreService { } } + /// Decrypts a restore response into its message map, reading the temp trade + /// key and node pubkey from the manager state. Delegates the transport branch + /// to the top-level [decodeRestoreMessage]. + Future> _decodeRestoreMessage(NostrEvent event) { + if (_tempTradeKey == null) { + throw Exception('Temp trade key not initialized'); + } + return decodeRestoreMessage( + event, + _tempTradeKey!, + ref.read(settingsProvider).mostroPublicKey, + ); + } + Future> _createTempSubscription() async { //use temporary trade key 1 to subscribe to restore notifications if (_tempTradeKey == null) { throw Exception('Temp trade key not initialized'); } - final filter = NostrFilter( + // Listen on both transports. The node info (kind 38385) that advertises + // protocol_version may not have loaded yet when restore starts, so we + // subscribe to v1 gift wrap (kind 1059) and v2 NIP-44 direct (kind 14) + // simultaneously rather than resolving a single transport — the node + // answers on whichever it speaks. (limit 0: only new events, no history.) + final mostroPubkey = ref.read(settingsProvider).mostroPublicKey; + final v1Filter = NostrFilter( kinds: [1059], p: [_tempTradeKey!.public], - limit: - 0, //IMPORTANT: limit 0 indicates we don't want historical events, only new ones https://nostrbook.dev/protocol/filter + limit: 0, + ); + final v2Filter = NostrFilter( + kinds: [14], + authors: [mostroPubkey], + p: [_tempTradeKey!.public], + limit: 0, ); - final request = NostrRequest(filters: [filter]); + final request = NostrRequest(filters: [v1Filter, v2Filter]); final stream = ref.read(nostrServiceProvider).subscribeToEvents(request); final subscription = stream.listen( @@ -252,7 +279,8 @@ class RestoreService { 'event may be rejected if node requires PoW', ); } - final wrappedEvent = await mostroMessage.wrap( + final wrappedEvent = await mostroMessage.wrapForTransport( + protocolVersion: mostroInstance?.protocolVersion, tradeKey: _tempTradeKey!, recipientPubKey: settings.mostroPublicKey, masterKey: settings.fullPrivacyMode ? null : _masterKey, @@ -269,19 +297,7 @@ class RestoreService { Future<({Map ordersMap, List disputes})> _extractRestoreData(NostrEvent event) async { try { - if (_tempTradeKey == null) { - throw Exception('Temp trade key not initialized'); - } - - // Unwrap the gift wrap (kind 1059) to get the rumor - final rumor = await event.mostroUnWrap(_tempTradeKey!); - - if (rumor.content == null || rumor.content!.isEmpty) { - throw Exception('Rumor content is empty'); - } - - final contentList = jsonDecode(rumor.content!) as List; - final messageData = contentList[0] as Map; + final messageData = await _decodeRestoreMessage(event); // Check if Mostro returned cant-do (not found) if (messageData.containsKey('cant-do')) { @@ -360,7 +376,8 @@ class RestoreService { 'event may be rejected if node requires PoW', ); } - final wrappedEvent = await mostroMessage.wrap( + final wrappedEvent = await mostroMessage.wrapForTransport( + protocolVersion: mostroInstance?.protocolVersion, tradeKey: _tempTradeKey!, recipientPubKey: settings.mostroPublicKey, masterKey: settings.fullPrivacyMode ? null : _masterKey, @@ -378,20 +395,7 @@ class RestoreService { 'Restore: extracting orders details from gift wrap event ${event.id}', ); - if (_tempTradeKey == null) { - throw Exception('Temp trade key not initialized'); - } - - // Unwrap the gift wrap (kind 1059) to get the rumor - final rumor = await event.mostroUnWrap(_tempTradeKey!); - - if (rumor.content == null || rumor.content!.isEmpty) { - throw Exception('Rumor content is empty'); - } - - // Parse response format: [{"order": {...}}, null] - final contentList = jsonDecode(rumor.content!) as List; - final messageData = contentList[0] as Map; + final messageData = await _decodeRestoreMessage(event); // Check if Mostro returned cant-do if (messageData.containsKey('cant-do')) { @@ -453,7 +457,8 @@ class RestoreService { 'event may be rejected if node requires PoW', ); } - final wrappedEvent = await mostroMessage.wrap( + final wrappedEvent = await mostroMessage.wrapForTransport( + protocolVersion: mostroInstance?.protocolVersion, tradeKey: _tempTradeKey!, recipientPubKey: settings.mostroPublicKey, masterKey: settings.fullPrivacyMode ? null : _masterKey, @@ -472,18 +477,7 @@ class RestoreService { 'Restore: extracting last trade index from gift wrap event ${event.id}', ); - if (_tempTradeKey == null) { - throw Exception('Temp trade key not initialized'); - } - - final rumor = await event.mostroUnWrap(_tempTradeKey!); - - if (rumor.content == null || rumor.content!.isEmpty) { - throw Exception('Rumor content is empty'); - } - - final contentList = jsonDecode(rumor.content!) as List; - final messageData = contentList[0] as Map; + final messageData = await _decodeRestoreMessage(event); // Check if Mostro returned cant-do if (messageData.containsKey('cant-do')) { @@ -1122,6 +1116,42 @@ class RestoreService { } } +/// Decodes a restore response into its message map, handling both transports: +/// v2 (kind 14, NIP-44 direct, decrypted straight to the tuple) and v1 +/// (kind 1059, gift wrap unwrapped to a rumor whose content is the tuple). Both +/// converge on `tuple[0]`. +/// +/// Top-level (not a private method) so the transport branch can be +/// regression-tested without the full [RestoreService] / Riverpod orchestration. +@visibleForTesting +Future> decodeRestoreMessage( + NostrEvent event, + NostrKeyPairs tempTradeKey, + String mostroPubkey, +) async { + final String content; + if (event.kind == 14) { + content = await NostrUtils.decryptNIP44DirectEvent( + event, + tempTradeKey.private, + expectedAuthor: mostroPubkey, + ); + } else { + final rumor = await event.mostroUnWrap(tempTradeKey); + content = rumor.content ?? ''; + } + + if (content.isEmpty) { + throw Exception('Restore message content is empty'); + } + + final contentList = jsonDecode(content) as List; + if (contentList.isEmpty) { + throw Exception('Restore message tuple is empty'); + } + return contentList[0] as Map; +} + /// Thrown when Mostro responds with cant-do: invalid_trade_index to /// Action.lastTradeIndex during the restore flow. class RestoreInvalidTradeIndexException implements Exception { diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 3337a8eab..7be83dbcd 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -363,7 +363,10 @@ class MostroService { ); } - final event = await order.wrap( + // Route through the transport advertised by the connected node (§5 Phase + // B). v1 nodes (default) keep the gift-wrap path byte-for-byte. + final event = await order.wrapForTransport( + protocolVersion: mostroInstance?.protocolVersion, tradeKey: session.tradeKey, recipientPubKey: _settings.mostroPublicKey, masterKey: session.fullPrivacy ? null : session.masterKey, @@ -371,7 +374,8 @@ class MostroService { difficulty: difficulty, ); logger.i( - 'Sending DM, Event ID: ${event.id} (PoW: $difficulty) with payload: ${order.toJson()}', + 'Sending DM (kind ${event.kind}), Event ID: ${event.id} ' + '(PoW: $difficulty) with payload: ${order.toJson()}', ); await ref.read(nostrServiceProvider).publishEvent(event); } diff --git a/test/data/mostro_message_nip44_test.dart b/test/data/mostro_message_nip44_test.dart new file mode 100644 index 000000000..ee56d901b --- /dev/null +++ b/test/data/mostro_message_nip44_test.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; + +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +/// Replicates the Mostro signing scheme (SHA-256 hex digest, Schnorr-verified) +/// for asserting the tuple signatures. +bool verifyMostroSig(String pubkey, String message, String sig) { + final hash = hex.encode(sha256.convert(utf8.encode(message)).bytes); + return NostrKeyPairs.verify(pubkey, hash, sig); +} + +void main() { + group('MostroMessage.wrapNip44 (protocol v2)', () { + late NostrKeyPairs tradeKey; + late NostrKeyPairs masterKey; + late NostrKeyPairs mostroKey; // stands in for the node / recipient + + setUp(() { + tradeKey = NostrUtils.generateKeyPair(); + masterKey = NostrUtils.generateKeyPair(); + mostroKey = NostrUtils.generateKeyPair(); + }); + + test('reputation mode: kind-14 event round-trips with identity proof', + () async { + final message = MostroMessage( + action: Action.fiatSent, + id: 'order-1', + requestId: 7, + ); + + final event = await message.wrapNip44( + tradeKey: tradeKey, + recipientPubKey: mostroKey.public, + masterKey: masterKey, + keyIndex: 5, + ); + + // Event shape: kind 14, authored by the trade key, p-tagged to the node. + expect(event.kind, 14); + expect(event.pubkey, tradeKey.public); + expect( + event.tags!.any((t) => t[0] == 'p' && t[1] == mostroKey.public), + isTrue, + ); + + // Node decrypts with its private key + the event author (trade key). + final content = await NostrUtils.decryptNIP44DirectEvent( + event, + mostroKey.private, + expectedAuthor: tradeKey.public, + ); + final tuple = jsonDecode(content) as List; + expect(tuple.length, 3); + + // Element 0: the message with version 2, decodes to the original. + final messageMap = tuple[0] as Map; + expect(messageMap['order']['version'], 2); + final decoded = MostroMessage.fromJson(messageMap); + expect(decoded.action, Action.fiatSent); + expect(decoded.id, 'order-1'); + + // Element 1: trade-key signature over the exact message JSON. + final messageJson = jsonEncode(messageMap); + expect(tuple[1], isNotNull); + expect(verifyMostroSig(tradeKey.public, messageJson, tuple[1] as String), + isTrue); + + // Element 2: identity proof = [identityPubkey, sig over domain payload]. + final proof = tuple[2] as List; + expect(proof[0], masterKey.public); + final domain = + 'mostro-transport-v2-identity:${tradeKey.public}:$messageJson'; + expect(verifyMostroSig(masterKey.public, domain, proof[1] as String), + isTrue); + }); + + test('full-privacy mode: tradeSig and identityProof are null', () async { + final message = MostroMessage( + action: Action.fiatSent, + id: 'order-2', + requestId: 9, + ); + + final event = await message.wrapNip44( + tradeKey: tradeKey, + recipientPubKey: mostroKey.public, + masterKey: null, + ); + + expect(event.kind, 14); + expect(event.pubkey, tradeKey.public); + + final content = await NostrUtils.decryptNIP44DirectEvent( + event, + mostroKey.private, + expectedAuthor: tradeKey.public, + ); + final tuple = jsonDecode(content) as List; + expect(tuple.length, 3); + expect((tuple[0] as Map)['order']['version'], 2); + expect(tuple[1], isNull); + expect(tuple[2], isNull); + + final decoded = MostroMessage.fromJson(tuple[0] as Map); + expect(decoded.action, Action.fiatSent); + expect(decoded.id, 'order-2'); + }); + }); +} diff --git a/test/features/restore/restore_decode_test.dart b/test/features/restore/restore_decode_test.dart new file mode 100644 index 000000000..bac757217 --- /dev/null +++ b/test/features/restore/restore_decode_test.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; + +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/features/restore/restore_manager.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +/// Regression tests for the restore receive path covering both transports: +/// v1 gift wrap (kind 1059) and v2 NIP-44 direct (kind 14). The node is the +/// sender; the client decrypts with its temporary restore trade key. +void main() { + group('decodeRestoreMessage (restore transport branch)', () { + late NostrKeyPairs tempTradeKey; + late NostrKeyPairs mostroKey; + + // A restore response as Mostro sends it: tuple [message, signature?]. + final restoreTuple = jsonEncode([ + { + 'restore': { + 'version': 1, + 'action': 'restore', + 'payload': { + 'restore_data': {'orders': [], 'disputes': []}, + }, + }, + }, + null, + ]); + + setUp(() { + tempTradeKey = NostrUtils.generateKeyPair(); + mostroKey = NostrUtils.generateKeyPair(); + }); + + /// Builds a v2 (kind 14) reply authored by the node toward the temp key. + Future buildV2Reply(String tuple) async { + final encrypted = await NostrUtils.encryptNIP44( + tuple, + mostroKey.private, + tempTradeKey.public, + ); + return NostrEvent.fromPartialData( + kind: 14, + content: encrypted, + keyPairs: mostroKey, + tags: [ + ['p', tempTradeKey.public], + ], + createdAt: DateTime.now(), + ); + } + + test('v1 gift wrap (kind 1059) decodes to the restore message', () async { + final event = await NostrUtils.createNIP59Event( + restoreTuple, + tempTradeKey.public, + mostroKey.private, + ); + + expect(event.kind, 1059); + final data = await decodeRestoreMessage( + event, + tempTradeKey, + mostroKey.public, + ); + expect(data.containsKey('restore'), isTrue); + }); + + test('v2 NIP-44 direct (kind 14) decodes to the restore message', () async { + final event = await buildV2Reply(restoreTuple); + + expect(event.kind, 14); + expect(event.pubkey, mostroKey.public); + final data = await decodeRestoreMessage( + event, + tempTradeKey, + mostroKey.public, + ); + expect(data.containsKey('restore'), isTrue); + }); + + test('v1 and v2 decode to identical message maps', () async { + final v1 = await NostrUtils.createNIP59Event( + restoreTuple, + tempTradeKey.public, + mostroKey.private, + ); + final v2 = await buildV2Reply(restoreTuple); + + final d1 = await decodeRestoreMessage(v1, tempTradeKey, mostroKey.public); + final d2 = await decodeRestoreMessage(v2, tempTradeKey, mostroKey.public); + + expect(d2, equals(d1)); + }); + + test('v2 reply from an unexpected author is rejected', () async { + final imposter = NostrUtils.generateKeyPair(); + final encrypted = await NostrUtils.encryptNIP44( + restoreTuple, + imposter.private, + tempTradeKey.public, + ); + final event = NostrEvent.fromPartialData( + kind: 14, + content: encrypted, + keyPairs: imposter, + tags: [ + ['p', tempTradeKey.public], + ], + createdAt: DateTime.now(), + ); + + // expectedAuthor is the real node; the imposter-authored event must fail. + expect( + () => decodeRestoreMessage(event, tempTradeKey, mostroKey.public), + throwsA(isA()), + ); + }); + }); +} From 0226336fd507751b171a23ebf6b1119c9aa4b311 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 24 Jun 2026 01:42:57 -0300 Subject: [PATCH 2/8] fix(order): prevent order creation race with node-switch restore session reset - Add RestoreService.isOperationInProgress and awaitOperationCompletion() to expose restore/sync state - AddOrderNotifier.submitOrder now waits for any in-flight restore to finish before creating an order - Prevents session orphaning when node-switch restore's _clearAll wipes sessions mid-order-creation --- .../order/notifiers/add_order_notifier.dart | 6 ++++++ lib/features/restore/restore_manager.dart | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/lib/features/order/notifiers/add_order_notifier.dart b/lib/features/order/notifiers/add_order_notifier.dart index a5de86a78..6f1c2288d 100644 --- a/lib/features/order/notifiers/add_order_notifier.dart +++ b/lib/features/order/notifiers/add_order_notifier.dart @@ -5,6 +5,7 @@ import 'package:mostro_mobile/data/models.dart'; import 'package:mostro_mobile/shared/providers.dart'; import 'package:mostro_mobile/features/order/notifiers/abstract_mostro_notifier.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/restore/restore_manager.dart'; import 'package:mostro_mobile/features/order/models/order_state.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; @@ -100,6 +101,11 @@ class AddOrderNotifier extends AbstractMostroNotifier { } Future submitOrder(Order order) async { + // A node-switch restore/sync resets all sessions (RestoreService._clearAll). + // Creating the order while it runs would have its session wiped, orphaning + // the order on the daemon. Wait for any in-flight restore to finish first. + await ref.read(restoreServiceProvider).awaitOperationCompletion(); + final message = MostroMessage( action: Action.newOrder, id: null, diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index d4ca7828a..265969479 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -63,6 +63,21 @@ class RestoreService { RestoreService(this.ref); + /// Whether a restore/sync operation that resets all sessions (via [_clearAll]) + /// is currently running. Used to avoid racing the reset with user actions that + /// create sessions (e.g. order creation). + bool get isOperationInProgress => _operationInProgress; + + /// Resolves when the in-flight restore/sync operation finishes, or immediately + /// if none is running. Callers that create sessions should await this first so + /// their session is not wiped by the restore's session reset. Safe against + /// hangs: every operation completes its completer in a `finally` block. + Future awaitOperationCompletion() async { + if (_operationInProgress && _operationCompleter != null) { + await _operationCompleter!.future; + } + } + Future importMnemonicAndRestore(String mnemonic) async { logger.i('Restore: importing mnemonic'); From a904d842add8cfed740ce4856327a73531f02440 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 24 Jun 2026 01:59:43 -0300 Subject: [PATCH 3/8] fix(restore): loop awaitOperationCompletion to handle overlapping operations - Change awaitOperationCompletion from `if` to `while` to wait for any operation that starts while we're already waiting - Prevents race where a second restore begins after the first completes but before the caller proceeds - Update doc comment to clarify looping behavior and note that restore triggers are mutually exclusive with order submission --- lib/features/restore/restore_manager.dart | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 265969479..3670cbf2e 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -68,12 +68,17 @@ class RestoreService { /// create sessions (e.g. order creation). bool get isOperationInProgress => _operationInProgress; - /// Resolves when the in-flight restore/sync operation finishes, or immediately - /// if none is running. Callers that create sessions should await this first so - /// their session is not wiped by the restore's session reset. Safe against - /// hangs: every operation completes its completer in a `finally` block. + /// Resolves when no restore/sync operation is running. Callers that create + /// sessions should await this first so their session is not wiped by the + /// restore's session reset. Loops so that an operation starting while we wait + /// is also awaited. Safe against hangs: every operation completes its completer + /// in a `finally` block. + /// + /// Note: restore is only triggered from node-switch, mnemonic import, manual + /// restore and app-init sync — all mutually exclusive with order submission in + /// the UI — so a true cross-flow mutex is unnecessary here. Future awaitOperationCompletion() async { - if (_operationInProgress && _operationCompleter != null) { + while (_operationInProgress && _operationCompleter != null) { await _operationCompleter!.future; } } From f0347d57e6c2e90625f6b85b7415665dbbb5cd2c Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 24 Jun 2026 02:05:57 -0300 Subject: [PATCH 4/8] fix(restore): wait for node info before sending restore request to prevent v1/v2 protocol mismatch - Add _waitForNodeConnectivity call before _createTempSubscription to ensure mostroInstance is populated - Prevents wrapForTransport from defaulting to v1 gift wrap when mostroInstance is null at app init - Avoids protocol v2 nodes receiving kind 1059 requests they won't answer, which would leave local key index stale --- lib/features/restore/restore_manager.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 3670cbf2e..46c82a5c7 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -1084,6 +1084,12 @@ class RestoreService { _masterKey = keyManager.masterKeyPair; _tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); + // Wait for the node info event (kind 38385) before sending: at app init + // mostroInstance is still null, which makes wrapForTransport default to v1 + // gift wrap. On a protocol v2 node the request would then go out as kind + // 1059 and never be answered, leaving the local key index stale. + await _waitForNodeConnectivity(ref.read(settingsProvider).mostroPublicKey); + _tempSubscription = await _createTempSubscription(); // Arm the completer before sending the request to avoid a race where From 3dd55d0850bbcdc95ba4ff47693a4e0a5f9d9c67 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 24 Jun 2026 02:33:51 -0300 Subject: [PATCH 5/8] fix(order): replace restore wait with session lock to close TOCTOU race in order creation - Replace awaitOperationCompletion with acquireSessionLock in AddOrderNotifier.submitOrder - Wrap session creation in try/finally with lock release to serialize with restore's _clearAll - Add _sessionLockTail future chain to RestoreService to implement critical-section lock - Hold session lock for entire restore process in initRestoreProcess - Prevents race where restore's session reset interleaves with order creation even after wait compl --- .../order/notifiers/add_order_notifier.dart | 36 +++++++++++-------- lib/features/restore/restore_manager.dart | 36 ++++++++++--------- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/lib/features/order/notifiers/add_order_notifier.dart b/lib/features/order/notifiers/add_order_notifier.dart index 6f1c2288d..2b794fa19 100644 --- a/lib/features/order/notifiers/add_order_notifier.dart +++ b/lib/features/order/notifiers/add_order_notifier.dart @@ -101,26 +101,34 @@ class AddOrderNotifier extends AbstractMostroNotifier { } Future submitOrder(Order order) async { - // A node-switch restore/sync resets all sessions (RestoreService._clearAll). - // Creating the order while it runs would have its session wiped, orphaning - // the order on the daemon. Wait for any in-flight restore to finish first. - await ref.read(restoreServiceProvider).awaitOperationCompletion(); - final message = MostroMessage( action: Action.newOrder, id: null, requestId: requestId, payload: order, ); - final sessionNotifier = ref.read(sessionNotifierProvider.notifier); - session = await sessionNotifier.newSession( - requestId: requestId, - role: order.kind == OrderType.buy ? Role.buyer : Role.seller, - ); - - // Start 10s timeout cleanup timer for create orders - AbstractMostroNotifier.startSessionTimeoutCleanupForRequestId(requestId, ref); - + + // A node-switch restore resets all sessions (RestoreService._clearAll). + // Serialize session creation with the restore behind a shared lock so the + // new session cannot be wiped by an interleaving reset (TOCTOU-safe): while + // we hold the lock no reset runs, and if a restore is in progress we block + // here until it releases. + final release = + await ref.read(restoreServiceProvider).acquireSessionLock(); + try { + final sessionNotifier = ref.read(sessionNotifierProvider.notifier); + session = await sessionNotifier.newSession( + requestId: requestId, + role: order.kind == OrderType.buy ? Role.buyer : Role.seller, + ); + + // Start 10s timeout cleanup timer for create orders + AbstractMostroNotifier.startSessionTimeoutCleanupForRequestId( + requestId, ref); + } finally { + release(); + } + await mostroService.submitOrder(message); state = state.updateWith(message); } diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 46c82a5c7..1c37e362b 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -61,26 +61,24 @@ class RestoreService { bool _operationInProgress = false; Completer? _operationCompleter; - RestoreService(this.ref); + /// Tail of the session critical-section lock chain. See [acquireSessionLock]. + Future _sessionLockTail = Future.value(); - /// Whether a restore/sync operation that resets all sessions (via [_clearAll]) - /// is currently running. Used to avoid racing the reset with user actions that - /// create sessions (e.g. order creation). - bool get isOperationInProgress => _operationInProgress; + RestoreService(this.ref); - /// Resolves when no restore/sync operation is running. Callers that create - /// sessions should await this first so their session is not wiped by the - /// restore's session reset. Loops so that an operation starting while we wait - /// is also awaited. Safe against hangs: every operation completes its completer - /// in a `finally` block. + /// Acquires the session critical-section lock, serializing the restore session + /// reset ([_clearAll] in [initRestoreProcess]) with session-creating flows + /// (e.g. order creation) so they never interleave. This closes the TOCTOU + /// window that a wait-only check would leave open: a caller holding the lock + /// is guaranteed no reset runs until it releases, and vice versa. /// - /// Note: restore is only triggered from node-switch, mnemonic import, manual - /// restore and app-init sync — all mutually exclusive with order submission in - /// the UI — so a true cross-flow mutex is unnecessary here. - Future awaitOperationCompletion() async { - while (_operationInProgress && _operationCompleter != null) { - await _operationCompleter!.future; - } + /// Returns a release callback the caller MUST invoke in a `finally` block. + Future acquireSessionLock() async { + final previous = _sessionLockTail; + final completer = Completer(); + _sessionLockTail = completer.future; + await previous; + return () => completer.complete(); } Future importMnemonicAndRestore(String mnemonic) async { @@ -917,6 +915,9 @@ class RestoreService { _operationInProgress = true; _operationCompleter = Completer(); + // Hold the session lock for the whole restore so order creation cannot + // interleave with the session reset (and rebuild) below. + final releaseSessionLock = await acquireSessionLock(); bool success = false; bool noHistoryFound = false; try { @@ -1038,6 +1039,7 @@ class RestoreService { .read(restoreProgressProvider.notifier) .showError(errorKey); // overlay maps the key to a localized message } finally { + releaseSessionLock(); // Cleanup: always cancel subscription and clear keys logger.i('Restore: cleaning up subscription and keys'); await _tempSubscription?.cancel(); From 844fda623723c9588590720122346057e0582f3f Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 24 Jun 2026 02:51:34 -0300 Subject: [PATCH 6/8] fix(order): move submitOrder inside session lock to prevent session orphaning - Move mostroService.submitOrder and state update inside try/finally lock block - Prevents race where restore's _clearAll can wipe session after creation but before publish - Ensures entire create critical section (session + publish) is atomic with respect to restore --- .../order/notifiers/add_order_notifier.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/features/order/notifiers/add_order_notifier.dart b/lib/features/order/notifiers/add_order_notifier.dart index 2b794fa19..377cca7a4 100644 --- a/lib/features/order/notifiers/add_order_notifier.dart +++ b/lib/features/order/notifiers/add_order_notifier.dart @@ -109,10 +109,10 @@ class AddOrderNotifier extends AbstractMostroNotifier { ); // A node-switch restore resets all sessions (RestoreService._clearAll). - // Serialize session creation with the restore behind a shared lock so the - // new session cannot be wiped by an interleaving reset (TOCTOU-safe): while - // we hold the lock no reset runs, and if a restore is in progress we block - // here until it releases. + // Serialize the whole create critical section (session creation + publish) + // with the restore behind a shared lock so the new session cannot be wiped + // by an interleaving reset (TOCTOU-safe): while we hold the lock no reset + // runs, and if a restore is in progress we block here until it releases. final release = await ref.read(restoreServiceProvider).acquireSessionLock(); try { @@ -125,12 +125,12 @@ class AddOrderNotifier extends AbstractMostroNotifier { // Start 10s timeout cleanup timer for create orders AbstractMostroNotifier.startSessionTimeoutCleanupForRequestId( requestId, ref); + + await mostroService.submitOrder(message); + state = state.updateWith(message); } finally { release(); } - - await mostroService.submitOrder(message); - state = state.updateWith(message); } /// Reset notifier state for retry after out_of_range_sats_amount error From f8d602410320a406c4ee1af442ee2699fd9e2640 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 24 Jun 2026 03:15:07 -0300 Subject: [PATCH 7/8] refactor(session): extract session lifecycle lock to shared provider for reuse across order flows --- .../order/notifiers/add_order_notifier.dart | 15 ++-- .../order/notifiers/order_notifier.dart | 72 +++++++++++-------- lib/features/restore/restore_manager.dart | 26 ++----- lib/shared/providers.dart | 1 + .../session_lifecycle_lock_provider.dart | 42 +++++++++++ 5 files changed, 94 insertions(+), 62 deletions(-) create mode 100644 lib/shared/providers/session_lifecycle_lock_provider.dart diff --git a/lib/features/order/notifiers/add_order_notifier.dart b/lib/features/order/notifiers/add_order_notifier.dart index 377cca7a4..c16d47ccf 100644 --- a/lib/features/order/notifiers/add_order_notifier.dart +++ b/lib/features/order/notifiers/add_order_notifier.dart @@ -5,7 +5,6 @@ import 'package:mostro_mobile/data/models.dart'; import 'package:mostro_mobile/shared/providers.dart'; import 'package:mostro_mobile/features/order/notifiers/abstract_mostro_notifier.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; -import 'package:mostro_mobile/features/restore/restore_manager.dart'; import 'package:mostro_mobile/features/order/models/order_state.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; @@ -110,12 +109,10 @@ class AddOrderNotifier extends AbstractMostroNotifier { // A node-switch restore resets all sessions (RestoreService._clearAll). // Serialize the whole create critical section (session creation + publish) - // with the restore behind a shared lock so the new session cannot be wiped - // by an interleaving reset (TOCTOU-safe): while we hold the lock no reset - // runs, and if a restore is in progress we block here until it releases. - final release = - await ref.read(restoreServiceProvider).acquireSessionLock(); - try { + // with the restore behind the shared session lock so the new session cannot + // be wiped by an interleaving reset (TOCTOU-safe): while we hold the lock no + // reset runs, and if a restore is in progress we block until it releases. + await ref.read(sessionLifecycleLockProvider).withSessionLock(() async { final sessionNotifier = ref.read(sessionNotifierProvider.notifier); session = await sessionNotifier.newSession( requestId: requestId, @@ -128,9 +125,7 @@ class AddOrderNotifier extends AbstractMostroNotifier { await mostroService.submitOrder(message); state = state.updateWith(message); - } finally { - release(); - } + }); } /// Reset notifier state for retry after out_of_range_sats_amount error diff --git a/lib/features/order/notifiers/order_notifier.dart b/lib/features/order/notifiers/order_notifier.dart index e06f03166..abb0a876e 100644 --- a/lib/features/order/notifiers/order_notifier.dart +++ b/lib/features/order/notifiers/order_notifier.dart @@ -86,44 +86,54 @@ class OrderNotifier extends AbstractMostroNotifier { Future takeSellOrder( String orderId, int? amount, String? lnAddress) async { - final sessionNotifier = ref.read(sessionNotifierProvider.notifier); - session = await sessionNotifier.newSession( - orderId: orderId, - role: Role.buyer, - ); + // Serialize session creation + publish with the restore reset behind the + // shared session lock so the new session can't be wiped by a concurrent + // restore (TOCTOU-safe). See [SessionLifecycleLock]. + await ref.read(sessionLifecycleLockProvider).withSessionLock(() async { + final sessionNotifier = ref.read(sessionNotifierProvider.notifier); + session = await sessionNotifier.newSession( + orderId: orderId, + role: Role.buyer, + ); - // Drop any stale grace timer/flag from a previous cycle on this order so - // it can't delete the session we just created (retake within 60s). - AbstractMostroNotifier.clearBondCancelDeletion(orderId); + // Drop any stale grace timer/flag from a previous cycle on this order so + // it can't delete the session we just created (retake within 60s). + AbstractMostroNotifier.clearBondCancelDeletion(orderId); - // Start 10s timeout cleanup timer for orphan session prevention - AbstractMostroNotifier.startSessionTimeoutCleanup(orderId, ref); - - await mostroService.takeSellOrder( - orderId, - amount, - lnAddress, - ); + // Start 10s timeout cleanup timer for orphan session prevention + AbstractMostroNotifier.startSessionTimeoutCleanup(orderId, ref); + + await mostroService.takeSellOrder( + orderId, + amount, + lnAddress, + ); + }); } Future takeBuyOrder(String orderId, int? amount) async { - final sessionNotifier = ref.read(sessionNotifierProvider.notifier); - session = await sessionNotifier.newSession( - orderId: orderId, - role: Role.seller, - ); + // Serialize session creation + publish with the restore reset behind the + // shared session lock so the new session can't be wiped by a concurrent + // restore (TOCTOU-safe). See [SessionLifecycleLock]. + await ref.read(sessionLifecycleLockProvider).withSessionLock(() async { + final sessionNotifier = ref.read(sessionNotifierProvider.notifier); + session = await sessionNotifier.newSession( + orderId: orderId, + role: Role.seller, + ); - // Drop any stale grace timer/flag from a previous cycle on this order so - // it can't delete the session we just created (retake within 60s). - AbstractMostroNotifier.clearBondCancelDeletion(orderId); + // Drop any stale grace timer/flag from a previous cycle on this order so + // it can't delete the session we just created (retake within 60s). + AbstractMostroNotifier.clearBondCancelDeletion(orderId); - // Start 10s timeout cleanup timer for orphan session prevention - AbstractMostroNotifier.startSessionTimeoutCleanup(orderId, ref); - - await mostroService.takeBuyOrder( - orderId, - amount, - ); + // Start 10s timeout cleanup timer for orphan session prevention + AbstractMostroNotifier.startSessionTimeoutCleanup(orderId, ref); + + await mostroService.takeBuyOrder( + orderId, + amount, + ); + }); } Future sendInvoice( diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 1c37e362b..a17fabd63 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -37,6 +37,7 @@ import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/notifications_history_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_lifecycle_lock_provider.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/notifications/providers/notifications_provider.dart'; @@ -61,26 +62,8 @@ class RestoreService { bool _operationInProgress = false; Completer? _operationCompleter; - /// Tail of the session critical-section lock chain. See [acquireSessionLock]. - Future _sessionLockTail = Future.value(); - RestoreService(this.ref); - /// Acquires the session critical-section lock, serializing the restore session - /// reset ([_clearAll] in [initRestoreProcess]) with session-creating flows - /// (e.g. order creation) so they never interleave. This closes the TOCTOU - /// window that a wait-only check would leave open: a caller holding the lock - /// is guaranteed no reset runs until it releases, and vice versa. - /// - /// Returns a release callback the caller MUST invoke in a `finally` block. - Future acquireSessionLock() async { - final previous = _sessionLockTail; - final completer = Completer(); - _sessionLockTail = completer.future; - await previous; - return () => completer.complete(); - } - Future importMnemonicAndRestore(String mnemonic) async { logger.i('Restore: importing mnemonic'); @@ -915,9 +898,10 @@ class RestoreService { _operationInProgress = true; _operationCompleter = Completer(); - // Hold the session lock for the whole restore so order creation cannot - // interleave with the session reset (and rebuild) below. - final releaseSessionLock = await acquireSessionLock(); + // Hold the shared session lock for the whole restore so order/take flows + // cannot interleave with the session reset (and rebuild) below. + final releaseSessionLock = + await ref.read(sessionLifecycleLockProvider).acquire(); bool success = false; bool noHistoryFound = false; try { diff --git a/lib/shared/providers.dart b/lib/shared/providers.dart index a35bce6f5..6d3d28357 100644 --- a/lib/shared/providers.dart +++ b/lib/shared/providers.dart @@ -4,3 +4,4 @@ export 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; export 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; export 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; export 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; +export 'package:mostro_mobile/shared/providers/session_lifecycle_lock_provider.dart'; diff --git a/lib/shared/providers/session_lifecycle_lock_provider.dart b/lib/shared/providers/session_lifecycle_lock_provider.dart new file mode 100644 index 000000000..c07bf8c98 --- /dev/null +++ b/lib/shared/providers/session_lifecycle_lock_provider.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Serializes session-mutating critical sections so they never interleave: +/// session creation plus the dependent publish (order create, take buy/sell) +/// and the restore session reset/rebuild. While one holder runs, no other +/// critical section can — eliminating the TOCTOU window where a freshly created +/// session could be wiped by a concurrent restore reset. +/// +/// Single shared instance via [sessionLifecycleLockProvider]; every flow must +/// go through the same instance for the serialization to hold. +class SessionLifecycleLock { + Future _tail = Future.value(); + + /// Acquires the lock, queued behind any current holder. Returns a release + /// callback the caller MUST invoke in a `finally` block. Prefer + /// [withSessionLock] for new call sites; use this only when an existing + /// try/finally already guarantees release. + Future acquire() async { + final previous = _tail; + final completer = Completer(); + _tail = completer.future; + await previous; + return () => completer.complete(); + } + + /// Runs [action] inside the critical section, releasing automatically even if + /// [action] throws. Do not call back into the lock from within [action] — it + /// is not re-entrant and would deadlock. + Future withSessionLock(Future Function() action) async { + final release = await acquire(); + try { + return await action(); + } finally { + release(); + } + } +} + +final sessionLifecycleLockProvider = + Provider((ref) => SessionLifecycleLock()); From db9f180fc36a1f3f51b694f7e26653e3dd648420 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 24 Jun 2026 03:20:01 -0300 Subject: [PATCH 8/8] fix(restore): hold session lock during syncTradeIndex to prevent stale index reads - Acquire sessionLifecycleLock before updating trade index in syncTradeIndex - Prevents order/take flows from creating sessions with stale keyIndex while sync is in progress - Release lock in finally block after index update completes --- lib/features/restore/restore_manager.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index a17fabd63..4e8d1230b 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -1066,6 +1066,11 @@ class RestoreService { _operationInProgress = true; _operationCompleter = Completer(); + // Hold the shared session lock across the whole index repair so order/take + // flows cannot create a session (and publish) reading a stale trade index + // while it is being updated by setCurrentKeyIndex below. + final releaseSessionLock = + await ref.read(sessionLifecycleLockProvider).acquire(); try { _masterKey = keyManager.masterKeyPair; _tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); @@ -1101,6 +1106,7 @@ class RestoreService { } catch (e, stack) { logger.e('syncTradeIndex: failed', error: e, stackTrace: stack); } finally { + releaseSessionLock(); await _tempSubscription?.cancel(); _tempSubscription = null; _currentCompleter = null;