瀏覽代碼

feat(mint): Add Acquired<T> type for compile-time row lock enforcement (#1446)

Move mint quote mutation logic (add_payment, add_issuance) from the
database layer to the domain model. Mutations are tracked via an internal
`changes` field, which the database layer extracts for granular persistence.

Introduce `Acquired<T>` wrapper type as a compile-time marker indicating
a resource was loaded with a row lock. This ensures mutation methods like
`update_mint_quote` require properly locked resources.
  - Add change tracking pattern for MintQuote payments and issuances
  - Refactor melt quote to acquire lock at load time rather than state transition
  - Add unit tests for state transition validation
  - Add logging when resources are locked
C 1 月之前
父節點
當前提交
2a9b6fa123

+ 69 - 23
crates/cdk-common/src/database/mint/mod.rs

@@ -7,7 +7,8 @@ use cashu::quote_id::QuoteId;
 use cashu::Amount;
 
 use super::{DbTransactionFinalizer, Error};
-use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote, Operation};
+use crate::database::Acquired;
+use crate::mint::{self, MeltQuote, MintKeySetInfo, MintQuote as MintMintQuote, Operation};
 use crate::nuts::{
     BlindSignature, BlindedMessage, CurrencyUnit, Id, MeltQuoteState, Proof, Proofs, PublicKey,
     State,
@@ -115,59 +116,98 @@ 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<(), Self::Err>;
-    /// Increment amount paid [`MintMintQuote`]
-    async fn increment_mint_quote_amount_paid(
+    async fn add_mint_quote(
         &mut self,
-        quote_id: &QuoteId,
-        amount_paid: Amount,
-        payment_id: String,
-    ) -> Result<Amount, Self::Err>;
-    /// Increment amount paid [`MintMintQuote`]
-    async fn increment_mint_quote_amount_issued(
+        quote: MintMintQuote,
+    ) -> Result<Acquired<MintMintQuote>, Self::Err>;
+
+    /// Persists any pending changes made to the mint quote.
+    ///
+    /// This method extracts changes accumulated in the quote (via [`mint::MintQuote::take_changes`])
+    /// and persists them to the database. Changes may include new payments received or new
+    /// issuances recorded against the quote.
+    ///
+    /// If no changes are pending, this method returns successfully without performing
+    /// any database operations.
+    ///
+    /// # Arguments
+    ///
+    /// * `quote` - A mutable reference to an acquired (row-locked) mint quote. The quote
+    ///   must be locked to ensure transactional consistency when persisting changes.
+    ///
+    /// # Implementation Notes
+    ///
+    /// Implementations should call [`mint::MintQuote::take_changes`] to retrieve pending
+    /// changes, then persist each payment and issuance record, and finally update the
+    /// quote's aggregate counters (`amount_paid`, `amount_issued`) in the database.
+    async fn update_mint_quote(
         &mut self,
-        quote_id: &QuoteId,
-        amount_issued: Amount,
-    ) -> Result<Amount, Self::Err>;
+        quote: &mut Acquired<mint::MintQuote>,
+    ) -> 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
+    /// Retrieves all melt quotes matching a payment lookup identifier and locks them for update.
+    ///
+    /// This method returns multiple quotes because certain payment methods (notably BOLT12 offers)
+    /// can generate multiple payment attempts that share the same lookup identifier. Locking all
+    /// related quotes prevents race conditions where concurrent melt operations could interfere
+    /// with each other, potentially leading to double-spending or state inconsistencies.
+    ///
+    /// The returned quotes are locked within the current transaction to ensure safe concurrent
+    /// modification. This is essential during melt saga initiation and finalization to guarantee
+    /// atomic state transitions across all related quotes.
+    ///
+    /// # Arguments
+    ///
+    /// * `request_lookup_id` - The payment identifier used by the Lightning backend to track
+    ///   payment state (e.g., payment hash, offer ID, or label).
+    async fn get_melt_quotes_by_request_lookup_id(
+        &mut self,
+        request_lookup_id: &PaymentIdentifier,
+    ) -> Result<Vec<Acquired<MeltQuote>>, Self::Err>;
+
+    /// 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
@@ -223,6 +263,12 @@ pub trait ProofsTransaction {
         proofs_state: State,
     ) -> Result<Vec<Option<State>>, Self::Err>;
 
+    /// get proofs states
+    async fn get_proofs_states(
+        &mut self,
+        ys: &[PublicKey],
+    ) -> Result<Vec<Option<State>>, Self::Err>;
+
     /// Remove [`Proofs`]
     async fn remove_proofs(
         &mut self,

+ 200 - 96
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,24 +89,24 @@ where
     );
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    assert!(tx.add_mint_quote(mint_quote.clone()).await.is_ok());
+    let mut mint_quote = tx.add_mint_quote(mint_quote).await.unwrap();
 
     let p1 = unique_string();
     let p2 = unique_string();
 
-    let new_paid_amount = tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1.clone())
-        .await
+    mint_quote
+        .add_payment(100.into(), p1.clone(), None)
         .unwrap();
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
 
-    assert_eq!(new_paid_amount, 100.into());
+    assert_eq!(mint_quote.amount_paid(), 100.into());
 
-    let new_paid_amount = tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 250.into(), p2.clone())
-        .await
+    mint_quote
+        .add_payment(250.into(), p2.clone(), None)
         .unwrap();
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
 
-    assert_eq!(new_paid_amount, 350.into());
+    assert_eq!(mint_quote.amount_paid(), 350.into());
 
     tx.commit().await.unwrap();
 
@@ -150,19 +151,19 @@ where
     let p2 = unique_string();
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_mint_quote(mint_quote.clone()).await.unwrap();
-    let new_paid_amount = tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1.clone())
-        .await
+    let mut mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+    mint_quote
+        .add_payment(100.into(), p1.clone(), None)
         .unwrap();
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
 
-    assert_eq!(new_paid_amount, 100.into());
+    assert_eq!(mint_quote.amount_paid(), 100.into());
 
-    let new_paid_amount = tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 250.into(), p2.clone())
-        .await
+    mint_quote
+        .add_payment(250.into(), p2.clone(), None)
         .unwrap();
-    assert_eq!(new_paid_amount, 350.into());
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
+    assert_eq!(mint_quote.amount_paid(), 350.into());
     tx.commit().await.unwrap();
 
     let mint_quote_from_db = db
@@ -186,7 +187,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,16 +214,14 @@ where
     let p1 = unique_string();
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_mint_quote(mint_quote.clone()).await.unwrap();
-    let amount_paid = tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1.clone())
-        .await
+    let mut mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+    mint_quote
+        .add_payment(100.into(), p1.clone(), None)
         .unwrap();
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
 
-    assert!(tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1)
-        .await
-        .is_err());
+    // Duplicate payment should fail
+    assert!(mint_quote.add_payment(100.into(), p1, None).is_err());
     tx.commit().await.unwrap();
 
     let mint_quote_from_db = db
@@ -230,7 +229,7 @@ where
         .await
         .unwrap()
         .expect("mint_from_db");
-    assert_eq!(mint_quote_from_db.amount_paid(), amount_paid);
+    assert_eq!(mint_quote_from_db.amount_paid(), mint_quote.amount_paid());
     assert_eq!(mint_quote_from_db.payments.len(), 1);
 }
 
@@ -258,18 +257,21 @@ where
     );
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_mint_quote(mint_quote.clone()).await.unwrap();
-    let amount_paid = tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1.clone())
-        .await
+    let mut mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+    mint_quote
+        .add_payment(100.into(), p1.clone(), None)
         .unwrap();
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
     tx.commit().await.unwrap();
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    assert!(tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1)
+    let mut mint_quote = tx
+        .get_mint_quote(&mint_quote.id)
         .await
-        .is_err());
+        .expect("no error")
+        .expect("quote");
+    // Duplicate payment should fail
+    assert!(mint_quote.add_payment(100.into(), p1, None).is_err());
     tx.commit().await.unwrap(); // although in theory nothing has changed, let's try it out
 
     let mint_quote_from_db = db
@@ -277,7 +279,7 @@ where
         .await
         .unwrap()
         .expect("mint_from_db");
-    assert_eq!(mint_quote_from_db.amount_paid(), amount_paid);
+    assert_eq!(mint_quote_from_db.amount_paid(), mint_quote.amount_paid());
     assert_eq!(mint_quote_from_db.payments.len(), 1);
 }
 
@@ -303,11 +305,9 @@ where
     );
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_mint_quote(mint_quote.clone()).await.unwrap();
-    assert!(tx
-        .increment_mint_quote_amount_issued(&mint_quote.id, 100.into())
-        .await
-        .is_err());
+    let mut mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+    // Trying to issue without any payment should fail (over-issue)
+    assert!(mint_quote.add_issuance(100.into()).is_err());
 }
 
 /// Reject over issue
@@ -336,10 +336,13 @@ where
     tx.commit().await.unwrap();
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    assert!(tx
-        .increment_mint_quote_amount_issued(&mint_quote.id, 100.into())
+    let mut mint_quote = tx
+        .get_mint_quote(&mint_quote.id)
         .await
-        .is_err());
+        .expect("no error")
+        .expect("quote");
+    // Trying to issue without any payment should fail (over-issue)
+    assert!(mint_quote.add_issuance(100.into()).is_err());
 }
 
 /// Reject over issue with payment
@@ -365,14 +368,13 @@ where
 
     let p1 = unique_string();
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_mint_quote(mint_quote.clone()).await.unwrap();
-    tx.increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1.clone())
-        .await
+    let mut mint_quote = tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+    mint_quote
+        .add_payment(100.into(), p1.clone(), None)
         .unwrap();
-    assert!(tx
-        .increment_mint_quote_amount_issued(&mint_quote.id, 101.into())
-        .await
-        .is_err());
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
+    // Trying to issue more than paid should fail (over-issue)
+    assert!(mint_quote.add_issuance(101.into()).is_err());
 }
 
 /// Reject over issue with payment
@@ -398,17 +400,22 @@ where
 
     let p1 = unique_string();
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_mint_quote(mint_quote.clone()).await.unwrap();
-    tx.increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1.clone())
-        .await
+    let mut mint_quote = tx.add_mint_quote(mint_quote).await.unwrap();
+    let quote_id = mint_quote.id.clone();
+    mint_quote
+        .add_payment(100.into(), p1.clone(), None)
         .unwrap();
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
     tx.commit().await.unwrap();
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    assert!(tx
-        .increment_mint_quote_amount_issued(&mint_quote.id, 101.into())
+    let mut mint_quote = tx
+        .get_mint_quote(&quote_id)
         .await
-        .is_err());
+        .expect("no error")
+        .expect("quote");
+    // Trying to issue more than paid should fail (over-issue)
+    assert!(mint_quote.add_issuance(101.into()).is_err());
 }
 /// Successful melt with unique blinded messages
 pub async fn add_melt_request_unique_blinded_messages<DB>(db: DB)
@@ -709,27 +716,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();
 }
@@ -760,7 +769,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();
@@ -1034,25 +1044,35 @@ where
 
     // Add quote
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+    let mint_quote = tx.add_mint_quote(mint_quote).await.unwrap();
     tx.commit().await.unwrap();
 
-    // Increment amount paid first time
+    // Add payment first time
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let new_total = tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 300.into(), "payment_1".to_string())
+    let mut mint_quote = tx
+        .get_mint_quote(&mint_quote.id)
         .await
+        .expect("valid quote")
+        .expect("valid result");
+    mint_quote
+        .add_payment(300.into(), "payment_1".to_string(), None)
         .unwrap();
-    assert_eq!(new_total, 300.into());
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
+    assert_eq!(mint_quote.amount_paid(), 300.into());
     tx.commit().await.unwrap();
 
-    // Increment amount paid second time
+    // Add payment second time
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let new_total = tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 200.into(), "payment_2".to_string())
+    let mut mint_quote = tx
+        .get_mint_quote(&mint_quote.id)
         .await
+        .expect("valid quote")
+        .expect("valid result");
+    mint_quote
+        .add_payment(200.into(), "payment_2".to_string(), None)
         .unwrap();
-    assert_eq!(new_total, 500.into());
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
+    assert_eq!(mint_quote.amount_paid(), 500.into());
     tx.commit().await.unwrap();
 
     // Verify final state
@@ -1088,29 +1108,41 @@ where
     tx.add_mint_quote(mint_quote.clone()).await.unwrap();
     tx.commit().await.unwrap();
 
-    // First increment amount_paid to allow issuing
+    // First add payment to allow issuing
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.increment_mint_quote_amount_paid(&mint_quote.id, 1000.into(), "payment_1".to_string())
+    let mut mint_quote = tx
+        .get_mint_quote(&mint_quote.id)
         .await
+        .expect("valid quote")
+        .expect("valid result");
+    mint_quote
+        .add_payment(1000.into(), "payment_1".to_string(), None)
         .unwrap();
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
     tx.commit().await.unwrap();
 
-    // Increment amount issued first time
+    // Add issuance first time
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let new_total = tx
-        .increment_mint_quote_amount_issued(&mint_quote.id, 400.into())
+    let mut mint_quote = tx
+        .get_mint_quote(&mint_quote.id)
         .await
-        .unwrap();
-    assert_eq!(new_total, 400.into());
+        .expect("valid quote")
+        .expect("valid result");
+    mint_quote.add_issuance(400.into()).unwrap();
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
+    assert_eq!(mint_quote.amount_issued(), 400.into());
     tx.commit().await.unwrap();
 
-    // Increment amount issued second time
+    // Add issuance second time
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let new_total = tx
-        .increment_mint_quote_amount_issued(&mint_quote.id, 300.into())
+    let mut mint_quote = tx
+        .get_mint_quote(&mint_quote.id)
         .await
-        .unwrap();
-    assert_eq!(new_total, 700.into());
+        .expect("valid quote")
+        .expect("valid result");
+    mint_quote.add_issuance(300.into()).unwrap();
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
+    assert_eq!(mint_quote.amount_issued(), 700.into());
     tx.commit().await.unwrap();
 
     // Verify final state
@@ -1335,21 +1367,30 @@ where
 
     // First payment with payment_id "payment_1"
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let new_total = tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 300.into(), "payment_1".to_string())
+    let mut mint_quote = tx
+        .get_mint_quote(&mint_quote.id)
         .await
+        .expect("valid quote")
+        .expect("valid result");
+    mint_quote
+        .add_payment(300.into(), "payment_1".to_string(), None)
         .unwrap();
-    assert_eq!(new_total, 300.into());
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
+    assert_eq!(mint_quote.amount_paid(), 300.into());
     tx.commit().await.unwrap();
 
-    // Try to add the same payment_id again - should fail with Duplicate error
+    // Try to add the same payment_id again - should fail with DuplicatePaymentId error
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let result = tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 300.into(), "payment_1".to_string())
-        .await;
+    let mut mint_quote = tx
+        .get_mint_quote(&mint_quote.id)
+        .await
+        .expect("valid quote")
+        .expect("valid result");
+
+    let result = mint_quote.add_payment(300.into(), "payment_1".to_string(), None);
 
     assert!(
-        matches!(result.unwrap_err(), Error::Duplicate),
+        matches!(result.unwrap_err(), crate::Error::DuplicatePaymentId),
         "Duplicate payment_id should be rejected"
     );
     tx.rollback().await.unwrap();
@@ -1360,14 +1401,77 @@ where
 
     // A different payment_id should succeed
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let new_total = tx
-        .increment_mint_quote_amount_paid(&mint_quote.id, 200.into(), "payment_2".to_string())
+    let mut mint_quote = tx
+        .get_mint_quote(&mint_quote.id)
         .await
+        .expect("valid quote")
+        .expect("valid result");
+
+    mint_quote
+        .add_payment(200.into(), "payment_2".to_string(), None)
         .unwrap();
-    assert_eq!(new_total, 500.into());
+    tx.update_mint_quote(&mut mint_quote).await.unwrap();
+
+    assert_eq!(mint_quote.amount_paid(), 500.into());
     tx.commit().await.unwrap();
 
     // Verify final state
     let retrieved = db.get_mint_quote(&mint_quote.id).await.unwrap().unwrap();
     assert_eq!(retrieved.amount_paid(), 500.into());
 }
+
+/// Test that loading the quote first allows modifications
+pub async fn modify_mint_quote_after_loading_succeeds<DB>(db: DB)
+where
+    DB: Database<Error> + KeysDatabase<Err = Error>,
+{
+    use crate::database::mint::test::unique_string;
+
+    let mint_quote = MintQuote::new(
+        None,
+        "".to_owned(),
+        cashu::CurrencyUnit::Sat,
+        None,
+        0,
+        PaymentIdentifier::CustomId(unique_string()),
+        None,
+        1000.into(),
+        0.into(),
+        cashu::PaymentMethod::Bolt11,
+        0,
+        vec![],
+        vec![],
+    );
+
+    let mut tx = Database::begin_transaction(&db).await.unwrap();
+    tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+    tx.commit().await.unwrap();
+
+    // Now load the quote first, then modify it
+    let mut tx = Database::begin_transaction(&db).await.unwrap();
+
+    // First load the quote (this should lock it)
+    let mut loaded_quote = tx
+        .get_mint_quote(&mint_quote.id)
+        .await
+        .unwrap()
+        .expect("quote should exist");
+
+    // Now modification should succeed
+    loaded_quote
+        .add_payment(100.into(), unique_string(), None)
+        .unwrap();
+    let result = tx.update_mint_quote(&mut loaded_quote).await;
+
+    assert!(
+        result.is_ok(),
+        "Modifying after loading should succeed, got: {:?}",
+        result.err()
+    );
+
+    tx.commit().await.unwrap();
+
+    // Verify the modification was persisted
+    let retrieved = db.get_mint_quote(&mint_quote.id).await.unwrap().unwrap();
+    assert_eq!(retrieved.amount_paid(), 100.into());
+}

+ 2 - 54
crates/cdk-common/src/database/mint/test/mod.rs

@@ -9,8 +9,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
 
 // For derivation path parsing
 use bitcoin::bip32::DerivationPath;
-use cashu::secret::Secret;
-use cashu::{Amount, CurrencyUnit, SecretKey};
+use cashu::CurrencyUnit;
 
 use super::*;
 use crate::database::KVStoreDatabase;
@@ -57,56 +56,6 @@ where
     keyset_id
 }
 
-/// State transition test
-pub async fn state_transition<DB>(db: DB)
-where
-    DB: Database<crate::database::Error> + KeysDatabase<Err = crate::database::Error>,
-{
-    let keyset_id = setup_keyset(&db).await;
-
-    let proofs = vec![
-        Proof {
-            amount: Amount::from(100),
-            keyset_id,
-            secret: Secret::generate(),
-            c: SecretKey::generate().public_key(),
-            witness: None,
-            dleq: None,
-        },
-        Proof {
-            amount: Amount::from(200),
-            keyset_id,
-            secret: Secret::generate(),
-            c: SecretKey::generate().public_key(),
-            witness: None,
-            dleq: None,
-        },
-    ];
-
-    // Add proofs to database
-    let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(
-        proofs.clone(),
-        None,
-        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
-    )
-    .await
-    .unwrap();
-
-    // Mark one proof as `pending`
-    assert!(tx
-        .update_proofs_states(&[proofs[0].y().unwrap()], State::Pending)
-        .await
-        .is_ok());
-
-    // Attempt to select the `pending` proof, as `pending` again (which should fail)
-    assert!(tx
-        .update_proofs_states(&[proofs[0].y().unwrap()], State::Pending)
-        .await
-        .is_err());
-    tx.commit().await.unwrap();
-}
-
 /// Test KV store functionality including write, read, list, update, and remove operations
 pub async fn kvstore_functionality<DB>(db: DB)
 where
@@ -238,7 +187,6 @@ macro_rules! mint_db_test {
     ($make_db_fn:ident) => {
         mint_db_test!(
             $make_db_fn,
-            state_transition,
             add_and_find_proofs,
             add_duplicate_proofs,
             kvstore_functionality,
@@ -306,7 +254,7 @@ macro_rules! mint_db_test {
             get_mint_quote_by_request_lookup_id_in_transaction,
             get_blind_signatures_in_transaction,
             reject_duplicate_payment_ids,
-            remove_spent_proofs_should_fail
+            remove_spent_proofs_should_fail,
         );
     };
     ($make_db_fn:ident, $($name:ident),+ $(,)?) => {

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

@@ -705,6 +705,10 @@ where
 
     // Transition proofs to Pending state
     let mut tx = Database::begin_transaction(&db).await.unwrap();
+    let _records = tx
+        .get_proof_ys_by_quote_id(&quote_id)
+        .await
+        .expect("valid records");
     tx.update_proofs_states(&ys, State::Pending).await.unwrap();
     tx.commit().await.unwrap();
 
@@ -716,6 +720,10 @@ where
 
     // Now transition proofs to Spent state
     let mut tx = Database::begin_transaction(&db).await.unwrap();
+    let _records = tx
+        .get_proof_ys_by_quote_id(&quote_id)
+        .await
+        .expect("valid records");
     tx.update_proofs_states(&ys, State::Spent).await.unwrap();
     tx.commit().await.unwrap();
 

+ 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

+ 77 - 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>;
@@ -134,6 +206,11 @@ pub enum Error {
     /// Duplicate entry
     #[error("Duplicate entry")]
     Duplicate,
+
+    /// Locked resource
+    #[error("Locked resource")]
+    Locked,
+
     /// Amount overflow
     #[error("Amount overflow")]
     AmountOverflow,

+ 3 - 0
crates/cdk-common/src/error.rs

@@ -47,6 +47,9 @@ pub enum Error {
     /// Amount overflow
     #[error("Amount Overflow")]
     AmountOverflow,
+    /// Over issue - tried to issue more than paid
+    #[error("Cannot issue more than amount paid")]
+    OverIssue,
     /// Witness missing or invalid
     #[error("Signature missing or invalid")]
     SignatureMissingOrInvalid,

+ 111 - 23
crates/cdk-common/src/mint.rs

@@ -358,6 +358,24 @@ impl Operation {
     }
 }
 
+/// Tracks pending changes made to a [`MintQuote`] that need to be persisted.
+///
+/// This struct implements a change-tracking pattern that separates domain logic from
+/// persistence concerns. When modifications are made to a `MintQuote` via methods like
+/// [`MintQuote::add_payment`] or [`MintQuote::add_issuance`], the changes are recorded
+/// here rather than being immediately persisted. The database layer can then call
+/// [`MintQuote::take_changes`] to retrieve and persist only the modifications.
+///
+/// This approach allows business rule validation to happen in the domain model while
+/// keeping the database layer focused purely on persistence.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
+pub struct MintQuoteChange {
+    /// New payments added since the quote was loaded or last persisted.
+    pub payments: Option<Vec<IncomingPayment>>,
+    /// New issuance amounts recorded since the quote was loaded or last persisted.
+    pub issuances: Option<Vec<Amount>>,
+}
+
 /// Mint Quote Info
 #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
 pub struct MintQuote {
@@ -393,6 +411,13 @@ pub struct MintQuote {
     /// Payment of payment(s) that filled quote
     #[serde(default)]
     pub issuance: Vec<Issuance>,
+    /// Accumulated changes since this quote was loaded or created.
+    ///
+    /// This field is not serialized and is used internally to track modifications
+    /// that need to be persisted. Use [`Self::take_changes`] to extract pending
+    /// changes for persistence.
+    #[serde(skip)]
+    changes: Option<MintQuoteChange>,
 }
 
 impl MintQuote {
@@ -429,38 +454,58 @@ impl MintQuote {
             payment_method,
             payments,
             issuance,
+            changes: None,
         }
     }
 
-    /// Increment the amount paid on the mint quote by a given amount
-    #[instrument(skip(self))]
-    pub fn increment_amount_paid(
-        &mut self,
-        additional_amount: Amount,
-    ) -> Result<Amount, crate::Error> {
-        self.amount_paid = self
-            .amount_paid
-            .checked_add(additional_amount)
-            .ok_or(crate::Error::AmountOverflow)?;
-        Ok(self.amount_paid)
-    }
-
     /// Amount paid
     #[instrument(skip(self))]
     pub fn amount_paid(&self) -> Amount {
         self.amount_paid
     }
 
-    /// Increment the amount issued on the mint quote by a given amount
+    /// Records tokens being issued against this mint quote.
+    ///
+    /// This method validates that the issuance doesn't exceed the amount paid, updates
+    /// the quote's internal state, and records the change for later persistence. The
+    /// `amount_issued` counter is incremented and the issuance is added to the change
+    /// tracker for the database layer to persist.
+    ///
+    /// # Arguments
+    ///
+    /// * `additional_amount` - The amount of tokens being issued.
+    ///
+    /// # Returns
+    ///
+    /// Returns the new total `amount_issued` after this issuance is recorded.
+    ///
+    /// # Errors
+    ///
+    /// Returns [`crate::Error::OverIssue`] if the new issued amount would exceed the
+    /// amount paid (cannot issue more tokens than have been paid for).
+    ///
+    /// Returns [`crate::Error::AmountOverflow`] if adding the issuance amount would
+    /// cause an arithmetic overflow.
     #[instrument(skip(self))]
-    pub fn increment_amount_issued(
-        &mut self,
-        additional_amount: Amount,
-    ) -> Result<Amount, crate::Error> {
-        self.amount_issued = self
+    pub fn add_issuance(&mut self, additional_amount: Amount) -> Result<Amount, crate::Error> {
+        let new_amount_issued = self
             .amount_issued
             .checked_add(additional_amount)
             .ok_or(crate::Error::AmountOverflow)?;
+
+        // Can't issue more than what's been paid
+        if new_amount_issued > self.amount_paid {
+            return Err(crate::Error::OverIssue);
+        }
+
+        self.changes
+            .get_or_insert_default()
+            .issuances
+            .get_or_insert_default()
+            .push(additional_amount);
+
+        self.amount_issued = new_amount_issued;
+
         Ok(self.amount_issued)
     }
 
@@ -491,16 +536,47 @@ impl MintQuote {
         self.amount_paid - self.amount_issued
     }
 
-    /// Add a payment ID to the list of payment IDs
+    /// Extracts and returns all pending changes, leaving the internal change tracker empty.
+    ///
+    /// This method is typically called by the database layer after loading or modifying a quote. It
+    /// returns any accumulated changes (new payments, issuances) that need to be persisted, and
+    /// clears the internal change buffer so that subsequent calls return `None` until new
+    /// modifications are made.
+    ///
+    /// Returns `None` if no changes have been made since the last call to this method or since the
+    /// quote was created/loaded.
+    pub fn take_changes(&mut self) -> Option<MintQuoteChange> {
+        self.changes.take()
+    }
+
+    /// Records a new payment received for this mint quote.
+    ///
+    /// This method validates the payment, updates the quote's internal state, and records the
+    /// change for later persistence. The `amount_paid` counter is incremented and the payment is
+    /// added to the change tracker for the database layer to persist.
+    ///
+    /// # Arguments
     ///
-    /// Returns an error if the payment ID is already in the list
+    /// * `amount` - The amount of the payment in the quote's currency unit. * `payment_id` - A
+    /// unique identifier for this payment (e.g., lightning payment hash). * `time` - Optional Unix
+    /// timestamp of when the payment was received. If `None`, the current time is used.
+    ///
+    /// # Errors
+    ///
+    /// Returns [`crate::Error::DuplicatePaymentId`] if a payment with the same ID has already been
+    /// recorded for this quote.
+    ///
+    /// Returns [`crate::Error::AmountOverflow`] if adding the payment amount would cause an
+    /// arithmetic overflow.
     #[instrument(skip(self))]
     pub fn add_payment(
         &mut self,
         amount: Amount,
         payment_id: String,
-        time: u64,
+        time: Option<u64>,
     ) -> Result<(), crate::Error> {
+        let time = time.unwrap_or_else(unix_time);
+
         let payment_ids = self.payment_ids();
         if payment_ids.contains(&&payment_id) {
             return Err(crate::Error::DuplicatePaymentId);
@@ -508,7 +584,19 @@ impl MintQuote {
 
         let payment = IncomingPayment::new(amount, payment_id, time);
 
-        self.payments.push(payment);
+        self.payments.push(payment.clone());
+
+        self.changes
+            .get_or_insert_default()
+            .payments
+            .get_or_insert_default()
+            .push(payment);
+
+        self.amount_paid = self
+            .amount_paid
+            .checked_add(amount)
+            .ok_or(crate::Error::AmountOverflow)?;
+
         Ok(())
     }
 

+ 221 - 0
crates/cdk-common/src/state.rs

@@ -81,3 +81,224 @@ pub fn check_melt_quote_state_transition(
         Ok(())
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    mod proof_state_transitions {
+        use super::*;
+
+        #[test]
+        fn unspent_to_pending_is_valid() {
+            assert!(check_state_transition(State::Unspent, State::Pending).is_ok());
+        }
+
+        #[test]
+        fn unspent_to_spent_is_valid() {
+            assert!(check_state_transition(State::Unspent, State::Spent).is_ok());
+        }
+
+        #[test]
+        fn pending_to_unspent_is_valid() {
+            assert!(check_state_transition(State::Pending, State::Unspent).is_ok());
+        }
+
+        #[test]
+        fn pending_to_spent_is_valid() {
+            assert!(check_state_transition(State::Pending, State::Spent).is_ok());
+        }
+
+        #[test]
+        fn unspent_to_unspent_is_invalid() {
+            let result = check_state_transition(State::Unspent, State::Unspent);
+            assert!(matches!(result, Err(Error::InvalidTransition(_, _))));
+        }
+
+        #[test]
+        fn pending_to_pending_returns_pending_error() {
+            let result = check_state_transition(State::Pending, State::Pending);
+            assert!(matches!(result, Err(Error::Pending)));
+        }
+
+        #[test]
+        fn spent_to_any_returns_already_spent() {
+            assert!(matches!(
+                check_state_transition(State::Spent, State::Unspent),
+                Err(Error::AlreadySpent)
+            ));
+            assert!(matches!(
+                check_state_transition(State::Spent, State::Pending),
+                Err(Error::AlreadySpent)
+            ));
+            assert!(matches!(
+                check_state_transition(State::Spent, State::Spent),
+                Err(Error::AlreadySpent)
+            ));
+        }
+
+        #[test]
+        fn reserved_state_is_invalid_source() {
+            let result = check_state_transition(State::Reserved, State::Unspent);
+            assert!(matches!(result, Err(Error::InvalidTransition(_, _))));
+        }
+    }
+
+    mod melt_quote_state_transitions {
+        use super::*;
+
+        #[test]
+        fn unpaid_to_pending_is_valid() {
+            assert!(check_melt_quote_state_transition(
+                MeltQuoteState::Unpaid,
+                MeltQuoteState::Pending
+            )
+            .is_ok());
+        }
+
+        #[test]
+        fn unpaid_to_failed_is_valid() {
+            assert!(check_melt_quote_state_transition(
+                MeltQuoteState::Unpaid,
+                MeltQuoteState::Failed
+            )
+            .is_ok());
+        }
+
+        #[test]
+        fn pending_to_unpaid_is_valid() {
+            assert!(check_melt_quote_state_transition(
+                MeltQuoteState::Pending,
+                MeltQuoteState::Unpaid
+            )
+            .is_ok());
+        }
+
+        #[test]
+        fn pending_to_paid_is_valid() {
+            assert!(check_melt_quote_state_transition(
+                MeltQuoteState::Pending,
+                MeltQuoteState::Paid
+            )
+            .is_ok());
+        }
+
+        #[test]
+        fn pending_to_failed_is_valid() {
+            assert!(check_melt_quote_state_transition(
+                MeltQuoteState::Pending,
+                MeltQuoteState::Failed
+            )
+            .is_ok());
+        }
+
+        #[test]
+        fn failed_to_pending_is_valid() {
+            assert!(check_melt_quote_state_transition(
+                MeltQuoteState::Failed,
+                MeltQuoteState::Pending
+            )
+            .is_ok());
+        }
+
+        #[test]
+        fn failed_to_unpaid_is_valid() {
+            assert!(check_melt_quote_state_transition(
+                MeltQuoteState::Failed,
+                MeltQuoteState::Unpaid
+            )
+            .is_ok());
+        }
+
+        #[test]
+        fn unknown_to_any_is_valid() {
+            assert!(check_melt_quote_state_transition(
+                MeltQuoteState::Unknown,
+                MeltQuoteState::Unpaid
+            )
+            .is_ok());
+            assert!(check_melt_quote_state_transition(
+                MeltQuoteState::Unknown,
+                MeltQuoteState::Pending
+            )
+            .is_ok());
+            assert!(check_melt_quote_state_transition(
+                MeltQuoteState::Unknown,
+                MeltQuoteState::Paid
+            )
+            .is_ok());
+            assert!(check_melt_quote_state_transition(
+                MeltQuoteState::Unknown,
+                MeltQuoteState::Failed
+            )
+            .is_ok());
+        }
+
+        #[test]
+        fn unpaid_to_paid_is_invalid() {
+            let result =
+                check_melt_quote_state_transition(MeltQuoteState::Unpaid, MeltQuoteState::Paid);
+            assert!(matches!(
+                result,
+                Err(Error::InvalidMeltQuoteTransition(_, _))
+            ));
+        }
+
+        #[test]
+        fn unpaid_to_unpaid_is_invalid() {
+            let result =
+                check_melt_quote_state_transition(MeltQuoteState::Unpaid, MeltQuoteState::Unpaid);
+            assert!(matches!(
+                result,
+                Err(Error::InvalidMeltQuoteTransition(_, _))
+            ));
+        }
+
+        #[test]
+        fn pending_to_pending_returns_pending_error() {
+            let result =
+                check_melt_quote_state_transition(MeltQuoteState::Pending, MeltQuoteState::Pending);
+            assert!(matches!(result, Err(Error::Pending)));
+        }
+
+        #[test]
+        fn paid_to_any_returns_already_paid() {
+            assert!(matches!(
+                check_melt_quote_state_transition(MeltQuoteState::Paid, MeltQuoteState::Unpaid),
+                Err(Error::AlreadyPaid)
+            ));
+            assert!(matches!(
+                check_melt_quote_state_transition(MeltQuoteState::Paid, MeltQuoteState::Pending),
+                Err(Error::AlreadyPaid)
+            ));
+            assert!(matches!(
+                check_melt_quote_state_transition(MeltQuoteState::Paid, MeltQuoteState::Paid),
+                Err(Error::AlreadyPaid)
+            ));
+            assert!(matches!(
+                check_melt_quote_state_transition(MeltQuoteState::Paid, MeltQuoteState::Failed),
+                Err(Error::AlreadyPaid)
+            ));
+        }
+
+        #[test]
+        fn failed_to_paid_is_invalid() {
+            let result =
+                check_melt_quote_state_transition(MeltQuoteState::Failed, MeltQuoteState::Paid);
+            assert!(matches!(
+                result,
+                Err(Error::InvalidMeltQuoteTransition(_, _))
+            ));
+        }
+
+        #[test]
+        fn failed_to_failed_is_invalid() {
+            let result =
+                check_melt_quote_state_transition(MeltQuoteState::Failed, MeltQuoteState::Failed);
+            assert!(matches!(
+                result,
+                Err(Error::InvalidMeltQuoteTransition(_, _))
+            ));
+        }
+    }
+}

+ 15 - 3
crates/cdk-integration-tests/tests/mint.rs

@@ -203,9 +203,21 @@ async fn test_concurrent_duplicate_payment_handling() {
 
         join_set.spawn(async move {
             let mut tx = MintDatabase::begin_transaction(&*db_clone).await.unwrap();
-            let result = tx
-                .increment_mint_quote_amount_paid(&quote_id, Amount::from(10), payment_id_clone)
-                .await;
+            let mut quote_from_db = tx
+                .get_mint_quote(&quote_id)
+                .await
+                .expect("no error")
+                .expect("some value");
+
+            let result = if let Err(err) =
+                quote_from_db.add_payment(Amount::from(10), payment_id_clone, None)
+            {
+                Err(err)
+            } else {
+                tx.update_mint_quote(&mut quote_from_db)
+                    .await
+                    .map_err(|err| cdk_common::Error::Database(err))
+            };
 
             if result.is_ok() {
                 tx.commit().await.unwrap();

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

@@ -665,8 +665,19 @@ impl CdkMint for MintRPCServer {
                     .await
                     .map_err(|_| Status::internal("Could not start db transaction".to_string()))?;
 
+                // Re-fetch the mint quote within the transaction to lock it
+                let mut mint_quote = tx
+                    .get_mint_quote(&quote_id)
+                    .await
+                    .map_err(|_| {
+                        Status::internal("Could not get quote in transaction".to_string())
+                    })?
+                    .ok_or(Status::invalid_argument(
+                        "Quote not found in transaction".to_string(),
+                    ))?;
+
                 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()))?;
 

+ 4 - 0
crates/cdk-postgres/src/db.rs

@@ -14,6 +14,10 @@ fn to_pgsql_error(err: PgError) -> Error {
         if code == SqlState::INTEGRITY_CONSTRAINT_VIOLATION || code == SqlState::UNIQUE_VIOLATION {
             return Error::Duplicate;
         }
+
+        if code == SqlState::T_R_DEADLOCK_DETECTED {
+            return Error::Locked;
+        }
     }
 
     Error::Database(Box::new(err))

+ 3 - 6
crates/cdk-sql-common/src/mint/auth/mod.rs

@@ -121,6 +121,7 @@ where
     }
 
     async fn add_proof(&mut self, proof: AuthProof) -> Result<(), database::Error> {
+        let y = proof.y()?;
         if let Err(err) = query(
             r#"
                 INSERT INTO proof
@@ -129,7 +130,7 @@ where
                 (:y, :keyset_id, :secret, :c, :state)
                 "#,
         )?
-        .bind("y", proof.y()?.to_bytes().to_vec())
+        .bind("y", y.to_bytes().to_vec())
         .bind("keyset_id", proof.keyset_id.to_string())
         .bind("secret", proof.secret.to_string())
         .bind("c", proof.c.to_bytes().to_vec())
@@ -154,12 +155,8 @@ where
             .map(|state| Ok::<_, Error>(column_as_string!(state, State::from_str)))
             .transpose()?;
 
-        query(r#"UPDATE proof SET state = :new_state WHERE state = :state AND y = :y"#)?
+        query(r#"UPDATE proof SET state = :new_state WHERE  y = :y"#)?
             .bind("y", y.to_bytes().to_vec())
-            .bind(
-                "state",
-                current_state.as_ref().map(|state| state.to_string()),
-            )
             .bind("new_state", proofs_state.to_string())
             .execute(&self.inner)
             .await?;

+ 197 - 294
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,
@@ -31,7 +31,7 @@ use cdk_common::nut00::ProofsMethods;
 use cdk_common::payment::PaymentIdentifier;
 use cdk_common::quote_id::QuoteId;
 use cdk_common::secret::Secret;
-use cdk_common::state::{check_melt_quote_state_transition, check_state_transition};
+use cdk_common::state::check_melt_quote_state_transition;
 use cdk_common::util::unix_time;
 use cdk_common::{
     Amount, BlindSignature, BlindSignatureDleq, BlindedMessage, CurrencyUnit, Id, MeltQuoteState,
@@ -80,31 +80,6 @@ where
     inner: ConnectionWithTransaction<RM::Connection, PooledResource<RM>>,
 }
 
-#[inline(always)]
-async fn get_current_states<C>(
-    conn: &C,
-    ys: &[PublicKey],
-) -> Result<HashMap<PublicKey, State>, Error>
-where
-    C: DatabaseExecutor + Send + Sync,
-{
-    if ys.is_empty() {
-        return Ok(Default::default());
-    }
-    query(r#"SELECT y, state FROM proof WHERE y IN (:ys)"#)?
-        .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect())
-        .fetch_all(conn)
-        .await?
-        .into_iter()
-        .map(|row| {
-            Ok((
-                column_as_string!(&row[0], PublicKey::from_hex, PublicKey::from_slice),
-                column_as_string!(&row[1], State::from_str),
-            ))
-        })
-        .collect::<Result<HashMap<_, _>, _>>()
-}
-
 impl<RM> SQLMintDatabase<RM>
 where
     RM: DatabasePool + 'static,
@@ -166,6 +141,8 @@ where
         }?;
 
         for proof in proofs {
+            let y = proof.y()?;
+
             query(
                 r#"
                   INSERT INTO proof
@@ -174,7 +151,7 @@ where
                   (:y, :amount, :keyset_id, :secret, :c, :witness, :state, :quote_id, :created_time, :operation_kind, :operation_id)
                   "#,
             )?
-            .bind("y", proof.y()?.to_bytes().to_vec())
+            .bind("y", y.to_bytes().to_vec())
             .bind("amount", proof.amount.to_i64())
             .bind("keyset_id", proof.keyset_id.to_string())
             .bind("secret", proof.secret.to_string())
@@ -200,7 +177,7 @@ where
         ys: &[PublicKey],
         new_state: State,
     ) -> Result<Vec<Option<State>>, Self::Err> {
-        let mut current_states = get_current_states(&self.inner, ys).await?;
+        let mut current_states = get_current_states(&self.inner, ys, true).await?;
 
         if current_states.len() != ys.len() {
             tracing::warn!(
@@ -211,10 +188,6 @@ where
             return Err(database::Error::ProofNotFound);
         }
 
-        for state in current_states.values() {
-            check_state_transition(*state, new_state)?;
-        }
-
         query(r#"UPDATE proof SET state = :new_state WHERE y IN (:ys)"#)?
             .bind("new_state", new_state.to_string())
             .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect())
@@ -261,7 +234,7 @@ where
 
         if total_deleted != ys.len() {
             // Query current states to provide detailed logging
-            let current_states = get_current_states(&self.inner, ys).await?;
+            let current_states = get_current_states(&self.inner, ys, true).await?;
 
             let missing_count = ys.len() - current_states.len();
             let spent_count = current_states
@@ -315,6 +288,7 @@ where
                 proof
             WHERE
                 quote_id = :quote_id
+            FOR UPDATE
             "#,
         )?
         .bind("quote_id", quote_id.to_string())
@@ -353,6 +327,15 @@ where
         })
         .collect::<Result<Vec<_>, _>>()?)
     }
+
+    async fn get_proofs_states(
+        &mut self,
+        ys: &[PublicKey],
+    ) -> Result<Vec<Option<State>>, Self::Err> {
+        let mut current_states = get_current_states(&self.inner, ys, true).await?;
+
+        Ok(ys.iter().map(|y| current_states.remove(y)).collect())
+    }
 }
 
 #[async_trait]
@@ -391,6 +374,37 @@ where
 }
 
 #[inline(always)]
+async fn get_current_states<C>(
+    conn: &C,
+    ys: &[PublicKey],
+    for_update: bool,
+) -> Result<HashMap<PublicKey, State>, Error>
+where
+    C: DatabaseExecutor + Send + Sync,
+{
+    if ys.is_empty() {
+        return Ok(Default::default());
+    }
+    let for_update_clause = if for_update { "FOR UPDATE" } else { "" };
+
+    query(&format!(
+        r#"SELECT y, state FROM proof WHERE y IN (:ys) {}"#,
+        for_update_clause
+    ))?
+    .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect())
+    .fetch_all(conn)
+    .await?
+    .into_iter()
+    .map(|row| {
+        Ok((
+            column_as_string!(&row[0], PublicKey::from_hex, PublicKey::from_slice),
+            column_as_string!(&row[1], State::from_str),
+        ))
+    })
+    .collect::<Result<HashMap<_, _>, _>>()
+}
+
+#[inline(always)]
 async fn get_mint_quote_payments<C>(
     conn: &C,
     quote_id: &QuoteId,
@@ -642,6 +656,52 @@ where
         .transpose()
 }
 
+#[inline]
+async fn get_melt_quotes_by_request_lookup_id_inner<T>(
+    executor: &T,
+    request_lookup_id: &PaymentIdentifier,
+    for_update: bool,
+) -> Result<Vec<mint::MeltQuote>, Error>
+where
+    T: DatabaseExecutor,
+{
+    let for_update_clause = if for_update { "FOR UPDATE" } else { "" };
+    let query_str = format!(
+        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
+            request_lookup_id = :request_lookup_id
+            AND request_lookup_id_kind = :request_lookup_id_kind
+        {for_update_clause}
+        "#
+    );
+
+    query(&query_str)?
+        .bind("request_lookup_id", request_lookup_id.to_string())
+        .bind("request_lookup_id_kind", request_lookup_id.kind())
+        .fetch_all(executor)
+        .await?
+        .into_iter()
+        .map(sql_row_to_melt_quote)
+        .collect::<Result<Vec<_>, _>>()
+}
+
 #[async_trait]
 impl<RM> MintKeyDatabaseTransaction<'_, Error> for SQLTransaction<RM>
 where
@@ -998,192 +1058,82 @@ where
         Ok(())
     }
 
-    #[instrument(skip(self))]
-    async fn increment_mint_quote_amount_paid(
+    async fn update_mint_quote(
         &mut self,
-        quote_id: &QuoteId,
-        amount_paid: Amount,
-        payment_id: String,
-    ) -> Result<Amount, Self::Err> {
-        if amount_paid == Amount::ZERO {
-            tracing::warn!("Amount payments of zero amount should not be recorded.");
-            return Err(Error::Duplicate);
-        }
-
-        // Check if payment_id already exists in mint_quote_payments
-        let exists = query(
-            r#"
-            SELECT payment_id
-            FROM mint_quote_payments
-            WHERE payment_id = :payment_id
-            FOR UPDATE
-            "#,
-        )?
-        .bind("payment_id", payment_id.clone())
-        .fetch_one(&self.inner)
-        .await?;
-
-        if exists.is_some() {
-            tracing::error!("Payment ID already exists: {}", payment_id);
-            return Err(database::Error::Duplicate);
-        }
-
-        // Get current amount_paid from quote
-        let current_amount = query(
-            r#"
-            SELECT amount_paid
-            FROM mint_quote
-            WHERE id = :quote_id
-            FOR UPDATE
-            "#,
-        )?
-        .bind("quote_id", quote_id.to_string())
-        .fetch_one(&self.inner)
-        .await
-        .inspect_err(|err| {
-            tracing::error!("SQLite could not get mint quote amount_paid: {}", err);
-        })?;
-
-        let current_amount_paid = if let Some(current_amount) = current_amount {
-            let amount: u64 = column_as_number!(current_amount[0].clone());
-            Amount::from(amount)
+        quote: &mut Acquired<mint::MintQuote>,
+    ) -> Result<(), Self::Err> {
+        let mut changes = if let Some(changes) = quote.take_changes() {
+            changes
         } else {
-            Amount::ZERO
+            return Ok(());
         };
 
-        // Calculate new amount_paid with overflow check
-        let new_amount_paid = current_amount_paid
-            .checked_add(amount_paid)
-            .ok_or_else(|| database::Error::AmountOverflow)?;
-
-        tracing::debug!(
-            "Mint quote {} amount paid was {} is now {}.",
-            quote_id,
-            current_amount_paid,
-            new_amount_paid
-        );
-
-        // Update the amount_paid
-        query(
-            r#"
-            UPDATE mint_quote
-            SET amount_paid = :amount_paid
-            WHERE id = :quote_id
-            "#,
-        )?
-        .bind("amount_paid", new_amount_paid.to_i64())
-        .bind("quote_id", quote_id.to_string())
-        .execute(&self.inner)
-        .await
-        .inspect_err(|err| {
-            tracing::error!("SQLite could not update mint quote amount_paid: {}", err);
-        })?;
-
-        // Add payment_id to mint_quote_payments table
-        query(
-            r#"
-            INSERT INTO mint_quote_payments
-            (quote_id, payment_id, amount, timestamp)
-            VALUES (:quote_id, :payment_id, :amount, :timestamp)
-            "#,
-        )?
-        .bind("quote_id", quote_id.to_string())
-        .bind("payment_id", payment_id)
-        .bind("amount", amount_paid.to_i64())
-        .bind("timestamp", unix_time() as i64)
-        .execute(&self.inner)
-        .await
-        .map_err(|err| {
-            tracing::error!("SQLite could not insert payment ID: {}", err);
-            err
-        })?;
-
-        Ok(new_amount_paid)
-    }
-
-    #[instrument(skip_all)]
-    async fn increment_mint_quote_amount_issued(
-        &mut self,
-        quote_id: &QuoteId,
-        amount_issued: Amount,
-    ) -> Result<Amount, Self::Err> {
-        // Get current amount_issued from quote
-        let current_amounts = query(
-            r#"
-            SELECT amount_issued, amount_paid
-            FROM mint_quote
-            WHERE id = :quote_id
-            FOR UPDATE
-            "#,
-        )?
-        .bind("quote_id", quote_id.to_string())
-        .fetch_one(&self.inner)
-        .await
-        .inspect_err(|err| {
-            tracing::error!("SQLite could not get mint quote amount_issued: {}", err);
-        })?
-        .ok_or(Error::QuoteNotFound)?;
-
-        let new_amount_issued = {
-            // Make sure the db protects issuing not paid quotes
-            unpack_into!(
-                let (current_amount_issued, current_amount_paid) = current_amounts
-            );
-
-            let current_amount_issued: u64 = column_as_number!(current_amount_issued);
-            let current_amount_paid: u64 = column_as_number!(current_amount_paid);
-
-            let current_amount_issued = Amount::from(current_amount_issued);
-            let current_amount_paid = Amount::from(current_amount_paid);
+        if changes.issuances.is_none() && changes.payments.is_none() {
+            return Ok(());
+        }
 
-            // Calculate new amount_issued with overflow check
-            let new_amount_issued = current_amount_issued
-                .checked_add(amount_issued)
-                .ok_or_else(|| database::Error::AmountOverflow)?;
+        for payment in changes.payments.take().unwrap_or_default() {
+            query(
+                r#"
+                INSERT INTO mint_quote_payments
+                (quote_id, payment_id, amount, timestamp)
+                VALUES (:quote_id, :payment_id, :amount, :timestamp)
+                "#,
+            )?
+            .bind("quote_id", quote.id.to_string())
+            .bind("payment_id", payment.payment_id)
+            .bind("amount", payment.amount.to_i64())
+            .bind("timestamp", payment.time as i64)
+            .execute(&self.inner)
+            .await
+            .map_err(|err| {
+                tracing::error!("SQLite could not insert payment ID: {}", err);
+                err
+            })?;
+        }
 
-            current_amount_paid
-                .checked_sub(new_amount_issued)
-                .ok_or(Error::Internal("Over-issued not allowed".to_owned()))?;
+        let current_time = unix_time();
 
-            new_amount_issued
-        };
+        for amount_issued in changes.issuances.take().unwrap_or_default() {
+            query(
+                r#"
+                INSERT INTO mint_quote_issued
+                (quote_id, amount, timestamp)
+                VALUES (:quote_id, :amount, :timestamp);
+                "#,
+            )?
+            .bind("quote_id", quote.id.to_string())
+            .bind("amount", amount_issued.to_i64())
+            .bind("timestamp", current_time as i64)
+            .execute(&self.inner)
+            .await?;
+        }
 
-        // Update the amount_issued
         query(
             r#"
-            UPDATE mint_quote
-            SET amount_issued = :amount_issued
-            WHERE id = :quote_id
+            UPDATE
+                mint_quote
+            SET
+                amount_issued = :amount_issued,
+                amount_paid = :amount_paid
+            WHERE
+                id = :quote_id
             "#,
         )?
-        .bind("amount_issued", new_amount_issued.to_i64())
-        .bind("quote_id", quote_id.to_string())
+        .bind("quote_id", quote.id.to_string())
+        .bind("amount_issued", quote.amount_issued().to_i64())
+        .bind("amount_paid", quote.amount_paid().to_i64())
         .execute(&self.inner)
         .await
         .inspect_err(|err| {
-            tracing::error!("SQLite could not update mint quote amount_issued: {}", err);
+            tracing::error!("SQLite could not update mint quote amount_paid: {}", err);
         })?;
 
-        let current_time = unix_time();
-
-        query(
-            r#"
-INSERT INTO mint_quote_issued
-(quote_id, amount, timestamp)
-VALUES (:quote_id, :amount, :timestamp);
-            "#,
-        )?
-        .bind("quote_id", quote_id.to_string())
-        .bind("amount", amount_issued.to_i64())
-        .bind("timestamp", current_time as i64)
-        .execute(&self.inner)
-        .await?;
-
-        Ok(new_amount_issued)
+        Ok(())
     }
 
     #[instrument(skip_all)]
-    async fn add_mint_quote(&mut self, quote: MintQuote) -> Result<(), Self::Err> {
+    async fn add_mint_quote(&mut self, quote: MintQuote) -> Result<Acquired<MintQuote>, Self::Err> {
         query(
             r#"
                 INSERT INTO mint_quote (
@@ -1197,7 +1147,7 @@ VALUES (:quote_id, :amount, :timestamp);
         .bind("id", quote.id.to_string())
         .bind("amount", quote.amount.map(|a| a.to_i64()))
         .bind("unit", quote.unit.to_string())
-        .bind("request", quote.request)
+        .bind("request", quote.request.clone())
         .bind("expiry", quote.expiry as i64)
         .bind(
             "request_lookup_id",
@@ -1210,7 +1160,7 @@ VALUES (:quote_id, :amount, :timestamp);
         .execute(&self.inner)
         .await?;
 
-        Ok(())
+        Ok(quote.into())
     }
 
     async fn add_melt_quote(&mut self, quote: mint::MeltQuote) -> Result<(), Self::Err> {
@@ -1262,110 +1212,44 @@ VALUES (:quote_id, :amount, :timestamp);
 
     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)?;
-
-        check_melt_quote_state_transition(quote.state, state)?;
+    ) -> Result<MeltQuoteState, Self::Err> {
+        let old_state = quote.state;
 
-        // When transitioning to Pending, lock all quotes with the same lookup_id
-        // and check if any are already pending or paid
-        if state == MeltQuoteState::Pending {
-            if let Some(ref lookup_id) = quote.request_lookup_id {
-                // Lock all quotes with the same lookup_id to prevent race conditions
-                let locked_quotes: Vec<(String, String)> = query(
-                    r#"
-                    SELECT id, state
-                    FROM melt_quote
-                    WHERE request_lookup_id = :lookup_id
-                    FOR UPDATE
-                    "#,
-                )?
-                .bind("lookup_id", lookup_id.to_string())
-                .fetch_all(&self.inner)
-                .await?
-                .into_iter()
-                .map(|row| {
-                    unpack_into!(let (id, state) = row);
-                    Ok((column_as_string!(id), column_as_string!(state)))
-                })
-                .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())
-                });
-
-                if has_conflict {
-                    tracing::warn!(
-                        "Cannot transition quote {} to Pending: another quote with lookup_id {} is already pending or paid",
-                        quote_id,
-                        lookup_id
-                    );
-                    return Err(Error::Duplicate);
-                }
-            }
-        }
+        check_melt_quote_state_transition(old_state, state)?;
 
         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
         };
@@ -1378,39 +1262,58 @@ 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> {
-        get_mint_quote_inner(&self.inner, quote_id, true).await
+    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
+            .map(|quote| quote.map(|inner| inner.into()))
     }
 
     async fn get_melt_quote(
         &mut self,
         quote_id: &QuoteId,
-    ) -> Result<Option<mint::MeltQuote>, Self::Err> {
-        get_melt_quote_inner(&self.inner, quote_id, true).await
+    ) -> Result<Option<Acquired<mint::MeltQuote>>, Self::Err> {
+        get_melt_quote_inner(&self.inner, quote_id, true)
+            .await
+            .map(|quote| quote.map(|inner| inner.into()))
+    }
+
+    async fn get_melt_quotes_by_request_lookup_id(
+        &mut self,
+        request_lookup_id: &PaymentIdentifier,
+    ) -> Result<Vec<Acquired<mint::MeltQuote>>, Self::Err> {
+        get_melt_quotes_by_request_lookup_id_inner(&self.inner, request_lookup_id, true)
+            .await
+            .map(|quote| quote.into_iter().map(|inner| inner.into()).collect())
     }
 
     async fn get_mint_quote_by_request(
         &mut self,
         request: &str,
-    ) -> Result<Option<MintQuote>, Self::Err> {
-        get_mint_quote_by_request_inner(&self.inner, request, true).await
+    ) -> Result<Option<Acquired<MintQuote>>, Self::Err> {
+        get_mint_quote_by_request_inner(&self.inner, request, true)
+            .await
+            .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> {
-        get_mint_quote_by_request_lookup_id_inner(&self.inner, request_lookup_id, true).await
+    ) -> Result<Option<Acquired<MintQuote>>, Self::Err> {
+        get_mint_quote_by_request_lookup_id_inner(&self.inner, request_lookup_id, true)
+            .await
+            .map(|quote| quote.map(|inner| inner.into()))
     }
 }
 
@@ -1633,7 +1536,7 @@ where
 
     async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result<Vec<Option<State>>, Self::Err> {
         let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
-        let mut current_states = get_current_states(&*conn, ys).await?;
+        let mut current_states = get_current_states(&*conn, ys, false).await?;
 
         Ok(ys.iter().map(|y| current_states.remove(y)).collect())
     }

+ 8 - 8
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,9 +675,8 @@ impl Mint {
             .await?;
 
 
-        let total_issued = tx
-            .increment_mint_quote_amount_issued(&mint_request.quote, amount_issued)
-            .await?;
+        mint_quote.add_issuance(amount_issued)?;
+        tx.update_mint_quote(&mut mint_quote).await?;
 
 
         // Mint operations have no input fees (no proofs being spent)
@@ -686,7 +686,7 @@ impl Mint {
         tx.commit().await?;
 
         self.pubsub_manager
-            .mint_quote_issue(&mint_quote, total_issued);
+            .mint_quote_issue(&mint_quote, mint_quote.amount_issued());
 
         Ok(MintResponse {
             signatures: blind_signatures,

+ 16 - 23
crates/cdk/src/mint/ln.rs

@@ -6,8 +6,7 @@ use cdk_common::common::PaymentProcessorKey;
 use cdk_common::database::DynMintDatabase;
 use cdk_common::mint::MintQuote;
 use cdk_common::payment::DynMintPayment;
-use cdk_common::util::unix_time;
-use cdk_common::{database, Amount, MintQuoteState, PaymentMethod};
+use cdk_common::{Amount, MintQuoteState, PaymentMethod};
 use tracing::instrument;
 
 use super::subscription::PubSubManager;
@@ -57,61 +56,55 @@ 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)?;
-
-                match tx
-                    .increment_mint_quote_amount_paid(
-                        &quote.id,
-                        amount_paid,
-                        payment.payment_id.clone(),
-                    )
-                    .await
-                {
-                    Ok(total_paid) => {
-                        quote.increment_amount_paid(amount_paid)?;
-                        quote.add_payment(amount_paid, payment.payment_id.clone(), unix_time())?;
+                let amount_paid = to_unit(payment.payment_amount, &payment.unit, &new_quote.unit)?;
+
+                match new_quote.add_payment(amount_paid, payment.payment_id.clone(), None) {
+                    Ok(()) => {
+                        tx.update_mint_quote(&mut new_quote).await?;
                         if let Some(pubsub_manager) = pubsub_manager.as_ref() {
-                            pubsub_manager.mint_quote_payment(quote, total_paid);
+                            pubsub_manager.mint_quote_payment(&new_quote, new_quote.amount_paid());
                         }
                     }
-                    Err(database::Error::Duplicate) => {
+                    Err(crate::Error::DuplicatePaymentId) => {
                         tracing::debug!(
                             "Payment ID {} already processed (caught race condition in check_mint_quote_paid)",
                             payment.payment_id
                         );
                         // This is fine - another concurrent request already processed this payment
                     }
-                    Err(e) => return Err(e.into()),
+                    Err(e) => return Err(e),
                 }
             }
         }
 
         tx.commit().await?;
 
+        *quote = new_quote.inner();
+
         Ok(())
     }
 

+ 51 - 25
crates/cdk/src/mint/melt/melt_saga/mod.rs

@@ -6,6 +6,7 @@ use cdk_common::database::mint::MeltRequestInfo;
 use cdk_common::database::DynMintDatabase;
 use cdk_common::mint::{MeltSagaState, Operation, Saga, SagaStateEnum};
 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;
@@ -15,6 +16,7 @@ use tracing::instrument;
 use self::compensation::{CompensatingAction, RemoveMeltSetup};
 use self::state::{Initial, PaymentConfirmed, SettlementDecision, SetupComplete};
 use crate::cdk_payment::MakePaymentResponse;
+use crate::mint::melt::shared;
 use crate::mint::subscription::PubSubManager;
 use crate::mint::verification::Verification;
 use crate::mint::{MeltQuoteBolt11Response, MeltRequest};
@@ -201,6 +203,15 @@ impl MeltSaga<Initial> {
 
         let mut tx = self.db.begin_transaction().await?;
 
+        let mut quote =
+            match shared::load_melt_quotes_exclusively(&mut tx, melt_request.quote()).await {
+                Ok(quote) => quote,
+                Err(err) => {
+                    tx.rollback().await?;
+                    return Err(err);
+                }
+            };
+
         // Calculate fee to create Operation with actual amounts
         let fee_breakdown = self.mint.get_proofs_fee(melt_request.inputs()).await?;
 
@@ -236,6 +247,17 @@ impl MeltSaga<Initial> {
 
         let input_ys = melt_request.inputs().ys()?;
 
+        for current_state in tx
+            .get_proofs_states(&input_ys)
+            .await?
+            .into_iter()
+            .collect::<Option<Vec<_>>>()
+            .ok_or(Error::UnexpectedProofState)?
+        {
+            check_state_transition(current_state, State::Pending)
+                .map_err(|_| Error::UnexpectedProofState)?;
+        }
+
         // Update proof states to Pending
         let original_states = match tx.update_proofs_states(&input_ys, State::Pending).await {
             Ok(states) => states,
@@ -269,17 +291,7 @@ 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,
-            Err(err) => {
-                tx.rollback().await?;
-                return Err(err.into());
-            }
-        };
+        let previous_state = quote.state;
 
         // Publish proof state changes
         for pk in input_ys.iter() {
@@ -291,7 +303,7 @@ impl MeltSaga<Initial> {
             return Err(Error::UnitMismatch);
         }
 
-        match state {
+        match previous_state {
             MeltQuoteState::Unpaid | MeltQuoteState::Failed => {}
             MeltQuoteState::Pending => {
                 tx.rollback().await?;
@@ -307,8 +319,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?;
 
@@ -404,6 +428,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,
@@ -413,7 +439,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,
@@ -465,7 +491,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
         {
@@ -527,15 +553,11 @@ impl MeltSaga<SetupComplete> {
         )
         .await?;
 
-        let total_paid = tx
-            .increment_mint_quote_amount_paid(
-                &mint_quote.id,
-                amount,
-                self.state_data.quote.id.to_string(),
-            )
-            .await?;
+        mint_quote.add_payment(amount, self.state_data.quote.id.to_string(), None)?;
+        tx.update_mint_quote(&mut mint_quote).await?;
 
-        self.pubsub.mint_quote_payment(&mint_quote, total_paid);
+        self.pubsub
+            .mint_quote_payment(&mint_quote, mint_quote.amount_paid());
 
         tracing::info!(
             "Melt quote {} paid Mint quote {}",
@@ -862,7 +884,11 @@ 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 =
+            shared::load_melt_quotes_exclusively(&mut tx, &self.state_data.quote.id).await?;
+
+        // Get melt request info (needed for validation and change)
         let MeltRequestInfo {
             inputs_amount,
             inputs_fee,
@@ -876,7 +902,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,

+ 2 - 0
crates/cdk/src/mint/melt/melt_saga/tests.rs

@@ -2755,6 +2755,7 @@ async fn test_duplicate_lookup_id_prevents_second_pending() {
         .await
         .unwrap()
         .expect("Quote 1 should exist");
+
     let quote2 = mint
         .localstore
         .get_melt_quote(&quote_response2.quote)
@@ -2846,6 +2847,7 @@ async fn test_duplicate_lookup_id_prevents_second_pending() {
         .await
         .unwrap()
         .unwrap();
+
     assert_eq!(
         still_unpaid_quote2.state,
         MeltQuoteState::Unpaid,

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

@@ -6,8 +6,9 @@
 //!
 //! 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};
 use cdk_signatory::signatory::SignatoryKeySet;
 
@@ -102,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
@@ -241,6 +244,79 @@ pub async fn process_melt_change(
     Ok((Some(change_sigs), tx))
 }
 
+/// Loads a melt quote and acquires exclusive locks on all related quotes.
+///
+/// This function combines quote loading with defensive locking to prevent race conditions in BOLT12
+/// scenarios where multiple melt quotes can share the same `request_lookup_id`. It performs three
+/// operations atomically:
+///
+/// 1. Loads the melt quote by ID 2. Acquires row-level locks on all sibling quotes sharing the same
+///    lookup identifier 3. Validates that no sibling quote is already in `Pending` or `Paid` state
+///
+/// This ensures that when a melt operation begins, no other concurrent melt can proceed on a
+/// related quote, preventing double-spending and state inconsistencies.
+///
+/// # Arguments
+///
+/// * `tx` - The active database transaction used to load and acquire locks. * `quote_id` - The ID
+///   of the melt quote to load and process.
+///
+/// # Returns
+///
+/// The loaded and locked melt quote, ready for state transitions.
+///
+/// # Errors
+///
+/// * [`Error::UnknownQuote`] if no quote exists with the given ID. * [`Error::Database(Duplicate)`]
+///   if another quote with the same lookup ID is already pending or paid, indicating a conflicting
+///   concurrent melt operation.
+pub async fn load_melt_quotes_exclusively(
+    tx: &mut Box<dyn database::MintTransaction<database::Error> + Send + Sync>,
+    quote_id: &QuoteId,
+) -> Result<Acquired<MeltQuote>, Error> {
+    let quote = tx
+        .get_melt_quote(quote_id)
+        .await
+        .map_err(|e| match e {
+            database::Error::Locked => {
+                tracing::warn!("Quote {quote_id} is locked by another process");
+                database::Error::Duplicate
+            }
+            e => e,
+        })?
+        .ok_or(Error::UnknownQuote)?;
+
+    // Lock any other quotes so they cannot be modified
+    let locked_quotes = if let Some(request_lookup_id) = quote.request_lookup_id.as_ref() {
+        tx.get_melt_quotes_by_request_lookup_id(request_lookup_id)
+            .await
+            .map_err(|e| match e {
+                database::Error::Locked => {
+                    tracing::warn!("Quotes with request_lookyup_id {request_lookup_id} is locked by another process");
+                    database::Error::Duplicate
+                }
+                e => e,
+            })?
+    } else {
+        vec![]
+    };
+
+    if locked_quotes.iter().any(|locked_quote| {
+        locked_quote.id != quote.id
+            && (locked_quote.state == MeltQuoteState::Pending
+                || locked_quote.state == MeltQuoteState::Paid)
+    }) {
+        tracing::warn!(
+            "Cannot transition quote {} to Pending: another quote with lookup_id {:?} is already pending or paid",
+            quote.id,
+            quote.request_lookup_id,
+        );
+        return Err(Error::Database(crate::cdk_database::Error::Duplicate));
+    }
+
+    Ok(quote)
+}
+
 /// Finalizes a melt quote by updating proofs, quote state, and publishing changes.
 ///
 /// This function performs the core finalization operations that are common to both
@@ -283,7 +359,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,
@@ -309,7 +385,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
@@ -320,10 +396,21 @@ 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?;
     }
 
+    for current_state in tx
+        .get_proofs_states(input_ys)
+        .await?
+        .into_iter()
+        .collect::<Option<Vec<_>>>()
+        .ok_or(Error::UnexpectedProofState)?
+    {
+        check_state_transition(current_state, State::Spent)
+            .map_err(|_| Error::UnexpectedProofState)?;
+    }
+
     // Mark input proofs as spent
     match tx.update_proofs_states(input_ys, State::Spent).await {
         Ok(_) => {}
@@ -385,6 +472,10 @@ 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 = load_melt_quotes_exclusively(&mut tx, &quote.id).await?;
+
     // Get melt request info
     let melt_request_info = match tx.get_melt_request_and_blinded_messages(&quote.id).await? {
         Some(info) => info,
@@ -414,7 +505,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,

+ 14 - 16
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> {
@@ -749,25 +749,23 @@ impl Mint {
                     payment_amount_quote_unit
                 );
 
-                match tx
-                    .increment_mint_quote_amount_paid(
-                        &mint_quote.id,
-                        payment_amount_quote_unit,
-                        wait_payment_response.payment_id.clone(),
-                    )
-                    .await
-                {
-                    Ok(total_paid) => {
-                        pubsub_manager.mint_quote_payment(mint_quote, total_paid);
+                match mint_quote.add_payment(
+                    payment_amount_quote_unit,
+                    wait_payment_response.payment_id.clone(),
+                    None,
+                ) {
+                    Ok(()) => {
+                        tx.update_mint_quote(mint_quote).await?;
+                        pubsub_manager.mint_quote_payment(mint_quote, mint_quote.amount_paid());
                     }
-                    Err(database::Error::Duplicate) => {
+                    Err(Error::DuplicatePaymentId) => {
                         tracing::info!(
                             "Payment ID {} already processed (caught race condition)",
                             wait_payment_response.payment_id
                         );
                         // This is fine - another concurrent request already processed this payment
                     }
-                    Err(e) => return Err(e.into()),
+                    Err(e) => return Err(e),
                 }
             }
         } else {

+ 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!(

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

@@ -4,6 +4,7 @@ use std::sync::Arc;
 use cdk_common::database::DynMintDatabase;
 use cdk_common::mint::{Operation, Saga, SwapSagaState};
 use cdk_common::nuts::BlindedMessage;
+use cdk_common::state::check_state_transition;
 use cdk_common::{database, Amount, Error, Proofs, ProofsMethods, PublicKey, QuoteId, State};
 use tokio::sync::Mutex;
 use tracing::instrument;
@@ -428,6 +429,17 @@ impl SwapSaga<'_, Signed> {
             }
         }
 
+        for current_state in tx
+            .get_proofs_states(&self.state_data.ys)
+            .await?
+            .into_iter()
+            .collect::<Option<Vec<_>>>()
+            .ok_or(Error::UnexpectedProofState)?
+        {
+            check_state_transition(current_state, State::Spent)
+                .map_err(|_| Error::UnexpectedProofState)?;
+        }
+
         match tx
             .update_proofs_states(&self.state_data.ys, State::Spent)
             .await