From 743314a66c8174c86ac06de1b05d84bf7290edda Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 22 Sep 2025 09:18:58 -0500 Subject: [PATCH 01/23] Add funding_txo to ChannelReady event When a channel is spliced, the existing funding transaction's output is spent and a new funding transaction output is formed. Once the splice is considered locked by both parties, LDK will emit a ChannelReady event which will include the new funding_txo. Additionally, the initial ChannelReady event now includes the original funding_txo. Include this data in LDK Node's ChannelReady event. --- src/event.rs | 44 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/event.rs b/src/event.rs index 1946350a3..2a3de54a0 100644 --- a/src/event.rs +++ b/src/event.rs @@ -199,6 +199,10 @@ pub enum Event { funding_txo: OutPoint, }, /// A channel is ready to be used. + /// + /// This event is emitted when: + /// - A new channel has been established and is ready for use + /// - An existing channel has been spliced and is ready with the new funding output ChannelReady { /// The `channel_id` of the channel. channel_id: ChannelId, @@ -208,6 +212,14 @@ pub enum Event { /// /// This will be `None` for events serialized by LDK Node v0.1.0 and prior. counterparty_node_id: Option, + /// The outpoint of the channel's funding transaction. + /// + /// This represents the channel's current funding output, which may change when the + /// channel is spliced. For spliced channels, this will contain the new funding output + /// from the confirmed splice transaction. + /// + /// This will be `None` for events serialized by LDK Node v0.6.0 and prior. + funding_txo: Option, }, /// A channel has been closed. ChannelClosed { @@ -246,6 +258,7 @@ impl_writeable_tlv_based_enum!(Event, (0, channel_id, required), (1, counterparty_node_id, option), (2, user_channel_id, required), + (3, funding_txo, option), }, (4, ChannelPending) => { (0, channel_id, required), @@ -1363,14 +1376,28 @@ where } }, LdkEvent::ChannelReady { - channel_id, user_channel_id, counterparty_node_id, .. + channel_id, + user_channel_id, + counterparty_node_id, + funding_txo, + .. } => { - log_info!( - self.logger, - "Channel {} with counterparty {} ready to be used.", - channel_id, - counterparty_node_id, - ); + if let Some(funding_txo) = funding_txo { + log_info!( + self.logger, + "Channel {} with counterparty {} ready to be used with funding_txo {}", + channel_id, + counterparty_node_id, + funding_txo, + ); + } else { + log_info!( + self.logger, + "Channel {} with counterparty {} ready to be used", + channel_id, + counterparty_node_id, + ); + } if let Some(liquidity_source) = self.liquidity_source.as_ref() { liquidity_source @@ -1382,6 +1409,7 @@ where channel_id, user_channel_id: UserChannelId(user_channel_id), counterparty_node_id: Some(counterparty_node_id), + funding_txo, }; match self.event_queue.add_event(event) { Ok(_) => {}, @@ -1620,6 +1648,7 @@ mod tests { channel_id: ChannelId([23u8; 32]), user_channel_id: UserChannelId(2323), counterparty_node_id: None, + funding_txo: None, }; event_queue.add_event(expected_event.clone()).unwrap(); @@ -1656,6 +1685,7 @@ mod tests { channel_id: ChannelId([23u8; 32]), user_channel_id: UserChannelId(2323), counterparty_node_id: None, + funding_txo: None, }; // Check `next_event_async` won't return if the queue is empty and always rather timeout. From bc3b23fcd9d966e86ed3ae2e4b8afefc6429813a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 28 Oct 2025 14:52:11 -0500 Subject: [PATCH 02/23] f - bindings --- bindings/ldk_node.udl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index ab2f483a1..885996852 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -384,7 +384,7 @@ interface Event { PaymentForwarded(ChannelId prev_channel_id, ChannelId next_channel_id, UserChannelId? prev_user_channel_id, UserChannelId? next_user_channel_id, PublicKey? prev_node_id, PublicKey? next_node_id, u64? total_fee_earned_msat, u64? skimmed_fee_msat, boolean claim_from_onchain_tx, u64? outbound_amount_forwarded_msat); ChannelPending(ChannelId channel_id, UserChannelId user_channel_id, ChannelId former_temporary_channel_id, PublicKey counterparty_node_id, OutPoint funding_txo); - ChannelReady(ChannelId channel_id, UserChannelId user_channel_id, PublicKey? counterparty_node_id); + ChannelReady(ChannelId channel_id, UserChannelId user_channel_id, PublicKey? counterparty_node_id, OutPoint? funding_txo); ChannelClosed(ChannelId channel_id, UserChannelId user_channel_id, PublicKey? counterparty_node_id, ClosureReason? reason); }; From bf749af1a0d7a72f960dea5e60d78b8fdaad2615 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 21 Oct 2025 21:35:17 -0500 Subject: [PATCH 03/23] Add SplicePending and SpiceFailed events LDK introduced similar events with splicing. SplicePending is largely informational like ChannelPending. SpliceFailed indicates the used UTXOs can be reclaimed. This requires UTXO locking, which is not yet implemented. --- src/event.rs | 111 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 10 deletions(-) diff --git a/src/event.rs b/src/event.rs index 2a3de54a0..a7fb6d63b 100644 --- a/src/event.rs +++ b/src/event.rs @@ -234,6 +234,28 @@ pub enum Event { /// This will be `None` for events serialized by LDK Node v0.2.1 and prior. reason: Option, }, + /// A channel splice is pending confirmation on-chain. + SplicePending { + /// The `channel_id` of the channel. + channel_id: ChannelId, + /// The `user_channel_id` of the channel. + user_channel_id: UserChannelId, + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The outpoint of the channel's splice funding transaction. + new_funding_txo: OutPoint, + }, + /// A channel splice has failed. + SpliceFailed { + /// The `channel_id` of the channel. + channel_id: ChannelId, + /// The `user_channel_id` of the channel. + user_channel_id: UserChannelId, + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The outpoint of the channel's splice funding transaction. + abandoned_funding_txo: Option, + }, } impl_writeable_tlv_based_enum!(Event, @@ -291,7 +313,19 @@ impl_writeable_tlv_based_enum!(Event, (10, skimmed_fee_msat, option), (12, claim_from_onchain_tx, required), (14, outbound_amount_forwarded_msat, option), - } + }, + (8, SplicePending) => { + (1, channel_id, required), + (3, counterparty_node_id, required), + (5, user_channel_id, required), + (7, new_funding_txo, required), + }, + (9, SpliceFailed) => { + (1, channel_id, required), + (3, counterparty_node_id, required), + (5, user_channel_id, required), + (7, abandoned_funding_txo, option), + }, ); pub struct EventQueue @@ -1611,17 +1645,74 @@ where LdkEvent::FundingTransactionReadyForSigning { .. } => { debug_assert!(false, "We currently don't support interactive-tx, so this event should never be emitted."); }, - LdkEvent::SplicePending { .. } => { - debug_assert!( - false, - "We currently don't support splicing, so this event should never be emitted." + LdkEvent::SplicePending { + channel_id, + user_channel_id, + counterparty_node_id, + new_funding_txo, + .. + } => { + log_info!( + self.logger, + "Channel {} with counterparty {} pending splice with funding_txo {}", + channel_id, + counterparty_node_id, + new_funding_txo, ); + + let event = Event::SplicePending { + channel_id, + user_channel_id: UserChannelId(user_channel_id), + counterparty_node_id, + new_funding_txo, + }; + + match self.event_queue.add_event(event) { + Ok(_) => {}, + Err(e) => { + log_error!(self.logger, "Failed to push to event queue: {}", e); + return Err(ReplayEvent()); + }, + }; }, - LdkEvent::SpliceFailed { .. } => { - debug_assert!( - false, - "We currently don't support splicing, so this event should never be emitted." - ); + LdkEvent::SpliceFailed { + channel_id, + user_channel_id, + counterparty_node_id, + abandoned_funding_txo, + .. + } => { + if let Some(funding_txo) = abandoned_funding_txo { + log_info!( + self.logger, + "Channel {} with counterparty {} failed splice with funding_txo {}", + channel_id, + counterparty_node_id, + funding_txo, + ); + } else { + log_info!( + self.logger, + "Channel {} with counterparty {} failed splice", + channel_id, + counterparty_node_id, + ); + } + + let event = Event::SpliceFailed { + channel_id, + user_channel_id: UserChannelId(user_channel_id), + counterparty_node_id, + abandoned_funding_txo, + }; + + match self.event_queue.add_event(event) { + Ok(_) => {}, + Err(e) => { + log_error!(self.logger, "Failed to push to event queue: {}", e); + return Err(ReplayEvent()); + }, + }; }, } Ok(()) From 1cb7bdde4a750603e27eb0dfdb1c88f7f602d73e Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 28 Oct 2025 14:21:48 -0500 Subject: [PATCH 04/23] f - bindings --- bindings/ldk_node.udl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 885996852..6356b8aec 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -386,6 +386,8 @@ interface Event { ChannelPending(ChannelId channel_id, UserChannelId user_channel_id, ChannelId former_temporary_channel_id, PublicKey counterparty_node_id, OutPoint funding_txo); ChannelReady(ChannelId channel_id, UserChannelId user_channel_id, PublicKey? counterparty_node_id, OutPoint? funding_txo); ChannelClosed(ChannelId channel_id, UserChannelId user_channel_id, PublicKey? counterparty_node_id, ClosureReason? reason); + SplicePending(ChannelId channel_id, UserChannelId user_channel_id, PublicKey counterparty_node_id, OutPoint new_funding_txo); + SpliceFailed(ChannelId channel_id, UserChannelId user_channel_id, PublicKey counterparty_node_id, OutPoint? abandoned_funding_txo); }; enum PaymentFailureReason { From 87bf792ff7b1031aa1e88c0701e389216ff40fbf Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 23 Oct 2025 12:45:46 -0500 Subject: [PATCH 05/23] Handle LdkEvent::FundingTransactionReadyForSigning When the interactive-tx construction protocol completes in LDK during splicing (and in the future dual-funding), LDK Node must provide signatures for any non-shared inputs belonging to its on-chain wallet. This commit implements this when handling the corresponding FundingTransactionReadyForSigning event. --- src/event.rs | 31 +++++++++++++++++++++++++++++-- src/wallet/mod.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/event.rs b/src/event.rs index a7fb6d63b..4b8752874 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1642,8 +1642,35 @@ where } } }, - LdkEvent::FundingTransactionReadyForSigning { .. } => { - debug_assert!(false, "We currently don't support interactive-tx, so this event should never be emitted."); + LdkEvent::FundingTransactionReadyForSigning { + channel_id, + counterparty_node_id, + unsigned_transaction, + .. + } => { + let partially_signed_tx = + self.wallet.sign_owned_inputs(unsigned_transaction).map_err(|()| { + log_error!(self.logger, "Failed signing funding transaction"); + ReplayEvent() + })?; + + self.channel_manager + .funding_transaction_signed( + &channel_id, + &counterparty_node_id, + partially_signed_tx, + ) + .map_err(|e| { + log_error!(self.logger, "Failed signing funding transaction: {:?}", e); + ReplayEvent() + })?; + + log_info!( + self.logger, + "Signed funding transaction for channel {} with counterparty {}", + channel_id, + counterparty_node_id + ); }, LdkEvent::SplicePending { channel_id, diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 0f3797431..2fca77934 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -664,6 +664,40 @@ impl Wallet { Ok(address_info.address.script_pubkey()) } + #[allow(deprecated)] + pub(crate) fn sign_owned_inputs(&self, unsigned_tx: Transaction) -> Result { + let locked_wallet = self.inner.lock().unwrap(); + + let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).map_err(|e| { + log_error!(self.logger, "Failed to construct PSBT: {}", e); + })?; + for (i, txin) in psbt.unsigned_tx.input.iter().enumerate() { + if let Some(utxo) = locked_wallet.get_utxo(txin.previous_output) { + psbt.inputs[i] = locked_wallet.get_psbt_input(utxo, None, true).map_err(|e| { + log_error!(self.logger, "Failed to construct PSBT input: {}", e); + })?; + } + } + + let mut sign_options = SignOptions::default(); + sign_options.trust_witness_utxo = true; + + match locked_wallet.sign(&mut psbt, sign_options) { + Ok(finalized) => debug_assert!(!finalized), + Err(e) => { + log_error!(self.logger, "Failed to sign owned inputs: {}", e); + return Err(()); + }, + } + + let mut tx = psbt.unsigned_tx; + for (txin, input) in tx.input.iter_mut().zip(psbt.inputs.into_iter()) { + txin.witness = input.final_script_witness.unwrap_or_default(); + } + + Ok(tx) + } + #[allow(deprecated)] fn sign_psbt_inner(&self, mut psbt: Psbt) -> Result { let locked_wallet = self.inner.lock().unwrap(); From be63d88d037e73f850807d418852b0b33d2163c7 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 28 Oct 2025 15:49:53 -0500 Subject: [PATCH 06/23] f - extract_tx --- src/wallet/mod.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 2fca77934..d2e729cdb 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -690,12 +690,14 @@ impl Wallet { }, } - let mut tx = psbt.unsigned_tx; - for (txin, input) in tx.input.iter_mut().zip(psbt.inputs.into_iter()) { - txin.witness = input.final_script_witness.unwrap_or_default(); + match psbt.extract_tx() { + Ok(tx) => Ok(tx), + Err(bitcoin::psbt::ExtractTxError::MissingInputValue { tx }) => Ok(tx), + Err(e) => { + log_error!(self.logger, "Failed to extract transaction: {}", e); + Err(()) + } } - - Ok(tx) } #[allow(deprecated)] From 6647b0397015940658ab7f6392ed6a9bc438f4ef Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 22 Sep 2025 10:40:22 -0500 Subject: [PATCH 07/23] Refactor funds checking logic into reusable method Extract the funds availability checking logic from open_channel_inner into a separate method so that it can be reused for channel splicing. --- src/lib.rs | 85 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 38 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 701a14dde..aea191542 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1059,50 +1059,14 @@ impl Node { let con_addr = peer_info.address.clone(); let con_cm = Arc::clone(&self.connection_manager); - let cur_anchor_reserve_sats = - total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); - let spendable_amount_sats = - self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); - - // Fail early if we have less than the channel value available. - if spendable_amount_sats < channel_amount_sats { - log_error!(self.logger, - "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", - spendable_amount_sats, channel_amount_sats - ); - return Err(Error::InsufficientFunds); - } - // We need to use our main runtime here as a local runtime might not be around to poll // connection futures going forward. self.runtime.block_on(async move { con_cm.connect_peer_if_necessary(con_node_id, con_addr).await })?; - // Fail if we have less than the channel value + anchor reserve available (if applicable). - let init_features = self - .peer_manager - .peer_by_node_id(&node_id) - .ok_or(Error::ConnectionFailed)? - .init_features; - let required_funds_sats = channel_amount_sats - + self.config.anchor_channels_config.as_ref().map_or(0, |c| { - if init_features.requires_anchors_zero_fee_htlc_tx() - && !c.trusted_peers_no_reserve.contains(&node_id) - { - c.per_channel_reserve_sats - } else { - 0 - } - }); - - if spendable_amount_sats < required_funds_sats { - log_error!(self.logger, - "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", - spendable_amount_sats, required_funds_sats - ); - return Err(Error::InsufficientFunds); - } + // Check funds availability after connection (includes anchor reserve calculation) + self.check_sufficient_funds_for_channel(channel_amount_sats, &node_id)?; let mut user_config = default_user_config(&self.config); user_config.channel_handshake_config.announce_for_forwarding = announce_for_forwarding; @@ -1143,6 +1107,51 @@ impl Node { } } + fn check_sufficient_funds_for_channel( + &self, amount_sats: u64, peer_node_id: &PublicKey, + ) -> Result<(), Error> { + let cur_anchor_reserve_sats = + total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); + let spendable_amount_sats = + self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); + + // Fail early if we have less than the channel value available. + if spendable_amount_sats < amount_sats { + log_error!(self.logger, + "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", + spendable_amount_sats, amount_sats + ); + return Err(Error::InsufficientFunds); + } + + // Fail if we have less than the channel value + anchor reserve available (if applicable). + let init_features = self + .peer_manager + .peer_by_node_id(peer_node_id) + .ok_or(Error::ConnectionFailed)? + .init_features; + let required_funds_sats = amount_sats + + self.config.anchor_channels_config.as_ref().map_or(0, |c| { + if init_features.requires_anchors_zero_fee_htlc_tx() + && !c.trusted_peers_no_reserve.contains(peer_node_id) + { + c.per_channel_reserve_sats + } else { + 0 + } + }); + + if spendable_amount_sats < required_funds_sats { + log_error!(self.logger, + "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", + spendable_amount_sats, required_funds_sats + ); + return Err(Error::InsufficientFunds); + } + + Ok(()) + } + /// Connect to a node and open a new unannounced channel. /// /// To open an announced channel, see [`Node::open_announced_channel`]. From 118df9fbf955e0927c55897430b4901a90e8a012 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 22 Sep 2025 12:26:18 -0500 Subject: [PATCH 08/23] Add Node::splice_in method Instead of closing and re-opening a channel when outbound liquidity is exhausted, splicing allows to adding more funds (splice-in) while keeping the channel operational. This commit implements splice-in using funds from the BDK on-chain wallet. --- src/error.rs | 3 ++ src/lib.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++++- src/wallet/mod.rs | 53 ++++++++++++++++++++++++-- 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/error.rs b/src/error.rs index 7e9dbac20..20b1cceab 100644 --- a/src/error.rs +++ b/src/error.rs @@ -43,6 +43,8 @@ pub enum Error { ChannelCreationFailed, /// A channel could not be closed. ChannelClosingFailed, + /// A channel could not be spliced. + ChannelSplicingFailed, /// A channel configuration could not be updated. ChannelConfigUpdateFailed, /// Persistence failed. @@ -145,6 +147,7 @@ impl fmt::Display for Error { Self::ProbeSendingFailed => write!(f, "Failed to send the given payment probe."), Self::ChannelCreationFailed => write!(f, "Failed to create channel."), Self::ChannelClosingFailed => write!(f, "Failed to close channel."), + Self::ChannelSplicingFailed => write!(f, "Failed to splice channel."), Self::ChannelConfigUpdateFailed => write!(f, "Failed to update channel config."), Self::PersistenceFailed => write!(f, "Failed to persist data."), Self::FeerateEstimationUpdateFailed => { diff --git a/src/lib.rs b/src/lib.rs index aea191542..8e62b36ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -109,6 +109,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; pub use balance::{BalanceDetails, LightningBalance, PendingSweepBalance}; use bitcoin::secp256k1::PublicKey; +use bitcoin::Amount; #[cfg(feature = "uniffi")] pub use builder::ArcedNodeBuilder as Builder; pub use builder::BuildError; @@ -131,10 +132,11 @@ use graph::NetworkGraph; pub use io::utils::generate_entropy_mnemonic; use io::utils::write_node_metrics; use lightning::chain::BestBlock; -use lightning::events::bump_transaction::Wallet as LdkWallet; +use lightning::events::bump_transaction::{Input, Wallet as LdkWallet}; use lightning::impl_writeable_tlv_based; use lightning::ln::channel_state::ChannelShutdownState; use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::SpliceContribution; use lightning::ln::msgs::SocketAddress; use lightning::routing::gossip::NodeAlias; use lightning::util::persist::KVStoreSync; @@ -1223,6 +1225,96 @@ impl Node { ) } + /// Add funds from the on-chain wallet into an existing channel. + /// + /// This provides for increasing a channel's outbound liquidity without re-balancing or closing + /// it. Once negotiation with the counterparty is complete, the channel remains operational + /// while waiting for a new funding transaction to confirm. + pub fn splice_in( + &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, + splice_amount_sats: u64, + ) -> Result<(), Error> { + let open_channels = + self.channel_manager.list_channels_with_counterparty(&counterparty_node_id); + if let Some(channel_details) = + open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) + { + self.check_sufficient_funds_for_channel(splice_amount_sats, &counterparty_node_id)?; + + const EMPTY_SCRIPT_SIG_WEIGHT: u64 = + 1 /* empty script_sig */ * bitcoin::constants::WITNESS_SCALE_FACTOR as u64; + let funding_txo = channel_details.funding_txo.ok_or_else(|| { + log_error!(self.logger, "Failed to splice channel: channel not yet ready",); + Error::ChannelSplicingFailed + })?; + let shared_input = Input { + outpoint: funding_txo.into_bitcoin_outpoint(), + previous_utxo: bitcoin::TxOut { + value: Amount::from_sat(channel_details.channel_value_satoshis), + script_pubkey: lightning::ln::chan_utils::make_funding_redeemscript( + &PublicKey::from_slice(&[2; 33]).unwrap(), + &PublicKey::from_slice(&[2; 33]).unwrap(), + ) + .to_p2wsh(), + }, + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + + lightning::ln::chan_utils::FUNDING_TRANSACTION_WITNESS_WEIGHT, + }; + + let shared_output = bitcoin::TxOut { + value: shared_input.previous_utxo.value + Amount::from_sat(splice_amount_sats), + script_pubkey: lightning::ln::chan_utils::make_funding_redeemscript( + &PublicKey::from_slice(&[2; 33]).unwrap(), + &PublicKey::from_slice(&[2; 33]).unwrap(), + ) + .to_p2wsh(), + }; + + let fee_rate = self.wallet.estimate_channel_funding_fee_rate(); + + let inputs = self + .wallet + .select_confirmed_utxos(vec![shared_input], &[shared_output], fee_rate) + .map_err(|()| { + log_error!( + self.logger, + "Failed to splice channel: insufficient confirmed UTXOs", + ); + Error::ChannelSplicingFailed + })?; + + let contribution = SpliceContribution::SpliceIn { + value: Amount::from_sat(splice_amount_sats), + inputs, + change_script: None, + }; + + let funding_feerate_per_kw = fee_rate.to_sat_per_kwu().try_into().unwrap_or(u32::MAX); + + self.channel_manager + .splice_channel( + &channel_details.channel_id, + &counterparty_node_id, + contribution, + funding_feerate_per_kw, + None, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to splice channel: {:?}", e); + Error::ChannelSplicingFailed + }) + } else { + log_error!( + self.logger, + "Channel not found for user_channel_id: {:?} and counterparty: {}", + user_channel_id, + counterparty_node_id + ); + + Err(Error::ChannelSplicingFailed) + } + } + /// Manually sync the LDK and BDK wallets with the current chain state and update the fee rate /// cache. /// diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d2e729cdb..3cb127259 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -6,6 +6,7 @@ // accordance with one or both of these licenses. use std::future::Future; +use std::ops::Deref; use std::pin::Pin; use std::str::FromStr; use std::sync::{Arc, Mutex}; @@ -19,19 +20,20 @@ use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::hashes::Hash; use bitcoin::key::XOnlyPublicKey; -use bitcoin::psbt::Psbt; +use bitcoin::psbt::{self, Psbt}; use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; use bitcoin::secp256k1::{All, PublicKey, Scalar, Secp256k1, SecretKey}; use bitcoin::{ - Address, Amount, FeeRate, Network, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, + Address, Amount, FeeRate, Network, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::BroadcasterInterface; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::{BestBlock, Listen}; -use lightning::events::bump_transaction::{Utxo, WalletSource}; +use lightning::events::bump_transaction::{Input, Utxo, WalletSource}; use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::FundingTxInput; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; use lightning::ln::script::ShutdownScript; @@ -273,6 +275,10 @@ impl Wallet { Ok(tx) } + pub(crate) fn estimate_channel_funding_fee_rate(&self) -> FeeRate { + self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding) + } + pub(crate) fn get_new_address(&self) -> Result { let mut locked_wallet = self.inner.lock().unwrap(); let mut locked_persister = self.persister.lock().unwrap(); @@ -559,6 +565,47 @@ impl Wallet { Ok(txid) } + pub(crate) fn select_confirmed_utxos( + &self, must_spend: Vec, must_pay_to: &[TxOut], fee_rate: FeeRate, + ) -> Result, ()> { + let mut locked_wallet = self.inner.lock().unwrap(); + let mut tx_builder = locked_wallet.build_tx(); + tx_builder.only_witness_utxo(); + + for input in &must_spend { + let psbt_input = psbt::Input { + witness_utxo: Some(input.previous_utxo.clone()), + ..Default::default() + }; + let weight = Weight::from_wu(input.satisfaction_weight); + tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|_| ())?; + } + + for output in must_pay_to { + tx_builder.add_recipient(output.script_pubkey.clone(), output.value); + } + + tx_builder.fee_rate(fee_rate); + tx_builder.exclude_unconfirmed(); + + tx_builder + .finish() + .map_err(|e| { + log_error!(self.logger, "Failed to select confirmed UTXOs: {}", e); + })? + .unsigned_tx + .input + .iter() + .filter(|txin| must_spend.iter().all(|input| input.outpoint != txin.previous_output)) + .filter_map(|txin| { + locked_wallet + .tx_details(txin.previous_output.txid) + .map(|tx_details| tx_details.tx.deref().clone()) + .map(|prevtx| FundingTxInput::new_p2wpkh(prevtx, txin.previous_output.vout)) + }) + .collect::, ()>>() + } + fn list_confirmed_utxos_inner(&self) -> Result, ()> { let locked_wallet = self.inner.lock().unwrap(); let mut utxos = Vec::new(); From 52627740146b8e2df290e807ebb0f313d9df1a04 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 28 Oct 2025 16:42:25 -0500 Subject: [PATCH 09/23] f - import --- src/lib.rs | 8 ++++---- src/wallet/mod.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8e62b36ce..e9247ecf1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -134,6 +134,7 @@ use io::utils::write_node_metrics; use lightning::chain::BestBlock; use lightning::events::bump_transaction::{Input, Wallet as LdkWallet}; use lightning::impl_writeable_tlv_based; +use lightning::ln::chan_utils::{make_funding_redeemscript, FUNDING_TRANSACTION_WITNESS_WEIGHT}; use lightning::ln::channel_state::ChannelShutdownState; use lightning::ln::channelmanager::PaymentId; use lightning::ln::funding::SpliceContribution; @@ -1251,19 +1252,18 @@ impl Node { outpoint: funding_txo.into_bitcoin_outpoint(), previous_utxo: bitcoin::TxOut { value: Amount::from_sat(channel_details.channel_value_satoshis), - script_pubkey: lightning::ln::chan_utils::make_funding_redeemscript( + script_pubkey: make_funding_redeemscript( &PublicKey::from_slice(&[2; 33]).unwrap(), &PublicKey::from_slice(&[2; 33]).unwrap(), ) .to_p2wsh(), }, - satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT - + lightning::ln::chan_utils::FUNDING_TRANSACTION_WITNESS_WEIGHT, + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, }; let shared_output = bitcoin::TxOut { value: shared_input.previous_utxo.value + Amount::from_sat(splice_amount_sats), - script_pubkey: lightning::ln::chan_utils::make_funding_redeemscript( + script_pubkey: make_funding_redeemscript( &PublicKey::from_slice(&[2; 33]).unwrap(), &PublicKey::from_slice(&[2; 33]).unwrap(), ) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3cb127259..88c30df14 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -743,7 +743,7 @@ impl Wallet { Err(e) => { log_error!(self.logger, "Failed to extract transaction: {}", e); Err(()) - } + }, } } From e1be0adac08660a7f4d34b175f81757fcad76a51 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 28 Oct 2025 17:21:14 -0500 Subject: [PATCH 10/23] f - fee_estimator clean-up --- src/builder.rs | 1 + src/lib.rs | 12 ++++++++++-- src/wallet/mod.rs | 4 ---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index c0e39af7a..2f0aec527 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1775,6 +1775,7 @@ fn build_with_store_internal( wallet, chain_source, tx_broadcaster, + fee_estimator, event_queue, channel_manager, chain_monitor, diff --git a/src/lib.rs b/src/lib.rs index e9247ecf1..9b1cb92dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -125,6 +125,7 @@ pub use error::Error as NodeError; use error::Error; pub use event::Event; use event::{EventHandler, EventQueue}; +use fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; #[cfg(feature = "uniffi")] use ffi::*; use gossip::GossipSource; @@ -181,6 +182,7 @@ pub struct Node { wallet: Arc, chain_source: Arc, tx_broadcaster: Arc, + fee_estimator: Arc, event_queue: Arc>>, channel_manager: Arc, chain_monitor: Arc, @@ -1270,7 +1272,7 @@ impl Node { .to_p2wsh(), }; - let fee_rate = self.wallet.estimate_channel_funding_fee_rate(); + let fee_rate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); let inputs = self .wallet @@ -1289,7 +1291,13 @@ impl Node { change_script: None, }; - let funding_feerate_per_kw = fee_rate.to_sat_per_kwu().try_into().unwrap_or(u32::MAX); + let funding_feerate_per_kw: u32 = match fee_rate.to_sat_per_kwu().try_into() { + Ok(fee_rate) => fee_rate, + Err(_) => { + debug_assert!(false); + fee_estimator::get_fallback_rate_for_target(ConfirmationTarget::ChannelFunding) + }, + }; self.channel_manager .splice_channel( diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 88c30df14..1371d17a1 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -275,10 +275,6 @@ impl Wallet { Ok(tx) } - pub(crate) fn estimate_channel_funding_fee_rate(&self) -> FeeRate { - self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding) - } - pub(crate) fn get_new_address(&self) -> Result { let mut locked_wallet = self.inner.lock().unwrap(); let mut locked_persister = self.persister.lock().unwrap(); From 71f52080b1f45770c822d9d28d269009fbaa2680 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 28 Oct 2025 18:27:32 -0500 Subject: [PATCH 11/23] f - bindings --- bindings/ldk_node.udl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 6356b8aec..121e5a3fa 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -141,6 +141,8 @@ interface Node { [Throws=NodeError] UserChannelId open_announced_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, ChannelConfig? channel_config); [Throws=NodeError] + void splice_in([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, u64 splice_amount_sats); + [Throws=NodeError] void close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); [Throws=NodeError] void force_close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, string? reason); @@ -281,6 +283,7 @@ enum NodeError { "ProbeSendingFailed", "ChannelCreationFailed", "ChannelClosingFailed", + "ChannelSplicingFailed", "ChannelConfigUpdateFailed", "PersistenceFailed", "FeerateEstimationUpdateFailed", From e8887897bbbfb6dcc990217d02616e76aedd9063 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 6 Nov 2025 09:59:08 -0600 Subject: [PATCH 12/23] f - update docs --- src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 9b1cb92dd..dd0bd1069 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1233,6 +1233,11 @@ impl Node { /// This provides for increasing a channel's outbound liquidity without re-balancing or closing /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. + /// + /// # Experimental API + /// + /// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but + /// this classification may change in the future. pub fn splice_in( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, splice_amount_sats: u64, From 45b74b03d4a081152b5bd29c1dc951754753dc98 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 6 Nov 2025 10:35:07 -0600 Subject: [PATCH 13/23] f - debug_assert --- src/wallet/mod.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1371d17a1..9302fab55 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -12,6 +12,7 @@ use std::str::FromStr; use std::sync::{Arc, Mutex}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; +use bdk_wallet::descriptor::ExtendedDescriptor; #[allow(deprecated)] use bdk_wallet::SignOptions; use bdk_wallet::{Balance, KeychainKind, PersistedWallet, Update}; @@ -565,6 +566,15 @@ impl Wallet { &self, must_spend: Vec, must_pay_to: &[TxOut], fee_rate: FeeRate, ) -> Result, ()> { let mut locked_wallet = self.inner.lock().unwrap(); + debug_assert!(matches!( + locked_wallet.public_descriptor(KeychainKind::External), + ExtendedDescriptor::Wpkh(_) + )); + debug_assert!(matches!( + locked_wallet.public_descriptor(KeychainKind::Internal), + ExtendedDescriptor::Wpkh(_) + )); + let mut tx_builder = locked_wallet.build_tx(); tx_builder.only_witness_utxo(); From 4a5fa583c28fa9d17b90c2c8f1693cedc57281d1 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 23 Oct 2025 16:04:46 -0500 Subject: [PATCH 14/23] Add Node::splice_out method Instead of closing and re-opening a channel when on-chain funds are needed, splicing allows removing funds (splice-out) while keeping the channel operational. This commit implements splice-out sending funds to a user-provided on-chain address. --- src/lib.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++- src/wallet/mod.rs | 10 ++++----- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index dd0bd1069..5bce39b53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -109,7 +109,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; pub use balance::{BalanceDetails, LightningBalance, PendingSweepBalance}; use bitcoin::secp256k1::PublicKey; -use bitcoin::Amount; +use bitcoin::{Address, Amount}; #[cfg(feature = "uniffi")] pub use builder::ArcedNodeBuilder as Builder; pub use builder::BuildError; @@ -1328,6 +1328,59 @@ impl Node { } } + /// Remove funds from an existing channel, sending them to an on-chain address. + /// + /// This provides for decreasing a channel's outbound liquidity without re-balancing or closing + /// it. Once negotiation with the counterparty is complete, the channel remains operational + /// while waiting for a new funding transaction to confirm. + pub fn splice_out( + &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, address: Address, + splice_amount_sats: u64, + ) -> Result<(), Error> { + let open_channels = + self.channel_manager.list_channels_with_counterparty(&counterparty_node_id); + if let Some(channel_details) = + open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) + { + if splice_amount_sats > channel_details.outbound_capacity_msat { + return Err(Error::ChannelSplicingFailed); + } + + self.wallet.parse_and_validate_address(&address)?; + + let contribution = SpliceContribution::SpliceOut { + outputs: vec![bitcoin::TxOut { + value: Amount::from_sat(splice_amount_sats), + script_pubkey: address.script_pubkey(), + }], + }; + + let fee_rate = self.wallet.estimate_channel_funding_fee_rate(); + let funding_feerate_per_kw = fee_rate.to_sat_per_kwu().try_into().unwrap_or(u32::MAX); + + self.channel_manager + .splice_channel( + &channel_details.channel_id, + &counterparty_node_id, + contribution, + funding_feerate_per_kw, + None, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to splice channel: {:?}", e); + Error::ChannelSplicingFailed + }) + } else { + log_error!( + self.logger, + "Channel not found for user_channel_id: {:?} and counterparty: {}", + user_channel_id, + counterparty_node_id + ); + Err(Error::ChannelSplicingFailed) + } + } + /// Manually sync the LDK and BDK wallets with the current chain state and update the fee rate /// cache. /// diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 9302fab55..68f61be6e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -26,7 +26,7 @@ use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; use bitcoin::secp256k1::{All, PublicKey, Scalar, Secp256k1, SecretKey}; use bitcoin::{ - Address, Amount, FeeRate, Network, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, Weight, + Address, Amount, FeeRate, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::BroadcasterInterface; @@ -335,12 +335,10 @@ impl Wallet { self.get_balances(total_anchor_channels_reserve_sats).map(|(_, s)| s) } - fn parse_and_validate_address( - &self, network: Network, address: &Address, - ) -> Result { + pub(crate) fn parse_and_validate_address(&self, address: &Address) -> Result { Address::::from_str(address.to_string().as_str()) .map_err(|_| Error::InvalidAddress)? - .require_network(network) + .require_network(self.config.network) .map_err(|_| Error::InvalidAddress) } @@ -349,7 +347,7 @@ impl Wallet { &self, address: &bitcoin::Address, send_amount: OnchainSendAmount, fee_rate: Option, ) -> Result { - self.parse_and_validate_address(self.config.network, &address)?; + self.parse_and_validate_address(&address)?; // Use the set fee_rate or default to fee estimation. let confirmation_target = ConfirmationTarget::OnchainPayment; From 49692d9aaaa5da0a5af7870b5fab47a05df9a517 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 28 Oct 2025 17:39:57 -0500 Subject: [PATCH 15/23] f - fee_estimator clean-up --- src/lib.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 5bce39b53..82e5f3ef0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1355,8 +1355,14 @@ impl Node { }], }; - let fee_rate = self.wallet.estimate_channel_funding_fee_rate(); - let funding_feerate_per_kw = fee_rate.to_sat_per_kwu().try_into().unwrap_or(u32::MAX); + let fee_rate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); + let funding_feerate_per_kw: u32 = match fee_rate.to_sat_per_kwu().try_into() { + Ok(fee_rate) => fee_rate, + Err(_) => { + debug_assert!(false); + fee_estimator::get_fallback_rate_for_target(ConfirmationTarget::ChannelFunding) + }, + }; self.channel_manager .splice_channel( From 1ae1da552f1f04ffed04542ae5cbbc093d721d64 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 28 Oct 2025 18:42:20 -0500 Subject: [PATCH 16/23] f - bindings --- bindings/ldk_node.udl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 121e5a3fa..75874d2c3 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -143,6 +143,8 @@ interface Node { [Throws=NodeError] void splice_in([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, u64 splice_amount_sats); [Throws=NodeError] + void splice_out([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, Address address, u64 splice_amount_sats); + [Throws=NodeError] void close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); [Throws=NodeError] void force_close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, string? reason); From fdd4e4184c42ce73307459b3afb42a7b07b46b2f Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 6 Nov 2025 10:01:23 -0600 Subject: [PATCH 17/23] f - update docs --- src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 82e5f3ef0..d2ca5e229 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1333,6 +1333,12 @@ impl Node { /// This provides for decreasing a channel's outbound liquidity without re-balancing or closing /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. + /// + /// # Experimental API + /// + /// This API is experimental. Currently, a splice-out will be marked as an inbound payment if + /// paid to an address associated with the on-chain wallet, but this classification may change + /// in the future. pub fn splice_out( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, address: Address, splice_amount_sats: u64, From d7f6c1266a25fafa1aca994f35244e4e0968db7b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 6 Nov 2025 10:38:14 -0600 Subject: [PATCH 18/23] f - use reference --- bindings/ldk_node.udl | 2 +- src/lib.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 75874d2c3..5201207b6 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -143,7 +143,7 @@ interface Node { [Throws=NodeError] void splice_in([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, u64 splice_amount_sats); [Throws=NodeError] - void splice_out([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, Address address, u64 splice_amount_sats); + void splice_out([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, [ByRef]Address address, u64 splice_amount_sats); [Throws=NodeError] void close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); [Throws=NodeError] diff --git a/src/lib.rs b/src/lib.rs index d2ca5e229..26a44b04e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1340,7 +1340,7 @@ impl Node { /// paid to an address associated with the on-chain wallet, but this classification may change /// in the future. pub fn splice_out( - &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, address: Address, + &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, address: &Address, splice_amount_sats: u64, ) -> Result<(), Error> { let open_channels = @@ -1352,7 +1352,7 @@ impl Node { return Err(Error::ChannelSplicingFailed); } - self.wallet.parse_and_validate_address(&address)?; + self.wallet.parse_and_validate_address(address)?; let contribution = SpliceContribution::SpliceOut { outputs: vec![bitcoin::TxOut { From 2609eea100f7d775eebaa98f9405e78bad7ab96a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 29 Oct 2025 12:42:46 -0500 Subject: [PATCH 19/23] Accept inbound splice attempts Since LDK Node does not support downgrades, there's no need to have a Config parameter for accepting inbound splices. Instead, enable it by default. --- src/config.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config.rs b/src/config.rs index ce361c45a..510bcc875 100644 --- a/src/config.rs +++ b/src/config.rs @@ -325,6 +325,7 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { user_config.manually_accept_inbound_channels = true; user_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = config.anchor_channels_config.is_some(); + user_config.reject_inbound_splices = false; if may_announce_channel(config).is_err() { user_config.accept_forwards_to_priv_channels = false; From 09b17c9b940e4a1dc3ee87102a46a109f68803c7 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 23 Oct 2025 13:03:36 -0500 Subject: [PATCH 20/23] Add an integration test for splicing --- tests/integration_tests_rust.rs | 142 ++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index e2d4207cd..24ada05ea 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -925,6 +925,148 @@ async fn concurrent_connections_succeed() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_channel() { + macro_rules! expect_splice_pending_event { + ($node: expr, $counterparty_node_id: expr) => {{ + match $node.next_event_async().await { + ref e @ Event::SplicePending { new_funding_txo, counterparty_node_id, .. } => { + println!("{} got event {:?}", $node.node_id(), e); + assert_eq!(counterparty_node_id, $counterparty_node_id); + $node.event_handled().unwrap(); + new_funding_txo + }, + ref e => { + panic!("{} got unexpected event!: {:?}", std::stringify!($node), e); + }, + } + }}; + } + + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + assert_eq!(node_a.list_balances().total_onchain_balance_sats, premine_amount_sat); + assert_eq!(node_b.list_balances().total_onchain_balance_sats, premine_amount_sat); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + + // Open a channel with Node A contributing the funding + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + let opening_transaction_fee_sat = 156; + let closing_transaction_fee_sat = 614; + let anchor_output_sat = 330; + + assert_eq!( + node_a.list_balances().total_onchain_balance_sats, + premine_amount_sat - 4_000_000 - opening_transaction_fee_sat + ); + assert_eq!( + node_a.list_balances().total_lightning_balance_sats, + 4_000_000 - closing_transaction_fee_sat - anchor_output_sat + ); + assert_eq!(node_b.list_balances().total_lightning_balance_sats, 0); + + // Test that splicing and payments fail when there are insufficient funds + let address = node_b.onchain_payment().new_address().unwrap(); + let amount_msat = 400_000_000; + + assert_eq!( + node_b.splice_in(&user_channel_id_a, node_b.node_id(), 5_000_000), + Err(NodeError::ChannelSplicingFailed), + ); + assert_eq!( + node_b.splice_out(&user_channel_id_a, node_b.node_id(), &address, amount_msat / 1000), + Err(NodeError::ChannelSplicingFailed), + ); + assert_eq!( + node_b.spontaneous_payment().send(amount_msat, node_a.node_id(), None), + Err(NodeError::PaymentSendingFailed) + ); + + // Splice-in funds for Node B so that it has outbound liquidity to make a payment + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 4_000_000).unwrap(); + + expect_splice_pending_event!(node_a, node_b.node_id()); + expect_splice_pending_event!(node_b, node_a.node_id()); + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let splice_in_fee_sat = 253; + + assert_eq!( + node_b.list_balances().total_onchain_balance_sats, + premine_amount_sat - 4_000_000 - splice_in_fee_sat + ); + assert_eq!(node_b.list_balances().total_lightning_balance_sats, 4_000_000); + + let payment_id = + node_b.spontaneous_payment().send(amount_msat, node_a.node_id(), None).unwrap(); + + expect_payment_successful_event!(node_b, Some(payment_id), None); + expect_payment_received_event!(node_a, amount_msat); + + assert_eq!( + node_a.list_balances().total_lightning_balance_sats, + 4_000_000 - closing_transaction_fee_sat - anchor_output_sat + amount_msat / 1000 + ); + assert_eq!(node_b.list_balances().total_lightning_balance_sats, 4_000_000 - amount_msat / 1000); + + // Splice-out funds for Node A from the payment sent by Node B + let address = node_a.onchain_payment().new_address().unwrap(); + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, amount_msat / 1000).unwrap(); + + expect_splice_pending_event!(node_a, node_b.node_id()); + expect_splice_pending_event!(node_b, node_a.node_id()); + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let splice_out_fee_sat = 184; + + assert_eq!( + node_a.list_balances().total_onchain_balance_sats, + premine_amount_sat - 4_000_000 - opening_transaction_fee_sat + amount_msat / 1000 + ); + assert_eq!( + node_a.list_balances().total_lightning_balance_sats, + 4_000_000 - closing_transaction_fee_sat - anchor_output_sat - splice_out_fee_sat + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 68f0d4db87b6de93d7a277c942bec7486c5af437 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 7 Nov 2025 10:22:09 -0600 Subject: [PATCH 21/23] f - fix test flakiness? --- tests/integration_tests_rust.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 24ada05ea..64266bb8e 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1034,6 +1034,9 @@ async fn splice_channel() { expect_payment_successful_event!(node_b, Some(payment_id), None); expect_payment_received_event!(node_a, amount_msat); + // Mine a block to give time for the HTLC to resolve + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + assert_eq!( node_a.list_balances().total_lightning_balance_sats, 4_000_000 - closing_transaction_fee_sat - anchor_output_sat + amount_msat / 1000 From 4bfb26f117558e8264a5c31f98279bf37319c0b0 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 29 Oct 2025 17:18:33 -0500 Subject: [PATCH 22/23] Test splicing in do_channel_full_cycle --- tests/common/mod.rs | 75 ++++++++++++++++++++++++++++++--- tests/integration_tests_rust.rs | 24 ++--------- 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f023da680..5a211b620 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -99,6 +99,24 @@ macro_rules! expect_channel_ready_event { pub(crate) use expect_channel_ready_event; +macro_rules! expect_splice_pending_event { + ($node: expr, $counterparty_node_id: expr) => {{ + match $node.next_event_async().await { + ref e @ Event::SplicePending { new_funding_txo, counterparty_node_id, .. } => { + println!("{} got event {:?}", $node.node_id(), e); + assert_eq!(counterparty_node_id, $counterparty_node_id); + $node.event_handled().unwrap(); + new_funding_txo + }, + ref e => { + panic!("{} got unexpected event!: {:?}", std::stringify!($node), e); + }, + } + }}; +} + +pub(crate) use expect_splice_pending_event; + macro_rules! expect_payment_received_event { ($node:expr, $amount_msat:expr) => {{ match $node.next_event_async().await { @@ -795,8 +813,8 @@ pub(crate) async fn do_channel_full_cycle( node_b_anchor_reserve_sat ); - let user_channel_id = expect_channel_ready_event!(node_a, node_b.node_id()); - expect_channel_ready_event!(node_b, node_a.node_id()); + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); println!("\nB receive"); let invoice_amount_1_msat = 2500_000; @@ -1085,12 +1103,57 @@ pub(crate) async fn do_channel_full_cycle( 1 ); + println!("\nB splices out to pay A"); + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let splice_out_sat = funding_amount_sat / 2; + node_b.splice_out(&user_channel_id_b, node_a.node_id(), &addr_a, splice_out_sat).unwrap(); + + expect_splice_pending_event!(node_a, node_b.node_id()); + expect_splice_pending_event!(node_b, node_a.node_id()); + + generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + assert_eq!( + node_a + .list_payments_with_filter(|p| p.direction == PaymentDirection::Inbound + && matches!(p.kind, PaymentKind::Onchain { .. })) + .len(), + 2 + ); + + println!("\nA splices in the splice-out payment from B"); + let splice_in_sat = splice_out_sat; + node_a.splice_in(&user_channel_id_a, node_b.node_id(), splice_in_sat).unwrap(); + + expect_splice_pending_event!(node_a, node_b.node_id()); + expect_splice_pending_event!(node_b, node_a.node_id()); + + generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + assert_eq!( + node_a + .list_payments_with_filter(|p| p.direction == PaymentDirection::Outbound + && matches!(p.kind, PaymentKind::Onchain { .. })) + .len(), + 2 + ); + println!("\nB close_channel (force: {})", force_close); if force_close { tokio::time::sleep(Duration::from_secs(1)).await; - node_a.force_close_channel(&user_channel_id, node_b.node_id(), None).unwrap(); + node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); } else { - node_a.close_channel(&user_channel_id, node_b.node_id()).unwrap(); + node_a.close_channel(&user_channel_id_a, node_b.node_id()).unwrap(); } expect_event!(node_a, ChannelClosed); @@ -1189,7 +1252,7 @@ pub(crate) async fn do_channel_full_cycle( + invoice_amount_3_msat + determined_amount_msat + keysend_amount_msat) - / 1000; + / 1000 - splice_out_sat; let node_a_upper_bound_sat = (premine_amount_sat - funding_amount_sat) + (funding_amount_sat - sum_of_all_payments_sat); let node_a_lower_bound_sat = node_a_upper_bound_sat - onchain_fee_buffer_sat; @@ -1210,7 +1273,7 @@ pub(crate) async fn do_channel_full_cycle( .list_payments_with_filter(|p| p.direction == PaymentDirection::Inbound && matches!(p.kind, PaymentKind::Onchain { .. })) .len(), - 2 + 3 ); assert_eq!( node_b diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 64266bb8e..ec45b5de1 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -20,10 +20,10 @@ use common::{ bump_fee_and_broadcast, distribute_funds_unconfirmed, do_channel_full_cycle, expect_channel_pending_event, expect_channel_ready_event, expect_event, expect_payment_claimable_event, expect_payment_received_event, expect_payment_successful_event, - generate_blocks_and_wait, open_channel, open_channel_push_amt, premine_and_distribute_funds, - premine_blocks, prepare_rbf, random_config, random_listening_addresses, - setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_node_for_async_payments, - setup_two_nodes, wait_for_tx, TestChainSource, TestSyncStore, + expect_splice_pending_event, generate_blocks_and_wait, open_channel, open_channel_push_amt, + premine_and_distribute_funds, premine_blocks, prepare_rbf, random_config, + random_listening_addresses, setup_bitcoind_and_electrsd, setup_builder, setup_node, + setup_node_for_async_payments, setup_two_nodes, wait_for_tx, TestChainSource, TestSyncStore, }; use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig}; use ldk_node::liquidity::LSPS2ServiceConfig; @@ -927,22 +927,6 @@ async fn concurrent_connections_succeed() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn splice_channel() { - macro_rules! expect_splice_pending_event { - ($node: expr, $counterparty_node_id: expr) => {{ - match $node.next_event_async().await { - ref e @ Event::SplicePending { new_funding_txo, counterparty_node_id, .. } => { - println!("{} got event {:?}", $node.node_id(), e); - assert_eq!(counterparty_node_id, $counterparty_node_id); - $node.event_handled().unwrap(); - new_funding_txo - }, - ref e => { - panic!("{} got unexpected event!: {:?}", std::stringify!($node), e); - }, - } - }}; - } - let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = TestChainSource::Esplora(&electrsd); let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); From 913b96849f4aadac625908fe2fe80678db329fa6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 7 Nov 2025 10:25:40 -0600 Subject: [PATCH 23/23] f - fix test flakiness? --- tests/common/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 5a211b620..699f8f1d0 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1103,6 +1103,9 @@ pub(crate) async fn do_channel_full_cycle( 1 ); + // Mine a block to give time for the HTLC to resolve + generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + println!("\nB splices out to pay A"); let addr_a = node_a.onchain_payment().new_address().unwrap(); let splice_out_sat = funding_amount_sat / 2;