From 9bd2144de124c0e9a19377d968a9193ddf78d4bd Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 28 Jul 2025 12:15:48 -0500 Subject: [PATCH 01/11] Check splice contributions against SignedAmount::MAX_MONEY Splice contributions should never exceed the total bitcoin supply. This check prevents a potential overflow when converting the contribution from sats to msats. The commit additionally begins to store the contribution using SignedAmount. --- lightning/src/ln/channel.rs | 119 +++++++++++++++++++---------- lightning/src/ln/channelmanager.rs | 4 +- lightning/src/ln/interactivetxs.rs | 19 ++--- 3 files changed, 91 insertions(+), 51 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 56d38d5545c..4d675f9224c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -8,7 +8,7 @@ // licenses. use bitcoin::absolute::LockTime; -use bitcoin::amount::Amount; +use bitcoin::amount::{Amount, SignedAmount}; use bitcoin::consensus::encode; use bitcoin::constants::ChainHash; use bitcoin::script::{Builder, Script, ScriptBuf, WScriptHash}; @@ -2244,20 +2244,23 @@ impl FundingScope { /// Constructs a `FundingScope` for splicing a channel. #[cfg(splicing)] fn for_splice( - prev_funding: &Self, context: &ChannelContext, our_funding_contribution_sats: i64, - their_funding_contribution_sats: i64, counterparty_funding_pubkey: PublicKey, + prev_funding: &Self, context: &ChannelContext, our_funding_contribution: SignedAmount, + their_funding_contribution: SignedAmount, counterparty_funding_pubkey: PublicKey, ) -> Result where SP::Target: SignerProvider, { + debug_assert!(our_funding_contribution.abs() <= SignedAmount::MAX_MONEY); + debug_assert!(their_funding_contribution.abs() <= SignedAmount::MAX_MONEY); + let post_channel_value = prev_funding.compute_post_splice_value( - our_funding_contribution_sats, - their_funding_contribution_sats, + our_funding_contribution.to_sat(), + their_funding_contribution.to_sat(), ); let post_value_to_self_msat = AddSigned::checked_add_signed( prev_funding.value_to_self_msat, - our_funding_contribution_sats * 1000, + our_funding_contribution.to_sat() * 1000, ); debug_assert!(post_value_to_self_msat.is_some()); let post_value_to_self_msat = post_value_to_self_msat.unwrap(); @@ -5964,7 +5967,7 @@ pub(super) struct FundingNegotiationContext { /// Whether we initiated the funding negotiation. pub is_initiator: bool, /// The amount in satoshis we will be contributing to the channel. - pub our_funding_contribution_satoshis: i64, + pub our_funding_contribution: SignedAmount, /// The amount in satoshis our counterparty will be contributing to the channel. #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. pub their_funding_contribution_satoshis: Option, @@ -6020,7 +6023,7 @@ impl FundingNegotiationContext { }; // Optionally add change output - if self.our_funding_contribution_satoshis > 0 { + if self.our_funding_contribution > SignedAmount::ZERO { let change_value_opt = calculate_change_output_value( &self, self.shared_funding_input.is_some(), @@ -10628,11 +10631,22 @@ where // TODO(splicing): check for quiescence - if our_funding_contribution_satoshis < 0 { + let our_funding_contribution = SignedAmount::from_sat(our_funding_contribution_satoshis); + if our_funding_contribution > SignedAmount::MAX_MONEY { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced; contribution exceeds total bitcoin supply: {}", + self.context.channel_id(), + our_funding_contribution, + ), + }); + } + + if our_funding_contribution < SignedAmount::ZERO { return Err(APIError::APIMisuseError { err: format!( "TODO(splicing): Splice-out not supported, only splice in; channel ID {}, contribution {}", - self.context.channel_id(), our_funding_contribution_satoshis, + self.context.channel_id(), our_funding_contribution, ), }); } @@ -10645,7 +10659,7 @@ where // Check that inputs are sufficient to cover our contribution. let _fee = check_v2_funding_inputs_sufficient( - our_funding_contribution_satoshis, + our_funding_contribution.to_sat(), &our_funding_inputs, true, true, @@ -10669,7 +10683,7 @@ where let prev_funding_input = self.funding.to_splice_funding_input(); let funding_negotiation_context = FundingNegotiationContext { is_initiator: true, - our_funding_contribution_satoshis, + our_funding_contribution, their_funding_contribution_satoshis: None, funding_tx_locktime: LockTime::from_consensus(locktime), funding_feerate_sat_per_1000_weight: funding_feerate_per_kw, @@ -10690,7 +10704,7 @@ where Ok(msgs::SpliceInit { channel_id: self.context.channel_id, - funding_contribution_satoshis: our_funding_contribution_satoshis, + funding_contribution_satoshis: our_funding_contribution.to_sat(), funding_feerate_per_kw, locktime, funding_pubkey, @@ -10701,10 +10715,8 @@ where /// Checks during handling splice_init #[cfg(splicing)] pub fn validate_splice_init( - &self, msg: &msgs::SpliceInit, our_funding_contribution_satoshis: i64, + &self, msg: &msgs::SpliceInit, our_funding_contribution: SignedAmount, ) -> Result { - let their_funding_contribution_satoshis = msg.funding_contribution_satoshis; - // TODO(splicing): Add check that we are the quiescence acceptor // Check if a splice has been initiated already. @@ -10724,21 +10736,40 @@ where ))); } - if their_funding_contribution_satoshis.saturating_add(our_funding_contribution_satoshis) < 0 - { + // TODO(splicing): Move this check once user-provided contributions are supported for + // counterparty-initiated splices. + if our_funding_contribution > SignedAmount::MAX_MONEY { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} cannot be spliced; our contribution exceeds total bitcoin supply: {}", + self.context.channel_id(), + our_funding_contribution, + ))); + } + + let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); + if their_funding_contribution > SignedAmount::MAX_MONEY { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} cannot be spliced; their contribution exceeds total bitcoin supply: {}", + self.context.channel_id(), + their_funding_contribution, + ))); + } + + debug_assert_eq!(our_funding_contribution, SignedAmount::ZERO); + if their_funding_contribution < SignedAmount::ZERO { return Err(ChannelError::WarnAndDisconnect(format!( "Splice-out not supported, only splice in, contribution is {} ({} + {})", - their_funding_contribution_satoshis + our_funding_contribution_satoshis, - their_funding_contribution_satoshis, - our_funding_contribution_satoshis, + their_funding_contribution + our_funding_contribution, + their_funding_contribution, + our_funding_contribution, ))); } let splice_funding = FundingScope::for_splice( &self.funding, &self.context, - our_funding_contribution_satoshis, - their_funding_contribution_satoshis, + our_funding_contribution, + their_funding_contribution, msg.funding_pubkey, )?; @@ -10763,7 +10794,8 @@ where ES::Target: EntropySource, L::Target: Logger, { - let splice_funding = self.validate_splice_init(msg, our_funding_contribution_satoshis)?; + let our_funding_contribution = SignedAmount::from_sat(our_funding_contribution_satoshis); + let splice_funding = self.validate_splice_init(msg, our_funding_contribution)?; log_info!( logger, @@ -10777,7 +10809,7 @@ where let prev_funding_input = self.funding.to_splice_funding_input(); let funding_negotiation_context = FundingNegotiationContext { is_initiator: false, - our_funding_contribution_satoshis, + our_funding_contribution, their_funding_contribution_satoshis: Some(their_funding_contribution_satoshis), funding_tx_locktime: LockTime::from_consensus(msg.locktime), funding_feerate_sat_per_1000_weight: msg.funding_feerate_per_kw, @@ -10815,7 +10847,7 @@ where Ok(msgs::SpliceAck { channel_id: self.context.channel_id, - funding_contribution_satoshis: our_funding_contribution_satoshis, + funding_contribution_satoshis: our_funding_contribution.to_sat(), funding_pubkey, require_confirmed_inputs: None, }) @@ -10862,15 +10894,23 @@ where }, }; - let our_funding_contribution_satoshis = - funding_negotiation_context.our_funding_contribution_satoshis; - let their_funding_contribution_satoshis = msg.funding_contribution_satoshis; + let our_funding_contribution = funding_negotiation_context.our_funding_contribution; + debug_assert!(our_funding_contribution <= SignedAmount::MAX_MONEY); + + let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); + if their_funding_contribution > SignedAmount::MAX_MONEY { + return Err(ChannelError::Warn(format!( + "Channel {} cannot be spliced; their contribution exceeds total bitcoin supply: {}", + self.context.channel_id(), + their_funding_contribution, + ))); + } let splice_funding = FundingScope::for_splice( &self.funding, &self.context, - our_funding_contribution_satoshis, - their_funding_contribution_satoshis, + our_funding_contribution, + their_funding_contribution, msg.funding_pubkey, )?; @@ -12468,7 +12508,7 @@ where }; let funding_negotiation_context = FundingNegotiationContext { is_initiator: true, - our_funding_contribution_satoshis: funding_satoshis as i64, + our_funding_contribution: SignedAmount::from_sat(funding_satoshis as i64), // TODO(dual_funding) TODO(splicing) Include counterparty contribution, once that's enabled their_funding_contribution_satoshis: None, funding_tx_locktime, @@ -12578,10 +12618,11 @@ where L::Target: Logger, { // TODO(dual_funding): Take these as input once supported - let our_funding_satoshis = 0u64; + let (our_funding_contribution, our_funding_contribution_sats) = (SignedAmount::ZERO, 0u64); let our_funding_inputs = Vec::new(); - let channel_value_satoshis = our_funding_satoshis.saturating_add(msg.common_fields.funding_satoshis); + let channel_value_satoshis = + our_funding_contribution_sats.saturating_add(msg.common_fields.funding_satoshis); let counterparty_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( channel_value_satoshis, msg.common_fields.dust_limit_satoshis); let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( @@ -12608,9 +12649,7 @@ where current_chain_height, logger, false, - - our_funding_satoshis, - + our_funding_contribution_sats, counterparty_pubkeys, channel_type, holder_selected_channel_reserve_satoshis, @@ -12625,7 +12664,7 @@ where let funding_negotiation_context = FundingNegotiationContext { is_initiator: false, - our_funding_contribution_satoshis: our_funding_satoshis as i64, + our_funding_contribution, their_funding_contribution_satoshis: Some(msg.common_fields.funding_satoshis as i64), funding_tx_locktime: LockTime::from_consensus(msg.locktime), funding_feerate_sat_per_1000_weight: msg.funding_feerate_sat_per_1000_weight, @@ -12649,7 +12688,7 @@ where is_initiator: false, inputs_to_contribute: our_funding_inputs, shared_funding_input: None, - shared_funding_output: SharedOwnedOutput::new(shared_funding_output, our_funding_satoshis), + shared_funding_output: SharedOwnedOutput::new(shared_funding_output, our_funding_contribution_sats), outputs_to_contribute: Vec::new(), } ).map_err(|err| { @@ -12730,7 +12769,7 @@ where }), channel_type: Some(self.funding.get_channel_type().clone()), }, - funding_satoshis: self.funding_negotiation_context.our_funding_contribution_satoshis + funding_satoshis: self.funding_negotiation_context.our_funding_contribution.to_sat() as u64, second_per_commitment_point, require_confirmed_inputs: None, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9d1c6292826..724fc2e64bc 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -30,7 +30,7 @@ use bitcoin::hashes::{Hash, HashEngine, HmacEngine}; use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; -use bitcoin::{secp256k1, Sequence}; +use bitcoin::{secp256k1, Sequence, SignedAmount}; #[cfg(splicing)] use bitcoin::{ScriptBuf, TxIn, Weight}; @@ -9401,7 +9401,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // Inbound V2 channels with contributed inputs are not considered unfunded. if let Some(unfunded_chan) = chan.as_unfunded_v2() { - if unfunded_chan.funding_negotiation_context.our_funding_contribution_satoshis > 0 { + if unfunded_chan.funding_negotiation_context.our_funding_contribution > SignedAmount::ZERO { continue; } } diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index b3b3d6df112..c4d2ed9389b 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -11,7 +11,7 @@ use crate::io_extras::sink; use crate::prelude::*; use bitcoin::absolute::LockTime as AbsoluteLockTime; -use bitcoin::amount::Amount; +use bitcoin::amount::{Amount, SignedAmount}; use bitcoin::consensus::Encodable; use bitcoin::constants::WITNESS_SCALE_FACTOR; use bitcoin::key::Secp256k1; @@ -2077,8 +2077,8 @@ pub(super) fn calculate_change_output_value( context: &FundingNegotiationContext, is_splice: bool, shared_output_funding_script: &ScriptBuf, funding_outputs: &Vec, change_output_dust_limit: u64, ) -> Result, AbortReason> { - assert!(context.our_funding_contribution_satoshis > 0); - let our_funding_contribution_satoshis = context.our_funding_contribution_satoshis as u64; + assert!(context.our_funding_contribution > SignedAmount::ZERO); + let our_funding_contribution_satoshis = context.our_funding_contribution.to_sat() as u64; let mut total_input_satoshis = 0u64; let mut our_funding_inputs_weight = 0u64; @@ -2156,7 +2156,8 @@ mod tests { use bitcoin::transaction::Version; use bitcoin::{opcodes, WScriptHash, Weight, XOnlyPublicKey}; use bitcoin::{ - OutPoint, PubkeyHash, ScriptBuf, Sequence, Transaction, TxIn, TxOut, WPubkeyHash, Witness, + OutPoint, PubkeyHash, ScriptBuf, Sequence, SignedAmount, Transaction, TxIn, TxOut, + WPubkeyHash, Witness, }; use core::ops::Deref; @@ -3186,7 +3187,7 @@ mod tests { // There is leftover for change let context = FundingNegotiationContext { is_initiator: true, - our_funding_contribution_satoshis: our_contributed as i64, + our_funding_contribution: SignedAmount::from_sat(our_contributed as i64), their_funding_contribution_satoshis: None, funding_tx_locktime: AbsoluteLockTime::ZERO, funding_feerate_sat_per_1000_weight, @@ -3209,7 +3210,7 @@ mod tests { // Insufficient inputs, no leftover let context = FundingNegotiationContext { is_initiator: false, - our_funding_contribution_satoshis: 130_000, + our_funding_contribution: SignedAmount::from_sat(130_000), ..context }; assert_eq!( @@ -3220,7 +3221,7 @@ mod tests { // Very small leftover let context = FundingNegotiationContext { is_initiator: false, - our_funding_contribution_satoshis: 118_000, + our_funding_contribution: SignedAmount::from_sat(118_000), ..context }; assert_eq!( @@ -3231,7 +3232,7 @@ mod tests { // Small leftover, but not dust let context = FundingNegotiationContext { is_initiator: false, - our_funding_contribution_satoshis: 117_992, + our_funding_contribution: SignedAmount::from_sat(117_992), ..context }; assert_eq!( @@ -3242,7 +3243,7 @@ mod tests { // Larger fee, smaller change let context = FundingNegotiationContext { is_initiator: true, - our_funding_contribution_satoshis: our_contributed as i64, + our_funding_contribution: SignedAmount::from_sat(our_contributed as i64), funding_feerate_sat_per_1000_weight: funding_feerate_sat_per_1000_weight * 3, ..context }; From c9ddcbfc011db9f60981a01fac1798f93535c90d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 30 Jul 2025 11:07:02 -0500 Subject: [PATCH 02/11] Remove their_funding_contribution_satoshis from FundingNegotiationContext Once the counterparty supplies their funding contribution, there is no longer a need to store it in FundingNegotiationContext as it will have already been used to create a FundingScope. --- lightning/src/ln/channel.rs | 9 --------- lightning/src/ln/interactivetxs.rs | 1 - 2 files changed, 10 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 4d675f9224c..1d6dae7fa52 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -5968,9 +5968,6 @@ pub(super) struct FundingNegotiationContext { pub is_initiator: bool, /// The amount in satoshis we will be contributing to the channel. pub our_funding_contribution: SignedAmount, - /// The amount in satoshis our counterparty will be contributing to the channel. - #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. - pub their_funding_contribution_satoshis: Option, /// The funding transaction locktime suggested by the initiator. If set by us, it is always set /// to the current block height to align incentives against fee-sniping. pub funding_tx_locktime: LockTime, @@ -10684,7 +10681,6 @@ where let funding_negotiation_context = FundingNegotiationContext { is_initiator: true, our_funding_contribution, - their_funding_contribution_satoshis: None, funding_tx_locktime: LockTime::from_consensus(locktime), funding_feerate_sat_per_1000_weight: funding_feerate_per_kw, shared_funding_input: Some(prev_funding_input), @@ -10805,12 +10801,10 @@ where self.funding.get_value_satoshis(), ); - let their_funding_contribution_satoshis = msg.funding_contribution_satoshis; let prev_funding_input = self.funding.to_splice_funding_input(); let funding_negotiation_context = FundingNegotiationContext { is_initiator: false, our_funding_contribution, - their_funding_contribution_satoshis: Some(their_funding_contribution_satoshis), funding_tx_locktime: LockTime::from_consensus(msg.locktime), funding_feerate_sat_per_1000_weight: msg.funding_feerate_per_kw, shared_funding_input: Some(prev_funding_input), @@ -12509,8 +12503,6 @@ where let funding_negotiation_context = FundingNegotiationContext { is_initiator: true, our_funding_contribution: SignedAmount::from_sat(funding_satoshis as i64), - // TODO(dual_funding) TODO(splicing) Include counterparty contribution, once that's enabled - their_funding_contribution_satoshis: None, funding_tx_locktime, funding_feerate_sat_per_1000_weight, shared_funding_input: None, @@ -12665,7 +12657,6 @@ where let funding_negotiation_context = FundingNegotiationContext { is_initiator: false, our_funding_contribution, - their_funding_contribution_satoshis: Some(msg.common_fields.funding_satoshis as i64), funding_tx_locktime: LockTime::from_consensus(msg.locktime), funding_feerate_sat_per_1000_weight: msg.funding_feerate_sat_per_1000_weight, shared_funding_input: None, diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index c4d2ed9389b..9853528fbf2 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -3188,7 +3188,6 @@ mod tests { let context = FundingNegotiationContext { is_initiator: true, our_funding_contribution: SignedAmount::from_sat(our_contributed as i64), - their_funding_contribution_satoshis: None, funding_tx_locktime: AbsoluteLockTime::ZERO, funding_feerate_sat_per_1000_weight, shared_funding_input: None, From 70929ae8688af21fe97843e926922e9532d9c52b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 30 Jul 2025 21:20:34 -0500 Subject: [PATCH 03/11] Remove TransactionU16LenLimited TransactionU16LenLimited was used to limit Transaction serialization size to u16::MAX. This was because messages can not be longer than u16::MAX bytes when serialized for the transport layer. However, this limit doesn't take into account other fields in a message containing a Transaction, including the length of the transaction itself. Remove TransactionU16LenLimited and instead check any user supplied transactions in the context of the enclosing message (e.g. TxAddInput). --- lightning/src/ln/channel.rs | 34 +++++++++--- lightning/src/ln/dual_funding_tests.rs | 5 +- lightning/src/ln/interactivetxs.rs | 58 ++++++++------------ lightning/src/ln/msgs.rs | 76 +++++++++++++++++++------- lightning/src/ln/splicing_tests.rs | 4 +- lightning/src/util/ser.rs | 57 ------------------- 6 files changed, 110 insertions(+), 124 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 1d6dae7fa52..60f35b94f85 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -72,6 +72,8 @@ use crate::ln::onion_utils::{ }; use crate::ln::script::{self, ShutdownScript}; use crate::ln::types::ChannelId; +#[cfg(splicing)] +use crate::ln::LN_MAX_MSG_LEN; use crate::routing::gossip::NodeId; use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::tx_builder::{SpecTxBuilder, TxBuilder}; @@ -85,9 +87,7 @@ use crate::util::config::{ use crate::util::errors::APIError; use crate::util::logger::{Logger, Record, WithContext}; use crate::util::scid_utils::{block_from_scid, scid_from_parts}; -use crate::util::ser::{ - Readable, ReadableArgs, RequiredWrapper, TransactionU16LenLimited, Writeable, Writer, -}; +use crate::util::ser::{Readable, ReadableArgs, RequiredWrapper, Writeable, Writer}; use alloc::collections::{btree_map, BTreeMap}; @@ -5979,7 +5979,7 @@ pub(super) struct FundingNegotiationContext { pub shared_funding_input: Option, /// The funding inputs we will be contributing to the channel. #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. - pub our_funding_inputs: Vec<(TxIn, TransactionU16LenLimited)>, + pub our_funding_inputs: Vec<(TxIn, Transaction)>, /// The change output script. This will be used if needed or -- if not set -- generated using /// `SignerProvider::get_destination_script`. #[allow(dead_code)] // TODO(splicing): Remove once splicing is enabled. @@ -10671,10 +10671,26 @@ where })?; // Convert inputs let mut funding_inputs = Vec::new(); - for (tx_in, tx, _w) in our_funding_inputs.into_iter() { - let tx16 = TransactionU16LenLimited::new(tx) - .map_err(|_e| APIError::APIMisuseError { err: format!("Too large transaction") })?; - funding_inputs.push((tx_in, tx16)); + for (txin, tx, _) in our_funding_inputs.into_iter() { + const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { + channel_id: ChannelId([0; 32]), + serial_id: 0, + prevtx: None, + prevtx_out: 0, + sequence: 0, + shared_input_txid: None, + }; + let message_len = MESSAGE_TEMPLATE.serialized_length() + tx.serialized_length(); + if message_len > LN_MAX_MSG_LEN { + return Err(APIError::APIMisuseError { + err: format!( + "Funding input references a prevtx that is too large for tx_add_input: {}", + txin.previous_output, + ), + }); + } + + funding_inputs.push((txin, tx)); } let prev_funding_input = self.funding.to_splice_funding_input(); @@ -12453,7 +12469,7 @@ where pub fn new_outbound( fee_estimator: &LowerBoundedFeeEstimator, entropy_source: &ES, signer_provider: &SP, counterparty_node_id: PublicKey, their_features: &InitFeatures, funding_satoshis: u64, - funding_inputs: Vec<(TxIn, TransactionU16LenLimited)>, user_id: u128, config: &UserConfig, + funding_inputs: Vec<(TxIn, Transaction)>, user_id: u128, config: &UserConfig, current_chain_height: u32, outbound_scid_alias: u64, funding_confirmation_target: ConfirmationTarget, logger: L, ) -> Result diff --git a/lightning/src/ln/dual_funding_tests.rs b/lightning/src/ln/dual_funding_tests.rs index 39cf6200765..ab968c34f62 100644 --- a/lightning/src/ln/dual_funding_tests.rs +++ b/lightning/src/ln/dual_funding_tests.rs @@ -23,7 +23,6 @@ use { crate::ln::msgs::{CommitmentSigned, TxAddInput, TxAddOutput, TxComplete, TxSignatures}, crate::ln::types::ChannelId, crate::prelude::*, - crate::util::ser::TransactionU16LenLimited, crate::util::test_utils, bitcoin::Witness, }; @@ -51,7 +50,7 @@ fn do_test_v2_channel_establishment(session: V2ChannelEstablishmentTestSession) &[session.initiator_input_value_satoshis], ) .into_iter() - .map(|(txin, tx, _)| (txin, TransactionU16LenLimited::new(tx).unwrap())) + .map(|(txin, tx, _)| (txin, tx)) .collect(); // Alice creates a dual-funded channel as initiator. @@ -94,7 +93,7 @@ fn do_test_v2_channel_establishment(session: V2ChannelEstablishmentTestSession) sequence: initiator_funding_inputs[0].0.sequence.0, shared_input_txid: None, }; - let input_value = tx_add_input_msg.prevtx.as_ref().unwrap().as_transaction().output + let input_value = tx_add_input_msg.prevtx.as_ref().unwrap().output [tx_add_input_msg.prevtx_out as usize] .value; assert_eq!(input_value.to_sat(), session.initiator_input_value_satoshis); diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 9853528fbf2..9fdd35e1129 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -32,7 +32,6 @@ use crate::ln::msgs; use crate::ln::msgs::{MessageSendEvent, SerialId, TxSignatures}; use crate::ln::types::ChannelId; use crate::sign::{EntropySource, P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; -use crate::util::ser::TransactionU16LenLimited; use core::fmt::Display; use core::ops::Deref; @@ -869,10 +868,9 @@ impl NegotiationContext { return Err(AbortReason::UnexpectedFundingInput); } } else if let Some(prevtx) = &msg.prevtx { - let transaction = prevtx.as_transaction(); - let txid = transaction.compute_txid(); + let txid = prevtx.compute_txid(); - if let Some(tx_out) = transaction.output.get(msg.prevtx_out as usize) { + if let Some(tx_out) = prevtx.output.get(msg.prevtx_out as usize) { if !tx_out.script_pubkey.is_witness_program() { // The receiving node: // - MUST fail the negotiation if: @@ -1053,14 +1051,9 @@ impl NegotiationContext { return Err(AbortReason::UnexpectedFundingInput); } } else if let Some(prevtx) = &msg.prevtx { - let prev_txid = prevtx.as_transaction().compute_txid(); + let prev_txid = prevtx.compute_txid(); let prev_outpoint = OutPoint { txid: prev_txid, vout: msg.prevtx_out }; - let prev_output = prevtx - .as_transaction() - .output - .get(vout) - .ok_or(AbortReason::PrevTxOutInvalid)? - .clone(); + let prev_output = prevtx.output.get(vout).ok_or(AbortReason::PrevTxOutInvalid)?.clone(); let txin = TxIn { previous_output: prev_outpoint, sequence: Sequence(msg.sequence), @@ -1441,7 +1434,7 @@ impl_writeable_tlv_based_enum!(AddingRole, #[derive(Clone, Debug, Eq, PartialEq)] struct SingleOwnedInput { input: TxIn, - prev_tx: TransactionU16LenLimited, + prev_tx: Transaction, prev_output: TxOut, } @@ -1843,7 +1836,7 @@ where pub feerate_sat_per_kw: u32, pub is_initiator: bool, pub funding_tx_locktime: AbsoluteLockTime, - pub inputs_to_contribute: Vec<(TxIn, TransactionU16LenLimited)>, + pub inputs_to_contribute: Vec<(TxIn, Transaction)>, pub shared_funding_input: Option, pub shared_funding_output: SharedOwnedOutput, pub outputs_to_contribute: Vec, @@ -1885,7 +1878,7 @@ impl InteractiveTxConstructor { // Check for the existence of prevouts' for (txin, tx) in inputs_to_contribute.iter() { let vout = txin.previous_output.vout as usize; - if tx.as_transaction().output.get(vout).is_none() { + if tx.output.get(vout).is_none() { return Err(AbortReason::PrevTxOutInvalid); } } @@ -1894,7 +1887,7 @@ impl InteractiveTxConstructor { .map(|(txin, tx)| { let serial_id = generate_holder_serial_id(entropy_source, is_initiator); let vout = txin.previous_output.vout as usize; - let prev_output = tx.as_transaction().output.get(vout).unwrap().clone(); // checked above + let prev_output = tx.output.get(vout).unwrap().clone(); // checked above let input = InputOwned::Single(SingleOwnedInput { input: txin, prev_tx: tx, prev_output }); (serial_id, input) @@ -2083,12 +2076,11 @@ pub(super) fn calculate_change_output_value( let mut total_input_satoshis = 0u64; let mut our_funding_inputs_weight = 0u64; for (txin, tx) in context.our_funding_inputs.iter() { - let txid = tx.as_transaction().compute_txid(); + let txid = tx.compute_txid(); if txin.previous_output.txid != txid { return Err(AbortReason::PrevTxOutInvalid); } let output = tx - .as_transaction() .output .get(txin.previous_output.vout as usize) .ok_or(AbortReason::PrevTxOutInvalid)?; @@ -2145,7 +2137,6 @@ mod tests { use crate::ln::types::ChannelId; use crate::sign::EntropySource; use crate::util::atomic_counter::AtomicCounter; - use crate::util::ser::TransactionU16LenLimited; use bitcoin::absolute::LockTime as AbsoluteLockTime; use bitcoin::amount::Amount; use bitcoin::hashes::Hash; @@ -2211,12 +2202,12 @@ mod tests { struct TestSession { description: &'static str, - inputs_a: Vec<(TxIn, TransactionU16LenLimited)>, + inputs_a: Vec<(TxIn, Transaction)>, a_shared_input: Option<(OutPoint, TxOut, u64)>, /// The funding output, with the value contributed shared_output_a: (TxOut, u64), outputs_a: Vec, - inputs_b: Vec<(TxIn, TransactionU16LenLimited)>, + inputs_b: Vec<(TxIn, Transaction)>, b_shared_input: Option<(OutPoint, TxOut, u64)>, /// The funding output, with the value contributed shared_output_b: (TxOut, u64), @@ -2482,7 +2473,7 @@ mod tests { } } - fn generate_inputs(outputs: &[TestOutput]) -> Vec<(TxIn, TransactionU16LenLimited)> { + fn generate_inputs(outputs: &[TestOutput]) -> Vec<(TxIn, Transaction)> { let tx = generate_tx(outputs); let txid = tx.compute_txid(); tx.output @@ -2495,7 +2486,7 @@ mod tests { sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, witness: Default::default(), }; - (txin, TransactionU16LenLimited::new(tx.clone()).unwrap()) + (txin, tx.clone()) }) .collect() } @@ -2543,12 +2534,12 @@ mod tests { (generate_txout(&TestOutput::P2WSH(value)), local_value) } - fn generate_fixed_number_of_inputs(count: u16) -> Vec<(TxIn, TransactionU16LenLimited)> { + fn generate_fixed_number_of_inputs(count: u16) -> Vec<(TxIn, Transaction)> { // Generate transactions with a total `count` number of outputs such that no transaction has a // serialized length greater than u16::MAX. let max_outputs_per_prevtx = 1_500; let mut remaining = count; - let mut inputs: Vec<(TxIn, TransactionU16LenLimited)> = Vec::with_capacity(count as usize); + let mut inputs: Vec<(TxIn, Transaction)> = Vec::with_capacity(count as usize); while remaining > 0 { let tx_output_count = remaining.min(max_outputs_per_prevtx); @@ -2561,7 +2552,7 @@ mod tests { ); let txid = tx.compute_txid(); - let mut temp: Vec<(TxIn, TransactionU16LenLimited)> = tx + let mut temp: Vec<(TxIn, Transaction)> = tx .output .iter() .enumerate() @@ -2572,7 +2563,7 @@ mod tests { sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, witness: Default::default(), }; - (input, TransactionU16LenLimited::new(tx.clone()).unwrap()) + (input, tx.clone()) }) .collect(); @@ -2783,10 +2774,9 @@ mod tests { expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeA)), }); - let tx = - TransactionU16LenLimited::new(generate_tx(&[TestOutput::P2WPKH(1_000_000)])).unwrap(); + let tx = generate_tx(&[TestOutput::P2WPKH(1_000_000)]); let invalid_sequence_input = TxIn { - previous_output: OutPoint { txid: tx.as_transaction().compute_txid(), vout: 0 }, + previous_output: OutPoint { txid: tx.compute_txid(), vout: 0 }, ..Default::default() }; do_test_interactive_tx_constructor(TestSession { @@ -2802,7 +2792,7 @@ mod tests { expect_error: Some((AbortReason::IncorrectInputSequenceValue, ErrorCulprit::NodeA)), }); let duplicate_input = TxIn { - previous_output: OutPoint { txid: tx.as_transaction().compute_txid(), vout: 0 }, + previous_output: OutPoint { txid: tx.compute_txid(), vout: 0 }, sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, ..Default::default() }; @@ -2820,7 +2810,7 @@ mod tests { }); // Non-initiator uses same prevout as initiator. let duplicate_input = TxIn { - previous_output: OutPoint { txid: tx.as_transaction().compute_txid(), vout: 0 }, + previous_output: OutPoint { txid: tx.compute_txid(), vout: 0 }, sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, ..Default::default() }; @@ -2837,7 +2827,7 @@ mod tests { expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeA)), }); let duplicate_input = TxIn { - previous_output: OutPoint { txid: tx.as_transaction().compute_txid(), vout: 0 }, + previous_output: OutPoint { txid: tx.compute_txid(), vout: 0 }, sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, ..Default::default() }; @@ -3170,9 +3160,9 @@ mod tests { sequence: Sequence::ZERO, witness: Witness::new(), }; - (txin, TransactionU16LenLimited::new(tx).unwrap()) + (txin, tx) }) - .collect::>(); + .collect::>(); let our_contributed = 110_000; let txout = TxOut { value: Amount::from_sat(10_000), script_pubkey: ScriptBuf::new() }; let outputs = vec![txout]; diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index e0219a5523f..71f73e04c2f 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -29,7 +29,7 @@ use bitcoin::hash_types::Txid; use bitcoin::script::ScriptBuf; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::PublicKey; -use bitcoin::{secp256k1, Witness}; +use bitcoin::{secp256k1, Transaction, Witness}; use crate::blinded_path::payment::{ BlindedPaymentTlvs, ForwardTlvs, ReceiveTlvs, UnauthenticatedReceiveTlvs, @@ -63,8 +63,7 @@ use crate::util::base32; use crate::util::logger; use crate::util::ser::{ BigSize, FixedLengthReader, HighZeroBytesDroppedBigSize, Hostname, LengthLimitedRead, - LengthReadable, LengthReadableArgs, Readable, ReadableArgs, TransactionU16LenLimited, - WithoutLength, Writeable, Writer, + LengthReadable, LengthReadableArgs, Readable, ReadableArgs, WithoutLength, Writeable, Writer, }; use crate::routing::gossip::{NodeAlias, NodeId}; @@ -524,7 +523,7 @@ pub struct TxAddInput { pub serial_id: SerialId, /// Serialized transaction that contains the output this input spends to verify that it is /// non-malleable. Omitted for shared input. - pub prevtx: Option, + pub prevtx: Option, /// The index of the output being spent pub prevtx_out: u32, /// The sequence number of this input @@ -2738,16 +2737,58 @@ impl_writeable_msg!(SpliceLocked, { splice_txid, }, {}); -impl_writeable_msg!(TxAddInput, { - channel_id, - serial_id, - prevtx, - prevtx_out, - sequence, -}, { - (0, shared_input_txid, option), // `funding_txid` -}); +impl Writeable for TxAddInput { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.channel_id.write(w)?; + self.serial_id.write(w)?; + + match &self.prevtx { + Some(tx) => { + (tx.serialized_length() as u16).write(w)?; + tx.write(w)?; + }, + None => 0u16.write(w)?, + } + + self.prevtx_out.write(w)?; + self.sequence.write(w)?; + + encode_tlv_stream!(w, { + (0, self.shared_input_txid, option), + }); + Ok(()) + } +} + +impl LengthReadable for TxAddInput { + fn read_from_fixed_length_buffer(r: &mut R) -> Result { + let channel_id: ChannelId = Readable::read(r)?; + let serial_id: SerialId = Readable::read(r)?; + + let prevtx_len: u16 = Readable::read(r)?; + let prevtx = if prevtx_len > 0 { + let mut tx_reader = FixedLengthReader::new(r, prevtx_len as u64); + let tx: Transaction = Readable::read(&mut tx_reader)?; + if tx_reader.bytes_remain() { + return Err(DecodeError::BadLengthDescriptor); + } + + Some(tx) + } else { + None + }; + + let prevtx_out: u32 = Readable::read(r)?; + let sequence: u32 = Readable::read(r)?; + let mut shared_input_txid: Option = None; + decode_tlv_stream!(r, { + (0, shared_input_txid, option), + }); + + Ok(TxAddInput { channel_id, serial_id, prevtx, prevtx_out, sequence, shared_input_txid }) + } +} impl_writeable_msg!(TxAddOutput, { channel_id, serial_id, @@ -4224,10 +4265,7 @@ mod tests { ChannelFeatures, ChannelTypeFeatures, InitFeatures, NodeFeatures, }; use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; - use crate::util::ser::{ - BigSize, Hostname, LengthReadable, Readable, ReadableArgs, TransactionU16LenLimited, - Writeable, - }; + use crate::util::ser::{BigSize, Hostname, LengthReadable, Readable, ReadableArgs, Writeable}; use crate::util::test_utils; use bitcoin::hex::DisplayHex; use bitcoin::{Amount, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; @@ -5299,7 +5337,7 @@ mod tests { let tx_add_input = msgs::TxAddInput { channel_id: ChannelId::from_bytes([2; 32]), serial_id: 4886718345, - prevtx: Some(TransactionU16LenLimited::new(Transaction { + prevtx: Some(Transaction { version: Version::TWO, lock_time: LockTime::ZERO, input: vec![TxIn { @@ -5320,7 +5358,7 @@ mod tests { script_pubkey: Address::from_str("bc1qxmk834g5marzm227dgqvynd23y2nvt2ztwcw2z").unwrap().assume_checked().script_pubkey(), }, ], - }).unwrap()), + }), prevtx_out: 305419896, sequence: 305419896, shared_input_txid: None, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 51f9f2e387e..c1340c88e72 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -154,7 +154,7 @@ fn test_v1_splice_in() { ); } else { // Input is the extra input - let prevtx_value = tx_add_input_msg.prevtx.as_ref().unwrap().as_transaction().output + let prevtx_value = tx_add_input_msg.prevtx.as_ref().unwrap().output [tx_add_input_msg.prevtx_out as usize] .value .to_sat(); @@ -182,7 +182,7 @@ fn test_v1_splice_in() { ); if !inputs_seen_in_reverse { // Input is the extra input - let prevtx_value = tx_add_input2_msg.prevtx.as_ref().unwrap().as_transaction().output + let prevtx_value = tx_add_input2_msg.prevtx.as_ref().unwrap().output [tx_add_input2_msg.prevtx_out as usize] .value .to_sat(); diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index ac2b529f0bd..ea49e59ab89 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1676,63 +1676,6 @@ impl Readable for Duration { } } -/// A wrapper for a `Transaction` which can only be constructed with [`TransactionU16LenLimited::new`] -/// if the `Transaction`'s consensus-serialized length is <= u16::MAX. -/// -/// Use [`TransactionU16LenLimited::into_transaction`] to convert into the contained `Transaction`. -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub struct TransactionU16LenLimited(Transaction); - -impl TransactionU16LenLimited { - /// Constructs a new `TransactionU16LenLimited` from a `Transaction` only if it's consensus- - /// serialized length is <= u16::MAX. - pub fn new(transaction: Transaction) -> Result { - if transaction.serialized_length() > (u16::MAX as usize) { - Err(()) - } else { - Ok(Self(transaction)) - } - } - - /// Consumes this `TransactionU16LenLimited` and returns its contained `Transaction`. - pub fn into_transaction(self) -> Transaction { - self.0 - } - - /// Returns a reference to the contained `Transaction` - pub fn as_transaction(&self) -> &Transaction { - &self.0 - } -} - -impl Writeable for Option { - fn write(&self, w: &mut W) -> Result<(), io::Error> { - match self { - Some(tx) => { - (tx.0.serialized_length() as u16).write(w)?; - tx.0.write(w) - }, - None => 0u16.write(w), - } - } -} - -impl Readable for Option { - fn read(r: &mut R) -> Result { - let len = ::read(r)?; - if len == 0 { - return Ok(None); - } - let mut tx_reader = FixedLengthReader::new(r, len as u64); - let tx: Transaction = Readable::read(&mut tx_reader)?; - if tx_reader.bytes_remain() { - Err(DecodeError::BadLengthDescriptor) - } else { - Ok(Some(TransactionU16LenLimited(tx))) - } - } -} - impl Writeable for ClaimId { fn write(&self, writer: &mut W) -> Result<(), io::Error> { self.0.write(writer) From 332738213234e59723d5e180fdd5aa7d725ec9f6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 6 Aug 2025 15:47:27 -0500 Subject: [PATCH 04/11] Include witness weights in FundingNegotiationContext ChannelManager::splice_channel takes witness weights with the funding inputs. Storing these in FundingNegotiationContext allows us to use them when calculating the change output and include them in a common struct used for initiating a splice-in. In preparation for having ChannelManager::splice_channel take FundingTxContributions, add a weight to the FundingTxContributions::InputsOnly, which supports the splice-in use case. --- lightning/src/ln/channel.rs | 22 ++++++++++++---------- lightning/src/ln/dual_funding_tests.rs | 5 +---- lightning/src/ln/interactivetxs.rs | 7 ++++--- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 60f35b94f85..2edeaa41ae6 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -5979,7 +5979,7 @@ pub(super) struct FundingNegotiationContext { pub shared_funding_input: Option, /// The funding inputs we will be contributing to the channel. #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. - pub our_funding_inputs: Vec<(TxIn, Transaction)>, + pub our_funding_inputs: Vec<(TxIn, Transaction, Weight)>, /// The change output script. This will be used if needed or -- if not set -- generated using /// `SignerProvider::get_destination_script`. #[allow(dead_code)] // TODO(splicing): Remove once splicing is enabled. @@ -6051,6 +6051,9 @@ impl FundingNegotiationContext { } } + let funding_inputs = + self.our_funding_inputs.into_iter().map(|(txin, tx, _)| (txin, tx)).collect(); + let constructor_args = InteractiveTxConstructorArgs { entropy_source, holder_node_id, @@ -6059,7 +6062,7 @@ impl FundingNegotiationContext { feerate_sat_per_kw: self.funding_feerate_sat_per_1000_weight, is_initiator: self.is_initiator, funding_tx_locktime: self.funding_tx_locktime, - inputs_to_contribute: self.our_funding_inputs, + inputs_to_contribute: funding_inputs, shared_funding_input: self.shared_funding_input, shared_funding_output: SharedOwnedOutput::new( shared_funding_output, @@ -10669,9 +10672,8 @@ where err, ), })?; - // Convert inputs - let mut funding_inputs = Vec::new(); - for (txin, tx, _) in our_funding_inputs.into_iter() { + + for (txin, tx, _) in our_funding_inputs.iter() { const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { channel_id: ChannelId([0; 32]), serial_id: 0, @@ -10689,8 +10691,6 @@ where ), }); } - - funding_inputs.push((txin, tx)); } let prev_funding_input = self.funding.to_splice_funding_input(); @@ -10700,7 +10700,7 @@ where funding_tx_locktime: LockTime::from_consensus(locktime), funding_feerate_sat_per_1000_weight: funding_feerate_per_kw, shared_funding_input: Some(prev_funding_input), - our_funding_inputs: funding_inputs, + our_funding_inputs, change_script, }; @@ -12469,7 +12469,7 @@ where pub fn new_outbound( fee_estimator: &LowerBoundedFeeEstimator, entropy_source: &ES, signer_provider: &SP, counterparty_node_id: PublicKey, their_features: &InitFeatures, funding_satoshis: u64, - funding_inputs: Vec<(TxIn, Transaction)>, user_id: u128, config: &UserConfig, + funding_inputs: Vec<(TxIn, Transaction, Weight)>, user_id: u128, config: &UserConfig, current_chain_height: u32, outbound_scid_alias: u64, funding_confirmation_target: ConfirmationTarget, logger: L, ) -> Result @@ -12683,6 +12683,8 @@ where value: Amount::from_sat(funding.get_value_satoshis()), script_pubkey: funding.get_funding_redeemscript().to_p2wsh(), }; + let inputs_to_contribute = + our_funding_inputs.into_iter().map(|(txin, tx, _)| (txin, tx)).collect(); let interactive_tx_constructor = Some(InteractiveTxConstructor::new( InteractiveTxConstructorArgs { @@ -12693,7 +12695,7 @@ where feerate_sat_per_kw: funding_negotiation_context.funding_feerate_sat_per_1000_weight, funding_tx_locktime: funding_negotiation_context.funding_tx_locktime, is_initiator: false, - inputs_to_contribute: our_funding_inputs, + inputs_to_contribute, shared_funding_input: None, shared_funding_output: SharedOwnedOutput::new(shared_funding_output, our_funding_contribution_sats), outputs_to_contribute: Vec::new(), diff --git a/lightning/src/ln/dual_funding_tests.rs b/lightning/src/ln/dual_funding_tests.rs index ab968c34f62..ee23cd61856 100644 --- a/lightning/src/ln/dual_funding_tests.rs +++ b/lightning/src/ln/dual_funding_tests.rs @@ -48,10 +48,7 @@ fn do_test_v2_channel_establishment(session: V2ChannelEstablishmentTestSession) let initiator_funding_inputs: Vec<_> = create_dual_funding_utxos_with_prev_txs( &nodes[0], &[session.initiator_input_value_satoshis], - ) - .into_iter() - .map(|(txin, tx, _)| (txin, tx)) - .collect(); + ); // Alice creates a dual-funded channel as initiator. let funding_satoshis = session.funding_input_sats; diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 9fdd35e1129..d88edfb932c 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -2075,7 +2075,7 @@ pub(super) fn calculate_change_output_value( let mut total_input_satoshis = 0u64; let mut our_funding_inputs_weight = 0u64; - for (txin, tx) in context.our_funding_inputs.iter() { + for (txin, tx, _) in context.our_funding_inputs.iter() { let txid = tx.compute_txid(); if txin.previous_output.txid != txid { return Err(AbortReason::PrevTxOutInvalid); @@ -3160,9 +3160,10 @@ mod tests { sequence: Sequence::ZERO, witness: Witness::new(), }; - (txin, tx) + let weight = Weight::ZERO; + (txin, tx, weight) }) - .collect::>(); + .collect::>(); let our_contributed = 110_000; let txout = TxOut { value: Amount::from_sat(10_000), script_pubkey: ScriptBuf::new() }; let outputs = vec![txout]; From 75b7e802afd84a45e268fb6e6d218cdd781eb907 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 21 Aug 2025 10:36:40 -0500 Subject: [PATCH 05/11] Account for shared input EMPTY_SCRIPT_SIG_WEIGHT When splicing a channel, the previous funding output is spent and fees for it are paid by the splice initiator. However, the witness weight was not including EMPTY_SCRIPT_SIG_WEIGHT. Fix this and update the variable name to make clear the weight needed is the input satisfaction. --- lightning/src/ln/channel.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 2edeaa41ae6..c5c0f396495 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -38,6 +38,8 @@ use crate::chain::channelmonitor::{ use crate::chain::transaction::{OutPoint, TransactionData}; use crate::chain::BestBlock; use crate::events::bump_transaction::BASE_INPUT_WEIGHT; +#[cfg(splicing)] +use crate::events::bump_transaction::EMPTY_SCRIPT_SIG_WEIGHT; use crate::events::ClosureReason; use crate::ln::chan_utils; #[cfg(splicing)] @@ -5879,18 +5881,18 @@ fn get_v2_channel_reserve_satoshis(channel_value_satoshis: u64, dust_limit_satos /// Estimate our part of the fee of the new funding transaction. /// input_count: Number of contributed inputs. -/// witness_weight: The witness weight for contributed inputs. +/// input_satisfaction_weight: The satisfaction weight for contributed inputs. #[allow(dead_code)] // TODO(dual_funding): TODO(splicing): Remove allow once used. #[rustfmt::skip] fn estimate_v2_funding_transaction_fee( - is_initiator: bool, input_count: usize, witness_weight: Weight, + is_initiator: bool, input_count: usize, input_satisfaction_weight: Weight, funding_feerate_sat_per_1000_weight: u32, ) -> u64 { // Inputs let mut weight = (input_count as u64) * BASE_INPUT_WEIGHT; // Witnesses - weight = weight.saturating_add(witness_weight.to_wu()); + weight = weight.saturating_add(input_satisfaction_weight.to_wu()); // If we are the initiator, we must pay for weight of all common fields in the funding transaction. if is_initiator { @@ -5919,14 +5921,15 @@ fn check_v2_funding_inputs_sufficient( contribution_amount: i64, funding_inputs: &[(TxIn, Transaction, Weight)], is_initiator: bool, is_splice: bool, funding_feerate_sat_per_1000_weight: u32, ) -> Result { - let mut total_input_witness_weight = Weight::from_wu(funding_inputs.iter().map(|(_, _, w)| w.to_wu()).sum()); + let mut total_input_satisfaction_weight = Weight::from_wu(funding_inputs.iter().map(|(_, _, w)| w.to_wu()).sum()); let mut funding_inputs_len = funding_inputs.len(); if is_initiator && is_splice { // consider the weight of the input and witness needed for spending the old funding transaction funding_inputs_len += 1; - total_input_witness_weight += Weight::from_wu(FUNDING_TRANSACTION_WITNESS_WEIGHT); + total_input_satisfaction_weight += + Weight::from_wu(EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT); } - let estimated_fee = estimate_v2_funding_transaction_fee(is_initiator, funding_inputs_len, total_input_witness_weight, funding_feerate_sat_per_1000_weight); + let estimated_fee = estimate_v2_funding_transaction_fee(is_initiator, funding_inputs_len, total_input_satisfaction_weight, funding_feerate_sat_per_1000_weight); let mut total_input_sats = 0u64; for (idx, input) in funding_inputs.iter().enumerate() { @@ -15931,7 +15934,7 @@ mod tests { true, 2000, ).unwrap(), - 2268, + 2276, ); // negative case, inputs clearly insufficient @@ -15947,13 +15950,13 @@ mod tests { ); assert_eq!( format!("{:?}", res.err().unwrap()), - "Warn: Total input amount 100000 is lower than needed for contribution 220000, considering fees of 1730. Need more inputs.", + "Warn: Total input amount 100000 is lower than needed for contribution 220000, considering fees of 1738. Need more inputs.", ); } // barely covers { - let expected_fee: u64 = 2268; + let expected_fee: u64 = 2276; assert_eq!( check_v2_funding_inputs_sufficient( (300_000 - expected_fee - 20) as i64, @@ -15983,7 +15986,7 @@ mod tests { ); assert_eq!( format!("{:?}", res.err().unwrap()), - "Warn: Total input amount 300000 is lower than needed for contribution 298032, considering fees of 2495. Need more inputs.", + "Warn: Total input amount 300000 is lower than needed for contribution 298032, considering fees of 2504. Need more inputs.", ); } From 7c936e2045caf11d04e380b0684bdcba10224d03 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 21 Aug 2025 10:54:56 -0500 Subject: [PATCH 06/11] Replace funding input tuple with struct The funding inputs used for splicing and v2 channel establishment are passed as a tuple of txin, prevtx, and witness weight. Add a struct so that the items included can be better documented. --- lightning/src/ln/channel.rs | 175 +++++++++++----------- lightning/src/ln/channelmanager.rs | 13 +- lightning/src/ln/dual_funding_tests.rs | 6 +- lightning/src/ln/functional_test_utils.rs | 29 ++-- lightning/src/ln/funding.rs | 110 ++++++++++++++ lightning/src/ln/interactivetxs.rs | 44 +++--- lightning/src/ln/mod.rs | 1 + 7 files changed, 241 insertions(+), 137 deletions(-) create mode 100644 lightning/src/ln/funding.rs diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index c5c0f396495..5b236e36c4b 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -13,8 +13,8 @@ use bitcoin::consensus::encode; use bitcoin::constants::ChainHash; use bitcoin::script::{Builder, Script, ScriptBuf, WScriptHash}; use bitcoin::sighash::EcdsaSighashType; -use bitcoin::transaction::{Transaction, TxIn, TxOut}; -use bitcoin::{Weight, Witness}; +use bitcoin::transaction::{Transaction, TxOut}; +use bitcoin::Witness; use bitcoin::hash_types::{BlockHash, Txid}; use bitcoin::hashes::sha256::Hash as Sha256; @@ -26,7 +26,7 @@ use bitcoin::secp256k1::{ecdsa::Signature, Secp256k1}; use bitcoin::secp256k1::{PublicKey, SecretKey}; #[cfg(splicing)] use bitcoin::Sequence; -use bitcoin::{secp256k1, sighash}; +use bitcoin::{secp256k1, sighash, TxIn}; use crate::chain::chaininterface::{ fee_for_weight, ConfirmationTarget, FeeEstimator, LowerBoundedFeeEstimator, @@ -37,18 +37,15 @@ use crate::chain::channelmonitor::{ }; use crate::chain::transaction::{OutPoint, TransactionData}; use crate::chain::BestBlock; -use crate::events::bump_transaction::BASE_INPUT_WEIGHT; -#[cfg(splicing)] -use crate::events::bump_transaction::EMPTY_SCRIPT_SIG_WEIGHT; +use crate::events::bump_transaction::{BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT}; use crate::events::ClosureReason; use crate::ln::chan_utils; -#[cfg(splicing)] -use crate::ln::chan_utils::FUNDING_TRANSACTION_WITNESS_WEIGHT; use crate::ln::chan_utils::{ get_commitment_transaction_number_obscure_factor, max_htlcs, second_stage_tx_fees_sat, selected_commitment_sat_per_1000_weight, ChannelPublicKeys, ChannelTransactionParameters, ClosingTransaction, CommitmentTransaction, CounterpartyChannelTransactionParameters, CounterpartyCommitmentSecrets, HTLCOutputInCommitment, HolderCommitmentTransaction, + FUNDING_TRANSACTION_WITNESS_WEIGHT, }; use crate::ln::channel_state::{ ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, InboundHTLCStateDetails, @@ -59,6 +56,7 @@ use crate::ln::channelmanager::{ PaymentClaimDetails, PendingHTLCInfo, PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; +use crate::ln::funding::FundingTxInput; #[cfg(splicing)] use crate::ln::interactivetxs::{ calculate_change_output_value, AbortReason, InteractiveTxMessageSend, @@ -5880,21 +5878,18 @@ fn get_v2_channel_reserve_satoshis(channel_value_satoshis: u64, dust_limit_satos } /// Estimate our part of the fee of the new funding transaction. -/// input_count: Number of contributed inputs. -/// input_satisfaction_weight: The satisfaction weight for contributed inputs. #[allow(dead_code)] // TODO(dual_funding): TODO(splicing): Remove allow once used. #[rustfmt::skip] fn estimate_v2_funding_transaction_fee( - is_initiator: bool, input_count: usize, input_satisfaction_weight: Weight, + funding_inputs: &[FundingTxInput], is_initiator: bool, is_splice: bool, funding_feerate_sat_per_1000_weight: u32, ) -> u64 { - // Inputs - let mut weight = (input_count as u64) * BASE_INPUT_WEIGHT; + let mut weight: u64 = funding_inputs + .iter() + .map(|input| BASE_INPUT_WEIGHT.saturating_add(input.utxo.satisfaction_weight)) + .fold(0, |total_weight, input_weight| total_weight.saturating_add(input_weight)); - // Witnesses - weight = weight.saturating_add(input_satisfaction_weight.to_wu()); - - // If we are the initiator, we must pay for weight of all common fields in the funding transaction. + // The initiator pays for all common fields and the shared output in the funding transaction. if is_initiator { weight = weight .saturating_add(TX_COMMON_FIELDS_WEIGHT) @@ -5903,7 +5898,15 @@ fn estimate_v2_funding_transaction_fee( // to calculate the contributed weight, so we use an all-zero hash. .saturating_add(get_output_weight(&ScriptBuf::new_p2wsh( &WScriptHash::from_raw_hash(Hash::all_zeros()) - )).to_wu()) + )).to_wu()); + + // The splice initiator pays for the input spending the previous funding output. + if is_splice { + weight = weight + .saturating_add(BASE_INPUT_WEIGHT) + .saturating_add(EMPTY_SCRIPT_SIG_WEIGHT) + .saturating_add(FUNDING_TRANSACTION_WITNESS_WEIGHT); + } } fee_for_weight(funding_feerate_sat_per_1000_weight, weight) @@ -5918,29 +5921,16 @@ fn estimate_v2_funding_transaction_fee( #[cfg(splicing)] #[rustfmt::skip] fn check_v2_funding_inputs_sufficient( - contribution_amount: i64, funding_inputs: &[(TxIn, Transaction, Weight)], is_initiator: bool, + contribution_amount: i64, funding_inputs: &[FundingTxInput], is_initiator: bool, is_splice: bool, funding_feerate_sat_per_1000_weight: u32, ) -> Result { - let mut total_input_satisfaction_weight = Weight::from_wu(funding_inputs.iter().map(|(_, _, w)| w.to_wu()).sum()); - let mut funding_inputs_len = funding_inputs.len(); - if is_initiator && is_splice { - // consider the weight of the input and witness needed for spending the old funding transaction - funding_inputs_len += 1; - total_input_satisfaction_weight += - Weight::from_wu(EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT); - } - let estimated_fee = estimate_v2_funding_transaction_fee(is_initiator, funding_inputs_len, total_input_satisfaction_weight, funding_feerate_sat_per_1000_weight); + let estimated_fee = estimate_v2_funding_transaction_fee( + funding_inputs, is_initiator, is_splice, funding_feerate_sat_per_1000_weight, + ); let mut total_input_sats = 0u64; - for (idx, input) in funding_inputs.iter().enumerate() { - if let Some(output) = input.1.output.get(input.0.previous_output.vout as usize) { - total_input_sats = total_input_sats.saturating_add(output.value.to_sat()); - } else { - return Err(ChannelError::Warn(format!( - "Transaction with txid {} does not have an output with vout of {} corresponding to TxIn at funding_inputs[{}]", - input.1.compute_txid(), input.0.previous_output.vout, idx - ))); - } + for FundingTxInput { utxo, .. } in funding_inputs.iter() { + total_input_sats = total_input_sats.saturating_add(utxo.output.value.to_sat()); } // If the inputs are enough to cover intended contribution amount, with fees even when @@ -5982,7 +5972,7 @@ pub(super) struct FundingNegotiationContext { pub shared_funding_input: Option, /// The funding inputs we will be contributing to the channel. #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. - pub our_funding_inputs: Vec<(TxIn, Transaction, Weight)>, + pub our_funding_inputs: Vec, /// The change output script. This will be used if needed or -- if not set -- generated using /// `SignerProvider::get_destination_script`. #[allow(dead_code)] // TODO(splicing): Remove once splicing is enabled. @@ -6054,8 +6044,13 @@ impl FundingNegotiationContext { } } - let funding_inputs = - self.our_funding_inputs.into_iter().map(|(txin, tx, _)| (txin, tx)).collect(); + let funding_inputs = self + .our_funding_inputs + .into_iter() + .map(|FundingTxInput { utxo, sequence, prevtx }| { + (TxIn { previous_output: utxo.outpoint, sequence, ..Default::default() }, prevtx) + }) + .collect(); let constructor_args = InteractiveTxConstructorArgs { entropy_source, @@ -10608,9 +10603,8 @@ where /// generated by `SignerProvider::get_destination_script`. #[cfg(splicing)] pub fn splice_channel( - &mut self, our_funding_contribution_satoshis: i64, - our_funding_inputs: Vec<(TxIn, Transaction, Weight)>, change_script: Option, - funding_feerate_per_kw: u32, locktime: u32, + &mut self, our_funding_contribution_satoshis: i64, our_funding_inputs: Vec, + change_script: Option, funding_feerate_per_kw: u32, locktime: u32, ) -> Result { // Check if a splice has been initiated already. // Note: only a single outstanding splice is supported (per spec) @@ -10676,21 +10670,22 @@ where ), })?; - for (txin, tx, _) in our_funding_inputs.iter() { + for FundingTxInput { utxo, prevtx, .. } in our_funding_inputs.iter() { const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { channel_id: ChannelId([0; 32]), serial_id: 0, prevtx: None, prevtx_out: 0, sequence: 0, + // Mutually exclusive with prevtx, which is accounted for below. shared_input_txid: None, }; - let message_len = MESSAGE_TEMPLATE.serialized_length() + tx.serialized_length(); + let message_len = MESSAGE_TEMPLATE.serialized_length() + prevtx.serialized_length(); if message_len > LN_MAX_MSG_LEN { return Err(APIError::APIMisuseError { err: format!( "Funding input references a prevtx that is too large for tx_add_input: {}", - txin.previous_output, + utxo.outpoint, ), }); } @@ -12472,7 +12467,7 @@ where pub fn new_outbound( fee_estimator: &LowerBoundedFeeEstimator, entropy_source: &ES, signer_provider: &SP, counterparty_node_id: PublicKey, their_features: &InitFeatures, funding_satoshis: u64, - funding_inputs: Vec<(TxIn, Transaction, Weight)>, user_id: u128, config: &UserConfig, + funding_inputs: Vec, user_id: u128, config: &UserConfig, current_chain_height: u32, outbound_scid_alias: u64, funding_confirmation_target: ConfirmationTarget, logger: L, ) -> Result @@ -12686,8 +12681,12 @@ where value: Amount::from_sat(funding.get_value_satoshis()), script_pubkey: funding.get_funding_redeemscript().to_p2wsh(), }; - let inputs_to_contribute = - our_funding_inputs.into_iter().map(|(txin, tx, _)| (txin, tx)).collect(); + let inputs_to_contribute = our_funding_inputs + .into_iter() + .map(|FundingTxInput { utxo, sequence, prevtx }| { + (TxIn { previous_output: utxo.outpoint, sequence, ..Default::default() }, prevtx) + }) + .collect(); let interactive_tx_constructor = Some(InteractiveTxConstructor::new( InteractiveTxConstructorArgs { @@ -14124,6 +14123,7 @@ mod tests { }; use crate::ln::channel_keys::{RevocationBasepoint, RevocationKey}; use crate::ln::channelmanager::{self, HTLCSource, PaymentId}; + use crate::ln::funding::FundingTxInput; use crate::ln::msgs; use crate::ln::msgs::{ChannelUpdate, UnsignedChannelUpdate, MAX_VALUE_MSAT}; use crate::ln::onion_utils::{AttributionData, LocalHTLCFailureReason}; @@ -14155,12 +14155,8 @@ mod tests { use bitcoin::secp256k1::ffi::Signature as FFISignature; use bitcoin::secp256k1::{ecdsa::Signature, Secp256k1}; use bitcoin::secp256k1::{PublicKey, SecretKey}; - #[cfg(splicing)] - use bitcoin::transaction::TxIn; use bitcoin::transaction::{Transaction, TxOut, Version}; - #[cfg(splicing)] - use bitcoin::Weight; - use bitcoin::{WitnessProgram, WitnessVersion}; + use bitcoin::{ScriptBuf, Sequence, WPubkeyHash, WitnessProgram, WitnessVersion}; use std::cmp; #[test] @@ -15866,54 +15862,65 @@ mod tests { #[rustfmt::skip] fn test_estimate_v2_funding_transaction_fee() { use crate::ln::channel::estimate_v2_funding_transaction_fee; - use bitcoin::Weight; - // 2 inputs with weight 300, initiator, 2000 sat/kw feerate + let one_input = [funding_input_sats(1_000)]; + let two_inputs = [funding_input_sats(1_000), funding_input_sats(1_000)]; + + // 2 inputs, initiator, 2000 sat/kw feerate assert_eq!( - estimate_v2_funding_transaction_fee(true, 2, Weight::from_wu(300), 2000), - 1668 + estimate_v2_funding_transaction_fee(&two_inputs, true, false, 2000), + 1520, ); // higher feerate assert_eq!( - estimate_v2_funding_transaction_fee(true, 2, Weight::from_wu(300), 3000), - 2502 + estimate_v2_funding_transaction_fee(&two_inputs, true, false, 3000), + 2280, ); // only 1 input assert_eq!( - estimate_v2_funding_transaction_fee(true, 1, Weight::from_wu(300), 2000), - 1348 + estimate_v2_funding_transaction_fee(&one_input, true, false, 2000), + 974, ); - // 0 input weight + // 0 inputs assert_eq!( - estimate_v2_funding_transaction_fee(true, 1, Weight::from_wu(0), 2000), - 748 + estimate_v2_funding_transaction_fee(&[], true, false, 2000), + 428, ); // not initiator assert_eq!( - estimate_v2_funding_transaction_fee(false, 1, Weight::from_wu(0), 2000), - 320 + estimate_v2_funding_transaction_fee(&[], false, false, 2000), + 0, + ); + + // splice initiator + assert_eq!( + estimate_v2_funding_transaction_fee(&one_input, true, true, 2000), + 1746, + ); + + // splice acceptor + assert_eq!( + estimate_v2_funding_transaction_fee(&one_input, false, true, 2000), + 546, ); } - #[cfg(splicing)] #[rustfmt::skip] - fn funding_input_sats(input_value_sats: u64) -> (TxIn, Transaction, Weight) { - use crate::sign::P2WPKH_WITNESS_WEIGHT; - - let input_1_prev_out = TxOut { value: Amount::from_sat(input_value_sats), script_pubkey: bitcoin::ScriptBuf::default() }; - let input_1_prev_tx = Transaction { - input: vec![], output: vec![input_1_prev_out], - version: Version::TWO, lock_time: bitcoin::absolute::LockTime::ZERO, + fn funding_input_sats(input_value_sats: u64) -> FundingTxInput { + let prevout = TxOut { + value: Amount::from_sat(input_value_sats), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), }; - let input_1_txin = TxIn { - previous_output: bitcoin::OutPoint { txid: input_1_prev_tx.compute_txid(), vout: 0 }, - ..Default::default() + let prevtx = Transaction { + input: vec![], output: vec![prevout], + version: Version::TWO, lock_time: bitcoin::absolute::LockTime::ZERO, }; - (input_1_txin, input_1_prev_tx, Weight::from_wu(P2WPKH_WITNESS_WEIGHT)) + + FundingTxInput::new_p2wpkh(prevtx, 0, Sequence::ZERO).unwrap() } #[cfg(splicing)] @@ -15934,7 +15941,7 @@ mod tests { true, 2000, ).unwrap(), - 2276, + 2292, ); // negative case, inputs clearly insufficient @@ -15950,13 +15957,13 @@ mod tests { ); assert_eq!( format!("{:?}", res.err().unwrap()), - "Warn: Total input amount 100000 is lower than needed for contribution 220000, considering fees of 1738. Need more inputs.", + "Warn: Total input amount 100000 is lower than needed for contribution 220000, considering fees of 1746. Need more inputs.", ); } // barely covers { - let expected_fee: u64 = 2276; + let expected_fee: u64 = 2292; assert_eq!( check_v2_funding_inputs_sufficient( (300_000 - expected_fee - 20) as i64, @@ -15986,13 +15993,13 @@ mod tests { ); assert_eq!( format!("{:?}", res.err().unwrap()), - "Warn: Total input amount 300000 is lower than needed for contribution 298032, considering fees of 2504. Need more inputs.", + "Warn: Total input amount 300000 is lower than needed for contribution 298032, considering fees of 2522. Need more inputs.", ); } // barely covers, less fees (no extra weight, no init) { - let expected_fee: u64 = 1076; + let expected_fee: u64 = 1092; assert_eq!( check_v2_funding_inputs_sufficient( (300_000 - expected_fee - 20) as i64, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 724fc2e64bc..abb1049a984 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -30,9 +30,9 @@ use bitcoin::hashes::{Hash, HashEngine, HmacEngine}; use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; -use bitcoin::{secp256k1, Sequence, SignedAmount}; #[cfg(splicing)] -use bitcoin::{ScriptBuf, TxIn, Weight}; +use bitcoin::ScriptBuf; +use bitcoin::{secp256k1, Sequence, SignedAmount}; use crate::blinded_path::message::MessageForwardNode; use crate::blinded_path::message::{AsyncPaymentsContext, OffersContext}; @@ -65,6 +65,8 @@ use crate::ln::channel::{ UpdateFulfillCommitFetch, WithChannelContext, }; use crate::ln::channel_state::ChannelDetails; +#[cfg(splicing)] +use crate::ln::funding::FundingTxInput; use crate::ln::inbound_payment; use crate::ln::interactivetxs::{HandleTxCompleteResult, InteractiveTxMessageSendResult}; use crate::ln::msgs; @@ -4459,7 +4461,7 @@ where #[rustfmt::skip] pub fn splice_channel( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, our_funding_contribution_satoshis: i64, - our_funding_inputs: Vec<(TxIn, Transaction, Weight)>, change_script: Option, + our_funding_inputs: Vec, change_script: Option, funding_feerate_per_kw: u32, locktime: Option, ) -> Result<(), APIError> { let mut res = Ok(()); @@ -4480,9 +4482,8 @@ where #[cfg(splicing)] fn internal_splice_channel( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, - our_funding_contribution_satoshis: i64, - our_funding_inputs: Vec<(TxIn, Transaction, Weight)>, change_script: Option, - funding_feerate_per_kw: u32, locktime: Option, + our_funding_contribution_satoshis: i64, our_funding_inputs: Vec, + change_script: Option, funding_feerate_per_kw: u32, locktime: Option, ) -> Result<(), APIError> { let per_peer_state = self.per_peer_state.read().unwrap(); diff --git a/lightning/src/ln/dual_funding_tests.rs b/lightning/src/ln/dual_funding_tests.rs index ee23cd61856..a91e04bbd82 100644 --- a/lightning/src/ln/dual_funding_tests.rs +++ b/lightning/src/ln/dual_funding_tests.rs @@ -19,6 +19,7 @@ use { crate::ln::channel::PendingV2Channel, crate::ln::channel_keys::{DelayedPaymentBasepoint, HtlcBasepoint, RevocationBasepoint}, crate::ln::functional_test_utils::*, + crate::ln::funding::FundingTxInput, crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, MessageSendEvent}, crate::ln::msgs::{CommitmentSigned, TxAddInput, TxAddOutput, TxComplete, TxSignatures}, crate::ln::types::ChannelId, @@ -82,12 +83,13 @@ fn do_test_v2_channel_establishment(session: V2ChannelEstablishmentTestSession) &RevocationBasepoint::from(open_channel_v2_msg.common_fields.revocation_basepoint), ); + let FundingTxInput { sequence, prevtx, .. } = &initiator_funding_inputs[0]; let tx_add_input_msg = TxAddInput { channel_id, serial_id: 2, // Even serial_id from initiator. - prevtx: Some(initiator_funding_inputs[0].1.clone()), + prevtx: Some(prevtx.clone()), prevtx_out: 0, - sequence: initiator_funding_inputs[0].0.sequence.0, + sequence: sequence.0, shared_input_txid: None, }; let input_value = tx_add_input_msg.prevtx.as_ref().unwrap().output diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index b14b2287f9b..b72395d96c3 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -26,6 +26,7 @@ use crate::ln::channelmanager::{ AChannelManager, ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, RAACommitmentOrder, RecipientOnionFields, MIN_CLTV_EXPIRY_DELTA, }; +use crate::ln::funding::FundingTxInput; use crate::ln::msgs; use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, MessageSendEvent, RoutingMessageHandler, @@ -62,12 +63,10 @@ use bitcoin::script::ScriptBuf; use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::transaction::{self, Version as TxVersion}; use bitcoin::transaction::{Sequence, Transaction, TxIn, TxOut}; -use bitcoin::witness::Witness; -use bitcoin::{WPubkeyHash, Weight}; +use bitcoin::WPubkeyHash; use crate::io; use crate::prelude::*; -use crate::sign::P2WPKH_WITNESS_WEIGHT; use crate::sync::{Arc, LockTestExt, Mutex, RwLock}; use alloc::rc::Rc; use core::cell::RefCell; @@ -1440,7 +1439,7 @@ fn internal_create_funding_transaction<'a, 'b, 'c>( /// Return the inputs (with prev tx), and the total witness weight for these inputs pub fn create_dual_funding_utxos_with_prev_txs( node: &Node<'_, '_, '_>, utxo_values_in_satoshis: &[u64], -) -> Vec<(TxIn, Transaction, Weight)> { +) -> Vec { // Ensure we have unique transactions per node by using the locktime. let tx = Transaction { version: TxVersion::TWO, @@ -1460,22 +1459,12 @@ pub fn create_dual_funding_utxos_with_prev_txs( .collect(), }; - let mut inputs = vec![]; - for i in 0..utxo_values_in_satoshis.len() { - inputs.push(( - TxIn { - previous_output: OutPoint { txid: tx.compute_txid(), index: i as u16 } - .into_bitcoin_outpoint(), - script_sig: ScriptBuf::new(), - sequence: Sequence::ZERO, - witness: Witness::new(), - }, - tx.clone(), - Weight::from_wu(P2WPKH_WITNESS_WEIGHT), - )); - } - - inputs + tx.output + .iter() + .enumerate() + .map(|(index, _)| index as u32) + .map(|vout| FundingTxInput::new_p2wpkh(tx.clone(), vout, Sequence::ZERO).unwrap()) + .collect() } pub fn sign_funding_transaction<'a, 'b, 'c>( diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs new file mode 100644 index 00000000000..c06c4ac37d3 --- /dev/null +++ b/lightning/src/ln/funding.rs @@ -0,0 +1,110 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Types pertaining to funding channels. + +use bitcoin::{Script, Sequence, Transaction, Weight}; + +use crate::events::bump_transaction::{Utxo, EMPTY_SCRIPT_SIG_WEIGHT}; +use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; + +/// An input to contribute to a channel's funding transaction either when using the v2 channel +/// establishment protocol or when splicing. +#[derive(Clone)] +pub struct FundingTxInput { + /// The unspent [`TxOut`] that the input spends. + /// + /// [`TxOut`]: bitcoin::TxOut + pub(super) utxo: Utxo, + + /// The sequence number to use in the [`TxIn`]. + /// + /// [`TxIn`]: bitcoin::TxIn + pub(super) sequence: Sequence, + + /// The transaction containing the unspent [`TxOut`] referenced by [`utxo`]. + /// + /// [`TxOut`]: bitcoin::TxOut + /// [`utxo`]: Self::utxo + pub(super) prevtx: Transaction, +} + +impl FundingTxInput { + fn new bool>( + prevtx: Transaction, vout: u32, sequence: Sequence, witness_weight: Weight, + script_filter: F, + ) -> Result { + Ok(FundingTxInput { + utxo: Utxo { + outpoint: bitcoin::OutPoint { txid: prevtx.compute_txid(), vout }, + output: prevtx + .output + .get(vout as usize) + .filter(|output| script_filter(&output.script_pubkey)) + .ok_or(())? + .clone(), + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + witness_weight.to_wu(), + }, + sequence, + prevtx, + }) + } + + /// Creates an input spending a P2WPKH output from the given `prevtx` at index `vout` using the + /// provided `sequence` number. + /// + /// Returns `Err` if no such output exists in `prevtx` at index `vout`. + pub fn new_p2wpkh(prevtx: Transaction, vout: u32, sequence: Sequence) -> Result { + let witness_weight = Weight::from_wu(P2WPKH_WITNESS_WEIGHT); + FundingTxInput::new(prevtx, vout, sequence, witness_weight, Script::is_p2wpkh) + } + + /// Creates an input spending a P2WSH output from the given `prevtx` at index `vout` using the + /// provided `sequence` number. + /// + /// Requires passing the weight of witness needed to satisfy the output's script. + /// + /// Returns `Err` if no such output exists in `prevtx` at index `vout`. + pub fn new_p2wsh( + prevtx: Transaction, vout: u32, sequence: Sequence, witness_weight: Weight, + ) -> Result { + FundingTxInput::new(prevtx, vout, sequence, witness_weight, Script::is_p2wsh) + } + + /// Creates an input spending a P2TR output from the given `prevtx` at index `vout` using the + /// provided `sequence` number. + /// + /// This is meant for inputs spending a taproot output using the key path. See + /// [`new_p2tr_script_spend`] for when spending using a script path. + /// + /// Returns `Err` if no such output exists in `prevtx` at index `vout`. + /// + /// [`new_p2tr_script_spend`]: Self::new_p2tr_script_spend + pub fn new_p2tr_key_spend( + prevtx: Transaction, vout: u32, sequence: Sequence, + ) -> Result { + let witness_weight = Weight::from_wu(P2TR_KEY_PATH_WITNESS_WEIGHT); + FundingTxInput::new(prevtx, vout, sequence, witness_weight, Script::is_p2tr) + } + + /// Creates an input spending a P2TR output from the given `prevtx` at index `vout` using the + /// provided `sequence` number. + /// + /// Requires passing the weight of witness needed to satisfy a script path of the taproot + /// output. See [`new_p2tr_key_spend`] for when spending using the key path. + /// + /// Returns `Err` if no such output exists in `prevtx` at index `vout`. + /// + /// [`new_p2tr_key_spend`]: Self::new_p2tr_key_spend + pub fn new_p2tr_script_spend( + prevtx: Transaction, vout: u32, sequence: Sequence, witness_weight: Weight, + ) -> Result { + FundingTxInput::new(prevtx, vout, sequence, witness_weight, Script::is_p2tr) + } +} diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index d88edfb932c..e6986b4ccfe 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -28,6 +28,7 @@ use crate::chain::chaininterface::fee_for_weight; use crate::events::bump_transaction::{BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT}; use crate::ln::chan_utils::FUNDING_TRANSACTION_WITNESS_WEIGHT; use crate::ln::channel::{FundingNegotiationContext, TOTAL_BITCOIN_SUPPLY_SATOSHIS}; +use crate::ln::funding::FundingTxInput; use crate::ln::msgs; use crate::ln::msgs::{MessageSendEvent, SerialId, TxSignatures}; use crate::ln::types::ChannelId; @@ -2075,17 +2076,10 @@ pub(super) fn calculate_change_output_value( let mut total_input_satoshis = 0u64; let mut our_funding_inputs_weight = 0u64; - for (txin, tx, _) in context.our_funding_inputs.iter() { - let txid = tx.compute_txid(); - if txin.previous_output.txid != txid { - return Err(AbortReason::PrevTxOutInvalid); - } - let output = tx - .output - .get(txin.previous_output.vout as usize) - .ok_or(AbortReason::PrevTxOutInvalid)?; - total_input_satoshis = total_input_satoshis.saturating_add(output.value.to_sat()); - let weight = estimate_input_weight(output).to_wu(); + for FundingTxInput { utxo, .. } in context.our_funding_inputs.iter() { + total_input_satoshis = total_input_satoshis.saturating_add(utxo.output.value.to_sat()); + + let weight = BASE_INPUT_WEIGHT + utxo.satisfaction_weight; our_funding_inputs_weight = our_funding_inputs_weight.saturating_add(weight); } @@ -2128,6 +2122,7 @@ pub(super) fn calculate_change_output_value( mod tests { use crate::chain::chaininterface::{fee_for_weight, FEERATE_FLOOR_SATS_PER_KW}; use crate::ln::channel::{FundingNegotiationContext, TOTAL_BITCOIN_SUPPLY_SATOSHIS}; + use crate::ln::funding::FundingTxInput; use crate::ln::interactivetxs::{ calculate_change_output_value, generate_holder_serial_id, AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, @@ -2148,7 +2143,7 @@ mod tests { use bitcoin::{opcodes, WScriptHash, Weight, XOnlyPublicKey}; use bitcoin::{ OutPoint, PubkeyHash, ScriptBuf, Sequence, SignedAmount, Transaction, TxIn, TxOut, - WPubkeyHash, Witness, + WPubkeyHash, }; use core::ops::Deref; @@ -3141,29 +3136,28 @@ mod tests { #[test] fn test_calculate_change_output_value_open() { let input_prevouts = [ - TxOut { value: Amount::from_sat(70_000), script_pubkey: ScriptBuf::new() }, - TxOut { value: Amount::from_sat(60_000), script_pubkey: ScriptBuf::new() }, + TxOut { + value: Amount::from_sat(70_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + }, + TxOut { + value: Amount::from_sat(60_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + }, ]; let inputs = input_prevouts .iter() .map(|txout| { - let tx = Transaction { + let prevtx = Transaction { input: Vec::new(), output: vec![(*txout).clone()], lock_time: AbsoluteLockTime::ZERO, version: Version::TWO, }; - let txid = tx.compute_txid(); - let txin = TxIn { - previous_output: OutPoint { txid, vout: 0 }, - script_sig: ScriptBuf::new(), - sequence: Sequence::ZERO, - witness: Witness::new(), - }; - let weight = Weight::ZERO; - (txin, tx, weight) + + FundingTxInput::new_p2wpkh(prevtx, 0, Sequence::ZERO).unwrap() }) - .collect::>(); + .collect(); let our_contributed = 110_000; let txout = TxOut { value: Amount::from_sat(10_000), script_pubkey: ScriptBuf::new() }; let outputs = vec![txout]; diff --git a/lightning/src/ln/mod.rs b/lightning/src/ln/mod.rs index a513582cb64..1169f6efe89 100644 --- a/lightning/src/ln/mod.rs +++ b/lightning/src/ln/mod.rs @@ -18,6 +18,7 @@ pub mod channel_keys; pub mod channel_state; pub mod channelmanager; mod features; +pub mod funding; pub mod inbound_payment; pub mod msgs; pub mod onion_payment; From 3e046918c9256ef7da36b75e9f206715420f3985 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 6 Aug 2025 18:11:31 -0500 Subject: [PATCH 07/11] Use a SpliceContribution enum for passing splice-in params ChannelManager::splice_channel takes individual parameters to support splice-in. Change these to an enum such that it can be used for splice-out as well. --- lightning/src/ln/channel.rs | 12 +++++---- lightning/src/ln/channelmanager.rs | 23 +++++----------- lightning/src/ln/funding.rs | 43 ++++++++++++++++++++++++++++++ lightning/src/ln/splicing_tests.rs | 24 ++++++++++++----- 4 files changed, 75 insertions(+), 27 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 5b236e36c4b..f0364f05d56 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -58,6 +58,8 @@ use crate::ln::channelmanager::{ }; use crate::ln::funding::FundingTxInput; #[cfg(splicing)] +use crate::ln::funding::SpliceContribution; +#[cfg(splicing)] use crate::ln::interactivetxs::{ calculate_change_output_value, AbortReason, InteractiveTxMessageSend, }; @@ -10603,8 +10605,7 @@ where /// generated by `SignerProvider::get_destination_script`. #[cfg(splicing)] pub fn splice_channel( - &mut self, our_funding_contribution_satoshis: i64, our_funding_inputs: Vec, - change_script: Option, funding_feerate_per_kw: u32, locktime: u32, + &mut self, contribution: SpliceContribution, funding_feerate_per_kw: u32, locktime: u32, ) -> Result { // Check if a splice has been initiated already. // Note: only a single outstanding splice is supported (per spec) @@ -10628,7 +10629,7 @@ where // TODO(splicing): check for quiescence - let our_funding_contribution = SignedAmount::from_sat(our_funding_contribution_satoshis); + let our_funding_contribution = contribution.value(); if our_funding_contribution > SignedAmount::MAX_MONEY { return Err(APIError::APIMisuseError { err: format!( @@ -10657,7 +10658,7 @@ where // Check that inputs are sufficient to cover our contribution. let _fee = check_v2_funding_inputs_sufficient( our_funding_contribution.to_sat(), - &our_funding_inputs, + contribution.inputs(), true, true, funding_feerate_per_kw, @@ -10670,7 +10671,7 @@ where ), })?; - for FundingTxInput { utxo, prevtx, .. } in our_funding_inputs.iter() { + for FundingTxInput { utxo, prevtx, .. } in contribution.inputs().iter() { const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { channel_id: ChannelId([0; 32]), serial_id: 0, @@ -10692,6 +10693,7 @@ where } let prev_funding_input = self.funding.to_splice_funding_input(); + let (our_funding_inputs, change_script) = contribution.into_tx_parts(); let funding_negotiation_context = FundingNegotiationContext { is_initiator: true, our_funding_contribution, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index abb1049a984..3d14937532b 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -30,8 +30,6 @@ use bitcoin::hashes::{Hash, HashEngine, HmacEngine}; use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; -#[cfg(splicing)] -use bitcoin::ScriptBuf; use bitcoin::{secp256k1, Sequence, SignedAmount}; use crate::blinded_path::message::MessageForwardNode; @@ -66,7 +64,7 @@ use crate::ln::channel::{ }; use crate::ln::channel_state::ChannelDetails; #[cfg(splicing)] -use crate::ln::funding::FundingTxInput; +use crate::ln::funding::SpliceContribution; use crate::ln::inbound_payment; use crate::ln::interactivetxs::{HandleTxCompleteResult, InteractiveTxMessageSendResult}; use crate::ln::msgs; @@ -4460,14 +4458,13 @@ where #[cfg(splicing)] #[rustfmt::skip] pub fn splice_channel( - &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, our_funding_contribution_satoshis: i64, - our_funding_inputs: Vec, change_script: Option, - funding_feerate_per_kw: u32, locktime: Option, + &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, + contribution: SpliceContribution, funding_feerate_per_kw: u32, locktime: Option, ) -> Result<(), APIError> { let mut res = Ok(()); PersistenceNotifierGuard::optionally_notify(self, || { let result = self.internal_splice_channel( - channel_id, counterparty_node_id, our_funding_contribution_satoshis, our_funding_inputs, change_script, funding_feerate_per_kw, locktime + channel_id, counterparty_node_id, contribution, funding_feerate_per_kw, locktime ); res = result; match res { @@ -4482,8 +4479,7 @@ where #[cfg(splicing)] fn internal_splice_channel( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, - our_funding_contribution_satoshis: i64, our_funding_inputs: Vec, - change_script: Option, funding_feerate_per_kw: u32, locktime: Option, + contribution: SpliceContribution, funding_feerate_per_kw: u32, locktime: Option, ) -> Result<(), APIError> { let per_peer_state = self.per_peer_state.read().unwrap(); @@ -4504,13 +4500,8 @@ where hash_map::Entry::Occupied(mut chan_phase_entry) => { let locktime = locktime.unwrap_or_else(|| self.current_best_block().height); if let Some(chan) = chan_phase_entry.get_mut().as_funded_mut() { - let msg = chan.splice_channel( - our_funding_contribution_satoshis, - our_funding_inputs, - change_script, - funding_feerate_per_kw, - locktime, - )?; + let msg = + chan.splice_channel(contribution, funding_feerate_per_kw, locktime)?; peer_state.pending_msg_events.push(MessageSendEvent::SendSpliceInit { node_id: *counterparty_node_id, msg, diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index c06c4ac37d3..644093f41a9 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -9,11 +9,54 @@ //! Types pertaining to funding channels. +#[cfg(splicing)] +use bitcoin::{Amount, ScriptBuf, SignedAmount}; use bitcoin::{Script, Sequence, Transaction, Weight}; use crate::events::bump_transaction::{Utxo, EMPTY_SCRIPT_SIG_WEIGHT}; use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; +/// The components of a splice's funding transaction that are contributed by one party. +#[cfg(splicing)] +pub enum SpliceContribution { + /// When funds are added to a channel. + SpliceIn { + /// The amount to contribute to the splice. + value: Amount, + + /// The inputs included in the splice's funding transaction to meet the contributed amount. + /// Any excess amount will be sent to a change output. + inputs: Vec, + + /// An optional change output script. This will be used if needed or, when not set, + /// generated using [`SignerProvider::get_destination_script`]. + change_script: Option, + }, +} + +#[cfg(splicing)] +impl SpliceContribution { + pub(super) fn value(&self) -> SignedAmount { + match self { + SpliceContribution::SpliceIn { value, .. } => { + value.to_signed().unwrap_or(SignedAmount::MAX) + }, + } + } + + pub(super) fn inputs(&self) -> &[FundingTxInput] { + match self { + SpliceContribution::SpliceIn { inputs, .. } => &inputs[..], + } + } + + pub(super) fn into_tx_parts(self) -> (Vec, Option) { + match self { + SpliceContribution::SpliceIn { inputs, change_script, .. } => (inputs, change_script), + } + } +} + /// An input to contribute to a channel's funding transaction either when using the v2 channel /// establishment protocol or when splicing. #[derive(Clone)] diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index c1340c88e72..60616326231 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -8,9 +8,12 @@ // licenses. use crate::ln::functional_test_utils::*; +use crate::ln::funding::SpliceContribution; use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, MessageSendEvent}; use crate::util::errors::APIError; +use bitcoin::Amount; + /// Splicing test, simple splice-in flow. Starts with opening a V1 channel first. /// Builds on test_channel_open_simple() #[test] @@ -66,15 +69,20 @@ fn test_v1_splice_in() { &initiator_node, &[extra_splice_funding_input_sats], ); + + let contribution = SpliceContribution::SpliceIn { + value: Amount::from_sat(splice_in_sats), + inputs: funding_inputs, + change_script: None, + }; + // Initiate splice-in let _res = initiator_node .node .splice_channel( &channel_id, &acceptor_node.node.get_our_node_id(), - splice_in_sats as i64, - funding_inputs, - None, // change_script + contribution, funding_feerate_per_kw, None, // locktime ) @@ -295,13 +303,17 @@ fn test_v1_splice_in_negative_insufficient_inputs() { let funding_inputs = create_dual_funding_utxos_with_prev_txs(&nodes[0], &[extra_splice_funding_input_sats]); + let contribution = SpliceContribution::SpliceIn { + value: Amount::from_sat(splice_in_sats), + inputs: funding_inputs, + change_script: None, + }; + // Initiate splice-in, with insufficient input contribution let res = nodes[0].node.splice_channel( &channel_id, &nodes[1].node.get_our_node_id(), - splice_in_sats as i64, - funding_inputs, - None, // change_script + contribution, 1024, // funding_feerate_per_kw, None, // locktime ); From 20b68098e9b15d78ab2112e07dffe02285cebd36 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 31 Jul 2025 10:04:53 -0500 Subject: [PATCH 08/11] Add splice-out support Update SpliceContribution with a variant used to support splice-out (i.e., removing funds from a channel). The TxOut values must not exceed the users channel balance after accounting for fees and the reserve requirement. --- lightning/src/ln/channel.rs | 198 ++++++++++++++++++++--------- lightning/src/ln/funding.rs | 32 ++++- lightning/src/ln/interactivetxs.rs | 16 ++- lightning/src/ln/splicing_tests.rs | 2 +- 4 files changed, 174 insertions(+), 74 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index f0364f05d56..e2552851c4f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -24,9 +24,9 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::constants::PUBLIC_KEY_SIZE; use bitcoin::secp256k1::{ecdsa::Signature, Secp256k1}; use bitcoin::secp256k1::{PublicKey, SecretKey}; -#[cfg(splicing)] -use bitcoin::Sequence; use bitcoin::{secp256k1, sighash, TxIn}; +#[cfg(splicing)] +use bitcoin::{FeeRate, Sequence}; use crate::chain::chaininterface::{ fee_for_weight, ConfirmationTarget, FeeEstimator, LowerBoundedFeeEstimator, @@ -5879,18 +5879,60 @@ fn get_v2_channel_reserve_satoshis(channel_value_satoshis: u64, dust_limit_satos cmp::min(channel_value_satoshis, cmp::max(q, dust_limit_satoshis)) } +#[cfg(splicing)] +fn check_splice_contribution_sufficient( + channel_balance: Amount, contribution: &SpliceContribution, is_initiator: bool, + funding_feerate: FeeRate, +) -> Result { + let contribution_amount = contribution.value(); + if contribution_amount < SignedAmount::ZERO { + let estimated_fee = Amount::from_sat(estimate_v2_funding_transaction_fee( + contribution.inputs(), + contribution.outputs(), + is_initiator, + true, // is_splice + funding_feerate.to_sat_per_kwu() as u32, + )); + + if channel_balance >= contribution_amount.unsigned_abs() + estimated_fee { + Ok(estimated_fee) + } else { + Err(ChannelError::Warn(format!( + "Available channel balance {} is lower than needed for splicing out {}, considering fees of {}", + channel_balance, contribution_amount.unsigned_abs(), estimated_fee, + ))) + } + } else { + check_v2_funding_inputs_sufficient( + contribution_amount.to_sat(), + contribution.inputs(), + is_initiator, + true, + funding_feerate.to_sat_per_kwu() as u32, + ) + .map(Amount::from_sat) + } +} + /// Estimate our part of the fee of the new funding transaction. #[allow(dead_code)] // TODO(dual_funding): TODO(splicing): Remove allow once used. #[rustfmt::skip] fn estimate_v2_funding_transaction_fee( - funding_inputs: &[FundingTxInput], is_initiator: bool, is_splice: bool, + funding_inputs: &[FundingTxInput], outputs: &[TxOut], is_initiator: bool, is_splice: bool, funding_feerate_sat_per_1000_weight: u32, ) -> u64 { - let mut weight: u64 = funding_inputs + let input_weight: u64 = funding_inputs .iter() .map(|input| BASE_INPUT_WEIGHT.saturating_add(input.utxo.satisfaction_weight)) .fold(0, |total_weight, input_weight| total_weight.saturating_add(input_weight)); + let output_weight: u64 = outputs + .iter() + .map(|txout| txout.weight().to_wu()) + .fold(0, |total_weight, output_weight| total_weight.saturating_add(output_weight)); + + let mut weight = input_weight.saturating_add(output_weight); + // The initiator pays for all common fields and the shared output in the funding transaction. if is_initiator { weight = weight @@ -5927,7 +5969,7 @@ fn check_v2_funding_inputs_sufficient( is_splice: bool, funding_feerate_sat_per_1000_weight: u32, ) -> Result { let estimated_fee = estimate_v2_funding_transaction_fee( - funding_inputs, is_initiator, is_splice, funding_feerate_sat_per_1000_weight, + funding_inputs, &[], is_initiator, is_splice, funding_feerate_sat_per_1000_weight, ); let mut total_input_sats = 0u64; @@ -5975,6 +6017,9 @@ pub(super) struct FundingNegotiationContext { /// The funding inputs we will be contributing to the channel. #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. pub our_funding_inputs: Vec, + /// The funding outputs we will be contributing to the channel. + #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. + pub our_funding_outputs: Vec, /// The change output script. This will be used if needed or -- if not set -- generated using /// `SignerProvider::get_destination_script`. #[allow(dead_code)] // TODO(splicing): Remove once splicing is enabled. @@ -6004,10 +6049,8 @@ impl FundingNegotiationContext { debug_assert!(matches!(context.channel_state, ChannelState::NegotiatingFunding(_))); } - // Add output for funding tx // Note: For the error case when the inputs are insufficient, it will be handled after // the `calculate_change_output_value` call below - let mut funding_outputs = Vec::new(); let shared_funding_output = TxOut { value: Amount::from_sat(funding.get_value_satoshis()), @@ -6015,34 +6058,37 @@ impl FundingNegotiationContext { }; // Optionally add change output - if self.our_funding_contribution > SignedAmount::ZERO { - let change_value_opt = calculate_change_output_value( + let change_value_opt = if self.our_funding_contribution > SignedAmount::ZERO { + calculate_change_output_value( &self, self.shared_funding_input.is_some(), &shared_funding_output.script_pubkey, - &funding_outputs, context.holder_dust_limit_satoshis, - )?; - if let Some(change_value) = change_value_opt { - let change_script = if let Some(script) = self.change_script { - script - } else { - signer_provider - .get_destination_script(context.channel_keys_id) - .map_err(|_err| AbortReason::InternalError("Error getting change script"))? - }; - let mut change_output = - TxOut { value: Amount::from_sat(change_value), script_pubkey: change_script }; - let change_output_weight = get_output_weight(&change_output.script_pubkey).to_wu(); - let change_output_fee = - fee_for_weight(self.funding_feerate_sat_per_1000_weight, change_output_weight); - let change_value_decreased_with_fee = - change_value.saturating_sub(change_output_fee); - // Check dust limit again - if change_value_decreased_with_fee > context.holder_dust_limit_satoshis { - change_output.value = Amount::from_sat(change_value_decreased_with_fee); - funding_outputs.push(change_output); - } + )? + } else { + None + }; + + let mut funding_outputs = self.our_funding_outputs; + + if let Some(change_value) = change_value_opt { + let change_script = if let Some(script) = self.change_script { + script + } else { + signer_provider + .get_destination_script(context.channel_keys_id) + .map_err(|_err| AbortReason::InternalError("Error getting change script"))? + }; + let mut change_output = + TxOut { value: Amount::from_sat(change_value), script_pubkey: change_script }; + let change_output_weight = get_output_weight(&change_output.script_pubkey).to_wu(); + let change_output_fee = + fee_for_weight(self.funding_feerate_sat_per_1000_weight, change_output_weight); + let change_value_decreased_with_fee = change_value.saturating_sub(change_output_fee); + // Check dust limit again + if change_value_decreased_with_fee > context.holder_dust_limit_satoshis { + change_output.value = Amount::from_sat(change_value_decreased_with_fee); + funding_outputs.push(change_output); } } @@ -10633,44 +10679,66 @@ where if our_funding_contribution > SignedAmount::MAX_MONEY { return Err(APIError::APIMisuseError { err: format!( - "Channel {} cannot be spliced; contribution exceeds total bitcoin supply: {}", + "Channel {} cannot be spliced in; contribution exceeds total bitcoin supply: {}", self.context.channel_id(), our_funding_contribution, ), }); } - if our_funding_contribution < SignedAmount::ZERO { + if our_funding_contribution < -SignedAmount::MAX_MONEY { return Err(APIError::APIMisuseError { err: format!( - "TODO(splicing): Splice-out not supported, only splice in; channel ID {}, contribution {}", - self.context.channel_id(), our_funding_contribution, - ), + "Channel {} cannot be spliced out; contribution exhausts total bitcoin supply: {}", + self.context.channel_id(), + our_funding_contribution, + ), }); } - // TODO(splicing): Once splice-out is supported, check that channel balance does not go below 0 - // (or below channel reserve) - // Note: post-splice channel value is not yet known at this point, counterparty contribution is not known // (Cannot test for miminum required post-splice channel value) - // Check that inputs are sufficient to cover our contribution. - let _fee = check_v2_funding_inputs_sufficient( - our_funding_contribution.to_sat(), - contribution.inputs(), - true, - true, - funding_feerate_per_kw, + let channel_balance = Amount::from_sat(self.funding.get_value_to_self_msat() / 1000); + let fees = check_splice_contribution_sufficient( + channel_balance, + &contribution, + true, // is_initiator + FeeRate::from_sat_per_kwu(funding_feerate_per_kw as u64), ) - .map_err(|err| APIError::APIMisuseError { - err: format!( - "Insufficient inputs for splicing; channel ID {}, err {}", - self.context.channel_id(), - err, - ), + .map_err(|e| { + let splice_type = if our_funding_contribution < SignedAmount::ZERO { + "spliced out" + } else { + "spliced in" + }; + APIError::APIMisuseError { + err: format!( + "Channel {} cannot be {}; {}", + self.context.channel_id(), + splice_type, + e, + ), + } })?; + // Fees for splice-out are paid from the channel balance whereas fees for splice-in are paid + // by the funding inputs. + let adjusted_funding_contribution = if our_funding_contribution < SignedAmount::ZERO { + let adjusted_funding_contribution = our_funding_contribution + - fees.to_signed().expect("fees should never exceed Amount::MAX_MONEY"); + + // TODO(splicing): Check that channel balance does not go below the channel reserve + let _post_channel_balance = AddSigned::checked_add_signed( + channel_balance.to_sat(), + adjusted_funding_contribution.to_sat(), + ); + + adjusted_funding_contribution + } else { + our_funding_contribution + }; + for FundingTxInput { utxo, prevtx, .. } in contribution.inputs().iter() { const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { channel_id: ChannelId([0; 32]), @@ -10693,14 +10761,15 @@ where } let prev_funding_input = self.funding.to_splice_funding_input(); - let (our_funding_inputs, change_script) = contribution.into_tx_parts(); + let (our_funding_inputs, our_funding_outputs, change_script) = contribution.into_tx_parts(); let funding_negotiation_context = FundingNegotiationContext { is_initiator: true, - our_funding_contribution, + our_funding_contribution: adjusted_funding_contribution, funding_tx_locktime: LockTime::from_consensus(locktime), funding_feerate_sat_per_1000_weight: funding_feerate_per_kw, shared_funding_input: Some(prev_funding_input), our_funding_inputs, + our_funding_outputs, change_script, }; @@ -10716,7 +10785,7 @@ where Ok(msgs::SpliceInit { channel_id: self.context.channel_id, - funding_contribution_satoshis: our_funding_contribution.to_sat(), + funding_contribution_satoshis: adjusted_funding_contribution.to_sat(), funding_feerate_per_kw, locktime, funding_pubkey, @@ -10825,6 +10894,7 @@ where funding_feerate_sat_per_1000_weight: msg.funding_feerate_per_kw, shared_funding_input: Some(prev_funding_input), our_funding_inputs: Vec::new(), + our_funding_outputs: Vec::new(), change_script: None, }; @@ -12523,6 +12593,7 @@ where funding_feerate_sat_per_1000_weight, shared_funding_input: None, our_funding_inputs: funding_inputs, + our_funding_outputs: Vec::new(), change_script: None, }; let chan = Self { @@ -12677,6 +12748,7 @@ where funding_feerate_sat_per_1000_weight: msg.funding_feerate_sat_per_1000_weight, shared_funding_input: None, our_funding_inputs: our_funding_inputs.clone(), + our_funding_outputs: Vec::new(), change_script: None, }; let shared_funding_output = TxOut { @@ -12702,7 +12774,7 @@ where inputs_to_contribute, shared_funding_input: None, shared_funding_output: SharedOwnedOutput::new(shared_funding_output, our_funding_contribution_sats), - outputs_to_contribute: Vec::new(), + outputs_to_contribute: funding_negotiation_context.our_funding_outputs.clone(), } ).map_err(|err| { let reason = ClosureReason::ProcessingError { err: err.to_string() }; @@ -15870,43 +15942,43 @@ mod tests { // 2 inputs, initiator, 2000 sat/kw feerate assert_eq!( - estimate_v2_funding_transaction_fee(&two_inputs, true, false, 2000), + estimate_v2_funding_transaction_fee(&two_inputs, &[], true, false, 2000), 1520, ); // higher feerate assert_eq!( - estimate_v2_funding_transaction_fee(&two_inputs, true, false, 3000), + estimate_v2_funding_transaction_fee(&two_inputs, &[], true, false, 3000), 2280, ); // only 1 input assert_eq!( - estimate_v2_funding_transaction_fee(&one_input, true, false, 2000), + estimate_v2_funding_transaction_fee(&one_input, &[], true, false, 2000), 974, ); // 0 inputs assert_eq!( - estimate_v2_funding_transaction_fee(&[], true, false, 2000), + estimate_v2_funding_transaction_fee(&[], &[], true, false, 2000), 428, ); // not initiator assert_eq!( - estimate_v2_funding_transaction_fee(&[], false, false, 2000), + estimate_v2_funding_transaction_fee(&[], &[], false, false, 2000), 0, ); // splice initiator assert_eq!( - estimate_v2_funding_transaction_fee(&one_input, true, true, 2000), + estimate_v2_funding_transaction_fee(&one_input, &[], true, true, 2000), 1746, ); // splice acceptor assert_eq!( - estimate_v2_funding_transaction_fee(&one_input, false, true, 2000), + estimate_v2_funding_transaction_fee(&one_input, &[], false, true, 2000), 546, ); } diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 644093f41a9..fb05ab89bc3 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -10,7 +10,7 @@ //! Types pertaining to funding channels. #[cfg(splicing)] -use bitcoin::{Amount, ScriptBuf, SignedAmount}; +use bitcoin::{Amount, ScriptBuf, SignedAmount, TxOut}; use bitcoin::{Script, Sequence, Transaction, Weight}; use crate::events::bump_transaction::{Utxo, EMPTY_SCRIPT_SIG_WEIGHT}; @@ -32,6 +32,12 @@ pub enum SpliceContribution { /// generated using [`SignerProvider::get_destination_script`]. change_script: Option, }, + /// When funds are removed from a channel. + SpliceOut { + /// The outputs to include in the splice's funding transaction. The total value of all + /// outputs will be the amount that is removed. + outputs: Vec, + }, } #[cfg(splicing)] @@ -41,18 +47,38 @@ impl SpliceContribution { SpliceContribution::SpliceIn { value, .. } => { value.to_signed().unwrap_or(SignedAmount::MAX) }, + SpliceContribution::SpliceOut { outputs } => { + let value_removed = outputs + .iter() + .map(|txout| txout.value) + .sum::() + .to_signed() + .unwrap_or(SignedAmount::MAX); + -value_removed + }, } } pub(super) fn inputs(&self) -> &[FundingTxInput] { match self { SpliceContribution::SpliceIn { inputs, .. } => &inputs[..], + SpliceContribution::SpliceOut { .. } => &[], } } - pub(super) fn into_tx_parts(self) -> (Vec, Option) { + pub(super) fn outputs(&self) -> &[TxOut] { match self { - SpliceContribution::SpliceIn { inputs, change_script, .. } => (inputs, change_script), + SpliceContribution::SpliceIn { .. } => &[], + SpliceContribution::SpliceOut { outputs } => &outputs[..], + } + } + + pub(super) fn into_tx_parts(self) -> (Vec, Vec, Option) { + match self { + SpliceContribution::SpliceIn { inputs, change_script, .. } => { + (inputs, vec![], change_script) + }, + SpliceContribution::SpliceOut { outputs } => (vec![], outputs, None), } } } diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index e6986b4ccfe..8d2406ef33f 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -2069,7 +2069,7 @@ impl InteractiveTxConstructor { /// - `change_output_dust_limit` - The dust limit (in sats) to consider. pub(super) fn calculate_change_output_value( context: &FundingNegotiationContext, is_splice: bool, shared_output_funding_script: &ScriptBuf, - funding_outputs: &Vec, change_output_dust_limit: u64, + change_output_dust_limit: u64, ) -> Result, AbortReason> { assert!(context.our_funding_contribution > SignedAmount::ZERO); let our_funding_contribution_satoshis = context.our_funding_contribution.to_sat() as u64; @@ -2083,6 +2083,7 @@ pub(super) fn calculate_change_output_value( our_funding_inputs_weight = our_funding_inputs_weight.saturating_add(weight); } + let funding_outputs = &context.our_funding_outputs; let total_output_satoshis = funding_outputs.iter().fold(0u64, |total, out| total.saturating_add(out.value.to_sat())); let our_funding_outputs_weight = funding_outputs.iter().fold(0u64, |weight, out| { @@ -3177,17 +3178,18 @@ mod tests { funding_feerate_sat_per_1000_weight, shared_funding_input: None, our_funding_inputs: inputs, + our_funding_outputs: outputs, change_script: None, }; assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), &outputs, 300), + calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), Ok(Some(gross_change - fees - common_fees)), ); // There is leftover for change, without common fees let context = FundingNegotiationContext { is_initiator: false, ..context }; assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), &outputs, 300), + calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), Ok(Some(gross_change - fees)), ); @@ -3198,7 +3200,7 @@ mod tests { ..context }; assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), &outputs, 300), + calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), Err(AbortReason::InsufficientFees), ); @@ -3209,7 +3211,7 @@ mod tests { ..context }; assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), &outputs, 300), + calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), Ok(None), ); @@ -3220,7 +3222,7 @@ mod tests { ..context }; assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), &outputs, 100), + calculate_change_output_value(&context, false, &ScriptBuf::new(), 100), Ok(Some(262)), ); @@ -3232,7 +3234,7 @@ mod tests { ..context }; assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), &outputs, 300), + calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), Ok(Some(4060)), ); } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 60616326231..2445a2d5fc2 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -319,7 +319,7 @@ fn test_v1_splice_in_negative_insufficient_inputs() { ); match res { Err(APIError::APIMisuseError { err }) => { - assert!(err.contains("Insufficient inputs for splicing")) + assert!(err.contains("Need more inputs")) }, _ => panic!("Wrong error {:?}", res.err().unwrap()), } From 604762a150d3a93c62053b24f55c095c664b54db Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 22 Aug 2025 15:26:59 -0500 Subject: [PATCH 09/11] f - ensure splice contribution is non-zero --- lightning/src/ln/channel.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index e2552851c4f..2f32e4bfcab 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10676,6 +10676,15 @@ where // TODO(splicing): check for quiescence let our_funding_contribution = contribution.value(); + if our_funding_contribution == SignedAmount::ZERO { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced; contribution cannot be zero", + self.context.channel_id(), + ), + }); + } + if our_funding_contribution > SignedAmount::MAX_MONEY { return Err(APIError::APIMisuseError { err: format!( From 74e68b0f87a94e5b24221e6da6270a0f47708a21 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 8 Aug 2025 08:34:35 -0500 Subject: [PATCH 10/11] Support accepting splice-out When a counterparty sends splice_init with a negative contribution, they are requesting to remove funds from a channel. Remove conditions guarding against this and check that they have enough channel balance to cover the removed funds. --- lightning/src/ln/channel.rs | 68 ++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 2f32e4bfcab..4861a8f71f7 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10826,11 +10826,21 @@ where ))); } + debug_assert_eq!(our_funding_contribution, SignedAmount::ZERO); + // TODO(splicing): Move this check once user-provided contributions are supported for // counterparty-initiated splices. if our_funding_contribution > SignedAmount::MAX_MONEY { return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} cannot be spliced; our contribution exceeds total bitcoin supply: {}", + "Channel {} cannot be spliced in; our {} contribution exceeds the total bitcoin supply", + self.context.channel_id(), + our_funding_contribution, + ))); + } + + if our_funding_contribution < -SignedAmount::MAX_MONEY { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} cannot be spliced out; our {} contribution exhausts the total bitcoin supply", self.context.channel_id(), our_funding_contribution, ))); @@ -10839,22 +10849,38 @@ where let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); if their_funding_contribution > SignedAmount::MAX_MONEY { return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} cannot be spliced; their contribution exceeds total bitcoin supply: {}", + "Channel {} cannot be spliced in; their {} contribution exceeds the total bitcoin supply", self.context.channel_id(), their_funding_contribution, ))); } - debug_assert_eq!(our_funding_contribution, SignedAmount::ZERO); - if their_funding_contribution < SignedAmount::ZERO { + if their_funding_contribution < -SignedAmount::MAX_MONEY { return Err(ChannelError::WarnAndDisconnect(format!( - "Splice-out not supported, only splice in, contribution is {} ({} + {})", - their_funding_contribution + our_funding_contribution, + "Channel {} cannot be spliced out; their {} contribution exhausts the total bitcoin supply", + self.context.channel_id(), their_funding_contribution, - our_funding_contribution, ))); } + let their_channel_balance = Amount::from_sat(self.funding.get_value_satoshis()) + - Amount::from_sat(self.funding.get_value_to_self_msat() / 1000); + let post_channel_balance = AddSigned::checked_add_signed( + their_channel_balance.to_sat(), + their_funding_contribution.to_sat(), + ); + + if post_channel_balance.is_none() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} cannot be spliced out; their {} contribution exhausts their channel balance: {}", + self.context.channel_id(), + their_funding_contribution, + their_channel_balance, + ))); + } + + // TODO(splicing): Check that channel balance does not go below the channel reserve + let splice_funding = FundingScope::for_splice( &self.funding, &self.context, @@ -10988,10 +11014,34 @@ where let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); if their_funding_contribution > SignedAmount::MAX_MONEY { - return Err(ChannelError::Warn(format!( - "Channel {} cannot be spliced; their contribution exceeds total bitcoin supply: {}", + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} cannot be spliced in; their {} contribution exceeds the total bitcoin supply", + self.context.channel_id(), + their_funding_contribution, + ))); + } + + if their_funding_contribution < -SignedAmount::MAX_MONEY { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} cannot be spliced out; their {} contribution exhausts the total bitcoin supply", + self.context.channel_id(), + their_funding_contribution, + ))); + } + + let their_channel_balance = Amount::from_sat(self.funding.get_value_satoshis()) + - Amount::from_sat(self.funding.get_value_to_self_msat() / 1000); + let post_channel_balance = AddSigned::checked_add_signed( + their_channel_balance.to_sat(), + their_funding_contribution.to_sat(), + ); + + if post_channel_balance.is_none() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} cannot be spliced out; their {} contribution exhausts their channel balance: {}", self.context.channel_id(), their_funding_contribution, + their_channel_balance, ))); } From ef24de4bd61580c346e6af485d7a4c2d7bb1ceae Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 22 Aug 2025 15:57:50 -0500 Subject: [PATCH 11/11] f - DRY --- lightning/src/ln/channel.rs | 82 +++++++++++++++---------------------- 1 file changed, 32 insertions(+), 50 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 4861a8f71f7..749a6a8b34d 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10847,6 +10847,33 @@ where } let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); + self.validate_splice_contribution(their_funding_contribution)?; + + // TODO(splicing): Check that channel balance does not go below the channel reserve + + let splice_funding = FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution, + their_funding_contribution, + msg.funding_pubkey, + )?; + + // TODO(splicing): Once splice acceptor can contribute, check that inputs are sufficient, + // similarly to the check in `splice_channel`. + + // Note on channel reserve requirement pre-check: as the splice acceptor does not contribute, + // it can't go below reserve, therefore no pre-check is done here. + + // TODO(splicing): Early check for reserve requirement + + Ok(splice_funding) + } + + #[cfg(splicing)] + fn validate_splice_contribution( + &self, their_funding_contribution: SignedAmount, + ) -> Result<(), ChannelError> { if their_funding_contribution > SignedAmount::MAX_MONEY { return Err(ChannelError::WarnAndDisconnect(format!( "Channel {} cannot be spliced in; their {} contribution exceeds the total bitcoin supply", @@ -10879,25 +10906,7 @@ where ))); } - // TODO(splicing): Check that channel balance does not go below the channel reserve - - let splice_funding = FundingScope::for_splice( - &self.funding, - &self.context, - our_funding_contribution, - their_funding_contribution, - msg.funding_pubkey, - )?; - - // TODO(splicing): Once splice acceptor can contribute, check that inputs are sufficient, - // similarly to the check in `splice_channel`. - - // Note on channel reserve requirement pre-check: as the splice acceptor does not contribute, - // it can't go below reserve, therefore no pre-check is done here. - - // TODO(splicing): Early check for reserve requirement - - Ok(splice_funding) + Ok(()) } /// See also [`validate_splice_init`] @@ -11013,37 +11022,7 @@ where debug_assert!(our_funding_contribution <= SignedAmount::MAX_MONEY); let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); - if their_funding_contribution > SignedAmount::MAX_MONEY { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} cannot be spliced in; their {} contribution exceeds the total bitcoin supply", - self.context.channel_id(), - their_funding_contribution, - ))); - } - - if their_funding_contribution < -SignedAmount::MAX_MONEY { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} cannot be spliced out; their {} contribution exhausts the total bitcoin supply", - self.context.channel_id(), - their_funding_contribution, - ))); - } - - let their_channel_balance = Amount::from_sat(self.funding.get_value_satoshis()) - - Amount::from_sat(self.funding.get_value_to_self_msat() / 1000); - let post_channel_balance = AddSigned::checked_add_signed( - their_channel_balance.to_sat(), - their_funding_contribution.to_sat(), - ); - - if post_channel_balance.is_none() { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} cannot be spliced out; their {} contribution exhausts their channel balance: {}", - self.context.channel_id(), - their_funding_contribution, - their_channel_balance, - ))); - } + self.validate_splice_contribution(their_funding_contribution)?; let splice_funding = FundingScope::for_splice( &self.funding, @@ -11081,6 +11060,9 @@ where let tx_msg_opt = interactive_tx_constructor.take_initiator_first_message(); debug_assert!(self.interactive_tx_signing_session.is_none()); + + let pending_splice = + self.pending_splice.as_mut().expect("pending_splice should still be set"); pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction( splice_funding, interactive_tx_constructor,