Skip to content

Commit 48f736f

Browse files
committed
fix(mint): clean finalized swap sagas during recovery
Detect swap sagas whose inputs are already spent and whose outputs are signed before running setup compensation. Recovery deletes only the orphaned saga record for those finalized swaps, avoiding repeated startup failures while preserving spent proofs and signed outputs. Add a regression test that reinserts a completed swap saga and verifies restart cleanup leaves the finalized swap state intact.
1 parent 10cab2a commit 48f736f

2 files changed

Lines changed: 218 additions & 24 deletions

File tree

crates/cdk/src/mint/start_up_check.rs

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,22 @@
66
use std::str::FromStr;
77

88
use cdk_common::mint::{OperationKind, Saga};
9-
use cdk_common::QuoteId;
9+
use cdk_common::{PublicKey, QuoteId, State};
1010

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

16+
/// Recovery decision for an incomplete swap saga found during startup.
17+
#[derive(Debug, PartialEq, Eq)]
18+
enum SwapSagaRecoveryAction {
19+
/// Roll back the pre-finalization setup state with the normal swap compensation.
20+
Compensate,
21+
/// Leave the saga alone because it was cleaned up or requires manual repair.
22+
SkipCompensation,
23+
}
24+
1625
impl Mint {
1726
/// Get incomplete melt saga by quote_id
1827
async fn get_melt_saga_by_quote_id(&self, quote_id: &str) -> Result<Option<Saga>, Error> {
@@ -237,6 +246,14 @@ impl Mint {
237246
.get_blinded_secrets_by_operation_id(&saga.operation_id)
238247
.await?;
239248

249+
match self
250+
.cleanup_finalized_swap_saga(&saga, &input_ys, &blinded_secrets)
251+
.await?
252+
{
253+
SwapSagaRecoveryAction::SkipCompensation => continue,
254+
SwapSagaRecoveryAction::Compensate => {}
255+
}
256+
240257
// Use the same compensation logic as in-process failures
241258
// Saga deletion is included in the compensation transaction
242259
let compensation = RemoveSwapSetup {
@@ -269,6 +286,79 @@ impl Mint {
269286
Ok(())
270287
}
271288

289+
async fn cleanup_finalized_swap_saga(
290+
&self,
291+
saga: &Saga,
292+
input_ys: &[PublicKey],
293+
blinded_secrets: &[PublicKey],
294+
) -> Result<SwapSagaRecoveryAction, Error> {
295+
if input_ys.is_empty() || blinded_secrets.is_empty() {
296+
return Ok(SwapSagaRecoveryAction::Compensate);
297+
}
298+
299+
let proof_states = self.localstore.get_proofs_states(input_ys).await?;
300+
let inputs_spent = proof_states
301+
.iter()
302+
.all(|state| matches!(state, Some(State::Spent)));
303+
304+
if !inputs_spent {
305+
return Ok(SwapSagaRecoveryAction::Compensate);
306+
}
307+
308+
let output_signatures = self
309+
.localstore
310+
.get_blind_signatures(blinded_secrets)
311+
.await?;
312+
let outputs_signed = output_signatures.iter().all(Option::is_some);
313+
314+
if !outputs_signed {
315+
tracing::error!(
316+
"Swap saga {} has spent inputs but missing output signatures; manual intervention required",
317+
saga.operation_id
318+
);
319+
return Ok(SwapSagaRecoveryAction::SkipCompensation);
320+
}
321+
322+
tracing::info!(
323+
"Swap saga {} already finalized; deleting orphaned saga record",
324+
saga.operation_id
325+
);
326+
327+
match self.localstore.begin_transaction().await {
328+
Ok(mut tx) => {
329+
if let Err(err) = tx.delete_saga(&saga.operation_id).await {
330+
tracing::warn!(
331+
"Failed to delete finalized orphaned swap saga {}: {}",
332+
saga.operation_id,
333+
err
334+
);
335+
if let Err(rollback_err) = tx.rollback().await {
336+
tracing::warn!(
337+
"Failed to roll back finalized orphaned swap saga cleanup {}: {}",
338+
saga.operation_id,
339+
rollback_err
340+
);
341+
}
342+
} else if let Err(err) = tx.commit().await {
343+
tracing::warn!(
344+
"Failed to commit finalized orphaned swap saga cleanup {}: {}",
345+
saga.operation_id,
346+
err
347+
);
348+
}
349+
}
350+
Err(err) => {
351+
tracing::warn!(
352+
"Failed to start finalized orphaned swap saga cleanup {}: {}",
353+
saga.operation_id,
354+
err
355+
);
356+
}
357+
}
358+
359+
Ok(SwapSagaRecoveryAction::SkipCompensation)
360+
}
361+
272362
/// Recover from incomplete melt sagas
273363
///
274364
/// Checks all persisted sagas for melt operations and determines whether to:
@@ -727,3 +817,92 @@ impl Mint {
727817
Ok(())
728818
}
729819
}
820+
821+
#[cfg(test)]
822+
mod tests {
823+
use cdk_common::nuts::ProofsMethods;
824+
use cdk_common::{Amount, State};
825+
826+
use super::*;
827+
use crate::mint::swap::swap_saga::SwapSaga;
828+
use crate::test_helpers::mint::{
829+
create_test_blinded_messages, create_test_mint, mint_test_proofs,
830+
};
831+
832+
#[tokio::test]
833+
async fn spent_swap_inputs_with_unsigned_outputs_skip_compensation() {
834+
let mint = create_test_mint().await.unwrap();
835+
let db = mint.localstore();
836+
837+
let amount = Amount::from(100);
838+
let input_proofs = mint_test_proofs(&mint, amount).await.unwrap();
839+
let input_ys = input_proofs.ys().unwrap();
840+
let input_verification = crate::mint::Verification {
841+
amount: amount.with_unit(cdk_common::nuts::CurrencyUnit::Sat),
842+
};
843+
let (output_blinded_messages, _) =
844+
create_test_blinded_messages(&mint, amount).await.unwrap();
845+
let blinded_secrets: Vec<_> = output_blinded_messages
846+
.iter()
847+
.map(|message| message.blinded_secret)
848+
.collect();
849+
850+
let saga = SwapSaga::new(&mint, db.clone(), mint.pubsub_manager())
851+
.setup_swap(
852+
&input_proofs,
853+
&output_blinded_messages,
854+
None,
855+
input_verification,
856+
)
857+
.await
858+
.expect("setup should succeed");
859+
drop(saga);
860+
861+
let operation_id = db
862+
.get_incomplete_sagas(OperationKind::Swap)
863+
.await
864+
.unwrap()
865+
.into_iter()
866+
.next()
867+
.expect("saga should exist")
868+
.operation_id;
869+
870+
{
871+
let mut tx = db.begin_transaction().await.unwrap();
872+
let mut proofs = tx.get_proofs(&input_ys).await.unwrap();
873+
Mint::update_proofs_state(&mut tx, &mut proofs, State::Spent)
874+
.await
875+
.unwrap();
876+
tx.commit().await.unwrap();
877+
}
878+
879+
let saga = {
880+
let mut tx = db.begin_transaction().await.unwrap();
881+
let saga = tx
882+
.get_saga(&operation_id)
883+
.await
884+
.unwrap()
885+
.expect("saga should exist");
886+
tx.commit().await.unwrap();
887+
saga
888+
};
889+
890+
let action = mint
891+
.cleanup_finalized_swap_saga(&saga, &input_ys, &blinded_secrets)
892+
.await
893+
.unwrap();
894+
895+
assert_eq!(action, SwapSagaRecoveryAction::SkipCompensation);
896+
897+
let saga_after = {
898+
let mut tx = db.begin_transaction().await.unwrap();
899+
let saga = tx.get_saga(&operation_id).await.unwrap();
900+
tx.commit().await.unwrap();
901+
saga
902+
};
903+
assert!(
904+
saga_after.is_some(),
905+
"manual-intervention saga should remain for repair"
906+
);
907+
}
908+
}

crates/cdk/src/mint/swap/swap_saga/tests.rs

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2575,13 +2575,11 @@ async fn test_recovery_idempotence() {
25752575
// ==================== PHASE 3: EDGE CASE TESTS ====================
25762576
// These tests verify edge cases and error handling scenarios.
25772577

2578-
/// Tests cleanup of orphaned saga (saga deletion fails but swap succeeds).
2578+
/// Tests cleanup of orphaned saga (saga deletion fails after swap succeeds).
25792579
///
25802580
/// # What This Tests
25812581
/// - Swap completes successfully (proofs marked SPENT)
2582-
/// - Saga deletion fails (simulated by test hook)
2583-
/// - Swap still succeeds (best-effort deletion)
2584-
/// - Saga remains orphaned in database
2582+
/// - Saga remains orphaned in database after a successful swap
25852583
/// - Recovery detects orphaned saga (proofs already SPENT)
25862584
/// - Recovery deletes orphaned saga
25872585
///
@@ -2591,12 +2589,14 @@ async fn test_recovery_idempotence() {
25912589
/// on next recovery.
25922590
///
25932591
/// # Success Criteria
2594-
/// - Swap succeeds despite deletion failure
2592+
/// - Swap succeeds
25952593
/// - Proofs are SPENT after swap
2596-
/// - Saga remains after swap (orphaned)
2594+
/// - Saga is manually reinserted to simulate failed best-effort deletion
25972595
/// - Recovery cleans up orphaned saga
25982596
#[tokio::test]
25992597
async fn test_orphaned_saga_cleanup() {
2598+
use cdk_common::mint::{Saga, SwapSagaState};
2599+
26002600
let mint = create_test_mint().await.unwrap();
26012601
let db = mint.localstore();
26022602

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

26152615
let operation_id = *saga.state_data.operation.id();
26162616
let ys = input_proofs.ys().unwrap();
2617+
let blinded_secrets: Vec<_> = output_blinded_messages
2618+
.iter()
2619+
.map(|bm| bm.blinded_secret)
2620+
.collect();
26172621

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

2620-
// Note: We cannot easily inject a failure for saga deletion within finalize
2621-
// because the deletion happens inside a database transaction and uses the
2622-
// transaction trait. For now, we'll test the recovery side: create a saga
2623-
// that completes, then manually verify recovery can handle scenarios where
2624-
// saga exists but proofs are already SPENT.
2625-
26262624
let _response = saga.finalize().await.expect("Finalize should succeed");
26272625

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

2635-
// In a real scenario with deletion failure, saga would remain.
2636-
// For this test, we'll verify that saga is properly deleted.
2637-
// TODO: Add failure injection for delete_saga to properly test this.
2638-
let saga = {
2633+
let signatures = db.get_blind_signatures(&blinded_secrets).await.unwrap();
2634+
assert!(
2635+
signatures.iter().all(Option::is_some),
2636+
"Outputs should remain signed after successful swap"
2637+
);
2638+
2639+
{
2640+
let mut tx = db.begin_transaction().await.unwrap();
2641+
tx.add_saga(&Saga::new_swap(operation_id, SwapSagaState::SetupComplete))
2642+
.await
2643+
.unwrap();
2644+
tx.commit().await.unwrap();
2645+
}
2646+
2647+
mint.stop().await.expect("Stop should succeed");
2648+
mint.start().await.expect("Start should succeed");
2649+
2650+
let saga_after = {
26392651
let mut tx = db.begin_transaction().await.unwrap();
26402652
let result = tx.get_saga(&operation_id).await.unwrap();
26412653
tx.commit().await.unwrap();
26422654
result
26432655
};
2656+
assert!(saga_after.is_none(), "Recovery should delete saga");
2657+
2658+
let states_after = db.get_proofs_states(&ys).await.unwrap();
26442659
assert!(
2645-
saga.is_none(),
2646-
"Saga should be deleted after successful swap"
2660+
states_after.iter().all(|s| s == &Some(State::Spent)),
2661+
"Recovery should not remove spent proofs"
26472662
);
26482663

2649-
// If we had a way to inject deletion failure, we would:
2650-
// 1. Verify saga remains (orphaned)
2651-
// 2. Run recovery
2652-
// 3. Verify recovery detects proofs are SPENT
2653-
// 4. Verify recovery deletes orphaned saga
2664+
let signatures_after = db.get_blind_signatures(&blinded_secrets).await.unwrap();
2665+
assert!(
2666+
signatures_after.iter().all(Option::is_some),
2667+
"Recovery should not remove signed outputs"
2668+
);
26542669
}
26552670

26562671
/// Tests recovery with orphaned proofs (proofs without corresponding saga).

0 commit comments

Comments
 (0)