diff --git a/src/main.rs b/src/main.rs index edecbeb9..f6100982 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,14 +54,26 @@ use tokio::time::{interval, Duration}; pub static SETTINGS: OnceLock = OnceLock::new(); /// Applies one [`OperationResult`] from the background task channel (save attachment, orders, etc.). +/// Results that must re-run the startup DB-to-UI sync (maker book cache + +/// order history messages) because background work changed SQLite rows the +/// in-memory projections are built from. +fn requires_db_projection_resync(result: &OperationResult) -> bool { + matches!( + result, + OperationResult::OrderHistoryDeleted { .. } | OperationResult::SessionRestored { .. } + ) +} + async fn apply_order_result(pool: &SqlitePool, app: &mut AppState, result: OperationResult) { let is_dispute_related = matches!(&result, OperationResult::Info(msg) if (msg.contains("Dispute") && msg.contains("taken successfully")) || msg.contains("Dispute finalized")); - let resync_my_trades_from_db = matches!(&result, OperationResult::OrderHistoryDeleted { .. }); + let resync_my_trades_from_db = requires_db_projection_resync(&result); let refresh_maker_book_cache = matches!( &result, - OperationResult::MyTradesMakerBookChanged | OperationResult::Success(_) + OperationResult::MyTradesMakerBookChanged + | OperationResult::Success(_) + | OperationResult::SessionRestored { .. } ); if refresh_maker_book_cache && app.user_role == UserRole::User { @@ -793,3 +805,29 @@ async fn main() -> Result<(), anyhow::Error> { Ok(()) } + +#[cfg(test)] +mod apply_order_result_tests { + use super::requires_db_projection_resync; + use crate::ui::OperationResult; + + #[test] + fn session_restore_triggers_the_startup_db_resync() { + // Regression: a restore rewrites SQLite from a background task; without + // the resync the recovered orders stay invisible until app restart. + assert!(requires_db_projection_resync( + &OperationResult::SessionRestored { + message: String::new() + } + )); + assert!(requires_db_projection_resync( + &OperationResult::OrderHistoryDeleted { + deleted_order_ids: vec![], + message: String::new() + } + )); + assert!(!requires_db_projection_resync(&OperationResult::Info( + String::new() + ))); + } +} diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index c5c63e88..9147fa64 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -60,6 +60,8 @@ pub enum UiMode { ConfirmClearCurrencies(bool), // (selected_button: true=Yes, false=No) ConfirmDeleteHistoryOrder(uuid::Uuid, bool), // (order_id, selected_button) ConfirmBulkDeleteHistory(bool), // (selected_button) + /// User Settings: ask Mostro to restore this identity's orders and disputes. + ConfirmRestoreSession(bool), // (selected_button: true=Yes, false=No) ConfirmExit(bool), // (selected_button: true=Yes, false=No) // Generate new keys flow (Settings tab) @@ -148,6 +150,7 @@ impl Clone for UiMode { UiMode::ConfirmBulkDeleteHistory(selected) => { UiMode::ConfirmBulkDeleteHistory(*selected) } + UiMode::ConfirmRestoreSession(selected) => UiMode::ConfirmRestoreSession(*selected), UiMode::ConfirmExit(selected) => UiMode::ConfirmExit(*selected), UiMode::ConfirmGenerateNewKeys(selected) => UiMode::ConfirmGenerateNewKeys(*selected), // Clamp cloning of secret mnemonic to avoid duplicating sensitive seed words. diff --git a/src/ui/draw.rs b/src/ui/draw.rs index 8d08b626..494c6d20 100644 --- a/src/ui/draw.rs +++ b/src/ui/draw.rs @@ -347,6 +347,15 @@ No: paste BOLT11 or Lightning address manually." Some("Delete selected terminal order from local database history?"), ); } + if let UiMode::ConfirmRestoreSession(selected_button) = &app.mode { + admin_key_confirm::render_admin_key_confirm_with_message( + f, + "\u{1F504} Restore Session", + "", + *selected_button, + Some("Ask Mostro to restore this identity's orders and disputes into the local database?"), + ); + } if let UiMode::ConfirmBulkDeleteHistory(selected_button) = &app.mode { admin_key_confirm::render_admin_key_confirm_with_message( f, diff --git a/src/ui/help_popup.rs b/src/ui/help_popup.rs index 6adc1d79..bc0f2259 100644 --- a/src/ui/help_popup.rs +++ b/src/ui/help_popup.rs @@ -283,6 +283,10 @@ fn settings_instruction_lines(user_role: UserRole) -> (String, Vec "View Seed Words", "Show your BIP-39 mnemonic from the local database. Treat as highly sensitive.", ), + ( + "Restore Session (from Mostro)", + "Recover this identity's orders and disputes from Mostro after a reinstall or on a new machine.", + ), ( "Generate New Keys", "Rotate identity/trade keys. Confirm prompts and back up any new mnemonic.", diff --git a/src/ui/key_handler/enter_handlers.rs b/src/ui/key_handler/enter_handlers.rs index 9b690aa3..beaa0a3b 100644 --- a/src/ui/key_handler/enter_handlers.rs +++ b/src/ui/key_handler/enter_handlers.rs @@ -60,7 +60,9 @@ use crate::ui::key_handler::validation::{ }; use crate::ui::tabs::settings_tab::{settings_action_for_index, SettingsMenuAction}; use crate::util::dm_utils::{apply_saved_ln_address_invoice_choice, present_add_invoice_popup}; -use crate::util::order_utils::BondSlashChoice; +use crate::util::order_utils::{ + execute_restore_session, restore_completion_result, BondSlashChoice, +}; fn invoice_popup_action_for_message_action(action: &Action) -> Option { match action { @@ -567,6 +569,36 @@ pub fn handle_enter_key(app: &mut AppState, ctx: &super::EnterKeyContext<'_>) -> } true } + UiMode::ConfirmRestoreSession(selected_button) => { + if selected_button { + app.mode = UiMode::operation_result(OperationResult::Info( + "Restoring session from Mostro...".to_string(), + )); + let pool = ctx.pool.clone(); + let client = ctx.client.clone(); + let mostro_pubkey = ctx.mostro_pubkey; + let mostro_info = ctx.mostro_info.clone(); + let result_tx = ctx.order_result_tx.clone(); + let dm_subscription_tx = ctx.dm_subscription_tx.clone(); + tokio::spawn(async move { + let outcome = execute_restore_session( + &pool, + &client, + mostro_pubkey, + mostro_info.as_ref(), + dm_subscription_tx, + ) + .await; + if let Err(e) = &outcome { + log::error!("Session restore failed: {e}"); + } + let _ = result_tx.send(restore_completion_result(&outcome)); + }); + } else { + app.mode = default_mode; + } + true + } UiMode::ConfirmGenerateNewKeys(selected_button) => { if !selected_button { // NO: just close warning popup. @@ -1251,6 +1283,9 @@ fn handle_enter_normal_mode(app: &mut AppState, ctx: &super::EnterKeyContext<'_> Some(SettingsMenuAction::ChangeAdminKey) => { app.mode = UiMode::AdminMode(AdminMode::SetupAdminKey(key_state)); } + Some(SettingsMenuAction::RestoreSession) => { + app.mode = UiMode::ConfirmRestoreSession(true); + } Some(SettingsMenuAction::GenerateNewKeys) => { app.mode = UiMode::ConfirmGenerateNewKeys(true); } diff --git a/src/ui/key_handler/esc_handlers.rs b/src/ui/key_handler/esc_handlers.rs index 16f2b46a..67e6ff5b 100644 --- a/src/ui/key_handler/esc_handlers.rs +++ b/src/ui/key_handler/esc_handlers.rs @@ -162,7 +162,9 @@ pub fn handle_esc_key(app: &mut AppState) -> bool { app.mode = default_mode.clone(); true } - UiMode::ConfirmDeleteHistoryOrder(_, _) | UiMode::ConfirmBulkDeleteHistory(_) => { + UiMode::ConfirmDeleteHistoryOrder(_, _) + | UiMode::ConfirmBulkDeleteHistory(_) + | UiMode::ConfirmRestoreSession(_) => { app.mode = default_mode.clone(); true } diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index 4f5c982f..79b15156 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -1178,6 +1178,7 @@ pub fn handle_key_event( | UiMode::ConfirmClearCurrencies(ref mut selected_button) | UiMode::ConfirmDeleteHistoryOrder(_, ref mut selected_button) | UiMode::ConfirmBulkDeleteHistory(ref mut selected_button) + | UiMode::ConfirmRestoreSession(ref mut selected_button) | UiMode::ConfirmGenerateNewKeys(ref mut selected_button) | UiMode::ConfirmExit(ref mut selected_button) => { *selected_button = !*selected_button; // Toggle between YES and NO diff --git a/src/ui/key_handler/navigation.rs b/src/ui/key_handler/navigation.rs index 163c4de8..a7311fd7 100644 --- a/src/ui/key_handler/navigation.rs +++ b/src/ui/key_handler/navigation.rs @@ -86,6 +86,7 @@ fn handle_left_key(app: &mut AppState, _orders: &Arc>>) { | UiMode::ConfirmClearCurrencies(ref mut selected_button) | UiMode::ConfirmDeleteHistoryOrder(_, ref mut selected_button) | UiMode::ConfirmBulkDeleteHistory(ref mut selected_button) + | UiMode::ConfirmRestoreSession(ref mut selected_button) | UiMode::ConfirmExit(ref mut selected_button) => { // Switch to YES button (left side) *selected_button = true; @@ -157,6 +158,7 @@ fn handle_right_key(app: &mut AppState, _orders: &Arc>>) { | UiMode::ConfirmClearCurrencies(ref mut selected_button) | UiMode::ConfirmDeleteHistoryOrder(_, ref mut selected_button) | UiMode::ConfirmBulkDeleteHistory(ref mut selected_button) + | UiMode::ConfirmRestoreSession(ref mut selected_button) | UiMode::ConfirmExit(ref mut selected_button) => { // Switch to NO button (right side) *selected_button = false; @@ -307,6 +309,7 @@ fn handle_up_key( | UiMode::ConfirmClearCurrencies(_) | UiMode::ConfirmDeleteHistoryOrder(_, _) | UiMode::ConfirmBulkDeleteHistory(_) + | UiMode::ConfirmRestoreSession(_) | UiMode::ConfirmGenerateNewKeys(_) | UiMode::BackupNewKeys(_) | UiMode::ConfirmExit(_) => { @@ -466,6 +469,7 @@ fn handle_down_key( | UiMode::ConfirmClearCurrencies(_) | UiMode::ConfirmDeleteHistoryOrder(_, _) | UiMode::ConfirmBulkDeleteHistory(_) + | UiMode::ConfirmRestoreSession(_) | UiMode::ConfirmGenerateNewKeys(_) | UiMode::BackupNewKeys(_) | UiMode::ConfirmExit(_) => { diff --git a/src/ui/operation_result.rs b/src/ui/operation_result.rs index c47330b1..06c4c3c2 100644 --- a/src/ui/operation_result.rs +++ b/src/ui/operation_result.rs @@ -46,13 +46,17 @@ fn info_popup_height(message: &str, popup_width: u16) -> u16 { pub fn render_operation_result(f: &mut ratatui::Frame, result: &OperationResult) { let area: Rect = f.area(); - let popup_width = 70; + // Never assume 70 columns: heights below are derived from the wrap width, + // so on narrower terminals the width must be clamped *before* the height + // is computed or wrapped text overflows the allocated rows. + let popup_width = 70.min(area.width); let popup_height = match result { OperationResult::Success(_) => 18, OperationResult::PaymentRequestRequired { .. } | OperationResult::ObserverChatLoaded(_) | OperationResult::ObserverChatError(_) => 8, OperationResult::Info(message) => info_popup_height(message, popup_width), + OperationResult::SessionRestored { message } => info_popup_height(message, popup_width), OperationResult::Error(_) | OperationResult::InvoiceSubmitted { .. } | OperationResult::TradeClosed { .. } @@ -63,6 +67,7 @@ pub fn render_operation_result(f: &mut ratatui::Frame, result: &OperationResult) | OperationResult::OrderChatAttachmentSendFailed { .. } | OperationResult::OrderChatAttachmentError { .. } => 8, }; + let popup_height = popup_height.min(area.height); // Center the popup using Flex::Center let popup = { let [popup] = Layout::horizontal([Constraint::Length(popup_width)]) @@ -203,6 +208,7 @@ pub fn render_operation_result(f: &mut ratatui::Frame, result: &OperationResult) f.render_widget(paragraph, inner); } OperationResult::Info(message) + | OperationResult::SessionRestored { message } | OperationResult::InvoiceSubmitted { message, .. } | OperationResult::TradeClosed { message, .. } | OperationResult::OrderHistoryDeleted { message, .. } => { @@ -261,3 +267,65 @@ pub fn render_operation_result(f: &mut ratatui::Frame, result: &OperationResult) | OperationResult::OrderChatAttachmentError { .. } => {} } } + +#[cfg(test)] +mod render_tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn buffer_contains(buf: &ratatui::buffer::Buffer, needle: &str) -> bool { + let mut flat = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + flat.push_str(buf[(x, y)].symbol()); + } + flat.push('\n'); + } + flat.contains(needle) + } + + const RESTORE_SUMMARY: &str = "Session restored: 3 order(s) recovered, 1 already known, \ +1 dispute(s). 1 order(s) had no relay details and were saved with minimal info."; + + #[test] + fn restore_summary_fits_on_a_narrow_terminal() { + // Regression: the popup height was computed for a 70-column wrap while + // the text wrapped at the real (narrower) width, clipping the footer. + let backend = TestBackend::new(40, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + render_operation_result( + f, + &OperationResult::SessionRestored { + message: RESTORE_SUMMARY.to_string(), + }, + ) + }) + .unwrap(); + let buf = terminal.backend().buffer(); + assert!(buffer_contains(buf, "recovered,")); + assert!(buffer_contains(buf, "Press ESC or ENTER to close")); + } + + #[test] + fn restore_summary_keeps_counts_visible_on_a_short_terminal() { + // 40x12: the popup must clamp to the viewport and keep the leading + // counts (the message head) on screen rather than overflow the frame. + let backend = TestBackend::new(40, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + render_operation_result( + f, + &OperationResult::SessionRestored { + message: RESTORE_SUMMARY.to_string(), + }, + ) + }) + .unwrap(); + let buf = terminal.backend().buffer(); + assert!(buffer_contains(buf, "Session restored:")); + } +} diff --git a/src/ui/orders.rs b/src/ui/orders.rs index 55bdc135..9e600ce9 100644 --- a/src/ui/orders.rs +++ b/src/ui/orders.rs @@ -169,6 +169,11 @@ pub enum OperationResult { }, /// Rebuild [`crate::ui::AppState::my_trades_maker_book`] from SQLite (no UI popup). MyTradesMakerBookChanged, + /// Session restore finished: resync My Trades/Messages projections from + /// SQLite (same DB-to-UI sync as startup), then show `message`. + SessionRestored { + message: String, + }, /// Open invoice / waiting popup from a synchronous execute reply (e.g. bond payout DM). OpenInvoicePopup { notification: MessageNotification, diff --git a/src/ui/tabs/settings_tab.rs b/src/ui/tabs/settings_tab.rs index 82803ce1..493a342a 100644 --- a/src/ui/tabs/settings_tab.rs +++ b/src/ui/tabs/settings_tab.rs @@ -15,6 +15,7 @@ pub enum SettingsMenuAction { AddCurrencyFilter, ClearCurrencyFilters, ViewSeedWords, + RestoreSession, AddDisputeSolver, ChangeAdminKey, GenerateNewKeys, @@ -44,7 +45,7 @@ const ADMIN_SETTINGS: [SettingsMenuRow; 9] = [ /// Single source of truth for User Settings rows (action + list label). #[allow(clippy::redundant_static_lifetimes)] -const USER_SETTINGS: [SettingsMenuRow; 9] = [ +const USER_SETTINGS: [SettingsMenuRow; 10] = [ (SettingsMenuAction::SwitchMode, "Switch Mode (User ↔ Admin)"), ( SettingsMenuAction::ChangeMostroPubkey, @@ -65,6 +66,10 @@ const USER_SETTINGS: [SettingsMenuRow; 9] = [ "Clear Currency Filters", ), (SettingsMenuAction::ViewSeedWords, "View Seed Words"), + ( + SettingsMenuAction::RestoreSession, + "Restore Session (from Mostro)", + ), (SettingsMenuAction::GenerateNewKeys, "Generate New Keys"), ]; @@ -198,3 +203,35 @@ pub fn render_settings_tab( chunks[4], ); } + +#[cfg(test)] +mod settings_menu_tests { + use super::*; + + #[test] + fn restore_session_is_a_user_option_but_not_an_admin_one() { + assert!(USER_SETTINGS + .iter() + .any(|(a, _)| *a == SettingsMenuAction::RestoreSession)); + // Admin mode signs with admin_privkey, not the identity mnemonic Mostro + // indexes users by, so a restore there would recover nothing. + assert!(!ADMIN_SETTINGS + .iter() + .any(|(a, _)| *a == SettingsMenuAction::RestoreSession)); + } + + #[test] + fn user_menu_keeps_restore_next_to_the_key_management_rows() { + let labels: Vec<&str> = USER_SETTINGS.iter().map(|(_, l)| *l).collect(); + let seed = labels.iter().position(|l| *l == "View Seed Words").unwrap(); + let restore = labels + .iter() + .position(|l| *l == "Restore Session (from Mostro)") + .unwrap(); + let generate = labels + .iter() + .position(|l| *l == "Generate New Keys") + .unwrap(); + assert!(seed < restore && restore < generate); + } +} diff --git a/src/util/dm_utils/order_ch_mng.rs b/src/util/dm_utils/order_ch_mng.rs index 762367a1..c0f491d5 100644 --- a/src/util/dm_utils/order_ch_mng.rs +++ b/src/util/dm_utils/order_ch_mng.rs @@ -129,6 +129,9 @@ pub fn handle_operation_result(mut result: OperationResult, app: &mut AppState) remove_many_orders_from_messages_tab(app, &deleted_order_ids); result = OperationResult::Info(message); } + if let OperationResult::SessionRestored { message } = result { + result = OperationResult::Info(message); + } if let OperationResult::InvoiceSubmitted { message, remember_buyer_saved_ln_address_for_order, diff --git a/src/util/order_utils/execute_restore.rs b/src/util/order_utils/execute_restore.rs new file mode 100644 index 00000000..609876f1 --- /dev/null +++ b/src/util/order_utils/execute_restore.rs @@ -0,0 +1,494 @@ +// Session restore: recover orders and disputes for this identity from Mostro. +use anyhow::Result; +use mostro_core::prelude::*; +use nostr_sdk::prelude::*; +use sqlx::SqlitePool; +use std::str::FromStr; +use tokio::sync::mpsc::UnboundedSender; + +use crate::models::{Order, User}; +use crate::util::dm_utils::{ + parse_dm_events, send_dm, wait_for_dm, OrderDmSubscriptionCmd, FETCH_EVENTS_TIMEOUT, +}; +use crate::util::mostro_info::MostroInstanceInfo; +use crate::util::types::get_cant_do_description; + +use super::helper::{fetch_small_order_by_id_from_relay, is_terminal_trade_status}; + +/// Outcome of a session restore, for the result popup. +#[derive(Debug, Default)] +pub struct RestoreSummary { + /// Orders inserted into the local database. + pub restored: usize, + /// Orders that already existed locally (only their status was refreshed). + pub already_known: usize, + /// Restored orders whose details could not be found on the relays + /// (persisted with what Mostro returned: id, trade index and status). + pub missing_details: usize, + /// Restored orders whose maker/taker role could not be determined + /// (persisted as taker). + pub role_unknown: usize, + /// Orders that could not be persisted at all. + pub failed: usize, + /// Disputes reported by Mostro for this identity. + pub disputes: usize, + /// Disputes whose local order could not be moved to `Dispute` (row missing + /// or write failed) — the dispute is still real on Mostro's side. + pub dispute_status_failed: usize, +} + +impl RestoreSummary { + pub fn to_user_message(&self) -> String { + let mut msg = format!( + "Session restored: {} order(s) recovered, {} already known, {} dispute(s).", + self.restored, self.already_known, self.disputes + ); + if self.missing_details > 0 { + msg.push_str(&format!( + " {} order(s) had no relay details and were saved with minimal info.", + self.missing_details + )); + } + if self.role_unknown > 0 { + msg.push_str(&format!( + " {} order(s) restored with unknown maker/taker role (shown as taker).", + self.role_unknown + )); + } + if self.failed > 0 { + msg.push_str(&format!( + " {} order(s) could not be saved — see log.", + self.failed + )); + } + if self.dispute_status_failed > 0 { + msg.push_str(&format!( + " {} dispute(s) could not be marked locally — see log.", + self.dispute_status_failed + )); + } + msg + } +} + +/// Map the outcome of [`execute_restore_session`] to the operation result the +/// restore task must emit. `Ok` MUST become [`OperationResult::SessionRestored`] +/// — not a plain `Info` — because only that variant makes `apply_order_result` +/// re-run the DB-to-UI projection sync; with `Info` the restored rows stay +/// invisible until a later sync or restart. +pub fn restore_completion_result(outcome: &Result) -> crate::ui::OperationResult { + match outcome { + Ok(summary) => crate::ui::OperationResult::SessionRestored { + message: summary.to_user_message(), + }, + Err(e) => crate::ui::OperationResult::Error(format!("Restore failed: {e}")), + } +} + +/// Ask Mostro for this identity's session state (`Action::RestoreSession`) and +/// rebuild the local database from the answer. +/// +/// Restore is account-scoped: Mostro indexes users by identity pubkey, so the +/// whole exchange (send, wait, decrypt) runs on the identity keys — a trade key +/// would look like an unknown user and recovery would return nothing. The +/// request carries no request id (`Message::new_restore`), so the response is +/// validated by action instead of by id. +/// +/// For every order Mostro reports, the trade keys are re-derived from the +/// user's mnemonic at the reported trade index, full details are fetched from +/// the relays when available, and the row is inserted locally. Non-terminal +/// orders are handed to the DM router (`TrackOrder`) so their messages route +/// live without a restart. `last_trade_index` advances to the highest index +/// seen so future trades never reuse a key. +pub async fn execute_restore_session( + pool: &SqlitePool, + client: &Client, + mostro_pubkey: PublicKey, + mostro_instance: Option<&MostroInstanceInfo>, + dm_subscription_tx: UnboundedSender, +) -> Result { + let user = User::get(pool).await?; + let identity_keys = User::get_identity_keys(pool).await?; + + let message = Message::new_restore(None); + let message_json = message + .as_json() + .map_err(|e| anyhow::anyhow!("Failed to serialize message: {e}"))?; + + log::info!( + "Restore: requesting session state from {mostro_pubkey} as {}", + identity_keys.public_key() + ); + + let sent_message = send_dm( + client, + Some(&identity_keys), + &identity_keys, + &mostro_pubkey, + message_json, + None, + mostro_instance, + ); + + let recv_event = wait_for_dm(&identity_keys, FETCH_EVENTS_TIMEOUT, sent_message).await?; + let messages = parse_dm_events(recv_event, &identity_keys, None).await; + + let Some((response_message, _, sender)) = messages.first() else { + return Err(anyhow::anyhow!("No response received from Mostro")); + }; + // The restore request carries no request id, so unlike the order flows the + // response cannot be tied back by a random id only Mostro could echo. The + // sender check is the only thing standing between us and a forged + // gift-wrapped RestoreData seeding attacker-controlled orders. + if sender != &mostro_pubkey { + return Err(anyhow::anyhow!( + "Restore response signed by {sender}, expected the configured Mostro instance" + )); + } + let inner = response_message.get_inner_message_kind(); + + if let Some(Payload::CantDo(reason)) = &inner.payload { + let error_msg = match reason { + Some(r) => get_cant_do_description(r), + None => "Unknown error - Mostro couldn't process your request".to_string(), + }; + return Err(anyhow::anyhow!(error_msg)); + } + if inner.action != Action::RestoreSession { + return Err(anyhow::anyhow!( + "Unexpected action in response: {:?}", + inner.action + )); + } + let Some(Payload::RestoreData(restore_data)) = &inner.payload else { + return Err(anyhow::anyhow!("No restore data payload in response")); + }; + + let mut summary = RestoreSummary { + disputes: restore_data.restore_disputes.len(), + ..Default::default() + }; + + // Advance last_trade_index BEFORE writing any order row. Mostro's index is + // authoritative, and this ordering guarantees the failure mode is always + // "index bumped, some rows missing" (a re-run repairs it) and never "rows + // with restored trade keys present, index stale" (a later order would reuse + // a restored key). If this write fails nothing else has been touched. + let max_trade_index = restore_data + .restore_orders + .iter() + .map(|o| o.trade_index) + .chain(restore_data.restore_disputes.iter().map(|d| d.trade_index)) + .max() + .unwrap_or(0); + if max_trade_index > user.last_trade_index.unwrap_or(0) { + User::update_last_trade_index(pool, max_trade_index).await?; + } + + for info in &restore_data.restore_orders { + match restore_one_order(pool, client, mostro_pubkey, &user, info).await { + Ok(RestoredAs::Inserted { + with_details, + role_unknown, + }) => { + summary.restored += 1; + if !with_details { + summary.missing_details += 1; + } + if role_unknown { + summary.role_unknown += 1; + } + } + Ok(RestoredAs::AlreadyKnown) => summary.already_known += 1, + Err(e) => { + // Keep going: one bad order must not abort the recovery of the rest. + log::error!("Restore failed for order {}: {e}", info.order_id); + summary.failed += 1; + continue; + } + } + + let terminal = Status::from_str(&info.status) + .map(is_terminal_trade_status) + .unwrap_or(false); + if !terminal { + let _ = dm_subscription_tx.send(OrderDmSubscriptionCmd::TrackOrder { + order_id: info.order_id, + trade_index: info.trade_index, + }); + } + } + + // Disputed orders come back in the orders list too; here we only make sure + // their local status reflects the dispute. User-side solver chat is not + // wired yet, so initiator/solver info has nowhere to be stored. + for dispute in &restore_data.restore_disputes { + let id_str = dispute.order_id.to_string(); + // UPDATE on a missing row is a silent no-op, so check presence first: + // a dispute whose order failed to restore must not be reported as applied. + let applied = match Order::get_by_id(pool, &id_str).await { + Ok(_) => Order::update_status(pool, &id_str, Status::Dispute) + .await + .map_err(|e| e.to_string()), + Err(e) => Err(format!("order row not present: {e}")), + }; + if let Err(e) = applied { + log::error!( + "Restore: could not mark order {} as disputed (dispute {}): {e}", + dispute.order_id, + dispute.dispute_id + ); + summary.dispute_status_failed += 1; + } + } + + // A successful restore used to leave no trace at all in the log, which made + // "did it run and find nothing?" indistinguishable from "did it run?". + log::info!("Restore: {}", summary.to_user_message()); + + Ok(summary) +} + +enum RestoredAs { + Inserted { + with_details: bool, + role_unknown: bool, + }, + AlreadyKnown, +} + +/// Maker/taker resolution for a restored order. +/// +/// Neither the restore payload nor the public relay events carry the role +/// (kind-38383 tags stop at the order terms — no buyer/seller pubkeys), so it +/// has to be inferred where the protocol allows it: +/// - `Pending` / `WaitingMakerBond` orders exist only for their maker; any +/// taker interaction immediately moves the order out of those states. +/// - Anything else is genuinely ambiguous. Those rows fall back to taker and +/// are counted in the summary so the fallback is never silent. +#[derive(Debug, PartialEq, Eq)] +enum RestoredRole { + Maker, + UnknownAsTaker, +} + +/// A row persisted without relay details: `Order::new` from a default +/// `SmallOrder` leaves `fiat_code` empty, which no real order has. +fn is_minimal_placeholder(order: &Order) -> bool { + order.fiat_code.trim().is_empty() +} + +fn restored_order_role(status: Option) -> RestoredRole { + match status { + Some(Status::Pending) | Some(Status::WaitingMakerBond) => RestoredRole::Maker, + _ => RestoredRole::UnknownAsTaker, + } +} + +async fn restore_one_order( + pool: &SqlitePool, + client: &Client, + mostro_pubkey: PublicKey, + user: &User, + info: &RestoredOrdersInfo, +) -> Result { + let id_str = info.order_id.to_string(); + + // A row from an earlier restore that never got relay details (empty fiat + // code is impossible for a real order) is treated as absent so this run + // retries the relay lookup and rehydrates it, instead of freezing the + // minimal placeholder forever. + if let Ok(existing) = Order::get_by_id(pool, &id_str).await { + if !is_minimal_placeholder(&existing) { + if let Ok(status) = Status::from_str(&info.status) { + Order::update_status(pool, &id_str, status).await?; + } + return Ok(RestoredAs::AlreadyKnown); + } + } + + let trade_keys = user.derive_trade_keys(info.trade_index)?; + + // A relay lookup *error* is not "not found": log it so a flaky relay is + // visible, but still persist the minimal row so the trade key is not lost. + let relay_order = + match fetch_small_order_by_id_from_relay(client, mostro_pubkey, info.order_id).await { + Ok(found) => found, + Err(e) => { + log::warn!( + "Restore: relay lookup failed for order {} (saving minimal row): {e}", + info.order_id + ); + None + } + }; + let with_details = relay_order.is_some(); + let mut small_order = relay_order.unwrap_or_default(); + small_order.id = Some(info.order_id); + // Mostro's database is authoritative for the status; relay events may lag. + if let Ok(status) = Status::from_str(&info.status) { + small_order.status = Some(status); + } + + let role = restored_order_role(small_order.status); + Order::new( + pool, + small_order, + &trade_keys, + None, + info.trade_index, + matches!(role, RestoredRole::Maker), + ) + .await?; + Ok(RestoredAs::Inserted { + with_details, + role_unknown: matches!(role, RestoredRole::UnknownAsTaker), + }) +} + +#[cfg(test)] +mod tests { + use super::{ + is_minimal_placeholder, restore_completion_result, restored_order_role, RestoreSummary, + RestoredRole, + }; + use crate::ui::OperationResult; + use mostro_core::prelude::Status; + + #[test] + fn successful_restore_emits_session_restored_not_info() { + // Regression (#114 review, twice): only SessionRestored makes + // apply_order_result re-run the DB-to-UI sync. A plain Info here means + // the restored rows stay invisible until restart. + let summary = RestoreSummary { + restored: 2, + ..Default::default() + }; + let expected = summary.to_user_message(); + match restore_completion_result(&Ok(summary)) { + OperationResult::SessionRestored { message } => assert_eq!(message, expected), + other => panic!("expected SessionRestored, got {other:?}"), + } + } + + #[test] + fn failed_restore_emits_an_error_result() { + match restore_completion_result(&Err(anyhow::anyhow!("boom"))) { + OperationResult::Error(message) => assert!(message.contains("boom")), + other => panic!("expected Error, got {other:?}"), + } + } + + #[test] + fn summary_message_covers_the_happy_path() { + let s = RestoreSummary { + restored: 3, + already_known: 1, + disputes: 1, + ..Default::default() + }; + assert_eq!( + s.to_user_message(), + "Session restored: 3 order(s) recovered, 1 already known, 1 dispute(s)." + ); + } + + #[test] + fn maker_is_inferred_only_from_maker_exclusive_statuses() { + // A pending / waiting-maker-bond order can only exist for its maker. + assert_eq!( + restored_order_role(Some(Status::Pending)), + RestoredRole::Maker + ); + assert_eq!( + restored_order_role(Some(Status::WaitingMakerBond)), + RestoredRole::Maker + ); + // Anything else is ambiguous: fall back to taker, but never silently. + assert_eq!( + restored_order_role(Some(Status::Active)), + RestoredRole::UnknownAsTaker + ); + assert_eq!( + restored_order_role(Some(Status::FiatSent)), + RestoredRole::UnknownAsTaker + ); + assert_eq!(restored_order_role(None), RestoredRole::UnknownAsTaker); + } + + #[test] + fn placeholder_rows_are_detected_by_their_empty_fiat_code() { + // Regression: a row saved without relay details must be re-hydrated on + // the next restore instead of being frozen as AlreadyKnown forever. + let mut order = crate::models::Order { + id: Some("x".into()), + kind: None, + status: None, + amount: 0, + fiat_code: String::new(), + min_amount: None, + max_amount: None, + fiat_amount: 0, + payment_method: String::new(), + premium: 0, + trade_keys: None, + counterparty_pubkey: None, + order_chat_shared_key_hex: None, + is_mine: false, + buyer_invoice: None, + request_id: None, + trade_index: Some(1), + created_at: None, + expires_at: None, + last_seen_dm_ts: None, + }; + assert!(is_minimal_placeholder(&order)); + order.fiat_code = "EUR".into(); + assert!(!is_minimal_placeholder(&order)); + } + + #[test] + fn summary_message_reports_dispute_status_failures() { + let s = RestoreSummary { + disputes: 2, + dispute_status_failed: 1, + ..Default::default() + } + .to_user_message(); + assert!(s.contains("1 dispute(s) could not be marked locally")); + assert!(!RestoreSummary::default() + .to_user_message() + .contains("could not be marked")); + } + + #[test] + fn summary_message_reports_unknown_roles() { + let s = RestoreSummary { + restored: 2, + role_unknown: 2, + ..Default::default() + } + .to_user_message(); + assert!(s.contains("2 order(s) restored with unknown maker/taker role")); + assert!(!RestoreSummary::default() + .to_user_message() + .contains("maker/taker")); + } + + #[test] + fn summary_message_mentions_missing_details_and_failures_only_when_present() { + let clean = RestoreSummary::default().to_user_message(); + assert!(!clean.contains("relay details")); + assert!(!clean.contains("could not be saved")); + + let bumpy = RestoreSummary { + restored: 2, + missing_details: 1, + failed: 1, + ..Default::default() + } + .to_user_message(); + assert!(bumpy.contains("1 order(s) had no relay details")); + assert!(bumpy.contains("1 order(s) could not be saved")); + } +} diff --git a/src/util/order_utils/mod.rs b/src/util/order_utils/mod.rs index 71bc712a..54b59e05 100644 --- a/src/util/order_utils/mod.rs +++ b/src/util/order_utils/mod.rs @@ -5,6 +5,7 @@ mod execute_admin_add_solver; mod execute_admin_cancel; mod execute_admin_settle; mod execute_finalize_dispute; +mod execute_restore; mod execute_send_msg; mod execute_take_dispute; mod fetch_scheduler; @@ -20,6 +21,7 @@ pub use execute_admin_add_solver::execute_admin_add_solver; pub use execute_admin_cancel::execute_admin_cancel; pub use execute_admin_settle::execute_admin_settle; pub use execute_finalize_dispute::execute_finalize_dispute; +pub use execute_restore::{execute_restore_session, restore_completion_result, RestoreSummary}; pub use execute_send_msg::{execute_dispute, execute_rate_user, execute_send_msg}; pub use execute_take_dispute::execute_take_dispute; pub use fetch_scheduler::{