Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
60 changes: 56 additions & 4 deletions docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,40 @@ Future<void> saveSession(Session session) async {
}
```

### 5. Session Lifecycle Lock

**Location**: `lib/shared/providers/session_lifecycle_lock_provider.dart`

A shared async mutex (`SessionLifecycleLock`) that **serializes session-mutating
critical sections so they never interleave**. It prevents an orphaned-session
TOCTOU race: a node-switch restore resets all sessions (`_clearAll()` +
rebuild), and if an order flow creates a session concurrently, the reset can wipe
the just-created session while its order was already published — the session
disappears from "My Trades" but the daemon still has the order.

**Who acquires it:**

- **Order flows** (session creation + the dependent publish, held together so the
pair is atomic w.r.t. the reset):
- `AddOrderNotifier.submitOrder`
- `OrderNotifier.takeSellOrder` / `takeBuyOrder`
- `OrderNotifier.sendFiatSent` / `releaseOrder` (range-order child session via
`_prepareChildOrderIfNeeded` → `createChildOrderSession`)
- **Restore** holds the lock at a high level across `_clearAll()` + the rebuild
(`initRestoreProcess`, `syncTradeIndex`).

**Why there is no deadlock:** the lock is non-reentrant. Restore rebuilds via
`saveSession()`, which does **not** take the lock, and `newSession()` /
`createChildOrderSession()` do not call each other. So while restore holds the
lock, order flows queue behind it (bounded by restore timeouts) rather than
deadlocking.

**Known residual (deferred):** the lock releases after publish, not after the
first daemon ack. A restore starting in that narrow window can still wipe a
pending session before its response arrives. Tracked for a follow-up (restore
should preserve pending/awaiting-ack sessions) rather than holding the lock
across the network round-trip.

## Recovery Process Flow

### Stage 1: Mnemonic Import and Cleanup
Expand Down Expand Up @@ -151,27 +185,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
57 changes: 42 additions & 15 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 @@ -113,7 +113,7 @@ natural home with no new fetch.
| inner payload | 2-tuple `[message, sig?]` | 3-tuple `[message, tradeSig?, identityProof?]` |
| identity proof | carried inside the seal | carried **inside** the NIP-44 ciphertext |
| `message.version` | `1` | `2` |
| expiration | none | NIP-40 `expiration` tag |
| expiration | none | optional NIP-40 tag — **this client omits it** (§3.3, §5) |

### 3.1 The Mostro message (identical logical content)

Expand Down Expand Up @@ -177,8 +177,10 @@ This string becomes the rumor content, sealed and gift-wrapped by

This entire 3-tuple JSON string is NIP-44 encrypted with `tradeKey.private` →
`mostroPubkey` and placed in the `content` of a kind-`14` event that is **signed
by the trade key**, carries a `["p", "<mostroPubkey>"]` tag and a NIP-40
`["expiration", "<unix>"]` tag.
by the trade key** and carries a `["p", "<mostroPubkey>"]` tag. The NIP-40
`["expiration", "<unix>"]` tag is **optional and this client omits it** — the
daemon manages its own expiration and accepts events with or without it (§5
Phase B).

### 3.4 Kind 14 is overloaded — disambiguation

Expand All @@ -200,7 +202,7 @@ rumor (k1, NIP-44) NIP-44 encrypt tuple (tradeKey -> mostro
-> seal (k13, NIP-44) wrap in k14 event:
-> wrap (k1059, ephemeral author) - author = trade key (SIGNED)
- p tag = mostro pubkey - p tag = mostro pubkey
- optional PoW (NIP-13) - expiration tag (NIP-40)
- optional PoW (NIP-13) - (no expiration tag; §3.3)
publish - optional PoW (NIP-13, first-contact)
publish
```
Expand Down Expand Up @@ -304,12 +306,27 @@ 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**. It is optional: `mostro-core`
supports it and the daemon accepts events with or without it (its receive path
does not validate expiration; it manages its own window). Reference clients
differ — the Rust app and `mostro-cli` also omit it, while `Mostrix` adds a
default 30-day window. This client omits it, avoiding any risk of a message
expiring before processing; adding a generous window later is a safe, optional
hygiene improvement.

### Phase C — Send-side wiring

Expand Down Expand Up @@ -353,6 +370,16 @@ unchanged.
event id. Keep `mineProofOfWork` and the `maxPowDifficulty` guard.
- **NIP-17 peer chat (also kind 14)** → not touched; disambiguated from Mostro
v2 by `author = mostroPubkey` + `p` tag.
- **Disputes now carry identity in reputation mode** →
`DisputeRepository.createDispute` passes the master key + key index through
`wrapForTransport`, so a reputation-mode dispute binds identity like every other
Mostro send. Previously disputes were always sent full-privacy-shaped
(`event.identity = trade key`), ignoring the user's privacy mode. This is a
deliberate change to the v1 dispute wire — the one exception to "v1
byte-for-byte unchanged" — and was confirmed accepted by the daemon on **both
v1 and v2**. The daemon does not require it (`check_trade_index` skips disputes;
`dispute_action` identifies the disputer by event sender), so full-privacy
disputes stay identity-less as before.

---

Expand All @@ -366,7 +393,7 @@ unchanged.

---

**Last Updated**: 2026-06-17
**Last Updated**: 2026-07-01
**Related docs**: `NOSTR.md` (Nostr integration), `MULTI_MOSTRO_SUPPORT.md`
(kind-38385 info-event parsing), `ANTI_ABUSE_BOND.md` (info-event tag parsing
and PoW), `SESSION_AND_KEY_MANAGEMENT.md` (trade vs master keys).
145 changes: 125 additions & 20 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,36 @@ class MostroMessage<T extends Payload> {
return null;
}

String sign(NostrKeyPairs keyPair) {
//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()};
/// Wrapper key for the message envelope: 'restore' for restore and
/// last-trade-index actions, 'order' for everything else, as per protocol.
/// Single source of truth — it is load-bearing for the signature, so the
/// signed content must be identical across sign/serialize/wrapNip44.
String get _wrapperKey =>
action == Action.restore || action == Action.lastTradeIndex
? 'restore'
: 'order';

String sign(NostrKeyPairs keyPair, {int? version}) {
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}) {
//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()};
String serialize({NostrKeyPairs? keyPair, int? version}) {
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 +166,102 @@ 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 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,
);
}
}
}
Loading
Loading