Split InteractiveTxConstructor::new into outbound/inbound variants
What changed, and why it matters
This commit refactors how an internal transaction-construction object is created in the Lightning Dev Kit. It splits one constructor into two separate ones for the party that starts the process versus the party that responds. The main practical effect is that callers no longer have to handle an error right after they have already committed to starting a splice, which avoids a tricky cleanup situation where they might otherwise have to emit a failure event after consuming internal state. It is a defensive code-quality change rather than a fix for a known active exploit.
Review as a defensive hardening/refactoring change. Ensure the invariant relied on by new_for_outbound (initiator always has shared funding output and therefore a first message) holds in all production paths, including future splicing variants. No immediate security patch or incident response is indicated by the diff alone.
Security signals we found
Eliminates error path after QuiescentAction has already been consumed
Removes public fallible constructor in favor of role-specific infallible constructors
Adds debug_assert to document invariant that outbound first message cannot fail
Prevents need to emit SpliceFailed/DiscardFunding events from constructor failure path
Evidence from the diff
InteractiveTxConstructor::new() previously returned Result and, for the initiator, prepared the first TxAddInput message inside the constructor. If that message preparation failed, callers had already consumed their QuiescentAction and had to synthesize SpliceFailed/DiscardFunding events. The patch replaces the public constructor with new_for_outbound() and new_for_inbound(). The outbound variant returns (constructor, Option
Changed components
lightning/src/ln/interactivetxs.rslightning/src/ln/channel.rsInteractiveTxConstructorsplice_init / splice_ack handlingPendingV2Channel inbound acceptanceInspect captured patch +105 / −135
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 9dff609..a87fff2 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -58,8 +58,7 @@ use crate::ln::channelmanager::{
use crate::ln::funding::{FundingContribution, FundingTemplate, FundingTxInput};
use crate::ln::interactivetxs::{
AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs,
- InteractiveTxMessageSend, InteractiveTxSigningSession, NegotiationError, SharedOwnedInput,
- SharedOwnedOutput,
+ InteractiveTxMessageSend, InteractiveTxSigningSession, SharedOwnedInput, SharedOwnedOutput,
};
use crate::ln::msgs;
use crate::ln::msgs::{ClosingSigned, ClosingSignedFeeRange, DecodeError, OnionErrorPacket};
@@ -6346,7 +6345,7 @@ impl FundingNegotiationContext {
fn into_interactive_tx_constructor<SP: SignerProvider, ES: EntropySource>(
self, context: &ChannelContext<SP>, funding: &FundingScope, entropy_source: &ES,
holder_node_id: PublicKey,
- ) -> Result<InteractiveTxConstructor, NegotiationError> {
+ ) -> (InteractiveTxConstructor, Option<InteractiveTxMessageSend>) {
debug_assert_eq!(
self.shared_funding_input.is_some(),
funding.channel_transaction_parameters.splice_parent_funding_txid.is_some(),
@@ -6369,7 +6368,6 @@ impl FundingNegotiationContext {
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: self.shared_funding_input,
@@ -6379,7 +6377,11 @@ impl FundingNegotiationContext {
),
outputs_to_contribute: self.our_funding_outputs,
};
- InteractiveTxConstructor::new(constructor_args)
+ if self.is_initiator {
+ InteractiveTxConstructor::new_for_outbound(constructor_args)
+ } else {
+ (InteractiveTxConstructor::new_for_inbound(constructor_args), None)
+ }
}
fn into_contributed_inputs_and_outputs(self) -> (Vec<bitcoin::OutPoint>, Vec<TxOut>) {
@@ -12085,20 +12087,14 @@ where
our_funding_outputs: Vec::new(),
};
- let mut interactive_tx_constructor = funding_negotiation_context
+ let (interactive_tx_constructor, first_message) = funding_negotiation_context
.into_interactive_tx_constructor(
&self.context,
&splice_funding,
entropy_source,
holder_node_id.clone(),
- )
- .map_err(|err| {
- ChannelError::WarnAndDisconnect(format!(
- "Failed to start interactive transaction construction, {:?}",
- err
- ))
- })?;
- debug_assert!(interactive_tx_constructor.take_initiator_first_message().is_none());
+ );
+ debug_assert!(first_message.is_none());
// TODO(splicing): if quiescent_action is set, integrate what the user wants to do into the
// counterparty-initiated splice. For always-on nodes this probably isn't a useful
@@ -12150,20 +12146,14 @@ where
panic!("We should have returned an error earlier!");
};
- let mut interactive_tx_constructor = funding_negotiation_context
+ let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context
.into_interactive_tx_constructor(
&self.context,
&splice_funding,
entropy_source,
holder_node_id.clone(),
- )
- .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!(tx_msg_opt.is_some());
debug_assert!(self.context.interactive_tx_signing_session.is_none());
@@ -14039,7 +14029,7 @@ impl<SP: SignerProvider> PendingV2Channel<SP> {
script_pubkey: funding.get_funding_redeemscript().to_p2wsh(),
};
- let interactive_tx_constructor = Some(InteractiveTxConstructor::new(
+ let interactive_tx_constructor = Some(InteractiveTxConstructor::new_for_inbound(
InteractiveTxConstructorArgs {
entropy_source,
holder_node_id,
@@ -14047,16 +14037,12 @@ impl<SP: SignerProvider> PendingV2Channel<SP> {
channel_id: context.channel_id,
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,
shared_funding_output: SharedOwnedOutput::new(shared_funding_output, our_funding_contribution_sats),
outputs_to_contribute: funding_negotiation_context.our_funding_outputs.clone(),
}
- ).map_err(|err| {
- let reason = ClosureReason::ProcessingError { err: err.reason.to_string() };
- ChannelError::Close((err.reason.to_string(), reason))
- })?);
+ ));
let unfunded_context = UnfundedChannelContext {
unfunded_channel_age_ticks: 0,
diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs
index 17dab19..f7e0ce3 100644
--- a/lightning/src/ln/interactivetxs.rs
+++ b/lightning/src/ln/interactivetxs.rs
@@ -1951,7 +1951,6 @@ impl InteractiveTxInput {
pub(super) struct InteractiveTxConstructor {
state_machine: StateMachine,
is_initiator: bool,
- initiator_first_message: Option<InteractiveTxMessageSend>,
channel_id: ChannelId,
inputs_to_contribute: Vec<(SerialId, InputOwned)>,
outputs_to_contribute: Vec<(SerialId, OutputOwned)>,
@@ -2020,7 +2019,6 @@ pub(super) struct InteractiveTxConstructorArgs<'a, ES: EntropySource> {
pub counterparty_node_id: PublicKey,
pub channel_id: ChannelId,
pub feerate_sat_per_kw: u32,
- pub is_initiator: bool,
pub funding_tx_locktime: AbsoluteLockTime,
pub inputs_to_contribute: Vec<FundingTxInput>,
pub shared_funding_input: Option<SharedOwnedInput>,
@@ -2031,18 +2029,15 @@ pub(super) struct InteractiveTxConstructorArgs<'a, ES: EntropySource> {
impl InteractiveTxConstructor {
/// Instantiates a new `InteractiveTxConstructor`.
///
- /// If the holder is the initiator, they need to send the first message which is a `TxAddInput`
- /// message.
- pub fn new<ES: EntropySource>(
- args: InteractiveTxConstructorArgs<ES>,
- ) -> Result<Self, NegotiationError> {
+ /// Use [`Self::new_for_outbound`] or [`Self::new_for_inbound`] instead to also prepare the
+ /// first message for the initiator.
+ fn new<ES: EntropySource>(args: InteractiveTxConstructorArgs<ES>, is_initiator: bool) -> Self {
let InteractiveTxConstructorArgs {
entropy_source,
holder_node_id,
counterparty_node_id,
channel_id,
feerate_sat_per_kw,
- is_initiator,
funding_tx_locktime,
inputs_to_contribute,
shared_funding_input,
@@ -2112,28 +2107,43 @@ impl InteractiveTxConstructor {
let next_input_index = (!inputs_to_contribute.is_empty()).then_some(0);
let next_output_index = (!outputs_to_contribute.is_empty()).then_some(0);
- let mut constructor = Self {
+ Self {
state_machine,
is_initiator,
- initiator_first_message: None,
channel_id,
inputs_to_contribute,
outputs_to_contribute,
next_input_index,
next_output_index,
- };
- // We'll store the first message for the initiator.
- if is_initiator {
- match constructor.maybe_send_message() {
- Ok(message) => {
- constructor.initiator_first_message = Some(message);
- },
- Err(reason) => {
- return Err(constructor.into_negotiation_error(reason));
- },
- }
}
- Ok(constructor)
+ }
+
+ /// Instantiates a new `InteractiveTxConstructor` for the initiator (outbound splice).
+ ///
+ /// The initiator always has the shared funding output added internally, so preparing the
+ /// first message should never fail. Debug asserts verify this invariant.
+ pub fn new_for_outbound<ES: EntropySource>(
+ args: InteractiveTxConstructorArgs<ES>,
+ ) -> (Self, Option<InteractiveTxMessageSend>) {
+ let mut constructor = Self::new(args, true);
+ let message = match constructor.maybe_send_message() {
+ Ok(message) => Some(message),
+ Err(reason) => {
+ debug_assert!(
+ false,
+ "Outbound constructor should always have inputs: {:?}",
+ reason
+ );
+ None
+ },
+ };
+ (constructor, message)
+ }
+
+ /// Instantiates a new `InteractiveTxConstructor` for the non-initiator (inbound splice or
+ /// dual-funded channel acceptor).
+ pub fn new_for_inbound<ES: EntropySource>(args: InteractiveTxConstructorArgs<ES>) -> Self {
+ Self::new(args, false)
}
fn into_negotiation_error(self, reason: AbortReason) -> NegotiationError {
@@ -2179,10 +2189,6 @@ impl InteractiveTxConstructor {
self.is_initiator
}
- pub fn take_initiator_first_message(&mut self) -> Option<InteractiveTxMessageSend> {
- self.initiator_first_message.take()
- }
-
fn maybe_send_message(&mut self) -> Result<InteractiveTxMessageSend, AbortReason> {
let channel_id = self.channel_id;
@@ -2438,84 +2444,64 @@ mod tests {
&SecretKey::from_slice(&[43; 32]).unwrap(),
);
- let mut constructor_a = match InteractiveTxConstructor::new(InteractiveTxConstructorArgs {
- entropy_source,
- channel_id,
- feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW,
- holder_node_id,
- counterparty_node_id,
- is_initiator: true,
- funding_tx_locktime,
- inputs_to_contribute: session.inputs_a,
- shared_funding_input: session.a_shared_input.map(|(op, prev_output, lo)| {
- SharedOwnedInput::new(
- TxIn {
- previous_output: op,
- sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
- ..Default::default()
- },
- prev_output,
- lo,
- true, // holder_sig_first
- generate_funding_script_pubkey(), // witness_script for test
- )
- }),
- shared_funding_output: SharedOwnedOutput::new(
- session.shared_output_a.0,
- session.shared_output_a.1,
- ),
- outputs_to_contribute: session.outputs_a,
- }) {
- Ok(r) => Some(r),
- Err(e) => {
- assert_eq!(
- Some((e.reason, ErrorCulprit::NodeA)),
- session.expect_error,
- "Test: {}",
- session.description
- );
- return;
- },
- };
- let mut constructor_b = match InteractiveTxConstructor::new(InteractiveTxConstructorArgs {
- entropy_source,
- holder_node_id,
- counterparty_node_id,
- channel_id,
- feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW,
- is_initiator: false,
- funding_tx_locktime,
- inputs_to_contribute: session.inputs_b,
- shared_funding_input: session.b_shared_input.map(|(op, prev_output, lo)| {
- SharedOwnedInput::new(
- TxIn {
- previous_output: op,
- sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
- ..Default::default()
- },
- prev_output,
- lo,
- false, // holder_sig_first
- generate_funding_script_pubkey(), // witness_script for test
- )
- }),
- shared_funding_output: SharedOwnedOutput::new(
- session.shared_output_b.0,
- session.shared_output_b.1,
- ),
- outputs_to_contribute: session.outputs_b,
- }) {
- Ok(r) => Some(r),
- Err(e) => {
- assert_eq!(
- Some((e.reason, ErrorCulprit::NodeB)),
- session.expect_error,
- "Test: {}",
- session.description
- );
- return;
- },
- };
+ let (constructor_a, mut message_send_a) =
+ InteractiveTxConstructor::new_for_outbound(InteractiveTxConstructorArgs {
+ entropy_source,
+ channel_id,
+ feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW,
+ holder_node_id,
+ counterparty_node_id,
+ funding_tx_locktime,
+ inputs_to_contribute: session.inputs_a,
+ shared_funding_input: session.a_shared_input.map(|(op, prev_output, lo)| {
+ SharedOwnedInput::new(
+ TxIn {
+ previous_output: op,
+ sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
+ ..Default::default()
+ },
+ prev_output,
+ lo,
+ true, // holder_sig_first
+ generate_funding_script_pubkey(), // witness_script for test
+ )
+ }),
+ shared_funding_output: SharedOwnedOutput::new(
+ session.shared_output_a.0,
+ session.shared_output_a.1,
+ ),
+ outputs_to_contribute: session.outputs_a,
+ });
+ let mut constructor_a = Some(constructor_a);
+ let mut constructor_b =
+ Some(InteractiveTxConstructor::new_for_inbound(InteractiveTxConstructorArgs {
+ entropy_source,
+ holder_node_id,
+ counterparty_node_id,
+ channel_id,
+ feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW,
+ funding_tx_locktime,
+ inputs_to_contribute: session.inputs_b,
+ shared_funding_input: session.b_shared_input.map(|(op, prev_output, lo)| {
+ SharedOwnedInput::new(
+ TxIn {
+ previous_output: op,
+ sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
+ ..Default::default()
+ },
+ prev_output,
+ lo,
+ false, // holder_sig_first
+ generate_funding_script_pubkey(), // witness_script for test
+ )
+ }),
+ shared_funding_output: SharedOwnedOutput::new(
+ session.shared_output_b.0,
+ session.shared_output_b.1,
+ ),
+ outputs_to_contribute: session.outputs_b,
+ }));
+ let mut message_send_b = None;
let handle_message_send =
|msg: InteractiveTxMessageSend, for_constructor: &mut InteractiveTxConstructor| {
@@ -2539,8 +2525,6 @@ mod tests {
}
};
- 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 constructor_a.is_some() || constructor_b.is_some() {
Why this scored 26/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.