From 76f59aecb4cf95ed0c5ed343a99f85765695a741 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 3 Jul 2026 12:47:37 +0000 Subject: [PATCH 1/3] DROPME: Bump Rust Lightning to unmerged LSPS2 APIs Point the Rust Lightning overrides at the local branch carrying the LSPS2 BOLT12 router changes and carry the temporary API updates needed to compile LDK Node. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 26 +++++++++--------- src/builder.rs | 1 + src/data_store.rs | 6 ++--- src/event.rs | 27 ++++++++++++------- src/ffi/types.rs | 2 +- src/io/vss_store.rs | 4 +-- src/lib.rs | 6 ++--- .../asynchronous/static_invoice_store.rs | 4 +-- src/payment/bolt11.rs | 8 +++--- src/payment/pending_payment_store.rs | 4 +-- src/payment/store.rs | 18 ++++++------- src/peer_store.rs | 4 +-- src/types.rs | 4 +-- 13 files changed, 62 insertions(+), 52 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bed984f071..a2b7961fec 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 = { path = "../rust-lightning-3/lightning" } +lightning-types = { path = "../rust-lightning-3/lightning-types" } +lightning-invoice = { path = "../rust-lightning-3/lightning-invoice" } +lightning-net-tokio = { path = "../rust-lightning-3/lightning-net-tokio" } +lightning-persister = { path = "../rust-lightning-3/lightning-persister" } +lightning-background-processor = { path = "../rust-lightning-3/lightning-background-processor" } +lightning-rapid-gossip-sync = { path = "../rust-lightning-3/lightning-rapid-gossip-sync" } +lightning-block-sync = { path = "../rust-lightning-3/lightning-block-sync" } +lightning-transaction-sync = { path = "../rust-lightning-3/lightning-transaction-sync" } +lightning-liquidity = { path = "../rust-lightning-3/lightning-liquidity" } +lightning-macros = { path = "../rust-lightning-3/lightning-macros" } +lightning-dns-resolver = { path = "../rust-lightning-3/lightning-dns-resolver" } diff --git a/src/builder.rs b/src/builder.rs index 3df594b7cf..81d607d5af 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1916,6 +1916,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 80acd0690e..671831d6ad 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), @@ -1725,15 +1725,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/ffi/types.rs b/src/ffi/types.rs index 7380d75cac..01660b7e03 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -1270,7 +1270,7 @@ impl Bolt11Invoice { } /// Recover the payee's public key (only to be used if none was included in the invoice) - pub fn recover_payee_pub_key(&self) -> PublicKey { + pub fn recover_payee_pub_key(&self) -> Option { self.inner.recover_payee_pub_key() } } 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 b450642879..2742b870f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -146,7 +146,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; @@ -2268,7 +2268,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), @@ -2338,7 +2338,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/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 df29caef5c..50396f3b8e 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), }); @@ -309,7 +309,7 @@ impl Bolt11Payment { let payee_pubkey = invoice.recover_payee_pub_key(); let amt_msat = invoice.amount_milli_satoshis().expect("invoice amount should be set"); - log_info!(self.logger, "Initiated sending {}msat to {}", amt_msat, payee_pubkey); + log_info!(self.logger, "Initiated sending {}msat to {:?}", amt_msat, payee_pubkey); let kind = PaymentKind::Bolt11 { hash: payment_hash, @@ -422,7 +422,7 @@ impl Bolt11Payment { let payee_pubkey = invoice.recover_payee_pub_key(); log_info!( self.logger, - "Initiated sending {} msat to {}", + "Initiated sending {} msat to {:?}", amount_msat, payee_pubkey ); 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 5672a5c349d1a192f86b614822a2c0cebfeb44dd Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 3 Jul 2026 17:50:06 +0000 Subject: [PATCH 2/3] Receive BOLT12 payments through LSPS2 JIT channels Wrap the default router with the LSPS2-aware `LSPS2Router` and wire it to the LSPS2 client handler, so JIT-channel blinded payment paths are injected into BOLT12 invoices based on the latest invoice parameters negotiated with our configured LSPs. If liquidity sources are configured, we negotiate the parameters with the cheapest LSP once at startup. From then on they simply apply to all BOLT12 receives, i.e., any offer created via the regular receive APIs will have JIT-channel paths injected into its invoices, and we force a refresh of any static invoices stored with a static invoice server so they include the new paths, too. As the router encodes the used parameters in the paths' payment metadata, they are available again upon receipt and used to verify any skimmed channel opening fee against what was negotiated. Parameters previously negotiated with LSPs that are no longer configured are wiped early during node building, before the node could create any invoices or connect to peers. The BOLT11 JIT receive flows are refactored to share the same negotiation logic. Co-Authored-By: Claude Fable 5 --- src/builder.rs | 55 +++++++++++++- src/event.rs | 105 +++++++++++++++++++++++++-- src/lib.rs | 43 +++++++++++ src/liquidity/client/lsps2.rs | 130 ++++++++++++++++++++++++---------- src/types.rs | 4 +- 5 files changed, 290 insertions(+), 47 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 81d607d5af..5b4ee5ecb0 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::LSPS2Router; use vss_client::headers::VssHeaderProvider; use crate::chain::ChainSource; @@ -70,7 +71,7 @@ use crate::io::{ }; use crate::liquidity::{LSPS2ServiceConfig, LiquiditySourceBuilder, LspConfig}; use crate::lnurl_auth::LnurlAuth; -use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; +use crate::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::peer_store::PeerStore; @@ -1762,13 +1763,17 @@ 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, - )); + ); + // Wrap the default router to inject LSPS2 JIT-channel blinded payment paths into BOLT12 + // invoices based on the latest invoice parameters negotiated with our configured LSPs. The + // LSPS2 client handler is wired up below, once the liquidity source is built. + let router = Arc::new(LSPS2Router::new(inner_router)); let mut user_config = default_user_config(&config); @@ -1993,6 +1998,50 @@ fn build_with_store_internal( let custom_message_handler = Arc::new(NodeCustomMessageHandler::new(Arc::clone(&liquidity_source))); + // Wire up the LSPS2 client handler, having the router inject JIT-channel blinded payment + // paths based on the latest invoice parameters negotiated with our configured LSPs. + // + // Beforehand, wipe any parameters previously negotiated with LSPs that are no longer + // configured, making sure the router won't have payments routed through them anymore. + if let Some(lsps2_client_handler) = + liquidity_source.liquidity_manager().lsps2_client_handler_arc() + { + let stale_lsp_node_ids = lsps2_client_handler + .latest_invoice_params() + .into_iter() + .map(|invoice_params| invoice_params.counterparty_node_id) + .filter(|counterparty_node_id| { + !liquidity_source_config.map_or(false, |lsc| { + lsc.lsp_nodes.iter().any(|n| n.node_id == *counterparty_node_id) + }) + }) + .collect::>(); + + for counterparty_node_id in stale_lsp_node_ids { + let wipe_handler = Arc::clone(&lsps2_client_handler); + let wipe_logger = Arc::clone(&logger); + runtime.spawn_background_task(async move { + log_info!( + wipe_logger, + "Wiping LSPS2 invoice parameters previously negotiated with now-unconfigured LSP {}", + counterparty_node_id, + ); + if let Err(e) = + wipe_handler.clear_latest_invoice_params(&counterparty_node_id).await + { + log_error!( + wipe_logger, + "Failed to wipe LSPS2 invoice parameters for LSP {}: {:?}", + counterparty_node_id, + e + ); + } + }); + } + + router.set_lsps2_client_handler(lsps2_client_handler); + } + (liquidity_source, custom_message_handler) }; diff --git a/src/event.rs b/src/event.rs index 671831d6ad..544de8d058 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}; @@ -30,6 +30,8 @@ use lightning::util::errors::APIError; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, ReadableArgs, Writeable, Writer}; use lightning::{impl_ser_tlv_based, impl_ser_tlv_based_enum}; +use lightning_liquidity::lsps2::client::LSPS2InvoiceParameters; +use lightning_liquidity::lsps2::router::LSPS2_PAYMENT_METADATA_KEY; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_types::payment::{PaymentHash, PaymentPreimage}; @@ -611,6 +613,21 @@ where }) } + fn lsps2_max_total_opening_fee_msat_from_bolt12_metadata( + payment_metadata: Option<&BTreeMap>>, payment_size_msat: u64, + ) -> Option { + // For BOLT12 payments, the router encoded the negotiated LSPS2 invoice parameters in the + // payment metadata of any JIT-channel blinded payment paths it constructed. Recompute the + // maximum acceptable opening fee from the negotiated opening fee parameters. + let encoded_params = payment_metadata?.get(&LSPS2_PAYMENT_METADATA_KEY)?; + let invoice_params = LSPS2InvoiceParameters::read(&mut &encoded_params[..]).ok()?; + compute_opening_fee( + payment_size_msat, + invoice_params.opening_fee_params.min_fee_msat, + invoice_params.opening_fee_params.proportional as u64, + ) + } + pub async fn handle_event(&self, event: LdkEvent) -> Result<(), ReplayEvent> { match event { LdkEvent::FundingGenerationReady { @@ -798,13 +815,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 + counterparty_skimmed_fee_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, ); @@ -828,18 +851,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."), + _ => {}, } } } @@ -1948,6 +1977,72 @@ mod tests { ); } + #[test] + fn lsps2_bolt12_payment_metadata_decodes_fee_limit() { + use lightning_liquidity::lsps0::ser::LSPSDateTime; + use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; + + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + use std::str::FromStr; + + let counterparty_node_id = PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[42; 32]).unwrap(), + ); + let invoice_params = LSPS2InvoiceParameters { + counterparty_node_id, + intercept_scid: 42, + cltv_expiry_delta: 144, + opening_fee_params: LSPS2OpeningFeeParams { + min_fee_msat: 21_000, + proportional: 10_000, + valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(), + min_lifetime: 144, + max_client_to_self_delay: 128, + min_payment_size_msat: 1, + max_payment_size_msat: 100_000_000, + promise: "promise".to_string(), + }, + }; + + let mut payment_metadata = BTreeMap::new(); + payment_metadata.insert(LSPS2_PAYMENT_METADATA_KEY, invoice_params.encode()); + + // max(min_fee_msat, proportional * payment_size / 1_000_000) = max(21_000, 10_000_000) + assert_eq!( + EventHandler::>::lsps2_max_total_opening_fee_msat_from_bolt12_metadata( + Some(&payment_metadata), + 1_000_000_000, + ), + Some(10_000_000) + ); + + // Missing metadata, missing key, or malformed parameters are rejected. + assert_eq!( + EventHandler::>::lsps2_max_total_opening_fee_msat_from_bolt12_metadata( + None, 1_000_000, + ), + None + ); + assert_eq!( + EventHandler::>::lsps2_max_total_opening_fee_msat_from_bolt12_metadata( + Some(&BTreeMap::new()), + 1_000_000, + ), + None + ); + let mut malformed_metadata = BTreeMap::new(); + malformed_metadata.insert(LSPS2_PAYMENT_METADATA_KEY, vec![0xff]); + assert_eq!( + EventHandler::>::lsps2_max_total_opening_fee_msat_from_bolt12_metadata( + Some(&malformed_metadata), + 1_000_000, + ), + None + ); + } + #[test] fn lsps2_payment_metadata_missing_or_malformed_limit_is_rejected() { let empty_metadata = PaymentMetadata { lsps2_parameters: None }.encode(); diff --git a/src/lib.rs b/src/lib.rs index 2742b870f6..6eb674e69a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -746,6 +746,49 @@ impl Node { } }); + // If we have configured liquidity sources, negotiate LSPS2 JIT-channel invoice parameters + // with the cheapest LSP once at startup. The negotiated parameters are stored in the + // LSPS2 client handler and will be used by our router to inject JIT-channel blinded + // payment paths into all BOLT12 invoices going forward. + if !self.liquidity_source.get_all_lsp_details().is_empty() { + let negotiation_liquidity_source = Arc::clone(&self.liquidity_source); + let negotiation_cm = Arc::clone(&self.connection_manager); + let negotiation_chan_man = Arc::clone(&self.channel_manager); + let negotiation_peer_store = Arc::clone(&self.peer_store); + let negotiation_logger = Arc::clone(&self.logger); + self.runtime.spawn_background_task(async move { + match negotiation_liquidity_source + .lsps2_client() + .lsps2_negotiate_variable_amount_jit_invoice_params(None, negotiation_cm) + .await + { + Ok((_, _, chosen_lsp)) => { + let peer_info = + PeerInfo { node_id: chosen_lsp.node_id, address: chosen_lsp.address }; + if let Err(e) = negotiation_peer_store.add_peer(peer_info).await { + log_error!( + negotiation_logger, + "Failed to add LSP to peer store: {}", + e + ); + } + + // Any static invoices stored with a static invoice server were built + // before the newly-negotiated parameters were available. Force a refresh + // so they include the JIT-channel payment paths going forward. + negotiation_chan_man.refresh_static_invoices(); + }, + Err(e) => { + log_error!( + negotiation_logger, + "Failed to negotiate LSPS2 JIT-channel parameters for BOLT12 receives: {}", + e + ); + }, + } + }); + } + log_info!(self.logger, "Startup complete."); *is_running_lock = true; Ok(()) diff --git a/src/liquidity/client/lsps2.rs b/src/liquidity/client/lsps2.rs index 3033f8d827..4853f569c3 100644 --- a/src/liquidity/client/lsps2.rs +++ b/src/liquidity/client/lsps2.rs @@ -61,14 +61,86 @@ where expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, payment_hash: Option, connection_manager: Arc>, ) -> Result<(Bolt11Invoice, LspConfig), Error> { + let (min_total_fee_msat, buy_response, cheapest_lsp) = Arc::clone(&self) + .lsps2_negotiate_jit_invoice_params( + amount_msat, + max_total_lsp_fee_limit_msat, + connection_manager, + ) + .await?; + + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: Some(min_total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + }; + + let invoice = self.lsps2_create_jit_invoice( + buy_response, + Some(amount_msat), + description, + expiry_secs, + payment_hash, + lsps2_parameters, + Some(&cheapest_lsp.node_id), + )?; + + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + Ok((invoice, cheapest_lsp)) + } + + pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( + self: Arc, description: &Bolt11InvoiceDescription, expiry_secs: u32, + max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, + connection_manager: Arc>, + ) -> Result<(Bolt11Invoice, LspConfig), Error> { + let (min_prop_fee_ppm_msat, buy_response, cheapest_lsp) = Arc::clone(&self) + .lsps2_negotiate_variable_amount_jit_invoice_params( + max_proportional_lsp_fee_limit_ppm_msat, + connection_manager, + ) + .await?; + + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: None, + max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), + }; + + let invoice = self.lsps2_create_jit_invoice( + buy_response, + None, + description, + expiry_secs, + payment_hash, + lsps2_parameters, + Some(&cheapest_lsp.node_id), + )?; + + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + Ok((invoice, cheapest_lsp)) + } + + /// Negotiates JIT-channel invoice parameters for receiving the given amount with the cheapest + /// of our configured LSPs. + /// + /// Upon success, the negotiated parameters are stored in the LSPS2 client handler. For BOLT11 + /// they can be used in a route hint via [`Self::lsps2_receive_to_jit_channel`], while for + /// BOLT12 they will be used by the [`LSPS2Router`] to inject JIT-channel blinded payment + /// paths when creating invoices. + /// + /// Returns the maximum total opening fee we agreed to pay, the LSP's buy response, and the + /// chosen LSP. + /// + /// [`LSPS2Router`]: lightning_liquidity::lsps2::router::LSPS2Router + pub(crate) async fn lsps2_negotiate_jit_invoice_params( + self: Arc, amount_msat: u64, max_total_lsp_fee_limit_msat: Option, + connection_manager: Arc>, + ) -> Result<(u64, LSPS2BuyResponse, LspConfig), Error> { // Connect to all candidate LSPs before querying fees. let all_offers = self.gather_lsps2_offers(&connection_manager).await?; let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers .into_iter() .flat_map(|(lsp, resp)| { - resp.opening_fee_params_menu - .into_iter() - .map(move |params| (lsp.clone(), params)) + resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params)) }) .filter_map(|(lsp, params)| { if amount_msat < params.min_payment_size_msat @@ -117,30 +189,26 @@ where Some(&cheapest_lsp.node_id), ) .await?; - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: Some(min_total_fee_msat), - max_proportional_opening_fee_ppm_msat: None, - }; - - let invoice = self.lsps2_create_jit_invoice( - buy_response, - Some(amount_msat), - description, - expiry_secs, - payment_hash, - lsps2_parameters, - Some(&cheapest_lsp.node_id), - )?; - log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok((invoice, cheapest_lsp)) + Ok((min_total_fee_msat, buy_response, cheapest_lsp)) } - pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( - self: Arc, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, + /// Negotiates JIT-channel invoice parameters for receiving a variable amount with the + /// cheapest of our configured LSPs. + /// + /// Upon success, the negotiated parameters are stored in the LSPS2 client handler. For BOLT11 + /// they can be used in a route hint via + /// [`Self::lsps2_receive_variable_amount_to_jit_channel`], while for BOLT12 they will be used + /// by the [`LSPS2Router`] to inject JIT-channel blinded payment paths when creating invoices. + /// + /// Returns the maximum proportional opening fee we agreed to pay, the LSP's buy response, and + /// the chosen LSP. + /// + /// [`LSPS2Router`]: lightning_liquidity::lsps2::router::LSPS2Router + pub(crate) async fn lsps2_negotiate_variable_amount_jit_invoice_params( + self: Arc, max_proportional_lsp_fee_limit_ppm_msat: Option, connection_manager: Arc>, - ) -> Result<(Bolt11Invoice, LspConfig), Error> { + ) -> Result<(u64, LSPS2BuyResponse, LspConfig), Error> { // Connect to all candidate LSPs before querying fees. let all_offers = self.gather_lsps2_offers(&connection_manager).await?; let (cheapest_lsp, min_prop_fee_ppm_msat, min_opening_params) = all_offers @@ -181,22 +249,8 @@ where let buy_response = self .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) .await?; - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: None, - max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), - }; - let invoice = self.lsps2_create_jit_invoice( - buy_response, - None, - description, - expiry_secs, - payment_hash, - lsps2_parameters, - Some(&cheapest_lsp.node_id), - )?; - log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok((invoice, cheapest_lsp)) + Ok((min_prop_fee_ppm_msat, buy_response, cheapest_lsp)) } async fn gather_lsps2_offers( diff --git a/src/types.rs b/src/types.rs index a8ce812b8e..c077a29d36 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::LSPS2Router; use lightning_liquidity::utils::time::DefaultTimeProvider; use lightning_net_tokio::SocketDescriptor; @@ -215,7 +216,7 @@ pub(crate) type Broadcaster = crate::tx_broadcaster::TransactionBroadcaster, Arc, Arc, @@ -223,6 +224,7 @@ pub(crate) type Router = DefaultRouter< ProbabilisticScoringFeeParameters, Scorer, >; +pub(crate) type Router = LSPS2Router, Arc>; pub(crate) type Scorer = CombinedScorer, Arc>; pub(crate) type Graph = gossip::NetworkGraph>; From 5f7907837bd094cadf270ea8730c6219f113846a Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 3 Jul 2026 17:50:06 +0000 Subject: [PATCH 3/3] Cover LSPS2 BOLT12 JIT payments Exercise the BOLT12 receive flow with LSPS2 JIT channels via plain BOLT12 offers relying on the parameters negotiated at startup: the initial channel open, paying an offer after LSP restart, and wiping of stale parameters when restarting without the LSP configured. Co-Authored-By: Claude Fable 5 --- tests/integration_tests_rust.rs | 306 ++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 5b07ab50d0..bc64b46232 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2127,6 +2127,312 @@ async fn drop_in_async_context() { node.stop().unwrap(); } +#[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.enable_liquidity_provider(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.add_liquidity_source(service_node_id, service_addr.clone(), None, true); + 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(jit_amount_msat, "lsps2-bolt12-after-restart", None, Some(1)) + .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 plain BOLT12 offer, a payer pays it, and the LSP opens a channel just-in-time. + // The LSPS2 parameters negotiated at startup are injected into the offer's invoices as + // JIT-channel blinded payment paths by the client's router. + 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.enable_liquidity_provider(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.add_liquidity_source(service_node_id, service_addr.clone(), None, true); + 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(jit_amount_msat, "jit-payment", None, Some(1)) + .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 lsps2_bolt12_params_wiped_for_unconfigured_lsp() { + // Verify that the LSPS2 invoice parameters negotiated at startup are persisted, and wiped + // early on restart if the LSP is no longer configured as a liquidity source. + 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.enable_liquidity_provider(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); + let client_store = + TestSyncStore::new(client_config.node_config.storage_dir_path.clone().into()); + setup_builder!(client_builder, client_config.node_config); + client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + client_builder.add_liquidity_source(service_node_id, service_addr.clone(), None, true); + let client_node = client_builder + .build_with_store(client_config.node_entropy.into(), client_store.clone()) + .unwrap(); + client_node.start().unwrap(); + + let params_key = service_node_id.to_string(); + let read_params_blob = || async { + use ldk_node::lightning::util::persist::KVStore; + use ldk_node::lightning_liquidity::persist::{ + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CLIENT_PERSISTENCE_SECONDARY_NAMESPACE, + }; + client_store + .read( + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CLIENT_PERSISTENCE_SECONDARY_NAMESPACE, + ¶ms_key, + ) + .await + .ok() + }; + + // Wait until the parameters negotiated at startup were persisted. + let mut negotiated_params_blob = None; + for _ in 0..100 { + if let Some(blob) = read_params_blob().await { + negotiated_params_blob = Some(blob); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + let negotiated_params_blob = + negotiated_params_blob.expect("LSPS2 params should be negotiated and persisted at startup"); + assert!(!negotiated_params_blob.is_empty()); + + client_node.stop().unwrap(); + drop(client_node); + + // Restart the client without the LSP configured as a liquidity source. The previously + // negotiated parameters must be wiped during build, before the node starts up. + setup_builder!(client_builder, client_config.node_config); + client_builder.set_chain_source_esplora(esplora_url, Some(sync_config)); + let client_node = client_builder + .build_with_store(client_config.node_entropy.into(), client_store.clone()) + .unwrap(); + + // The stored blob is rewritten with the parameters removed (the now-empty entry itself is + // garbage collected later). As the parameters make up virtually all of the encoded peer + // state, check the remaining blob is just a few bytes of empty TLV stream. Note the wipe + // happens in a background task, so we might need to wait for it briefly. + let mut wiped = false; + for _ in 0..100 { + let wiped_params_blob = read_params_blob().await.unwrap(); + if wiped_params_blob.len() < 10 { + assert!(wiped_params_blob.len() < negotiated_params_blob.len()); + wiped = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + assert!(wiped, "expected stale LSPS2 params to be wiped during node building"); + + client_node.start().unwrap(); + client_node.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn lsps2_client_trusts_lsp() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();