diff --git a/docs/architecture/DISPUTE_CHAT_RESTORE.md b/docs/architecture/DISPUTE_CHAT_RESTORE.md index 4f36db8c..2eedea5d 100644 --- a/docs/architecture/DISPUTE_CHAT_RESTORE.md +++ b/docs/architecture/DISPUTE_CHAT_RESTORE.md @@ -62,7 +62,10 @@ RestoreService.importMnemonicAndRestore() ▼ restore(ordersMap, lastTradeIndex, ordersResponse, disputes) ├─ keyManager.setCurrentKeyIndex(lastTradeIndex + 1) - ├─ isRestoringProvider = true (blocks MostroService._onData processing) + ├─ isRestoringProvider = true + │ ├─ SubscriptionManager: orders filter uses limit:0 + │ │ → relay delivers NO historical events during restore window + │ └─ MostroService: any arriving event goes to _restoreBuffer │ ├─ FOR EACH order in ordersIds: │ ├─ derive tradeKey from tradeIndex @@ -75,24 +78,26 @@ RestoreService.importMnemonicAndRestore() │ │ ) │ ├─ sessionNotifier.saveSession(session) │ │ └─ triggers SubscriptionManager._updateAllSubscriptions() - │ │ → relay REQ recreated for this tradeKey + │ │ → relay REQ with limit:0 (no historical replay) │ └─ 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) + │ └─ only genuinely live events can arrive (limit:0 blocks history); + │ MostroService buffers them in _restoreBuffer instead of storing │ ├─ FOR EACH order in ordersResponse: │ ├─ build MostroMessage from OrderDetail + dispute state - │ ├─ storage.addMessage(key, message) + │ ├─ storage.addMessage(key, message) ← authoritative synthetic state │ └─ notifier.updateStateFromMessage(message) │ - └─ isRestoringProvider = false + ├─ isRestoringProvider = false + │ ├─ MostroService._restoreListener fires → _flushRestoreBuffer() + │ │ → buffered live events processed on top of synthetic state + │ └─ SubscriptionManager._restoreModeListener fires + │ → _updateAllSubscriptions() with limit:null + │ → relay backfill resumes normally; eventStorage dedup + │ discards already-seen events + └─ (done) ``` ### Dispute Chat Subscription During Restore @@ -191,39 +196,27 @@ 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. +**Status: Fixed** — `lib/services/mostro_service.dart`, `lib/features/subscriptions/subscription_manager.dart` -#### 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. +#### Root Cause -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. +During restore, `MostroService._onData` saved all relay-replayed historical gift-wrap events +to `mostroStorage` with `DateTime.now()` timestamps — newer than the authoritative synthetic +messages written by the restore process (which use `orderDetail.createdAt`). On the next +app launch, `OrderNotifier.sync()` replayed messages in ascending timestamp order, ending on +a stale relay event representing an earlier trade stage instead of 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. +#### Fix -#### Scope +Two-part fix: -Out of scope for the current restore feature milestone. Tracked here for future resolution. +1. **`SubscriptionManager`** passes `limit: 0` on the orders filter while `isRestoringProvider` + is true. Relays deliver only new events during the restore window — no historical replay. -#### Suspected Files +2. **`MostroService._onData`** buffers any live event that arrives during restore into + `_restoreBuffer` instead of discarding it. A `ref.listen(isRestoringProvider)` in `init()` + flushes the buffer through normal `_onData` processing once restore completes, so live + events are applied on top of the synthetic messages written by the restore process. -- `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` +This guarantees: historical events never arrive during restore (relay-side filter), live events +are not lost (client-side buffer), and state on relaunch is always correct. diff --git a/docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md b/docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md index fcd4d7a1..98d1ef53 100644 --- a/docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md +++ b/docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md @@ -459,35 +459,29 @@ Action _getActionFromStatus(Status status, Role? userRole) { ### Restore Mode Protection -**File**: `lib/features/restore/restore_manager.dart:466-468` +**File**: `lib/features/restore/restore_manager.dart` -During recovery, a global flag prevents processing of old messages: +During recovery, a global flag is set to `true` before sessions are created and cleared after +synthetic messages are written. It serves two purposes: + +1. **`MostroService._onData`** — skips `addMessage` to `mostroStorage` while restoring. + Event IDs are still registered in `eventStorage` for deduplication. This prevents + relay-replayed historical events (timestamped with `DateTime.now()`) from sorting after + the authoritative synthetic messages (timestamped with `orderDetail.createdAt`) and + corrupting state on the next app launch. + +2. **`AbstractMostroNotifier.subscribe()`** — skips `state.updateWith()` so that DB stream + emissions triggered by synthetic message writes do not double-apply state updates already + applied by `notifier.updateStateFromMessage()`. ```dart -// Enable restore mode to block all old message processing +// Enable restore mode ref.read(isRestoringProvider.notifier).state = true; -_logger.i('Restore: enabled restore mode - blocking all old message processing'); -``` -**File**: `lib/services/mostro_service.dart:44-96` +// ... 10s delay, synthetic messages written ... -```dart -bool _isRestorePayload(Map json) { - // Check if this is a restore-specific payload that should be ignored - // during normal operation - - final wrapper = json['restore'] ?? json['order']; - if (wrapper == null || wrapper is! Map) return false; - - final payload = wrapper['payload']; - if (payload == null || payload is! Map) return false; - - // Check for restore-specific fields - if (payload.containsKey('restore_data')) return true; - if (payload.containsKey('trade_index')) return true; - - return false; -} +// Disable restore mode +ref.read(isRestoringProvider.notifier).state = false; ``` ### Session Validation diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index d98763df..a1c839b4 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -58,6 +58,7 @@ class RestoreService { NostrKeyPairs? _masterKey; // Master key pair used during restore process bool _operationInProgress = false; Completer? _operationCompleter; + int _restoreStartTime = 0; RestoreService(this.ref); @@ -806,14 +807,12 @@ class RestoreService { id: orderDetail.id, action: action, payload: dispute, - timestamp: - orderDetail.createdAt ?? - DateTime.now().millisecondsSinceEpoch, + timestamp: _restoreStartTime, ); // Save dispute message to storage final disputeKey = - '${orderDetail.id}_restore_${action.value}_${DateTime.now().millisecondsSinceEpoch}'; + '${orderDetail.id}_restore_${action.value}_$_restoreStartTime'; await storage.addMessage(disputeKey, disputeMessage); // Update state with dispute message @@ -848,14 +847,12 @@ class RestoreService { id: orderDetail.id, action: action, payload: order, - timestamp: - orderDetail.createdAt ?? - DateTime.now().millisecondsSinceEpoch, + timestamp: _restoreStartTime, ); // Save order message to storage final key = - '${orderDetail.id}_restore_${action.value}_${DateTime.now().millisecondsSinceEpoch}'; + '${orderDetail.id}_restore_${action.value}_$_restoreStartTime'; await storage.addMessage(key, mostroMessage); // Update state with order message @@ -903,6 +900,7 @@ class RestoreService { _operationInProgress = true; _operationCompleter = Completer(); + _restoreStartTime = DateTime.now().millisecondsSinceEpoch; bool success = false; bool noHistoryFound = false; try { diff --git a/lib/features/subscriptions/subscription_manager.dart b/lib/features/subscriptions/subscription_manager.dart index 47bf5b65..a174ce05 100644 --- a/lib/features/subscriptions/subscription_manager.dart +++ b/lib/features/subscriptions/subscription_manager.dart @@ -13,6 +13,7 @@ import 'package:mostro_mobile/features/subscriptions/subscription_type.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/features/restore/restore_mode_provider.dart'; /// Manages Nostr subscriptions across different parts of the application. /// @@ -181,6 +182,7 @@ class SubscriptionManager { return null; } final tradeKeys = sessions.map((s) => s.tradeKey.public).toList(); + final restoreLimit = ref.read(isRestoringProvider) ? 0 : null; // Transport selected per node from its advertised protocol_version // (§2, §4.1). Tracked so the info-event listener can detect a change // and re-subscribe when the node info arrives after this subscription. @@ -191,6 +193,7 @@ class SubscriptionManager { return NostrFilter( kinds: [1059], p: tradeKeys, + limit: restoreLimit, ); case Transport.nip44: // v2 Mostro replies are kind 14 authored by the node and addressed @@ -200,6 +203,7 @@ class SubscriptionManager { kinds: [14], authors: [ref.read(settingsProvider).mostroPublicKey], p: tradeKeys, + limit: restoreLimit, ); } case SubscriptionType.chat: diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 3337a8ea..43844d28 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -15,21 +15,22 @@ import 'package:mostro_mobile/features/order/providers/order_notifier_provider.d import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; +import 'package:mostro_mobile/features/restore/restore_mode_provider.dart'; class MostroService { final Ref ref; Settings _settings; StreamSubscription? _ordersSubscription; + ProviderSubscription? _restoreListener; + final List _restoreBuffer = []; MostroService(this.ref) : _settings = ref.read(settingsProvider); void init() { - // Cancel any existing subscription to prevent leaks on re-init _ordersSubscription?.cancel(); + _restoreListener?.close(); - // Subscribe to the orders stream from SubscriptionManager - // The SubscriptionManager will automatically manage subscriptions based on SessionNotifier changes _ordersSubscription = ref .read(subscriptionManagerProvider) .orders @@ -44,10 +45,18 @@ class MostroService { }, cancelOnError: false, ); + + // Flush buffered live events when restore completes (success or error path) + _restoreListener = ref.listen(isRestoringProvider, (previous, next) { + if (previous == true && next == false) { + unawaited(_flushRestoreBuffer()); + } + }); } void dispose() { _ordersSubscription?.cancel(); + _restoreListener?.close(); logger.i('MostroService disposed'); } @@ -132,6 +141,9 @@ class MostroService { // decrypts straight to the tuple. Both converge on jsonDecode below. String? content; String? decryptedId; + // Inner rumor's created_at is the real send time (outer gift wrap is + // NIP-59 randomized for privacy). Use it for timestamp anchoring below. + DateTime? innerCreatedAt; if (event.kind == 14) { content = await NostrUtils.decryptNIP44DirectEvent( event, @@ -142,6 +154,7 @@ class MostroService { final decryptedEvent = await event.unWrap(privateKey); content = decryptedEvent.content; decryptedId = decryptedEvent.id; + innerCreatedAt = decryptedEvent.createdAt; } if (content == null) return; @@ -177,6 +190,17 @@ class MostroService { decryptedId ?? event.id ?? 'msg_${DateTime.now().millisecondsSinceEpoch}'; + if (ref.read(isRestoringProvider)) { + _restoreBuffer.add(event); + logger.i('Restore: buffered live event ${event.id} for ${msg.action}'); + return; + } + + // Use inner rumor's created_at (real send time) — outer gift wrap is + // NIP-59 randomized. Historical events have old inner timestamps (sort + // below synthetics); live events have recent inner timestamps (gate passes). + msg.timestamp ??= innerCreatedAt?.millisecondsSinceEpoch; + await messageStorage.addMessage(messageKey, msg); logger.i( 'Received DM, Event ID: ${decryptedId ?? event.id} with payload: $content', @@ -188,6 +212,16 @@ class MostroService { } } + Future _flushRestoreBuffer() async { + if (_restoreBuffer.isEmpty) return; + final buffer = List.from(_restoreBuffer); + _restoreBuffer.clear(); + logger.i('Restore: flushing ${buffer.length} buffered live events'); + for (final event in buffer) { + await _onData(event); + } + } + Future _maybeLinkChildOrder( MostroMessage message, Session session,