From 26aa86e8d0c2a77597a3f526c5474c15412e4973 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 2 Apr 2026 19:46:16 +0200 Subject: [PATCH] WIP: Add LSPS2-backed BOLT12 JIT offer routing Integrate the LSPS2 BOLT12 router into ldk-node, allowing JIT channel offers to be created and paid via the LSPS2 protocol with BOLT12. Co-Authored-By: HAL 9000 --- Cargo.toml | 52 ++++----- src/builder.rs | 18 ++- src/event.rs | 33 ++++-- src/lib.rs | 8 ++ src/liquidity.rs | 118 ++++++++++++++++++- src/payment/bolt12.rs | 174 +++++++++++++++++++++++++--- src/types.rs | 7 +- tests/integration_tests_rust.rs | 199 ++++++++++++++++++++++++++++++++ 8 files changed, 548 insertions(+), 61 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8a85c6574..f40e173f4 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 = "dcf0c203e166da2348bef12b2e5eff4a250cdec7", features = ["std"] } -lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "dcf0c203e166da2348bef12b2e5eff4a250cdec7" } -lightning-invoice = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "dcf0c203e166da2348bef12b2e5eff4a250cdec7", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "dcf0c203e166da2348bef12b2e5eff4a250cdec7" } -lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "dcf0c203e166da2348bef12b2e5eff4a250cdec7", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "dcf0c203e166da2348bef12b2e5eff4a250cdec7" } -lightning-rapid-gossip-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "dcf0c203e166da2348bef12b2e5eff4a250cdec7" } -lightning-block-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "dcf0c203e166da2348bef12b2e5eff4a250cdec7", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "dcf0c203e166da2348bef12b2e5eff4a250cdec7", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "dcf0c203e166da2348bef12b2e5eff4a250cdec7", features = ["std"] } -lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "dcf0c203e166da2348bef12b2e5eff4a250cdec7" } +lightning = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0", features = ["std"] } +lightning-types = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-invoice = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-persister = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-rapid-gossip-sync = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-block-sync = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0", features = ["std"] } +lightning-macros = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } 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"]} @@ -66,7 +66,7 @@ bip21 = { version = "0.5", features = ["std"], default-features = false } base64 = { version = "0.22.1", default-features = false, features = ["std"] } getrandom = { version = "0.3", default-features = false } chrono = { version = "0.4", default-features = false, features = ["clock"] } -tokio = { version = "1.37", default-features = false, features = [ "rt-multi-thread", "time", "sync", "macros", "net" ] } +tokio = { version = "1.37", default-features = false, features = [ "rt-multi-thread", "time", "sync", "macros" ] } esplora-client = { version = "0.12", default-features = false, features = ["tokio", "async-https-rustls"] } electrum-client = { version = "0.24.0", default-features = false, features = ["proxy", "use-rustls-ring"] } libc = "0.2" @@ -79,13 +79,13 @@ async-trait = { version = "0.1", default-features = false } vss-client = { package = "vss-client-ng", version = "0.5" } prost = { version = "0.11.6", default-features = false} #bitcoin-payment-instructions = { version = "0.6" } -bitcoin-payment-instructions = { git = "https://github.com/joostjager/bitcoin-payment-instructions", branch = "ldk-dcf0c203e166da2348bef12b2e5eff4a250cdec7" } +bitcoin-payment-instructions = { git = "https://github.com/jkczyz/bitcoin-payment-instructions", rev = "0138feb7acefb1e49102a6fb46d7b776bf43265e" } [target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "dcf0c203e166da2348bef12b2e5eff4a250cdec7", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0", features = ["std", "_test_utils"] } rand = { version = "0.9.2", default-features = false, features = ["std", "thread_rng", "os_rng"] } proptest = "1.0.0" regex = "1.5.6" @@ -172,15 +172,15 @@ 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" } +[patch."https://github.com/lightningdevkit/rust-lightning"] +lightning = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-types = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-invoice = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-net-tokio = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-persister = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-background-processor = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-rapid-gossip-sync = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-block-sync = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-transaction-sync = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-liquidity = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } +lightning-macros = { git = "https://github.com/tnull/rust-lightning", rev = "71242155f2b90007e850be89daef4db78d8508f0" } diff --git a/src/builder.rs b/src/builder.rs index cd8cc184f..7d2803faa 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -27,6 +27,7 @@ use lightning::ln::peer_handler::{IgnoringMessageHandler, MessageHandler}; use lightning::log_trace; use lightning::routing::gossip::NodeAlias; use lightning::routing::router::DefaultRouter; +use lightning_liquidity::lsps2::router::LSPS2BOLT12Router; use lightning::routing::scoring::{ CombinedScorer, ProbabilisticScorer, ProbabilisticScoringDecayParameters, ProbabilisticScoringFeeParameters, @@ -77,7 +78,7 @@ use crate::runtime::{Runtime, RuntimeSpawner}; use crate::tx_broadcaster::TransactionBroadcaster; use crate::types::{ AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreRef, DynStoreWrapper, - GossipSync, Graph, KeysManager, MessageRouter, OnionMessenger, PaymentStore, PeerManager, + GossipSync, Graph, InnerMessageRouter, KeysManager, OnionMessenger, PaymentStore, PeerManager, PendingPaymentStore, SyncAndAsyncKVStore, }; use crate::wallet::persist::KVStoreWalletPersister; @@ -1622,12 +1623,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( + inner_router, + inner_message_router, + Arc::clone(&keys_manager), )); let mut user_config = default_user_config(&config); @@ -1656,8 +1664,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::clone(&router); // Initialize the ChannelManager let channel_manager = { @@ -1791,6 +1798,7 @@ fn build_with_store_internal( Arc::clone(&wallet), Arc::clone(&channel_manager), Arc::clone(&keys_manager), + Arc::clone(&router), Arc::clone(&tx_broadcaster), Arc::clone(&kv_store), Arc::clone(&config), @@ -1828,6 +1836,8 @@ fn build_with_store_internal( let liquidity_source = runtime .block_on(async move { liquidity_source_builder.build().await.map(Arc::new) })?; + // TODO: Rehydrate persisted intercept SCID -> LSPS2Bolt12InvoiceParameters mappings here + // for client nodes and call `router.register_intercept_scid(...)` before startup completes. let custom_message_handler = Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source))); (Some(liquidity_source), custom_message_handler) diff --git a/src/event.rs b/src/event.rs index f06d701bc..324e926ae 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1333,6 +1333,12 @@ where claim_from_onchain_tx, outbound_amount_forwarded_msat, } => { + let prev_channel_id = prev_htlcs.first().map(|h| h.channel_id); + let next_channel_id = next_htlcs.first().map(|h| h.channel_id); + let prev_user_channel_id = prev_htlcs.first().and_then(|h| h.user_channel_id); + let next_user_channel_id = next_htlcs.first().and_then(|h| h.user_channel_id); + let prev_node_id = prev_htlcs.first().and_then(|h| h.node_id); + let next_node_id = next_htlcs.first().and_then(|h| h.node_id); { let read_only_network_graph = self.network_graph.read_only(); let nodes = read_only_network_graph.nodes(); @@ -1653,14 +1659,25 @@ 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 { - log_trace!( - self.logger, - "Onion message intercepted, but no onion message mailbox available" - ); + 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 for unknown SCID {}, ignoring", + scid + ); + }, } }, LdkEvent::OnionMessagePeerConnected { peer_node_id } => { diff --git a/src/lib.rs b/src/lib.rs index 2ac4697e8..39a7f28fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -901,9 +901,13 @@ impl Node { #[cfg(not(feature = "uniffi"))] pub fn bolt12_payment(&self) -> Bolt12Payment { Bolt12Payment::new( + Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), + 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), @@ -917,9 +921,13 @@ impl Node { #[cfg(feature = "uniffi")] pub fn bolt12_payment(&self) -> Arc { Arc::new(Bolt12Payment::new( + Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), + 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 485da941c..e7f034e89 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -32,6 +32,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}; @@ -43,7 +44,8 @@ use crate::connection::ConnectionManager; use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; use crate::runtime::Runtime; use crate::types::{ - Broadcaster, ChannelManager, DynStore, KeysManager, LiquidityManager, PeerManager, Wallet, + Broadcaster, ChannelManager, DynStore, KeysManager, LiquidityManager, PeerManager, Router, + Wallet, }; use crate::{total_anchor_channels_reserve_sats, Config, Error}; @@ -153,6 +155,7 @@ where lsps2_service: Option, wallet: Arc, channel_manager: Arc, + router: Arc, keys_manager: Arc, tx_broadcaster: Arc, kv_store: Arc, @@ -166,7 +169,8 @@ where { pub(crate) fn new( wallet: Arc, channel_manager: Arc, keys_manager: Arc, - tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: L, + router: Arc, tx_broadcaster: Arc, kv_store: Arc, + config: Arc, logger: L, ) -> Self { let lsps1_client = None; let lsps2_client = None; @@ -177,6 +181,7 @@ where lsps2_service, wallet, channel_manager, + router, keys_manager, tx_broadcaster, kv_store, @@ -272,6 +277,7 @@ where lsps2_service: self.lsps2_service, wallet: self.wallet, channel_manager: self.channel_manager, + router: self.router, peer_manager: RwLock::new(None), keys_manager: self.keys_manager, liquidity_manager, @@ -290,6 +296,7 @@ where lsps2_service: Option, wallet: Arc, channel_manager: Arc, + router: Arc, peer_manager: RwLock>>, keys_manager: Arc, liquidity_manager: Arc, @@ -1190,6 +1197,104 @@ where Ok((invoice, min_prop_fee_ppm_msat)) } + pub(crate) async fn lsps2_register_bolt12_payment_paths( + &self, amount_msat: u64, max_total_lsp_fee_limit_msat: Option, + ) -> Result { + 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?; + self.register_lsps2_bolt12_payment_paths(buy_response)?; + + Ok(min_total_fee_msat) + } + + pub(crate) async fn lsps2_register_variable_amount_bolt12_payment_paths( + &self, max_proportional_lsp_fee_limit_ppm_msat: Option, + ) -> Result { + 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?; + self.register_lsps2_bolt12_payment_paths(buy_response)?; + + Ok(min_prop_fee_ppm_msat) + } + + fn register_lsps2_bolt12_payment_paths( + &self, buy_response: LSPS2BuyResponse, + ) -> Result<(), Error> { + let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + + self.router.register_intercept_scid( + buy_response.intercept_scid, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsps2_client.lsp_node_id, + intercept_scid: buy_response.intercept_scid, + cltv_expiry_delta: buy_response.cltv_expiry_delta, + }, + ); + + Ok(()) + } + async fn lsps2_request_opening_fee_params(&self) -> Result { let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; @@ -1302,7 +1407,14 @@ 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: u16::try_from(buy_response.cltv_expiry_delta).map_err(|_| { + log_error!( + self.logger, + "Failed to create JIT invoice as LSPS2 CLTV delta {} exceeds supported range", + buy_response.cltv_expiry_delta + ); + Error::LiquidityRequestFailed + })?, htlc_minimum_msat: None, htlc_maximum_msat: None, }]); diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index 980e20696..4f3b00c4a 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -25,10 +25,14 @@ 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}; #[cfg(not(feature = "uniffi"))] @@ -59,9 +63,13 @@ type HumanReadableName = Arc; /// [`Node::bolt12_payment`]: crate::Node::bolt12_payment #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] pub struct Bolt12Payment { + runtime: Arc, channel_manager: Arc, + connection_manager: Arc>>, + liquidity_source: Option>>>, keys_manager: Arc, payment_store: Arc, + peer_store: Arc>>, config: Arc, is_running: Arc>, logger: Arc, @@ -70,14 +78,22 @@ pub struct Bolt12Payment { impl Bolt12Payment { pub(crate) fn new( - channel_manager: Arc, keys_manager: Arc, - payment_store: Arc, config: Arc, is_running: Arc>, - logger: Arc, async_payments_role: Option, + runtime: Arc, channel_manager: 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, + connection_manager, + liquidity_source, keys_manager, payment_store, + peer_store, config, is_running, logger, @@ -231,6 +247,104 @@ impl Bolt12Payment { Ok(finalized_offer) } + fn receive_variable_amount_inner( + &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) + .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 offer = if let Some(amount_msat) = amount_msat { + self.receive_inner(amount_msat, description, expiry_secs, quantity)? + } else { + self.receive_variable_amount_inner(description, expiry_secs)? + }; + + let liquidity_source = Arc::clone(liquidity_source); + let (lsp_total_opening_fee, lsp_prop_opening_fee) = self.runtime.block_on(async move { + if let Some(amount_msat) = amount_msat { + liquidity_source + .lsps2_register_bolt12_payment_paths( + amount_msat, + max_total_lsp_fee_limit_msat, + ) + .await + .map(|total_fee| (Some(total_fee), None)) + } else { + liquidity_source + .lsps2_register_variable_amount_bolt12_payment_paths( + max_proportional_lsp_fee_limit_ppm_msat, + ) + .await + .map(|prop_fee| (None, Some(prop_fee))) + } + })?; + + 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.peer_store.add_peer(peer_info)?; + + Ok(offer) + } + fn blinded_paths_for_async_recipient_internal( &self, recipient_id: Vec, ) -> Result, Error> { @@ -397,23 +511,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) - .unwrap(); - 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)) } diff --git a/src/types.rs b/src/types.rs index dae315ae0..ae7e265e4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -27,6 +27,7 @@ use lightning::util::persist::{KVStore, KVStoreSync, MonitorUpdatingPersisterAsy 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; @@ -283,7 +284,7 @@ pub(crate) type Broadcaster = crate::tx_broadcaster::TransactionBroadcaster, Arc, Arc, @@ -291,6 +292,7 @@ pub(crate) type Router = DefaultRouter< ProbabilisticScoringFeeParameters, Scorer, >; +pub(crate) type Router = LSPS2BOLT12Router>; pub(crate) type Scorer = CombinedScorer, Arc>; pub(crate) type Graph = gossip::NetworkGraph>; @@ -324,11 +326,12 @@ pub(crate) type OnionMessenger = lightning::onion_message::messenger::OnionMesse pub(crate) type HRNResolver = LDKOnionMessageDNSSECHrnResolver, Arc>; -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 = Router; pub(crate) type Sweeper = OutputSweeper< Arc, diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 413b2d44a..e3090a544 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1906,6 +1906,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, + }; + + 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, + }; + + 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();