From 12f6e13c4e44ab5c88423ecb12e274da8000d0ad Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:05:42 +0200 Subject: [PATCH 1/4] DROPME: Bump Rust Lightning to unmerged LSPS2 APIs Point the Rust Lightning dependency overrides at the unmerged LSPS2 PR revisions and carry the temporary API updates needed for LDK Node to compile against them. Co-Authored-By: HAL 9000 --- Cargo.toml | 26 +++++++++--------- src/builder.rs | 1 + src/data_store.rs | 6 ++--- src/event.rs | 27 ++++++++++++------- src/io/vss_store.rs | 4 +-- src/lib.rs | 6 ++--- src/liquidity.rs | 4 +-- .../asynchronous/static_invoice_store.rs | 4 +-- src/payment/bolt11.rs | 4 +-- src/payment/pending_payment_store.rs | 4 +-- src/payment/store.rs | 18 ++++++------- src/peer_store.rs | 4 +-- src/types.rs | 4 +-- 13 files changed, 61 insertions(+), 51 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bed984f071..707365f930 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -183,16 +183,16 @@ harness = false #vss-client-ng = { path = "../vss-client" } #vss-client-ng = { git = "https://github.com/lightningdevkit/vss-client", branch = "main" } # -#[patch."https://github.com/lightningdevkit/rust-lightning"] -#lightning = { path = "../rust-lightning/lightning" } -#lightning-types = { path = "../rust-lightning/lightning-types" } -#lightning-invoice = { path = "../rust-lightning/lightning-invoice" } -#lightning-net-tokio = { path = "../rust-lightning/lightning-net-tokio" } -#lightning-persister = { path = "../rust-lightning/lightning-persister" } -#lightning-background-processor = { path = "../rust-lightning/lightning-background-processor" } -#lightning-rapid-gossip-sync = { path = "../rust-lightning/lightning-rapid-gossip-sync" } -#lightning-block-sync = { path = "../rust-lightning/lightning-block-sync" } -#lightning-transaction-sync = { path = "../rust-lightning/lightning-transaction-sync" } -#lightning-liquidity = { path = "../rust-lightning/lightning-liquidity" } -#lightning-macros = { path = "../rust-lightning/lightning-macros" } -#lightning-dns-resolver = { path = "../rust-lightning/lightning-dns-resolver" } +[patch."https://github.com/lightningdevkit/rust-lightning"] +lightning = { git = "https://github.com/tnull/rust-lightning", rev = "26da1dde77c27563121245ea5f506093f88b1323" } +lightning-types = { git = "https://github.com/tnull/rust-lightning", rev = "26da1dde77c27563121245ea5f506093f88b1323" } +lightning-invoice = { git = "https://github.com/tnull/rust-lightning", rev = "26da1dde77c27563121245ea5f506093f88b1323" } +lightning-net-tokio = { git = "https://github.com/tnull/rust-lightning", rev = "26da1dde77c27563121245ea5f506093f88b1323" } +lightning-persister = { git = "https://github.com/tnull/rust-lightning", rev = "26da1dde77c27563121245ea5f506093f88b1323" } +lightning-background-processor = { git = "https://github.com/tnull/rust-lightning", rev = "26da1dde77c27563121245ea5f506093f88b1323" } +lightning-rapid-gossip-sync = { git = "https://github.com/tnull/rust-lightning", rev = "26da1dde77c27563121245ea5f506093f88b1323" } +lightning-block-sync = { git = "https://github.com/tnull/rust-lightning", rev = "26da1dde77c27563121245ea5f506093f88b1323" } +lightning-transaction-sync = { git = "https://github.com/tnull/rust-lightning", rev = "26da1dde77c27563121245ea5f506093f88b1323" } +lightning-liquidity = { git = "https://github.com/tnull/rust-lightning", rev = "26da1dde77c27563121245ea5f506093f88b1323" } +lightning-macros = { git = "https://github.com/tnull/rust-lightning", rev = "26da1dde77c27563121245ea5f506093f88b1323" } +lightning-dns-resolver = { git = "https://github.com/tnull/rust-lightning", rev = "26da1dde77c27563121245ea5f506093f88b1323" } diff --git a/src/builder.rs b/src/builder.rs index c88c867cc1..e0e6c5220b 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1932,6 +1932,7 @@ fn build_with_store_internal( Arc::clone(&channel_manager), Arc::clone(&om_resolver), IgnoringMessageHandler {}, + false, )) } else { Arc::new(OnionMessenger::new( diff --git a/src/data_store.rs b/src/data_store.rs index 70abfcc3fd..3c60bc684a 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -218,7 +218,7 @@ where #[cfg(test)] mod tests { - use lightning::impl_writeable_tlv_based; + use lightning::impl_ser_tlv_based; use lightning::util::test_utils::TestLogger; use super::*; @@ -236,7 +236,7 @@ mod tests { hex_utils::to_string(&self.id) } } - impl_writeable_tlv_based!(TestObjectId, { (0, id, required) }); + impl_ser_tlv_based!(TestObjectId, { (0, id, required) }); struct TestObjectUpdate { id: TestObjectId, @@ -276,7 +276,7 @@ mod tests { } } - impl_writeable_tlv_based!(TestObject, { + impl_ser_tlv_based!(TestObject, { (0, id, required), (2, data, required), }); diff --git a/src/event.rs b/src/event.rs index 86ee7bb05a..6623d2ace5 100644 --- a/src/event.rs +++ b/src/event.rs @@ -29,7 +29,7 @@ use lightning::util::config::{ChannelConfigOverrides, ChannelConfigUpdate}; use lightning::util::errors::APIError; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, ReadableArgs, Writeable, Writer}; -use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use lightning::{impl_ser_tlv_based, impl_ser_tlv_based_enum}; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_types::payment::{PaymentHash, PaymentPreimage}; @@ -78,7 +78,7 @@ pub struct HTLCLocator { pub node_id: Option, } -impl_writeable_tlv_based!(HTLCLocator, { +impl_ser_tlv_based!(HTLCLocator, { (1, channel_id, required), (3, user_channel_id, option), (5, node_id, option), @@ -294,7 +294,7 @@ pub enum Event { }, } -impl_writeable_tlv_based_enum!(Event, +impl_ser_tlv_based_enum!(Event, (0, PaymentSuccessful) => { (0, payment_hash, required), (1, fee_paid_msat, option), @@ -1724,15 +1724,24 @@ where self.bump_tx_event_handler.handle_event(&bte).await; }, - LdkEvent::OnionMessageIntercepted { peer_node_id, message } => { - if let Some(om_mailbox) = self.om_mailbox.as_ref() { - om_mailbox.onion_message_intercepted(peer_node_id, message); - } else { + LdkEvent::OnionMessageIntercepted { next_hop, message } => match next_hop { + lightning::blinded_path::message::NextMessageHop::NodeId(peer_node_id) => { + if let Some(om_mailbox) = self.om_mailbox.as_ref() { + om_mailbox.onion_message_intercepted(peer_node_id, message); + } else { + log_trace!( + self.logger, + "Onion message intercepted, but no onion message mailbox available" + ); + } + }, + lightning::blinded_path::message::NextMessageHop::ShortChannelId(scid) => { log_trace!( self.logger, - "Onion message intercepted, but no onion message mailbox available" + "Onion message intercepted for unknown SCID {}, ignoring", + scid ); - } + }, }, LdkEvent::OnionMessagePeerConnected { peer_node_id } => { if let Some(om_mailbox) = self.om_mailbox.as_ref() { diff --git a/src/io/vss_store.rs b/src/io/vss_store.rs index 6c3535627a..559116ad2d 100644 --- a/src/io/vss_store.rs +++ b/src/io/vss_store.rs @@ -21,7 +21,7 @@ use bitcoin::bip32::{ChildNumber, Xpriv}; use bitcoin::hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine}; use bitcoin::key::Secp256k1; use bitcoin::Network; -use lightning::impl_writeable_tlv_based_enum; +use lightning::impl_ser_tlv_based_enum; use lightning::io::{self, Error, ErrorKind}; use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes}; use lightning::util::persist::KVStore; @@ -65,7 +65,7 @@ enum VssSchemaVersion { V1, } -impl_writeable_tlv_based_enum!(VssSchemaVersion, +impl_ser_tlv_based_enum!(VssSchemaVersion, (0, V0) => {}, (1, V1) => {}, ); diff --git a/src/lib.rs b/src/lib.rs index 7465dfabf5..2c2d2f2eb2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -147,7 +147,7 @@ use graph::NetworkGraph; use io::utils::update_and_persist_node_metrics; pub use lightning; use lightning::chain::BlockLocator; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::ln::chan_utils::FUNDING_TRANSACTION_WITNESS_WEIGHT; use lightning::ln::channel_state::ChannelDetails as LdkChannelDetails; pub use lightning::ln::channel_state::ChannelShutdownState; @@ -2223,7 +2223,7 @@ impl PersistedNodeMetrics { } } -impl_writeable_tlv_based!(NodeMetrics, { +impl_ser_tlv_based!(NodeMetrics, { (0, latest_lightning_wallet_sync_timestamp, option), (1, latest_pathfinding_scores_sync_timestamp, option), (2, latest_onchain_wallet_sync_timestamp, option), @@ -2293,7 +2293,7 @@ mod tests { latest_pathfinding_scores_sync_timestamp: Option, latest_node_announcement_broadcast_timestamp: Option, } - impl_writeable_tlv_based!(OldNodeMetrics, { + impl_ser_tlv_based!(OldNodeMetrics, { (0, latest_lightning_wallet_sync_timestamp, option), (1, latest_pathfinding_scores_sync_timestamp, option), (2, latest_onchain_wallet_sync_timestamp, option), diff --git a/src/liquidity.rs b/src/liquidity.rs index 3cd6d110da..30adfff24b 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -53,7 +53,7 @@ use crate::{total_anchor_channels_reserve_sats, Config, Error}; const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); -const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; +const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u16 = 72; struct LSPS1Client { lsp_node_id: PublicKey, @@ -1493,7 +1493,7 @@ pub(crate) struct LSPS2FeeResponse { #[derive(Debug, Clone)] pub(crate) struct LSPS2BuyResponse { intercept_scid: u64, - cltv_expiry_delta: u32, + cltv_expiry_delta: u16, } /// A liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. diff --git a/src/payment/asynchronous/static_invoice_store.rs b/src/payment/asynchronous/static_invoice_store.rs index f1e2378c23..85d9479234 100644 --- a/src/payment/asynchronous/static_invoice_store.rs +++ b/src/payment/asynchronous/static_invoice_store.rs @@ -13,7 +13,7 @@ use std::time::Duration; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; use lightning::blinded_path::message::BlindedMessagePath; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::offers::static_invoice::StaticInvoice; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, Writeable}; @@ -28,7 +28,7 @@ struct PersistedStaticInvoice { request_path: BlindedMessagePath, } -impl_writeable_tlv_based!(PersistedStaticInvoice, { +impl_ser_tlv_based!(PersistedStaticInvoice, { (0, invoice, required), (2, request_path, required) }); diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 068269997f..97dd7a3493 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, RwLock}; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::ln::channelmanager::{ Bolt11InvoiceParameters, OptionalBolt11PaymentParams, PaymentId, }; @@ -55,7 +55,7 @@ pub(crate) struct PaymentMetadata { pub(crate) lsps2_parameters: Option, } -impl_writeable_tlv_based!(PaymentMetadata, { +impl_ser_tlv_based!(PaymentMetadata, { (0, lsps2_parameters, option), }); diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index eb72f89ec9..37a3b09347 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -6,7 +6,7 @@ // accordance with one or both of these licenses. use bitcoin::Txid; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::ln::channelmanager::PaymentId; use crate::data_store::{StorableObject, StorableObjectUpdate}; @@ -33,7 +33,7 @@ impl PendingPaymentDetails { } } -impl_writeable_tlv_based!(PendingPaymentDetails, { +impl_ser_tlv_based!(PendingPaymentDetails, { (0, details, required), (2, conflicting_txids, optional_vec), }); diff --git a/src/payment/store.rs b/src/payment/store.rs index f80ab6f8a5..db4ba06ed8 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -13,8 +13,8 @@ use lightning::ln::msgs::DecodeError; use lightning::offers::offer::OfferId; use lightning::util::ser::{Readable, Writeable}; use lightning::{ - _init_and_read_len_prefixed_tlv_fields, impl_writeable_tlv_based, - impl_writeable_tlv_based_enum, write_tlv_fields, + _init_and_read_len_prefixed_tlv_fields, impl_ser_tlv_based, impl_ser_tlv_based_enum, + write_tlv_fields, }; use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use lightning_types::string::UntrustedString; @@ -307,7 +307,7 @@ pub enum PaymentDirection { Outbound, } -impl_writeable_tlv_based_enum!(PaymentDirection, +impl_ser_tlv_based_enum!(PaymentDirection, (0, Inbound) => {}, (1, Outbound) => {} ); @@ -324,7 +324,7 @@ pub enum PaymentStatus { Failed, } -impl_writeable_tlv_based_enum!(PaymentStatus, +impl_ser_tlv_based_enum!(PaymentStatus, (0, Pending) => {}, (2, Succeeded) => {}, (4, Failed) => {} @@ -420,7 +420,7 @@ pub enum PaymentKind { }, } -impl_writeable_tlv_based_enum!(PaymentKind, +impl_ser_tlv_based_enum!(PaymentKind, (0, Onchain) => { (0, txid, required), (2, status, required), @@ -479,7 +479,7 @@ pub enum ConfirmationStatus { Unconfirmed, } -impl_writeable_tlv_based_enum!(ConfirmationStatus, +impl_ser_tlv_based_enum!(ConfirmationStatus, (0, Confirmed) => { (0, block_hash, required), (2, height, required), @@ -504,7 +504,7 @@ pub struct LSPS2Parameters { pub max_proportional_opening_fee_ppm_msat: Option, } -impl_writeable_tlv_based!(LSPS2Parameters, { +impl_ser_tlv_based!(LSPS2Parameters, { (0, max_total_opening_fee_msat, option), (2, max_proportional_opening_fee_ppm_msat, option), }); @@ -604,7 +604,7 @@ mod tests { pub status: PaymentStatus, } - impl_writeable_tlv_based!(OldPaymentDetails, { + impl_ser_tlv_based!(OldPaymentDetails, { (0, hash, required), (2, preimage, required), (4, secret, required), @@ -706,7 +706,7 @@ mod tests { lsp_fee_limits: LSPS2Parameters, } - impl_writeable_tlv_based!(LegacyBolt11JitKind, { + impl_ser_tlv_based!(LegacyBolt11JitKind, { (0, hash, required), (1, counterparty_skimmed_fee_msat, option), (2, preimage, option), diff --git a/src/peer_store.rs b/src/peer_store.rs index 8037f93471..f43b24c57c 100644 --- a/src/peer_store.rs +++ b/src/peer_store.rs @@ -10,7 +10,7 @@ use std::ops::Deref; use std::sync::{Arc, RwLock}; use bitcoin::secp256k1::PublicKey; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, ReadableArgs, Writeable, Writer}; @@ -160,7 +160,7 @@ pub(crate) struct PeerInfo { pub address: SocketAddress, } -impl_writeable_tlv_based!(PeerInfo, { +impl_ser_tlv_based!(PeerInfo, { (0, node_id, required), (2, address, required), }); diff --git a/src/types.rs b/src/types.rs index 64209430be..a8ce812b8e 100644 --- a/src/types.rs +++ b/src/types.rs @@ -19,7 +19,7 @@ use bitcoin_payment_instructions::hrn_resolution::{ }; use bitcoin_payment_instructions::onion_message_resolver::LDKOnionMessageDNSSECHrnResolver; use lightning::chain::chainmonitor; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::ln::channel_state::{ChannelDetails as LdkChannelDetails, ChannelShutdownState}; use lightning::ln::msgs::{RoutingMessageHandler, SocketAddress}; use lightning::ln::peer_handler::IgnoringMessageHandler; @@ -616,7 +616,7 @@ pub struct CustomTlvRecord { pub value: Vec, } -impl_writeable_tlv_based!(CustomTlvRecord, { +impl_ser_tlv_based!(CustomTlvRecord, { (0, type_num, required), (2, value, required), }); From ab61bcf0fc04b9d68666448ec823962a81a77995 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 14:16:15 +0200 Subject: [PATCH 2/4] Support LSPS2 metadata in BOLT12 payment context Encode LSPS2 parameters in a shared payment metadata type so both BOLT11 invoices and BOLT12 payment contexts can carry the fee limits needed when handling intercepted payments. Co-Authored-By: HAL 9000 --- src/event.rs | 68 ++++++++++++++++++++---- src/payment/bolt11.rs | 45 +--------------- src/payment/metadata.rs | 111 ++++++++++++++++++++++++++++++++++++++++ src/payment/mod.rs | 5 +- 4 files changed, 175 insertions(+), 54 deletions(-) create mode 100644 src/payment/metadata.rs diff --git a/src/event.rs b/src/event.rs index 6623d2ace5..6dade3f981 100644 --- a/src/event.rs +++ b/src/event.rs @@ -7,7 +7,7 @@ use core::future::Future; use core::task::{Poll, Waker}; -use std::collections::VecDeque; +use std::collections::{BTreeMap, VecDeque}; use std::ops::Deref; use std::sync::{Arc, Mutex}; @@ -50,7 +50,7 @@ use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore; use crate::payment::store::{ PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; -use crate::payment::PaymentMetadata; +use crate::payment::{PaymentMetadata, LDK_NODE_BOLT12_PAYMENT_METADATA_KEY}; use crate::runtime::Runtime; use crate::types::{ CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, @@ -600,8 +600,9 @@ where } } - fn lsps2_max_total_opening_fee_msat(payment_metadata: &[u8], amount_msat: u64) -> Option { - let metadata = PaymentMetadata::read(&mut &payment_metadata[..]).ok()?; + fn lsps2_max_total_opening_fee_msat_from_metadata( + metadata: PaymentMetadata, amount_msat: u64, + ) -> Option { let lsps2_parameters = metadata.lsps2_parameters?; lsps2_parameters.max_total_opening_fee_msat.or_else(|| { lsps2_parameters.max_proportional_opening_fee_ppm_msat.and_then(|max_prop_fee| { @@ -611,6 +612,19 @@ where }) } + fn lsps2_max_total_opening_fee_msat(payment_metadata: &[u8], amount_msat: u64) -> Option { + let metadata = PaymentMetadata::read(&mut &payment_metadata[..]).ok()?; + Self::lsps2_max_total_opening_fee_msat_from_metadata(metadata, amount_msat) + } + + fn lsps2_max_total_opening_fee_msat_from_bolt12_metadata( + payment_metadata: Option<&BTreeMap>>, amount_msat: u64, + ) -> Option { + let encoded_metadata = payment_metadata?.get(&LDK_NODE_BOLT12_PAYMENT_METADATA_KEY)?; + let metadata = PaymentMetadata::read(&mut &encoded_metadata[..]).ok()?; + Self::lsps2_max_total_opening_fee_msat_from_metadata(metadata, amount_msat) + } + pub async fn handle_event(&self, event: LdkEvent) -> Result<(), ReplayEvent> { match event { LdkEvent::FundingGenerationReady { @@ -799,13 +813,19 @@ where .and_then(|metadata| { Self::lsps2_max_total_opening_fee_msat(metadata, amount_msat) }), + PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => { + Self::lsps2_max_total_opening_fee_msat_from_bolt12_metadata( + payment_context.payment_metadata.as_ref(), + amount_msat, + ) + }, _ => None, }; let Some(max_total_opening_fee_msat) = max_total_opening_fee_msat else { log_info!( self.logger, - "Refusing inbound payment with hash {} as the counterparty withheld {}msat without valid BOLT11 LSPS2 payment metadata", + "Refusing inbound payment with hash {} as the counterparty withheld {}msat without valid LSPS2 payment metadata", hex_utils::to_string(&payment_hash.0), counterparty_skimmed_fee_msat, ); @@ -829,18 +849,24 @@ where match &info.kind { PaymentKind::Bolt11 { .. } => { let update = PaymentDetailsUpdate { - counterparty_skimmed_fee_msat: Some(Some(counterparty_skimmed_fee_msat)), + counterparty_skimmed_fee_msat: Some(Some( + counterparty_skimmed_fee_msat, + )), ..PaymentDetailsUpdate::new(payment_id) }; match self.payment_store.update(update).await { Ok(_) => (), Err(e) => { - log_error!(self.logger, "Failed to access payment store: {}", e); + log_error!( + self.logger, + "Failed to access payment store: {}", + e + ); return Err(ReplayEvent()); }, }; }, - _ => debug_assert!(false, "We only expect the counterparty to get away with withholding fees for BOLT11 payments."), + _ => {}, } } } @@ -1936,6 +1962,7 @@ mod tests { max_total_opening_fee_msat: Some(42_000), max_proportional_opening_fee_ppm_msat: None, }), + lsps2_bolt12_invoice_parameters: None, }; assert_eq!( @@ -1947,14 +1974,37 @@ mod tests { ); } + #[test] + fn lsps2_bolt12_payment_metadata_decodes_total_fee_limit() { + let metadata = PaymentMetadata { + lsps2_parameters: Some(LSPS2Parameters { + max_total_opening_fee_msat: None, + max_proportional_opening_fee_ppm_msat: Some(10_000), + }), + lsps2_bolt12_invoice_parameters: None, + } + .encode_as_bolt12_payment_metadata(); + + assert_eq!( + EventHandler::>::lsps2_max_total_opening_fee_msat_from_bolt12_metadata( + Some(&metadata), + 100_000 + ), + Some(1_000) + ); + } + #[test] fn lsps2_payment_metadata_missing_or_malformed_limit_is_rejected() { - let empty_metadata = PaymentMetadata { lsps2_parameters: None }.encode(); + let empty_metadata = + PaymentMetadata { lsps2_parameters: None, lsps2_bolt12_invoice_parameters: None } + .encode(); let metadata_without_fee_limit = PaymentMetadata { lsps2_parameters: Some(LSPS2Parameters { max_total_opening_fee_msat: None, max_proportional_opening_fee_ppm_msat: None, }), + lsps2_bolt12_invoice_parameters: None, } .encode(); diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 97dd7a3493..c8a2f78f26 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -13,7 +13,6 @@ use std::sync::{Arc, RwLock}; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; -use lightning::impl_ser_tlv_based; use lightning::ln::channelmanager::{ Bolt11InvoiceParameters, OptionalBolt11PaymentParams, PaymentId, }; @@ -32,8 +31,7 @@ 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::{ - LSPS2Parameters, PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, - PaymentStatus, + PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; use crate::peer_store::{PeerInfo, PeerStore}; use crate::runtime::Runtime; @@ -49,16 +47,6 @@ type Bolt11InvoiceDescription = LdkBolt11InvoiceDescription; #[cfg(feature = "uniffi")] type Bolt11InvoiceDescription = crate::ffi::Bolt11InvoiceDescription; -/// Metadata carried in BOLT11 invoice `payment_metadata`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct PaymentMetadata { - pub(crate) lsps2_parameters: Option, -} - -impl_ser_tlv_based!(PaymentMetadata, { - (0, lsps2_parameters, option), -}); - /// A payment handler allowing to create and pay [BOLT 11] invoices. /// /// Should be retrieved by calling [`Node::bolt11_payment`]. @@ -248,37 +236,6 @@ impl Bolt11Payment { } } -#[cfg(test)] -mod tests { - use lightning::util::ser::{Readable, Writeable}; - - use super::*; - - #[test] - fn empty_metadata_roundtrips() { - let metadata = PaymentMetadata { lsps2_parameters: None }; - - let encoded = metadata.encode(); - let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); - - assert_eq!(metadata, decoded); - } - - #[test] - fn lsps2_parameters_roundtrip() { - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: Some(42_000), - max_proportional_opening_fee_ppm_msat: Some(17_000), - }; - let metadata = PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }; - - let encoded = metadata.encode(); - let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); - - assert_eq!(metadata, decoded); - } -} - #[cfg_attr(feature = "uniffi", uniffi::export)] impl Bolt11Payment { /// Send a payment given an invoice. diff --git a/src/payment/metadata.rs b/src/payment/metadata.rs new file mode 100644 index 0000000000..d51108d1bd --- /dev/null +++ b/src/payment/metadata.rs @@ -0,0 +1,111 @@ +use std::collections::BTreeMap; + +use lightning::impl_ser_tlv_based; +use lightning::util::ser::{Readable, Writeable}; +use lightning_liquidity::lsps2::router::{ + LSPS2Bolt12InvoiceParameters, LSPS2Bolt12PaymentMetadataDecoder, +}; + +use crate::payment::store::LSPS2Parameters; + +pub(crate) const LDK_NODE_BOLT12_PAYMENT_METADATA_KEY: u64 = 0; + +/// Metadata carried in BOLT11 invoice `payment_metadata` or BOLT12 payment metadata maps. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PaymentMetadata { + pub(crate) lsps2_parameters: Option, + pub(crate) lsps2_bolt12_invoice_parameters: Option, +} + +impl PaymentMetadata { + pub(crate) fn encode_as_bolt12_payment_metadata(&self) -> BTreeMap> { + let mut metadata = BTreeMap::new(); + metadata.insert(LDK_NODE_BOLT12_PAYMENT_METADATA_KEY, self.encode()); + metadata + } +} + +impl_ser_tlv_based!(PaymentMetadata, { + (0, lsps2_parameters, option), + (2, lsps2_bolt12_invoice_parameters, option), +}); + +#[derive(Clone, Copy)] +pub(crate) struct LdkNodeLSPS2Bolt12PaymentMetadataDecoder; + +impl LSPS2Bolt12PaymentMetadataDecoder for LdkNodeLSPS2Bolt12PaymentMetadataDecoder { + fn decode_lsps2_invoice_parameters( + &self, payment_metadata: &BTreeMap>, + ) -> Vec { + payment_metadata + .get(&LDK_NODE_BOLT12_PAYMENT_METADATA_KEY) + .and_then(|encoded| PaymentMetadata::read(&mut &encoded[..]).ok()) + .and_then(|metadata| metadata.lsps2_bolt12_invoice_parameters) + .into_iter() + .collect() + } +} + +#[cfg(test)] +mod tests { + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use lightning::util::ser::{Readable, Writeable}; + + use super::*; + + fn pubkey(byte: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[byte; 32]).unwrap()) + } + + #[test] + fn empty_metadata_roundtrips() { + let metadata = + PaymentMetadata { lsps2_parameters: None, lsps2_bolt12_invoice_parameters: None }; + + let encoded = metadata.encode(); + let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); + + assert_eq!(metadata, decoded); + } + + #[test] + fn lsps2_parameters_roundtrip() { + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: Some(42_000), + max_proportional_opening_fee_ppm_msat: Some(17_000), + }; + let lsps2_bolt12_invoice_parameters = LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(2), + intercept_scid: 42, + cltv_expiry_delta: 144, + }; + let metadata = PaymentMetadata { + lsps2_parameters: Some(lsps2_parameters), + lsps2_bolt12_invoice_parameters: Some(lsps2_bolt12_invoice_parameters), + }; + + let encoded = metadata.encode(); + let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); + + assert_eq!(metadata, decoded); + } + + #[test] + fn bolt12_metadata_decoder_extracts_invoice_parameters() { + let lsps2_bolt12_invoice_parameters = LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(3), + intercept_scid: 43, + cltv_expiry_delta: 72, + }; + let metadata = PaymentMetadata { + lsps2_parameters: None, + lsps2_bolt12_invoice_parameters: Some(lsps2_bolt12_invoice_parameters), + } + .encode_as_bolt12_payment_metadata(); + + let decoded = + LdkNodeLSPS2Bolt12PaymentMetadataDecoder.decode_lsps2_invoice_parameters(&metadata); + + assert_eq!(decoded, vec![lsps2_bolt12_invoice_parameters]); + } +} diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 71daa48b0a..5fd27738f1 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod asynchronous; mod bolt11; mod bolt12; +mod metadata; mod onchain; pub(crate) mod pending_payment_store; mod spontaneous; @@ -17,8 +18,10 @@ pub(crate) mod store; mod unified; pub use bolt11::Bolt11Payment; -pub(crate) use bolt11::PaymentMetadata; pub use bolt12::Bolt12Payment; +pub(crate) use metadata::{ + LdkNodeLSPS2Bolt12PaymentMetadataDecoder, PaymentMetadata, LDK_NODE_BOLT12_PAYMENT_METADATA_KEY, +}; pub use onchain::OnchainPayment; pub use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; From b2fcb95328a538a8f77b3350166837801765aef0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 14:16:32 +0200 Subject: [PATCH 3/4] Receive BOLT12 payments through LSPS2 JIT channels Route BOLT12 payments through the LSPS2-aware router and expose JIT-channel receive flows so offers can carry the LSP invoice parameters needed for intercepted payments. Co-Authored-By: HAL 9000 --- src/builder.rs | 23 ++- src/lib.rs | 15 +- src/liquidity.rs | 118 +++++++++++++++- src/payment/bolt12.rs | 317 +++++++++++++++++++++++++++++++++++++++--- src/types.rs | 12 +- 5 files changed, 449 insertions(+), 36 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index e0e6c5220b..95593b5e2f 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -43,6 +43,7 @@ use lightning::util::persist::{ use lightning::util::ser::ReadableArgs; use lightning::util::sweep::OutputSweeper; use lightning_dns_resolver::OMDomainResolver; +use lightning_liquidity::lsps2::router::LSPS2BOLT12Router; use vss_client::headers::VssHeaderProvider; use crate::chain::ChainSource; @@ -75,13 +76,14 @@ use crate::lnurl_auth::LnurlAuth; use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; +use crate::payment::LdkNodeLSPS2Bolt12PaymentMetadataDecoder; use crate::peer_store::PeerStore; use crate::runtime::{Runtime, RuntimeSpawner}; use crate::tx_broadcaster::TransactionBroadcaster; use crate::types::{ AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreRef, DynStoreWrapper, - GossipSync, Graph, HRNResolver, KeysManager, MessageRouter, OnionMessenger, PaymentStore, - PeerManager, PendingPaymentStore, + GossipSync, Graph, HRNResolver, InnerMessageRouter, KeysManager, MessageRouter, OnionMessenger, + PaymentStore, PeerManager, PendingPaymentStore, }; use crate::wallet::persist::KVStoreWalletPersister; use crate::wallet::Wallet; @@ -1778,12 +1780,19 @@ fn build_with_store_internal( } let scoring_fee_params = ProbabilisticScoringFeeParameters::default(); - let router = Arc::new(DefaultRouter::new( + let inner_router = DefaultRouter::new( Arc::clone(&network_graph), Arc::clone(&logger), Arc::clone(&keys_manager), Arc::clone(&scorer), scoring_fee_params, + ); + let inner_message_router = + InnerMessageRouter::new(Arc::clone(&network_graph), Arc::clone(&keys_manager)); + let router = Arc::new(LSPS2BOLT12Router::new_with_payment_metadata_decoder( + inner_router, + Arc::clone(&keys_manager), + LdkNodeLSPS2Bolt12PaymentMetadataDecoder, )); let mut user_config = default_user_config(&config); @@ -1807,8 +1816,7 @@ fn build_with_store_internal( } } - let message_router = - Arc::new(MessageRouter::new(Arc::clone(&network_graph), Arc::clone(&keys_manager))); + let message_router: Arc = Arc::new(inner_message_router); // Initialize the ChannelManager let channel_manager = { @@ -1927,7 +1935,7 @@ fn build_with_store_internal( Arc::clone(&keys_manager), Arc::clone(&logger), Arc::clone(&channel_manager), - message_router, + Arc::clone(&message_router), Arc::clone(&channel_manager), Arc::clone(&channel_manager), Arc::clone(&om_resolver), @@ -1940,7 +1948,7 @@ fn build_with_store_internal( Arc::clone(&keys_manager), Arc::clone(&logger), Arc::clone(&channel_manager), - message_router, + Arc::clone(&message_router), Arc::clone(&channel_manager), Arc::clone(&channel_manager), Arc::clone(&om_resolver), @@ -2168,6 +2176,7 @@ fn build_with_store_internal( output_sweeper, peer_manager, onion_messenger, + message_router, connection_manager, keys_manager, network_graph, diff --git a/src/lib.rs b/src/lib.rs index 2c2d2f2eb2..955e553637 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -113,7 +113,6 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; #[cfg(cycle_tests)] use std::{any::Any, sync::Weak}; -use crate::ffi::maybe_wrap; pub use balance::{BalanceDetails, LightningBalance, PendingSweepBalance}; pub use bip39; pub use bitcoin; @@ -177,12 +176,13 @@ use runtime::Runtime; pub use tokio; use types::{ Broadcaster, BumpTransactionEventHandler, ChainMonitor, ChannelManager, DynStore, Graph, - HRNResolver, KeysManager, OnionMessenger, PaymentStore, PeerManager, Router, Scorer, Sweeper, - Wallet, + HRNResolver, KeysManager, MessageRouter, OnionMessenger, PaymentStore, PeerManager, Router, + Scorer, Sweeper, Wallet, }; pub use types::{ChannelDetails, CustomTlvRecord, PeerDetails, UserChannelId}; pub use vss_client; +use crate::ffi::maybe_wrap; use crate::scoring::setup_background_pathfinding_scores_sync; use crate::wallet::FundingAmount; @@ -229,6 +229,7 @@ pub struct Node { output_sweeper: Arc, peer_manager: Arc, onion_messenger: Arc, + message_router: Arc, connection_manager: Arc>>, keys_manager: Arc, network_graph: Arc, @@ -928,8 +929,12 @@ impl Node { Bolt12Payment::new( Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), + Arc::clone(&self.message_router), + Arc::clone(&self.connection_manager), + self.liquidity_source.clone(), Arc::clone(&self.keys_manager), Arc::clone(&self.payment_store), + Arc::clone(&self.peer_store), Arc::clone(&self.config), Arc::clone(&self.is_running), Arc::clone(&self.logger), @@ -945,8 +950,12 @@ impl Node { Arc::new(Bolt12Payment::new( Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), + Arc::clone(&self.message_router), + Arc::clone(&self.connection_manager), + self.liquidity_source.clone(), Arc::clone(&self.keys_manager), Arc::clone(&self.payment_store), + Arc::clone(&self.peer_store), Arc::clone(&self.config), Arc::clone(&self.is_running), Arc::clone(&self.logger), diff --git a/src/liquidity.rs b/src/liquidity.rs index 30adfff24b..8bc61634ae 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -33,6 +33,7 @@ use lightning_liquidity::lsps1::msgs::{ use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; use lightning_liquidity::lsps2::event::{LSPS2ClientEvent, LSPS2ServiceEvent}; use lightning_liquidity::lsps2::msgs::{LSPS2OpeningFeeParams, LSPS2RawOpeningFeeParams}; +use lightning_liquidity::lsps2::router::LSPS2Bolt12InvoiceParameters; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; @@ -1232,6 +1233,114 @@ where Ok(invoice) } + pub(crate) async fn lsps2_bolt12_payment_metadata( + &self, amount_msat: u64, max_total_lsp_fee_limit_msat: Option, + ) -> Result<(u64, PaymentMetadata), Error> { + let fee_response = self.lsps2_request_opening_fee_params().await?; + + let (min_total_fee_msat, min_opening_params) = fee_response + .opening_fee_params_menu + .into_iter() + .filter_map(|params| { + if amount_msat < params.min_payment_size_msat + || amount_msat > params.max_payment_size_msat + { + log_debug!(self.logger, + "Skipping LSP-offered JIT parameters as the payment of {}msat doesn't meet LSP limits (min: {}msat, max: {}msat)", + amount_msat, + params.min_payment_size_msat, + params.max_payment_size_msat + ); + None + } else { + compute_opening_fee(amount_msat, params.min_fee_msat, params.proportional as u64) + .map(|fee| (fee, params)) + } + }) + .min_by_key(|p| p.0) + .ok_or_else(|| { + log_error!(self.logger, "Failed to handle response from liquidity service",); + Error::LiquidityRequestFailed + })?; + + if let Some(max_total_lsp_fee_limit_msat) = max_total_lsp_fee_limit_msat { + if min_total_fee_msat > max_total_lsp_fee_limit_msat { + log_error!(self.logger, + "Failed to request inbound JIT channel as LSP's requested total opening fee of {}msat exceeds our fee limit of {}msat", + min_total_fee_msat, max_total_lsp_fee_limit_msat + ); + return Err(Error::LiquidityFeeTooHigh); + } + } + + let buy_response = + self.lsps2_send_buy_request(Some(amount_msat), min_opening_params).await?; + let metadata = self.lsps2_bolt12_metadata_from_buy_response( + buy_response, + LSPS2Parameters { + max_total_opening_fee_msat: Some(min_total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + }, + )?; + + Ok((min_total_fee_msat, metadata)) + } + + pub(crate) async fn lsps2_variable_amount_bolt12_payment_metadata( + &self, max_proportional_lsp_fee_limit_ppm_msat: Option, + ) -> Result<(u64, PaymentMetadata), Error> { + let fee_response = self.lsps2_request_opening_fee_params().await?; + + let (min_prop_fee_ppm_msat, min_opening_params) = fee_response + .opening_fee_params_menu + .into_iter() + .map(|params| (params.proportional as u64, params)) + .min_by_key(|p| p.0) + .ok_or_else(|| { + log_error!(self.logger, "Failed to handle response from liquidity service",); + Error::LiquidityRequestFailed + })?; + + if let Some(max_proportional_lsp_fee_limit_ppm_msat) = + max_proportional_lsp_fee_limit_ppm_msat + { + if min_prop_fee_ppm_msat > max_proportional_lsp_fee_limit_ppm_msat { + log_error!(self.logger, + "Failed to request inbound JIT channel as LSP's requested proportional opening fee of {} ppm msat exceeds our fee limit of {} ppm msat", + min_prop_fee_ppm_msat, + max_proportional_lsp_fee_limit_ppm_msat + ); + return Err(Error::LiquidityFeeTooHigh); + } + } + + let buy_response = self.lsps2_send_buy_request(None, min_opening_params).await?; + let metadata = self.lsps2_bolt12_metadata_from_buy_response( + buy_response, + LSPS2Parameters { + max_total_opening_fee_msat: None, + max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), + }, + )?; + + Ok((min_prop_fee_ppm_msat, metadata)) + } + + fn lsps2_bolt12_metadata_from_buy_response( + &self, buy_response: LSPS2BuyResponse, lsps2_parameters: LSPS2Parameters, + ) -> Result { + let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + + Ok(PaymentMetadata { + lsps2_parameters: Some(lsps2_parameters), + lsps2_bolt12_invoice_parameters: Some(LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsps2_client.lsp_node_id, + intercept_scid: buy_response.intercept_scid, + cltv_expiry_delta: buy_response.cltv_expiry_delta, + }), + }) + } + async fn lsps2_request_opening_fee_params(&self) -> Result { let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; @@ -1317,8 +1426,11 @@ where // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; - let encoded_payment_metadata = - PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }.encode(); + let encoded_payment_metadata = PaymentMetadata { + lsps2_parameters: Some(lsps2_parameters), + lsps2_bolt12_invoice_parameters: None, + } + .encode(); let (payment_hash, payment_secret, payment_metadata) = match payment_hash { Some(payment_hash) => { let (payment_secret, payment_metadata) = self @@ -1354,7 +1466,7 @@ where src_node_id: lsps2_client.lsp_node_id, short_channel_id: buy_response.intercept_scid, fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, - cltv_expiry_delta: buy_response.cltv_expiry_delta as u16, + cltv_expiry_delta: buy_response.cltv_expiry_delta, htlc_minimum_msat: None, htlc_maximum_msat: None, }]); diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index d79aca6c24..148ac86fe1 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -9,28 +9,38 @@ //! //! [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md +use std::collections::BTreeMap; use std::num::NonZeroU64; use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use lightning::blinded_path::message::BlindedMessagePath; +use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; +use lightning::blinded_path::message::{ + BlindedMessagePath, MessageContext, MessageForwardNode, OffersContext, +}; use lightning::ln::channelmanager::{OptionalOfferPaymentParams, PaymentId}; use lightning::ln::outbound_payment::Retry; use lightning::offers::offer::{Amount, Offer as LdkOffer, OfferFromHrn, Quantity}; use lightning::offers::parse::Bolt12SemanticError; +use lightning::onion_message::messenger::{ + Destination, MessageRouter as LdkMessageRouter, OnionMessagePath, +}; use lightning::routing::router::RouteParametersConfig; -use lightning::sign::EntropySource; +use lightning::sign::{EntropySource, ReceiveAuthKey}; #[cfg(feature = "uniffi")] use lightning::util::ser::{Readable, Writeable}; use lightning_types::string::UntrustedString; use crate::config::{AsyncPaymentsRole, Config, LDK_PAYMENT_RETRY_TIMEOUT}; +use crate::connection::ConnectionManager; use crate::error::Error; use crate::ffi::{maybe_deref, maybe_wrap}; +use crate::liquidity::LiquiditySource; use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::payment::store::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; +use crate::peer_store::{PeerInfo, PeerStore}; use crate::runtime::Runtime; -use crate::types::{ChannelManager, KeysManager, PaymentStore}; +use crate::types::{ChannelManager, KeysManager, MessageRouter, PaymentStore}; #[cfg(not(feature = "uniffi"))] type Bolt12Invoice = lightning::offers::invoice::Bolt12Invoice; @@ -52,6 +62,32 @@ type HumanReadableName = lightning::onion_message::dns_resolution::HumanReadable #[cfg(feature = "uniffi")] type HumanReadableName = Arc; +struct PaymentMetadataMessageRouter { + inner: MR, + payment_metadata: BTreeMap>, +} + +impl LdkMessageRouter for PaymentMetadataMessageRouter { + fn find_path( + &self, sender: PublicKey, peers: Vec, destination: Destination, + ) -> Result { + self.inner.find_path(sender, peers, destination) + } + + fn create_blinded_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + mut context: MessageContext, peers: Vec, secp_ctx: &Secp256k1, + ) -> Result, ()> { + if let MessageContext::Offers(OffersContext::InvoiceRequest { payment_metadata, .. }) = + &mut context + { + *payment_metadata = Some(self.payment_metadata.clone()); + } + + self.inner.create_blinded_paths(recipient, local_node_receive_key, context, peers, secp_ctx) + } +} + /// A payment handler allowing to create and pay [BOLT 12] offers and refunds. /// /// Should be retrieved by calling [`Node::bolt12_payment`]. @@ -62,8 +98,12 @@ type HumanReadableName = Arc; pub struct Bolt12Payment { runtime: Arc, channel_manager: Arc, + message_router: Arc, + connection_manager: Arc>>, + liquidity_source: Option>>>, keys_manager: Arc, payment_store: Arc, + peer_store: Arc>>, config: Arc, is_running: Arc>, logger: Arc, @@ -73,15 +113,23 @@ pub struct Bolt12Payment { impl Bolt12Payment { pub(crate) fn new( runtime: Arc, channel_manager: Arc, - keys_manager: Arc, payment_store: Arc, config: Arc, + message_router: Arc, + connection_manager: Arc>>, + liquidity_source: Option>>>, + keys_manager: Arc, payment_store: Arc, + peer_store: Arc>>, config: Arc, is_running: Arc>, logger: Arc, async_payments_role: Option, ) -> Self { Self { runtime, channel_manager, + message_router, + connection_manager, + liquidity_source, keys_manager, payment_store, + peer_store, config, is_running, logger, @@ -203,7 +251,28 @@ impl Bolt12Payment { pub(crate) fn receive_inner( &self, amount_msat: u64, description: &str, expiry_secs: Option, quantity: Option, ) -> Result { - let mut offer_builder = self.channel_manager.create_offer_builder().map_err(|e| { + self.receive_inner_with_payment_metadata( + amount_msat, + description, + expiry_secs, + quantity, + None, + ) + } + + fn receive_inner_with_payment_metadata( + &self, amount_msat: u64, description: &str, expiry_secs: Option, + quantity: Option, payment_metadata: Option>>, + ) -> Result { + let mut offer_builder = if let Some(payment_metadata) = payment_metadata { + self.channel_manager.create_offer_builder_using_router(PaymentMetadataMessageRouter { + inner: Arc::clone(&self.message_router), + payment_metadata, + }) + } else { + self.channel_manager.create_offer_builder() + } + .map_err(|e| { log_error!(self.logger, "Failed to create offer builder: {:?}", e); Error::OfferCreationFailed })?; @@ -237,6 +306,175 @@ impl Bolt12Payment { Ok(finalized_offer) } + fn receive_variable_amount_inner( + &self, description: &str, expiry_secs: Option, + ) -> Result { + self.receive_variable_amount_inner_with_payment_metadata(description, expiry_secs, None) + } + + fn receive_variable_amount_inner_with_payment_metadata( + &self, description: &str, expiry_secs: Option, + payment_metadata: Option>>, + ) -> Result { + let mut offer_builder = if let Some(payment_metadata) = payment_metadata { + self.channel_manager.create_offer_builder_using_router(PaymentMetadataMessageRouter { + inner: Arc::clone(&self.message_router), + payment_metadata, + }) + } else { + self.channel_manager.create_offer_builder() + } + .map_err(|e| { + log_error!(self.logger, "Failed to create offer builder: {:?}", e); + Error::OfferCreationFailed + })?; + + if let Some(expiry_secs) = expiry_secs { + let absolute_expiry = (SystemTime::now() + Duration::from_secs(expiry_secs as u64)) + .duration_since(UNIX_EPOCH) + .unwrap(); + offer_builder = offer_builder.absolute_expiry(absolute_expiry); + } + + offer_builder.description(description.to_string()).build().map_err(|e| { + log_error!(self.logger, "Failed to create offer: {:?}", e); + Error::OfferCreationFailed + }) + } + + fn connect_to_lsps2_peer( + &self, liquidity_source: Arc>>, + ) -> Result { + let (node_id, address) = + liquidity_source.get_lsps2_lsp_details().ok_or(Error::LiquiditySourceUnavailable)?; + + let peer_info = PeerInfo { node_id, address }; + let con_node_id = peer_info.node_id; + let con_addr = peer_info.address.clone(); + let connection_manager = Arc::clone(&self.connection_manager); + + self.runtime.block_on(async move { + connection_manager.connect_peer_if_necessary(con_node_id, con_addr).await + })?; + + log_info!(self.logger, "Connected to LSP {}@{}. ", peer_info.node_id, peer_info.address); + + Ok(peer_info) + } + + fn receive_jit_channel_inner( + &self, amount_msat: Option, description: &str, expiry_secs: Option, + quantity: Option, max_total_lsp_fee_limit_msat: Option, + max_proportional_lsp_fee_limit_ppm_msat: Option, + ) -> Result { + let liquidity_source = + self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + + let peer_info = self.connect_to_lsps2_peer(Arc::clone(liquidity_source))?; + let liquidity_source = Arc::clone(liquidity_source); + let (lsp_total_opening_fee, lsp_prop_opening_fee, payment_metadata) = + self.runtime.block_on(async move { + if let Some(amount_msat) = amount_msat { + liquidity_source + .lsps2_bolt12_payment_metadata(amount_msat, max_total_lsp_fee_limit_msat) + .await + .map(|(total_fee, payment_metadata)| { + (Some(total_fee), None, payment_metadata) + }) + } else { + liquidity_source + .lsps2_variable_amount_bolt12_payment_metadata( + max_proportional_lsp_fee_limit_ppm_msat, + ) + .await + .map(|(prop_fee, payment_metadata)| { + (None, Some(prop_fee), payment_metadata) + }) + } + })?; + let bolt12_payment_metadata = payment_metadata.encode_as_bolt12_payment_metadata(); + let offer = if let Some(amount_msat) = amount_msat { + self.receive_inner_with_payment_metadata( + amount_msat, + description, + expiry_secs, + quantity, + Some(bolt12_payment_metadata), + )? + } else { + self.receive_variable_amount_inner_with_payment_metadata( + description, + expiry_secs, + Some(bolt12_payment_metadata), + )? + }; + + if let Some(total_fee_msat) = lsp_total_opening_fee { + log_info!( + self.logger, + "JIT-channel BOLT12 offer created: {} (max total LSP opening fee: {}msat)", + offer, + total_fee_msat + ); + } + if let Some(prop_fee_ppm_msat) = lsp_prop_opening_fee { + log_info!( + self.logger, + "JIT-channel variable-amount BOLT12 offer created: {} (max proportional LSP opening fee: {}ppm msat)", + offer, + prop_fee_ppm_msat + ); + } + + self.runtime.block_on(self.peer_store.add_peer(peer_info))?; + + Ok(offer) + } + + fn receive_async_jit_channel_inner( + &self, max_proportional_lsp_fee_limit_ppm_msat: Option, + ) -> Result { + let liquidity_source = + self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + + let peer_info = self.connect_to_lsps2_peer(Arc::clone(liquidity_source))?; + let liquidity_source = Arc::clone(liquidity_source); + let (lsp_prop_opening_fee, payment_metadata) = self.runtime.block_on(async move { + liquidity_source + .lsps2_variable_amount_bolt12_payment_metadata( + max_proportional_lsp_fee_limit_ppm_msat, + ) + .await + })?; + + self.runtime.block_on(self.peer_store.add_peer(peer_info))?; + self.channel_manager + .refresh_async_receive_offers_with_payment_metadata( + payment_metadata.encode_as_bolt12_payment_metadata(), + ) + .or(Err(Error::OfferCreationFailed))?; + let offer = self + .runtime + .block_on(async { + tokio::time::timeout( + Duration::from_secs(10), + self.channel_manager.await_async_receive_offer(), + ) + .await + }) + .map_err(|_| Error::OfferCreationFailed)? + .or(Err(Error::OfferCreationFailed))?; + + log_info!( + self.logger, + "JIT-channel async BOLT12 offer created: {} (max proportional LSP opening fee: {}ppm msat)", + offer, + lsp_prop_opening_fee + ); + + Ok(offer) + } + fn blinded_paths_for_async_recipient_internal( &self, recipient_id: Vec, ) -> Result, Error> { @@ -403,23 +641,47 @@ impl Bolt12Payment { pub fn receive_variable_amount( &self, description: &str, expiry_secs: Option, ) -> Result { - let mut offer_builder = self.channel_manager.create_offer_builder().map_err(|e| { - log_error!(self.logger, "Failed to create offer builder: {:?}", e); - Error::OfferCreationFailed - })?; - - if let Some(expiry_secs) = expiry_secs { - let absolute_expiry = (SystemTime::now() + Duration::from_secs(expiry_secs as u64)) - .duration_since(UNIX_EPOCH) - .expect("system time must be after Unix epoch"); - offer_builder = offer_builder.absolute_expiry(absolute_expiry); - } + let offer = self.receive_variable_amount_inner(description, expiry_secs)?; + Ok(maybe_wrap(offer)) + } - let offer = offer_builder.description(description.to_string()).build().map_err(|e| { - log_error!(self.logger, "Failed to create offer: {:?}", e); - Error::OfferCreationFailed - })?; + /// Returns a payable offer that can be used to request a payment of the amount given and + /// receive it via a just-in-time (JIT) channel. + /// + /// If the node already has sufficient inbound liquidity via pre-existing channels, the + /// payment may be received through those channels without opening a new JIT channel. + pub fn receive_via_jit_channel( + &self, amount_msat: u64, description: &str, expiry_secs: Option, + quantity: Option, max_total_lsp_fee_limit_msat: Option, + ) -> Result { + let offer = self.receive_jit_channel_inner( + Some(amount_msat), + description, + expiry_secs, + quantity, + max_total_lsp_fee_limit_msat, + None, + )?; + Ok(maybe_wrap(offer)) + } + /// Returns a payable offer that can be used to request a variable amount payment and receive it + /// via a just-in-time (JIT) channel. + /// + /// If the node already has sufficient inbound liquidity via pre-existing channels, the + /// payment may be received through those channels without opening a new JIT channel. + pub fn receive_variable_amount_via_jit_channel( + &self, description: &str, expiry_secs: Option, + max_proportional_lsp_fee_limit_ppm_msat: Option, + ) -> Result { + let offer = self.receive_jit_channel_inner( + None, + description, + expiry_secs, + None, + None, + max_proportional_lsp_fee_limit_ppm_msat, + )?; Ok(maybe_wrap(offer)) } @@ -552,6 +814,21 @@ impl Bolt12Payment { .map(maybe_wrap) .or(Err(Error::OfferCreationFailed)) } + + /// Retrieve an async [`Offer`] for receiving payments via an LSPS2 just-in-time (JIT) channel. + /// + /// This requires a configured LSPS2 liquidity source as well as paths to a static invoice server + /// via [`Bolt12Payment::set_paths_to_static_invoice_server`]. + /// + /// Since async offers are variable-amount, the LSP fee limit is expressed as a proportional + /// limit in parts-per-million millisatoshis. + pub fn receive_async_via_jit_channel( + &self, max_proportional_lsp_fee_limit_ppm_msat: Option, + ) -> Result { + let offer = + self.receive_async_jit_channel_inner(max_proportional_lsp_fee_limit_ppm_msat)?; + Ok(maybe_wrap(offer)) + } } #[cfg(not(feature = "uniffi"))] diff --git a/src/types.rs b/src/types.rs index a8ce812b8e..67b3e59ebf 100644 --- a/src/types.rs +++ b/src/types.rs @@ -33,6 +33,7 @@ use lightning::util::persist::{KVStore, MonitorUpdatingPersisterAsync}; use lightning::util::ser::{Readable, Writeable, Writer}; use lightning::util::sweep::OutputSweeper; use lightning_block_sync::gossip::GossipVerifier; +use lightning_liquidity::lsps2::router::LSPS2BOLT12Router; use lightning_liquidity::utils::time::DefaultTimeProvider; use lightning_net_tokio::SocketDescriptor; @@ -43,7 +44,9 @@ use crate::data_store::DataStore; use crate::fee_estimator::OnchainFeeEstimator; use crate::logger::Logger; use crate::message_handler::NodeCustomMessageHandler; -use crate::payment::{PaymentDetails, PendingPaymentDetails}; +use crate::payment::{ + LdkNodeLSPS2Bolt12PaymentMetadataDecoder, PaymentDetails, PendingPaymentDetails, +}; use crate::runtime::RuntimeSpawner; pub(crate) trait DynStoreTrait: Send + Sync { @@ -215,7 +218,7 @@ pub(crate) type Broadcaster = crate::tx_broadcaster::TransactionBroadcaster, Arc, Arc, @@ -223,6 +226,8 @@ pub(crate) type Router = DefaultRouter< ProbabilisticScoringFeeParameters, Scorer, >; +pub(crate) type Router = + LSPS2BOLT12Router, LdkNodeLSPS2Bolt12PaymentMetadataDecoder>; pub(crate) type Scorer = CombinedScorer, Arc>; pub(crate) type Graph = gossip::NetworkGraph>; @@ -289,11 +294,12 @@ impl HrnResolver for HRNResolver { } } -pub(crate) type MessageRouter = lightning::onion_message::messenger::DefaultMessageRouter< +pub(crate) type InnerMessageRouter = lightning::onion_message::messenger::DefaultMessageRouter< Arc, Arc, Arc, >; +pub(crate) type MessageRouter = InnerMessageRouter; pub(crate) type Sweeper = OutputSweeper< Arc, From fc22dd00e3261a8ae2add233ea2fe871d74d4948 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 14:16:49 +0200 Subject: [PATCH 4/4] Cover LSPS2 BOLT12 JIT payments Exercise fixed, variable, and async BOLT12 receive flows with LSPS2 JIT channels so the payment metadata and fee-limit handling remain covered. Co-Authored-By: HAL 9000 --- tests/integration_tests_rust.rs | 493 ++++++++++++++++++++++++++++++++ 1 file changed, 493 insertions(+) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 1ea6c45845..172e2808c5 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1515,6 +1515,300 @@ async fn async_payment() { expect_payment_successful_event!(node_sender, Some(payment_id), None); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn async_payment_via_lsps2_jit_channel() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let mut config_sender = random_config(true); + config_sender.node_config.listening_addresses = None; + config_sender.node_config.node_alias = None; + config_sender.log_writer = + TestLogWriter::Custom(Arc::new(MultiNodeLogger::new("sender ".to_string()))); + config_sender.async_payments_role = Some(AsyncPaymentsRole::Client); + let node_sender = setup_node(&chain_source, config_sender); + + let mut config_sender_lsp = random_config(true); + config_sender_lsp.log_writer = + TestLogWriter::Custom(Arc::new(MultiNodeLogger::new("sender_lsp ".to_string()))); + config_sender_lsp.async_payments_role = Some(AsyncPaymentsRole::Server); + let node_sender_lsp = setup_node(&chain_source, config_sender_lsp); + + let mut config_receiver_lsp = random_config(true); + config_receiver_lsp.log_writer = + TestLogWriter::Custom(Arc::new(MultiNodeLogger::new("receiver_lsp".to_string()))); + config_receiver_lsp.async_payments_role = Some(AsyncPaymentsRole::Server); + + let lsps2_service_config = LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm: 10_000, + channel_over_provisioning_ppm: 100_000, + max_payment_size_msat: 1_000_000_000, + min_payment_size_msat: 0, + min_channel_lifetime: 100, + min_channel_opening_fee_msat: 0, + max_client_to_self_delay: 1024, + client_trusts_lsp: true, + disable_client_reserve: false, + }; + + setup_builder!(receiver_lsp_builder, config_receiver_lsp.node_config); + match &chain_source { + TestChainSource::Esplora(electrsd) => { + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + let mut sync_config = EsploraSyncConfig::default(); + sync_config.background_sync_config = None; + receiver_lsp_builder.set_chain_source_esplora(esplora_url, Some(sync_config)); + }, + TestChainSource::Electrum(electrsd) => { + let electrum_url = format!("tcp://{}", electrsd.electrum_url); + let mut sync_config = ldk_node::config::ElectrumSyncConfig::default(); + sync_config.background_sync_config = None; + receiver_lsp_builder.set_chain_source_electrum(electrum_url, Some(sync_config)); + }, + TestChainSource::BitcoindRpcSync(bitcoind) => { + let rpc_host = bitcoind.params.rpc_socket.ip().to_string(); + let rpc_port = bitcoind.params.rpc_socket.port(); + let values = bitcoind.params.get_cookie_values().unwrap().unwrap(); + receiver_lsp_builder.set_chain_source_bitcoind_rpc( + rpc_host, + rpc_port, + values.user, + values.password, + ); + }, + TestChainSource::BitcoindRestSync(bitcoind) => { + let rpc_host = bitcoind.params.rpc_socket.ip().to_string(); + let rpc_port = bitcoind.params.rpc_socket.port(); + let values = bitcoind.params.get_cookie_values().unwrap().unwrap(); + let rest_host = bitcoind.params.rpc_socket.ip().to_string(); + let rest_port = bitcoind.params.rpc_socket.port(); + receiver_lsp_builder.set_chain_source_bitcoind_rest( + rest_host, + rest_port, + rpc_host, + rpc_port, + values.user, + values.password, + ); + }, + } + receiver_lsp_builder.set_custom_logger(Arc::clone(match &config_receiver_lsp.log_writer { + TestLogWriter::Custom(logger) => logger, + _ => unreachable!(), + })); + receiver_lsp_builder.set_async_payments_role(config_receiver_lsp.async_payments_role).unwrap(); + receiver_lsp_builder.set_liquidity_provider_lsps2(lsps2_service_config); + let node_receiver_lsp = + receiver_lsp_builder.build(config_receiver_lsp.node_entropy.into()).unwrap(); + node_receiver_lsp.start().unwrap(); + + let receiver_lsp_node_id = node_receiver_lsp.node_id(); + let receiver_lsp_addr = + node_receiver_lsp.listening_addresses().unwrap().first().unwrap().clone(); + + let mut config_receiver = random_config(true); + config_receiver.node_config.listening_addresses = None; + config_receiver.node_config.node_alias = None; + config_receiver.log_writer = + TestLogWriter::Custom(Arc::new(MultiNodeLogger::new("receiver ".to_string()))); + setup_builder!(receiver_builder, config_receiver.node_config); + match &chain_source { + TestChainSource::Esplora(electrsd) => { + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + let mut sync_config = EsploraSyncConfig::default(); + sync_config.background_sync_config = None; + receiver_builder.set_chain_source_esplora(esplora_url, Some(sync_config)); + }, + TestChainSource::Electrum(electrsd) => { + let electrum_url = format!("tcp://{}", electrsd.electrum_url); + let mut sync_config = ldk_node::config::ElectrumSyncConfig::default(); + sync_config.background_sync_config = None; + receiver_builder.set_chain_source_electrum(electrum_url, Some(sync_config)); + }, + TestChainSource::BitcoindRpcSync(bitcoind) => { + let rpc_host = bitcoind.params.rpc_socket.ip().to_string(); + let rpc_port = bitcoind.params.rpc_socket.port(); + let values = bitcoind.params.get_cookie_values().unwrap().unwrap(); + receiver_builder.set_chain_source_bitcoind_rpc( + rpc_host, + rpc_port, + values.user, + values.password, + ); + }, + TestChainSource::BitcoindRestSync(bitcoind) => { + let rpc_host = bitcoind.params.rpc_socket.ip().to_string(); + let rpc_port = bitcoind.params.rpc_socket.port(); + let values = bitcoind.params.get_cookie_values().unwrap().unwrap(); + let rest_host = bitcoind.params.rpc_socket.ip().to_string(); + let rest_port = bitcoind.params.rpc_socket.port(); + receiver_builder.set_chain_source_bitcoind_rest( + rest_host, + rest_port, + rpc_host, + rpc_port, + values.user, + values.password, + ); + }, + } + receiver_builder.set_custom_logger(Arc::clone(match &config_receiver.log_writer { + TestLogWriter::Custom(logger) => logger, + _ => unreachable!(), + })); + receiver_builder.set_async_payments_role(config_receiver.async_payments_role).unwrap(); + receiver_builder.set_liquidity_source_lsps2(receiver_lsp_node_id, receiver_lsp_addr, None); + let node_receiver = receiver_builder.build(config_receiver.node_entropy.into()).unwrap(); + node_receiver.start().unwrap(); + + let addresses = vec![ + node_sender.onchain_payment().new_address().unwrap(), + node_sender_lsp.onchain_payment().new_address().unwrap(), + node_receiver_lsp.onchain_payment().new_address().unwrap(), + node_receiver.onchain_payment().new_address().unwrap(), + ]; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + addresses, + Amount::from_sat(4_000_000), + ) + .await; + + node_sender.sync_wallets().unwrap(); + node_sender_lsp.sync_wallets().unwrap(); + node_receiver_lsp.sync_wallets().unwrap(); + node_receiver.sync_wallets().unwrap(); + + open_channel(&node_sender, &node_sender_lsp, 400_000, false, &electrsd).await; + open_channel(&node_sender_lsp, &node_receiver_lsp, 400_000, true, &electrsd).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_sender.sync_wallets().unwrap(); + node_sender_lsp.sync_wallets().unwrap(); + node_receiver_lsp.sync_wallets().unwrap(); + node_receiver.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_sender, node_sender_lsp.node_id()); + expect_channel_ready_events!( + node_sender_lsp, + node_sender.node_id(), + node_receiver_lsp.node_id() + ); + expect_channel_ready_event!(node_receiver_lsp, node_sender_lsp.node_id()); + + let has_node_announcements = |node: &ldk_node::Node| { + node.network_graph() + .list_nodes() + .iter() + .filter(|n| { + node.network_graph().node(n).map_or(false, |info| info.announcement_info.is_some()) + }) + .count() >= 2 + }; + + while node_sender.network_graph().list_channels().len() < 1 + || node_sender_lsp.network_graph().list_channels().len() < 1 + || node_receiver_lsp.network_graph().list_channels().len() < 1 + || !has_node_announcements(&node_sender) + || !has_node_announcements(&node_sender_lsp) + || !has_node_announcements(&node_receiver_lsp) + { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + let recipient_id = vec![4, 5, 6]; + let blinded_paths = + node_receiver_lsp.bolt12_payment().blinded_paths_for_async_recipient(recipient_id).unwrap(); + node_receiver.bolt12_payment().set_paths_to_static_invoice_server(blinded_paths).unwrap(); + + let offer = node_receiver.bolt12_payment().receive_async_via_jit_channel(None).unwrap(); + + let amount_msat = 5_000_000; + let _payment_id = node_sender + .bolt12_payment() + .send_using_amount(&offer, amount_msat, None, None, None) + .unwrap(); + let receiver_node_id = node_receiver.node_id(); + let receiver_lsp_node_id = node_receiver_lsp.node_id(); + + tokio::time::timeout(std::time::Duration::from_secs(30), async { + loop { + match node_receiver_lsp.next_event_async().await { + ref e @ Event::ChannelPending { counterparty_node_id, .. } + if counterparty_node_id == receiver_node_id => + { + println!("{} got event {:?}", node_receiver_lsp.node_id(), e); + node_receiver_lsp.event_handled().unwrap(); + break; + }, + Event::ChannelPending { .. } | Event::ChannelReady { .. } => { + node_receiver_lsp.event_handled().unwrap(); + }, + e => panic!("node_receiver_lsp got unexpected event!: {:?}", e), + } + } + loop { + match node_receiver_lsp.next_event_async().await { + ref e @ Event::ChannelReady { counterparty_node_id, .. } + if counterparty_node_id == Some(receiver_node_id) => + { + println!("{} got event {:?}", node_receiver_lsp.node_id(), e); + node_receiver_lsp.event_handled().unwrap(); + break; + }, + Event::ChannelPending { .. } | Event::ChannelReady { .. } => { + node_receiver_lsp.event_handled().unwrap(); + }, + e => panic!("node_receiver_lsp got unexpected event!: {:?}", e), + } + } + loop { + match node_receiver.next_event_async().await { + ref e @ Event::ChannelPending { counterparty_node_id, .. } + if counterparty_node_id == receiver_lsp_node_id => + { + println!("{} got event {:?}", node_receiver.node_id(), e); + node_receiver.event_handled().unwrap(); + break; + }, + Event::ChannelPending { .. } | Event::ChannelReady { .. } => { + node_receiver.event_handled().unwrap(); + }, + e => panic!("node_receiver got unexpected event!: {:?}", e), + } + } + loop { + match node_receiver.next_event_async().await { + ref e @ Event::ChannelReady { counterparty_node_id, .. } + if counterparty_node_id == Some(receiver_lsp_node_id) => + { + println!("{} got event {:?}", node_receiver.node_id(), e); + node_receiver.event_handled().unwrap(); + break; + }, + Event::ChannelPending { .. } | Event::ChannelReady { .. } => { + node_receiver.event_handled().unwrap(); + }, + e => panic!("node_receiver got unexpected event!: {:?}", e), + } + } + }) + .await + .expect("async LSPS2 payment did not open a receiver-side JIT channel"); + + assert!( + node_receiver_lsp + .list_channels() + .iter() + .any(|c| c.counterparty_node_id == node_receiver.node_id()), + "receiver LSP failed to open a JIT channel for the async payment" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_node_announcement_propagation() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -2003,6 +2297,205 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { assert_eq!(client_node.payment(&payment_id).unwrap().status, PaymentStatus::Failed); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn lsps2_bolt12_payment_succeeds_after_lsp_restart() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + + let mut sync_config = EsploraSyncConfig::default(); + sync_config.background_sync_config = None; + + let lsps2_service_config = LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm: 10_000, + channel_over_provisioning_ppm: 100_000, + max_payment_size_msat: 1_000_000_000, + min_payment_size_msat: 0, + min_channel_lifetime: 100, + min_channel_opening_fee_msat: 0, + max_client_to_self_delay: 1024, + client_trusts_lsp: true, + disable_client_reserve: false, + }; + + let service_config = random_config(true); + setup_builder!(service_builder, service_config.node_config); + service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + service_builder.set_liquidity_provider_lsps2(lsps2_service_config); + let service_node = service_builder.build(service_config.node_entropy.into()).unwrap(); + service_node.start().unwrap(); + let service_node_id = service_node.node_id(); + let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); + + let client_config = random_config(true); + setup_builder!(client_builder, client_config.node_config); + client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + client_builder.set_liquidity_source_lsps2(service_node_id, service_addr.clone(), None); + let client_node = client_builder.build(client_config.node_entropy.into()).unwrap(); + client_node.start().unwrap(); + + let payer_config = random_config(true); + setup_builder!(payer_builder, payer_config.node_config); + payer_builder.set_chain_source_esplora(esplora_url, Some(sync_config)); + let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); + payer_node.start().unwrap(); + + let service_onchain_addr = service_node.onchain_payment().new_address().unwrap(); + let client_onchain_addr = client_node.onchain_payment().new_address().unwrap(); + let payer_onchain_addr = payer_node.onchain_payment().new_address().unwrap(); + + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![service_onchain_addr, client_onchain_addr, payer_onchain_addr], + Amount::from_sat(10_000_000), + ) + .await; + service_node.sync_wallets().unwrap(); + client_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + + open_channel(&payer_node, &service_node, 5_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + service_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + expect_channel_ready_event!(payer_node, service_node.node_id()); + expect_channel_ready_event!(service_node, payer_node.node_id()); + + let jit_amount_msat = 100_000_000; + let offer = client_node + .bolt12_payment() + .receive_via_jit_channel(jit_amount_msat, "lsps2-bolt12-after-restart", None, Some(1), None) + .unwrap(); + + service_node.stop().unwrap(); + service_node.start().unwrap(); + + // Ensure peers are connected after the restart before paying the offer. + let _ = payer_node.connect(service_node_id, service_addr.clone(), false); + let _ = client_node.connect(service_node_id, service_addr, false); + + let payment_id = payer_node + .bolt12_payment() + .send(&offer, Some(1), Some("restart".to_string()), None) + .unwrap(); + + expect_channel_pending_event!(service_node, client_node.node_id()); + expect_channel_ready_event!(service_node, client_node.node_id()); + expect_channel_pending_event!(client_node, service_node.node_id()); + expect_channel_ready_event!(client_node, service_node.node_id()); + + let service_fee_msat = (jit_amount_msat * 10_000) / 1_000_000; + let expected_received_amount_msat = jit_amount_msat - service_fee_msat; + + expect_payment_successful_event!(payer_node, Some(payment_id), None); + expect_payment_received_event!(client_node, expected_received_amount_msat); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn lsps2_bolt12_jit_channel_opens_successfully() { + // Verify the full BOLT12 + LSPS2 JIT channel flow: a client with no pre-existing channels + // creates a JIT offer, a payer pays it, and the LSP opens a channel just-in-time. + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + + let mut sync_config = EsploraSyncConfig::default(); + sync_config.background_sync_config = None; + + let lsps2_service_config = LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm: 10_000, + channel_over_provisioning_ppm: 100_000, + max_payment_size_msat: 1_000_000_000, + min_payment_size_msat: 0, + min_channel_lifetime: 100, + min_channel_opening_fee_msat: 0, + max_client_to_self_delay: 1024, + client_trusts_lsp: true, + disable_client_reserve: false, + }; + + let service_config = random_config(true); + setup_builder!(service_builder, service_config.node_config); + service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + service_builder.set_liquidity_provider_lsps2(lsps2_service_config); + let service_node = service_builder.build(service_config.node_entropy.into()).unwrap(); + service_node.start().unwrap(); + let service_node_id = service_node.node_id(); + let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); + + let client_config = random_config(true); + setup_builder!(client_builder, client_config.node_config); + client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + client_builder.set_liquidity_source_lsps2(service_node_id, service_addr.clone(), None); + let client_node = client_builder.build(client_config.node_entropy.into()).unwrap(); + client_node.start().unwrap(); + + let payer_config = random_config(true); + setup_builder!(payer_builder, payer_config.node_config); + payer_builder.set_chain_source_esplora(esplora_url, Some(sync_config)); + let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); + payer_node.start().unwrap(); + + let service_onchain_addr = service_node.onchain_payment().new_address().unwrap(); + let client_onchain_addr = client_node.onchain_payment().new_address().unwrap(); + let payer_onchain_addr = payer_node.onchain_payment().new_address().unwrap(); + + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![service_onchain_addr, client_onchain_addr, payer_onchain_addr], + Amount::from_sat(10_000_000), + ) + .await; + service_node.sync_wallets().unwrap(); + client_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + + open_channel(&payer_node, &service_node, 5_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + service_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + expect_channel_ready_event!(payer_node, service_node.node_id()); + expect_channel_ready_event!(service_node, payer_node.node_id()); + + assert_eq!( + service_node.list_channels().len(), + 1, + "Only payer-service channel should exist before JIT flow" + ); + + let jit_amount_msat = 100_000_000; + let offer = client_node + .bolt12_payment() + .receive_via_jit_channel(jit_amount_msat, "jit-payment", None, Some(1), None) + .unwrap(); + + let payment_id = + payer_node.bolt12_payment().send(&offer, Some(1), Some("pay".to_string()), None).unwrap(); + + expect_channel_pending_event!(service_node, client_node.node_id()); + expect_channel_ready_event!(service_node, client_node.node_id()); + expect_channel_pending_event!(client_node, service_node.node_id()); + expect_channel_ready_event!(client_node, service_node.node_id()); + + let service_fee_msat = (jit_amount_msat * 10_000) / 1_000_000; + let expected_received = jit_amount_msat - service_fee_msat; + expect_payment_successful_event!(payer_node, Some(payment_id), None); + expect_payment_received_event!(client_node, expected_received); + + // The LSP should now have two channels: payer<->service and service<->client. + assert_eq!( + service_node.list_channels().len(), + 2, + "JIT channel should have been opened alongside the payer channel" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn facade_logging() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();