// 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, } 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 )); } 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}"))?; 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() }; let mut max_trade_index: i64 = 0; for info in &restore_data.restore_orders { max_trade_index = max_trade_index.max(info.trade_index); 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 { max_trade_index = max_trade_index.max(dispute.trade_index); let _ = Order::update_status(pool, &dispute.order_id.to_string(), Status::Dispute).await; } if max_trade_index > user.last_trade_index.unwrap_or(0) { User::update_last_trade_index(pool, max_trade_index).await?; } 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, } 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(); if Order::get_by_id(pool, &id_str).await.is_ok() { if let Ok(status) = Status::from_str(&info.status) { let _ = Order::update_status(pool, &id_str, status).await; } return Ok(RestoredAs::AlreadyKnown); } let trade_keys = user.derive_trade_keys(info.trade_index)?; let relay_order = fetch_small_order_by_id_from_relay(client, mostro_pubkey, info.order_id) .await .unwrap_or_default(); 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::{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 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")); } }