Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 99 additions & 13 deletions docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,12 +353,14 @@ for (final orderDetail in ordersResponse.orders) {
// ... other fields
);

// Build synthetic MostroMessage
// Build synthetic MostroMessage. The ordering timestamp is the single
// restore-start anchor (_restoreStartTime), NOT orderDetail.createdAt —
// see "Restore-Time Live Event Buffering and Replay" below for why.
final mostroMessage = MostroMessage<Order>(
id: orderDetail.id,
action: action,
payload: order,
timestamp: orderDetail.createdAt ?? DateTime.now().millisecondsSinceEpoch,
timestamp: _restoreStartTime,
);

// Save message to storage and update state
Expand Down Expand Up @@ -511,37 +513,121 @@ 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 (`isRestoringProvider`) marks the window in
which historical order/dispute state is being rebuilt from Mostro's restore
response:

```dart
// Enable restore mode to block all old message processing
// Enable restore mode to block synthetic/live processing races
ref.read(isRestoringProvider.notifier).state = true;
_logger.i('Restore: enabled restore mode - blocking all old message processing');
logger.i('Restore: enabled restore mode - blocking all old message processing');
```

**File**: `lib/services/mostro_service.dart:44-96`
`isRestoringProvider` is cleared on **both** the success path and the catch
block of `restore()`, so the transition back to `false` is outcome-agnostic
— see D3 below.

**File**: `lib/services/mostro_service.dart`

```dart
bool _isRestorePayload(Map<String, dynamic> 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<String, dynamic>) return false;

final payload = wrapper['payload'];
if (payload == null || payload is! Map<String, dynamic>) return false;

// Check for restore-specific fields
if (payload.containsKey('restore_data')) return true;
if (payload.containsKey('trade_index')) return true;

return false;
}
```

`_isRestorePayload` only recognizes the restore protocol's OWN response
payloads (restore data, orders list, trade index) arriving on the temporary
subscription — it has nothing to do with regular live order/dispute events
addressed to the user's real sessions. Those are handled by the
buffer-and-replay mechanism below.

### Restore-Time Live Event Buffering and Replay

**File**: `lib/services/mostro_service.dart`

Fixes GitHub #584: previously, any live event (order update, dispute
message) that arrived on the user's real sessions while a restore was in
progress was either dropped outright or — in an earlier, buggy port of this
fix — buffered too late (after the session-match check), so events for a
session restore had not recreated yet were still lost. Live events are now
buffered unconditionally while `isRestoringProvider` is true and replayed,
in arrival order, once restore ends.

**Architecture decisions**:

- **D1 — Buffer check precedes session-match**: `_onData` checks
`isRestoringProvider` immediately after the dedup reserve, **before** the
`matchingSession == null` early return and before any decrypt. Restore
recreates sessions incrementally (one `saveSession()` call per order), so
an event addressed to a session that has not been recreated yet must still
be preserved, not dropped as "no matching session".
- **D2 — Keyed buffer**: `_restoreBuffer` is a `Map<String, NostrEvent>`
keyed by `event.id`, which gives dedup-by-id (re-buffering the same id
keeps a single entry in its original arrival position — Dart `Map` is
insertion-ordered) as a defensive backstop behind the top-level
`eventStore.hasItem`/`putItem` dedup check.
- **D3 — Outcome-agnostic flush trigger**: `MostroService.init()` registers
`ref.listen<bool>(isRestoringProvider, ...)` and flushes the buffer on any
`true → false` transition. This covers `RestoreService.restore()`'s
success path and its catch block identically — the flush does not need to
know why restore ended.
- **D4 — Dedup entry cleared before replay**: `_flushRestoreBuffer()` calls
`eventStore.deleteItem(event.id)` immediately before replaying each event
through `_onData`, because `_onData` reserved that id when the event was
first buffered. Without this, the dedup check at the top of `_onData`
would silently drop the replay.
- **D5 — Single restore-start anchor for synthetic messages**: synthetic
order/dispute messages built in `RestoreService.restore()` use one
timestamp, `_restoreStartTime` — captured once at the start of
`initRestoreProcess()`, before restore mode is enabled — instead of
`orderDetail.createdAt`. `createdAt` reflects when the order was
originally created, not when this snapshot was taken; for a long-lived
order, using it as the ordering timestamp would let intervening historical
replay outrank the current-state snapshot. The restore-start anchor sorts
newer than all pre-restore history yet older than any live event that
arrives during restore. The real creation time is still preserved in the
`Order`/`Dispute` payload for display; only the ordering timestamp
changes.

**Target `_onData` control flow** (landmark-relative — decrypt, session-match,
DM/restore-payload skips, and the timestamp fallback already existed; only
the buffer check's *position*, relative to session-match, is new):

```
_onData(event):
1. if eventStore.hasItem(id): return // dedup check
2. eventStore.putItem(id, ...) // dedup reserve
3. if isRestoringProvider: buffer[id] = event; return // <== reorder fix
4. matchingSession = ...; if null: return // now AFTER buffer
5. decrypt (v1 gift-wrap unWrap / v2 NIP-44 direct)
6. jsonDecode; skip DM payloads; skip restore payloads
7. msg = MostroMessage.fromJson(...)
8. msg.timestamp ??= innerRumorCreatedAt ?? event.createdAt
9. messageStorage.addMessage(...); link child order if applicable
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

On flush, `isRestoringProvider` is `false`, so replayed events flow past
step 3 straight to step 4 onward and receive their real protocol timestamp:
the inner rumor's `created_at` for v1 gift wrap (canonical per NIP-59 — the
outer wrap/seal timestamps are randomized for privacy), or the event's own
`created_at` for v2 NIP-44 direct (kind 14 has no seal/rumor layer, so its
own timestamp is already the real send time).

### Session Validation

The system validates that recreated sessions match the expected order data:
Expand Down Expand Up @@ -718,8 +804,8 @@ sequenceDiagram

---

**Last Modified**: November 25, 2025
**Version**: 1.0.0
**Last Modified**: July 8, 2026
**Version**: 1.1.0
**Author**: Architecture Documentation
**Related Files**:
- `lib/features/restore/restore_manager.dart`
Expand Down
11 changes: 10 additions & 1 deletion integration_test/test_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,20 @@ class FakeMostroService implements MostroService {

@override
void updateSettings(Settings settings) {}

@override
void dispose() {
// TODO: implement dispose
}

@override
Future<void> onDataForTesting(NostrEvent event) async {}

@override
Future<void> flushRestoreBufferForTesting() async {}

@override
Map<String, NostrEvent> get restoreBufferForTesting => {};
}

Future<void> pumpTestApp(WidgetTester tester) async {
Expand Down
31 changes: 23 additions & 8 deletions lib/features/restore/restore_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ class RestoreService {
bool _operationInProgress = false;
Completer<bool>? _operationCompleter;

// Single anchor timestamp for every synthetic message built by this restore
// run, captured once at the start of initRestoreProcess() (before restore
// mode is enabled). Ensures synthetic snapshots sort newer than pre-restore
// history yet older than any live event arriving during restore, regardless
// of the order's own (possibly stale) createdAt. Defaults to construction
// time as a safe fallback in case restore() is ever invoked directly.
int _restoreStartTime = DateTime.now().millisecondsSinceEpoch;

RestoreService(this.ref);

Future<void> importMnemonicAndRestore(String mnemonic) async {
Expand Down Expand Up @@ -797,18 +805,20 @@ class RestoreService {
}

// Create dispute message with Dispute payload (per Mostro protocol)
// Timestamp is the restore-start anchor (not orderDetail.createdAt,
// which reflects the order's original creation time, not this
// snapshot's currency) so it outranks pre-restore history while
// still sorting behind any live event arriving during restore.
final disputeMessage = MostroMessage<Dispute>(
id: orderDetail.id,
action: action,
payload: dispute,
timestamp:
orderDetail.createdAt ??
DateTime.now().millisecondsSinceEpoch,
timestamp: _restoreStartTime,
Comment thread
BraCR10 marked this conversation as resolved.
Comment thread
BraCR10 marked this conversation as resolved.
);

// 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
Expand Down Expand Up @@ -839,18 +849,20 @@ class RestoreService {
}

// Create regular order message with Order payload
// Timestamp is the restore-start anchor (not orderDetail.createdAt,
// which reflects the order's original creation time, not this
// snapshot's currency) so it outranks pre-restore history while
// still sorting behind any live event arriving during restore.
final mostroMessage = MostroMessage<Order>(
id: orderDetail.id,
action: action,
payload: order,
timestamp:
orderDetail.createdAt ??
DateTime.now().millisecondsSinceEpoch,
timestamp: _restoreStartTime,
Comment thread
BraCR10 marked this conversation as resolved.
);

// 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
Expand Down Expand Up @@ -898,6 +910,9 @@ class RestoreService {

_operationInProgress = true;
_operationCompleter = Completer<bool>();
// Snapshot the restore-start anchor before anything else, so every
// synthetic message this run produces shares one ordering timestamp.
_restoreStartTime = DateTime.now().millisecondsSinceEpoch;
// Hold the shared session lock for the whole restore so order/take flows
// cannot interleave with the session reset (and rebuild) below.
final releaseSessionLock =
Expand Down
67 changes: 67 additions & 0 deletions lib/services/mostro_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:dart_nostr/dart_nostr.dart';
import 'package:flutter/foundation.dart';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mostro_mobile/services/logger_service.dart';
import 'package:mostro_mobile/data/enums.dart';
import 'package:mostro_mobile/data/models.dart';
import 'package:mostro_mobile/features/restore/restore_mode_provider.dart';
import 'package:mostro_mobile/features/settings/settings.dart';
import 'package:mostro_mobile/features/subscriptions/subscription_manager_provider.dart';
import 'package:mostro_mobile/shared/providers.dart';
Expand All @@ -21,12 +23,19 @@ class MostroService {

Settings _settings;
StreamSubscription<NostrEvent>? _ordersSubscription;
ProviderSubscription<bool>? _restoreListener;

// Live events received while a restore is in progress are buffered here
// (keyed by event id, so re-delivery of the same id keeps a single entry
// in its original arrival position) and replayed once restore ends.
final Map<String, NostrEvent> _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
Expand All @@ -44,10 +53,20 @@ class MostroService {
},
cancelOnError: false,
);

// Flush buffered live events once restore ends, regardless of outcome
// (RestoreService.restore() clears isRestoringProvider on both its
// success path and its catch block).
_restoreListener = ref.listen<bool>(isRestoringProvider, (previous, next) {
if (previous == true && next == false) {
unawaited(_flushRestoreBuffer());
}
});
}

void dispose() {
_ordersSubscription?.cancel();
_restoreListener?.close();
logger.i('MostroService disposed');
}

Expand Down Expand Up @@ -116,6 +135,17 @@ class MostroService {
'created_at': event.createdAt!.millisecondsSinceEpoch ~/ 1000,
});

// Buffer live events while a restore is in progress. This runs BEFORE the
// session-match check below so events addressed to a session restore has
// not recreated yet are preserved instead of being dropped by "no
// matching session". Buffered events are replayed through _onData once
// restore completes (success or error path alike); see _flushRestoreBuffer.
if (ref.read(isRestoringProvider)) {
_restoreBuffer[event.id!] = event;
logger.i('Restore: buffered live event ${event.id}');
return;
Comment thread
BraCR10 marked this conversation as resolved.
Comment thread
BraCR10 marked this conversation as resolved.
}

final sessions = ref.read(sessionNotifierProvider);
final matchingSession = sessions.firstWhereOrNull(
(s) => s.tradeKey.public == event.recipient,
Expand All @@ -132,6 +162,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); used for timestamp anchoring below.
DateTime? innerCreatedAt;
if (event.kind == 14) {
content = await NostrUtils.decryptNIP44DirectEvent(
event,
Expand All @@ -142,6 +175,7 @@ class MostroService {
final decryptedEvent = await event.unWrap(privateKey);
content = decryptedEvent.content;
decryptedId = decryptedEvent.id;
innerCreatedAt = decryptedEvent.createdAt;
}

if (content == null) return;
Expand Down Expand Up @@ -169,6 +203,14 @@ class MostroService {

final msg = MostroMessage.fromJson(result[0]);

// For v1 gift-wrap (kind 1059) use the inner rumor's created_at (real
// send time; the outer wrap is NIP-59 randomized). For v2 NIP-44 direct
// (kind 14) innerCreatedAt is null (no rumor layer), so fall back to
// event.createdAt, which is already the real send time.
msg.timestamp ??=
innerCreatedAt?.millisecondsSinceEpoch ??
event.createdAt?.millisecondsSinceEpoch;
Comment thread
BraCR10 marked this conversation as resolved.

final messageStorage = ref.read(mostroStorageProvider);

// Use the inner rumor id if available (v1), otherwise fall back to the
Expand All @@ -188,6 +230,31 @@ class MostroService {
}
}

/// Replays every event buffered during restore through [_onData], in
/// arrival order, once restore has ended. Clears each event's dedup entry
/// immediately before replaying it, since [_onData] reserved that entry
/// when the event was first buffered.
Future<void> _flushRestoreBuffer() async {
if (_restoreBuffer.isEmpty) return;
final events = List<NostrEvent>.from(_restoreBuffer.values);
_restoreBuffer.clear();
logger.i('Restore: flushing ${events.length} buffered live events');
final eventStore = ref.read(eventStorageProvider);
for (final event in events) {
await eventStore.deleteItem(event.id!);
await _onData(event);
}
}

@visibleForTesting
Future<void> onDataForTesting(NostrEvent event) => _onData(event);

@visibleForTesting
Future<void> flushRestoreBufferForTesting() => _flushRestoreBuffer();

@visibleForTesting
Map<String, NostrEvent> get restoreBufferForTesting => _restoreBuffer;

Future<void> _maybeLinkChildOrder(
MostroMessage message,
Session session,
Expand Down
Loading