fix: Prevent corrupting restored order state on relaunch#589
Conversation
…restore During account restoration, live direct messages (DMs) were being written to local storage. This could interfere with the restoration process, potentially leading to an inconsistent or corrupted state where restored data is overwritten by new events, causing issues upon app relaunch. This change prevents new DMs from being saved while a restoration is active, ensuring the integrity of the restored account data.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
✅ Files skipped from review due to trivial changes (1)
WalkthroughDuring restore, MostroService buffers incoming relay events instead of persisting decrypted DMs, records event IDs for dedupe, and replays buffered events when restore ends. SubscriptionManager sets the orders subscription limit to 0 while restoring. Documentation was updated to describe these behaviors and mark the dispute-restore fix. ChangesRestore-mode behavior and docs
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
lib/services/mostro_service.dart (1)
115-118:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftLive events arriving during restore are permanently dropped from
mostroStorageThe event ID is committed to
eventStoreunconditionally at lines 115–118, before the restore guard at lines 166–169. WhenisRestoringProvideristrue, the guard returns early without writing tomostroStorage. After restore completes andisRestoringProvidertransitions tofalse, theeventStoreretains those cached IDs indefinitely. If the relay replays or resends those events,eventStore.hasItem(event.id!)at line 112 returnstrueand the handler exits before reaching the guard — the message is never stored, regardless of relay retries.Any genuinely live event (e.g. a counterparty payment confirmation arriving while restore was running) is silently and permanently excluded from
mostroStorage. The restore process does not invalidateeventStorageProvideror trigger a re-subscription withsincefilters to recover missed events.A minimal mitigation is to defer the event ID write until after the restore guard, so the event remains eligible for normal processing once
isRestoringProvidertransitions tofalse:🔧 Proposed fix
Future<void> _onData(NostrEvent event) async { final eventStore = ref.read(eventStorageProvider); if (await eventStore.hasItem(event.id!)) return; - // Reserve event ID immediately to prevent duplicate processing from multiple relays - await eventStore.putItem(event.id!, { - 'id': event.id, - 'created_at': event.createdAt!.millisecondsSinceEpoch ~/ 1000, - }); // ... (decryption, JSON parsing, restore-payload filters) ... + final isRestoring = ref.read(isRestoringProvider); + + if (isRestoring) { + logger.i('Restore in progress, skipping storage write for ${msg.action}'); + return; + } + + // Reserve event ID to prevent duplicate processing from multiple relays. + // Deferred until after restore guard to allow post-restore reprocessing. + await eventStore.putItem(event.id!, { + 'id': event.id, + 'created_at': event.createdAt!.millisecondsSinceEpoch ~/ 1000, + }); - if (ref.read(isRestoringProvider)) { - logger.i('Restore in progress, skipping storage write for ${msg.action}'); - return; - } await messageStorage.addMessage(messageKey, msg);This ensures that events received during restore are not marked as processed in
eventStore, allowing them to be correctly stored when they arrive again post-restore.🤖 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 115 - 118, The event ID is being committed to eventStore via eventStore.putItem before the isRestoringProvider guard, causing live events received during restore to be marked processed and permanently skipped; move the eventStore.putItem call so it runs only after the isRestoringProvider check and after the event has been successfully written to mostroStorage (i.e., only write the ID in the branch where you call mostroStorage write/success), ensuring eventStore.hasItem(event.id!) remains false during restore and the event can be processed/replayed later once isRestoringProvider is false.
🧹 Nitpick comments (1)
lib/services/mostro_service.dart (1)
156-169: 💤 Low valueMinor inefficiency: JSON parsing before the restore guard
MostroMessage.fromJson(result[0])at line 156 is invoked before theisRestoringProvidercheck at line 166. During high-volume restore replay, every incoming event pays the full deserialization cost even though the result is discarded. Moving the guard to just after the restore-payload filter (line 154) avoids this work.♻️ Proposed refactor
+ final isRestoring = ref.read(isRestoringProvider); + if (isRestoring) { + logger.i('Restore in progress, skipping storage write'); + return; + } + final msg = MostroMessage.fromJson(result[0]); final messageStorage = ref.read(mostroStorageProvider); final messageKey = decryptedEvent.id ?? event.id ?? 'msg_${DateTime.now().millisecondsSinceEpoch}'; - if (ref.read(isRestoringProvider)) { - logger.i('Restore in progress, skipping storage write for ${msg.action}'); - return; - }🤖 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 156 - 169, Move the isRestoringProvider guard so we skip expensive JSON deserialization when restoring: check ref.read(isRestoringProvider) (and the existing payload filter at the top of the handler) before calling MostroMessage.fromJson(result[0]). In practice, relocate the restore-check above the current MostroMessage.fromJson call and only construct msg and compute messageKey (using decryptedEvent.id/event) after the guard passes; preserve the existing logger.i('Restore in progress...') early-return behavior and keep references to ref.read(mostroStorageProvider), decryptedEvent, event, and messageKey intact.
🤖 Prompt for all review comments with 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.
Outside diff comments:
In `@lib/services/mostro_service.dart`:
- Around line 115-118: The event ID is being committed to eventStore via
eventStore.putItem before the isRestoringProvider guard, causing live events
received during restore to be marked processed and permanently skipped; move the
eventStore.putItem call so it runs only after the isRestoringProvider check and
after the event has been successfully written to mostroStorage (i.e., only write
the ID in the branch where you call mostroStorage write/success), ensuring
eventStore.hasItem(event.id!) remains false during restore and the event can be
processed/replayed later once isRestoringProvider is false.
---
Nitpick comments:
In `@lib/services/mostro_service.dart`:
- Around line 156-169: Move the isRestoringProvider guard so we skip expensive
JSON deserialization when restoring: check ref.read(isRestoringProvider) (and
the existing payload filter at the top of the handler) before calling
MostroMessage.fromJson(result[0]). In practice, relocate the restore-check above
the current MostroMessage.fromJson call and only construct msg and compute
messageKey (using decryptedEvent.id/event) after the guard passes; preserve the
existing logger.i('Restore in progress...') early-return behavior and keep
references to ref.read(mostroStorageProvider), decryptedEvent, event, and
messageKey intact.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 363e3505-e6a5-4e82-ad3b-1de7259320e3
📒 Files selected for processing (1)
lib/services/mostro_service.dart
During account restoration, `MostroService` saved historical relay events with current timestamps. This caused them to be replayed after the authoritative synthetic messages (which use original creation timestamps), leading to incorrect order state after an app relaunch. The `isRestoringProvider` flag now prevents `addMessage` during restore, ensuring only the correct restored state persists.
There was a problem hiding this comment.
I agree with the direction of the fix, but there is still a real correctness issue in the current implementation that makes this unsafe to merge as-is.
The problem is the interaction between the new restore guard and event deduplication in MostroService._onData:
- event IDs are still registered in
eventStoragebefore the restore guard returns - when
isRestoringProvideris true, the message is not written tomostroStorage - after restore finishes, that event is now considered already seen and will not be reprocessed
So any genuinely live DM that arrives during the restore window can be permanently dropped from mostroStorage. This fixes one corruption mode, but introduces another one: missing live events that happened during restore.
That tradeoff is not safe unless the restore flow explicitly guarantees that every event seen during restore is either:
- replayed again after restore, or
- intentionally buffered and persisted later, or
- guaranteed to be fully represented by the synthetic restore state.
From the code in this PR, I do not see that guarantee. Right now the implementation looks like: dedup yes, persist no, replay no. That can lose data.
Please address that gap before merge, either by moving the restore guard earlier so skipped events are not marked as processed, or by buffering/replaying skipped live events once restore completes, or by documenting and enforcing a stronger invariant that makes the drop impossible in practice.
I understand the concern. The filter exists because Nostr relays sometimes replay old events with newer timestamps, which caused |
|
Thanks for the clarification. I agree that this patch fixes the specific relaunch corruption path you were targeting, and I also agree the probability of a genuinely live DM landing inside that restore window is likely low in practice. That said, I do not think the reply fully resolves the correctness concern I raised. The current behavior is still: an event can be marked as seen in So my view is:
Because of that, I am not going to claim my original objection was wrong. If the team decides the low-probability drop during the restore window is acceptable compared with the relaunch corruption it prevents, that is a project call. But I think it should be acknowledged as such, ideally with a follow-up issue tracking either buffering, replay, or moving the dedup boundary. |
There was a problem hiding this comment.
Code Review Summary
Verdict: Changes Requested
🔴 Critical
lib/services/mostro_service.dart— the newif (ref.read(isRestoringProvider)) return;guard is too broad. It skips all incoming DMs while restore is active, buteventStorage.putItem(...)already ran, so those events are permanently marked as seen and will not be replayed later. If a real live message arrives during the restore window, it is silently lost.
⚠️ Warning
- The fix still relies on a 10-second restore window. Even if the intention is only to block historical relay replay, the current implementation cannot distinguish replayed messages from legitimate live messages. This can reintroduce the same class of corruption or cause message loss.
What to change
- Scope the protection to restore-generated/historical payloads only, or buffer incoming messages during restore and process them after the restore completes. Do not drop them after reserving the event ID.
✅ Good
- The root-cause docs were updated consistently.
- Keeping event IDs in
eventStoragefor deduplication is correct in principle; the issue is the timing of the storage reservation relative to the restore gate.
|
Hey @BraCR10, sorry for the delay. I think Mostroneitor has a valid point: a user can restore their account in the middle of an order and, right within those ~10 seconds, receive a status change or another important DM, so I think we should account for that. The catch is that the event ID gets reserved in eventStorage (the putItem) before the isRestoring check, so the event is marked as processed but never stored → on the next launch hasItem discards it and it's never reprocessed. So it's dedup yes, persist no, replay no = the message is lost. I'd suggest buffer-and-replay, which seems cleanest:
That way we don't break the dedup contract and no message gets lost. As a bonus, it also fixes another gap: a live event arriving before its session is recreated is currently lost too, and with the buffer it'll match since the session exists by flush time. |
During account restoration, live Nostr events could interfere with the integrity of the restored state, leading to inconsistencies or potential state loss. This change introduces a mechanism to: - Buffer all incoming live events via `MostroService` while restoration is active, processing them only after restoration completes. - Temporarily halt live order subscriptions (`limit: 0`) to prevent new order events from conflicting with historical data. This ensures a clean restoration process, prevents state corruption, and guarantees that all live events are eventually processed without loss or interference.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
lib/features/subscriptions/subscription_manager.dart (1)
42-47:⚠️ Potential issue | 🟠 Major | ⚡ Quick winOrders subscription is not refreshed when restore mode flips back to false.
limitis computed fromref.read(isRestoringProvider)at filter-build time (Line 132), but subscriptions are rebuilt from session changes only. If sessions are unchanged on restore completion, the orders subscription keeps restore-time filter settings.Also applies to: 122-133
🤖 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/features/subscriptions/subscription_manager.dart` around lines 42 - 47, The orders subscription keeps restore-time filters because limit is computed only when filters are built and subscriptions are only rebuilt on session changes; add a listener for the restore flag so subscriptions are rebuilt when restore mode flips. In _initSessionListener add a ref.listen<bool>(isRestoringProvider, ...) that calls _updateAllSubscriptions(ref.read(sessionNotifierProvider)) (or otherwise triggers the same rebuild path), and ensure _updateAllSubscriptions (and the filter-building code that computes limit) reads ref.read/isRestoringProvider at rebuild time rather than capturing it once earlier.lib/services/mostro_service.dart (1)
120-127:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftBuffered events are effectively dropped due to dedupe short-circuit.
Line 123 reserves the event ID before the restore gate, then
_flushRestoreBuffer()replays with_onData(Line 197). On replay, Line 120 returns immediately (hasItem == true), so buffered live events never reachaddMessage.Also applies to: 174-177, 191-198
🤖 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 120 - 127, The dedupe short-circuit causes buffered events replayed by _flushRestoreBuffer -> _onData to be dropped because eventStore.putItem is called before the restore/replay completes; change the flow so reservation does not block replay: either move the eventStore.putItem call to after the restore/restore-buffer gate (i.e., only reserve the ID once _flushRestoreBuffer has replayed buffered events), or add a replay flag/state (e.g., _isRestoring or pass isReplay into _onData) and modify the early-return that uses eventStore.hasItem to ignore dedupe when replaying; update references to eventStore.hasItem, eventStore.putItem, _flushRestoreBuffer, _onData, and addMessage accordingly so buffered events reach addMessage during replay.
🤖 Prompt for all review comments with 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.
Inline comments:
In `@lib/services/mostro_service.dart`:
- Around line 50-55: init() is attaching a new ref.listen to isRestoringProvider
every time, causing duplicate listeners because dispose() only cancels
_ordersSubscription; fix by storing the listener handle when calling ref.listen
(e.g., assign the returned subscription to a field like _restoreListener or
similar) and ensure you cancel/close it in dispose(), and also cancel any
existing _restoreListener before re-assigning in init() so you never accumulate
multiple ref.listen callbacks that call _flushRestoreBuffer().
---
Outside diff comments:
In `@lib/features/subscriptions/subscription_manager.dart`:
- Around line 42-47: The orders subscription keeps restore-time filters because
limit is computed only when filters are built and subscriptions are only rebuilt
on session changes; add a listener for the restore flag so subscriptions are
rebuilt when restore mode flips. In _initSessionListener add a
ref.listen<bool>(isRestoringProvider, ...) that calls
_updateAllSubscriptions(ref.read(sessionNotifierProvider)) (or otherwise
triggers the same rebuild path), and ensure _updateAllSubscriptions (and the
filter-building code that computes limit) reads ref.read/isRestoringProvider at
rebuild time rather than capturing it once earlier.
In `@lib/services/mostro_service.dart`:
- Around line 120-127: The dedupe short-circuit causes buffered events replayed
by _flushRestoreBuffer -> _onData to be dropped because eventStore.putItem is
called before the restore/replay completes; change the flow so reservation does
not block replay: either move the eventStore.putItem call to after the
restore/restore-buffer gate (i.e., only reserve the ID once _flushRestoreBuffer
has replayed buffered events), or add a replay flag/state (e.g., _isRestoring or
pass isReplay into _onData) and modify the early-return that uses
eventStore.hasItem to ignore dedupe when replaying; update references to
eventStore.hasItem, eventStore.putItem, _flushRestoreBuffer, _onData, and
addMessage accordingly so buffered events reach addMessage during replay.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 622889c4-d343-4b54-80df-6d0a732f9d51
📒 Files selected for processing (4)
docs/architecture/DISPUTE_CHAT_RESTORE.mddocs/architecture/SESSION_RECOVERY_ARCHITECTURE.mdlib/features/subscriptions/subscription_manager.dartlib/services/mostro_service.dart
✅ Files skipped from review due to trivial changes (1)
- docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md
| // Flush buffered live events when restore completes (success or error path) | ||
| ref.listen<bool>(isRestoringProvider, (previous, next) { | ||
| if (previous == true && next == false) { | ||
| unawaited(_flushRestoreBuffer()); | ||
| } | ||
| }); |
There was a problem hiding this comment.
init() accumulates restore listeners across re-inits.
A new ref.listen is created each init() call, but dispose() only cancels _ordersSubscription. This can trigger duplicate flush attempts and unnecessary async work over time.
Also applies to: 58-61
🤖 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 50 - 55, init() is attaching a
new ref.listen to isRestoringProvider every time, causing duplicate listeners
because dispose() only cancels _ordersSubscription; fix by storing the listener
handle when calling ref.listen (e.g., assign the returned subscription to a
field like _restoreListener or similar) and ensure you cancel/close it in
dispose(), and also cancel any existing _restoreListener before re-assigning in
init() so you never accumulate multiple ref.listen callbacks that call
_flushRestoreBuffer().
There was a problem hiding this comment.
Pull request overview
This PR aims to prevent restored order/dispute state from being overwritten on app relaunch by avoiding persistence of relay-replayed events during the restore window, so that sync() replays only the authoritative synthetic restore messages.
Changes:
- Add restore-mode gating in
MostroService._onDatato avoid writing messages tomostroStorageduring restore (and introduce buffering/flush logic). - Adjust orders subscription filter during restore (
SubscriptionManager) to reduce/avoid historical replay. - Update architecture documentation to describe restore-mode protections and mark the dispute restore/relaunch issue as fixed.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| lib/services/mostro_service.dart | Adds restore-mode checks around DM processing/storage and introduces a restore buffer + flush mechanism. |
| lib/features/subscriptions/subscription_manager.dart | Modifies orders subscription filter to apply a restore-dependent limit. |
| docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md | Documents restore-mode protections and intended effects on message persistence/state reconstruction. |
| docs/architecture/DISPUTE_CHAT_RESTORE.md | Updates root-cause + fix narrative and marks the relaunch-after-restore issue as fixed. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return NostrFilter( | ||
| kinds: [1059], | ||
| p: sessions.map((s) => s.tradeKey.public).toList(), | ||
| limit: ref.read(isRestoringProvider) ? 0 : null, | ||
| ); |
Update the documentation for "Issue 3 — Dispute State Not Persisted After Restore + App Kill" in `DISPUTE_CHAT_RESTORE.md`. This revision clarifies the two-part fix for robust account restoration: 1. `SubscriptionManager` now filters historical order events by passing `limit: 0` to relays during restore. 2. `MostroService._onData` buffers any incoming live events, applying them only after the restoration process has completed. This ensures a clean and consistent state on app relaunch by preventing interference from live or historical events during the restoration window.
There was a problem hiding this comment.
I re-reviewed the current head and I still see one real correctness issue that I would block on.
Blocking issue:
SubscriptionManagernow computes the orders filter withlimit: ref.read(isRestoringProvider) ? 0 : null, but that value is captured when the filter is built.- From the changes in this PR, I do not see a corresponding subscription refresh triggered when restore mode flips from
trueback tofalse. - If the session set is unchanged when restore completes, the existing orders subscription can remain alive with the restore-time
limit: 0filter instead of being rebuilt into the normal runtime filter.
Why this matters:
- The PR’s safety story depends on restore mode being temporary: block historical replay during restore, then return to the normal live subscription shape afterwards.
- If the orders subscription is not refreshed on restore exit, the app can stay in the restore-mode subscription shape longer than intended.
- That makes the fix incomplete because the relay-side protection is no longer scoped cleanly to the restore window.
What I would want:
- Rebuild or refresh the orders subscription when
isRestoringProviderchanges, not only when the session set changes. - In other words, restore-mode transition itself needs to be a first-class resubscription trigger.
The buffering work in MostroService is the right direction and does address the earlier data-loss issue, but this subscription-lifecycle gap is still a blocker for me.
Closes #584
Summary
MostroService._onDatawas saving relay-replayed events tomostroStoragewithDateTime.now()timestamps. Those were newer than the synthetic restore messages (which useorderDetail.createdAt). On relaunch,sync()replayed them last, overwriting the correct restored state.addMessagein_onDatawhenisRestoringProvideris true. Event IDs still register ineventStoragefor dedup.How to test
Summary by CodeRabbit