Skip to content

Commit 90723bd

Browse files
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 <fjcalderon@gmail.com>
1 parent 738e5f2 commit 90723bd

8 files changed

Lines changed: 489 additions & 75 deletions

File tree

docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,27 +151,45 @@ Future<void> importMnemonicAndRestore(String mnemonic) async {
151151

152152
**File**: `lib/features/restore/restore_manager.dart:116-141`
153153

154-
Creates a temporary subscription using trade key index 1 to receive Mostro responses:
154+
Creates a temporary subscription using trade key index 1 to receive Mostro
155+
responses. To interoperate across the transport v2 migration it listens on
156+
**both** wire transports at once — v1 gift wrap (kind 1059) and v2 NIP-44 direct
157+
(kind 14) — because the node info (kind 38385) that advertises `protocol_version`
158+
may not have loaded yet when restore starts. The node answers on whichever
159+
transport it speaks; the v2 filter pins `authors = [mostroPubkey]` to disambiguate
160+
from NIP-17 chat (also kind 14). See `TRANSPORT_V2_MIGRATION.md`.
155161

156162
```dart
157163
Future<StreamSubscription<NostrEvent>> _createTempSubscription() async {
158164
if (_tempTradeKey == null) {
159165
throw Exception('Temp trade key not initialized');
160166
}
161167
162-
final filter = NostrFilter(
168+
final mostroPubkey = ref.read(settingsProvider).mostroPublicKey;
169+
final v1Filter = NostrFilter(
163170
kinds: [1059],
164171
p: [_tempTradeKey!.public],
165172
limit: 0, // No historical events, only new ones
166173
);
174+
final v2Filter = NostrFilter(
175+
kinds: [14],
176+
authors: [mostroPubkey],
177+
p: [_tempTradeKey!.public],
178+
limit: 0,
179+
);
167180
168-
final request = NostrRequest(filters: [filter]);
181+
final request = NostrRequest(filters: [v1Filter, v2Filter]);
169182
final stream = ref.read(nostrServiceProvider).subscribeToEvents(request);
170-
183+
171184
return stream.listen(_handleTempSubscriptionsResponse);
172185
}
173186
```
174187

188+
The response is decoded by the top-level `decodeRestoreMessage(event, tempTradeKey,
189+
mostroPubkey)`, which branches on `event.kind`: kind 14 is NIP-44 decrypted (and
190+
the node signature verified) straight to the tuple, while kind 1059 is gift-wrap
191+
unwrapped to a rumor whose content is the tuple. Both converge on `tuple[0]`.
192+
175193
### Stage 3: Data Request Sequence
176194

177195
The recovery process follows a structured request sequence:

docs/architecture/TRANSPORT_V2_MIGRATION.md

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ during the migration window.
1818
> - **Reference client (CLI)**: `MostroP2P/mostro-cli` PRs #176, #177, #178 and
1919
> its `docs/TRANSPORT_V2_SPEC.md`.
2020
>
21-
> **Status.** Living design specification. **Phase A (dual receive) is
22-
> implemented** in this branch, including `protocol_version` auto-detection and
23-
> per-node transport resolution on the receive path. The remaining phases (§5)
24-
> are pending.
21+
> **Status.** Living design specification. **Phase A (dual receive)**
22+
> including `protocol_version` auto-detection and per-node transport resolution
23+
> on the receive path — is merged to `main`. **Phase B (dual send)** is
24+
> implemented in this branch. Phases C–D (§5) are pending.
2525
2626
---
2727

@@ -304,12 +304,22 @@ unchanged.
304304
`version: 2`; computes the trade signature; computes the identity proof
305305
(domain-tagged string signed with the master key, `null` in full-privacy);
306306
NIP-44 encrypts the 3-tuple toward the node; emits a kind-`14` event **signed
307-
by the trade key** with `p` and NIP-40 `expiration` tags.
308-
- Route `MostroService.publishOrder`
309-
(`lib/services/mostro_service.dart:338-360`) through the resolved transport.
310-
**Preserve PoW** (`NostrUtils.mineProofOfWork`,
311-
`lib/shared/utils/nostr_utils.dart:564-630`) for the first-contact lane — the
312-
daemon may still require PoW on the kind-14 event id.
307+
by the trade key** with a `p` tag (the NIP-40 `expiration` tag is omitted; see
308+
the note below).
309+
- Route **every** outbound Mostro send through the resolved transport via a
310+
single `MostroMessage.wrapForTransport(protocolVersion: …)` entry point — not
311+
just `MostroService.publishOrder`, but also the `RestoreManager` requests
312+
(restore, order-details, last-trade-index) and
313+
`DisputeRepository.createDispute`, so a v2 node never receives a stray v1 gift
314+
wrap. **Preserve PoW** (`NostrUtils.mineProofOfWork`) for the first-contact
315+
lane — the daemon may still require PoW on the kind-14 event id.
316+
- **Identity proof signature** mirrors `mostro-core`'s `transport.rs`: the
317+
trade-key signature (tuple element 1) is the existing `MostroMessage.sign`
318+
(SHA-256 hex digest then Schnorr), and the identity proof (element 2) is the
319+
master key signing `mostro-transport-v2-identity:<tradePubkey>:<messageJSON>`
320+
with the same scheme. Both are `null` in full-privacy mode.
321+
- The NIP-40 `expiration` tag is **omitted** (the daemon treats it as optional /
322+
caller-supplied), avoiding any risk of a message expiring before processing.
313323

314324
### Phase C — Send-side wiring
315325

lib/data/models/mostro_message.dart

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:dart_nostr/nostr/model/event/event.dart';
66
import 'package:mostro_mobile/core/config.dart';
77
import 'package:mostro_mobile/data/models/enums/action.dart';
88
import 'package:mostro_mobile/data/models/payload.dart';
9+
import 'package:mostro_mobile/features/mostro/transport.dart';
910
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';
1011

1112
class MostroMessage<T extends Payload> {
@@ -25,9 +26,9 @@ class MostroMessage<T extends Payload> {
2526
this.timestamp,
2627
}) : _payload = payload;
2728

28-
Map<String, dynamic> toJson() {
29+
Map<String, dynamic> toJson({int? version}) {
2930
Map<String, dynamic> json = {
30-
'version': Config.mostroVersion,
31+
'version': version ?? Config.mostroVersion,
3132
'request_id': requestId,
3233
'trade_index': tradeIndex,
3334
};
@@ -97,30 +98,37 @@ class MostroMessage<T extends Payload> {
9798
return null;
9899
}
99100

100-
String sign(NostrKeyPairs keyPair) {
101+
String sign(NostrKeyPairs keyPair, {int? version}) {
101102
//IMPORTANT : Use 'restore' key for restore and last-trade-index actions, 'order' for everything else, as per protocol
102103
final wrapperKey =
103104
action == Action.restore || action == Action.lastTradeIndex
104105
? 'restore'
105106
: 'order';
106-
final message = {wrapperKey: toJson()};
107+
final message = {wrapperKey: toJson(version: version)};
107108
final serializedEvent = jsonEncode(message);
108-
final bytes = utf8.encode(serializedEvent);
109+
return _mostroSign(serializedEvent, keyPair);
110+
}
111+
112+
/// Signs a UTF-8 string the Mostro way: SHA-256 digest, hex-encoded, then
113+
/// Schnorr-signed. Shared by the message [sign] and the protocol-v2 identity
114+
/// proof so both produce signatures the daemon can verify identically.
115+
String _mostroSign(String message, NostrKeyPairs keyPair) {
116+
final bytes = utf8.encode(message);
109117
final digest = sha256.convert(bytes);
110118
final hash = hex.encode(digest.bytes);
111-
final signature = keyPair.sign(hash);
112-
return signature;
119+
return keyPair.sign(hash);
113120
}
114121

115-
String serialize({NostrKeyPairs? keyPair}) {
122+
String serialize({NostrKeyPairs? keyPair, int? version}) {
116123
//IMPORTANT : Use 'restore' key for restore and last-trade-index actions, 'order' for everything else, as per protocol
117124
final wrapperKey =
118125
action == Action.restore || action == Action.lastTradeIndex
119126
? 'restore'
120127
: 'order';
121-
final message = {wrapperKey: toJson()};
128+
final message = {wrapperKey: toJson(version: version)};
122129
final serializedEvent = jsonEncode(message);
123-
final signature = (keyPair != null) ? '"${sign(keyPair)}"' : null;
130+
final signature =
131+
(keyPair != null) ? '"${sign(keyPair, version: version)}"' : null;
124132
final content = '[$serializedEvent, $signature]';
125133
return content;
126134
}
@@ -159,4 +167,106 @@ class MostroMessage<T extends Payload> {
159167
difficulty: difficulty,
160168
);
161169
}
170+
171+
/// Wraps the message for protocol v2 (NIP-44 direct, kind 14).
172+
///
173+
/// Produces the 3-tuple `[message, tradeSig, identityProof]` (§3.3), NIP-44
174+
/// encrypts it toward [recipientPubKey] with the trade key, and emits a
175+
/// kind-14 event **signed by the trade key** carrying a `p` tag. Mirrors
176+
/// `mostro-core`'s `transport.rs` wrap:
177+
/// - the message JSON carries `version: 2`;
178+
/// - in reputation mode (master key present) `tradeSig` is the trade-key
179+
/// signature over the message and `identityProof` is
180+
/// `[identityPubkey, sig]` where the signature is over
181+
/// `mostro-transport-v2-identity:<tradePubkey>:<messageJSON>` made with the
182+
/// master key;
183+
/// - in full-privacy mode (no master key) both are `null`.
184+
///
185+
/// PoW (NIP-13), when [difficulty] > 0, is mined on the kind-14 event id and
186+
/// signed by the trade key — the first-contact lane is preserved.
187+
Future<NostrEvent> wrapNip44({
188+
required NostrKeyPairs tradeKey,
189+
required String recipientPubKey,
190+
NostrKeyPairs? masterKey,
191+
int? keyIndex,
192+
int difficulty = 0,
193+
}) async {
194+
tradeIndex = keyIndex;
195+
196+
final wrapperKey =
197+
action == Action.restore || action == Action.lastTradeIndex
198+
? 'restore'
199+
: 'order';
200+
final messageMap = {wrapperKey: toJson(version: 2)};
201+
final messageJson = jsonEncode(messageMap);
202+
203+
// Reputation mode binds the identity; full privacy omits both signatures.
204+
final String? tradeSig =
205+
masterKey != null ? _mostroSign(messageJson, tradeKey) : null;
206+
207+
List<String>? identityProof;
208+
if (masterKey != null) {
209+
final payload =
210+
'mostro-transport-v2-identity:${tradeKey.public}:$messageJson';
211+
identityProof = [masterKey.public, _mostroSign(payload, masterKey)];
212+
}
213+
214+
final tuple = jsonEncode([messageMap, tradeSig, identityProof]);
215+
216+
final encrypted = await NostrUtils.encryptNIP44(
217+
tuple,
218+
tradeKey.private,
219+
recipientPubKey,
220+
);
221+
222+
final event = NostrEvent.fromPartialData(
223+
kind: 14,
224+
content: encrypted,
225+
keyPairs: tradeKey,
226+
tags: [
227+
['p', recipientPubKey],
228+
],
229+
createdAt: DateTime.now(),
230+
);
231+
232+
if (difficulty > 0) {
233+
return NostrUtils.mineProofOfWork(event, difficulty, tradeKey);
234+
}
235+
return event;
236+
}
237+
238+
/// Wraps the message for the transport advertised by the node's
239+
/// [protocolVersion] (§5 Phase B): v2 (NIP-44 direct, kind 14) via
240+
/// [wrapNip44] or v1 (gift wrap, kind 1059) via [wrap].
241+
///
242+
/// Single entry point so every outbound Mostro send — order actions, restore
243+
/// requests, dispute creation — selects the transport consistently from the
244+
/// connected node, instead of hard-coding the v1 path.
245+
Future<NostrEvent> wrapForTransport({
246+
required int? protocolVersion,
247+
required NostrKeyPairs tradeKey,
248+
required String recipientPubKey,
249+
NostrKeyPairs? masterKey,
250+
int? keyIndex,
251+
int difficulty = 0,
252+
}) {
253+
switch (resolveTransport(protocolVersion)) {
254+
case Transport.nip44:
255+
return wrapNip44(
256+
tradeKey: tradeKey,
257+
recipientPubKey: recipientPubKey,
258+
masterKey: masterKey,
259+
keyIndex: keyIndex,
260+
difficulty: difficulty,
261+
);
262+
case Transport.giftWrap:
263+
return wrap(
264+
tradeKey: tradeKey,
265+
recipientPubKey: recipientPubKey,
266+
masterKey: masterKey,
267+
keyIndex: keyIndex,
268+
difficulty: difficulty,
269+
);
270+
}
271+
}
162272
}

lib/data/repositories/dispute_repository.dart

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,13 @@ class DisputeRepository {
4040
return false;
4141
}
4242

43-
// Create dispute message using Gift Wrap protocol (NIP-59)
43+
// Create dispute message
4444
final disputeMessage = MostroMessage(action: Action.dispute, id: orderId);
4545

46-
// Wrap message using Gift Wrap protocol (NIP-59) with PoW from Mostro instance
46+
// Wrap the message for the transport advertised by the connected node
47+
// (v1 gift wrap kind 1059 / v2 NIP-44 direct kind 14), with PoW from the
48+
// Mostro instance. In reputation mode the master key and key index bind
49+
// the identity proof; full privacy omits both.
4750
final mostroInstance = _ref.read(orderRepositoryProvider).mostroInstance;
4851
if (mostroInstance == null) {
4952
logger.w(
@@ -52,9 +55,12 @@ class DisputeRepository {
5255
);
5356
}
5457
final mostroPow = mostroInstance?.pow ?? 0;
55-
final event = await disputeMessage.wrap(
58+
final event = await disputeMessage.wrapForTransport(
59+
protocolVersion: mostroInstance?.protocolVersion,
5660
tradeKey: session.tradeKey,
5761
recipientPubKey: _mostroPubkey,
62+
masterKey: session.fullPrivacy ? null : session.masterKey,
63+
keyIndex: session.fullPrivacy ? null : session.keyIndex,
5864
difficulty: mostroPow,
5965
);
6066

0 commit comments

Comments
 (0)