Selaa lähdekoodia

fix: remove use of unwrap or default in melt saga (#1309)

fix: remove compensate in melt finalize
tsk 2 kuukautta sitten
vanhempi
säilyke
c25f196c5b

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

@@ -91,15 +91,15 @@ impl FromStr for SwapSagaState {
 pub enum MeltSagaState {
     /// Setup complete (proofs reserved, quote verified)
     SetupComplete,
-    /// Payment sent to Lightning network
-    PaymentSent,
+    /// Payment attempted to Lightning network (may or may not have succeeded)
+    PaymentAttempted,
 }
 
 impl fmt::Display for MeltSagaState {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
             MeltSagaState::SetupComplete => write!(f, "setup_complete"),
-            MeltSagaState::PaymentSent => write!(f, "payment_sent"),
+            MeltSagaState::PaymentAttempted => write!(f, "payment_attempted"),
         }
     }
 }
@@ -110,7 +110,7 @@ impl FromStr for MeltSagaState {
         let value = value.to_lowercase();
         match value.as_str() {
             "setup_complete" => Ok(MeltSagaState::SetupComplete),
-            "payment_sent" => Ok(MeltSagaState::PaymentSent),
+            "payment_attempted" => Ok(MeltSagaState::PaymentAttempted),
             _ => Err(Error::Custom(format!("Invalid melt saga state: {}", value))),
         }
     }
@@ -147,7 +147,7 @@ impl SagaStateEnum {
             },
             SagaStateEnum::Melt(state) => match state {
                 MeltSagaState::SetupComplete => "setup_complete",
-                MeltSagaState::PaymentSent => "payment_sent",
+                MeltSagaState::PaymentAttempted => "payment_attempted",
             },
         }
     }

+ 86 - 9
crates/cdk/src/mint/melt/melt_saga/mod.rs

@@ -4,7 +4,7 @@ use std::sync::Arc;
 use cdk_common::amount::to_unit;
 use cdk_common::database::mint::MeltRequestInfo;
 use cdk_common::database::DynMintDatabase;
-use cdk_common::mint::{MeltSagaState, Operation, Saga};
+use cdk_common::mint::{MeltSagaState, Operation, Saga, SagaStateEnum};
 use cdk_common::nuts::MeltQuoteState;
 use cdk_common::{Amount, Error, ProofsMethods, PublicKey, QuoteId, State};
 #[cfg(feature = "prometheus")]
@@ -62,14 +62,27 @@ mod tests;
 ///    - Triggers compensation if payment fails
 ///    - Special handling for pending/unknown states
 /// 3. **finalize**: Commits the melt, issues change, marks complete
-///    - Triggers compensation if finalization fails
+///    - Does NOT compensate if finalization fails (payment already confirmed)
+///    - Startup check will retry finalization on recovery
 ///    - Clears compensations on success (melt complete)
 ///
 /// # Failure Handling
 ///
-/// If any step fails after setup_melt, all compensating actions are executed in reverse
-/// order to restore the database to its pre-melt state. This ensures no partial melts
-/// leave the system in an inconsistent state.
+/// Failure handling depends on whether payment was attempted:
+///
+/// **Before payment attempt (SetupComplete state):**
+/// - All compensating actions are executed in reverse order
+/// - Database is restored to pre-melt state
+/// - User can retry with same proofs
+///
+/// **After payment attempt (PaymentAttempted state):**
+/// - Compensation is NOT executed (would cause fund loss)
+/// - Startup check will verify payment status with LN backend
+/// - If payment succeeded: finalize is retried
+/// - If payment failed: compensation runs
+///
+/// This two-phase approach prevents fund loss where the mint pays the LN invoice
+/// but returns the proofs to the user.
 ///
 /// # Payment State Complexity
 ///
@@ -77,7 +90,17 @@ mod tests;
 /// - **Paid**: Proceed to finalize
 /// - **Failed/Unpaid**: Compensate and return error
 /// - **Pending/Unknown**: Proofs remain pending, saga cannot complete
-///   (current behavior: leave proofs pending, return error for manual intervention)
+///   (leave proofs pending for startup check to resolve)
+///
+/// # Crash Recovery
+///
+/// The saga persists its state for crash recovery:
+/// - **SetupComplete**: Payment was never attempted → safe to compensate
+/// - **PaymentAttempted**: Payment may have succeeded → must check LN backend
+///
+/// On startup, the recovery process checks the persisted saga state and takes
+/// appropriate action to either finalize (if payment succeeded) or compensate
+/// (if payment was never sent or confirmed failed).
 ///
 /// # Typestate Pattern
 ///
@@ -469,6 +492,14 @@ impl MeltSaga<SetupComplete> {
             amount
         );
 
+        // Update saga state to PaymentAttempted BEFORE internal settlement commits
+        // This ensures crash recovery knows payment may have occurred
+        tx.update_saga(
+            self.operation.id(),
+            SagaStateEnum::Melt(MeltSagaState::PaymentAttempted),
+        )
+        .await?;
+
         let total_paid = tx
             .increment_mint_quote_amount_paid(
                 &mint_quote.id,
@@ -499,10 +530,21 @@ impl MeltSaga<SetupComplete> {
     ///
     /// 1. Takes a SettlementDecision from attempt_internal_settlement
     /// 2. If Internal: creates payment result directly
-    /// 3. If RequiresExternalPayment: calls LN backend
+    /// 3. If RequiresExternalPayment:
+    ///    - Updates saga state to `PaymentAttempted` (for crash recovery)
+    ///    - Calls LN backend to make payment
     /// 4. Handles payment result states with idempotent verification
     /// 5. Transitions to PaymentConfirmed state on success
     ///
+    /// # Crash Tolerance
+    ///
+    /// For external payments, the saga state is updated to `PaymentAttempted` BEFORE
+    /// calling the LN backend. This write-ahead logging ensures that if the process
+    /// crashes after payment but before finalize, the startup recovery will:
+    /// - See `PaymentAttempted` state
+    /// - Check with LN backend to determine if payment succeeded
+    /// - Finalize if paid, compensate if failed
+    ///
     /// # Idempotent Payment Verification
     ///
     /// Lightning payments are asynchronous, and the LN backend may return different
@@ -573,6 +615,18 @@ impl MeltSaga<SetupComplete> {
                         Error::UnsupportedUnit
                     })?;
 
+                // Update saga state to PaymentAttempted BEFORE making payment
+                // This ensures crash recovery knows payment may have been attempted
+                {
+                    let mut tx = self.db.begin_transaction().await?;
+                    tx.update_saga(
+                        self.operation.id(),
+                        SagaStateEnum::Melt(MeltSagaState::PaymentAttempted),
+                    )
+                    .await?;
+                    tx.commit().await?;
+                }
+
                 // Make payment with idempotent verification
                 let payment_response = match ln
                     .make_payment(
@@ -743,10 +797,23 @@ impl MeltSaga<PaymentConfirmed> {
     ///
     /// On success, compensations are cleared and the melt is complete.
     ///
+    /// # Failure Handling
+    ///
+    /// **Critical**: If finalization fails, compensation is NOT executed because
+    /// payment was already confirmed as Paid. Compensating would return proofs to
+    /// the user while the mint has already paid the Lightning invoice, causing fund loss.
+    ///
+    /// Instead, the error is returned and the saga remains in the database with
+    /// `PaymentAttempted` state. On startup, the recovery process will:
+    /// 1. Find the incomplete saga
+    /// 2. Check the LN backend (which will confirm payment as Paid)
+    /// 3. Retry finalization
+    ///
     /// # Errors
     ///
     /// - `TokenAlreadySpent`: Input proofs were already spent
     /// - `BlindedMessageAlreadySigned`: Change outputs already signed
+    /// - `UnitMismatch`: Failed to convert payment amount to quote unit
     #[instrument(skip_all)]
     pub async fn finalize(self) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
         tracing::info!("TX2: Finalizing melt (mark spent + change)");
@@ -756,7 +823,10 @@ impl MeltSaga<PaymentConfirmed> {
             &self.state_data.payment_result.unit,
             &self.state_data.quote.unit,
         )
-        .unwrap_or_default();
+        .map_err(|e| {
+            tracing::error!("Failed to convert total_spent to quote unit: {:?}", e);
+            Error::UnitMismatch
+        })?;
 
         let payment_preimage = self.state_data.payment_result.payment_proof.clone();
         let payment_lookup_id = &self.state_data.payment_result.payment_lookup_id;
@@ -787,8 +857,15 @@ impl MeltSaga<PaymentConfirmed> {
         )
         .await
         {
+            // Do NOT compensate here - payment was already confirmed as Paid
+            // Startup check will retry finalization on next recovery cycle
+            tracing::error!(
+                "Finalize failed for paid melt quote {} - will retry on startup: {}",
+                self.state_data.quote.id,
+                err
+            );
+
             tx.rollback().await?;
-            self.compensate_all().await?;
             return Err(err);
         }
 

+ 375 - 7
crates/cdk/src/mint/melt/melt_saga/tests.rs

@@ -8,6 +8,8 @@
 //! - Concurrent operations
 //! - Failure handling
 
+use std::str::FromStr;
+
 use cdk_common::mint::{MeltSagaState, OperationKind, Saga};
 use cdk_common::nuts::MeltQuoteState;
 use cdk_common::{Amount, ProofsMethods, State};
@@ -440,6 +442,179 @@ async fn test_crash_recovery_partial_failure() {
     // 5. Verify failed saga is logged but doesn't stop recovery
 }
 
+/// Test: Crash recovery after internal settlement commits but before finalize
+///
+/// This test verifies that if the mint crashes after internal settlement
+/// (melt-to-mint on same mint) commits but before finalize() completes,
+/// recovery will correctly finalize the melt rather than compensating.
+///
+/// This prevents fund loss where:
+/// - The mint quote was credited (mint received funds)
+/// - But proofs are returned to user (double-spend)
+#[tokio::test]
+async fn test_crash_recovery_internal_settlement() {
+    use cdk_common::nuts::MintQuoteState;
+    use cdk_common::MintQuoteBolt11Request;
+
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let input_ys = proofs.ys().unwrap();
+
+    // STEP 2: Create a mint quote that will be paid internally
+    // This creates a payment request (invoice) on this mint
+    // Note: We use a smaller amount (4000) because the test mint has 100% fee reserve,
+    // so required = amount + fee_reserve = 4000 + 4000 = 8000 < 10000 inputs
+    let mint_quote_response: cdk_common::MintQuoteBolt11Response<_> = mint
+        .get_mint_quote(
+            MintQuoteBolt11Request {
+                amount: Amount::from(4_000),
+                unit: cdk_common::CurrencyUnit::Sat,
+                description: None,
+                pubkey: None,
+            }
+            .into(),
+        )
+        .await
+        .unwrap()
+        .into();
+
+    // Get the mint quote from database
+    let mint_quote_id = cdk_common::QuoteId::from_str(&mint_quote_response.quote).unwrap();
+    let mint_quote = mint
+        .localstore
+        .get_mint_quote(&mint_quote_id)
+        .await
+        .unwrap()
+        .expect("Mint quote should exist");
+
+    // STEP 3: Create a melt quote that uses the mint quote's payment request
+    // This will trigger internal settlement since it's the same mint
+    use cdk_common::melt::MeltQuoteRequest;
+    use cdk_common::nuts::MeltQuoteBolt11Request;
+
+    let melt_bolt11_request = MeltQuoteBolt11Request {
+        request: mint_quote.request.to_string().parse().unwrap(),
+        unit: cdk_common::CurrencyUnit::Sat,
+        options: None,
+    };
+    let melt_quote_request = MeltQuoteRequest::Bolt11(melt_bolt11_request);
+
+    let melt_quote_response = mint.get_melt_quote(melt_quote_request).await.unwrap();
+    let melt_quote = mint
+        .localstore
+        .get_melt_quote(&melt_quote_response.quote)
+        .await
+        .unwrap()
+        .expect("Melt quote should exist");
+
+    // STEP 4: Create melt request and setup saga
+    let melt_request = create_test_melt_request(&proofs, &melt_quote);
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let operation_id = *setup_saga.operation.id();
+
+    // STEP 5: Attempt internal settlement - this will commit and update saga state
+    let (payment_saga, decision) = setup_saga
+        .attempt_internal_settlement(&melt_request)
+        .await
+        .unwrap();
+
+    // Verify internal settlement was detected
+    match decision {
+        crate::mint::melt::melt_saga::state::SettlementDecision::Internal { amount } => {
+            assert_eq!(
+                amount,
+                Amount::from(4_000),
+                "Internal settlement amount should match"
+            );
+        }
+        _ => panic!("Expected internal settlement decision"),
+    }
+
+    // STEP 6: Simulate crash - drop saga WITHOUT calling make_payment/finalize
+    drop(payment_saga);
+
+    // STEP 7: Verify pre-recovery state
+    // Saga should exist in PaymentAttempted state (updated by internal settlement)
+    let persisted_saga = assert_saga_exists(&mint, &operation_id).await;
+    match &persisted_saga.state {
+        cdk_common::mint::SagaStateEnum::Melt(state) => {
+            assert_eq!(
+                *state,
+                MeltSagaState::PaymentAttempted,
+                "Saga should be in PaymentAttempted state after internal settlement"
+            );
+        }
+        _ => panic!("Expected Melt saga state"),
+    }
+
+    // Proofs should still be Pending
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    // Mint quote should be paid (internal settlement committed)
+    let mint_quote_after = mint
+        .localstore
+        .get_mint_quote(&mint_quote_id)
+        .await
+        .unwrap()
+        .expect("Mint quote should exist");
+    assert_eq!(
+        mint_quote_after.state(),
+        MintQuoteState::Paid,
+        "Mint quote should be paid after internal settlement"
+    );
+
+    // STEP 8: Run recovery
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed");
+
+    // STEP 9: Verify post-recovery state
+    // Saga should be deleted (successfully finalized)
+    assert_saga_not_exists(&mint, &operation_id).await;
+
+    // CRITICAL: Proofs should be SPENT, not returned (None)
+    // This is the key assertion - if proofs were compensated, they'd be None
+    assert_proofs_state(&mint, &input_ys, Some(State::Spent)).await;
+
+    // Melt quote should be Paid
+    let melt_quote_after = mint
+        .localstore
+        .get_melt_quote(&melt_quote.id)
+        .await
+        .unwrap()
+        .expect("Melt quote should exist");
+    assert_eq!(
+        melt_quote_after.state,
+        MeltQuoteState::Paid,
+        "Melt quote should be paid after recovery"
+    );
+
+    // Mint quote should still be paid
+    let mint_quote_final = mint
+        .localstore
+        .get_mint_quote(&mint_quote_id)
+        .await
+        .unwrap()
+        .expect("Mint quote should exist");
+    assert_eq!(
+        mint_quote_final.state(),
+        MintQuoteState::Paid,
+        "Mint quote should remain paid after recovery"
+    );
+
+    // SUCCESS: Recovery correctly finalized internal settlement!
+    // No fund loss - proofs spent and mint quote paid
+}
+
 // ============================================================================
 // Startup Integration Tests
 // ============================================================================
@@ -1956,12 +2131,18 @@ async fn test_saga_drop_without_finalize() {
     // SUCCESS: Drop without finalize doesn't panic!
 }
 
-/// Test: Saga drop after payment is recoverable
+/// Test: Saga drop after payment is recoverable and finalizes correctly
+///
+/// This test verifies that when a saga is dropped after payment but before finalize,
+/// the recovery process correctly finalizes the melt (marks proofs as spent) rather
+/// than compensating (returning proofs to user). This is critical for preventing
+/// fund loss where the mint pays the LN invoice but returns the proofs.
 #[tokio::test]
 async fn test_saga_drop_after_payment() {
     // STEP 1: Setup test environment
     let mint = create_test_mint().await.unwrap();
     let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let input_ys = proofs.ys().unwrap();
     let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
     let melt_request = create_test_melt_request(&proofs, &quote);
 
@@ -1975,6 +2156,9 @@ async fn test_saga_drop_after_payment() {
     let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
     let operation_id = *setup_saga.operation.id();
 
+    // Verify proofs are PENDING after setup
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
     // Attempt internal settlement
     let (payment_saga, decision) = setup_saga
         .attempt_internal_settlement(&melt_request)
@@ -1984,12 +2168,21 @@ async fn test_saga_drop_after_payment() {
     // Make payment
     let confirmed_saga = payment_saga.make_payment(decision).await.unwrap();
 
-    // STEP 3: Drop before finalize (simulates crash after payment)
-    drop(confirmed_saga);
-
-    // STEP 4: Verify saga still exists (wasn't finalized)
+    // STEP 3: Verify saga state is now PaymentAttempted (not SetupComplete)
     let saga_in_db = assert_saga_exists(&mint, &operation_id).await;
-    assert_eq!(saga_in_db.operation_id, operation_id);
+    match &saga_in_db.state {
+        cdk_common::mint::SagaStateEnum::Melt(state) => {
+            assert_eq!(
+                *state,
+                MeltSagaState::PaymentAttempted,
+                "Saga state should be PaymentAttempted after make_payment"
+            );
+        }
+        _ => panic!("Expected Melt saga state"),
+    }
+
+    // STEP 4: Drop before finalize (simulates crash after payment)
+    drop(confirmed_saga);
 
     // STEP 5: Run recovery to complete the operation
     mint.recover_from_incomplete_melt_sagas()
@@ -1999,7 +2192,182 @@ async fn test_saga_drop_after_payment() {
     // STEP 6: Verify saga was recovered and cleaned up
     assert_saga_not_exists(&mint, &operation_id).await;
 
-    // SUCCESS: Drop after payment is recoverable!
+    // STEP 7: Verify proofs were marked SPENT (not returned to user)
+    // This is the critical check - if compensation ran instead of finalize,
+    // proofs would be None (returned) instead of Spent
+    assert_proofs_state(&mint, &input_ys, Some(State::Spent)).await;
+
+    // STEP 8: Verify quote is marked as PAID
+    let final_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .expect("Quote should exist");
+    assert_eq!(
+        final_quote.state,
+        MeltQuoteState::Paid,
+        "Quote should be marked as Paid after recovery finalization"
+    );
+
+    // SUCCESS: Drop after payment correctly finalizes (doesn't compensate)!
+}
+
+/// Test: PaymentAttempted state triggers LN backend check during recovery
+///
+/// This test verifies that when recovery finds a saga in PaymentAttempted state,
+/// it checks the LN backend to determine whether to finalize or compensate,
+/// rather than blindly compensating like SetupComplete state.
+#[tokio::test]
+async fn test_payment_attempted_state_triggers_ln_check() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let input_ys = proofs.ys().unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 2: Setup saga and make payment
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let operation_id = *setup_saga.operation.id();
+
+    // Check initial state is SetupComplete
+    let saga_before_payment = assert_saga_exists(&mint, &operation_id).await;
+    match &saga_before_payment.state {
+        cdk_common::mint::SagaStateEnum::Melt(state) => {
+            assert_eq!(
+                *state,
+                MeltSagaState::SetupComplete,
+                "Initial state should be SetupComplete"
+            );
+        }
+        _ => panic!("Expected Melt saga state"),
+    }
+
+    // Attempt internal settlement and make payment
+    let (payment_saga, decision) = setup_saga
+        .attempt_internal_settlement(&melt_request)
+        .await
+        .unwrap();
+    let confirmed_saga = payment_saga.make_payment(decision).await.unwrap();
+
+    // STEP 3: Verify state transitioned to PaymentAttempted
+    let saga_after_payment = assert_saga_exists(&mint, &operation_id).await;
+    match &saga_after_payment.state {
+        cdk_common::mint::SagaStateEnum::Melt(state) => {
+            assert_eq!(
+                *state,
+                MeltSagaState::PaymentAttempted,
+                "State should be PaymentAttempted after make_payment"
+            );
+        }
+        _ => panic!("Expected Melt saga state"),
+    }
+
+    // STEP 4: Drop saga (simulate crash after payment but before finalize)
+    drop(confirmed_saga);
+
+    // STEP 5: Run recovery - should check LN backend and finalize
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed");
+
+    // STEP 6: Verify correct outcome - finalized, not compensated
+    assert_saga_not_exists(&mint, &operation_id).await;
+
+    // Proofs should be SPENT (finalized), not None (compensated)
+    assert_proofs_state(&mint, &input_ys, Some(State::Spent)).await;
+
+    // Quote should be PAID
+    let final_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .expect("Quote should exist");
+    assert_eq!(
+        final_quote.state,
+        MeltQuoteState::Paid,
+        "Quote should be Paid - LN backend check should have triggered finalization"
+    );
+
+    // SUCCESS: PaymentAttempted state correctly triggers LN check and finalizes!
+}
+
+/// Test: SetupComplete state compensates without LN check
+///
+/// This test verifies that when recovery finds a saga in SetupComplete state,
+/// it compensates (returns proofs) without checking LN backend, because
+/// payment was never sent.
+#[tokio::test]
+async fn test_setup_complete_state_compensates() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let input_ys = proofs.ys().unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 2: Setup saga but don't make payment
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let operation_id = *setup_saga.operation.id();
+
+    // Verify state is SetupComplete
+    let saga_in_db = assert_saga_exists(&mint, &operation_id).await;
+    match &saga_in_db.state {
+        cdk_common::mint::SagaStateEnum::Melt(state) => {
+            assert_eq!(
+                *state,
+                MeltSagaState::SetupComplete,
+                "State should be SetupComplete"
+            );
+        }
+        _ => panic!("Expected Melt saga state"),
+    }
+
+    // Verify proofs are PENDING
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    // STEP 3: Drop saga (simulate crash before payment)
+    drop(setup_saga);
+
+    // STEP 4: Run recovery - should compensate without LN check
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed");
+
+    // STEP 5: Verify correct outcome - compensated, not finalized
+    assert_saga_not_exists(&mint, &operation_id).await;
+
+    // Proofs should be None (compensated/returned), not Spent
+    assert_proofs_state(&mint, &input_ys, None).await;
+
+    // Quote should be UNPAID (reset)
+    let final_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .expect("Quote should exist");
+    assert_eq!(
+        final_quote.state,
+        MeltQuoteState::Unpaid,
+        "Quote should be Unpaid - compensation should have reset it"
+    );
+
+    // SUCCESS: SetupComplete state correctly compensates!
 }
 
 // ============================================================================

+ 88 - 7
crates/cdk/src/mint/start_up_check.rs

@@ -315,22 +315,103 @@ impl Mint {
                 }
             };
 
-            // Check saga state to determine if payment was sent
-            // SetupComplete means setup transaction committed but payment NOT yet sent
+            // Check saga state to determine if payment was attempted
+            // SetupComplete means setup transaction committed but payment NOT yet attempted
+            // PaymentAttempted means payment was attempted - must check LN backend
             let should_compensate = match &saga.state {
                 cdk_common::mint::SagaStateEnum::Melt(state) => {
                     match state {
                         cdk_common::mint::MeltSagaState::SetupComplete => {
-                            // Setup complete but payment never sent - always compensate
+                            // Setup complete but payment never attempted - always compensate
                             tracing::info!(
-                                "Saga {} in SetupComplete state - payment never sent, will compensate",
+                                "Saga {} in SetupComplete state - payment never attempted, will compensate",
                                 saga.operation_id
                             );
                             true
                         }
-                        _ => {
-                            // Other states - should not happen in incomplete sagas, but check payment status anyway
-                            false // Will check payment status below
+                        cdk_common::mint::MeltSagaState::PaymentAttempted => {
+                            // Payment was attempted - check for internal settlement first, then LN backend
+                            tracing::info!(
+                                "Saga {} in PaymentAttempted state - checking for internal or external payment",
+                                saga.operation_id
+                            );
+
+                            // Check if this was an internal settlement by looking for a mint quote
+                            // that was paid by this melt quote
+                            let is_internal_settlement = match self
+                                .localstore
+                                .get_mint_quote_by_request(&quote.request.to_string())
+                                .await
+                            {
+                                Ok(Some(mint_quote)) => {
+                                    // Check if this mint quote was paid by our melt quote
+                                    let melt_quote_id_str = quote.id.to_string();
+                                    mint_quote.payment_ids().contains(&&melt_quote_id_str)
+                                }
+                                Ok(None) => false,
+                                Err(e) => {
+                                    tracing::warn!(
+                                        "Error checking for internal settlement for saga {}: {}",
+                                        saga.operation_id,
+                                        e
+                                    );
+                                    false
+                                }
+                            };
+
+                            if is_internal_settlement {
+                                // Internal settlement was completed - finalize directly
+                                tracing::info!(
+                                    "Saga {} was internal settlement - will finalize directly",
+                                    saga.operation_id
+                                );
+
+                                // Get payment info for finalization
+                                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, // No preimage for internal settlement
+                                        &payment_lookup_id,
+                                    )
+                                    .await
+                                {
+                                    tracing::error!(
+                                        "Failed to finalize internal settlement saga {}: {}",
+                                        saga.operation_id,
+                                        err
+                                    );
+                                }
+
+                                // 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 for {}: {}",
+                                        saga.operation_id,
+                                        e
+                                    );
+                                    tx.rollback().await?;
+                                } else {
+                                    tx.commit().await?;
+                                    tracing::info!(
+                                        "Successfully recovered and finalized internal settlement saga {}",
+                                        saga.operation_id
+                                    );
+                                }
+
+                                continue; // Skip to next saga
+                            }
+
+                            false // Will check LN payment status below
                         }
                     }
                 }