mirror of
https://github.com/aljazceru/cdk.git
synced 2026-01-25 15:55:38 +01:00
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
This commit is contained in:
@@ -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>(
|
||||
|
||||
@@ -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("e.id, inputs_amount, inputs_fee)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.add_blinded_messages(Some("e.id), &blinded_messages)
|
||||
tx.add_blinded_messages(Some("e.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("e2.id), &blinded_messages)
|
||||
.add_blinded_messages(Some("e2.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("e.id), &blinded_messages)
|
||||
.add_blinded_messages(Some("e.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("e.id), &blinded_messages)
|
||||
.add_blinded_messages(Some("e.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("e.id, inputs_amount, inputs_fee)
|
||||
.await
|
||||
.unwrap();
|
||||
tx1.add_blinded_messages(Some("e.id), &blinded_messages)
|
||||
tx1.add_blinded_messages(Some("e.id), &blinded_messages, &Operation::new_melt())
|
||||
.await
|
||||
.unwrap();
|
||||
tx1.commit().await.unwrap();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user