# 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
This commit is contained in:
tsk
2025-10-22 08:30:33 -05:00
committed by GitHub
parent db2764c566
commit 33c206a310
28 changed files with 4550 additions and 361 deletions

View File

@@ -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(
@@ -353,6 +355,45 @@ pub trait SignaturesDatabase {
) -> Result<Vec<BlindSignature>, Self::Err>;
}
#[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 {
@@ -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>(

View File

@@ -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();

View File

@@ -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

View File

@@ -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),

View File

@@ -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)]

View File

@@ -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)]