From 823e2511a10a5d7b8622402def156723d4cfd810 Mon Sep 17 00:00:00 2001 From: optout <13562139+optout21@users.noreply.github.com> Date: Tue, 3 Jun 2025 11:21:15 +0200 Subject: [PATCH 1/9] Rename DualFundingContext This is a simple rename, DualFundingContext to FundingNegotiationContext, to suggest that this is use not only in dual-funded channel open. Also rename the field dual_funding_context to funding_negotiation_context. --- lightning/src/ln/channel.rs | 38 +++++++++++++++--------------- lightning/src/ln/channelmanager.rs | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index b5b77972a6c..0df8d254ece 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2772,7 +2772,7 @@ where debug_assert!(self.interactive_tx_constructor.is_none()); let mut funding_inputs = Vec::new(); - mem::swap(&mut self.dual_funding_context.our_funding_inputs, &mut funding_inputs); + mem::swap(&mut self.funding_negotiation_context.our_funding_inputs, &mut funding_inputs); // TODO(splicing): Add prev funding tx as input, must be provided as a parameter @@ -2794,10 +2794,10 @@ where .map_err(|_err| AbortReason::InternalError("Error getting destination script"))? }; let change_value_opt = calculate_change_output_value( - self.funding.is_outbound(), self.dual_funding_context.our_funding_satoshis, + self.funding.is_outbound(), self.funding_negotiation_context.our_funding_satoshis, &funding_inputs, None, &shared_funding_output.script_pubkey, &funding_outputs, - self.dual_funding_context.funding_feerate_sat_per_1000_weight, + self.funding_negotiation_context.funding_feerate_sat_per_1000_weight, change_script.minimal_non_dust().to_sat(), )?; if let Some(change_value) = change_value_opt { @@ -2806,7 +2806,7 @@ where 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.dual_funding_context.funding_feerate_sat_per_1000_weight, change_output_weight); + let change_output_fee = fee_for_weight(self.funding_negotiation_context.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 > self.context.holder_dust_limit_satoshis { @@ -2820,12 +2820,12 @@ where holder_node_id, counterparty_node_id: self.context.counterparty_node_id, channel_id: self.context.channel_id(), - feerate_sat_per_kw: self.dual_funding_context.funding_feerate_sat_per_1000_weight, + feerate_sat_per_kw: self.funding_negotiation_context.funding_feerate_sat_per_1000_weight, is_initiator: self.funding.is_outbound(), - funding_tx_locktime: self.dual_funding_context.funding_tx_locktime, + funding_tx_locktime: self.funding_negotiation_context.funding_tx_locktime, inputs_to_contribute: funding_inputs, shared_funding_input: None, - shared_funding_output: SharedOwnedOutput::new(shared_funding_output, self.dual_funding_context.our_funding_satoshis), + shared_funding_output: SharedOwnedOutput::new(shared_funding_output, self.funding_negotiation_context.our_funding_satoshis), outputs_to_contribute: funding_outputs, }; let mut tx_constructor = InteractiveTxConstructor::new(constructor_args)?; @@ -2915,7 +2915,7 @@ where where L::Target: Logger { - let our_funding_satoshis = self.dual_funding_context.our_funding_satoshis; + let our_funding_satoshis = self.funding_negotiation_context.our_funding_satoshis; let transaction_number = self.unfunded_context.transaction_number(); let mut output_index = None; @@ -5853,8 +5853,8 @@ fn check_v2_funding_inputs_sufficient( } } -/// Context for dual-funded channels. -pub(super) struct DualFundingChannelContext { +/// Context for negotiating channels (dual-funded V2 open, splicing) +pub(super) struct FundingNegotiationContext { /// The amount in satoshis we will be contributing to the channel. pub our_funding_satoshis: u64, /// The amount in satoshis our counterparty will be contributing to the channel. @@ -12021,7 +12021,7 @@ where pub funding: FundingScope, pub context: ChannelContext, pub unfunded_context: UnfundedChannelContext, - pub dual_funding_context: DualFundingChannelContext, + pub funding_negotiation_context: FundingNegotiationContext, /// The current interactive transaction construction session under negotiation. pub interactive_tx_constructor: Option, /// The signing session created after `tx_complete` handling @@ -12084,7 +12084,7 @@ where unfunded_channel_age_ticks: 0, holder_commitment_point: HolderCommitmentPoint::new(&context.holder_signer, &context.secp_ctx), }; - let dual_funding_context = DualFundingChannelContext { + let funding_negotiation_context = FundingNegotiationContext { our_funding_satoshis: funding_satoshis, // TODO(dual_funding) TODO(splicing) Include counterparty contribution, once that's enabled their_funding_satoshis: None, @@ -12096,7 +12096,7 @@ where funding, context, unfunded_context, - dual_funding_context, + funding_negotiation_context, interactive_tx_constructor: None, interactive_tx_signing_session: None, }; @@ -12172,7 +12172,7 @@ where }, funding_feerate_sat_per_1000_weight: self.context.feerate_per_kw, second_per_commitment_point, - locktime: self.dual_funding_context.funding_tx_locktime.to_consensus_u32(), + locktime: self.funding_negotiation_context.funding_tx_locktime.to_consensus_u32(), require_confirmed_inputs: None, } } @@ -12238,7 +12238,7 @@ where &funding.get_counterparty_pubkeys().revocation_basepoint); context.channel_id = channel_id; - let dual_funding_context = DualFundingChannelContext { + let funding_negotiation_context = FundingNegotiationContext { our_funding_satoshis: our_funding_satoshis, their_funding_satoshis: Some(msg.common_fields.funding_satoshis), funding_tx_locktime: LockTime::from_consensus(msg.locktime), @@ -12256,8 +12256,8 @@ where holder_node_id, counterparty_node_id, channel_id: context.channel_id, - feerate_sat_per_kw: dual_funding_context.funding_feerate_sat_per_1000_weight, - funding_tx_locktime: dual_funding_context.funding_tx_locktime, + 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, shared_funding_input: None, @@ -12276,7 +12276,7 @@ where Ok(Self { funding, context, - dual_funding_context, + funding_negotiation_context, interactive_tx_constructor, interactive_tx_signing_session: None, unfunded_context, @@ -12342,7 +12342,7 @@ where }), channel_type: Some(self.funding.get_channel_type().clone()), }, - funding_satoshis: self.dual_funding_context.our_funding_satoshis, + funding_satoshis: self.funding_negotiation_context.our_funding_satoshis, second_per_commitment_point, require_confirmed_inputs: None, } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2214676ceb7..39d7904c200 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -9190,7 +9190,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.dual_funding_context.our_funding_satoshis != 0 { + if unfunded_chan.funding_negotiation_context.our_funding_satoshis != 0 { continue; } } From f1db67e58261ce877460cf4f3240a8f2fdb16757 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 17 Jul 2025 17:40:24 -0500 Subject: [PATCH 2/9] Track funding negotiation state with an enum PendingSplice holds a FundingScope being negotiated. However, when implementing funding negotiation, other states are possible depending on which party initiated the splice. Using an enum prevents needing various Option fields which may result in invalid states. When the user initiates the splice, the FundingNegotiationContext must be held until the counterparty responds with splice_ack. At that point enough information becomes available to create a new FundingScope and an InteractiveTxConstructor. When the counterparty initiates the splice, both a new FundingScope and an InteractiveTxConstructor can be created immediately when responding with splice_ack. After the transaction is constructed, those are no longer needed. At that point an InteractiveTxSigningSession is tracked until signatures are exchanged. --- lightning/src/ln/channel.rs | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 0df8d254ece..ac6cddead9b 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1821,7 +1821,11 @@ where ChannelPhase::Funded(mut funded_channel) => { #[cfg(splicing)] let has_negotiated_pending_splice = funded_channel.pending_splice.as_ref() - .map(|pending_splice| pending_splice.funding.is_some()) + .and_then(|pending_splice| pending_splice.funding_negotiation.as_ref()) + .filter(|funding_negotiation| { + matches!(funding_negotiation, FundingNegotiation::AwaitingSignatures(_)) + }) + .map(|funding_negotiation| funding_negotiation.as_funding().is_some()) .unwrap_or(false); #[cfg(splicing)] let session_received_commitment_signed = funded_channel @@ -2171,7 +2175,7 @@ impl FundingScope { #[cfg(splicing)] struct PendingSplice { pub our_funding_contribution: i64, - funding: Option, + funding_negotiation: Option, /// The funding txid used in the `splice_locked` sent to the counterparty. sent_funding_txid: Option, @@ -2180,6 +2184,24 @@ struct PendingSplice { received_funding_txid: Option, } +#[cfg(splicing)] +enum FundingNegotiation { + AwaitingAck(FundingNegotiationContext), + ConstructingTransaction(FundingScope, InteractiveTxConstructor), + AwaitingSignatures(FundingScope), +} + +#[cfg(splicing)] +impl FundingNegotiation { + fn as_funding(&self) -> Option<&FundingScope> { + match self { + FundingNegotiation::AwaitingAck(_) => None, + FundingNegotiation::ConstructingTransaction(funding, _) => Some(funding), + FundingNegotiation::AwaitingSignatures(funding) => Some(funding), + } + } +} + #[cfg(splicing)] impl PendingSplice { fn check_get_splice_locked( @@ -6808,7 +6830,11 @@ where let pending_splice_funding = self .pending_splice .as_ref() - .and_then(|pending_splice| pending_splice.funding.as_ref()) + .and_then(|pending_splice| pending_splice.funding_negotiation.as_ref()) + .filter(|funding_negotiation| { + matches!(funding_negotiation, FundingNegotiation::AwaitingSignatures(_)) + }) + .and_then(|funding_negotiation| funding_negotiation.as_funding()) .expect("Funding must exist for negotiated pending splice"); let (holder_commitment_tx, _) = self.context.validate_commitment_signed( pending_splice_funding, @@ -10436,7 +10462,7 @@ where self.pending_splice = Some(PendingSplice { our_funding_contribution: our_funding_contribution_satoshis, - funding: None, + funding_negotiation: None, sent_funding_txid: None, received_funding_txid: None, }); From 127277232dd7ec85fd91bee5606a515f951b9267 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 17 Jun 2025 05:30:42 +0200 Subject: [PATCH 3/9] Refactor FundingNegotiationContext and PendingSplice FundingNegotiationContext and PendingSplice both hold the user's contribution to a splice, which doesn't need to be duplicated. Instead, only store this in FundingNegotiationContext, which then can be used to create an InteractiveTxConstructor when transitioning to FundingNegotiation:::ConstructingTransaction. This commit updates that code to properly compute change outputs using the FundingNegotiationContext by not considering the shared input since it is accounted for in the shared output. Co-authored-by: Wilmer Paulino Co-authored-by: Jeffrey Czyz --- lightning/src/ln/channel.rs | 215 +++++++++++++++-------------- lightning/src/ln/channelmanager.rs | 2 +- lightning/src/ln/interactivetxs.rs | 193 +++++++++++--------------- 3 files changed, 191 insertions(+), 219 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index ac6cddead9b..1bcfbd0db55 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -57,9 +57,8 @@ use crate::ln::channelmanager::{ }; use crate::ln::interactivetxs::{ calculate_change_output_value, get_output_weight, AbortReason, HandleTxCompleteResult, - InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxMessageSend, - InteractiveTxMessageSendResult, InteractiveTxSigningSession, SharedOwnedOutput, - TX_COMMON_FIELDS_WEIGHT, + InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxMessageSendResult, + InteractiveTxSigningSession, SharedOwnedInput, SharedOwnedOutput, TX_COMMON_FIELDS_WEIGHT, }; use crate::ln::msgs; use crate::ln::msgs::{ClosingSigned, ClosingSignedFeeRange, DecodeError, OnionErrorPacket}; @@ -2174,7 +2173,6 @@ impl FundingScope { /// Info about a pending splice, used in the pre-splice channel #[cfg(splicing)] struct PendingSplice { - pub our_funding_contribution: i64, funding_negotiation: Option, /// The funding txid used in the `splice_locked` sent to the counterparty. @@ -2778,86 +2776,6 @@ impl PendingV2Channel where SP::Target: SignerProvider, { - /// Prepare and start interactive transaction negotiation. - /// `change_destination_opt` - Optional destination for optional change; if None, - /// default destination address is used. - /// If error occurs, it is caused by our side, not the counterparty. - #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled - #[rustfmt::skip] - fn begin_interactive_funding_tx_construction( - &mut self, signer_provider: &SP, entropy_source: &ES, holder_node_id: PublicKey, - change_destination_opt: Option, - ) -> Result, AbortReason> - where ES::Target: EntropySource - { - debug_assert!(matches!(self.context.channel_state, ChannelState::NegotiatingFunding(_))); - debug_assert!(self.interactive_tx_constructor.is_none()); - - let mut funding_inputs = Vec::new(); - mem::swap(&mut self.funding_negotiation_context.our_funding_inputs, &mut funding_inputs); - - // TODO(splicing): Add prev funding tx as input, must be provided as a parameter - - // 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(self.funding.get_value_satoshis()), - script_pubkey: self.funding.get_funding_redeemscript().to_p2wsh(), - }; - - // Optionally add change output - let change_script = if let Some(script) = change_destination_opt { - script - } else { - signer_provider.get_destination_script(self.context.channel_keys_id) - .map_err(|_err| AbortReason::InternalError("Error getting destination script"))? - }; - let change_value_opt = calculate_change_output_value( - self.funding.is_outbound(), self.funding_negotiation_context.our_funding_satoshis, - &funding_inputs, None, - &shared_funding_output.script_pubkey, &funding_outputs, - self.funding_negotiation_context.funding_feerate_sat_per_1000_weight, - change_script.minimal_non_dust().to_sat(), - )?; - if let Some(change_value) = change_value_opt { - 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_negotiation_context.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 > self.context.holder_dust_limit_satoshis { - change_output.value = Amount::from_sat(change_value_decreased_with_fee); - funding_outputs.push(change_output); - } - } - - let constructor_args = InteractiveTxConstructorArgs { - entropy_source, - holder_node_id, - counterparty_node_id: self.context.counterparty_node_id, - channel_id: self.context.channel_id(), - feerate_sat_per_kw: self.funding_negotiation_context.funding_feerate_sat_per_1000_weight, - is_initiator: self.funding.is_outbound(), - funding_tx_locktime: self.funding_negotiation_context.funding_tx_locktime, - inputs_to_contribute: funding_inputs, - shared_funding_input: None, - shared_funding_output: SharedOwnedOutput::new(shared_funding_output, self.funding_negotiation_context.our_funding_satoshis), - outputs_to_contribute: funding_outputs, - }; - let mut tx_constructor = InteractiveTxConstructor::new(constructor_args)?; - let msg = tx_constructor.take_initiator_first_message(); - - self.interactive_tx_constructor = Some(tx_constructor); - - Ok(msg) - } - pub fn tx_add_input(&mut self, msg: &msgs::TxAddInput) -> InteractiveTxMessageSendResult { InteractiveTxMessageSendResult(match &mut self.interactive_tx_constructor { Some(ref mut tx_constructor) => tx_constructor @@ -2937,7 +2855,6 @@ where where L::Target: Logger { - let our_funding_satoshis = self.funding_negotiation_context.our_funding_satoshis; let transaction_number = self.unfunded_context.transaction_number(); let mut output_index = None; @@ -2972,7 +2889,7 @@ where }; let funding_ready_for_sig_event = if signing_session.local_inputs_count() == 0 { - debug_assert_eq!(our_funding_satoshis, 0); + debug_assert_eq!(self.funding_negotiation_context.our_funding_contribution_satoshis, 0); if signing_session.provide_holder_witnesses(self.context.channel_id, Vec::new()).is_err() { debug_assert!( false, @@ -5877,11 +5794,13 @@ fn check_v2_funding_inputs_sufficient( /// Context for negotiating channels (dual-funded V2 open, splicing) 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_satoshis: u64, + pub our_funding_contribution_satoshis: i64, /// 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_satoshis: Option, + 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, @@ -5889,16 +5808,93 @@ pub(super) struct FundingNegotiationContext { #[allow(dead_code)] // TODO(dual_funding): Remove once V2 channels is enabled. pub funding_feerate_sat_per_1000_weight: u32, /// The funding inputs we will be contributing to the channel. - /// - /// Note that the `our_funding_satoshis` field is equal to the total value of `our_funding_inputs` - /// minus any fees paid for our contributed weight. This means that change will never be generated - /// and the maximum value possible will go towards funding the channel. - /// - /// Note that this field may be emptied once the interactive negotiation has been started. #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. pub our_funding_inputs: Vec<(TxIn, TransactionU16LenLimited)>, } +impl FundingNegotiationContext { + /// Prepare and start interactive transaction negotiation. + /// `change_destination_opt` - Optional destination for optional change; if None, + /// default destination address is used. + /// If error occurs, it is caused by our side, not the counterparty. + #[cfg(splicing)] + fn into_interactive_tx_constructor( + self, context: &ChannelContext, funding: &FundingScope, signer_provider: &SP, + entropy_source: &ES, holder_node_id: PublicKey, change_destination_opt: Option, + shared_funding_input: Option, + ) -> Result + where + SP::Target: SignerProvider, + ES::Target: EntropySource, + { + if shared_funding_input.is_some() { + debug_assert!(matches!(context.channel_state, ChannelState::ChannelReady(_))); + } else { + 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()), + script_pubkey: funding.get_funding_redeemscript().to_p2wsh(), + }; + + // Optionally add change output + if self.our_funding_contribution_satoshis > 0 { + let change_value_opt = calculate_change_output_value( + &self, + funding.channel_transaction_parameters.splice_parent_funding_txid.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) = change_destination_opt { + script + } else { + signer_provider.get_destination_script(context.channel_keys_id).map_err( + |_err| AbortReason::InternalError("Error getting destination 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); + } + } + } + + let constructor_args = InteractiveTxConstructorArgs { + entropy_source, + holder_node_id, + counterparty_node_id: context.counterparty_node_id, + channel_id: context.channel_id(), + 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, + shared_funding_input, + shared_funding_output: SharedOwnedOutput::new( + shared_funding_output, + funding.value_to_self_msat / 1000, + ), + outputs_to_contribute: funding_outputs, + }; + InteractiveTxConstructor::new(constructor_args) + } +} + // Holder designates channel data owned for the benefit of the user client. // Counterparty designates channel data owned by the another channel participant entity. pub(super) struct FundedChannel @@ -10424,11 +10420,13 @@ where ) -> Result { // Check if a splice has been initiated already. // Note: only a single outstanding splice is supported (per spec) - if let Some(splice_info) = &self.pending_splice { - return Err(APIError::APIMisuseError { err: format!( - "Channel {} cannot be spliced, as it has already a splice pending (contribution {})", - self.context.channel_id(), splice_info.our_funding_contribution - )}); + if self.pending_splice.is_some() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced, as it has already a splice pending", + self.context.channel_id(), + ), + }); } if !self.context.is_live() { @@ -10461,7 +10459,6 @@ where )})?; self.pending_splice = Some(PendingSplice { - our_funding_contribution: our_funding_contribution_satoshis, funding_negotiation: None, sent_funding_txid: None, received_funding_txid: None, @@ -10498,9 +10495,10 @@ where let our_funding_contribution_satoshis = 0i64; // Check if a splice has been initiated already. - if let Some(splice_info) = &self.pending_splice { + if self.pending_splice.is_some() { return Err(ChannelError::Warn(format!( - "Channel has already a splice pending, contribution {}", splice_info.our_funding_contribution, + "Channel {} already has a splice pending", + self.context.channel_id(), ))); } @@ -12111,9 +12109,10 @@ where holder_commitment_point: HolderCommitmentPoint::new(&context.holder_signer, &context.secp_ctx), }; let funding_negotiation_context = FundingNegotiationContext { - our_funding_satoshis: funding_satoshis, + is_initiator: true, + our_funding_contribution_satoshis: funding_satoshis as i64, // TODO(dual_funding) TODO(splicing) Include counterparty contribution, once that's enabled - their_funding_satoshis: None, + their_funding_contribution_satoshis: None, funding_tx_locktime, funding_feerate_sat_per_1000_weight, our_funding_inputs: funding_inputs, @@ -12265,8 +12264,9 @@ where context.channel_id = channel_id; let funding_negotiation_context = FundingNegotiationContext { - our_funding_satoshis: our_funding_satoshis, - their_funding_satoshis: Some(msg.common_fields.funding_satoshis), + is_initiator: false, + our_funding_contribution_satoshis: our_funding_satoshis as i64, + 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, our_funding_inputs: our_funding_inputs.clone(), @@ -12368,7 +12368,8 @@ where }), channel_type: Some(self.funding.get_channel_type().clone()), }, - funding_satoshis: self.funding_negotiation_context.our_funding_satoshis, + funding_satoshis: self.funding_negotiation_context.our_funding_contribution_satoshis + 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 39d7904c200..3fc8252efc5 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -9190,7 +9190,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_satoshis != 0 { + if unfunded_chan.funding_negotiation_context.our_funding_contribution_satoshis > 0 { continue; } } diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index acf28cf8fd3..aa83c251e1b 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -22,7 +22,7 @@ use bitcoin::{OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Wei 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::TOTAL_BITCOIN_SUPPLY_SATOSHIS; +use crate::ln::channel::{FundingNegotiationContext, TOTAL_BITCOIN_SUPPLY_SATOSHIS}; use crate::ln::msgs; use crate::ln::msgs::{MessageSendEvent, SerialId, TxSignatures}; use crate::ln::types::ChannelId; @@ -1874,26 +1874,21 @@ impl InteractiveTxConstructor { /// `Err(AbortReason::InsufficientFees)` /// /// Parameters: -/// - `is_initiator` - Whether we are the negotiation initiator or not (acceptor). -/// - `our_contribution` - The sats amount we intend to contribute to the funding -/// transaction being negotiated. -/// - `funding_inputs` - List of our inputs. It does not include the shared input, if there is one. -/// - `shared_input` - The locally owned amount of the shared input (in sats), if there is one. +/// - `context` - Context of the funding negotiation, including non-shared inputs and feerate. +/// - `is_splice` - Whether we splicing an existing channel or dual-funding a new one. /// - `shared_output_funding_script` - The script of the shared output. /// - `funding_outputs` - Our funding outputs. -/// - `funding_feerate_sat_per_1000_weight` - Fee rate to be used. /// - `change_output_dust_limit` - The dust limit (in sats) to consider. pub(super) fn calculate_change_output_value( - is_initiator: bool, our_contribution: u64, - funding_inputs: &Vec<(TxIn, TransactionU16LenLimited)>, shared_input: Option, - shared_output_funding_script: &ScriptBuf, funding_outputs: &Vec, - funding_feerate_sat_per_1000_weight: u32, change_output_dust_limit: u64, + context: &FundingNegotiationContext, is_splice: bool, shared_output_funding_script: &ScriptBuf, + funding_outputs: &Vec, change_output_dust_limit: u64, ) -> Result, AbortReason> { - // Process inputs and their prev txs: - // calculate value sum and weight sum of inputs, also perform checks + assert!(context.our_funding_contribution_satoshis > 0); + let our_funding_contribution_satoshis = context.our_funding_contribution_satoshis as u64; + let mut total_input_satoshis = 0u64; let mut our_funding_inputs_weight = 0u64; - for (txin, tx) in funding_inputs.iter() { + for (txin, tx) in context.our_funding_inputs.iter() { let txid = tx.as_transaction().compute_txid(); if txin.previous_output.txid != txid { return Err(AbortReason::PrevTxOutInvalid); @@ -1908,13 +1903,8 @@ pub(super) fn calculate_change_output_value( our_funding_inputs_weight = our_funding_inputs_weight.saturating_add(weight); } - if let Some(shared_input) = shared_input { - total_input_satoshis = total_input_satoshis.saturating_add(shared_input); - } - 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| { weight.saturating_add(get_output_weight(&out.script_pubkey).to_wu()) }); @@ -1922,24 +1912,23 @@ pub(super) fn calculate_change_output_value( // If we are the initiator, we must pay for the weight of the funding output and // all common fields in the funding transaction. - if is_initiator { + if context.is_initiator { weight = weight.saturating_add(get_output_weight(shared_output_funding_script).to_wu()); weight = weight.saturating_add(TX_COMMON_FIELDS_WEIGHT); - - if shared_input.is_some() { + if is_splice { + // TODO(taproot): Needs to consider different weights based on channel type weight = weight.saturating_add(FUNDING_TRANSACTION_WITNESS_WEIGHT); } } - let fees_sats = fee_for_weight(funding_feerate_sat_per_1000_weight, weight); - + let fees_sats = fee_for_weight(context.funding_feerate_sat_per_1000_weight, weight); let net_total_less_fees = total_input_satoshis.saturating_sub(total_output_satoshis).saturating_sub(fees_sats); - if net_total_less_fees < our_contribution { + if net_total_less_fees < our_funding_contribution_satoshis { // Not enough to cover contribution plus fees return Err(AbortReason::InsufficientFees); } - let remaining_value = net_total_less_fees.saturating_sub(our_contribution); + let remaining_value = net_total_less_fees.saturating_sub(our_funding_contribution_satoshis); if remaining_value < change_output_dust_limit { // Enough to cover contribution plus fees, but leftover is below dust limit; no change Ok(None) @@ -1952,7 +1941,7 @@ pub(super) fn calculate_change_output_value( #[cfg(test)] mod tests { use crate::chain::chaininterface::{fee_for_weight, FEERATE_FLOOR_SATS_PER_KW}; - use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; + use crate::ln::channel::{FundingNegotiationContext, TOTAL_BITCOIN_SUPPLY_SATOSHIS}; use crate::ln::interactivetxs::{ calculate_change_output_value, generate_holder_serial_id, AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, @@ -2980,89 +2969,71 @@ mod tests { let gross_change = total_inputs - total_outputs - our_contributed; let fees = 1746; let common_fees = 234; - { - // There is leftover for change - let res = calculate_change_output_value( - true, - our_contributed, - &inputs, - None, - &ScriptBuf::new(), - &outputs, - funding_feerate_sat_per_1000_weight, - 300, - ); - assert_eq!(res, Ok(Some(gross_change - fees - common_fees))); - } - { - // There is leftover for change, without common fees - let res = calculate_change_output_value( - false, - our_contributed, - &inputs, - None, - &ScriptBuf::new(), - &outputs, - funding_feerate_sat_per_1000_weight, - 300, - ); - assert_eq!(res, Ok(Some(gross_change - fees))); - } - { - // Larger fee, smaller change - let res = calculate_change_output_value( - true, - our_contributed, - &inputs, - None, - &ScriptBuf::new(), - &outputs, - funding_feerate_sat_per_1000_weight * 3, - 300, - ); - assert_eq!(res, Ok(Some(4060))); - } - { - // Insufficient inputs, no leftover - let res = calculate_change_output_value( - false, - 130_000, - &inputs, - None, - &ScriptBuf::new(), - &outputs, - funding_feerate_sat_per_1000_weight, - 300, - ); - assert_eq!(res, Err(AbortReason::InsufficientFees)); - } - { - // Very small leftover - let res = calculate_change_output_value( - false, - 118_000, - &inputs, - None, - &ScriptBuf::new(), - &outputs, - funding_feerate_sat_per_1000_weight, - 300, - ); - assert_eq!(res, Ok(None)); - } - { - // Small leftover, but not dust - let res = calculate_change_output_value( - false, - 117_992, - &inputs, - None, - &ScriptBuf::new(), - &outputs, - funding_feerate_sat_per_1000_weight, - 100, - ); - assert_eq!(res, Ok(Some(262))); - } + + // There is leftover for change + let context = FundingNegotiationContext { + is_initiator: true, + our_funding_contribution_satoshis: our_contributed as i64, + their_funding_contribution_satoshis: None, + funding_tx_locktime: AbsoluteLockTime::ZERO, + funding_feerate_sat_per_1000_weight, + our_funding_inputs: inputs, + }; + assert_eq!( + calculate_change_output_value(&context, false, &ScriptBuf::new(), &outputs, 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), + Ok(Some(gross_change - fees)), + ); + + // Insufficient inputs, no leftover + let context = FundingNegotiationContext { + is_initiator: false, + our_funding_contribution_satoshis: 130_000, + ..context + }; + assert_eq!( + calculate_change_output_value(&context, false, &ScriptBuf::new(), &outputs, 300), + Err(AbortReason::InsufficientFees), + ); + + // Very small leftover + let context = FundingNegotiationContext { + is_initiator: false, + our_funding_contribution_satoshis: 118_000, + ..context + }; + assert_eq!( + calculate_change_output_value(&context, false, &ScriptBuf::new(), &outputs, 300), + Ok(None), + ); + + // Small leftover, but not dust + let context = FundingNegotiationContext { + is_initiator: false, + our_funding_contribution_satoshis: 117_992, + ..context + }; + assert_eq!( + calculate_change_output_value(&context, false, &ScriptBuf::new(), &outputs, 100), + Ok(Some(262)), + ); + + // Larger fee, smaller change + let context = FundingNegotiationContext { + is_initiator: true, + our_funding_contribution_satoshis: our_contributed as i64, + funding_feerate_sat_per_1000_weight: funding_feerate_sat_per_1000_weight * 3, + ..context + }; + assert_eq!( + calculate_change_output_value(&context, false, &ScriptBuf::new(), &outputs, 300), + Ok(Some(4060)), + ); } } From cc65d089a62cda818e469d7fb223a5d47a2fe0d8 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 28 Jul 2025 17:20:42 -0500 Subject: [PATCH 4/9] Add shared_funding_input to FundingNegotiationContext Instead of passing the shared funding input as another parameter to FundingNegotiationContext::into_interactive_tx_constructor, make it a member of FundingNegotiationContext. --- lightning/src/ln/channel.rs | 25 ++++++++++++++++++------- lightning/src/ln/interactivetxs.rs | 1 + 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 1bcfbd0db55..a416806ba90 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -55,10 +55,12 @@ use crate::ln::channelmanager::{ PaymentClaimDetails, PendingHTLCInfo, PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; +#[cfg(splicing)] +use crate::ln::interactivetxs::{calculate_change_output_value, AbortReason}; use crate::ln::interactivetxs::{ - calculate_change_output_value, get_output_weight, AbortReason, HandleTxCompleteResult, - InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxMessageSendResult, - InteractiveTxSigningSession, SharedOwnedInput, SharedOwnedOutput, TX_COMMON_FIELDS_WEIGHT, + get_output_weight, HandleTxCompleteResult, InteractiveTxConstructor, + InteractiveTxConstructorArgs, InteractiveTxMessageSendResult, InteractiveTxSigningSession, + SharedOwnedInput, SharedOwnedOutput, TX_COMMON_FIELDS_WEIGHT, }; use crate::ln::msgs; use crate::ln::msgs::{ClosingSigned, ClosingSignedFeeRange, DecodeError, OnionErrorPacket}; @@ -5807,6 +5809,9 @@ pub(super) struct FundingNegotiationContext { /// The feerate set by the initiator to be used for the funding transaction. #[allow(dead_code)] // TODO(dual_funding): Remove once V2 channels is enabled. pub funding_feerate_sat_per_1000_weight: u32, + /// The input spending the previous funding output, if this is a splice. + #[allow(dead_code)] // TODO(splicing): Remove once splicing is enabled. + 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)>, @@ -5821,13 +5826,17 @@ impl FundingNegotiationContext { fn into_interactive_tx_constructor( self, context: &ChannelContext, funding: &FundingScope, signer_provider: &SP, entropy_source: &ES, holder_node_id: PublicKey, change_destination_opt: Option, - shared_funding_input: Option, ) -> Result where SP::Target: SignerProvider, ES::Target: EntropySource, { - if shared_funding_input.is_some() { + debug_assert_eq!( + self.shared_funding_input.is_some(), + funding.channel_transaction_parameters.splice_parent_funding_txid.is_some(), + ); + + if self.shared_funding_input.is_some() { debug_assert!(matches!(context.channel_state, ChannelState::ChannelReady(_))); } else { debug_assert!(matches!(context.channel_state, ChannelState::NegotiatingFunding(_))); @@ -5847,7 +5856,7 @@ impl FundingNegotiationContext { if self.our_funding_contribution_satoshis > 0 { let change_value_opt = calculate_change_output_value( &self, - funding.channel_transaction_parameters.splice_parent_funding_txid.is_some(), + self.shared_funding_input.is_some(), &shared_funding_output.script_pubkey, &funding_outputs, context.holder_dust_limit_satoshis, @@ -5884,7 +5893,7 @@ impl FundingNegotiationContext { is_initiator: self.is_initiator, funding_tx_locktime: self.funding_tx_locktime, inputs_to_contribute: self.our_funding_inputs, - shared_funding_input, + shared_funding_input: self.shared_funding_input, shared_funding_output: SharedOwnedOutput::new( shared_funding_output, funding.value_to_self_msat / 1000, @@ -12115,6 +12124,7 @@ where their_funding_contribution_satoshis: None, funding_tx_locktime, funding_feerate_sat_per_1000_weight, + shared_funding_input: None, our_funding_inputs: funding_inputs, }; let chan = Self { @@ -12269,6 +12279,7 @@ where 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, our_funding_inputs: our_funding_inputs.clone(), }; let shared_funding_output = TxOut { diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index aa83c251e1b..e678d99b26d 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -2977,6 +2977,7 @@ mod tests { their_funding_contribution_satoshis: None, funding_tx_locktime: AbsoluteLockTime::ZERO, funding_feerate_sat_per_1000_weight, + shared_funding_input: None, our_funding_inputs: inputs, }; assert_eq!( From a1d80e04820c7ff1d41f8b9eb30963266f11df4e Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 21 Jul 2025 23:23:38 -0500 Subject: [PATCH 5/9] Allow InteractiveTxConstructor use in splicing InteractiveTxConstructor was only used in PendingV2Channel methods, but for splicing those methods are needed for FundedChannel, too. Refactor the code such that each type has a method for accessing its InteractiveTxConstructor such that it can be called in either use, refactoring code out of PendingV2Channel as needed. Co-authored-by: Wilmer Paulino Co-authored-by: Jeffrey Czyz --- lightning/src/ln/channel.rs | 357 +++++++++++++++-------------- lightning/src/ln/channelmanager.rs | 121 +++++----- lightning/src/ln/interactivetxs.rs | 96 +++++--- 3 files changed, 307 insertions(+), 267 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index a416806ba90..53ce3a1006f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -58,9 +58,8 @@ use crate::ln::channelmanager::{ #[cfg(splicing)] use crate::ln::interactivetxs::{calculate_change_output_value, AbortReason}; use crate::ln::interactivetxs::{ - get_output_weight, HandleTxCompleteResult, InteractiveTxConstructor, - InteractiveTxConstructorArgs, InteractiveTxMessageSendResult, InteractiveTxSigningSession, - SharedOwnedInput, SharedOwnedOutput, TX_COMMON_FIELDS_WEIGHT, + get_output_weight, InteractiveTxConstructor, InteractiveTxConstructorArgs, + InteractiveTxSigningSession, SharedOwnedInput, SharedOwnedOutput, TX_COMMON_FIELDS_WEIGHT, }; use crate::ln::msgs; use crate::ln::msgs::{ClosingSigned, ClosingSignedFeeRange, DecodeError, OnionErrorPacket}; @@ -1728,6 +1727,15 @@ where } } + pub fn interactive_tx_constructor_mut(&mut self) -> Option<&mut InteractiveTxConstructor> { + match &mut self.phase { + ChannelPhase::UnfundedV2(chan) => chan.interactive_tx_constructor.as_mut(), + #[cfg(splicing)] + ChannelPhase::Funded(chan) => chan.interactive_tx_constructor_mut(), + _ => None, + } + } + #[rustfmt::skip] pub fn funding_signed( &mut self, msg: &msgs::FundingSigned, best_block: BestBlock, signer_provider: &SP, logger: &L @@ -1761,16 +1769,71 @@ where } pub fn funding_tx_constructed( - &mut self, signing_session: InteractiveTxSigningSession, logger: &L, + &mut self, logger: &L, ) -> Result<(msgs::CommitmentSigned, Option), ChannelError> where L::Target: Logger, { - if let ChannelPhase::UnfundedV2(chan) = &mut self.phase { - let logger = WithChannelContext::from(logger, &chan.context, None); - chan.funding_tx_constructed(signing_session, &&logger) - } else { - Err(ChannelError::Warn("Got a tx_complete message with no interactive transaction construction expected or in-progress".to_owned())) + let logger = WithChannelContext::from(logger, self.context(), None); + match &mut self.phase { + ChannelPhase::UnfundedV2(chan) => { + let mut signing_session = chan + .interactive_tx_constructor + .take() + .expect("PendingV2Channel::interactive_tx_constructor should be set") + .into_signing_session(); + let (commitment_signed, event) = chan.context.funding_tx_constructed( + &mut chan.funding, + &mut signing_session, + false, + chan.unfunded_context.transaction_number(), + &&logger, + )?; + + chan.interactive_tx_signing_session = Some(signing_session); + + return Ok((commitment_signed, event)); + }, + #[cfg(splicing)] + ChannelPhase::Funded(chan) => { + if let Some(pending_splice) = chan.pending_splice.as_mut() { + if let Some(funding_negotiation) = pending_splice.funding_negotiation.take() { + if let FundingNegotiation::ConstructingTransaction( + mut funding, + interactive_tx_constructor, + ) = funding_negotiation + { + let mut signing_session = + interactive_tx_constructor.into_signing_session(); + let (commitment_signed, event) = chan.context.funding_tx_constructed( + &mut funding, + &mut signing_session, + true, + chan.holder_commitment_point.transaction_number(), + &&logger, + )?; + + chan.interactive_tx_signing_session = Some(signing_session); + pending_splice.funding_negotiation = + Some(FundingNegotiation::AwaitingSignatures(funding)); + + return Ok((commitment_signed, event)); + } else { + // Replace the taken state + pending_splice.funding_negotiation = Some(funding_negotiation); + } + } + } + + return Err(ChannelError::Warn( + "Got a tx_complete message in an invalid state".to_owned(), + )); + }, + _ => { + return Err(ChannelError::Warn( + "Got a tx_complete message in an invalid phase".to_owned(), + )) + }, } } @@ -2774,170 +2837,6 @@ where } } -impl PendingV2Channel -where - SP::Target: SignerProvider, -{ - pub fn tx_add_input(&mut self, msg: &msgs::TxAddInput) -> InteractiveTxMessageSendResult { - InteractiveTxMessageSendResult(match &mut self.interactive_tx_constructor { - Some(ref mut tx_constructor) => tx_constructor - .handle_tx_add_input(msg) - .map_err(|reason| reason.into_tx_abort_msg(self.context.channel_id())), - None => Err(msgs::TxAbort { - channel_id: self.context.channel_id(), - data: b"No interactive transaction negotiation in progress".to_vec(), - }), - }) - } - - pub fn tx_add_output(&mut self, msg: &msgs::TxAddOutput) -> InteractiveTxMessageSendResult { - InteractiveTxMessageSendResult(match &mut self.interactive_tx_constructor { - Some(ref mut tx_constructor) => tx_constructor - .handle_tx_add_output(msg) - .map_err(|reason| reason.into_tx_abort_msg(self.context.channel_id())), - None => Err(msgs::TxAbort { - channel_id: self.context.channel_id(), - data: b"No interactive transaction negotiation in progress".to_vec(), - }), - }) - } - - pub fn tx_remove_input(&mut self, msg: &msgs::TxRemoveInput) -> InteractiveTxMessageSendResult { - InteractiveTxMessageSendResult(match &mut self.interactive_tx_constructor { - Some(ref mut tx_constructor) => tx_constructor - .handle_tx_remove_input(msg) - .map_err(|reason| reason.into_tx_abort_msg(self.context.channel_id())), - None => Err(msgs::TxAbort { - channel_id: self.context.channel_id(), - data: b"No interactive transaction negotiation in progress".to_vec(), - }), - }) - } - - pub fn tx_remove_output( - &mut self, msg: &msgs::TxRemoveOutput, - ) -> InteractiveTxMessageSendResult { - InteractiveTxMessageSendResult(match &mut self.interactive_tx_constructor { - Some(ref mut tx_constructor) => tx_constructor - .handle_tx_remove_output(msg) - .map_err(|reason| reason.into_tx_abort_msg(self.context.channel_id())), - None => Err(msgs::TxAbort { - channel_id: self.context.channel_id(), - data: b"No interactive transaction negotiation in progress".to_vec(), - }), - }) - } - - pub fn tx_complete(&mut self, msg: &msgs::TxComplete) -> HandleTxCompleteResult { - let tx_constructor = match &mut self.interactive_tx_constructor { - Some(ref mut tx_constructor) => tx_constructor, - None => { - let tx_abort = msgs::TxAbort { - channel_id: msg.channel_id, - data: b"No interactive transaction negotiation in progress".to_vec(), - }; - return HandleTxCompleteResult(Err(tx_abort)); - }, - }; - - let tx_complete = match tx_constructor.handle_tx_complete(msg) { - Ok(tx_complete) => tx_complete, - Err(reason) => { - return HandleTxCompleteResult(Err(reason.into_tx_abort_msg(msg.channel_id))) - }, - }; - - HandleTxCompleteResult(Ok(tx_complete)) - } - - #[rustfmt::skip] - pub fn funding_tx_constructed( - &mut self, mut signing_session: InteractiveTxSigningSession, logger: &L - ) -> Result<(msgs::CommitmentSigned, Option), ChannelError> - where - L::Target: Logger - { - let transaction_number = self.unfunded_context.transaction_number(); - - let mut output_index = None; - let expected_spk = self.funding.get_funding_redeemscript().to_p2wsh(); - for (idx, outp) in signing_session.unsigned_tx().outputs().enumerate() { - if outp.script_pubkey() == &expected_spk && outp.value() == self.funding.get_value_satoshis() { - if output_index.is_some() { - let msg = "Multiple outputs matched the expected script and value"; - let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; - return Err(ChannelError::Close((msg.to_owned(), reason))); - } - output_index = Some(idx as u16); - } - } - let outpoint = if let Some(output_index) = output_index { - OutPoint { txid: signing_session.unsigned_tx().compute_txid(), index: output_index } - } else { - let msg = "No output matched the funding script_pubkey"; - let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; - return Err(ChannelError::Close((msg.to_owned(), reason))); - }; - self.funding.channel_transaction_parameters.funding_outpoint = Some(outpoint); - - self.context.assert_no_commitment_advancement(transaction_number, "initial commitment_signed"); - let commitment_signed = self.context.get_initial_commitment_signed(&self.funding, logger); - let commitment_signed = match commitment_signed { - Ok(commitment_signed) => commitment_signed, - Err(e) => { - self.funding.channel_transaction_parameters.funding_outpoint = None; - return Err(e) - }, - }; - - let funding_ready_for_sig_event = if signing_session.local_inputs_count() == 0 { - debug_assert_eq!(self.funding_negotiation_context.our_funding_contribution_satoshis, 0); - if signing_session.provide_holder_witnesses(self.context.channel_id, Vec::new()).is_err() { - debug_assert!( - false, - "Zero inputs were provided & zero witnesses were provided, but a count mismatch was somehow found", - ); - let msg = "V2 channel rejected due to sender error"; - let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; - return Err(ChannelError::Close((msg.to_owned(), reason))); - } - None - } else { - // TODO(dual_funding): Send event for signing if we've contributed funds. - // Inform the user that SIGHASH_ALL must be used for all signatures when contributing - // inputs/signatures. - // Also warn the user that we don't do anything to prevent the counterparty from - // providing non-standard witnesses which will prevent the funding transaction from - // confirming. This warning must appear in doc comments wherever the user is contributing - // funds, whether they are initiator or acceptor. - // - // The following warning can be used when the APIs allowing contributing inputs become available: - //
- // WARNING: LDK makes no attempt to prevent the counterparty from using non-standard inputs which - // will prevent the funding transaction from being relayed on the bitcoin network and hence being - // confirmed. - //
- debug_assert!( - false, - "We don't support users providing inputs but somehow we had more than zero inputs", - ); - let msg = "V2 channel rejected due to sender error"; - let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; - return Err(ChannelError::Close((msg.to_owned(), reason))); - }; - - let mut channel_state = ChannelState::FundingNegotiated(FundingNegotiatedFlags::new()); - channel_state.set_interactive_signing(); - self.context.channel_state = channel_state; - - // Clear the interactive transaction constructor - self.interactive_tx_constructor.take(); - self.interactive_tx_signing_session = Some(signing_session); - - Ok((commitment_signed, funding_ready_for_sig_event)) - } -} - impl ChannelContext where SP::Target: SignerProvider, @@ -5421,6 +5320,102 @@ where Ok(()) } + #[rustfmt::skip] + fn funding_tx_constructed( + &mut self, funding: &mut FundingScope, signing_session: &mut InteractiveTxSigningSession, + is_splice: bool, holder_commitment_transaction_number: u64, logger: &L + ) -> Result<(msgs::CommitmentSigned, Option), ChannelError> + where + L::Target: Logger + { + let mut output_index = None; + let expected_spk = funding.get_funding_redeemscript().to_p2wsh(); + for (idx, outp) in signing_session.unsigned_tx().outputs().enumerate() { + if outp.script_pubkey() == &expected_spk && outp.value() == funding.get_value_satoshis() { + if output_index.is_some() { + let msg = "Multiple outputs matched the expected script and value"; + let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; + return Err(ChannelError::Close((msg.to_owned(), reason))); + } + output_index = Some(idx as u16); + } + } + let outpoint = if let Some(output_index) = output_index { + OutPoint { txid: signing_session.unsigned_tx().compute_txid(), index: output_index } + } else { + let msg = "No output matched the funding script_pubkey"; + let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; + return Err(ChannelError::Close((msg.to_owned(), reason))); + }; + funding + .channel_transaction_parameters.funding_outpoint = Some(outpoint); + + if is_splice { + debug_assert_eq!( + holder_commitment_transaction_number, + self.cur_counterparty_commitment_transaction_number, + ); + let message = "TODO Forced error, incomplete implementation".to_owned(); + // TODO(splicing) Forced error, as the use case is not complete + return Err(ChannelError::Close(( + message.clone(), + ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(false), message } + ))); + } else { + self.assert_no_commitment_advancement(holder_commitment_transaction_number, "initial commitment_signed"); + } + + let commitment_signed = self.get_initial_commitment_signed(&funding, logger); + let commitment_signed = match commitment_signed { + Ok(commitment_signed) => commitment_signed, + Err(e) => { + funding.channel_transaction_parameters.funding_outpoint = None; + return Err(e) + }, + }; + + let funding_ready_for_sig_event = if signing_session.local_inputs_count() == 0 { + if signing_session.provide_holder_witnesses(self.channel_id, Vec::new()).is_err() { + debug_assert!( + false, + "Zero inputs were provided & zero witnesses were provided, but a count mismatch was somehow found", + ); + let msg = "V2 channel rejected due to sender error"; + let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; + return Err(ChannelError::Close((msg.to_owned(), reason))); + } + None + } else { + // TODO(dual_funding): Send event for signing if we've contributed funds. + // Inform the user that SIGHASH_ALL must be used for all signatures when contributing + // inputs/signatures. + // Also warn the user that we don't do anything to prevent the counterparty from + // providing non-standard witnesses which will prevent the funding transaction from + // confirming. This warning must appear in doc comments wherever the user is contributing + // funds, whether they are initiator or acceptor. + // + // The following warning can be used when the APIs allowing contributing inputs become available: + //
+ // WARNING: LDK makes no attempt to prevent the counterparty from using non-standard inputs which + // will prevent the funding transaction from being relayed on the bitcoin network and hence being + // confirmed. + //
+ debug_assert!( + false, + "We don't support users providing inputs but somehow we had more than zero inputs", + ); + let msg = "V2 channel rejected due to sender error"; + let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; + return Err(ChannelError::Close((msg.to_owned(), reason))); + }; + + let mut channel_state = ChannelState::FundingNegotiated(FundingNegotiatedFlags::new()); + channel_state.set_interactive_signing(); + self.channel_state = channel_state; + + Ok((commitment_signed, funding_ready_for_sig_event)) + } + /// Asserts that the commitment tx numbers have not advanced from their initial number. #[rustfmt::skip] fn assert_no_commitment_advancement(&self, holder_commitment_transaction_number: u64, msg_name: &str) { @@ -6036,6 +6031,22 @@ where self.context.force_shutdown(&self.funding, closure_reason) } + #[cfg(splicing)] + fn interactive_tx_constructor_mut(&mut self) -> Option<&mut InteractiveTxConstructor> { + self.pending_splice + .as_mut() + .and_then(|pending_splice| pending_splice.funding_negotiation.as_mut()) + .and_then(|funding_negotiation| { + if let FundingNegotiation::ConstructingTransaction(_, interactive_tx_constructor) = + funding_negotiation + { + Some(interactive_tx_constructor) + } else { + None + } + }) + } + #[rustfmt::skip] fn check_remote_fee( channel_type: &ChannelTypeFeatures, fee_estimator: &LowerBoundedFeeEstimator, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 3fc8252efc5..f6bf0d2db60 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -66,6 +66,7 @@ use crate::ln::channel::{ }; use crate::ln::channel_state::ChannelDetails; use crate::ln::inbound_payment; +use crate::ln::interactivetxs::{HandleTxCompleteResult, InteractiveTxMessageSendResult}; use crate::ln::msgs; use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, CommitmentUpdate, DecodeError, LightningError, @@ -9590,28 +9591,29 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } - #[rustfmt::skip] - fn internal_tx_msg) -> Result>( - &self, counterparty_node_id: &PublicKey, channel_id: ChannelId, tx_msg_handler: HandleTxMsgFn + fn internal_tx_msg) -> Option>( + &self, counterparty_node_id: &PublicKey, channel_id: ChannelId, + tx_msg_handler: HandleTxMsgFn, ) -> Result<(), MsgHandleErrInternal> { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close( - format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), - channel_id) - })?; + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::send_err_msg_no_close( + format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), + channel_id, + ) + })?; let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(channel_id) { hash_map::Entry::Occupied(mut chan_entry) => { let channel = chan_entry.get_mut(); let msg_send_event = match tx_msg_handler(channel) { - Ok(msg_send_event) => msg_send_event, - Err(tx_msg_str) => return Err(MsgHandleErrInternal::from_chan_no_close(ChannelError::Warn( - format!("Got a {tx_msg_str} message with no interactive transaction construction expected or in-progress") - ), channel_id)), + Some(msg_send_event) => msg_send_event, + None => { + let err = ChannelError::Warn("Received unexpected interactive transaction negotiation message".to_owned()); + return Err(MsgHandleErrInternal::from_chan_no_close(err, channel_id)) + }, }; peer_state.pending_msg_events.push(msg_send_event); Ok(()) @@ -9629,12 +9631,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &self, counterparty_node_id: PublicKey, msg: &msgs::TxAddInput, ) -> Result<(), MsgHandleErrInternal> { self.internal_tx_msg(&counterparty_node_id, msg.channel_id, |channel: &mut Channel| { - match channel.as_unfunded_v2_mut() { - Some(unfunded_channel) => { - Ok(unfunded_channel.tx_add_input(msg).into_msg_send_event(counterparty_node_id)) - }, - None => Err("tx_add_input"), - } + Some( + InteractiveTxMessageSendResult( + channel + .interactive_tx_constructor_mut()? + .handle_tx_add_input(msg) + .map_err(|reason| reason.into_tx_abort_msg(msg.channel_id)), + ) + .into_msg_send_event(counterparty_node_id), + ) }) } @@ -9642,15 +9647,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &self, counterparty_node_id: PublicKey, msg: &msgs::TxAddOutput, ) -> Result<(), MsgHandleErrInternal> { self.internal_tx_msg(&counterparty_node_id, msg.channel_id, |channel: &mut Channel| { - match channel.as_unfunded_v2_mut() { - Some(unfunded_channel) => { - let msg_send_event = unfunded_channel - .tx_add_output(msg) - .into_msg_send_event(counterparty_node_id); - Ok(msg_send_event) - }, - None => Err("tx_add_output"), - } + Some( + InteractiveTxMessageSendResult( + channel + .interactive_tx_constructor_mut()? + .handle_tx_add_output(msg) + .map_err(|reason| reason.into_tx_abort_msg(msg.channel_id)), + ) + .into_msg_send_event(counterparty_node_id), + ) }) } @@ -9658,15 +9663,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &self, counterparty_node_id: PublicKey, msg: &msgs::TxRemoveInput, ) -> Result<(), MsgHandleErrInternal> { self.internal_tx_msg(&counterparty_node_id, msg.channel_id, |channel: &mut Channel| { - match channel.as_unfunded_v2_mut() { - Some(unfunded_channel) => { - let msg_send_event = unfunded_channel - .tx_remove_input(msg) - .into_msg_send_event(counterparty_node_id); - Ok(msg_send_event) - }, - None => Err("tx_remove_input"), - } + Some( + InteractiveTxMessageSendResult( + channel + .interactive_tx_constructor_mut()? + .handle_tx_remove_input(msg) + .map_err(|reason| reason.into_tx_abort_msg(msg.channel_id)), + ) + .into_msg_send_event(counterparty_node_id), + ) }) } @@ -9674,15 +9679,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &self, counterparty_node_id: PublicKey, msg: &msgs::TxRemoveOutput, ) -> Result<(), MsgHandleErrInternal> { self.internal_tx_msg(&counterparty_node_id, msg.channel_id, |channel: &mut Channel| { - match channel.as_unfunded_v2_mut() { - Some(unfunded_channel) => { - let msg_send_event = unfunded_channel - .tx_remove_output(msg) - .into_msg_send_event(counterparty_node_id); - Ok(msg_send_event) - }, - None => Err("tx_remove_output"), - } + Some( + InteractiveTxMessageSendResult( + channel + .interactive_tx_constructor_mut()? + .handle_tx_remove_output(msg) + .map_err(|reason| reason.into_tx_abort_msg(msg.channel_id)), + ) + .into_msg_send_event(counterparty_node_id), + ) }) } @@ -9700,23 +9705,27 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.entry(msg.channel_id) { hash_map::Entry::Occupied(mut chan_entry) => { - let (msg_send_event_opt, signing_session_opt) = match chan_entry.get_mut().as_unfunded_v2_mut() { - Some(chan) => chan.tx_complete(msg) - .into_msg_send_event_or_signing_session(counterparty_node_id), + let (msg_send_event_opt, negotiation_complete) = match chan_entry.get_mut().interactive_tx_constructor_mut() { + Some(interactive_tx_constructor) => { + HandleTxCompleteResult( + interactive_tx_constructor + .handle_tx_complete(msg) + .map_err(|reason| reason.into_tx_abort_msg(msg.channel_id)), + ) + .into_msg_send_event(counterparty_node_id) + }, None => { - let msg = "Got a tx_complete message with no interactive transaction construction expected or in-progress"; - let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; - let err = ChannelError::Close((msg.to_owned(), reason)); - try_channel_entry!(self, peer_state, Err(err), chan_entry) + let err = ChannelError::Warn("Received unexpected tx_complete message".to_owned()); + return Err(MsgHandleErrInternal::from_chan_no_close(err, msg.channel_id)) }, }; if let Some(msg_send_event) = msg_send_event_opt { peer_state.pending_msg_events.push(msg_send_event); }; - if let Some(signing_session) = signing_session_opt { + if negotiation_complete { let (commitment_signed, funding_ready_for_sig_event_opt) = chan_entry .get_mut() - .funding_tx_constructed(signing_session, &self.logger) + .funding_tx_constructed(&self.logger) .map_err(|err| MsgHandleErrInternal::send_err_msg_no_close(format!("{}", err), msg.channel_id))?; if let Some(funding_ready_for_sig_event) = funding_ready_for_sig_event_opt { let mut pending_events = self.pending_events.lock().unwrap(); diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index e678d99b26d..e39ebb8327a 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -1600,24 +1600,22 @@ where pub(super) enum HandleTxCompleteValue { SendTxMessage(InteractiveTxMessageSend), - SendTxComplete(InteractiveTxMessageSend, InteractiveTxSigningSession), - NegotiationComplete(InteractiveTxSigningSession), + SendTxComplete(InteractiveTxMessageSend, bool), + NegotiationComplete, } impl HandleTxCompleteValue { - pub fn into_msg_send_event_or_signing_session( + pub fn into_msg_send_event( self, counterparty_node_id: PublicKey, - ) -> (Option, Option) { + ) -> (Option, bool) { match self { HandleTxCompleteValue::SendTxMessage(msg) => { - (Some(msg.into_msg_send_event(counterparty_node_id)), None) + (Some(msg.into_msg_send_event(counterparty_node_id)), false) }, - HandleTxCompleteValue::SendTxComplete(msg, signing_session) => { - (Some(msg.into_msg_send_event(counterparty_node_id)), Some(signing_session)) - }, - HandleTxCompleteValue::NegotiationComplete(signing_session) => { - (None, Some(signing_session)) + HandleTxCompleteValue::SendTxComplete(msg, negotiation_complete) => { + (Some(msg.into_msg_send_event(counterparty_node_id)), negotiation_complete) }, + HandleTxCompleteValue::NegotiationComplete => (None, true), } } } @@ -1625,19 +1623,19 @@ impl HandleTxCompleteValue { pub(super) struct HandleTxCompleteResult(pub Result); impl HandleTxCompleteResult { - pub fn into_msg_send_event_or_signing_session( + pub fn into_msg_send_event( self, counterparty_node_id: PublicKey, - ) -> (Option, Option) { + ) -> (Option, bool) { match self.0 { Ok(interactive_tx_msg_send) => { - interactive_tx_msg_send.into_msg_send_event_or_signing_session(counterparty_node_id) + interactive_tx_msg_send.into_msg_send_event(counterparty_node_id) }, Err(tx_abort_msg) => ( Some(MessageSendEvent::SendTxAbort { node_id: counterparty_node_id, msg: tx_abort_msg, }), - None, + false, ), } } @@ -1835,8 +1833,8 @@ impl InteractiveTxConstructor { StateMachine::ReceivedTxComplete(_) => { let msg_send = self.maybe_send_message()?; match &self.state_machine { - StateMachine::NegotiationComplete(s) => { - Ok(HandleTxCompleteValue::SendTxComplete(msg_send, s.0.clone())) + StateMachine::NegotiationComplete(_) => { + Ok(HandleTxCompleteValue::SendTxComplete(msg_send, true)) }, StateMachine::SentChangeMsg(_) => { Ok(HandleTxCompleteValue::SendTxMessage(msg_send)) @@ -1847,9 +1845,7 @@ impl InteractiveTxConstructor { }, } }, - StateMachine::NegotiationComplete(s) => { - Ok(HandleTxCompleteValue::NegotiationComplete(s.0.clone())) - }, + StateMachine::NegotiationComplete(_) => Ok(HandleTxCompleteValue::NegotiationComplete), _ => { debug_assert!( false, @@ -1859,6 +1855,13 @@ impl InteractiveTxConstructor { }, } } + + pub fn into_signing_session(self) -> InteractiveTxSigningSession { + match self.state_machine { + StateMachine::NegotiationComplete(s) => s.0, + _ => panic!("Signing session is not ready yet"), + } + } } /// Determine whether a change output should be added, and if yes, of what size, considering our @@ -2082,7 +2085,7 @@ mod tests { ), outputs_to_contribute: session.outputs_a, }) { - Ok(r) => r, + Ok(r) => Some(r), Err(abort_reason) => { assert_eq!( Some((abort_reason, ErrorCulprit::NodeA)), @@ -2119,7 +2122,7 @@ mod tests { ), outputs_to_contribute: session.outputs_b, }) { - Ok(r) => r, + Ok(r) => Some(r), Err(abort_reason) => { assert_eq!( Some((abort_reason, ErrorCulprit::NodeB)), @@ -2136,35 +2139,44 @@ mod tests { match msg { InteractiveTxMessageSend::TxAddInput(msg) => for_constructor .handle_tx_add_input(&msg) - .map(|msg_send| (Some(msg_send), None)), + .map(|msg_send| (Some(msg_send), false)), InteractiveTxMessageSend::TxAddOutput(msg) => for_constructor .handle_tx_add_output(&msg) - .map(|msg_send| (Some(msg_send), None)), + .map(|msg_send| (Some(msg_send), false)), InteractiveTxMessageSend::TxComplete(msg) => { for_constructor.handle_tx_complete(&msg).map(|value| match value { HandleTxCompleteValue::SendTxMessage(msg_send) => { - (Some(msg_send), None) - }, - HandleTxCompleteValue::SendTxComplete(msg_send, tx) => { - (Some(msg_send), Some(tx)) + (Some(msg_send), false) }, - HandleTxCompleteValue::NegotiationComplete(tx) => (None, Some(tx)), + HandleTxCompleteValue::SendTxComplete( + msg_send, + negotiation_complete, + ) => (Some(msg_send), negotiation_complete), + HandleTxCompleteValue::NegotiationComplete => (None, true), }) }, } }; - let mut message_send_a = constructor_a.take_initiator_first_message(); + let mut message_send_a = constructor_a.as_mut().unwrap().take_initiator_first_message(); let mut message_send_b = None; let mut final_tx_a = None; let mut final_tx_b = None; - while final_tx_a.is_none() || final_tx_b.is_none() { + while constructor_a.is_some() || constructor_b.is_some() { if let Some(message_send_a) = message_send_a.take() { - match handle_message_send(message_send_a, &mut constructor_b) { - Ok((msg_send, interactive_signing_session)) => { + match handle_message_send(message_send_a, constructor_b.as_mut().unwrap()) { + Ok((msg_send, negotiation_complete)) => { message_send_b = msg_send; - final_tx_b = interactive_signing_session - .map(|session| session.unsigned_tx.compute_txid()); + if negotiation_complete { + final_tx_b = Some( + constructor_b + .take() + .unwrap() + .into_signing_session() + .unsigned_tx + .compute_txid(), + ); + } }, Err(abort_reason) => { let error_culprit = match abort_reason { @@ -2185,11 +2197,19 @@ mod tests { } } if let Some(message_send_b) = message_send_b.take() { - match handle_message_send(message_send_b, &mut constructor_a) { - Ok((msg_send, interactive_signing_session)) => { + match handle_message_send(message_send_b, constructor_a.as_mut().unwrap()) { + Ok((msg_send, negotiation_complete)) => { message_send_a = msg_send; - final_tx_a = interactive_signing_session - .map(|session| session.unsigned_tx.compute_txid()); + if negotiation_complete { + final_tx_a = Some( + constructor_a + .take() + .unwrap() + .into_signing_session() + .unsigned_tx + .compute_txid(), + ); + } }, Err(abort_reason) => { let error_culprit = match abort_reason { From a2375f565a6c23712a4486b4ad2b3c7654956d91 Mon Sep 17 00:00:00 2001 From: optout <13562139+optout21@users.noreply.github.com> Date: Mon, 21 Jul 2025 23:31:18 -0500 Subject: [PATCH 6/9] Complete splice initialization Update splice_channel, split_init, and splice_ack to implement transitioning from splice initialization to funding transaction negotiation. Co-authored-by: optout <13562139+optout21@users.noreply.github.com> Co-authored-by: Jeffrey Czyz --- lightning/src/ln/channel.rs | 529 ++++++++++++++++++++++++++--- lightning/src/ln/channelmanager.rs | 89 ++--- lightning/src/ln/splicing_tests.rs | 158 ++++++++- 3 files changed, 672 insertions(+), 104 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 53ce3a1006f..d140e637016 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -25,6 +25,8 @@ use bitcoin::secp256k1::constants::PUBLIC_KEY_SIZE; use bitcoin::secp256k1::{ecdsa::Signature, Secp256k1}; use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::{secp256k1, sighash}; +#[cfg(splicing)] +use bitcoin::{Sequence, Witness}; use crate::chain::chaininterface::{ fee_for_weight, ConfirmationTarget, FeeEstimator, LowerBoundedFeeEstimator, @@ -56,7 +58,9 @@ use crate::ln::channelmanager::{ BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; #[cfg(splicing)] -use crate::ln::interactivetxs::{calculate_change_output_value, AbortReason}; +use crate::ln::interactivetxs::{ + calculate_change_output_value, AbortReason, InteractiveTxMessageSend, +}; use crate::ln::interactivetxs::{ get_output_weight, InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxSigningSession, SharedOwnedInput, SharedOwnedOutput, TX_COMMON_FIELDS_WEIGHT, @@ -2233,9 +2237,146 @@ impl FundingScope { pub fn get_short_channel_id(&self) -> Option { self.short_channel_id } + + /// 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, + ) -> Result + where + SP::Target: SignerProvider, + { + let post_channel_value = prev_funding.compute_post_splice_value( + our_funding_contribution_sats, + their_funding_contribution_sats, + ); + + let post_value_to_self_msat = AddSigned::checked_add_signed( + prev_funding.value_to_self_msat, + our_funding_contribution_sats * 1000, + ); + debug_assert!(post_value_to_self_msat.is_some()); + let post_value_to_self_msat = post_value_to_self_msat.unwrap(); + + // Rotate the pubkeys using the prev_funding_txid as a tweak + let prev_funding_txid = prev_funding.get_funding_txid(); + let holder_pubkeys = context.holder_pubkeys(prev_funding_txid); + + let channel_parameters = &prev_funding.channel_transaction_parameters; + let mut post_channel_transaction_parameters = ChannelTransactionParameters { + holder_pubkeys, + holder_selected_contest_delay: channel_parameters.holder_selected_contest_delay, + // The 'outbound' attribute doesn't change, even if the splice initiator is the other node + is_outbound_from_holder: channel_parameters.is_outbound_from_holder, + counterparty_parameters: channel_parameters.counterparty_parameters.clone(), + funding_outpoint: None, // filled later + splice_parent_funding_txid: prev_funding_txid, + channel_type_features: channel_parameters.channel_type_features.clone(), + channel_value_satoshis: post_channel_value, + }; + post_channel_transaction_parameters + .counterparty_parameters + .as_mut() + .expect("counterparty_parameters should be set") + .pubkeys + .funding_pubkey = counterparty_funding_pubkey; + + // New reserve values are based on the new channel value and are v2-specific + let counterparty_selected_channel_reserve_satoshis = Some(get_v2_channel_reserve_satoshis( + post_channel_value, + context.counterparty_dust_limit_satoshis, + )); + let holder_selected_channel_reserve_satoshis = + get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS); + + Ok(Self { + channel_transaction_parameters: post_channel_transaction_parameters, + value_to_self_msat: post_value_to_self_msat, + funding_transaction: None, + counterparty_selected_channel_reserve_satoshis, + holder_selected_channel_reserve_satoshis, + #[cfg(debug_assertions)] + holder_max_commitment_tx_output: Mutex::new(( + post_value_to_self_msat, + (post_channel_value * 1000).saturating_sub(post_value_to_self_msat), + )), + #[cfg(debug_assertions)] + counterparty_max_commitment_tx_output: Mutex::new(( + post_value_to_self_msat, + (post_channel_value * 1000).saturating_sub(post_value_to_self_msat), + )), + #[cfg(any(test, fuzzing))] + next_local_commitment_tx_fee_info_cached: Mutex::new(None), + #[cfg(any(test, fuzzing))] + next_remote_commitment_tx_fee_info_cached: Mutex::new(None), + funding_tx_confirmation_height: 0, + funding_tx_confirmed_in: None, + minimum_depth_override: None, + short_channel_id: None, + }) + } + + /// Compute the post-splice channel value from each counterparty's contributions. + #[cfg(splicing)] + pub(super) fn compute_post_splice_value( + &self, our_funding_contribution: i64, their_funding_contribution: i64, + ) -> u64 { + AddSigned::saturating_add_signed( + self.get_value_satoshis(), + our_funding_contribution.saturating_add(their_funding_contribution), + ) + } + + /// Returns a `SharedOwnedInput` for using this `FundingScope` as the input to a new splice. + #[cfg(splicing)] + fn to_splice_funding_input(&self) -> SharedOwnedInput { + let funding_txo = self.get_funding_txo().expect("funding_txo should be set"); + let input = TxIn { + previous_output: funding_txo.into_bitcoin_outpoint(), + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }; + + let prev_output = TxOut { + value: Amount::from_sat(self.get_value_satoshis()), + script_pubkey: self.get_funding_redeemscript().to_p2wsh(), + }; + + let local_owned = self.value_to_self_msat / 1000; + + SharedOwnedInput::new(input, prev_output, local_owned) + } +} + +// TODO: Remove once MSRV is at least 1.66 +#[cfg(splicing)] +trait AddSigned { + fn checked_add_signed(self, rhs: i64) -> Option; + fn saturating_add_signed(self, rhs: i64) -> u64; +} + +#[cfg(splicing)] +impl AddSigned for u64 { + fn checked_add_signed(self, rhs: i64) -> Option { + if rhs >= 0 { + self.checked_add(rhs as u64) + } else { + self.checked_sub(rhs.unsigned_abs()) + } + } + + fn saturating_add_signed(self, rhs: i64) -> u64 { + if rhs >= 0 { + self.saturating_add(rhs as u64) + } else { + self.saturating_sub(rhs.unsigned_abs()) + } + } } -/// Info about a pending splice, used in the pre-splice channel +/// Info about a pending splice #[cfg(splicing)] struct PendingSplice { funding_negotiation: Option, @@ -3629,6 +3770,17 @@ where return &mut self.holder_signer; } + /// Returns holder pubkeys to use for the channel. + #[cfg(splicing)] + fn holder_pubkeys(&self, prev_funding_txid: Option) -> ChannelPublicKeys { + match &self.holder_signer { + ChannelSignerType::Ecdsa(ecdsa) => ecdsa.pubkeys(prev_funding_txid, &self.secp_ctx), + // TODO (taproot|arik) + #[cfg(taproot)] + _ => todo!(), + } + } + /// Only allowed immediately after deserialization if get_outbound_scid_alias returns 0, /// indicating we were written by LDK prior to 0.0.106 which did not set outbound SCID aliases /// or prior to any channel actions during `Channel` initialization. @@ -10433,10 +10585,10 @@ where /// - `our_funding_inputs`: the inputs we contribute to the new funding transaction. /// Includes the witness weight for this input (e.g. P2WPKH_WITNESS_WEIGHT=109 for typical P2WPKH inputs). #[cfg(splicing)] - #[rustfmt::skip] - pub fn splice_channel(&mut self, our_funding_contribution_satoshis: i64, - our_funding_inputs: &Vec<(TxIn, Transaction, Weight)>, - funding_feerate_per_kw: u32, locktime: u32, + pub fn splice_channel( + &mut self, our_funding_contribution_satoshis: i64, + our_funding_inputs: Vec<(TxIn, Transaction, Weight)>, 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) @@ -10450,19 +10602,23 @@ where } if !self.context.is_live() { - return Err(APIError::APIMisuseError { err: format!( - "Channel {} cannot be spliced, as channel is not live", - self.context.channel_id() - )}); + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced, as channel is not live", + self.context.channel_id() + ), + }); } // TODO(splicing): check for quiescence if our_funding_contribution_satoshis < 0 { - return Err(APIError::APIMisuseError { err: format!( + 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, - )}); + ), + }); } // TODO(splicing): Once splice-out is supported, check that channel balance does not go below 0 @@ -10472,51 +10628,71 @@ where // (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_satoshis, &our_funding_inputs, true, true, funding_feerate_per_kw) - .map_err(|err| APIError::APIMisuseError { err: format!( + let _fee = check_v2_funding_inputs_sufficient( + our_funding_contribution_satoshis, + &our_funding_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, - )})?; + self.context.channel_id(), + err, + ), + })?; + // 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)); + } + + let prev_funding_input = self.funding.to_splice_funding_input(); + let funding_negotiation_context = FundingNegotiationContext { + is_initiator: true, + our_funding_contribution_satoshis, + 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), + our_funding_inputs: funding_inputs, + }; self.pending_splice = Some(PendingSplice { - funding_negotiation: None, + funding_negotiation: Some(FundingNegotiation::AwaitingAck(funding_negotiation_context)), sent_funding_txid: None, received_funding_txid: None, }); - let msg = self.get_splice_init(our_funding_contribution_satoshis, funding_feerate_per_kw, locktime); - Ok(msg) - } + // Rotate the pubkeys using the prev_funding_txid as a tweak + let prev_funding_txid = self.funding.get_funding_txid(); + let funding_pubkey = self.context.holder_pubkeys(prev_funding_txid).funding_pubkey; - /// Get the splice message that can be sent during splice initiation. - #[cfg(splicing)] - fn get_splice_init( - &self, our_funding_contribution_satoshis: i64, funding_feerate_per_kw: u32, locktime: u32, - ) -> msgs::SpliceInit { - // TODO(splicing): The exisiting pubkey is reused, but a new one should be generated. See #3542. - // Note that channel_keys_id is supposed NOT to change - let funding_pubkey = self.funding.get_holder_pubkeys().funding_pubkey.clone(); - msgs::SpliceInit { + Ok(msgs::SpliceInit { channel_id: self.context.channel_id, funding_contribution_satoshis: our_funding_contribution_satoshis, funding_feerate_per_kw, locktime, funding_pubkey, require_confirmed_inputs: None, - } + }) } - /// Handle splice_init + /// Checks during handling splice_init #[cfg(splicing)] - #[rustfmt::skip] - pub fn splice_init(&mut self, msg: &msgs::SpliceInit) -> Result { + pub fn validate_splice_init( + &self, msg: &msgs::SpliceInit, our_funding_contribution_satoshis: i64, + ) -> Result { let their_funding_contribution_satoshis = msg.funding_contribution_satoshis; - // TODO(splicing): Currently not possible to contribute on the splicing-acceptor side - let our_funding_contribution_satoshis = 0i64; + + // TODO(splicing): Add check that we are the quiescence acceptor // Check if a splice has been initiated already. if self.pending_splice.is_some() { - return Err(ChannelError::Warn(format!( + return Err(ChannelError::WarnAndDisconnect(format!( "Channel {} already has a splice pending", self.context.channel_id(), ))); @@ -10526,50 +10702,196 @@ where // MUST send a warning and close the connection or send an error // and fail the channel. if !self.context.is_live() { - return Err(ChannelError::Warn(format!("Splicing requested on a channel that is not live"))); + return Err(ChannelError::WarnAndDisconnect(format!( + "Splicing requested on a channel that is not live" + ))); } - if their_funding_contribution_satoshis.saturating_add(our_funding_contribution_satoshis) < 0 { - return Err(ChannelError::Warn(format!( + if their_funding_contribution_satoshis.saturating_add(our_funding_contribution_satoshis) < 0 + { + 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_satoshis, + our_funding_contribution_satoshis, ))); } + let splice_funding = FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution_satoshis, + their_funding_contribution_satoshis, + msg.funding_pubkey, + )?; + // TODO(splicing): Once splice acceptor can contribute, check that inputs are sufficient, // similarly to the check in `splice_channel`. // Note on channel reserve requirement pre-check: as the splice acceptor does not contribute, // it can't go below reserve, therefore no pre-check is done here. - // TODO(splicing): Once splice acceptor can contribute, add reserve pre-check, similar to the one in `splice_ack`. - // TODO(splicing): Store msg.funding_pubkey - // TODO(splicing): Apply start of splice (splice_start) + // TODO(splicing): Early check for reserve requirement + + Ok(splice_funding) + } + + /// See also [`validate_splice_init`] + #[cfg(splicing)] + pub(crate) fn splice_init( + &mut self, msg: &msgs::SpliceInit, our_funding_contribution_satoshis: i64, + signer_provider: &SP, entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, + ) -> Result + where + ES::Target: EntropySource, + L::Target: Logger, + { + let splice_funding = self.validate_splice_init(msg, our_funding_contribution_satoshis)?; + + log_info!( + logger, + "Starting splice funding negotiation for channel {} after receiving splice_init; new channel value: {} sats (old: {} sats)", + self.context.channel_id, + splice_funding.get_value_satoshis(), + 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_satoshis, + 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), + our_funding_inputs: Vec::new(), + }; + + let mut interactive_tx_constructor = funding_negotiation_context + .into_interactive_tx_constructor( + &self.context, + &splice_funding, + signer_provider, + entropy_source, + holder_node_id.clone(), + None, + ) + .map_err(|err| { + ChannelError::WarnAndDisconnect(format!( + "Failed to start interactive transaction construction, {:?}", + err + )) + })?; + debug_assert!(interactive_tx_constructor.take_initiator_first_message().is_none()); + + let funding_pubkey = splice_funding.get_holder_pubkeys().funding_pubkey; + + self.pending_splice = Some(PendingSplice { + funding_negotiation: Some(FundingNegotiation::ConstructingTransaction( + splice_funding, + interactive_tx_constructor, + )), + received_funding_txid: None, + sent_funding_txid: None, + }); - // TODO(splicing): The exisiting pubkey is reused, but a new one should be generated. See #3542. - // Note that channel_keys_id is supposed NOT to change - let splice_ack_msg = msgs::SpliceAck { + Ok(msgs::SpliceAck { channel_id: self.context.channel_id, funding_contribution_satoshis: our_funding_contribution_satoshis, - funding_pubkey: self.funding.get_holder_pubkeys().funding_pubkey, + funding_pubkey, require_confirmed_inputs: None, - }; - // TODO(splicing): start interactive funding negotiation - Ok(splice_ack_msg) + }) } /// Handle splice_ack #[cfg(splicing)] - pub fn splice_ack(&mut self, _msg: &msgs::SpliceAck) -> Result<(), ChannelError> { - // check if splice is pending - if self.pending_splice.is_none() { - return Err(ChannelError::Warn(format!("Channel is not in pending splice"))); + pub(crate) fn splice_ack( + &mut self, msg: &msgs::SpliceAck, signer_provider: &SP, entropy_source: &ES, + holder_node_id: &PublicKey, logger: &L, + ) -> Result, ChannelError> + where + ES::Target: EntropySource, + L::Target: Logger, + { + let pending_splice = if let Some(ref mut pending_splice) = &mut self.pending_splice { + pending_splice + } else { + return Err(ChannelError::Ignore(format!("Channel is not in pending splice"))); + }; + + // TODO(splicing): Add check that we are the splice (quiescence) initiator + + let funding_negotiation_context = match pending_splice.funding_negotiation.take() { + Some(FundingNegotiation::AwaitingAck(context)) => context, + Some(FundingNegotiation::ConstructingTransaction(funding, constructor)) => { + pending_splice.funding_negotiation = + Some(FundingNegotiation::ConstructingTransaction(funding, constructor)); + return Err(ChannelError::WarnAndDisconnect(format!( + "Got unexpected splice_ack; splice negotiation already in progress" + ))); + }, + Some(FundingNegotiation::AwaitingSignatures(funding)) => { + pending_splice.funding_negotiation = + Some(FundingNegotiation::AwaitingSignatures(funding)); + return Err(ChannelError::WarnAndDisconnect(format!( + "Got unexpected splice_ack; splice negotiation already in progress" + ))); + }, + None => { + return Err(ChannelError::Ignore(format!( + "Got unexpected splice_ack; no splice negotiation in progress" + ))); + }, }; + let our_funding_contribution_satoshis = + funding_negotiation_context.our_funding_contribution_satoshis; + let their_funding_contribution_satoshis = msg.funding_contribution_satoshis; + + let splice_funding = FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution_satoshis, + their_funding_contribution_satoshis, + msg.funding_pubkey, + )?; + // TODO(splicing): Pre-check for reserve requirement // (Note: It should also be checked later at tx_complete) - Ok(()) + + log_info!( + logger, + "Starting splice funding negotiation for channel {} after receiving splice_ack; new channel value: {} sats (old: {} sats)", + self.context.channel_id, + splice_funding.get_value_satoshis(), + self.funding.get_value_satoshis(), + ); + + let mut interactive_tx_constructor = funding_negotiation_context + .into_interactive_tx_constructor( + &self.context, + &splice_funding, + signer_provider, + entropy_source, + holder_node_id.clone(), + None, + ) + .map_err(|err| { + ChannelError::WarnAndDisconnect(format!( + "Failed to start interactive transaction construction, {:?}", + err + )) + })?; + let tx_msg_opt = interactive_tx_constructor.take_initiator_first_message(); + + debug_assert!(self.interactive_tx_signing_session.is_none()); + pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction( + splice_funding, + interactive_tx_constructor, + )); + + Ok(tx_msg_opt) } #[cfg(splicing)] @@ -13719,6 +14041,8 @@ mod tests { use crate::chain::chaininterface::LowerBoundedFeeEstimator; use crate::chain::transaction::OutPoint; use crate::chain::BestBlock; + #[cfg(splicing)] + use crate::ln::chan_utils::ChannelTransactionParameters; use crate::ln::chan_utils::{self, commit_tx_fee_sat}; use crate::ln::channel::{ AwaitingChannelReadyFlags, ChannelState, FundedChannel, HTLCCandidate, HTLCInitiator, @@ -13738,6 +14062,8 @@ mod tests { use crate::prelude::*; use crate::routing::router::{Path, RouteHop}; use crate::sign::{ChannelSigner, EntropySource, InMemorySigner, SignerProvider}; + #[cfg(splicing)] + use crate::sync::Mutex; #[cfg(ldk_test_vectors)] use crate::types::features::ChannelTypeFeatures; use crate::types::features::{ChannelFeatures, NodeFeatures}; @@ -15608,4 +15934,97 @@ mod tests { ); } } + + #[cfg(splicing)] + fn get_pre_and_post( + pre_channel_value: u64, our_funding_contribution: i64, their_funding_contribution: i64, + ) -> (u64, u64) { + use crate::ln::channel::FundingScope; + + let funding = FundingScope { + value_to_self_msat: 0, + counterparty_selected_channel_reserve_satoshis: None, + holder_selected_channel_reserve_satoshis: 0, + + #[cfg(debug_assertions)] + holder_max_commitment_tx_output: Mutex::new((0, 0)), + #[cfg(debug_assertions)] + counterparty_max_commitment_tx_output: Mutex::new((0, 0)), + + #[cfg(any(test, fuzzing))] + next_local_commitment_tx_fee_info_cached: Mutex::new(None), + #[cfg(any(test, fuzzing))] + next_remote_commitment_tx_fee_info_cached: Mutex::new(None), + + channel_transaction_parameters: ChannelTransactionParameters::test_dummy( + pre_channel_value, + ), + funding_transaction: None, + funding_tx_confirmed_in: None, + funding_tx_confirmation_height: 0, + short_channel_id: None, + minimum_depth_override: None, + }; + let post_channel_value = + funding.compute_post_splice_value(our_funding_contribution, their_funding_contribution); + (pre_channel_value, post_channel_value) + } + + #[cfg(splicing)] + #[test] + fn test_compute_post_splice_value() { + { + // increase, small amounts + let (pre_channel_value, post_channel_value) = get_pre_and_post(9_000, 6_000, 0); + assert_eq!(pre_channel_value, 9_000); + assert_eq!(post_channel_value, 15_000); + } + { + // increase, small amounts + let (pre_channel_value, post_channel_value) = get_pre_and_post(9_000, 4_000, 2_000); + assert_eq!(pre_channel_value, 9_000); + assert_eq!(post_channel_value, 15_000); + } + { + // increase, small amounts + let (pre_channel_value, post_channel_value) = get_pre_and_post(9_000, 0, 6_000); + assert_eq!(pre_channel_value, 9_000); + assert_eq!(post_channel_value, 15_000); + } + { + // decrease, small amounts + let (pre_channel_value, post_channel_value) = get_pre_and_post(15_000, -6_000, 0); + assert_eq!(pre_channel_value, 15_000); + assert_eq!(post_channel_value, 9_000); + } + { + // decrease, small amounts + let (pre_channel_value, post_channel_value) = get_pre_and_post(15_000, -4_000, -2_000); + assert_eq!(pre_channel_value, 15_000); + assert_eq!(post_channel_value, 9_000); + } + { + // increase and decrease + let (pre_channel_value, post_channel_value) = get_pre_and_post(15_000, 4_000, -2_000); + assert_eq!(pre_channel_value, 15_000); + assert_eq!(post_channel_value, 17_000); + } + let base2: u64 = 2; + let huge63i3 = (base2.pow(63) - 3) as i64; + assert_eq!(huge63i3, 9223372036854775805); + assert_eq!(-huge63i3, -9223372036854775805); + { + // increase, large amount + let (pre_channel_value, post_channel_value) = get_pre_and_post(9_000, huge63i3, 3); + assert_eq!(pre_channel_value, 9_000); + assert_eq!(post_channel_value, 9223372036854784807); + } + { + // increase, large amounts + let (pre_channel_value, post_channel_value) = + get_pre_and_post(9_000, huge63i3, huge63i3); + assert_eq!(pre_channel_value, 9_000); + assert_eq!(post_channel_value, 9223372036854784807); + } + } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index f6bf0d2db60..8f3c49d57e9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4443,7 +4443,7 @@ where 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, funding_feerate_per_kw, locktime + channel_id, counterparty_node_id, our_funding_contribution_satoshis, our_funding_inputs, funding_feerate_per_kw, locktime ); res = result; match res { @@ -4456,16 +4456,19 @@ where /// See [`splice_channel`] #[cfg(splicing)] - #[rustfmt::skip] fn internal_splice_channel( - &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, our_funding_contribution_satoshis: i64, - our_funding_inputs: &Vec<(TxIn, Transaction, Weight)>, - funding_feerate_per_kw: u32, locktime: Option, + &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, + our_funding_contribution_satoshis: i64, + our_funding_inputs: Vec<(TxIn, Transaction, Weight)>, funding_feerate_per_kw: u32, + locktime: Option, ) -> Result<(), APIError> { let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = match per_peer_state.get(counterparty_node_id) - .ok_or_else(|| APIError::ChannelUnavailable { err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}") }) { + let peer_state_mutex = match per_peer_state.get(counterparty_node_id).ok_or_else(|| { + APIError::ChannelUnavailable { + err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), + } + }) { Ok(p) => p, Err(e) => return Err(e), }; @@ -4478,7 +4481,12 @@ 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, funding_feerate_per_kw, locktime)?; + let msg = chan.splice_channel( + our_funding_contribution_satoshis, + our_funding_inputs, + funding_feerate_per_kw, + locktime, + )?; peer_state.pending_msg_events.push(MessageSendEvent::SendSpliceInit { node_id: *counterparty_node_id, msg, @@ -4489,18 +4497,16 @@ where err: format!( "Channel with id {} is not funded, cannot splice it", channel_id - ) + ), }) } }, - hash_map::Entry::Vacant(_) => { - Err(APIError::ChannelUnavailable { - err: format!( - "Channel with id {} not found for the passed counterparty node_id {}", - channel_id, counterparty_node_id, - ) - }) - }, + hash_map::Entry::Vacant(_) => Err(APIError::ChannelUnavailable { + err: format!( + "Channel with id {} not found for the passed counterparty node_id {}", + channel_id, counterparty_node_id, + ), + }), } } @@ -10857,6 +10863,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; + // TODO(splicing): Currently not possible to contribute on the splicing-acceptor side + let our_funding_contribution = 0i64; + // Look for the channel match peer_state.channel_by_id.entry(msg.channel_id) { hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!( @@ -10864,24 +10873,22 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ counterparty_node_id, msg.channel_id, ), msg.channel_id)), hash_map::Entry::Occupied(mut chan_entry) => { - if let Some(chan) = chan_entry.get_mut().as_funded_mut() { - let splice_ack_msg = try_channel_entry!(self, peer_state, chan.splice_init(msg), chan_entry); + if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { + let init_res = funded_channel.splice_init( + msg, our_funding_contribution, &self.signer_provider, &self.entropy_source, + &self.get_our_node_id(), &self.logger + ); + let splice_ack_msg = try_channel_entry!(self, peer_state, init_res, chan_entry); peer_state.pending_msg_events.push(MessageSendEvent::SendSpliceAck { node_id: *counterparty_node_id, msg: splice_ack_msg, }); + Ok(()) } else { - return Err(MsgHandleErrInternal::send_err_msg_no_close("Channel is not funded, cannot be spliced".to_owned(), msg.channel_id)); + try_channel_entry!(self, peer_state, Err(ChannelError::close("Channel is not funded, cannot be spliced".into())), chan_entry) } }, - }; - - // TODO(splicing): - // Change channel, change phase (remove and add) - // Create new post-splice channel - // etc. - - Ok(()) + } } /// Handle incoming splice request ack, transition channel to splice-pending (unless some check fails). @@ -10899,26 +10906,26 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // Look for the channel match peer_state.channel_by_id.entry(msg.channel_id) { - hash_map::Entry::Vacant(_) => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!( + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::send_err_msg_no_close(format!( "Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id ), msg.channel_id)), hash_map::Entry::Occupied(mut chan_entry) => { - if let Some(chan) = chan_entry.get_mut().as_funded_mut() { - try_channel_entry!(self, peer_state, chan.splice_ack(msg), chan_entry); + if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { + let splice_ack_res = funded_channel.splice_ack( + msg, &self.signer_provider, &self.entropy_source, + &self.get_our_node_id(), &self.logger + ); + let tx_msg_opt = try_channel_entry!(self, peer_state, splice_ack_res, chan_entry); + if let Some(tx_msg) = tx_msg_opt { + peer_state.pending_msg_events.push(tx_msg.into_msg_send_event(counterparty_node_id.clone())); + } + Ok(()) } else { - return Err(MsgHandleErrInternal::send_err_msg_no_close("Channel is not funded, cannot splice".to_owned(), msg.channel_id)); + try_channel_entry!(self, peer_state, Err(ChannelError::close("Channel is not funded, cannot be spliced".into())), chan_entry) } }, - }; - - // TODO(splicing): - // Change channel, change phase (remove and add) - // Create new post-splice channel - // Start splice funding transaction negotiation - // etc. - - Err(MsgHandleErrInternal::send_err_msg_no_close("TODO(splicing): Splicing is not implemented (splice_ack)".to_owned(), msg.channel_id)) + } } #[cfg(splicing)] diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 33f5a500789..af1fa1f3ab3 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -28,6 +28,7 @@ fn test_v1_splice_in() { let channel_value_sat = 100_000; let channel_reserve_amnt_sat = 1_000; + let expect_outputs_in_reverse = true; let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value( &nodes, @@ -42,16 +43,21 @@ fn test_v1_splice_in() { assert_eq!(channel_id.to_string(), expected_funded_channel_id); let expected_initiator_funding_key = - "03c21e841cbc0b48197d060c71e116c185fa0ac281b7d0aa5924f535154437ca3b"; + "020abf01c18d5a2543124a12150d698ebf3a8e17df9993521151a49e115678ceea"; let expected_acceptor_funding_key = - "039481c28b904cbe12681e79937373fc76245c1b29871028ae60ba3152162c319b"; + "036b47248c628fca98159f30f6b03a6cf0be0c4808cff17c75dc855fe94a244766"; // ==== Channel is now ready for normal operation + // Expected balances + let mut exp_balance1 = 1000 * channel_value_sat; + let mut _exp_balance2 = 0; + // === Start of Splicing // Amount being added to the channel through the splice-in let splice_in_sats = 20_000; + let post_splice_channel_value = channel_value_sat + splice_in_sats; let funding_feerate_per_kw = 1024; // Create additional inputs @@ -121,17 +127,153 @@ fn test_v1_splice_in() { assert!(channel.is_usable); assert!(channel.is_channel_ready); assert_eq!(channel.channel_value_satoshis, channel_value_sat); - assert_eq!( - channel.outbound_capacity_msat, - 1000 * (channel_value_sat - channel_reserve_amnt_sat) - ); + assert_eq!(channel.outbound_capacity_msat, exp_balance1 - 1000 * channel_reserve_amnt_sat); assert!(channel.funding_txo.is_some()); assert!(channel.confirmations.unwrap() > 0); } - let _error_msg = get_err_msg(initiator_node, &acceptor_node.node.get_our_node_id()); + // exp_balance1 += 1000 * splice_in_sats; // increase in balance + + // Negotiate transaction inputs and outputs + + // First input + let tx_add_input_msg = get_event_msg!( + &initiator_node, + MessageSendEvent::SendTxAddInput, + acceptor_node.node.get_our_node_id() + ); + // check which input is this (order is non-deterministic), based on the presense of prevtx + let inputs_seen_in_reverse = tx_add_input_msg.prevtx.is_some(); + if !inputs_seen_in_reverse { + // Input is the revious funding input + assert_eq!(tx_add_input_msg.prevtx, None); + assert_eq!( + tx_add_input_msg.shared_input_txid.unwrap().to_string(), + "4f128bedf1a15baf465ab1bfd6e97c8f82628f4156bf86eb1cbc132cda6733ae" + ); + } else { + // Input is the extra input + let prevtx_value = tx_add_input_msg.prevtx.as_ref().unwrap().as_transaction().output + [tx_add_input_msg.prevtx_out as usize] + .value + .to_sat(); + assert_eq!(prevtx_value, extra_splice_funding_input_sats); + assert_eq!(tx_add_input_msg.shared_input_txid, None); + } + + let _res = acceptor_node + .node + .handle_tx_add_input(initiator_node.node.get_our_node_id(), &tx_add_input_msg); + let tx_complete_msg = get_event_msg!( + acceptor_node, + MessageSendEvent::SendTxComplete, + initiator_node.node.get_our_node_id() + ); + + let _res = initiator_node + .node + .handle_tx_complete(acceptor_node.node.get_our_node_id(), &tx_complete_msg); + // Second input + let tx_add_input2_msg = get_event_msg!( + &initiator_node, + MessageSendEvent::SendTxAddInput, + acceptor_node.node.get_our_node_id() + ); + if !inputs_seen_in_reverse { + // Input is the extra input + let prevtx_value = tx_add_input2_msg.prevtx.as_ref().unwrap().as_transaction().output + [tx_add_input2_msg.prevtx_out as usize] + .value + .to_sat(); + assert_eq!(prevtx_value, extra_splice_funding_input_sats); + assert_eq!(tx_add_input2_msg.shared_input_txid, None); + } else { + // Input is the revious funding input + assert_eq!(tx_add_input2_msg.prevtx, None); + assert_eq!( + tx_add_input2_msg.shared_input_txid.unwrap().to_string(), + "4f128bedf1a15baf465ab1bfd6e97c8f82628f4156bf86eb1cbc132cda6733ae" + ); + } + + let _res = acceptor_node + .node + .handle_tx_add_input(initiator_node.node.get_our_node_id(), &tx_add_input2_msg); + let tx_complete_msg = get_event_msg!( + acceptor_node, + MessageSendEvent::SendTxComplete, + initiator_node.node.get_our_node_id() + ); + + let _res = initiator_node + .node + .handle_tx_complete(acceptor_node.node.get_our_node_id(), &tx_complete_msg); + + // TxAddOutput for the change output + let tx_add_output_msg = get_event_msg!( + &initiator_node, + MessageSendEvent::SendTxAddOutput, + acceptor_node.node.get_our_node_id() + ); + if !expect_outputs_in_reverse { + assert!(tx_add_output_msg.script.is_p2wsh()); + assert_eq!(tx_add_output_msg.sats, post_splice_channel_value); + } else { + assert!(tx_add_output_msg.script.is_p2wpkh()); + assert_eq!(tx_add_output_msg.sats, 14146); // extra_splice_funding_input_sats - splice_in_sats + } + + let _res = acceptor_node + .node + .handle_tx_add_output(initiator_node.node.get_our_node_id(), &tx_add_output_msg); + let tx_complete_msg = get_event_msg!( + &acceptor_node, + MessageSendEvent::SendTxComplete, + initiator_node.node.get_our_node_id() + ); + + let _res = initiator_node + .node + .handle_tx_complete(acceptor_node.node.get_our_node_id(), &tx_complete_msg); + // TxAddOutput for the splice funding + let tx_add_output2_msg = get_event_msg!( + &initiator_node, + MessageSendEvent::SendTxAddOutput, + acceptor_node.node.get_our_node_id() + ); + if !expect_outputs_in_reverse { + assert!(tx_add_output2_msg.script.is_p2wpkh()); + assert_eq!(tx_add_output2_msg.sats, 14146); // extra_splice_funding_input_sats - splice_in_sats + } else { + assert!(tx_add_output2_msg.script.is_p2wsh()); + assert_eq!(tx_add_output2_msg.sats, post_splice_channel_value); + } + + let _res = acceptor_node + .node + .handle_tx_add_output(initiator_node.node.get_our_node_id(), &tx_add_output2_msg); + let _tx_complete_msg = get_event_msg!( + acceptor_node, + MessageSendEvent::SendTxComplete, + initiator_node.node.get_our_node_id() + ); + + // TODO(splicing) This is the last tx_complete, which triggers the commitment flow, which is not yet fully implemented + let _res = initiator_node + .node + .handle_tx_complete(acceptor_node.node.get_our_node_id(), &tx_complete_msg); + let events = initiator_node.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 2); + match events[0] { + MessageSendEvent::SendTxComplete { .. } => {}, + _ => panic!("Unexpected event {:?}", events[0]), + } + match events[1] { + MessageSendEvent::HandleError { .. } => {}, + _ => panic!("Unexpected event {:?}", events[1]), + } - // TODO(splicing): continue with splice transaction negotiation + // TODO(splicing): Continue with commitment flow, new tx confirmation // === Close channel, cooperatively initiator_node.node.close_channel(&channel_id, &acceptor_node.node.get_our_node_id()).unwrap(); From 05d2a8ecc35582d67fb2140635d20d83b01a009f Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 29 Jul 2025 15:25:04 -0500 Subject: [PATCH 7/9] Add change_script to FundingNegotiationContext Instead of passing the optional change script as another parameter to FundingNegotiationContext::into_interactive_tx_constructor, make it a member of FundingNegotiationContext. Also, allow it to be passed to ChannelManager::splice_channel. --- lightning/src/ln/channel.rs | 28 +++++++++++++++++----------- lightning/src/ln/channelmanager.rs | 11 ++++++----- lightning/src/ln/interactivetxs.rs | 1 + lightning/src/ln/splicing_tests.rs | 2 ++ 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d140e637016..686ff29989e 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -5962,17 +5962,19 @@ 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<(TxIn, TransactionU16LenLimited)>, + /// 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. + pub change_script: Option, } impl FundingNegotiationContext { /// Prepare and start interactive transaction negotiation. - /// `change_destination_opt` - Optional destination for optional change; if None, - /// default destination address is used. /// If error occurs, it is caused by our side, not the counterparty. #[cfg(splicing)] fn into_interactive_tx_constructor( self, context: &ChannelContext, funding: &FundingScope, signer_provider: &SP, - entropy_source: &ES, holder_node_id: PublicKey, change_destination_opt: Option, + entropy_source: &ES, holder_node_id: PublicKey, ) -> Result where SP::Target: SignerProvider, @@ -6009,12 +6011,12 @@ impl FundingNegotiationContext { context.holder_dust_limit_satoshis, )?; if let Some(change_value) = change_value_opt { - let change_script = if let Some(script) = change_destination_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 destination script"), - )? + 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 }; @@ -10584,11 +10586,13 @@ where /// Initiate splicing. /// - `our_funding_inputs`: the inputs we contribute to the new funding transaction. /// Includes the witness weight for this input (e.g. P2WPKH_WITNESS_WEIGHT=109 for typical P2WPKH inputs). + /// - `change_script`: an option change output script. If `None` and needed, one will be + /// 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)>, funding_feerate_per_kw: u32, - locktime: u32, + our_funding_inputs: Vec<(TxIn, Transaction, Weight)>, 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) @@ -10659,6 +10663,7 @@ where funding_feerate_sat_per_1000_weight: funding_feerate_per_kw, shared_funding_input: Some(prev_funding_input), our_funding_inputs: funding_inputs, + change_script, }; self.pending_splice = Some(PendingSplice { @@ -10766,6 +10771,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(), + change_script: None, }; let mut interactive_tx_constructor = funding_negotiation_context @@ -10775,7 +10781,6 @@ where signer_provider, entropy_source, holder_node_id.clone(), - None, ) .map_err(|err| { ChannelError::WarnAndDisconnect(format!( @@ -10875,7 +10880,6 @@ where signer_provider, entropy_source, holder_node_id.clone(), - None, ) .map_err(|err| { ChannelError::WarnAndDisconnect(format!( @@ -12459,6 +12463,7 @@ where funding_feerate_sat_per_1000_weight, shared_funding_input: None, our_funding_inputs: funding_inputs, + change_script: None, }; let chan = Self { funding, @@ -12614,6 +12619,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(), + change_script: None, }; let shared_funding_output = TxOut { value: Amount::from_sat(funding.get_value_satoshis()), diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 8f3c49d57e9..85f65643a05 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}; #[cfg(splicing)] -use bitcoin::{TxIn, Weight}; +use bitcoin::{ScriptBuf, TxIn, Weight}; use crate::blinded_path::message::MessageForwardNode; use crate::blinded_path::message::{AsyncPaymentsContext, OffersContext}; @@ -4437,13 +4437,13 @@ 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)>, + our_funding_inputs: Vec<(TxIn, Transaction, Weight)>, change_script: Option, 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, funding_feerate_per_kw, locktime + channel_id, counterparty_node_id, our_funding_contribution_satoshis, our_funding_inputs, change_script, funding_feerate_per_kw, locktime ); res = result; match res { @@ -4459,8 +4459,8 @@ where fn internal_splice_channel( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, our_funding_contribution_satoshis: i64, - our_funding_inputs: Vec<(TxIn, Transaction, Weight)>, funding_feerate_per_kw: u32, - locktime: Option, + our_funding_inputs: Vec<(TxIn, Transaction, Weight)>, change_script: Option, + funding_feerate_per_kw: u32, locktime: Option, ) -> Result<(), APIError> { let per_peer_state = self.per_peer_state.read().unwrap(); @@ -4484,6 +4484,7 @@ where let msg = chan.splice_channel( our_funding_contribution_satoshis, our_funding_inputs, + change_script, funding_feerate_per_kw, locktime, )?; diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index e39ebb8327a..3fcf3f4ee01 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -2999,6 +2999,7 @@ mod tests { funding_feerate_sat_per_1000_weight, shared_funding_input: None, our_funding_inputs: inputs, + change_script: None, }; assert_eq!( calculate_change_output_value(&context, false, &ScriptBuf::new(), &outputs, 300), diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index af1fa1f3ab3..ab3b279a871 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -74,6 +74,7 @@ fn test_v1_splice_in() { &acceptor_node.node.get_our_node_id(), splice_in_sats as i64, funding_inputs, + None, // change_script funding_feerate_per_kw, None, // locktime ) @@ -322,6 +323,7 @@ fn test_v1_splice_in_negative_insufficient_inputs() { &nodes[1].node.get_our_node_id(), splice_in_sats as i64, funding_inputs, + None, // change_script 1024, // funding_feerate_per_kw, None, // locktime ); From bb5ef8eb3d29c87d7d8b1e2c4fa0dc1ef1be51da Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 29 Jul 2025 15:50:53 -0500 Subject: [PATCH 8/9] Don't close a funded channel on splice failure If splicing fails, the previous funding is still usable. Send a tx_abort message if the channel was already successfully funded, thus aborting the negotiation. Otherwise, close the channel as this means the failure occurred during v2 channel establishment. --- lightning/src/ln/channel.rs | 71 +++++++++++++++++++----------- lightning/src/ln/channelmanager.rs | 21 ++++++++- lightning/src/ln/splicing_tests.rs | 2 +- 3 files changed, 65 insertions(+), 29 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 686ff29989e..f612843e564 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1009,6 +1009,16 @@ impl ChannelError { pub(super) fn close(err: String) -> Self { ChannelError::Close((err.clone(), ClosureReason::ProcessingError { err })) } + + pub(super) fn message(&self) -> &str { + match self { + &ChannelError::Ignore(ref e) => &e, + &ChannelError::Warn(ref e) => &e, + &ChannelError::WarnAndDisconnect(ref e) => &e, + &ChannelError::Close((ref e, _)) => &e, + &ChannelError::SendError(ref e) => &e, + } + } } pub(super) struct WithChannelContext<'a, L: Deref> @@ -1774,7 +1784,7 @@ where pub fn funding_tx_constructed( &mut self, logger: &L, - ) -> Result<(msgs::CommitmentSigned, Option), ChannelError> + ) -> Result<(msgs::CommitmentSigned, Option), msgs::TxAbort> where L::Target: Logger, { @@ -1829,14 +1839,17 @@ where } } - return Err(ChannelError::Warn( - "Got a tx_complete message in an invalid state".to_owned(), - )); + return Err(msgs::TxAbort { + channel_id: chan.context.channel_id(), + data: "Got a tx_complete message in an invalid state".to_owned().into_bytes(), + }); }, _ => { - return Err(ChannelError::Warn( - "Got a tx_complete message in an invalid phase".to_owned(), - )) + debug_assert!(false); + return Err(msgs::TxAbort { + channel_id: self.context().channel_id(), + data: "Got a tx_complete message in an invalid phase".to_owned().into_bytes(), + }); }, } } @@ -5476,7 +5489,7 @@ where fn funding_tx_constructed( &mut self, funding: &mut FundingScope, signing_session: &mut InteractiveTxSigningSession, is_splice: bool, holder_commitment_transaction_number: u64, logger: &L - ) -> Result<(msgs::CommitmentSigned, Option), ChannelError> + ) -> Result<(msgs::CommitmentSigned, Option), msgs::TxAbort> where L::Target: Logger { @@ -5485,9 +5498,10 @@ where for (idx, outp) in signing_session.unsigned_tx().outputs().enumerate() { if outp.script_pubkey() == &expected_spk && outp.value() == funding.get_value_satoshis() { if output_index.is_some() { - let msg = "Multiple outputs matched the expected script and value"; - let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; - return Err(ChannelError::Close((msg.to_owned(), reason))); + return Err(msgs::TxAbort { + channel_id: self.channel_id(), + data: "Multiple outputs matched the expected script and value".to_owned().into_bytes(), + }); } output_index = Some(idx as u16); } @@ -5495,9 +5509,10 @@ where let outpoint = if let Some(output_index) = output_index { OutPoint { txid: signing_session.unsigned_tx().compute_txid(), index: output_index } } else { - let msg = "No output matched the funding script_pubkey"; - let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; - return Err(ChannelError::Close((msg.to_owned(), reason))); + return Err(msgs::TxAbort { + channel_id: self.channel_id(), + data: "No output matched the funding script_pubkey".to_owned().into_bytes(), + }); }; funding .channel_transaction_parameters.funding_outpoint = Some(outpoint); @@ -5507,12 +5522,11 @@ where holder_commitment_transaction_number, self.cur_counterparty_commitment_transaction_number, ); - let message = "TODO Forced error, incomplete implementation".to_owned(); // TODO(splicing) Forced error, as the use case is not complete - return Err(ChannelError::Close(( - message.clone(), - ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(false), message } - ))); + return Err(msgs::TxAbort { + channel_id: self.channel_id(), + data: "Splicing not yet supported".to_owned().into_bytes(), + }); } else { self.assert_no_commitment_advancement(holder_commitment_transaction_number, "initial commitment_signed"); } @@ -5522,7 +5536,10 @@ where Ok(commitment_signed) => commitment_signed, Err(e) => { funding.channel_transaction_parameters.funding_outpoint = None; - return Err(e) + return Err(msgs::TxAbort { + channel_id: self.channel_id(), + data: e.message().to_owned().into_bytes(), + }); }, }; @@ -5532,9 +5549,10 @@ where false, "Zero inputs were provided & zero witnesses were provided, but a count mismatch was somehow found", ); - let msg = "V2 channel rejected due to sender error"; - let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; - return Err(ChannelError::Close((msg.to_owned(), reason))); + return Err(msgs::TxAbort { + channel_id: self.channel_id(), + data: "V2 channel rejected due to sender error".to_owned().into_bytes(), + }); } None } else { @@ -5556,9 +5574,10 @@ where false, "We don't support users providing inputs but somehow we had more than zero inputs", ); - let msg = "V2 channel rejected due to sender error"; - let reason = ClosureReason::ProcessingError { err: msg.to_owned() }; - return Err(ChannelError::Close((msg.to_owned(), reason))); + return Err(msgs::TxAbort { + channel_id: self.channel_id(), + data: "V2 channel rejected due to sender error".to_owned().into_bytes(), + }); }; let mut channel_state = ChannelState::FundingNegotiated(FundingNegotiatedFlags::new()); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 85f65643a05..6b94678ce61 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -9730,10 +9730,27 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ peer_state.pending_msg_events.push(msg_send_event); }; if negotiation_complete { - let (commitment_signed, funding_ready_for_sig_event_opt) = chan_entry + let (commitment_signed, funding_ready_for_sig_event_opt) = match chan_entry .get_mut() .funding_tx_constructed(&self.logger) - .map_err(|err| MsgHandleErrInternal::send_err_msg_no_close(format!("{}", err), msg.channel_id))?; + { + Ok((commitment_signed, event)) => (commitment_signed, event), + Err(tx_abort) => { + if chan_entry.get().is_funded() { + peer_state.pending_msg_events.push(MessageSendEvent::SendTxAbort { + node_id: counterparty_node_id, + msg: tx_abort, + }); + return Ok(()); + } else { + let msg = String::from_utf8(tx_abort.data) + .expect("tx_abort data should contain valid UTF-8"); + let reason = ClosureReason::ProcessingError { err: msg.clone() }; + let err = ChannelError::Close((msg, reason)); + try_channel_entry!(self, peer_state, Err(err), chan_entry) + } + }, + }; if let Some(funding_ready_for_sig_event) = funding_ready_for_sig_event_opt { let mut pending_events = self.pending_events.lock().unwrap(); pending_events.push_back((funding_ready_for_sig_event, None)); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index ab3b279a871..a88a5f76c7e 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -270,7 +270,7 @@ fn test_v1_splice_in() { _ => panic!("Unexpected event {:?}", events[0]), } match events[1] { - MessageSendEvent::HandleError { .. } => {}, + MessageSendEvent::SendTxAbort { .. } => {}, _ => panic!("Unexpected event {:?}", events[1]), } From 41cbc1fd6383e7e73f2e725ec2d54b305a384dcb Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 29 Jul 2025 16:34:23 -0500 Subject: [PATCH 9/9] Clear interactive_tx_signing_session upon splice promotion Once splice has been promoted, the interactive_tx_signing_session is no longer needed. Clear it at this time to prevent being in an inconsistent state. --- lightning/src/ln/channel.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index f612843e564..0796369d4c6 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6102,6 +6102,7 @@ macro_rules! promote_splice_funding { $self.context.historical_scids.push(scid); } core::mem::swap(&mut $self.funding, $funding); + $self.interactive_tx_signing_session = None; $self.pending_splice = None; $self.pending_funding.clear(); $self.context.announcement_sigs_state = AnnouncementSigsState::NotSent;