Эх сурвалжийг харах

Wallet get unissued mint quotes (#1303)

* feat: optimize pending mint quotes query performance

- add get_pending_mint_quotes trait method to wallet database interface
- implement optimized SQL query filtering by payment method and balance
- create composite index on (payment_method, amount_paid, amount_issued)
- add created_time column to mint_quote table for ordering
- implement get_pending_mint_quotes for SQLite, ReDB, and FFI backends
- add wallet method to retrieve pending quotes filtered by mint URL and expiry

* refactor(cdk): improve mint quote handling and state management

- Make quote_info mutable to properly track state changes
- Update quote amounts (amount_issued, amount_paid) after successful minting
- Set quote state to Issued upon completion
- Ensure updated quotes are properly stored in localstore
- Simplify quote retrieval logic with better error handling

* feat: add pending tests

---------

Co-authored-by: vnprc <vnprc@protonmail.com>
tsk 4 долоо хоног өмнө
parent
commit
f184795aa0

+ 4 - 0
crates/cdk-common/src/database/wallet.rs

@@ -141,6 +141,10 @@ pub trait Database: Debug {
 
     /// Get mint quotes from storage
     async fn get_mint_quotes(&self) -> Result<Vec<WalletMintQuote>, Self::Err>;
+    /// Get unissued mint quotes from storage
+    /// Returns bolt11 quotes where nothing has been issued yet (amount_issued = 0) and all bolt12 quotes.
+    /// Includes unpaid bolt11 quotes to allow checking with the mint if they've been paid (wallet state may be outdated).
+    async fn get_unissued_mint_quotes(&self) -> Result<Vec<WalletMintQuote>, Self::Err>;
 
     /// Get melt quote from storage
     async fn get_melt_quote(&self, quote_id: &str) -> Result<Option<wallet::MeltQuote>, Self::Err>;

+ 28 - 0
crates/cdk-ffi/src/database.rs

@@ -49,6 +49,10 @@ pub trait WalletDatabase: Send + Sync {
     /// Get mint quotes from storage
     async fn get_mint_quotes(&self) -> Result<Vec<MintQuote>, FfiError>;
 
+    /// Get unissued mint quotes from storage
+    /// Returns bolt11 quotes where nothing has been issued yet (amount_issued = 0) and all bolt12 quotes.
+    async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, FfiError>;
+
     /// Get melt quote from storage
     async fn get_melt_quote(&self, quote_id: String) -> Result<Option<MeltQuote>, FfiError>;
 
@@ -465,6 +469,21 @@ impl CdkWalletDatabase for WalletDatabaseBridge {
             .collect::<Result<Vec<_>, _>>()?)
     }
 
+    async fn get_unissued_mint_quotes(&self) -> Result<Vec<cdk::wallet::MintQuote>, Self::Err> {
+        let result = self
+            .ffi_db
+            .get_unissued_mint_quotes()
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+        Ok(result
+            .into_iter()
+            .map(|q| {
+                q.try_into()
+                    .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))
+            })
+            .collect::<Result<Vec<_>, _>>()?)
+    }
+
     // Melt Quote Management
     async fn get_melt_quote(
         &self,
@@ -1146,6 +1165,15 @@ where
         Ok(result.into_iter().map(|q| q.into()).collect())
     }
 
+    async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, FfiError> {
+        let result = self
+            .inner
+            .get_unissued_mint_quotes()
+            .await
+            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
+        Ok(result.into_iter().map(|q| q.into()).collect())
+    }
+
     async fn get_melt_quote(&self, quote_id: String) -> Result<Option<MeltQuote>, FfiError> {
         let result = self
             .inner

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

@@ -99,6 +99,10 @@ impl WalletDatabase for WalletPostgresDatabase {
         self.inner.get_mint_quotes().await
     }
 
+    async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, FfiError> {
+        self.inner.get_unissued_mint_quotes().await
+    }
+
     async fn get_melt_quote(&self, quote_id: String) -> Result<Option<MeltQuote>, FfiError> {
         self.inner.get_melt_quote(quote_id).await
     }

+ 4 - 0
crates/cdk-ffi/src/sqlite.rs

@@ -100,6 +100,10 @@ impl WalletDatabase for WalletSqliteDatabase {
         self.inner.get_mint_quotes().await
     }
 
+    async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, FfiError> {
+        self.inner.get_unissued_mint_quotes().await
+    }
+
     async fn get_melt_quote(&self, quote_id: String) -> Result<Option<MeltQuote>, FfiError> {
         self.inner.get_melt_quote(quote_id).await
     }

+ 142 - 0
crates/cdk-integration-tests/tests/bolt12.rs

@@ -463,3 +463,145 @@ async fn test_attempt_to_mint_unpaid() {
         }
     }
 }
+
+/// Tests the check_all_mint_quotes functionality for Bolt12 quotes
+///
+/// This test verifies that:
+/// 1. Paid Bolt12 quotes are automatically minted when check_all_mint_quotes is called
+/// 2. The method correctly handles the Bolt12-specific logic (amount_paid > amount_issued)
+/// 3. Quote state is properly updated after minting
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_check_all_mint_quotes_bolt12() -> Result<()> {
+    let wallet = Wallet::new(
+        &get_mint_url_from_env(),
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await?),
+        Mnemonic::generate(12)?.to_seed_normalized(""),
+        None,
+    )?;
+
+    let mint_amount = Amount::from(100);
+
+    // Create a Bolt12 quote
+    let mint_quote = wallet.mint_bolt12_quote(Some(mint_amount), None).await?;
+
+    assert_eq!(mint_quote.amount, Some(mint_amount));
+
+    // Verify the quote is in unissued quotes before payment
+    let unissued_before = wallet.get_unissued_mint_quotes().await?;
+    assert!(
+        unissued_before.iter().any(|q| q.id == mint_quote.id),
+        "Bolt12 quote should be in unissued quotes before payment"
+    );
+
+    // Pay the quote
+    let work_dir = get_test_temp_dir();
+    let cln_one_dir = get_cln_dir(&work_dir, "one");
+    let cln_client = create_cln_client_with_retry(cln_one_dir.clone()).await?;
+    cln_client
+        .pay_bolt12_offer(None, mint_quote.request.clone())
+        .await?;
+
+    // Wait for payment to be recognized
+    wallet
+        .wait_for_payment(&mint_quote, tokio::time::Duration::from_secs(30))
+        .await?;
+
+    // Verify initial balance is zero
+    assert_eq!(wallet.total_balance().await?, Amount::ZERO);
+
+    // Call check_all_mint_quotes - this should mint the paid Bolt12 quote
+    let total_minted = wallet.check_all_mint_quotes().await?;
+
+    // Verify the amount minted is correct
+    assert_eq!(
+        total_minted, mint_amount,
+        "check_all_mint_quotes should have minted the Bolt12 quote"
+    );
+
+    // Verify wallet balance matches
+    assert_eq!(wallet.total_balance().await?, mint_amount);
+
+    // Calling check_all_mint_quotes again should return 0 (quote already fully issued)
+    let second_check = wallet.check_all_mint_quotes().await?;
+    assert_eq!(
+        second_check,
+        Amount::ZERO,
+        "Second check should return 0 as quote is fully issued"
+    );
+
+    Ok(())
+}
+
+/// Tests that Bolt12 quote state (amount_issued) is properly updated after minting
+///
+/// This test verifies that:
+/// 1. amount_issued starts at 0
+/// 2. amount_issued is updated after minting
+/// 3. The quote correctly tracks issued vs paid amounts
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_bolt12_quote_amount_issued_tracking() -> Result<()> {
+    let wallet = Wallet::new(
+        &get_mint_url_from_env(),
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await?),
+        Mnemonic::generate(12)?.to_seed_normalized(""),
+        None,
+    )?;
+
+    // Create an open-ended Bolt12 quote (no amount specified)
+    let mint_quote = wallet.mint_bolt12_quote(None, None).await?;
+
+    // Verify initial state
+    let state_before = wallet.mint_bolt12_quote_state(&mint_quote.id).await?;
+    assert_eq!(state_before.amount_paid, Amount::ZERO);
+    assert_eq!(state_before.amount_issued, Amount::ZERO);
+
+    // Pay the quote with a specific amount
+    let pay_amount_msats = 50_000; // 50 sats
+    let work_dir = get_test_temp_dir();
+    let cln_one_dir = get_cln_dir(&work_dir, "one");
+    let cln_client = create_cln_client_with_retry(cln_one_dir.clone()).await?;
+    cln_client
+        .pay_bolt12_offer(Some(pay_amount_msats), mint_quote.request.clone())
+        .await?;
+
+    // Wait for payment
+    let payment = wallet
+        .wait_for_payment(&mint_quote, tokio::time::Duration::from_secs(30))
+        .await?
+        .expect("Should receive payment notification");
+
+    // Check state after payment but before minting
+    let state_after_payment = wallet.mint_bolt12_quote_state(&mint_quote.id).await?;
+    assert_eq!(
+        state_after_payment.amount_paid,
+        Amount::from(pay_amount_msats / 1000)
+    );
+    assert_eq!(
+        state_after_payment.amount_issued,
+        Amount::ZERO,
+        "amount_issued should still be 0 before minting"
+    );
+
+    // Now mint the tokens
+    let proofs = wallet
+        .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None)
+        .await?;
+
+    let minted_amount = proofs.total_amount()?;
+    assert_eq!(minted_amount, payment);
+
+    // Check state after minting
+    let state_after_mint = wallet.mint_bolt12_quote_state(&mint_quote.id).await?;
+    assert_eq!(
+        state_after_mint.amount_issued, minted_amount,
+        "amount_issued should be updated after minting"
+    );
+    assert_eq!(
+        state_after_mint.amount_paid, state_after_mint.amount_issued,
+        "For a single payment, amount_paid should equal amount_issued after minting"
+    );
+
+    Ok(())
+}

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

@@ -1861,3 +1861,199 @@ async fn test_melt_exact_proofs_no_swap_needed() {
         initial_balance - melted.amount - melted.fee_paid
     );
 }
+
+/// Tests the check_all_mint_quotes functionality for Bolt11 quotes
+///
+/// This test verifies that:
+/// 1. Paid mint quotes are automatically minted when check_all_mint_quotes is called
+/// 2. The total amount returned matches the minted proofs
+/// 3. Quote state is properly updated after minting
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_check_all_mint_quotes_bolt11() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Create first mint quote and pay it (using proof_stream triggers fake wallet payment)
+    let mint_quote_1 = wallet.mint_quote(100.into(), None).await.unwrap();
+
+    // Wait for the payment to be registered (fake wallet auto-pays)
+    let mut payment_stream_1 = wallet.payment_stream(&mint_quote_1);
+    payment_stream_1
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    // Create second mint quote and pay it
+    let mint_quote_2 = wallet.mint_quote(50.into(), None).await.unwrap();
+
+    let mut payment_stream_2 = wallet.payment_stream(&mint_quote_2);
+    payment_stream_2
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    // Verify no proofs have been minted yet
+    assert_eq!(wallet.total_balance().await.unwrap(), Amount::ZERO);
+
+    // Call check_all_mint_quotes - this should mint both paid quotes
+    let total_minted = wallet.check_all_mint_quotes().await.unwrap();
+
+    // Verify the total amount minted is correct (100 + 50 = 150)
+    assert_eq!(total_minted, Amount::from(150));
+
+    // Verify wallet balance matches
+    assert_eq!(wallet.total_balance().await.unwrap(), Amount::from(150));
+
+    // Calling check_all_mint_quotes again should return 0 (quotes already minted)
+    let second_check = wallet.check_all_mint_quotes().await.unwrap();
+    assert_eq!(second_check, Amount::ZERO);
+}
+
+/// Tests the get_unissued_mint_quotes wallet method
+///
+/// This test verifies that:
+/// 1. Unpaid quotes are included (wallet needs to check with mint)
+/// 2. Paid but not issued quotes are included
+/// 3. Fully issued quotes are excluded
+/// 4. Only quotes for the current mint URL are returned
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_get_unissued_mint_quotes_wallet() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Create a quote but don't pay it (stays unpaid)
+    let unpaid_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+
+    // Create another quote and pay it but don't mint
+    let paid_quote = wallet.mint_quote(50.into(), None).await.unwrap();
+    let mut payment_stream = wallet.payment_stream(&paid_quote);
+    payment_stream
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    // Create a third quote and fully mint it
+    let minted_quote = wallet.mint_quote(25.into(), None).await.unwrap();
+    let mut proof_stream = wallet.proof_stream(minted_quote.clone(), SplitTarget::default(), None);
+    proof_stream
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    // Get unissued quotes
+    let unissued_quotes = wallet.get_unissued_mint_quotes().await.unwrap();
+
+    // Should have 2 quotes: unpaid and paid-but-not-issued
+    // The fully minted quote should be excluded
+    assert_eq!(
+        unissued_quotes.len(),
+        2,
+        "Should have 2 unissued quotes (unpaid and paid-not-issued)"
+    );
+
+    let quote_ids: Vec<&str> = unissued_quotes.iter().map(|q| q.id.as_str()).collect();
+    assert!(
+        quote_ids.contains(&unpaid_quote.id.as_str()),
+        "Unpaid quote should be included"
+    );
+    assert!(
+        quote_ids.contains(&paid_quote.id.as_str()),
+        "Paid but not issued quote should be included"
+    );
+    assert!(
+        !quote_ids.contains(&minted_quote.id.as_str()),
+        "Fully minted quote should NOT be included"
+    );
+}
+
+/// Tests that mint quote state is properly updated after minting
+///
+/// This test verifies that:
+/// 1. amount_issued is updated after successful minting
+/// 2. Quote state is updated correctly
+/// 3. The quote is stored properly in the localstore
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_mint_quote_state_updates_after_minting() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    let mint_amount = Amount::from(100);
+    let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap();
+
+    // Get the quote from localstore before minting
+    let quote_before = wallet
+        .localstore
+        .get_mint_quote(&mint_quote.id)
+        .await
+        .unwrap()
+        .expect("Quote should exist");
+
+    // Verify initial state
+    assert_eq!(quote_before.amount_issued, Amount::ZERO);
+
+    // Mint the tokens using wait_and_mint_quote
+    let proofs = wallet
+        .wait_and_mint_quote(
+            mint_quote.clone(),
+            SplitTarget::default(),
+            None,
+            Duration::from_secs(60),
+        )
+        .await
+        .expect("minting should succeed");
+
+    let minted_amount = proofs.total_amount().unwrap();
+    assert_eq!(minted_amount, mint_amount);
+
+    // Check the quote is now either removed or updated in the localstore
+    // After minting, the quote should be removed from localstore (it's fully issued)
+    let quote_after = wallet
+        .localstore
+        .get_mint_quote(&mint_quote.id)
+        .await
+        .unwrap();
+
+    // The quote should either be removed or have amount_issued updated
+    match quote_after {
+        Some(quote) => {
+            // If still present, amount_issued should equal the minted amount
+            assert_eq!(
+                quote.amount_issued, minted_amount,
+                "amount_issued should be updated after minting"
+            );
+        }
+        None => {
+            // Quote was removed after being fully issued - this is also valid behavior
+        }
+    }
+
+    // Verify the unissued quotes no longer contains this quote
+    let unissued = wallet.get_unissued_mint_quotes().await.unwrap();
+    let unissued_ids: Vec<&str> = unissued.iter().map(|q| q.id.as_str()).collect();
+    assert!(
+        !unissued_ids.contains(&mint_quote.id.as_str()),
+        "Fully minted quote should not appear in unissued quotes"
+    );
+}

+ 19 - 2
crates/cdk-redb/src/wallet/mod.rs

@@ -13,8 +13,8 @@ use cdk_common::mint_url::MintUrl;
 use cdk_common::util::unix_time;
 use cdk_common::wallet::{self, MintQuote, Transaction, TransactionDirection, TransactionId};
 use cdk_common::{
-    database, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions,
-    State,
+    database, Amount, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PaymentMethod,
+    PublicKey, SpendingConditions, State,
 };
 use redb::{Database, MultimapTableDefinition, ReadableTable, TableDefinition};
 use tracing::instrument;
@@ -330,6 +330,23 @@ impl WalletDatabase for WalletRedbDatabase {
             .collect())
     }
 
+    async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, Self::Err> {
+        let read_txn = self.db.begin_read().map_err(Into::<Error>::into)?;
+        let table = read_txn
+            .open_table(MINT_QUOTES_TABLE)
+            .map_err(Error::from)?;
+
+        Ok(table
+            .iter()
+            .map_err(Error::from)?
+            .flatten()
+            .flat_map(|(_id, quote)| serde_json::from_str::<MintQuote>(quote.value()).ok())
+            .filter(|quote| {
+                quote.amount_issued == Amount::ZERO || quote.payment_method == PaymentMethod::Bolt12
+            })
+            .collect())
+    }
+
     #[instrument(skip_all)]
     async fn get_melt_quote(&self, quote_id: &str) -> Result<Option<wallet::MeltQuote>, Self::Err> {
         let read_txn = self.db.begin_read().map_err(Error::from)?;

+ 7 - 0
crates/cdk-sql-common/src/wallet/migrations/postgres/20251021000000_pending_quotes_optimization.sql

@@ -0,0 +1,7 @@
+-- Add created_time column to mint_quote table for ordering queries
+ALTER TABLE mint_quote ADD COLUMN created_time BIGINT NOT NULL DEFAULT 0;
+
+-- Composite index for optimized pending quotes query
+-- Supports WHERE amount_issued = 0 OR payment_method = 'bolt12'
+CREATE INDEX IF NOT EXISTS idx_mint_quote_pending
+ON mint_quote(payment_method, amount_issued);

+ 7 - 0
crates/cdk-sql-common/src/wallet/migrations/sqlite/20251021000000_pending_quotes_optimization.sql

@@ -0,0 +1,7 @@
+-- Add created_time column to mint_quote table for ordering queries
+ALTER TABLE mint_quote ADD COLUMN created_time INTEGER NOT NULL DEFAULT 0;
+
+-- Composite index for optimized pending quotes query
+-- Supports WHERE amount_issued = 0 OR payment_method = 'bolt12'
+CREATE INDEX IF NOT EXISTS idx_mint_quote_pending
+ON mint_quote(payment_method, amount_issued);

+ 32 - 0
crates/cdk-sql-common/src/wallet/mod.rs

@@ -1069,6 +1069,38 @@ where
     }
 
     #[instrument(skip(self))]
+    async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, Self::Err> {
+        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
+        Ok(query(
+            r#"
+            SELECT
+                id,
+                mint_url,
+                amount,
+                unit,
+                request,
+                state,
+                expiry,
+                secret_key,
+                payment_method,
+                amount_issued,
+                amount_paid
+            FROM
+                mint_quote
+            WHERE
+                amount_issued = 0
+                OR
+                payment_method = 'bolt12'
+            "#,
+        )?
+        .fetch_all(&*conn)
+        .await?
+        .into_iter()
+        .map(sql_row_to_mint_quote)
+        .collect::<Result<_, _>>()?)
+    }
+
+    #[instrument(skip(self))]
     async fn get_melt_quote(&self, quote_id: &str) -> Result<Option<wallet::MeltQuote>, Self::Err> {
         let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
         get_melt_quote_inner(&*conn, quote_id, false).await

+ 114 - 0
crates/cdk-sqlite/src/wallet/mod.rs

@@ -281,4 +281,118 @@ mod tests {
         assert_eq!(single_proof[0].mint_url, proof_infos[2].mint_url);
         assert_eq!(single_proof[0].state, proof_infos[2].state);
     }
+
+    #[tokio::test]
+    async fn test_get_unissued_mint_quotes() {
+        use cdk_common::mint_url::MintUrl;
+        use cdk_common::nuts::{CurrencyUnit, MintQuoteState, PaymentMethod};
+        use cdk_common::wallet::MintQuote;
+        use cdk_common::Amount;
+
+        // Create a temporary database
+        let path = std::env::temp_dir().to_path_buf().join(format!(
+            "cdk-test-unpaid-quotes-{}.sqlite",
+            uuid::Uuid::new_v4()
+        ));
+
+        #[cfg(feature = "sqlcipher")]
+        let db = WalletSqliteDatabase::new((path, "password".to_string()))
+            .await
+            .unwrap();
+
+        #[cfg(not(feature = "sqlcipher"))]
+        let db = WalletSqliteDatabase::new(path).await.unwrap();
+
+        let mint_url = MintUrl::from_str("https://example.com").unwrap();
+
+        // Quote 1: Fully paid and issued (should NOT be returned)
+        let quote1 = MintQuote {
+            id: "quote_fully_paid".to_string(),
+            mint_url: mint_url.clone(),
+            amount: Some(Amount::from(100)),
+            unit: CurrencyUnit::Sat,
+            request: "test_request_1".to_string(),
+            state: MintQuoteState::Paid,
+            expiry: 1000000000,
+            secret_key: None,
+            payment_method: PaymentMethod::Bolt11,
+            amount_issued: Amount::from(100),
+            amount_paid: Amount::from(100),
+        };
+
+        // Quote 2: Paid but not yet issued (should be returned - has pending balance)
+        let quote2 = MintQuote {
+            id: "quote_pending_balance".to_string(),
+            mint_url: mint_url.clone(),
+            amount: Some(Amount::from(100)),
+            unit: CurrencyUnit::Sat,
+            request: "test_request_2".to_string(),
+            state: MintQuoteState::Paid,
+            expiry: 1000000000,
+            secret_key: None,
+            payment_method: PaymentMethod::Bolt11,
+            amount_issued: Amount::from(0),
+            amount_paid: Amount::from(100),
+        };
+
+        // Quote 3: Bolt12 quote with no balance (should be returned - bolt12 is reusable)
+        let quote3 = MintQuote {
+            id: "quote_bolt12".to_string(),
+            mint_url: mint_url.clone(),
+            amount: Some(Amount::from(100)),
+            unit: CurrencyUnit::Sat,
+            request: "test_request_3".to_string(),
+            state: MintQuoteState::Unpaid,
+            expiry: 1000000000,
+            secret_key: None,
+            payment_method: PaymentMethod::Bolt12,
+            amount_issued: Amount::from(0),
+            amount_paid: Amount::from(0),
+        };
+
+        // Quote 4: Unpaid bolt11 quote (should be returned - wallet needs to check with mint)
+        let quote4 = MintQuote {
+            id: "quote_unpaid".to_string(),
+            mint_url: mint_url.clone(),
+            amount: Some(Amount::from(100)),
+            unit: CurrencyUnit::Sat,
+            request: "test_request_4".to_string(),
+            state: MintQuoteState::Unpaid,
+            expiry: 1000000000,
+            secret_key: None,
+            payment_method: PaymentMethod::Bolt11,
+            amount_issued: Amount::from(0),
+            amount_paid: Amount::from(0),
+        };
+
+        {
+            let mut tx = db.begin_db_transaction().await.unwrap();
+
+            // Add all quotes to the database
+            tx.add_mint_quote(quote1).await.unwrap();
+            tx.add_mint_quote(quote2.clone()).await.unwrap();
+            tx.add_mint_quote(quote3.clone()).await.unwrap();
+            tx.add_mint_quote(quote4.clone()).await.unwrap();
+
+            tx.commit().await.unwrap();
+        }
+
+        // Get unissued mint quotes
+        let unissued_quotes = db.get_unissued_mint_quotes().await.unwrap();
+
+        // Should return 3 quotes: quote2, quote3, and quote4
+        // - quote2: bolt11 with amount_issued = 0 (needs minting)
+        // - quote3: bolt12 (always returned, reusable)
+        // - quote4: bolt11 with amount_issued = 0 (check with mint if paid)
+        assert_eq!(unissued_quotes.len(), 3);
+
+        // Verify the returned quotes are the expected ones
+        let quote_ids: Vec<&str> = unissued_quotes.iter().map(|q| q.id.as_str()).collect();
+        assert!(quote_ids.contains(&"quote_pending_balance"));
+        assert!(quote_ids.contains(&"quote_bolt12"));
+        assert!(quote_ids.contains(&"quote_unpaid"));
+
+        // Verify that fully paid and issued quote is not returned
+        assert!(!quote_ids.contains(&"quote_fully_paid"));
+    }
 }

+ 39 - 14
crates/cdk/src/wallet/issue/issue_bolt11.rs

@@ -127,21 +127,33 @@ impl Wallet {
     /// Check status of pending mint quotes
     #[instrument(skip(self))]
     pub async fn check_all_mint_quotes(&self) -> Result<Amount, Error> {
-        let mint_quotes = self.localstore.get_mint_quotes().await?;
+        let mint_quotes = self.localstore.get_unissued_mint_quotes().await?;
         let mut total_amount = Amount::ZERO;
 
         for mint_quote in mint_quotes {
-            let mint_quote_response = self.mint_quote_state(&mint_quote.id).await?;
-
-            if mint_quote_response.state == MintQuoteState::Paid {
-                let proofs = self
-                    .mint(&mint_quote.id, SplitTarget::default(), None)
-                    .await?;
-                total_amount += proofs.total_amount()?;
-            } else if mint_quote.expiry.le(&unix_time()) {
-                let mut tx = self.localstore.begin_db_transaction().await?;
-                tx.remove_mint_quote(&mint_quote.id).await?;
-                tx.commit().await?;
+            match mint_quote.payment_method {
+                PaymentMethod::Bolt11 => {
+                    let mint_quote_response = self.mint_quote_state(&mint_quote.id).await?;
+
+                    if mint_quote_response.state == MintQuoteState::Paid {
+                        let proofs = self
+                            .mint(&mint_quote.id, SplitTarget::default(), None)
+                            .await?;
+                        total_amount += proofs.total_amount()?;
+                    }
+                }
+                PaymentMethod::Bolt12 => {
+                    let mint_quote_response = self.mint_bolt12_quote_state(&mint_quote.id).await?;
+                    if mint_quote_response.amount_paid > mint_quote_response.amount_issued {
+                        let proofs = self
+                            .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None)
+                            .await?;
+                        total_amount += proofs.total_amount()?;
+                    }
+                }
+                PaymentMethod::Custom(_) => {
+                    tracing::warn!("We cannot check unknown types");
+                }
             }
         }
         Ok(total_amount)
@@ -161,6 +173,18 @@ impl Wallet {
         Ok(mint_quotes)
     }
 
+    /// Get unissued mint quotes
+    /// Returns bolt11 quotes where nothing has been issued yet (amount_issued = 0) and all bolt12 quotes.
+    /// Includes unpaid bolt11 quotes to allow checking with the mint if they've been paid (wallet state may be outdated).
+    /// Filters out quotes from other mints. Does not filter by expiry time to allow
+    /// checking with the mint if expired quotes can still be minted.
+    #[instrument(skip(self))]
+    pub async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, Error> {
+        let mut pending_quotes = self.localstore.get_unissued_mint_quotes().await?;
+        pending_quotes.retain(|quote| quote.mint_url == self.mint_url);
+        Ok(pending_quotes)
+    }
+
     /// Mint
     /// # Synopsis
     /// ```rust,no_run
@@ -279,8 +303,8 @@ impl Wallet {
             signature: None,
         };
 
-        if let Some(secret_key) = quote_info.secret_key {
-            request.sign(secret_key)?;
+        if let Some(secret_key) = &quote_info.secret_key {
+            request.sign(secret_key.clone())?;
         }
 
         tx.commit().await?;
@@ -308,6 +332,7 @@ impl Wallet {
             &keys,
         )?;
 
+        // Start new transaction for post-mint operations
         let mut tx = self.localstore.begin_db_transaction().await?;
 
         // Remove filled quote from store

+ 1 - 1
crates/cdk/src/wallet/issue/issue_bolt12.rs

@@ -208,7 +208,7 @@ impl Wallet {
             &keys,
         )?;
 
-        // Remove filled quote from store
+        // Update quote with issued amount
         let mut quote_info = tx
             .get_mint_quote(quote_id)
             .await?