Skip to content

perf: bound NWC response replay and memoize the countdown state lookup - #718

Merged
Catrya merged 3 commits into
mainfrom
perf/nwc-since-and-countdown-memo
Sep 4, 2026
Merged

perf: bound NWC response replay and memoize the countdown state lookup#718
Catrya merged 3 commits into
mainfrom
perf/nwc-since-and-countdown-memo

Conversation

@grunch

@grunch grunch commented Sep 1, 2026

Copy link
Copy Markdown
Member

Summary

Final Phase-5 PR, closing the app-side scope of plan items 5.5 and 5.2.

  1. NWC replay per request: every request subscribed to the wallet's kind-23195 responses with no since, replaying the wallet's entire response history from the relay per request — and the balance tick (post-perf: timer hygiene for settings poll, countdown and NWC health #690) sends one request per minute. Responses are always newer than their request in real time, but since is the phone's clock while the relay filters on the wallet service's signed created_at — so the window is NwcClient.responseReplayWindow = 10 minutes, matching ChatCursorStore.cursorOverlap, the skew budget already used for relay-side since filters here. It kills the replay while leaving a realistic clock margin. The e-tag verification in the handler is untouched.
  2. Countdown state lookup: the trade-detail countdown re-copied and re-sorted the order's message history on every 1-second tick. Memoized per history-list instance via an Expando: Riverpod hands back the same list instance between ticks, so ticks hit the memo and a new instance recomputes. (perf: serve mostro message queries from a single in-memory index #715 is not a prerequisite — it only reduces spurious invalidations from unrelated writes to the orders store.) Results made time-dependent by the future-timestamp guard are deliberately left uncached, so nothing freezes.

Deliberately out of scope (documented)

  • Bounding the dart_nostr fork's internal registries (allDataEntitiesRegister/eventsRegistry) — fork-repo follow-up, as with the reconnect backoff.
  • Migrating the legacy SharedPreferences.getInstance() sites to SharedPreferencesAsync — the two APIs use different backing stores on Android; a blind swap would drop existing chat/dispute read cursors. Needs a small migration, not a cleanup.
  • Wrapping the restore loop in a single transaction — the writes go through per-record APIs whose transactional semantics other flows rely on.

Test plan

  • NWC + trades suites — green
  • Full suite halves — features shows only the known test: fix the duplicate-envelope race pin for cross-isolate unwrapping #710 failure; rest all green
  • flutter analyze — no new issues
  • Review fixes (5e029a3): 10 min NWC skew window + no-freeze memo, 11 new tests
  • Manual: NWC wallet connected — balance refresh works, relay logs show minute-window REQs; trade-detail countdown ticks smoothly and updates on new messages

🤖 Generated with Claude Code

https://claude.ai/code/session_018fTxqxhpdL5siTgKZqwtur

- Every NWC request subscribed to the wallet's kind-23195 responses with
  no since, replaying the wallet's entire response history from the relay
  per request - and the balance tick sends one request per minute.
  Responses are always newer than their request; a one-minute since
  absorbs clock skew and kills the replay.
- The trade-detail countdown re-copied and re-sorted the order's message
  history on every 1-second tick to find the state message. The result is
  now memoized per history-list instance (the storage index emits a new
  list only on real changes).

Closes the app-side scope of plan items 5.5 and 5.2; bounding the
dart_nostr fork's internal registries remains a fork-repo follow-up, and
the legacy SharedPreferences.getInstance sites stay untouched because
migrating them to SharedPreferencesAsync moves the backing store and
would drop existing read cursors.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 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-09-01T01:14:41.424685Z 3106709 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: 31067098d1

ℹ️ 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 thread lib/services/nwc/nwc_client.dart Outdated
Comment thread lib/features/trades/screens/trade_detail_screen.dart Outdated
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 58 minutes.

Check out review usage here.

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: 432b4e84-f9da-43d5-b38f-6417d5169c21

📥 Commits

Reviewing files that changed from the base of the PR and between 0381ff8 and 5e029a3.

📒 Files selected for processing (5)
  • lib/features/trades/screens/trade_detail_screen.dart
  • lib/features/trades/state_message_finder.dart
  • lib/services/nwc/nwc_client.dart
  • test/features/trades/state_message_finder_test.dart
  • test/services/nwc/nwc_response_filter_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.

@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.

Request changes — one line needs a bigger number. Everything else is comment-level.

On CI: the red check is not from this PR. Full suite on this branch is 1282 pass / 1 fail, and the single failure is dispute_chat_duplicate_envelope_test.dart, which fails identically on pristine main; #708 already fixes it. flutter analyze is clean.

I confirmed the countdown hot path is real: trade_detail_screen.dart:1015 watches countdownTimeProvider purely as a 1 s rebuild ticker, and mostroMessageHistoryProvider hands back the same list instance between ticks, so the memo lands exactly where intended.

Blocking: one minute is too tight a clock-skew budget

Codex flagged this as P1 and it is right. Some detail worth adding.

since is computed from the phone's clock, but the relay filters against the created_at signed by the wallet service — two independent machines. If the wallet's clock runs more than 60 s behind the phone's, or (more common in consumer hands) if the phone's clock runs ahead because automatic time is off, the response arrives with a created_at older than since, the relay drops it, and completer.future.timeout(requestTimeout) (nwc_client.dart:488, 30 s) fires.

The failure mode is silent and total: nothing degrades, everything stops — balance, pay_invoice, all of it — and from the outside it looks like the wallet never answered, when it did.

There is also a precedent in this repo that argues against the number: ChatCursorStore.cursorOverlap is 10 minutes, chosen for exactly this. The 60 s in NostrEventExtensions.chatMaxClockSkewSecs is a validation tolerance for rejecting envelopes, not a relay-side filter that discards silently. So this PR applies the tightest skew budget in the codebase to the one place where the counterparty is a third-party server rather than Mostro.

One-line fix: raise it to 10 minutes, matching cursorOverlap. It kills the replay just as effectively — the point is not to re-receive the wallet's entire history, and ten minutes of it is nothing — while leaving a realistic clock margin.

The memo P2 is valid but largely theoretical

Codex notes that _findMessageForStateUncached is not pure — it calls DateTime.now() in the future-timestamp guard — so memoizing freezes a result whose validity depends on time.

Correct in principle, but worth knowing that the guard is close to dead code: MostroStorage.addMessage back-fills message.timestamp ??= DateTime.now().millisecondsSinceEpoch at write time, so a stored message cannot carry a timestamp more than an hour in the future. The only route would be background_notification_service.dart:383, which uses the Nostr event's createdAt, and that would need Mostro's clock to be over an hour fast. It is also self-limiting: a new message produces a new list instance and the memo recomputes.

I would leave this as a comment rather than a change. If you do want it closed, the clean move is to drop the DateTime.now() guard from the memoized function — it buys nothing today — rather than adding time-based invalidation to the memo.

No tests

The PR changes two behaviors and adds no test — the suite count is identical to main. That departs from the standard of this series: #708, #711, #712 and #715 all ship RED-first tests.

The NWC filter's since in particular deserves a pin: it is one line that nothing would catch if someone later edits or drops it, and it is the change with the most expensive failure mode.

Small accuracy note on the PR body

It says the memo works because "the storage index (#715) emits a new list instance only on real changes". The dependency is weaker than that: on main, Riverpod already caches the AsyncValue, so the 1 s ticks reuse the same list instance and the memo hits regardless. What #715 adds is fewer spurious invalidations — today BaseStorage.watch emits a fresh list on any write to the orders store, including writes for a different order. The optimization does not depend on #715 to work.

What is right

Both hot paths are real and correctly identified. Expando is the right tool here: its keys are weak, so entries are collected along with the list and nothing leaks — a static Map would have leaked. The memo caches negative results properly via containsKey rather than ??, which is the usual mistake in this pattern. And the e-tag verification in the handler is untouched, so since relaxes no security check — it only trims the replay.

Review feedback on #718.

Blocking (P1): the kind-23195 `since` was computed from the phone's clock
while the relay filters on the `created_at` signed by the wallet service.
One minute of skew is the tightest budget in the codebase, applied to the
one place where the counterparty is a third-party server. A wallet running
more than 60 s behind (or a phone running ahead) had its live response
dropped by the relay, timing out every NWC operation, payments included —
silently, and indistinguishably from the wallet never answering.

The window is now a named `NwcClient.responseReplayWindow` of 10 minutes,
matching `ChatCursorStore.cursorOverlap`, the skew budget this codebase
already uses for relay-side `since` filters. It bounds the replay just as
effectively. The filter moves into a testable `responseFilter()`; the
e-tag verification in the handler is untouched.

P2: the countdown memo froze a result that depends on `DateTime.now()` —
a message timestamped over an hour ahead was skipped and the skip cached
for the lifetime of the list instance. The lookup now reports whether the
future-timestamp guard discarded a candidate and leaves those results
uncached, so a later tick re-evaluates once the clock catches up. The
guard is kept rather than dropped, so no bogus timestamp can drive a
countdown.

The lookup moves out of the private widget into `StateMessageFinder` so
both behaviors are testable; #718 shipped no tests, which departed from
the rest of the series.

Tests: 11 new — the `since` bound and its skew margin pinned against
`cursorOverlap`, and the memo's hit/recompute/no-freeze behavior.
@grunch

grunch commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

Thanks @Catrya — all three points addressed in 5e029a3.

Blocking (P1) — one minute is too tight. Agreed, and the cursorOverlap precedent settles it. The window is now a named NwcClient.responseReplayWindow = Duration(minutes: 10), matching ChatCursorStore.cursorOverlap. Your framing was the useful part: this was the codebase's tightest skew budget applied to the one place where the counterparty is a third-party server rather than Mostro, and where the relay discards silently instead of rejecting loudly like the chatMaxClockSkewSecs validation path. Ten minutes kills the replay just as effectively.

P2 memo. Closed, but not by dropping the DateTime.now() guard — dropping it would let a bogus future timestamp drive the countdown start. Instead the lookup reports whether the guard actually discarded a candidate, and results in that case are left uncached. The memo still hits on every ordinary 1 s tick (the whole point of the change), and the time-dependent case re-evaluates until the clock catches up. Cheap, since the skip path is the rare one.

No tests. Fair, and it did depart from #708/#711/#712/#715. Both behaviors are now testable and pinned — 11 tests:

  • test/services/nwc/nwc_response_filter_test.dart — the filter moved into NwcClient.responseFilter(walletPubkey, {now}); tests pin kind/author, the since cutoff, a ≥10 min skew margin against a lagging wallet, and equality with cursorOverlap. That is the pin you asked for: the one line nothing would otherwise catch if someone edits or drops it.
  • test/features/trades/state_message_finder_test.dart — the lookup moved out of the private widget into StateMessageFinder; tests cover newest-match selection, invalid-timestamp filtering, the memo hit, recompute on a new list instance, the future-timestamp skip, and the no-freeze fix above.

PR body accuracy. You're right and I've stopped claiming it — the memo works on main regardless, since Riverpod already hands back the same AsyncValue between ticks. #715 only reduces spurious invalidations from unrelated writes to the orders store; the optimization does not depend on it.

flutter analyze clean; test/services/ + test/features/trades/ green (144).

@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.

tACK

@Catrya
Catrya merged commit dae4a25 into main Sep 4, 2026
2 checks passed
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