Skip to content
Merged
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
181 changes: 180 additions & 1 deletion crates/cdk/src/mint/start_up_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@
use std::str::FromStr;

use cdk_common::mint::{OperationKind, Saga};
use cdk_common::QuoteId;
use cdk_common::{PublicKey, QuoteId, State};

use super::{Error, Mint};
use crate::mint::swap::swap_saga::compensation::{CompensatingAction, RemoveSwapSetup};
use crate::mint::{MeltQuote, MeltQuoteState};
use crate::types::PaymentProcessorKey;

/// Recovery decision for an incomplete swap saga found during startup.
#[derive(Debug, PartialEq, Eq)]
enum SwapSagaRecoveryAction {
/// Roll back the pre-finalization setup state with the normal swap compensation.
Compensate,
/// Leave the saga alone because it was cleaned up or requires manual repair.
SkipCompensation,
}

impl Mint {
/// Get incomplete melt saga by quote_id
async fn get_melt_saga_by_quote_id(&self, quote_id: &str) -> Result<Option<Saga>, Error> {
Expand Down Expand Up @@ -237,6 +246,14 @@ impl Mint {
.get_blinded_secrets_by_operation_id(&saga.operation_id)
.await?;

match self
.cleanup_finalized_swap_saga(&saga, &input_ys, &blinded_secrets)
.await?
{
SwapSagaRecoveryAction::SkipCompensation => continue,
SwapSagaRecoveryAction::Compensate => {}
}

// Use the same compensation logic as in-process failures
// Saga deletion is included in the compensation transaction
let compensation = RemoveSwapSetup {
Expand Down Expand Up @@ -269,6 +286,79 @@ impl Mint {
Ok(())
}

async fn cleanup_finalized_swap_saga(
&self,
saga: &Saga,
input_ys: &[PublicKey],
blinded_secrets: &[PublicKey],
) -> Result<SwapSagaRecoveryAction, Error> {
if input_ys.is_empty() || blinded_secrets.is_empty() {
return Ok(SwapSagaRecoveryAction::Compensate);
}

let proof_states = self.localstore.get_proofs_states(input_ys).await?;
let inputs_spent = proof_states
.iter()
.all(|state| matches!(state, Some(State::Spent)));

if !inputs_spent {
return Ok(SwapSagaRecoveryAction::Compensate);
}

let output_signatures = self
.localstore
.get_blind_signatures(blinded_secrets)
.await?;
let outputs_signed = output_signatures.iter().all(Option::is_some);

if !outputs_signed {
tracing::error!(
"Swap saga {} has spent inputs but missing output signatures; manual intervention required",
saga.operation_id
);
return Ok(SwapSagaRecoveryAction::SkipCompensation);
}

tracing::info!(
"Swap saga {} already finalized; deleting orphaned saga record",
saga.operation_id
);

match self.localstore.begin_transaction().await {
Ok(mut tx) => {
if let Err(err) = tx.delete_saga(&saga.operation_id).await {
tracing::warn!(
"Failed to delete finalized orphaned swap saga {}: {}",
saga.operation_id,
err
);
if let Err(rollback_err) = tx.rollback().await {
tracing::warn!(
"Failed to roll back finalized orphaned swap saga cleanup {}: {}",
saga.operation_id,
rollback_err
);
}
} else if let Err(err) = tx.commit().await {
tracing::warn!(
"Failed to commit finalized orphaned swap saga cleanup {}: {}",
saga.operation_id,
err
);
}
}
Err(err) => {
tracing::warn!(
"Failed to start finalized orphaned swap saga cleanup {}: {}",
saga.operation_id,
err
);
}
}

Ok(SwapSagaRecoveryAction::SkipCompensation)
}

/// Recover from incomplete melt sagas
///
/// Checks all persisted sagas for melt operations and determines whether to:
Expand Down Expand Up @@ -727,3 +817,92 @@ impl Mint {
Ok(())
}
}

#[cfg(test)]
mod tests {
use cdk_common::nuts::ProofsMethods;
use cdk_common::{Amount, State};

use super::*;
use crate::mint::swap::swap_saga::SwapSaga;
use crate::test_helpers::mint::{
create_test_blinded_messages, create_test_mint, mint_test_proofs,
};

#[tokio::test]
async fn spent_swap_inputs_with_unsigned_outputs_skip_compensation() {
let mint = create_test_mint().await.unwrap();
let db = mint.localstore();

let amount = Amount::from(100);
let input_proofs = mint_test_proofs(&mint, amount).await.unwrap();
let input_ys = input_proofs.ys().unwrap();
let input_verification = crate::mint::Verification {
amount: amount.with_unit(cdk_common::nuts::CurrencyUnit::Sat),
};
let (output_blinded_messages, _) =
create_test_blinded_messages(&mint, amount).await.unwrap();
let blinded_secrets: Vec<_> = output_blinded_messages
.iter()
.map(|message| message.blinded_secret)
.collect();

let saga = SwapSaga::new(&mint, db.clone(), mint.pubsub_manager())
.setup_swap(
&input_proofs,
&output_blinded_messages,
None,
input_verification,
)
.await
.expect("setup should succeed");
drop(saga);

let operation_id = db
.get_incomplete_sagas(OperationKind::Swap)
.await
.unwrap()
.into_iter()
.next()
.expect("saga should exist")
.operation_id;

{
let mut tx = db.begin_transaction().await.unwrap();
let mut proofs = tx.get_proofs(&input_ys).await.unwrap();
Mint::update_proofs_state(&mut tx, &mut proofs, State::Spent)
.await
.unwrap();
tx.commit().await.unwrap();
}

let saga = {
let mut tx = db.begin_transaction().await.unwrap();
let saga = tx
.get_saga(&operation_id)
.await
.unwrap()
.expect("saga should exist");
tx.commit().await.unwrap();
saga
};

let action = mint
.cleanup_finalized_swap_saga(&saga, &input_ys, &blinded_secrets)
.await
.unwrap();

assert_eq!(action, SwapSagaRecoveryAction::SkipCompensation);

let saga_after = {
let mut tx = db.begin_transaction().await.unwrap();
let saga = tx.get_saga(&operation_id).await.unwrap();
tx.commit().await.unwrap();
saga
};
assert!(
saga_after.is_some(),
"manual-intervention saga should remain for repair"
);
}
}
61 changes: 38 additions & 23 deletions crates/cdk/src/mint/swap/swap_saga/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2575,13 +2575,11 @@ async fn test_recovery_idempotence() {
// ==================== PHASE 3: EDGE CASE TESTS ====================
// These tests verify edge cases and error handling scenarios.

/// Tests cleanup of orphaned saga (saga deletion fails but swap succeeds).
/// Tests cleanup of orphaned saga (saga deletion fails after swap succeeds).
///
/// # What This Tests
/// - Swap completes successfully (proofs marked SPENT)
/// - Saga deletion fails (simulated by test hook)
/// - Swap still succeeds (best-effort deletion)
/// - Saga remains orphaned in database
/// - Saga remains orphaned in database after a successful swap
/// - Recovery detects orphaned saga (proofs already SPENT)
/// - Recovery deletes orphaned saga
///
Expand All @@ -2591,12 +2589,14 @@ async fn test_recovery_idempotence() {
/// on next recovery.
///
/// # Success Criteria
/// - Swap succeeds despite deletion failure
/// - Swap succeeds
/// - Proofs are SPENT after swap
/// - Saga remains after swap (orphaned)
/// - Saga is manually reinserted to simulate failed best-effort deletion
/// - Recovery cleans up orphaned saga
#[tokio::test]
async fn test_orphaned_saga_cleanup() {
use cdk_common::mint::{Saga, SwapSagaState};

let mint = create_test_mint().await.unwrap();
let db = mint.localstore();

Expand All @@ -2614,15 +2614,13 @@ async fn test_orphaned_saga_cleanup() {

let operation_id = *saga.state_data.operation.id();
let ys = input_proofs.ys().unwrap();
let blinded_secrets: Vec<_> = output_blinded_messages
.iter()
.map(|bm| bm.blinded_secret)
.collect();

let saga = saga.sign_outputs().await.expect("Signing should succeed");

// Note: We cannot easily inject a failure for saga deletion within finalize
// because the deletion happens inside a database transaction and uses the
// transaction trait. For now, we'll test the recovery side: create a saga
// that completes, then manually verify recovery can handle scenarios where
// saga exists but proofs are already SPENT.

let _response = saga.finalize().await.expect("Finalize should succeed");

// Verify swap succeeded (proofs SPENT)
Expand All @@ -2632,25 +2630,42 @@ async fn test_orphaned_saga_cleanup() {
"Proofs should be SPENT after successful swap"
);

// In a real scenario with deletion failure, saga would remain.
// For this test, we'll verify that saga is properly deleted.
// TODO: Add failure injection for delete_saga to properly test this.
let saga = {
let signatures = db.get_blind_signatures(&blinded_secrets).await.unwrap();
assert!(
signatures.iter().all(Option::is_some),
"Outputs should remain signed after successful swap"
);

{
let mut tx = db.begin_transaction().await.unwrap();
tx.add_saga(&Saga::new_swap(operation_id, SwapSagaState::SetupComplete))
.await
.unwrap();
tx.commit().await.unwrap();
}

mint.stop().await.expect("Stop should succeed");
mint.start().await.expect("Start should succeed");

let saga_after = {
let mut tx = db.begin_transaction().await.unwrap();
let result = tx.get_saga(&operation_id).await.unwrap();
tx.commit().await.unwrap();
result
};
assert!(saga_after.is_none(), "Recovery should delete saga");

let states_after = db.get_proofs_states(&ys).await.unwrap();
assert!(
saga.is_none(),
"Saga should be deleted after successful swap"
states_after.iter().all(|s| s == &Some(State::Spent)),
"Recovery should not remove spent proofs"
);

// If we had a way to inject deletion failure, we would:
// 1. Verify saga remains (orphaned)
// 2. Run recovery
// 3. Verify recovery detects proofs are SPENT
// 4. Verify recovery deletes orphaned saga
let signatures_after = db.get_blind_signatures(&blinded_secrets).await.unwrap();
assert!(
signatures_after.iter().all(Option::is_some),
"Recovery should not remove signed outputs"
);
}

/// Tests recovery with orphaned proofs (proofs without corresponding saga).
Expand Down
Loading