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
73 changes: 33 additions & 40 deletions docs/architecture/DISPUTE_CHAT_RESTORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
40 changes: 17 additions & 23 deletions docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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;
}
// Disable restore mode
ref.read(isRestoringProvider.notifier).state = false;
```

### Session Validation
Expand Down
14 changes: 6 additions & 8 deletions lib/features/restore/restore_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class RestoreService {
NostrKeyPairs? _masterKey; // Master key pair used during restore process
bool _operationInProgress = false;
Completer<bool>? _operationCompleter;
int _restoreStartTime = 0;

RestoreService(this.ref);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -903,6 +900,7 @@ class RestoreService {

_operationInProgress = true;
_operationCompleter = Completer<bool>();
_restoreStartTime = DateTime.now().millisecondsSinceEpoch;
bool success = false;
bool noHistoryFound = false;
try {
Expand Down
4 changes: 4 additions & 0 deletions lib/features/subscriptions/subscription_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -200,6 +203,7 @@ class SubscriptionManager {
kinds: [14],
authors: [ref.read(settingsProvider).mostroPublicKey],
p: tradeKeys,
limit: restoreLimit,
);
}
case SubscriptionType.chat:
Expand Down
46 changes: 43 additions & 3 deletions lib/services/mostro_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<NostrEvent>? _ordersSubscription;
ProviderSubscription<bool>? _restoreListener;
final List<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
_ordersSubscription = ref
.read(subscriptionManagerProvider)
.orders
Expand All @@ -44,10 +45,18 @@ class MostroService {
},
cancelOnError: false,
);

// Flush buffered live events when restore completes (success or error path)
_restoreListener = ref.listen<bool>(isRestoringProvider, (previous, next) {
if (previous == true && next == false) {
unawaited(_flushRestoreBuffer());
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
Catrya marked this conversation as resolved.
}

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

Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -177,6 +190,19 @@ 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;
}
Comment thread
BraCR10 marked this conversation as resolved.

// For v1 gift-wrap (kind 1059) use the inner rumor's created_at (real send
// time; outer wrap is NIP-59 randomized). For v2 NIP-44 direct (kind 14)
// innerCreatedAt is null, so fall back to event.createdAt which is the
// real send time (no NIP-59 randomization on kind 14).
msg.timestamp ??= innerCreatedAt?.millisecondsSinceEpoch ??
event.createdAt?.millisecondsSinceEpoch;

await messageStorage.addMessage(messageKey, msg);
logger.i(
'Received DM, Event ID: ${decryptedId ?? event.id} with payload: $content',
Expand All @@ -188,6 +214,20 @@ class MostroService {
}
}

Future<void> _flushRestoreBuffer() async {
if (_restoreBuffer.isEmpty) return;
final buffer = List<NostrEvent>.from(_restoreBuffer);
_restoreBuffer.clear();
logger.i('Restore: flushing ${buffer.length} buffered live events');
final eventStore = ref.read(eventStorageProvider);
for (final event in buffer) {
// Remove the dedup entry so _onData can re-process the event now that
// restore is complete and synthetics have been written.
await eventStore.deleteItem(event.id!);
await _onData(event);
}
}

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