Add cross-version tests for queued splice failure events
What changed, and why it matters
This commit only adds new regression tests for upgrading and downgrading Lightning node state across software versions. It does not change any production code. The tests verify that a splice contribution stuck waiting for network quietness is handled safely when moving between LDK 0.2 and current versions: an older 0.2 node reading newer state gets a failure event so the user can reclaim funds, while a newer node reading an older 0.2 state simply drops the stale queued splice with no harmful effects. There is no vulnerability being introduced or fixed here.
No action required; this is a test-only change. Reviewers may want to confirm the test assertions match the intended backward-compatibility behavior described in the commit message.
Security signals we found
Cross-version state migration test coverage for queued splice contributions
Verification that downgraded nodes surface reclaimable outputs via SpliceFailed event
Verification that legacy queued splice state is dropped rather than misinterpreted on upgrade
Evidence from the diff
The diff adds two test functions to lightning-tests/src/upgrade_downgrade_tests.rs. downgrade_queued_splice_contribution_fails_on_0_2 checks that a current ChannelManager with a queued splice contribution (waiting on quiescence) serializes synthesized failure events that a downgraded 0.2 node surfaces as SpliceFailed carrying the contributed outputs. upgrade_queued_splice_contribution_from_0_2 checks that a 0.2-persisted queued splice in the legacy SpliceInstructions form is ignored by current code, loading the channel with splice_details cleared and no pending events. No library logic is modified; only test coverage is added.
Changed components
lightning-tests/src/upgrade_downgrade_tests.rsInspect captured patch +114 / −0
### lightning-tests/src/upgrade_downgrade_tests.rs
@@ -1261,6 +1261,120 @@ fn downgrade_mid_splice_negotiation_to_0_2() {
assert!(mgr_1.get_and_clear_pending_events().is_empty());
}
+#[test]
+fn downgrade_queued_splice_contribution_fails_on_0_2() {
+ // A contribution committed via `funding_contributed` that is still queued waiting on
+ // quiescence is not persisted, so a ChannelManager written with one embeds synthesized
+ // `SpliceNegotiationFailed` and `DiscardFunding` events. A downgraded 0.2 node skips the
+ // `DiscardFunding` and surfaces the failure as `SpliceFailed` with the contributed outputs,
+ // letting the user reclaim them.
+ let (node_0_ser, mon_0_ser, channel_id, outputs);
+ {
+ 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();
+ channel_id =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000).2;
+
+ // Commit to a contribution while disconnected so it sits queued waiting on quiescence when
+ // the node is persisted.
+ nodes[0].node.peer_disconnected(node_id_1);
+ nodes[1].node.peer_disconnected(node_id_0);
+ outputs = vec![TxOut {
+ value: Amount::from_sat(1_000),
+ script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(),
+ }];
+ initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()).unwrap();
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ node_0_ser = nodes[0].node.encode();
+ mon_0_ser = get_monitor!(nodes[0], channel_id).encode();
+ }
+
+ let mut chanmon_cfgs = lightning_0_2_utils::create_chanmon_cfgs(2);
+ chanmon_cfgs[0].keys_manager.disable_all_state_policy_checks = true;
+ let node_cfgs = lightning_0_2_utils::create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = lightning_0_2_utils::create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = lightning_0_2_utils::create_network(2, &node_cfgs, &node_chanmgrs);
+ let mut config = lightning_0_2_utils::test_default_channel_config();
+ // The current side uses the anchors channel type by default; 0.2 only accepts a channel whose
+ // type it advertises support for.
+ config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true;
+ let mgr = lightning_0_2_utils::_reload_node(&nodes[0], config, &node_0_ser, &[&mon_0_ser[..]]);
+ assert_eq!(mgr.list_channels().len(), 1);
+ let events = mgr.get_and_clear_pending_events();
+ assert_eq!(events.len(), 1, "{events:?}");
+ match &events[0] {
+ Event_0_2::SpliceFailed {
+ channel_id: chan_id,
+ contributed_inputs,
+ contributed_outputs,
+ ..
+ } => {
+ assert_eq!(chan_id.0, channel_id.0);
+ assert!(contributed_inputs.is_empty());
+ assert_eq!(*contributed_outputs, outputs);
+ },
+ ev => panic!("Expected SpliceFailed, got {ev:?}"),
+ }
+}
+
+#[test]
+fn upgrade_queued_splice_contribution_from_0_2() {
+ // A splice queued by LDK 0.2 while waiting on quiescence was persisted in a legacy form
+ // (SpliceInstructions) that current versions do not read: the channel loads and the queued
+ // intent is dropped.
+ let (node_0_ser, mon_0_ser, chan_id_bytes);
+ {
+ let chanmon_cfgs = lightning_0_2_utils::create_chanmon_cfgs(2);
+ let node_cfgs = lightning_0_2_utils::create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = lightning_0_2_utils::create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = lightning_0_2_utils::create_network(2, &node_cfgs, &node_chanmgrs);
+ let channel_id = lightning_0_2_utils::create_announced_chan_between_nodes_with_value(
+ &nodes, 0, 1, 100_000, 50_000_000,
+ )
+ .2;
+ chan_id_bytes = channel_id.0;
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+ nodes[0].node.peer_disconnected(node_id_1);
+ nodes[1].node.peer_disconnected(node_id_0);
+
+ // Propose a splice while disconnected so 0.2 persists the queued QuiescentAction.
+ let contribution = lightning_0_2::ln::funding::SpliceContribution::SpliceOut {
+ outputs: vec![bitcoin::TxOut {
+ value: bitcoin::Amount::from_sat(1_000),
+ script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(),
+ }],
+ };
+ nodes[0].node.splice_channel(&channel_id, &node_id_1, contribution, 1024, None).unwrap();
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ node_0_ser = nodes[0].node.encode();
+ mon_0_ser = get_monitor_0_2!(nodes[0], channel_id).encode();
+ }
+
+ let mut chanmon_cfgs = create_chanmon_cfgs(2);
+ chanmon_cfgs[0].keys_manager.disable_all_state_policy_checks = true;
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let (persister, chain_mon, new_node);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+ let config = test_default_channel_config();
+ reload_node!(nodes[0], config, &node_0_ser, &[&mon_0_ser[..]], persister, chain_mon, new_node);
+
+ // The 0.2 queued splice is dropped without generating any events; no splice is pending.
+ let channel_id = ChannelId(chan_id_bytes);
+ let channels = nodes[0].node.list_channels();
+ let details = channels.iter().find(|c| c.channel_id == channel_id).unwrap();
+ assert!(details.splice_details.is_none());
+ assert!(nodes[0].node.get_and_clear_pending_events().is_empty());
+}
+
#[test]
fn splice_inherited_across_0_2_checks_funding_transaction_for_overlap() {
// Negotiate a contributory splice on current, downgrade to LDK 0.2, then upgrade back. LDK 0.2Why this scored 22/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.