From b084ee6762e02f6c3cd171a0dc7493721202becb Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 28 Jul 2025 12:15:48 -0500 Subject: [PATCH 01/21] 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 | 116 +++++++++++++++++++---------- lightning/src/ln/channelmanager.rs | 4 +- lightning/src/ln/interactivetxs.rs | 19 ++--- 3 files changed, 88 insertions(+), 51 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 0796369d4c6..9d839f0b18a 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}; @@ -2254,20 +2254,22 @@ 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 <= 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(); @@ -5965,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, @@ -6021,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(), @@ -10636,11 +10638,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, ), }); } @@ -10653,7 +10666,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, @@ -10677,7 +10690,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, @@ -10698,7 +10711,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, @@ -10709,10 +10722,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. @@ -10732,21 +10743,38 @@ where ))); } - if their_funding_contribution_satoshis.saturating_add(our_funding_contribution_satoshis) < 0 - { + 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, )?; @@ -10771,7 +10799,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, @@ -10785,7 +10814,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, @@ -10823,7 +10852,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, }) @@ -10870,15 +10899,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, )?; @@ -12476,7 +12513,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, @@ -12586,10 +12623,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( @@ -12616,9 +12654,7 @@ where current_chain_height, logger, false, - - our_funding_satoshis, - + our_funding_contribution_sats, counterparty_pubkeys, channel_type, holder_selected_channel_reserve_satoshis, @@ -12633,7 +12669,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, @@ -12657,7 +12693,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| { @@ -12738,7 +12774,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 6f411273aab..6ca232f71a6 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}; @@ -9198,7 +9198,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 3fcf3f4ee01..be1a097c2d6 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::policy::MAX_STANDARD_TX_WEIGHT; @@ -1886,8 +1886,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; @@ -1964,7 +1964,8 @@ mod tests { use bitcoin::secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey}; use bitcoin::transaction::Version; 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; @@ -2993,7 +2994,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, @@ -3016,7 +3017,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!( @@ -3027,7 +3028,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!( @@ -3038,7 +3039,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!( @@ -3049,7 +3050,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 fe6f0038588944c040a7c6473139a8d68cb307fd Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 30 Jul 2025 11:07:02 -0500 Subject: [PATCH 02/21] 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 9d839f0b18a..b9576bf2a41 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, @@ -10691,7 +10688,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), @@ -10810,12 +10806,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), @@ -12514,8 +12508,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, @@ -12670,7 +12662,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 be1a097c2d6..ba686042043 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -2995,7 +2995,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 0e30acc01bd4f1fa96629bd9ea2c71e89b364377 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 30 Jul 2025 21:20:34 -0500 Subject: [PATCH 03/21] 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 | 29 +++++++--- 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, 105 insertions(+), 124 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index b9576bf2a41..6396351de68 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -85,9 +85,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 +5977,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. @@ -10678,10 +10676,23 @@ 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 > u16::MAX as usize { + return Err(APIError::APIMisuseError { + err: format!("Funding input's prevtx is too large for tx_add_input"), + }); + } + + funding_inputs.push((txin, tx)); } let prev_funding_input = self.funding.to_splice_funding_input(); @@ -12458,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 ba686042043..428a2fe83c5 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -27,7 +27,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; @@ -676,10 +675,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: @@ -860,14 +858,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), @@ -1247,7 +1240,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, } @@ -1652,7 +1645,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, @@ -1694,7 +1687,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); } } @@ -1703,7 +1696,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) @@ -1892,12 +1885,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)?; @@ -1954,7 +1946,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; @@ -2018,12 +2009,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), @@ -2289,7 +2280,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 @@ -2302,7 +2293,7 @@ mod tests { sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, witness: Default::default(), }; - (txin, TransactionU16LenLimited::new(tx.clone()).unwrap()) + (txin, tx.clone()) }) .collect() } @@ -2350,12 +2341,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); @@ -2368,7 +2359,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() @@ -2379,7 +2370,7 @@ mod tests { sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, witness: Default::default(), }; - (input, TransactionU16LenLimited::new(tx.clone()).unwrap()) + (input, tx.clone()) }) .collect(); @@ -2590,10 +2581,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 { @@ -2609,7 +2599,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() }; @@ -2627,7 +2617,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() }; @@ -2644,7 +2634,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() }; @@ -2977,9 +2967,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 a88a5f76c7e..080870646c8 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 dc0b7b771a88441baab6537afb62481b99267c4c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 6 Aug 2025 21:02:21 -0500 Subject: [PATCH 04/21] f - use LN_MAX_MSG_LEN --- lightning/src/ln/channel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 6396351de68..881833d29f5 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -39,7 +39,6 @@ use crate::chain::transaction::{OutPoint, TransactionData}; use crate::chain::BestBlock; use crate::events::bump_transaction::BASE_INPUT_WEIGHT; use crate::events::{ClosureReason, Event}; -use crate::ln::chan_utils; #[cfg(splicing)] use crate::ln::chan_utils::FUNDING_TRANSACTION_WITNESS_WEIGHT; use crate::ln::chan_utils::{ @@ -72,6 +71,7 @@ use crate::ln::onion_utils::{ }; use crate::ln::script::{self, ShutdownScript}; use crate::ln::types::ChannelId; +use crate::ln::{chan_utils, LN_MAX_MSG_LEN}; use crate::routing::gossip::NodeId; use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::tx_builder::{SpecTxBuilder, TxBuilder}; @@ -10686,7 +10686,7 @@ where shared_input_txid: None, }; let message_len = MESSAGE_TEMPLATE.serialized_length() + tx.serialized_length(); - if message_len > u16::MAX as usize { + if message_len > LN_MAX_MSG_LEN { return Err(APIError::APIMisuseError { err: format!("Funding input's prevtx is too large for tx_add_input"), }); From 63413b0d356e6159a30ec8f68897641692c8a272 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 6 Aug 2025 21:09:25 -0500 Subject: [PATCH 05/21] f - log outpoint --- lightning/src/ln/channel.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 881833d29f5..df87cd661cc 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10688,7 +10688,10 @@ where 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's prevtx is too large for tx_add_input"), + err: format!( + "Funding input references a prevtx that is too large for tx_add_input: {}", + txin.previous_output, + ), }); } From 229086d5c7d088c147ef96721b5945a1b0fe8fd7 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 7 Aug 2025 16:05:20 -0500 Subject: [PATCH 06/21] f - fix lint warning --- lightning/src/ln/channel.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index df87cd661cc..3b7daedcc29 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -39,6 +39,7 @@ use crate::chain::transaction::{OutPoint, TransactionData}; use crate::chain::BestBlock; use crate::events::bump_transaction::BASE_INPUT_WEIGHT; use crate::events::{ClosureReason, Event}; +use crate::ln::chan_utils; #[cfg(splicing)] use crate::ln::chan_utils::FUNDING_TRANSACTION_WITNESS_WEIGHT; use crate::ln::chan_utils::{ @@ -71,7 +72,8 @@ use crate::ln::onion_utils::{ }; use crate::ln::script::{self, ShutdownScript}; use crate::ln::types::ChannelId; -use crate::ln::{chan_utils, LN_MAX_MSG_LEN}; +#[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}; From b99da94e6cfab773697c4ff750f4aadca98ba7f0 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 6 Aug 2025 15:47:27 -0500 Subject: [PATCH 07/21] 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 | 10 ++++++---- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 3b7daedcc29..1c1a6c93a49 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, @@ -10676,9 +10679,8 @@ where err, ), })?; - // Convert inputs - let mut funding_inputs = Vec::new(); - for (txin, tx, _) in our_funding_inputs.into_iter() { + + for (_, tx, _) in our_funding_inputs.iter() { const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { channel_id: ChannelId([0; 32]), serial_id: 0, @@ -10696,8 +10698,6 @@ where ), }); } - - funding_inputs.push((txin, tx)); } let prev_funding_input = self.funding.to_splice_funding_input(); @@ -10707,7 +10707,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, }; @@ -12474,7 +12474,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 @@ -12688,6 +12688,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 { @@ -12698,7 +12700,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 428a2fe83c5..1f2e3802a13 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -1884,7 +1884,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); @@ -1894,6 +1894,7 @@ pub(super) fn calculate_change_output_value( .get(txin.previous_output.vout as usize) .ok_or(AbortReason::PrevTxOutInvalid)?; total_input_satoshis = total_input_satoshis.saturating_add(output.value.to_sat()); + // FIXME: Can we use the Weight from context.our_funding_inputs? let weight = estimate_input_weight(output).to_wu(); our_funding_inputs_weight = our_funding_inputs_weight.saturating_add(weight); } @@ -1956,7 +1957,7 @@ mod tests { use bitcoin::transaction::Version; use bitcoin::{ OutPoint, PubkeyHash, ScriptBuf, Sequence, SignedAmount, Transaction, TxIn, TxOut, - WPubkeyHash, Witness, + WPubkeyHash, Weight, Witness, }; use core::ops::Deref; @@ -2967,9 +2968,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 bd860786acc32b780a00f9fcdfa47ebf9e20cacc Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 6 Aug 2025 21:19:43 -0500 Subject: [PATCH 08/21] f - drop FIXME --- lightning/src/ln/interactivetxs.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 1f2e3802a13..b17412d292f 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -1894,7 +1894,6 @@ pub(super) fn calculate_change_output_value( .get(txin.previous_output.vout as usize) .ok_or(AbortReason::PrevTxOutInvalid)?; total_input_satoshis = total_input_satoshis.saturating_add(output.value.to_sat()); - // FIXME: Can we use the Weight from context.our_funding_inputs? let weight = estimate_input_weight(output).to_wu(); our_funding_inputs_weight = our_funding_inputs_weight.saturating_add(weight); } From c9db4994d855a93dd242f7eeb457a59450862d15 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 6 Aug 2025 17:01:52 -0500 Subject: [PATCH 09/21] 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 | 57 +++++++++++++---------- lightning/src/ln/channelmanager.rs | 27 ++++++++--- lightning/src/ln/dual_funding_tests.rs | 6 ++- lightning/src/ln/functional_test_utils.rs | 16 +++---- lightning/src/ln/interactivetxs.rs | 18 +++---- 5 files changed, 75 insertions(+), 49 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 1c1a6c93a49..abae725992f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -53,7 +53,7 @@ use crate::ln::channel_state::{ OutboundHTLCDetails, OutboundHTLCStateDetails, }; use crate::ln::channelmanager::{ - self, FundingConfirmedMessage, HTLCFailureMsg, HTLCSource, OpenChannelMessage, + self, FundingConfirmedMessage, FundingTxInput, HTLCFailureMsg, HTLCSource, OpenChannelMessage, PaymentClaimDetails, PendingHTLCInfo, PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; @@ -5916,10 +5916,11 @@ 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_witness_weight = Weight::from_wu(funding_inputs.iter().map(|(_, _, w)| w.to_wu()).sum()); + let mut total_input_witness_weight = + funding_inputs.iter().map(|FundingTxInput { witness_weight, .. }| witness_weight).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 @@ -5929,13 +5930,13 @@ fn check_v2_funding_inputs_sufficient( let estimated_fee = estimate_v2_funding_transaction_fee(is_initiator, funding_inputs_len, total_input_witness_weight, 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) { + for (idx, FundingTxInput { txin, prevtx, .. }) in funding_inputs.iter().enumerate() { + if let Some(output) = prevtx.output.get(txin.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 + prevtx.compute_txid(), txin.previous_output.vout, idx ))); } } @@ -5979,7 +5980,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. @@ -6051,8 +6052,11 @@ 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 { txin, prevtx, .. }| (txin, prevtx)) + .collect(); let constructor_args = InteractiveTxConstructorArgs { entropy_source, @@ -10612,9 +10616,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) @@ -10680,7 +10683,7 @@ where ), })?; - for (_, tx, _) in our_funding_inputs.iter() { + for FundingTxInput { txin, prevtx, .. } in our_funding_inputs.iter() { const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { channel_id: ChannelId([0; 32]), serial_id: 0, @@ -10689,7 +10692,7 @@ where sequence: 0, 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!( @@ -12474,7 +12477,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 @@ -12688,8 +12691,10 @@ 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 { txin, prevtx, .. }| (txin, prevtx)) + .collect(); let interactive_tx_constructor = Some(InteractiveTxConstructor::new( InteractiveTxConstructorArgs { @@ -14125,7 +14130,7 @@ mod tests { TOTAL_BITCOIN_SUPPLY_SATOSHIS, }; use crate::ln::channel_keys::{RevocationBasepoint, RevocationKey}; - use crate::ln::channelmanager::{self, HTLCSource, PaymentId}; + use crate::ln::channelmanager::{self, FundingTxInput, HTLCSource, PaymentId}; use crate::ln::msgs; use crate::ln::msgs::{ChannelUpdate, UnsignedChannelUpdate, MAX_VALUE_MSAT}; use crate::ln::onion_utils::{AttributionData, LocalHTLCFailureReason}; @@ -15898,19 +15903,21 @@ mod tests { #[cfg(splicing)] #[rustfmt::skip] - fn funding_input_sats(input_value_sats: u64) -> (TxIn, Transaction, Weight) { + fn funding_input_sats(input_value_sats: u64) -> FundingTxInput { use crate::sign::P2WPKH_WITNESS_WEIGHT; - let input_1_prev_out = TxOut { value: Amount::from_sat(input_value_sats), script_pubkey: ScriptBuf::default() }; - let input_1_prev_tx = Transaction { - input: vec![], output: vec![input_1_prev_out], + let prevout = TxOut { value: Amount::from_sat(input_value_sats), script_pubkey: ScriptBuf::default() }; + let prevtx = Transaction { + input: vec![], output: vec![prevout], version: Version::TWO, lock_time: bitcoin::absolute::LockTime::ZERO, }; - let input_1_txin = TxIn { - previous_output: bitcoin::OutPoint { txid: input_1_prev_tx.compute_txid(), vout: 0 }, + let txin = TxIn { + previous_output: bitcoin::OutPoint { txid: prevtx.compute_txid(), vout: 0 }, ..Default::default() }; - (input_1_txin, input_1_prev_tx, Weight::from_wu(P2WPKH_WITNESS_WEIGHT)) + let witness_weight = Weight::from_wu(P2WPKH_WITNESS_WEIGHT); + + FundingTxInput { txin, prevtx, witness_weight } } #[cfg(splicing)] diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 6ca232f71a6..dc6390ad1f2 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, TxIn, Weight}; use crate::blinded_path::message::MessageForwardNode; use crate::blinded_path::message::{AsyncPaymentsContext, OffersContext}; @@ -200,6 +200,22 @@ pub use crate::ln::outbound_payment::{ }; use crate::ln::script::ShutdownScript; +/// 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 { + /// An input for the funding transaction used to cover the channel contributions. + pub txin: TxIn, + + /// The transaction containing the unspent [`TxOut`] referenced by [`txin`]. + /// + /// [`txin`]: Self::txin + pub prevtx: Transaction, + + /// The weight of the witness that is needed to spend the [`TxOut`] referenced by [`txin`]. + pub witness_weight: Weight, +} + // We hold various information about HTLC relay in the HTLC objects in Channel itself: // // Upon receipt of an HTLC from a peer, we'll give it a PendingHTLCStatus indicating if it should @@ -4437,7 +4453,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(()); @@ -4458,9 +4474,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..d40bdf1c2df 100644 --- a/lightning/src/ln/dual_funding_tests.rs +++ b/lightning/src/ln/dual_funding_tests.rs @@ -18,6 +18,7 @@ use { }, crate::ln::channel::PendingV2Channel, crate::ln::channel_keys::{DelayedPaymentBasepoint, HtlcBasepoint, RevocationBasepoint}, + crate::ln::channelmanager::FundingTxInput, crate::ln::functional_test_utils::*, crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, MessageSendEvent}, crate::ln::msgs::{CommitmentSigned, TxAddInput, TxAddOutput, TxComplete, TxSignatures}, @@ -82,12 +83,13 @@ fn do_test_v2_channel_establishment(session: V2ChannelEstablishmentTestSession) &RevocationBasepoint::from(open_channel_v2_msg.common_fields.revocation_basepoint), ); + let FundingTxInput { txin, 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: txin.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 00e883e8561..2fa21b4d00f 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -23,8 +23,8 @@ use crate::events::{ }; use crate::ln::chan_utils::{commitment_tx_base_weight, COMMITMENT_TX_WEIGHT_PER_HTLC}; use crate::ln::channelmanager::{ - AChannelManager, ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, - RAACommitmentOrder, RecipientOnionFields, MIN_CLTV_EXPIRY_DELTA, + AChannelManager, ChainParameters, ChannelManager, ChannelManagerReadArgs, FundingTxInput, + PaymentId, RAACommitmentOrder, RecipientOnionFields, MIN_CLTV_EXPIRY_DELTA, }; use crate::ln::msgs; use crate::ln::msgs::{ @@ -1440,7 +1440,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, @@ -1462,17 +1462,17 @@ pub fn create_dual_funding_utxos_with_prev_txs( let mut inputs = vec![]; for i in 0..utxo_values_in_satoshis.len() { - inputs.push(( - TxIn { + inputs.push(FundingTxInput { + txin: 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), - )); + prevtx: tx.clone(), + witness_weight: Weight::from_wu(P2WPKH_WITNESS_WEIGHT), + }); } inputs diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index b17412d292f..29195489239 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -23,6 +23,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::channelmanager::FundingTxInput; use crate::ln::msgs; use crate::ln::msgs::{MessageSendEvent, SerialId, TxSignatures}; use crate::ln::types::ChannelId; @@ -1884,12 +1885,12 @@ 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(); + for FundingTxInput { txin, prevtx, .. } in context.our_funding_inputs.iter() { + let txid = prevtx.compute_txid(); if txin.previous_output.txid != txid { return Err(AbortReason::PrevTxOutInvalid); } - let output = tx + let output = prevtx .output .get(txin.previous_output.vout as usize) .ok_or(AbortReason::PrevTxOutInvalid)?; @@ -1937,6 +1938,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::channelmanager::FundingTxInput; use crate::ln::interactivetxs::{ calculate_change_output_value, generate_holder_serial_id, AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, @@ -2954,23 +2956,23 @@ mod tests { 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 txid = prevtx.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) + let witness_weight = Weight::ZERO; + FundingTxInput { txin, prevtx, witness_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 5b15f9bdcd1063b720110f92ae1d9200044e7e10 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 6 Aug 2025 22:03:45 -0500 Subject: [PATCH 10/21] f - use witness_weight --- lightning/src/ln/interactivetxs.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 29195489239..dbc54a181ea 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -1885,7 +1885,7 @@ pub(super) fn calculate_change_output_value( let mut total_input_satoshis = 0u64; let mut our_funding_inputs_weight = 0u64; - for FundingTxInput { txin, prevtx, .. } in context.our_funding_inputs.iter() { + for FundingTxInput { txin, prevtx, witness_weight } in context.our_funding_inputs.iter() { let txid = prevtx.compute_txid(); if txin.previous_output.txid != txid { return Err(AbortReason::PrevTxOutInvalid); @@ -1895,7 +1895,8 @@ pub(super) fn calculate_change_output_value( .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(); + + let weight = BASE_INPUT_WEIGHT + EMPTY_SCRIPT_SIG_WEIGHT + witness_weight.to_wu(); our_funding_inputs_weight = our_funding_inputs_weight.saturating_add(weight); } @@ -1964,7 +1965,7 @@ mod tests { use super::{ get_output_weight, P2TR_INPUT_WEIGHT_LOWER_BOUND, P2WPKH_INPUT_WEIGHT_LOWER_BOUND, - P2WSH_INPUT_WEIGHT_LOWER_BOUND, TX_COMMON_FIELDS_WEIGHT, + P2WPKH_WITNESS_WEIGHT, P2WSH_INPUT_WEIGHT_LOWER_BOUND, TX_COMMON_FIELDS_WEIGHT, }; const TEST_FEERATE_SATS_PER_KW: u32 = FEERATE_FLOOR_SATS_PER_KW * 10; @@ -2969,7 +2970,7 @@ mod tests { sequence: Sequence::ZERO, witness: Witness::new(), }; - let witness_weight = Weight::ZERO; + let witness_weight = Weight::from_wu(P2WPKH_WITNESS_WEIGHT); FundingTxInput { txin, prevtx, witness_weight } }) .collect(); From f5933cbdbf3a1f1b45081c4c377bb480b53c0526 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 7 Aug 2025 15:42:37 -0500 Subject: [PATCH 11/21] f - fix doc links --- lightning/src/ln/channelmanager.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index dc6390ad1f2..1673e6f4d08 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -209,10 +209,14 @@ pub struct FundingTxInput { /// The transaction containing the unspent [`TxOut`] referenced by [`txin`]. /// + /// [`TxOut`]: bitcoin::TxOut /// [`txin`]: Self::txin pub prevtx: Transaction, /// The weight of the witness that is needed to spend the [`TxOut`] referenced by [`txin`]. + /// + /// [`TxOut`]: bitcoin::TxOut + /// [`txin`]: Self::txin pub witness_weight: Weight, } From 4913b30310f180b6e6c701aa292afcb690d04b85 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 7 Aug 2025 16:18:38 -0500 Subject: [PATCH 12/21] f - fix lint warnings --- lightning/src/ln/channel.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index abae725992f..cd6b2efb2d4 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -13,7 +13,7 @@ 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::transaction::{Transaction, TxOut}; use bitcoin::Weight; use bitcoin::hash_types::{BlockHash, Txid}; @@ -26,7 +26,7 @@ use bitcoin::secp256k1::{ecdsa::Signature, Secp256k1}; use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::{secp256k1, sighash}; #[cfg(splicing)] -use bitcoin::{Sequence, Witness}; +use bitcoin::{Sequence, TxIn, Witness}; use crate::chain::chaininterface::{ fee_for_weight, ConfirmationTarget, FeeEstimator, LowerBoundedFeeEstimator, @@ -14130,7 +14130,9 @@ mod tests { TOTAL_BITCOIN_SUPPLY_SATOSHIS, }; use crate::ln::channel_keys::{RevocationBasepoint, RevocationKey}; - use crate::ln::channelmanager::{self, FundingTxInput, HTLCSource, PaymentId}; + #[cfg(splicing)] + use crate::ln::channelmanager::FundingTxInput; + use crate::ln::channelmanager::{self, HTLCSource, PaymentId}; use crate::ln::msgs; use crate::ln::msgs::{ChannelUpdate, UnsignedChannelUpdate, MAX_VALUE_MSAT}; use crate::ln::onion_utils::{AttributionData, LocalHTLCFailureReason}; From 5839ae440d21981c999336d663c3c5db30370326 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 6 Aug 2025 18:11:31 -0500 Subject: [PATCH 13/21] 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 | 64 +++++++++++++++++++++++------- lightning/src/ln/splicing_tests.rs | 24 ++++++++--- 3 files changed, 74 insertions(+), 26 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index cd6b2efb2d4..c3403ab6064 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -52,6 +52,8 @@ use crate::ln::channel_state::{ ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, InboundHTLCStateDetails, OutboundHTLCDetails, OutboundHTLCStateDetails, }; +#[cfg(splicing)] +use crate::ln::channelmanager::SpliceContribution; use crate::ln::channelmanager::{ self, FundingConfirmedMessage, FundingTxInput, HTLCFailureMsg, HTLCSource, OpenChannelMessage, PaymentClaimDetails, PendingHTLCInfo, PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, @@ -10616,8 +10618,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) @@ -10641,7 +10642,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!( @@ -10670,7 +10671,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, @@ -10683,7 +10684,7 @@ where ), })?; - for FundingTxInput { txin, prevtx, .. } in our_funding_inputs.iter() { + for FundingTxInput { txin, prevtx, .. } in contribution.inputs().iter() { const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { channel_id: ChannelId([0; 32]), serial_id: 0, @@ -10704,6 +10705,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 1673e6f4d08..c31f06c8933 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}; -#[cfg(splicing)] -use bitcoin::ScriptBuf; use bitcoin::{secp256k1, Sequence, SignedAmount, TxIn, Weight}; +#[cfg(splicing)] +use bitcoin::{Amount, ScriptBuf}; use crate::blinded_path::message::MessageForwardNode; use crate::blinded_path::message::{AsyncPaymentsContext, OffersContext}; @@ -200,6 +200,47 @@ pub use crate::ln::outbound_payment::{ }; use crate::ln::script::ShutdownScript; +/// The components of a splice's funding transaction that are contributed by one party. +#[cfg(splicing)] +pub enum SpliceContribution { + /// When only inputs -- except for a possible change output -- are contributed to the splice. + SpliceIn { + /// The amount to contribute to the splice. + value: Amount, + + /// The inputs used 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, if 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)] @@ -4456,14 +4497,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 { @@ -4478,8 +4518,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(); @@ -4500,13 +4539,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/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 080870646c8..3e2dd385744 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -7,10 +7,13 @@ // You may not use this file except in accordance with one or both of these // licenses. +use crate::ln::channelmanager::SpliceContribution; use crate::ln::functional_test_utils::*; 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 ) @@ -317,13 +325,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 009ffa261f0d762735d42202100723ac77e7a864 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 7 Aug 2025 15:47:08 -0500 Subject: [PATCH 14/21] f - rephrase docs --- lightning/src/ln/channelmanager.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index c31f06c8933..7915c390ab7 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -203,17 +203,17 @@ use crate::ln::script::ShutdownScript; /// The components of a splice's funding transaction that are contributed by one party. #[cfg(splicing)] pub enum SpliceContribution { - /// When only inputs -- except for a possible change output -- are contributed to the splice. + /// When funds are added to a channel. SpliceIn { /// The amount to contribute to the splice. value: Amount, - /// The inputs used to meet the contributed amount. Any excess amount will be sent to a - /// change output. + /// The inputs used to include in the splice's funding transaction used 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, if not set, generated - /// using `SignerProvider::get_destination_script`. + /// An optional change output script. This will be used if needed or, when not set, + /// generated using `SignerProvider::get_destination_script`. change_script: Option, }, } From 2b57d4c0d6e685b21beb7c57d14f3352e1072e2b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 31 Jul 2025 10:04:53 -0500 Subject: [PATCH 15/21] 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 | 154 +++++++++++++++++++---------- lightning/src/ln/channelmanager.rs | 28 +++++- lightning/src/ln/interactivetxs.rs | 16 +-- 3 files changed, 136 insertions(+), 62 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index c3403ab6064..22d289f6d4a 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -5983,6 +5983,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. @@ -6012,10 +6015,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()), @@ -6023,34 +6024,38 @@ 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); } } @@ -10646,43 +10651,84 @@ 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 exceeds 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) + let funding_inputs = contribution.inputs(); + let funding_outputs = contribution.outputs(); + if !funding_inputs.is_empty() && !funding_outputs.is_empty() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be both spliced in and out; operation not supported", + self.context.channel_id(), + ), + }); + } + + if our_funding_contribution < SignedAmount::ZERO { + // TODO(splicing): Check that channel balance does not go below the channel reserve + let post_channel_value = AddSigned::checked_add_signed( + self.funding.get_value_satoshis(), + our_funding_contribution.to_sat(), + ); + // FIXME: Should we check value_to_self instead? Do HTLCs need to be accounted for? + // FIXME: Check that we can pay for the outputs from the channel value? + if post_channel_value.is_none() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced out; contribution exceeds the channel value: {}", + self.context.channel_id(), + our_funding_contribution, + ), + }); + } - // 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) + let value_removed: Amount = + contribution.outputs().iter().map(|txout| txout.value).sum(); + let negated_value_removed = -value_removed.to_signed().unwrap_or(SignedAmount::MAX); + if negated_value_removed != our_funding_contribution { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced out; unexpected txout amounts: {}", + self.context.channel_id(), + value_removed, + ), + }); + } + } else { + // 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, - ) - .map_err(|err| APIError::APIMisuseError { - err: format!( - "Insufficient inputs for splicing; channel ID {}, err {}", - self.context.channel_id(), - err, - ), - })?; + // 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, + ) + .map_err(|err| APIError::APIMisuseError { + err: format!( + "Insufficient inputs for splicing; channel ID {}, err {}", + self.context.channel_id(), + err, + ), + })?; + } for FundingTxInput { txin, prevtx, .. } in contribution.inputs().iter() { const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { @@ -10705,7 +10751,7 @@ 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, @@ -10713,6 +10759,7 @@ where 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, }; @@ -10835,6 +10882,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, }; @@ -12533,6 +12581,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 { @@ -12687,6 +12736,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 { @@ -12710,7 +12760,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() }; diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7915c390ab7..0f2361905c6 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -32,7 +32,7 @@ use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::{secp256k1, Sequence, SignedAmount, TxIn, Weight}; #[cfg(splicing)] -use bitcoin::{Amount, ScriptBuf}; +use bitcoin::{Amount, ScriptBuf, TxOut}; use crate::blinded_path::message::MessageForwardNode; use crate::blinded_path::message::{AsyncPaymentsContext, OffersContext}; @@ -216,6 +216,14 @@ pub enum SpliceContribution { /// generated using `SignerProvider::get_destination_script`. change_script: Option, }, + /// When only outputs are contributed to then funding transaction. + SpliceOut { + /// The amount to remove from the channel. + value: Amount, + /// The outputs used for removing the amount. The total value of all outputs must equal + /// [`SpliceOut::value`]. + outputs: Vec, + }, } #[cfg(splicing)] @@ -225,18 +233,32 @@ impl SpliceContribution { SpliceContribution::SpliceIn { value, .. } => { value.to_signed().unwrap_or(SignedAmount::MAX) }, + SpliceContribution::SpliceOut { value, .. } => { + value.to_signed().map(|value| -value).unwrap_or(SignedAmount::MIN) + }, } } pub(super) fn inputs(&self) -> &[FundingTxInput] { match self { SpliceContribution::SpliceIn { inputs, .. } => &inputs[..], + SpliceContribution::SpliceOut { .. } => &[], + } + } + + pub(super) fn outputs(&self) -> &[TxOut] { + match self { + SpliceContribution::SpliceIn { .. } => &[], + SpliceContribution::SpliceOut { outputs, .. } => &outputs[..], } } - pub(super) fn into_tx_parts(self) -> (Vec, Option) { + pub(super) fn into_tx_parts(self) -> (Vec, Vec, Option) { match self { - SpliceContribution::SpliceIn { inputs, change_script, .. } => (inputs, change_script), + 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 dbc54a181ea..a531f1ab04e 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -1878,7 +1878,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; @@ -1900,6 +1900,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| { @@ -2993,17 +2994,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)), ); @@ -3014,7 +3016,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), ); @@ -3025,7 +3027,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), ); @@ -3036,7 +3038,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)), ); @@ -3048,7 +3050,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)), ); } From b68efb90adcc31da1617674ddde5f023c866d38b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 6 Aug 2025 22:23:06 -0500 Subject: [PATCH 16/21] f - drop SpliceOut::value --- lightning/src/ln/channel.rs | 13 ------------- lightning/src/ln/channelmanager.rs | 22 +++++++++++++--------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 22d289f6d4a..7868b49f4dc 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10696,19 +10696,6 @@ where ), }); } - - let value_removed: Amount = - contribution.outputs().iter().map(|txout| txout.value).sum(); - let negated_value_removed = -value_removed.to_signed().unwrap_or(SignedAmount::MAX); - if negated_value_removed != our_funding_contribution { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} cannot be spliced out; unexpected txout amounts: {}", - self.context.channel_id(), - value_removed, - ), - }); - } } else { // 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) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 0f2361905c6..8a7408cea2c 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -216,12 +216,10 @@ pub enum SpliceContribution { /// generated using `SignerProvider::get_destination_script`. change_script: Option, }, - /// When only outputs are contributed to then funding transaction. + /// When funds are removed from a channel. SpliceOut { - /// The amount to remove from the channel. - value: Amount, - /// The outputs used for removing the amount. The total value of all outputs must equal - /// [`SpliceOut::value`]. + /// 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, }, } @@ -233,8 +231,14 @@ impl SpliceContribution { SpliceContribution::SpliceIn { value, .. } => { value.to_signed().unwrap_or(SignedAmount::MAX) }, - SpliceContribution::SpliceOut { value, .. } => { - value.to_signed().map(|value| -value).unwrap_or(SignedAmount::MIN) + SpliceContribution::SpliceOut { outputs } => { + let value_removed = outputs + .iter() + .map(|txout| txout.value) + .sum::() + .to_signed() + .unwrap_or(SignedAmount::MAX); + -value_removed }, } } @@ -249,7 +253,7 @@ impl SpliceContribution { pub(super) fn outputs(&self) -> &[TxOut] { match self { SpliceContribution::SpliceIn { .. } => &[], - SpliceContribution::SpliceOut { outputs, .. } => &outputs[..], + SpliceContribution::SpliceOut { outputs } => &outputs[..], } } @@ -258,7 +262,7 @@ impl SpliceContribution { SpliceContribution::SpliceIn { inputs, change_script, .. } => { (inputs, vec![], change_script) }, - SpliceContribution::SpliceOut { outputs, .. } => (vec![], outputs, None), + SpliceContribution::SpliceOut { outputs } => (vec![], outputs, None), } } } From d9f10e43003e39376bd15f9d4bd303b4e4b53419 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 6 Aug 2025 22:29:32 -0500 Subject: [PATCH 17/21] f - use get_value_to_self_msat --- lightning/src/ln/channel.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 7868b49f4dc..510775ffb58 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10682,10 +10682,9 @@ where if our_funding_contribution < SignedAmount::ZERO { // TODO(splicing): Check that channel balance does not go below the channel reserve let post_channel_value = AddSigned::checked_add_signed( - self.funding.get_value_satoshis(), + self.funding.get_value_to_self_msat() / 1000, our_funding_contribution.to_sat(), ); - // FIXME: Should we check value_to_self instead? Do HTLCs need to be accounted for? // FIXME: Check that we can pay for the outputs from the channel value? if post_channel_value.is_none() { return Err(APIError::APIMisuseError { From 59fbd7e11a2f62aa456a3d54bf299cc82d29c7e6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 7 Aug 2025 18:35:57 -0500 Subject: [PATCH 18/21] f - check that splice-out outputs can be paid for by channel balance --- lightning/src/ln/channel.rs | 108 ++++++++++++++++++++--------- lightning/src/ln/splicing_tests.rs | 2 +- 2 files changed, 75 insertions(+), 35 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 510775ffb58..7e8f0106097 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -26,7 +26,7 @@ use bitcoin::secp256k1::{ecdsa::Signature, Secp256k1}; use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::{secp256k1, sighash}; #[cfg(splicing)] -use bitcoin::{Sequence, TxIn, Witness}; +use bitcoin::{FeeRate, Sequence, TxIn, Witness}; use crate::chain::chaininterface::{ fee_for_weight, ConfirmationTarget, FeeEstimator, LowerBoundedFeeEstimator, @@ -5879,6 +5879,40 @@ 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( + is_initiator, + 1, // spends the previous funding output + Weight::from_wu(FUNDING_TRANSACTION_WITNESS_WEIGHT), + 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. /// input_count: Number of contributed inputs. /// witness_weight: The witness weight for contributed inputs. @@ -10679,42 +10713,48 @@ where }); } - if our_funding_contribution < SignedAmount::ZERO { - // TODO(splicing): Check that channel balance does not go below the channel reserve - let post_channel_value = AddSigned::checked_add_signed( - self.funding.get_value_to_self_msat() / 1000, - our_funding_contribution.to_sat(), - ); - // FIXME: Check that we can pay for the outputs from the channel value? - if post_channel_value.is_none() { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} cannot be spliced out; contribution exceeds the channel value: {}", - self.context.channel_id(), - our_funding_contribution, - ), - }); - } - } else { - // 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) + // 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, - ) - .map_err(|err| APIError::APIMisuseError { + 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(|e| { + let splice_type = if our_funding_contribution < SignedAmount::ZERO { + "spliced out" + } else { + "spliced in" + }; + APIError::APIMisuseError { err: format!( - "Insufficient inputs for splicing; channel ID {}, err {}", + "Channel {} cannot be {}; {}", self.context.channel_id(), - err, + 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 splice-out value"); + + // 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 { txin, prevtx, .. } in contribution.inputs().iter() { const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { @@ -10740,7 +10780,7 @@ where 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), diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 3e2dd385744..19b274de75f 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -341,7 +341,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 e8dd4b64951a4708e2c25d6f6fe29c78e4044fc3 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 7 Aug 2025 19:05:32 -0500 Subject: [PATCH 19/21] f - include output weights --- lightning/src/ln/channel.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 7e8f0106097..8633c292482 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -5890,6 +5890,7 @@ fn check_splice_contribution_sufficient( is_initiator, 1, // spends the previous funding output Weight::from_wu(FUNDING_TRANSACTION_WITNESS_WEIGHT), + contribution.outputs(), funding_feerate.to_sat_per_kwu() as u32, )); @@ -5919,14 +5920,12 @@ fn check_splice_contribution_sufficient( #[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, witness_weight: Weight, outputs: &[TxOut], 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(outputs.iter().map(|txout| txout.weight().to_wu()).sum()); // If we are the initiator, we must pay for weight of all common fields in the funding transaction. if is_initiator { @@ -5963,7 +5962,7 @@ fn check_v2_funding_inputs_sufficient( funding_inputs_len += 1; total_input_witness_weight += Weight::from_wu(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_witness_weight, &[], funding_feerate_sat_per_1000_weight); let mut total_input_sats = 0u64; for (idx, FundingTxInput { txin, prevtx, .. }) in funding_inputs.iter().enumerate() { @@ -15952,31 +15951,31 @@ mod tests { // 2 inputs with weight 300, initiator, 2000 sat/kw feerate assert_eq!( - estimate_v2_funding_transaction_fee(true, 2, Weight::from_wu(300), 2000), + estimate_v2_funding_transaction_fee(true, 2, Weight::from_wu(300), &[], 2000), 1668 ); // higher feerate assert_eq!( - estimate_v2_funding_transaction_fee(true, 2, Weight::from_wu(300), 3000), + estimate_v2_funding_transaction_fee(true, 2, Weight::from_wu(300), &[], 3000), 2502 ); // only 1 input assert_eq!( - estimate_v2_funding_transaction_fee(true, 1, Weight::from_wu(300), 2000), + estimate_v2_funding_transaction_fee(true, 1, Weight::from_wu(300), &[], 2000), 1348 ); // 0 input weight assert_eq!( - estimate_v2_funding_transaction_fee(true, 1, Weight::from_wu(0), 2000), + estimate_v2_funding_transaction_fee(true, 1, Weight::from_wu(0), &[], 2000), 748 ); // not initiator assert_eq!( - estimate_v2_funding_transaction_fee(false, 1, Weight::from_wu(0), 2000), + estimate_v2_funding_transaction_fee(false, 1, Weight::from_wu(0), &[], 2000), 320 ); } From 56ed8ba08684d11b79ed0ec73df43df51852e6e4 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 7 Aug 2025 19:32:19 -0500 Subject: [PATCH 20/21] f - correctly adjust contribution for fees --- lightning/src/ln/channel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 8633c292482..99f87ad303a 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10742,7 +10742,7 @@ where // 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 splice-out value"); + - fees.to_signed().expect("fees should never exceed splice-out value"); // TODO(splicing): Check that channel balance does not go below the channel reserve let _post_channel_balance = AddSigned::checked_add_signed( @@ -10800,7 +10800,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, From fde2f617caef62085a0a59297c215e01b8134979 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 8 Aug 2025 08:34:35 -0500 Subject: [PATCH 21/21] 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 | 40 ++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 99f87ad303a..eb878eff4a9 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10832,9 +10832,19 @@ where ))); } + debug_assert_eq!(our_funding_contribution, SignedAmount::ZERO); + 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, ))); @@ -10843,22 +10853,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,