Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,27 +151,45 @@ Future<void> 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<StreamSubscription<NostrEvent>> _createTempSubscription() async {
if (_tempTradeKey == null) {
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:
Expand Down
30 changes: 20 additions & 10 deletions docs/architecture/TRANSPORT_V2_MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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:<tradePubkey>:<messageJSON>`
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

Expand Down
130 changes: 120 additions & 10 deletions lib/data/models/mostro_message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Payload> {
Expand All @@ -25,9 +26,9 @@ class MostroMessage<T extends Payload> {
this.timestamp,
}) : _payload = payload;

Map<String, dynamic> toJson() {
Map<String, dynamic> toJson({int? version}) {
Map<String, dynamic> json = {
'version': Config.mostroVersion,
'version': version ?? Config.mostroVersion,
'request_id': requestId,
'trade_index': tradeIndex,
};
Expand Down Expand Up @@ -97,30 +98,37 @@ class MostroMessage<T extends Payload> {
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;
}
Expand Down Expand Up @@ -159,4 +167,106 @@ class MostroMessage<T extends Payload> {
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:<tradePubkey>:<messageJSON>` 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<NostrEvent> 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<String>? 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<NostrEvent> 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,
);
}
}
}
12 changes: 9 additions & 3 deletions lib/data/repositories/dispute_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
);

Expand Down
6 changes: 6 additions & 0 deletions lib/features/order/notifiers/add_order_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -100,6 +101,11 @@ class AddOrderNotifier extends AbstractMostroNotifier {
}

Future<void> 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();
Comment thread
AndreaDiazCorreia marked this conversation as resolved.
Outdated

final message = MostroMessage<Order>(
action: Action.newOrder,
id: null,
Expand Down
Loading
Loading