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
3 changes: 2 additions & 1 deletion lib/data/repositories/dispute_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ class DisputeRepository {
// (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;
final mostroInstance =
await _ref.read(orderRepositoryProvider).awaitMostroInstance();
if (mostroInstance == null) {
logger.w(
'Mostro instance info unavailable, sending dispute with PoW 0 — '
Expand Down
36 changes: 36 additions & 0 deletions lib/data/repositories/open_orders_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ const orderEventKind = 38383;
const infoEventKind = 38385;
const orderFilterDurationHours = 48;

/// How long a send path waits for the node's kind-38385 info event before
/// falling back to defaults. The info event normally arrives with the initial
/// order subscription; this only bounds a cold start against a slow relay and
/// stays well under the 10s orphan-session cleanup timer.
const mostroInstanceWaitTimeout = Duration(seconds: 3);

class OpenOrdersRepository implements OrderRepository<NostrEvent> {
final NostrService _nostrService;
NostrEvent? _mostroInstance;
Expand Down Expand Up @@ -46,6 +52,36 @@ class OpenOrdersRepository implements OrderRepository<NostrEvent> {
Stream<NostrEvent> get mostroInstanceStream =>
_mostroInstanceController.stream;

/// Returns the node's kind-38385 info event, waiting up to [timeout] for it
/// if it has not arrived yet.
///
/// The info event carries both the PoW difficulty and the `protocol_version`
/// that selects the outbound transport, so a send issued before it lands
/// would have to guess both. Guessing the transport wrong is unrecoverable:
/// the node ignores the envelope it does not speak and nothing retries the
/// action. Send paths await this instead; a timeout still falls back to the
/// caller's defaults (PoW 0, [resolveTransport]'s v2 default) so an
/// unreachable node degrades rather than blocks the UI.
Future<NostrEvent?> awaitMostroInstance({
Duration timeout = mostroInstanceWaitTimeout,
}) async {
final cached = _mostroInstance;
if (cached != null) return cached;
if (_mostroInstanceController.isClosed) return null;
try {
return await _mostroInstanceController.stream.first.timeout(timeout);
} on TimeoutException {
logger.w(
'Mostro instance info did not arrive within '
'${timeout.inSeconds}s; proceeding with defaults',
);
return null;
} catch (e) {
logger.w('Failed while waiting for Mostro instance info: $e');
return null;
}
}

OpenOrdersRepository(this._nostrService, this._settings) {
// Subscribe to orders and initialize data
_subscribeToOrders();
Expand Down
22 changes: 13 additions & 9 deletions lib/features/mostro/mostro_instance.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,13 @@ class MostroInstance {
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;
/// transport v2 migration), or `null` when the node does not advertise it.
///
/// Kept nullable so "not advertised" stays distinguishable from an explicit
/// version: that is the distinction [resolveTransport] negotiates on, and
/// collapsing it to `1` here would also make the About screen report v1 for
/// a node the app is actually speaking v2 to.
final int? protocolVersion;

/// Bond policy state. See [BondPolicy] for the three-state semantics.
final BondPolicy bondPolicy;
Expand Down Expand Up @@ -78,7 +82,7 @@ class MostroInstance {
this.lndNodeUri,
this.fiatCurrenciesAccepted,
this.maxOrdersPerResponse, {
this.protocolVersion = 1,
this.protocolVersion,
this.bondPolicy = BondPolicy.unsupported,
this.bondApplyTo,
this.bondSlashOnWaitingTimeout,
Expand Down Expand Up @@ -111,7 +115,7 @@ class MostroInstance {
event.lndNodeUri,
event.fiatCurrenciesAccepted,
event.maxOrdersPerResponse,
protocolVersion: event.protocolVersion ?? 1,
protocolVersion: event.protocolVersion,
bondPolicy: event.bondPolicy,
bondApplyTo: event.bondApplyTo,
bondSlashOnWaitingTimeout: event.bondSlashOnWaitingTimeout,
Expand Down Expand Up @@ -179,10 +183,10 @@ extension MostroInstanceExtensions on NostrEvent {

/// 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.
/// Returns `null` when the tag is absent or unparseable. [resolveTransport]
/// treats `null` as v2 (NIP-44), the live transport; the nullable form is
/// preserved so it can distinguish "not advertised" from an explicit
/// `protocol_version=1`, the one value that still selects gift wrap.
int? get protocolVersion {
final raw = _getOptionalTagValue('protocol_version');
return raw == null ? null : int.tryParse(raw);
Expand Down
36 changes: 23 additions & 13 deletions lib/features/mostro/transport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,35 @@ enum Transport { giftWrap, nip44 }
/// `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.
/// - `1` → [Transport.giftWrap] (v1, explicitly advertised). This is the only
/// input that selects the legacy gift wrap path.
/// - `null` → [Transport.nip44]. The tag is absent or the node info has not
/// been fetched yet. Advertising `protocol_version` is mandatory and gift
/// wrap is obsolete in the protocol, so "unknown" has exactly one sensible
/// answer: the live transport. Defaulting to v1 here used to cost a
/// kind-1059 REQ on every relay at every cold start, CLOSEd and re-REQd as
/// kind 14 the moment the info event landed.
/// - any other value → [Transport.nip44], logged at `warn`. The old rule
/// degraded to v1 as a version-skew guard; that guard was worth its cost
/// only while v1 was the live transport. A node advertising a version we do
/// not know (3, say) is far likelier to speak v2 than the obsolete v1, so
/// the safer guess is v2 — the `warn` still surfaces the skew.
Transport resolveTransport(int? protocolVersion) {
switch (protocolVersion) {
case 2:
return Transport.nip44;
case 1:
case null:
// Legacy nodes only: the gift wrap transport is obsolete in the
// protocol and its code paths are scheduled for removal.
return Transport.giftWrap;
case 2:
case null:
// v2 is the live transport. Defaulting to it when the node info has
// not arrived yet avoids a useless kind-1059 REQ at every cold start
// followed by a CLOSE + re-REQ once protocol_version resolves.
return Transport.nip44;
Comment on lines +39 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve v1 sends until the node advertises its transport

When a user sends an order during cold start against a supported protocol_version=1 node, MostroService.publishOrder passes mostroInstance?.protocolVersion as null, so this branch now emits an unsupported kind-14 message that the v1 node ignores. The later instance listener only replaces the receive subscription; it cannot retry the lost outbound action. Limit the v2 default to the initial orders subscription optimization, or wait for transport discovery before selecting the outbound envelope.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 80dffa5 — but by waiting for transport discovery, not by preserving the v1 default on the send path.

The concern is real: an outbound envelope on the wrong transport is dropped by the node and nothing retries it. Keeping null → giftWrap for sends does not fix it though, it just moves the loss: on a v2 node (the mandatory case now) a cold-start send would go out as kind 1059 and be ignored exactly the same way. Either default is a guess, and one of the two nodes always loses.

So the send path no longer guesses. OpenOrdersRepository.awaitMostroInstance() returns the cached kind-38385 info event, or waits for it (bounded, 3s, well under the 10s orphan-session cleanup timer) and logs + falls back to the previous defaults on timeout. Applied at every wrapForTransport call site: MostroService.publishOrder, DisputeRepository, and the three restore requests. Same call also removes the PoW-0 guess those paths had, which had the same root cause.

The v2 default in resolveTransport stays as the last-resort fallback for the case where the info event genuinely never arrives.

Tests: test/data/repositories/open_orders_repository_await_instance_test.dart pins the three outcomes (cached, resolves on arrival, null on timeout). Full suite green (1196), flutter analyze clean.

default:
logger.w(
'Unsupported protocol_version $protocolVersion; '
'degrading to v1 gift wrap',
'Unknown protocol_version $protocolVersion; assuming v2 (NIP-44)',
);
return Transport.giftWrap;
return Transport.nip44;
}
}
54 changes: 36 additions & 18 deletions lib/features/restore/restore_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart';
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/features/mostro/transport.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';
Expand Down Expand Up @@ -219,25 +220,39 @@ class RestoreService {
throw Exception('Temp trade key not initialized');
}

// 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.)
// Resolve the node's transport and open a single filter for it.
// _waitForNodeConnectivity() has already polled for the kind-38385 info
// event by this point, so protocol_version is normally known here.
// (limit 0: only new events, no history.)
final mostroPubkey = ref.read(settingsProvider).mostroPublicKey;
final v1Filter = NostrFilter(
kinds: [1059],
p: [_tempTradeKey!.public],
limit: 0,
);
final advertisedVersion =
ref.read(orderRepositoryProvider).mostroInstance?.protocolVersion;

final v2Filter = NostrFilter(
kinds: [14],
authors: [mostroPubkey],
p: [_tempTradeKey!.public],
limit: 0,
);
// No `authors` pin: NIP-59 signs the outer gift wrap with a per-event
// ephemeral key, so the node's pubkey never appears there. The sender is
// verified after decryption instead (decodeRestoreMessage).
final v1Filter = NostrFilter(
kinds: [1059],
p: [_tempTradeKey!.public],
limit: 0,
);

// Only when the info event never arrived do we fall back to listening on
// both transports, since the node then answers on whichever it speaks.
final filters = advertisedVersion == null
? [v1Filter, v2Filter]
: switch (resolveTransport(advertisedVersion)) {
Transport.nip44 => [v2Filter],
Transport.giftWrap => [v1Filter],
};

final request = NostrRequest(filters: [v1Filter, v2Filter]);
final request = NostrRequest(filters: filters);
final stream = ref.read(nostrServiceProvider).subscribeToEvents(request);

final subscription = stream.listen(
Expand Down Expand Up @@ -272,7 +287,8 @@ class RestoreService {
);

// Respect full privacy mode: if enabled, don't pass master key, wrap will be done just with trade key
final mostroInstance = ref.read(orderRepositoryProvider).mostroInstance;
final mostroInstance =
await ref.read(orderRepositoryProvider).awaitMostroInstance();
final mostroPow = mostroInstance?.pow ?? 0;
if (mostroInstance == null) {
logger.w(
Expand Down Expand Up @@ -369,7 +385,8 @@ class RestoreService {
);

// Respect full privacy mode: if enabled, don't pass master key, wrap will be done just with trade key
final mostroInstance = ref.read(orderRepositoryProvider).mostroInstance;
final mostroInstance =
await ref.read(orderRepositoryProvider).awaitMostroInstance();
final mostroPow = mostroInstance?.pow ?? 0;
if (mostroInstance == null) {
logger.w(
Expand Down Expand Up @@ -450,7 +467,8 @@ class RestoreService {
);

// Respect full privacy mode: if enabled, don't pass master key, wrap will be done just with trade key
final mostroInstance = ref.read(orderRepositoryProvider).mostroInstance;
final mostroInstance =
await ref.read(orderRepositoryProvider).awaitMostroInstance();
final mostroPow = mostroInstance?.pow ?? 0;
if (mostroInstance == null) {
logger.w(
Expand Down Expand Up @@ -1075,10 +1093,10 @@ 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.
// Wait for the node info event (kind 38385) before sending: it carries
// the PoW difficulty and the protocol_version that selects the outbound
// transport, and it also lets _createTempSubscription() open a single
// filter instead of listening on both transports.
await _waitForNodeConnectivity(ref.read(settingsProvider).mostroPublicKey);

_tempSubscription = await _createTempSubscription();
Expand Down
2 changes: 1 addition & 1 deletion lib/features/settings/about_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ class AboutScreen extends ConsumerWidget {
_buildInfoRowWithDialog(
context,
S.of(context)!.protocolVersion,
instance.protocolVersion.toString(),
instance.protocolVersion?.toString() ?? '—',
S.of(context)!.protocolVersionExplanation,
),
const SizedBox(height: 16),
Expand Down
16 changes: 10 additions & 6 deletions lib/features/subscriptions/subscription_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,10 @@ class SubscriptionManager {
/// switch to the v2 (kind 14) transport once the node advertises
/// `protocol_version=2`. The info event arrives asynchronously after the
/// initial subscription, so without this the orders filter would stay pinned
/// to the transport resolved at subscription time (typically v1 at cold
/// start). Re-subscribes only when the resolved transport actually changes.
/// to the transport resolved at subscription time (v2 by default, see
/// [resolveTransport]) and would never fall back for a node that actually
/// advertises `protocol_version=1`. Re-subscribes only when the resolved
/// transport actually changes.
void _initMostroInstanceListener() {
try {
_mostroInstanceListener =
Expand All @@ -124,15 +126,17 @@ class SubscriptionManager {
}

/// Resolves the transport for the orders subscription from the connected
/// node's advertised `protocol_version` (§2, §4.1). Defaults to v1 gift wrap
/// when the node info is not yet available or unreadable.
/// node's advertised `protocol_version` (§2, §4.1). Defaults to v2 NIP-44
/// when the node info is not yet available or unreadable, matching
/// [resolveTransport]: gift wrap is obsolete, so falling back to it would
/// pin the session to a kind-1059 REQ the node never answers.
Transport _resolveOrdersTransport() {
try {
final infoEvent = ref.read(orderRepositoryProvider).mostroInstance;
return resolveTransport(infoEvent?.protocolVersion);
} catch (e) {
logger.w('Failed to resolve orders transport, defaulting to v1: $e');
return Transport.giftWrap;
logger.w('Failed to resolve orders transport, defaulting to v2: $e');
return Transport.nip44;
}
}

Expand Down
11 changes: 8 additions & 3 deletions lib/services/mostro_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -393,8 +393,12 @@ class MostroService {
Future<void> publishOrder(MostroMessage order) async {
final session = await _getSession(order);

// Read PoW difficulty from the connected Mostro instance (kind 38385)
final mostroInstance = ref.read(orderRepositoryProvider).mostroInstance;
// Read PoW difficulty and protocol version from the connected Mostro
// instance (kind 38385), waiting for it if the info event has not landed
// yet: an outbound envelope sent on the transport the node does not speak
// is dropped silently and never retried.
final mostroInstance =
await ref.read(orderRepositoryProvider).awaitMostroInstance();
final difficulty = mostroInstance?.pow ?? 0;
if (mostroInstance == null) {
logger.w(
Expand All @@ -404,7 +408,8 @@ class MostroService {
}

// Route through the transport advertised by the connected node (§5 Phase
// B). v1 nodes (default) keep the gift-wrap path byte-for-byte.
// B). Nodes that advertise protocol_version 1 keep the gift-wrap path
// byte-for-byte.
final event = await order.wrapForTransport(
protocolVersion: mostroInstance?.protocolVersion,
tradeKey: session.tradeKey,
Expand Down
Loading
Loading