Test propose channel splice while disconnected
What changed, and why it matters
This commit only adds a new test case and a small test helper. It does not change production code, so it cannot introduce a runtime security vulnerability. The test exercises a specific Lightning protocol scenario: proposing a channel splice while the peer is temporarily disconnected, including node reload and zero-conf channel variants.
No action required; this is a test-only change. Reviewers may optionally confirm the new test covers the intended edge case and passes in CI.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff adds test_propose_splice_while_disconnected and do_test_propose_splice_while_disconnected to lightning/src/ln/splicing_tests.rs, plus a parameterized helper open_zero_conf_channel_with_value in lightning/src/ln/functional_test_utils.rs. The test verifies that both peers can queue a splice proposal while disconnected, that the quiescence tie-breaker picks one initiator on reconnect, and that the losing peer retries after the first splice locks. No library behavior is modified; only test infrastructure is extended.
Changed components
lightning/src/ln/splicing_tests.rslightning/src/ln/functional_test_utils.rsInspect captured patch +323 / −2
diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs
index 3d7d69c..d8e59dd 100644
--- a/lightning/src/ln/functional_test_utils.rs
+++ b/lightning/src/ln/functional_test_utils.rs
@@ -1572,6 +1572,14 @@ pub fn sign_funding_transaction<'a, 'b, 'c>(
pub fn open_zero_conf_channel<'a, 'b, 'c, 'd>(
initiator: &'a Node<'b, 'c, 'd>, receiver: &'a Node<'b, 'c, 'd>,
initiator_config: Option<UserConfig>,
+) -> (bitcoin::Transaction, ChannelId) {
+ open_zero_conf_channel_with_value(initiator, receiver, initiator_config, 100_000, 10_001)
+}
+
+// Receiver must have been initialized with manually_accept_inbound_channels set to true.
+pub fn open_zero_conf_channel_with_value<'a, 'b, 'c, 'd>(
+ initiator: &'a Node<'b, 'c, 'd>, receiver: &'a Node<'b, 'c, 'd>,
+ initiator_config: Option<UserConfig>, channel_value_sat: u64, push_msat: u64,
) -> (bitcoin::Transaction, ChannelId) {
let initiator_channels = initiator.node.list_usable_channels().len();
let receiver_channels = receiver.node.list_usable_channels().len();
@@ -1581,7 +1589,7 @@ pub fn open_zero_conf_channel<'a, 'b, 'c, 'd>(
initiator
.node
- .create_channel(receiver_node_id, 100_000, 10_001, 42, None, initiator_config)
+ .create_channel(receiver_node_id, channel_value_sat, push_msat, 42, None, initiator_config)
.unwrap();
let open_channel =
get_event_msg!(initiator, MessageSendEvent::SendOpenChannel, receiver_node_id);
@@ -1610,7 +1618,7 @@ pub fn open_zero_conf_channel<'a, 'b, 'c, 'd>(
initiator.node.handle_accept_channel(receiver_node_id, &accept_channel);
let (temporary_channel_id, tx, _) =
- create_funding_transaction(&initiator, &receiver_node_id, 100_000, 42);
+ create_funding_transaction(&initiator, &receiver_node_id, channel_value_sat, 42);
initiator
.node
.funding_transaction_generated(temporary_channel_id, receiver_node_id, tx.clone())
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index deb76a7..db34969 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -1178,6 +1178,319 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) {
.remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script);
}
+#[test]
+fn test_propose_splice_while_disconnected() {
+ do_test_propose_splice_while_disconnected(false, false);
+ do_test_propose_splice_while_disconnected(false, true);
+ do_test_propose_splice_while_disconnected(true, false);
+ do_test_propose_splice_while_disconnected(true, true);
+}
+
+fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) {
+ // Test that both nodes are able to propose a splice while the counterparty is disconnected, and
+ // whoever doesn't go first due to the quiescence tie-breaker, will retry their splice after the
+ // first one becomes locked.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let (persister_0a, persister_0b, persister_1a, persister_1b);
+ let (chain_monitor_0a, chain_monitor_0b, chain_monitor_1a, chain_monitor_1b);
+ let mut config = test_default_channel_config();
+ if use_0conf {
+ config.manually_accept_inbound_channels = true;
+ config.channel_handshake_limits.trust_own_funding_0conf = true;
+ }
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]);
+ let (node_0a, node_0b, node_1a, node_1b);
+ 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 initial_channel_value_sat = 1_000_000;
+ let push_msat = initial_channel_value_sat / 2 * 1000;
+ let channel_id = if use_0conf {
+ let (funding_tx, channel_id) = open_zero_conf_channel_with_value(
+ &nodes[0],
+ &nodes[1],
+ None,
+ initial_channel_value_sat,
+ push_msat,
+ );
+ mine_transaction(&nodes[0], &funding_tx);
+ mine_transaction(&nodes[1], &funding_tx);
+ channel_id
+ } else {
+ let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(
+ &nodes,
+ 0,
+ 1,
+ initial_channel_value_sat,
+ push_msat,
+ );
+ channel_id
+ };
+
+ // Start with the nodes disconnected, and have each one attempt a splice.
+ nodes[0].node.peer_disconnected(node_id_1);
+ nodes[1].node.peer_disconnected(node_id_0);
+
+ let splice_out_sat = initial_channel_value_sat / 4;
+ let node_0_contribution = SpliceContribution::SpliceOut {
+ outputs: vec![TxOut {
+ value: Amount::from_sat(splice_out_sat),
+ script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(),
+ }],
+ };
+ nodes[0]
+ .node
+ .splice_channel(
+ &channel_id,
+ &node_id_1,
+ node_0_contribution.clone(),
+ FEERATE_FLOOR_SATS_PER_KW,
+ None,
+ )
+ .unwrap();
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ let node_1_contribution = SpliceContribution::SpliceOut {
+ outputs: vec![TxOut {
+ value: Amount::from_sat(splice_out_sat),
+ script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(),
+ }],
+ };
+ nodes[1]
+ .node
+ .splice_channel(
+ &channel_id,
+ &node_id_0,
+ node_1_contribution.clone(),
+ FEERATE_FLOOR_SATS_PER_KW,
+ None,
+ )
+ .unwrap();
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+
+ 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_0a,
+ chain_monitor_0a,
+ node_0a
+ );
+ let encoded_monitor_1 = get_monitor!(nodes[1], channel_id).encode();
+ reload_node!(
+ nodes[1],
+ nodes[1].node.encode(),
+ &[&encoded_monitor_1],
+ persister_1a,
+ chain_monitor_1a,
+ node_1a
+ );
+ }
+
+ // Reconnect the nodes. Both nodes should attempt quiescence as the initiator, but only one will
+ // be it via the tie-breaker.
+ let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
+ reconnect_args.send_channel_ready = (true, true);
+ if !use_0conf {
+ reconnect_args.send_announcement_sigs = (true, true);
+ }
+ reconnect_args.send_stfu = (true, true);
+ reconnect_nodes(reconnect_args);
+ let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+
+ let (prev_funding_outpoint, prev_funding_script) = nodes[0]
+ .chain_monitor
+ .chain_monitor
+ .get_monitor(channel_id)
+ .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script()))
+ .unwrap();
+
+ // Negotiate the first splice to completion.
+ let initial_commit_sig = {
+ nodes[1].node.handle_splice_init(node_id_0, &splice_init);
+ let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0);
+ nodes[0].node.handle_splice_ack(node_id_1, &splice_ack);
+ let new_funding_script = chan_utils::make_funding_redeemscript(
+ &splice_init.funding_pubkey,
+ &splice_ack.funding_pubkey,
+ )
+ .to_p2wsh();
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ node_0_contribution,
+ new_funding_script,
+ )
+ };
+ let (splice_tx, splice_locked) =
+ sign_interactive_funding_tx(&nodes[0], &nodes[1], initial_commit_sig, use_0conf);
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+
+ let splice_locked = if use_0conf {
+ let (splice_locked, for_node_id) = splice_locked.unwrap();
+ assert_eq!(for_node_id, node_id_1);
+ splice_locked
+ } else {
+ assert!(splice_locked.is_none());
+
+ mine_transaction(&nodes[0], &splice_tx);
+ mine_transaction(&nodes[1], &splice_tx);
+
+ // Mine enough blocks for the first splice to become locked.
+ connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
+ connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1);
+
+ get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1)
+ };
+ nodes[1].node.handle_splice_locked(node_id_0, &splice_locked);
+
+ // We should see the node which lost the tie-breaker attempt their splice now by first
+ // negotiating quiescence, but their `stfu` won't be sent until after another reconnection.
+ let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), if use_0conf { 2 } else { 3 }, "{msg_events:?}");
+ if let MessageSendEvent::SendSpliceLocked { ref msg, .. } = &msg_events[0] {
+ nodes[0].node.handle_splice_locked(node_id_1, msg);
+ if use_0conf {
+ // TODO(splicing): Revisit splice transaction rebroadcasts.
+ let txn_0 = nodes[0].tx_broadcaster.txn_broadcast();
+ assert_eq!(txn_0.len(), 1);
+ assert_eq!(&txn_0[0], &splice_tx);
+ mine_transaction(&nodes[0], &splice_tx);
+ mine_transaction(&nodes[1], &splice_tx);
+ }
+ } else {
+ panic!("Unexpected event {:?}", &msg_events[0]);
+ }
+ if !use_0conf {
+ if let MessageSendEvent::SendAnnouncementSignatures { ref msg, .. } = &msg_events[1] {
+ nodes[0].node.handle_announcement_signatures(node_id_1, msg);
+ } else {
+ panic!("Unexpected event {:?}", &msg_events[1]);
+ }
+ }
+ assert!(matches!(
+ &msg_events[if use_0conf { 1 } else { 2 }],
+ MessageSendEvent::SendStfu { .. }
+ ));
+
+ let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), if use_0conf { 0 } else { 2 }, "{msg_events:?}");
+ if !use_0conf {
+ if let MessageSendEvent::SendAnnouncementSignatures { ref msg, .. } = &msg_events[0] {
+ nodes[1].node.handle_announcement_signatures(node_id_0, msg);
+ } else {
+ panic!("Unexpected event {:?}", &msg_events[1]);
+ }
+ assert!(matches!(&msg_events[1], MessageSendEvent::BroadcastChannelAnnouncement { .. }));
+ }
+
+ let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), if use_0conf { 0 } else { 1 }, "{msg_events:?}");
+ if !use_0conf {
+ assert!(matches!(&msg_events[0], MessageSendEvent::BroadcastChannelAnnouncement { .. }));
+ }
+
+ expect_channel_ready_event(&nodes[0], &node_id_1);
+ check_added_monitors(&nodes[0], 1);
+ expect_channel_ready_event(&nodes[1], &node_id_0);
+ check_added_monitors(&nodes[1], 1);
+
+ // Remove the corresponding outputs and transactions the chain source is watching for the
+ // old funding as it is no longer being tracked.
+ nodes[0]
+ .chain_source
+ .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script.clone());
+ nodes[1]
+ .chain_source
+ .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script);
+
+ // Reconnect the nodes. This should trigger the node which lost the tie-breaker to resend `stfu`
+ // for their splice attempt.
+ 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_0b,
+ chain_monitor_0b,
+ node_0b
+ );
+ let encoded_monitor_1 = get_monitor!(nodes[1], channel_id).encode();
+ reload_node!(
+ nodes[1],
+ nodes[1].node.encode(),
+ &[&encoded_monitor_1],
+ persister_1b,
+ chain_monitor_1b,
+ node_1b
+ );
+ } else {
+ nodes[0].node.peer_disconnected(node_id_1);
+ nodes[1].node.peer_disconnected(node_id_0);
+ }
+ let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
+ if !use_0conf {
+ reconnect_args.send_announcement_sigs = (true, true);
+ }
+ reconnect_args.send_stfu = (true, false);
+ reconnect_nodes(reconnect_args);
+
+ // Drive the second splice to completion.
+ let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 1, "{msg_events:?}");
+ if let MessageSendEvent::SendStfu { ref msg, .. } = msg_events[0] {
+ nodes[1].node.handle_stfu(node_id_0, msg);
+ } else {
+ panic!("Unexpected event {:?}", &msg_events[0]);
+ }
+
+ let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0);
+ let initial_commit_sig = {
+ nodes[0].node.handle_splice_init(node_id_1, &splice_init);
+ let splice_ack = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceAck, node_id_1);
+ nodes[1].node.handle_splice_ack(node_id_0, &splice_ack);
+ let new_funding_script = chan_utils::make_funding_redeemscript(
+ &splice_init.funding_pubkey,
+ &splice_ack.funding_pubkey,
+ )
+ .to_p2wsh();
+ complete_interactive_funding_negotiation(
+ &nodes[1],
+ &nodes[0],
+ channel_id,
+ node_1_contribution,
+ new_funding_script,
+ )
+ };
+ let (splice_tx, splice_locked) =
+ sign_interactive_funding_tx(&nodes[1], &nodes[0], initial_commit_sig, use_0conf);
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+
+ if use_0conf {
+ let (splice_locked, for_node_id) = splice_locked.unwrap();
+ assert_eq!(for_node_id, node_id_0);
+ lock_splice(&nodes[1], &nodes[0], &splice_locked, true);
+ } else {
+ assert!(splice_locked.is_none());
+ mine_transaction(&nodes[0], &splice_tx);
+ mine_transaction(&nodes[1], &splice_tx);
+ lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1);
+ }
+
+ // Sanity check that we can still make a test payment.
+ send_payment(&nodes[0], &[&nodes[1]], 1_000_000);
+}
+
#[test]
fn disconnect_on_unexpected_interactive_tx_message() {
let chanmon_cfgs = create_chanmon_cfgs(2);
Why this scored 15/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.