瀏覽代碼

Replace runtime row-locking checks with compile-time Acquired<T> type

The database layer previously used a LockedRows structure to track which rows
had been locked during a transaction. This approach had drawbacks: the
verification happened at runtime and required explicit lock tracking that could
be forgotten or bypassed.

This change introduces an Acquired<T> wrapper type that serves as a
compile-time marker indicating a resource was properly locked when retrieved
from the database. By returning Acquired<T> from locking queries (e.g.,
get_mint_quote, get_melt_quote), the type system now enforces that callers have
obtained a lock before making modifications.

The modification methods (increment_mint_quote_amount_paid,
update_melt_quote_state, etc.) now take &mut Acquired<T> instead of owned
values. This provides two benefits: it proves the caller holds a lock, and it
allows in-place updates rather than requiring callers to track returned values.

This approach shifts locking verification from runtime panics in tests to
compile-time type checking in all environments, making the safety guarantees
more robust and eliminating the need for the testing-only LockedRows
infrastructure.
Cesar Rodas 1 月之前
父節點
當前提交
a6d0b3bd34

+ 26 - 15
crates/cdk-common/src/database/mint/mod.rs

@@ -7,6 +7,7 @@ use cashu::quote_id::QuoteId;
 use cashu::Amount;
 
 use super::{DbTransactionFinalizer, Error};
+use crate::database::Acquired;
 use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote, Operation};
 use crate::nuts::{
     BlindSignature, BlindedMessage, CurrencyUnit, Id, MeltQuoteState, Proof, Proofs, PublicKey,
@@ -115,59 +116,69 @@ pub trait QuotesTransaction {
     async fn get_mint_quote(
         &mut self,
         quote_id: &QuoteId,
-    ) -> Result<Option<MintMintQuote>, Self::Err>;
+    ) -> Result<Option<Acquired<MintMintQuote>>, Self::Err>;
+
     /// Add [`MintMintQuote`]
-    async fn add_mint_quote(&mut self, quote: MintMintQuote) -> Result<MintMintQuote, Self::Err>;
+    async fn add_mint_quote(
+        &mut self,
+        quote: MintMintQuote,
+    ) -> Result<Acquired<MintMintQuote>, Self::Err>;
+
     /// Increment amount paid [`MintMintQuote`]
     async fn increment_mint_quote_amount_paid(
         &mut self,
-        quote: mint::MintQuote,
+        quote: &mut Acquired<mint::MintQuote>,
         amount_paid: Amount,
         payment_id: String,
-    ) -> Result<mint::MintQuote, Self::Err>;
+    ) -> Result<(), Self::Err>;
+
     /// Increment amount paid [`MintMintQuote`]
     async fn increment_mint_quote_amount_issued(
         &mut self,
-        quote: mint::MintQuote,
+        quote: &mut Acquired<mint::MintQuote>,
         amount_issued: Amount,
-    ) -> Result<mint::MintQuote, Self::Err>;
+    ) -> Result<(), Self::Err>;
 
     /// Get [`mint::MeltQuote`] and lock it for update in this transaction
     async fn get_melt_quote(
         &mut self,
         quote_id: &QuoteId,
-    ) -> Result<Option<mint::MeltQuote>, Self::Err>;
+    ) -> Result<Option<Acquired<mint::MeltQuote>>, Self::Err>;
+
     /// Add [`mint::MeltQuote`]
     async fn add_melt_quote(&mut self, quote: mint::MeltQuote) -> Result<(), Self::Err>;
 
-    /// Updates the request lookup id for a melt quote
+    /// Updates the request lookup id for a melt quote.
+    ///
+    /// Requires an [`Acquired`] melt quote to ensure the row is locked before modification.
     async fn update_melt_quote_request_lookup_id(
         &mut self,
-        quote_id: &QuoteId,
+        quote: &mut Acquired<mint::MeltQuote>,
         new_request_lookup_id: &PaymentIdentifier,
     ) -> Result<(), Self::Err>;
 
-    /// Update [`mint::MeltQuote`] state
+    /// Update [`mint::MeltQuote`] state.
     ///
-    /// It is expected for this function to fail if the state is already set to the new state
+    /// Requires an [`Acquired`] melt quote to ensure the row is locked before modification.
+    /// Returns the previous state.
     async fn update_melt_quote_state(
         &mut self,
-        quote_id: &QuoteId,
+        quote: &mut Acquired<mint::MeltQuote>,
         new_state: MeltQuoteState,
         payment_proof: Option<String>,
-    ) -> Result<(MeltQuoteState, mint::MeltQuote), Self::Err>;
+    ) -> Result<MeltQuoteState, Self::Err>;
 
     /// Get all [`MintMintQuote`]s and lock it for update in this transaction
     async fn get_mint_quote_by_request(
         &mut self,
         request: &str,
-    ) -> Result<Option<MintMintQuote>, Self::Err>;
+    ) -> Result<Option<Acquired<MintMintQuote>>, Self::Err>;
 
     /// Get all [`MintMintQuote`]s
     async fn get_mint_quote_by_request_lookup_id(
         &mut self,
         request_lookup_id: &PaymentIdentifier,
-    ) -> Result<Option<MintMintQuote>, Self::Err>;
+    ) -> Result<Option<Acquired<MintMintQuote>>, Self::Err>;
 }
 
 /// Mint Quote Database trait

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

@@ -1,5 +1,6 @@
 //! Payments
 
+use std::ops::Deref;
 use std::str::FromStr;
 
 use cashu::quote_id::QuoteId;
@@ -88,20 +89,18 @@ where
     );
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx.add_mint_quote(mint_quote).await.unwrap();
+    let mut mint_quote = tx.add_mint_quote(mint_quote).await.unwrap();
 
     let p1 = unique_string();
     let p2 = unique_string();
 
-    let mint_quote = tx
-        .increment_mint_quote_amount_paid(mint_quote, 100.into(), p1.clone())
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 100.into(), p1.clone())
         .await
         .unwrap();
 
     assert_eq!(mint_quote.amount_paid(), 100.into());
 
-    let mint_quote = tx
-        .increment_mint_quote_amount_paid(mint_quote, 250.into(), p2.clone())
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 250.into(), p2.clone())
         .await
         .unwrap();
 
@@ -150,16 +149,14 @@ where
     let p2 = unique_string();
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
-    let mint_quote = tx
-        .increment_mint_quote_amount_paid(mint_quote, 100.into(), p1.clone())
+    let mut mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 100.into(), p1.clone())
         .await
         .unwrap();
 
     assert_eq!(mint_quote.amount_paid(), 100.into());
 
-    let mint_quote = tx
-        .increment_mint_quote_amount_paid(mint_quote, 250.into(), p2.clone())
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 250.into(), p2.clone())
         .await
         .unwrap();
     assert_eq!(mint_quote.amount_paid(), 350.into());
@@ -186,7 +183,7 @@ where
         .await
         .unwrap()
         .expect("mint_quote_from_tx");
-    assert_eq!(mint_quote_from_db, mint_quote_from_tx);
+    assert_eq!(mint_quote_from_db, mint_quote_from_tx.deref().to_owned());
 }
 
 /// Reject duplicate payments in the same txs
@@ -213,14 +210,13 @@ where
     let p1 = unique_string();
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
-    let mint_quote = tx
-        .increment_mint_quote_amount_paid(mint_quote, 100.into(), p1.clone())
+    let mut mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 100.into(), p1.clone())
         .await
         .unwrap();
 
     assert!(tx
-        .increment_mint_quote_amount_paid(mint_quote.clone(), 100.into(), p1)
+        .increment_mint_quote_amount_paid(&mut mint_quote, 100.into(), p1)
         .await
         .is_err());
     tx.commit().await.unwrap();
@@ -258,21 +254,20 @@ where
     );
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
-    let mint_quote = tx
-        .increment_mint_quote_amount_paid(mint_quote, 100.into(), p1.clone())
+    let mut mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 100.into(), p1.clone())
         .await
         .unwrap();
     tx.commit().await.unwrap();
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx
+    let mut mint_quote = tx
         .get_mint_quote(&mint_quote.id)
         .await
         .expect("no error")
         .expect("quote");
     assert!(tx
-        .increment_mint_quote_amount_paid(mint_quote.clone(), 100.into(), p1)
+        .increment_mint_quote_amount_paid(&mut mint_quote, 100.into(), p1)
         .await
         .is_err());
     tx.commit().await.unwrap(); // although in theory nothing has changed, let's try it out
@@ -308,9 +303,9 @@ where
     );
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+    let mut mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
     assert!(tx
-        .increment_mint_quote_amount_issued(mint_quote, 100.into())
+        .increment_mint_quote_amount_issued(&mut mint_quote, 100.into())
         .await
         .is_err());
 }
@@ -341,13 +336,13 @@ where
     tx.commit().await.unwrap();
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx
+    let mut mint_quote = tx
         .get_mint_quote(&mint_quote.id)
         .await
         .expect("no error")
         .expect("quote");
     assert!(tx
-        .increment_mint_quote_amount_issued(mint_quote, 100.into())
+        .increment_mint_quote_amount_issued(&mut mint_quote, 100.into())
         .await
         .is_err());
 }
@@ -375,13 +370,12 @@ where
 
     let p1 = unique_string();
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
-    let mint_quote = tx
-        .increment_mint_quote_amount_paid(mint_quote, 100.into(), p1.clone())
+    let mut mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 100.into(), p1.clone())
         .await
         .unwrap();
     assert!(tx
-        .increment_mint_quote_amount_issued(mint_quote, 101.into())
+        .increment_mint_quote_amount_issued(&mut mint_quote, 101.into())
         .await
         .is_err());
 }
@@ -409,21 +403,21 @@ where
 
     let p1 = unique_string();
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx.add_mint_quote(mint_quote).await.unwrap();
+    let mut mint_quote = tx.add_mint_quote(mint_quote).await.unwrap();
     let quote_id = mint_quote.id.clone();
-    tx.increment_mint_quote_amount_paid(mint_quote, 100.into(), p1.clone())
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 100.into(), p1.clone())
         .await
         .unwrap();
     tx.commit().await.unwrap();
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx
+    let mut mint_quote = tx
         .get_mint_quote(&quote_id)
         .await
         .expect("no error")
         .expect("quote");
     assert!(tx
-        .increment_mint_quote_amount_issued(mint_quote, 101.into())
+        .increment_mint_quote_amount_issued(&mut mint_quote, 101.into())
         .await
         .is_err());
 }
@@ -726,27 +720,29 @@ where
 
     // Update to Pending state
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let (old_state, updated) = tx
-        .update_melt_quote_state(&melt_quote.id, MeltQuoteState::Pending, None)
+    let mut quote = tx.get_melt_quote(&melt_quote.id).await.unwrap().unwrap();
+    let old_state = tx
+        .update_melt_quote_state(&mut quote, MeltQuoteState::Pending, None)
         .await
         .unwrap();
     assert_eq!(old_state, MeltQuoteState::Unpaid);
-    assert_eq!(updated.state, MeltQuoteState::Pending);
+    assert_eq!(quote.state, MeltQuoteState::Pending);
     tx.commit().await.unwrap();
 
     // Update to Paid state with payment proof
     let mut tx = Database::begin_transaction(&db).await.unwrap();
+    let mut quote = tx.get_melt_quote(&melt_quote.id).await.unwrap().unwrap();
     let payment_proof = "payment_proof_123".to_string();
-    let (old_state, updated) = tx
+    let old_state = tx
         .update_melt_quote_state(
-            &melt_quote.id,
+            &mut quote,
             MeltQuoteState::Paid,
             Some(payment_proof.clone()),
         )
         .await
         .unwrap();
     assert_eq!(old_state, MeltQuoteState::Pending);
-    assert_eq!(updated.state, MeltQuoteState::Paid);
+    assert_eq!(quote.state, MeltQuoteState::Paid);
     // The payment proof is stored in the melt quote (verification depends on implementation)
     tx.commit().await.unwrap();
 }
@@ -777,7 +773,8 @@ where
     // Update request lookup id
     let new_lookup_id = PaymentIdentifier::CustomId("new_lookup_id".to_string());
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.update_melt_quote_request_lookup_id(&melt_quote.id, &new_lookup_id)
+    let mut quote = tx.get_melt_quote(&melt_quote.id).await.unwrap().unwrap();
+    tx.update_melt_quote_request_lookup_id(&mut quote, &new_lookup_id)
         .await
         .unwrap();
     tx.commit().await.unwrap();
@@ -1056,13 +1053,12 @@ where
 
     // Increment amount paid first time
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx
+    let mut mint_quote = tx
         .get_mint_quote(&mint_quote.id)
         .await
         .expect("valid quote")
         .expect("valid result");
-    let mint_quote = tx
-        .increment_mint_quote_amount_paid(mint_quote, 300.into(), "payment_1".to_string())
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 300.into(), "payment_1".to_string())
         .await
         .unwrap();
     assert_eq!(mint_quote.amount_paid(), 300.into());
@@ -1070,13 +1066,12 @@ where
 
     // Increment amount paid second time
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx
+    let mut mint_quote = tx
         .get_mint_quote(&mint_quote.id)
         .await
         .expect("valid quote")
         .expect("valid result");
-    let mint_quote = tx
-        .increment_mint_quote_amount_paid(mint_quote, 200.into(), "payment_2".to_string())
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 200.into(), "payment_2".to_string())
         .await
         .unwrap();
     assert_eq!(mint_quote.amount_paid(), 500.into());
@@ -1117,25 +1112,24 @@ where
 
     // First increment amount_paid to allow issuing
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx
+    let mut mint_quote = tx
         .get_mint_quote(&mint_quote.id)
         .await
         .expect("valid quote")
         .expect("valid result");
-    tx.increment_mint_quote_amount_paid(mint_quote.clone(), 1000.into(), "payment_1".to_string())
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 1000.into(), "payment_1".to_string())
         .await
         .unwrap();
     tx.commit().await.unwrap();
 
     // Increment amount issued first time
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx
+    let mut mint_quote = tx
         .get_mint_quote(&mint_quote.id)
         .await
         .expect("valid quote")
         .expect("valid result");
-    let mint_quote = tx
-        .increment_mint_quote_amount_issued(mint_quote, 400.into())
+    tx.increment_mint_quote_amount_issued(&mut mint_quote, 400.into())
         .await
         .unwrap();
     assert_eq!(mint_quote.amount_issued(), 400.into());
@@ -1143,13 +1137,12 @@ where
 
     // Increment amount issued second time
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx
+    let mut mint_quote = tx
         .get_mint_quote(&mint_quote.id)
         .await
         .expect("valid quote")
         .expect("valid result");
-    let mint_quote = tx
-        .increment_mint_quote_amount_issued(mint_quote, 300.into())
+    tx.increment_mint_quote_amount_issued(&mut mint_quote, 300.into())
         .await
         .unwrap();
     assert_eq!(mint_quote.amount_issued(), 700.into());
@@ -1377,13 +1370,12 @@ where
 
     // First payment with payment_id "payment_1"
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx
+    let mut mint_quote = tx
         .get_mint_quote(&mint_quote.id)
         .await
         .expect("valid quote")
         .expect("valid result");
-    let mint_quote = tx
-        .increment_mint_quote_amount_paid(mint_quote, 300.into(), "payment_1".to_string())
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 300.into(), "payment_1".to_string())
         .await
         .unwrap();
     assert_eq!(mint_quote.amount_paid(), 300.into());
@@ -1391,13 +1383,14 @@ where
 
     // Try to add the same payment_id again - should fail with Duplicate error
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx
+    let mut mint_quote = tx
         .get_mint_quote(&mint_quote.id)
         .await
         .expect("valid quote")
         .expect("valid result");
+
     let result = tx
-        .increment_mint_quote_amount_paid(mint_quote.clone(), 300.into(), "payment_1".to_string())
+        .increment_mint_quote_amount_paid(&mut mint_quote, 300.into(), "payment_1".to_string())
         .await;
 
     assert!(
@@ -1412,15 +1405,16 @@ where
 
     // A different payment_id should succeed
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let mint_quote = tx
+    let mut mint_quote = tx
         .get_mint_quote(&mint_quote.id)
         .await
         .expect("valid quote")
         .expect("valid result");
-    let mint_quote = tx
-        .increment_mint_quote_amount_paid(mint_quote, 200.into(), "payment_2".to_string())
+
+    tx.increment_mint_quote_amount_paid(&mut mint_quote, 200.into(), "payment_2".to_string())
         .await
         .unwrap();
+
     assert_eq!(mint_quote.amount_paid(), 500.into());
     tx.commit().await.unwrap();
 
@@ -1460,7 +1454,7 @@ where
     let mut tx = Database::begin_transaction(&db).await.unwrap();
 
     // First load the quote (this should lock it)
-    let loaded_quote = tx
+    let mut loaded_quote = tx
         .get_mint_quote(&mint_quote.id)
         .await
         .unwrap()
@@ -1468,7 +1462,7 @@ where
 
     // Now modification should succeed
     let result = tx
-        .increment_mint_quote_amount_paid(loaded_quote, 100.into(), unique_string())
+        .increment_mint_quote_amount_paid(&mut loaded_quote, 100.into(), unique_string())
         .await;
 
     assert!(

+ 8 - 18
crates/cdk-common/src/database/mint/test/saga.rs

@@ -271,18 +271,12 @@ where
     DB: Database<Error>,
 {
     let operation_id = uuid::Uuid::new_v4();
-    let new_state = SagaStateEnum::Swap(SwapSagaState::Signed);
 
-    // Try to update non-existent saga - behavior may vary
+    // Try to get non-existent saga - should return None
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let result = tx.update_saga(&operation_id, new_state).await;
-    // Some implementations may return Ok, others may return Err
-    // Both are acceptable as long as they don't panic
-    if result.is_ok() {
-        tx.commit().await.unwrap();
-    } else {
-        tx.rollback().await.unwrap();
-    }
+    let saga = tx.get_saga(&operation_id).await.unwrap();
+    assert!(saga.is_none(), "Non-existent saga should return None");
+    tx.commit().await.unwrap();
 }
 
 /// Test deleting non-existent saga is idempotent
@@ -292,15 +286,11 @@ where
 {
     let operation_id = uuid::Uuid::new_v4();
 
-    // Try to delete non-existent saga - should succeed (idempotent)
+    // Try to get non-existent saga - should return None
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let result = tx.delete_saga(&operation_id).await;
-    // Delete should be idempotent - either succeed or fail gracefully
-    if result.is_ok() {
-        tx.commit().await.unwrap();
-    } else {
-        tx.rollback().await.unwrap();
-    }
+    let saga = tx.get_saga(&operation_id).await.unwrap();
+    assert!(saga.is_none(), "Non-existent saga should return None");
+    tx.commit().await.unwrap();
 }
 
 /// Test saga with quote_id for melt operations

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

@@ -7,6 +7,8 @@ pub mod mint;
 #[cfg(feature = "wallet")]
 pub mod wallet;
 
+use std::ops::{Deref, DerefMut};
+
 // Re-export shared KVStore types at the top level for both mint and wallet
 pub use kvstore::{
     validate_kvstore_params, validate_kvstore_string, KVStore, KVStoreDatabase, KVStoreTransaction,
@@ -32,6 +34,76 @@ pub use wallet::{
     DynWalletDatabaseTransaction,
 };
 
+/// A wrapper indicating that a resource has been acquired with a database lock.
+///
+/// This type is returned by database operations that lock rows for update
+/// (e.g., `SELECT ... FOR UPDATE`). It serves as a compile-time marker that
+/// the wrapped resource was properly locked before being returned, ensuring
+/// that subsequent modifications are safe from race conditions.
+///
+/// # Usage
+///
+/// When you need to modify a database record, first acquire it using a locking
+/// query method. The returned `Acquired<T>` guarantees the row is locked for
+/// the duration of the transaction.
+///
+/// ```ignore
+/// // Acquire a quote with a row lock
+/// let mut quote: Acquired<MintQuote> = tx.get_mint_quote_for_update(&quote_id).await?;
+///
+/// // Safely modify the quote (row is locked)
+/// quote.state = QuoteState::Paid;
+///
+/// // Persist the changes
+/// tx.update_mint_quote(&mut quote).await?;
+/// ```
+///
+/// # Deref Behavior
+///
+/// `Acquired<T>` implements `Deref` and `DerefMut`, allowing transparent access
+/// to the inner value's methods and fields.
+#[derive(Debug)]
+pub struct Acquired<T> {
+    inner: T,
+}
+
+impl<T> From<T> for Acquired<T> {
+    /// Wraps a value to indicate it has been acquired with a lock.
+    ///
+    /// This is typically called by database layer implementations after
+    /// executing a locking query.
+    fn from(value: T) -> Self {
+        Acquired { inner: value }
+    }
+}
+
+impl<T> Acquired<T> {
+    /// Consumes the wrapper and returns the inner resource.
+    ///
+    /// Use this when you need to take ownership of the inner value,
+    /// for example when passing it to a function that doesn't accept
+    /// `Acquired<T>`.
+    pub fn inner(self) -> T {
+        self.inner
+    }
+}
+
+impl<T> Deref for Acquired<T> {
+    type Target = T;
+
+    /// Returns a reference to the inner resource.
+    fn deref(&self) -> &Self::Target {
+        &self.inner
+    }
+}
+
+impl<T> DerefMut for Acquired<T> {
+    /// Returns a mutable reference to the inner resource.
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.inner
+    }
+}
+
 /// Type alias for dynamic Wallet Database
 #[cfg(feature = "wallet")]
 pub type DynWalletDatabase = std::sync::Arc<dyn WalletDatabase<Error> + Send + Sync>;

+ 6 - 2
crates/cdk-integration-tests/tests/mint.rs

@@ -203,13 +203,17 @@ async fn test_concurrent_duplicate_payment_handling() {
 
         join_set.spawn(async move {
             let mut tx = MintDatabase::begin_transaction(&*db_clone).await.unwrap();
-            let quote_from_db = tx
+            let mut quote_from_db = tx
                 .get_mint_quote(&quote_id)
                 .await
                 .expect("no error")
                 .expect("some value");
             let result = tx
-                .increment_mint_quote_amount_paid(quote_from_db, Amount::from(10), payment_id_clone)
+                .increment_mint_quote_amount_paid(
+                    &mut quote_from_db,
+                    Amount::from(10),
+                    payment_id_clone,
+                )
                 .await;
 
             if result.is_ok() {

+ 2 - 2
crates/cdk-mint-rpc/src/proto/server.rs

@@ -666,7 +666,7 @@ impl CdkMint for MintRPCServer {
                     .map_err(|_| Status::internal("Could not start db transaction".to_string()))?;
 
                 // Re-fetch the mint quote within the transaction to lock it
-                let mint_quote = tx
+                let mut mint_quote = tx
                     .get_mint_quote(&quote_id)
                     .await
                     .map_err(|_| {
@@ -677,7 +677,7 @@ impl CdkMint for MintRPCServer {
                     ))?;
 
                 self.mint
-                    .pay_mint_quote(&mut tx, mint_quote, response)
+                    .pay_mint_quote(&mut tx, &mut mint_quote, response)
                     .await
                     .map_err(|_| Status::internal("Could not process payment".to_string()))?;
 

+ 0 - 3
crates/cdk-postgres/Cargo.toml

@@ -35,8 +35,5 @@ native-tls = "0.2"
 once_cell.workspace = true
 paste = "1.0.15"
 
-[dev-dependencies]
-cdk-sql-common = { workspace = true, features = ["testing"] }
-
 [lints]
 workspace = true

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

@@ -17,7 +17,6 @@ mint = ["cdk-common/mint"]
 wallet = ["cdk-common/wallet"]
 auth = ["cdk-common/auth"]
 prometheus = ["cdk-prometheus"]
-testing = []
 [dependencies]
 async-trait.workspace = true
 cdk-common = { workspace = true, features = ["test"] }

+ 0 - 6
crates/cdk-sql-common/src/lib.rs

@@ -20,9 +20,3 @@ pub mod wallet;
 pub use mint::SQLMintDatabase;
 #[cfg(feature = "wallet")]
 pub use wallet::SQLWalletDatabase;
-
-#[cfg(feature = "testing")]
-mod locked_row;
-
-#[cfg(feature = "testing")]
-pub use locked_row::{LockedRows, RowId};

+ 0 - 136
crates/cdk-sql-common/src/locked_row.rs

@@ -1,136 +0,0 @@
-//! Row locking mechanism for database transactions.
-//!
-//! This module provides a mechanism for database layers to track which rows are currently
-//! locked within a transaction. The primary advantage is ensuring that upper layers always
-//! read the latest state from the database and properly lock resources before modifications.
-//!
-//! By requiring explicit locking before updates, this prevents race conditions and ensures
-//! data consistency when multiple operations might attempt to modify the same resources
-//! concurrently.
-//!
-//! This module is only available when the `testing` feature is enabled and is intended
-//! for use in test environments to validate proper row locking behavior.
-
-#![allow(clippy::panic)]
-
-use std::collections::HashSet;
-
-use cdk_common::nuts::PublicKey;
-use cdk_common::quote_id::QuoteId;
-
-/// Identifies a database row that can be locked.
-///
-/// This enum represents the different types of resources that can be locked
-/// during a database transaction, allowing for type-safe tracking of locked rows.
-#[derive(Debug, Hash, Eq, PartialEq)]
-pub enum RowId {
-    /// A proof identified by its public key.
-    Proof(PublicKey),
-    /// A quote identified by its quote ID.
-    Quote(QuoteId),
-}
-
-impl From<PublicKey> for RowId {
-    #[inline(always)]
-    fn from(value: PublicKey) -> Self {
-        RowId::Proof(value)
-    }
-}
-
-impl From<&PublicKey> for RowId {
-    #[inline(always)]
-    fn from(value: &PublicKey) -> Self {
-        RowId::Proof(*value)
-    }
-}
-
-impl From<&QuoteId> for RowId {
-    #[inline(always)]
-    fn from(value: &QuoteId) -> Self {
-        RowId::Quote(value.to_owned())
-    }
-}
-
-/// Tracks which rows are currently locked within a transaction.
-///
-/// This structure maintains a set of locked row identifiers, allowing the database
-/// layer to verify that rows have been properly locked before allowing modifications.
-/// This ensures that:
-///
-/// - Resources are read from the database before being modified (forcing fresh reads)
-/// - Multiple concurrent operations cannot modify the same resource simultaneously
-/// - Updates to unlocked rows are rejected, preventing accidental data corruption
-#[derive(Debug, Default)]
-pub struct LockedRows {
-    inner: HashSet<RowId>,
-}
-
-impl LockedRows {
-    /// Locks a single row, marking it as acquired for modification.
-    ///
-    /// After locking, any subsequent calls to [`is_locked`](Self::is_locked) for this
-    /// row will succeed. This should be called when reading a row that will be modified.
-    #[inline(always)]
-    pub fn lock<T>(&mut self, record_id: T)
-    where
-        T: Into<RowId>,
-    {
-        self.inner.insert(record_id.into());
-    }
-
-    /// Locks multiple rows at once.
-    ///
-    /// This is a convenience method equivalent to calling [`lock`](Self::lock)
-    /// for each item in the collection.
-    #[inline(always)]
-    pub fn lock_many<T>(&mut self, records_id: Vec<T>)
-    where
-        T: Into<RowId>,
-    {
-        records_id.into_iter().for_each(|record_id| {
-            self.inner.insert(record_id.into());
-        });
-    }
-
-    /// Verifies that all specified rows are currently locked.
-    ///
-    /// # Panics
-    ///
-    /// Panics if any of the specified rows have not been locked. This is intentional
-    /// as this module is only used in tests to validate proper row locking behavior.
-    #[inline(always)]
-    pub fn is_locked_many<T>(&self, records_id: Vec<T>)
-    where
-        T: Into<RowId>,
-    {
-        for resource_id in records_id {
-            let id = resource_id.into();
-            if !self.inner.contains(&id) {
-                panic!(
-                    "Attempting to update record without previously locking it: {:?}",
-                    id
-                );
-            }
-        }
-    }
-
-    /// Verifies that a single row is currently locked.
-    ///
-    /// # Panics
-    ///
-    /// Panics if the specified row has not been locked. This is intentional
-    /// as this module is only used in tests to validate proper row locking behavior.
-    #[inline(always)]
-    pub fn is_locked<T>(&self, resource_id: T)
-    where
-        T: Into<RowId>,
-    {
-        let id = resource_id.into();
-        if !self.inner.contains(&id) {
-            panic!(
-                "Attempting to update record without previously locking it: {:?}",
-                id
-            );
-        }
-    }
-}

+ 0 - 4
crates/cdk-sql-common/src/mint/auth/mod.rs

@@ -140,7 +140,6 @@ where
         {
             tracing::debug!("Attempting to add known proof. Skipping.... {:?}", err);
         }
-        self.lock_record(y);
         Ok(())
     }
 
@@ -149,7 +148,6 @@ where
         y: &PublicKey,
         proofs_state: State,
     ) -> Result<Option<State>, Self::Err> {
-        self.verify_locked(y);
         let current_state = query(r#"SELECT state FROM proof WHERE y = :y FOR UPDATE"#)?
             .bind("y", y.to_bytes().to_vec())
             .pluck(&self.inner)
@@ -254,8 +252,6 @@ where
                 self.pool.get().map_err(|e| Error::Database(Box::new(e)))?,
             )
             .await?,
-            #[cfg(feature = "testing")]
-            locked_records: Default::default(),
         }))
     }
 

+ 43 - 162
crates/cdk-sql-common/src/mint/mod.rs

@@ -19,9 +19,9 @@ use cdk_common::database::mint::{
     CompletedOperationsDatabase, CompletedOperationsTransaction, SagaDatabase, SagaTransaction,
 };
 use cdk_common::database::{
-    self, ConversionError, DbTransactionFinalizer, Error, MintDatabase, MintKeyDatabaseTransaction,
-    MintKeysDatabase, MintProofsDatabase, MintQuotesDatabase, MintQuotesTransaction,
-    MintSignatureTransaction, MintSignaturesDatabase,
+    self, Acquired, ConversionError, DbTransactionFinalizer, Error, MintDatabase,
+    MintKeyDatabaseTransaction, MintKeysDatabase, MintProofsDatabase, MintQuotesDatabase,
+    MintQuotesTransaction, MintSignatureTransaction, MintSignaturesDatabase,
 };
 use cdk_common::mint::{
     self, IncomingPayment, Issuance, MeltPaymentRequest, MeltQuote, MintKeySetInfo, MintQuote,
@@ -78,61 +78,6 @@ where
     RM: DatabasePool + 'static,
 {
     inner: ConnectionWithTransaction<RM::Connection, PooledResource<RM>>,
-    #[cfg(feature = "testing")]
-    locked_records: crate::LockedRows,
-}
-
-impl<RM> SQLTransaction<RM>
-where
-    RM: DatabasePool + 'static,
-{
-    /// Lock a record for modification (only active when testing feature is enabled)
-    #[cfg(feature = "testing")]
-    #[inline(always)]
-    fn lock_record<T: Into<crate::RowId>>(&mut self, record_id: T) {
-        self.locked_records.lock(record_id);
-    }
-
-    /// Lock a record for modification (no-op when testing feature is disabled)
-    #[cfg(not(feature = "testing"))]
-    #[inline(always)]
-    fn lock_record<T>(&mut self, _record_id: T) {}
-
-    /// Lock multiple records for modification (only active when testing feature is enabled)
-    #[cfg(feature = "testing")]
-    #[inline(always)]
-    fn lock_records<T: Into<crate::RowId>>(&mut self, records: Vec<T>) {
-        self.locked_records.lock_many(records);
-    }
-
-    /// Lock multiple records for modification (no-op when testing feature is disabled)
-    #[cfg(not(feature = "testing"))]
-    #[inline(always)]
-    fn lock_records<T>(&mut self, _records: Vec<T>) {}
-
-    /// Verify records are locked (only active when testing feature is enabled)
-    #[cfg(feature = "testing")]
-    #[inline(always)]
-    fn verify_locked<T: Into<crate::RowId>>(&self, record_id: T) {
-        self.locked_records.is_locked(record_id);
-    }
-
-    /// Verify records are locked (no-op when testing feature is disabled)
-    #[cfg(not(feature = "testing"))]
-    #[inline(always)]
-    fn verify_locked<T>(&self, _record_id: T) {}
-
-    /// Verify multiple records are locked (only active when testing feature is enabled)
-    #[cfg(feature = "testing")]
-    #[inline(always)]
-    fn verify_locked_many<T: Into<crate::RowId>>(&self, records: Vec<T>) {
-        self.locked_records.is_locked_many(records);
-    }
-
-    /// Verify multiple records are locked (no-op when testing feature is disabled)
-    #[cfg(not(feature = "testing"))]
-    #[inline(always)]
-    fn verify_locked_many<T>(&self, _records: Vec<T>) {}
 }
 
 impl<RM> SQLMintDatabase<RM>
@@ -197,7 +142,6 @@ where
 
         for proof in proofs {
             let y = proof.y()?;
-            self.lock_record(y);
 
             query(
                 r#"
@@ -233,8 +177,6 @@ where
         ys: &[PublicKey],
         new_state: State,
     ) -> Result<Vec<Option<State>>, Self::Err> {
-        self.verify_locked_many(ys.to_owned());
-
         let mut current_states = get_current_states(&self.inner, ys, true).await?;
 
         if current_states.len() != ys.len() {
@@ -353,11 +295,7 @@ where
         .fetch_all(&self.inner)
         .await?
         .into_iter()
-        .map(|row| {
-            sql_row_to_proof(row).inspect(|row| {
-                let _ = row.y().map(|c| self.lock_record(c));
-            })
-        })
+        .map(sql_row_to_proof)
         .collect::<Result<Vec<Proof>, _>>()?
         .ys()?)
     }
@@ -394,12 +332,7 @@ where
         &mut self,
         ys: &[PublicKey],
     ) -> Result<Vec<Option<State>>, Self::Err> {
-        let mut current_states =
-            get_current_states(&self.inner, ys, true)
-                .await
-                .inspect(|public_keys| {
-                    self.lock_records(public_keys.keys().collect::<Vec<_>>());
-                })?;
+        let mut current_states = get_current_states(&self.inner, ys, true).await?;
 
         Ok(ys.iter().map(|y| current_states.remove(y)).collect())
     }
@@ -797,8 +730,6 @@ where
                 self.pool.get().map_err(|e| Error::Database(Box::new(e)))?,
             )
             .await?,
-            #[cfg(feature = "testing")]
-            locked_records: Default::default(),
         };
 
         Ok(Box::new(tx))
@@ -1084,11 +1015,10 @@ where
     #[instrument(skip(self))]
     async fn increment_mint_quote_amount_paid(
         &mut self,
-        mut quote: mint::MintQuote,
+        quote: &mut Acquired<mint::MintQuote>,
         amount_paid: Amount,
         payment_id: String,
-    ) -> Result<mint::MintQuote, Self::Err> {
-        self.verify_locked(&quote.id);
+    ) -> Result<(), Self::Err> {
         if amount_paid == Amount::ZERO {
             tracing::warn!("Amount payments of zero amount should not be recorded.");
             return Err(Error::Duplicate);
@@ -1159,17 +1089,15 @@ where
             err
         })?;
 
-        Ok(quote)
+        Ok(())
     }
 
     #[instrument(skip_all)]
     async fn increment_mint_quote_amount_issued(
         &mut self,
-        mut quote: mint::MintQuote,
+        quote: &mut Acquired<mint::MintQuote>,
         amount_issued: Amount,
-    ) -> Result<mint::MintQuote, Self::Err> {
-        self.verify_locked(&quote.id);
-
+    ) -> Result<(), Self::Err> {
         let new_amount_issued = quote
             .increment_amount_issued(amount_issued)
             .map_err(|_| Error::AmountOverflow)?;
@@ -1205,11 +1133,11 @@ VALUES (:quote_id, :amount, :timestamp);
         .execute(&self.inner)
         .await?;
 
-        Ok(quote)
+        Ok(())
     }
 
     #[instrument(skip_all)]
-    async fn add_mint_quote(&mut self, quote: MintQuote) -> Result<MintQuote, Self::Err> {
+    async fn add_mint_quote(&mut self, quote: MintQuote) -> Result<Acquired<MintQuote>, Self::Err> {
         query(
             r#"
                 INSERT INTO mint_quote (
@@ -1236,9 +1164,7 @@ VALUES (:quote_id, :amount, :timestamp);
         .execute(&self.inner)
         .await?;
 
-        self.lock_record(&quote.id);
-
-        Ok(quote)
+        Ok(quote.into())
     }
 
     async fn add_melt_quote(&mut self, quote: mint::MeltQuote) -> Result<(), Self::Err> {
@@ -1285,62 +1211,33 @@ VALUES (:quote_id, :amount, :timestamp);
         .execute(&self.inner)
         .await?;
 
-        self.lock_record(&quote.id);
-
         Ok(())
     }
 
     async fn update_melt_quote_request_lookup_id(
         &mut self,
-        quote_id: &QuoteId,
+        quote: &mut Acquired<mint::MeltQuote>,
         new_request_lookup_id: &PaymentIdentifier,
     ) -> Result<(), Self::Err> {
         query(r#"UPDATE melt_quote SET request_lookup_id = :new_req_id, request_lookup_id_kind = :new_kind WHERE id = :id"#)?
             .bind("new_req_id", new_request_lookup_id.to_string())
-            .bind("new_kind",new_request_lookup_id.kind() )
-            .bind("id", quote_id.to_string())
+            .bind("new_kind", new_request_lookup_id.kind())
+            .bind("id", quote.id.to_string())
             .execute(&self.inner)
             .await?;
+        quote.request_lookup_id = Some(new_request_lookup_id.clone());
         Ok(())
     }
 
     async fn update_melt_quote_state(
         &mut self,
-        quote_id: &QuoteId,
+        quote: &mut Acquired<mint::MeltQuote>,
         state: MeltQuoteState,
         payment_proof: Option<String>,
-    ) -> Result<(MeltQuoteState, mint::MeltQuote), Self::Err> {
-        let mut quote = query(
-            r#"
-            SELECT
-                id,
-                unit,
-                amount,
-                request,
-                fee_reserve,
-                expiry,
-                state,
-                payment_preimage,
-                request_lookup_id,
-                created_time,
-                paid_time,
-                payment_method,
-                options,
-                request_lookup_id_kind
-            FROM
-                melt_quote
-            WHERE
-                id=:id
-            "#,
-        )?
-        .bind("id", quote_id.to_string())
-        .fetch_one(&self.inner)
-        .await?
-        .map(sql_row_to_melt_quote)
-        .transpose()?
-        .ok_or(Error::QuoteNotFound)?;
+    ) -> Result<MeltQuoteState, Self::Err> {
+        let old_state = quote.state;
 
-        check_melt_quote_state_transition(quote.state, state)?;
+        check_melt_quote_state_transition(old_state, state)?;
 
         // When transitioning to Pending, lock all quotes with the same lookup_id
         // and check if any are already pending or paid
@@ -1366,16 +1263,16 @@ VALUES (:quote_id, :amount, :timestamp);
                 .collect::<Result<Vec<_>, Error>>()?;
 
                 // Check if any other quote with the same lookup_id is pending or paid
-                let has_conflict = locked_quotes.iter().any(|(id, state)| {
-                    id != &quote_id.to_string()
-                        && (state == &MeltQuoteState::Pending.to_string()
-                            || state == &MeltQuoteState::Paid.to_string())
+                let has_conflict = locked_quotes.iter().any(|(id, s)| {
+                    id != &quote.id.to_string()
+                        && (s == &MeltQuoteState::Pending.to_string()
+                            || s == &MeltQuoteState::Paid.to_string())
                 });
 
                 if has_conflict {
                     tracing::warn!(
                         "Cannot transition quote {} to Pending: another quote with lookup_id {} is already pending or paid",
-                        quote_id,
+                        quote.id,
                         lookup_id
                     );
                     return Err(Error::Duplicate);
@@ -1385,17 +1282,19 @@ VALUES (:quote_id, :amount, :timestamp);
 
         let rec = if state == MeltQuoteState::Paid {
             let current_time = unix_time();
+            quote.paid_time = Some(current_time);
+            quote.payment_preimage = payment_proof.clone();
             query(r#"UPDATE melt_quote SET state = :state, paid_time = :paid_time, payment_preimage = :payment_preimage WHERE id = :id"#)?
                 .bind("state", state.to_string())
                 .bind("paid_time", current_time as i64)
                 .bind("payment_preimage", payment_proof)
-                .bind("id", quote_id.to_string())
+                .bind("id", quote.id.to_string())
                 .execute(&self.inner)
                 .await
         } else {
             query(r#"UPDATE melt_quote SET state = :state WHERE id = :id"#)?
                 .bind("state", state.to_string())
-                .bind("id", quote_id.to_string())
+                .bind("id", quote.id.to_string())
                 .execute(&self.inner)
                 .await
         };
@@ -1408,63 +1307,49 @@ VALUES (:quote_id, :amount, :timestamp);
             }
         };
 
-        let old_state = quote.state;
         quote.state = state;
 
         if state == MeltQuoteState::Unpaid || state == MeltQuoteState::Failed {
-            self.delete_melt_request(quote_id).await?;
+            self.delete_melt_request(&quote.id).await?;
         }
 
-        Ok((old_state, quote))
+        Ok(old_state)
     }
 
-    async fn get_mint_quote(&mut self, quote_id: &QuoteId) -> Result<Option<MintQuote>, Self::Err> {
+    async fn get_mint_quote(
+        &mut self,
+        quote_id: &QuoteId,
+    ) -> Result<Option<Acquired<MintQuote>>, Self::Err> {
         get_mint_quote_inner(&self.inner, quote_id, true)
             .await
-            .inspect(|quote| {
-                quote.as_ref().inspect(|mint_quote| {
-                    self.lock_record(&mint_quote.id);
-                });
-            })
+            .map(|quote| quote.map(|inner| inner.into()))
     }
 
     async fn get_melt_quote(
         &mut self,
         quote_id: &QuoteId,
-    ) -> Result<Option<mint::MeltQuote>, Self::Err> {
+    ) -> Result<Option<Acquired<mint::MeltQuote>>, Self::Err> {
         get_melt_quote_inner(&self.inner, quote_id, true)
             .await
-            .inspect(|quote| {
-                quote.as_ref().inspect(|melt_quote| {
-                    self.lock_record(&melt_quote.id);
-                });
-            })
+            .map(|quote| quote.map(|inner| inner.into()))
     }
 
     async fn get_mint_quote_by_request(
         &mut self,
         request: &str,
-    ) -> Result<Option<MintQuote>, Self::Err> {
+    ) -> Result<Option<Acquired<MintQuote>>, Self::Err> {
         get_mint_quote_by_request_inner(&self.inner, request, true)
             .await
-            .inspect(|quote| {
-                quote.as_ref().inspect(|mint_quote| {
-                    self.lock_record(&mint_quote.id);
-                });
-            })
+            .map(|quote| quote.map(|inner| inner.into()))
     }
 
     async fn get_mint_quote_by_request_lookup_id(
         &mut self,
         request_lookup_id: &PaymentIdentifier,
-    ) -> Result<Option<MintQuote>, Self::Err> {
+    ) -> Result<Option<Acquired<MintQuote>>, Self::Err> {
         get_mint_quote_by_request_lookup_id_inner(&self.inner, request_lookup_id, true)
             .await
-            .inspect(|quote| {
-                quote.as_ref().inspect(|mint_quote| {
-                    self.lock_record(&mint_quote.id);
-                });
-            })
+            .map(|quote| quote.map(|inner| inner.into()))
     }
 }
 
@@ -2234,8 +2119,6 @@ where
                 self.pool.get().map_err(|e| Error::Database(Box::new(e)))?,
             )
             .await?,
-            #[cfg(feature = "testing")]
-            locked_records: Default::default(),
         }))
     }
 }
@@ -2529,8 +2412,6 @@ where
                 self.pool.get().map_err(|e| Error::Database(Box::new(e)))?,
             )
             .await?,
-            #[cfg(feature = "testing")]
-            locked_records: Default::default(),
         };
 
         Ok(Box::new(tx))

+ 0 - 3
crates/cdk-sqlite/Cargo.toml

@@ -37,8 +37,5 @@ paste = "1.0.15"
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 uuid = { workspace = true, features = ["js"] }
 
-[dev-dependencies]
-cdk-sql-common = { workspace = true, features = ["testing"] }
-
 [lints]
 workspace = true

+ 8 - 7
crates/cdk/src/mint/issue/mod.rs

@@ -1,3 +1,4 @@
+use cdk_common::database::Acquired;
 use cdk_common::mint::{MintQuote, Operation};
 use cdk_common::payment::{
     Bolt11IncomingPaymentOptions, Bolt11Settings, Bolt12IncomingPaymentOptions,
@@ -405,11 +406,11 @@ impl Mint {
 
             let mut tx = self.localstore.begin_transaction().await?;
 
-            if let Ok(Some(mint_quote)) = tx
+            if let Ok(Some(mut mint_quote)) = tx
                 .get_mint_quote_by_request_lookup_id(&wait_payment_response.payment_identifier)
                 .await
             {
-                self.pay_mint_quote(&mut tx, mint_quote, wait_payment_response)
+                self.pay_mint_quote(&mut tx, &mut mint_quote, wait_payment_response)
                     .await?;
             } else {
                 tracing::warn!(
@@ -452,7 +453,7 @@ impl Mint {
     pub async fn pay_mint_quote(
         &self,
         tx: &mut Box<dyn database::MintTransaction<database::Error> + Send + Sync>,
-        mint_quote: MintQuote,
+        mint_quote: &mut Acquired<MintQuote>,
         wait_payment_response: WaitPaymentResponse,
     ) -> Result<(), Error> {
         #[cfg(feature = "prometheus")]
@@ -564,7 +565,7 @@ impl Mint {
 
         let mut tx = self.localstore.begin_transaction().await?;
 
-        let mint_quote = tx
+        let mut mint_quote = tx
             .get_mint_quote(&mint_request.quote)
             .await?
             .ok_or(Error::UnknownQuote)?;
@@ -674,8 +675,8 @@ impl Mint {
             .await?;
 
 
-        let updated_quote = tx
-            .increment_mint_quote_amount_issued(mint_quote.clone(), amount_issued)
+            tx
+            .increment_mint_quote_amount_issued(&mut mint_quote, amount_issued)
             .await?;
 
 
@@ -686,7 +687,7 @@ impl Mint {
         tx.commit().await?;
 
         self.pubsub_manager
-            .mint_quote_issue(&mint_quote, updated_quote.amount_issued());
+            .mint_quote_issue(&mint_quote, mint_quote.amount_issued());
 
         Ok(MintResponse {
             signatures: blind_signatures,

+ 11 - 10
crates/cdk/src/mint/ln.rs

@@ -56,45 +56,44 @@ impl Mint {
         let mut tx = localstore.begin_transaction().await?;
 
         // reload the quote, as it state may have changed
-        *quote = tx
+        let mut new_quote = tx
             .get_mint_quote(&quote.id)
             .await?
             .ok_or(Error::UnknownQuote)?;
 
-        let current_state = quote.state();
+        let current_state = new_quote.state();
 
-        if quote.payment_method == PaymentMethod::Bolt11
+        if new_quote.payment_method == PaymentMethod::Bolt11
             && (current_state == MintQuoteState::Issued || current_state == MintQuoteState::Paid)
         {
             return Ok(());
         }
 
         for payment in ln_status {
-            if !quote.payment_ids().contains(&&payment.payment_id)
+            if !new_quote.payment_ids().contains(&&payment.payment_id)
                 && payment.payment_amount > Amount::ZERO
             {
                 tracing::debug!(
                     "Found payment of {} {} for quote {} when checking.",
                     payment.payment_amount,
                     payment.unit,
-                    quote.id
+                    new_quote.id
                 );
 
-                let amount_paid = to_unit(payment.payment_amount, &payment.unit, &quote.unit)?;
+                let amount_paid = to_unit(payment.payment_amount, &payment.unit, &new_quote.unit)?;
 
                 match tx
                     .increment_mint_quote_amount_paid(
-                        quote.clone(),
+                        &mut new_quote,
                         amount_paid,
                         payment.payment_id.clone(),
                     )
                     .await
                 {
-                    Ok(updated_quote) => {
+                    Ok(()) => {
                         if let Some(pubsub_manager) = pubsub_manager.as_ref() {
-                            pubsub_manager.mint_quote_payment(quote, updated_quote.amount_paid());
+                            pubsub_manager.mint_quote_payment(&new_quote, new_quote.amount_paid());
                         }
-                        *quote = updated_quote;
                     }
                     Err(database::Error::Duplicate) => {
                         tracing::debug!(
@@ -110,6 +109,8 @@ impl Mint {
 
         tx.commit().await?;
 
+        *quote = new_quote.inner();
+
         Ok(())
     }
 

+ 41 - 19
crates/cdk/src/mint/melt/melt_saga/mod.rs

@@ -281,18 +281,21 @@ impl MeltSaga<Initial> {
             );
         }
 
-        // Update quote state to Pending
-        let (state, quote) = match tx
-            .update_melt_quote_state(melt_request.quote(), MeltQuoteState::Pending, None)
-            .await
-        {
-            Ok(result) => result,
+        // Get and lock the quote
+        let mut quote = match tx.get_melt_quote(melt_request.quote()).await {
+            Ok(Some(q)) => q,
+            Ok(None) => {
+                tx.rollback().await?;
+                return Err(Error::UnknownQuote);
+            }
             Err(err) => {
                 tx.rollback().await?;
                 return Err(err.into());
             }
         };
 
+        let previous_state = quote.state;
+
         // Publish proof state changes
         for pk in input_ys.iter() {
             self.pubsub.proof_state((*pk, State::Pending));
@@ -303,7 +306,7 @@ impl MeltSaga<Initial> {
             return Err(Error::UnitMismatch);
         }
 
-        match state {
+        match previous_state {
             MeltQuoteState::Unpaid | MeltQuoteState::Failed => {}
             MeltQuoteState::Pending => {
                 tx.rollback().await?;
@@ -319,8 +322,20 @@ impl MeltSaga<Initial> {
             }
         }
 
+        // Update quote state to Pending
+        match tx
+            .update_melt_quote_state(&mut quote, MeltQuoteState::Pending, None)
+            .await
+        {
+            Ok(_) => {}
+            Err(err) => {
+                tx.rollback().await?;
+                return Err(err.into());
+            }
+        };
+
         self.pubsub
-            .melt_quote_status(&quote, None, None, MeltQuoteState::Pending);
+            .melt_quote_status(&*quote, None, None, MeltQuoteState::Pending);
 
         let inputs_fee_breakdown = self.mint.get_proofs_fee(melt_request.inputs()).await?;
 
@@ -416,6 +431,8 @@ impl MeltSaga<Initial> {
             }));
 
         // Transition to SetupComplete state
+        // Extract inner MeltQuote from Acquired wrapper - the lock was only meaningful
+        // within the transaction that just committed
         Ok(MeltSaga {
             mint: self.mint,
             db: self.db,
@@ -425,7 +442,7 @@ impl MeltSaga<Initial> {
             #[cfg(feature = "prometheus")]
             metrics_incremented: self.metrics_incremented,
             state_data: SetupComplete {
-                quote,
+                quote: quote.inner(),
                 input_ys,
                 blinded_messages: blinded_messages_vec,
                 operation,
@@ -477,7 +494,7 @@ impl MeltSaga<SetupComplete> {
 
         let mut tx = self.db.begin_transaction().await?;
 
-        let mint_quote = match tx
+        let mut mint_quote = match tx
             .get_mint_quote_by_request(&self.state_data.quote.request.to_string())
             .await
         {
@@ -539,13 +556,12 @@ impl MeltSaga<SetupComplete> {
         )
         .await?;
 
-        let mint_quote = tx
-            .increment_mint_quote_amount_paid(
-                mint_quote,
-                amount,
-                self.state_data.quote.id.to_string(),
-            )
-            .await?;
+        tx.increment_mint_quote_amount_paid(
+            &mut mint_quote,
+            amount,
+            self.state_data.quote.id.to_string(),
+        )
+        .await?;
 
         self.pubsub
             .mint_quote_payment(&mint_quote, mint_quote.amount_paid());
@@ -875,7 +891,13 @@ impl MeltSaga<PaymentConfirmed> {
 
         let mut tx = self.db.begin_transaction().await?;
 
-        // Get melt request info first (needed for validation and change)
+        // Acquire lock on the quote for safe state update
+        let mut quote = tx
+            .get_melt_quote(&self.state_data.quote.id)
+            .await?
+            .ok_or(Error::UnknownQuote)?;
+
+        // Get melt request info (needed for validation and change)
         let MeltRequestInfo {
             inputs_amount,
             inputs_fee,
@@ -889,7 +911,7 @@ impl MeltSaga<PaymentConfirmed> {
         if let Err(err) = super::shared::finalize_melt_core(
             &mut tx,
             &self.pubsub,
-            &self.state_data.quote,
+            &mut quote,
             &self.state_data.input_ys,
             inputs_amount,
             inputs_fee,

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

@@ -6,7 +6,7 @@
 //!
 //! The functions here ensure consistency between these two code paths.
 
-use cdk_common::database::{self, DynMintDatabase};
+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};
@@ -103,16 +103,18 @@ pub async fn rollback_melt_quote(
         tx.delete_blinded_messages(blinded_secrets).await?;
     }
 
-    // Reset quote state from Pending to Unpaid
-    let (previous_state, _quote) = tx
-        .update_melt_quote_state(quote_id, MeltQuoteState::Unpaid, None)
-        .await?;
+    // Get and lock the quote, then reset state from Pending to Unpaid
+    if let Some(mut quote) = tx.get_melt_quote(quote_id).await? {
+        let previous_state = tx
+            .update_melt_quote_state(&mut quote, MeltQuoteState::Unpaid, None)
+            .await?;
 
-    if previous_state != MeltQuoteState::Pending {
-        tracing::warn!(
-            "Unexpected quote state during rollback: expected Pending, got {}",
-            previous_state
-        );
+        if previous_state != MeltQuoteState::Pending {
+            tracing::warn!(
+                "Unexpected quote state during rollback: expected Pending, got {}",
+                previous_state
+            );
+        }
     }
 
     // Delete melt request tracking record
@@ -284,7 +286,7 @@ pub async fn process_melt_change(
 pub async fn finalize_melt_core(
     tx: &mut Box<dyn database::MintTransaction<database::Error> + Send + Sync>,
     pubsub: &PubSubManager,
-    quote: &MeltQuote,
+    quote: &mut Acquired<MeltQuote>,
     input_ys: &[PublicKey],
     inputs_amount: Amount,
     inputs_fee: Amount,
@@ -310,7 +312,7 @@ pub async fn finalize_melt_core(
     }
 
     // Update quote state to Paid
-    tx.update_melt_quote_state(&quote.id, MeltQuoteState::Paid, payment_preimage.clone())
+    tx.update_melt_quote_state(quote, MeltQuoteState::Paid, payment_preimage.clone())
         .await?;
 
     // Update payment lookup ID if changed
@@ -321,7 +323,7 @@ pub async fn finalize_melt_core(
             payment_lookup_id
         );
 
-        tx.update_melt_quote_request_lookup_id(&quote.id, payment_lookup_id)
+        tx.update_melt_quote_request_lookup_id(quote, payment_lookup_id)
             .await?;
     }
 
@@ -397,6 +399,19 @@ pub async fn finalize_melt_quote(
 
     let mut tx = db.begin_transaction().await?;
 
+    // Acquire lock on the quote for safe state update
+    let mut locked_quote = match tx.get_melt_quote(&quote.id).await? {
+        Some(q) => q,
+        None => {
+            tracing::warn!(
+                "Melt quote {} not found - may have been completed already",
+                quote.id
+            );
+            tx.rollback().await?;
+            return Ok(None);
+        }
+    };
+
     // Get melt request info
     let melt_request_info = match tx.get_melt_request_and_blinded_messages(&quote.id).await? {
         Some(info) => info,
@@ -426,7 +441,7 @@ pub async fn finalize_melt_quote(
     finalize_melt_core(
         &mut tx,
         pubsub,
-        quote,
+        &mut locked_quote,
         &input_ys,
         melt_request_info.inputs_amount,
         melt_request_info.inputs_fee,

+ 6 - 7
crates/cdk/src/mint/mod.rs

@@ -9,7 +9,7 @@ use cdk_common::amount::to_unit;
 use cdk_common::common::{PaymentProcessorKey, QuoteTTL};
 #[cfg(feature = "auth")]
 use cdk_common::database::DynMintAuthDatabase;
-use cdk_common::database::{self, DynMintDatabase};
+use cdk_common::database::{self, Acquired, DynMintDatabase};
 use cdk_common::nuts::{BlindSignature, BlindedMessage, CurrencyUnit, Id};
 use cdk_common::payment::{DynMintPayment, WaitPaymentResponse};
 pub use cdk_common::quote_id::QuoteId;
@@ -684,13 +684,13 @@ impl Mint {
 
         let mut tx = localstore.begin_transaction().await?;
 
-        if let Ok(Some(mint_quote)) = tx
+        if let Ok(Some(mut mint_quote)) = tx
             .get_mint_quote_by_request_lookup_id(&wait_payment_response.payment_identifier)
             .await
         {
             Self::handle_mint_quote_payment(
                 &mut tx,
-                mint_quote,
+                &mut mint_quote,
                 wait_payment_response,
                 pubsub_manager,
             )
@@ -710,7 +710,7 @@ impl Mint {
     #[instrument(skip_all)]
     async fn handle_mint_quote_payment(
         tx: &mut Box<dyn database::MintTransaction<database::Error> + Send + Sync>,
-        mint_quote: MintQuote,
+        mint_quote: &mut Acquired<MintQuote>,
         wait_payment_response: WaitPaymentResponse,
         pubsub_manager: &Arc<PubSubManager>,
     ) -> Result<(), Error> {
@@ -757,9 +757,8 @@ impl Mint {
                     )
                     .await
                 {
-                    Ok(updated_quote) => {
-                        pubsub_manager
-                            .mint_quote_payment(&updated_quote, updated_quote.amount_paid());
+                    Ok(()) => {
+                        pubsub_manager.mint_quote_payment(mint_quote, mint_quote.amount_paid());
                     }
                     Err(database::Error::Duplicate) => {
                         tracing::info!(

+ 30 - 1
crates/cdk/src/mint/start_up_check.rs

@@ -583,8 +583,37 @@ impl Mint {
                 }
 
                 // Reset quote state to Unpaid (melt-specific, unlike swap)
+                // Acquire lock on the quote first
+                let mut locked_quote = match tx.get_melt_quote(&quote_id_parsed).await {
+                    Ok(Some(q)) => q,
+                    Ok(None) => {
+                        tracing::warn!(
+                            "Melt quote {} not found for saga {} - may have been cleaned up",
+                            quote_id_parsed,
+                            saga.operation_id
+                        );
+                        // Continue with saga deletion even if quote is gone
+                        if let Err(e) = tx.delete_saga(&saga.operation_id).await {
+                            tracing::error!("Failed to delete saga {}: {}", saga.operation_id, e);
+                            tx.rollback().await?;
+                            continue;
+                        }
+                        tx.commit().await?;
+                        continue;
+                    }
+                    Err(e) => {
+                        tracing::error!(
+                            "Failed to get quote for saga {}: {}",
+                            saga.operation_id,
+                            e
+                        );
+                        tx.rollback().await?;
+                        continue;
+                    }
+                };
+
                 if let Err(e) = tx
-                    .update_melt_quote_state(&quote_id_parsed, MeltQuoteState::Unpaid, None)
+                    .update_melt_quote_state(&mut locked_quote, MeltQuoteState::Unpaid, None)
                     .await
                 {
                     tracing::error!(