diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index b59a38b04..7c76314e0 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -400,7 +400,7 @@ enum VssHeaderProviderError { [Enum] interface Event { - PaymentSuccessful(PaymentId? payment_id, PaymentHash payment_hash, PaymentPreimage? payment_preimage, u64? fee_paid_msat); + PaymentSuccessful(PaymentId? payment_id, PaymentHash payment_hash, PaymentPreimage? payment_preimage, u64? fee_paid_msat, PaidBolt12Invoice? bolt12_invoice); PaymentFailed(PaymentId? payment_id, PaymentHash? payment_hash, PaymentFailureReason? reason); PaymentReceived(PaymentId? payment_id, PaymentHash payment_hash, u64 amount_msat, sequence custom_records); PaymentClaimable(PaymentId payment_id, PaymentHash payment_hash, u64 claimable_amount_msat, u32? claim_deadline, sequence custom_records); @@ -857,6 +857,30 @@ interface Bolt12Invoice { sequence encode(); }; +interface StaticInvoice { + [Throws=NodeError, Name=from_str] + constructor([ByRef] string invoice_str); + OfferId offer_id(); + boolean is_offer_expired(); + PublicKey signing_pubkey(); + PublicKey? issuer_signing_pubkey(); + string? invoice_description(); + string? issuer(); + OfferAmount? amount(); + sequence chain(); + sequence? metadata(); + u64? absolute_expiry_seconds(); + sequence encode(); +}; + +// Note: UniFFI doesn't support Object types in enum variant data, so we use +// a dictionary with optional fields. Check which field is Some to determine +// the invoice type. +dictionary PaidBolt12Invoice { + Bolt12Invoice? bolt12_invoice; + StaticInvoice? static_invoice; +}; + [Custom] typedef string Txid; diff --git a/src/event.rs b/src/event.rs index 75270bf53..bd77e47b5 100644 --- a/src/event.rs +++ b/src/event.rs @@ -48,7 +48,9 @@ use crate::payment::store::{ PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; use crate::runtime::Runtime; -use crate::types::{CustomTlvRecord, DynStore, OnionMessenger, PaymentStore, Sweeper, Wallet}; +use crate::types::{ + CustomTlvRecord, DynStore, OnionMessenger, PaidBolt12Invoice, PaymentStore, Sweeper, Wallet, +}; use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, UserChannelId, @@ -75,6 +77,17 @@ pub enum Event { payment_preimage: Option, /// The total fee which was spent at intermediate hops in this payment. fee_paid_msat: Option, + /// The BOLT12 invoice that was paid. + /// + /// This is useful for proof of payment. A third party can verify that the payment was made + /// by checking that the `payment_hash` in the invoice matches `sha256(payment_preimage)`. + /// + /// Will be `None` for non-BOLT12 payments. + /// + /// Note that static invoices (indicated by [`PaidBolt12Invoice::StaticInvoice`], used for + /// async payments) do not support proof of payment as the payment hash is not derived + /// from a preimage known only to the recipient. + bolt12_invoice: Option, }, /// A sent payment has failed. PaymentFailed { @@ -264,6 +277,7 @@ impl_writeable_tlv_based_enum!(Event, (1, fee_paid_msat, option), (3, payment_id, option), (5, payment_preimage, option), + (7, bolt12_invoice, option), }, (1, PaymentFailed) => { (0, payment_hash, option), @@ -1022,6 +1036,7 @@ where payment_preimage, payment_hash, fee_paid_msat, + bolt12_invoice, .. } => { let payment_id = if let Some(id) = payment_id { @@ -1062,11 +1077,18 @@ where hex_utils::to_string(&payment_preimage.0) ); }); + + // For UniFFI builds, convert LDK's PaidBolt12Invoice to our wrapped type. + // For non-UniFFI builds, we use LDK's type directly. + #[cfg(feature = "uniffi")] + let bolt12_invoice = bolt12_invoice.map(PaidBolt12Invoice::from); + let event = Event::PaymentSuccessful { payment_id: Some(payment_id), payment_hash, payment_preimage: Some(payment_preimage), fee_paid_msat, + bolt12_invoice, }; match self.event_queue.add_event(event).await { diff --git a/src/ffi/types.rs b/src/ffi/types.rs index bed040fcd..20f31de8a 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -22,17 +22,20 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; pub use bitcoin::{Address, BlockHash, FeeRate, Network, OutPoint, ScriptBuf, Txid}; pub use lightning::chain::channelmonitor::BalanceSource; +use lightning::events::PaidBolt12Invoice as LdkPaidBolt12Invoice; pub use lightning::events::{ClosureReason, PaymentFailureReason}; use lightning::ln::channelmanager::PaymentId; +use lightning::ln::msgs::DecodeError; pub use lightning::ln::types::ChannelId; use lightning::offers::invoice::Bolt12Invoice as LdkBolt12Invoice; pub use lightning::offers::offer::OfferId; use lightning::offers::offer::{Amount as LdkAmount, Offer as LdkOffer}; use lightning::offers::refund::Refund as LdkRefund; +use lightning::offers::static_invoice::StaticInvoice as LdkStaticInvoice; use lightning::onion_message::dns_resolution::HumanReadableName as LdkHumanReadableName; pub use lightning::routing::gossip::{NodeAlias, NodeId, RoutingFees}; pub use lightning::routing::router::RouteParametersConfig; -use lightning::util::ser::Writeable; +use lightning::util::ser::{Readable, Writeable, Writer}; use lightning_invoice::{Bolt11Invoice as LdkBolt11Invoice, Bolt11InvoiceDescriptionRef}; pub use lightning_invoice::{Description, SignedRawBolt11Invoice}; pub use lightning_liquidity::lsps0::ser::LSPSDateTime; @@ -686,6 +689,172 @@ impl AsRef for Bolt12Invoice { } } +/// A `StaticInvoice` is used for async payments where the recipient may be offline. +/// +/// Unlike [`Bolt12Invoice`], a `StaticInvoice` does not support proof of payment +/// because the payment hash is not derived from a preimage known only to the recipient. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StaticInvoice { + pub(crate) inner: LdkStaticInvoice, +} + +impl StaticInvoice { + pub fn from_str(invoice_str: &str) -> Result { + invoice_str.parse() + } + + /// Returns the [`OfferId`] of the underlying [`Offer`] this invoice corresponds to. + /// + /// [`Offer`]: lightning::offers::offer::Offer + pub fn offer_id(&self) -> OfferId { + OfferId(self.inner.offer_id().0) + } + + /// Whether the offer this invoice corresponds to has expired. + pub fn is_offer_expired(&self) -> bool { + self.inner.is_offer_expired() + } + + /// A typically transient public key corresponding to the key used to sign the invoice. + pub fn signing_pubkey(&self) -> PublicKey { + self.inner.signing_pubkey() + } + + /// The public key used by the recipient to sign invoices. + pub fn issuer_signing_pubkey(&self) -> Option { + self.inner.issuer_signing_pubkey() + } + + /// A complete description of the purpose of the originating offer. + pub fn invoice_description(&self) -> Option { + self.inner.description().map(|printable| printable.to_string()) + } + + /// The issuer of the offer. + 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()) + } + + /// The chain that must be used when paying the invoice. + pub fn chain(&self) -> Vec { + self.inner.chain().to_bytes().to_vec() + } + + /// Opaque bytes set by the originating [`Offer`]. + /// + /// [`Offer`]: lightning::offers::offer::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()) + } + + /// Writes `self` out to a `Vec`. + pub fn encode(&self) -> Vec { + self.inner.encode() + } +} + +impl std::str::FromStr for StaticInvoice { + 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) = LdkStaticInvoice::try_from(bytes_vec) { + return Ok(StaticInvoice { inner: invoice }); + } + } + Err(Error::InvalidInvoice) + } +} + +impl From for StaticInvoice { + fn from(invoice: LdkStaticInvoice) -> Self { + StaticInvoice { inner: invoice } + } +} + +impl Deref for StaticInvoice { + type Target = LdkStaticInvoice; + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl AsRef for StaticInvoice { + fn as_ref(&self) -> &LdkStaticInvoice { + self.deref() + } +} + +/// Represents a BOLT12 invoice that was paid. +/// +/// This is used in [`Event::PaymentSuccessful`] to provide proof of payment for BOLT12 payments. +/// +/// Note: Due to UniFFI limitations with Object types in enum variants, this is exposed as a +/// struct with optional fields. Check which field is `Some` to determine the invoice type: +/// - `bolt12_invoice`: A standard BOLT12 invoice (supports proof of payment) +/// - `static_invoice`: A static invoice for async payments (does NOT support proof of payment) +/// +/// [`Event::PaymentSuccessful`]: crate::Event::PaymentSuccessful +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaidBolt12Invoice { + /// The paid BOLT12 invoice, if this is a regular BOLT12 invoice. + pub bolt12_invoice: Option>, + /// The paid static invoice, if this is a static invoice (async payment). + pub static_invoice: Option>, +} + +impl From for PaidBolt12Invoice { + fn from(ldk_invoice: LdkPaidBolt12Invoice) -> Self { + match ldk_invoice { + LdkPaidBolt12Invoice::Bolt12Invoice(invoice) => PaidBolt12Invoice { + bolt12_invoice: Some(super::maybe_wrap(invoice)), + static_invoice: None, + }, + LdkPaidBolt12Invoice::StaticInvoice(invoice) => PaidBolt12Invoice { + bolt12_invoice: None, + static_invoice: Some(super::maybe_wrap(invoice)), + }, + } + } +} + +impl Writeable for PaidBolt12Invoice { + fn write(&self, writer: &mut W) -> Result<(), lightning::io::Error> { + // Convert our struct back to LDK's enum and delegate serialization + let ldk_invoice: LdkPaidBolt12Invoice = match (&self.bolt12_invoice, &self.static_invoice) { + (Some(inv), None) => LdkPaidBolt12Invoice::Bolt12Invoice(inv.inner.clone()), + (None, Some(inv)) => LdkPaidBolt12Invoice::StaticInvoice(inv.inner.clone()), + _ => { + return Err(lightning::io::Error::new( + lightning::io::ErrorKind::InvalidData, + "PaidBolt12Invoice must have exactly one variant set", + )); + }, + }; + ldk_invoice.write(writer) + } +} + +impl Readable for PaidBolt12Invoice { + fn read(reader: &mut R) -> Result { + // Read using LDK's deserialization, then convert to our type + let ldk_invoice: LdkPaidBolt12Invoice = Readable::read(reader)?; + Ok(PaidBolt12Invoice::from(ldk_invoice)) + } +} + impl UniffiCustomTypeConverter for OfferId { type Builtin = String; diff --git a/src/io/utils.rs b/src/io/utils.rs index b94a329f7..4ddc03b07 100644 --- a/src/io/utils.rs +++ b/src/io/utils.rs @@ -8,11 +8,10 @@ use std::fs::{self, OpenOptions}; use std::io::Write; use std::ops::Deref; -use std::path::Path; -use std::sync::Arc; - #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::sync::Arc; use bdk_chain::indexer::keychain_txout::ChangeSet as BdkIndexerChangeSet; use bdk_chain::local_chain::ChangeSet as BdkLocalChainChangeSet; diff --git a/src/payment/mod.rs b/src/payment/mod.rs index c82f35c8f..492d4306a 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -23,3 +23,7 @@ pub use store::{ ConfirmationStatus, LSPFeeLimits, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, }; pub use unified::{UnifiedPayment, UnifiedPaymentResult}; + +pub use crate::types::PaidBolt12Invoice; +#[cfg(feature = "uniffi")] +pub use crate::types::{Bolt12Invoice, StaticInvoice}; diff --git a/src/types.rs b/src/types.rs index 5e9cd74c9..b498e30ec 100644 --- a/src/types.rs +++ b/src/types.rs @@ -609,3 +609,12 @@ impl From<&(u64, Vec)> for CustomTlvRecord { CustomTlvRecord { type_num: tlv.0, value: tlv.1.clone() } } } + +// Re-export the invoice types. When UniFFI is enabled, we use the types from ffi::types +// which have additional UniFFI-specific implementations. Otherwise, we re-export LDK's +// types directly to avoid unnecessary wrappers and serialization reimplementation. +#[cfg(not(feature = "uniffi"))] +pub use lightning::events::PaidBolt12Invoice; + +#[cfg(feature = "uniffi")] +pub use crate::ffi::{Bolt12Invoice, PaidBolt12Invoice, StaticInvoice}; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 4b82d1f4f..19cac48fa 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -30,8 +30,8 @@ use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig}; use ldk_node::entropy::NodeEntropy; use ldk_node::liquidity::LSPS2ServiceConfig; use ldk_node::payment::{ - ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, - UnifiedPaymentResult, + ConfirmationStatus, PaidBolt12Invoice, PaymentDetails, PaymentDirection, PaymentKind, + PaymentStatus, UnifiedPaymentResult, }; use ldk_node::{Builder, Event, NodeError}; use lightning::ln::channelmanager::PaymentId; @@ -1305,6 +1305,98 @@ async fn simple_bolt12_send_receive() { assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(overpaid_amount)); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn bolt12_proof_of_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Sleep until we broadcasted a node announcement. + while node_b.status().latest_node_announcement_broadcast_timestamp.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + // Sleep one more sec to make sure the node announcement propagates. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let expected_amount_msat = 100_000_000; + let offer = node_b + .bolt12_payment() + .receive(expected_amount_msat, "proof of payment test", None, Some(1)) + .unwrap(); + let payment_id = + node_a.bolt12_payment().send(&offer, Some(1), Some("Test".to_string()), None).unwrap(); + + // Wait for payment and verify proof of payment + match node_a.next_event_async().await { + Event::PaymentSuccessful { + payment_id: event_payment_id, + payment_hash, + payment_preimage, + fee_paid_msat: _, + bolt12_invoice, + } => { + assert_eq!(event_payment_id, Some(payment_id)); + + // Verify proof of payment: sha256(preimage) == payment_hash + let preimage = payment_preimage.expect("preimage should be present"); + let computed_hash = Sha256Hash::hash(&preimage.0); + assert_eq!(PaymentHash(computed_hash.to_byte_array()), payment_hash); + + // Verify the BOLT12 invoice is present and contains the correct payment hash + let paid_invoice = + bolt12_invoice.expect("bolt12_invoice should be present for BOLT12 payments"); + #[cfg(not(feature = "uniffi"))] + { + match paid_invoice { + PaidBolt12Invoice::Bolt12Invoice(invoice) => { + assert_eq!(invoice.payment_hash(), payment_hash); + assert_eq!(invoice.amount_msats(), expected_amount_msat); + }, + PaidBolt12Invoice::StaticInvoice(_) => { + panic!("Expected Bolt12Invoice, got StaticInvoice"); + }, + } + } + #[cfg(feature = "uniffi")] + { + let invoice = + paid_invoice.bolt12_invoice.expect("bolt12_invoice should be present"); + assert_eq!(invoice.payment_hash(), payment_hash); + assert_eq!(invoice.amount_msats(), expected_amount_msat); + } + + node_a.event_handled().unwrap(); + }, + ref e => { + panic!("Unexpected event: {:?}", e); + }, + } + + expect_payment_received_event!(node_b, expected_amount_msat); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn async_payment() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -2445,7 +2537,11 @@ async fn persistence_backwards_compatibility() { let storage_path = common::random_storage_path().to_str().unwrap().to_owned(); let seed_bytes = [42u8; 64]; + #[cfg(not(feature = "uniffi"))] let node_entropy = NodeEntropy::from_seed_bytes(seed_bytes); + #[cfg(feature = "uniffi")] + let node_entropy = + Arc::new(NodeEntropy::from_seed_bytes(seed_bytes.to_vec()).expect("valid seed")); // Setup a v0.6.2 `Node` let (old_balance, old_node_id) = {