Kaynağa Gözat

feat(wallet): fetch_mint_quote (#1569)

---

Co-authored-by: thesimplekid <tsk@thesimplekid.com>
asmo 1 ay önce
ebeveyn
işleme
11a39b6c4b

+ 6 - 1
crates/cashu/src/nuts/nut04.rs

@@ -18,6 +18,7 @@ use crate::nut23::QuoteState;
 use crate::quote_id::QuoteId;
 #[cfg(feature = "mint")]
 use crate::quote_id::QuoteIdError;
+use crate::util::serde_helpers::deserialize_empty_string_as_none;
 use crate::{Amount, PublicKey};
 
 /// NUT04 Error
@@ -380,7 +381,11 @@ pub struct MintQuoteCustomResponse<Q> {
     /// Unix timestamp until the quote is valid
     pub expiry: Option<u64>,
     /// NUT-19 Pubkey
-    #[serde(skip_serializing_if = "Option::is_none")]
+    #[serde(
+        default,
+        skip_serializing_if = "Option::is_none",
+        deserialize_with = "deserialize_empty_string_as_none"
+    )]
     pub pubkey: Option<PublicKey>,
     /// Extra payment-method-specific fields
     ///

+ 6 - 1
crates/cashu/src/nuts/nut06.rs

@@ -13,6 +13,7 @@ use super::{
     nut04, nut05, nut15, nut19, AuthRequired, BlindAuthSettings, ClearAuthSettings,
     MppMethodSettings, ProtectedEndpoint,
 };
+use crate::util::serde_helpers::deserialize_empty_string_as_none;
 use crate::CurrencyUnit;
 
 /// Mint Version
@@ -73,7 +74,11 @@ pub struct MintInfo {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub name: Option<String>,
     /// hex pubkey of the mint
-    #[serde(skip_serializing_if = "Option::is_none")]
+    #[serde(
+        default,
+        skip_serializing_if = "Option::is_none",
+        deserialize_with = "deserialize_empty_string_as_none"
+    )]
     pub pubkey: Option<PublicKey>,
     /// implementation name and the version running
     #[serde(skip_serializing_if = "Option::is_none")]

+ 7 - 1
crates/cashu/src/nuts/nut23.rs

@@ -6,6 +6,8 @@ use std::str::FromStr;
 use lightning_invoice::Bolt11Invoice;
 use serde::de::DeserializeOwned;
 use serde::{Deserialize, Serialize};
+
+use crate::util::serde_helpers::deserialize_empty_string_as_none;
 use thiserror::Error;
 
 use super::{BlindSignature, CurrencyUnit, MeltQuoteState, Mpp, PublicKey};
@@ -100,7 +102,11 @@ pub struct MintQuoteBolt11Response<Q> {
     /// Unix timestamp until the quote is valid
     pub expiry: Option<u64>,
     /// NUT-19 Pubkey
-    #[serde(skip_serializing_if = "Option::is_none")]
+    #[serde(
+        default,
+        skip_serializing_if = "Option::is_none",
+        deserialize_with = "deserialize_empty_string_as_none"
+    )]
     pub pubkey: Option<PublicKey>,
 }
 impl<Q: ToString> MintQuoteBolt11Response<Q> {

+ 1 - 0
crates/cashu/src/util/mod.rs

@@ -1,6 +1,7 @@
 //! Cashu utils
 
 pub mod hex;
+pub mod serde_helpers;
 
 use bitcoin::secp256k1::{rand, All, Secp256k1};
 use once_cell::sync::Lazy;

+ 67 - 0
crates/cashu/src/util/serde_helpers.rs

@@ -0,0 +1,67 @@
+//! Serde helper functions
+
+use serde::{Deserialize, Deserializer};
+
+/// Deserializes an optional value, treating empty strings as `None`.
+///
+/// This is useful when external APIs return `"pubkey": ""` instead of `null`
+/// or omitting the field entirely.
+pub fn deserialize_empty_string_as_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
+where
+    D: Deserializer<'de>,
+    T: std::str::FromStr,
+    T::Err: std::fmt::Display,
+{
+    // First deserialize as an Option<String> to handle both null and string values
+    let opt: Option<String> = Option::deserialize(deserializer)?;
+
+    match opt {
+        None => Ok(None),
+        Some(s) if s.is_empty() => Ok(None),
+        Some(s) => s.parse::<T>().map(Some).map_err(serde::de::Error::custom),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use serde::Deserialize;
+
+    use crate::PublicKey;
+
+    use super::*;
+
+    #[derive(Debug, Deserialize, PartialEq)]
+    struct TestStruct {
+        #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
+        pubkey: Option<PublicKey>,
+    }
+
+    #[test]
+    fn test_empty_string_as_none() {
+        let json = r#"{"pubkey": ""}"#;
+        let result: TestStruct = serde_json::from_str(json).unwrap();
+        assert_eq!(result.pubkey, None);
+    }
+
+    #[test]
+    fn test_null_as_none() {
+        let json = r#"{"pubkey": null}"#;
+        let result: TestStruct = serde_json::from_str(json).unwrap();
+        assert_eq!(result.pubkey, None);
+    }
+
+    #[test]
+    fn test_missing_field_as_none() {
+        let json = r#"{}"#;
+        let result: TestStruct = serde_json::from_str(json).unwrap();
+        assert_eq!(result.pubkey, None);
+    }
+
+    #[test]
+    fn test_valid_pubkey() {
+        let json =
+            r#"{"pubkey": "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"}"#;
+        let result: TestStruct = serde_json::from_str(json).unwrap();
+        assert!(result.pubkey.is_some());
+    }
+}

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

@@ -110,6 +110,9 @@ pub enum Error {
     /// Unsupported payment method
     #[error("Payment method unsupported")]
     UnsupportedPaymentMethod,
+    /// Payment method required
+    #[error("Payment method required")]
+    PaymentMethodRequired,
     /// Could not parse bolt12
     #[error("Could not parse bolt12")]
     Bolt12parse,

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

@@ -387,6 +387,34 @@ impl MultiMintWallet {
         Ok(quote.into())
     }
 
+    /// Fetch a mint quote from the mint and store it locally
+    ///
+    /// This method contacts the mint to get the current state of a quote,
+    /// creates or updates the quote in local storage, and returns the stored quote.
+    ///
+    /// Works with all payment methods (Bolt11, Bolt12, and custom payment methods).
+    ///
+    /// # Arguments
+    /// * `mint_url` - The URL of the mint
+    /// * `quote_id` - The ID of the quote to fetch
+    /// * `payment_method` - The payment method for the quote. Required if the quote
+    ///   is not already stored locally. If the quote exists locally, the stored
+    ///   payment method will be used and this parameter is ignored.
+    pub async fn fetch_mint_quote(
+        &self,
+        mint_url: MintUrl,
+        quote_id: String,
+        payment_method: Option<PaymentMethod>,
+    ) -> Result<MintQuote, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let method = payment_method.map(Into::into);
+        let quote = self
+            .inner
+            .fetch_mint_quote(&cdk_mint_url, &quote_id, method)
+            .await?;
+        Ok(quote.into())
+    }
+
     /// Mint tokens at a specific mint
     pub async fn mint(
         &self,

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

@@ -233,6 +233,25 @@ impl Wallet {
         Ok(quote.into())
     }
 
+    /// Fetch a mint quote from the mint and store it locally
+    ///
+    /// Works with all payment methods (Bolt11, Bolt12, and custom payment methods).
+    ///
+    /// # Arguments
+    /// * `quote_id` - The ID of the quote to fetch
+    /// * `payment_method` - The payment method for the quote. Required if the quote
+    ///   is not already stored locally. If the quote exists locally, the stored
+    ///   payment method will be used and this parameter is ignored.
+    pub async fn fetch_mint_quote(
+        &self,
+        quote_id: String,
+        payment_method: Option<PaymentMethod>,
+    ) -> Result<MintQuote, FfiError> {
+        let method = payment_method.map(Into::into);
+        let quote = self.inner.fetch_mint_quote(&quote_id, method).await?;
+        Ok(quote.into())
+    }
+
     /// Mint tokens
     pub async fn mint(
         &self,

+ 119 - 0
crates/cdk/src/wallet/issue/mod.rs

@@ -358,4 +358,123 @@ impl Wallet {
 
         Ok(saga.into_proofs())
     }
+
+    /// Fetch a mint quote from the mint and store it locally
+    ///
+    /// This method contacts the mint to get the current state of a quote,
+    /// creates or updates the quote in local storage, and returns the stored quote.
+    ///
+    /// Works with all payment methods (Bolt11, Bolt12, and custom payment methods).
+    ///
+    /// # Arguments
+    /// * `quote_id` - The ID of the quote to fetch
+    /// * `payment_method` - The payment method for the quote. Required if the quote
+    ///   is not already stored locally. If the quote exists locally, the stored
+    ///   payment method will be used and this parameter is ignored.
+    ///
+    /// # Errors
+    /// Returns `Error::PaymentMethodRequired` if the quote is not found locally
+    /// and no payment method is provided.
+    #[instrument(skip(self, quote_id))]
+    pub async fn fetch_mint_quote(
+        &self,
+        quote_id: &str,
+        payment_method: Option<PaymentMethod>,
+    ) -> Result<MintQuote, Error> {
+        // Check if we already have this quote stored locally
+        let existing_quote = self.localstore.get_mint_quote(quote_id).await?;
+
+        // Determine the payment method to use
+        let method = match (&existing_quote, &payment_method) {
+            (Some(q), _) => q.payment_method.clone(),
+            (None, Some(m)) => m.clone(),
+            (None, None) => return Err(Error::PaymentMethodRequired),
+        };
+
+        // Fetch the quote status from the mint based on payment method
+        let quote = match &method {
+            PaymentMethod::Known(KnownMethod::Bolt11) => {
+                let response = self.client.get_mint_quote_status(quote_id).await?;
+
+                match existing_quote {
+                    Some(mut existing) => {
+                        // Update the existing quote with new state
+                        existing.state = response.state;
+                        existing
+                    }
+                    None => {
+                        // Create a new quote from the response
+                        MintQuote::new(
+                            quote_id.to_string(),
+                            self.mint_url.clone(),
+                            method,
+                            response.amount,
+                            response.unit.unwrap_or(self.unit.clone()),
+                            response.request,
+                            response.expiry.unwrap_or(0),
+                            None,
+                        )
+                    }
+                }
+            }
+            PaymentMethod::Known(KnownMethod::Bolt12) => {
+                let response = self.client.get_mint_quote_bolt12_status(quote_id).await?;
+
+                match existing_quote {
+                    Some(mut existing) => {
+                        // Update the existing quote with new state from bolt12 response
+                        existing.amount_paid = response.amount_paid;
+                        existing.amount_issued = response.amount_issued;
+                        existing
+                    }
+                    None => {
+                        // Create a new quote from the response
+                        MintQuote::new(
+                            quote_id.to_string(),
+                            self.mint_url.clone(),
+                            method,
+                            response.amount,
+                            response.unit,
+                            response.request,
+                            response.expiry.unwrap_or(0),
+                            None,
+                        )
+                    }
+                }
+            }
+            PaymentMethod::Custom(custom_method) => {
+                let response = self
+                    .client
+                    .get_mint_quote_custom_status(custom_method, quote_id)
+                    .await?;
+
+                match existing_quote {
+                    Some(mut existing) => {
+                        // Update the existing quote with new state
+                        existing.amount_paid = response.amount.unwrap_or_default();
+                        existing.amount_issued = response.amount.unwrap_or_default();
+                        existing
+                    }
+                    None => {
+                        // Create a new quote from the response
+                        MintQuote::new(
+                            quote_id.to_string(),
+                            self.mint_url.clone(),
+                            method,
+                            response.amount,
+                            response.unit.unwrap_or(self.unit.clone()),
+                            response.request,
+                            response.expiry.unwrap_or(0),
+                            None,
+                        )
+                    }
+                }
+            }
+        };
+
+        // Store the quote
+        self.localstore.add_mint_quote(quote.clone()).await?;
+
+        Ok(quote)
+    }
 }

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

@@ -1642,6 +1642,31 @@ impl MultiMintWallet {
         Ok(quote)
     }
 
+    /// Fetch a mint quote from the mint and store it locally
+    ///
+    /// Works with all payment methods (Bolt11, Bolt12, and custom payment methods).
+    ///
+    /// # Arguments
+    /// * `mint_url` - The URL of the mint
+    /// * `quote_id` - The ID of the quote to fetch
+    /// * `payment_method` - The payment method for the quote. Required if the quote
+    ///   is not already stored locally. If the quote exists locally, the stored
+    ///   payment method will be used and this parameter is ignored.
+    #[instrument(skip(self))]
+    pub async fn fetch_mint_quote(
+        &self,
+        mint_url: &MintUrl,
+        quote_id: &str,
+        payment_method: Option<PaymentMethod>,
+    ) -> Result<MintQuote, Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet.fetch_mint_quote(quote_id, payment_method).await
+    }
+
     /// Mint tokens at a specific mint
     #[instrument(skip(self))]
     pub async fn mint(