Merge PR 'Defer splice_locked during RBF signing' (#4891)
What changed, and why it matters
This commit fixes a timing bug in Lightning Dev Kit's splicing code. Previously, a node could prematurely send a 'splice_locked' message and promote an older splice transaction to active while a replacement transaction was still being signed through RBF. The patch defers sending splice_locked until the RBF signing round completes and the channel is no longer quiescent, then releases the lock on a timer tick. This prevents inconsistent channel states between peers and avoids potential fund loss or channel closure from conflicting funding transactions.
Review and merge if not already merged; ensure downstream users running splicing/RBF pick up this commit. No immediate emergency response is warranted, but the fix should be included in the next release because it prevents a real consensus/state inconsistency between channel peers.
Security signals we found
Prevents premature splice funding promotion during incomplete RBF signing
Defers splice_locked emission until quiescence ends
Adds timer-driven release of deferred splice_locked
Refactors promotion handling to avoid duplicate code paths
Adds regression test covering reload, async monitor, async signer, and early peer lock cases
Evidence from the diff
The change modifies channel splicing logic so that check_get_splice_locked returns None while the channel is quiescent (i.e., during an ongoing RBF signing round). A new timer_check_splice_locked method is added to Channel and invoked from ChannelManager::timer_tick_occurred to emit deferred SpliceLocked messages after funding negotiation completes. The patch also moves exit_quiescence() earlier when tx_signatures are received, refactors splice promotion handling into handle_splice_promotion_with_funded_channel, and updates error messages to clarify that a splice transaction already meeting lock depth cannot be RBF’d. A comprehensive test verifies deferral across reload, async monitor, async signer, and early peer splice_locked scenarios.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/splicing_tests.rslightning/src/ln/functional_test_utils.rsfuzz/src/chanmon_consistency.rsInspect captured patch +532 / −85
### fuzz/src/chanmon_consistency.rs
@@ -953,11 +953,13 @@ fn assert_disconnect_action<'a>(
msgs::ErrorAction::DisconnectPeerWithWarning { ref msg } => {
// Since sending/receiving messages may be delayed, `timer_tick_occurred` may cause
// a node to disconnect their counterparty if they're expecting a timely response.
- let is_quiescent_msg = msg.data.contains("already sent splice_locked, cannot RBF")
- || msg.data.contains(
- "Waiting for splice to lock before potentially proceeding with queued contribution",
- )
- || msg.data.contains("contribution no longer valid at quiescence")
+ let is_quiescent_msg = msg.data.contains(
+ "A splice transaction already met the confirmations required to lock, cannot RBF",
+ ) || msg.data.contains(
+ "Waiting for splice to lock before potentially proceeding with queued contribution",
+ ) || msg
+ .data
+ .contains("contribution no longer valid at quiescence")
|| msg.data.contains("Quiescence no longer needed");
assert!(
msg.data.contains("Disconnecting due to timeout awaiting response")
### lightning/src/ln/channel.rs
@@ -3561,6 +3561,13 @@ impl PendingFunding {
) -> Option<msgs::SpliceLocked> {
debug_assert!(confirmed_funding_index < self.negotiated_candidates.len());
+ // While quiescent, defer locking any candidate. We may be quiescent due to an ongoing
+ // splice RBF negotiation that is mid-signing. Once its `tx_signatures` is exchanged and
+ // quiescence terminates, our `splice_locked` is sent on a timer tick.
+ if context.channel_state.is_quiescent() {
+ return None;
+ }
+
let funding = &self.negotiated_candidates[confirmed_funding_index].funding;
if !context.check_funding_meets_minimum_depth(funding, height) {
return None;
@@ -9867,7 +9874,10 @@ where
);
debug_assert!(!self.context.is_waiting_on_peer_pending_channel_update());
- if let Some(pending_splice) = self.pending_splice.as_mut() {
+ if self.pending_splice.is_some() {
+ self.exit_quiescence();
+
+ let pending_splice = self.pending_splice.as_mut().expect("We just checked it above");
if let Some(FundingNegotiation::AwaitingSignatures {
mut funding,
funding_feerate_sat_per_1000_weight,
@@ -9946,8 +9956,6 @@ where
} else {
debug_assert!(false);
}
-
- self.exit_quiescence();
} else {
self.funding.funding_transaction = Some(funding_tx.clone());
self.context.channel_state =
@@ -10954,7 +10962,7 @@ where
// `maybe_promote_splice_funding` will emit correct post-splice sigs once
// `inferred_splice_locked` is processed.
let our_splice_txid =
- self.pending_splice.as_ref().and_then(|ps| ps.sent_funding_txid);
+ self.pending_splice.as_ref().and_then(|pending| pending.sent_funding_txid);
let splice_promotion_pending = msg
.my_current_funding_locked
.as_ref()
@@ -12269,17 +12277,8 @@ where
&mut self, node_signer: &NS, chain_hash: ChainHash, user_config: &UserConfig,
block_height: u32, logger: &L,
) -> Option<SpliceFundingPromotion> {
- debug_assert!(self.pending_splice.is_some());
-
- let pending_splice = self.pending_splice.as_mut().unwrap();
- let splice_txid = match pending_splice.sent_funding_txid {
- Some(sent_funding_txid) => sent_funding_txid,
- None => {
- debug_assert!(false);
- return None;
- },
- };
-
+ let pending_splice = self.pending_splice.as_mut()?;
+ let splice_txid = pending_splice.sent_funding_txid?;
if let Some(received_funding_txid) = pending_splice.received_funding_txid {
if splice_txid != received_funding_txid {
log_warn!(
@@ -12424,6 +12423,47 @@ where
})
}
+ /// Generates a pending `splice_locked` once any funding negotiation has completed, along with
+ /// the resulting funding promotion if the counterparty's lock was already received.
+ pub fn timer_check_splice_locked<NS: NodeSigner, L: Logger>(
+ &mut self, node_signer: &NS, chain_hash: ChainHash, user_config: &UserConfig,
+ best_block_height: u32, logger: &L,
+ ) -> Option<(msgs::SpliceLocked, Option<SpliceFundingPromotion>)> {
+ // We intentionally check this early even though `check_get_splice_locked` already does to
+ // prevent scanning splice candidates unnecessarily.
+ if self.context.channel_state.is_quiescent() {
+ return None;
+ }
+
+ let candidate_idx = {
+ let pending_splice = self.pending_splice.as_ref()?;
+ pending_splice.negotiated_candidates.iter().rposition(|candidate| {
+ self.context
+ .check_funding_meets_minimum_depth(&candidate.funding, best_block_height)
+ })?
+ };
+
+ let splice_locked = self.pending_splice.as_mut()?.check_get_splice_locked(
+ &self.context,
+ candidate_idx,
+ best_block_height,
+ )?;
+ log_info!(
+ logger,
+ "Sending splice_locked txid {} after completing funding negotiation",
+ splice_locked.splice_txid,
+ );
+
+ let splice_promotion = self.maybe_promote_splice_funding(
+ node_signer,
+ chain_hash,
+ user_config,
+ best_block_height,
+ logger,
+ );
+ Some((splice_locked, splice_promotion))
+ }
+
/// When a transaction is confirmed, we check whether it is or spends the funding transaction
/// In the first case, we store the confirmation height and calculating the short channel id.
/// In the second, we simply return an Err indicating we need to be force-closed now.
@@ -13007,7 +13047,7 @@ where
fn maybe_get_my_current_funding_locked(&self) -> Option<msgs::FundingLocked> {
self.pending_splice
.as_ref()
- .and_then(|pending_splice| pending_splice.sent_funding_txid)
+ .and_then(|pending| pending.sent_funding_txid)
.or_else(|| {
self.is_our_channel_ready().then(|| self.funding.get_funding_txid()).flatten()
})
@@ -13252,10 +13292,10 @@ where
}
if pending_splice.sent_funding_txid.is_some() {
- return Err(format!(
- "Channel {} already sent splice_locked, cannot RBF",
- self.context.channel_id(),
- ));
+ return Err(
+ "A splice transaction already met the confirmations required to lock, cannot RBF"
+ .to_owned(),
+ );
}
if pending_splice.received_funding_txid.is_some() {
@@ -13972,7 +14012,8 @@ where
if pending_splice.sent_funding_txid.is_some() {
return Err(ChannelError::Abort(AbortReason::RbfUnavailable(
- "Already sent splice_locked".into(),
+ "A splice transaction already met the confirmations required to lock, cannot RBF"
+ .into(),
)));
}
### lightning/src/ln/channelmanager.rs
@@ -62,7 +62,7 @@ use crate::ln::channel::{
self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult,
FundedChannel, FundingTxSigned, InboundV1Channel, InteractiveTxMsgError, OutboundHop,
OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, SpliceFundingFailed,
- StfuResponse, UpdateFulfillCommitFetch, WithChannelContext,
+ SpliceFundingPromotion, StfuResponse, UpdateFulfillCommitFetch, WithChannelContext,
};
use crate::ln::channel_state::{ChannelDetails, InboundHTLCReference, OutboundHTLCSource};
use crate::ln::funding::{FundingContribution, FundingTemplate};
@@ -9313,6 +9313,7 @@ impl<
/// the channel.
/// * Expiring a channel's previous [`ChannelConfig`] if necessary to only allow forwarding HTLCs
/// with the current [`ChannelConfig`].
+ /// * Sending pending `splice_locked` messages after funding negotiations complete.
/// * Removing peers which have disconnected but and no longer have any channels.
/// * Force-closing and removing channels which have not completed establishment in a timely manner.
/// * Forgetting about stale outbound payments, either those that have already been fulfilled
@@ -9333,6 +9334,8 @@ impl<
let mut timed_out_mpp_htlcs = Vec::new();
let mut pending_peers_awaiting_removal = Vec::new();
let mut feerate_cache = new_hash_map();
+ let mut post_update_results = Vec::new();
+ let user_config = self.config.read().unwrap().clone();
{
let per_peer_state = self.per_peer_state.read().unwrap();
@@ -9344,6 +9347,47 @@ impl<
peer_state.channel_by_id.retain(|chan_id, chan| {
match chan.as_funded_mut() {
Some(funded_chan) => {
+ let channel_is_connected = funded_chan.context.is_connected();
+ let logger = WithChannelContext::from(
+ &self.logger,
+ &funded_chan.context,
+ None,
+ );
+ let best_block_height = self.best_block.read().unwrap().height;
+ let pending_splice_locked =
+ funded_chan.timer_check_splice_locked(
+ &self.node_signer,
+ self.chain_hash,
+ &user_config,
+ best_block_height,
+ &&logger,
+ );
+ if let Some((splice_locked, splice_promotion)) = pending_splice_locked
+ {
+ should_persist = NotifyOption::DoPersist;
+ if channel_is_connected {
+ pending_msg_events.push(MessageSendEvent::SendSpliceLocked {
+ node_id: counterparty_node_id,
+ msg: splice_locked,
+ });
+ }
+ if let Some(splice_promotion) = splice_promotion {
+ if let Some(post_update_data) = self
+ .handle_splice_promotion_with_funded_channel(
+ &counterparty_node_id,
+ splice_promotion,
+ funded_chan,
+ &mut peer_state.in_flight_monitor_updates,
+ &mut peer_state.monitor_update_blocked_actions,
+ pending_msg_events,
+ channel_is_connected,
+ )
+ {
+ post_update_results.push(post_update_data);
+ }
+ }
+ }
+
let channel_type = funded_chan.funding.get_channel_type();
let new_feerate = feerate_cache.get(channel_type).copied().or_else(|| {
let feerate = selected_commitment_sat_per_1000_weight(&self.fee_estimator, &channel_type);
@@ -9480,6 +9524,10 @@ impl<
}
}
+ for post_update_data in post_update_results {
+ let _ = self.handle_post_monitor_update_chan_resume(post_update_data);
+ }
+
// When a peer disconnects but still has channels, the peer's `peer_state` entry in the
// `per_peer_state` is not removed by the `peer_disconnected` function. If the channels
// of to that peer is later closed while still being disconnected (i.e. force closed),
@@ -12830,7 +12878,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
msg: tx_abort_msg,
});
}
-
if let Some(splice_funding_failed) = splice_failed {
let pending_events = &mut self.pending_events.lock().unwrap();
pending_events.extend(
@@ -14185,69 +14232,86 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
self.best_block.read().unwrap().height,
&&logger,
)?;
- let mut post_update_data = None;
- if let Some(splice_promotion) = splice_promotion {
- let splice_funding_failed = splice_promotion.splice_funding_failed;
- {
- let mut short_to_chan_info = self.short_to_chan_info.write().unwrap();
- insert_short_channel_id!(short_to_chan_info, chan);
- }
+ Ok(splice_promotion.and_then(|splice_promotion| {
+ self.handle_splice_promotion_with_funded_channel(
+ counterparty_node_id,
+ splice_promotion,
+ chan,
+ in_flight_monitor_updates,
+ monitor_update_blocked_actions,
+ pending_msg_events,
+ is_connected,
+ )
+ }))
+ }
- {
- let mut pending_events = self.pending_events.lock().unwrap();
- pending_events.push_back((
- events::Event::ChannelReady {
- channel_id: chan.context.channel_id(),
- user_channel_id: chan.context.get_user_id(),
- counterparty_node_id: chan.context.get_counterparty_node_id(),
- funding_txo: Some(splice_promotion.funding_txo.into_bitcoin_outpoint()),
- channel_type: chan.funding.get_channel_type().clone(),
- },
- None,
- ));
- splice_promotion.discarded_funding.into_iter().for_each(|funding_info| {
- let event = Event::DiscardFunding {
- channel_id: chan.context.channel_id(),
- funding_info,
- };
- pending_events.push_back((event, None));
- });
- }
+ fn handle_splice_promotion_with_funded_channel(
+ &self, counterparty_node_id: &PublicKey, splice_promotion: SpliceFundingPromotion,
+ chan: &mut FundedChannel<SP>,
+ in_flight_monitor_updates: &mut BTreeMap<ChannelId, (OutPoint, Vec<ChannelMonitorUpdate>)>,
+ monitor_update_blocked_actions: &mut BTreeMap<
+ ChannelId,
+ Vec<MonitorUpdateCompletionAction>,
+ >,
+ pending_msg_events: &mut Vec<MessageSendEvent>, is_connected: bool,
+ ) -> Option<PostMonitorUpdateChanResume> {
+ let logger = WithChannelContext::from(&self.logger, &chan.context, None);
+ let splice_funding_failed = splice_promotion.splice_funding_failed;
+ {
+ let mut short_to_chan_info = self.short_to_chan_info.write().unwrap();
+ insert_short_channel_id!(short_to_chan_info, chan);
+ }
- if let Some(splice_funding_failed) = splice_funding_failed {
- self.handle_quiescent_error(
- chan.context.channel_id(),
- chan.context.get_counterparty_node_id(),
- chan.context.get_user_id(),
- QuiescentError::FailSplice(
- splice_funding_failed,
- events::NegotiationFailureReason::CannotInitiateRbf,
- ),
- );
- }
+ {
+ let mut pending_events = self.pending_events.lock().unwrap();
+ pending_events.push_back((
+ events::Event::ChannelReady {
+ channel_id: chan.context.channel_id(),
+ user_channel_id: chan.context.get_user_id(),
+ counterparty_node_id: chan.context.get_counterparty_node_id(),
+ funding_txo: Some(splice_promotion.funding_txo.into_bitcoin_outpoint()),
+ channel_type: chan.funding.get_channel_type().clone(),
+ },
+ None,
+ ));
+ splice_promotion.discarded_funding.into_iter().for_each(|funding_info| {
+ let event =
+ Event::DiscardFunding { channel_id: chan.context.channel_id(), funding_info };
+ pending_events.push_back((event, None));
+ });
+ }
- if let Some(announcement_sigs) = splice_promotion.announcement_sigs {
- log_trace!(logger, "Sending announcement_signatures",);
- pending_msg_events.push(MessageSendEvent::SendAnnouncementSignatures {
- node_id: counterparty_node_id.clone(),
- msg: announcement_sigs,
- });
- }
+ if let Some(splice_funding_failed) = splice_funding_failed {
+ self.handle_quiescent_error(
+ chan.context.channel_id(),
+ chan.context.get_counterparty_node_id(),
+ chan.context.get_user_id(),
+ QuiescentError::FailSplice(
+ splice_funding_failed,
+ events::NegotiationFailureReason::CannotInitiateRbf,
+ ),
+ );
+ }
- if let Some(monitor_update) = splice_promotion.monitor_update {
- post_update_data = self.handle_new_monitor_update(
- in_flight_monitor_updates,
- monitor_update_blocked_actions,
- pending_msg_events,
- is_connected,
- chan,
- splice_promotion.funding_txo,
- monitor_update,
- );
- }
+ if let Some(announcement_sigs) = splice_promotion.announcement_sigs {
+ log_trace!(logger, "Sending announcement_signatures",);
+ pending_msg_events.push(MessageSendEvent::SendAnnouncementSignatures {
+ node_id: counterparty_node_id.clone(),
+ msg: announcement_sigs,
+ });
}
- Ok(post_update_data)
+ splice_promotion.monitor_update.and_then(|monitor_update| {
+ self.handle_new_monitor_update(
+ in_flight_monitor_updates,
+ monitor_update_blocked_actions,
+ pending_msg_events,
+ is_connected,
+ chan,
+ splice_promotion.funding_txo,
+ monitor_update,
+ )
+ })
}
/// Process pending events from the [`chain::Watch`], returning the appropriate
### lightning/src/ln/functional_test_utils.rs
@@ -1052,6 +1052,14 @@ pub fn get_updates_and_revoke<CM: AChannelManager, H: NodeHolder<CM = CM>>(
/// Get an specific event message from the pending events queue.
#[macro_export]
macro_rules! get_event_msg {
+ ($node: expr, $event_type: path) => {{
+ let events = $node.node.get_and_clear_pending_msg_events();
+ assert_eq!(events.len(), 1, "{events:?}");
+ match events[0] {
+ $event_type { ref msg, .. } => (*msg).clone(),
+ _ => panic!("Unexpected event {:?}", events[0]),
+ }
+ }};
($node: expr, $event_type: path, $node_id: expr) => {{
let events = $node.node.get_and_clear_pending_msg_events();
assert_eq!(events.len(), 1, "{events:?}");
### lightning/src/ln/splicing_tests.rs
@@ -8747,6 +8747,338 @@ fn test_splice_rbf_after_counterparty_rbf_aborted() {
assert!(rbf_contribution.is_ok());
}
+#[cfg(test)]
+fn do_test_splice_locked_deferred_during_rbf_signing(
+ reload: bool, async_monitor: bool, async_signer: bool, early_peer_splice_locked: bool,
+) {
+ // Tests that a confirmed splice candidate does not cause us to send `splice_locked` or promote
+ // while its replacement RBF round is awaiting signatures. Both peers first negotiate a splice
+ // and then negotiate an RBF replacement, leaving the replacement in `AwaitingSignatures` before
+ // the original candidate confirms. The test verifies that confirmation is recorded without
+ // emitting `splice_locked` or changing the active funding outpoint, then completes the RBF
+ // signing round through either an asynchronous monitor update or an asynchronous signer
+ // operation. In both cases, completing the signing round still leaves the confirmed candidate
+ // pending until a timer tick emits the deferred `splice_locked`.
+ //
+ // The monitor variant reloads and reconnects both nodes while the RBF signing round is pending,
+ // and delivers an early `splice_locked` from the peer to model implementations that do not defer
+ // the message during RBF signing. It verifies that this peer lock survives reload without causing
+ // an early promotion and that the timer later sends our lock and promotes the confirmed candidate
+ // atomically. The signer variant omits the early peer lock, exercises completion through
+ // `signer_unblocked`, and reconnects before the timer runs. It verifies that transport
+ // reconnection alone does not release the lock, then completes reestablishment and the reciprocal
+ // `splice_locked` exchange. Finally, both variants complete the announcement exchange and send a
+ // payment over the promoted channel.
+ assert_ne!(async_monitor, async_signer);
+
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let mut config = test_default_channel_config();
+ config.channel_handshake_config.minimum_depth = 1;
+ let (persister_0, persister_1);
+ let (chain_monitor_0, chain_monitor_1);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]);
+ let (node_0, node_1);
+ let mut 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 (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+ let previous_funding_txid = get_monitor!(nodes[0], channel_id).get_funding_txo().txid;
+ // Keep the signer variant's later reconnect focused on the splice-lock exchange by ensuring the
+ // initial funding's announcement signatures have already been acknowledged.
+ if async_signer {
+ send_payment(&nodes[0], &[&nodes[1]], 1_000_000);
+ }
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let first_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (first_splice_tx, new_funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, first_contribution);
+ let first_splice_txid = first_splice_tx.compute_txid();
+
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
+ let rbf_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate);
+ complete_rbf_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ rbf_contribution.clone(),
+ new_funding_script,
+ );
+
+ let event = get_event!(nodes[0], Event::FundingTransactionReadyForSigning);
+ let rbf_txid = if let Event::FundingTransactionReadyForSigning {
+ channel_id,
+ counterparty_node_id,
+ unsigned_transaction,
+ ..
+ } = event
+ {
+ let txid = unsigned_transaction.compute_txid();
+ let partially_signed_tx = nodes[0].wallet_source.sign_tx(unsigned_transaction).unwrap();
+ if async_signer {
+ nodes[0].disable_channel_signer_op(
+ &node_id_1,
+ &channel_id,
+ SignerOp::SignSpliceSharedInput,
+ );
+ }
+ nodes[0]
+ .node
+ .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx)
+ .unwrap();
+ txid
+ } else {
+ panic!("Unexpected event {event:?}");
+ };
+
+ let initiator_commitment_signed = get_htlc_update_msgs(&nodes[0], &node_id_1);
+ let acceptor_commitment_signed = get_htlc_update_msgs(&nodes[1], &node_id_0);
+
+ // Both sides are already in `AwaitingSignatures` when the earlier candidate confirms, so the
+ // advanced RBF signing round remains intact.
+ mine_transaction(&nodes[0], &first_splice_tx);
+ mine_transaction(&nodes[1], &first_splice_tx);
+ for node in &nodes {
+ assert!(node.node.get_and_clear_pending_msg_events().is_empty());
+ assert!(node.node.get_and_clear_pending_events().is_empty());
+ let details = node.node.list_channels()[0].splice_details.clone().unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert!(matches!(
+ details.candidates[1].status,
+ SpliceCandidateStatus::AwaitingSignatures { .. }
+ ));
+ let confirmed = details.confirmed_candidate.unwrap();
+ assert_eq!(confirmed.txid, first_splice_txid);
+ assert!(!confirmed.splice_locked_sent);
+ assert_eq!(get_monitor!(node, channel_id).get_funding_txo().txid, previous_funding_txid);
+ }
+
+ if early_peer_splice_locked {
+ // Inject the early peer lock synthetically because the test peer follows the same deferral
+ // policy.
+ nodes[0].node.handle_splice_locked(
+ node_id_1,
+ &msgs::SpliceLocked { channel_id, splice_txid: first_splice_txid },
+ );
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+ assert!(nodes[0].node.get_and_clear_pending_events().is_empty());
+ check_added_monitors(&nodes[0], 0);
+ }
+
+ nodes[1].node.handle_commitment_signed_batch_test(
+ node_id_0,
+ &initiator_commitment_signed.commitment_signed,
+ );
+ check_added_monitors(&nodes[1], 1);
+ let acceptor_tx_signatures =
+ get_event_msg!(nodes[1], MessageSendEvent::SendTxSignatures, node_id_0);
+
+ let reloaded_persister_0;
+ if reload {
+ let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode();
+ reload_node!(
+ nodes[0],
+ &nodes[0].node.encode(),
+ &[&encoded_monitor_0],
+ persister_0,
+ chain_monitor_0,
+ node_0
+ );
+ let encoded_monitor_1 = get_monitor!(nodes[1], channel_id).encode();
+ reload_node!(
+ nodes[1],
+ &nodes[1].node.encode(),
+ &[&encoded_monitor_1],
+ persister_1,
+ chain_monitor_1,
+ node_1
+ );
+ reloaded_persister_0 = Some(&persister_0);
+ } else {
+ reloaded_persister_0 = None;
+ }
+ if async_monitor {
+ match reloaded_persister_0 {
+ Some(persister) => persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress),
+ None => {
+ chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress)
+ },
+ }
+ }
+
+ let details = nodes[0].node.list_channels()[0].splice_details.clone().unwrap();
+ assert_eq!(
+ details.received_splice_locked_txid,
+ early_peer_splice_locked.then_some(first_splice_txid),
+ );
+ assert!(!details.confirmed_candidate.unwrap().splice_locked_sent);
+ assert_eq!(get_monitor!(nodes[0], channel_id).get_funding_txo().txid, previous_funding_txid);
+
+ if reload {
+ let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
+ reconnect_args.send_announcement_sigs = (true, true);
+ reconnect_args.send_interactive_tx_commit_sig = (true, false);
+ reconnect_args.send_interactive_tx_sigs = (true, false);
+ reconnect_nodes(reconnect_args);
+ } else {
+ nodes[0].node.handle_commitment_signed_batch_test(
+ node_id_1,
+ &acceptor_commitment_signed.commitment_signed,
+ );
+ nodes[0].node.handle_tx_signatures(node_id_1, &acceptor_tx_signatures);
+ }
+ check_added_monitors(&nodes[0], 1);
+
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+ assert!(nodes[0].node.get_and_clear_pending_events().is_empty());
+ check_added_monitors(&nodes[0], 0);
+
+ if async_monitor {
+ nodes[0].chain_monitor.complete_sole_pending_chan_update(&channel_id);
+ match reloaded_persister_0 {
+ Some(persister) => persister.set_update_ret(ChannelMonitorUpdateStatus::Completed),
+ None => chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed),
+ }
+ } else {
+ nodes[0].enable_channel_signer_op(&node_id_1, &channel_id, SignerOp::SignSpliceSharedInput);
+ nodes[0].node.signer_unblocked(None);
+ }
+
+ let initiator_tx_signatures =
+ get_event_msg!(nodes[0], MessageSendEvent::SendTxSignatures, node_id_1);
+ check_added_monitors(&nodes[0], 0);
+ assert_eq!(get_monitor!(nodes[0], channel_id).get_funding_txo().txid, previous_funding_txid);
+
+ let _ = get_event!(nodes[0], Event::SpliceNegotiated);
+
+ // A timer tick, rather than signing completion, releases the deferred lock and handles any
+ // resulting promotion.
+ let early_initiator_splice_locked = if early_peer_splice_locked {
+ nodes[0].node.timer_tick_occurred();
+ let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
+ assert_eq!(splice_locked.splice_txid, first_splice_txid);
+ check_added_monitors(&nodes[0], 1);
+ let _ = get_event!(nodes[0], Event::ChannelReady);
+ assert_eq!(get_monitor!(nodes[0], channel_id).get_funding_txo().txid, first_splice_txid);
+ Some(splice_locked)
+ } else {
+ None
+ };
+
+ // The final signatures complete the RBF round on the counterparty, but its deferred lock also
+ // remains withheld until its timer runs.
+ nodes[1].node.handle_tx_signatures(node_id_0, &initiator_tx_signatures);
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
+ check_added_monitors(&nodes[1], 0);
+ assert_eq!(get_monitor!(nodes[1], channel_id).get_funding_txo().txid, previous_funding_txid);
+
+ if let Some(initiator_splice_locked) = early_initiator_splice_locked {
+ // The initiator already promoted against the early peer lock. Deliver its real lock, then
+ // let the responder's timer send its own lock and promote in the same transition.
+ nodes[1].node.handle_splice_locked(node_id_0, &initiator_splice_locked);
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
+ check_added_monitors(&nodes[1], 0);
+
+ nodes[1].node.timer_tick_occurred();
+ let responder_splice_locked =
+ get_event_msg!(nodes[1], MessageSendEvent::SendSpliceLocked, node_id_0);
+ assert_eq!(responder_splice_locked.splice_txid, first_splice_txid);
+ check_added_monitors(&nodes[1], 1);
+ let _ = get_event!(nodes[1], Event::ChannelReady);
+ assert_eq!(get_monitor!(nodes[1], channel_id).get_funding_txo().txid, first_splice_txid);
+
+ // Handle the real duplicate of the synthetic early lock after the initiator has promoted.
+ nodes[0].node.handle_splice_locked(node_id_1, &responder_splice_locked);
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+ assert!(nodes[0].node.get_and_clear_pending_events().is_empty());
+ check_added_monitors(&nodes[0], 0);
+ } else {
+ // Reconnect, but leave both sides waiting for the counterparty's `channel_reestablish`.
+ nodes[0].node.peer_disconnected(node_id_1);
+ nodes[1].node.peer_disconnected(node_id_0);
+ connect_nodes(&nodes[0], &nodes[1]);
+ let reestablish_0 =
+ get_event_msg!(nodes[0], MessageSendEvent::SendChannelReestablish, node_id_1);
+ let reestablish_1 =
+ get_event_msg!(nodes[1], MessageSendEvent::SendChannelReestablish, node_id_0);
+ assert_ne!(
+ reestablish_0.my_current_funding_locked.as_ref().map(|funding| funding.txid),
+ Some(first_splice_txid),
+ );
+
+ // A transport-level connection is not enough to send channel messages. The timer records the
+ // lock internally, but it remains withheld until the channel reestablishment handshake.
+ nodes[0].node.timer_tick_occurred();
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+ assert!(nodes[0].node.get_and_clear_pending_events().is_empty());
+ check_added_monitors(&nodes[0], 0);
+
+ nodes[1].node.handle_channel_reestablish(node_id_0, &reestablish_0);
+ let _ = get_event_msg!(nodes[1], MessageSendEvent::SendChannelUpdate, node_id_0);
+
+ nodes[0].node.handle_channel_reestablish(node_id_1, &reestablish_1);
+ let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 2, "{msg_events:?}");
+ let initiator_splice_locked = match msg_events.remove(0) {
+ MessageSendEvent::SendSpliceLocked { msg, .. } => msg,
+ event => panic!("Unexpected event {event:?}"),
+ };
+ assert_eq!(initiator_splice_locked.splice_txid, first_splice_txid);
+ assert!(matches!(msg_events[0], MessageSendEvent::SendChannelUpdate { .. }));
+
+ // The initiator's lock is released exactly once after reestablishment. The responder records
+ // it, then its timer sends the reciprocal lock and promotes atomically.
+ nodes[1].node.handle_splice_locked(node_id_0, &initiator_splice_locked);
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
+ check_added_monitors(&nodes[1], 0);
+
+ nodes[1].node.timer_tick_occurred();
+ let responder_splice_locked =
+ get_event_msg!(nodes[1], MessageSendEvent::SendSpliceLocked, node_id_0);
+ assert_eq!(responder_splice_locked.splice_txid, first_splice_txid);
+ check_added_monitors(&nodes[1], 1);
+ let _ = get_event!(nodes[1], Event::ChannelReady);
+ assert_eq!(get_monitor!(nodes[1], channel_id).get_funding_txo().txid, first_splice_txid);
+
+ nodes[0].node.handle_splice_locked(node_id_1, &responder_splice_locked);
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+ let _ = get_event!(nodes[0], Event::ChannelReady);
+ check_added_monitors(&nodes[0], 1);
+ assert_eq!(get_monitor!(nodes[0], channel_id).get_funding_txo().txid, first_splice_txid);
+ }
+
+ connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
+ connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1);
+ let announcement_sigs_0 =
+ get_event_msg!(nodes[0], MessageSendEvent::SendAnnouncementSignatures, node_id_1);
+ let announcement_sigs_1 =
+ get_event_msg!(nodes[1], MessageSendEvent::SendAnnouncementSignatures, node_id_0);
+ nodes[0].node.handle_announcement_signatures(node_id_1, &announcement_sigs_1);
+ nodes[1].node.handle_announcement_signatures(node_id_0, &announcement_sigs_0);
+ for node in &nodes {
+ let _ = get_event_msg!(node, MessageSendEvent::BroadcastChannelAnnouncement);
+ assert!(node.node.get_and_clear_pending_events().is_empty());
+ node.chain_source.remove_watched_by_txid(previous_funding_txid);
+ node.chain_source.remove_watched_by_txid(rbf_txid);
+ }
+ send_payment(&nodes[0], &[&nodes[1]], 1_000_000);
+}
+
+#[test]
+fn test_splice_locked_deferred_during_rbf_signing() {
+ do_test_splice_locked_deferred_during_rbf_signing(true, true, false, true);
+ do_test_splice_locked_deferred_during_rbf_signing(false, false, true, false);
+}
+
#[test]
fn test_splice_rbf_recontributes_feerate_too_high() {
// When the counterparty RBFs at a feerate too high for our prior contribution,Why this scored 44/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.