diff --git a/Cargo.toml b/Cargo.toml index 59ad2b767..41b8adbbe 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,17 +39,17 @@ default = [] #lightning-liquidity = { version = "0.2.0", features = ["std"] } #lightning-macros = { version = "0.2.0" } -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "5bf0d1e2427d759fc1ba4108ddc7e9b32e8bacfc", features = ["std"] } -lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "5bf0d1e2427d759fc1ba4108ddc7e9b32e8bacfc" } -lightning-invoice = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "5bf0d1e2427d759fc1ba4108ddc7e9b32e8bacfc", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "5bf0d1e2427d759fc1ba4108ddc7e9b32e8bacfc" } -lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "5bf0d1e2427d759fc1ba4108ddc7e9b32e8bacfc", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "5bf0d1e2427d759fc1ba4108ddc7e9b32e8bacfc" } -lightning-rapid-gossip-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "5bf0d1e2427d759fc1ba4108ddc7e9b32e8bacfc" } -lightning-block-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "5bf0d1e2427d759fc1ba4108ddc7e9b32e8bacfc", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "5bf0d1e2427d759fc1ba4108ddc7e9b32e8bacfc", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "5bf0d1e2427d759fc1ba4108ddc7e9b32e8bacfc", features = ["std"] } -lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "5bf0d1e2427d759fc1ba4108ddc7e9b32e8bacfc" } +lightning = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2", features = ["std"] } +lightning-types = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +lightning-invoice = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +lightning-persister = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +lightning-rapid-gossip-sync = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +lightning-block-sync = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2", features = ["std"] } +lightning-macros = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -82,7 +82,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "5bf0d1e2427d759fc1ba4108ddc7e9b32e8bacfc", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2", features = ["std", "_test_utils"] } proptest = "1.0.0" regex = "1.5.6" criterion = { version = "0.7.0", features = ["async_tokio"] } @@ -129,6 +129,18 @@ name = "payments" harness = false #[patch.crates-io] +#lightning = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +#lightning-types = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +#lightning-invoice = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +#lightning-net-tokio = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +#lightning-persister = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +#lightning-background-processor = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +#lightning-rapid-gossip-sync = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +#lightning-block-sync = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +#lightning-transaction-sync = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +#lightning-liquidity = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } +#lightning-macros = { git = "https://github.com/vincenzopalazzo/rust-lightning", branch = "macros/blip02-prep-v2" } + #lightning = { path = "../rust-lightning/lightning" } #lightning-types = { path = "../rust-lightning/lightning-types" } #lightning-invoice = { path = "../rust-lightning/lightning-invoice" } diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index e89158b59..f31451a72 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -227,10 +227,19 @@ interface Bolt12Payment { PaymentId send([ByRef]Offer offer, u64? quantity, string? payer_note, RouteParametersConfig? route_parameters); [Throws=NodeError] PaymentId send_using_amount([ByRef]Offer offer, u64 amount_msat, u64? quantity, string? payer_note, RouteParametersConfig? route_parameters); + /// Send a payment with BLIP-42 contact information for contact establishment. + [Throws=NodeError] + PaymentId send_with_contact([ByRef]Offer offer, u64? quantity, string? payer_note, RouteParametersConfig? route_parameters, ContactSecrets? contact_secrets, Offer? payer_offer); + /// Send a payment with an explicit amount and BLIP-42 contact information. + [Throws=NodeError] + PaymentId send_using_amount_with_contact([ByRef]Offer offer, u64 amount_msat, u64? quantity, string? payer_note, RouteParametersConfig? route_parameters, ContactSecrets? contact_secrets, Offer? payer_offer); [Throws=NodeError] Offer receive(u64 amount_msat, [ByRef]string description, u32? expiry_secs, u64? quantity); [Throws=NodeError] Offer receive_variable_amount([ByRef]string description, u32? expiry_secs); + /// Creates a compact contact offer for BLIP-42's payer_offer field. + [Throws=NodeError] + Offer create_contact_offer(PublicKey? intro_node); [Throws=NodeError] Bolt12Invoice request_refund_payment([ByRef]Refund refund); [Throws=NodeError] @@ -402,7 +411,7 @@ enum VssHeaderProviderError { interface Event { PaymentSuccessful(PaymentId? payment_id, PaymentHash payment_hash, PaymentPreimage? payment_preimage, u64? fee_paid_msat); PaymentFailed(PaymentId? payment_id, PaymentHash? payment_hash, PaymentFailureReason? reason); - PaymentReceived(PaymentId? payment_id, PaymentHash payment_hash, u64 amount_msat, sequence custom_records); + PaymentReceived(PaymentId? payment_id, PaymentHash payment_hash, u64 amount_msat, sequence custom_records, sequence? contact_secret, string? payer_offer); PaymentClaimable(PaymentId payment_id, PaymentHash payment_hash, u64 claimable_amount_msat, u32? claim_deadline, sequence custom_records); PaymentForwarded(ChannelId prev_channel_id, ChannelId next_channel_id, UserChannelId? prev_user_channel_id, UserChannelId? next_user_channel_id, PublicKey? prev_node_id, PublicKey? next_node_id, u64? total_fee_earned_msat, u64? skimmed_fee_msat, boolean claim_from_onchain_tx, u64? outbound_amount_forwarded_msat); @@ -763,6 +772,18 @@ dictionary RouteHintHop { RoutingFees fees; }; +/// BLIP-42 Contact secrets used for mutual authentication in payments. +/// +/// When sending payments with contact information, the primary secret is sent to establish +/// the contact relationship. Additional remote secrets can be stored for recognizing +/// payments from contacts who independently added us. +dictionary ContactSecrets { + /// The primary secret (32 bytes) used when sending payments to identify ourselves. + sequence primary_secret; + /// Additional secrets received from contacts for recognizing their payments. + sequence> additional_remote_secrets; +}; + [Traits=(Debug, Display, Eq)] interface Bolt11Invoice { [Throws=NodeError, Name=from_str] diff --git a/src/event.rs b/src/event.rs index 75270bf53..7489c3312 100644 --- a/src/event.rs +++ b/src/event.rs @@ -106,6 +106,25 @@ pub enum Event { amount_msat: u64, /// Custom TLV records received on the payment custom_records: Vec, + /// BLIP-42: The contact secret sent by the payer. + /// + /// If present, this indicates the payer wants to establish a contact relationship. + /// The recipient can use this secret along with `payer_offer` to add the payer as a contact. + /// + /// See [BLIP-42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. + /// + /// Will only be `Some` for BOLT12 payments where the payer included contact information. + /// The value is 32 bytes. + contact_secret: Option>, + /// BLIP-42: The payer's BOLT12 offer. + /// + /// If present, this is the payer's offer that can be used to pay them back or establish + /// bidirectional contact. This is typically a compact offer with minimal blinded paths. + /// + /// See [BLIP-42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. + /// + /// Will only be `Some` for BOLT12 payments where the payer included their offer. + payer_offer: Option, }, /// A payment has been forwarded. PaymentForwarded { @@ -275,6 +294,9 @@ impl_writeable_tlv_based_enum!(Event, (1, payment_id, option), (2, amount_msat, required), (3, custom_records, optional_vec), + // BLIP-42 contact fields + (5, contact_secret, option), + (7, payer_offer, option), }, (3, ChannelReady) => { (0, channel_id, required), @@ -938,6 +960,21 @@ where amount_msat, ); + // Extract BLIP-42 contact fields from Bolt12 payments + let (contact_secret, payer_offer) = match &purpose { + PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => { + let contact_secret = + payment_context.invoice_request.contact_secret.map(|s| s.to_vec()); + let payer_offer = payment_context + .invoice_request + .payer_offer + .as_ref() + .map(|offer| offer.to_string()); + (contact_secret, payer_offer) + }, + _ => (None, None), + }; + let update = match purpose { PaymentPurpose::Bolt11InvoicePayment { payment_preimage, @@ -1008,6 +1045,8 @@ where custom_records: onion_fields .map(|cf| cf.custom_tlvs().into_iter().map(|tlv| tlv.into()).collect()) .unwrap_or_default(), + contact_secret, + payer_offer, }; match self.event_queue.add_event(event).await { Ok(_) => return Ok(()), diff --git a/src/io/test_utils.rs b/src/io/test_utils.rs index 6eb04df3f..8bfab495f 100644 --- a/src/io/test_utils.rs +++ b/src/io/test_utils.rs @@ -13,15 +13,15 @@ use std::sync::Mutex; use lightning::events::ClosureReason; use lightning::ln::functional_test_utils::{ - check_closed_event, connect_block, create_announced_chan_between_nodes, create_chanmon_cfgs, - create_dummy_block, create_network, create_node_cfgs, create_node_chanmgrs, send_payment, - TestChanMonCfg, + check_added_monitors, check_closed_broadcast, check_closed_event, connect_block, + create_announced_chan_between_nodes, create_chanmon_cfgs, create_dummy_block, create_network, + create_node_cfgs, create_node_chanmgrs, send_payment, TestChanMonCfg, }; use lightning::util::persist::{ KVStore, KVStoreSync, MonitorUpdatingPersister, KVSTORE_NAMESPACE_KEY_MAX_LEN, }; use lightning::util::test_utils; -use lightning::{check_added_monitors, check_closed_broadcast, io}; +use lightning::io; use rand::distr::Alphanumeric; use rand::{rng, Rng}; @@ -332,8 +332,8 @@ pub(crate) fn do_test_store(store_0: &K, store_1: &K) { &[nodes[1].node.get_our_node_id()], 100000, ); - check_closed_broadcast!(nodes[0], true); - check_added_monitors!(nodes[0], 1); + check_closed_broadcast(&nodes[0], 1, true); + check_added_monitors(&nodes[0], 1); let node_txn = nodes[0].tx_broadcaster.txn_broadcast(); assert_eq!(node_txn.len(), 1); @@ -341,11 +341,11 @@ pub(crate) fn do_test_store(store_0: &K, store_1: &K) { let dummy_block = create_dummy_block(nodes[0].best_block_hash(), 42, txn); connect_block(&nodes[1], &dummy_block); - check_closed_broadcast!(nodes[1], true); + check_closed_broadcast(&nodes[1], 1, true); let reason = ClosureReason::CommitmentTxConfirmed; let node_id_0 = nodes[0].node.get_our_node_id(); check_closed_event(&nodes[1], 1, reason, &[node_id_0], 100000); - check_added_monitors!(nodes[1], 1); + check_added_monitors(&nodes[1], 1); // Make sure everything is persisted as expected after close. check_persisted_data!(persister_0_max_pending_updates * 2 * EXPECTED_UPDATES_PER_PAYMENT + 1); diff --git a/src/lib.rs b/src/lib.rs index 0031269dd..44472e80b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -161,7 +161,10 @@ use types::{ Broadcaster, BumpTransactionEventHandler, ChainMonitor, ChannelManager, DynStore, Graph, KeysManager, OnionMessenger, PaymentStore, PeerManager, Router, Scorer, Sweeper, Wallet, }; -pub use types::{ChannelDetails, CustomTlvRecord, PeerDetails, SyncAndAsyncKVStore, UserChannelId}; +pub use types::{ + ChannelDetails, ContactSecrets, CustomTlvRecord, PeerDetails, SyncAndAsyncKVStore, + UserChannelId, +}; pub use { bip39, bitcoin, lightning, lightning_invoice, lightning_liquidity, lightning_types, tokio, vss_client, diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index 0dd38edca..611466083 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -13,8 +13,10 @@ use std::num::NonZeroU64; use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use bitcoin::secp256k1::PublicKey; use lightning::blinded_path::message::BlindedMessagePath; use lightning::ln::channelmanager::{OptionalOfferPaymentParams, PaymentId, Retry}; +use lightning::offers::contacts::ContactSecrets as LdkContactSecrets; use lightning::offers::offer::{Amount, Offer as LdkOffer, Quantity}; use lightning::offers::parse::Bolt12SemanticError; use lightning::routing::router::RouteParametersConfig; @@ -30,6 +32,12 @@ use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::payment::store::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; use crate::types::{ChannelManager, PaymentStore}; +// For UniFFI, use the FFI wrapper type; otherwise use the LDK type directly +#[cfg(not(feature = "uniffi"))] +type ContactSecrets = LdkContactSecrets; +#[cfg(feature = "uniffi")] +type ContactSecrets = crate::types::ContactSecrets; + #[cfg(not(feature = "uniffi"))] type Bolt12Invoice = lightning::offers::invoice::Bolt12Invoice; #[cfg(feature = "uniffi")] @@ -111,6 +119,9 @@ impl Bolt12Payment { payer_note: payer_note.clone(), retry_strategy, route_params_config: route_parameters, + // BLIP-42 contact fields - set to None for basic send + contact_secrets: None, + payer_offer: None, }; let res = if let Some(quantity) = quantity { self.channel_manager @@ -227,6 +238,9 @@ impl Bolt12Payment { payer_note: payer_note.clone(), retry_strategy, route_params_config: route_parameters, + // BLIP-42 contact fields - set to None for basic send + contact_secrets: None, + payer_offer: None, }; let res = if let Some(quantity) = quantity { self.channel_manager.pay_for_offer_with_quantity( @@ -299,6 +313,283 @@ impl Bolt12Payment { } } + /// Send a payment given an offer, including BLIP-42 contact information. + /// + /// This is similar to [`send`] but additionally includes contact information that allows + /// the recipient to establish a bidirectional contact relationship with the payer. + /// + /// If `payer_note` is `Some` it will be seen by the recipient and reflected back in the invoice + /// response. + /// + /// If `quantity` is `Some` it represents the number of items requested. + /// + /// If `route_parameters` are provided they will override the default as well as the + /// node-wide parameters configured via [`Config::route_parameters`] on a per-field basis. + /// + /// If `contact_secrets` is `Some`, the contact information will be included in the invoice + /// request, enabling BLIP-42 contact management. + /// + /// If `payer_offer` is `Some`, the payer's BOLT12 offer will be included, allowing the + /// recipient to pay the sender back. + /// + /// See [BLIP-42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. + /// + /// [`send`]: Self::send + pub fn send_with_contact( + &self, offer: &Offer, quantity: Option, payer_note: Option, + route_parameters: Option, contact_secrets: Option, + payer_offer: Option, + ) -> Result { + if !*self.is_running.read().unwrap() { + return Err(Error::NotRunning); + } + + let offer = maybe_deref(offer); + let payer_offer = payer_offer.map(|o| maybe_deref(&o).clone()); + + let mut random_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut random_bytes); + let payment_id = PaymentId(random_bytes); + let retry_strategy = Retry::Timeout(LDK_PAYMENT_RETRY_TIMEOUT); + let route_parameters = + route_parameters.or(self.config.route_parameters).unwrap_or_default(); + + let offer_amount_msat = match offer.amount() { + Some(Amount::Bitcoin { amount_msats }) => amount_msats, + Some(_) => { + log_error!(self.logger, "Failed to send payment as the provided offer was denominated in an unsupported currency."); + return Err(Error::UnsupportedCurrency); + }, + None => { + log_error!(self.logger, "Failed to send payment due to the given offer being \"zero-amount\". Please use send_using_amount_with_contact instead."); + return Err(Error::InvalidOffer); + }, + }; + + // Convert FFI ContactSecrets to LDK type if necessary + #[cfg(feature = "uniffi")] + let contact_secrets: Option = contact_secrets.map(|s| s.into()); + #[cfg(not(feature = "uniffi"))] + let contact_secrets = contact_secrets; + + let params = OptionalOfferPaymentParams { + payer_note: payer_note.clone(), + retry_strategy, + route_params_config: route_parameters, + contact_secrets, + payer_offer, + }; + let res = if let Some(quantity) = quantity { + self.channel_manager + .pay_for_offer_with_quantity(&offer, None, payment_id, params, quantity) + } else { + self.channel_manager.pay_for_offer(&offer, None, payment_id, params) + }; + + match res { + Ok(()) => { + let payee_pubkey = offer.issuer_signing_pubkey(); + log_info!( + self.logger, + "Initiated sending {}msat to {:?} with BLIP-42 contact info", + offer_amount_msat, + payee_pubkey + ); + + let kind = PaymentKind::Bolt12Offer { + hash: None, + preimage: None, + secret: None, + offer_id: offer.id(), + payer_note: payer_note.map(UntrustedString), + quantity, + }; + let payment = PaymentDetails::new( + payment_id, + kind, + Some(offer_amount_msat), + None, + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + self.payment_store.insert(payment)?; + + Ok(payment_id) + }, + Err(e) => { + log_error!(self.logger, "Failed to send invoice request with contact: {:?}", e); + match e { + Bolt12SemanticError::DuplicatePaymentId => Err(Error::DuplicatePayment), + _ => { + let kind = PaymentKind::Bolt12Offer { + hash: None, + preimage: None, + secret: None, + offer_id: offer.id(), + payer_note: payer_note.map(UntrustedString), + quantity, + }; + let payment = PaymentDetails::new( + payment_id, + kind, + Some(offer_amount_msat), + None, + PaymentDirection::Outbound, + PaymentStatus::Failed, + ); + self.payment_store.insert(payment)?; + Err(Error::InvoiceRequestCreationFailed) + }, + } + }, + } + } + + /// Send a payment given an offer and an amount in millisatoshi, including BLIP-42 contact + /// information. + /// + /// This is similar to [`send_using_amount`] but additionally includes contact information + /// that allows the recipient to establish a bidirectional contact relationship with the payer. + /// + /// This will fail if the amount given is less than the value required by the given offer. + /// + /// This can be used to pay a so-called "zero-amount" offers, i.e., an offer that leaves the + /// amount paid to be determined by the user. + /// + /// If `payer_note` is `Some` it will be seen by the recipient and reflected back in the invoice + /// response. + /// + /// If `route_parameters` are provided they will override the default as well as the + /// node-wide parameters configured via [`Config::route_parameters`] on a per-field basis. + /// + /// If `contact_secrets` is `Some`, the contact information will be included in the invoice + /// request, enabling BLIP-42 contact management. + /// + /// If `payer_offer` is `Some`, the payer's BOLT12 offer will be included, allowing the + /// recipient to pay the sender back. + /// + /// See [BLIP-42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. + /// + /// [`send_using_amount`]: Self::send_using_amount + pub fn send_using_amount_with_contact( + &self, offer: &Offer, amount_msat: u64, quantity: Option, payer_note: Option, + route_parameters: Option, contact_secrets: Option, + payer_offer: Option, + ) -> Result { + if !*self.is_running.read().unwrap() { + return Err(Error::NotRunning); + } + + let offer = maybe_deref(offer); + let payer_offer = payer_offer.map(|o| maybe_deref(&o).clone()); + + let mut random_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut random_bytes); + let payment_id = PaymentId(random_bytes); + let retry_strategy = Retry::Timeout(LDK_PAYMENT_RETRY_TIMEOUT); + let route_parameters = + route_parameters.or(self.config.route_parameters).unwrap_or_default(); + + let offer_amount_msat = match offer.amount() { + Some(Amount::Bitcoin { amount_msats }) => amount_msats, + Some(_) => { + log_error!(self.logger, "Failed to send payment as the provided offer was denominated in an unsupported currency."); + return Err(Error::UnsupportedCurrency); + }, + None => amount_msat, + }; + + if amount_msat < offer_amount_msat { + log_error!( + self.logger, + "Failed to pay as the given amount needs to be at least the offer amount: required {}msat, gave {}msat.", offer_amount_msat, amount_msat); + return Err(Error::InvalidAmount); + } + + // Convert FFI ContactSecrets to LDK type if necessary + #[cfg(feature = "uniffi")] + let contact_secrets: Option = contact_secrets.map(|s| s.into()); + #[cfg(not(feature = "uniffi"))] + let contact_secrets = contact_secrets; + + let params = OptionalOfferPaymentParams { + payer_note: payer_note.clone(), + retry_strategy, + route_params_config: route_parameters, + contact_secrets, + payer_offer, + }; + let res = if let Some(quantity) = quantity { + self.channel_manager.pay_for_offer_with_quantity( + &offer, + Some(amount_msat), + payment_id, + params, + quantity, + ) + } else { + self.channel_manager.pay_for_offer(&offer, Some(amount_msat), payment_id, params) + }; + + match res { + Ok(()) => { + let payee_pubkey = offer.issuer_signing_pubkey(); + log_info!( + self.logger, + "Initiated sending {}msat to {:?} with BLIP-42 contact info", + amount_msat, + payee_pubkey + ); + + let kind = PaymentKind::Bolt12Offer { + hash: None, + preimage: None, + secret: None, + offer_id: offer.id(), + payer_note: payer_note.map(UntrustedString), + quantity, + }; + let payment = PaymentDetails::new( + payment_id, + kind, + Some(amount_msat), + None, + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + self.payment_store.insert(payment)?; + + Ok(payment_id) + }, + Err(e) => { + log_error!(self.logger, "Failed to send payment with contact: {:?}", e); + match e { + Bolt12SemanticError::DuplicatePaymentId => Err(Error::DuplicatePayment), + _ => { + let kind = PaymentKind::Bolt12Offer { + hash: None, + preimage: None, + secret: None, + offer_id: offer.id(), + payer_note: payer_note.map(UntrustedString), + quantity, + }; + let payment = PaymentDetails::new( + payment_id, + kind, + Some(amount_msat), + None, + PaymentDirection::Outbound, + PaymentStatus::Failed, + ); + self.payment_store.insert(payment)?; + Err(Error::PaymentSendingFailed) + }, + } + }, + } + } + pub(crate) fn receive_inner( &self, amount_msat: u64, description: &str, expiry_secs: Option, quantity: Option, ) -> Result { @@ -368,6 +659,74 @@ impl Bolt12Payment { Ok(maybe_wrap(offer)) } + /// Creates a compact contact offer suitable for BLIP-42's `payer_offer` field. + /// + /// Contact offers are designed to be embedded in invoice requests and should be + /// as compact as possible while still being payable. Unlike regular offers created + /// by [`receive`] or [`receive_variable_amount`], contact offers have minimal or + /// no blinded paths. + /// + /// # Privacy Modes + /// + /// - `intro_node: None` - Creates an offer with no blinded paths. The offer exposes + /// the node's derived signing pubkey directly. This is the most compact form but + /// provides no path privacy. Suitable when privacy is not a concern or when the + /// offer will only be shared with trusted contacts. + /// + /// - `intro_node: Some(node_id)` - Creates an offer with a single blinded path through + /// the specified introduction node. The intro node should be a well-connected, + /// trusted peer that can route onion messages to this node. + /// + /// # Example + /// + /// ```ignore + /// // Create a compact contact offer (no privacy) + /// let contact_offer = node.bolt12_payment() + /// .create_contact_offer(None) + /// .unwrap(); + /// + /// // Create a contact offer with privacy through a trusted peer + /// let contact_offer = node.bolt12_payment() + /// .create_contact_offer(Some(trusted_peer_id)) + /// .unwrap(); + /// + /// // Use it when paying someone with BLIP-42 contact info + /// let payment_id = node.bolt12_payment() + /// .send_with_contact( + /// &their_offer, None, None, None, + /// Some(contact_secrets), + /// Some(contact_offer), + /// ) + /// .unwrap(); + /// ``` + /// + /// See [BLIP-42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. + /// + /// [`receive`]: Self::receive + /// [`receive_variable_amount`]: Self::receive_variable_amount + pub fn create_contact_offer(&self, intro_node: Option) -> Result { + let offer = self + .channel_manager + .create_compact_offer_builder(intro_node) + .map_err(|e| { + log_error!(self.logger, "Failed to create compact offer builder: {:?}", e); + Error::OfferCreationFailed + })? + .build() + .map_err(|e| { + log_error!(self.logger, "Failed to build contact offer: {:?}", e); + Error::OfferCreationFailed + })?; + + log_info!( + self.logger, + "Created contact offer with intro_node: {:?}", + intro_node.map(|n| n.to_string()) + ); + + Ok(maybe_wrap(offer)) + } + /// Requests a refund payment for the given [`Refund`]. /// /// The returned [`Bolt12Invoice`] is for informational purposes only (i.e., isn't needed to diff --git a/src/payment/mod.rs b/src/payment/mod.rs index f629960e1..b43f67269 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -23,3 +23,6 @@ pub use store::{ ConfirmationStatus, LSPFeeLimits, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, }; pub use unified_qr::{QrPaymentResult, UnifiedQrPayment}; + +// Re-export BLIP-42 contact types for convenience +pub use lightning::offers::contacts::{ContactSecret, ContactSecrets}; diff --git a/src/types.rs b/src/types.rs index 7c0e1227a..79ca24e24 100644 --- a/src/types.rs +++ b/src/types.rs @@ -606,3 +606,52 @@ impl From<&(u64, Vec)> for CustomTlvRecord { CustomTlvRecord { type_num: tlv.0, value: tlv.1.clone() } } } + +/// BLIP-42 Contact secrets for payment identification. +/// +/// Contact secrets are used to mutually authenticate payments between contacts. +/// See [BLIP-42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContactSecrets { + /// The primary secret (32 bytes) used when sending payments to identify ourselves. + pub primary_secret: Vec, + /// Additional secrets received from contacts for recognizing their payments. + pub additional_remote_secrets: Vec>, +} + +impl From for lightning::offers::contacts::ContactSecrets { + fn from(secrets: ContactSecrets) -> Self { + let primary: [u8; 32] = secrets + .primary_secret + .try_into() + .expect("primary_secret must be 32 bytes"); + let primary_secret = lightning::offers::contacts::ContactSecret::new(primary); + + let additional: Vec = secrets + .additional_remote_secrets + .into_iter() + .map(|s| { + let arr: [u8; 32] = s.try_into().expect("each secret must be 32 bytes"); + lightning::offers::contacts::ContactSecret::new(arr) + }) + .collect(); + + lightning::offers::contacts::ContactSecrets::with_additional_secrets( + primary_secret, + additional, + ) + } +} + +impl From<&lightning::offers::contacts::ContactSecrets> for ContactSecrets { + fn from(secrets: &lightning::offers::contacts::ContactSecrets) -> Self { + ContactSecrets { + primary_secret: secrets.primary_secret().as_bytes().to_vec(), + additional_remote_secrets: secrets + .additional_remote_secrets() + .iter() + .map(|s| s.as_bytes().to_vec()) + .collect(), + } + } +} diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 9b02cd61f..8cca5c268 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1303,6 +1303,136 @@ 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_with_blip42_contact() { + 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; + } + while node_a.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 announcements propagate. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // Node B creates an offer to receive payment + let expected_amount_msat = 100_000_000; + let node_b_offer = + node_b.bolt12_payment().receive(expected_amount_msat, "test payment", None, Some(1)).unwrap(); + + // Node A creates a COMPACT contact offer for BLIP-42's payer_offer field. + // Using None for intro_node creates an offer with no blinded paths (maximum compactness). + // This is suitable for embedding in invoice requests per BLIP-42 specification. + let node_a_offer = node_a.bolt12_payment().create_contact_offer(None).unwrap(); + + // Verify the contact offer is compact (either no paths or single-hop paths) + assert!( + node_a_offer.paths().is_empty() + || node_a_offer.paths().iter().all(|p| p.blinded_hops().len() <= 1), + "Contact offer should be compact with no paths or single-hop paths" + ); + + // Create a contact secret (32 random bytes) + use ldk_node::payment::{ContactSecret, ContactSecrets}; + let contact_secret_bytes: [u8; 32] = std::array::from_fn(|i| i as u8); + let contact_secret = ContactSecret::new(contact_secret_bytes); + let contact_secrets = ContactSecrets::new(contact_secret); + + // Node A sends payment to Node B with BLIP-42 contact info + let payment_id = node_a + .bolt12_payment() + .send_with_contact( + &node_b_offer, + Some(1), + Some("BLIP-42 test".to_string()), + None, + Some(contact_secrets), + Some(node_a_offer.clone()), + ) + .unwrap(); + + expect_payment_successful_event!(node_a, Some(payment_id), None); + + // Node B should receive the payment with BLIP-42 contact information + let event = node_b.next_event_async().await; + match event { + Event::PaymentReceived { + amount_msat, + contact_secret: received_contact_secret, + payer_offer: received_payer_offer, + .. + } => { + println!("Node B received payment with BLIP-42 contact info"); + assert_eq!(amount_msat, expected_amount_msat); + + // Verify contact_secret is present and matches + assert!(received_contact_secret.is_some(), "Expected contact_secret to be Some"); + assert_eq!( + received_contact_secret.unwrap(), + contact_secret_bytes.to_vec(), + "Contact secret mismatch" + ); + + // Verify payer_offer is present + assert!(received_payer_offer.is_some(), "Expected payer_offer to be Some"); + let payer_offer_str = received_payer_offer.unwrap(); + + // Parse the payer_offer and verify it matches node_a's offer + let parsed_offer: lightning::offers::offer::Offer = + payer_offer_str.parse().expect("Failed to parse payer_offer"); + assert_eq!( + parsed_offer.id(), + node_a_offer.id(), + "Parsed offer ID should match node A's offer" + ); + + node_b.event_handled().unwrap(); + }, + ref e => { + panic!("Expected PaymentReceived event, got: {:?}", e); + }, + } + + // Verify payment records + let node_a_payments = + node_a.list_payments_with_filter(|p| matches!(p.kind, PaymentKind::Bolt12Offer { .. })); + assert_eq!(node_a_payments.len(), 1); + assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(expected_amount_msat)); + assert_eq!(node_a_payments.first().unwrap().status, PaymentStatus::Succeeded); + + let node_b_payments = + node_b.list_payments_with_filter(|p| matches!(p.kind, PaymentKind::Bolt12Offer { .. })); + assert_eq!(node_b_payments.len(), 1); + assert_eq!(node_b_payments.first().unwrap().amount_msat, Some(expected_amount_msat)); + assert_eq!(node_b_payments.first().unwrap().status, PaymentStatus::Succeeded); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn async_payment() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();