Replace splice failure wire tests with a 0.2 downgrade test
What changed, and why it matters
This commit is a test-only cleanup in the Lightning Dev Kit (LDK) Rust codebase. It removes low-level byte-level tests for splice failure events and replaces them with a single cross-version test that actually loads a 0.2 node with serialized state from current code. There is no change to production logic, no fix for an active vulnerability, and no new attack surface. The work is defensive testing to ensure future serialization changes stay compatible with older LDK versions.
No security action required. This is a routine test-quality refactor. Reviewers may want to confirm the new downgrade test is run in CI and that the removed tests are not needed elsewhere.
Security signals we found
Cross-version serialization compatibility test added
Removal of byte-level tests that could not detect real 0.2 mismatches
No production code changes
No vulnerability fix or advisory language in commit
References prior issue #4919 for context on DiscardFunding/SpliceFailed event handling
Evidence from the diff
The commit removes hand-crafted wire-encoding tests in lightning/src/events/mod.rs and a test-only FundingContribution::new_for_test constructor in lightning/src/ln/funding.rs. It adds one integration test in lightning-tests/src/upgrade_downgrade_tests.rs that exercises a real downgrade path: current LDK serializes a channel manager containing a partial splice failure, then LDK 0.2 deserializes it and verifies the SpliceFailed event exposes exactly the released outputs. The production serialization code is unchanged; only test coverage is refactored to use actual 0.2 deserialization instead of a re-implementation of it with current types.
Changed components
lightning-tests/src/upgrade_downgrade_tests.rslightning/src/events/mod.rslightning/src/ln/funding.rsInspect captured patch +95 / −266
### lightning-tests/src/upgrade_downgrade_tests.rs
@@ -54,6 +54,7 @@ use lightning_0_0_125::routing::router as router_0_0_125;
use lightning_0_0_125::util::ser::Writeable as _;
use lightning::blinded_path::message::NextMessageHop;
+use lightning::chain::chaininterface::FEERATE_FLOOR_SATS_PER_KW;
use lightning::chain::channelmonitor::{ANTI_REORG_DELAY, HTLC_FAIL_BACK_BUFFER};
use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType, NegotiationFailureReason};
use lightning::ln::channel_state::SpliceCandidateStatus;
@@ -1375,6 +1376,100 @@ fn upgrade_queued_splice_contribution_from_0_2() {
assert!(nodes[0].node.get_and_clear_pending_events().is_empty());
}
+#[test]
+fn downgrade_overlapping_splice_failure_to_0_2() {
+ // A contribution reusing an output already committed to a pending splice fails, releasing only
+ // the outputs unique to it; the rest stay committed to that splice. Check that a downgraded 0.2
+ // node is told about exactly the released ones, since it cannot read the `DiscardFunding`
+ // carrying them and has to rely on `SpliceFailed` instead (see #4919).
+ let (node_0_ser, mon_0_ser, released_script, chan_id_bytes);
+ {
+ 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_1 = nodes[1].node.get_our_node_id();
+ let channel_id = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0).2;
+ chan_id_bytes = channel_id.0;
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let committed_output = TxOut {
+ value: Amount::from_sat(1_000),
+ script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(),
+ };
+ let contribution = do_initiate_splice_in_and_out(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ added_value,
+ vec![committed_output.clone()],
+ );
+ let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
+ mine_transaction(&nodes[0], &splice_tx);
+ mine_transaction(&nodes[1], &splice_tx);
+
+ // Send `splice_locked` without receiving the counterparty's, leaving the splice pending
+ // but no longer RBF-able.
+ connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
+ let _ = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
+ nodes[0].node.get_and_clear_pending_events();
+ nodes[0].node.get_and_clear_pending_msg_events();
+
+ // As the splice cannot be RBF'd, a contribution reusing its output is refused with only
+ // the additional output released.
+ let script_pubkey = nodes[1].wallet_source.get_change_script().unwrap();
+ released_script = script_pubkey.clone();
+ let released_output = TxOut { value: Amount::from_sat(1_000), script_pubkey };
+ let floor_feerate = bitcoin::FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate);
+ let contribution = funding_template
+ .without_prior_contribution(feerate, bitcoin::FeeRate::MAX)
+ .add_outputs(vec![committed_output, released_output])
+ .build()
+ .unwrap();
+ assert_eq!(
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None),
+ Err(APIError::APIMisuseError {
+ err: format!("Channel {} cannot accept funding contribution", channel_id),
+ })
+ );
+
+ node_0_ser = nodes[0].node.encode();
+ mon_0_ser = get_monitor!(nodes[0], channel_id).encode();
+
+ // The failure events have to remain in the serialization above for the 0.2 read below, so
+ // only drain them here, satisfying the check for unhandled events as the node is dropped.
+ nodes[0].node.get_and_clear_pending_events();
+ }
+
+ 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_0 =
+ lightning_0_2_utils::_reload_node(&nodes[0], config, &node_0_ser, &[&mon_0_ser[..]]);
+ assert_eq!(mgr_0.list_channels().len(), 1);
+ let events = mgr_0.get_and_clear_pending_events();
+ assert_eq!(events.len(), 1, "{events:?}");
+ match &events[0] {
+ Event_0_2::SpliceFailed { channel_id, contributed_inputs, contributed_outputs, .. } => {
+ assert_eq!(channel_id.0, chan_id_bytes);
+ assert!(contributed_inputs.is_empty());
+ assert_eq!(contributed_outputs.len(), 1, "{contributed_outputs:?}");
+ assert_eq!(contributed_outputs[0].script_pubkey, released_script);
+ },
+ ev => panic!("Expected SpliceFailed, got {ev:?}"),
+ }
+}
+
#[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.2
### lightning/src/events/mod.rs
@@ -103,8 +103,6 @@ impl_ser_tlv_based_enum!(FundingInfo,
/// The funding contribution from a failed splice negotiation round, see
/// [`Event::SpliceNegotiationFailed`].
-///
-/// This has no serialization of its own; it is written and read as part of the event.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FailedSpliceContribution {
/// UTXOs spent as inputs contributed to the failed round that were released by the failure,
@@ -3384,39 +3382,6 @@ impl MaybeReadable for Event {
#[cfg(test)]
mod tests {
use super::*;
- use crate::util::wallet_utils::ConfirmedUtxo;
-
- fn test_funding_contribution() -> (FundingContribution, Vec<OutPoint>, Vec<bitcoin::TxOut>) {
- let input_prevtx = Transaction {
- version: bitcoin::transaction::Version::TWO,
- lock_time: bitcoin::absolute::LockTime::ZERO,
- input: vec![],
- output: vec![bitcoin::TxOut {
- value: bitcoin::Amount::from_sat(50_000),
- script_pubkey: ScriptBuf::new_p2wpkh(
- &bitcoin::WPubkeyHash::from_slice(&[6; 20]).unwrap(),
- ),
- }],
- };
- let expected_inputs = vec![OutPoint { txid: input_prevtx.compute_txid(), vout: 0 }];
- let inputs = vec![ConfirmedUtxo::new_p2wpkh(input_prevtx, 0).unwrap()];
- let outputs = vec![bitcoin::TxOut {
- value: bitcoin::Amount::from_sat(10_000),
- script_pubkey: ScriptBuf::new_p2wpkh(
- &bitcoin::WPubkeyHash::from_slice(&[7; 20]).unwrap(),
- ),
- }];
- let change_output = bitcoin::TxOut {
- value: bitcoin::Amount::from_sat(30_000),
- script_pubkey: ScriptBuf::new_p2wpkh(
- &bitcoin::WPubkeyHash::from_slice(&[8; 20]).unwrap(),
- ),
- };
- let mut expected_outputs = outputs.clone();
- expected_outputs.push(change_output.clone());
- let contribution = FundingContribution::new_for_test(inputs, outputs, Some(change_output));
- (contribution, expected_inputs, expected_outputs)
- }
#[test]
fn legacy_payment_forwarded_preserves_unknown_inbound_htlc_amount() {
@@ -3455,219 +3420,6 @@ mod tests {
_ => panic!("expected PaymentForwarded event"),
}
}
-
- #[test]
- fn legacy_splice_failed_event_read() {
- let expected_channel_id = ChannelId::from_bytes([2; 32]);
- let secp_ctx = bitcoin::secp256k1::Secp256k1::new();
- let secret_key = bitcoin::secp256k1::SecretKey::from_slice(&[42; 32]).unwrap();
- let expected_node_id = PublicKey::from_secret_key(&secp_ctx, &secret_key);
-
- // A 0.2-serialized `SpliceFailed` event, which wrote `abandoned_funding_txo`,
- // `contributed_inputs`, and `contributed_outputs` at types 9, 11, and 13.
- let mut tlvs = vec![1, 32]; // channel_id
- tlvs.extend_from_slice(&[2; 32]);
- tlvs.extend_from_slice(&[5, 16]); // user_channel_id
- tlvs.extend_from_slice(&786u128.to_be_bytes());
- tlvs.extend_from_slice(&[7, 33]); // counterparty_node_id
- tlvs.extend_from_slice(&expected_node_id.serialize());
- tlvs.extend_from_slice(&[9, 36]); // abandoned_funding_txo
- tlvs.extend_from_slice(&[3; 32]);
- tlvs.extend_from_slice(&[0, 0, 0, 1]);
- tlvs.extend_from_slice(&[11, 72]); // contributed_inputs: two outpoints
- tlvs.extend_from_slice(&[4; 32]);
- tlvs.extend_from_slice(&[0, 0, 0, 2]);
- tlvs.extend_from_slice(&[5; 32]);
- tlvs.extend_from_slice(&[0, 0, 0, 3]);
- tlvs.extend_from_slice(&[13, 31]); // contributed_outputs: one 546-sat P2WPKH output
- tlvs.extend_from_slice(&546u64.to_le_bytes());
- tlvs.push(22); // script length
- tlvs.extend_from_slice(&[0, 20]); // OP_0 OP_PUSHBYTES_20
- tlvs.extend_from_slice(&[6; 20]);
-
- let mut encoded_legacy_event = vec![52, tlvs.len() as u8];
- encoded_legacy_event.extend_from_slice(&tlvs);
-
- match Event::read(&mut &encoded_legacy_event[..]).unwrap().unwrap() {
- Event::SpliceNegotiationFailed {
- channel_id,
- user_channel_id,
- counterparty_node_id,
- reason,
- contribution,
- } => {
- assert_eq!(channel_id, expected_channel_id);
- assert_eq!(user_channel_id, 786);
- assert_eq!(counterparty_node_id, expected_node_id);
- assert_eq!(reason, NegotiationFailureReason::Unknown);
- assert_eq!(contribution, None);
- },
- _ => panic!("expected SpliceNegotiationFailed event"),
- }
- }
-
- #[test]
- fn splice_negotiation_failed_event_read_by_0_2() -> Result<(), msgs::DecodeError> {
- do_splice_negotiation_failed_event_read_by_0_2(None, Vec::new(), Vec::new())
- }
-
- #[test]
- fn splice_negotiation_failed_event_contribution_read_by_0_2() -> Result<(), msgs::DecodeError> {
- let (contribution, expected_inputs, expected_outputs) = test_funding_contribution();
- let contribution = FailedSpliceContribution {
- contributed_inputs: expected_inputs.clone(),
- contributed_outputs: expected_outputs.clone(),
- contribution,
- };
- do_splice_negotiation_failed_event_read_by_0_2(
- Some(contribution),
- expected_inputs,
- expected_outputs,
- )
- }
-
- #[test]
- fn splice_negotiation_failed_event_overlapping_contribution_read_by_0_2(
- ) -> Result<(), msgs::DecodeError> {
- // Only the inputs and outputs released by the failure are written at types 11 and 13, not
- // the contribution's full sets, which may include some still committed to an existing
- // splice attempt.
- let (contribution, _, outputs) = test_funding_contribution();
- let expected_outputs = vec![outputs[0].clone()];
- let contribution = FailedSpliceContribution {
- contributed_inputs: Vec::new(),
- contributed_outputs: expected_outputs.clone(),
- contribution,
- };
- do_splice_negotiation_failed_event_read_by_0_2(
- Some(contribution),
- Vec::new(),
- expected_outputs,
- )
- }
-
- fn do_splice_negotiation_failed_event_read_by_0_2(
- contribution: Option<FailedSpliceContribution>, expected_inputs: Vec<OutPoint>,
- expected_outputs: Vec<bitcoin::TxOut>,
- ) -> Result<(), msgs::DecodeError> {
- let expected_channel_id = ChannelId::from_bytes([2; 32]);
- let secp_ctx = bitcoin::secp256k1::Secp256k1::new();
- let secret_key = bitcoin::secp256k1::SecretKey::from_slice(&[42; 32]).unwrap();
- let expected_node_id = PublicKey::from_secret_key(&secp_ctx, &secret_key);
-
- let event = Event::SpliceNegotiationFailed {
- channel_id: expected_channel_id,
- user_channel_id: 786,
- counterparty_node_id: expected_node_id,
- reason: NegotiationFailureReason::PeerDisconnected,
- contribution,
- };
- let encoded = event.encode();
- let mut cursor = &encoded[..];
- let event_type: u8 = Readable::read(&mut cursor)?;
- assert_eq!(event_type, 52);
-
- // Mirrors the 0.2 read for event type 52, which must not error on newly written events.
- let reader = &mut cursor;
- _init_and_read_len_prefixed_tlv_fields!(reader, {
- (1, channel_id, required),
- (3, channel_type, option),
- (5, user_channel_id, required),
- (7, counterparty_node_id, required),
- (9, abandoned_funding_txo, option),
- (11, contributed_inputs, optional_vec),
- (13, contributed_outputs, optional_vec),
- });
- assert!(cursor.is_empty());
-
- let channel_id: ChannelId = channel_id.0.unwrap();
- assert_eq!(channel_id, expected_channel_id);
- let channel_type: Option<ChannelTypeFeatures> = channel_type;
- assert_eq!(channel_type, None);
- let user_channel_id: u128 = user_channel_id.0.unwrap();
- assert_eq!(user_channel_id, 786);
- let counterparty_node_id: PublicKey = counterparty_node_id.0.unwrap();
- assert_eq!(counterparty_node_id, expected_node_id);
- let abandoned_funding_txo: Option<OutPoint> = abandoned_funding_txo;
- assert_eq!(abandoned_funding_txo, None);
- let contributed_inputs: Option<Vec<OutPoint>> = contributed_inputs;
- assert_eq!(contributed_inputs.unwrap(), expected_inputs);
- let contributed_outputs: Option<Vec<bitcoin::TxOut>> = contributed_outputs;
- assert_eq!(contributed_outputs.unwrap(), expected_outputs);
-
- Ok(())
- }
-
- #[test]
- fn splice_negotiation_failed_event_round_trip() {
- let secp_ctx = bitcoin::secp256k1::Secp256k1::new();
- let secret_key = bitcoin::secp256k1::SecretKey::from_slice(&[42; 32]).unwrap();
- let (contribution, inputs, outputs) = test_funding_contribution();
- let full_contribution = FailedSpliceContribution {
- contributed_inputs: inputs,
- contributed_outputs: outputs.clone(),
- contribution: contribution.clone(),
- };
- // The contributed inputs and outputs must round trip independently of the contribution's
- // own sets since some may still be committed to an existing splice attempt.
- let overlapping_contribution = FailedSpliceContribution {
- contributed_inputs: Vec::new(),
- contributed_outputs: vec![outputs[0].clone()],
- contribution,
- };
- for contribution in [None, Some(full_contribution), Some(overlapping_contribution)] {
- let event = Event::SpliceNegotiationFailed {
- channel_id: ChannelId::from_bytes([2; 32]),
- user_channel_id: 786,
- counterparty_node_id: PublicKey::from_secret_key(&secp_ctx, &secret_key),
- reason: NegotiationFailureReason::CounterpartyAborted {
- msg: UntrustedString("hodl".to_owned()),
- },
- contribution,
- };
- let encoded = event.encode();
- let decoded = Event::read(&mut &encoded[..]).unwrap().unwrap();
- assert_eq!(event, decoded);
- }
- }
-
- #[test]
- fn discard_funding_event_type_depends_on_funding_info() {
- let channel_id = ChannelId::from_bytes([2; 32]);
-
- // `FundingInfo::Contribution` is written under an odd event type, which 0.2 skips as it
- // cannot read the enum variant.
- let event = Event::DiscardFunding {
- channel_id,
- funding_info: FundingInfo::Contribution {
- inputs: vec![OutPoint {
- txid: bitcoin::Txid::from_slice(&[9; 32]).unwrap(),
- vout: 1,
- }],
- outputs: vec![ScriptBuf::new_p2wpkh(
- &bitcoin::WPubkeyHash::from_slice(&[7; 20]).unwrap(),
- )],
- },
- };
- let encoded = event.encode();
- assert_eq!(encoded[0], 53);
- let decoded = Event::read(&mut &encoded[..]).unwrap().unwrap();
- assert_eq!(event, decoded);
-
- // Other variants remain under event type 11, which 0.2 can read.
- let transaction = Transaction {
- version: bitcoin::transaction::Version::TWO,
- lock_time: bitcoin::absolute::LockTime::ZERO,
- input: vec![],
- output: vec![],
- };
- let event =
- Event::DiscardFunding { channel_id, funding_info: FundingInfo::Tx { transaction } };
- let encoded = event.encode();
- assert_eq!(encoded[0], 11);
- let decoded = Event::read(&mut &encoded[..]).unwrap().unwrap();
- assert_eq!(event, decoded);
- }
}
/// A trait indicating an object may generate events.
### lightning/src/ln/funding.rs
@@ -724,24 +724,6 @@ impl FundingContribution {
self.max_feerate
}
- #[cfg(test)]
- pub(crate) fn new_for_test(
- inputs: Vec<ConfirmedUtxo>, outputs: Vec<TxOut>, change_output: Option<TxOut>,
- ) -> Self {
- FundingContribution {
- estimated_fee: Amount::ZERO,
- inputs,
- outputs,
- change_output,
- feerate: FeeRate::from_sat_per_kwu(
- crate::chain::chaininterface::FEERATE_FLOOR_SATS_PER_KW as u64,
- ),
- max_feerate: FeeRate::MAX,
- is_splice: true,
- input_mode: Some(FundingInputMode::CoinSelected),
- }
- }
-
/// Tries to satisfy a new request using only this contribution's existing inputs.
///
/// For input-backed contributions, this reuses the current inputs, adjusts the explicitWhy this scored 17/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.