Skip to content
Open
46 changes: 17 additions & 29 deletions docs/architecture/DISPUTE_CHAT_RESTORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,39 +191,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
2 changes: 2 additions & 0 deletions lib/features/subscriptions/subscription_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:mostro_mobile/features/subscriptions/subscription.dart';
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/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 @@ -128,6 +129,7 @@ class SubscriptionManager {
return NostrFilter(
kinds: [1059],
p: sessions.map((s) => s.tradeKey.public).toList(),
limit: ref.read(isRestoringProvider) ? 0 : null,
);
case SubscriptionType.chat:
if (sessions.isEmpty) {
Expand Down
25 changes: 25 additions & 0 deletions lib/services/mostro_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ 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;
final List<NostrEvent> _restoreBuffer = [];

MostroService(this.ref) : _settings = ref.read(settingsProvider);

Expand All @@ -44,6 +46,13 @@ class MostroService {
},
cancelOnError: false,
);

// Flush buffered live events when restore completes (success or error path)
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() {
Expand Down Expand Up @@ -162,6 +171,12 @@ class MostroService {
decryptedEvent.id ??
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.

await messageStorage.addMessage(messageKey, msg);
Comment on lines +199 to 204

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant service and storage behavior
git ls-files lib/services/mostro_service.dart lib/**/message_storage.dart lib/**/storage*.dart lib/**/message*.dart

echo '--- mostro_service.dart outline ---'
ast-grep outline lib/services/mostro_service.dart --view expanded || true

echo '--- search for addMessage and timestamp fallback ---'
rg -n "addMessage\\(|timestamp .*DateTime\\.now|msg\\.timestamp|innerCreatedAt|kind == 14|createdAt" lib/services lib -g '*.dart' -A 3 -B 3

Repository: MostroP2P/mobile

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant section with line numbers once the file is located.
sed -n '160,240p' lib/services/mostro_service.dart

Repository: MostroP2P/mobile

Length of output: 2891


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the implementation of messageStorage.addMessage if present.
rg -n "class .*MessageStorage|Future<.*addMessage|void addMessage|DateTime\\.now\\(" lib -g '*.dart' -A 5 -B 5

Repository: MostroP2P/mobile

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the MostroMessage model and its timestamp parsing.
rg -n "class MostroMessage|MostroMessage\\.fromJson|timestamp" lib -g '*.dart' -A 6 -B 6 | sed -n '1,220p'

Repository: MostroP2P/mobile

Length of output: 16989


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the concrete storage implementation used by mostroStorageProvider and its addMessage fallback.
sed -n '1,220p' lib/shared/providers/storage_providers.dart
echo '---'
rg -n "class .*Storage|addMessage\\(|DateTime\\.now\\(" lib -g '*.dart' -A 8 -B 4 | sed -n '1,240p'

Repository: MostroP2P/mobile

Length of output: 18652


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the MostroMessage model and its timestamp field handling.
fd -a "mostro_message.dart" lib
fd -a "message.dart" lib | sed -n '1,80p'
rg -n "class MostroMessage|MostroMessage\\.fromJson|timestamp" lib -g '*.dart' -A 8 -B 6 | sed -n '1,220p'

Repository: MostroP2P/mobile

Length of output: 17321


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the concrete storage implementation behind mostroStorageProvider and its addMessage behavior.
rg -n "mostroStorageProvider|class .*Storage|addMessage\\(" lib -g '*.dart' -A 8 -B 6 | sed -n '1,260p'

Repository: MostroP2P/mobile

Length of output: 23071


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the MostroMessage model and the concrete MostroStorage implementation.
sed -n '1,220p' lib/data/models/mostro_message.dart
echo '---'
fd -a "mostro_storage.dart" lib
echo '---'
sed -n '1,260p' lib/data/repositories/mostro_storage.dart

Repository: MostroP2P/mobile

Length of output: 10636


Use event.createdAt for direct DMs. kind == 14 leaves innerCreatedAt null here, so MostroStorage.addMessage() falls back to DateTime.now() and reorders NIP-44 messages by processing time. Use event.createdAt as the fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/services/mostro_service.dart` around lines 199 - 204, The timestamp
fallback in MostroService’s message handling should use the original event time
for direct DMs instead of processing time. Update the logic around msg.timestamp
assignment so that when innerCreatedAt is null (such as kind 14), it falls back
to event.createdAt before calling messageStorage.addMessage, keeping NIP-44
ordering consistent. Use the existing MostroService event/message flow and the
msg.timestamp ??= assignment to locate the fix.

logger.i(
'Received DM, Event ID: ${decryptedEvent.id ?? event.id} with payload: ${decryptedEvent.content}',
Expand All @@ -173,6 +188,16 @@ 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');
for (final event in buffer) {
await _onData(event);
}
}

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