Skip to content

perf: default the orders transport to v2 (NIP-44 kind 14) - #704

Merged
grunch merged 4 commits into
mainfrom
perf/default-transport-v2
Aug 31, 2026
Merged

perf: default the orders transport to v2 (NIP-44 kind 14)#704
grunch merged 4 commits into
mainfrom
perf/default-transport-v2

Conversation

@grunch

@grunch grunch commented Aug 31, 2026

Copy link
Copy Markdown
Member

Summary

Item 3.6 of the performance plan. The gift wrap transport (kind 1059 / NIP-59) is obsolete in the Mostro protocol, but resolveTransport still defaulted to it whenever the node's kind-38385 info event had not arrived yet. Every cold start therefore:

  1. opened a useless kind-1059 REQ on every relay,
  2. then CLOSEd and re-opened it as kind 14 once protocol_version resolved — a free resubscription (and its history replay) per launch, plus a live dead-transport subscription window.

Changes

  • resolveTransport: null (info not yet fetched) and unknown versions resolve to Transport.nip44; only an explicit protocol_version: 1 selects the legacy path. The transport-change listener still re-subscribes if a legacy node later advertises v1.
  • Test pins flipped to the new contract (deliberate, mirroring the resolver's comment).

Follow-up (needs team confirmation): once no v1 nodes remain supported, delete the 1059/NIP-59 branches entirely — buildOrdersFilter's 1059 arm, wrapForTransport's gift wrap arm, decryptNIP59Event/createNIP59Event, and the background isolate's mirror paths. That removes a second decrypt pipeline from the hot path.

Test plan

  • transport_test.dart updated pins + mostro/subscriptions/data suites — green
  • Full flutter test — all green
  • flutter analyze — no new issues
  • Manual: cold start against a current node — exactly one orders REQ (kind 14) per relay, no CLOSE+REQ when the info event arrives

🤖 Generated with Claude Code

https://claude.ai/code/session_018fTxqxhpdL5siTgKZqwtur

The gift wrap transport (kind 1059 / NIP-59) is obsolete in the Mostro
protocol, but resolveTransport still fell back to it whenever the node's
info event had not arrived. At every cold start the app therefore opened
a useless kind-1059 REQ on every relay and then closed and re-opened it
as kind 14 once protocol_version resolved - a free resubscription (and
its replay) per launch.

The default and unknown versions now resolve to v2; only an explicit
protocol_version 1 selects the legacy path. Deleting the 1059/NIP-59
branches entirely (filters, wrap, unwrap, background isolate) is the
follow-up once the team confirms no v1 nodes remain (plan item 3.6).
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 25 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 6a9b7e34-4d57-4860-bcb3-35ae4429a849

📥 Commits

Reviewing files that changed from the base of the PR and between 65a5801 and 683c76a.

📒 Files selected for processing (13)
  • lib/data/repositories/dispute_repository.dart
  • lib/data/repositories/open_orders_repository.dart
  • lib/features/mostro/mostro_instance.dart
  • lib/features/mostro/transport.dart
  • lib/features/restore/restore_manager.dart
  • lib/features/settings/about_screen.dart
  • lib/features/subscriptions/subscription_manager.dart
  • lib/services/mostro_service.dart
  • test/data/repositories/open_orders_repository_await_instance_test.dart
  • test/features/mostro/mostro_instance_test.dart
  • test/features/mostro/transport_test.dart
  • test/features/subscriptions/subscription_filter_diff_test.dart
  • test/services/mostro_service_test.dart

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-31T00:49:38.704203Z d7ecbef PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d7ecbefc32

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +33 to +37
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;

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.

@Catrya Catrya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The behaviour change is correct and I verified the win is real. _initMostroInstanceListener guards on if (newTransport == _appliedOrdersTransport) return; (subscription_manager.dart:87), so with this change a cold start resolves nip44, the node's info event (protocol_version=2) resolves nip44 too, and the CLOSE + re-REQ on every relay no longer happens. That was the only recurring v1 cost in the app, and it is gone.

I also checked the one thing that could have quietly broken it: MostroInstance.protocolVersion is a non-nullable int defaulting to 1 (mostro_instance.dart:81), which would have collapsed the "not advertised" vs "advertised 1" distinction the resolver depends on. It does not apply here — subscription_manager.dart:124 passes infoEvent?.protocolVersion where infoEvent is a NostrEvent?, so it goes through the nullable raw-tag getter (mostro_instance.dart:186). The distinction is preserved and the commit message is accurate.

I agree with flipping the default: case too. A node advertising 3 is far more likely to speak v2 than v1, so assuming v2 is the better guess.

flutter analyze is clean apart from the two pre-existing stale-mock errors, the full suite shows no new failures against main, and transport_test.dart covers all five branches of the switch.

What blocks this is that the rule changed in one function while three places around it still state — and in one case still execute — the old rule.

1. The catch fallback still selects v1 (this is code, not a comment)

// subscription_manager.dart:118-129
/// 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.        // no longer true
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;                                     // still v1
  }
}

If this branch ever fires, the app opens a kind-1059 REQ on every relay and stays pinned to it for the whole session against a node that will never answer there — silently, with only a logger.w. That is precisely the failure this PR exists to remove, left alive inside the PR's own blast radius. It should return Transport.nip44, with the log wording updated to match.

Given that advertising protocol_version is mandatory, this fallback has no remaining justification: it exists to cover "I don't know what the node speaks", and that case now has one correct answer.

2. resolveTransport's own docstring contradicts the code

transport.dart:18-25 still reads:

/// - `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) ...

Both statements are now the opposite of what the function does. This is not an incidental comment: the enum Transport docstring five lines above says the enum exists "so the send path, the receive subscription filters and the message version field cannot drift out of sync". A file whose only job is documenting the negotiation rule, documenting it backwards, is exactly the drift it was written to prevent — and CLAUDE.md points readers here as the source of truth.

Worth writing down why the version-skew guard was dropped, too, rather than just deleting the rationale.

3. subscription_manager.dart:78-79

▎ "the transport resolved at subscription time (typically v1 at cold start)"

No longer the case.

Non-blocking

The restore flow still subscribes to both transports unconditionally. restore_manager.dart:222-240:

// 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.
final v1Filter = NostrFilter(kinds: [1059], p: [_tempTradeKey!.public], limit: 0);
final v2Filter = NostrFilter(kinds: [14], authors: [mostroPubkey], p: [...], limit: 0);

The justification in that comment is the exact reasoning this PR invalidates — "node info may not have loaded yet" now resolves to v2. The runtime cost is small (one extra filter per relay, restore only, limit: 0 so no history replay), which is why I am not blocking on it, but it is the one remaining place that opens a kind-1059 REQ against a v2 node, and leaving it makes the full v1 removal in plan item 3.6 harder to reason about. Note also that the v1 filter has no authors pin while the v2 one does, so it accepts gift wraps to the temp key from anyone.

MostroInstance.protocolVersion default. It still defaults to 1 (mostro_instance.dart:81) with a docstring saying that matches legacy-daemon behaviour, which no longer matches how the app actually negotiates. The About screen (about_screen.dart:341) would therefore show "Protocol Version: 1" for a node the app is speaking v2 to. Harmless functionally, but it is wrong information shown to exactly the person debugging this. Separate layer, so a follow-up issue is fine.

For the record, I did trace every remaining kind-1059 site: the orders filter (subscription_manager.dart:601) is now correctly gated behind an explicit protocol_version = 1, and the rest — mostro_service.dart:130, restore_manager.dart:1158, the legacy pre-migration rows in chat_room_notifier.dart:398 / dispute_chat_notifier.dart:366, and the FCM kind guard in background_notification_service.dart:246 — are decode-only paths with no relay subscription behind them. After the two fixes above, no v1 REQ is opened against a v2 node.

Review follow-ups on the v2 transport default.

- Send paths (order actions, dispute creation, the three restore requests)
  now await the node's kind-38385 info event via
  OpenOrdersRepository.awaitMostroInstance() instead of reading whatever
  happens to be cached. Guessing the transport is unrecoverable: the node
  drops the envelope it does not speak and nothing retries the action. The
  wait is bounded (3s) and falls back to the previous defaults, so an
  unreachable node degrades rather than blocks the UI. Fixes the PoW-0
  guess on the same paths.
- SubscriptionManager._resolveOrdersTransport()'s catch fallback returned
  Transport.giftWrap, which would pin a whole session to a kind-1059 REQ a
  v2 node never answers. It now matches resolveTransport and returns v2.
- resolveTransport's docstring documented the old rule (null and unknown
  versions → v1) and the reason the version-skew guard existed. Rewritten
  to the rule the code implements, with why the guard was dropped. Same
  for the two stale comments in subscription_manager and restore_manager.
- Restore opens a single filter for the resolved transport instead of
  subscribing to kinds 1059 and 14 unconditionally; the node info has
  already been polled for by then. Both filters remain only when the info
  event never arrived. Documents why the v1 filter has no authors pin
  (NIP-59 signs the outer wrap with an ephemeral key).
- MostroInstance.protocolVersion is nullable, so the About screen no
  longer reports "1" for a node the app speaks v2 to.
@grunch

grunch commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Thanks for the trace — all three blockers plus both non-blocking items are addressed in 80dffa5.

1. The catch fallback still selected v1 — fixed. _resolveOrdersTransport()'s catch now returns Transport.nip44 and the log says "defaulting to v2". You are right that this was the PR's own failure mode left alive inside its blast radius: it would have pinned the whole session to a kind-1059 REQ the node never answers, behind a single logger.w. And right that the fallback has no remaining justification — "I don't know what the node speaks" now has exactly one correct answer.

2. resolveTransport's docstring contradicted the code — rewritten. null and unknown versions now document resolving to v2, 1 is called out as the only input that still selects gift wrap, and the reason is written down instead of deleted: the version-skew guard was worth its cost only while v1 was the live transport; a node advertising an unknown version is likelier to speak v2 than the obsolete v1, and the warn still surfaces the skew.

3. subscription_manager.dart:78-79 — updated. It now says the filter would otherwise stay pinned to the v2 default and never fall back for a node that actually advertises protocol_version=1, which is what the listener is for.

Non-blocking: restore's dual subscription — fixed rather than deferred, since your point that it makes the full v1 removal harder to reason about is the real cost. _createTempSubscription() resolves the transport and opens a single filter; _waitForNodeConnectivity() has already polled for the info event by then, so protocol_version is normally known. Both filters remain only when the info event never arrived, since the node then answers on whichever it speaks. The missing authors pin on the v1 filter is intentional and now documented: 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 in decodeRestoreMessage. Also removed the stale comment at restore_manager.dart:1080 that still described the v1 default.

Non-blocking: MostroInstance.protocolVersion default — done here rather than as a follow-up; it was a two-line change. The field is int?, fromEvent no longer collapses "not advertised" to 1, and the About screen shows instead of a wrong 1 for a node the app is speaking v2 to. Model tests updated to the new contract.

Also, from the Codex review: the send path no longer guesses the transport at all. Every wrapForTransport call site awaits the node info via OpenOrdersRepository.awaitMostroInstance() (bounded 3s, under the 10s orphan-session timer, falls back to the previous defaults on timeout). An outbound envelope on the wrong transport is dropped and never retried, and keeping null → giftWrap for sends would only have moved that loss onto v2 nodes. This also removes the PoW-0 guess those same paths had.

Tests: new test/data/repositories/open_orders_repository_await_instance_test.dart; full suite green (1196 tests), flutter analyze clean.

grunch added 2 commits August 31, 2026 18:21
subscription_filter_diff_test's warm-up race test matched a chat REQ by
"any filter with kind 14". The orders subscription is kind 14 too now that
the transport defaults to v2, so the orders REQ was counted as a second
chat REQ and the test failed on the merge with main.

Chat filters are keyed on the peer-derived K_sign pubkeys and the orders
filter on the node's pubkey, so the author separates them regardless of
kind.
@grunch
grunch merged commit ef3aad3 into main Aug 31, 2026
2 checks passed
@grunch
grunch deleted the perf/default-transport-v2 branch August 31, 2026 21:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants