Skip to content

feat: migrate dispute chat to shared key encryption Phase 1#495

Merged
grunch merged 7 commits intomainfrom
phase1-chat-user-admin
Feb 25, 2026
Merged

feat: migrate dispute chat to shared key encryption Phase 1#495
grunch merged 7 commits intomainfrom
phase1-chat-user-admin

Conversation

@Catrya
Copy link
Member

@Catrya Catrya commented Feb 23, 2026

  • Add adminSharedKey field and setAdminPeer() to Session model with serialization
  • Add adminTookDispute handler in abstract_mostro_notifier to compute/persist admin shared key
  • Switch DisputeChatNotifier from mostroWrap/mostroUnWrap to p2pWrap/p2pUnwrap
  • Route dispute chat subscription via adminSharedKey.public instead of tradeKey.public
  • Use plain text content instead of MostroMessage JSON wrapper
  • Remove dm skip logic from MostroService (no longer needed with shared key routing)
  • Add dispute shared key tests (7 tests) and p2pWrap/p2pUnwrap round-trip tests (4 tests)

Summary by CodeRabbit

  • New Features

    • Admin role added for disputes with secure admin-routed peer-to-peer chat and session-persistent admin keys.
  • Bug Fixes

    • Dispute chat messages now processed and routed correctly; improved validation, unauthorized-message filtering, and outbound delivery handling.
  • Tests

    • New tests covering p2p wrap/unwrap and dispute shared-key computation and persistence.
  • Documentation

    • Dispute multimedia plan Phase 1 marked DONE

  - Add adminSharedKey field and setAdminPeer() to Session model with serialization
  - Add adminTookDispute handler in abstract_mostro_notifier to compute/persist admin shared key
  - Switch DisputeChatNotifier from mostroWrap/mostroUnWrap to p2pWrap/p2pUnwrap
  - Route dispute chat subscription via adminSharedKey.public instead of tradeKey.public
  - Use plain text content instead of MostroMessage JSON wrapper
  - Remove dm skip logic from MostroService (no longer needed with shared key routing)
  - Add dispute shared key tests (7 tests) and p2pWrap/p2pUnwrap round-trip tests (4 tests)
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 23, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds admin peer/pubkey and ECDH-derived adminSharedKey to Session; migrates dispute chat to p2pWrap/p2pUnwrap routed by adminSharedKey; adds Action.adminTookDispute handling to notifier; removes a prior dm guard in MostroService; adds tests for wrap/unwrap and shared-key behavior.

Changes

Cohort / File(s) Summary
Documentation
docs/DISPUTE_CHAT_MULTIMEDIA_PLAN.md
Phase 1 status changed from TODO to DONE; no functional changes.
Session model
lib/data/models/session.dart
Add admin_peer storage and adminSharedKey derivation; ctor accepts admin peer; add setAdminPeer() to validate/store admin pubkey and compute ECDH shared key; include admin_peer in JSON.
Dispute chat logic
lib/features/disputes/notifiers/dispute_chat_notifier.dart
Replace MostroMessage/gift-wrap flow with NIP‑59 p2pWrap/p2pUnwrap routed by adminSharedKey; gate subscriptions on adminSharedKey readiness; simplify incoming/outgoing paths, validate admin pubkey, adjust persisted fields and pending/error handling.
Order notifier
lib/features/order/notfiers/abstract_mostro_notifier.dart
Add handling for Action.adminTookDispute: extract admin pubkey from event, call session.setAdminPeer(), refresh session, and navigate to trade detail when appropriate.
Service layer
lib/services/mostro_service.dart
Remove guard that previously skipped Map {'dm': ...} payloads so those messages are processed as regular payloads.
P2P wrap/unwrap tests
test/data/models/nostr_event_wrap_test.dart
New tests validating p2pWrap/p2pUnwrap round-trips: shared key agreement, wrapper kind/tags, ephemeral wrapper pubkey, and plaintext content preservation.
Shared-key/session tests
test/features/disputes/dispute_shared_key_test.dart
New tests verifying ECDH shared-key correctness, Session adminPeer behavior and setAdminPeer(), JSON round-trips preserving admin_peer, and independence of user vs admin shared keys.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant ChatUI as Chat UI
    participant Notifier as Dispute Notifier
    participant Session
    participant Relay as Relay
    actor Admin

    User->>ChatUI: compose/send message
    ChatUI->>Notifier: send outbound
    Notifier->>Session: request adminSharedKey
    alt adminSharedKey available
        Notifier->>Notifier: create plain-text event
        Notifier->>Notifier: p2pWrap(event, adminSharedKey)
        Notifier->>Relay: publish wrapped event (kind 1059, p-tag -> shared-key pub)
        Relay->>Admin: deliver wrapped event
        Admin->>Admin: p2pUnwrap(wrapped, adminSharedKey) and read content
    else waiting for adminSharedKey
        Notifier->>Session: subscribe/listen for adminSharedKey
        Note right of Notifier: subscription gated until key ready
    end

    Admin->>Relay: publish wrapped reply
    Relay->>Notifier: deliver wrapped event
    Notifier->>Notifier: verify p-tag and p2pUnwrap using adminSharedKey
    Notifier->>Session: store message (isFromAdmin/isFromUser, pending/error)
    Notifier->>ChatUI: surface admin reply to user
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • grunch
  • AndreaDiazCorreia

Poem

🐇 I hopped with keys beneath my paw,
Hid notes in wraps the admin saw,
Secrets shared, then soft unbind,
Phase one done — disputes aligned,
A carrot cheer for code and law! 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The PR title accurately and specifically describes the main objective—migrating dispute chat to shared key encryption in Phase 1—and directly reflects the core changes across the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch phase1-chat-user-admin

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
lib/features/order/notfiers/abstract_mostro_notifier.dart (1)

502-528: this.session not refreshed after updating admin peer.

Other handlers that mutate the session (e.g., buyerTookOrder at Line 221–226 and holdInvoicePaymentAccepted at Line 269–275) explicitly re-fetch session from the session notifier after the update. Here, this.session retains the stale copy without adminSharedKey. If any future code in this notifier accesses session.adminSharedKey, it will be null.

♻️ Re-fetch session after mutation
         if (adminPubkey != null && adminPubkey.isNotEmpty) {
           final sessionNotifier = ref.read(sessionNotifierProvider.notifier);
+          final fetchedSession = sessionNotifier.getSessionByOrderId(orderId);
+          if (fetchedSession == null) {
+            logger.e('Session not found for order $orderId in adminTookDispute');
+            break;
+          }
+          session = fetchedSession;
           await sessionNotifier.updateSession(
               orderId, (s) => s.setAdminPeer(adminPubkey!));
+          // Re-fetch to get the updated session with admin shared key
+          final updatedSession = sessionNotifier.getSessionByOrderId(orderId);
+          if (updatedSession != null) {
+            session = updatedSession;
+          }
           logger.i(
               'Admin shared key computed and persisted for order $orderId');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/order/notfiers/abstract_mostro_notifier.dart` around lines 502 -
528, The handler updates the session via
sessionNotifierProvider.notifier.updateSession(...) but does not refresh
this.session, leaving this.session.adminSharedKey stale; after the await
sessionNotifier.updateSession(orderId, (s) => s.setAdminPeer(adminPubkey!)),
re-fetch and assign the updated session into this.session (e.g., read the
session state from sessionNotifierProvider) so subsequent access to
this.session.adminSharedKey reflects the newly persisted adminPubkey.
lib/features/disputes/notifiers/dispute_chat_notifier.dart (1)

480-487: Fire-and-forget initialize() in provider factory — unhandled-future risk.

notifier.initialize() is async but the returned Future is neither awaited nor wrapped in unawaited(). If the future rejects before the internal try-catch (e.g., a thrown error in _loadHistoricalMessages propagating past the catch), it becomes an unhandled future. Dart's linter (unawaited_futures / discarded_futures) will flag this.

♻️ Wrap in unawaited to silence the lint
   (ref, disputeId) {
     final notifier = DisputeChatNotifier(disputeId, ref);
-    notifier.initialize();
+    unawaited(notifier.initialize());
     return notifier;
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart` around lines 480
- 487, The provider factory is calling the async method
DisputeChatNotifier.initialize() in a fire-and-forget manner which can create
unhandled futures; wrap that call with unawaited(...) to explicitly discard the
Future and satisfy the linter (or make the factory async and await the call if
you need initialization to complete before returning). Specifically, update the
disputeChatNotifierProvider factory to call unawaited(notifier.initialize()) and
add the appropriate import for unawaited (e.g., package:pedantic or
package:async) so the linter no longer flags the discarded Future; reference
DisputeChatNotifier.initialize and any internal helpers like
_loadHistoricalMessages when making the change.
lib/data/models/session.dart (1)

156-163: Missing type validation for admin_peer in fromJson.

Other optional fields like peer (Line 138) and parent_order_id (Line 149) include an else if (value is! String) branch that throws a FormatException on invalid types. The admin_peer parsing silently ignores non-string values, which is inconsistent.

♻️ Suggested fix for consistency
       String? adminPeer;
       final adminPeerValue = json['admin_peer'];
       if (adminPeerValue != null) {
         if (adminPeerValue is String && adminPeerValue.isNotEmpty) {
           adminPeer = adminPeerValue;
         }
+        else if (adminPeerValue is! String) {
+          throw FormatException(
+            'Invalid admin_peer type: ${adminPeerValue.runtimeType}',
+          );
+        }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/models/session.dart` around lines 156 - 163, The fromJson parsing
for admin_peer is currently skipping non-string values; update the fromJson (in
the Session model) to validate adminPeerValue like the other fields: if
adminPeerValue is a non-empty String assign it to adminPeer, else if
adminPeerValue is not null and not a String throw a FormatException (mentioning
'admin_peer' and the offending type) so behavior matches peer and
parent_order_id validation; adjust the adminPeer/adminPeerValue handling in the
Session.fromJson method accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@lib/data/models/session.dart`:
- Around line 156-163: The fromJson parsing for admin_peer is currently skipping
non-string values; update the fromJson (in the Session model) to validate
adminPeerValue like the other fields: if adminPeerValue is a non-empty String
assign it to adminPeer, else if adminPeerValue is not null and not a String
throw a FormatException (mentioning 'admin_peer' and the offending type) so
behavior matches peer and parent_order_id validation; adjust the
adminPeer/adminPeerValue handling in the Session.fromJson method accordingly.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart`:
- Around line 480-487: The provider factory is calling the async method
DisputeChatNotifier.initialize() in a fire-and-forget manner which can create
unhandled futures; wrap that call with unawaited(...) to explicitly discard the
Future and satisfy the linter (or make the factory async and await the call if
you need initialization to complete before returning). Specifically, update the
disputeChatNotifierProvider factory to call unawaited(notifier.initialize()) and
add the appropriate import for unawaited (e.g., package:pedantic or
package:async) so the linter no longer flags the discarded Future; reference
DisputeChatNotifier.initialize and any internal helpers like
_loadHistoricalMessages when making the change.

In `@lib/features/order/notfiers/abstract_mostro_notifier.dart`:
- Around line 502-528: The handler updates the session via
sessionNotifierProvider.notifier.updateSession(...) but does not refresh
this.session, leaving this.session.adminSharedKey stale; after the await
sessionNotifier.updateSession(orderId, (s) => s.setAdminPeer(adminPubkey!)),
re-fetch and assign the updated session into this.session (e.g., read the
session state from sessionNotifierProvider) so subsequent access to
this.session.adminSharedKey reflects the newly persisted adminPubkey.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 149010c and 0f6fd96.

📒 Files selected for processing (7)
  • docs/DISPUTE_CHAT_MULTIMEDIA_PLAN.md
  • lib/data/models/session.dart
  • lib/features/disputes/notifiers/dispute_chat_notifier.dart
  • lib/features/order/notfiers/abstract_mostro_notifier.dart
  • lib/services/mostro_service.dart
  • test/data/models/nostr_event_wrap_test.dart
  • test/features/disputes/dispute_shared_key_test.dart
💤 Files with no reviewable changes (1)
  • lib/services/mostro_service.dart

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/features/disputes/notifiers/dispute_chat_notifier.dart (2)

179-193: ⚠️ Potential issue | 🟡 Minor

admin_pubkey is never persisted, so DisputeChat.adminPubkey is always null after reload.

_onChatEvent stores the sender's pubkey under the key 'pubkey' (line 186) but never writes 'admin_pubkey'. However, _loadHistoricalMessages reads eventData['admin_pubkey'] as String? (line 266), which is always null from storage. DisputeChat.adminPubkey is correctly populated in memory (line 202) but silently lost after an app restart.

🐛 Proposed fix — persist the field when it's from the admin
       'is_from_user': !isFromAdmin,
       'isPending': false,
+      if (isFromAdmin) 'admin_pubkey': senderPubkey,
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart` around lines 179
- 193, The stored event map written in _onChatEvent via eventStore.putItem omits
the 'admin_pubkey' field, so DisputeChat.adminPubkey (read in
_loadHistoricalMessages) is null after reload; update the payload passed to
eventStore.putItem (in the _onChatEvent code path where you build the map and
call eventStore.putItem/eventId) to include 'admin_pubkey': isFromAdmin ?
senderPubkey : null (or omit when null), ensuring that the persisted key name
matches what _loadHistoricalMessages expects, so DisputeChat.adminPubkey is
restored on reload.

480-487: ⚠️ Potential issue | 🟡 Minor

Errors from _subscribe() inside initialize() are silently swallowed by unawaited.

_loadHistoricalMessages properly routes failures into state.copyWith(error:), but _subscribe() has no equivalent guard. If subscribeToEvents throws, the error escapes _subscribe(), propagates through initialize(), and is discarded by unawaited. The user sees a blank chat with no error message and _isInitialized stays false.

🛡️ Proposed fix — catch subscribe errors inside `initialize()`
 Future<void> initialize() async {
   if (_isInitialized) return;
   logger.i('Initializing dispute chat for disputeId: $disputeId');
   await _loadHistoricalMessages();
-  await _subscribe();
+  try {
+    await _subscribe();
+  } catch (e, st) {
+    logger.e('Failed to subscribe for dispute $disputeId: $e', stackTrace: st);
+    state = state.copyWith(error: 'Subscription failed: $e');
+  }
   _isInitialized = true;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart` around lines 480
- 487, The initialize() flow in DisputeChatNotifier (created by
disputeChatNotifierProvider) currently unawaits the async initialize so
exceptions from _subscribe() are swallowed; wrap the call to
_subscribe()/subscribeToEvents inside initialize() with a try/catch that
captures any thrown error and updates state via state.copyWith(error: <wrapped
error>) (similar to _loadHistoricalMessages), optionally log the error, and
ensure _isInitialized remains false (or set to false explicitly) so the UI shows
an error instead of a silent blank chat; reference
DisputeChatNotifier.initialize(), DisputeChatNotifier._subscribe(),
subscribeToEvents, and state.copyWith(error:) when making the change.
🧹 Nitpick comments (1)
lib/data/models/session.dart (1)

35-35: Rename adminPeer constructor parameter to adminPubkey for consistency.

The sibling peer parameter takes a Peer? object, so adminPeer implies the same type. Everywhere else — the private field (_adminPubkey), the getter (adminPubkey), and the method (setAdminPeer(String adminPubkey)) — the value is correctly named as a pubkey. The constructor parameter should match.

♻️ Proposed rename
-    String? adminPeer,
+    String? adminPubkey,
-    if (adminPeer != null) {
-      setAdminPeer(adminPeer);
+    if (adminPubkey != null) {
+      setAdminPeer(adminPubkey);
     }

Also update the fromJson call site:

-        adminPeer: adminPeer,
+        adminPubkey: adminPeer,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/data/models/session.dart` at line 35, Rename the Session constructor
parameter adminPeer to adminPubkey to match the existing private field
_adminPubkey, the getter adminPubkey, and the setter setAdminPeer(String
adminPubkey); update all constructor callers including the fromJson call site to
pass adminPubkey instead of adminPeer so naming is consistent across Session,
_adminPubkey, adminPubkey, and setAdminPeer.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/data/models/session.dart`:
- Around line 192-198: The setAdminPeer method currently passes whatever string
it receives into NostrUtils.computeSharedKey which will throw on empty or
malformed keys; update setAdminPeer to validate adminPubkey before using it
(e.g., check adminPubkey.isNotEmpty and that it matches your expected pubkey
format/length) and return early or throw a clear error if invalid, ensuring
_adminPubkey and _adminSharedKey are only set when adminPubkey is valid;
reference setAdminPeer, _adminPubkey, _adminSharedKey,
NostrUtils.computeSharedKey and tradeKey.private when making the guard so
callers like the adminTookDispute handler no longer cause uncaught exceptions.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart`:
- Around line 163-173: The current admin-auth block in
dispute_chat_notifier.dart uses await
ref.read(disputeDetailsProvider(disputeId).future) and returns early if
dispute?.adminPubkey == null, which can silently drop legitimate admin messages
during a loading race; change the logic in the isFromAdmin branch (the
disputeDetailsProvider(disputeId) check around disputeId, senderPubkey,
adminPubkey) to either (a) defer the security decision until the provider
settles by waiting/retrying a short period for a non-null dispute.adminPubkey,
or (b) accept the message temporarily and queue/mark it for re-verification once
disputeDetailsProvider resolves, and in the meantime emit a structured
logger.warn that includes disputeId and senderPubkey so drops are diagnosable;
ensure you keep the existing unauthorized check (senderPubkey !=
dispute.adminPubkey) once dispute data is confirmed.

---

Outside diff comments:
In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart`:
- Around line 179-193: The stored event map written in _onChatEvent via
eventStore.putItem omits the 'admin_pubkey' field, so DisputeChat.adminPubkey
(read in _loadHistoricalMessages) is null after reload; update the payload
passed to eventStore.putItem (in the _onChatEvent code path where you build the
map and call eventStore.putItem/eventId) to include 'admin_pubkey': isFromAdmin
? senderPubkey : null (or omit when null), ensuring that the persisted key name
matches what _loadHistoricalMessages expects, so DisputeChat.adminPubkey is
restored on reload.
- Around line 480-487: The initialize() flow in DisputeChatNotifier (created by
disputeChatNotifierProvider) currently unawaits the async initialize so
exceptions from _subscribe() are swallowed; wrap the call to
_subscribe()/subscribeToEvents inside initialize() with a try/catch that
captures any thrown error and updates state via state.copyWith(error: <wrapped
error>) (similar to _loadHistoricalMessages), optionally log the error, and
ensure _isInitialized remains false (or set to false explicitly) so the UI shows
an error instead of a silent blank chat; reference
DisputeChatNotifier.initialize(), DisputeChatNotifier._subscribe(),
subscribeToEvents, and state.copyWith(error:) when making the change.

---

Nitpick comments:
In `@lib/data/models/session.dart`:
- Line 35: Rename the Session constructor parameter adminPeer to adminPubkey to
match the existing private field _adminPubkey, the getter adminPubkey, and the
setter setAdminPeer(String adminPubkey); update all constructor callers
including the fromJson call site to pass adminPubkey instead of adminPeer so
naming is consistent across Session, _adminPubkey, adminPubkey, and
setAdminPeer.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0f6fd96 and bd77986.

📒 Files selected for processing (3)
  • lib/data/models/session.dart
  • lib/features/disputes/notifiers/dispute_chat_notifier.dart
  • lib/features/order/notfiers/abstract_mostro_notifier.dart
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/features/order/notfiers/abstract_mostro_notifier.dart

  - Rename adminPeer param to adminPubkey for naming
  consistency
  - Add validation guard in setAdminPeer() for
  empty/invalid pubkeys
  - Re-fetch session after updateSession in
  adminTookDispute handler
  - Wrap initialize() with unawaited() in dispute chat
  provider
  - Improve admin message rejection logs with eventId
  context
  - Add type validation for admin_peer in Session.fromJson
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
lib/features/disputes/notifiers/dispute_chat_notifier.dart (1)

143-180: Deduplicate using the unwrapped event id.

You store events under the unwrapped id, but the pre-check uses the wrapper id. This makes the duplicate guard ineffective when the same inner event arrives from multiple relays.

♻️ Suggested tweak
-      // Check for duplicate events
-      final eventStore = ref.read(eventStorageProvider);
-      if (await eventStore.hasItem(event.id!)) {
-        return;
-      }
-
-      // Unwrap using admin shared key (1-layer p2p decryption)
-      final unwrappedEvent = await event.p2pUnwrap(session.adminSharedKey!);
+      final eventStore = ref.read(eventStorageProvider);
+
+      // Unwrap using admin shared key (1-layer p2p decryption)
+      final unwrappedEvent = await event.p2pUnwrap(session.adminSharedKey!);
@@
       final messageText = unwrappedEvent.content ?? '';
       if (messageText.isEmpty) {
         logger.w('Received empty message, skipping');
         return;
       }
+
+      final eventId = unwrappedEvent.id ??
+          event.id ??
+          'chat_${DateTime.now().millisecondsSinceEpoch}_${messageText.hashCode}';
+      if (await eventStore.hasItem(eventId)) {
+        return;
+      }
@@
-      final eventId = unwrappedEvent.id ?? event.id ?? 'chat_${DateTime.now().millisecondsSinceEpoch}_${messageText.hashCode}';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart` around lines 143
- 180, The duplicate-check currently uses the wrapper id (event.id) so
duplicates slip through when the same inner event arrives from other relays;
move or change the dedupe to use the unwrapped id: after calling
event.p2pUnwrap(session.adminSharedKey!) (p2pUnwrap) compute the canonical id
(use unwrappedEvent.id if non-null, else fall back to event.id or the existing
generated eventId) and call eventStore.hasItem(...) (eventStore from
eventStorageProvider) with that canonical id; ensure the same canonical id is
used later when storing the event so the guard is effective.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart`:
- Around line 143-180: The duplicate-check currently uses the wrapper id
(event.id) so duplicates slip through when the same inner event arrives from
other relays; move or change the dedupe to use the unwrapped id: after calling
event.p2pUnwrap(session.adminSharedKey!) (p2pUnwrap) compute the canonical id
(use unwrappedEvent.id if non-null, else fall back to event.id or the existing
generated eventId) and call eventStore.hasItem(...) (eventStore from
eventStorageProvider) with that canonical id; ensure the same canonical id is
used later when storing the event so the guard is effective.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bd77986 and e619f87.

📒 Files selected for processing (3)
  • lib/data/models/session.dart
  • lib/features/disputes/notifiers/dispute_chat_notifier.dart
  • test/features/disputes/dispute_shared_key_test.dart

@Catrya Catrya marked this pull request as draft February 23, 2026 19:34
@Catrya Catrya marked this pull request as ready for review February 24, 2026 05:46
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
lib/features/disputes/notifiers/dispute_chat_notifier.dart (1)

95-96: Add an onError handler to the stream subscription.

Without an onError callback, any error emitted by the underlying relay stream (e.g., relay disconnect, malformed frame) propagates to the ambient Zone unhandled, bypassing the _onChatEvent try/catch entirely.

♻️ Proposed fix
-    _subscription = nostrService.subscribeToEvents(request).listen(_onChatEvent);
+    _subscription = nostrService.subscribeToEvents(request).listen(
+      _onChatEvent,
+      onError: (Object e, StackTrace st) =>
+          logger.e('Stream error in dispute chat subscription: $e', stackTrace: st),
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart` around lines 95 -
96, The stream subscription created by calling
nostrService.subscribeToEvents(request).listen(_onChatEvent) needs an onError
handler so relay errors don't escape the Zone; update the listen call on the
_subscription assignment to provide an onError (and optional onDone) callback
that catches/logs the error (use logger.e with context "disputeId" and the
error/stackTrace), and perform appropriate cleanup or reconnection (e.g., cancel
_subscription or invoke your reconnect logic) so errors are handled inside the
DisputeChatNotifier rather than propagating unhandled; reference the existing
symbols _subscription, nostrService.subscribeToEvents, and _onChatEvent when
making this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart`:
- Around line 250-261: The security check in dispute_chat_notifier.dart
incorrectly allows null messagePubkey to bypass admin authorization; update the
branch inside the if (!isFromUser) block so that messages with a null pubkey are
treated as unauthorized by changing the check to reject when messagePubkey is
null OR does not equal dispute!.adminPubkey (i.e., if (messagePubkey == null ||
messagePubkey != dispute!.adminPubkey) { logger.w(...); filteredCount++;
continue; }), keeping the existing early null-dispute check
(dispute?.adminPubkey == null) intact.
- Around line 309-322: Move the NostrEvent.fromPartialData call and the
rumor.id/rumorTimestamp extraction inside the existing try block so any
exceptions are caught and handled; specifically, wrap the creation of the
`rumor` (NostrEvent.fromPartialData(...)), the assignment `final rumorId =
rumor.id`, and `final rumorTimestamp = rumor.createdAt ?? DateTime.now()` inside
the try, and add a null-check for `rumor.id` (avoid `!`) to set `state.error`
and add the pending message on failure; update code paths that reference
`rumorId` to account for the null-check so the catch block reliably sets
`state.error` and updates pending state when event construction fails.
- Around line 144-147: Replace the force-unwrap usage of event.id in the hasItem
call with an explicit null guard: compute or read the nullable id first (e.g.
local variable referencing event.id), if id is null return/skip early, then call
eventStore.hasItem(id); this keeps behavior consistent with the later null-aware
usage (unwrappedEvent.id ?? event.id ?? 'chat_...') and avoids relying on
exceptions for control flow; look for the hasItem call and the unwrappedEvent.id
usage to update both places accordingly.

---

Nitpick comments:
In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart`:
- Around line 95-96: The stream subscription created by calling
nostrService.subscribeToEvents(request).listen(_onChatEvent) needs an onError
handler so relay errors don't escape the Zone; update the listen call on the
_subscription assignment to provide an onError (and optional onDone) callback
that catches/logs the error (use logger.e with context "disputeId" and the
error/stackTrace), and perform appropriate cleanup or reconnection (e.g., cancel
_subscription or invoke your reconnect logic) so errors are handled inside the
DisputeChatNotifier rather than propagating unhandled; reference the existing
symbols _subscription, nostrService.subscribeToEvents, and _onChatEvent when
making this change.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e619f87 and 7b611c1.

📒 Files selected for processing (1)
  • lib/features/disputes/notifiers/dispute_chat_notifier.dart

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
lib/features/disputes/notifiers/dispute_chat_notifier.dart (4)

265-275: ⚠️ Potential issue | 🟡 Minor

Storage key mismatch: admin_pubkey is read but never written.

Line 272 reads eventData['admin_pubkey'], but the storage at line 192 writes the admin's pubkey under the key 'pubkey'. This means DisputeChat.adminPubkey will always be null for historically loaded messages, even though the security check at line 250 correctly reads 'pubkey'.

Proposed fix
           messages.add(DisputeChat(
             id: eventData['id'] as String,
             message: eventData['content'] as String? ?? '',
             timestamp: DateTime.fromMillisecondsSinceEpoch(
               (eventData['created_at'] as int) * 1000,
             ),
             isFromUser: isFromUser,
-            adminPubkey: eventData['admin_pubkey'] as String?,
+            adminPubkey: !isFromUser ? (eventData['pubkey'] as String?) : null,
             isPending: eventData['isPending'] as bool? ?? false,
             error: eventData['error'] as String?,
           ));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart` around lines 265
- 275, The code constructs DisputeChat entries using eventData['admin_pubkey']
but the stored admin key is written under 'pubkey', causing adminPubkey to be
null for historical messages; update the reader in the message assembly (where
DisputeChat is created) to use eventData['pubkey'] (or make the writer use
'admin_pubkey' consistently) so that DisputeChat.adminPubkey and the security
check that reads 'pubkey' reference the same storage key.

367-370: ⚠️ Potential issue | 🟡 Minor

Raw exception details leak into state.error which may be user-facing.

Lines 369, 433 set state.error to strings like 'Failed to send message: $publishError' and 'Failed to send message: $e'. If this is rendered in the UI, raw exception details (stack info, internal errors) are exposed. Per project convention, keep technical details in logger.e() and use a generic user-facing message.

Proposed fix (apply similarly at line 433)
         state = state.copyWith(
           messages: updatedMessages,
-          error: 'Failed to send message: $publishError',
+          error: 'Failed to send message',
         );

Based on learnings: "do not expose raw exception messages (e.g., e.toString()) in the UI via showError() calls. The team prefers to keep technical error details out of the user interface for security and UX reasons."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart` around lines 367
- 370, The state currently embeds raw exception details (e.g., using
publishError or e) into state.error (see the state.copyWith that sets messages:
updatedMessages, error: 'Failed to send message: $publishError' and the similar
assignment at line 433) which may be surfaced to users; instead log the full
error via the notifier's logger (e.g., logger.e(..., error: publishError)) and
set state.error to a generic, user-facing string like "Failed to send message"
(no exception text); apply the same change at both occurrences (the
state.copyWith that uses publishError and the later one that uses e) so UI gets
only the generic message while technical details remain in logs.

143-200: ⚠️ Potential issue | 🔴 Critical

Dedup check is broken: wrapper event ID vs. unwrapped event ID mismatch.

Line 147 checks eventStore.hasItem(wrapperEventId) where wrapperEventId is event.id (the kind-1059 Gift Wrap ID). But line 186 stores the record under eventId (the unwrapped rumor ID from line 181). These are different IDs — the wrapper is never stored under its own ID, so the dedup check will never match and duplicate deliveries from relays will produce duplicate chat messages.

Proposed fix — also store the wrapper ID for dedup
       // Store the event
+      // Store wrapper event ID for deduplication
+      await eventStore.putItem(wrapperEventId, {'wrapper_for': eventId});
+
       await eventStore.putItem(
         eventId,
         {
           'id': eventId,

Alternatively, you could use wrapperEventId as the primary storage key if the unwrapped event ID isn't needed for other lookups.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart` around lines 143
- 200, The dedup check uses wrapperEventId (event.id) but you store the
unwrapped event under eventId (unwrappedEvent.id), so duplicates slip through;
fix by ensuring the wrapper ID is recorded for dedup: after computing eventId
(or instead of it) write the same stored record under wrapperEventId (or store a
separate mapping from wrapperEventId -> eventId) so
eventStore.hasItem(wrapperEventId) will hit; update the eventStore.putItem call
(and any metadata) to include wrapperEventId, referencing wrapperEventId,
eventId, eventStore.hasItem and eventStore.putItem (or alternatively switch to
always using wrapperEventId as the primary storage key if unwrappedEvent.id is
not required).

27-37: ⚠️ Potential issue | 🟠 Major

Bug: copyWith cannot clear error back to null.

error: error ?? this.error means calling copyWith(error: null) preserves the previous error. Line 345 relies on copyWith(error: null) to clear errors when the user sends a new message — this silently fails, leaving stale error text in state.

A common Dart pattern for nullable copyWith fields uses a sentinel or a wrapper:

Proposed fix
   DisputeChatState copyWith({
     List<DisputeChat>? messages,
     bool? isLoading,
-    String? error,
+    Object? error = _sentinel,
   }) {
     return DisputeChatState(
       messages: messages ?? this.messages,
       isLoading: isLoading ?? this.isLoading,
-      error: error ?? this.error,
+      error: error == _sentinel ? this.error : error as String?,
     );
   }
 }
+
+const Object _sentinel = Object();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart` around lines 27 -
37, The copyWith currently uses `error: error ?? this.error` so passing
`copyWith(error: null)` cannot clear the error; modify DisputeChatState.copyWith
to accept a sentinel for nullable fields (e.g., change the parameter signature
to use Object? error = _noChange with a private const _noChange = Object()) and
then set the returned `error` to `error == _noChange ? this.error : error as
String?` so callers can pass `error: null` to clear it; apply the same pattern
for any other nullable fields you need to explicitly clear and keep referencing
DisputeChatState.copyWith, the `error` parameter, and the `_noChange` sentinel
in your change.
♻️ Duplicate comments (1)
lib/features/disputes/notifiers/dispute_chat_notifier.dart (1)

311-329: Rumor construction still outside try — unhandled throw from fromPartialData.

The null-check on rumorId (lines 321-326) addresses the NPE risk from the prior review, but NostrEvent.fromPartialData itself can throw (e.g., invalid key material), and that exception escapes the try block starting at line 329. The user gets no error feedback and the message silently disappears.

Move the rumor construction inside the try block:

Proposed fix
+    try {
       // Create rumor (kind 1) with plain text content FIRST to get real event ID
       final rumor = NostrEvent.fromPartialData(
         keyPairs: session.tradeKey,
         content: text,
         kind: 1,
         tags: [
           ["p", session.adminSharedKey!.public],
         ],
       );

       final rumorId = rumor.id;
       if (rumorId == null) {
         logger.e('Failed to compute rumor ID for dispute: $disputeId');
         state = state.copyWith(error: 'Failed to prepare message');
         return;
       }
       final rumorTimestamp = rumor.createdAt ?? DateTime.now();

-    try {
       logger.i('Sending p2pWrap DM to admin via shared key for dispute: $disputeId');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart` around lines 311
- 329, The construction of rumor via NostrEvent.fromPartialData is currently
outside the try block and can throw (e.g., invalid key material), so move the
entire rumor creation (the call to NostrEvent.fromPartialData and the subsequent
checks for rumor.id and rumor.createdAt) inside the existing try block that
begins after rumorTimestamp; ensure error handling uses the same
logger.e('Failed to compute rumor ID for dispute: $disputeId') and state =
state.copyWith(error: 'Failed to prepare message') path when fromPartialData
throws or when rumor.id is null so exceptions are caught and the user gets
proper feedback.
🧹 Nitpick comments (1)
lib/features/disputes/notifiers/dispute_chat_notifier.dart (1)

356-456: Consider extracting the duplicated error-handling logic.

The inner publish-failure handler (lines 362-392) and the outer catch (lines 418-456) follow nearly identical patterns: mark the pending message as failed, update state, and persist error metadata. A small helper like _handleSendFailure(rumorId, text, rumorTimestamp, error) would eliminate the duplication and make both paths easier to maintain.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart` around lines 356
- 456, The publish-failure block and the outer catch in
dispute_chat_notifier.dart duplicate logic for marking a message failed,
updating state, and persisting error metadata; extract this into a helper (e.g.,
_handleSendFailure) that takes (String rumorId, String text, DateTime
rumorTimestamp, Object error, {String? pubkey, int? kind, DisputeChat?
fallbackMessage}) and centralizes: build the failed DisputeChat (using
DisputeChat fallback when needed), update state.messages, set state.error, and
write the same eventStore.putItem payload (including error, isPending=false,
dispute_id, etc.); then replace both the inner catch (publishError) and the
outer catch (e) to call this helper and remove the duplicated code paths,
preserving existing fields like 'sig', 'tags', 'kind', and 'pubkey' via optional
args.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart`:
- Around line 265-275: The code constructs DisputeChat entries using
eventData['admin_pubkey'] but the stored admin key is written under 'pubkey',
causing adminPubkey to be null for historical messages; update the reader in the
message assembly (where DisputeChat is created) to use eventData['pubkey'] (or
make the writer use 'admin_pubkey' consistently) so that DisputeChat.adminPubkey
and the security check that reads 'pubkey' reference the same storage key.
- Around line 367-370: The state currently embeds raw exception details (e.g.,
using publishError or e) into state.error (see the state.copyWith that sets
messages: updatedMessages, error: 'Failed to send message: $publishError' and
the similar assignment at line 433) which may be surfaced to users; instead log
the full error via the notifier's logger (e.g., logger.e(..., error:
publishError)) and set state.error to a generic, user-facing string like "Failed
to send message" (no exception text); apply the same change at both occurrences
(the state.copyWith that uses publishError and the later one that uses e) so UI
gets only the generic message while technical details remain in logs.
- Around line 143-200: The dedup check uses wrapperEventId (event.id) but you
store the unwrapped event under eventId (unwrappedEvent.id), so duplicates slip
through; fix by ensuring the wrapper ID is recorded for dedup: after computing
eventId (or instead of it) write the same stored record under wrapperEventId (or
store a separate mapping from wrapperEventId -> eventId) so
eventStore.hasItem(wrapperEventId) will hit; update the eventStore.putItem call
(and any metadata) to include wrapperEventId, referencing wrapperEventId,
eventId, eventStore.hasItem and eventStore.putItem (or alternatively switch to
always using wrapperEventId as the primary storage key if unwrappedEvent.id is
not required).
- Around line 27-37: The copyWith currently uses `error: error ?? this.error` so
passing `copyWith(error: null)` cannot clear the error; modify
DisputeChatState.copyWith to accept a sentinel for nullable fields (e.g., change
the parameter signature to use Object? error = _noChange with a private const
_noChange = Object()) and then set the returned `error` to `error == _noChange ?
this.error : error as String?` so callers can pass `error: null` to clear it;
apply the same pattern for any other nullable fields you need to explicitly
clear and keep referencing DisputeChatState.copyWith, the `error` parameter, and
the `_noChange` sentinel in your change.

---

Duplicate comments:
In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart`:
- Around line 311-329: The construction of rumor via NostrEvent.fromPartialData
is currently outside the try block and can throw (e.g., invalid key material),
so move the entire rumor creation (the call to NostrEvent.fromPartialData and
the subsequent checks for rumor.id and rumor.createdAt) inside the existing try
block that begins after rumorTimestamp; ensure error handling uses the same
logger.e('Failed to compute rumor ID for dispute: $disputeId') and state =
state.copyWith(error: 'Failed to prepare message') path when fromPartialData
throws or when rumor.id is null so exceptions are caught and the user gets
proper feedback.

---

Nitpick comments:
In `@lib/features/disputes/notifiers/dispute_chat_notifier.dart`:
- Around line 356-456: The publish-failure block and the outer catch in
dispute_chat_notifier.dart duplicate logic for marking a message failed,
updating state, and persisting error metadata; extract this into a helper (e.g.,
_handleSendFailure) that takes (String rumorId, String text, DateTime
rumorTimestamp, Object error, {String? pubkey, int? kind, DisputeChat?
fallbackMessage}) and centralizes: build the failed DisputeChat (using
DisputeChat fallback when needed), update state.messages, set state.error, and
write the same eventStore.putItem payload (including error, isPending=false,
dispute_id, etc.); then replace both the inner catch (publishError) and the
outer catch (e) to call this helper and remove the duplicated code paths,
preserving existing fields like 'sig', 'tags', 'kind', and 'pubkey' via optional
args.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7b611c1 and 43991c9.

📒 Files selected for processing (1)
  • lib/features/disputes/notifiers/dispute_chat_notifier.dart

@Catrya Catrya changed the title feat: migrate dispute chat to shared key encryption feat: migrate dispute chat to shared key encryption Phase 1 Feb 24, 2026
Copy link
Contributor

@mostronatorcoder mostronatorcoder bot left a comment

Choose a reason for hiding this comment

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

Solid architectural change — migrating from mostroWrap/mostroUnWrap (2-layer NIP-59) to p2pWrap/p2pUnwrap (1-layer ECDH shared key) for dispute chat is the right call. The code is significantly cleaner than the old JSON parsing mess. Tests are good. CodeRabbit issues were addressed. But I found some issues that need fixing:

Must Fix

1. Race condition: admin messages permanently lost (SECURITY)

In _onChatEvent (line ~170), when an admin message arrives but disputeDetailsProvider hasn't resolved yet (race with adminTookDispute), the message is silently dropped and never re-delivered. The Nostr subscription won't replay it.

CodeRabbit flagged this too and it was marked "addressed" in commit e619f87, but looking at the current code, it only improved the log message — the message is still permanently dropped. This is a real bug: adminTookDispute computes the shared key → subscription starts → first admin message arrives → but disputeDetailsProvider cache still has adminPubkey: null → message dropped.

Fix options:

  • Queue the message and re-process after disputeDetailsProvider refreshes
  • Accept the message when adminSharedKey is set (the ECDH itself is the auth — if you can decrypt it, it came from the admin)
  • At minimum: store the event and replay on next _loadHistoricalMessages

This is the most critical issue.

2. setAdminPeer throws but adminTookDispute handler doesn't catch

In abstract_mostro_notifier.dart line ~510, setAdminPeer is called inside updateSession callback. setAdminPeer now throws ArgumentError on invalid input (good validation added in the fix commit). But the adminTookDispute handler has no try/catch around it — an invalid admin pubkey from the protocol would crash the entire order processing flow.

// This can throw ArgumentError:
await sessionNotifier.updateSession(
    orderId, (s) => s.setAdminPeer(adminPubkey!));

Wrap it in try/catch and log the error gracefully.

3. Commit messages: typo and low quality

Two commits are just "coderabbit suggestions" and "coderabbit suggetsions" (with typo). Per repo AGENTS.md: "Write concise, imperative commits, optionally prefixed with a scope."

Not a blocker but worth noting for future PRs.

Minor / Nice to have

4. _isNearBottom threshold is arbitrary

_nearBottomThreshold = 150.0 pixels — this varies dramatically across devices. On a small phone 150px could be half the chat. Consider making it a percentage of viewport height, or at least document why 150.

5. _getSessionForDispute swallows all info

The method was simplified (removed logging), but now when session lookup fails there's zero diagnostic info. The old code had useful logs. Consider keeping at least one logger.w for the not-found case — disputes are a critical flow and debugging blind is painful.

What looks good

  • ✅ CI passes, no conflicts
  • ✅ 11 tests covering shared key computation, serialization round-trip, p2pWrap/p2pUnwrap, and wrong-key rejection
  • ✅ Security: p-tag verification, sender pubkey validation, dedup via eventStore.hasItem
  • ✅ Optimistic UI with real rumor ID for dedup
  • ✅ Clean removal of the old JSON parsing logic (dm/MostroMessage format)
  • _normalizeInitiator-style validation in setAdminPeer (length check)
  • ✅ The unawaited(notifier.initialize()) fix for the provider

Please address #1 and #2. The rest are suggestions.

  - Remove redundant disputeDetailsProvider check — ECDH is sufficient auth
  - Wrap setAdminPeer() in try/catch to handle malformed admin pubkeys
  - Add diagnostic logging when session lookup fails for dispute
@Catrya
Copy link
Member Author

Catrya commented Feb 25, 2026

@mostronatorcoder review

Copy link
Contributor

@mostronatorcoder mostronatorcoder bot left a comment

Choose a reason for hiding this comment

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

All three issues from my previous review are resolved in f18c040:

  1. Race condition fixed — Removed the redundant disputeDetailsProvider check. The ECDH shared key IS the authentication: if p2pUnwrap succeeds, the sender holds the admin's private key. Clean and correct.
  2. setAdminPeer now wrapped in try/catch — Malformed admin pubkeys are logged gracefully instead of crashing the order flow.
  3. Diagnostic logging restoredlogger.w added back for failed session lookups.

Also good: historical message loading no longer re-filters by admin pubkey (they were already authenticated at receive time via ECDH). Simplifies the code and eliminates another race window.

CI ✅ | No conflicts ✅

Approved.

@mostronatorcoder
Copy link
Contributor

@Catrya All three issues resolved ✅ — approved.

Copy link
Member

@grunch grunch left a comment

Choose a reason for hiding this comment

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

LGTM

@grunch grunch merged commit 5e8b542 into main Feb 25, 2026
2 of 3 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