Browse Source

feat(cdk): add Bolt12 mint quote subscription support (#976)

* feat(cdk): add Bolt12 mint quote subscription support

Extends subscription to handle Bolt12 payment method alongside existing Bolt11 support across wallet, mint, and CLI components.
thesimplekid 1 month ago
parent
commit
d3a3c30d99

+ 1 - 1
Cargo.toml

@@ -68,7 +68,7 @@ lightning = { version = "0.1.2", default-features = false, features = ["std"]}
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 thiserror = { version = "2" }
-tokio = { version = "1", default-features = false, features = ["rt", "macros", "test-util"] }
+tokio = { version = "1", default-features = false, features = ["rt", "macros", "test-util", "sync"] }
 tokio-util = { version = "0.7.11", default-features = false }
 tower-http = { version = "0.6.1", features = ["compression-full", "decompression-full", "cors", "trace"] }
 tokio-tungstenite = { version = "0.26.0", default-features = false }

+ 2 - 0
crates/cashu/src/nuts/nut17/mod.rs

@@ -174,6 +174,8 @@ pub enum Kind {
     Bolt11MintQuote,
     /// Proof State
     ProofState,
+    /// Bolt 12 Mint Quote
+    Bolt12MintQuote,
 }
 
 impl<I> AsRef<I> for Params<I> {

+ 2 - 2
crates/cdk-cli/src/main.rs

@@ -102,9 +102,9 @@ async fn main() -> Result<()> {
     let args: Cli = Cli::parse();
     let default_filter = args.log_level;
 
-    let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn";
+    let filter = "rustls=warn,hyper_util=warn,reqwest=warn";
 
-    let env_filter = EnvFilter::new(format!("{default_filter},{sqlx_filter}"));
+    let env_filter = EnvFilter::new(format!("{default_filter},{filter}"));
 
     // Parse input
     tracing_subscriber::fmt().with_env_filter(env_filter).init();

+ 38 - 69
crates/cdk-cli/src/sub_commands/mint.rs

@@ -1,11 +1,12 @@
 use std::str::FromStr;
+use std::time::Duration;
 
 use anyhow::{anyhow, Result};
 use cdk::amount::SplitTarget;
 use cdk::mint_url::MintUrl;
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload, PaymentMethod};
-use cdk::wallet::{MultiMintWallet, WalletSubscription};
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
+use cdk::wallet::MultiMintWallet;
 use cdk::Amount;
 use clap::Args;
 use serde::{Deserialize, Serialize};
@@ -36,6 +37,9 @@ pub struct MintSubCommand {
     /// Expiry
     #[arg(short, long)]
     single_use: Option<bool>,
+    /// Wait duration in seconds for mint quote polling
+    #[arg(long, default_value = "30")]
+    wait_duration: u64,
 }
 
 pub async fn mint(
@@ -48,9 +52,9 @@ pub async fn mint(
 
     let wallet = get_or_create_wallet(multi_mint_wallet, &mint_url, unit).await?;
 
-    let mut payment_method = PaymentMethod::from_str(&sub_command_args.method)?;
+    let payment_method = PaymentMethod::from_str(&sub_command_args.method)?;
 
-    let quote_id = match &sub_command_args.quote_id {
+    let quote = match &sub_command_args.quote_id {
         None => match payment_method {
             PaymentMethod::Bolt11 => {
                 let amount = sub_command_args
@@ -62,20 +66,7 @@ pub async fn mint(
 
                 println!("Please pay: {}", quote.request);
 
-                let mut subscription = wallet
-                    .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote
-                        .id
-                        .clone()]))
-                    .await;
-
-                while let Some(msg) = subscription.recv().await {
-                    if let NotificationPayload::MintQuoteBolt11Response(response) = msg {
-                        if response.state == MintQuoteState::Paid {
-                            break;
-                        }
-                    }
-                }
-                quote.id
+                quote
             }
             PaymentMethod::Bolt12 => {
                 let amount = sub_command_args.amount;
@@ -88,68 +79,46 @@ pub async fn mint(
 
                 println!("Please pay: {}", quote.request);
 
-                let mut subscription = wallet
-                    .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote
-                        .id
-                        .clone()]))
-                    .await;
-
-                while let Some(msg) = subscription.recv().await {
-                    if let NotificationPayload::MintQuoteBolt11Response(response) = msg {
-                        if response.state == MintQuoteState::Paid {
-                            break;
-                        }
-                    }
-                }
-                quote.id
+                quote
             }
             _ => {
                 todo!()
             }
         },
-        Some(quote_id) => {
-            let quote = wallet
-                .localstore
-                .get_mint_quote(quote_id)
-                .await?
-                .ok_or(anyhow!("Unknown quote"))?;
-
-            payment_method = quote.payment_method;
-            quote_id.to_string()
-        }
+        Some(quote_id) => wallet
+            .localstore
+            .get_mint_quote(quote_id)
+            .await?
+            .ok_or(anyhow!("Unknown quote"))?,
     };
 
     tracing::debug!("Attempting mint for: {}", payment_method);
 
-    let proofs = match payment_method {
-        PaymentMethod::Bolt11 => wallet.mint(&quote_id, SplitTarget::default(), None).await?,
-        PaymentMethod::Bolt12 => {
-            let response = wallet.mint_bolt12_quote_state(&quote_id).await?;
-
-            let amount_mintable = response.amount_paid - response.amount_issued;
-
-            if amount_mintable == Amount::ZERO {
-                println!("Mint quote does not have amount that can be minted.");
-                return Ok(());
-            }
-
-            wallet
-                .mint_bolt12(
-                    &quote_id,
-                    Some(amount_mintable),
-                    SplitTarget::default(),
-                    None,
-                )
-                .await?
-        }
-        _ => {
-            todo!()
+    let mut amount_minted = Amount::ZERO;
+
+    loop {
+        let proofs = wallet
+            .wait_and_mint_quote(
+                quote.clone(),
+                SplitTarget::default(),
+                None,
+                Duration::from_secs(sub_command_args.wait_duration),
+            )
+            .await?;
+
+        amount_minted += proofs.total_amount()?;
+
+        if sub_command_args.quote_id.is_none() || quote.payment_method == PaymentMethod::Bolt11 {
+            break;
+        } else {
+            println!(
+                "Minted {} waiting for next payment.",
+                proofs.total_amount()?
+            );
         }
-    };
-
-    let receive_amount = proofs.total_amount()?;
+    }
 
-    println!("Received {receive_amount} from mint {mint_url}");
+    println!("Received {amount_minted} from mint {mint_url}");
 
     Ok(())
 }

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

@@ -51,6 +51,9 @@ impl TryFrom<IndexableParams> for Vec<Index<Notification>> {
                         Notification::MintQuoteBolt11(Uuid::from_str(&filter)?)
                     }
                     Kind::ProofState => Notification::ProofState(PublicKey::from_str(&filter)?),
+                    Kind::Bolt12MintQuote => {
+                        Notification::MintQuoteBolt12(Uuid::from_str(&filter)?)
+                    }
                 };
 
                 Ok(Index::from((idx, params.id.clone(), sub_id)))

+ 1 - 1
crates/cdk-fake-wallet/src/lib.rs

@@ -108,7 +108,7 @@ impl SecondaryRepaymentQueue {
 
             loop {
                 // Wait for a random interval between 30 seconds and 3 minutes (180 seconds)
-                let delay_secs = rng.gen_range(30..=180);
+                let delay_secs = rng.gen_range(1..=3);
                 time::sleep(time::Duration::from_secs(delay_secs)).await;
 
                 // Try to process a random payment from the queue without removing it

+ 8 - 4
crates/cdk-integration-tests/tests/fake_auth.rs

@@ -330,13 +330,17 @@ async fn test_mint_with_auth() {
 
     let mint_amount: Amount = 100.into();
 
-    let (_, proofs) = wallet
-        .mint_once_paid(mint_amount, None, Duration::from_secs(10))
+    let quote = wallet.mint_quote(mint_amount, None).await.unwrap();
+    let proofs = wallet
+        .wait_and_mint_quote(
+            quote,
+            Default::default(),
+            Default::default(),
+            Duration::from_secs(10),
+        )
         .await
         .unwrap();
 
-    let proofs = proofs.await.expect("could not mint");
-
     assert!(proofs.total_amount().expect("Could not get proofs amount") == mint_amount);
 }
 

+ 7 - 7
crates/cdk/examples/auth_wallet.rs

@@ -5,6 +5,7 @@ use cdk::error::Error;
 use cdk::nuts::CurrencyUnit;
 use cdk::wallet::{SendOptions, Wallet};
 use cdk::{Amount, OidcClient};
+use cdk_common::amount::SplitTarget;
 use cdk_common::{MintInfo, ProofsMethods};
 use cdk_sqlite::wallet::memory;
 use rand::Rng;
@@ -57,14 +58,13 @@ async fn main() -> Result<(), Error> {
         .await
         .expect("Could not mint blind auth");
 
-    let (_invoice_to_pay, proofs) = wallet
-        .mint_once_paid(amount, None, Duration::from_secs(10))
-        .await?;
-
-    // Mint the received amount
-    let receive_amount = proofs.await?;
+    let quote = wallet.mint_quote(amount, None).await.unwrap();
+    let proofs = wallet
+        .wait_and_mint_quote(quote, SplitTarget::default(), None, Duration::from_secs(10))
+        .await
+        .unwrap();
 
-    println!("Received: {}", receive_amount.total_amount()?);
+    println!("Received: {}", proofs.total_amount()?);
 
     // Get the total balance of the wallet
     let balance = wallet.total_balance().await?;

+ 8 - 4
crates/cdk/examples/melt-token.rs

@@ -29,12 +29,16 @@ async fn main() -> Result<(), Error> {
     // Create a new wallet
     let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None)?;
 
-    let (_invoice_to_pay, proofs) = wallet
-        .mint_once_paid(amount, None, Duration::from_secs(10))
+    let quote = wallet.mint_quote(amount, None).await?;
+    let proofs = wallet
+        .wait_and_mint_quote(
+            quote,
+            Default::default(),
+            Default::default(),
+            Duration::from_secs(10),
+        )
         .await?;
 
-    // Mint the received amount
-    let proofs = proofs.await?;
     let receive_amount = proofs.total_amount()?;
     println!("Received {} from mint {}", receive_amount, mint_url);
 

+ 8 - 3
crates/cdk/examples/mint-token.rs

@@ -35,12 +35,17 @@ async fn main() -> Result<(), Error> {
     // Create a new wallet
     let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?;
 
-    let (_invoice_to_pay, proofs) = wallet
-        .mint_once_paid(amount, None, Duration::from_secs(10))
+    let quote = wallet.mint_quote(amount, None).await?;
+    let proofs = wallet
+        .wait_and_mint_quote(
+            quote,
+            Default::default(),
+            Default::default(),
+            Duration::from_secs(10),
+        )
         .await?;
 
     // Mint the received amount
-    let proofs = proofs.await?;
     let receive_amount = proofs.total_amount()?;
     println!("Received {} from mint {}", receive_amount, mint_url);
 

+ 9 - 7
crates/cdk/examples/p2pk.rs

@@ -34,18 +34,20 @@ async fn main() -> Result<(), Error> {
     // Create a new wallet
     let wallet = Wallet::new(mint_url, unit, localstore, seed, None).unwrap();
 
-    let (_invoice_to_pay, proofs) = wallet
-        .mint_once_paid(amount, None, Duration::from_secs(10))
+    let quote = wallet.mint_quote(amount, None).await?;
+    let proofs = wallet
+        .wait_and_mint_quote(
+            quote,
+            Default::default(),
+            Default::default(),
+            Duration::from_secs(10),
+        )
         .await?;
 
     // Mint the received amount
-    let received_proofs = proofs.await?;
     println!(
         "Minted nuts: {:?}",
-        received_proofs
-            .into_iter()
-            .map(|p| p.amount)
-            .collect::<Vec<_>>()
+        proofs.into_iter().map(|p| p.amount).collect::<Vec<_>>()
     );
 
     // Generate a secret key for spending conditions

+ 8 - 3
crates/cdk/examples/proof-selection.rs

@@ -31,12 +31,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     for amount in [64] {
         let amount = Amount::from(amount);
 
-        let (_invoice_to_pay, proofs) = wallet
-            .mint_once_paid(amount, None, Duration::from_secs(10))
+        let quote = wallet.mint_quote(amount, None).await?;
+        let proofs = wallet
+            .wait_and_mint_quote(
+                quote,
+                Default::default(),
+                Default::default(),
+                Duration::from_secs(10),
+            )
             .await?;
 
         // Mint the received amount
-        let proofs = proofs.await?;
         let receive_amount = proofs.total_amount()?;
         println!("Minted {}", receive_amount);
     }

+ 8 - 3
crates/cdk/examples/wallet.rs

@@ -24,12 +24,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     // Create a new wallet
     let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?;
 
-    let (_invoice_to_pay, proofs) = wallet
-        .mint_once_paid(amount, None, Duration::from_secs(10))
+    let quote = wallet.mint_quote(amount, None).await?;
+    let proofs = wallet
+        .wait_and_mint_quote(
+            quote,
+            Default::default(),
+            Default::default(),
+            Duration::from_secs(10),
+        )
         .await?;
 
     // Mint the received amount
-    let proofs = proofs.await?;
     let receive_amount = proofs.total_amount()?;
     println!("Minted {}", receive_amount);
 

+ 17 - 3
crates/cdk/src/mint/subscription/on_subscription.rs

@@ -6,7 +6,7 @@ use std::sync::Arc;
 use cdk_common::database::{self, MintDatabase};
 use cdk_common::nut17::Notification;
 use cdk_common::pub_sub::OnNewSubscription;
-use cdk_common::NotificationPayload;
+use cdk_common::{MintQuoteBolt12Response, NotificationPayload, PaymentMethod};
 use uuid::Uuid;
 
 use crate::nuts::{MeltQuoteBolt11Response, MintQuoteBolt11Response, ProofState, PublicKey};
@@ -79,8 +79,22 @@ impl OnNewSubscription for OnSubscription {
                     .map(|quotes| {
                         quotes
                             .into_iter()
-                            .filter_map(|quote| quote.map(|x| x.into()))
-                            .map(|x: MintQuoteBolt11Response<Uuid>| x.into())
+                            .filter_map(|quote| {
+                                quote.and_then(|x| match x.payment_method {
+                                    PaymentMethod::Bolt11 => {
+                                        let response: MintQuoteBolt11Response<Uuid> = x.into();
+                                        Some(response.into())
+                                    }
+                                    PaymentMethod::Bolt12 => match x.try_into() {
+                                        Ok(response) => {
+                                            let response: MintQuoteBolt12Response<Uuid> = response;
+                                            Some(response.into())
+                                        }
+                                        Err(_) => None,
+                                    },
+                                    PaymentMethod::Custom(_) => None,
+                                })
+                            })
                             .collect::<Vec<_>>()
                     })
                     .map_err(|e| e.to_string())?,

+ 7 - 0
crates/cdk/src/wallet/mod.rs

@@ -94,6 +94,8 @@ pub enum WalletSubscription {
     Bolt11MintQuoteState(Vec<String>),
     /// Melt quote subscription
     Bolt11MeltQuoteState(Vec<String>),
+    /// Mint bolt12 quote subscription
+    Bolt12MintQuoteState(Vec<String>),
 }
 
 impl From<WalletSubscription> for Params {
@@ -126,6 +128,11 @@ impl From<WalletSubscription> for Params {
                 kind: Kind::Bolt11MeltQuote,
                 id: id.into(),
             },
+            WalletSubscription::Bolt12MintQuoteState(filters) => Params {
+                filters,
+                kind: Kind::Bolt12MintQuote,
+                id: id.into(),
+            },
         }
     }
 }

+ 5 - 0
crates/cdk/src/wallet/subscription/http.rs

@@ -66,6 +66,11 @@ async fn convert_subscription(
                 }
             }
         }
+        Kind::Bolt12MintQuote => {
+            for id in sub.1.filters.iter().map(|id| UrlType::Mint(id.clone())) {
+                subscribed_to.insert(id, (sub.0.clone(), sub.1.id.clone(), AnyState::Empty));
+            }
+        }
     }
 
     Some(())

+ 32 - 51
crates/cdk/src/wallet/wait.rs

@@ -1,9 +1,8 @@
-use std::future::Future;
-
 use cdk_common::amount::SplitTarget;
 use cdk_common::wallet::{MeltQuote, MintQuote};
 use cdk_common::{
-    Amount, Error, MeltQuoteState, MintQuoteState, NotificationPayload, Proofs, SpendingConditions,
+    Amount, Error, MeltQuoteState, MintQuoteState, NotificationPayload, PaymentMethod, Proofs,
+    SpendingConditions,
 };
 use futures::future::BoxFuture;
 use tokio::time::{timeout, Duration};
@@ -11,9 +10,11 @@ use tokio::time::{timeout, Duration};
 use super::{Wallet, WalletSubscription};
 
 #[allow(private_bounds)]
+#[allow(clippy::enum_variant_names)]
 enum WaitableEvent {
     MeltQuote(String),
     MintQuote(String),
+    Bolt12MintQuote(String),
 }
 
 impl From<&MeltQuote> for WaitableEvent {
@@ -24,7 +25,11 @@ impl From<&MeltQuote> for WaitableEvent {
 
 impl From<&MintQuote> for WaitableEvent {
     fn from(event: &MintQuote) -> Self {
-        WaitableEvent::MintQuote(event.id.to_owned())
+        match event.payment_method {
+            PaymentMethod::Bolt11 => WaitableEvent::MintQuote(event.id.to_owned()),
+            PaymentMethod::Bolt12 => WaitableEvent::Bolt12MintQuote(event.id.to_owned()),
+            PaymentMethod::Custom(_) => WaitableEvent::MintQuote(event.id.to_owned()),
+        }
     }
 }
 
@@ -37,62 +42,38 @@ impl From<WaitableEvent> for WalletSubscription {
             WaitableEvent::MintQuote(quote_id) => {
                 WalletSubscription::Bolt11MintQuoteState(vec![quote_id])
             }
+            WaitableEvent::Bolt12MintQuote(quote_id) => {
+                WalletSubscription::Bolt12MintQuoteState(vec![quote_id])
+            }
         }
     }
 }
 
 impl Wallet {
     #[inline(always)]
-    async fn wait_and_mint_quote(
+    /// Mints a mint quote once it is paid
+    pub async fn wait_and_mint_quote(
         &self,
         quote: MintQuote,
         amount_split_target: SplitTarget,
         spending_conditions: Option<SpendingConditions>,
         timeout_duration: Duration,
     ) -> Result<Proofs, Error> {
-        self.wait_for_payment(&quote, timeout_duration).await?;
-        self.mint(&quote.id, amount_split_target, spending_conditions)
-            .await
-    }
-
-    /// Mints an amount and returns the invoice to be paid, and a BoxFuture that will finalize the
-    /// mint once the invoice has been paid
-    pub async fn mint_once_paid(
-        &self,
-        amount: Amount,
-        description: Option<String>,
-        timeout_duration: Duration,
-    ) -> Result<(String, impl Future<Output = Result<Proofs, Error>> + '_), Error> {
-        self.mint_once_paid_ex(
-            amount,
-            description,
-            Default::default(),
-            None,
-            timeout_duration,
-        )
-        .await
-    }
+        let amount = self.wait_for_payment(&quote, timeout_duration).await?;
 
-    /// Similar function to mint_once_paid but with no default options
-    pub async fn mint_once_paid_ex(
-        &self,
-        amount: Amount,
-        description: Option<String>,
-        amount_split_target: SplitTarget,
-        spending_conditions: Option<SpendingConditions>,
-        timeout_duration: Duration,
-    ) -> Result<(String, impl Future<Output = Result<Proofs, Error>> + '_), Error> {
-        let quote = self.mint_quote(amount, description).await?;
+        tracing::debug!("Received payment notification for {}. Minting...", quote.id);
 
-        Ok((
-            quote.request.clone(),
-            self.wait_and_mint_quote(
-                quote,
-                amount_split_target,
-                spending_conditions,
-                timeout_duration,
-            ),
-        ))
+        match quote.payment_method {
+            PaymentMethod::Bolt11 => {
+                self.mint(&quote.id, amount_split_target, spending_conditions)
+                    .await
+            }
+            PaymentMethod::Bolt12 => {
+                self.mint_bolt12(&quote.id, amount, amount_split_target, spending_conditions)
+                    .await
+            }
+            _ => Err(Error::UnsupportedPaymentMethod),
+        }
     }
 
     /// Returns a BoxFuture that will wait for payment on the given event with a timeout check
@@ -101,7 +82,7 @@ impl Wallet {
         &self,
         event: T,
         timeout_duration: Duration,
-    ) -> BoxFuture<'_, Result<(), Error>>
+    ) -> BoxFuture<'_, Result<Option<Amount>, Error>>
     where
         T: Into<WaitableEvent>,
     {
@@ -114,17 +95,17 @@ impl Wallet {
                     match subscription.recv().await.ok_or(Error::Internal)? {
                         NotificationPayload::MintQuoteBolt11Response(info) => {
                             if info.state == MintQuoteState::Paid {
-                                return Ok(());
+                                return Ok(None);
                             }
                         }
                         NotificationPayload::MintQuoteBolt12Response(info) => {
-                            if info.amount_paid > Amount::ZERO {
-                                return Ok(());
+                            if info.amount_paid - info.amount_issued > Amount::ZERO {
+                                return Ok(Some(info.amount_paid - info.amount_issued));
                             }
                         }
                         NotificationPayload::MeltQuoteBolt11Response(info) => {
                             if info.state == MeltQuoteState::Paid {
-                                return Ok(());
+                                return Ok(None);
                             }
                         }
                         _ => {}