فهرست منبع

Swap saga (#1183)

# Implement Saga Pattern for Swap Operations with Recovery Mechanism

## Overview

This PR refactors the swap operation implementation to use the saga pattern - a distributed transaction pattern that provides reliable transaction management through explicit state tracking and compensation-based error handling. The implementation includes a robust recovery mechanism that automatically handles swap operations interrupted by crashes, power loss, or network failures.

## What Changed

**Saga Pattern Implementation:**
- Introduced a strict linear state machine for swaps: `Initial` → `SetupComplete` → `Signed` → `Completed`
- New modular `swap_saga` module with state validation, compensation logic, and saga orchestration
- Automatic rollback of database changes on failure, ensuring atomic swap operations
- Replaced previous swap implementation (`swap.rs`, `blinded_message_writer.rs`) with saga-based approach

**Recovery Mechanism:**
- Added `operation_id` and `operation_kind` columns to database schema for tracking which operation proofs belong to
- New `recover_from_bad_swaps()` method that runs on mint startup to handle incomplete swaps
- For proofs left in `PENDING` state from swap operations:
  - If blind signatures exist: marks proofs as `SPENT` (swap completed but not finalized)
  - If no blind signatures exist: removes proofs from database (swap failed partway through)
- Database migrations included for both PostgreSQL and SQLite
tsk 2 هفته پیش
والد
کامیت
33c206a310
28فایلهای تغییر یافته به همراه4550 افزوده شده و 361 حذف شده
  1. 44 1
      crates/cdk-common/src/database/mint/mod.rs
  2. 6 6
      crates/cdk-common/src/database/mint/test/mint.rs
  3. 3 1
      crates/cdk-common/src/database/mint/test/mod.rs
  4. 25 8
      crates/cdk-common/src/database/mint/test/proofs.rs
  5. 4 1
      crates/cdk-common/src/error.rs
  6. 203 1
      crates/cdk-common/src/mint.rs
  7. 0 5
      crates/cdk-mintd/src/lib.rs
  8. 1 0
      crates/cdk-sql-common/Cargo.toml
  9. 24 0
      crates/cdk-sql-common/src/mint/migrations/postgres/20251010144317_add_saga_support.sql
  10. 24 0
      crates/cdk-sql-common/src/mint/migrations/sqlite/20251010144317_add_saga_support.sql
  11. 200 5
      crates/cdk-sql-common/src/mint/mod.rs
  12. 5 3
      crates/cdk-sqlite/src/mint/memory.rs
  13. 2 0
      crates/cdk/Cargo.toml
  14. 3 0
      crates/cdk/src/lib.rs
  15. 0 152
      crates/cdk/src/mint/blinded_message_writer.rs
  16. 5 1
      crates/cdk/src/mint/issue/mod.rs
  17. 6 2
      crates/cdk/src/mint/melt.rs
  18. 16 1
      crates/cdk/src/mint/mod.rs
  19. 7 1
      crates/cdk/src/mint/proof_writer.rs
  20. 66 0
      crates/cdk/src/mint/start_up_check.rs
  21. 0 173
      crates/cdk/src/mint/swap.rs
  22. 84 0
      crates/cdk/src/mint/swap/mod.rs
  23. 61 0
      crates/cdk/src/mint/swap/swap_saga/compensation.rs
  24. 514 0
      crates/cdk/src/mint/swap/swap_saga/mod.rs
  25. 26 0
      crates/cdk/src/mint/swap/swap_saga/state.rs
  26. 2993 0
      crates/cdk/src/mint/swap/swap_saga/tests.rs
  27. 218 0
      crates/cdk/src/test_helpers/mint.rs
  28. 10 0
      crates/cdk/src/test_helpers/mod.rs

+ 44 - 1
crates/cdk-common/src/database/mint/mod.rs

@@ -7,7 +7,7 @@ use cashu::quote_id::QuoteId;
 use cashu::Amount;
 
 use super::Error;
-use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote};
+use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote, Operation};
 use crate::nuts::{
     BlindSignature, BlindedMessage, CurrencyUnit, Id, MeltQuoteState, Proof, Proofs, PublicKey,
     State,
@@ -145,6 +145,7 @@ pub trait QuotesTransaction<'a> {
         &mut self,
         quote_id: Option<&QuoteId>,
         blinded_messages: &[BlindedMessage],
+        operation: &Operation,
     ) -> Result<(), Self::Err>;
 
     /// Delete blinded_messages by their blinded secrets
@@ -265,6 +266,7 @@ pub trait ProofsTransaction<'a> {
         &mut self,
         proof: Proofs,
         quote_id: Option<QuoteId>,
+        operation: &Operation,
     ) -> Result<(), Self::Err>;
     /// Updates the proofs to a given states and return the previous states
     async fn update_proofs_states(
@@ -354,6 +356,45 @@ pub trait SignaturesDatabase {
 }
 
 #[async_trait]
+/// Saga Transaction trait
+pub trait SagaTransaction<'a> {
+    /// Saga Database Error
+    type Err: Into<Error> + From<Error>;
+
+    /// Get saga by operation_id
+    async fn get_saga(
+        &mut self,
+        operation_id: &uuid::Uuid,
+    ) -> Result<Option<mint::Saga>, Self::Err>;
+
+    /// Add saga
+    async fn add_saga(&mut self, saga: &mint::Saga) -> Result<(), Self::Err>;
+
+    /// Update saga state (only updates state and updated_at fields)
+    async fn update_saga(
+        &mut self,
+        operation_id: &uuid::Uuid,
+        new_state: mint::SagaStateEnum,
+    ) -> Result<(), Self::Err>;
+
+    /// Delete saga
+    async fn delete_saga(&mut self, operation_id: &uuid::Uuid) -> Result<(), Self::Err>;
+}
+
+#[async_trait]
+/// Saga Database trait
+pub trait SagaDatabase {
+    /// Saga Database Error
+    type Err: Into<Error> + From<Error>;
+
+    /// Get all incomplete sagas for a given operation kind
+    async fn get_incomplete_sagas(
+        &self,
+        operation_kind: mint::OperationKind,
+    ) -> Result<Vec<mint::Saga>, Self::Err>;
+}
+
+#[async_trait]
 /// Commit and Rollback
 pub trait DbTransactionFinalizer {
     /// Mint Signature Database Error
@@ -409,6 +450,7 @@ pub trait Transaction<'a, Error>:
     + SignaturesTransaction<'a, Err = Error>
     + ProofsTransaction<'a, Err = Error>
     + KVStoreTransaction<'a, Error>
+    + SagaTransaction<'a, Err = Error>
 {
 }
 
@@ -453,6 +495,7 @@ pub trait Database<Error>:
     + QuotesDatabase<Err = Error>
     + ProofsDatabase<Err = Error>
     + SignaturesDatabase<Err = Error>
+    + SagaDatabase<Err = Error>
 {
     /// Beings a transaction
     async fn begin_transaction<'a>(

+ 6 - 6
crates/cdk-common/src/database/mint/test/mint.rs

@@ -8,7 +8,7 @@ use cashu::{Amount, Id, SecretKey};
 use crate::database::mint::test::unique_string;
 use crate::database::mint::{Database, Error, KeysDatabase};
 use crate::database::MintSignaturesDatabase;
-use crate::mint::{MeltPaymentRequest, MeltQuote, MintQuote};
+use crate::mint::{MeltPaymentRequest, MeltQuote, MintQuote, Operation};
 use crate::payment::PaymentIdentifier;
 
 /// Add a mint quote
@@ -435,7 +435,7 @@ where
     tx.add_melt_request(&quote.id, inputs_amount, inputs_fee)
         .await
         .unwrap();
-    tx.add_blinded_messages(Some(&quote.id), &blinded_messages)
+    tx.add_blinded_messages(Some(&quote.id), &blinded_messages, &Operation::new_melt())
         .await
         .unwrap();
     tx.commit().await.unwrap();
@@ -497,7 +497,7 @@ where
         .await
         .unwrap();
     let result = tx
-        .add_blinded_messages(Some(&quote2.id), &blinded_messages)
+        .add_blinded_messages(Some(&quote2.id), &blinded_messages, &Operation::new_melt())
         .await;
     assert!(result.is_err() && matches!(result.unwrap_err(), Error::Duplicate));
     tx.rollback().await.unwrap(); // Rollback to avoid partial state
@@ -530,7 +530,7 @@ where
         .await
         .unwrap();
     assert!(tx
-        .add_blinded_messages(Some(&quote.id), &blinded_messages)
+        .add_blinded_messages(Some(&quote.id), &blinded_messages, &Operation::new_melt())
         .await
         .is_ok());
     tx.commit().await.unwrap();
@@ -543,7 +543,7 @@ where
         .await
         .unwrap();
     let result = tx
-        .add_blinded_messages(Some(&quote.id), &blinded_messages)
+        .add_blinded_messages(Some(&quote.id), &blinded_messages, &Operation::new_melt())
         .await;
     // Expect a database error due to unique violation
     assert!(result.is_err()); // Specific error might be DB-specific, e.g., SqliteError or PostgresError
@@ -576,7 +576,7 @@ where
     tx1.add_melt_request(&quote.id, inputs_amount, inputs_fee)
         .await
         .unwrap();
-    tx1.add_blinded_messages(Some(&quote.id), &blinded_messages)
+    tx1.add_blinded_messages(Some(&quote.id), &blinded_messages, &Operation::new_melt())
         .await
         .unwrap();
     tx1.commit().await.unwrap();

+ 3 - 1
crates/cdk-common/src/database/mint/test/mod.rs

@@ -74,7 +74,9 @@ where
 
     // Add proofs to database
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs.clone(), None).await.unwrap();
+    tx.add_proofs(proofs.clone(), None, &Operation::new_swap())
+        .await
+        .unwrap();
 
     // Mark one proof as `pending`
     assert!(tx

+ 25 - 8
crates/cdk-common/src/database/mint/test/proofs.rs

@@ -7,6 +7,7 @@ use cashu::{Amount, Id, SecretKey};
 
 use crate::database::mint::test::setup_keyset;
 use crate::database::mint::{Database, Error, KeysDatabase, Proof, QuoteId};
+use crate::mint::Operation;
 
 /// Test get proofs by keyset id
 pub async fn get_proofs_by_keyset_id<DB>(db: DB)
@@ -36,7 +37,9 @@ where
 
     // Add proofs to database
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs, Some(quote_id)).await.unwrap();
+    tx.add_proofs(proofs, Some(quote_id), &Operation::new_swap())
+        .await
+        .unwrap();
     assert!(tx.commit().await.is_ok());
 
     let (proofs, states) = db.get_proofs_by_keyset_id(&keyset_id).await.unwrap();
@@ -88,9 +91,13 @@ where
 
     // Add proofs to database
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs.clone(), Some(quote_id.clone()))
-        .await
-        .unwrap();
+    tx.add_proofs(
+        proofs.clone(),
+        Some(quote_id.clone()),
+        &Operation::new_swap(),
+    )
+    .await
+    .unwrap();
     assert!(tx.commit().await.is_ok());
 
     let proofs_from_db = db.get_proofs_by_ys(&[proofs[0].c, proofs[1].c]).await;
@@ -132,13 +139,23 @@ where
 
     // Add proofs to database
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs.clone(), Some(quote_id.clone()))
-        .await
-        .unwrap();
+    tx.add_proofs(
+        proofs.clone(),
+        Some(quote_id.clone()),
+        &Operation::new_swap(),
+    )
+    .await
+    .unwrap();
     assert!(tx.commit().await.is_ok());
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let result = tx.add_proofs(proofs.clone(), Some(quote_id.clone())).await;
+    let result = tx
+        .add_proofs(
+            proofs.clone(),
+            Some(quote_id.clone()),
+            &Operation::new_swap(),
+        )
+        .await;
 
     assert!(
         matches!(result.unwrap_err(), Error::Duplicate),

+ 4 - 1
crates/cdk-common/src/error.rs

@@ -335,7 +335,10 @@ pub enum Error {
     /// Http transport error
     #[error("Http transport error {0:?}: {1}")]
     HttpError(Option<u16>, String),
-    #[cfg(feature = "wallet")]
+    /// Parse invoice error
+    #[cfg(feature = "mint")]
+    #[error(transparent)]
+    Uuid(#[from] uuid::Error),
     // Crate error conversions
     /// Cashu Url Error
     #[error(transparent)]

+ 203 - 1
crates/cdk-common/src/mint.rs

@@ -1,5 +1,8 @@
 //! Mint types
 
+use std::fmt;
+use std::str::FromStr;
+
 use bitcoin::bip32::DerivationPath;
 use cashu::quote_id::QuoteId;
 use cashu::util::unix_time;
@@ -14,7 +17,206 @@ use uuid::Uuid;
 
 use crate::nuts::{MeltQuoteState, MintQuoteState};
 use crate::payment::PaymentIdentifier;
-use crate::{Amount, CurrencyUnit, Id, KeySetInfo, PublicKey};
+use crate::{Amount, CurrencyUnit, Error, Id, KeySetInfo, PublicKey};
+
+/// Operation kind for saga persistence
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum OperationKind {
+    /// Swap operation
+    Swap,
+    /// Mint operation
+    Mint,
+    /// Melt operation
+    Melt,
+}
+
+impl fmt::Display for OperationKind {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            OperationKind::Swap => write!(f, "swap"),
+            OperationKind::Mint => write!(f, "mint"),
+            OperationKind::Melt => write!(f, "melt"),
+        }
+    }
+}
+
+impl FromStr for OperationKind {
+    type Err = Error;
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        let value = value.to_lowercase();
+        match value.as_str() {
+            "swap" => Ok(OperationKind::Swap),
+            "mint" => Ok(OperationKind::Mint),
+            "melt" => Ok(OperationKind::Melt),
+            _ => Err(Error::Custom(format!("Invalid operation kind: {}", value))),
+        }
+    }
+}
+
+/// States specific to swap saga
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SwapSagaState {
+    /// Swap setup complete (proofs added, blinded messages added)
+    SetupComplete,
+    /// Outputs signed (signatures generated but not persisted)
+    Signed,
+}
+
+impl fmt::Display for SwapSagaState {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            SwapSagaState::SetupComplete => write!(f, "setup_complete"),
+            SwapSagaState::Signed => write!(f, "signed"),
+        }
+    }
+}
+
+impl FromStr for SwapSagaState {
+    type Err = Error;
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        let value = value.to_lowercase();
+        match value.as_str() {
+            "setup_complete" => Ok(SwapSagaState::SetupComplete),
+            "signed" => Ok(SwapSagaState::Signed),
+            _ => Err(Error::Custom(format!("Invalid swap saga state: {}", value))),
+        }
+    }
+}
+
+/// Saga state for different operation types
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum SagaStateEnum {
+    /// Swap saga states
+    Swap(SwapSagaState),
+    // Future: Mint saga states
+    // Mint(MintSagaState),
+    // Future: Melt saga states
+    // Melt(MeltSagaState),
+}
+
+impl SagaStateEnum {
+    /// Create from string given operation kind
+    pub fn new(operation_kind: OperationKind, s: &str) -> Result<Self, Error> {
+        match operation_kind {
+            OperationKind::Swap => Ok(SagaStateEnum::Swap(SwapSagaState::from_str(s)?)),
+            OperationKind::Mint => Err(Error::Custom("Mint saga not implemented yet".to_string())),
+            OperationKind::Melt => Err(Error::Custom("Melt saga not implemented yet".to_string())),
+        }
+    }
+
+    /// Get string representation of the state
+    pub fn state(&self) -> &str {
+        match self {
+            SagaStateEnum::Swap(state) => match state {
+                SwapSagaState::SetupComplete => "setup_complete",
+                SwapSagaState::Signed => "signed",
+            },
+        }
+    }
+}
+
+/// Persisted saga for recovery
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Saga {
+    /// Operation ID (correlation key)
+    pub operation_id: Uuid,
+    /// Operation kind (swap, mint, melt)
+    pub operation_kind: OperationKind,
+    /// Current saga state (operation-specific)
+    pub state: SagaStateEnum,
+    /// Blinded secrets (B values) from output blinded messages
+    pub blinded_secrets: Vec<PublicKey>,
+    /// Y values (public keys) from input proofs
+    pub input_ys: Vec<PublicKey>,
+    /// Unix timestamp when saga was created
+    pub created_at: u64,
+    /// Unix timestamp when saga was last updated
+    pub updated_at: u64,
+}
+
+impl Saga {
+    /// Create new swap saga
+    pub fn new_swap(
+        operation_id: Uuid,
+        state: SwapSagaState,
+        blinded_secrets: Vec<PublicKey>,
+        input_ys: Vec<PublicKey>,
+    ) -> Self {
+        let now = unix_time();
+        Self {
+            operation_id,
+            operation_kind: OperationKind::Swap,
+            state: SagaStateEnum::Swap(state),
+            blinded_secrets,
+            input_ys,
+            created_at: now,
+            updated_at: now,
+        }
+    }
+
+    /// Update swap saga state
+    pub fn update_swap_state(&mut self, new_state: SwapSagaState) {
+        self.state = SagaStateEnum::Swap(new_state);
+        self.updated_at = unix_time();
+    }
+}
+
+/// Operation
+pub enum Operation {
+    /// Mint
+    Mint(Uuid),
+    /// Melt
+    Melt(Uuid),
+    /// Swap
+    Swap(Uuid),
+}
+
+impl Operation {
+    /// Mint
+    pub fn new_mint() -> Self {
+        Self::Mint(Uuid::new_v4())
+    }
+    /// Melt
+    pub fn new_melt() -> Self {
+        Self::Melt(Uuid::new_v4())
+    }
+    /// Swap
+    pub fn new_swap() -> Self {
+        Self::Swap(Uuid::new_v4())
+    }
+
+    /// Operation id
+    pub fn id(&self) -> &Uuid {
+        match self {
+            Operation::Mint(id) => id,
+            Operation::Melt(id) => id,
+            Operation::Swap(id) => id,
+        }
+    }
+
+    /// Operation kind
+    pub fn kind(&self) -> &str {
+        match self {
+            Operation::Mint(_) => "mint",
+            Operation::Melt(_) => "melt",
+            Operation::Swap(_) => "swap",
+        }
+    }
+
+    /// From kind and i
+    pub fn from_kind_and_id(kind: &str, id: &str) -> Result<Self, Error> {
+        let uuid = Uuid::parse_str(id)?;
+        match kind {
+            "mint" => Ok(Self::Mint(uuid)),
+            "melt" => Ok(Self::Melt(uuid)),
+            "swap" => Ok(Self::Swap(uuid)),
+            _ => Err(Error::Custom(format!("Invalid operation kind: {}", kind))),
+        }
+    }
+}
 
 /// Mint Quote Info
 #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]

+ 0 - 5
crates/cdk-mintd/src/lib.rs

@@ -1183,11 +1183,6 @@ pub async fn run_mintd_with_shutdown(
 
     let mint = Arc::new(mint);
 
-    // Checks the status of all pending melt quotes
-    // Pending melt quotes where the payment has gone through inputs are burnt
-    // Pending melt quotes where the payment has **failed** inputs are reset to unspent
-    mint.check_pending_melt_quotes().await?;
-
     start_services_with_shutdown(
         mint.clone(),
         settings,

+ 1 - 0
crates/cdk-sql-common/Cargo.toml

@@ -29,3 +29,4 @@ serde.workspace = true
 serde_json.workspace = true
 lightning-invoice.workspace = true
 once_cell.workspace = true
+uuid.workspace = true

+ 24 - 0
crates/cdk-sql-common/src/mint/migrations/postgres/20251010144317_add_saga_support.sql

@@ -0,0 +1,24 @@
+-- Add operation and operation_id columns to proof table
+ALTER TABLE proof ADD COLUMN operation_kind TEXT;
+ALTER TABLE proof ADD COLUMN operation_id TEXT;
+
+-- Add operation and operation_id columns to blind_signature table
+ALTER TABLE blind_signature ADD COLUMN operation_kind TEXT;
+ALTER TABLE blind_signature ADD COLUMN operation_id TEXT;
+
+CREATE INDEX idx_proof_state_operation ON proof(state, operation_kind);
+CREATE INDEX idx_proof_operation_id ON proof(operation_kind, operation_id);
+CREATE INDEX idx_blind_sig_operation_id ON blind_signature(operation_kind, operation_id);
+
+-- Add saga_state table for persisting saga state
+CREATE TABLE IF NOT EXISTS saga_state (
+    operation_id TEXT PRIMARY KEY,
+    operation_kind TEXT NOT NULL,
+    state TEXT NOT NULL,
+    blinded_secrets TEXT NOT NULL,
+    input_ys TEXT NOT NULL,
+    created_at BIGINT NOT NULL,
+    updated_at BIGINT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_saga_state_operation_kind ON saga_state(operation_kind);

+ 24 - 0
crates/cdk-sql-common/src/mint/migrations/sqlite/20251010144317_add_saga_support.sql

@@ -0,0 +1,24 @@
+-- Add operation and operation_id columns to proof table
+ALTER TABLE proof ADD COLUMN operation_kind TEXT;
+ALTER TABLE proof ADD COLUMN operation_id TEXT;
+
+-- Add operation and operation_id columns to blind_signature table
+ALTER TABLE blind_signature ADD COLUMN operation_kind TEXT;
+ALTER TABLE blind_signature ADD COLUMN operation_id TEXT;
+
+CREATE INDEX idx_proof_state_operation ON proof(state, operation_kind);
+CREATE INDEX idx_proof_operation_id ON proof(operation_kind, operation_id);
+CREATE INDEX idx_blind_sig_operation_id ON blind_signature(operation_kind, operation_id);
+
+-- Add saga_state table for persisting saga state
+CREATE TABLE IF NOT EXISTS saga_state (
+    operation_id TEXT PRIMARY KEY,
+    operation_kind TEXT NOT NULL,
+    state TEXT NOT NULL,
+    blinded_secrets TEXT NOT NULL,
+    input_ys TEXT NOT NULL,
+    created_at INTEGER NOT NULL,
+    updated_at INTEGER NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_saga_state_operation_kind ON saga_state(operation_kind);

+ 200 - 5
crates/cdk-sql-common/src/mint/mod.rs

@@ -15,7 +15,7 @@ use std::sync::Arc;
 
 use async_trait::async_trait;
 use bitcoin::bip32::DerivationPath;
-use cdk_common::database::mint::validate_kvstore_params;
+use cdk_common::database::mint::{validate_kvstore_params, SagaDatabase, SagaTransaction};
 use cdk_common::database::{
     self, ConversionError, Error, MintDatabase, MintDbWriterFinalizer, MintKeyDatabaseTransaction,
     MintKeysDatabase, MintProofsDatabase, MintQuotesDatabase, MintQuotesTransaction,
@@ -23,6 +23,7 @@ use cdk_common::database::{
 };
 use cdk_common::mint::{
     self, IncomingPayment, Issuance, MeltPaymentRequest, MeltQuote, MintKeySetInfo, MintQuote,
+    Operation,
 };
 use cdk_common::nut00::ProofsMethods;
 use cdk_common::payment::PaymentIdentifier;
@@ -138,6 +139,7 @@ where
         &mut self,
         proofs: Proofs,
         quote_id: Option<QuoteId>,
+        operation: &Operation,
     ) -> Result<(), Self::Err> {
         let current_time = unix_time();
 
@@ -165,9 +167,9 @@ where
             query(
                 r#"
                   INSERT INTO proof
-                  (y, amount, keyset_id, secret, c, witness, state, quote_id, created_time)
+                  (y, amount, keyset_id, secret, c, witness, state, quote_id, created_time, operation_kind, operation_id)
                   VALUES
-                  (:y, :amount, :keyset_id, :secret, :c, :witness, :state, :quote_id, :created_time)
+                  (:y, :amount, :keyset_id, :secret, :c, :witness, :state, :quote_id, :created_time, :operation_kind, :operation_id)
                   "#,
             )?
             .bind("y", proof.y()?.to_bytes().to_vec())
@@ -182,6 +184,8 @@ where
             .bind("state", "UNSPENT".to_string())
             .bind("quote_id", quote_id.clone().map(|q| q.to_string()))
             .bind("created_time", current_time as i64)
+            .bind("operation_kind", operation.kind())
+            .bind("operation_id", operation.id().to_string())
             .execute(&self.inner)
             .await?;
         }
@@ -574,6 +578,7 @@ where
         &mut self,
         quote_id: Option<&QuoteId>,
         blinded_messages: &[BlindedMessage],
+        operation: &Operation,
     ) -> Result<(), Self::Err> {
         let current_time = unix_time();
 
@@ -583,9 +588,9 @@ where
             match query(
                 r#"
                 INSERT INTO blind_signature
-                (blinded_message, amount, keyset_id, c, quote_id, created_time)
+                (blinded_message, amount, keyset_id, c, quote_id, created_time, operation_kind, operation_id)
                 VALUES
-                (:blinded_message, :amount, :keyset_id, NULL, :quote_id, :created_time)
+                (:blinded_message, :amount, :keyset_id, NULL, :quote_id, :created_time, :operation_kind, :operation_id)
                 "#,
             )?
             .bind(
@@ -596,6 +601,8 @@ where
             .bind("keyset_id", message.keyset_id.to_string())
             .bind("quote_id", quote_id.map(|q| q.to_string()))
             .bind("created_time", current_time as i64)
+            .bind("operation_kind", operation.kind())
+            .bind("operation_id", operation.id().to_string())
             .execute(&self.inner)
             .await
             {
@@ -2121,6 +2128,147 @@ where
 }
 
 #[async_trait]
+impl<RM> SagaTransaction<'_> for SQLTransaction<RM>
+where
+    RM: DatabasePool + 'static,
+{
+    type Err = Error;
+
+    async fn get_saga(
+        &mut self,
+        operation_id: &uuid::Uuid,
+    ) -> Result<Option<mint::Saga>, Self::Err> {
+        Ok(query(
+            r#"
+            SELECT
+                operation_id,
+                operation_kind,
+                state,
+                blinded_secrets,
+                input_ys,
+                created_at,
+                updated_at
+            FROM
+                saga_state
+            WHERE
+                operation_id = :operation_id
+            FOR UPDATE
+            "#,
+        )?
+        .bind("operation_id", operation_id.to_string())
+        .fetch_one(&self.inner)
+        .await?
+        .map(sql_row_to_saga)
+        .transpose()?)
+    }
+
+    async fn add_saga(&mut self, saga: &mint::Saga) -> Result<(), Self::Err> {
+        let current_time = unix_time();
+
+        let blinded_secrets_json = serde_json::to_string(&saga.blinded_secrets)
+            .map_err(|e| Error::Internal(format!("Failed to serialize blinded_secrets: {}", e)))?;
+
+        let input_ys_json = serde_json::to_string(&saga.input_ys)
+            .map_err(|e| Error::Internal(format!("Failed to serialize input_ys: {}", e)))?;
+
+        query(
+            r#"
+            INSERT INTO saga_state
+            (operation_id, operation_kind, state, blinded_secrets, input_ys, created_at, updated_at)
+            VALUES
+            (:operation_id, :operation_kind, :state, :blinded_secrets, :input_ys, :created_at, :updated_at)
+            "#,
+        )?
+        .bind("operation_id", saga.operation_id.to_string())
+        .bind("operation_kind", saga.operation_kind.to_string())
+        .bind("state", saga.state.state())
+        .bind("blinded_secrets", blinded_secrets_json)
+        .bind("input_ys", input_ys_json)
+        .bind("created_at", saga.created_at as i64)
+        .bind("updated_at", current_time as i64)
+        .execute(&self.inner)
+        .await?;
+
+        Ok(())
+    }
+
+    async fn update_saga(
+        &mut self,
+        operation_id: &uuid::Uuid,
+        new_state: mint::SagaStateEnum,
+    ) -> Result<(), Self::Err> {
+        let current_time = unix_time();
+
+        query(
+            r#"
+            UPDATE saga_state
+            SET state = :state, updated_at = :updated_at
+            WHERE operation_id = :operation_id
+            "#,
+        )?
+        .bind("state", new_state.state())
+        .bind("updated_at", current_time as i64)
+        .bind("operation_id", operation_id.to_string())
+        .execute(&self.inner)
+        .await?;
+
+        Ok(())
+    }
+
+    async fn delete_saga(&mut self, operation_id: &uuid::Uuid) -> Result<(), Self::Err> {
+        query(
+            r#"
+            DELETE FROM saga_state
+            WHERE operation_id = :operation_id
+            "#,
+        )?
+        .bind("operation_id", operation_id.to_string())
+        .execute(&self.inner)
+        .await?;
+
+        Ok(())
+    }
+}
+
+#[async_trait]
+impl<RM> SagaDatabase for SQLMintDatabase<RM>
+where
+    RM: DatabasePool + 'static,
+{
+    type Err = Error;
+
+    async fn get_incomplete_sagas(
+        &self,
+        operation_kind: mint::OperationKind,
+    ) -> Result<Vec<mint::Saga>, Self::Err> {
+        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
+        Ok(query(
+            r#"
+            SELECT
+                operation_id,
+                operation_kind,
+                state,
+                blinded_secrets,
+                input_ys,
+                created_at,
+                updated_at
+            FROM
+                saga_state
+            WHERE
+                operation_kind = :operation_kind
+            ORDER BY created_at ASC
+            "#,
+        )?
+        .bind("operation_kind", operation_kind.to_string())
+        .fetch_all(&*conn)
+        .await?
+        .into_iter()
+        .map(sql_row_to_saga)
+        .collect::<Result<Vec<_>, _>>()?)
+    }
+}
+
+#[async_trait]
 impl<RM> MintDatabase<Error> for SQLMintDatabase<RM>
 where
     RM: DatabasePool + 'static,
@@ -2383,6 +2531,53 @@ fn sql_row_to_blind_signature(row: Vec<Column>) -> Result<BlindSignature, Error>
     })
 }
 
+fn sql_row_to_saga(row: Vec<Column>) -> Result<mint::Saga, Error> {
+    unpack_into!(
+        let (
+            operation_id,
+            operation_kind,
+            state,
+            blinded_secrets,
+            input_ys,
+            created_at,
+            updated_at
+        ) = row
+    );
+
+    let operation_id_str = column_as_string!(&operation_id);
+    let operation_id = uuid::Uuid::parse_str(&operation_id_str)
+        .map_err(|e| Error::Internal(format!("Invalid operation_id UUID: {}", e)))?;
+
+    let operation_kind_str = column_as_string!(&operation_kind);
+    let operation_kind = mint::OperationKind::from_str(&operation_kind_str)
+        .map_err(|e| Error::Internal(format!("Invalid operation kind: {}", e)))?;
+
+    let state_str = column_as_string!(&state);
+    let state = mint::SagaStateEnum::new(operation_kind, &state_str)
+        .map_err(|e| Error::Internal(format!("Invalid saga state: {}", e)))?;
+
+    let blinded_secrets_str = column_as_string!(&blinded_secrets);
+    let blinded_secrets: Vec<PublicKey> = serde_json::from_str(&blinded_secrets_str)
+        .map_err(|e| Error::Internal(format!("Failed to deserialize blinded_secrets: {}", e)))?;
+
+    let input_ys_str = column_as_string!(&input_ys);
+    let input_ys: Vec<PublicKey> = serde_json::from_str(&input_ys_str)
+        .map_err(|e| Error::Internal(format!("Failed to deserialize input_ys: {}", e)))?;
+
+    let created_at: u64 = column_as_number!(created_at);
+    let updated_at: u64 = column_as_number!(updated_at);
+
+    Ok(mint::Saga {
+        operation_id,
+        operation_kind,
+        state,
+        blinded_secrets,
+        input_ys,
+        created_at,
+        updated_at,
+    })
+}
+
 #[cfg(test)]
 mod test {
     use super::*;

+ 5 - 3
crates/cdk-sqlite/src/mint/memory.rs

@@ -2,7 +2,7 @@
 use std::collections::HashMap;
 
 use cdk_common::database::{self, MintDatabase, MintKeysDatabase};
-use cdk_common::mint::{self, MintKeySetInfo, MintQuote};
+use cdk_common::mint::{self, MintKeySetInfo, MintQuote, Operation};
 use cdk_common::nuts::{CurrencyUnit, Id, Proofs};
 use cdk_common::MintInfo;
 
@@ -56,8 +56,10 @@ pub async fn new_with_state(
         tx.add_melt_quote(quote).await?;
     }
 
-    tx.add_proofs(pending_proofs, None).await?;
-    tx.add_proofs(spent_proofs, None).await?;
+    tx.add_proofs(pending_proofs, None, &Operation::new_swap())
+        .await?;
+    tx.add_proofs(spent_proofs, None, &Operation::new_swap())
+        .await?;
     let mint_info_bytes = serde_json::to_vec(&mint_info)?;
     tx.kv_write(
         CDK_MINT_PRIMARY_NAMESPACE,

+ 2 - 0
crates/cdk/Cargo.toml

@@ -141,12 +141,14 @@ required-features = ["wallet"]
 [dev-dependencies]
 rand.workspace = true
 cdk-sqlite.workspace = true
+cdk-fake-wallet.workspace = true
 bip39.workspace = true
 tracing-subscriber.workspace = true
 criterion.workspace = true
 reqwest = { workspace = true }
 anyhow.workspace = true
 ureq = { version = "3.1.0", features = ["json"] }
+tokio = { workspace = true, features = ["full"] }
 
 
 [[bench]]

+ 3 - 0
crates/cdk/src/lib.rs

@@ -49,6 +49,9 @@ pub use oidc_client::OidcClient;
 pub mod event;
 pub mod fees;
 
+#[cfg(test)]
+pub mod test_helpers;
+
 #[doc(hidden)]
 pub use bitcoin::secp256k1;
 #[cfg(feature = "mint")]

+ 0 - 152
crates/cdk/src/mint/blinded_message_writer.rs

@@ -1,152 +0,0 @@
-//! Blinded message writer
-use std::collections::HashSet;
-
-use cdk_common::database::{self, DynMintDatabase, MintTransaction};
-use cdk_common::nuts::BlindedMessage;
-use cdk_common::{Error, PublicKey, QuoteId};
-
-type Tx<'a, 'b> = Box<dyn MintTransaction<'a, database::Error> + Send + Sync + 'b>;
-
-/// Blinded message writer
-///
-/// This is a blinded message writer that emulates a database transaction but without holding the
-/// transaction alive while waiting for external events to be fully committed to the database;
-/// instead, it maintains a `pending` state.
-///
-/// This struct allows for premature exit on error, enabling it to remove blinded messages that
-/// were added during the operation.
-///
-/// This struct is not fully ACID. If the process exits due to a panic, and the `Drop` function
-/// cannot be run, the cleanup process should reset the state.
-pub struct BlindedMessageWriter {
-    db: Option<DynMintDatabase>,
-    added_blinded_secrets: Option<HashSet<PublicKey>>,
-}
-
-impl BlindedMessageWriter {
-    /// Creates a new BlindedMessageWriter on top of the database
-    pub fn new(db: DynMintDatabase) -> Self {
-        Self {
-            db: Some(db),
-            added_blinded_secrets: Some(Default::default()),
-        }
-    }
-
-    /// The changes are permanent, consume the struct removing the database, so the Drop does
-    /// nothing
-    pub fn commit(mut self) {
-        self.db.take();
-        self.added_blinded_secrets.take();
-    }
-
-    /// Add blinded messages
-    pub async fn add_blinded_messages(
-        &mut self,
-        tx: &mut Tx<'_, '_>,
-        quote_id: Option<QuoteId>,
-        blinded_messages: &[BlindedMessage],
-    ) -> Result<Vec<PublicKey>, Error> {
-        let added_secrets = if let Some(secrets) = self.added_blinded_secrets.as_mut() {
-            secrets
-        } else {
-            return Err(Error::Internal);
-        };
-
-        if let Some(err) = tx
-            .add_blinded_messages(quote_id.as_ref(), blinded_messages)
-            .await
-            .err()
-        {
-            return match err {
-                cdk_common::database::Error::Duplicate => Err(Error::DuplicateOutputs),
-                err => Err(Error::Database(err)),
-            };
-        }
-
-        let blinded_secrets: Vec<PublicKey> = blinded_messages
-            .iter()
-            .map(|bm| bm.blinded_secret)
-            .collect();
-
-        for blinded_secret in &blinded_secrets {
-            added_secrets.insert(*blinded_secret);
-        }
-
-        Ok(blinded_secrets)
-    }
-
-    /// Rollback all changes in this BlindedMessageWriter consuming it.
-    pub async fn rollback(mut self) -> Result<(), Error> {
-        let db = if let Some(db) = self.db.take() {
-            db
-        } else {
-            return Ok(());
-        };
-        let mut tx = db.begin_transaction().await?;
-        let blinded_secrets: Vec<PublicKey> =
-            if let Some(secrets) = self.added_blinded_secrets.take() {
-                secrets.into_iter().collect()
-            } else {
-                return Ok(());
-            };
-
-        if !blinded_secrets.is_empty() {
-            tracing::info!("Rollback {} blinded messages", blinded_secrets.len(),);
-
-            remove_blinded_messages(&mut tx, &blinded_secrets).await?;
-        }
-
-        tx.commit().await?;
-
-        Ok(())
-    }
-}
-
-/// Removes blinded messages from the database
-#[inline(always)]
-async fn remove_blinded_messages(
-    tx: &mut Tx<'_, '_>,
-    blinded_secrets: &[PublicKey],
-) -> Result<(), Error> {
-    tx.delete_blinded_messages(blinded_secrets)
-        .await
-        .map_err(Error::Database)
-}
-
-#[inline(always)]
-async fn rollback_blinded_messages(
-    db: DynMintDatabase,
-    blinded_secrets: Vec<PublicKey>,
-) -> Result<(), Error> {
-    let mut tx = db.begin_transaction().await?;
-    remove_blinded_messages(&mut tx, &blinded_secrets).await?;
-    tx.commit().await?;
-
-    Ok(())
-}
-
-impl Drop for BlindedMessageWriter {
-    fn drop(&mut self) {
-        let db = if let Some(db) = self.db.take() {
-            db
-        } else {
-            tracing::debug!("Blinded message writer dropped after commit, no need to rollback.");
-            return;
-        };
-        let blinded_secrets: Vec<PublicKey> =
-            if let Some(secrets) = self.added_blinded_secrets.take() {
-                secrets.into_iter().collect()
-            } else {
-                return;
-            };
-
-        if !blinded_secrets.is_empty() {
-            tracing::debug!("Blinded message writer dropper with messages attempting to remove.");
-            tokio::spawn(async move {
-                if let Err(err) = rollback_blinded_messages(db, blinded_secrets).await {
-                    tracing::error!("Failed to rollback blinded messages in Drop: {}", err);
-                }
-            });
-        }
-    }
-}

+ 5 - 1
crates/cdk/src/mint/issue/mod.rs

@@ -1,4 +1,4 @@
-use cdk_common::mint::MintQuote;
+use cdk_common::mint::{MintQuote, Operation};
 use cdk_common::payment::{
     Bolt11IncomingPaymentOptions, Bolt11Settings, Bolt12IncomingPaymentOptions,
     IncomingPaymentOptions, WaitPaymentResponse,
@@ -657,6 +657,10 @@ impl Mint {
         let unit = unit.ok_or(Error::UnsupportedUnit).unwrap();
         ensure_cdk!(unit == mint_quote.unit, Error::UnsupportedUnit);
 
+        let operation = Operation::new_mint();
+
+        tx.add_blinded_messages(Some(&mint_request.quote), &mint_request.outputs, &operation).await?;
+
         tx.add_blind_signatures(
             &mint_request
                 .outputs

+ 6 - 2
crates/cdk/src/mint/melt.rs

@@ -5,7 +5,7 @@ use cdk_common::amount::amount_for_offer;
 use cdk_common::database::mint::MeltRequestInfo;
 use cdk_common::database::{self, MintTransaction};
 use cdk_common::melt::MeltQuoteRequest;
-use cdk_common::mint::MeltPaymentRequest;
+use cdk_common::mint::{MeltPaymentRequest, Operation};
 use cdk_common::nut05::MeltMethodOptions;
 use cdk_common::payment::{
     Bolt11OutgoingPaymentOptions, Bolt12OutgoingPaymentOptions, DynMintPayment,
@@ -506,6 +506,7 @@ impl Mint {
         tx: &mut Box<dyn MintTransaction<'_, database::Error> + Send + Sync + '_>,
         input_verification: Verification,
         melt_request: &MeltRequest<QuoteId>,
+        operation: &Operation,
     ) -> Result<(ProofWriter, MeltQuote), Error> {
         let Verification {
             amount: input_amount,
@@ -520,6 +521,7 @@ impl Mint {
                 tx,
                 melt_request.inputs(),
                 Some(melt_request.quote_id().to_owned()),
+                operation,
             )
             .await?;
 
@@ -613,10 +615,11 @@ impl Mint {
 
         let verification = self.verify_inputs(melt_request.inputs()).await?;
 
+        let melt_operation = Operation::new_melt();
         let mut tx = self.localstore.begin_transaction().await?;
 
         let (proof_writer, quote) = match self
-            .verify_melt_request(&mut tx, verification, melt_request)
+            .verify_melt_request(&mut tx, verification, melt_request, &melt_operation)
             .await
         {
             Ok(result) => result,
@@ -646,6 +649,7 @@ impl Mint {
         tx.add_blinded_messages(
             Some(melt_request.quote_id()),
             melt_request.outputs().as_ref().unwrap_or(&Vec::new()),
+            &melt_operation,
         )
         .await?;
 

+ 16 - 1
crates/cdk/src/mint/mod.rs

@@ -34,7 +34,6 @@ use crate::{cdk_database, Amount};
 
 #[cfg(feature = "auth")]
 pub(crate) mod auth;
-mod blinded_message_writer;
 mod builder;
 mod check_spendable;
 mod issue;
@@ -239,6 +238,15 @@ impl Mint {
     /// - Payment processor initialization and startup
     /// - Invoice payment monitoring across all configured payment processors
     pub async fn start(&self) -> Result<(), Error> {
+        // Checks the status of all pending melt quotes
+        // Pending melt quotes where the payment has gone through inputs are burnt
+        // Pending melt quotes where the payment has **failed** inputs are reset to unspent
+        self.check_pending_melt_quotes().await?;
+
+        // Recover from incomplete swap sagas
+        // This cleans up incomplete swap operations using persisted saga state
+        self.recover_from_incomplete_sagas().await?;
+
         let mut task_state = self.task_state.lock().await;
 
         // Prevent starting if already running
@@ -813,6 +821,13 @@ impl Mint {
         &self,
         blinded_message: Vec<BlindedMessage>,
     ) -> Result<Vec<BlindSignature>, Error> {
+        #[cfg(test)]
+        {
+            if crate::test_helpers::mint::should_fail_in_test() {
+                return Err(Error::SignatureMissingOrInvalid);
+            }
+        }
+
         #[cfg(feature = "prometheus")]
         global::inc_in_flight_requests("blind_sign");
 

+ 7 - 1
crates/cdk/src/mint/proof_writer.rs

@@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
 use cdk_common::database::{self, DynMintDatabase, MintTransaction};
+use cdk_common::mint::Operation;
 use cdk_common::{Error, Proofs, ProofsMethods, PublicKey, QuoteId, State};
 
 use super::subscription::PubSubManager;
@@ -49,6 +50,7 @@ impl ProofWriter {
         tx: &mut Tx<'_, '_>,
         proofs: &Proofs,
         quote_id: Option<QuoteId>,
+        operation_id: &Operation,
     ) -> Result<Vec<PublicKey>, Error> {
         let proof_states = if let Some(proofs) = self.proof_original_states.as_mut() {
             proofs
@@ -56,7 +58,11 @@ impl ProofWriter {
             return Err(Error::Internal);
         };
 
-        if let Some(err) = tx.add_proofs(proofs.clone(), quote_id).await.err() {
+        if let Some(err) = tx
+            .add_proofs(proofs.clone(), quote_id, operation_id)
+            .await
+            .err()
+        {
             return match err {
                 cdk_common::database::Error::Duplicate => Err(Error::TokenPending),
                 cdk_common::database::Error::AttemptUpdateSpentProof => {

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

@@ -3,7 +3,10 @@
 //! These checks are need in the case the mint was offline and the lightning node was node.
 //! These ensure that the status of the mint or melt quote matches in the mint db and on the node.
 
+use cdk_common::mint::OperationKind;
+
 use super::{Error, Mint};
+use crate::mint::swap::swap_saga::compensation::{CompensatingAction, RemoveSwapSetup};
 use crate::mint::{MeltQuote, MeltQuoteState, PaymentMethod};
 use crate::types::PaymentProcessorKey;
 
@@ -79,4 +82,67 @@ impl Mint {
 
         Ok(())
     }
+
+    /// Recover from incomplete swap sagas
+    ///
+    /// Checks all persisted sagas for swap operations and compensates
+    /// incomplete ones by removing both proofs and blinded messages.
+    pub async fn recover_from_incomplete_sagas(&self) -> Result<(), Error> {
+        let incomplete_sagas = self
+            .localstore
+            .get_incomplete_sagas(OperationKind::Swap)
+            .await?;
+
+        if incomplete_sagas.is_empty() {
+            tracing::info!("No incomplete swap sagas found to recover.");
+            return Ok(());
+        }
+
+        let total_sagas = incomplete_sagas.len();
+        tracing::info!("Found {} incomplete swap sagas to recover.", total_sagas);
+
+        for saga in incomplete_sagas {
+            tracing::info!(
+                "Recovering saga {} in state '{}' (created: {}, updated: {})",
+                saga.operation_id,
+                saga.state.state(),
+                saga.created_at,
+                saga.updated_at
+            );
+
+            // Use the same compensation logic as in-process failures
+            let compensation = RemoveSwapSetup {
+                blinded_secrets: saga.blinded_secrets.clone(),
+                input_ys: saga.input_ys.clone(),
+            };
+
+            // Execute compensation
+            if let Err(e) = compensation.execute(&self.localstore).await {
+                tracing::error!(
+                    "Failed to compensate saga {}: {}. Continuing...",
+                    saga.operation_id,
+                    e
+                );
+                continue;
+            }
+
+            // Delete saga after successful compensation
+            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?;
+                continue;
+            }
+            tx.commit().await?;
+
+            tracing::info!("Successfully recovered saga {}", saga.operation_id);
+        }
+
+        tracing::info!(
+            "Successfully recovered {} incomplete swap sagas.",
+            total_sagas
+        );
+
+        Ok(())
+    }
 }

+ 0 - 173
crates/cdk/src/mint/swap.rs

@@ -1,173 +0,0 @@
-#[cfg(feature = "prometheus")]
-use cdk_prometheus::METRICS;
-use tracing::instrument;
-
-use super::blinded_message_writer::BlindedMessageWriter;
-use super::nut11::{enforce_sig_flag, EnforceSigFlag};
-use super::proof_writer::ProofWriter;
-use super::{Mint, PublicKey, SigFlag, State, SwapRequest, SwapResponse};
-use crate::Error;
-
-impl Mint {
-    /// Process Swap
-    #[instrument(skip_all)]
-    pub async fn process_swap_request(
-        &self,
-        swap_request: SwapRequest,
-    ) -> Result<SwapResponse, Error> {
-        #[cfg(feature = "prometheus")]
-        METRICS.inc_in_flight_requests("process_swap_request");
-        // Do the external call before beginning the db transaction
-        // Check any overflow before talking to the signatory
-        swap_request.input_amount()?;
-        swap_request.output_amount()?;
-
-        // We add blinded messages to db before attempting to sign
-        // this ensures that they are unique and have not been used before
-        let mut blinded_message_writer = BlindedMessageWriter::new(self.localstore.clone());
-        let mut tx = self.localstore.begin_transaction().await?;
-
-        match blinded_message_writer
-            .add_blinded_messages(&mut tx, None, swap_request.outputs())
-            .await
-        {
-            Ok(_) => {
-                tx.commit().await?;
-            }
-            Err(err) => {
-                #[cfg(feature = "prometheus")]
-                {
-                    METRICS.dec_in_flight_requests("process_swap_request");
-                    METRICS.record_mint_operation("process_swap_request", false);
-                    METRICS.record_error();
-                }
-                return Err(err);
-            }
-        }
-
-        let promises = self.blind_sign(swap_request.outputs().to_owned()).await?;
-        let input_verification =
-            self.verify_inputs(swap_request.inputs())
-                .await
-                .map_err(|err| {
-                    #[cfg(feature = "prometheus")]
-                    {
-                        METRICS.dec_in_flight_requests("process_swap_request");
-                        METRICS.record_mint_operation("process_swap_request", false);
-                        METRICS.record_error();
-                    }
-
-                    tracing::debug!("Input verification failed: {:?}", err);
-                    err
-                })?;
-        let mut tx = self.localstore.begin_transaction().await?;
-
-        if let Err(err) = self
-            .verify_transaction_balanced(
-                &mut tx,
-                input_verification,
-                swap_request.inputs(),
-                swap_request.outputs(),
-            )
-            .await
-        {
-            tracing::debug!("Attempt to swap unbalanced transaction, aborting: {err}");
-
-            #[cfg(feature = "prometheus")]
-            {
-                METRICS.dec_in_flight_requests("process_swap_request");
-                METRICS.record_mint_operation("process_swap_request", false);
-                METRICS.record_error();
-            }
-
-            tx.rollback().await?;
-            blinded_message_writer.rollback().await?;
-
-            return Err(err);
-        };
-
-        let validate_sig_result = self.validate_sig_flag(&swap_request).await;
-
-        if let Err(err) = validate_sig_result {
-            tx.rollback().await?;
-            blinded_message_writer.rollback().await?;
-
-            #[cfg(feature = "prometheus")]
-            self.record_swap_failure("process_swap_request");
-            return Err(err);
-        }
-        let mut proof_writer =
-            ProofWriter::new(self.localstore.clone(), self.pubsub_manager.clone());
-        let input_ys = match proof_writer
-            .add_proofs(&mut tx, swap_request.inputs(), None)
-            .await
-        {
-            Ok(ys) => ys,
-            Err(err) => {
-                #[cfg(feature = "prometheus")]
-                {
-                    METRICS.dec_in_flight_requests("process_swap_request");
-                    METRICS.record_mint_operation("process_swap_request", false);
-                    METRICS.record_error();
-                }
-                tx.rollback().await?;
-                blinded_message_writer.rollback().await?;
-                return Err(err);
-            }
-        };
-
-        let update_proof_states_result = proof_writer
-            .update_proofs_states(&mut tx, &input_ys, State::Spent)
-            .await;
-
-        if let Err(err) = update_proof_states_result {
-            #[cfg(feature = "prometheus")]
-            self.record_swap_failure("process_swap_request");
-
-            tx.rollback().await?;
-            blinded_message_writer.rollback().await?;
-            return Err(err);
-        }
-
-        tx.add_blind_signatures(
-            &swap_request
-                .outputs()
-                .iter()
-                .map(|o| o.blinded_secret)
-                .collect::<Vec<PublicKey>>(),
-            &promises,
-            None,
-        )
-        .await?;
-
-        proof_writer.commit();
-        blinded_message_writer.commit();
-        tx.commit().await?;
-
-        let response = SwapResponse::new(promises);
-
-        #[cfg(feature = "prometheus")]
-        {
-            METRICS.dec_in_flight_requests("process_swap_request");
-            METRICS.record_mint_operation("process_swap_request", true);
-        }
-
-        Ok(response)
-    }
-
-    async fn validate_sig_flag(&self, swap_request: &SwapRequest) -> Result<(), Error> {
-        let EnforceSigFlag { sig_flag, .. } = enforce_sig_flag(swap_request.inputs().clone());
-
-        if sig_flag == SigFlag::SigAll {
-            swap_request.verify_sig_all()?;
-        }
-
-        Ok(())
-    }
-    #[cfg(feature = "prometheus")]
-    fn record_swap_failure(&self, operation: &str) {
-        METRICS.dec_in_flight_requests(operation);
-        METRICS.record_mint_operation(operation, false);
-        METRICS.record_error();
-    }
-}

+ 84 - 0
crates/cdk/src/mint/swap/mod.rs

@@ -0,0 +1,84 @@
+#[cfg(feature = "prometheus")]
+use cdk_prometheus::METRICS;
+use swap_saga::SwapSaga;
+use tracing::instrument;
+
+use super::nut11::{enforce_sig_flag, EnforceSigFlag};
+use super::{Mint, SigFlag, SwapRequest, SwapResponse};
+use crate::Error;
+
+pub mod swap_saga;
+
+impl Mint {
+    /// Process Swap
+    #[instrument(skip_all)]
+    pub async fn process_swap_request(
+        &self,
+        swap_request: SwapRequest,
+    ) -> Result<SwapResponse, Error> {
+        #[cfg(feature = "prometheus")]
+        METRICS.inc_in_flight_requests("process_swap_request");
+
+        swap_request.input_amount()?;
+        swap_request.output_amount()?;
+
+        // Verify inputs (cryptographic verification, no DB needed)
+        let input_verification =
+            self.verify_inputs(swap_request.inputs())
+                .await
+                .map_err(|err| {
+                    #[cfg(feature = "prometheus")]
+                    self.record_swap_failure("process_swap_request");
+
+                    tracing::debug!("Input verification failed: {:?}", err);
+                    err
+                })?;
+
+        // Verify signature flag (no DB needed)
+        self.validate_sig_flag(&swap_request).await?;
+
+        // Step 1: Initialize the swap saga
+        let init_saga = SwapSaga::new(self, self.localstore.clone(), self.pubsub_manager.clone());
+
+        // Step 2: TX1 - Setup swap (verify balance + add inputs as pending + add output blinded messages)
+        let setup_saga = init_saga
+            .setup_swap(
+                swap_request.inputs(),
+                swap_request.outputs(),
+                None,
+                input_verification,
+            )
+            .await?;
+
+        // Step 3: Blind sign outputs (no DB transaction)
+        let signed_saga = setup_saga.sign_outputs().await?;
+
+        // Step 4: TX2 - Finalize swap (add signatures + mark inputs spent)
+        let response = signed_saga.finalize().await?;
+
+        #[cfg(feature = "prometheus")]
+        {
+            METRICS.dec_in_flight_requests("process_swap_request");
+            METRICS.record_mint_operation("process_swap_request", true);
+        }
+
+        Ok(response)
+    }
+
+    async fn validate_sig_flag(&self, swap_request: &SwapRequest) -> Result<(), Error> {
+        let EnforceSigFlag { sig_flag, .. } = enforce_sig_flag(swap_request.inputs().clone());
+
+        if sig_flag == SigFlag::SigAll {
+            swap_request.verify_sig_all()?;
+        }
+
+        Ok(())
+    }
+
+    #[cfg(feature = "prometheus")]
+    fn record_swap_failure(&self, operation: &str) {
+        METRICS.dec_in_flight_requests(operation);
+        METRICS.record_mint_operation(operation, false);
+        METRICS.record_error();
+    }
+}

+ 61 - 0
crates/cdk/src/mint/swap/swap_saga/compensation.rs

@@ -0,0 +1,61 @@
+use async_trait::async_trait;
+use cdk_common::database::DynMintDatabase;
+use cdk_common::{Error, PublicKey};
+use tracing::instrument;
+
+#[async_trait]
+pub trait CompensatingAction: Send + Sync {
+    async fn execute(&self, db: &DynMintDatabase) -> Result<(), Error>;
+    fn name(&self) -> &'static str;
+}
+
+/// Compensation action to remove swap setup (both proofs and blinded messages).
+///
+/// This compensation is used when blind signing fails or finalization fails after
+/// the setup transaction has committed. It removes:
+/// - Output blinded messages (identified by blinded_secrets)
+/// - Input proofs (identified by input_ys)
+///
+/// This restores the database to its pre-swap state.
+pub struct RemoveSwapSetup {
+    /// Blinded secrets (B values) from the output blinded messages
+    pub blinded_secrets: Vec<PublicKey>,
+    /// Y values (public keys) from the input proofs
+    pub input_ys: Vec<PublicKey>,
+}
+
+#[async_trait]
+impl CompensatingAction for RemoveSwapSetup {
+    #[instrument(skip_all)]
+    async fn execute(&self, db: &DynMintDatabase) -> Result<(), Error> {
+        if self.blinded_secrets.is_empty() && self.input_ys.is_empty() {
+            return Ok(());
+        }
+
+        tracing::info!(
+            "Compensation: Removing swap setup ({} blinded messages, {} proofs)",
+            self.blinded_secrets.len(),
+            self.input_ys.len()
+        );
+
+        let mut tx = db.begin_transaction().await?;
+
+        // Remove blinded messages (outputs)
+        if !self.blinded_secrets.is_empty() {
+            tx.delete_blinded_messages(&self.blinded_secrets).await?;
+        }
+
+        // Remove proofs (inputs)
+        if !self.input_ys.is_empty() {
+            tx.remove_proofs(&self.input_ys, None).await?;
+        }
+
+        tx.commit().await?;
+
+        Ok(())
+    }
+
+    fn name(&self) -> &'static str {
+        "RemoveSwapSetup"
+    }
+}

+ 514 - 0
crates/cdk/src/mint/swap/swap_saga/mod.rs

@@ -0,0 +1,514 @@
+use std::collections::VecDeque;
+use std::sync::Arc;
+
+use cdk_common::database::DynMintDatabase;
+use cdk_common::mint::{Operation, Saga, SwapSagaState};
+use cdk_common::nuts::BlindedMessage;
+use cdk_common::{database, Error, Proofs, ProofsMethods, PublicKey, QuoteId, State};
+use tokio::sync::Mutex;
+use tracing::instrument;
+
+use self::compensation::{CompensatingAction, RemoveSwapSetup};
+use self::state::{Initial, SetupComplete, Signed};
+use crate::mint::subscription::PubSubManager;
+
+pub mod compensation;
+mod state;
+
+#[cfg(test)]
+mod tests;
+
+/// Saga pattern implementation for atomic swap operations.
+///
+/// # Why Use the Saga Pattern?
+///
+/// The swap operation consists of multiple steps that span database transactions
+/// and non-transactional operations (blind signing). We need to ensure atomicity
+/// across these heterogeneous steps while maintaining consistency in failure scenarios.
+///
+/// Traditional ACID transactions cannot span:
+/// 1. Multiple database transactions (TX1: setup, TX2: finalize)
+/// 2. Non-database operations (blind signing of outputs)
+///
+/// The saga pattern solves this by:
+/// - Breaking the operation into discrete steps with clear state transitions
+/// - Recording compensating actions for each forward step
+/// - Automatically rolling back via compensations if any step fails
+///
+/// # Transaction Boundaries
+///
+/// - **TX1 (setup_swap)**: Atomically verifies balance, adds input proofs (pending),
+///   adds output blinded messages, and persists saga state for crash recovery
+/// - **Signing (sign_outputs)**: Non-transactional cryptographic operation
+/// - **TX2 (finalize)**: Atomically adds signatures to outputs, marks inputs as spent,
+///   and deletes saga state (best-effort, will be cleaned up on recovery if this fails)
+///
+/// Saga state persistence is atomic with swap state changes, ensuring consistency
+/// for crash recovery scenarios.
+///
+/// # Expected Actions
+///
+/// 1. **setup_swap**: Verifies the swap is balanced, reserves inputs, prepares outputs
+///    - Compensation: Removes both inputs and outputs if later steps fail
+/// 2. **sign_outputs**: Performs blind signing (no DB changes)
+///    - Triggers compensation if signing fails
+/// 3. **finalize**: Commits signatures and marks inputs spent
+///    - Triggers compensation if finalization fails
+///    - Clears compensations on success (swap complete)
+///
+/// # Failure Handling
+///
+/// If any step fails after setup_swap, all compensating actions are executed in reverse
+/// order to restore the database to its pre-swap state. This ensures no partial swaps
+/// leave the system in an inconsistent state.
+///
+/// # Compensation Order (LIFO)
+///
+/// Compensations are stored in a VecDeque and executed in LIFO (Last-In-First-Out) order
+/// using `push_front` + iteration. This ensures that actions are undone in the reverse
+/// order they were performed, which is critical for maintaining data consistency.
+///
+/// Example: If we perform actions A → B → C in the forward path, compensations must
+/// execute as C' → B' → A' to properly reverse the operations without violating
+/// any invariants or constraints.
+///
+/// # Typestate Pattern
+///
+/// This saga uses the **typestate pattern** to enforce state transitions at compile-time.
+/// Each state (Initial, SetupComplete, Signed) is a distinct type, and operations are
+/// only available on the appropriate type:
+///
+/// ```text
+/// SwapSaga<Initial>
+///   └─> setup_swap() -> SwapSaga<SetupComplete>
+///         └─> sign_outputs() -> SwapSaga<Signed>
+///               └─> finalize() -> SwapResponse
+/// ```
+///
+/// **Benefits:**
+/// - Invalid state transitions (e.g., `finalize()` before `sign_outputs()`) won't compile
+/// - State-specific data (e.g., signatures) only exists in the appropriate state type
+/// - No runtime state checks or `Option<T>` unwrapping needed
+/// - IDE autocomplete only shows valid operations for each state
+pub struct SwapSaga<'a, S> {
+    mint: &'a super::Mint,
+    db: DynMintDatabase,
+    pubsub: Arc<PubSubManager>,
+    /// Compensating actions in LIFO order (most recent first)
+    compensations: Arc<Mutex<VecDeque<Box<dyn CompensatingAction>>>>,
+    operation: Operation,
+    state_data: S,
+}
+
+impl<'a> SwapSaga<'a, Initial> {
+    pub fn new(mint: &'a super::Mint, db: DynMintDatabase, pubsub: Arc<PubSubManager>) -> Self {
+        Self {
+            mint,
+            db,
+            pubsub,
+            compensations: Arc::new(Mutex::new(VecDeque::new())),
+            operation: Operation::new_swap(),
+            state_data: Initial,
+        }
+    }
+
+    /// Sets up the swap by atomically verifying balance and reserving inputs/outputs.
+    ///
+    /// This is the first transaction (TX1) in the saga and must complete before blind signing.
+    ///
+    /// # What This Does
+    ///
+    /// Within a single database transaction:
+    /// 1. Verifies the swap is balanced (input amount >= output amount + fees)
+    /// 2. Adds input proofs to the database
+    /// 3. Updates input proof states from Unspent to Pending
+    /// 4. Adds output blinded messages to the database
+    /// 5. Persists saga state for crash recovery (atomic with steps 1-4)
+    /// 6. Publishes proof state changes via pubsub
+    ///
+    /// # Compensation
+    ///
+    /// Registers a compensation action that will remove both the input proofs and output
+    /// blinded messages if any subsequent step (signing or finalization) fails.
+    ///
+    /// # Errors
+    ///
+    /// - `TokenPending`: Proofs are already pending or blinded messages are duplicates
+    /// - `TokenAlreadySpent`: Proofs have already been spent
+    /// - `DuplicateOutputs`: Output blinded messages already exist
+    #[instrument(skip_all)]
+    pub async fn setup_swap(
+        self,
+        input_proofs: &Proofs,
+        blinded_messages: &[BlindedMessage],
+        quote_id: Option<QuoteId>,
+        input_verification: crate::mint::Verification,
+    ) -> Result<SwapSaga<'a, SetupComplete>, Error> {
+        tracing::info!("TX1: Setting up swap (verify + inputs + outputs)");
+
+        let mut tx = self.db.begin_transaction().await?;
+
+        // Verify balance within the transaction
+        self.mint
+            .verify_transaction_balanced(
+                &mut tx,
+                input_verification,
+                input_proofs,
+                blinded_messages,
+            )
+            .await?;
+
+        // Add input proofs to DB
+        if let Err(err) = tx
+            .add_proofs(input_proofs.clone(), quote_id.clone(), &self.operation)
+            .await
+        {
+            tx.rollback().await?;
+            return Err(match err {
+                database::Error::Duplicate => Error::TokenPending,
+                database::Error::AttemptUpdateSpentProof => Error::TokenAlreadySpent,
+                _ => Error::Database(err),
+            });
+        }
+
+        let ys = match input_proofs.ys() {
+            Ok(ys) => ys,
+            Err(err) => return Err(Error::NUT00(err)),
+        };
+
+        // Update input proof states to Pending
+        let original_proof_states = match tx.update_proofs_states(&ys, State::Pending).await {
+            Ok(states) => states,
+            Err(database::Error::AttemptUpdateSpentProof)
+            | Err(database::Error::AttemptRemoveSpentProof) => {
+                tx.rollback().await?;
+                return Err(Error::TokenAlreadySpent);
+            }
+            Err(err) => {
+                tx.rollback().await?;
+                return Err(err.into());
+            }
+        };
+
+        // Verify proofs weren't already pending or spent
+        if ys.len() != original_proof_states.len() {
+            tracing::error!("Mismatched proof states");
+            tx.rollback().await?;
+            return Err(Error::Internal);
+        }
+
+        let forbidden_states = [State::Pending, State::Spent];
+        for original_state in original_proof_states.iter().flatten() {
+            if forbidden_states.contains(original_state) {
+                tx.rollback().await?;
+                return Err(if *original_state == State::Pending {
+                    Error::TokenPending
+                } else {
+                    Error::TokenAlreadySpent
+                });
+            }
+        }
+
+        // Add output blinded messages
+        if let Err(err) = tx
+            .add_blinded_messages(quote_id.as_ref(), blinded_messages, &self.operation)
+            .await
+        {
+            tx.rollback().await?;
+            return Err(match err {
+                database::Error::Duplicate => Error::DuplicateOutputs,
+                _ => Error::Database(err),
+            });
+        }
+
+        // Publish proof state changes
+        for pk in &ys {
+            self.pubsub.proof_state((*pk, State::Pending));
+        }
+
+        // Store data in saga struct (avoid duplication in state enum)
+        let blinded_messages_vec = blinded_messages.to_vec();
+        let blinded_secrets: Vec<PublicKey> = blinded_messages_vec
+            .iter()
+            .map(|bm| bm.blinded_secret)
+            .collect();
+
+        // Persist saga state for crash recovery (atomic with TX1)
+        let saga = Saga::new_swap(
+            *self.operation.id(),
+            SwapSagaState::SetupComplete,
+            blinded_secrets.clone(),
+            ys.clone(),
+        );
+
+        if let Err(err) = tx.add_saga(&saga).await {
+            tx.rollback().await?;
+            return Err(err.into());
+        }
+
+        tx.commit().await?;
+
+        // Register compensation (uses LIFO via push_front)
+        let compensations = Arc::clone(&self.compensations);
+        compensations
+            .lock()
+            .await
+            .push_front(Box::new(RemoveSwapSetup {
+                blinded_secrets: blinded_secrets.clone(),
+                input_ys: ys.clone(),
+            }));
+
+        // Transition to SetupComplete state
+        Ok(SwapSaga {
+            mint: self.mint,
+            db: self.db,
+            pubsub: self.pubsub,
+            compensations: self.compensations,
+            operation: self.operation,
+            state_data: SetupComplete {
+                blinded_messages: blinded_messages_vec,
+                ys,
+            },
+        })
+    }
+}
+
+impl<'a> SwapSaga<'a, SetupComplete> {
+    /// Performs blind signing of output blinded messages.
+    ///
+    /// This is a non-transactional cryptographic operation that happens after `setup_swap`
+    /// and before `finalize`. No database changes occur in this step.
+    ///
+    /// # What This Does
+    ///
+    /// 1. Retrieves blinded messages from the state data
+    /// 2. Calls the mint's blind signing function to generate signatures
+    /// 3. Stores signatures and transitions to the Signed state
+    ///
+    /// # Failure Handling
+    ///
+    /// If blind signing fails, all registered compensations are executed to roll back
+    /// the setup transaction, removing both input proofs and output blinded messages.
+    ///
+    /// # Errors
+    ///
+    /// - Propagates any errors from the blind signing operation
+    #[instrument(skip_all)]
+    pub async fn sign_outputs(self) -> Result<SwapSaga<'a, Signed>, Error> {
+        tracing::info!("Signing outputs (no DB)");
+
+        match self
+            .mint
+            .blind_sign(self.state_data.blinded_messages.clone())
+            .await
+        {
+            Ok(signatures) => {
+                // Transition to Signed state
+                // Note: We don't update saga state here because the "signed" state
+                // is not used by recovery logic - saga state remains "SetupComplete"
+                // until the swap is finalized or compensated
+                Ok(SwapSaga {
+                    mint: self.mint,
+                    db: self.db,
+                    pubsub: self.pubsub,
+                    compensations: self.compensations,
+                    operation: self.operation,
+                    state_data: Signed {
+                        blinded_messages: self.state_data.blinded_messages,
+                        ys: self.state_data.ys,
+                        signatures,
+                    },
+                })
+            }
+            Err(err) => {
+                self.compensate_all().await?;
+                Err(err)
+            }
+        }
+    }
+}
+
+impl SwapSaga<'_, Signed> {
+    /// Finalizes the swap by committing signatures and marking inputs as spent.
+    ///
+    /// This is the second and final transaction (TX2) in the saga and completes the swap.
+    ///
+    /// # What This Does
+    ///
+    /// Within a single database transaction:
+    /// 1. Adds the blind signatures to the output blinded messages
+    /// 2. Updates input proof states from Pending to Spent
+    /// 3. Deletes saga state (best-effort, won't fail swap if this fails)
+    /// 4. Publishes proof state changes via pubsub
+    /// 5. Clears all registered compensations (swap successfully completed)
+    ///
+    /// # Failure Handling
+    ///
+    /// If finalization fails, all registered compensations are executed to roll back
+    /// the setup transaction, removing both input proofs and output blinded messages.
+    /// The signatures are not persisted, so they are lost.
+    ///
+    /// # Success
+    ///
+    /// On success, compensations are cleared and the swap is complete. The client
+    /// can now use the returned signatures to construct valid proofs. If saga state
+    /// deletion fails, a warning is logged but the swap still succeeds (orphaned
+    /// saga state will be cleaned up on next recovery).
+    ///
+    /// # Errors
+    ///
+    /// - `TokenAlreadySpent`: Input proofs were already spent by another operation
+    /// - Propagates any database errors
+    #[instrument(skip_all)]
+    pub async fn finalize(self) -> Result<cdk_common::nuts::SwapResponse, Error> {
+        tracing::info!("TX2: Finalizing swap (signatures + mark spent)");
+
+        let blinded_secrets: Vec<PublicKey> = self
+            .state_data
+            .blinded_messages
+            .iter()
+            .map(|bm| bm.blinded_secret)
+            .collect();
+
+        let mut tx = self.db.begin_transaction().await?;
+
+        // Add blind signatures to outputs
+        // TODO: WE should move the should fail to the db so the there is not this extra rollback.
+        // This would allow the error to be from the same place in test and prod
+        #[cfg(test)]
+        {
+            if crate::test_helpers::mint::should_fail_for("ADD_SIGNATURES") {
+                tx.rollback().await?;
+                self.compensate_all().await?;
+                return Err(Error::Database(database::Error::Database(
+                    "Test failure: ADD_SIGNATURES".into(),
+                )));
+            }
+        }
+
+        if let Err(err) = tx
+            .add_blind_signatures(&blinded_secrets, &self.state_data.signatures, None)
+            .await
+        {
+            tx.rollback().await?;
+            self.compensate_all().await?;
+            return Err(err.into());
+        }
+
+        // Mark input proofs as spent
+        // TODO: WE should move the should fail to the db so the there is not this extra rollback.
+        // This would allow the error to be from the same place in test and prod
+        #[cfg(test)]
+        {
+            if crate::test_helpers::mint::should_fail_for("UPDATE_PROOFS") {
+                tx.rollback().await?;
+                self.compensate_all().await?;
+                return Err(Error::Database(database::Error::Database(
+                    "Test failure: UPDATE_PROOFS".into(),
+                )));
+            }
+        }
+
+        match tx
+            .update_proofs_states(&self.state_data.ys, State::Spent)
+            .await
+        {
+            Ok(_) => {}
+            Err(database::Error::AttemptUpdateSpentProof)
+            | Err(database::Error::AttemptRemoveSpentProof) => {
+                tx.rollback().await?;
+                self.compensate_all().await?;
+                return Err(Error::TokenAlreadySpent);
+            }
+            Err(err) => {
+                tx.rollback().await?;
+                self.compensate_all().await?;
+                return Err(err.into());
+            }
+        }
+
+        // Publish proof state changes
+        for pk in &self.state_data.ys {
+            self.pubsub.proof_state((*pk, State::Spent));
+        }
+
+        // Delete saga - swap completed successfully (best-effort, atomic with TX2)
+        // Don't fail the swap if saga deletion fails - orphaned saga will be
+        // cleaned up on next recovery
+        if let Err(e) = tx.delete_saga(self.operation.id()).await {
+            tracing::warn!(
+                "Failed to delete saga in finalize (will be cleaned up on recovery): {}",
+                e
+            );
+            // Don't rollback - swap succeeded, orphaned saga is harmless
+        }
+
+        tx.commit().await?;
+
+        // Clear compensations - swap is complete
+        self.compensations.lock().await.clear();
+
+        Ok(cdk_common::nuts::SwapResponse::new(
+            self.state_data.signatures,
+        ))
+    }
+}
+
+impl<S> SwapSaga<'_, S> {
+    /// Execute all compensating actions and consume the saga.
+    ///
+    /// This method takes ownership of self to ensure the saga cannot be used
+    /// after compensation has been triggered.
+    #[instrument(skip_all)]
+    async fn compensate_all(self) -> Result<(), Error> {
+        let mut compensations = self.compensations.lock().await;
+
+        if compensations.is_empty() {
+            return Ok(());
+        }
+
+        #[cfg(feature = "prometheus")]
+        {
+            use cdk_prometheus::METRICS;
+
+            self.mint.record_swap_failure("process_swap_request");
+            METRICS.dec_in_flight_requests("process_swap_request");
+        }
+
+        tracing::warn!("Running {} compensating actions", compensations.len());
+
+        while let Some(compensation) = compensations.pop_front() {
+            tracing::debug!("Running compensation: {}", compensation.name());
+            if let Err(e) = compensation.execute(&self.db).await {
+                tracing::error!(
+                    "Compensation {} failed: {}. Continuing...",
+                    compensation.name(),
+                    e
+                );
+            }
+        }
+
+        // Delete saga - swap was compensated
+        // Use a separate transaction since compensations already ran
+        // Don't fail the compensation if saga cleanup fails (log only)
+        let mut tx = match self.db.begin_transaction().await {
+            Ok(tx) => tx,
+            Err(e) => {
+                tracing::error!(
+                    "Failed to begin tx for saga cleanup after compensation: {}",
+                    e
+                );
+                return Ok(()); // Compensations already ran, don't fail now
+            }
+        };
+
+        if let Err(e) = tx.delete_saga(self.operation.id()).await {
+            tracing::warn!("Failed to delete saga after compensation: {}", e);
+        } else if let Err(e) = tx.commit().await {
+            tracing::error!("Failed to commit saga cleanup after compensation: {}", e);
+        }
+        // Always succeed - compensations are done, saga cleanup is best-effort
+
+        Ok(())
+    }
+}

+ 26 - 0
crates/cdk/src/mint/swap/swap_saga/state.rs

@@ -0,0 +1,26 @@
+use cdk_common::nuts::{BlindSignature, BlindedMessage};
+use cdk_common::PublicKey;
+
+/// Initial state - no data yet.
+///
+/// The swap saga starts in this state. Only the `setup_swap` method is available.
+pub struct Initial;
+
+/// Setup complete - has blinded messages and input Y values.
+///
+/// After successful setup, the saga transitions to this state.
+/// Only the `sign_outputs` method is available.
+pub struct SetupComplete {
+    pub blinded_messages: Vec<BlindedMessage>,
+    pub ys: Vec<PublicKey>,
+}
+
+/// Signed state - has everything including signatures.
+///
+/// After successful signing, the saga transitions to this state.
+/// Only the `finalize` method is available.
+pub struct Signed {
+    pub blinded_messages: Vec<BlindedMessage>,
+    pub ys: Vec<PublicKey>,
+    pub signatures: Vec<BlindSignature>,
+}

+ 2993 - 0
crates/cdk/src/mint/swap/swap_saga/tests.rs

@@ -0,0 +1,2993 @@
+#![cfg(test)]
+//! Unit tests for the swap saga implementation
+//!
+//! These tests verify the swap saga pattern using in-memory mints and databases,
+//! without requiring external dependencies like Lightning nodes.
+
+use std::sync::Arc;
+
+use cdk_common::nuts::{Proofs, ProofsMethods};
+use cdk_common::{Amount, State};
+
+use super::SwapSaga;
+use crate::mint::swap::Mint;
+use crate::mint::Verification;
+use crate::test_helpers::mint::{create_test_blinded_messages, create_test_mint};
+
+/// Helper to create a verification result for testing
+fn create_verification(amount: Amount) -> Verification {
+    Verification {
+        amount,
+        unit: Some(cdk_common::nuts::CurrencyUnit::Sat),
+    }
+}
+
+/// Helper to create test proofs for swapping using the mint's process
+async fn create_swap_inputs(mint: &Mint, amount: Amount) -> (Proofs, Verification) {
+    let proofs = crate::test_helpers::mint::mint_test_proofs(mint, amount)
+        .await
+        .expect("Failed to create test proofs");
+
+    let verification = create_verification(amount);
+
+    (proofs, verification)
+}
+
+/// Tests that a SwapSaga can be created in the Initial state.
+///
+/// # What This Tests
+/// - SwapSaga::new() creates a saga in the Initial state
+/// - The typestate pattern ensures only Initial state is accessible after creation
+/// - No database operations occur during construction
+///
+/// # Success Criteria
+/// - Saga can be instantiated without errors
+/// - Saga is in Initial state (enforced by type system)
+#[tokio::test]
+async fn test_swap_saga_initial_state_creation() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let _saga = SwapSaga::new(&mint, db, pubsub);
+
+    // If we can create the saga, we're in the Initial state
+    // This is verified by the type system - only SwapSaga<Initial> can be created with new()
+}
+
+/// Tests the complete happy path flow through all sagas.
+///
+/// # What This Tests
+/// - Initial -> SetupComplete -> Signed -> Response state transitions
+/// - Database transactions commit successfully at each stage
+/// - Input proofs are marked as Pending during setup, then Spent after finalization
+/// - Output signatures are generated and returned correctly
+/// - Compensations are cleared on successful completion
+///
+/// # Flow
+/// 1. Create saga in Initial state
+/// 2. setup_swap: Transition to SetupComplete (TX1: add proofs + blinded messages)
+/// 3. sign_outputs: Transition to Signed (blind signing, no DB operations)
+/// 4. finalize: Complete saga (TX2: add signatures, mark proofs spent)
+///
+/// # Success Criteria
+/// - All state transitions succeed
+/// - Response contains correct number of signatures
+/// - All input proofs are marked as Spent
+/// - No errors occur during the entire flow
+#[tokio::test]
+async fn test_swap_saga_full_flow_success() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages, _pre_mint) =
+        create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    let response = saga.finalize().await.expect("Finalize should succeed");
+
+    assert_eq!(
+        response.signatures.len(),
+        output_blinded_messages.len(),
+        "Should have signatures for all outputs"
+    );
+
+    let ys = input_proofs.ys().unwrap();
+    let states = mint
+        .localstore()
+        .get_proofs_states(&ys)
+        .await
+        .expect("Failed to get proof states");
+
+    for state in states {
+        assert_eq!(
+            state.unwrap(),
+            State::Spent,
+            "Input proofs should be marked as spent"
+        );
+    }
+}
+
+/// Tests the Initial -> SetupComplete state transition.
+///
+/// # What This Tests
+/// - setup_swap() successfully transitions saga from Initial to SetupComplete state
+/// - State data contains blinded messages and input proof Y values
+/// - Database transaction (TX1) commits successfully
+/// - Input proofs are marked as Pending (not Spent)
+/// - Compensation action is registered for potential rollback
+///
+/// # Database Operations (TX1)
+/// 1. Verify transaction is balanced
+/// 2. Add input proofs to database
+/// 3. Update proof states to Pending
+/// 4. Add output blinded messages to database
+/// 5. Commit transaction
+///
+/// # Success Criteria
+/// - Saga transitions to SetupComplete state
+/// - State data correctly stores blinded messages and input Ys
+/// - All input proofs have state = Pending in database
+#[tokio::test]
+async fn test_swap_saga_setup_transition() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(64);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    assert_eq!(
+        saga.state_data.blinded_messages.len(),
+        output_blinded_messages.len(),
+        "SetupComplete state should contain blinded messages"
+    );
+
+    assert_eq!(
+        saga.state_data.ys.len(),
+        input_proofs.len(),
+        "SetupComplete state should contain input ys"
+    );
+
+    let ys = input_proofs.ys().unwrap();
+    let states = mint
+        .localstore()
+        .get_proofs_states(&ys)
+        .await
+        .expect("Failed to get proof states");
+
+    for state in states {
+        assert_eq!(
+            state.unwrap(),
+            State::Pending,
+            "Input proofs should be marked as pending after setup"
+        );
+    }
+}
+
+/// Tests the SetupComplete -> Signed state transition.
+///
+/// # What This Tests
+/// - sign_outputs() successfully transitions saga from SetupComplete to Signed state
+/// - Blind signatures are generated for all output blinded messages
+/// - No database operations occur during signing (cryptographic operation only)
+/// - State data contains signatures matching the number of blinded messages
+///
+/// # Operations
+/// 1. Performs blind signing on blinded messages (non-transactional)
+/// 2. Stores signatures in Signed state
+/// 3. Preserves blinded messages and input Ys from previous state
+///
+/// # Success Criteria
+/// - Saga transitions to Signed state
+/// - Number of signatures equals number of blinded messages
+/// - Compensations are still registered (cleared only on finalize)
+#[tokio::test]
+async fn test_swap_saga_sign_outputs_transition() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(128);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    assert_eq!(
+        saga.state_data.signatures.len(),
+        output_blinded_messages.len(),
+        "Signed state should contain signatures for all outputs"
+    );
+}
+
+/// Tests that duplicate input proofs are rejected during setup.
+///
+/// # What This Tests
+/// - Database detects and rejects duplicate proof additions
+/// - setup_swap() fails with appropriate error (TokenPending or duplicate error)
+/// - Transaction is rolled back, leaving no partial state
+///
+/// # Attack Vector
+/// This prevents an attacker from trying to spend the same proof twice
+/// within a single swap request.
+///
+/// # Success Criteria
+/// - setup_swap() returns an error
+/// - Database remains unchanged (transaction rollback)
+#[tokio::test]
+async fn test_swap_saga_duplicate_inputs() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(100);
+    let (mut input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    input_proofs.push(input_proofs[0].clone());
+
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await;
+
+    assert!(result.is_err(), "Setup should fail with duplicate inputs");
+}
+
+/// Tests that duplicate output blinded messages are rejected during setup.
+///
+/// # What This Tests
+/// - Database detects and rejects duplicate blinded message additions
+/// - setup_swap() fails with DuplicateOutputs error
+/// - Transaction is rolled back, leaving no partial state
+///
+/// # Attack Vector
+/// This prevents reuse of blinded messages, which would allow an attacker
+/// to receive the same blind signature multiple times.
+///
+/// # Success Criteria
+/// - setup_swap() returns an error
+/// - Database remains unchanged (transaction rollback)
+#[tokio::test]
+async fn test_swap_saga_duplicate_outputs() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (mut output_blinded_messages, _) =
+        create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    output_blinded_messages.push(output_blinded_messages[0].clone());
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await;
+
+    assert!(result.is_err(), "Setup should fail with duplicate outputs");
+}
+
+/// Tests that unbalanced swap requests are rejected (outputs > inputs).
+///
+/// # What This Tests
+/// - Balance verification detects when output amount exceeds input amount
+/// - setup_swap() fails with TransactionUnbalanced error
+/// - Transaction is rolled back before any database changes
+///
+/// # Attack Vector
+/// This prevents an attacker from creating value out of thin air by
+/// requesting more outputs than they provided in inputs.
+///
+/// # Success Criteria
+/// - setup_swap() returns an error
+/// - Database remains unchanged (no proofs or blinded messages added)
+#[tokio::test]
+async fn test_swap_saga_unbalanced_transaction_more_outputs() {
+    let mint = create_test_mint().await.unwrap();
+
+    let input_amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, input_amount).await;
+
+    let output_amount = Amount::from(150);
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, output_amount)
+        .await
+        .unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await;
+
+    assert!(
+        result.is_err(),
+        "Setup should fail when outputs exceed inputs"
+    );
+}
+
+/// Tests that compensation actions are registered and cleared correctly.
+///
+/// # What This Tests
+/// - Compensations start empty
+/// - setup_swap() registers one compensation action (RemoveSwapSetup)
+/// - sign_outputs() preserves compensations (no change)
+/// - finalize() clears all compensations on success
+///
+/// # Saga Pattern
+/// Compensations allow rollback if any step fails. They are cleared only
+/// when the entire saga completes successfully. This test verifies the
+/// lifecycle of compensation tracking.
+///
+/// # Success Criteria
+/// - 0 compensations initially
+/// - 1 compensation after setup
+/// - 1 compensation after signing
+/// - Compensations cleared after successful finalize
+#[tokio::test]
+async fn test_swap_saga_compensation_clears_on_success() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let compensations_before = saga.compensations.lock().await.len();
+
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    let compensations_after_setup = saga.compensations.lock().await.len();
+    assert_eq!(
+        compensations_after_setup, 1,
+        "Should have one compensation after setup"
+    );
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    let compensations_after_sign = saga.compensations.lock().await.len();
+    assert_eq!(
+        compensations_after_sign, 1,
+        "Should still have one compensation after signing"
+    );
+
+    let _response = saga.finalize().await.expect("Finalize should succeed");
+
+    assert_eq!(
+        compensations_before, 0,
+        "Should start with no compensations"
+    );
+}
+
+/// Tests that empty input proofs are rejected during setup.
+///
+/// # What This Tests
+/// - Swap with empty input proofs should fail gracefully
+/// - No database changes should occur
+///
+/// # Success Criteria
+/// - setup_swap() returns an error (not panic)
+/// - Database remains unchanged
+///
+/// # Note
+/// Empty inputs with non-empty outputs creates an unbalanced transaction
+/// (trying to create value from nothing), which should be rejected by
+/// the balance verification step.
+#[tokio::test]
+async fn test_swap_saga_empty_inputs() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+
+    let empty_proofs = Proofs::new();
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    // Verification must match the actual input amount (zero for empty proofs)
+    let verification = create_verification(Amount::from(0));
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga
+        .setup_swap(&empty_proofs, &output_blinded_messages, None, verification)
+        .await;
+
+    // This should fail because outputs (100) > inputs (0)
+    assert!(
+        result.is_err(),
+        "Empty inputs with non-empty outputs should be rejected (unbalanced)"
+    );
+}
+
+/// Tests that empty output blinded messages are rejected during setup.
+///
+/// # What This Tests
+/// - Swap with empty output blinded messages should fail gracefully
+/// - No database changes should occur
+///
+/// # Success Criteria
+/// - setup_swap() returns an error (not panic)
+/// - Database remains unchanged
+#[tokio::test]
+async fn test_swap_saga_empty_outputs() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let empty_blinded_messages = vec![];
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga
+        .setup_swap(
+            &input_proofs,
+            &empty_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await;
+
+    assert!(result.is_err(), "Empty outputs should be rejected");
+}
+
+/// Tests that both empty inputs and outputs are rejected during setup.
+///
+/// # What This Tests
+/// - Swap with both empty inputs and outputs should fail gracefully
+/// - No database changes should occur
+///
+/// # Success Criteria
+/// - setup_swap() returns an error (not panic)
+/// - Database remains unchanged
+#[tokio::test]
+async fn test_swap_saga_both_empty() {
+    let mint = create_test_mint().await.unwrap();
+
+    let empty_proofs = Proofs::new();
+    let empty_blinded_messages = vec![];
+    let verification = create_verification(Amount::from(0));
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga
+        .setup_swap(&empty_proofs, &empty_blinded_messages, None, verification)
+        .await;
+
+    assert!(result.is_err(), "Empty swap should be rejected");
+}
+
+/// Tests that a saga dropped without finalize does not auto-cleanup.
+///
+/// # What This Tests
+/// - When a saga is dropped after setup but before finalize:
+///   - Proofs remain in Pending state (no automatic cleanup)
+///   - Blinded messages remain in database
+///   - No compensations run automatically on drop
+///
+/// # Design Choice
+/// This tests for resource leaks and documents expected behavior.
+/// Cleanup requires explicit compensation or timeout mechanism.
+///
+/// # Success Criteria
+/// - After saga drop, proofs still Pending
+/// - Blinded messages still exist in database
+#[tokio::test]
+async fn test_swap_saga_drop_without_finalize() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let ys = input_proofs.ys().unwrap();
+
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let _saga = saga
+            .setup_swap(
+                &input_proofs,
+                &output_blinded_messages,
+                None,
+                input_verification,
+            )
+            .await
+            .expect("Setup should succeed");
+
+        // Verify setup state
+        let states = db.get_proofs_states(&ys).await.unwrap();
+        assert!(states.iter().all(|s| s == &Some(State::Pending)));
+
+        // _saga is dropped here without calling finalize
+    }
+
+    // Verify state is NOT automatically cleaned up
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s == &Some(State::Pending)),
+        "Proofs should remain Pending after saga drop (no auto-cleanup)"
+    );
+
+    // NOTE: This is expected behavior - compensations don't run on drop
+    // Cleanup requires either:
+    // 1. Explicit compensation call
+    // 2. Timeout mechanism to clean up stale Pending proofs
+    // 3. Manual intervention
+}
+
+/// Tests that a saga dropped after signing loses signatures.
+///
+/// # What This Tests
+/// - When a saga is dropped after signing but before finalize:
+///   - Proofs remain Pending
+///   - Signatures are lost (not persisted)
+///   - Demonstrates the importance of calling finalize
+///
+/// # Success Criteria
+/// - Proofs still Pending after drop
+/// - No signatures in database (they were only in memory)
+#[tokio::test]
+async fn test_swap_saga_drop_after_signing() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let ys = input_proofs.ys().unwrap();
+    let _blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(
+                &input_proofs,
+                &output_blinded_messages,
+                None,
+                input_verification,
+            )
+            .await
+            .expect("Setup should succeed");
+
+        let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+        // Verify we're in Signed state (has signatures)
+        assert_eq!(
+            saga.state_data.signatures.len(),
+            output_blinded_messages.len()
+        );
+
+        // saga is dropped here - signatures are lost!
+    }
+
+    // Verify proofs still Pending
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_after.iter().all(|s| s == &Some(State::Pending)));
+
+    // Verify signatures were NOT persisted (they were only in memory in the saga)
+    let signatures = db.get_blind_signatures(&_blinded_secrets).await.unwrap();
+    assert!(
+        signatures.iter().all(|s| s.is_none()),
+        "Signatures should be lost when saga is dropped (never persisted)"
+    );
+
+    // This demonstrates why finalize() is critical - without it, the signatures
+    // generated during signing are lost and the swap cannot complete
+}
+
+/// Tests that compensations execute when sign_outputs() fails.
+///
+/// # What This Tests
+/// - Verify that compensations execute when sign_outputs() fails
+/// - Verify that proofs are removed from database (rollback of setup)
+/// - Verify that blinded messages are removed from database
+/// - Verify that proof states are cleared (no longer Pending)
+///
+/// # Implementation
+/// Uses TEST_FAIL environment variable to make blind_sign() fail
+///
+/// # Success Criteria
+/// - Signing fails with error
+/// - Proofs are removed from database after failure
+/// - Blinded messages are removed after failure
+#[tokio::test]
+async fn test_swap_saga_compensation_on_signing_failure() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    // Setup should succeed
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    // Verify setup state
+    let ys = input_proofs.ys().unwrap();
+    let states = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states.iter().all(|s| s == &Some(State::Pending)));
+
+    // Enable test failure mode
+    std::env::set_var("TEST_FAIL", "1");
+
+    // Attempt signing (should fail due to TEST_FAIL)
+    let result = saga.sign_outputs().await;
+
+    // Clean up environment variable immediately
+    std::env::remove_var("TEST_FAIL");
+
+    assert!(result.is_err(), "Signing should fail");
+
+    // Verify compensation executed - proofs removed
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed"
+    );
+
+    // Verify blinded messages removed (compensation removes blinded messages, not signatures)
+    // Since signatures are never created (only during finalize), we verify that
+    // if we query for them, we get None for all (they were never added)
+    let _blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+    let signatures = db.get_blind_signatures(&_blinded_secrets).await.unwrap();
+    assert!(
+        signatures.iter().all(|s| s.is_none()),
+        "No signatures should exist (never created)"
+    );
+}
+
+/// Tests that double-spend attempts are detected and rejected.
+///
+/// # What This Tests
+/// - First complete swap marks proofs as Spent
+/// - Second swap attempt with same proofs fails immediately
+/// - Database proof state prevents double-spending
+///
+/// # Security
+/// This is a critical security test. Double-spending would allow an
+/// attacker to reuse the same ecash tokens multiple times. The database
+/// must detect that proofs are already spent and reject the second swap.
+///
+/// # Flow
+/// 1. Complete first swap successfully (proofs marked Spent)
+/// 2. Attempt second swap with same proofs
+/// 3. Second setup_swap() fails with TokenAlreadySpent error
+///
+/// # Success Criteria
+/// - First swap completes successfully
+/// - Second swap fails with error
+/// - Proofs remain in Spent state
+#[tokio::test]
+async fn test_swap_saga_double_spend_detection() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages_1, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (output_blinded_messages_2, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga1 = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+
+    let saga1 = saga1
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages_1,
+            None,
+            input_verification.clone(),
+        )
+        .await
+        .expect("First setup should succeed");
+
+    let saga1 = saga1
+        .sign_outputs()
+        .await
+        .expect("First signing should succeed");
+
+    let _response1 = saga1
+        .finalize()
+        .await
+        .expect("First finalize should succeed");
+
+    let saga2 = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga2
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages_2,
+            None,
+            input_verification,
+        )
+        .await;
+
+    assert!(
+        result.is_err(),
+        "Second setup should fail due to double-spend"
+    );
+}
+
+/// Tests that pending proofs are detected and rejected.
+///
+/// # What This Tests
+/// - First swap marks proofs as Pending during setup
+/// - Second swap attempt with same proofs fails immediately
+/// - Database proof state prevents concurrent use of same proofs
+///
+/// # Concurrency Protection
+/// When proofs are marked Pending, they are reserved for an in-progress
+/// swap. No other swap should be able to use them until the first swap
+/// completes or rolls back.
+///
+/// # Flow
+/// 1. Start first swap (proofs marked Pending)
+/// 2. DO NOT finalize first swap
+/// 3. Attempt second swap with same proofs
+/// 4. Second setup_swap() fails with TokenPending error
+///
+/// # Success Criteria
+/// - First setup succeeds (proofs marked Pending)
+/// - Second setup fails with error
+/// - Proofs remain in Pending state
+#[tokio::test]
+async fn test_swap_saga_pending_proof_detection() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages_1, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (output_blinded_messages_2, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga1 = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+
+    let saga1 = saga1
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages_1,
+            None,
+            input_verification.clone(),
+        )
+        .await
+        .expect("First setup should succeed");
+
+    // Keep saga1 in scope to maintain pending proofs
+    drop(saga1);
+
+    let saga2 = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga2
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages_2,
+            None,
+            input_verification,
+        )
+        .await;
+
+    assert!(
+        result.is_err(),
+        "Second setup should fail because proofs are pending"
+    );
+}
+
+/// Tests concurrent swap attempts with the same proofs.
+///
+/// # What This Tests
+/// - Database serialization ensures only one concurrent swap succeeds
+/// - Exactly one of N concurrent swaps with same proofs completes
+/// - Other swaps fail with TokenPending or TokenAlreadySpent errors
+/// - Final proof state is Spent (from the successful swap)
+///
+/// # Race Condition Protection
+/// This test verifies that the saga pattern combined with database
+/// transactions provides proper serialization. Even with 3 tasks racing
+/// to setup/sign/finalize, only one can succeed.
+///
+/// # Flow
+/// 1. Spawn 3 concurrent tasks, each trying to swap the same proofs
+/// 2. Each task creates its own saga and attempts full flow
+/// 3. Database ensures only one can mark proofs as Pending/Spent
+/// 4. Count successes and failures
+///
+/// # Success Criteria
+/// - Exactly 1 swap succeeds
+/// - Exactly 2 swaps fail
+/// - All proofs end up in Spent state
+#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
+async fn test_swap_saga_concurrent_swaps() {
+    let mint = Arc::new(create_test_mint().await.unwrap());
+
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages_1, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (output_blinded_messages_2, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (output_blinded_messages_3, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let mint1 = Arc::clone(&mint);
+    let mint2 = Arc::clone(&mint);
+    let mint3 = Arc::clone(&mint);
+
+    let proofs1 = input_proofs.clone();
+    let proofs2 = input_proofs.clone();
+    let proofs3 = input_proofs.clone();
+
+    let verification1 = input_verification.clone();
+    let verification2 = input_verification.clone();
+    let verification3 = input_verification.clone();
+
+    let task1 = tokio::spawn(async move {
+        let db = mint1.localstore();
+        let pubsub = mint1.pubsub_manager();
+        let saga = SwapSaga::new(&*mint1, db, pubsub);
+
+        let saga = saga
+            .setup_swap(&proofs1, &output_blinded_messages_1, None, verification1)
+            .await?;
+        let saga = saga.sign_outputs().await?;
+        saga.finalize().await
+    });
+
+    let task2 = tokio::spawn(async move {
+        let db = mint2.localstore();
+        let pubsub = mint2.pubsub_manager();
+        let saga = SwapSaga::new(&*mint2, db, pubsub);
+
+        let saga = saga
+            .setup_swap(&proofs2, &output_blinded_messages_2, None, verification2)
+            .await?;
+        let saga = saga.sign_outputs().await?;
+        saga.finalize().await
+    });
+
+    let task3 = tokio::spawn(async move {
+        let db = mint3.localstore();
+        let pubsub = mint3.pubsub_manager();
+        let saga = SwapSaga::new(&*mint3, db, pubsub);
+
+        let saga = saga
+            .setup_swap(&proofs3, &output_blinded_messages_3, None, verification3)
+            .await?;
+        let saga = saga.sign_outputs().await?;
+        saga.finalize().await
+    });
+
+    let results = tokio::try_join!(task1, task2, task3).expect("Tasks should complete");
+
+    let mut success_count = 0;
+    let mut error_count = 0;
+
+    for result in [results.0, results.1, results.2] {
+        match result {
+            Ok(_) => success_count += 1,
+            Err(_) => error_count += 1,
+        }
+    }
+
+    assert_eq!(success_count, 1, "Only one concurrent swap should succeed");
+    assert_eq!(error_count, 2, "Two concurrent swaps should fail");
+
+    let ys = input_proofs.ys().unwrap();
+    let states = mint
+        .localstore()
+        .get_proofs_states(&ys)
+        .await
+        .expect("Failed to get proof states");
+
+    for state in states {
+        assert_eq!(
+            state.unwrap(),
+            State::Spent,
+            "Proofs should be marked as spent after successful swap"
+        );
+    }
+}
+
+/// Tests that compensations execute when finalize() fails during add_blind_signatures.
+///
+/// # What This Tests
+/// - Verify that compensations execute when finalize() fails at signature addition
+/// - Verify that proofs are removed from database (compensation rollback)
+/// - Verify that blinded messages are removed from database
+/// - Verify that signatures are NOT persisted to database
+/// - Transaction rollback + compensation cleanup both occur
+///
+/// # Implementation
+/// Uses TEST_FAIL_ADD_SIGNATURES environment variable to inject failure
+/// at the signature addition step within the finalize transaction.
+///
+/// # Success Criteria
+/// - Finalize fails with error
+/// - Proofs are removed from database after failure
+/// - Blinded messages are removed after failure
+/// - No signatures persisted to database
+#[tokio::test]
+async fn test_swap_saga_compensation_on_finalize_add_signatures_failure() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    // Setup and sign should succeed
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    // Verify we're in Signed state
+    assert_eq!(
+        saga.state_data.signatures.len(),
+        output_blinded_messages.len()
+    );
+
+    // Enable test failure mode for ADD_SIGNATURES
+    std::env::set_var("TEST_FAIL_ADD_SIGNATURES", "1");
+
+    // Attempt finalize (should fail due to TEST_FAIL_ADD_SIGNATURES)
+    let result = saga.finalize().await;
+
+    // Clean up environment variable immediately
+    std::env::remove_var("TEST_FAIL_ADD_SIGNATURES");
+
+    assert!(result.is_err(), "Finalize should fail");
+
+    // Verify compensation executed - proofs removed
+    let ys = input_proofs.ys().unwrap();
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed by compensation"
+    );
+
+    // Verify signatures were NOT persisted (transaction rolled back)
+    let blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+    let signatures = db.get_blind_signatures(&blinded_secrets).await.unwrap();
+    assert!(
+        signatures.iter().all(|s| s.is_none()),
+        "Signatures should not be persisted after rollback"
+    );
+}
+
+/// Tests that compensations execute when finalize() fails during update_proofs_states.
+///
+/// # What This Tests
+/// - Verify that compensations execute when finalize() fails at proof state update
+/// - Verify that proofs are removed from database (compensation rollback)
+/// - Verify that blinded messages are removed from database
+/// - Verify that signatures are NOT persisted to database
+/// - Transaction rollback + compensation cleanup both occur
+///
+/// # Implementation
+/// Uses TEST_FAIL_UPDATE_PROOFS environment variable to inject failure
+/// at the proof state update step within the finalize transaction.
+///
+/// # Success Criteria
+/// - Finalize fails with error
+/// - Proofs are removed from database after failure
+/// - Blinded messages are removed after failure
+/// - No signatures persisted to database
+#[tokio::test]
+async fn test_swap_saga_compensation_on_finalize_update_proofs_failure() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    // Setup and sign should succeed
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    // Verify we're in Signed state
+    assert_eq!(
+        saga.state_data.signatures.len(),
+        output_blinded_messages.len()
+    );
+
+    // Enable test failure mode for UPDATE_PROOFS
+    std::env::set_var("TEST_FAIL_UPDATE_PROOFS", "1");
+
+    // Attempt finalize (should fail due to TEST_FAIL_UPDATE_PROOFS)
+    let result = saga.finalize().await;
+
+    // Clean up environment variable immediately
+    std::env::remove_var("TEST_FAIL_UPDATE_PROOFS");
+
+    assert!(result.is_err(), "Finalize should fail");
+
+    // Verify compensation executed - proofs removed
+    let ys = input_proofs.ys().unwrap();
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed by compensation"
+    );
+
+    // Verify signatures were NOT persisted (transaction rolled back)
+    let blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+    let signatures = db.get_blind_signatures(&blinded_secrets).await.unwrap();
+    assert!(
+        signatures.iter().all(|s| s.is_none()),
+        "Signatures should not be persisted after rollback"
+    );
+}
+
+// ==================== PHASE 1: FOUNDATION TESTS ====================
+// These tests verify the basic saga persistence mechanism.
+
+/// Tests that saga is persisted to the database after setup.
+///
+/// # What This Tests
+/// - Saga is written to database during setup_swap()
+/// - get_saga() can retrieve the persisted state
+/// - State content is correct (operation_id, state, blinded_secrets, input_ys)
+///
+/// # Success Criteria
+/// - Saga exists in database after setup
+/// - State matches SwapSagaState::SetupComplete
+/// - All expected data is present and correct
+#[tokio::test]
+async fn test_saga_state_persistence_after_setup() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    let saga = saga
+        .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+        .await
+        .expect("Setup should succeed");
+
+    let operation_id = saga.operation.id();
+
+    // Verify saga exists in database
+    let saga = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(operation_id).await.expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result.expect("Saga should exist after setup")
+    };
+
+    // Verify state is SetupComplete
+    use cdk_common::mint::{SagaStateEnum, SwapSagaState};
+    assert_eq!(
+        saga.state,
+        SagaStateEnum::Swap(SwapSagaState::SetupComplete),
+        "Saga should be SetupComplete"
+    );
+
+    // Verify operation_id matches
+    assert_eq!(saga.operation_id, *operation_id);
+
+    // Verify blinded_secrets are stored correctly
+    let expected_blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+    assert_eq!(saga.blinded_secrets.len(), expected_blinded_secrets.len());
+    for bs in &expected_blinded_secrets {
+        assert!(
+            saga.blinded_secrets.contains(bs),
+            "Blinded secret should be in saga"
+        );
+    }
+
+    // Verify input_ys are stored correctly
+    let expected_ys = input_proofs.ys().unwrap();
+    assert_eq!(saga.input_ys.len(), expected_ys.len());
+    for y in &expected_ys {
+        assert!(saga.input_ys.contains(y), "Input Y should be in saga");
+    }
+}
+
+/// Tests that saga is deleted after successful finalization.
+///
+/// # What This Tests
+/// - Saga exists after setup
+/// - Saga still exists after signing
+/// - Saga is DELETED after successful finalize
+/// - get_incomplete_sagas() returns empty after success
+///
+/// # Success Criteria
+/// - Saga deleted from database
+/// - No incomplete sagas remain
+#[tokio::test]
+async fn test_saga_deletion_on_success() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    let saga = saga
+        .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+        .await
+        .expect("Setup should succeed");
+
+    let operation_id = *saga.operation.id();
+
+    // Verify saga exists after setup
+    let saga_after_setup = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_after_setup.is_some(), "Saga should exist after setup");
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    // Verify saga still exists after signing
+    let saga_after_sign = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(
+        saga_after_sign.is_some(),
+        "Saga should still exist after signing"
+    );
+
+    let _response = saga.finalize().await.expect("Finalize should succeed");
+
+    // CRITICAL: Verify saga is DELETED after success
+    let saga_after_finalize = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(
+        saga_after_finalize.is_none(),
+        "Saga should be deleted after successful finalization"
+    );
+
+    // Verify no incomplete sagas exist
+    use cdk_common::mint::OperationKind;
+    let incomplete = db
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .expect("Failed to get incomplete sagas");
+    assert_eq!(incomplete.len(), 0, "No incomplete sagas should exist");
+}
+
+/// Tests querying incomplete sagas.
+///
+/// # What This Tests
+/// - get_incomplete_sagas() returns saga after setup
+/// - get_incomplete_sagas() still returns saga after signing
+/// - get_incomplete_sagas() returns empty after finalize
+/// - Multiple incomplete sagas can be queried
+///
+/// # Success Criteria
+/// - Incomplete saga appears in query results
+/// - Completed saga does not appear in query results
+#[tokio::test]
+async fn test_get_incomplete_sagas_basic() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs_1, verification_1) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages_1, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let (input_proofs_2, verification_2) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages_2, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    use cdk_common::mint::OperationKind;
+
+    // Initially no incomplete sagas
+    let incomplete_initial = db
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .expect("Failed to get incomplete sagas");
+    assert_eq!(incomplete_initial.len(), 0);
+
+    let pubsub = mint.pubsub_manager();
+
+    // Setup first saga
+    let saga_1 = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+    let saga_1 = saga_1
+        .setup_swap(
+            &input_proofs_1,
+            &output_blinded_messages_1,
+            None,
+            verification_1,
+        )
+        .await
+        .expect("Setup should succeed");
+    let op_id_1 = *saga_1.operation.id();
+
+    // Should have 1 incomplete saga
+    let incomplete_after_1 = db
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .expect("Failed to get incomplete sagas");
+    assert_eq!(incomplete_after_1.len(), 1);
+    assert_eq!(incomplete_after_1[0].operation_id, op_id_1);
+
+    // Setup second saga
+    let saga_2 = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+    let saga_2 = saga_2
+        .setup_swap(
+            &input_proofs_2,
+            &output_blinded_messages_2,
+            None,
+            verification_2,
+        )
+        .await
+        .expect("Setup should succeed");
+    let op_id_2 = *saga_2.operation.id();
+
+    // Should have 2 incomplete sagas
+    let incomplete_after_2 = db
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .expect("Failed to get incomplete sagas");
+    assert_eq!(incomplete_after_2.len(), 2);
+
+    // Finalize first saga
+    let saga_1 = saga_1.sign_outputs().await.expect("Signing should succeed");
+    let _response_1 = saga_1.finalize().await.expect("Finalize should succeed");
+
+    // Should have 1 incomplete saga (second one still incomplete)
+    let incomplete_after_finalize = db
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .expect("Failed to get incomplete sagas");
+    assert_eq!(incomplete_after_finalize.len(), 1);
+    assert_eq!(incomplete_after_finalize[0].operation_id, op_id_2);
+
+    // Finalize second saga
+    let saga_2 = saga_2.sign_outputs().await.expect("Signing should succeed");
+    let _response_2 = saga_2.finalize().await.expect("Finalize should succeed");
+
+    // Should have 0 incomplete sagas
+    let incomplete_final = db
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .expect("Failed to get incomplete sagas");
+    assert_eq!(incomplete_final.len(), 0);
+}
+
+/// Tests detailed validation of saga content.
+///
+/// # What This Tests
+/// - Operation ID is correct
+/// - Operation kind is correct
+/// - State enum is correct
+/// - Blinded secrets are all present
+/// - Input Ys are all present
+/// - Timestamps are reasonable (created_at, updated_at)
+///
+/// # Success Criteria
+/// - All fields match expected values
+/// - Timestamps are within reasonable range
+#[tokio::test]
+async fn test_saga_content_validation() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let expected_ys: Vec<_> = input_proofs.ys().unwrap();
+    let expected_blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    let saga = saga
+        .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+        .await
+        .expect("Setup should succeed");
+
+    let operation_id = *saga.operation.id();
+
+    // Query saga
+    let saga = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result.expect("Saga should exist after setup")
+    };
+
+    // Validate content
+    use cdk_common::mint::{OperationKind, SagaStateEnum, SwapSagaState};
+    assert_eq!(saga.operation_id, operation_id);
+    assert_eq!(saga.operation_kind, OperationKind::Swap);
+    assert_eq!(
+        saga.state,
+        SagaStateEnum::Swap(SwapSagaState::SetupComplete)
+    );
+
+    // Validate blinded secrets
+    assert_eq!(saga.blinded_secrets.len(), expected_blinded_secrets.len());
+    for bs in &expected_blinded_secrets {
+        assert!(saga.blinded_secrets.contains(bs));
+    }
+
+    // Validate input Ys
+    assert_eq!(saga.input_ys.len(), expected_ys.len());
+    for y in &expected_ys {
+        assert!(saga.input_ys.contains(y));
+    }
+
+    // Validate timestamps
+    use cdk_common::util::unix_time;
+    let now = unix_time();
+    assert!(
+        saga.created_at <= now,
+        "created_at should be <= current time"
+    );
+    assert!(
+        saga.updated_at <= now,
+        "updated_at should be <= current time"
+    );
+    assert!(
+        saga.created_at <= saga.updated_at,
+        "created_at should be <= updated_at"
+    );
+}
+
+/// Tests that saga updates are persisted correctly.
+///
+/// # What This Tests
+/// - Saga persisted after setup
+/// - updated_at timestamp changes after state updates
+/// - Other fields remain unchanged during updates
+///
+/// # Note
+/// Currently sign_outputs() does NOT update saga in the database
+/// (the "signed" state is not persisted). This test documents that behavior.
+///
+/// # Success Criteria
+/// - State exists after setup
+/// - If state is updated, updated_at increases
+/// - Other fields remain consistent
+#[tokio::test]
+async fn test_saga_state_updates_persisted() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    let saga = saga
+        .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+        .await
+        .expect("Setup should succeed");
+
+    let operation_id = *saga.operation.id();
+
+    // Query saga
+    let state_after_setup = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result.expect("Saga should exist after setup")
+    };
+
+    use cdk_common::mint::{SagaStateEnum, SwapSagaState};
+    assert_eq!(
+        state_after_setup.state,
+        SagaStateEnum::Swap(SwapSagaState::SetupComplete)
+    );
+    let initial_created_at = state_after_setup.created_at;
+    let initial_updated_at = state_after_setup.updated_at;
+
+    // Small delay to ensure timestamp would change if updated
+    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    // Query saga
+    let state_after_sign = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result.expect("Saga should exist after setup")
+    };
+
+    // State should still be SetupComplete (not updated to Signed)
+    assert_eq!(
+        state_after_sign.state,
+        SagaStateEnum::Swap(SwapSagaState::SetupComplete),
+        "Saga remains SetupComplete (signing doesn't update DB)"
+    );
+
+    // Verify other fields unchanged
+    assert_eq!(state_after_sign.operation_id, operation_id);
+    assert_eq!(
+        state_after_sign.blinded_secrets,
+        state_after_setup.blinded_secrets
+    );
+    assert_eq!(state_after_sign.input_ys, state_after_setup.input_ys);
+    assert_eq!(state_after_sign.created_at, initial_created_at);
+
+    // updated_at might not change since state wasn't updated
+    assert_eq!(state_after_sign.updated_at, initial_updated_at);
+
+    // Finalize and verify state is deleted (not updated)
+    let _response = saga.finalize().await.expect("Finalize should succeed");
+
+    // Query saga
+    let state_after_finalize = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+
+        result
+    };
+
+    assert!(
+        state_after_finalize.is_none(),
+        "Saga should be deleted after finalize"
+    );
+}
+
+// ==================== STARTUP RECOVERY TESTS ====================
+// These tests verify the `recover_from_bad_swaps()` startup check that
+// cleans up orphaned swap state when the mint restarts.
+
+/// Tests startup recovery when saga is dropped before signing.
+///
+/// # What This Tests
+/// - Saga dropped after setup (proofs PENDING, no signatures)
+/// - recover_from_bad_swaps() removes the proofs
+/// - Blinded messages are removed
+/// - Same proofs can be used in a new swap after recovery
+///
+/// # Recovery Behavior
+/// When no blind signatures exist for an operation_id:
+/// - Proofs are removed from database
+/// - Blinded messages are removed
+/// - User can retry the swap with same proofs
+///
+/// # Flow
+/// 1. Setup swap (proofs marked PENDING)
+/// 2. Drop saga without signing
+/// 3. Call recover_from_bad_swaps() (simulates mint restart)
+/// 4. Verify proofs removed
+/// 5. Verify can use same proofs in new swap
+///
+/// # Success Criteria
+/// - Recovery removes proofs completely
+/// - Blinded messages removed
+/// - Second swap with same proofs succeeds
+#[tokio::test]
+async fn test_startup_recovery_saga_dropped_before_signing() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let ys = input_proofs.ys().unwrap();
+
+    // Setup swap and drop without signing
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let _saga = saga
+            .setup_swap(
+                &input_proofs,
+                &output_blinded_messages,
+                None,
+                input_verification.clone(),
+            )
+            .await
+            .expect("Setup should succeed");
+
+        // Verify proofs are PENDING
+        let states = db.get_proofs_states(&ys).await.unwrap();
+        assert!(states.iter().all(|s| s == &Some(State::Pending)));
+
+        // Saga dropped here without signing
+    }
+
+    // Proofs still PENDING after drop (no auto-cleanup)
+    let states_before_recovery = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_before_recovery
+        .iter()
+        .all(|s| s == &Some(State::Pending)));
+
+    // Simulate mint restart - run recovery
+    mint.stop().await.expect("Recovery should succeed");
+    mint.start().await.expect("Recovery should succeed");
+
+    // Verify proofs are REMOVED (not just state cleared)
+    let states_after_recovery = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after_recovery.iter().all(|s| s.is_none()),
+        "Proofs should be removed after recovery (no signatures exist)"
+    );
+
+    // Verify we can now use the same proofs in a new swap
+    let (new_output_blinded_messages, _) =
+        create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let new_saga = SwapSaga::new(&mint, db, pubsub);
+
+    let new_saga = new_saga
+        .setup_swap(
+            &input_proofs,
+            &new_output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Second swap should succeed after recovery");
+
+    let new_saga = new_saga
+        .sign_outputs()
+        .await
+        .expect("Signing should succeed");
+
+    let _response = new_saga.finalize().await.expect("Finalize should succeed");
+
+    // Verify proofs are now SPENT
+    let final_states = mint.localstore().get_proofs_states(&ys).await.unwrap();
+    assert!(final_states.iter().all(|s| s == &Some(State::Spent)));
+}
+
+/// Tests startup recovery when saga is dropped after signing.
+///
+/// # What This Tests
+/// - Saga dropped after signing but before finalize
+/// - Signatures exist in memory but were never persisted to database
+/// - recover_from_bad_swaps() removes the proofs (no signatures in DB)
+/// - Same proofs can be used in a new swap after recovery
+///
+/// # Recovery Behavior
+/// When no blind signatures exist in database for an operation_id:
+/// - Proofs are removed from database
+/// - User can retry the swap
+///
+/// Note: Signatures from sign_outputs() are in memory only. They're only
+/// persisted during finalize(). So a dropped saga after signing has no
+/// signatures in the database.
+///
+/// # Flow
+/// 1. Setup swap and sign outputs
+/// 2. Drop saga without finalize (signatures lost)
+/// 3. Call recover_from_bad_swaps()
+/// 4. Verify proofs removed
+/// 5. Verify can use same proofs in new swap
+///
+/// # Success Criteria
+/// - Recovery removes proofs completely
+/// - No signatures in database (never persisted)
+/// - Second swap with same proofs succeeds
+#[tokio::test]
+async fn test_startup_recovery_saga_dropped_after_signing() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let ys = input_proofs.ys().unwrap();
+    let blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // Setup swap, sign, and drop without finalize
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(
+                &input_proofs,
+                &output_blinded_messages,
+                None,
+                input_verification.clone(),
+            )
+            .await
+            .expect("Setup should succeed");
+
+        let _saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+        // Saga dropped here - signatures were in memory only, never persisted
+    }
+
+    // Verify proofs still PENDING
+    let states_before = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_before.iter().all(|s| s == &Some(State::Pending)));
+
+    // Verify no signatures in database (they were only in memory)
+    let sigs_before = db.get_blind_signatures(&blinded_secrets).await.unwrap();
+    assert!(sigs_before.iter().all(|s| s.is_none()));
+
+    // Simulate mint restart - run recovery
+    mint.stop().await.expect("Recovery should succeed");
+    mint.start().await.expect("Recovery should succeed");
+
+    // Verify proofs are REMOVED
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed (no signatures in DB)"
+    );
+
+    // Verify we can use the same proofs in a new swap
+    let (new_output_blinded_messages, _) =
+        create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let new_saga = SwapSaga::new(&mint, db, pubsub);
+
+    let new_saga = new_saga
+        .setup_swap(
+            &input_proofs,
+            &new_output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Second swap should succeed after recovery");
+
+    let new_saga = new_saga
+        .sign_outputs()
+        .await
+        .expect("Signing should succeed");
+
+    let _response = new_saga.finalize().await.expect("Finalize should succeed");
+}
+
+/// Tests startup recovery with multiple abandoned operations.
+///
+/// # What This Tests
+/// - Multiple swap operations in different states
+/// - recover_from_bad_swaps() processes all operations correctly
+/// - Each operation is handled according to its state
+///
+/// # Test Scenario
+/// - Operation A: Dropped after setup (no signatures) → proofs removed
+/// - Operation B: Dropped after signing (signatures not persisted) → proofs removed
+/// - Operation C: Completed successfully (has signatures, SPENT) → untouched
+///
+/// # Success Criteria
+/// - Operation A proofs removed
+/// - Operation B proofs removed
+/// - Operation C proofs remain SPENT
+/// - All operations processed in single recovery call
+#[tokio::test]
+async fn test_startup_recovery_multiple_operations() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+
+    // Create three separate sets of proofs for three operations
+    let (proofs_a, verification_a) = create_swap_inputs(&mint, amount).await;
+    let (proofs_b, verification_b) = create_swap_inputs(&mint, amount).await;
+    let (proofs_c, verification_c) = create_swap_inputs(&mint, amount).await;
+
+    let (outputs_a, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (outputs_b, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (outputs_c, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let ys_a = proofs_a.ys().unwrap();
+    let ys_b = proofs_b.ys().unwrap();
+    let ys_c = proofs_c.ys().unwrap();
+
+    // Operation A: Setup only (dropped before signing)
+    {
+        let saga_a = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+        let _saga_a = saga_a
+            .setup_swap(&proofs_a, &outputs_a, None, verification_a)
+            .await
+            .expect("Operation A setup should succeed");
+        // Dropped without signing
+    }
+
+    // Operation B: Setup + Sign (dropped before finalize)
+    {
+        let saga_b = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+        let saga_b = saga_b
+            .setup_swap(&proofs_b, &outputs_b, None, verification_b)
+            .await
+            .expect("Operation B setup should succeed");
+        let _saga_b = saga_b
+            .sign_outputs()
+            .await
+            .expect("Operation B signing should succeed");
+        // Dropped without finalize
+    }
+
+    // Operation C: Complete successfully
+    {
+        let saga_c = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+        let saga_c = saga_c
+            .setup_swap(&proofs_c, &outputs_c, None, verification_c)
+            .await
+            .expect("Operation C setup should succeed");
+        let saga_c = saga_c
+            .sign_outputs()
+            .await
+            .expect("Operation C signing should succeed");
+        let _response = saga_c
+            .finalize()
+            .await
+            .expect("Operation C finalize should succeed");
+    }
+
+    // Verify states before recovery
+    let states_a_before = db.get_proofs_states(&ys_a).await.unwrap();
+    let states_b_before = db.get_proofs_states(&ys_b).await.unwrap();
+    let states_c_before = db.get_proofs_states(&ys_c).await.unwrap();
+
+    assert!(states_a_before.iter().all(|s| s == &Some(State::Pending)));
+    assert!(states_b_before.iter().all(|s| s == &Some(State::Pending)));
+    assert!(states_c_before.iter().all(|s| s == &Some(State::Spent)));
+
+    // Simulate mint restart - run recovery
+    mint.stop().await.expect("Recovery should succeed");
+    mint.start().await.expect("Recovery should succeed");
+
+    // Verify states after recovery
+    let states_a_after = db.get_proofs_states(&ys_a).await.unwrap();
+    let states_b_after = db.get_proofs_states(&ys_b).await.unwrap();
+    let states_c_after = db.get_proofs_states(&ys_c).await.unwrap();
+
+    assert!(
+        states_a_after.iter().all(|s| s.is_none()),
+        "Operation A proofs should be removed (no signatures)"
+    );
+    assert!(
+        states_b_after.iter().all(|s| s.is_none()),
+        "Operation B proofs should be removed (no signatures in DB)"
+    );
+    assert!(
+        states_c_after.iter().all(|s| s == &Some(State::Spent)),
+        "Operation C proofs should remain SPENT (completed successfully)"
+    );
+}
+
+/// Tests startup recovery with operation ID uniqueness and tracking.
+///
+/// # What This Tests
+/// - Multiple concurrent swaps get unique operation_ids
+/// - Proofs are correctly associated with their operation_ids
+/// - Recovery can distinguish between different operations
+/// - Each operation is tracked independently
+///
+/// # Flow
+/// 1. Create multiple swaps concurrently
+/// 2. Drop all sagas without finalize
+/// 3. Verify proofs are associated with different operations
+/// 4. Run recovery
+/// 5. Verify all operations cleaned up correctly
+///
+/// # Success Criteria
+/// - Each swap has unique operation_id
+/// - Proofs correctly tracked per operation
+/// - Recovery processes each operation independently
+/// - All proofs removed after recovery
+#[tokio::test]
+async fn test_operation_id_uniqueness_and_tracking() {
+    let mint = Arc::new(create_test_mint().await.unwrap());
+    let amount = Amount::from(100);
+
+    // Create three separate sets of proofs
+    let (proofs_1, verification_1) = create_swap_inputs(&mint, amount).await;
+    let (proofs_2, verification_2) = create_swap_inputs(&mint, amount).await;
+    let (proofs_3, verification_3) = create_swap_inputs(&mint, amount).await;
+
+    let (outputs_1, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (outputs_2, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (outputs_3, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+
+    let ys_1 = proofs_1.ys().unwrap();
+    let ys_2 = proofs_2.ys().unwrap();
+    let ys_3 = proofs_3.ys().unwrap();
+
+    // Create all three swaps and drop without finalize
+    {
+        let pubsub = mint.pubsub_manager();
+
+        let saga_1 = SwapSaga::new(&*mint, db.clone(), pubsub.clone());
+        let _saga_1 = saga_1
+            .setup_swap(&proofs_1, &outputs_1, None, verification_1)
+            .await
+            .expect("Swap 1 setup should succeed");
+
+        let saga_2 = SwapSaga::new(&*mint, db.clone(), pubsub.clone());
+        let _saga_2 = saga_2
+            .setup_swap(&proofs_2, &outputs_2, None, verification_2)
+            .await
+            .expect("Swap 2 setup should succeed");
+
+        let saga_3 = SwapSaga::new(&*mint, db.clone(), pubsub.clone());
+        let _saga_3 = saga_3
+            .setup_swap(&proofs_3, &outputs_3, None, verification_3)
+            .await
+            .expect("Swap 3 setup should succeed");
+
+        // All sagas dropped without finalize
+    }
+
+    // Verify all proofs are PENDING
+    let states_1 = db.get_proofs_states(&ys_1).await.unwrap();
+    let states_2 = db.get_proofs_states(&ys_2).await.unwrap();
+    let states_3 = db.get_proofs_states(&ys_3).await.unwrap();
+
+    assert!(states_1.iter().all(|s| s == &Some(State::Pending)));
+    assert!(states_2.iter().all(|s| s == &Some(State::Pending)));
+    assert!(states_3.iter().all(|s| s == &Some(State::Pending)));
+
+    // Simulate mint restart - run recovery
+    mint.stop().await.expect("Recovery should succeed");
+    mint.start().await.expect("Recovery should succeed");
+
+    // Verify all proofs removed
+    let states_1_after = db.get_proofs_states(&ys_1).await.unwrap();
+    let states_2_after = db.get_proofs_states(&ys_2).await.unwrap();
+    let states_3_after = db.get_proofs_states(&ys_3).await.unwrap();
+
+    assert!(
+        states_1_after.iter().all(|s| s.is_none()),
+        "Swap 1 proofs should be removed"
+    );
+    assert!(
+        states_2_after.iter().all(|s| s.is_none()),
+        "Swap 2 proofs should be removed"
+    );
+    assert!(
+        states_3_after.iter().all(|s| s.is_none()),
+        "Swap 3 proofs should be removed"
+    );
+
+    // Verify each set of proofs can now be used in new swaps
+    let (new_outputs_1, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let verification = create_verification(amount);
+
+    let pubsub = mint.pubsub_manager();
+    let new_saga = SwapSaga::new(&*mint, db, pubsub);
+
+    let result = new_saga
+        .setup_swap(&proofs_1, &new_outputs_1, None, verification)
+        .await;
+
+    assert!(
+        result.is_ok(),
+        "Should be able to reuse proofs after recovery"
+    );
+}
+
+// ==================== PHASE 2: CRASH RECOVERY TESTS ====================
+// These tests verify crash recovery using saga persistence.
+
+/// Tests crash recovery without calling compensate_all().
+///
+/// # What This Tests
+/// - Saga dropped WITHOUT calling compensate_all() (simulates process crash)
+/// - Saga persists in database after crash
+/// - Proofs remain PENDING after crash (not cleaned up)
+/// - Recovery mechanism finds incomplete saga via get_incomplete_sagas()
+/// - Recovery cleans up orphaned state (proofs, blinded messages, saga)
+///
+/// # This Is The PRIMARY USE CASE for Saga Persistence
+/// The in-memory compensation mechanism only works if the process stays alive.
+/// When the process crashes, we lose in-memory compensations and must rely
+/// on persisted saga to recover.
+///
+/// # Success Criteria
+/// - Saga exists after crash
+/// - Proofs are PENDING after crash (compensation didn't run)
+/// - Recovery removes proofs
+/// - Recovery removes blinded messages
+/// - Recovery deletes saga
+#[tokio::test]
+async fn test_crash_recovery_without_compensation() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let operation_id;
+    let ys = input_proofs.ys().unwrap();
+    let _blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // Simulate crash: setup swap, then drop WITHOUT calling compensate_all()
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+
+        operation_id = *saga.operation.id();
+
+        // CRITICAL: Drop saga WITHOUT calling compensate_all()
+        // This simulates a crash where in-memory compensations are lost
+        drop(saga);
+    }
+
+    // Verify saga still exists in database (persisted during setup)
+    let saga = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga.is_some(), "Saga should persist after crash");
+
+    // Verify proofs are still Pending (compensation didn't run)
+    let states = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states.iter().all(|s| s == &Some(State::Pending)),
+        "Proofs should still be Pending after crash (compensation didn't run)"
+    );
+
+    // Note: We cannot directly verify blinded messages exist (no query method)
+    // but the recovery process will delete them along with proofs
+
+    // Simulate mint restart - run recovery
+    mint.stop().await.expect("Stop should succeed");
+    mint.start().await.expect("Start should succeed");
+
+    // Verify recovery cleaned up:
+    // 1. Proofs removed from database
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Recovery should remove proofs"
+    );
+
+    // 2. Blinded messages removed (implicitly - no query method available)
+
+    // 3. Saga deleted
+    let saga_after = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_after.is_none(), "Recovery should delete saga");
+}
+
+/// Tests crash recovery after setup only (before signing).
+///
+/// # What This Tests
+/// - Saga in SetupComplete state when crashed
+/// - No signatures exist in database
+/// - Recovery removes all swap state
+///
+/// # Success Criteria
+/// - Saga exists before recovery
+/// - Proofs are Pending before recovery
+/// - Everything cleaned up after recovery
+#[tokio::test]
+async fn test_crash_recovery_after_setup_only() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let operation_id;
+    let ys = input_proofs.ys().unwrap();
+    let _blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // Setup and crash
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+
+        operation_id = *saga.operation.id();
+
+        // Verify saga was persisted
+        let saga = {
+            let mut tx = db.begin_transaction().await.unwrap();
+            let result = tx.get_saga(&operation_id).await.unwrap();
+            tx.commit().await.unwrap();
+            result
+        };
+        assert!(saga.is_some());
+
+        // Drop without compensation (crash)
+        drop(saga);
+    }
+
+    // Verify state before recovery
+    let saga_before = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_before.is_some());
+
+    let states_before = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_before.iter().all(|s| s == &Some(State::Pending)));
+
+    // Run recovery
+    mint.stop().await.expect("Stop should succeed");
+    mint.start().await.expect("Start should succeed");
+
+    // Verify cleanup
+    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(), "Saga should be deleted");
+
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed"
+    );
+
+    // Blinded messages also removed by recovery (no query method to verify)
+}
+
+/// Tests crash recovery after signing (before finalize).
+///
+/// # What This Tests
+/// - Saga crashed after sign_outputs() but before finalize()
+/// - Signatures were in memory only (never persisted)
+/// - Recovery treats this the same as crashed after setup
+/// - All state is cleaned up
+///
+/// # Success Criteria
+/// - Saga exists before recovery
+/// - No signatures in database (never persisted)
+/// - Everything cleaned up after recovery
+#[tokio::test]
+async fn test_crash_recovery_after_signing() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let operation_id;
+    let ys = input_proofs.ys().unwrap();
+    let blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // Setup, sign, and crash
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+
+        operation_id = *saga.operation.id();
+
+        let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+        // Verify we have signatures in memory
+        assert_eq!(
+            saga.state_data.signatures.len(),
+            output_blinded_messages.len()
+        );
+
+        // Drop without finalize (crash) - signatures lost
+        drop(saga);
+    }
+
+    // Verify state before recovery
+    let saga_before = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_before.is_some());
+
+    // Verify no signatures in database (they were in memory only)
+    let sigs_before = db.get_blind_signatures(&blinded_secrets).await.unwrap();
+    assert!(
+        sigs_before.iter().all(|s| s.is_none()),
+        "Signatures should not be in DB (never persisted)"
+    );
+
+    // Run recovery
+    mint.stop().await.expect("Stop should succeed");
+    mint.start().await.expect("Start should succeed");
+
+    // Verify cleanup
+    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(), "Saga should be deleted");
+
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed"
+    );
+
+    // Blinded messages also removed by recovery (no query method to verify)
+}
+
+/// Tests recovery with multiple incomplete sagas in different states.
+///
+/// # What This Tests
+/// - Multiple sagas can be incomplete simultaneously
+/// - Recovery processes all incomplete sagas
+/// - Each saga is handled correctly based on its state
+///
+/// # Test Scenario
+/// - Saga A: Setup only (incomplete)
+/// - Saga B: Setup + Sign (incomplete, signatures lost)
+/// - Saga C: Completed (should NOT be affected by recovery)
+///
+/// # Success Criteria
+/// - Saga A cleaned up
+/// - Saga B cleaned up
+/// - Saga C unaffected
+#[tokio::test]
+async fn test_recovery_multiple_incomplete_sagas() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+
+    // Create three sets of inputs/outputs
+    let (proofs_a, verification_a) = create_swap_inputs(&mint, amount).await;
+    let (proofs_b, verification_b) = create_swap_inputs(&mint, amount).await;
+    let (proofs_c, verification_c) = create_swap_inputs(&mint, amount).await;
+
+    let (outputs_a, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (outputs_b, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (outputs_c, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let ys_a = proofs_a.ys().unwrap();
+    let ys_b = proofs_b.ys().unwrap();
+    let ys_c = proofs_c.ys().unwrap();
+
+    let op_id_a;
+    let op_id_b;
+    let op_id_c;
+
+    // Saga A: Setup only, then crash
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+        let saga = saga
+            .setup_swap(&proofs_a, &outputs_a, None, verification_a)
+            .await
+            .expect("Setup A should succeed");
+        op_id_a = *saga.operation.id();
+        drop(saga);
+    }
+
+    // Saga B: Setup + Sign, then crash
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+        let saga = saga
+            .setup_swap(&proofs_b, &outputs_b, None, verification_b)
+            .await
+            .expect("Setup B should succeed");
+        op_id_b = *saga.operation.id();
+        let saga = saga.sign_outputs().await.expect("Sign B should succeed");
+        drop(saga);
+    }
+
+    // Saga C: Complete successfully
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+        let saga = saga
+            .setup_swap(&proofs_c, &outputs_c, None, verification_c)
+            .await
+            .expect("Setup C should succeed");
+        op_id_c = *saga.operation.id();
+        let saga = saga.sign_outputs().await.expect("Sign C should succeed");
+        let _response = saga.finalize().await.expect("Finalize C should succeed");
+    }
+
+    // Verify state before recovery
+    use cdk_common::mint::OperationKind;
+    let incomplete_before = db.get_incomplete_sagas(OperationKind::Swap).await.unwrap();
+    assert_eq!(
+        incomplete_before.len(),
+        2,
+        "Should have 2 incomplete sagas (A and B)"
+    );
+
+    let states_a_before = db.get_proofs_states(&ys_a).await.unwrap();
+    let states_b_before = db.get_proofs_states(&ys_b).await.unwrap();
+    let states_c_before = db.get_proofs_states(&ys_c).await.unwrap();
+
+    assert!(states_a_before.iter().all(|s| s == &Some(State::Pending)));
+    assert!(states_b_before.iter().all(|s| s == &Some(State::Pending)));
+    assert!(states_c_before.iter().all(|s| s == &Some(State::Spent)));
+
+    // Run recovery
+    mint.stop().await.expect("Stop should succeed");
+    mint.start().await.expect("Start should succeed");
+
+    // Verify cleanup
+    let incomplete_after = db.get_incomplete_sagas(OperationKind::Swap).await.unwrap();
+    assert_eq!(
+        incomplete_after.len(),
+        0,
+        "No incomplete sagas after recovery"
+    );
+
+    // Saga A cleaned up
+    let saga_a = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&op_id_a).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_a.is_none());
+    let states_a_after = db.get_proofs_states(&ys_a).await.unwrap();
+    assert!(states_a_after.iter().all(|s| s.is_none()));
+
+    // Saga B cleaned up
+    let saga_b = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&op_id_b).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_b.is_none());
+    let states_b_after = db.get_proofs_states(&ys_b).await.unwrap();
+    assert!(states_b_after.iter().all(|s| s.is_none()));
+
+    // Saga C unaffected (still spent, saga was already deleted)
+    let saga_c = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&op_id_c).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_c.is_none(), "Completed saga was deleted");
+    let states_c_after = db.get_proofs_states(&ys_c).await.unwrap();
+    assert!(
+        states_c_after.iter().all(|s| s == &Some(State::Spent)),
+        "Completed saga proofs remain spent"
+    );
+}
+
+/// Tests that recovery is idempotent (can be run multiple times safely).
+///
+/// # What This Tests
+/// - Recovery can be run multiple times without errors
+/// - Second recovery run is a no-op
+/// - State remains consistent after multiple recoveries
+///
+/// # Success Criteria
+/// - First recovery cleans up incomplete saga
+/// - Second recovery succeeds (no incomplete sagas to process)
+/// - State is consistent after both runs
+#[tokio::test]
+async fn test_recovery_idempotence() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let operation_id;
+    let ys = input_proofs.ys().unwrap();
+
+    // Create incomplete saga
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+        operation_id = *saga.operation.id();
+        drop(saga);
+    }
+
+    // Verify incomplete saga exists
+    let saga_before = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_before.is_some());
+
+    // First recovery
+    mint.stop().await.expect("First stop should succeed");
+    mint.start().await.expect("First start should succeed");
+
+    // Verify cleanup
+    let saga_after_1 = {
+        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_1.is_none());
+    let states_after_1 = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_after_1.iter().all(|s| s.is_none()));
+
+    // Second recovery (should be idempotent - no work to do)
+    mint.stop().await.expect("Second stop should succeed");
+    mint.start().await.expect("Second start should succeed");
+
+    // Verify state unchanged
+    let saga_after_2 = {
+        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_2.is_none());
+    let states_after_2 = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_after_2.iter().all(|s| s.is_none()));
+
+    // Third recovery for good measure
+    mint.stop().await.expect("Third stop should succeed");
+    mint.start().await.expect("Third start should succeed");
+
+    let saga_after_3 = {
+        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_3.is_none());
+}
+
+// ==================== 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).
+///
+/// # 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
+/// - Recovery detects orphaned saga (proofs already SPENT)
+/// - Recovery deletes orphaned saga
+///
+/// # Why This Matters
+/// According to the implementation, saga deletion is best-effort. If it fails,
+/// the swap should still succeed. The orphaned saga will be cleaned up
+/// on next recovery.
+///
+/// # Success Criteria
+/// - Swap succeeds despite deletion failure
+/// - Proofs are SPENT after swap
+/// - Saga remains after swap (orphaned)
+/// - Recovery cleans up orphaned saga
+#[tokio::test]
+async fn test_orphaned_saga_cleanup() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    let saga = saga
+        .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+        .await
+        .expect("Setup should succeed");
+
+    let operation_id = *saga.operation.id();
+    let ys = input_proofs.ys().unwrap();
+
+    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)
+    let states = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states.iter().all(|s| s == &Some(State::Spent)),
+        "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 mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(
+        saga.is_none(),
+        "Saga should be deleted after successful swap"
+    );
+
+    // 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
+}
+
+/// Tests recovery with orphaned proofs (proofs without corresponding saga).
+///
+/// # What This Tests
+/// - Proofs exist in database without saga
+/// - Recovery handles this gracefully (no crash)
+/// - Proofs remain in their current state
+///
+/// # Scenario
+/// This could happen if:
+/// - Manual database intervention removed saga but not proofs
+/// - A bug caused saga deletion without proof cleanup
+/// - Database corruption
+///
+/// # Success Criteria
+/// - Recovery runs without errors
+/// - Proofs remain in database (recovery doesn't remove them without saga)
+/// - No crashes or panics
+#[tokio::test]
+async fn test_recovery_with_orphaned_proofs() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let ys = input_proofs.ys().unwrap();
+
+    // Setup saga to get proofs into PENDING state
+    let operation_id = {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+
+        let op_id = *saga.operation.id();
+
+        // Drop saga (crash simulation)
+        drop(saga);
+
+        op_id
+    };
+
+    // Verify proofs are PENDING and saga exists
+    let states_before = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_before.iter().all(|s| s == &Some(State::Pending)));
+
+    let saga_before = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_before.is_some());
+
+    // Manually delete saga (simulating orphaned proofs scenario)
+    {
+        let mut tx = db.begin_transaction().await.unwrap();
+        tx.delete_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+    }
+
+    // Verify saga is gone but proofs remain
+    let saga_after_delete = {
+        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_delete.is_none(), "Saga should be deleted");
+
+    let states_after_delete = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after_delete
+            .iter()
+            .all(|s| s == &Some(State::Pending)),
+        "Proofs should still be PENDING (orphaned)"
+    );
+
+    // Run recovery - should handle gracefully
+    mint.stop().await.expect("Stop should succeed");
+    mint.start().await.expect("Start should succeed");
+
+    // Verify recovery completed without errors
+    // Orphaned PENDING proofs without saga should remain (not cleaned up)
+    // This is by design - recovery only acts on incomplete sagas, not orphaned proofs
+    let states_after_recovery = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after_recovery
+            .iter()
+            .all(|s| s == &Some(State::Pending)),
+        "Orphaned proofs remain PENDING (recovery doesn't clean up proofs without saga)"
+    );
+
+    // Note: In production, a separate cleanup mechanism (e.g., timeout-based)
+    // would be needed to handle such orphaned resources. Saga recovery only
+    // processes incomplete sagas that have saga.
+}
+
+/// Tests recovery with partial state (missing blinded messages).
+///
+/// # What This Tests
+/// - Saga exists
+/// - Proofs exist
+/// - Blinded messages are missing (deleted manually)
+/// - Recovery handles this gracefully
+///
+/// # Scenario
+/// This could occur due to:
+/// - Partial transaction commit (unlikely with proper atomicity)
+/// - Manual database intervention
+/// - Database corruption
+///
+/// # Success Criteria
+/// - Recovery runs without errors
+/// - Saga is cleaned up
+/// - Proofs are removed
+/// - No crashes due to missing blinded messages
+#[tokio::test]
+async fn test_recovery_with_partial_state() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let ys = input_proofs.ys().unwrap();
+    let blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // Setup saga
+    let operation_id = {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+
+        let op_id = *saga.operation.id();
+
+        // Drop saga (crash simulation)
+        drop(saga);
+
+        op_id
+    };
+
+    // Verify setup
+    let saga_before = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_before.is_some());
+
+    let states_before = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_before.iter().all(|s| s == &Some(State::Pending)));
+
+    // Manually delete blinded messages (simulating partial state)
+    {
+        let mut tx = db.begin_transaction().await.unwrap();
+        tx.delete_blinded_messages(&blinded_secrets).await.unwrap();
+        tx.commit().await.unwrap();
+    }
+
+    // Verify blinded messages are gone but saga and proofs remain
+    // (Note: We can't directly query blinded messages to verify they're gone,
+    // but the recovery mechanism will attempt to delete them regardless)
+
+    // Run recovery - should handle missing blinded messages gracefully
+    mint.stop().await.expect("Stop should succeed");
+    mint.start().await.expect("Start should succeed");
+
+    // Verify recovery completed successfully
+    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(), "Saga should be deleted");
+
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed"
+    );
+
+    // Recovery should succeed even if blinded messages were already gone
+}
+
+/// Tests recovery when blinded messages are missing (but proofs and saga exist).
+///
+/// # What This Tests
+/// - Saga exists with blinded_secrets
+/// - Proofs exist and are PENDING
+/// - Blinded messages themselves are missing from database
+/// - Recovery completes without errors
+/// - Saga is cleaned up
+/// - Proofs are removed
+///
+/// # Success Criteria
+/// - No errors when trying to delete missing blinded messages
+/// - Recovery completes successfully
+/// - All saga cleaned up
+#[tokio::test]
+async fn test_recovery_with_missing_blinded_messages() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let ys = input_proofs.ys().unwrap();
+    let blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // Setup saga and crash
+    let operation_id = {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+
+        let op_id = *saga.operation.id();
+        drop(saga); // Crash
+
+        op_id
+    };
+
+    // Verify initial state
+    let saga = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga.is_some(), "Saga should exist");
+
+    // Manually delete blinded messages before recovery
+    {
+        let mut tx = db.begin_transaction().await.unwrap();
+        tx.delete_blinded_messages(&blinded_secrets).await.unwrap();
+        tx.commit().await.unwrap();
+    }
+
+    // Run recovery - should handle missing blinded messages gracefully
+    mint.stop().await.expect("Stop should succeed");
+    mint.start()
+        .await
+        .expect("Start should succeed despite missing blinded messages");
+
+    // Verify cleanup
+    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(), "Saga should be cleaned up");
+
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed"
+    );
+}
+
+/// Tests that saga deletion failure is handled gracefully during finalize.
+///
+/// # What This Tests
+/// - Swap completes successfully through finalize
+/// - Even if saga deletion fails internally, swap succeeds
+/// - Best-effort saga deletion doesn't fail the swap
+///
+/// # Note
+/// This test verifies the design decision that saga deletion is best-effort.
+/// Currently we cannot easily inject deletion failures, so this test documents
+/// the expected behavior and verifies normal deletion.
+///
+/// # Success Criteria
+/// - Swap completes successfully
+/// - Saga is deleted (in normal case)
+/// - If deletion fails (not testable yet), swap still succeeds
+#[tokio::test]
+async fn test_saga_deletion_failure_handling() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    let saga = saga
+        .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+        .await
+        .expect("Setup should succeed");
+
+    let operation_id = *saga.operation.id();
+    let ys = input_proofs.ys().unwrap();
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    // In normal operation, deletion succeeds
+    let response = saga.finalize().await.expect("Finalize should succeed");
+
+    // Verify swap succeeded
+    assert_eq!(
+        response.signatures.len(),
+        output_blinded_messages.len(),
+        "Should have signatures for all outputs"
+    );
+
+    let states = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states.iter().all(|s| s == &Some(State::Spent)),
+        "Proofs should be SPENT"
+    );
+
+    // Verify saga is deleted
+    let saga = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga.is_none(), "Saga should be deleted");
+
+    // TODO: Add test failure injection for delete_saga to verify that:
+    // 1. Swap still succeeds even if deletion fails
+    // 2. Orphaned saga remains
+    // 3. Recovery can clean it up later
+    //
+    // This would require adding a TEST_FAIL_DELETE_SAGA env var check in the
+    // database implementation's delete_saga method.
+}

+ 218 - 0
crates/cdk/src/test_helpers/mint.rs

@@ -0,0 +1,218 @@
+#![cfg(test)]
+//! Test helpers for creating test mints and related utilities
+
+use std::collections::{HashMap, HashSet};
+use std::str::FromStr;
+use std::sync::Arc;
+use std::time::Duration;
+
+use bip39::Mnemonic;
+use cdk_common::amount::SplitTarget;
+use cdk_common::dhke::construct_proofs;
+use cdk_common::nuts::{BlindedMessage, CurrencyUnit, Id, PaymentMethod, PreMintSecrets, Proofs};
+use cdk_common::{
+    Amount, MintQuoteBolt11Request, MintQuoteBolt11Response, MintQuoteState, MintRequest,
+};
+use cdk_fake_wallet::FakeWallet;
+use tokio::time::sleep;
+
+use crate::mint::{Mint, MintBuilder, MintMeltLimits};
+use crate::types::{FeeReserve, QuoteTTL};
+use crate::Error;
+
+#[cfg(test)]
+pub(crate) fn should_fail_in_test() -> bool {
+    // Some condition that determines when to fail in tests
+    std::env::var("TEST_FAIL").is_ok()
+}
+
+#[cfg(test)]
+pub(crate) fn should_fail_for(operation: &str) -> bool {
+    // Check for specific failure modes using environment variables
+    // Format: TEST_FAIL_<OPERATION>
+    let var_name = format!("TEST_FAIL_{}", operation);
+    std::env::var(&var_name).is_ok()
+}
+
+/// Creates and starts a test mint with in-memory storage and a fake Lightning backend.
+///
+/// This mint can be used for unit tests without requiring external dependencies
+/// like Lightning nodes or persistent databases.
+///
+/// # Example
+///
+/// ```
+/// use cdk::test_helpers::mint::create_test_mint;
+///
+/// #[tokio::test]
+/// async fn test_something() {
+///     let mint = create_test_mint().await.unwrap();
+///     // Use the mint for testing
+/// }
+/// ```
+pub async fn create_test_mint() -> Result<Mint, Error> {
+    let db = Arc::new(cdk_sqlite::mint::memory::empty().await?);
+
+    let mut mint_builder = MintBuilder::new(db.clone());
+
+    let fee_reserve = FeeReserve {
+        min_fee_reserve: 1.into(),
+        percent_fee_reserve: 1.0,
+    };
+
+    let ln_fake_backend = FakeWallet::new(
+        fee_reserve.clone(),
+        HashMap::default(),
+        HashSet::default(),
+        2,
+        CurrencyUnit::Sat,
+    );
+
+    mint_builder
+        .add_payment_processor(
+            CurrencyUnit::Sat,
+            PaymentMethod::Bolt11,
+            MintMeltLimits::new(1, 10_000),
+            Arc::new(ln_fake_backend),
+        )
+        .await?;
+
+    let mnemonic = Mnemonic::generate(12).map_err(|e| Error::Custom(e.to_string()))?;
+
+    mint_builder = mint_builder
+        .with_name("test mint".to_string())
+        .with_description("test mint for unit tests".to_string())
+        .with_urls(vec!["https://test-mint".to_string()]);
+
+    let quote_ttl = QuoteTTL::new(10000, 10000);
+
+    let mint = mint_builder
+        .build_with_seed(db.clone(), &mnemonic.to_seed_normalized(""))
+        .await?;
+
+    mint.set_quote_ttl(quote_ttl).await?;
+
+    mint.start().await?;
+
+    Ok(mint)
+}
+
+/// Creates test proofs by performing a mock mint operation.
+///
+/// This helper creates valid proofs for the given amount by:
+/// 1. Creating blinded messages
+/// 2. Performing a swap to get signatures
+/// 3. Constructing valid proofs from the signatures
+///
+/// # Arguments
+///
+/// * `mint` - The test mint to use for creating proofs
+/// * `amount` - The total amount to create proofs for
+pub async fn mint_test_proofs(mint: &Mint, amount: Amount) -> Result<Proofs, Error> {
+    // Just use fund_mint_with_proofs which creates proofs via swap
+    let mint_quote: MintQuoteBolt11Response<_> = mint
+        .get_mint_quote(
+            MintQuoteBolt11Request {
+                amount,
+                unit: CurrencyUnit::Sat,
+                description: None,
+                pubkey: None,
+            }
+            .into(),
+        )
+        .await?
+        .into();
+
+    loop {
+        let check: MintQuoteBolt11Response<_> = mint
+            .check_mint_quote(&cdk_common::QuoteId::from_str(&mint_quote.quote).unwrap())
+            .await
+            .unwrap()
+            .into();
+
+        if check.state == MintQuoteState::Paid {
+            break;
+        }
+
+        sleep(Duration::from_secs(1)).await;
+    }
+
+    let keysets = mint
+        .get_active_keysets()
+        .get(&CurrencyUnit::Sat)
+        .unwrap()
+        .clone();
+
+    let keys = mint
+        .keyset_pubkeys(&keysets)?
+        .keysets
+        .first()
+        .unwrap()
+        .keys
+        .clone();
+
+    let fees: (u64, Vec<u64>) = (
+        0,
+        keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>().into(),
+    );
+
+    let premint_secrets =
+        PreMintSecrets::random(keysets, amount, &SplitTarget::None, &fees.into()).unwrap();
+
+    let request = MintRequest {
+        quote: mint_quote.quote,
+        outputs: premint_secrets.blinded_messages(),
+        signature: None,
+    };
+
+    let mint_res = mint
+        .process_mint_request(request.try_into().unwrap())
+        .await?;
+
+    Ok(construct_proofs(
+        mint_res.signatures,
+        premint_secrets.rs(),
+        premint_secrets.secrets(),
+        &keys,
+    )?)
+}
+
+/// Creates test blinded messages for the given amount.
+///
+/// This is useful for testing operations that require blinded messages as input.
+///
+/// # Arguments
+///
+/// * `mint` - The test mint (used to get the active keyset)
+/// * `amount` - The total amount to create blinded messages for
+///
+/// # Returns
+///
+/// A tuple containing:
+/// - Vector of blinded messages
+/// - PreMintSecrets (needed to construct proofs later)
+pub async fn create_test_blinded_messages(
+    mint: &Mint,
+    amount: Amount,
+) -> Result<(Vec<BlindedMessage>, PreMintSecrets), Error> {
+    let keyset_id = get_active_keyset_id(mint).await?;
+    let split_target = SplitTarget::default();
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    let pre_mint = PreMintSecrets::random(keyset_id, amount, &split_target, &fee_and_amounts)?;
+    let blinded_messages = pre_mint.blinded_messages().to_vec();
+
+    Ok((blinded_messages, pre_mint))
+}
+
+/// Gets the active keyset ID from the mint.
+pub async fn get_active_keyset_id(mint: &Mint) -> Result<Id, Error> {
+    let keys = mint
+        .pubkeys()
+        .keysets
+        .first()
+        .ok_or(Error::Internal)?
+        .clone();
+    keys.verify_id()?;
+    Ok(keys.id)
+}

+ 10 - 0
crates/cdk/src/test_helpers/mod.rs

@@ -0,0 +1,10 @@
+#![cfg(test)]
+//! Test helper utilities for CDK unit tests
+//!
+//! This module provides shared test utilities for creating test mints, wallets,
+//! and test data without external dependencies (Lightning nodes, databases).
+//!
+//! These helpers are only compiled when running tests.
+
+#[cfg(feature = "mint")]
+pub mod mint;