소스 검색

Add Mint::update_proofs_state with validation and tests

Introduce a new method on Mint that wraps the database layer's
update_proofs_state with state transition validation:

- Validates transitions via check_state_transition before persisting
- Maps database errors to appropriate business logic errors
- Simplifies call sites in melt_saga, swap_saga, and shared modules

Tests cover:
- Unspent → Pending transition (success)
- Pending → Spent transition (success)
- Same-state transition rejection (Pending → Pending)
- Invalid transition from Spent state
- ProofsWithState.state field update after success
Cesar Rodas 3 주 전
부모
커밋
0a3bfb5b0c

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

@@ -527,3 +527,6 @@ pub trait Database<Error>:
 
 /// Type alias for Mint Database
 pub type DynMintDatabase = std::sync::Arc<dyn Database<Error> + Send + Sync>;
+
+/// Type alias for Mint Transaction
+pub type DynMintTransaction = Box<dyn Transaction<Error> + Send + Sync>;

+ 5 - 4
crates/cdk-common/src/database/mod.rs

@@ -20,10 +20,11 @@ pub type DynKVStore = std::sync::Arc<dyn KVStore<Err = Error> + Send + Sync>;
 
 #[cfg(feature = "mint")]
 pub use mint::{
-    Database as MintDatabase, DynMintDatabase, KeysDatabase as MintKeysDatabase,
-    KeysDatabaseTransaction as MintKeyDatabaseTransaction, ProofsDatabase as MintProofsDatabase,
-    ProofsTransaction as MintProofsTransaction, QuotesDatabase as MintQuotesDatabase,
-    QuotesTransaction as MintQuotesTransaction, SignaturesDatabase as MintSignaturesDatabase,
+    Database as MintDatabase, DynMintDatabase, DynMintTransaction,
+    KeysDatabase as MintKeysDatabase, KeysDatabaseTransaction as MintKeyDatabaseTransaction,
+    ProofsDatabase as MintProofsDatabase, ProofsTransaction as MintProofsTransaction,
+    QuotesDatabase as MintQuotesDatabase, QuotesTransaction as MintQuotesTransaction,
+    SignaturesDatabase as MintSignaturesDatabase,
     SignaturesTransaction as MintSignatureTransaction, Transaction as MintTransaction,
 };
 #[cfg(all(feature = "mint", feature = "auth"))]

+ 5 - 17
crates/cdk/src/mint/melt/melt_saga/mod.rs

@@ -7,7 +7,6 @@ use cdk_common::database::DynMintDatabase;
 use cdk_common::mint::{MeltSagaState, Operation, Saga, SagaStateEnum};
 use cdk_common::nut00::KnownMethod;
 use cdk_common::nuts::MeltQuoteState;
-use cdk_common::state::check_state_transition;
 use cdk_common::{Amount, Error, ProofsMethods, PublicKey, QuoteId, State};
 #[cfg(feature = "prometheus")]
 use cdk_prometheus::METRICS;
@@ -21,6 +20,7 @@ use crate::mint::melt::shared;
 use crate::mint::subscription::PubSubManager;
 use crate::mint::verification::Verification;
 use crate::mint::{MeltQuoteBolt11Response, MeltRequest};
+use crate::Mint;
 
 mod compensation;
 mod state;
@@ -259,22 +259,10 @@ impl MeltSaga<Initial> {
             });
         }
 
-        check_state_transition(proofs.state, State::Pending)
-            .map_err(|_| Error::UnexpectedProofState)?;
-
-        // Update proof states to Pending
-        match tx.update_proofs_state(&mut proofs, State::Pending).await {
-            Ok(states) => states,
-            Err(cdk_common::database::Error::AttemptUpdateSpentProof)
-            | Err(cdk_common::database::Error::AttemptRemoveSpentProof) => {
-                tx.rollback().await?;
-                return Err(Error::TokenAlreadySpent);
-            }
-            Err(err) => {
-                tx.rollback().await?;
-                return Err(err.into());
-            }
-        };
+        if let Err(err) = Mint::update_proofs_state(&mut tx, &mut proofs, State::Pending).await {
+            tx.rollback().await?;
+            return Err(err);
+        }
 
         let previous_state = quote.state;
 

+ 3 - 14
crates/cdk/src/mint/melt/shared.rs

@@ -8,12 +8,12 @@
 
 use cdk_common::database::{self, Acquired, DynMintDatabase};
 use cdk_common::nuts::{BlindSignature, BlindedMessage, MeltQuoteState, State};
-use cdk_common::state::check_state_transition;
 use cdk_common::{Amount, Error, PublicKey, QuoteId};
 use cdk_signatory::signatory::SignatoryKeySet;
 
 use crate::mint::subscription::PubSubManager;
 use crate::mint::MeltQuote;
+use crate::Mint;
 
 /// Retrieves fee and amount configuration for the keyset matching the change outputs.
 ///
@@ -394,19 +394,8 @@ pub async fn finalize_melt_core(
     }
 
     let mut proofs = tx.get_proofs(input_ys).await?;
-    check_state_transition(proofs.state, State::Spent).map_err(|_| Error::UnexpectedProofState)?;
-
-    // Mark input proofs as spent
-    match tx.update_proofs_state(&mut proofs, State::Spent).await {
-        Ok(_) => {}
-        Err(database::Error::AttemptUpdateSpentProof) => {
-            tracing::info!("Proofs for quote {} already marked as spent", quote.id);
-            return Ok(());
-        }
-        Err(err) => {
-            return Err(err.into());
-        }
-    }
+
+    Mint::update_proofs_state(tx, &mut proofs, State::Spent).await?;
 
     // Publish proof state changes
     for pk in input_ys.iter() {

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

@@ -39,6 +39,7 @@ mod issue;
 mod keysets;
 mod ln;
 mod melt;
+mod proofs;
 mod start_up_check;
 mod subscription;
 mod swap;

+ 263 - 0
crates/cdk/src/mint/proofs.rs

@@ -0,0 +1,263 @@
+use cdk_common::database::{Acquired, DynMintTransaction};
+use cdk_common::mint::ProofsWithState;
+use cdk_common::state::check_state_transition;
+use cdk_common::{Error, State};
+
+use crate::Mint;
+
+impl Mint {
+    /// Updates the state of proofs with validation and error handling.
+    ///
+    /// This method:
+    /// 1. Validates the state transition is allowed via `check_state_transition`
+    /// 2. Persists the new state to the database
+    /// 3. Updates the `ProofsWithState.state` field on success
+    ///
+    /// # Errors
+    ///
+    /// - [`Error::UnexpectedProofState`] if the state transition is invalid
+    /// - [`Error::TokenAlreadySpent`] if the database rejects the update (proofs already spent)
+    pub async fn update_proofs_state(
+        tx: &mut DynMintTransaction,
+        proofs: &mut Acquired<ProofsWithState>,
+        new_state: State,
+    ) -> Result<(), Error> {
+        check_state_transition(proofs.state, new_state).map_err(|_| Error::UnexpectedProofState)?;
+
+        tx.update_proofs_state(proofs, new_state)
+            .await
+            .map_err(|err| match err {
+                cdk_common::database::Error::AttemptUpdateSpentProof
+                | cdk_common::database::Error::AttemptRemoveSpentProof => Error::TokenAlreadySpent,
+                err => err.into(),
+            })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use cdk_common::mint::Operation;
+    use cdk_common::nuts::ProofsMethods;
+    use cdk_common::{Amount, Error, State};
+
+    use crate::test_helpers::mint::{create_test_mint, mint_test_proofs};
+    use crate::Mint;
+
+    /// Test successful transition from Unspent to Pending
+    #[tokio::test]
+    async fn test_update_proofs_state_unspent_to_pending() {
+        let mint = create_test_mint().await.unwrap();
+        let proofs = mint_test_proofs(&mint, Amount::from(100)).await.unwrap();
+        let ys = proofs.ys().unwrap();
+
+        let db = mint.localstore();
+
+        // Add proofs to the database first
+        {
+            let mut tx = db.begin_transaction().await.unwrap();
+            tx.add_proofs(
+                proofs.clone(),
+                None,
+                &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+            )
+            .await
+            .unwrap();
+            tx.commit().await.unwrap();
+        }
+
+        let mut tx = db.begin_transaction().await.unwrap();
+        let mut acquired = tx.get_proofs(&ys).await.unwrap();
+
+        assert_eq!(acquired.state, State::Unspent);
+
+        Mint::update_proofs_state(&mut tx, &mut acquired, State::Pending)
+            .await
+            .unwrap();
+
+        assert_eq!(acquired.state, State::Pending);
+        tx.commit().await.unwrap();
+
+        // Verify state persisted to database
+        let states = db.get_proofs_states(&ys).await.unwrap();
+        assert!(states.iter().all(|s| *s == Some(State::Pending)));
+    }
+
+    /// Test successful transition from Pending to Spent
+    #[tokio::test]
+    async fn test_update_proofs_state_pending_to_spent() {
+        let mint = create_test_mint().await.unwrap();
+        let proofs = mint_test_proofs(&mint, Amount::from(100)).await.unwrap();
+        let ys = proofs.ys().unwrap();
+
+        let db = mint.localstore();
+
+        // Add proofs and transition to Pending
+        {
+            let mut tx = db.begin_transaction().await.unwrap();
+            tx.add_proofs(
+                proofs.clone(),
+                None,
+                &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+            )
+            .await
+            .unwrap();
+            tx.commit().await.unwrap();
+        }
+
+        {
+            let mut tx = db.begin_transaction().await.unwrap();
+            let mut acquired = tx.get_proofs(&ys).await.unwrap();
+            Mint::update_proofs_state(&mut tx, &mut acquired, State::Pending)
+                .await
+                .unwrap();
+            tx.commit().await.unwrap();
+        }
+
+        // Now test Pending -> Spent transition
+        let mut tx = db.begin_transaction().await.unwrap();
+        let mut acquired = tx.get_proofs(&ys).await.unwrap();
+
+        assert_eq!(acquired.state, State::Pending);
+
+        Mint::update_proofs_state(&mut tx, &mut acquired, State::Spent)
+            .await
+            .unwrap();
+
+        assert_eq!(acquired.state, State::Spent);
+        tx.commit().await.unwrap();
+
+        // Verify state persisted to database
+        let states = db.get_proofs_states(&ys).await.unwrap();
+        assert!(states.iter().all(|s| *s == Some(State::Spent)));
+    }
+
+    /// Test that update_proofs_state rejects same-state transition (Pending -> Pending)
+    #[tokio::test]
+    async fn test_update_proofs_state_rejects_same_state_transition() {
+        let mint = create_test_mint().await.unwrap();
+        let proofs = mint_test_proofs(&mint, Amount::from(100)).await.unwrap();
+        let ys = proofs.ys().unwrap();
+
+        let db = mint.localstore();
+
+        // Add proofs and transition to Pending
+        {
+            let mut tx = db.begin_transaction().await.unwrap();
+            tx.add_proofs(
+                proofs.clone(),
+                None,
+                &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+            )
+            .await
+            .unwrap();
+            tx.commit().await.unwrap();
+        }
+
+        {
+            let mut tx = db.begin_transaction().await.unwrap();
+            let mut acquired = tx.get_proofs(&ys).await.unwrap();
+            Mint::update_proofs_state(&mut tx, &mut acquired, State::Pending)
+                .await
+                .unwrap();
+            tx.commit().await.unwrap();
+        }
+
+        // Try invalid transition: Pending -> Pending (same state)
+        let mut tx = db.begin_transaction().await.unwrap();
+        let mut acquired = tx.get_proofs(&ys).await.unwrap();
+
+        assert_eq!(acquired.state, State::Pending);
+
+        let result = Mint::update_proofs_state(&mut tx, &mut acquired, State::Pending).await;
+
+        assert!(matches!(result, Err(Error::UnexpectedProofState)));
+    }
+
+    /// Test that update_proofs_state rejects invalid transition from Spent
+    #[tokio::test]
+    async fn test_update_proofs_state_invalid_transition_from_spent() {
+        let mint = create_test_mint().await.unwrap();
+        let proofs = mint_test_proofs(&mint, Amount::from(100)).await.unwrap();
+        let ys = proofs.ys().unwrap();
+
+        let db = mint.localstore();
+
+        // Add proofs and transition to Spent
+        {
+            let mut tx = db.begin_transaction().await.unwrap();
+            tx.add_proofs(
+                proofs.clone(),
+                None,
+                &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+            )
+            .await
+            .unwrap();
+            tx.commit().await.unwrap();
+        }
+
+        {
+            let mut tx = db.begin_transaction().await.unwrap();
+            let mut acquired = tx.get_proofs(&ys).await.unwrap();
+            Mint::update_proofs_state(&mut tx, &mut acquired, State::Pending)
+                .await
+                .unwrap();
+            Mint::update_proofs_state(&mut tx, &mut acquired, State::Spent)
+                .await
+                .unwrap();
+            tx.commit().await.unwrap();
+        }
+
+        // Try invalid transition: Spent -> Pending (not allowed)
+        let mut tx = db.begin_transaction().await.unwrap();
+        let mut acquired = tx.get_proofs(&ys).await.unwrap();
+
+        assert_eq!(acquired.state, State::Spent);
+
+        let result = Mint::update_proofs_state(&mut tx, &mut acquired, State::Pending).await;
+
+        assert!(matches!(result, Err(Error::UnexpectedProofState)));
+    }
+
+    /// Test that ProofsWithState.state is updated after successful update
+    #[tokio::test]
+    async fn test_update_proofs_state_updates_wrapper_state() {
+        let mint = create_test_mint().await.unwrap();
+        let proofs = mint_test_proofs(&mint, Amount::from(100)).await.unwrap();
+        let ys = proofs.ys().unwrap();
+
+        let db = mint.localstore();
+
+        // Add proofs to the database first
+        {
+            let mut tx = db.begin_transaction().await.unwrap();
+            tx.add_proofs(
+                proofs.clone(),
+                None,
+                &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+            )
+            .await
+            .unwrap();
+            tx.commit().await.unwrap();
+        }
+
+        let mut tx = db.begin_transaction().await.unwrap();
+        let mut acquired = tx.get_proofs(&ys).await.unwrap();
+
+        // Before update
+        assert_eq!(acquired.state, State::Unspent);
+
+        // After update
+        Mint::update_proofs_state(&mut tx, &mut acquired, State::Pending)
+            .await
+            .unwrap();
+
+        // The wrapper's state field should be updated
+        assert_eq!(
+            acquired.state,
+            State::Pending,
+            "ProofsWithState.state should be updated after successful update_proofs_state"
+        );
+
+        tx.commit().await.unwrap();
+    }
+}

+ 8 - 21
crates/cdk/src/mint/swap/swap_saga/mod.rs

@@ -12,6 +12,7 @@ use tracing::instrument;
 use self::compensation::{CompensatingAction, RemoveSwapSetup};
 use self::state::{Initial, SetupComplete, Signed};
 use crate::mint::subscription::PubSubManager;
+use crate::Mint;
 
 pub mod compensation;
 mod state;
@@ -208,17 +209,11 @@ impl<'a> SwapSaga<'a, Initial> {
             Err(err) => return Err(Error::NUT00(err)),
         };
 
-        // Update input proof states to Pending
-        match tx
-            .update_proofs_state(&mut new_proofs, State::Pending)
-            .await
+        if let Err(err) = Mint::update_proofs_state(&mut tx, &mut new_proofs, State::Pending).await
         {
-            Ok(states) => states,
-            Err(err) => {
-                tx.rollback().await?;
-                return Err(err.into());
-            }
-        };
+            tx.rollback().await?;
+            return Err(err);
+        }
 
         // Add output blinded messages
         if let Err(err) = tx
@@ -424,19 +419,11 @@ impl SwapSaga<'_, Signed> {
                 return Err(err.into());
             }
         };
-        if check_state_transition(proofs.state, State::Spent).is_err() {
+
+        if let Err(err) = Mint::update_proofs_state(&mut tx, &mut proofs, State::Spent).await {
             tx.rollback().await?;
             self.compensate_all().await?;
-            return Err(Error::TokenAlreadySpent);
-        }
-
-        match tx.update_proofs_state(&mut proofs, State::Spent).await {
-            Ok(_) => {}
-            Err(err) => {
-                tx.rollback().await?;
-                self.compensate_all().await?;
-                return Err(err.into());
-            }
+            return Err(err);
         }
 
         // Publish proof state changes