Преглед на файлове

feat: add extra stored state to melt mint saga (#1613)

tsk преди 1 седмица
родител
ревизия
87f555629f
променени са 4 файла, в които са добавени 114 реда и са изтрити 26 реда
  1. 5 0
      crates/cdk-common/src/mint.rs
  2. 6 0
      crates/cdk/src/mint/melt/melt_saga/mod.rs
  3. 53 26
      crates/cdk/src/mint/melt/shared.rs
  4. 50 0
      crates/cdk/src/mint/start_up_check.rs

+ 5 - 0
crates/cdk-common/src/mint.rs

@@ -155,6 +155,8 @@ pub enum MeltSagaState {
     SetupComplete,
     /// Payment attempted to Lightning network (may or may not have succeeded)
     PaymentAttempted,
+    /// TX1 committed (proofs Spent, quote Paid) - change signing + cleanup pending
+    Finalizing,
 }
 
 impl fmt::Display for MeltSagaState {
@@ -162,6 +164,7 @@ impl fmt::Display for MeltSagaState {
         match self {
             MeltSagaState::SetupComplete => write!(f, "setup_complete"),
             MeltSagaState::PaymentAttempted => write!(f, "payment_attempted"),
+            MeltSagaState::Finalizing => write!(f, "finalizing"),
         }
     }
 }
@@ -173,6 +176,7 @@ impl FromStr for MeltSagaState {
         match value.as_str() {
             "setup_complete" => Ok(MeltSagaState::SetupComplete),
             "payment_attempted" => Ok(MeltSagaState::PaymentAttempted),
+            "finalizing" => Ok(MeltSagaState::Finalizing),
             _ => Err(Error::Custom(format!("Invalid melt saga state: {}", value))),
         }
     }
@@ -210,6 +214,7 @@ impl SagaStateEnum {
             SagaStateEnum::Melt(state) => match state {
                 MeltSagaState::SetupComplete => "setup_complete",
                 MeltSagaState::PaymentAttempted => "payment_attempted",
+                MeltSagaState::Finalizing => "finalizing",
             },
         }
     }

+ 6 - 0
crates/cdk/src/mint/melt/melt_saga/mod.rs

@@ -972,6 +972,12 @@ impl MeltSaga<PaymentConfirmed> {
         } else {
             // We commit tx here as process_change can make external call to blind sign
             // We do not want to hold db txs across external calls
+            // Persist Finalizing state so recovery knows TX1 completed
+            tx.update_saga(
+                &self.operation_id,
+                cdk_common::mint::SagaStateEnum::Melt(cdk_common::mint::MeltSagaState::Finalizing),
+            )
+            .await?;
             tx.commit().await?;
             super::shared::process_melt_change(
                 &self.mint,

+ 53 - 26
crates/cdk/src/mint/melt/shared.rs

@@ -544,34 +544,61 @@ pub async fn finalize_melt_quote(
         return Ok(None);
     }
 
-    // Core finalization (marks proofs spent, updates quote)
-    finalize_melt_core(
-        &mut tx,
-        pubsub,
-        &mut locked_quote,
-        &input_ys,
-        melt_request_info.inputs_amount.clone(),
-        melt_request_info.inputs_fee.clone(),
-        total_spent.clone(),
-        payment_preimage.clone(),
-        payment_lookup_id,
-    )
-    .await?;
-
-    // Close transaction before external call
-    tx.commit().await?;
+    // Check if TX1 already completed (e.g., crash between TX1 commit and TX2 commit).
+    // If the quote is already Paid, proofs are already Spent — calling finalize_melt_core
+    // would fail on the Paid→Paid and Spent→Spent state transitions. Skip directly to
+    // change signing and cleanup so the user receives their change.
+    if locked_quote.state == MeltQuoteState::Paid {
+        tracing::info!(
+            "Melt quote {} already Paid — TX1 previously committed, skipping to change/cleanup",
+            quote.id
+        );
+        tx.commit().await?;
+    } else {
+        // Core finalization (marks proofs spent, updates quote)
+        finalize_melt_core(
+            &mut tx,
+            pubsub,
+            &mut locked_quote,
+            &input_ys,
+            melt_request_info.inputs_amount.clone(),
+            melt_request_info.inputs_fee.clone(),
+            total_spent.clone(),
+            payment_preimage.clone(),
+            payment_lookup_id,
+        )
+        .await?;
+
+        // Close transaction before external call
+        tx.commit().await?;
+    }
+
+    // Check if change signatures already exist from a previous attempt
+    let existing_sigs = db.get_blind_signatures_for_quote(&quote.id).await?;
+    let needs_change = melt_request_info.inputs_amount > total_spent;
 
     // Process change (if needed) - opens new transaction
-    let (change_sigs, mut tx) = process_melt_change(
-        mint,
-        db,
-        &quote.id,
-        melt_request_info.inputs_amount,
-        total_spent,
-        melt_request_info.inputs_fee,
-        melt_request_info.change_outputs.clone(),
-    )
-    .await?;
+    let (change_sigs, mut tx) = if needs_change && !existing_sigs.is_empty() {
+        // Change already signed from a previous attempt — skip re-signing
+        tracing::info!(
+            "Change signatures already exist for quote {} ({} sigs), skipping re-sign",
+            quote.id,
+            existing_sigs.len()
+        );
+        let tx = db.begin_transaction().await?;
+        (Some(existing_sigs), tx)
+    } else {
+        process_melt_change(
+            mint,
+            db,
+            &quote.id,
+            melt_request_info.inputs_amount,
+            total_spent,
+            melt_request_info.inputs_fee,
+            melt_request_info.change_outputs.clone(),
+        )
+        .await?
+    };
 
     // Delete melt request tracking
     tx.delete_melt_request(&quote.id).await?;

+ 50 - 0
crates/cdk/src/mint/start_up_check.rs

@@ -362,6 +362,56 @@ impl Mint {
                             );
                             true
                         }
+                        cdk_common::mint::MeltSagaState::Finalizing => {
+                            // TX1 committed (proofs Spent, quote Paid) - finalize change + cleanup
+                            tracing::info!(
+                                "Saga {} in Finalizing state - TX1 completed, will finalize change and cleanup",
+                                saga.operation_id
+                            );
+
+                            let total_spent = quote.amount();
+                            let payment_lookup_id =
+                                quote.request_lookup_id.clone().unwrap_or_else(|| {
+                                    cdk_common::payment::PaymentIdentifier::CustomId(
+                                        quote.id.to_string(),
+                                    )
+                                });
+
+                            if let Err(err) = self
+                                .finalize_paid_melt_quote(
+                                    &quote,
+                                    total_spent,
+                                    None,
+                                    &payment_lookup_id,
+                                )
+                                .await
+                            {
+                                tracing::error!(
+                                    "Failed to finalize Finalizing saga {}: {}. Will retry.",
+                                    saga.operation_id,
+                                    err
+                                );
+                                continue;
+                            }
+
+                            // Delete saga after successful finalization
+                            let mut tx = self.localstore.begin_transaction().await?;
+                            if let Err(e) = tx.delete_saga(&saga.operation_id).await {
+                                tracing::error!(
+                                    "Failed to delete saga {}: {}. Will retry on next recovery cycle.",
+                                    saga.operation_id,
+                                    e
+                                );
+                                tx.rollback().await?;
+                                continue;
+                            }
+                            tx.commit().await?;
+                            tracing::info!(
+                                "Successfully recovered Finalizing saga {}",
+                                saga.operation_id
+                            );
+                            continue;
+                        }
                         cdk_common::mint::MeltSagaState::PaymentAttempted => {
                             // Payment was attempted - check for internal settlement first, then LN backend
                             tracing::info!(