Sfoglia il codice sorgente

Merge branch 'feature/ffi-python-test' into feature/wallet-db-transactions

Cesar Rodas 2 mesi fa
parent
commit
007dc56057

+ 4 - 0
.cargo/mutants.toml

@@ -36,4 +36,8 @@ exclude_re = [
 
     # amount.rs:331 - Sub returning Default/0 causes infinite loops
     "crates/cashu/src/amount.rs:331:.*replace.*sub.*with Default",
+
+    # Trivial getters - not worth testing
+    "FeeAndAmounts::fee",
+    "FeeAndAmounts::amounts",
 ]

+ 17 - 57
.github/workflows/ci.yml

@@ -6,7 +6,7 @@ on:
   pull_request:
     branches:
       - main
-      - 'v[0-9]*.[0-9]*.x'  # Match version branches like v0.13.x, v1.0.x, etc.
+      - "v[0-9]*.[0-9]*.x" # Match version branches like v0.13.x, v1.0.x, etc.
   release:
     types: [created]
 
@@ -48,14 +48,7 @@ jobs:
     strategy:
       fail-fast: true
       matrix:
-        build-args:
-          [
-            mint-token,
-            melt-token,
-            p2pk,
-            proof-selection,
-            wallet
-          ]
+        build-args: [mint-token, melt-token, p2pk, proof-selection, wallet]
     steps:
       - name: checkout
         uses: actions/checkout@v4
@@ -84,8 +77,7 @@ jobs:
     strategy:
       fail-fast: true
       matrix:
-        build-args:
-          [
+        build-args: [
             # Core crate testing
             -p cashu,
             -p cashu --no-default-features,
@@ -105,18 +97,18 @@ jobs:
             -p cdk-sql-common,
             -p cdk-sql-common --no-default-features --features wallet,
             -p cdk-sql-common --no-default-features --features mint,
-            
+
             # Database and infrastructure crates
             -p cdk-redb,
             -p cdk-sqlite,
             -p cdk-sqlite --features sqlcipher,
-            
+
             # HTTP/API layer - consolidated
             -p cdk-axum,
             -p cdk-axum --no-default-features,
             -p cdk-axum --no-default-features --features redis,
             -p cdk-axum --no-default-features --features "redis swagger",
-            
+
             # Lightning backends
             -p cdk-cln,
             -p cdk-lnd,
@@ -124,12 +116,12 @@ jobs:
             -p cdk-fake-wallet,
             -p cdk-payment-processor,
             -p cdk-ldk-node,
-            
+
             -p cdk-signatory,
             -p cdk-mint-rpc,
 
             -p cdk-prometheus,
-            
+
             # FFI bindings
             -p cdk-ffi,
             -p cdk-ffi --no-default-features,
@@ -187,15 +179,8 @@ jobs:
     strategy:
       fail-fast: true
       matrix:
-        build-args:
-          [
-            -p cdk-integration-tests,
-          ]
-        database:
-          [
-            SQLITE,
-            POSTGRES
-          ]
+        build-args: [-p cdk-integration-tests]
+        database: [SQLITE, POSTGRES]
     steps:
       - name: checkout
         uses: actions/checkout@v4
@@ -234,14 +219,8 @@ jobs:
     strategy:
       fail-fast: true
       matrix:
-        build-args:
-          [
-            -p cdk-integration-tests,
-          ]
-        database:
-          [
-          SQLITE,
-          ]
+        build-args: [-p cdk-integration-tests]
+        database: [SQLITE]
     steps:
       - name: checkout
         uses: actions/checkout@v4
@@ -282,12 +261,7 @@ jobs:
     strategy:
       fail-fast: true
       matrix:
-        database:
-          [
-          memory,
-          sqlite,
-          redb
-          ]
+        database: [memory, sqlite, redb]
     steps:
       - name: checkout
         uses: actions/checkout@v4
@@ -322,7 +296,6 @@ jobs:
       - name: Test mint
         run: nix develop -i -L .#stable --command just test
 
-
   payment-processor-itests:
     name: "Payment processor tests"
     runs-on: ubuntu-latest
@@ -331,12 +304,7 @@ jobs:
     strategy:
       fail-fast: true
       matrix:
-        ln:
-          [
-          FAKEWALLET,
-          CLN,
-          LND
-          ]
+        ln: [FAKEWALLET, CLN, LND]
     steps:
       - name: checkout
         uses: actions/checkout@v4
@@ -375,8 +343,7 @@ jobs:
     strategy:
       fail-fast: true
       matrix:
-        build-args:
-          [
+        build-args: [
             # Core library - all features EXCEPT swagger (which breaks MSRV)
             '-p cdk --features "mint,wallet,auth,nostr,bip353,tor,prometheus"',
 
@@ -456,10 +423,7 @@ jobs:
     strategy:
       fail-fast: true
       matrix:
-        database:
-          [
-          SQLITE,
-          ]
+        database: [SQLITE]
     steps:
       - name: checkout
         uses: actions/checkout@v4
@@ -560,9 +524,5 @@ jobs:
         uses: Swatinem/rust-cache@v2
         with:
           shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
-      - name: Setup Python
-        uses: actions/setup-python@v5
-        with:
-          python-version: '3.11'
       - name: Run FFI tests
-        run: nix develop -i -L .#stable --command just ffi-test
+        run: nix develop -i -L .#integration --command just ffi-test

+ 84 - 0
crates/cashu/src/amount.rs

@@ -1098,4 +1098,88 @@ mod tests {
         let result = amount.convert_unit(&CurrencyUnit::Sat, &CurrencyUnit::Eur);
         assert!(result.is_err());
     }
+
+    /// Tests that Amount::to_i64() returns the correct value.
+    ///
+    /// Mutant testing: Kills mutations that replace the return value with:
+    /// - None
+    /// - Some(0)
+    /// - Some(1)
+    /// - Some(-1)
+    /// Also catches mutation that replaces <= with > in the comparison.
+    #[test]
+    fn test_amount_to_i64_returns_correct_value() {
+        // Test with value 100 (catches None, Some(0), Some(1), Some(-1) mutations)
+        let amount = Amount::from(100);
+        let result = amount.to_i64();
+        assert_eq!(result, Some(100));
+        assert!(result.is_some());
+        assert_ne!(result, Some(0));
+        assert_ne!(result, Some(1));
+        assert_ne!(result, Some(-1));
+
+        // Test with value 1000 (catches all constant mutations)
+        let amount = Amount::from(1000);
+        let result = amount.to_i64();
+        assert_eq!(result, Some(1000));
+        assert_ne!(result, None);
+        assert_ne!(result, Some(0));
+        assert_ne!(result, Some(1));
+        assert_ne!(result, Some(-1));
+
+        // Test with value 2 (specifically catches Some(1) mutation)
+        let amount = Amount::from(2);
+        let result = amount.to_i64();
+        assert_eq!(result, Some(2));
+        assert_ne!(result, Some(1));
+
+        // Test with i64::MAX (should return Some(i64::MAX))
+        // This catches the <= vs > mutation: if <= becomes >, this would return None
+        let amount = Amount::from(i64::MAX as u64);
+        let result = amount.to_i64();
+        assert_eq!(result, Some(i64::MAX));
+        assert!(result.is_some());
+
+        // Test with i64::MAX + 1 (should return None)
+        // This is the boundary case for the <= comparison
+        let amount = Amount::from(i64::MAX as u64 + 1);
+        let result = amount.to_i64();
+        assert!(result.is_none());
+
+        // Test with u64::MAX (should return None)
+        let amount = Amount::from(u64::MAX);
+        let result = amount.to_i64();
+        assert!(result.is_none());
+
+        // Edge case: 0 should return Some(0)
+        let amount = Amount::from(0);
+        let result = amount.to_i64();
+        assert_eq!(result, Some(0));
+
+        // Edge case: 1 should return Some(1)
+        let amount = Amount::from(1);
+        let result = amount.to_i64();
+        assert_eq!(result, Some(1));
+    }
+
+    /// Tests the boundary condition for Amount::to_i64() at i64::MAX.
+    ///
+    /// This specifically tests the <= vs > mutation in the condition
+    /// `if self.0 <= i64::MAX as u64`.
+    #[test]
+    fn test_amount_to_i64_boundary() {
+        // Exactly at i64::MAX - should succeed
+        let at_max = Amount::from(i64::MAX as u64);
+        assert!(at_max.to_i64().is_some());
+        assert_eq!(at_max.to_i64().unwrap(), i64::MAX);
+
+        // One above i64::MAX - should fail
+        let above_max = Amount::from(i64::MAX as u64 + 1);
+        assert!(above_max.to_i64().is_none());
+
+        // One below i64::MAX - should succeed
+        let below_max = Amount::from(i64::MAX as u64 - 1);
+        assert!(below_max.to_i64().is_some());
+        assert_eq!(below_max.to_i64().unwrap(), i64::MAX - 1);
+    }
 }

+ 33 - 0
crates/cdk-ffi/src/multi_mint_wallet.rs

@@ -442,6 +442,39 @@ impl MultiMintWallet {
         Ok(melted.into())
     }
 
+    /// Melt specific proofs from a specific mint
+    ///
+    /// This method allows melting proofs that may not be in the wallet's database,
+    /// similar to how `receive_proofs` handles external proofs. The proofs will be
+    /// added to the database and used for the melt operation.
+    ///
+    /// # Arguments
+    ///
+    /// * `mint_url` - The mint to use for the melt operation
+    /// * `quote_id` - The melt quote ID (obtained from `melt_quote`)
+    /// * `proofs` - The proofs to melt (can be external proofs not in the wallet's database)
+    ///
+    /// # Returns
+    ///
+    /// A `Melted` result containing the payment details and any change proofs
+    pub async fn melt_proofs(
+        &self,
+        mint_url: MintUrl,
+        quote_id: String,
+        proofs: Proofs,
+    ) -> Result<Melted, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
+            proofs.into_iter().map(|p| p.try_into()).collect();
+        let cdk_proofs = cdk_proofs?;
+
+        let melted = self
+            .inner
+            .melt_proofs(&cdk_mint_url, &quote_id, cdk_proofs)
+            .await?;
+        Ok(melted.into())
+    }
+
     /// Check melt quote status
     pub async fn check_melt_quote(
         &self,

+ 23 - 0
crates/cdk-ffi/src/wallet.rs

@@ -225,6 +225,29 @@ impl Wallet {
         Ok(melted.into())
     }
 
+    /// Melt specific proofs
+    ///
+    /// This method allows melting proofs that may not be in the wallet's database,
+    /// similar to how `receive_proofs` handles external proofs. The proofs will be
+    /// added to the database and used for the melt operation.
+    ///
+    /// # Arguments
+    ///
+    /// * `quote_id` - The melt quote ID (obtained from `melt_quote`)
+    /// * `proofs` - The proofs to melt (can be external proofs not in the wallet's database)
+    ///
+    /// # Returns
+    ///
+    /// A `Melted` result containing the payment details and any change proofs
+    pub async fn melt_proofs(&self, quote_id: String, proofs: Proofs) -> Result<Melted, FfiError> {
+        let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
+            proofs.into_iter().map(|p| p.try_into()).collect();
+        let cdk_proofs = cdk_proofs?;
+
+        let melted = self.inner.melt_proofs(&quote_id, cdk_proofs).await?;
+        Ok(melted.into())
+    }
+
     /// Get a quote for a bolt12 mint
     pub async fn mint_bolt12_quote(
         &self,

+ 193 - 0
crates/cdk-integration-tests/tests/fake_wallet.rs

@@ -1462,6 +1462,110 @@ async fn test_wallet_proof_recovery_after_failed_melt() {
     );
 }
 
+/// Tests that concurrent melt attempts for the same invoice result in exactly one success
+///
+/// This test verifies the race condition protection: when multiple melt quotes exist for the
+/// same invoice and all are attempted concurrently, only one should succeed due to
+/// the FOR UPDATE locking on quotes with the same request_lookup_id.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn test_concurrent_melt_same_invoice() {
+    const NUM_WALLETS: usize = 4;
+
+    // Create multiple wallets to simulate concurrent requests
+    let mut wallets = Vec::with_capacity(NUM_WALLETS);
+    for i in 0..NUM_WALLETS {
+        let wallet = Arc::new(
+            Wallet::new(
+                MINT_URL,
+                CurrencyUnit::Sat,
+                Arc::new(memory::empty().await.unwrap()),
+                Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+                None,
+            )
+            .expect(&format!("failed to create wallet {}", i)),
+        );
+        wallets.push(wallet);
+    }
+
+    // Mint proofs for all wallets
+    for (i, wallet) in wallets.iter().enumerate() {
+        let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+        let mut proof_streams =
+            wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+        proof_streams
+            .next()
+            .await
+            .expect(&format!("payment for wallet {}", i))
+            .expect("no error");
+    }
+
+    // Create a single invoice that all wallets will try to pay
+    let fake_description = FakeInvoiceDescription::default();
+    let invoice = create_fake_invoice(9000, serde_json::to_string(&fake_description).unwrap());
+
+    // All wallets create melt quotes for the same invoice
+    let mut melt_quotes = Vec::with_capacity(NUM_WALLETS);
+    for wallet in &wallets {
+        let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+        melt_quotes.push(melt_quote);
+    }
+
+    // Verify all quotes have the same request (same invoice = same lookup_id)
+    for quote in &melt_quotes[1..] {
+        assert_eq!(
+            melt_quotes[0].request, quote.request,
+            "All quotes should be for the same invoice"
+        );
+    }
+
+    // Attempt all melts concurrently
+    let mut handles = Vec::with_capacity(NUM_WALLETS);
+    for (wallet, quote) in wallets.iter().zip(melt_quotes.iter()) {
+        let wallet_clone = Arc::clone(wallet);
+        let quote_id = quote.id.clone();
+        handles.push(tokio::spawn(
+            async move { wallet_clone.melt(&quote_id).await },
+        ));
+    }
+
+    // Collect results
+    let mut results = Vec::with_capacity(NUM_WALLETS);
+    for handle in handles {
+        results.push(handle.await.expect("task panicked"));
+    }
+
+    // Count successes and failures
+    let success_count = results.iter().filter(|r| r.is_ok()).count();
+    let failure_count = results.iter().filter(|r| r.is_err()).count();
+
+    assert_eq!(
+        success_count, 1,
+        "Expected exactly one successful melt, got {}. Results: {:?}",
+        success_count, results
+    );
+    assert_eq!(
+        failure_count,
+        NUM_WALLETS - 1,
+        "Expected {} failed melts, got {}",
+        NUM_WALLETS - 1,
+        failure_count
+    );
+
+    // Verify all failures were due to duplicate detection
+    for result in &results {
+        if let Err(err) = result {
+            let err_str = err.to_string().to_lowercase();
+            assert!(
+                err_str.contains("duplicate")
+                    || err_str.contains("already paid")
+                    || err_str.contains("pending"),
+                "Expected duplicate/already paid/pending error, got: {}",
+                err
+            );
+        }
+    }
+}
+
 /// Tests that wallet automatically recovers proofs after a failed swap operation
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_wallet_proof_recovery_after_failed_swap() {
@@ -1541,3 +1645,92 @@ async fn test_wallet_proof_recovery_after_failed_swap() {
         "Proofs should have been swapped to new ones"
     );
 }
+
+/// Tests that melt_proofs works correctly with proofs that are not already in the wallet's database.
+/// This is similar to the receive flow where proofs come from an external source.
+///
+/// Flow:
+/// 1. Wallet A mints proofs (proofs ARE in Wallet A's database)
+/// 2. Wallet B creates a melt quote
+/// 3. Wallet B calls melt_proofs with proofs from Wallet A (proofs are NOT in Wallet B's database)
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_proofs_external() {
+    // Create sender wallet (Wallet A) and mint some proofs
+    let wallet_sender = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create sender wallet");
+
+    let mint_quote = wallet_sender.mint_quote(100.into(), None).await.unwrap();
+
+    let mut proof_streams =
+        wallet_sender.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let proofs = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    assert_eq!(proofs.total_amount().unwrap(), Amount::from(100));
+
+    // Create receiver/melter wallet (Wallet B) with a separate database
+    // These proofs are NOT in Wallet B's database
+    let wallet_melter = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create melter wallet");
+
+    // Verify proofs are not in the melter wallet's database
+    let melter_proofs = wallet_melter.get_unspent_proofs().await.unwrap();
+    assert!(
+        melter_proofs.is_empty(),
+        "Melter wallet should have no proofs initially"
+    );
+
+    // Create a fake invoice for melting
+    let fake_description = FakeInvoiceDescription::default();
+    let invoice = create_fake_invoice(9000, serde_json::to_string(&fake_description).unwrap());
+
+    // Wallet B creates a melt quote
+    let melt_quote = wallet_melter
+        .melt_quote(invoice.to_string(), None)
+        .await
+        .unwrap();
+
+    // Wallet B calls melt_proofs with external proofs (from Wallet A)
+    // These proofs are NOT in wallet_melter's database
+    let melted = wallet_melter
+        .melt_proofs(&melt_quote.id, proofs.clone())
+        .await
+        .unwrap();
+
+    // Verify the melt succeeded
+    assert_eq!(melted.amount, Amount::from(9));
+    assert_eq!(melted.fee_paid, 1.into());
+
+    // Verify change was returned (100 input - 9 melt amount = 91 change, minus fee reserve)
+    assert!(melted.change.is_some());
+    let change_amount = melted.change.unwrap().total_amount().unwrap();
+    assert!(change_amount > Amount::ZERO, "Should have received change");
+
+    // Verify the melter wallet now has the change proofs
+    let melter_balance = wallet_melter.total_balance().await.unwrap();
+    assert_eq!(melter_balance, change_amount);
+
+    // Verify a transaction was recorded
+    let transactions = wallet_melter
+        .list_transactions(Some(TransactionDirection::Outgoing))
+        .await
+        .unwrap();
+    assert_eq!(transactions.len(), 1);
+    assert_eq!(transactions[0].amount, Amount::from(9));
+}

+ 17 - 8
crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs

@@ -628,17 +628,26 @@ async fn test_pay_invoice_twice() {
 
     let melt = wallet.melt(&melt_quote.id).await.unwrap();
 
-    let melt_two = wallet.melt_quote(invoice, None).await;
+    // Creating a second quote for the same invoice is allowed
+    let melt_quote_two = wallet.melt_quote(invoice, None).await.unwrap();
+
+    // But attempting to melt (pay) the second quote should fail
+    // since the first quote with the same lookup_id is already paid
+    let melt_two = wallet.melt(&melt_quote_two.id).await;
 
     match melt_two {
-        Err(err) => match err {
-            cdk::Error::RequestAlreadyPaid => (),
-            err => {
-                if !err.to_string().contains("Duplicate entry") {
-                    panic!("Wrong invoice already paid: {}", err.to_string());
-                }
+        Err(err) => {
+            let err_str = err.to_string().to_lowercase();
+            if !err_str.contains("duplicate")
+                && !err_str.contains("already paid")
+                && !err_str.contains("request already paid")
+            {
+                panic!(
+                    "Expected duplicate/already paid error, got: {}",
+                    err.to_string()
+                );
             }
-        },
+        }
         Ok(_) => {
             panic!("Should not have allowed second payment");
         }

+ 15 - 0
crates/cdk-sql-common/src/mint/migrations/postgres/20251127000000_allow_duplicate_melt_request_lookup_id.sql

@@ -0,0 +1,15 @@
+-- Remove unique constraint on request_lookup_id for melt_quote
+-- This allows multiple melt quotes for the same payment request
+-- The constraint that only one can be PENDING or PAID at a time is enforced by a partial unique index
+
+-- Drop the unique index on request_lookup_id
+DROP INDEX IF EXISTS unique_request_lookup_id_melt;
+
+-- Create a non-unique index for lookup performance
+CREATE INDEX IF NOT EXISTS idx_melt_quote_request_lookup_id ON melt_quote(request_lookup_id);
+
+-- Create a partial unique index to enforce that only one quote per lookup_id can be PENDING or PAID
+-- This provides database-level enforcement of the constraint
+CREATE UNIQUE INDEX IF NOT EXISTS unique_pending_paid_lookup_id
+ON melt_quote(request_lookup_id)
+WHERE state IN ('PENDING', 'PAID');

+ 9 - 0
crates/cdk-sql-common/src/mint/migrations/sqlite/20251127000000_allow_duplicate_melt_request_lookup_id.sql

@@ -0,0 +1,9 @@
+-- Remove unique constraint on request_lookup_id for melt_quote
+-- This allows multiple melt quotes for the same payment request
+-- The constraint that only one can be pending at a time is enforced in application logic
+
+-- Drop the unique index on request_lookup_id
+DROP INDEX IF EXISTS unique_request_lookup_id_melt;
+
+-- Create a non-unique index for lookup performance
+CREATE INDEX IF NOT EXISTS idx_melt_quote_request_lookup_id ON melt_quote(request_lookup_id);

+ 41 - 2
crates/cdk-sql-common/src/mint/mod.rs

@@ -1239,11 +1239,9 @@ VALUES (:quote_id, :amount, :timestamp);
                 melt_quote
             WHERE
                 id=:id
-                AND state != :state
             "#,
         )?
         .bind("id", quote_id.to_string())
-        .bind("state", state.to_string())
         .fetch_one(&self.inner)
         .await?
         .map(sql_row_to_melt_quote)
@@ -1252,6 +1250,47 @@ VALUES (:quote_id, :amount, :timestamp);
 
         check_melt_quote_state_transition(quote.state, 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);
+                }
+            }
+        }
+
         let rec = if state == MeltQuoteState::Paid {
             let current_time = unix_time();
             query(r#"UPDATE melt_quote SET state = :state, paid_time = :paid_time, payment_preimage = :payment_preimage WHERE id = :id"#)?

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

@@ -250,16 +250,23 @@ 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());
+            }
+        };
+
         // Publish proof state changes
         for pk in input_ys.iter() {
             self.pubsub.proof_state((*pk, State::Pending));
         }
 
-        // Update quote state to Pending
-        let (state, quote) = tx
-            .update_melt_quote_state(melt_request.quote(), MeltQuoteState::Pending, None)
-            .await?;
-
         if input_unit != Some(quote.unit.clone()) {
             tx.rollback().await?;
             return Err(Error::UnitMismatch);

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

@@ -2496,3 +2496,371 @@ async fn assert_proofs_state(
         assert_eq!(state, expected_state, "Proof state mismatch");
     }
 }
+
+// ============================================================================
+// Duplicate request_lookup_id Constraint Tests
+// ============================================================================
+
+/// Test: Cannot set melt quote to pending if another quote with same lookup_id is already pending
+///
+/// This test verifies that when two melt quotes share the same request_lookup_id,
+/// only one can be in PENDING state at a time.
+#[tokio::test]
+async fn test_duplicate_lookup_id_prevents_second_pending() {
+    use cdk_common::melt::MeltQuoteRequest;
+    use cdk_common::nuts::MeltQuoteBolt11Request;
+    use cdk_common::CurrencyUnit;
+    use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
+
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // Create a fake invoice description
+    let fake_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Paid,
+        check_payment_state: MeltQuoteState::Paid,
+        pay_err: false,
+        check_err: false,
+    };
+
+    // Create a single invoice that will be used for both quotes
+    let amount_msats: u64 = 9000;
+    let invoice = create_fake_invoice(
+        amount_msats,
+        serde_json::to_string(&fake_description).unwrap(),
+    );
+
+    // STEP 2: Create two melt quotes for the same invoice (same request_lookup_id)
+    let bolt11_request1 = MeltQuoteBolt11Request {
+        request: invoice.clone(),
+        unit: CurrencyUnit::Sat,
+        options: None,
+    };
+    let quote_response1 = mint
+        .get_melt_quote(MeltQuoteRequest::Bolt11(bolt11_request1))
+        .await
+        .unwrap();
+
+    let bolt11_request2 = MeltQuoteBolt11Request {
+        request: invoice,
+        unit: CurrencyUnit::Sat,
+        options: None,
+    };
+    let quote_response2 = mint
+        .get_melt_quote(MeltQuoteRequest::Bolt11(bolt11_request2))
+        .await
+        .unwrap();
+
+    // Retrieve full quotes
+    let quote1 = mint
+        .localstore
+        .get_melt_quote(&quote_response1.quote)
+        .await
+        .unwrap()
+        .expect("Quote 1 should exist");
+    let quote2 = mint
+        .localstore
+        .get_melt_quote(&quote_response2.quote)
+        .await
+        .unwrap()
+        .expect("Quote 2 should exist");
+
+    // Verify both quotes have the same lookup_id
+    assert_eq!(
+        quote1.request_lookup_id, quote2.request_lookup_id,
+        "Both quotes should have the same request_lookup_id"
+    );
+
+    // STEP 3: Setup first melt saga (puts quote1 in PENDING state)
+    let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let melt_request1 = create_test_melt_request(&proofs1, &quote1);
+
+    let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
+    let saga1 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga1 = saga1
+        .setup_melt(&melt_request1, verification1)
+        .await
+        .unwrap();
+
+    // Continue through the payment flow to release any transaction locks
+    // The quote will stay in PENDING state because FakeWallet returns Paid
+    // but we don't call finalize()
+    let (payment_saga1, decision1) = setup_saga1
+        .attempt_internal_settlement(&melt_request1)
+        .await
+        .unwrap();
+
+    // Make payment but don't finalize - keeps quote in PENDING
+    let confirmed_saga1 = payment_saga1.make_payment(decision1).await.unwrap();
+
+    // Drop the saga to release resources (simulates crash before finalize)
+    drop(confirmed_saga1);
+
+    // Verify quote1 is now pending
+    let pending_quote1 = mint
+        .localstore
+        .get_melt_quote(&quote1.id)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        pending_quote1.state,
+        MeltQuoteState::Pending,
+        "Quote 1 should be pending"
+    );
+
+    // STEP 4: Try to setup second saga with quote2 (same lookup_id)
+    let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let melt_request2 = create_test_melt_request(&proofs2, &quote2);
+
+    let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap();
+    let saga2 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await;
+
+    // STEP 5: Verify second setup fails due to duplicate pending lookup_id
+    assert!(
+        setup_result2.is_err(),
+        "Setup should fail when another quote with same lookup_id is already pending"
+    );
+
+    if let Err(error) = setup_result2 {
+        let error_msg = error.to_string().to_lowercase();
+        assert!(
+            error_msg.contains("duplicate") || error_msg.contains("pending"),
+            "Error should mention duplicate or pending, got: {}",
+            error
+        );
+    }
+
+    // Verify quote2 is still unpaid
+    let still_unpaid_quote2 = mint
+        .localstore
+        .get_melt_quote(&quote2.id)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        still_unpaid_quote2.state,
+        MeltQuoteState::Unpaid,
+        "Quote 2 should still be unpaid"
+    );
+
+    // SUCCESS: Duplicate pending lookup_id prevented!
+}
+
+/// Test: Cannot set melt quote to pending if another quote with same lookup_id is already paid
+///
+/// This test verifies that once a melt quote with a specific request_lookup_id is paid,
+/// no other quote with the same lookup_id can transition to pending.
+#[tokio::test]
+async fn test_paid_lookup_id_prevents_pending() {
+    use cdk_common::melt::MeltQuoteRequest;
+    use cdk_common::nuts::MeltQuoteBolt11Request;
+    use cdk_common::CurrencyUnit;
+    use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
+
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // Create a fake invoice description
+    let fake_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Paid,
+        check_payment_state: MeltQuoteState::Paid,
+        pay_err: false,
+        check_err: false,
+    };
+
+    // Create a single invoice that will be used for both quotes
+    let amount_msats: u64 = 9000;
+    let invoice = create_fake_invoice(
+        amount_msats,
+        serde_json::to_string(&fake_description).unwrap(),
+    );
+
+    // STEP 2: Create two melt quotes for the same invoice (same request_lookup_id)
+    let bolt11_request1 = MeltQuoteBolt11Request {
+        request: invoice.clone(),
+        unit: CurrencyUnit::Sat,
+        options: None,
+    };
+    let quote_response1 = mint
+        .get_melt_quote(MeltQuoteRequest::Bolt11(bolt11_request1))
+        .await
+        .unwrap();
+
+    let bolt11_request2 = MeltQuoteBolt11Request {
+        request: invoice,
+        unit: CurrencyUnit::Sat,
+        options: None,
+    };
+    let quote_response2 = mint
+        .get_melt_quote(MeltQuoteRequest::Bolt11(bolt11_request2))
+        .await
+        .unwrap();
+
+    // Retrieve full quotes
+    let quote1 = mint
+        .localstore
+        .get_melt_quote(&quote_response1.quote)
+        .await
+        .unwrap()
+        .expect("Quote 1 should exist");
+    let quote2 = mint
+        .localstore
+        .get_melt_quote(&quote_response2.quote)
+        .await
+        .unwrap()
+        .expect("Quote 2 should exist");
+
+    // STEP 3: Complete the first melt (marks quote1 as PAID)
+    let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let melt_request1 = create_test_melt_request(&proofs1, &quote1);
+
+    let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
+    let saga1 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga1 = saga1
+        .setup_melt(&melt_request1, verification1)
+        .await
+        .unwrap();
+
+    // Complete the full melt flow for quote1
+    let (payment_saga, decision) = setup_saga1
+        .attempt_internal_settlement(&melt_request1)
+        .await
+        .unwrap();
+    let confirmed_saga = payment_saga.make_payment(decision).await.unwrap();
+    let _response = confirmed_saga.finalize().await.unwrap();
+
+    // Verify quote1 is now paid
+    let paid_quote1 = mint
+        .localstore
+        .get_melt_quote(&quote1.id)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        paid_quote1.state,
+        MeltQuoteState::Paid,
+        "Quote 1 should be paid"
+    );
+
+    // STEP 4: Try to setup second saga with quote2 (same lookup_id as paid quote)
+    let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let melt_request2 = create_test_melt_request(&proofs2, &quote2);
+
+    let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap();
+    let saga2 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await;
+
+    // STEP 5: Verify second setup fails due to already paid lookup_id
+    assert!(
+        setup_result2.is_err(),
+        "Setup should fail when another quote with same lookup_id is already paid"
+    );
+
+    if let Err(error) = setup_result2 {
+        let error_msg = error.to_string().to_lowercase();
+        assert!(
+            error_msg.contains("duplicate")
+                || error_msg.contains("paid")
+                || error_msg.contains("pending"),
+            "Error should mention duplicate or paid, got: {}",
+            error
+        );
+    }
+
+    // SUCCESS: Paid lookup_id prevents new pending!
+}
+
+/// Test: Different lookup_ids allow concurrent pending quotes
+///
+/// This test verifies that melt quotes with different request_lookup_ids
+/// can both be in PENDING state simultaneously.
+#[tokio::test]
+async fn test_different_lookup_ids_allow_concurrent_pending() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create two quotes with different lookup_ids (different invoices)
+    let quote1 = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let quote2 = create_test_melt_quote(&mint, Amount::from(8_000)).await;
+
+    // Verify quotes have different lookup_ids
+    assert_ne!(
+        quote1.request_lookup_id, quote2.request_lookup_id,
+        "Quotes should have different request_lookup_ids"
+    );
+
+    // STEP 3: Setup first saga (puts quote1 in PENDING state)
+    let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let melt_request1 = create_test_melt_request(&proofs1, &quote1);
+
+    let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
+    let saga1 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let _setup_saga1 = saga1
+        .setup_melt(&melt_request1, verification1)
+        .await
+        .unwrap();
+
+    // STEP 4: Setup second saga (puts quote2 in PENDING state)
+    let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let melt_request2 = create_test_melt_request(&proofs2, &quote2);
+
+    let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap();
+    let saga2 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let _setup_saga2 = saga2
+        .setup_melt(&melt_request2, verification2)
+        .await
+        .unwrap();
+
+    // STEP 5: Verify both quotes are pending
+    let pending_quote1 = mint
+        .localstore
+        .get_melt_quote(&quote1.id)
+        .await
+        .unwrap()
+        .unwrap();
+    let pending_quote2 = mint
+        .localstore
+        .get_melt_quote(&quote2.id)
+        .await
+        .unwrap()
+        .unwrap();
+
+    assert_eq!(
+        pending_quote1.state,
+        MeltQuoteState::Pending,
+        "Quote 1 should be pending"
+    );
+    assert_eq!(
+        pending_quote2.state,
+        MeltQuoteState::Pending,
+        "Quote 2 should be pending"
+    );
+
+    // SUCCESS: Different lookup_ids allow concurrent pending!
+}

+ 8 - 2
crates/cdk/src/wallet/melt/melt_bolt11.rs

@@ -160,8 +160,14 @@ impl Wallet {
             return Err(Error::InsufficientFunds);
         }
 
-        let ys = proofs.ys()?;
-        tx.update_proofs_state(ys, State::Pending).await?;
+        // Since the proofs may be external (not in our database), add them first
+        let proofs_info = proofs
+            .clone()
+            .into_iter()
+            .map(|p| ProofInfo::new(p, self.mint_url.clone(), State::Pending, self.unit.clone()))
+            .collect::<Result<Vec<ProofInfo>, _>>()?;
+
+        tx.update_proofs(proofs_info, vec![]).await?;
 
         let active_keyset_id = self.fetch_active_keyset().await?.id;
 

+ 30 - 0
crates/cdk/src/wallet/multi_mint_wallet.rs

@@ -1343,6 +1343,36 @@ impl MultiMintWallet {
         wallet.melt(quote_id).await
     }
 
+    /// Melt specific proofs from a specific mint using a quote ID
+    ///
+    /// This method allows melting proofs that may not be in the wallet's database,
+    /// similar to how `receive_proofs` handles external proofs. The proofs will be
+    /// added to the database and used for the melt operation.
+    ///
+    /// # Arguments
+    ///
+    /// * `mint_url` - The mint to use for the melt operation
+    /// * `quote_id` - The melt quote ID (obtained from `melt_quote`)
+    /// * `proofs` - The proofs to melt (can be external proofs not in the wallet's database)
+    ///
+    /// # Returns
+    ///
+    /// A `Melted` result containing the payment details and any change proofs
+    #[instrument(skip(self, proofs))]
+    pub async fn melt_proofs(
+        &self,
+        mint_url: &MintUrl,
+        quote_id: &str,
+        proofs: Proofs,
+    ) -> Result<Melted, Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet.melt_proofs(quote_id, proofs).await
+    }
+
     /// Check a specific melt quote status
     #[instrument(skip(self))]
     pub async fn check_melt_quote(

+ 1 - 0
flake.nix

@@ -236,6 +236,7 @@
                 buildInputs = buildInputs ++ [
                   stable_toolchain
                   pkgs.docker-client
+                  pkgs.python311
                 ];
                 inherit nativeBuildInputs;
               }