Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
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):

```dart
_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
```

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
14 changes: 13 additions & 1 deletion integration_test/test_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,23 @@ 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> flushRestoreBuffer() async {}

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

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

Future<void> pumpTestApp(WidgetTester tester) async {
Expand Down
135 changes: 91 additions & 44 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 @@ -593,6 +601,10 @@ class RestoreService {
OrdersResponse ordersResponse,
List<RestoredDispute> disputes,
) async {
// Orders needing a fiat-sent recheck once the restore buffer drains.
final pendingReconciliation =
<({String orderId, MostroMessage message})>[];

try {
if (_masterKey == null) {
throw Exception('Master key not initialized');
Expand Down Expand Up @@ -779,82 +791,58 @@ class RestoreService {
);

if (dispute != null) {
// For disputed orders, check if fiat was sent before the dispute
// so future cooperative cancel actions get remapped correctly
final disputeMessages = await storage.getAllMessagesForOrderId(
orderDetail.id,
);
final hadFiatSent = disputeMessages.any(
(m) =>
m.action == Action.fiatSent ||
m.action == Action.fiatSentOk,
);
if (hadFiatSent) {
notifier.setFiatWasSent();
logger.i(
'Restore: fiatWasSent=true for disputed order ${orderDetail.id}',
);
}
// Fiat-sent status is checked later, once the buffer has flushed.

// 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
notifier.updateStateFromMessage(disputeMessage);
pendingReconciliation
.add((orderId: orderDetail.id, message: disputeMessage));
logger.i(
'Restore: created dispute message for order ${orderDetail.id}',
);
} else {
// For cooperativelyCanceled orders, check message history to
// determine if fiat was sent before the cancel was initiated.
// This sets fiatWasSent so updateWith can remap to the correct
// semantic action variant.
if (order.status == Status.cooperativelyCanceled) {
final messages = await storage.getAllMessagesForOrderId(
orderDetail.id,
);
final hadFiatSent = messages.any(
(m) =>
m.action == Action.fiatSent ||
m.action == Action.fiatSentOk,
);
if (hadFiatSent) {
notifier.setFiatWasSent();
logger.i(
'Restore: fiatWasSent=true for cooperativelyCanceled order ${orderDetail.id}',
);
}
}
// Fiat-sent status is checked later, once the buffer has flushed.

// 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
notifier.updateStateFromMessage(mostroMessage);
if (order.status == Status.cooperativelyCanceled) {
pendingReconciliation
.add((orderId: orderDetail.id, message: mostroMessage));
}
}
} catch (e, stack) {
logger.e(
Expand All @@ -877,9 +865,65 @@ class RestoreService {
ref.read(isRestoringProvider.notifier).state = false;
logger.e('Restore: error during restore', error: e, stackTrace: stack);
rethrow;
} finally {
// Restore mode is already off here, so the flush's replayed events
// reach storage instead of re-buffering. Never let this throw — it
// would mask a real restore error being rethrown above.
try {
await ref.read(mostroServiceProvider).flushRestoreBuffer();
await _reconcileFiatSent(pendingReconciliation);
Comment thread
BraCR10 marked this conversation as resolved.
} catch (e, stack) {
logger.e(
'Restore: post-restore flush/reconciliation failed',
error: e,
stackTrace: stack,
);
}
}
}

// Rechecks fiat-sent status for orders deferred during the restore loop,
// now that the buffer has drained. Per-order failures are isolated, same
// as MostroService._flushRestoreBuffer.
Future<void> _reconcileFiatSent(
List<({String orderId, MostroMessage message})> pending,
) async {
if (pending.isEmpty) return;
final storage = ref.read(mostroStorageProvider);
logger.i('Restore: reconciling fiat-sent status for ${pending.length} orders');
for (final entry in pending) {
try {
final messages =
await storage.getAllMessagesForOrderId(entry.orderId);
final hadFiatSent = messages.any(
(m) =>
m.action == Action.fiatSent || m.action == Action.fiatSentOk,
);
if (!hadFiatSent) continue;

final notifier =
ref.read(orderNotifierProvider(entry.orderId).notifier);
notifier.setFiatWasSent();
notifier.updateStateFromMessage(entry.message);
logger.i(
'Restore: reconciled fiatWasSent=true for order ${entry.orderId}',
);
} catch (e, stack) {
logger.e(
'Restore: fiat reconciliation failed for order ${entry.orderId}',
error: e,
stackTrace: stack,
);
}
}
}

@visibleForTesting
Future<void> reconcileFiatSentForTesting(
List<({String orderId, MostroMessage message})> pending,
) =>
_reconcileFiatSent(pending);

//Workflow:
// 1. Clear existing data
// 2. Create temporary subscription to key index 1 for restore notifications
Expand All @@ -898,6 +942,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
Loading
Loading