Send 0conf splice_locked upon tx_signatures exchange
What changed, and why it matters
This commit fixes a protocol-handling gap for zero-confirmation channel splices in the Lightning Dev Kit. When two peers agree to a splice that does not require waiting for blockchain confirmations, the node now immediately sends a 'splice_locked' message right after exchanging transaction signatures. Previously, this message was only sent after blocks were mined, which could leave a 0-conf splice stuck and unusable. The change is a correctness fix in the Lightning state machine rather than a patch for a remote exploit.
Reviewers should confirm that check_get_splice_locked correctly identifies only 0-conf splices and that sending splice_locked before on-chain confirmation does not bypass any anti-reorg or fraud checks. Users running nodes that support 0-conf splicing should upgrade to avoid channels getting stuck in a pending-splice state.
Security signals we found
Protocol state machine fix for 0-conf splice locking
New optional splice_locked message returned at tx_signatures exchange
Early SendSpliceLocked event enqueued in channel manager
Test coverage added for 0-conf splice_locked flow
No explicit security advisory or CVE referenced in commit
Evidence from the diff
The patch extends FundingTxSigned to carry an optional splice_locked message and updates on_tx_signatures_exchange to call check_get_splice_locked when a pending splice completes. channelmanager.rs now enqueues SendSpliceLocked alongside SendTxSignatures when the returned splice_locked is Some. The logic is gated by the existing 0-conf splice criteria inside check_get_splice_locked, so only splices negotiated with zero confirmations trigger the early message. Tests are updated to assert the new message flow and to skip announcement-signature exchange for 0-conf splices.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/splicing_tests.rsInspect captured patch +209 / −79
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 81166d3..75ac056 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -6846,6 +6846,9 @@ pub struct FundingTxSigned {
/// Information about the completed funding negotiation.
pub splice_negotiated: Option<SpliceFundingNegotiated>,
+
+ /// A `splice_locked` to send to the counterparty when the splice requires 0 confirmations.
+ pub splice_locked: Option<msgs::SpliceLocked>,
}
/// Information about a splice funding negotiation that has been completed.
@@ -8877,9 +8880,13 @@ where
}
}
- fn on_tx_signatures_exchange(
- &mut self, funding_tx: Transaction,
- ) -> Option<SpliceFundingNegotiated> {
+ fn on_tx_signatures_exchange<'a, L: Deref>(
+ &mut self, funding_tx: Transaction, best_block_height: u32,
+ logger: &WithChannelContext<'a, L>,
+ ) -> (Option<SpliceFundingNegotiated>, Option<msgs::SpliceLocked>)
+ where
+ L::Target: Logger,
+ {
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
@@ -8901,22 +8908,42 @@ where
channel_type,
};
- Some(splice_negotiated)
+ let splice_locked = pending_splice.check_get_splice_locked(
+ &self.context,
+ pending_splice.negotiated_candidates.len() - 1,
+ best_block_height,
+ );
+ if let Some(splice_txid) =
+ splice_locked.as_ref().map(|splice_locked| splice_locked.splice_txid)
+ {
+ log_info!(
+ logger,
+ "Sending 0conf splice_locked txid {} to our peer for channel {}",
+ splice_txid,
+ &self.context.channel_id
+ );
+ }
+
+ (Some(splice_negotiated), splice_locked)
} else {
debug_assert!(false);
- None
+ (None, None)
}
} else {
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
- None
+ (None, None)
}
}
- pub fn funding_transaction_signed(
- &mut self, funding_txid_signed: Txid, witnesses: Vec<Witness>,
- ) -> Result<FundingTxSigned, APIError> {
+ pub fn funding_transaction_signed<L: Deref>(
+ &mut self, funding_txid_signed: Txid, witnesses: Vec<Witness>, best_block_height: u32,
+ logger: &L,
+ ) -> Result<FundingTxSigned, APIError>
+ where
+ L::Target: Logger,
+ {
let signing_session =
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_mut() {
if let Some(pending_splice) = self.pending_splice.as_ref() {
@@ -8937,6 +8964,7 @@ where
tx_signatures: None,
funding_tx: None,
splice_negotiated: None,
+ splice_locked: None,
});
}
@@ -8949,6 +8977,7 @@ where
tx_signatures: None,
funding_tx: None,
splice_negotiated: None,
+ splice_locked: None,
});
}
let err =
@@ -8991,19 +9020,30 @@ where
.provide_holder_witnesses(tx_signatures, &self.context.secp_ctx)
.map_err(|err| APIError::APIMisuseError { err })?;
- let splice_negotiated = if let Some(funding_tx) = funding_tx.clone() {
+ let logger = WithChannelContext::from(logger, &self.context, None);
+ if tx_signatures.is_some() {
+ log_info!(
+ logger,
+ "Sending tx_signatures for interactive funding transaction {funding_txid_signed}"
+ );
+ }
+
+ let (splice_negotiated, splice_locked) = if let Some(funding_tx) = funding_tx.clone() {
debug_assert!(tx_signatures.is_some());
- self.on_tx_signatures_exchange(funding_tx)
+ self.on_tx_signatures_exchange(funding_tx, best_block_height, &logger)
} else {
- None
+ (None, None)
};
- Ok(FundingTxSigned { tx_signatures, funding_tx, splice_negotiated })
+ Ok(FundingTxSigned { tx_signatures, funding_tx, splice_negotiated, splice_locked })
}
- pub fn tx_signatures(
- &mut self, msg: &msgs::TxSignatures,
- ) -> Result<FundingTxSigned, ChannelError> {
+ pub fn tx_signatures<L: Deref>(
+ &mut self, msg: &msgs::TxSignatures, best_block_height: u32, logger: &L,
+ ) -> Result<FundingTxSigned, ChannelError>
+ where
+ L::Target: Logger,
+ {
let signing_session = if let Some(signing_session) =
self.context.interactive_tx_signing_session.as_mut()
{
@@ -9049,13 +9089,25 @@ where
let (holder_tx_signatures, funding_tx) =
signing_session.received_tx_signatures(msg).map_err(|msg| ChannelError::Warn(msg))?;
- let splice_negotiated = if let Some(funding_tx) = funding_tx.clone() {
- self.on_tx_signatures_exchange(funding_tx)
+ let logger = WithChannelContext::from(logger, &self.context, None);
+ log_info!(
+ logger,
+ "Received tx_signatures for interactive funding transaction {}",
+ msg.tx_hash
+ );
+
+ let (splice_negotiated, splice_locked) = if let Some(funding_tx) = funding_tx.clone() {
+ self.on_tx_signatures_exchange(funding_tx, best_block_height, &logger)
} else {
- None
+ (None, None)
};
- Ok(FundingTxSigned { tx_signatures: holder_tx_signatures, funding_tx, splice_negotiated })
+ Ok(FundingTxSigned {
+ tx_signatures: holder_tx_signatures,
+ funding_tx,
+ splice_negotiated,
+ splice_locked,
+ })
}
/// Queues up an outbound update fee by placing it in the holding cell. You should call
@@ -11362,7 +11414,11 @@ where
confirmed_funding_index,
height,
) {
- log_info!(logger, "Sending a splice_locked to our peer for channel {}", &self.context.channel_id);
+ log_info!(
+ logger, "Sending splice_locked txid {} to our peer for channel {}",
+ splice_locked.splice_txid,
+ &self.context.channel_id
+ );
let (funding_txo, monitor_update, announcement_sigs, discarded_funding) = chain_node_signer
.and_then(|(chain_hash, node_signer, user_config)| {
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 4c96f5d..ff57e95 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -6431,11 +6431,18 @@ where
.map(|input| input.witness)
.filter(|witness| !witness.is_empty())
.collect();
- match chan.funding_transaction_signed(txid, witnesses) {
+ let best_block_height = self.best_block.read().unwrap().height;
+ match chan.funding_transaction_signed(
+ txid,
+ witnesses,
+ best_block_height,
+ &self.logger,
+ ) {
Ok(FundingTxSigned {
tx_signatures: Some(tx_signatures),
funding_tx,
splice_negotiated,
+ splice_locked,
}) => {
if let Some(funding_tx) = funding_tx {
self.broadcast_interactive_funding(
@@ -6462,6 +6469,14 @@ where
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) => {
@@ -6472,9 +6487,11 @@ where
tx_signatures: None,
funding_tx,
splice_negotiated,
+ splice_locked,
}) => {
debug_assert!(funding_tx.is_none());
debug_assert!(splice_negotiated.is_none());
+ debug_assert!(splice_locked.is_none());
return NotifyOption::SkipPersistNoEvents;
},
}
@@ -9578,8 +9595,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
}
} else {
let txid = signing_session.unsigned_tx().compute_txid();
- match channel.funding_transaction_signed(txid, vec![]) {
- Ok(FundingTxSigned { tx_signatures: Some(tx_signatures), funding_tx, splice_negotiated }) => {
+ let best_block_height = self.best_block.read().unwrap().height;
+ match channel.funding_transaction_signed(txid, vec![], best_block_height, &self.logger) {
+ Ok(FundingTxSigned {
+ tx_signatures: Some(tx_signatures),
+ funding_tx,
+ splice_negotiated,
+ splice_locked,
+ }) => {
if let Some(funding_tx) = funding_tx {
self.broadcast_interactive_funding(channel, &funding_tx, &self.logger);
}
@@ -9602,6 +9625,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
node_id: counterparty_node_id,
msg: tx_signatures,
});
+ if let Some(splice_locked) = splice_locked {
+ pending_msg_events.push(MessageSendEvent::SendSpliceLocked {
+ node_id: counterparty_node_id,
+ msg: splice_locked,
+ });
+ }
}
},
Ok(FundingTxSigned { tx_signatures: None, .. }) => {
@@ -10580,14 +10609,30 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
hash_map::Entry::Occupied(mut chan_entry) => {
match chan_entry.get_mut().as_funded_mut() {
Some(chan) => {
- let FundingTxSigned { tx_signatures, funding_tx, splice_negotiated } =
- try_channel_entry!(self, peer_state, chan.tx_signatures(msg), chan_entry);
+ let best_block_height = self.best_block.read().unwrap().height;
+ let FundingTxSigned {
+ tx_signatures,
+ funding_tx,
+ splice_negotiated,
+ splice_locked,
+ } = try_channel_entry!(
+ self,
+ peer_state,
+ chan.tx_signatures(msg, best_block_height, &self.logger),
+ chan_entry
+ );
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,
+ });
+ }
if let Some(ref funding_tx) = funding_tx {
self.broadcast_interactive_funding(chan, funding_tx, &self.logger);
}
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 3edd051..deb76a7 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -23,6 +23,7 @@ use crate::util::errors::APIError;
use crate::util::ser::Writeable;
use crate::util::test_channel_signer::SignerOp;
+use bitcoin::secp256k1::PublicKey;
use bitcoin::{Amount, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut};
#[test]
@@ -206,25 +207,25 @@ fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>(
}
}
-fn sign_interactive_funding_transaction<'a, 'b, 'c, 'd>(
+fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>(
initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>,
- initial_commit_sig_for_acceptor: msgs::CommitmentSigned,
-) {
+ initial_commit_sig_for_acceptor: msgs::CommitmentSigned, is_0conf: bool,
+) -> (Transaction, Option<(msgs::SpliceLocked, PublicKey)>) {
let node_id_initiator = initiator.node.get_our_node_id();
let node_id_acceptor = acceptor.node.get_our_node_id();
assert!(initiator.node.get_and_clear_pending_msg_events().is_empty());
acceptor.node.handle_commitment_signed(node_id_initiator, &initial_commit_sig_for_acceptor);
- let mut msg_events = acceptor.node.get_and_clear_pending_msg_events();
+ let msg_events = acceptor.node.get_and_clear_pending_msg_events();
assert_eq!(msg_events.len(), 2, "{msg_events:?}");
- if let MessageSendEvent::UpdateHTLCs { mut updates, .. } = msg_events.remove(0) {
- let commitment_signed = updates.commitment_signed.remove(0);
- initiator.node.handle_commitment_signed(node_id_acceptor, &commitment_signed);
+ if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &msg_events[0] {
+ let commitment_signed = &updates.commitment_signed[0];
+ initiator.node.handle_commitment_signed(node_id_acceptor, commitment_signed);
} else {
panic!();
}
- if let MessageSendEvent::SendTxSignatures { ref msg, .. } = msg_events.remove(0) {
+ if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &msg_events[1] {
initiator.node.handle_tx_signatures(node_id_acceptor, msg);
} else {
panic!();
@@ -244,12 +245,34 @@ fn sign_interactive_funding_transaction<'a, 'b, 'c, 'd>(
.funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx)
.unwrap();
}
- let tx_signatures =
- get_event_msg!(initiator, MessageSendEvent::SendTxSignatures, node_id_acceptor);
- acceptor.node.handle_tx_signatures(node_id_initiator, &tx_signatures);
+ let mut msg_events = initiator.node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), if is_0conf { 2 } else { 1 }, "{msg_events:?}");
+ if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &msg_events[0] {
+ acceptor.node.handle_tx_signatures(node_id_initiator, msg);
+ } else {
+ panic!();
+ }
+ let splice_locked = if is_0conf {
+ if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(1) {
+ Some((msg, node_id_acceptor))
+ } else {
+ panic!();
+ }
+ } else {
+ None
+ };
check_added_monitors(&initiator, 1);
check_added_monitors(&acceptor, 1);
+
+ let tx = {
+ let mut initiator_txn = initiator.tx_broadcaster.txn_broadcast();
+ assert_eq!(initiator_txn.len(), 1);
+ let acceptor_txn = acceptor.tx_broadcaster.txn_broadcast();
+ assert_eq!(initiator_txn, acceptor_txn,);
+ initiator_txn.remove(0)
+ };
+ (tx, splice_locked)
}
fn splice_channel<'a, 'b, 'c, 'd>(
@@ -269,15 +292,9 @@ fn splice_channel<'a, 'b, 'c, 'd>(
initiator_contribution,
new_funding_script,
);
- sign_interactive_funding_transaction(initiator, acceptor, initial_commit_sig_for_acceptor);
-
- let splice_tx = {
- let mut initiator_txn = initiator.tx_broadcaster.txn_broadcast();
- assert_eq!(initiator_txn.len(), 1);
- let acceptor_txn = acceptor.tx_broadcaster.txn_broadcast();
- assert_eq!(initiator_txn, acceptor_txn);
- initiator_txn.remove(0)
- };
+ let (splice_tx, splice_locked) =
+ sign_interactive_funding_tx(initiator, acceptor, initial_commit_sig_for_acceptor, false);
+ assert!(splice_locked.is_none());
expect_splice_pending_event(initiator, &node_id_acceptor);
expect_splice_pending_event(acceptor, &node_id_initiator);
@@ -286,36 +303,46 @@ fn splice_channel<'a, 'b, 'c, 'd>(
}
fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
- node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, channel_id: ChannelId,
- num_blocks: u32,
+ node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, num_blocks: u32,
+) {
+ connect_blocks(node_a, num_blocks);
+ connect_blocks(node_b, num_blocks);
+
+ let node_id_b = node_b.node.get_our_node_id();
+ let splice_locked_for_node_b =
+ get_event_msg!(node_a, MessageSendEvent::SendSpliceLocked, node_id_b);
+ lock_splice(node_a, node_b, &splice_locked_for_node_b, false);
+}
+
+fn lock_splice<'a, 'b, 'c, 'd>(
+ node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>,
+ splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool,
) {
let (prev_funding_outpoint, prev_funding_script) = node_a
.chain_monitor
.chain_monitor
- .get_monitor(channel_id)
+ .get_monitor(splice_locked_for_node_b.channel_id)
.map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script()))
.unwrap();
- connect_blocks(node_a, num_blocks);
- connect_blocks(node_b, num_blocks);
-
let node_id_a = node_a.node.get_our_node_id();
let node_id_b = node_b.node.get_our_node_id();
- let splice_locked_a = get_event_msg!(node_a, MessageSendEvent::SendSpliceLocked, node_id_b);
- node_b.node.handle_splice_locked(node_id_a, &splice_locked_a);
+ node_b.node.handle_splice_locked(node_id_a, splice_locked_for_node_b);
let mut msg_events = node_b.node.get_and_clear_pending_msg_events();
- assert_eq!(msg_events.len(), 2, "{msg_events:?}");
+ assert_eq!(msg_events.len(), if is_0conf { 1 } else { 2 }, "{msg_events:?}");
if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(0) {
node_a.node.handle_splice_locked(node_id_b, &msg);
} else {
panic!();
}
- if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) {
- node_a.node.handle_announcement_signatures(node_id_b, &msg);
- } else {
- panic!();
+ if !is_0conf {
+ if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) {
+ node_a.node.handle_announcement_signatures(node_id_b, &msg);
+ } else {
+ panic!();
+ }
}
expect_channel_ready_event(&node_a, &node_id_b);
@@ -323,23 +350,25 @@ fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
expect_channel_ready_event(&node_b, &node_id_a);
check_added_monitors(&node_b, 1);
- let mut msg_events = node_a.node.get_and_clear_pending_msg_events();
- assert_eq!(msg_events.len(), 2, "{msg_events:?}");
- if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) {
- node_b.node.handle_announcement_signatures(node_id_a, &msg);
- } else {
- panic!();
- }
- if let MessageSendEvent::BroadcastChannelAnnouncement { .. } = msg_events.remove(0) {
- } else {
- panic!();
- }
+ if !is_0conf {
+ let mut msg_events = node_a.node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 2, "{msg_events:?}");
+ if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) {
+ node_b.node.handle_announcement_signatures(node_id_a, &msg);
+ } else {
+ panic!();
+ }
+ if let MessageSendEvent::BroadcastChannelAnnouncement { .. } = msg_events.remove(0) {
+ } else {
+ panic!();
+ }
- let mut msg_events = node_b.node.get_and_clear_pending_msg_events();
- assert_eq!(msg_events.len(), 1, "{msg_events:?}");
- if let MessageSendEvent::BroadcastChannelAnnouncement { .. } = msg_events.remove(0) {
- } else {
- panic!();
+ let mut msg_events = node_b.node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 1, "{msg_events:?}");
+ if let MessageSendEvent::BroadcastChannelAnnouncement { .. } = msg_events.remove(0) {
+ } else {
+ panic!();
+ }
}
// Remove the corresponding outputs and transactions the chain source is watching for the
@@ -533,7 +562,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) {
mine_transaction(&nodes[0], &splice_tx);
mine_transaction(&nodes[1], &splice_tx);
- lock_splice_after_blocks(&nodes[0], &nodes[1], channel_id, ANTI_REORG_DELAY - 1);
+ lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
}
#[test]
@@ -633,7 +662,7 @@ fn test_splice_in() {
assert!(htlc_limit_msat < initial_channel_value_sat * 1000);
let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat);
- lock_splice_after_blocks(&nodes[0], &nodes[1], channel_id, ANTI_REORG_DELAY - 1);
+ lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat;
assert!(htlc_limit_msat > initial_channel_value_sat);
@@ -676,7 +705,7 @@ fn test_splice_out() {
assert!(htlc_limit_msat < initial_channel_value_sat / 2 * 1000);
let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat);
- lock_splice_after_blocks(&nodes[0], &nodes[1], channel_id, ANTI_REORG_DELAY - 1);
+ lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat;
assert!(htlc_limit_msat < initial_channel_value_sat / 2 * 1000);
@@ -736,7 +765,7 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs:
mine_transaction(&nodes[1], &splice_tx);
}
if splice_status == SpliceStatus::Locked {
- lock_splice_after_blocks(&nodes[0], &nodes[1], channel_id, ANTI_REORG_DELAY - 1);
+ lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
}
if claim_htlcs {
Why this scored 42/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.