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
32 changes: 30 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,15 @@ async fn apply_order_result(pool: &SqlitePool, app: &mut AppState, result: Opera
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 = matches!(
&result,
OperationResult::OrderHistoryDeleted { .. } | OperationResult::OrdersRefreshed { .. }
);
let refresh_maker_book_cache = matches!(
&result,
OperationResult::MyTradesMakerBookChanged | OperationResult::Success(_)
OperationResult::MyTradesMakerBookChanged
| OperationResult::Success(_)
| OperationResult::OrdersRefreshed { .. }
);

if refresh_maker_book_cache && app.user_role == UserRole::User {
Expand Down Expand Up @@ -808,3 +813,26 @@ async fn main() -> Result<(), anyhow::Error> {

Ok(())
}

#[cfg(test)]
mod apply_order_result_tests {
use crate::ui::OperationResult;

/// Mirrors the predicate in `apply_order_result`: results produced by
/// background work that rewrote SQLite must re-run the DB-to-UI sync, or the
/// refreshed rows stay invisible until restart.
fn triggers_db_resync(result: &OperationResult) -> bool {
matches!(
result,
OperationResult::OrderHistoryDeleted { .. } | OperationResult::OrdersRefreshed { .. }
)
}

#[test]
fn orders_refresh_triggers_the_db_resync() {
assert!(triggers_db_resync(&OperationResult::OrdersRefreshed {
message: String::new()
}));
assert!(!triggers_db_resync(&OperationResult::Info(String::new())));
}
}
1 change: 1 addition & 0 deletions src/ui/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ pub const HELP_MY_TRADES_SHIFT_F_FIAT_SENT: &str = "Shift+F: Mark fiat as sent (
pub const HELP_MY_TRADES_SHIFT_R_RELEASE: &str = "Shift+R: Release sats (Release message)";
pub const HELP_MY_TRADES_SHIFT_V_RATE: &str = "Shift+V: Rate counterparty (open rating popup)";
pub const HELP_MY_TRADES_SHIFT_D_DISPUTE: &str = "Shift+D: Open a dispute (Dispute message)";
pub const HELP_MY_TRADES_SHIFT_U_REFRESH: &str = "Shift+U: Refresh order details from Mostro";
pub const HELP_MY_TRADES_SHIFT_H_HELP: &str = "Shift+H: Show shortcuts help";
pub const HELP_MY_TRADES_SHIFT_K_KCONV: &str =
"Shift+K: Reveal Shared key (read-only grant for solvers; never your signing key)";
Expand Down
5 changes: 5 additions & 0 deletions src/ui/help_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ fn help_content(app: &AppState, tab: Tab) -> (String, Vec<String>) {
HELP_MY_TRADES_SHIFT_R_RELEASE.to_string(),
HELP_MY_TRADES_SHIFT_V_RATE.to_string(),
HELP_MY_TRADES_SHIFT_D_DISPUTE.to_string(),
HELP_MY_TRADES_SHIFT_U_REFRESH.to_string(),
HELP_MY_TRADES_SHIFT_K_KCONV.to_string(),
HELP_MY_TRADES_CTRL_S_ATTACH.to_string(),
HELP_MY_TRADES_CTRL_O_SEND.to_string(),
Expand Down Expand Up @@ -473,6 +474,10 @@ mod help_content_tests {
lines.iter().any(|l| l == HELP_MY_TRADES_SHIFT_D_DISPUTE),
"Shift+D missing from My Trades help: {lines:?}"
);
assert!(
lines.iter().any(|l| l == HELP_MY_TRADES_SHIFT_U_REFRESH),
"Shift+U missing from My Trades help: {lines:?}"
);
assert!(
lines.iter().any(|l| l == HELP_MY_TRADES_SHIFT_K_KCONV),
"Shift+K missing from My Trades help: {lines:?}"
Expand Down
65 changes: 64 additions & 1 deletion src/ui/key_handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ mod validation;

use crate::ui::key_handler::chat_helpers::{
build_order_action_view_state, build_rating_state_for_mytrades,
resolve_selected_mytrades_order_status,
resolve_selected_mytrades_order_id, resolve_selected_mytrades_order_status,
};
use crate::ui::{
helpers::{
Expand Down Expand Up @@ -154,6 +154,53 @@ fn dispute_shortcut_next_mode(
Some(UiMode::ViewingMessage(view_state))
}

/// Ask Mostro for the selected order's authoritative details and merge them
/// into SQLite, then let the main loop resync the projections.
///
/// Uses the live Mostro pubkey (not a settings snapshot) and the cached
/// kind-38385 info, which carries the PoW difficulty the instance requires —
/// without it the request is rejected outright by instances that enforce PoW.
fn spawn_orders_info(
order_id: uuid::Uuid,
pool: &SqlitePool,
client: &Client,
current_mostro_pubkey: &Arc<Mutex<PublicKey>>,
mostro_info: Option<MostroInstanceInfo>,
order_result_tx: &UnboundedSender<OperationResult>,
) {
let Ok(mostro_pubkey) = current_mostro_pubkey.lock().map(|pk| *pk) else {
crate::util::request_fatal_restart(
"Mostrix encountered an internal error (poisoned Mostro pubkey lock). Please restart the app."
.to_string(),
);
return;
};
let pool = pool.clone();
let client = client.clone();
let result_tx = order_result_tx.clone();
tokio::spawn(async move {
match crate::util::order_utils::execute_orders_info(
&[order_id],
&pool,
&client,
mostro_pubkey,
mostro_info.as_ref(),
)
.await
{
Ok(summary) => {
let _ = result_tx.send(OperationResult::OrdersRefreshed {
message: summary.to_user_message(),
});
}
Err(e) => {
log::error!("Orders info failed for {order_id}: {e}");
let _ = result_tx.send(OperationResult::Error(format!("Refresh failed: {e}")));
}
}
});
}

// Re-export public functions
pub use async_tasks::{
apply_pending_fetch_scheduler_reload, apply_pending_key_reload, apply_pending_runtime_reloads,
Expand Down Expand Up @@ -1212,6 +1259,22 @@ pub fn handle_key_event(
return Some(true);
}
}
KeyCode::Char('u') | KeyCode::Char('U') => {
if !app.mode.user_my_trades_interactive() {
return Some(true);
}
if let Some(order_id) = resolve_selected_mytrades_order_id(app) {
spawn_orders_info(
order_id,
pool,
client,
current_mostro_pubkey,
app.mostro_info.clone(),
order_result_tx,
);
return Some(true);
}
}
KeyCode::Char('v') | KeyCode::Char('V') => {
if let Some(state) = build_rating_state_for_mytrades(app, 5) {
app.mode = UiMode::RatingOrder(state);
Expand Down
5 changes: 4 additions & 1 deletion src/ui/operation_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,9 @@ pub fn render_operation_result(f: &mut ratatui::Frame, result: &OperationResult)
OperationResult::PaymentRequestRequired { .. }
| OperationResult::ObserverChatLoaded { .. }
| OperationResult::ObserverChatError { .. } => 8,
OperationResult::Info(message) => info_popup_height(message, popup_width),
OperationResult::Info(message) | OperationResult::OrdersRefreshed { message } => {
info_popup_height(message, popup_width)
}
OperationResult::Error(_)
| OperationResult::InvoiceSubmitted { .. }
| OperationResult::TradeClosed { .. }
Expand Down Expand Up @@ -357,6 +359,7 @@ pub fn render_operation_result(f: &mut ratatui::Frame, result: &OperationResult)
f.render_widget(paragraph, inner);
}
OperationResult::Info(message)
| OperationResult::OrdersRefreshed { message }
| OperationResult::InvoiceSubmitted { message, .. }
| OperationResult::TradeClosed { message, .. }
| OperationResult::OrderHistoryDeleted { message, .. } => {
Expand Down
5 changes: 5 additions & 0 deletions src/ui/orders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ pub enum OperationResult {
},
/// Rebuild [`crate::ui::AppState::my_trades_maker_book`] from SQLite (no UI popup).
MyTradesMakerBookChanged,
/// Orders refreshed from Mostro (`Action::Orders`): resync the My Trades and
/// Messages projections from SQLite, then show `message`.
OrdersRefreshed {
message: String,
},
/// Open invoice / waiting popup from a synchronous execute reply (e.g. bond payout DM).
OpenInvoicePopup {
notification: MessageNotification,
Expand Down
3 changes: 3 additions & 0 deletions src/util/dm_utils/order_ch_mng.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::OrdersRefreshed { message } = result {
result = OperationResult::Info(message);
}
if let OperationResult::InvoiceSubmitted {
message,
remember_buyer_saved_ln_address_for_order,
Expand Down
182 changes: 182 additions & 0 deletions src/util/order_utils/execute_orders_info.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Ask Mostro for authoritative details of the user's own orders.
use anyhow::Result;
use mostro_core::prelude::*;
use nostr_sdk::prelude::*;
use sqlx::SqlitePool;
use uuid::Uuid;

use crate::models::{Order, User};
use crate::util::dm_utils::{parse_dm_events, send_dm, wait_for_dm, FETCH_EVENTS_TIMEOUT};
use crate::util::mostro_info::MostroInstanceInfo;

use super::helper::handle_mostro_response;

/// Outcome of an orders-info refresh, for the result popup.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct OrdersInfoSummary {
/// Orders Mostro returned and that were merged into the local database.
pub refreshed: usize,
/// Orders Mostro returned that could not be persisted locally.
pub failed: usize,
}

impl OrdersInfoSummary {
pub fn to_user_message(&self) -> String {
let mut msg = format!("Refreshed {} order(s) from Mostro.", self.refreshed);
if self.failed > 0 {
msg.push_str(&format!(
" {} could not be saved locally — see log.",
self.failed
));
}
msg
}
}

/// Request full details for `order_ids` (`Action::Orders`) and merge them locally.
///
/// Account-scoped, like restore: Mostro resolves the ids against the requesting
/// **identity** pubkey (`get_user_orders_by_id`) and answers `CantDo(NotFound)`
/// for anything that is not yours, so the whole exchange runs on the identity
/// keys and carries no trade index.
///
/// Mostro's database is the authority here: unlike the public kind-38383 events,
/// which only carry the terms of pending orders, this answer includes the buyer
/// and seller trade pubkeys. Merging goes through
/// [`Order::upsert_from_small_order_dm`], which keeps the row's trade keys,
/// dispute and chat columns intact and can derive the peer chat secret once
/// those pubkeys are known.
pub async fn execute_orders_info(
order_ids: &[Uuid],
pool: &SqlitePool,
client: &Client,
mostro_pubkey: PublicKey,
mostro_instance: Option<&MostroInstanceInfo>,
) -> Result<OrdersInfoSummary> {
if order_ids.is_empty() {
return Err(anyhow::anyhow!("No order selected"));
}

let identity_keys = User::get_identity_keys(pool).await?;
let request_id = Uuid::new_v4().as_u128() as u64;
let message = Message::new_order(
None,
Some(request_id),
None,
Action::Orders,
Some(Payload::Ids(order_ids.to_vec())),
);
let message_json = message
.as_json()
.map_err(|e| anyhow::anyhow!("Failed to serialize message: {e}"))?;

log::info!(
"OrdersInfo: requesting {} order(s) from {mostro_pubkey}",
order_ids.len()
);

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"));
};
if sender != &mostro_pubkey {
return Err(anyhow::anyhow!(
"Orders response signed by {sender}, expected the configured Mostro instance"
));
}

let inner = handle_mostro_response(response_message, request_id)?;
if inner.action != Action::Orders {
return Err(anyhow::anyhow!(
"Unexpected action in response: {:?}",
inner.action
));
}
let Some(Payload::Orders(orders)) = &inner.payload else {
return Err(anyhow::anyhow!("No orders payload in response"));
};

let mut summary = OrdersInfoSummary::default();
for small_order in orders {
let Some(order_id) = small_order.id else {
log::warn!("OrdersInfo: Mostro returned an order without id, skipping");
summary.failed += 1;
continue;
};
let id_str = order_id.to_string();

// Only refresh rows we already hold: the trade keys live there and must
// not be invented, and an id we never traded has nothing to merge onto.
let trade_keys = match Order::get_by_id(pool, &id_str).await {
Ok(row) => row
.trade_keys
.as_deref()
.and_then(|hex| Keys::parse(hex).ok()),
Err(e) => {
log::warn!("OrdersInfo: no local row for {id_str}: {e}");
summary.failed += 1;
continue;
}
};
let Some(trade_keys) = trade_keys else {
log::warn!("OrdersInfo: local row {id_str} has no usable trade keys");
summary.failed += 1;
continue;
};

match Order::upsert_from_small_order_dm(
pool,
order_id,
small_order.clone(),
&trade_keys,
None,
)
.await
{
Ok(_) => summary.refreshed += 1,
Err(e) => {
log::error!("OrdersInfo: failed to merge order {id_str}: {e}");
summary.failed += 1;
}
}
}

log::info!("OrdersInfo: {}", summary.to_user_message());
Ok(summary)
}

#[cfg(test)]
mod tests {
use super::OrdersInfoSummary;

#[test]
fn summary_message_reports_failures_only_when_present() {
assert_eq!(
OrdersInfoSummary {
refreshed: 2,
failed: 0
}
.to_user_message(),
"Refreshed 2 order(s) from Mostro."
);
let bumpy = OrdersInfoSummary {
refreshed: 1,
failed: 2,
}
.to_user_message();
assert!(bumpy.contains("Refreshed 1 order(s)"));
assert!(bumpy.contains("2 could not be saved locally"));
}
}
2 changes: 2 additions & 0 deletions src/util/order_utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod execute_admin_add_solver;
mod execute_admin_cancel;
mod execute_admin_settle;
mod execute_finalize_dispute;
mod execute_orders_info;
mod execute_send_msg;
mod execute_take_dispute;
mod fetch_scheduler;
Expand All @@ -21,6 +22,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_orders_info::{execute_orders_info, OrdersInfoSummary};
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::{
Expand Down