Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
126 changes: 113 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,135 @@ 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. Real Nostr timestamps are always second-precision, never exact
to the millisecond, so `_restoreStartTime` is floored to just before the
current second (`_floorToPreviousSecond`) rather than used raw — otherwise
a live event created in the same second could sort older than the anchor
despite happening after it.
- **D6 — Deferred fiat-sent classification, reconciled once after flush**:
`RestoreService.restore()` no longer checks `storage.getAllMessagesForOrderId()`
inline while building disputed/cooperativelyCanceled snapshots — the
confirming `fiatSent`/`fiatSentOk` message may still be sitting unflushed
in `MostroService._restoreBuffer` at that point. Affected orders are
tracked and rechecked once, in a `finally` block, after awaiting the new
public `MostroService.flushRestoreBuffer()` (single-flight guarded against
the existing reactive listener). The tracked snapshot is only replayed if
no stored message for that order is newer, so a buffered live update
flushed on the same pass is never overwritten by the stale snapshot.

**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 +818,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
18 changes: 17 additions & 1 deletion integration_test/test_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,27 @@ class FakeMostroService implements MostroService {

@override
void updateSettings(Settings settings) {}

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

@override
Future<void> onDataForTesting(
NostrEvent event, {
int? bufferedReceivedAt,
}) async {}

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

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

@override
Map<String, ({NostrEvent event, int receivedAtMs})>
get restoreBufferForTesting => {};
}

Future<void> pumpTestApp(WidgetTester tester) async {
Expand Down
8 changes: 8 additions & 0 deletions lib/data/models/mostro_message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class MostroMessage<T extends Payload> {
int? tradeIndex;
T? _payload;
int? timestamp;
int? receivedAt;

MostroMessage({
required this.action,
Expand All @@ -23,6 +24,7 @@ class MostroMessage<T extends Payload> {
T? payload,
this.tradeIndex,
this.timestamp,
this.receivedAt,
}) : _payload = payload;

Map<String, dynamic> toJson({int? version}) {
Expand All @@ -46,6 +48,11 @@ class MostroMessage<T extends Payload> {

factory MostroMessage.fromJson(Map<String, dynamic> json) {
final timestamp = json['timestamp'];
// receivedAt is only present when reconstructing from a stored Sembast
// record (injected by MostroStorage.addMessage); live wire payloads and
// restore's synthetic messages never carry this key, so it parses as
// null for those.
final receivedAt = json['receivedAt'];
// IMPORTANT : Use 'order', 'restore' or 'cant-do' key as per protocol
json = json['order'] ?? json['restore'] ?? json['cant-do'] ?? json;
final num requestId = json['request_id'] ?? 0;
Expand All @@ -59,6 +66,7 @@ class MostroMessage<T extends Payload> {
? Payload.fromJson(json['payload']) as T?
: null,
timestamp: timestamp,
receivedAt: receivedAt,
);
}

Expand Down
30 changes: 20 additions & 10 deletions lib/data/repositories/mostro_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,26 @@ class MostroStorage extends BaseStorage<MostroMessage> {
Future<void> addMessage(String key, MostroMessage message) async {
final id = key;
try {
if (await hasItem(id)) return;
// Add metadata for easier querying
final Map<String, dynamic> dbMap = message.toJson();
message.timestamp ??= DateTime.now().millisecondsSinceEpoch;
dbMap['timestamp'] = message.timestamp;

await store.record(id).put(db, dbMap);
logger.i(
'Saved message of type ${message.action} with order id ${message.id}',
);
// The existence check and the write happen inside the same
// transaction so a concurrent addMessage for the same key (e.g. the
// same event redelivered by a second relay) can't slip past the
// check before the first call finishes writing.
final wrote = await db.transaction((txn) async {
if (await store.record(id).exists(txn)) return false;
// Add metadata for easier querying
final Map<String, dynamic> dbMap = message.toJson();
message.timestamp ??= DateTime.now().millisecondsSinceEpoch;
dbMap['timestamp'] = message.timestamp;
dbMap['receivedAt'] = message.receivedAt;

await store.record(id).put(txn, dbMap);
return true;
});
if (wrote) {
logger.i(
'Saved message of type ${message.action} with order id ${message.id}',
);
}
} catch (e, stack) {
logger.e(
'addMessage failed for $id',
Expand Down
22 changes: 9 additions & 13 deletions lib/features/order/models/order_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -158,20 +158,16 @@ class OrderState {
// Handle dispute status updates based on action
Dispute? updatedDispute = message.getPayload<Dispute>() ?? dispute;

// If we got a dispute from the message payload, ensure it has the message timestamp
// This is critical for correct sorting in the dispute list
if (updatedDispute != null && message.getPayload<Dispute>() != null) {
// Use message timestamp if dispute doesn't have a createdAt or if message has a timestamp
// Note: Nostr timestamps are in seconds, so convert to milliseconds
if (message.timestamp != null) {
final tsMs = message.timestamp! * 1000;
if (updatedDispute.createdAt == null ||
updatedDispute.createdAt!.millisecondsSinceEpoch != tsMs) {
updatedDispute = updatedDispute.copyWith(
createdAt: DateTime.fromMillisecondsSinceEpoch(tsMs),
);
logger.i('Updated dispute ${updatedDispute.disputeId} createdAt from message timestamp: ${updatedDispute.createdAt}');
}
// Only fill createdAt when the dispute doesn't already carry one — a
// dispute's creation time is fixed and must never be re-stamped by a
// later message's timestamp (which, during restore, is an ordering
// anchor, not a real creation time).
if (updatedDispute.createdAt == null && message.timestamp != null) {
updatedDispute = updatedDispute.copyWith(
createdAt: DateTime.fromMillisecondsSinceEpoch(message.timestamp!),
);
logger.i('Set dispute ${updatedDispute.disputeId} createdAt from message timestamp: ${updatedDispute.createdAt}');
}
}

Expand Down
16 changes: 8 additions & 8 deletions lib/features/order/notifiers/abstract_mostro_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,19 +110,19 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
if (mounted) {
state = state.updateWith(msg);
}
if (msg.timestamp != null &&
msg.timestamp! >
if (msg.receivedAt != null &&
msg.receivedAt! >
DateTime.now()
.subtract(const Duration(seconds: 60))
.millisecondsSinceEpoch) {
logger.i(
'Message timestamp check passed, calling handleEvent for ${msg.action}');
'Notification eligible for ${msg.action}: live event, calling handleEvent');
unawaited(handleEvent(msg,
previousStatus: previousStatus,
wasUserInitiatedCancel: wasUserInitiatedCancel));
} else {
logger.w(
'Message timestamp check failed for ${msg.action}. Timestamp: ${msg.timestamp}, Current: ${DateTime.now().millisecondsSinceEpoch}, Threshold: ${DateTime.now().subtract(const Duration(seconds: 60)).millisecondsSinceEpoch}');
logger.i(
'Notification skipped for ${msg.action}: not a live event (receivedAt: ${msg.receivedAt})');

// Handle dispute actions even if timestamp is old, since they're critical for UI state
// but bypass navigation/notification side effects
Expand Down Expand Up @@ -180,8 +180,8 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
final navProvider = ref.read(navigationProvider.notifier);

// Check if this is a recent event for notification/navigation purposes
final isRecent = event.timestamp != null &&
event.timestamp! >
final isRecent = event.receivedAt != null &&
event.receivedAt! >
DateTime.now()
.subtract(const Duration(seconds: 60))
.millisecondsSinceEpoch;
Expand All @@ -203,7 +203,7 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
);
} else if (notificationData != null && bypassTimestampGate) {
logger.i(
'Skipping notification for old event: ${event.action} (timestamp: ${event.timestamp})');
'Skipping notification for old event: ${event.action} (receivedAt: ${event.receivedAt})');
}

/// Handle incoming events and update state accordingly
Expand Down
12 changes: 12 additions & 0 deletions lib/features/order/notifiers/order_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,18 @@ class OrderNotifier extends AbstractMostroNotifier {
state = state.copyWith(fiatWasSent: true);
}

// Corrects a stale no-fiat cooperative-cancel action after the fact,
// regardless of what set it (a restore snapshot or a live event that
// ran before setFiatWasSent()).
void upgradeCooperativeCancelToFiatSent() {
if (!mounted) return;
if (state.action == Action.cooperativeCancelNoFiatByYou) {
state = state.copyWith(action: Action.cooperativeCancelFiatSentByYou);
} else if (state.action == Action.cooperativeCancelNoFiatByPeer) {
state = state.copyWith(action: Action.cooperativeCancelFiatSentByPeer);
}
}

/// Update dispute in state (used during restore)
void updateDispute(Dispute dispute) {
if (mounted) {
Expand Down
Loading
Loading