From 9efec1004411b0cdfa8886c836a0ba70d88ba697 Mon Sep 17 00:00:00 2001 From: BRACR10 Date: Thu, 23 Apr 2026 07:53:08 -0600 Subject: [PATCH 1/6] refactor: simplify dispute initiation detection during restore - Remove `_determineIfUserInitiatedDispute` and its manual pubkey/index verification logic - Use the `initiator` role directly from `RestoredDispute` to determine if the user started the dispute - Streamline the restoration flow by relying on the explicit role provided in the restored dispute data --- lib/features/restore/restore_manager.dart | 63 ++--------------------- 1 file changed, 4 insertions(+), 59 deletions(-) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 4f04206f7..02f6c29c9 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -457,49 +457,6 @@ class RestoreService { } } - /// Determines if the user initiated the dispute with double verification - /// - /// Security checks: - /// 1. Verify session belongs to this order (compare pubkeys based on role) - /// 2. Compare trade_index to determine who initiated the dispute - /// - /// The dispute's trade_index indicates which party initiated it. - /// If it matches the user's session trade_index, the user initiated the dispute. - bool _determineIfUserInitiatedDispute({ - required RestoredDispute restoredDispute, - required Session session, - required Order order, - }) { - // Security verification: ensure session's trade pubkey matches order's pubkey for the role - final sessionPubkey = session.tradeKey.public; - final sessionRole = session.role; - - bool sessionMatchesOrder = false; - if (sessionRole == Role.buyer && order.buyerTradePubkey == sessionPubkey) { - sessionMatchesOrder = true; - } else if (sessionRole == Role.seller && - order.sellerTradePubkey == sessionPubkey) { - sessionMatchesOrder = true; - } - - if (!sessionMatchesOrder) { - logger.w( - 'Restore: session pubkey mismatch for order ${order.id} - ' - 'session role: $sessionRole, session pubkey: $sessionPubkey, ' - 'buyer pubkey: ${order.buyerTradePubkey}, seller pubkey: ${order.sellerTradePubkey}', - ); - // Default to peer-initiated if we can't verify session belongs to order - return false; - } - - // Compare trade indexes: if dispute trade_index matches user's session trade_index, - // then the user initiated the dispute - final userInitiated = restoredDispute.tradeIndex == session.keyIndex; - - // TODO: Improve dispute initiation detection if protocol changes in future - return userInitiated; - } - /// Maps Status to the appropriate Action for restored orders Action _getActionFromStatus(Status status, Role? userRole) { switch (status) { @@ -699,26 +656,14 @@ class RestoreService { Dispute? dispute; if (restoredDispute != null && order.status == Status.dispute) { - // This is a disputed order - determine who initiated final session = ref .read(sessionNotifierProvider.notifier) .getSessionByOrderId(orderDetail.id); - // We need the session to compare trade indexes - bool userInitiated = false; - if (session == null) { - logger.w( - 'Restore: no session found for disputed order ${orderDetail.id}, defaulting to peer-initiated', - ); - action = Action.disputeInitiatedByPeer; - } else { - // Determine if user initiated with double verification. - userInitiated = _determineIfUserInitiatedDispute( - restoredDispute: restoredDispute, - session: session, - order: order, - ); - } + final initiator = restoredDispute.initiator; + final bool userInitiated = + (initiator == 'buyer' && session?.role == Role.buyer) || + (initiator == 'seller' && session?.role == Role.seller); action = userInitiated ? Action.disputeInitiatedByYou From 84691cd1c22a38c0cddb882d95b442c37ae50bce Mon Sep 17 00:00:00 2001 From: BRACR10 Date: Thu, 23 Apr 2026 17:26:12 -0600 Subject: [PATCH 2/6] feat: include solver pubkey and dispute ID in restored sessions - Update `RestoredDispute` model to include the `solverPubkey` field from the restore response. - In `RestoreService`, look up dispute metadata for restored orders to populate the `adminPubkey` and `disputeId` in the session. - Ensures dispute chat functionality and admin identification are preserved after account restoration. --- lib/data/models/restore_response.dart | 4 ++++ lib/features/restore/restore_manager.dart | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/lib/data/models/restore_response.dart b/lib/data/models/restore_response.dart index 70cbe0a60..f7c185a94 100644 --- a/lib/data/models/restore_response.dart +++ b/lib/data/models/restore_response.dart @@ -66,6 +66,7 @@ class RestoredDispute { final int tradeIndex; final String status; final String? initiator; + final String? solverPubkey; RestoredDispute({ required this.disputeId, @@ -73,6 +74,7 @@ class RestoredDispute { required this.tradeIndex, required this.status, this.initiator, + this.solverPubkey, }); factory RestoredDispute.fromJson(Map json) { @@ -85,6 +87,7 @@ class RestoredDispute { tradeIndex: json['trade_index'] as int, status: json['status'] as String, initiator: normalizedInitiator, + solverPubkey: json['solver_pubkey'] as String?, ); } @@ -105,5 +108,6 @@ class RestoredDispute { 'trade_index': tradeIndex, 'status': status, if (initiator != null) 'initiator': initiator, + if (solverPubkey != null) 'solver_pubkey': solverPubkey, }; } diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 02f6c29c9..e59bb0934 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -584,6 +584,11 @@ class RestoreService { ); } + final restoredDispute = disputes.where((d) => d.orderId == orderId).firstOrNull; + final adminPubkey = restoredDispute?.solverPubkey?.isNotEmpty == true + ? restoredDispute!.solverPubkey + : null; + final session = Session( masterKey: _masterKey!, tradeKey: tradeKey, @@ -593,6 +598,8 @@ class RestoreService { orderId: orderDetail.id, role: role, peer: peer, + adminPubkey: adminPubkey, + disputeId: restoredDispute?.disputeId, ); // Store session From 08df9349c4c8f0578aac992982e3494fbe413639 Mon Sep 17 00:00:00 2001 From: BRACR10 Date: Thu, 23 Apr 2026 22:20:30 -0600 Subject: [PATCH 3/6] fix: invalidate dispute chat providers during restore - Collect existing dispute IDs before clearing storage during the restoration process. - Invalidate `disputeChatNotifierProvider` for each ID to cancel stale subscriptions and ensure a clean state before restoration. - Update cleanup logs to include the count of dispute chat providers cleared. --- lib/features/restore/restore_manager.dart | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index e59bb0934..a9b208b02 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -37,6 +37,8 @@ import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/notifications/providers/notifications_provider.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/features/disputes/notifiers/dispute_chat_notifier.dart'; + enum RestoreStage { gettingRestoreData, @@ -80,9 +82,14 @@ class RestoreService { try { logger.i('Restore: clearing all existing data before restore'); - // Get current chat orderIds BEFORE clearing + // Get current chat orderIds and disputesIds before clearing final currentChats = ref.read(chatRoomsNotifierProvider); final chatOrderIds = currentChats.map((c) => c.orderId).toList(); + final disputeIds = ref + .read(sessionNotifierProvider) + .where((s) => s.disputeId != null) + .map((s) => s.disputeId!) + .toList(); // Clear storage await ref.read(sessionNotifierProvider.notifier).reset(); @@ -98,7 +105,15 @@ class RestoreService { ref.invalidate(chatRoomInitializedProvider(orderId)); } - logger.i('Restore: cleared ${chatOrderIds.length} chat providers'); + // Invalidate dispute chat providers to cancel stale subscriptions + for (final disputeId in disputeIds) { + ref.invalidate(disputeChatNotifierProvider(disputeId)); + } + + logger.i( + 'Restore: cleared ${chatOrderIds.length} chat providers, ' + '${disputeIds.length} dispute chat providers', + ); } catch (e) { logger.w('Restore: cleanup error', error: e); } From a28d66ea26a7b7dbe7a6925cdcc165de2529b069 Mon Sep 17 00:00:00 2001 From: BRACR10 Date: Fri, 24 Apr 2026 00:29:04 -0600 Subject: [PATCH 4/6] fix: ensure safe state updates in restore progress notifier - Use `addPostFrameCallback` when updating state during the persistent callbacks phase to avoid "setState during build" errors. - Add `mounted` guards and prevent redundant transitions to already completed or error states. - Update `RestoreService` to set a success flag instead of manually triggering progress completion to prevent timing issues with the UI overlay. --- lib/features/restore/restore_manager.dart | 2 +- .../restore/restore_progress_notifier.dart | 54 ++++++++++++++----- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index a9b208b02..44a5bc03a 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -895,7 +895,7 @@ class RestoreService { ); final lastTradeIndex = lastTradeIndexResponse.tradeIndex; await keyManager.setCurrentKeyIndex(lastTradeIndex + 1); - progress.completeRestore(); + success = true; return true; } diff --git a/lib/features/restore/restore_progress_notifier.dart b/lib/features/restore/restore_progress_notifier.dart index 0139ae054..5a49d3ab9 100644 --- a/lib/features/restore/restore_progress_notifier.dart +++ b/lib/features/restore/restore_progress_notifier.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/features/restore/restore_progress_state.dart'; @@ -10,54 +12,74 @@ class RestoreProgressNotifier extends StateNotifier { RestoreProgressNotifier() : super(RestoreProgressState.initial()); + bool get _canUpdateState => mounted; + + void _setStateSafely(RestoreProgressState newState) { + if (!_canUpdateState) return; + + final schedulerPhase = WidgetsBinding.instance.schedulerPhase; + if (schedulerPhase == SchedulerPhase.persistentCallbacks) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_canUpdateState) { + state = newState; + } + }); + return; + } + + state = newState; + } + void startRestore() { logger.i('Starting restore overlay'); _cancelAutoHideTimer(); - state = RestoreProgressState.initial().copyWith( + _setStateSafely(RestoreProgressState.initial().copyWith( isVisible: true, step: RestoreStep.requesting, - ); + )); _startTimeoutTimer(); } void updateStep(RestoreStep step, {int? current, int? total}) { logger.i('Restore step: $step (${current ?? 0}/${total ?? 0})'); - state = state.copyWith( + _setStateSafely(state.copyWith( step: step, currentProgress: current ?? state.currentProgress, totalProgress: total ?? state.totalProgress, - ); + )); _resetTimeoutTimer(); } void setOrdersReceived(int count) { logger.i('Received $count orders'); - state = state.copyWith( + _setStateSafely(state.copyWith( step: RestoreStep.receivingOrders, totalProgress: count, currentProgress: 0, - ); + )); _resetTimeoutTimer(); } void incrementProgress() { - state = state.copyWith( + _setStateSafely(state.copyWith( currentProgress: state.currentProgress + 1, - ); + )); _resetTimeoutTimer(); } void completeRestore() { + if (!_canUpdateState || state.step == RestoreStep.completed) return; + logger.i('Restore completed successfully'); _cancelTimeoutTimer(); - state = state.copyWith( + _setStateSafely(state.copyWith( step: RestoreStep.completed, - ); + )); // Auto-hide after 3 seconds (cancellable) _cancelAutoHideTimer(); @@ -69,13 +91,15 @@ class RestoreProgressNotifier extends StateNotifier { } void showError(String message) { + if (!_canUpdateState || state.step == RestoreStep.error) return; + logger.w('Restore error: $message'); _cancelTimeoutTimer(); - state = state.copyWith( + _setStateSafely(state.copyWith( step: RestoreStep.error, errorMessage: message, - ); + )); // Auto-hide after 3 seconds (cancellable) _cancelAutoHideTimer(); @@ -87,10 +111,12 @@ class RestoreProgressNotifier extends StateNotifier { } void hide() { + if (!_canUpdateState) return; + logger.i('Hiding restore overlay'); _cancelTimeoutTimer(); _cancelAutoHideTimer(); - state = RestoreProgressState.initial(); + _setStateSafely(RestoreProgressState.initial()); } void _startTimeoutTimer() { @@ -130,4 +156,4 @@ class RestoreProgressNotifier extends StateNotifier { final restoreProgressProvider = StateNotifierProvider((ref) { return RestoreProgressNotifier(); -}); \ No newline at end of file +}); From 307f036b27255d86f83567d161fb03a5fdb856cb Mon Sep 17 00:00:00 2001 From: BRACR10 Date: Fri, 24 Apr 2026 02:12:50 -0600 Subject: [PATCH 5/6] docs: document dispute chat restore and harden notifier - Add `DISPUTE_CHAT_RESTORE.md` detailing the restoration protocol flow, subscription management, and identified edge cases. - Add `mounted` guards to `DisputeChatNotifier` to prevent state updates after disposal during asynchronous initialization, history loading, and event processing. --- docs/architecture/DISPUTE_CHAT_RESTORE.md | 229 ++++++++++++++++++ .../notifiers/dispute_chat_notifier.dart | 29 ++- 2 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/DISPUTE_CHAT_RESTORE.md diff --git a/docs/architecture/DISPUTE_CHAT_RESTORE.md b/docs/architecture/DISPUTE_CHAT_RESTORE.md new file mode 100644 index 000000000..9a8aa6e38 --- /dev/null +++ b/docs/architecture/DISPUTE_CHAT_RESTORE.md @@ -0,0 +1,229 @@ +# Dispute Chat Restore + +## Current Implementation + +### Overview + +When a user restores their wallet from a mnemonic, `RestoreService` reconstructs all active +trading sessions and their associated states — including sessions with open disputes. For +disputed orders, the restore process must also re-enable the dispute chat so the user can +continue communicating with the assigned admin (solver). + +### Protocol Flow + +``` +User enters mnemonic + │ + ▼ +RestoreService.importMnemonicAndRestore() + │ + ├─ KeyManager.importMnemonic() → derives master key + ├─ KeyManager.init() → loads keys from storage + └─ initRestoreProcess() + │ + ▼ + _clearAll() + ├─ sessionNotifier.reset() + ├─ mostroStorage.deleteAll() + ├─ eventStorage.deleteAll() + ├─ notificationsRepository.clearAll() + ├─ invalidate chatRooms providers + └─ invalidate disputeChat providers (stale subscriptions) + │ + ▼ + _createTempSubscription() + └─ kind 1059 filter on tempTradeKey (index 1) + │ + ▼ + Stage 1 — Action.restore + ├─ _sendRestoreRequest() + ├─ _waitForEvent(gettingRestoreData) + └─ _extractRestoreData() + → Map + → List (includes solverPubkey, disputeId) + │ + ▼ + Stage 2 — Action.orders + ├─ _sendOrdersDetailsRequest(orderIds) + ├─ _waitForEvent(gettingOrdersDetails) + └─ _extractOrdersDetails() + → OrdersResponse (full order state per orderId) + │ + ▼ + Stage 3 — Action.lastTradeIndex + ├─ _sendLastTradeIndexRequest() + ├─ _waitForEvent(gettingTradeIndex) + └─ _extractLastTradeIndex() + → int lastTradeIndex + │ + ▼ + _tempSubscription.cancel() + │ + ▼ + restore(ordersMap, lastTradeIndex, ordersResponse, disputes) + ├─ keyManager.setCurrentKeyIndex(lastTradeIndex + 1) + ├─ isRestoringProvider = true (blocks MostroService._onData processing) + │ + ├─ FOR EACH order in ordersIds: + │ ├─ derive tradeKey from tradeIndex + │ ├─ determine role/peer from buyerTradePubkey / sellerTradePubkey + │ ├─ find matching RestoredDispute (if any) + │ ├─ extract solverPubkey from RestoredDispute + │ ├─ Session( + │ │ adminPubkey: solverPubkey, ← enables adminSharedKey computation + │ │ disputeId: dispute.disputeId + │ │ ) + │ ├─ sessionNotifier.saveSession(session) + │ │ └─ triggers SubscriptionManager._updateAllSubscriptions() + │ │ → relay REQ recreated for this tradeKey + │ └─ if peer != null: chatRoomsProvider.subscribe() + │ + ├─ Future.delayed(10 seconds) + │ └─ relay delivers historical gift-wrap events during this window; + │ MostroService._onData stores them in mostroStorage but + │ isRestoringProvider=true blocks state.updateWith() + │ + ├─ storage.deleteAll() + │ └─ clears relay-replayed events that arrived during 10s window + │ (prevents stale events from overwriting restore messages) + │ + ├─ FOR EACH order in ordersResponse: + │ ├─ build MostroMessage from OrderDetail + dispute state + │ ├─ storage.addMessage(key, message) + │ └─ notifier.updateStateFromMessage(message) + │ + └─ isRestoringProvider = false +``` + +### Dispute Chat Subscription During Restore + +`DisputeChatNotifier` subscribes to kind 1059 events addressed to the `adminSharedKey` — an +ECDH keypair derived from `tradeKey` and the solver's public key. + +For this to work post-restore, `Session.adminSharedKey` must be non-null at the time the +dispute chat provider is first accessed. The restore flow guarantees this by passing +`adminPubkey: solverPubkey` to the `Session` constructor during the session-creation loop. +`Session` computes `adminSharedKey` in its constructor from `tradeKey × adminPubkey`. + +`DisputeChatNotifier._subscribe()` checks for `adminSharedKey != null`. If present, it +immediately registers a relay subscription. If absent (e.g. solver not yet assigned), it +calls `_listenForSession()` which watches `sessionNotifierProvider` for the key to appear. + +The provider factory auto-calls `unawaited(notifier.initialize())` on first access, so the +subscription begins as soon as any widget or restore code reads the provider. + +--- + +## Known Issues + +### Issue 1 — Isolated Subscription Instead of SubscriptionManager + +#### Description + +`DisputeChatNotifier` creates its own direct relay subscription via +`nostrService.subscribeToEvents(request)` — bypassing `SubscriptionManager`. + +An earlier attempt integrated dispute chat subscriptions into `SubscriptionManager` so all +relay REQs are managed centrally. This approach was abandoned because of a critical side +effect: `SubscriptionManager._updateAllSubscriptions()` fires every time any session +changes (including during the restore loop's `saveSession` calls). Adding dispute chat keys +into that path caused `_updateAllSubscriptions` to recreate **all** relay subscriptions on +every `saveSession` iteration — including subscriptions for orders currently being placed by +the user. This caused in-flight orders to receive their responses on a new subscription +that had not yet returned the dedup'd events, breaking the order flow entirely. + +#### Current Workaround + +Dispute chat subscriptions remain isolated from `SubscriptionManager`. Each +`DisputeChatNotifier` instance manages its own `StreamSubscription`. The +subscription is cancelled on `dispose()` and recreated if the provider is invalidated. + +#### Consequence + +Relay subscription management is split across two systems. If relay connections are +recycled (e.g. app foreground/background cycle), `SubscriptionManager` resubscribes all +sessions automatically, but dispute chat subscriptions must re-initialize independently via +`DisputeChatNotifier.initialize()`. + +--- + +### Issue 2 — `FormatException: Public key cannot be empty` in `MostroService._onData` + +#### Error + +``` +FormatException: Failed to parse Peer from JSON: FormatException: Public key cannot be empty +#0 MostroService._onData (mostro_service.dart:172) +``` + +Line 172 is `final msg = MostroMessage.fromJson(result[0])`. + +#### Root Cause + +Some Mostro protocol events (typically `adminTookDispute` or similar admin-side messages) +include a `Peer` payload where `public_key` is either an empty string or absent. The +`Peer.fromJson()` constructor throws `FormatException` on empty/missing keys rather than +returning null or a sentinel value. + +These events arrive through the normal `SubscriptionManager` → `MostroService._onData` +pipeline. The error is caught by the `catch (e)` block at the bottom of `_onData`, so the +app does not crash, but the message is silently discarded and state is not updated. + +This occurs post-restore when the relay replays `adminTookDispute` events that have no +`public_key` in the peer field — possibly because the solver's public key was not yet +assigned at the time of the original event, or the backend serializes an absent solver as +an empty string. + +#### Impact + +- Event discarded silently — admin-assigned-to-dispute state is not applied. +- If `adminTookDispute` is the only source of `adminSharedKey` in normal flow, dispute chat + subscriptions will not start. Post-restore this is mitigated by `solverPubkey` set + directly during the restore session-creation loop (see implementation above). + +#### Fix Needed + +`Peer.fromJson()` should tolerate an empty or absent `public_key` by returning `null` +rather than throwing. Alternatively, `MostroMessage.fromJson` should catch this specific +case and degrade gracefully (e.g. strip the peer field and continue parsing). + +--- + +### Issue 3 — Dispute State Not Persisted After Restore + App Kill + +#### Description + +After a successful restore, if the user force-kills the app and relaunches, disputed order +state is not recovered. The orders either show an incorrect status or disappear from +"My Trades". This does **not** happen for users who have never performed a restore. + +#### Root Cause (Preliminary) + +The normal (non-restore) app startup path relies on `mostroStorage` containing +`MostroMessage` records that were received live from the relay. On restart, +`OrderNotifier.sync()` reads all messages for each orderId from storage and reconstructs +state by replaying them in timestamp order. + +After restore, `restore_manager` calls `storage.deleteAll()` to clear relay-replayed events +and then writes fresh `MostroMessage` records derived from `OrdersResponse`. These records +are written with `orderDetail.createdAt` timestamps (original order creation time, which +may be months old). On the next app start, `sync()` replays these messages correctly — but +relay-replayed events that arrive after `isRestoringProvider = false` may be stored with +`DateTime.now()` timestamps (see `MostroService._onData` timestamp behavior) and therefore +sort after the restore messages in `watchLatestMessage` (DESC), causing `state.updateWith` +to apply a stale relay event over the correct restored state. + +Additionally, if the `Session` persisted to Sembast after restore does not include +`adminPubkey` / `disputeId` (e.g. due to a serialization gap in `Session.toJson` / +`Session.fromJson`), then on relaunch `adminSharedKey` will be null and dispute chat +subscriptions will not start. + +#### Scope + +Out of scope for the current restore feature milestone. Tracked here for future resolution. + +#### Suspected Files + +- `lib/features/order/notifiers/abstract_mostro_notifier.dart` — `sync()` and `subscribe()` replay logic +- `lib/services/mostro_service.dart` — timestamp assignment on relay-replayed events +- `lib/data/models/session.dart` — `toJson()` / `fromJson()` for `adminPubkey` / `disputeId` diff --git a/lib/features/disputes/notifiers/dispute_chat_notifier.dart b/lib/features/disputes/notifiers/dispute_chat_notifier.dart index 887d39019..b9ead08ac 100644 --- a/lib/features/disputes/notifiers/dispute_chat_notifier.dart +++ b/lib/features/disputes/notifiers/dispute_chat_notifier.dart @@ -93,16 +93,20 @@ class DisputeChatNotifier extends StateNotifier with MediaCach /// Initialize the dispute chat by loading historical messages and subscribing to new events Future initialize() async { - if (_isInitialized) return; + if (_isInitialized || !mounted) return; logger.i('Initializing dispute chat for disputeId: $disputeId'); await _loadHistoricalMessages(); + if (!mounted) return; await _subscribe(); + if (!mounted) return; _isInitialized = true; } /// Subscribe to new dispute chat messages using admin shared key Future _subscribe() async { + if (!mounted) return; + final session = _getSessionForDispute(); if (session == null) { logger.w('No session found for dispute: $disputeId'); @@ -120,6 +124,7 @@ class DisputeChatNotifier extends StateNotifier with MediaCach if (_subscription != null) { logger.i('Cancelling previous subscription for dispute: $disputeId'); await _subscription!.cancel(); + if (!mounted) return; _subscription = null; } @@ -140,6 +145,8 @@ class DisputeChatNotifier extends StateNotifier with MediaCach /// Listen for session changes and subscribe when admin shared key is ready void _listenForSession() { + if (!mounted) return; + // Cancel any previous listener to avoid leaks _sessionListener?.close(); _sessionListener = null; @@ -150,6 +157,8 @@ class DisputeChatNotifier extends StateNotifier with MediaCach _sessionListener = ref.listen>( sessionNotifierProvider, (previous, next) { + if (!mounted) return; + final session = _getSessionForDispute(); if (session != null && session.adminSharedKey != null) { logger.i('Admin shared key available for dispute $disputeId, subscribing'); @@ -165,7 +174,7 @@ class DisputeChatNotifier extends StateNotifier with MediaCach /// Stores the gift wrap event (encrypted) to disk, then unwraps for display. void _onChatEvent(NostrEvent event) async { try { - if (event.kind != 1059) return; + if (!mounted || event.kind != 1059) return; final session = _getSessionForDispute(); if (session == null || session.adminSharedKey == null) return; @@ -200,9 +209,11 @@ class DisputeChatNotifier extends StateNotifier with MediaCach 'dispute_id': disputeId, }, ); + if (!mounted) return; // Unwrap using admin shared key (1-layer p2p decryption) final unwrappedEvent = await event.p2pUnwrap(session.adminSharedKey!); + if (!mounted) return; // SECURITY: The ECDH shared key IS the authentication. // If p2pUnwrap succeeded, the sender holds the admin's private key. @@ -241,6 +252,8 @@ class DisputeChatNotifier extends StateNotifier with MediaCach /// are filtered upstream (`isFromAdmin`). void _maybeShowInAppNotification() { try { + if (!mounted) return; + final activeScreens = ref.read(activeChatScreensProvider); if (activeScreens.contains(disputeId)) return; @@ -256,6 +269,7 @@ class DisputeChatNotifier extends StateNotifier with MediaCach /// Reconstructs gift wrap events and unwraps them with adminSharedKey. Future _loadHistoricalMessages() async { try { + if (!mounted) return; logger.i('Loading historical messages for dispute: $disputeId'); state = state.copyWith(isLoading: true); @@ -278,6 +292,7 @@ class DisputeChatNotifier extends StateNotifier with MediaCach ); logger.i('Found ${chatEvents.length} historical messages for dispute: $disputeId'); + if (!mounted) return; final List messages = []; @@ -313,6 +328,7 @@ class DisputeChatNotifier extends StateNotifier with MediaCach // Decrypt and unwrap the message final unwrappedEvent = await storedEvent.p2pUnwrap(session.adminSharedKey!); + if (!mounted) return; // Fire-and-forget: pre-download media without blocking history load unawaited(_processMessageContent(unwrappedEvent)); messages.add(DisputeChatMessage(event: unwrappedEvent)); @@ -325,9 +341,11 @@ class DisputeChatNotifier extends StateNotifier with MediaCach final deduped = {for (var m in messages) m.id: m}.values.toList(); deduped.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + if (!mounted) return; state = state.copyWith(messages: deduped, isLoading: false); } catch (e, stackTrace) { logger.e('Error loading historical messages: $e', stackTrace: stackTrace); + if (!mounted) return; state = state.copyWith(isLoading: false, error: e.toString()); } } @@ -335,6 +353,8 @@ class DisputeChatNotifier extends StateNotifier with MediaCach /// Send a message in the dispute chat using p2pWrap with admin shared key. /// Stores the gift wrap event (encrypted) on success. Future sendMessage(String text) async { + if (!mounted) return; + final session = _getSessionForDispute(); if (session == null) { logger.w('Cannot send message: Session is null for dispute: $disputeId'); @@ -378,10 +398,12 @@ class DisputeChatNotifier extends StateNotifier with MediaCach session.tradeKey, session.adminSharedKey!.public, ); + if (!mounted) return; // Publish to network try { await ref.read(nostrServiceProvider).publishEvent(wrappedEvent); + if (!mounted) return; logger.i('Dispute message sent successfully for dispute: $disputeId'); } catch (publishError, publishStack) { logger.e('Failed to publish dispute message: $publishError', @@ -406,6 +428,7 @@ class DisputeChatNotifier extends StateNotifier with MediaCach 'dispute_id': disputeId, }, ); + if (!mounted) return; // Update message to isPending=false (success) _updateMessageState(rumorId, isPending: false); @@ -419,6 +442,8 @@ class DisputeChatNotifier extends StateNotifier with MediaCach /// Per-message errors stay at message level; state.error is reserved /// for initialization/loading failures only. void _updateMessageState(String messageId, {required bool isPending, String? error}) { + if (!mounted) return; + final updatedMessages = state.messages.map((m) { if (m.id == messageId) { return DisputeChatMessage( From ffba9b73839deff4292a862119aa663764172894 Mon Sep 17 00:00:00 2001 From: BRACR10 Date: Fri, 24 Apr 2026 02:32:20 -0600 Subject: [PATCH 6/6] fix: code rabbit minor issues --- docs/architecture/DISPUTE_CHAT_RESTORE.md | 4 ++-- lib/features/restore/restore_manager.dart | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture/DISPUTE_CHAT_RESTORE.md b/docs/architecture/DISPUTE_CHAT_RESTORE.md index 9a8aa6e38..4f36db8c0 100644 --- a/docs/architecture/DISPUTE_CHAT_RESTORE.md +++ b/docs/architecture/DISPUTE_CHAT_RESTORE.md @@ -11,7 +11,7 @@ continue communicating with the assigned admin (solver). ### Protocol Flow -``` +```text User enters mnemonic │ ▼ @@ -151,7 +151,7 @@ sessions automatically, but dispute chat subscriptions must re-initialize indepe #### Error -``` +```text FormatException: Failed to parse Peer from JSON: FormatException: Public key cannot be empty #0 MostroService._onData (mostro_service.dart:172) ``` diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 44a5bc03a..2c95478fe 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -113,7 +113,7 @@ class RestoreService { logger.i( 'Restore: cleared ${chatOrderIds.length} chat providers, ' '${disputeIds.length} dispute chat providers', - ); + ); } catch (e) { logger.w('Restore: cleanup error', error: e); }