|
6 | 6 | use std::str::FromStr; |
7 | 7 |
|
8 | 8 | use cdk_common::mint::{OperationKind, Saga}; |
9 | | -use cdk_common::QuoteId; |
| 9 | +use cdk_common::{PublicKey, QuoteId, State}; |
10 | 10 |
|
11 | 11 | use super::{Error, Mint}; |
12 | 12 | use crate::mint::swap::swap_saga::compensation::{CompensatingAction, RemoveSwapSetup}; |
13 | 13 | use crate::mint::{MeltQuote, MeltQuoteState}; |
14 | 14 | use crate::types::PaymentProcessorKey; |
15 | 15 |
|
| 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 | + |
16 | 25 | impl Mint { |
17 | 26 | /// Get incomplete melt saga by quote_id |
18 | 27 | async fn get_melt_saga_by_quote_id(&self, quote_id: &str) -> Result<Option<Saga>, Error> { |
@@ -237,6 +246,14 @@ impl Mint { |
237 | 246 | .get_blinded_secrets_by_operation_id(&saga.operation_id) |
238 | 247 | .await?; |
239 | 248 |
|
| 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 | + |
240 | 257 | // Use the same compensation logic as in-process failures |
241 | 258 | // Saga deletion is included in the compensation transaction |
242 | 259 | let compensation = RemoveSwapSetup { |
@@ -269,6 +286,79 @@ impl Mint { |
269 | 286 | Ok(()) |
270 | 287 | } |
271 | 288 |
|
| 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 | + |
272 | 362 | /// Recover from incomplete melt sagas |
273 | 363 | /// |
274 | 364 | /// Checks all persisted sagas for melt operations and determines whether to: |
@@ -727,3 +817,92 @@ impl Mint { |
727 | 817 | Ok(()) |
728 | 818 | } |
729 | 819 | } |
| 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 | +} |
0 commit comments