Buffer interactive-tx initial commitment signed from counterparty
What changed, and why it matters
This change is a defensive fix for the Lightning Dev Kit's handling of channel splicing. Previously, when a counterparty sent their initial commitment signature during a splice negotiation, LDK would immediately apply an irreversible monitor update. That made it impossible for the user to safely cancel the splice if they changed their mind. The patch buffers that counterparty message and only processes it after the user explicitly approves the splice by calling funding_transaction_signed. This prevents a state where a splice could be partially committed to before the user actually wanted to proceed.
Review as a hardening fix for splice cancellation. No immediate emergency action is indicated, but downstream users relying on interactive-tx splicing/RBF should ensure they upgrade to avoid the prior behavior where a counterparty commitment_signed could prematurely commit an irreversible monitor update.
Security signals we found
State-machine change to defer irreversible monitor update until user approval
New buffer field initial_commitment_signed_from_counterparty in FundingNegotiation::AwaitingSignatures
funding_transaction_signed now processes buffered counterparty commitment_signed and can trigger monitor update or force-close
ChannelManager updated to route buffered-message result through handle_new_monitor_update and locked_handle_funded_force_close
Tests added for both valid buffered signature flow and invalid signature force-close path
Evidence from the diff
The commit modifies interactive transaction handling in LDK so that the initial commitment_signed from the counterparty during a splice or RBF attempt is buffered in FundingNegotiation::AwaitingSignatures rather than applied immediately. The buffered message is processed only inside FundedChannel::funding_transaction_signed after the holder has provided their own signatures. The ChannelManager now handles the resulting monitor update or force-close when the buffered message is later processed. Tests confirm that a valid signature is buffered and applied after funding_transaction_signed, while an invalid signature causes a force-close at that later point.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/splicing_tests.rsInspect captured patch +481 / −110
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 23edf61..de42f41 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -1902,10 +1902,11 @@ where
}
}
- pub fn tx_complete<L: Deref>(
- &mut self, msg: &msgs::TxComplete, logger: &L,
+ pub fn tx_complete<F: Deref, L: Deref>(
+ &mut self, msg: &msgs::TxComplete, fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
) -> Result<TxCompleteResult, (ChannelError, Option<SpliceFundingFailed>)>
where
+ F::Target: FeeEstimator,
L::Target: Logger,
{
let tx_complete_action = match self.interactive_tx_constructor_mut() {
@@ -1954,7 +1955,7 @@ where
let funding_tx_signed = if !has_local_contribution {
let funding_txid = signing_session.unsigned_tx().tx().compute_txid();
if let ChannelPhase::Funded(chan) = &mut self.phase {
- chan.funding_transaction_signed(funding_txid, vec![], 0, logger).ok()
+ chan.funding_transaction_signed(funding_txid, vec![], 0, fee_estimator, logger).ok()
} else {
None
}
@@ -2104,7 +2105,11 @@ where
funding.channel_transaction_parameters.funding_outpoint =
Some(funding_outpoint);
pending_splice.funding_negotiation =
- Some(FundingNegotiation::AwaitingSignatures { is_initiator, funding });
+ Some(FundingNegotiation::AwaitingSignatures {
+ is_initiator,
+ funding,
+ initial_commitment_signed_from_counterparty: None,
+ });
interactive_tx_constructor
} else {
// Replace the taken state for later error handling
@@ -2193,9 +2198,33 @@ where
// which must always come after the initial commitment signed is sent.
.unwrap_or(true);
let res = if has_negotiated_pending_splice && !session_received_commitment_signed {
- funded_channel
- .splice_initial_commitment_signed(msg, fee_estimator, logger)
- .map(|monitor_update_opt| (None, monitor_update_opt))
+ let has_holder_tx_signatures = funded_channel
+ .context
+ .interactive_tx_signing_session
+ .as_ref()
+ .map(|session| session.holder_tx_signatures().is_some())
+ .unwrap_or(false);
+
+ // We delay processing this until the user manually approves the splice via
+ // [`FundedChannel::funding_transaction_signed`], as otherwise, there would be a
+ // [`ChannelMonitorUpdateStep::RenegotiatedFunding`] committed that we would
+ // need to undo if they no longer wish to proceed.
+ if has_holder_tx_signatures {
+ funded_channel
+ .splice_initial_commitment_signed(msg, fee_estimator, logger)
+ .map(|monitor_update_opt| (None, monitor_update_opt))
+ } else {
+ let pending_splice = funded_channel.pending_splice.as_mut()
+ .expect("We have a pending splice negotiated");
+ let funding_negotiation = pending_splice.funding_negotiation.as_mut()
+ .expect("We have a pending splice negotiated");
+ if let FundingNegotiation::AwaitingSignatures {
+ ref mut initial_commitment_signed_from_counterparty, ..
+ } = funding_negotiation {
+ *initial_commitment_signed_from_counterparty = Some(msg.clone());
+ }
+ Ok((None, None))
+ }
} else {
funded_channel.commitment_signed(msg, fee_estimator, logger)
.map(|monitor_update_opt| (None, monitor_update_opt))
@@ -2679,6 +2708,17 @@ enum FundingNegotiation {
AwaitingSignatures {
funding: FundingScope,
is_initiator: bool,
+ /// The initial [`msgs::CommitmentSigned`] message received for the [`FundingScope`] above.
+ /// We delay processing this until the user manually approves the splice via
+ /// [`FundedChannel::funding_transaction_signed`], as otherwise, there would be a
+ /// [`ChannelMonitorUpdateStep::RenegotiatedFunding`] committed that we would need to undo
+ /// if they no longer wish to proceed.
+ ///
+ /// Note that this doesn't need to be done with dual-funded channels as there is no
+ /// equivalent monitor update for them, and we can just force close the channel.
+ ///
+ /// This field is not persisted as the message should be resent on reconnections.
+ initial_commitment_signed_from_counterparty: Option<msgs::CommitmentSigned>,
},
}
@@ -2686,6 +2726,7 @@ impl_writeable_tlv_based_enum_upgradable!(FundingNegotiation,
(0, AwaitingSignatures) => {
(1, funding, required),
(3, is_initiator, required),
+ (_unused, initial_commitment_signed_from_counterparty, (static_value, None)),
},
unread_variants: AwaitingAck, ConstructingTransaction
);
@@ -6834,7 +6875,7 @@ type BestBlockUpdatedRes = (
);
/// The result of handling a `tx_complete` message during interactive transaction construction.
-pub(crate) struct TxCompleteResult {
+pub(super) struct TxCompleteResult {
/// The message to send to the counterparty, if any.
pub interactive_tx_msg_send: Option<InteractiveTxMessageSend>,
@@ -6848,10 +6889,15 @@ pub(crate) struct TxCompleteResult {
}
/// The result of signing a funding transaction negotiated using the interactive-tx protocol.
-pub struct FundingTxSigned {
+pub(super) struct FundingTxSigned {
/// The initial `commitment_signed` message to send to the counterparty, if necessary.
pub commitment_signed: Option<msgs::CommitmentSigned>,
+ /// The result of processing a buffered initial commitment signed from our counterparty,
+ /// if any.
+ pub counterparty_initial_commitment_signed_result:
+ Option<Result<Option<ChannelMonitorUpdate>, ChannelError>>,
+
/// Signatures that should be sent to the counterparty, if necessary.
pub tx_signatures: Option<msgs::TxSignatures>,
@@ -9071,11 +9117,12 @@ where
}
}
- pub fn funding_transaction_signed<L: Deref>(
+ pub fn funding_transaction_signed<F: Deref, L: Deref>(
&mut self, funding_txid_signed: Txid, witnesses: Vec<Witness>, best_block_height: u32,
- logger: &L,
+ fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
) -> Result<FundingTxSigned, APIError>
where
+ F::Target: FeeEstimator,
L::Target: Logger,
{
let signing_session =
@@ -9096,6 +9143,7 @@ where
// or we're waiting for our counterparty to send theirs first.
return Ok(FundingTxSigned {
commitment_signed: None,
+ counterparty_initial_commitment_signed_result: None,
tx_signatures: None,
funding_tx: None,
splice_negotiated: None,
@@ -9110,6 +9158,7 @@ where
// no longer have the signing session present.
return Ok(FundingTxSigned {
commitment_signed: None,
+ counterparty_initial_commitment_signed_result: None,
tx_signatures: None,
funding_tx: None,
splice_negotiated: None,
@@ -9179,8 +9228,30 @@ where
.unwrap_or(&self.funding);
let commitment_signed = self.context.get_initial_commitment_signed_v2(funding, &&logger);
+ // If we have a pending splice with a buffered initial commitment_signed from our
+ // counterparty, process it now that we have provided our signatures.
+ let counterparty_initial_commitment_signed_result = self
+ .pending_splice
+ .as_mut()
+ .and_then(|pending_splice| pending_splice.funding_negotiation.as_mut())
+ .and_then(|funding_negotiation| {
+ if let FundingNegotiation::AwaitingSignatures {
+ ref mut initial_commitment_signed_from_counterparty,
+ ..
+ } = funding_negotiation
+ {
+ initial_commitment_signed_from_counterparty.take()
+ } else {
+ None
+ }
+ })
+ .map(|commit_sig| {
+ self.splice_initial_commitment_signed(&commit_sig, fee_estimator, &&logger)
+ });
+
Ok(FundingTxSigned {
commitment_signed,
+ counterparty_initial_commitment_signed_result,
tx_signatures,
funding_tx,
splice_negotiated,
@@ -9254,6 +9325,7 @@ where
Ok(FundingTxSigned {
commitment_signed: None,
+ counterparty_initial_commitment_signed_result: None,
tx_signatures: holder_tx_signatures,
funding_tx,
splice_negotiated,
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 598e1d3..63e11d2 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -6424,118 +6424,164 @@ where
pub fn funding_transaction_signed(
&self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, transaction: Transaction,
) -> Result<(), APIError> {
- let mut result = Ok(());
+ let mut funding_tx_signed_result = Ok(());
+ let mut monitor_update_result: Option<
+ Result<PostMonitorUpdateChanResume, MsgHandleErrInternal>,
+ > = None;
+
PersistenceNotifierGuard::optionally_notify(self, || {
let per_peer_state = self.per_peer_state.read().unwrap();
let peer_state_mutex_opt = per_peer_state.get(counterparty_node_id);
if peer_state_mutex_opt.is_none() {
- result = Err(APIError::ChannelUnavailable {
+ funding_tx_signed_result = Err(APIError::ChannelUnavailable {
err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}")
});
return NotifyOption::SkipPersistNoEvents;
}
- let mut peer_state = peer_state_mutex_opt.unwrap().lock().unwrap();
+ let mut peer_state_lock = peer_state_mutex_opt.unwrap().lock().unwrap();
+ let peer_state = &mut *peer_state_lock;
- match peer_state.channel_by_id.get_mut(channel_id) {
- Some(channel) => match channel.as_funded_mut() {
- Some(chan) => {
- let txid = transaction.compute_txid();
- let witnesses: Vec<_> = transaction
- .input
- .into_iter()
- .map(|input| input.witness)
- .filter(|witness| !witness.is_empty())
- .collect();
- let best_block_height = self.best_block.read().unwrap().height;
- match chan.funding_transaction_signed(
- txid,
- witnesses,
- best_block_height,
- &self.logger,
- ) {
- Ok(FundingTxSigned {
- commitment_signed,
- tx_signatures,
- funding_tx,
- splice_negotiated,
- splice_locked,
- }) => {
- if let Some(funding_tx) = funding_tx {
- self.broadcast_interactive_funding(
- chan,
- &funding_tx,
- &self.logger,
- );
- }
- if let Some(splice_negotiated) = splice_negotiated {
- self.pending_events.lock().unwrap().push_back((
- events::Event::SplicePending {
- channel_id: *channel_id,
- counterparty_node_id: *counterparty_node_id,
- user_channel_id: chan.context.get_user_id(),
- new_funding_txo: splice_negotiated.funding_txo,
- channel_type: splice_negotiated.channel_type,
- new_funding_redeem_script: splice_negotiated
- .funding_redeem_script,
- },
- None,
- ));
- }
- if chan.context.is_connected() {
- if let Some(commitment_signed) = commitment_signed {
- peer_state.pending_msg_events.push(
- MessageSendEvent::UpdateHTLCs {
- node_id: *counterparty_node_id,
- channel_id: *channel_id,
- updates: CommitmentUpdate {
- commitment_signed: vec![commitment_signed],
- update_add_htlcs: vec![],
- update_fulfill_htlcs: vec![],
- update_fail_htlcs: vec![],
- update_fail_malformed_htlcs: vec![],
- update_fee: None,
- },
- },
+ match peer_state.channel_by_id.entry(*channel_id) {
+ hash_map::Entry::Occupied(mut chan_entry) => {
+ match chan_entry.get_mut().as_funded_mut() {
+ Some(chan) => {
+ let txid = transaction.compute_txid();
+ let witnesses: Vec<_> = transaction
+ .input
+ .into_iter()
+ .map(|input| input.witness)
+ .filter(|witness| !witness.is_empty())
+ .collect();
+ let best_block_height = self.best_block.read().unwrap().height;
+
+ match chan.funding_transaction_signed(
+ txid,
+ witnesses,
+ best_block_height,
+ &self.fee_estimator,
+ &self.logger,
+ ) {
+ Ok(FundingTxSigned {
+ commitment_signed,
+ counterparty_initial_commitment_signed_result,
+ tx_signatures,
+ funding_tx,
+ splice_negotiated,
+ splice_locked,
+ }) => {
+ if let Some(funding_tx) = funding_tx {
+ self.broadcast_interactive_funding(
+ chan,
+ &funding_tx,
+ &self.logger,
);
}
- if let Some(tx_signatures) = tx_signatures {
- peer_state.pending_msg_events.push(
- MessageSendEvent::SendTxSignatures {
- node_id: *counterparty_node_id,
- msg: tx_signatures,
+ if let Some(splice_negotiated) = splice_negotiated {
+ self.pending_events.lock().unwrap().push_back((
+ events::Event::SplicePending {
+ channel_id: *channel_id,
+ counterparty_node_id: *counterparty_node_id,
+ user_channel_id: chan.context.get_user_id(),
+ new_funding_txo: splice_negotiated.funding_txo,
+ channel_type: splice_negotiated.channel_type,
+ new_funding_redeem_script: splice_negotiated
+ .funding_redeem_script,
},
- );
+ None,
+ ));
}
- if let Some(splice_locked) = splice_locked {
- peer_state.pending_msg_events.push(
- MessageSendEvent::SendSpliceLocked {
- node_id: *counterparty_node_id,
- msg: splice_locked,
- },
- );
+
+ if chan.context.is_connected() {
+ if let Some(commitment_signed) = commitment_signed {
+ peer_state.pending_msg_events.push(
+ MessageSendEvent::UpdateHTLCs {
+ node_id: *counterparty_node_id,
+ channel_id: *channel_id,
+ updates: CommitmentUpdate {
+ commitment_signed: vec![commitment_signed],
+ update_add_htlcs: vec![],
+ update_fulfill_htlcs: vec![],
+ update_fail_htlcs: vec![],
+ update_fail_malformed_htlcs: vec![],
+ update_fee: None,
+ },
+ },
+ );
+ }
+ if let Some(tx_signatures) = tx_signatures {
+ peer_state.pending_msg_events.push(
+ MessageSendEvent::SendTxSignatures {
+ node_id: *counterparty_node_id,
+ msg: tx_signatures,
+ },
+ );
+ }
+ if let Some(splice_locked) = splice_locked {
+ peer_state.pending_msg_events.push(
+ MessageSendEvent::SendSpliceLocked {
+ node_id: *counterparty_node_id,
+ msg: splice_locked,
+ },
+ );
+ }
}
- }
- return NotifyOption::DoPersist;
- },
- Err(err) => {
- result = Err(err);
- return NotifyOption::SkipPersistNoEvents;
- },
- }
- },
- None => {
- result = Err(APIError::APIMisuseError {
- err: format!(
- "Channel with id {} not expecting funding signatures",
- channel_id
- ),
- });
- return NotifyOption::SkipPersistNoEvents;
- },
+
+ match counterparty_initial_commitment_signed_result {
+ Some(Ok(Some(monitor_update))) => {
+ let funding_txo = chan.funding.get_funding_txo();
+ if let Some(post_update_data) = self
+ .handle_new_monitor_update(
+ &mut peer_state.in_flight_monitor_updates,
+ &mut peer_state.monitor_update_blocked_actions,
+ &mut peer_state.pending_msg_events,
+ peer_state.is_connected,
+ chan,
+ funding_txo.unwrap(),
+ monitor_update,
+ ) {
+ monitor_update_result = Some(Ok(post_update_data));
+ }
+ },
+ Some(Err(err)) => {
+ let (drop, err) = self
+ .locked_handle_funded_force_close(
+ &mut peer_state
+ .closed_channel_monitor_update_ids,
+ &mut peer_state.in_flight_monitor_updates,
+ err,
+ chan,
+ );
+ if drop {
+ chan_entry.remove_entry();
+ }
+
+ monitor_update_result = Some(Err(err));
+ },
+ Some(Ok(None)) | None => {},
+ }
+
+ funding_tx_signed_result = Ok(());
+ },
+ Err(err) => {
+ funding_tx_signed_result = Err(err);
+ return NotifyOption::SkipPersistNoEvents;
+ },
+ }
+ },
+ None => {
+ funding_tx_signed_result = Err(APIError::APIMisuseError {
+ err: format!(
+ "Channel with id {} not expecting funding signatures",
+ channel_id
+ ),
+ });
+ return NotifyOption::SkipPersistNoEvents;
+ },
+ }
},
- None => {
- result = Err(APIError::ChannelUnavailable {
+ hash_map::Entry::Vacant(_) => {
+ funding_tx_signed_result = Err(APIError::ChannelUnavailable {
err: format!(
"Channel with id {} not found for the passed counterparty node_id {}",
channel_id, counterparty_node_id
@@ -6544,9 +6590,25 @@ where
return NotifyOption::SkipPersistNoEvents;
},
}
+
+ mem::drop(peer_state_lock);
+ mem::drop(per_peer_state);
+
+ if let Some(monitor_update_result) = monitor_update_result {
+ match monitor_update_result {
+ Ok(post_update_data) => {
+ self.handle_post_monitor_update_chan_resume(post_update_data);
+ },
+ Err(_) => {
+ let _ = self.handle_error(monitor_update_result, *counterparty_node_id);
+ },
+ }
+ }
+
+ NotifyOption::DoPersist
});
- result
+ funding_tx_signed_result
}
fn broadcast_interactive_funding(
@@ -11142,7 +11204,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
match peer_state.channel_by_id.entry(msg.channel_id) {
hash_map::Entry::Occupied(mut chan_entry) => {
let chan = chan_entry.get_mut();
- match chan.tx_complete(msg, &self.logger) {
+ match chan.tx_complete(msg, &self.fee_estimator, &self.logger) {
Ok(tx_complete_result) => {
let mut persist = NotifyOption::SkipPersistNoEvents;
@@ -11169,6 +11231,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
if let Some(FundingTxSigned {
commitment_signed,
+ counterparty_initial_commitment_signed_result,
tx_signatures,
funding_tx,
splice_negotiated,
@@ -11176,10 +11239,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
}) = tx_complete_result.funding_tx_signed
{
// We shouldn't expect to see the splice negotiated or locked yet as we
- // haven't exchanged `tx_signatures` at this point.
+ // haven't exchanged `tx_signatures` at this point. Similarly, we
+ // shouldn't have a result for the counterparty's initial commitment
+ // signed as they haven't sent it yet.
debug_assert!(funding_tx.is_none());
debug_assert!(splice_negotiated.is_none());
debug_assert!(splice_locked.is_none());
+ debug_assert!(counterparty_initial_commitment_signed_result.is_none());
if let Some(commitment_signed) = commitment_signed {
peer_state.pending_msg_events.push(MessageSendEvent::UpdateHTLCs {
@@ -11251,6 +11317,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
let best_block_height = self.best_block.read().unwrap().height;
let FundingTxSigned {
commitment_signed,
+ counterparty_initial_commitment_signed_result,
tx_signatures,
funding_tx,
splice_negotiated,
@@ -11265,6 +11332,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
// We should never be sending a `commitment_signed` in response to their
// `tx_signatures`.
debug_assert!(commitment_signed.is_none());
+ debug_assert!(counterparty_initial_commitment_signed_result.is_none());
if let Some(tx_signatures) = tx_signatures {
peer_state.pending_msg_events.push(MessageSendEvent::SendTxSignatures {
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index c0ba401..ef524db 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -29,6 +29,7 @@ use crate::util::errors::APIError;
use crate::util::ser::Writeable;
use bitcoin::hashes::Hash;
+use bitcoin::secp256k1::ecdsa::Signature;
use bitcoin::secp256k1::PublicKey;
use bitcoin::{Amount, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash};
@@ -2221,3 +2222,233 @@ fn test_splice_with_inflight_htlc_forward_and_resolution() {
do_test_splice_with_inflight_htlc_forward_and_resolution(true);
do_test_splice_with_inflight_htlc_forward_and_resolution(false);
}
+
+#[test]
+fn test_splice_buffer_commitment_signed_until_funding_tx_signed() {
+ // Test that when the counterparty sends their initial `commitment_signed` before the user has
+ // called `funding_transaction_signed`, we buffer the message and process it at the end of
+ // `funding_transaction_signed`. This allows the user to cancel the splice negotiation if
+ // desired without having queued an irreversible monitor update.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ // Negotiate a splice-out where only the initiator (node 0) has a contribution.
+ // This means node 1 will send their commitment_signed immediately after tx_complete.
+ let initiator_contribution = SpliceContribution::splice_out(vec![TxOut {
+ value: Amount::from_sat(1_000),
+ script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(),
+ }]);
+ negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution);
+
+ // Node 0 (initiator with contribution) should have a signing event to handle.
+ let signing_event = get_event!(nodes[0], Event::FundingTransactionReadyForSigning);
+
+ // Node 1 (acceptor with no contribution) won't have a signing event and will immediately
+ // send their initial commitment_signed.
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+ let acceptor_commit_sig = get_htlc_update_msgs(&nodes[1], &node_id_0);
+
+ // Deliver the acceptor's commitment_signed to the initiator BEFORE the initiator has called
+ // funding_transaction_signed. The message should be buffered, not processed.
+ nodes[0].node.handle_commitment_signed(node_id_1, &acceptor_commit_sig.commitment_signed[0]);
+
+ // No monitor update should have happened since the message is buffered.
+ check_added_monitors(&nodes[0], 0);
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ // Now handle the signing event and call `funding_transaction_signed`.
+ if let Event::FundingTransactionReadyForSigning {
+ channel_id: event_channel_id,
+ counterparty_node_id,
+ unsigned_transaction,
+ ..
+ } = signing_event
+ {
+ assert_eq!(event_channel_id, channel_id);
+ assert_eq!(counterparty_node_id, node_id_1);
+
+ let partially_signed_tx = nodes[0].wallet_source.sign_tx(unsigned_transaction).unwrap();
+ nodes[0]
+ .node
+ .funding_transaction_signed(&channel_id, &node_id_1, partially_signed_tx)
+ .unwrap();
+ } else {
+ panic!("Expected FundingTransactionReadyForSigning event");
+ }
+
+ // After funding_transaction_signed:
+ // 1. The initiator should send their commitment_signed
+ // 2. The buffered commitment_signed from the acceptor should be processed (monitor update)
+ let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 1, "{msg_events:?}");
+ let initiator_commit_sig =
+ if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &msg_events[0] {
+ updates.commitment_signed[0].clone()
+ } else {
+ panic!("Expected UpdateHTLCs message");
+ };
+
+ // The buffered commitment_signed should have been processed, resulting in a monitor update.
+ check_added_monitors(&nodes[0], 1);
+
+ // Complete the rest of the flow normally.
+ nodes[1].node.handle_commitment_signed(node_id_0, &initiator_commit_sig);
+ let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 1, "{msg_events:?}");
+ if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &msg_events[0] {
+ nodes[0].node.handle_tx_signatures(node_id_1, msg);
+ } else {
+ panic!("Expected SendTxSignatures message");
+ }
+ check_added_monitors(&nodes[1], 1);
+
+ let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 1, "{msg_events:?}");
+ if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &msg_events[0] {
+ nodes[1].node.handle_tx_signatures(node_id_0, msg);
+ } else {
+ panic!("Expected SendTxSignatures message");
+ }
+
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+
+ // Both nodes should broadcast the splice transaction.
+ let splice_tx = {
+ let mut txn_0 = nodes[0].tx_broadcaster.txn_broadcast();
+ assert_eq!(txn_0.len(), 1);
+ let txn_1 = nodes[1].tx_broadcaster.txn_broadcast();
+ assert_eq!(txn_0, txn_1);
+ txn_0.remove(0)
+ };
+
+ // Verify the channel is operational by sending a payment.
+ send_payment(&nodes[0], &[&nodes[1]], 1_000_000);
+
+ // Lock the splice by confirming the transaction.
+ mine_transaction(&nodes[0], &splice_tx);
+ mine_transaction(&nodes[1], &splice_tx);
+ lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
+
+ // Verify the channel is still operational by sending another payment.
+ send_payment(&nodes[0], &[&nodes[1]], 1_000_000);
+}
+
+#[test]
+fn test_splice_buffer_invalid_commitment_signed_closes_channel() {
+ // Test that when the counterparty sends an invalid `commitment_signed` (with a bad signature)
+ // before the user has called `funding_transaction_signed`, the channel is closed with an error
+ // when `ChannelManager::funding_transaction_signed` processes the buffered message.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ // Negotiate a splice-out where only the initiator (node 0) has a contribution.
+ // This means node 1 will send their commitment_signed immediately after tx_complete.
+ let initiator_contribution = SpliceContribution::splice_out(vec![TxOut {
+ value: Amount::from_sat(1_000),
+ script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(),
+ }]);
+ negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution);
+
+ // Node 0 (initiator with contribution) should have a signing event to handle.
+ let signing_event = get_event!(nodes[0], Event::FundingTransactionReadyForSigning);
+
+ // Node 1 (acceptor with no contribution) won't have a signing event and will immediately
+ // send their initial commitment_signed.
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+ let mut acceptor_commit_sig = get_htlc_update_msgs(&nodes[1], &node_id_0);
+
+ // Invalidate the signature by modifying one byte. This will cause signature verification
+ // to fail when the buffered message is processed.
+ let original_sig = acceptor_commit_sig.commitment_signed[0].signature;
+ let mut sig_bytes = original_sig.serialize_compact();
+ sig_bytes[0] ^= 0x01; // Flip a bit to corrupt the signature
+ acceptor_commit_sig.commitment_signed[0].signature =
+ Signature::from_compact(&sig_bytes).unwrap();
+
+ // Deliver the acceptor's invalid commitment_signed to the initiator BEFORE the initiator has
+ // called funding_transaction_signed. The message should be buffered, not processed.
+ nodes[0].node.handle_commitment_signed(node_id_1, &acceptor_commit_sig.commitment_signed[0]);
+
+ // No monitor update should have happened since the message is buffered.
+ check_added_monitors(&nodes[0], 0);
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ // Now handle the signing event and call `funding_transaction_signed`.
+ // This should process the buffered invalid commitment_signed and close the channel.
+ if let Event::FundingTransactionReadyForSigning {
+ channel_id: event_channel_id,
+ counterparty_node_id,
+ unsigned_transaction,
+ ..
+ } = signing_event
+ {
+ assert_eq!(event_channel_id, channel_id);
+ assert_eq!(counterparty_node_id, node_id_1);
+
+ let partially_signed_tx = nodes[0].wallet_source.sign_tx(unsigned_transaction).unwrap();
+ nodes[0]
+ .node
+ .funding_transaction_signed(&channel_id, &node_id_1, partially_signed_tx)
+ .unwrap();
+ } else {
+ panic!("Expected FundingTransactionReadyForSigning event");
+ }
+
+ // After funding_transaction_signed:
+ // 1. The initiator sends its commitment_signed (UpdateHTLCs message).
+ // 2. The buffered invalid commitment_signed from the acceptor is processed, causing the
+ // channel to close due to the invalid signature.
+ // We expect 3 message events: UpdateHTLCs, BroadcastChannelUpdate, and HandleError.
+ let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 3, "{msg_events:?}");
+ match &msg_events[0] {
+ MessageSendEvent::UpdateHTLCs { ref updates, .. } => {
+ assert!(!updates.commitment_signed.is_empty());
+ },
+ _ => panic!("Expected UpdateHTLCs message, got {:?}", msg_events[0]),
+ }
+ match &msg_events[1] {
+ MessageSendEvent::HandleError {
+ action: msgs::ErrorAction::SendErrorMessage { ref msg },
+ ..
+ } => {
+ assert!(msg.data.contains("Invalid commitment tx signature from peer"));
+ },
+ _ => panic!("Expected HandleError with SendErrorMessage, got {:?}", msg_events[1]),
+ }
+ match &msg_events[2] {
+ MessageSendEvent::BroadcastChannelUpdate { ref msg, .. } => {
+ assert_eq!(msg.contents.channel_flags & 2, 2);
+ },
+ _ => panic!("Expected BroadcastChannelUpdate, got {:?}", msg_events[2]),
+ }
+
+ let err = "Invalid commitment tx signature from peer".to_owned();
+ let reason = ClosureReason::ProcessingError { err };
+ check_closed_events(
+ &nodes[0],
+ &[ExpectedCloseEvent::from_id_reason(channel_id, false, reason)],
+ );
+ check_added_monitors(&nodes[0], 1);
+}
Why this scored 37/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.