From e9c1f29b21723dbb8724ca543c7c156b47e76927 Mon Sep 17 00:00:00 2001 From: codaMW Date: Tue, 28 Apr 2026 10:26:47 +0200 Subject: [PATCH] fix(restore-session): re-send AddInvoice for failed payments on session restore When a buyer performs restore-session after a Lightning payment fails in settled-hold-invoice state, Mostro was returning the order without re-sending the AddInvoice action. The buyer had no way to know the payment failed and that a new invoice was required. Fix: after sending the restore-session response, query for orders with failed_payment == true and status == settled-hold-invoice. For any that match the restored order IDs, re-queue Action::AddInvoice to the buyer so they receive the prompt to submit a new invoice. The existing payment safety model is unchanged - atomic state transitions and serial scheduler execution already prevent double payments. Closes #601 --- src/app/restore_session.rs | 57 +++++++++++++++++--- src/db.rs | 106 +++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 7 deletions(-) diff --git a/src/app/restore_session.rs b/src/app/restore_session.rs index 8c874efe..a706157e 100644 --- a/src/app/restore_session.rs +++ b/src/app/restore_session.rs @@ -1,5 +1,6 @@ use crate::app::context::AppContext; -use crate::{db::RestoreSessionManager, util::enqueue_restore_session_msg}; +use crate::db::{find_failed_payment_for_master_key, RestoreSessionManager}; +use crate::util::{enqueue_order_msg, enqueue_restore_session_msg}; use mostro_core::prelude::*; use nostr_sdk::prelude::*; @@ -10,7 +11,7 @@ pub async fn restore_session_action( ctx: &AppContext, event: &UnwrappedMessage, ) -> Result<(), MostroError> { - let pool = ctx.pool(); + let pool = ctx.pool_arc(); // Get user master key from the event sender let master_key = event.identity.to_string(); // Get trade key from the event rumor @@ -33,23 +34,27 @@ pub async fn restore_session_action( // Create a new manager for this specific restore session let manager = RestoreSessionManager::new(); - let pool_clone = pool.clone(); // Start the background processing manager - .start_restore_session(pool_clone, master_key.clone()) + .start_restore_session(pool.as_ref().clone(), master_key.clone()) .await?; // Start a background task to handle the results tokio::spawn(async move { - handle_restore_session_results(manager, trade_key).await; + handle_restore_session_results(manager, trade_key, master_key, pool).await; }); Ok(()) } /// Handle restore session results in the background -async fn handle_restore_session_results(mut manager: RestoreSessionManager, trade_key: String) { +async fn handle_restore_session_results( + mut manager: RestoreSessionManager, + trade_key: String, + master_key: String, + pool: std::sync::Arc, +) { // Wait for the result with a timeout let timeout = tokio::time::Duration::from_secs(60 * 60); // 1 hour timeout @@ -58,8 +63,10 @@ async fn handle_restore_session_results(mut manager: RestoreSessionManager, trad // Send the restore session response if let Err(e) = send_restore_session_response( &trade_key, + &master_key, result.restore_orders, result.restore_disputes, + pool.clone(), ) .await { @@ -82,13 +89,19 @@ async fn handle_restore_session_results(mut manager: RestoreSessionManager, trad /// Send restore session response to the user async fn send_restore_session_response( trade_key: &str, + master_key: &str, orders: Vec, disputes: Vec, + pool: std::sync::Arc, ) -> Result<(), MostroError> { // Convert trade_key string to PublicKey let trade_pubkey = PublicKey::from_hex(trade_key).map_err(|_| MostroCantDo(CantDoReason::InvalidPubkey))?; + // Collect restored order IDs before moving orders into the payload + let restored_ids: std::collections::HashSet = + orders.iter().map(|o| o.order_id).collect(); + // Send the order data using the flat structure enqueue_restore_session_msg( Some(Payload::RestoreData(RestoreSessionInfo { @@ -99,7 +112,37 @@ async fn send_restore_session_response( ) .await; - tracing::info!("Restore session response sent to user {}", trade_key,); + tracing::info!("Restore session response sent to user {}", trade_key); + + // Re-send AddInvoice for any orders stuck in settled-hold-invoice with failed payment + + match find_failed_payment_for_master_key(&pool, master_key).await { + Ok(failed_orders) => { + for order in failed_orders { + if restored_ids.contains(&order.id) { + enqueue_order_msg( + None, + Some(order.id), + Action::AddInvoice, + Some(Payload::Order(SmallOrder::from(order.clone()))), + trade_pubkey, + None, + ) + .await; + tracing::info!( + "Re-sent AddInvoice for order {} on restore-session (failed payment)", + order.id + ); + } + } + } + Err(e) => { + tracing::error!( + "Failed to query failed payments during restore-session: {}", + e + ); + } + } Ok(()) } diff --git a/src/db.rs b/src/db.rs index 92fb95bb..df7d5ce2 100644 --- a/src/db.rs +++ b/src/db.rs @@ -568,6 +568,29 @@ pub async fn edit_pubkeys_order(pool: &SqlitePool, order: &Order) -> Result Result, MostroError> { + let orders = sqlx::query_as::<_, Order>( + r#" + SELECT * + FROM orders + WHERE failed_payment = true + AND status = 'settled-hold-invoice' + AND master_buyer_pubkey = ?1 + "#, + ) + .bind(master_key) + .fetch_all(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + Ok(orders) +} + pub async fn find_order_by_hash(pool: &SqlitePool, hash: &str) -> Result { let order = sqlx::query_as::<_, Order>( r#" @@ -1660,6 +1683,89 @@ mod tests { ); } + // -- Tests for find_failed_payment_for_master_key -- + #[tokio::test] + async fn test_find_failed_payment_for_master_key_returns_matching() { + let pool = setup_orders_db().await.unwrap(); + let master_key = "a".repeat(64); + // Insert matching order: correct master_buyer_pubkey, failed_payment=true, correct status + sqlx::query( + r#"INSERT INTO orders (id, kind, event_id, status, premium, payment_method, + amount, fiat_code, fiat_amount, created_at, expires_at, + failed_payment, payment_attempts, dev_fee, dev_fee_paid, + master_buyer_pubkey) + VALUES (?1, 'buy', 'ev1', 'settled-hold-invoice', 0, 'lightning', + 100000, 'USD', 100, 1700000000, 1700086400, + 1, 3, 0, 0, ?2)"#, + ) + .bind(uuid::Uuid::new_v4()) + .bind(&master_key) + .execute(&pool) + .await + .unwrap(); + let result = super::find_failed_payment_for_master_key(&pool, &master_key) + .await + .unwrap(); + assert_eq!(result.len(), 1, "Should find matching failed payment order"); + } + + #[tokio::test] + async fn test_find_failed_payment_for_master_key_ignores_different_key() { + let pool = setup_orders_db().await.unwrap(); + let master_key = "a".repeat(64); + let other_key = "b".repeat(64); + // Insert order for a different master key + sqlx::query( + r#"INSERT INTO orders (id, kind, event_id, status, premium, payment_method, + amount, fiat_code, fiat_amount, created_at, expires_at, + failed_payment, payment_attempts, dev_fee, dev_fee_paid, + master_buyer_pubkey) + VALUES (?1, 'buy', 'ev1', 'settled-hold-invoice', 0, 'lightning', + 100000, 'USD', 100, 1700000000, 1700086400, + 1, 3, 0, 0, ?2)"#, + ) + .bind(uuid::Uuid::new_v4()) + .bind(&other_key) + .execute(&pool) + .await + .unwrap(); + let result = super::find_failed_payment_for_master_key(&pool, &master_key) + .await + .unwrap(); + assert!( + result.is_empty(), + "Should not return orders for different master key" + ); + } + + #[tokio::test] + async fn test_find_failed_payment_for_master_key_ignores_non_failed() { + let pool = setup_orders_db().await.unwrap(); + let master_key = "a".repeat(64); + // Insert order with failed_payment = false + sqlx::query( + r#"INSERT INTO orders (id, kind, event_id, status, premium, payment_method, + amount, fiat_code, fiat_amount, created_at, expires_at, + failed_payment, payment_attempts, dev_fee, dev_fee_paid, + master_buyer_pubkey) + VALUES (?1, 'buy', 'ev1', 'settled-hold-invoice', 0, 'lightning', + 100000, 'USD', 100, 1700000000, 1700086400, + 0, 0, 0, 0, ?2)"#, + ) + .bind(uuid::Uuid::new_v4()) + .bind(&master_key) + .execute(&pool) + .await + .unwrap(); + let result = super::find_failed_payment_for_master_key(&pool, &master_key) + .await + .unwrap(); + assert!( + result.is_empty(), + "Should not return orders where failed_payment is false" + ); + } + // -- Tests for find_order_by_hash -- #[tokio::test]