浏览代码

feat(cdk): add invoice decoding for bolt11 and bolt12 (#1294)

* feat(cdk): add invoice decoding for bolt11 and bolt12

Add decode_invoice function to parse both bolt11 invoices and bolt12 offers,
extracting amount, expiry, and description. Includes FFI bindings for cross-language support.
tsk 2 天之前
父节点
当前提交
15c315ea09

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

@@ -110,6 +110,9 @@ pub enum Error {
     /// Could not parse bolt12
     #[error("Could not parse bolt12")]
     Bolt12parse,
+    /// Could not parse invoice (bolt11 or bolt12)
+    #[error("Could not parse invoice")]
+    InvalidInvoice,
 
     /// BIP353 address parsing error
     #[error("Failed to parse BIP353 address: {0}")]

+ 96 - 0
crates/cdk-ffi/src/types/invoice.rs

@@ -0,0 +1,96 @@
+//! Invoice decoding FFI types and functions
+
+use serde::{Deserialize, Serialize};
+
+use crate::error::FfiError;
+
+/// Type of Lightning payment request
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Enum)]
+pub enum PaymentType {
+    /// Bolt11 invoice
+    Bolt11,
+    /// Bolt12 offer
+    Bolt12,
+}
+
+impl From<cdk::invoice::PaymentType> for PaymentType {
+    fn from(payment_type: cdk::invoice::PaymentType) -> Self {
+        match payment_type {
+            cdk::invoice::PaymentType::Bolt11 => Self::Bolt11,
+            cdk::invoice::PaymentType::Bolt12 => Self::Bolt12,
+        }
+    }
+}
+
+impl From<PaymentType> for cdk::invoice::PaymentType {
+    fn from(payment_type: PaymentType) -> Self {
+        match payment_type {
+            PaymentType::Bolt11 => Self::Bolt11,
+            PaymentType::Bolt12 => Self::Bolt12,
+        }
+    }
+}
+
+/// Decoded invoice or offer information
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct DecodedInvoice {
+    /// Type of payment request (Bolt11 or Bolt12)
+    pub payment_type: PaymentType,
+    /// Amount in millisatoshis, if specified
+    pub amount_msat: Option<u64>,
+    /// Expiry timestamp (Unix timestamp), if specified
+    pub expiry: Option<u64>,
+    /// Description or offer description, if specified
+    pub description: Option<String>,
+}
+
+impl From<cdk::invoice::DecodedInvoice> for DecodedInvoice {
+    fn from(decoded: cdk::invoice::DecodedInvoice) -> Self {
+        Self {
+            payment_type: decoded.payment_type.into(),
+            amount_msat: decoded.amount_msat,
+            expiry: decoded.expiry,
+            description: decoded.description,
+        }
+    }
+}
+
+impl From<DecodedInvoice> for cdk::invoice::DecodedInvoice {
+    fn from(decoded: DecodedInvoice) -> Self {
+        Self {
+            payment_type: decoded.payment_type.into(),
+            amount_msat: decoded.amount_msat,
+            expiry: decoded.expiry,
+            description: decoded.description,
+        }
+    }
+}
+
+/// Decode a bolt11 invoice or bolt12 offer from a string
+///
+/// This function attempts to parse the input as a bolt11 invoice first,
+/// then as a bolt12 offer if bolt11 parsing fails.
+///
+/// # Arguments
+///
+/// * `invoice_str` - The invoice or offer string to decode
+///
+/// # Returns
+///
+/// * `Ok(DecodedInvoice)` - Successfully decoded invoice/offer information
+/// * `Err(FfiError)` - Failed to parse as either bolt11 or bolt12
+///
+/// # Example
+///
+/// ```kotlin
+/// val decoded = decodeInvoice("lnbc...")
+/// when (decoded.paymentType) {
+///     PaymentType.BOLT11 -> println("Bolt11 invoice")
+///     PaymentType.BOLT12 -> println("Bolt12 offer")
+/// }
+/// ```
+#[uniffi::export]
+pub fn decode_invoice(invoice_str: String) -> Result<DecodedInvoice, FfiError> {
+    let decoded = cdk::invoice::decode_invoice(&invoice_str)?;
+    Ok(decoded.into())
+}

+ 2 - 0
crates/cdk-ffi/src/types/mod.rs

@@ -5,6 +5,7 @@
 
 // Module declarations
 pub mod amount;
+pub mod invoice;
 pub mod keys;
 pub mod mint;
 pub mod proof;
@@ -15,6 +16,7 @@ pub mod wallet;
 
 // Re-export all types for convenient access
 pub use amount::*;
+pub use invoice::*;
 pub use keys::*;
 pub use mint::*;
 pub use proof::*;

+ 129 - 0
crates/cdk/src/invoice.rs

@@ -0,0 +1,129 @@
+//! Invoice and offer decoding utilities
+//!
+//! Provides standalone functions to decode bolt11 invoices and bolt12 offers
+//! without requiring a wallet instance or creating melt quotes.
+
+use std::str::FromStr;
+
+use lightning::offers::offer::Offer;
+use lightning_invoice::Bolt11Invoice;
+
+use crate::error::Error;
+
+/// Type of Lightning payment request
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum PaymentType {
+    /// Bolt11 invoice
+    Bolt11,
+    /// Bolt12 offer
+    Bolt12,
+}
+
+/// Decoded invoice or offer information
+#[derive(Debug, Clone)]
+pub struct DecodedInvoice {
+    /// Type of payment request (Bolt11 or Bolt12)
+    pub payment_type: PaymentType,
+    /// Amount in millisatoshis, if specified
+    pub amount_msat: Option<u64>,
+    /// Expiry timestamp (Unix timestamp), if specified
+    pub expiry: Option<u64>,
+    /// Description or offer description, if specified
+    pub description: Option<String>,
+}
+
+/// Decode a bolt11 invoice or bolt12 offer from a string
+///
+/// This function attempts to parse the input as a bolt11 invoice first,
+/// then as a bolt12 offer if bolt11 parsing fails.
+///
+/// # Arguments
+///
+/// * `invoice_str` - The invoice or offer string to decode
+///
+/// # Returns
+///
+/// * `Ok(DecodedInvoice)` - Successfully decoded invoice/offer information
+/// * `Err(Error)` - Failed to parse as either bolt11 or bolt12
+///
+/// # Example
+///
+/// ```ignore
+/// let decoded = decode_invoice("lnbc...")?;
+/// match decoded.payment_type {
+///     PaymentType::Bolt11 => println!("Bolt11 invoice"),
+///     PaymentType::Bolt12 => println!("Bolt12 offer"),
+/// }
+/// ```
+pub fn decode_invoice(invoice_str: &str) -> Result<DecodedInvoice, Error> {
+    // Try to parse as Bolt11 first
+    if let Ok(invoice) = Bolt11Invoice::from_str(invoice_str) {
+        let amount_msat = invoice.amount_milli_satoshis();
+
+        let expiry = invoice.expires_at().map(|duration| duration.as_secs());
+
+        let description = match invoice.description() {
+            lightning_invoice::Bolt11InvoiceDescriptionRef::Direct(desc) => Some(desc.to_string()),
+            lightning_invoice::Bolt11InvoiceDescriptionRef::Hash(hash) => {
+                Some(format!("Hash: {}", hash.0))
+            }
+        };
+
+        return Ok(DecodedInvoice {
+            payment_type: PaymentType::Bolt11,
+            amount_msat,
+            expiry,
+            description,
+        });
+    }
+
+    // Try to parse as Bolt12
+    if let Ok(offer) = Offer::from_str(invoice_str) {
+        let amount_msat = offer.amount().and_then(|amount| {
+            // Bolt12 amounts can be in different currencies
+            // For now, we only extract if it's in Bitcoin (millisatoshis)
+            match amount {
+                lightning::offers::offer::Amount::Bitcoin { amount_msats } => Some(amount_msats),
+                _ => None,
+            }
+        });
+
+        let expiry = offer.absolute_expiry().map(|duration| duration.as_secs());
+
+        let description = offer.description().map(|d| d.to_string());
+
+        return Ok(DecodedInvoice {
+            payment_type: PaymentType::Bolt12,
+            amount_msat,
+            expiry,
+            description,
+        });
+    }
+
+    // If both parsing attempts failed
+    Err(Error::InvalidInvoice)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_decode_bolt11() {
+        // This is a valid bolt11 invoice for 100 sats
+        let bolt11 = "lnbc1u1p53kkd9pp5ve8pd9zr60yjyvs6tn77mndavzrl5lwd2gx5hk934f6q8jwguzgsdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5482y73fxmlvg4t66nupdaph93h7dcmfsg2ud72wajf0cpk3a96rq9qxpqysgqujexd0l89u5dutn8hxnsec0c7jrt8wz0z67rut0eah0g7p6zhycn2vff0ts5vwn2h93kx8zzqy3tzu4gfhkya2zpdmqelg0ceqnjztcqma65pr";
+
+        let result = decode_invoice(bolt11);
+        assert!(result.is_ok());
+
+        let decoded = result.unwrap();
+        assert_eq!(decoded.payment_type, PaymentType::Bolt11);
+        assert_eq!(decoded.amount_msat, Some(100000));
+    }
+
+    #[test]
+    fn test_invalid_invoice() {
+        let result = decode_invoice("invalid_string");
+        assert!(result.is_err());
+    }
+}

+ 1 - 0
crates/cdk/src/lib.rs

@@ -51,6 +51,7 @@ pub use oidc_client::OidcClient;
 #[cfg(any(feature = "wallet", feature = "mint"))]
 pub mod event;
 pub mod fees;
+pub mod invoice;
 
 #[doc(hidden)]
 pub use bitcoin::secp256k1;

+ 1 - 2
crates/cdk/src/mint/melt/tests/htlc_sigall_spending_conditions_tests.rs

@@ -8,8 +8,7 @@ use std::str::FromStr;
 use cdk_common::dhke::construct_proofs;
 use cdk_common::melt::MeltQuoteRequest;
 use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions};
-use cdk_common::Amount;
-use cdk_common::SpendingConditionVerification;
+use cdk_common::{Amount, SpendingConditionVerification};
 
 use crate::test_helpers::nut10::{
     create_test_hash_and_preimage, create_test_keypair, unzip3, TestMintHelper,

+ 2 - 1
crates/cdk/src/mint/melt/tests/htlc_spending_conditions_tests.rs

@@ -96,8 +96,9 @@ async fn test_htlc_requiring_preimage_and_one_signature() {
     );
 
     // Step 6: Create a real melt quote that we'll use for all tests
-    use cdk_common::SpendingConditionVerification;
     use std::str::FromStr;
+
+    use cdk_common::SpendingConditionVerification;
     let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
     let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap();
 

+ 1 - 2
crates/cdk/src/mint/melt/tests/locktime_spending_conditions_tests.rs

@@ -8,8 +8,7 @@ use std::str::FromStr;
 use cdk_common::dhke::construct_proofs;
 use cdk_common::melt::MeltQuoteRequest;
 use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions};
-use cdk_common::Amount;
-use cdk_common::SpendingConditionVerification;
+use cdk_common::{Amount, SpendingConditionVerification};
 
 use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper};
 use crate::util::unix_time;

+ 2 - 1
crates/cdk/src/mint/melt/tests/p2pk_sigall_spending_conditions_tests.rs

@@ -90,8 +90,9 @@ async fn test_p2pk_sig_all_requires_transaction_signature() {
     );
 
     // Step 5: Create a real melt quote that we'll use for all tests
-    use cdk_common::SpendingConditionVerification;
     use std::str::FromStr;
+
+    use cdk_common::SpendingConditionVerification;
     let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
     let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap();
 

+ 1 - 2
crates/cdk/src/mint/melt/tests/p2pk_spending_conditions_tests.rs

@@ -8,8 +8,7 @@ use std::str::FromStr;
 use cdk_common::dhke::construct_proofs;
 use cdk_common::melt::MeltQuoteRequest;
 use cdk_common::nuts::SpendingConditions;
-use cdk_common::Amount;
-use cdk_common::SpendingConditionVerification;
+use cdk_common::{Amount, SpendingConditionVerification};
 
 use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper};
 

+ 1 - 1
crates/cdk/src/mint/swap/mod.rs

@@ -1,3 +1,4 @@
+use cdk_common::SpendingConditionVerification;
 #[cfg(feature = "prometheus")]
 use cdk_prometheus::METRICS;
 use swap_saga::SwapSaga;
@@ -5,7 +6,6 @@ use tracing::instrument;
 
 use super::{Mint, SwapRequest, SwapResponse};
 use crate::Error;
-use cdk_common::SpendingConditionVerification;
 
 pub mod swap_saga;
 

+ 1 - 1
crates/cdk/src/mint/swap/tests/p2pk_sigall_spending_conditions_tests.rs

@@ -2,13 +2,13 @@
 //!
 //! These tests verify that the mint correctly enforces SIG_ALL flag behavior
 
-use crate::util::unix_time;
 use cdk_common::dhke::construct_proofs;
 use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions};
 use cdk_common::Amount;
 
 use crate::test_helpers::mint::create_test_blinded_messages;
 use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper};
+use crate::util::unix_time;
 
 /// Test: P2PK with SIG_ALL flag requires transaction signature
 ///

+ 1 - 1
crates/cdk/src/mint/swap/tests/p2pk_spending_conditions_tests.rs

@@ -8,13 +8,13 @@
 //! - Refund keys
 //! - Signature validation
 
-use crate::util::unix_time;
 use cdk_common::dhke::construct_proofs;
 use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions};
 use cdk_common::Amount;
 
 use crate::test_helpers::mint::create_test_blinded_messages;
 use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper};
+use crate::util::unix_time;
 
 /// Test: P2PK with single pubkey requires all proofs signed
 ///

+ 3 - 3
crates/cdk/src/test_helpers/nut10.rs

@@ -1,9 +1,6 @@
 #![cfg(test)]
 //! Shared test helpers for spending condition tests (P2PK, HTLC, etc.)
 
-use crate::mint::Mint;
-use crate::secret::Secret;
-use crate::Error;
 use cdk_common::dhke::blind_message;
 use cdk_common::nuts::nut10::Secret as Nut10Secret;
 use cdk_common::nuts::{
@@ -11,7 +8,10 @@ use cdk_common::nuts::{
 };
 use cdk_common::Amount;
 
+use crate::mint::Mint;
+use crate::secret::Secret;
 use crate::test_helpers::mint::{create_test_mint, mint_test_proofs};
+use crate::Error;
 
 /// Test mint wrapper with convenient access to common keyset info
 pub struct TestMintHelper {