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
48 changes: 36 additions & 12 deletions docs/architecture/TRANSPORT_V2_MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ during the migration window.
> - **Reference client (CLI)**: `MostroP2P/mostro-cli` PRs #176, #177, #178 and
> its `docs/TRANSPORT_V2_SPEC.md`.
>
> **Status.** Design specification. No `lib/` code has been changed yet; this
> document drives the implementation phases (§5).
> **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.

---

Expand Down Expand Up @@ -251,6 +253,7 @@ for either transport without the two drifting apart.
| Publish + PoW + recipient | `lib/services/mostro_service.dart:338-360` |
| Receive subscription filters (kind 1059) | `lib/features/subscriptions/subscription_manager.dart:121-160` |
| Receive decrypt (unWrap → `result[0]`) | `lib/services/mostro_service.dart:129-155` |
| Background receive decrypt (unWrap → `result[0]`) | `lib/features/notifications/services/background_notification_service.dart:199-322` |
| NIP-59 unwrap | `lib/shared/utils/nostr_utils.dart:383-443` |
| Node info / `protocol_version` parse | `lib/features/mostro/mostro_instance.dart:119-251` |
| Global `version` constant | `lib/core/config.dart:56` |
Expand All @@ -263,19 +266,36 @@ The phases below define the code work for **subsequent branches**; this
document does not execute them. Each phase keeps the v1 path behaviourally
unchanged.

### Phase A — Dual receive
### Phase A — Dual receive (with receive-side auto-detection)

- Parse `protocol_version` into `MostroInstance.protocolVersion` (default v1 when
absent or unparseable) — `lib/features/mostro/mostro_instance.dart`. Resolve a
per-node `Transport` from it (`lib/features/mostro/transport.dart`), degrading
to v1 on an unsupported value and logging it (version-skew guard, §4.1).
> Receive-side detection lives here (not Phase C) so the dual-receive path is
> actually reachable and reviewable; Phase C only threads the resolved
> transport into the **send** path.
- Make the subscription filters transport-aware
(`lib/features/subscriptions/subscription_manager.dart:121-160`): for a v2
node, subscribe to kind `14` pinned to `authors = [mostroPubkey]` and
`p = [tradeKeys]`, instead of kind `1059`.
(`lib/features/subscriptions/subscription_manager.dart`): for a v2 node,
subscribe to kind `14` pinned to `authors = [mostroPubkey]` and
`p = [tradeKeys]`, instead of kind `1059`. The node info (kind 38385) arrives
asynchronously, so the manager listens to `OpenOrdersRepository`'s info-event
stream and re-subscribes when the resolved transport changes.
- Add a v2 unwrap (sibling to `NostrUtils.decryptNIP59Event`,
`lib/shared/utils/nostr_utils.dart:383-443`): verify the kind-14 event
signature (author = node), NIP-44 decrypt `content` with `tradeKey.private` +
`event.pubkey`, parse the 3-tuple, take `message = tuple[0]`.
- Branch `MostroService` receive (`lib/services/mostro_service.dart:129-155`):
v1 yields an inner rumor whose content is the 2-tuple; v2's decrypted content
**is** the tuple directly. Both converge on `MostroMessage.fromJson(tuple[0])`.
- Apply the **same receive branch to the background isolate**
(`lib/features/notifications/services/background_notification_service.dart`):
accept kind `14` in `_decryptAndProcessEvent` and branch `_handleTradeKeyEvent`
on `event.kind`. The background isolate has no Riverpod settings provider, so
the node pubkey (the v2 author to verify) is read from persisted settings
(`SharedPreferencesKeys.appSettings`). Without this, v2 replies received while
the app is backgrounded would be silently dropped once a node advertises
`protocol_version=2`.

### Phase B — Dual send

Expand All @@ -291,13 +311,17 @@ unchanged.
`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.

### Phase C — Auto-detection and wiring
### Phase C — Send-side wiring

- Parse `protocol_version` into `MostroInstance.protocolVersion`
(default v1 when absent) — `lib/features/mostro/mostro_instance.dart`.
- Resolve transport per node (§4.1) and thread it into send and receive.
- Degrade to v1 on absent tag / unreachable node (version-skew guard), and
**log the downgrade explicitly** (`warn`) so the degraded state is observable.
> `protocol_version` parsing and per-node transport resolution already landed in
> Phase A (receive). This phase only threads that same resolved transport into
> the **send** path.

- Thread the resolved `Transport` (§4.1) into `MostroService.publishOrder` so
outbound messages use the v2 `wrap` from Phase B against a v2 node, while v1
nodes keep the gift-wrap path.
- Reuse the version-skew guard from Phase A: degrade to v1 on an unsupported /
unreachable node, keeping the existing explicit downgrade logging.

### Phase D — Tests

Expand Down
14 changes: 14 additions & 0 deletions lib/data/repositories/open_orders_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,21 @@ class OpenOrdersRepository implements OrderRepository<NostrEvent> {

final StreamController<List<NostrEvent>> _eventStreamController =
StreamController.broadcast();

/// Emits the connected node's kind-38385 info event whenever it is (re)loaded.
/// Consumers (e.g. the transport resolver in [SubscriptionManager]) listen to
/// this to react when the node's `protocol_version` becomes known, since the
/// info event arrives asynchronously after the initial subscription.
final StreamController<NostrEvent> _mostroInstanceController =
StreamController.broadcast();
final Map<String, NostrEvent> _events = {};
StreamSubscription<NostrEvent>? _subscription;

NostrEvent? get mostroInstance => _mostroInstance;

Stream<NostrEvent> get mostroInstanceStream =>
_mostroInstanceController.stream;

OpenOrdersRepository(this._nostrService, this._settings) {
// Subscribe to orders and initialize data
_subscribeToOrders();
Expand Down Expand Up @@ -56,6 +66,9 @@ class OpenOrdersRepository implements OrderRepository<NostrEvent> {
event.pubkey == _settings.mostroPublicKey) {
logger.i('Mostro instance info loaded: $event');
_mostroInstance = event;
if (!_mostroInstanceController.isClosed) {
_mostroInstanceController.add(event);
}
}
}, onError: (error) {
logger.e('Error in order subscription: $error');
Expand All @@ -76,6 +89,7 @@ class OpenOrdersRepository implements OrderRepository<NostrEvent> {
void dispose() {
_subscription?.cancel();
_eventStreamController.close();
_mostroInstanceController.close();
_events.clear();
}

Expand Down
18 changes: 18 additions & 0 deletions lib/features/mostro/mostro_instance.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ class MostroInstance {
final String fiatCurrenciesAccepted;
final int maxOrdersPerResponse;

/// Wire transport advertised via the `protocol_version` tag (§2 of the
/// transport v2 migration). Defaults to `1` (NIP-59 gift wrap) when the tag
/// is absent or unparseable, matching the legacy-daemon behaviour.
final int protocolVersion;

/// Bond policy state. See [BondPolicy] for the three-state semantics.
final BondPolicy bondPolicy;

Expand Down Expand Up @@ -73,6 +78,7 @@ class MostroInstance {
this.lndNodeUri,
this.fiatCurrenciesAccepted,
this.maxOrdersPerResponse, {
this.protocolVersion = 1,
this.bondPolicy = BondPolicy.unsupported,
this.bondApplyTo,
this.bondSlashOnWaitingTimeout,
Expand Down Expand Up @@ -105,6 +111,7 @@ class MostroInstance {
event.lndNodeUri,
event.fiatCurrenciesAccepted,
event.maxOrdersPerResponse,
protocolVersion: event.protocolVersion ?? 1,
bondPolicy: event.bondPolicy,
bondApplyTo: event.bondApplyTo,
bondSlashOnWaitingTimeout: event.bondSlashOnWaitingTimeout,
Expand Down Expand Up @@ -170,6 +177,17 @@ extension MostroInstanceExtensions on NostrEvent {
int get maxOrdersPerResponse =>
int.parse(_getTagValue('max_orders_per_response'));

/// Parses the wire transport version from the `protocol_version` tag (§2).
///
/// Returns `null` when the tag is absent or unparseable. Callers treat
/// `null` as legacy v1 (NIP-59 gift wrap); the nullable form is preserved so
/// the transport resolver can distinguish "not advertised" from an explicit
/// version when deciding whether to log a version-skew downgrade.
int? get protocolVersion {
final raw = _getOptionalTagValue('protocol_version');
return raw == null ? null : int.tryParse(raw);
}

/// Parses the anti-abuse bond policy from the `bond_enabled` tag.
///
/// - Tag absent → [BondPolicy.unsupported] (legacy daemon).
Expand Down
40 changes: 40 additions & 0 deletions lib/features/mostro/transport.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import 'package:mostro_mobile/services/logger_service.dart';

/// Wire transport a Mostro node speaks.
///
/// - [giftWrap]: protocol v1, NIP-59 gift wrap (kind 1059).
/// - [nip44]: protocol v2, NIP-44 direct message signed by the trade key
/// (kind 14).
///
/// Modelled as an enum (rather than a raw integer threaded through the code) so
/// the send path, the receive subscription filters and the message `version`
/// field cannot drift out of sync. See
/// `docs/architecture/TRANSPORT_V2_MIGRATION.md` (§4.1).
enum Transport { giftWrap, nip44 }

/// Resolves the wire transport for a node from its advertised
/// `protocol_version` (§2, §4.1).
///
/// - `2` → [Transport.nip44] (v2).
/// - `1` → [Transport.giftWrap] (v1, explicitly advertised).
/// - `null` → [Transport.giftWrap]. The tag is absent or the node info has not
/// been fetched yet; during the migration window this is the common legacy
/// case, so it resolves to v1 without noise.
/// - any other value → [Transport.giftWrap], logged at `warn`. We do not speak
/// that protocol, so we degrade to v1 (version-skew guard) and surface the
/// degraded state so a misconfigured node is not silently mis-paired.
Transport resolveTransport(int? protocolVersion) {
switch (protocolVersion) {
case 2:
return Transport.nip44;
case 1:
case null:
return Transport.giftWrap;
default:
logger.w(
'Unsupported protocol_version $protocolVersion; '
'degrading to v1 gift wrap',
);
return Transport.giftWrap;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import 'package:mostro_mobile/data/models/peer.dart';
import 'package:mostro_mobile/data/models/session.dart';
import 'package:mostro_mobile/data/models/enums/action.dart' as mostro_action;
import 'package:mostro_mobile/data/models/enums/role.dart';
import 'package:mostro_mobile/data/models/enums/storage_keys.dart';
import 'package:mostro_mobile/data/repositories/session_storage.dart';
import 'package:mostro_mobile/features/settings/settings.dart';
import 'package:mostro_mobile/features/key_manager/key_derivator.dart';
import 'package:mostro_mobile/features/key_manager/key_manager.dart';
import 'package:mostro_mobile/features/key_manager/key_storage.dart';
Expand Down Expand Up @@ -198,7 +200,7 @@ Future<void> showLocalNotification(NostrEvent event) async {

Future<MostroMessage?> _decryptAndProcessEvent(NostrEvent event) async {
try {
if (event.kind != 4 && event.kind != 1059) {
if (event.kind != 4 && event.kind != 1059 && event.kind != 14) {
return null;
}

Expand Down Expand Up @@ -283,12 +285,29 @@ Future<MostroMessage?> _processAdminDm(NostrEvent event, Session session) async

/// Handle events matched by tradeKey (Mostro protocol + admin/dispute DMs)
Future<MostroMessage?> _handleTradeKeyEvent(NostrEvent event, Session session) async {
final decryptedEvent = await event.unWrap(session.tradeKey.private);
if (decryptedEvent.content == null) {
// Transport branch (§5 Phase A): v1 gift wrap (kind 1059) yields an inner
// rumor whose content is the message tuple; v2 NIP-44 direct (kind 14)
// decrypts straight to the tuple. Both converge on jsonDecode below.
final String? content;
if (event.kind == 14) {
final mostroPubkey = await _loadMostroPubkey();
if (mostroPubkey == null) {
logger.w('No Mostro pubkey available, cannot decrypt kind-14 event');
return null;
}
content = await NostrUtils.decryptNIP44DirectEvent(
event,
session.tradeKey.private,
expectedAuthor: mostroPubkey,
);
} else {
content = (await event.unWrap(session.tradeKey.private)).content;
}
if (content == null) {
return null;
}

final result = jsonDecode(decryptedEvent.content!);
final result = jsonDecode(content);
if (result is! List || result.isEmpty) {
return null;
}
Expand Down Expand Up @@ -429,6 +448,24 @@ Future<MostroMessage?> _handleP2PChatEvent(NostrEvent event, Session session) as
}
}

/// Reads the connected Mostro node pubkey from persisted settings. Needed in
/// the background isolate to verify the author of v2 (kind-14) replies, since
/// there is no Riverpod settings provider available here.
Future<String?> _loadMostroPubkey() async {
try {
final sharedPrefs = SharedPreferencesAsync();
final settingsJson =
await sharedPrefs.getString(SharedPreferencesKeys.appSettings.value);
if (settingsJson == null) {
return null;
}
return Settings.fromJson(jsonDecode(settingsJson)).mostroPublicKey;
} catch (e) {
logger.e('Failed to load Mostro pubkey in background: $e');
return null;
}
}

Future<List<Session>> _loadSessionsFromDatabase() async {
try {
final db = await openMostroDatabase('mostro.db');
Expand Down
Loading
Loading