From 07b5471d10b71e5693fac9dcb9a183da1aaff46e Mon Sep 17 00:00:00 2001 From: alexanderwiederin Date: Sun, 1 Jun 2025 06:43:02 +0200 Subject: [PATCH 1/4] Restructure FFI bindings with conversion traits for Lightning types This commit reorganizes the FFI architecture by introducing conversion traits for lightning types. Moves code from uniffi_types.rs to a dedicated ffi module for separation of concerns. --- src/ffi/mod.rs | 47 ++++++++++++++++ src/{uniffi_types.rs => ffi/types.rs} | 19 ++++--- src/lib.rs | 5 +- src/liquidity.rs | 2 +- src/payment/bolt11.rs | 80 +++++++++------------------ src/payment/unified_qr.rs | 5 +- 6 files changed, 89 insertions(+), 69 deletions(-) create mode 100644 src/ffi/mod.rs rename src/{uniffi_types.rs => ffi/types.rs} (98%) diff --git a/src/ffi/mod.rs b/src/ffi/mod.rs new file mode 100644 index 000000000..32464d044 --- /dev/null +++ b/src/ffi/mod.rs @@ -0,0 +1,47 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in + +#[cfg(feature = "uniffi")] +mod types; + +#[cfg(feature = "uniffi")] +pub use types::*; + +#[cfg(feature = "uniffi")] +pub fn maybe_deref(wrapped_type: &std::sync::Arc) -> &R +where + T: AsRef, +{ + wrapped_type.as_ref().as_ref() +} + +#[cfg(feature = "uniffi")] +pub fn maybe_try_convert_enum(wrapped_type: &T) -> Result +where + for<'a> R: TryFrom<&'a T, Error = crate::error::Error>, +{ + R::try_from(wrapped_type) +} + +#[cfg(feature = "uniffi")] +pub fn maybe_wrap(ldk_type: impl Into) -> std::sync::Arc { + std::sync::Arc::new(ldk_type.into()) +} + +#[cfg(not(feature = "uniffi"))] +pub fn maybe_deref(value: &T) -> &T { + value +} + +#[cfg(not(feature = "uniffi"))] +pub fn maybe_try_convert_enum(value: &T) -> Result<&T, crate::error::Error> { + Ok(value) +} + +#[cfg(not(feature = "uniffi"))] +pub fn maybe_wrap(value: T) -> T { + value +} diff --git a/src/uniffi_types.rs b/src/ffi/types.rs similarity index 98% rename from src/uniffi_types.rs rename to src/ffi/types.rs index 77f9348cc..4d3093476 100644 --- a/src/uniffi_types.rs +++ b/src/ffi/types.rs @@ -61,6 +61,7 @@ use lightning::util::ser::Writeable; use lightning_invoice::{Bolt11Invoice as LdkBolt11Invoice, Bolt11InvoiceDescriptionRef}; use std::convert::TryInto; +use std::ops::Deref; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; @@ -475,11 +476,6 @@ impl Bolt11Invoice { invoice_str.parse() } - /// Returns the underlying invoice [`LdkBolt11Invoice`] - pub fn into_inner(self) -> LdkBolt11Invoice { - self.inner - } - /// The hash of the [`RawBolt11Invoice`] that was signed. /// /// [`RawBolt11Invoice`]: lightning_invoice::RawBolt11Invoice @@ -593,9 +589,16 @@ impl From for Bolt11Invoice { } } -impl From for LdkBolt11Invoice { - fn from(wrapper: Bolt11Invoice) -> Self { - wrapper.into_inner() +impl Deref for Bolt11Invoice { + type Target = LdkBolt11Invoice; + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl AsRef for Bolt11Invoice { + fn as_ref(&self) -> &LdkBolt11Invoice { + self.deref() } } diff --git a/src/lib.rs b/src/lib.rs index c3bfe16d8..e80ca964d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,6 +84,7 @@ mod data_store; mod error; mod event; mod fee_estimator; +mod ffi; mod gossip; pub mod graph; mod hex_utils; @@ -96,8 +97,6 @@ mod peer_store; mod sweep; mod tx_broadcaster; mod types; -#[cfg(feature = "uniffi")] -mod uniffi_types; mod wallet; pub use bip39; @@ -117,7 +116,7 @@ pub use event::Event; pub use io::utils::generate_entropy_mnemonic; #[cfg(feature = "uniffi")] -use uniffi_types::*; +use ffi::*; #[cfg(feature = "uniffi")] pub use builder::ArcedNodeBuilder as Builder; diff --git a/src/liquidity.rs b/src/liquidity.rs index 47f3dcce4..a4516edd0 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -1308,7 +1308,7 @@ type PaymentInfo = lightning_liquidity::lsps1::msgs::PaymentInfo; #[derive(Clone, Debug, PartialEq, Eq)] pub struct PaymentInfo { /// A Lightning payment using BOLT 11. - pub bolt11: Option, + pub bolt11: Option, /// An onchain payment. pub onchain: Option, } diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 052571818..817a428bd 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -13,6 +13,7 @@ use crate::config::{Config, LDK_PAYMENT_RETRY_TIMEOUT}; use crate::connection::ConnectionManager; use crate::data_store::DataStoreUpdateResult; use crate::error::Error; +use crate::ffi::{maybe_deref, maybe_try_convert_enum, maybe_wrap}; use crate::liquidity::LiquiditySource; use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::payment::store::{ @@ -42,43 +43,12 @@ use std::sync::{Arc, RwLock}; #[cfg(not(feature = "uniffi"))] type Bolt11Invoice = LdkBolt11Invoice; #[cfg(feature = "uniffi")] -type Bolt11Invoice = Arc; - -#[cfg(not(feature = "uniffi"))] -pub(crate) fn maybe_wrap_invoice(invoice: LdkBolt11Invoice) -> Bolt11Invoice { - invoice -} -#[cfg(feature = "uniffi")] -pub(crate) fn maybe_wrap_invoice(invoice: LdkBolt11Invoice) -> Bolt11Invoice { - Arc::new(invoice.into()) -} - -#[cfg(not(feature = "uniffi"))] -pub fn maybe_convert_invoice(invoice: &Bolt11Invoice) -> &LdkBolt11Invoice { - invoice -} -#[cfg(feature = "uniffi")] -pub fn maybe_convert_invoice(invoice: &Bolt11Invoice) -> &LdkBolt11Invoice { - &invoice.inner -} +type Bolt11Invoice = Arc; #[cfg(not(feature = "uniffi"))] type Bolt11InvoiceDescription = LdkBolt11InvoiceDescription; #[cfg(feature = "uniffi")] -type Bolt11InvoiceDescription = crate::uniffi_types::Bolt11InvoiceDescription; - -macro_rules! maybe_convert_description { - ($description: expr) => {{ - #[cfg(not(feature = "uniffi"))] - { - $description - } - #[cfg(feature = "uniffi")] - { - &LdkBolt11InvoiceDescription::try_from($description)? - } - }}; -} +type Bolt11InvoiceDescription = crate::ffi::Bolt11InvoiceDescription; /// A payment handler allowing to create and pay [BOLT 11] invoices. /// @@ -125,7 +95,7 @@ impl Bolt11Payment { pub fn send( &self, invoice: &Bolt11Invoice, sending_parameters: Option, ) -> Result { - let invoice = maybe_convert_invoice(invoice); + let invoice = maybe_deref(invoice); let rt_lock = self.runtime.read().unwrap(); if rt_lock.is_none() { return Err(Error::NotRunning); @@ -234,7 +204,7 @@ impl Bolt11Payment { &self, invoice: &Bolt11Invoice, amount_msat: u64, sending_parameters: Option, ) -> Result { - let invoice = maybe_convert_invoice(invoice); + let invoice = maybe_deref(invoice); let rt_lock = self.runtime.read().unwrap(); if rt_lock.is_none() { return Err(Error::NotRunning); @@ -466,9 +436,9 @@ impl Bolt11Payment { pub fn receive( &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, ) -> Result { - let description = maybe_convert_description!(description); - let invoice = self.receive_inner(Some(amount_msat), description, expiry_secs, None)?; - Ok(maybe_wrap_invoice(invoice)) + let description = maybe_try_convert_enum(description)?; + let invoice = self.receive_inner(Some(amount_msat), &description, expiry_secs, None)?; + Ok(maybe_wrap(invoice)) } /// Returns a payable invoice that can be used to request a payment of the amount @@ -489,10 +459,10 @@ impl Bolt11Payment { &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: PaymentHash, ) -> Result { - let description = maybe_convert_description!(description); + let description = maybe_try_convert_enum(description)?; let invoice = - self.receive_inner(Some(amount_msat), description, expiry_secs, Some(payment_hash))?; - Ok(maybe_wrap_invoice(invoice)) + self.receive_inner(Some(amount_msat), &description, expiry_secs, Some(payment_hash))?; + Ok(maybe_wrap(invoice)) } /// Returns a payable invoice that can be used to request and receive a payment for which the @@ -502,9 +472,9 @@ impl Bolt11Payment { pub fn receive_variable_amount( &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, ) -> Result { - let description = maybe_convert_description!(description); - let invoice = self.receive_inner(None, description, expiry_secs, None)?; - Ok(maybe_wrap_invoice(invoice)) + let description = maybe_try_convert_enum(description)?; + let invoice = self.receive_inner(None, &description, expiry_secs, None)?; + Ok(maybe_wrap(invoice)) } /// Returns a payable invoice that can be used to request a payment for the given payment hash @@ -524,9 +494,9 @@ impl Bolt11Payment { pub fn receive_variable_amount_for_hash( &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: PaymentHash, ) -> Result { - let description = maybe_convert_description!(description); - let invoice = self.receive_inner(None, description, expiry_secs, Some(payment_hash))?; - Ok(maybe_wrap_invoice(invoice)) + let description = maybe_try_convert_enum(description)?; + let invoice = self.receive_inner(None, &description, expiry_secs, Some(payment_hash))?; + Ok(maybe_wrap(invoice)) } pub(crate) fn receive_inner( @@ -601,15 +571,15 @@ impl Bolt11Payment { &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, ) -> Result { - let description = maybe_convert_description!(description); + let description = maybe_try_convert_enum(description)?; let invoice = self.receive_via_jit_channel_inner( Some(amount_msat), - description, + &description, expiry_secs, max_total_lsp_fee_limit_msat, None, )?; - Ok(maybe_wrap_invoice(invoice)) + Ok(maybe_wrap(invoice)) } /// Returns a payable invoice that can be used to request a variable amount payment (also known @@ -627,15 +597,15 @@ impl Bolt11Payment { &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, max_proportional_lsp_fee_limit_ppm_msat: Option, ) -> Result { - let description = maybe_convert_description!(description); + let description = maybe_try_convert_enum(description)?; let invoice = self.receive_via_jit_channel_inner( None, - description, + &description, expiry_secs, None, max_proportional_lsp_fee_limit_ppm_msat, )?; - Ok(maybe_wrap_invoice(invoice)) + Ok(maybe_wrap(invoice)) } fn receive_via_jit_channel_inner( @@ -742,7 +712,7 @@ impl Bolt11Payment { /// amount times [`Config::probing_liquidity_limit_multiplier`] won't be used to send /// pre-flight probes. pub fn send_probes(&self, invoice: &Bolt11Invoice) -> Result<(), Error> { - let invoice = maybe_convert_invoice(invoice); + let invoice = maybe_deref(invoice); let rt_lock = self.runtime.read().unwrap(); if rt_lock.is_none() { return Err(Error::NotRunning); @@ -775,7 +745,7 @@ impl Bolt11Payment { pub fn send_probes_using_amount( &self, invoice: &Bolt11Invoice, amount_msat: u64, ) -> Result<(), Error> { - let invoice = maybe_convert_invoice(invoice); + let invoice = maybe_deref(invoice); let rt_lock = self.runtime.read().unwrap(); if rt_lock.is_none() { return Err(Error::NotRunning); diff --git a/src/payment/unified_qr.rs b/src/payment/unified_qr.rs index 5e6c1ef60..abfc5b784 100644 --- a/src/payment/unified_qr.rs +++ b/src/payment/unified_qr.rs @@ -12,8 +12,9 @@ //! [BOLT 11]: https://github.com/lightning/bolts/blob/master/11-payment-encoding.md //! [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md use crate::error::Error; +use crate::ffi::maybe_wrap; use crate::logger::{log_error, LdkLogger, Logger}; -use crate::payment::{bolt11::maybe_wrap_invoice, Bolt11Payment, Bolt12Payment, OnchainPayment}; +use crate::payment::{Bolt11Payment, Bolt12Payment, OnchainPayment}; use crate::Config; use lightning::ln::channelmanager::PaymentId; @@ -153,7 +154,7 @@ impl UnifiedQrPayment { } if let Some(invoice) = uri_network_checked.extras.bolt11_invoice { - let invoice = maybe_wrap_invoice(invoice); + let invoice = maybe_wrap(invoice); match self.bolt11_invoice.send(&invoice, None) { Ok(payment_id) => return Ok(QrPaymentResult::Bolt11 { payment_id }), Err(e) => log_error!(self.logger, "Failed to send BOLT11 invoice: {:?}. This is part of a unified QR code payment. Falling back to the on-chain transaction.", e), From ef81e0d65947aa797f5c91e92036e37c735b6737 Mon Sep 17 00:00:00 2001 From: alexanderwiederin Date: Mon, 5 May 2025 19:49:02 +0200 Subject: [PATCH 2/4] Add Offer wrapper for FFI bindings Implement Offer struct in ffi/types.rs to provide a wrapper around LDK's Offer for cross-language bindings. Modified payment handling in bolt12.rs to: - Support both native and FFI-compatible types via type aliasing - Implement conditional compilation for transparent FFI support - Update payment functions to handle wrapped types Added testing to verify that properties are preserved when wrapping/unwrapping between native and FFI types. --- bindings/ldk_node.udl | 27 +++- src/ffi/types.rs | 310 +++++++++++++++++++++++++++++++++++++- src/payment/bolt12.rs | 27 +++- src/payment/unified_qr.rs | 17 ++- 4 files changed, 357 insertions(+), 24 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index c2f0166c8..38ab4677c 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -736,6 +736,30 @@ interface Bolt11Invoice { PublicKey recover_payee_pub_key(); }; +[Enum] +interface OfferAmount { + Bitcoin(u64 amount_msats); + Currency(string iso4217_code, u64 amount); +}; + +[Traits=(Debug, Display, Eq)] +interface Offer { + [Throws=NodeError, Name=from_str] + constructor([ByRef] string offer_str); + OfferId id(); + boolean is_expired(); + string? description(); + string? issuer(); + OfferAmount? amount(); + boolean is_valid_quantity(u64 quantity); + boolean expects_quantity(); + boolean supports_chain(Network chain); + sequence chains(); + sequence? metadata(); + u64? absolute_expiry_seconds(); + PublicKey? issuer_signing_pubkey(); +}; + [Custom] typedef string Txid; @@ -754,9 +778,6 @@ typedef string NodeId; [Custom] typedef string Address; -[Custom] -typedef string Offer; - [Custom] typedef string Refund; diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 4d3093476..8b511c830 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -26,7 +26,7 @@ pub use lightning::chain::channelmonitor::BalanceSource; pub use lightning::events::{ClosureReason, PaymentFailureReason}; pub use lightning::ln::types::ChannelId; pub use lightning::offers::invoice::Bolt12Invoice; -pub use lightning::offers::offer::{Offer, OfferId}; +pub use lightning::offers::offer::OfferId; pub use lightning::offers::refund::Refund; pub use lightning::routing::gossip::{NodeAlias, NodeId, RoutingFees}; pub use lightning::util::string::UntrustedString; @@ -57,6 +57,7 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; use lightning::ln::channelmanager::PaymentId; +use lightning::offers::offer::{Amount as LdkAmount, Offer as LdkOffer}; use lightning::util::ser::Writeable; use lightning_invoice::{Bolt11Invoice as LdkBolt11Invoice, Bolt11InvoiceDescriptionRef}; @@ -114,15 +115,166 @@ impl UniffiCustomTypeConverter for Address { } } -impl UniffiCustomTypeConverter for Offer { - type Builtin = String; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OfferAmount { + Bitcoin { amount_msats: u64 }, + Currency { iso4217_code: String, amount: u64 }, +} - fn into_custom(val: Self::Builtin) -> uniffi::Result { - Offer::from_str(&val).map_err(|_| Error::InvalidOffer.into()) +impl From for OfferAmount { + fn from(ldk_amount: LdkAmount) -> Self { + match ldk_amount { + LdkAmount::Bitcoin { amount_msats } => OfferAmount::Bitcoin { amount_msats }, + LdkAmount::Currency { iso4217_code, amount } => OfferAmount::Currency { + iso4217_code: iso4217_code.iter().map(|&b| b as char).collect(), + amount, + }, + } } +} - fn from_custom(obj: Self) -> Self::Builtin { - obj.to_string() +/// An `Offer` is a potentially long-lived proposal for payment of a good or service. +/// +/// An offer is a precursor to an [`InvoiceRequest`]. A merchant publishes an offer from which a +/// customer may request an [`Bolt12Invoice`] for a specific quantity and using an amount sufficient +/// to cover that quantity (i.e., at least `quantity * amount`). See [`Offer::amount`]. +/// +/// Offers may be denominated in currency other than bitcoin but are ultimately paid using the +/// latter. +/// +/// Through the use of [`BlindedMessagePath`]s, offers provide recipient privacy. +/// +/// [`InvoiceRequest`]: lightning::offers::invoice_request::InvoiceRequest +/// [`Bolt12Invoice`]: lightning::offers::invoice::Bolt12Invoice +/// [`Offer`]: lightning::offers::Offer:amount +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Offer { + pub(crate) inner: LdkOffer, +} + +impl Offer { + pub fn from_str(offer_str: &str) -> Result { + offer_str.parse() + } + + /// Returns the id of the offer. + pub fn id(&self) -> OfferId { + OfferId(self.inner.id().0) + } + + /// Whether the offer has expired. + pub fn is_expired(&self) -> bool { + self.inner.is_expired() + } + + /// A complete description of the purpose of the payment. + /// + /// Intended to be displayed to the user but with the caveat that it has not been verified in any way. + pub fn description(&self) -> Option { + self.inner.description().map(|printable| printable.to_string()) + } + + /// The issuer of the offer, possibly beginning with `user@domain` or `domain`. + /// + /// Intended to be displayed to the user but with the caveat that it has not been verified in any way. + pub fn issuer(&self) -> Option { + self.inner.issuer().map(|printable| printable.to_string()) + } + + /// The minimum amount required for a successful payment of a single item. + pub fn amount(&self) -> Option { + self.inner.amount().map(|amount| amount.into()) + } + + /// Returns whether the given quantity is valid for the offer. + pub fn is_valid_quantity(&self, quantity: u64) -> bool { + self.inner.is_valid_quantity(quantity) + } + + /// Returns whether a quantity is expected in an [`InvoiceRequest`] for the offer. + /// + /// [`InvoiceRequest`]: lightning::offers::invoice_request::InvoiceRequest + pub fn expects_quantity(&self) -> bool { + self.inner.expects_quantity() + } + + /// Returns whether the given chain is supported by the offer. + pub fn supports_chain(&self, chain: Network) -> bool { + self.inner.supports_chain(chain.chain_hash()) + } + + /// The chains that may be used when paying a requested invoice (e.g., bitcoin mainnet). + /// + /// Payments must be denominated in units of the minimal lightning-payable unit (e.g., msats) + /// for the selected chain. + pub fn chains(&self) -> Vec { + self.inner.chains().into_iter().filter_map(Network::from_chain_hash).collect() + } + + /// Opaque bytes set by the originator. + /// + /// Useful for authentication and validating fields since it is reflected in `invoice_request` + /// messages along with all the other fields from the `offer`. + pub fn metadata(&self) -> Option> { + self.inner.metadata().cloned() + } + + /// Seconds since the Unix epoch when an invoice should no longer be requested. + /// + /// If `None`, the offer does not expire. + pub fn absolute_expiry_seconds(&self) -> Option { + self.inner.absolute_expiry().map(|duration| duration.as_secs()) + } + + /// The public key corresponding to the key used by the recipient to sign invoices. + /// - If [`Offer::paths`] is empty, MUST be `Some` and contain the recipient's node id for + /// sending an [`InvoiceRequest`]. + /// - If [`Offer::paths`] is not empty, MAY be `Some` and contain a transient id. + /// - If `None`, the signing pubkey will be the final blinded node id from the + /// [`BlindedMessagePath`] in [`Offer::paths`] used to send the [`InvoiceRequest`]. + /// + /// See also [`Bolt12Invoice::signing_pubkey`]. + /// + /// [`InvoiceRequest`]: lightning::offers::invoice_request::InvoiceRequest + /// [`Bolt12Invoice::signing_pubkey`]: lightning::offers::invoice::Bolt12Invoice::signing_pubkey + pub fn issuer_signing_pubkey(&self) -> Option { + self.inner.issuer_signing_pubkey() + } +} + +impl std::str::FromStr for Offer { + type Err = Error; + + fn from_str(offer_str: &str) -> Result { + offer_str + .parse::() + .map(|offer| Offer { inner: offer }) + .map_err(|_| Error::InvalidOffer) + } +} + +impl From for Offer { + fn from(offer: LdkOffer) -> Self { + Offer { inner: offer } + } +} + +impl Deref for Offer { + type Target = LdkOffer; + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl AsRef for Offer { + fn as_ref(&self) -> &LdkOffer { + self.deref() + } +} + +impl std::fmt::Display for Offer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.inner) } } @@ -661,6 +813,13 @@ impl UniffiCustomTypeConverter for DateTime { #[cfg(test)] mod tests { + use std::{ + num::NonZeroU64, + time::{SystemTime, UNIX_EPOCH}, + }; + + use lightning::offers::offer::{OfferBuilder, Quantity}; + use super::*; fn create_test_invoice() -> (LdkBolt11Invoice, Bolt11Invoice) { @@ -670,6 +829,36 @@ mod tests { (ldk_invoice, wrapped_invoice) } + fn create_test_offer() -> (LdkOffer, Offer) { + let pubkey = bitcoin::secp256k1::PublicKey::from_str( + "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619", + ) + .unwrap(); + + let expiry = + (SystemTime::now() + Duration::from_secs(3600)).duration_since(UNIX_EPOCH).unwrap(); + + let quantity = NonZeroU64::new(10_000).unwrap(); + + let builder = OfferBuilder::new(pubkey) + .description("Test offer description".to_string()) + .amount_msats(100_000) + .issuer("Offer issuer".to_string()) + .absolute_expiry(expiry) + .chain(Network::Bitcoin) + .supported_quantity(Quantity::Bounded(quantity)) + .metadata(vec![ + 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, + 0xcd, 0xef, + ]) + .unwrap(); + + let ldk_offer = builder.build().unwrap(); + let wrapped_offer = Offer::from(ldk_offer.clone()); + + (ldk_offer, wrapped_offer) + } + #[test] fn test_invoice_description_conversion() { let hash = "09d08d4865e8af9266f6cc7c0ae23a1d6bf868207cf8f7c5979b9f6ed850dfb0".to_string(); @@ -779,4 +968,111 @@ mod tests { parsed_invoice.payment_hash().to_byte_array().to_vec() ); } + + #[test] + fn test_offer() { + let (ldk_offer, wrapped_offer) = create_test_offer(); + match (ldk_offer.description(), wrapped_offer.description()) { + (Some(ldk_desc), Some(wrapped_desc)) => { + assert_eq!(ldk_desc.to_string(), wrapped_desc); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK offer had a description but wrapped offer did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped offer had a description but LDK offer did not!"); + }, + } + + match (ldk_offer.amount(), wrapped_offer.amount()) { + (Some(ldk_amount), Some(wrapped_amount)) => { + let ldk_amount: OfferAmount = ldk_amount.into(); + assert_eq!(ldk_amount, wrapped_amount); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK offer had an amount but wrapped offer did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped offer had an amount but LDK offer did not!"); + }, + } + + match (ldk_offer.issuer(), wrapped_offer.issuer()) { + (Some(ldk_issuer), Some(wrapped_issuer)) => { + assert_eq!(ldk_issuer.to_string(), wrapped_issuer); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK offer had an issuer but wrapped offer did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped offer had an issuer but LDK offer did not!"); + }, + } + + assert_eq!(ldk_offer.is_expired(), wrapped_offer.is_expired()); + assert_eq!(ldk_offer.id(), wrapped_offer.id()); + assert_eq!(ldk_offer.is_valid_quantity(10_000), wrapped_offer.is_valid_quantity(10_000)); + assert_eq!(ldk_offer.expects_quantity(), wrapped_offer.expects_quantity()); + assert_eq!( + ldk_offer.supports_chain(Network::Bitcoin.chain_hash()), + wrapped_offer.supports_chain(Network::Bitcoin) + ); + assert_eq!( + ldk_offer.chains(), + wrapped_offer.chains().iter().map(|c| c.chain_hash()).collect::>() + ); + match (ldk_offer.metadata(), wrapped_offer.metadata()) { + (Some(ldk_metadata), Some(wrapped_metadata)) => { + assert_eq!(ldk_metadata.clone(), wrapped_metadata); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK offer had metadata but wrapped offer did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped offer had metadata but LDK offer did not!"); + }, + } + + match (ldk_offer.absolute_expiry(), wrapped_offer.absolute_expiry_seconds()) { + (Some(ldk_expiry), Some(wrapped_expiry)) => { + assert_eq!(ldk_expiry.as_secs(), wrapped_expiry); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK offer had an absolute expiry but wrapped offer did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped offer had an absolute expiry but LDK offer did not!"); + }, + } + + match (ldk_offer.issuer_signing_pubkey(), wrapped_offer.issuer_signing_pubkey()) { + (Some(ldk_expiry_signing_pubkey), Some(wrapped_issuer_signing_pubkey)) => { + assert_eq!(ldk_expiry_signing_pubkey, wrapped_issuer_signing_pubkey); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK offer had an issuer signing pubkey but wrapped offer did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped offer had an issuer signing pubkey but LDK offer did not!"); + }, + } + } } diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index 8006f4bb9..aa642a084 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -11,13 +11,14 @@ use crate::config::LDK_PAYMENT_RETRY_TIMEOUT; use crate::error::Error; +use crate::ffi::{maybe_deref, maybe_wrap}; use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::payment::store::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; use crate::types::{ChannelManager, PaymentStore}; use lightning::ln::channelmanager::{PaymentId, Retry}; use lightning::offers::invoice::Bolt12Invoice; -use lightning::offers::offer::{Amount, Offer, Quantity}; +use lightning::offers::offer::{Amount, Offer as LdkOffer, Quantity}; use lightning::offers::parse::Bolt12SemanticError; use lightning::offers::refund::Refund; use lightning::util::string::UntrustedString; @@ -28,6 +29,11 @@ use std::num::NonZeroU64; use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +#[cfg(not(feature = "uniffi"))] +type Offer = LdkOffer; +#[cfg(feature = "uniffi")] +type Offer = Arc; + /// A payment handler allowing to create and pay [BOLT 12] offers and refunds. /// /// Should be retrieved by calling [`Node::bolt12_payment`]. @@ -59,6 +65,7 @@ impl Bolt12Payment { pub fn send( &self, offer: &Offer, quantity: Option, payer_note: Option, ) -> Result { + let offer = maybe_deref(offer); let rt_lock = self.runtime.read().unwrap(); if rt_lock.is_none() { return Err(Error::NotRunning); @@ -160,6 +167,7 @@ impl Bolt12Payment { pub fn send_using_amount( &self, offer: &Offer, amount_msat: u64, quantity: Option, payer_note: Option, ) -> Result { + let offer = maybe_deref(offer); let rt_lock = self.runtime.read().unwrap(); if rt_lock.is_none() { return Err(Error::NotRunning); @@ -254,11 +262,9 @@ impl Bolt12Payment { } } - /// Returns a payable offer that can be used to request and receive a payment of the amount - /// given. - pub fn receive( + pub(crate) fn receive_inner( &self, amount_msat: u64, description: &str, expiry_secs: Option, quantity: Option, - ) -> Result { + ) -> Result { let absolute_expiry = expiry_secs.map(|secs| { (SystemTime::now() + Duration::from_secs(secs as u64)) .duration_since(UNIX_EPOCH) @@ -291,6 +297,15 @@ impl Bolt12Payment { Ok(finalized_offer) } + /// Returns a payable offer that can be used to request and receive a payment of the amount + /// given. + pub fn receive( + &self, amount_msat: u64, description: &str, expiry_secs: Option, quantity: Option, + ) -> Result { + let offer = self.receive_inner(amount_msat, description, expiry_secs, quantity)?; + Ok(maybe_wrap(offer)) + } + /// Returns a payable offer that can be used to request and receive a payment for which the /// amount is to be determined by the user, also known as a "zero-amount" offer. pub fn receive_variable_amount( @@ -312,7 +327,7 @@ impl Bolt12Payment { Error::OfferCreationFailed })?; - Ok(offer) + Ok(maybe_wrap(offer)) } /// Requests a refund payment for the given [`Refund`]. diff --git a/src/payment/unified_qr.rs b/src/payment/unified_qr.rs index abfc5b784..125e1d09b 100644 --- a/src/payment/unified_qr.rs +++ b/src/payment/unified_qr.rs @@ -95,14 +95,14 @@ impl UnifiedQrPayment { let amount_msats = amount_sats * 1_000; - let bolt12_offer = match self.bolt12_payment.receive(amount_msats, description, None, None) - { - Ok(offer) => Some(offer), - Err(e) => { - log_error!(self.logger, "Failed to create offer: {}", e); - None - }, - }; + let bolt12_offer = + match self.bolt12_payment.receive_inner(amount_msats, description, None, None) { + Ok(offer) => Some(offer), + Err(e) => { + log_error!(self.logger, "Failed to create offer: {}", e); + None + }, + }; let invoice_description = Bolt11InvoiceDescription::Direct( Description::new(description.to_string()).map_err(|_| Error::InvoiceCreationFailed)?, @@ -147,6 +147,7 @@ impl UnifiedQrPayment { uri.clone().require_network(self.config.network).map_err(|_| Error::InvalidNetwork)?; if let Some(offer) = uri_network_checked.extras.bolt12_offer { + let offer = maybe_wrap(offer); match self.bolt12_payment.send(&offer, None, None) { Ok(payment_id) => return Ok(QrPaymentResult::Bolt12 { payment_id }), Err(e) => log_error!(self.logger, "Failed to send BOLT12 offer: {:?}. This is part of a unified QR code payment. Falling back to the BOLT11 invoice.", e), From dc6a8f20548a21d38d1906c2a90100ae82e78861 Mon Sep 17 00:00:00 2001 From: alexanderwiederin Date: Mon, 5 May 2025 19:50:24 +0200 Subject: [PATCH 3/4] Add Refund wrapper for FFI bindings Implement Refund struct in ffi/types.rs to provide a wrapper around LDK's Refund for cross-language bindings. Modified payment handling in bolt12.rs to: - Support both native and FFI-compatible types via type aliasing - Implement conditional compilation for transparent FFI support - Update payment functions to handle wrapped types Added testing to verify that properties are preserved when wrapping/unwrapping between native and FFI types. --- bindings/ldk_node.udl | 19 +++- src/ffi/types.rs | 227 ++++++++++++++++++++++++++++++++++++++++-- src/payment/bolt12.rs | 15 ++- 3 files changed, 246 insertions(+), 15 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 38ab4677c..d48993532 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -760,6 +760,22 @@ interface Offer { PublicKey? issuer_signing_pubkey(); }; +[Traits=(Debug, Display, Eq)] +interface Refund { + [Throws=NodeError, Name=from_str] + constructor([ByRef] string refund_str); + string description(); + u64? absolute_expiry_seconds(); + boolean is_expired(); + string? issuer(); + sequence payer_metadata(); + Network? chain(); + u64 amount_msats(); + u64? quantity(); + PublicKey payer_signing_pubkey(); + string? payer_note(); +}; + [Custom] typedef string Txid; @@ -778,9 +794,6 @@ typedef string NodeId; [Custom] typedef string Address; -[Custom] -typedef string Refund; - [Custom] typedef string Bolt12Invoice; diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 8b511c830..6c0aaf880 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -27,7 +27,6 @@ pub use lightning::events::{ClosureReason, PaymentFailureReason}; pub use lightning::ln::types::ChannelId; pub use lightning::offers::invoice::Bolt12Invoice; pub use lightning::offers::offer::OfferId; -pub use lightning::offers::refund::Refund; pub use lightning::routing::gossip::{NodeAlias, NodeId, RoutingFees}; pub use lightning::util::string::UntrustedString; @@ -58,6 +57,7 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; use lightning::ln::channelmanager::PaymentId; use lightning::offers::offer::{Amount as LdkAmount, Offer as LdkOffer}; +use lightning::offers::refund::Refund as LdkRefund; use lightning::util::ser::Writeable; use lightning_invoice::{Bolt11Invoice as LdkBolt11Invoice, Bolt11InvoiceDescriptionRef}; @@ -278,15 +278,123 @@ impl std::fmt::Display for Offer { } } -impl UniffiCustomTypeConverter for Refund { - type Builtin = String; +/// A `Refund` is a request to send an [`Bolt12Invoice`] without a preceding [`Offer`]. +/// +/// Typically, after an invoice is paid, the recipient may publish a refund allowing the sender to +/// recoup their funds. A refund may be used more generally as an "offer for money", such as with a +/// bitcoin ATM. +/// +/// [`Bolt12Invoice`]: lightning::offers::invoice::Bolt12Invoice +/// [`Offer`]: lightning::offers::offer::Offer +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refund { + pub(crate) inner: LdkRefund, +} - fn into_custom(val: Self::Builtin) -> uniffi::Result { - Refund::from_str(&val).map_err(|_| Error::InvalidRefund.into()) +impl Refund { + pub fn from_str(refund_str: &str) -> Result { + refund_str.parse() } - fn from_custom(obj: Self) -> Self::Builtin { - obj.to_string() + /// A complete description of the purpose of the refund. + /// + /// Intended to be displayed to the user but with the caveat that it has not been verified in any way. + pub fn description(&self) -> String { + self.inner.description().to_string() + } + + /// Seconds since the Unix epoch when an invoice should no longer be sent. + /// + /// If `None`, the refund does not expire. + pub fn absolute_expiry_seconds(&self) -> Option { + self.inner.absolute_expiry().map(|duration| duration.as_secs()) + } + + /// Whether the refund has expired. + pub fn is_expired(&self) -> bool { + self.inner.is_expired() + } + + /// The issuer of the refund, possibly beginning with `user@domain` or `domain`. + /// + /// Intended to be displayed to the user but with the caveat that it has not been verified in any way. + pub fn issuer(&self) -> Option { + self.inner.issuer().map(|printable| printable.to_string()) + } + + /// An unpredictable series of bytes, typically containing information about the derivation of + /// [`payer_signing_pubkey`]. + /// + /// [`payer_signing_pubkey`]: Self::payer_signing_pubkey + pub fn payer_metadata(&self) -> Vec { + self.inner.payer_metadata().to_vec() + } + + /// A chain that the refund is valid for. + pub fn chain(&self) -> Option { + Network::try_from(self.inner.chain()).ok() + } + + /// The amount to refund in msats (i.e., the minimum lightning-payable unit for [`chain`]). + /// + /// [`chain`]: Self::chain + pub fn amount_msats(&self) -> u64 { + self.inner.amount_msats() + } + + /// The quantity of an item that refund is for. + pub fn quantity(&self) -> Option { + self.inner.quantity() + } + + /// A public node id to send to in the case where there are no [`paths`]. + /// + /// Otherwise, a possibly transient pubkey. + /// + /// [`paths`]: lightning::offers::refund::Refund::paths + pub fn payer_signing_pubkey(&self) -> PublicKey { + self.inner.payer_signing_pubkey() + } + + /// Payer provided note to include in the invoice. + pub fn payer_note(&self) -> Option { + self.inner.payer_note().map(|printable| printable.to_string()) + } +} + +impl std::str::FromStr for Refund { + type Err = Error; + + fn from_str(refund_str: &str) -> Result { + refund_str + .parse::() + .map(|refund| Refund { inner: refund }) + .map_err(|_| Error::InvalidRefund) + } +} + +impl From for Refund { + fn from(refund: LdkRefund) -> Self { + Refund { inner: refund } + } +} + +impl Deref for Refund { + type Target = LdkRefund; + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl AsRef for Refund { + fn as_ref(&self) -> &LdkRefund { + self.deref() + } +} + +impl std::fmt::Display for Refund { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.inner) } } @@ -818,9 +926,11 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use lightning::offers::offer::{OfferBuilder, Quantity}; - use super::*; + use lightning::offers::{ + offer::{OfferBuilder, Quantity}, + refund::RefundBuilder, + }; fn create_test_invoice() -> (LdkBolt11Invoice, Bolt11Invoice) { let invoice_string = "lnbc1pn8g249pp5f6ytj32ty90jhvw69enf30hwfgdhyymjewywcmfjevflg6s4z86qdqqcqzzgxqyz5vqrzjqwnvuc0u4txn35cafc7w94gxvq5p3cu9dd95f7hlrh0fvs46wpvhdfjjzh2j9f7ye5qqqqryqqqqthqqpysp5mm832athgcal3m7h35sc29j63lmgzvwc5smfjh2es65elc2ns7dq9qrsgqu2xcje2gsnjp0wn97aknyd3h58an7sjj6nhcrm40846jxphv47958c6th76whmec8ttr2wmg6sxwchvxmsc00kqrzqcga6lvsf9jtqgqy5yexa"; @@ -859,6 +969,28 @@ mod tests { (ldk_offer, wrapped_offer) } + fn create_test_refund() -> (LdkRefund, Refund) { + let payer_key = bitcoin::secp256k1::PublicKey::from_str( + "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619", + ) + .unwrap(); + + let expiry = + (SystemTime::now() + Duration::from_secs(3600)).duration_since(UNIX_EPOCH).unwrap(); + + let builder = RefundBuilder::new("Test refund".to_string().into(), payer_key, 100_000) + .unwrap() + .description("Test refund description".to_string()) + .absolute_expiry(expiry) + .quantity(3) + .issuer("test_issuer".to_string()); + + let ldk_refund = builder.build().unwrap(); + let wrapped_refund = Refund::from(ldk_refund.clone()); + + (ldk_refund, wrapped_refund) + } + #[test] fn test_invoice_description_conversion() { let hash = "09d08d4865e8af9266f6cc7c0ae23a1d6bf868207cf8f7c5979b9f6ed850dfb0".to_string(); @@ -1075,4 +1207,81 @@ mod tests { }, } } + + #[test] + fn test_refund_roundtrip() { + let (ldk_refund, _) = create_test_refund(); + + let refund_str = ldk_refund.to_string(); + + let parsed_refund = Refund::from_str(&refund_str); + assert!(parsed_refund.is_ok(), "Failed to parse refund from string!"); + + let invalid_result = Refund::from_str("invalid_refund_string"); + assert!(invalid_result.is_err()); + assert!(matches!(invalid_result.err().unwrap(), Error::InvalidRefund)); + } + + #[test] + fn test_refund_properties() { + let (ldk_refund, wrapped_refund) = create_test_refund(); + + assert_eq!(ldk_refund.description().to_string(), wrapped_refund.description()); + assert_eq!(ldk_refund.amount_msats(), wrapped_refund.amount_msats()); + assert_eq!(ldk_refund.is_expired(), wrapped_refund.is_expired()); + + match (ldk_refund.absolute_expiry(), wrapped_refund.absolute_expiry_seconds()) { + (Some(ldk_expiry), Some(wrapped_expiry)) => { + assert_eq!(ldk_expiry.as_secs(), wrapped_expiry); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK refund had an expiry but wrapped refund did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped refund had an expiry but LDK refund did not!"); + }, + } + + match (ldk_refund.quantity(), wrapped_refund.quantity()) { + (Some(ldk_expiry), Some(wrapped_expiry)) => { + assert_eq!(ldk_expiry, wrapped_expiry); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK refund had an quantity but wrapped refund did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped refund had an quantity but LDK refund did not!"); + }, + } + + match (ldk_refund.issuer(), wrapped_refund.issuer()) { + (Some(ldk_issuer), Some(wrapped_issuer)) => { + assert_eq!(ldk_issuer.to_string(), wrapped_issuer); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK refund had an issuer but wrapped refund did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped refund had an issuer but LDK refund did not!"); + }, + } + + assert_eq!(ldk_refund.payer_metadata().to_vec(), wrapped_refund.payer_metadata()); + assert_eq!(ldk_refund.payer_signing_pubkey(), wrapped_refund.payer_signing_pubkey()); + + if let Ok(network) = Network::try_from(ldk_refund.chain()) { + assert_eq!(wrapped_refund.chain(), Some(network)); + } + + assert_eq!(ldk_refund.payer_note().map(|p| p.to_string()), wrapped_refund.payer_note()); + } } diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index aa642a084..74c3ac45f 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -20,7 +20,6 @@ use lightning::ln::channelmanager::{PaymentId, Retry}; use lightning::offers::invoice::Bolt12Invoice; use lightning::offers::offer::{Amount, Offer as LdkOffer, Quantity}; use lightning::offers::parse::Bolt12SemanticError; -use lightning::offers::refund::Refund; use lightning::util::string::UntrustedString; use rand::RngCore; @@ -34,6 +33,11 @@ type Offer = LdkOffer; #[cfg(feature = "uniffi")] type Offer = Arc; +#[cfg(not(feature = "uniffi"))] +type Refund = lightning::offers::refund::Refund; +#[cfg(feature = "uniffi")] +type Refund = Arc; + /// A payment handler allowing to create and pay [BOLT 12] offers and refunds. /// /// Should be retrieved by calling [`Node::bolt12_payment`]. @@ -334,8 +338,11 @@ impl Bolt12Payment { /// /// The returned [`Bolt12Invoice`] is for informational purposes only (i.e., isn't needed to /// retrieve the refund). + /// + /// [`Refund`]: lightning::offers::refund::Refund pub fn request_refund_payment(&self, refund: &Refund) -> Result { - let invoice = self.channel_manager.request_refund_payment(refund).map_err(|e| { + let refund = maybe_deref(refund); + let invoice = self.channel_manager.request_refund_payment(&refund).map_err(|e| { log_error!(self.logger, "Failed to request refund payment: {:?}", e); Error::InvoiceRequestCreationFailed })?; @@ -366,6 +373,8 @@ impl Bolt12Payment { } /// Returns a [`Refund`] object that can be used to offer a refund payment of the amount given. + /// + /// [`Refund`]: lightning::offers::refund::Refund pub fn initiate_refund( &self, amount_msat: u64, expiry_secs: u32, quantity: Option, payer_note: Option, @@ -427,6 +436,6 @@ impl Bolt12Payment { self.payment_store.insert(payment)?; - Ok(refund) + Ok(maybe_wrap(refund)) } } From f01d0219efc1fb7542e1e760f1a10504806edd5e Mon Sep 17 00:00:00 2001 From: alexanderwiederin Date: Mon, 12 May 2025 22:50:49 +0200 Subject: [PATCH 4/4] Add Bolt12Invoice wrapper for FFI bindings Implement Bolt12Invoice struct in ffi/types.rs to provide a wrapper around LDK's Bolt12Invoice for cross-language bindings. Modified payment handling in bolt12.rs to: - Support both native and FFI-compatible types via type aliasing - Implement conditional compilation for transparent FFI support - Update payment functions to handle wrapped types Added testing to verify that properties are preserved when wrapping/unwrapping between native and FFI types. --- bindings/ldk_node.udl | 28 +++- src/ffi/types.rs | 369 ++++++++++++++++++++++++++++++++++++++++-- src/payment/bolt12.rs | 9 +- 3 files changed, 385 insertions(+), 21 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index d48993532..505f0db8d 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -776,6 +776,31 @@ interface Refund { string? payer_note(); }; +interface Bolt12Invoice { + [Throws=NodeError, Name=from_str] + constructor([ByRef] string invoice_str); + PaymentHash payment_hash(); + u64 amount_msats(); + OfferAmount? amount(); + PublicKey signing_pubkey(); + u64 created_at(); + u64? absolute_expiry_seconds(); + u64 relative_expiry(); + boolean is_expired(); + string? description(); + string? issuer(); + string? payer_note(); + sequence? metadata(); + u64? quantity(); + sequence signable_hash(); + PublicKey payer_signing_pubkey(); + PublicKey? issuer_signing_pubkey(); + sequence chain(); + sequence>? offer_chains(); + sequence
fallback_addresses(); + sequence encode(); +}; + [Custom] typedef string Txid; @@ -794,9 +819,6 @@ typedef string NodeId; [Custom] typedef string Address; -[Custom] -typedef string Bolt12Invoice; - [Custom] typedef string OfferId; diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 6c0aaf880..bbf730211 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -25,7 +25,6 @@ pub use crate::payment::{MaxTotalRoutingFeeLimit, QrPaymentResult, SendingParame pub use lightning::chain::channelmonitor::BalanceSource; pub use lightning::events::{ClosureReason, PaymentFailureReason}; pub use lightning::ln::types::ChannelId; -pub use lightning::offers::invoice::Bolt12Invoice; pub use lightning::offers::offer::OfferId; pub use lightning::routing::gossip::{NodeAlias, NodeId, RoutingFees}; pub use lightning::util::string::UntrustedString; @@ -56,6 +55,7 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; use lightning::ln::channelmanager::PaymentId; +use lightning::offers::invoice::Bolt12Invoice as LdkBolt12Invoice; use lightning::offers::offer::{Amount as LdkAmount, Offer as LdkOffer}; use lightning::offers::refund::Refund as LdkRefund; use lightning::util::ser::Writeable; @@ -398,20 +398,218 @@ impl std::fmt::Display for Refund { } } -impl UniffiCustomTypeConverter for Bolt12Invoice { - type Builtin = String; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bolt12Invoice { + pub(crate) inner: LdkBolt12Invoice, +} - fn into_custom(val: Self::Builtin) -> uniffi::Result { - if let Some(bytes_vec) = hex_utils::to_vec(&val) { - if let Ok(invoice) = Bolt12Invoice::try_from(bytes_vec) { - return Ok(invoice); +impl Bolt12Invoice { + pub fn from_str(invoice_str: &str) -> Result { + invoice_str.parse() + } + + /// SHA256 hash of the payment preimage that will be given in return for paying the invoice. + pub fn payment_hash(&self) -> PaymentHash { + PaymentHash(self.inner.payment_hash().0) + } + + /// The minimum amount required for a successful payment of the invoice. + pub fn amount_msats(&self) -> u64 { + self.inner.amount_msats() + } + + /// The minimum amount required for a successful payment of a single item. + /// + /// From [`Offer::amount`]; `None` if the invoice was created in response to a [`Refund`] or if + /// the [`Offer`] did not set it. + /// + /// [`Offer`]: lightning::offers::offer::Offer + /// [`Offer::amount`]: lightning::offers::offer::Offer::amount + /// [`Refund`]: lightning::offers::refund::Refund + pub fn amount(&self) -> Option { + self.inner.amount().map(|amount| amount.into()) + } + + /// A typically transient public key corresponding to the key used to sign the invoice. + /// + /// If the invoices was created in response to an [`Offer`], then this will be: + /// - [`Offer::issuer_signing_pubkey`] if it's `Some`, otherwise + /// - the final blinded node id from a [`BlindedMessagePath`] in [`Offer::paths`] if `None`. + /// + /// If the invoice was created in response to a [`Refund`], then it is a valid pubkey chosen by + /// the recipient. + /// + /// [`Offer`]: lightning::offers::offer::Offer + /// [`Offer::issuer_signing_pubkey`]: lightning::offers::offer::Offer::issuer_signing_pubkey + /// [`Offer::paths`]: lightning::offers::offer::Offer::paths + /// [`Refund`]: lightning::offers::refund::Refund + pub fn signing_pubkey(&self) -> PublicKey { + self.inner.signing_pubkey() + } + + /// Duration since the Unix epoch when the invoice was created. + pub fn created_at(&self) -> u64 { + self.inner.created_at().as_secs() + } + + /// Seconds since the Unix epoch when an invoice should no longer be requested. + /// + /// From [`Offer::absolute_expiry`] or [`Refund::absolute_expiry`]. + /// + /// [`Offer::absolute_expiry`]: lightning::offers::offer::Offer::absolute_expiry + pub fn absolute_expiry_seconds(&self) -> Option { + self.inner.absolute_expiry().map(|duration| duration.as_secs()) + } + + /// When the invoice has expired and therefore should no longer be paid. + pub fn relative_expiry(&self) -> u64 { + self.inner.relative_expiry().as_secs() + } + + /// Whether the invoice has expired. + pub fn is_expired(&self) -> bool { + self.inner.is_expired() + } + + /// A complete description of the purpose of the originating offer or refund. + /// + /// From [`Offer::description`] or [`Refund::description`]. + /// + /// [`Offer::description`]: lightning::offers::offer::Offer::description + /// [`Refund::description`]: lightning::offers::refund::Refund::description + pub fn description(&self) -> Option { + self.inner.description().map(|printable| printable.to_string()) + } + + /// The issuer of the offer or refund. + /// + /// From [`Offer::issuer`] or [`Refund::issuer`]. + /// + /// [`Offer::issuer`]: lightning::offers::offer::Offer::issuer + /// [`Refund::issuer`]: lightning::offers::refund::Refund::issuer + pub fn issuer(&self) -> Option { + self.inner.issuer().map(|printable| printable.to_string()) + } + + /// A payer-provided note reflected back in the invoice. + /// + /// From [`InvoiceRequest::payer_note`] or [`Refund::payer_note`]. + /// + /// [`Refund::payer_note`]: lightning::offers::refund::Refund::payer_note + pub fn payer_note(&self) -> Option { + self.inner.payer_note().map(|note| note.to_string()) + } + + /// Opaque bytes set by the originating [`Offer`]. + /// + /// From [`Offer::metadata`]; `None` if the invoice was created in response to a [`Refund`] or + /// if the [`Offer`] did not set it. + /// + /// [`Offer`]: lightning::offers::offer::Offer + /// [`Offer::metadata`]: lightning::offers::offer::Offer::metadata + /// [`Refund`]: lightning::offers::refund::Refund + pub fn metadata(&self) -> Option> { + self.inner.metadata().cloned() + } + + /// The quantity of items requested or refunded for. + /// + /// From [`InvoiceRequest::quantity`] or [`Refund::quantity`]. + /// + /// [`Refund::quantity`]: lightning::offers::refund::Refund::quantity + pub fn quantity(&self) -> Option { + self.inner.quantity() + } + + /// Hash that was used for signing the invoice. + pub fn signable_hash(&self) -> Vec { + self.inner.signable_hash().to_vec() + } + + /// A possibly transient pubkey used to sign the invoice request or to send an invoice for a + /// refund in case there are no [`message_paths`]. + /// + /// [`message_paths`]: lightning::offers::invoice::Bolt12Invoice + pub fn payer_signing_pubkey(&self) -> PublicKey { + self.inner.payer_signing_pubkey() + } + + /// The public key used by the recipient to sign invoices. + /// + /// From [`Offer::issuer_signing_pubkey`] and may be `None`; also `None` if the invoice was + /// created in response to a [`Refund`]. + /// + /// [`Offer::issuer_signing_pubkey`]: lightning::offers::offer::Offer::issuer_signing_pubkey + /// [`Refund`]: lightning::offers::refund::Refund + pub fn issuer_signing_pubkey(&self) -> Option { + self.inner.issuer_signing_pubkey() + } + + /// The chain that must be used when paying the invoice; selected from [`offer_chains`] if the + /// invoice originated from an offer. + /// + /// From [`InvoiceRequest::chain`] or [`Refund::chain`]. + /// + /// [`offer_chains`]: lightning::offers::invoice::Bolt12Invoice::offer_chains + /// [`InvoiceRequest::chain`]: lightning::offers::invoice_request::InvoiceRequest::chain + /// [`Refund::chain`]: lightning::offers::refund::Refund::chain + pub fn chain(&self) -> Vec { + self.inner.chain().to_bytes().to_vec() + } + + /// The chains that may be used when paying a requested invoice. + /// + /// From [`Offer::chains`]; `None` if the invoice was created in response to a [`Refund`]. + /// + /// [`Offer::chains`]: lightning::offers::offer::Offer::chains + /// [`Refund`]: lightning::offers::refund::Refund + pub fn offer_chains(&self) -> Option>> { + self.inner + .offer_chains() + .map(|chains| chains.iter().map(|chain| chain.to_bytes().to_vec()).collect()) + } + + /// Fallback addresses for paying the invoice on-chain, in order of most-preferred to + /// least-preferred. + pub fn fallback_addresses(&self) -> Vec
{ + self.inner.fallbacks() + } + + /// Writes `self` out to a `Vec`. + pub fn encode(&self) -> Vec { + self.inner.encode() + } +} + +impl std::str::FromStr for Bolt12Invoice { + type Err = Error; + + fn from_str(invoice_str: &str) -> Result { + if let Some(bytes_vec) = hex_utils::to_vec(invoice_str) { + if let Ok(invoice) = LdkBolt12Invoice::try_from(bytes_vec) { + return Ok(Bolt12Invoice { inner: invoice }); } } - Err(Error::InvalidInvoice.into()) + Err(Error::InvalidInvoice) } +} - fn from_custom(obj: Self) -> Self::Builtin { - hex_utils::to_string(&obj.encode()) +impl From for Bolt12Invoice { + fn from(invoice: LdkBolt12Invoice) -> Self { + Bolt12Invoice { inner: invoice } + } +} + +impl Deref for Bolt12Invoice { + type Target = LdkBolt12Invoice; + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl AsRef for Bolt12Invoice { + fn as_ref(&self) -> &LdkBolt12Invoice { + self.deref() } } @@ -932,7 +1130,7 @@ mod tests { refund::RefundBuilder, }; - fn create_test_invoice() -> (LdkBolt11Invoice, Bolt11Invoice) { + fn create_test_bolt11_invoice() -> (LdkBolt11Invoice, Bolt11Invoice) { let invoice_string = "lnbc1pn8g249pp5f6ytj32ty90jhvw69enf30hwfgdhyymjewywcmfjevflg6s4z86qdqqcqzzgxqyz5vqrzjqwnvuc0u4txn35cafc7w94gxvq5p3cu9dd95f7hlrh0fvs46wpvhdfjjzh2j9f7ye5qqqqryqqqqthqqpysp5mm832athgcal3m7h35sc29j63lmgzvwc5smfjh2es65elc2ns7dq9qrsgqu2xcje2gsnjp0wn97aknyd3h58an7sjj6nhcrm40846jxphv47958c6th76whmec8ttr2wmg6sxwchvxmsc00kqrzqcga6lvsf9jtqgqy5yexa"; let ldk_invoice: LdkBolt11Invoice = invoice_string.parse().unwrap(); let wrapped_invoice = Bolt11Invoice::from(ldk_invoice.clone()); @@ -991,6 +1189,19 @@ mod tests { (ldk_refund, wrapped_refund) } + fn create_test_bolt12_invoice() -> (LdkBolt12Invoice, Bolt12Invoice) { + let invoice_hex = "0020a5b7104b95f17442d6638143ded62b02c2fda98cdf35841713fd0f44b59286560a000e04682cb028502006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f520227105601015821034b4f0765a115caeff6787a8fb2d976c02467a36aea32901539d76473817937c65904546573745a9c00000068000001000003e75203dee4b3e5d48650caf1faadda53ac6e0dc3f509cc5e9c46defb8aeeec14010348e84fab39b226b1e0696cb6fb40bdb293952c184cf02007fa6e983cd311d189004e7bd75ff9ef069642f2abfa5916099e5a16144e1a6d9b4f246624d3b57d2895d5d2e46fe8661e49717d1663ad2c07b023738370a3e44a960f683040b1862fe36e22347c2dbe429c51af377bdbe01ca0e103f295d1678c68b628957a53a820afcc25763cc67b38aca82067bdf52dc68c061a02575d91c01beca64cc09735c395e91d034841d3e61b58948da631192ce556b85b01028e2284ead4ce184981f4d0f387f8d47295d4fa1dab6a6ae3a417550ac1c8b1aa007b38c926212fbf23154c6ff707621d6eedafc4298b133111d90934bb9d5a2103f0c8e4a3f3daa992334aad300677f23b4285db2ee5caf0a0ecc39c6596c3c4e42318040bec46add3626501f6e422be9c791adc81ea5c83ff0bfa91b7d42bcac0ed128a640fe970da584cff80fd5c12a8ea9b546a2d63515343a933daa21c0000000000000000001800000000000000011d24b2dfac5200000000a404682ca218a820a4a878fb352e63673c05eb07e53563fc8022ff039ad4c66e65848a7cde7ee780aa022710ae03020000b02103800fd75bf6b1e7c5f3fab33a372f6599730e0fae7a30fa4e5c8fbc69c3a87981f0403c9a40e6c9d08e12b0a155101d23a170b4f5b38051b0a0a09a794ce49e820f65d50c8fad7518200d3a28331aa5c668a8f7d70206aaf8bea2e8f05f0904b6e033"; + + let invoice_bytes = hex_utils::to_vec(invoice_hex).expect("Valid hex string"); + + let ldk_invoice = + LdkBolt12Invoice::try_from(invoice_bytes).expect("Valid Bolt12Invoice bytes"); + + let wrapped_invoice = Bolt12Invoice { inner: ldk_invoice.clone() }; + + (ldk_invoice, wrapped_invoice) + } + #[test] fn test_invoice_description_conversion() { let hash = "09d08d4865e8af9266f6cc7c0ae23a1d6bf868207cf8f7c5979b9f6ed850dfb0".to_string(); @@ -1003,7 +1214,7 @@ mod tests { #[test] fn test_bolt11_invoice_basic_properties() { - let (ldk_invoice, wrapped_invoice) = create_test_invoice(); + let (ldk_invoice, wrapped_invoice) = create_test_bolt11_invoice(); assert_eq!( ldk_invoice.payment_hash().to_string(), @@ -1029,7 +1240,7 @@ mod tests { #[test] fn test_bolt11_invoice_time_related_fields() { - let (ldk_invoice, wrapped_invoice) = create_test_invoice(); + let (ldk_invoice, wrapped_invoice) = create_test_bolt11_invoice(); assert_eq!(ldk_invoice.expiry_time().as_secs(), wrapped_invoice.expiry_time_seconds()); assert_eq!( @@ -1048,7 +1259,7 @@ mod tests { #[test] fn test_bolt11_invoice_description() { - let (ldk_invoice, wrapped_invoice) = create_test_invoice(); + let (ldk_invoice, wrapped_invoice) = create_test_bolt11_invoice(); let ldk_description = ldk_invoice.description(); let wrapped_description = wrapped_invoice.description(); @@ -1072,7 +1283,7 @@ mod tests { #[test] fn test_bolt11_invoice_route_hints() { - let (ldk_invoice, wrapped_invoice) = create_test_invoice(); + let (ldk_invoice, wrapped_invoice) = create_test_bolt11_invoice(); let wrapped_route_hints = wrapped_invoice.route_hints(); let ldk_route_hints = ldk_invoice.route_hints(); @@ -1091,7 +1302,7 @@ mod tests { #[test] fn test_bolt11_invoice_roundtrip() { - let (ldk_invoice, wrapped_invoice) = create_test_invoice(); + let (ldk_invoice, wrapped_invoice) = create_test_bolt11_invoice(); let invoice_str = wrapped_invoice.to_string(); let parsed_invoice: LdkBolt11Invoice = invoice_str.parse().unwrap(); @@ -1284,4 +1495,130 @@ mod tests { assert_eq!(ldk_refund.payer_note().map(|p| p.to_string()), wrapped_refund.payer_note()); } + + #[test] + fn test_bolt12_invoice_properties() { + let (ldk_invoice, wrapped_invoice) = create_test_bolt12_invoice(); + + assert_eq!( + ldk_invoice.payment_hash().0.to_vec(), + wrapped_invoice.payment_hash().0.to_vec() + ); + assert_eq!(ldk_invoice.amount_msats(), wrapped_invoice.amount_msats()); + assert_eq!(ldk_invoice.is_expired(), wrapped_invoice.is_expired()); + + assert_eq!(ldk_invoice.signing_pubkey(), wrapped_invoice.signing_pubkey()); + + assert_eq!(ldk_invoice.created_at().as_secs(), wrapped_invoice.created_at()); + + match (ldk_invoice.absolute_expiry(), wrapped_invoice.absolute_expiry_seconds()) { + (Some(ldk_expiry), Some(wrapped_expiry)) => { + assert_eq!(ldk_expiry.as_secs(), wrapped_expiry); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK invoice had an absolute expiry but wrapped invoice did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped invoice had an absolute expiry but LDK invoice did not!"); + }, + } + + assert_eq!(ldk_invoice.relative_expiry().as_secs(), wrapped_invoice.relative_expiry()); + + match (ldk_invoice.description(), wrapped_invoice.description()) { + (Some(ldk_desc), Some(wrapped_desc)) => { + assert_eq!(ldk_desc.to_string(), wrapped_desc); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK invoice had a description but wrapped invoice did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped invoice had a description but LDK invoice did not!"); + }, + } + + match (ldk_invoice.issuer(), wrapped_invoice.issuer()) { + (Some(ldk_issuer), Some(wrapped_issuer)) => { + assert_eq!(ldk_issuer.to_string(), wrapped_issuer); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK invoice had an issuer but wrapped invoice did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped invoice had an issuer but LDK invoice did not!"); + }, + } + + match (ldk_invoice.payer_note(), wrapped_invoice.payer_note()) { + (Some(ldk_note), Some(wrapped_note)) => { + assert_eq!(ldk_note.to_string(), wrapped_note); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK invoice had a payer note but wrapped invoice did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped invoice had a payer note but LDK invoice did not!"); + }, + } + + match (ldk_invoice.metadata(), wrapped_invoice.metadata()) { + (Some(ldk_metadata), Some(wrapped_metadata)) => { + assert_eq!(ldk_metadata.as_slice(), wrapped_metadata.as_slice()); + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK invoice had metadata but wrapped invoice did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped invoice had metadata but LDK invoice did not!"); + }, + } + + assert_eq!(ldk_invoice.quantity(), wrapped_invoice.quantity()); + + assert_eq!(ldk_invoice.chain().to_bytes().to_vec(), wrapped_invoice.chain()); + + match (ldk_invoice.offer_chains(), wrapped_invoice.offer_chains()) { + (Some(ldk_chains), Some(wrapped_chains)) => { + assert_eq!(ldk_chains.len(), wrapped_chains.len()); + for (i, ldk_chain) in ldk_chains.iter().enumerate() { + assert_eq!(ldk_chain.to_bytes().to_vec(), wrapped_chains[i]); + } + }, + (None, None) => { + // Both fields are missing which is expected behaviour when converting + }, + (Some(_), None) => { + panic!("LDK invoice had offer chains but wrapped invoice did not!"); + }, + (None, Some(_)) => { + panic!("Wrapped invoice had offer chains but LDK invoice did not!"); + }, + } + + let ldk_fallbacks = ldk_invoice.fallbacks(); + let wrapped_fallbacks = wrapped_invoice.fallback_addresses(); + assert_eq!(ldk_fallbacks.len(), wrapped_fallbacks.len()); + for (i, ldk_fallback) in ldk_fallbacks.iter().enumerate() { + assert_eq!(*ldk_fallback, wrapped_fallbacks[i]); + } + + assert_eq!(ldk_invoice.encode(), wrapped_invoice.encode()); + + assert_eq!(ldk_invoice.signable_hash().to_vec(), wrapped_invoice.signable_hash()); + } } diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index 74c3ac45f..b9efa3241 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -17,7 +17,6 @@ use crate::payment::store::{PaymentDetails, PaymentDirection, PaymentKind, Payme use crate::types::{ChannelManager, PaymentStore}; use lightning::ln::channelmanager::{PaymentId, Retry}; -use lightning::offers::invoice::Bolt12Invoice; use lightning::offers::offer::{Amount, Offer as LdkOffer, Quantity}; use lightning::offers::parse::Bolt12SemanticError; use lightning::util::string::UntrustedString; @@ -28,6 +27,11 @@ use std::num::NonZeroU64; use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +#[cfg(not(feature = "uniffi"))] +type Bolt12Invoice = lightning::offers::invoice::Bolt12Invoice; +#[cfg(feature = "uniffi")] +type Bolt12Invoice = Arc; + #[cfg(not(feature = "uniffi"))] type Offer = LdkOffer; #[cfg(feature = "uniffi")] @@ -340,6 +344,7 @@ impl Bolt12Payment { /// retrieve the refund). /// /// [`Refund`]: lightning::offers::refund::Refund + /// [`Bolt12Invoice`]: lightning::offers::invoice::Bolt12Invoice pub fn request_refund_payment(&self, refund: &Refund) -> Result { let refund = maybe_deref(refund); let invoice = self.channel_manager.request_refund_payment(&refund).map_err(|e| { @@ -369,7 +374,7 @@ impl Bolt12Payment { self.payment_store.insert(payment)?; - Ok(invoice) + Ok(maybe_wrap(invoice)) } /// Returns a [`Refund`] object that can be used to offer a refund payment of the amount given.