Fix SpliceNegotiationFailed TLV collision with 0.2
What changed, and why it matters
This commit fixes a data-format compatibility bug between versions 0.2 and 0.3 of the Lightning Dev Kit. When a pending splice-failure event was saved to disk by one version, the other version could misread it because new fields reused old numeric identifiers (TLV types). The patch assigns fresh identifiers so old and new data no longer collide. It is a backward-compatibility fix, not an exploitable security vulnerability, and it includes tests to confirm old data can now be read correctly.
No immediate security action required. Users upgrading from LDK 0.2 to 0.3 (post-patch) should ensure no pending `Event::SpliceFailed` or `Event::DiscardFunding` with `FundingInfo::Contribution` exists before downgrading, because downgrade remains blocked for that case. Node operators on v0.3.0-beta1 with a pending splice event should be aware that their serialized state is no longer readable after this patch.
Security signals we found
Serialization format incompatibility between versions
TLV type reuse causing deserialization failure
Backward-compatibility regression in persisted state
No cryptographic, network, or memory-safety flaw present
Evidence from the diff
The patch resolves a TLV type collision in the serialization of Event::SpliceNegotiationFailed (formerly Event::SpliceFailed). In 0.2, types 11 and 13 carried contributed_inputs and contributed_outputs; after those fields were removed, 0.3 reused the same types for reason and contribution, causing deserialization failures when a ChannelManager written by one version contained a pending splice event and was loaded by the other. The fix moves reason to type 15 and contribution to type 17, leaving the legacy odd types to be skipped by unknown-type handling. The change updates both the writer and the reader in lightning/src/events/mod.rs and adds unit and integration tests covering legacy reads, 0.2-style reads, and round-trips. A note is added that v0.3.0-beta1 state with a pending splice event remains unloadable.
Changed components
lightning/src/events/mod.rsEvent::SpliceNegotiationFailed serialization/deserializationChannelManager persistence compatibility between LDK 0.2 and 0.3Inspect captured patch +235 / −4
### lightning-tests/src/upgrade_downgrade_tests.rs
@@ -10,14 +10,18 @@
//! Tests which test upgrading from previous versions of LDK or downgrading to previous versions of
//! LDK.
+use lightning_0_2::chain::chaininterface::FEERATE_FLOOR_SATS_PER_KW as FEERATE_FLOOR_0_2;
use lightning_0_2::commitment_signed_dance as commitment_signed_dance_0_2;
use lightning_0_2::events::bump_transaction::sync::WalletSourceSync as WalletSourceSync_0_2;
use lightning_0_2::events::Event as Event_0_2;
+use lightning_0_2::get_event_msg as get_event_msg_0_2;
use lightning_0_2::get_monitor as get_monitor_0_2;
use lightning_0_2::ln::channelmanager::PaymentId as PaymentId_0_2;
use lightning_0_2::ln::channelmanager::RecipientOnionFields as RecipientOnionFields_0_2;
use lightning_0_2::ln::functional_test_utils as lightning_0_2_utils;
+use lightning_0_2::ln::msgs::BaseMessageHandler as _;
use lightning_0_2::ln::msgs::ChannelMessageHandler as _;
+use lightning_0_2::ln::msgs::MessageSendEvent as MessageSendEvent_0_2;
use lightning_0_2::ln::msgs::OnionMessage as OnionMessage_0_2;
use lightning_0_2::onion_message::packet::Packet as Packet_0_2;
use lightning_0_2::routing::router as router_0_2;
@@ -1059,6 +1063,107 @@ fn upgrade_single_splice_from_0_2() {
assert!(funding_template.min_rbf_feerate().is_none());
}
+#[test]
+fn upgrade_mid_splice_negotiation_from_0_2() {
+ // An incomplete splice negotiation does not persist, so a ChannelManager written by 0.2
+ // mid-negotiation embeds a synthesized `SpliceFailed` event carrying the contributed
+ // inputs/outputs at TLV types since reused for `reason` and `contribution`. Ensure current
+ // code can read it (see #4919).
+ let (node_0_ser, node_1_ser, mon_0_ser, mon_1_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 node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ 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 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, FEERATE_FLOOR_0_2, None)
+ .unwrap();
+
+ // Stop the negotiation after `splice_ack`, leaving both nodes in a state that isn't
+ // persisted.
+ let stfu = get_event_msg_0_2!(nodes[0], MessageSendEvent_0_2::SendStfu, node_id_1);
+ nodes[1].node.handle_stfu(node_id_0, &stfu);
+ let stfu = get_event_msg_0_2!(nodes[1], MessageSendEvent_0_2::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu);
+ let splice_init =
+ get_event_msg_0_2!(nodes[0], MessageSendEvent_0_2::SendSpliceInit, node_id_1);
+ nodes[1].node.handle_splice_init(node_id_0, &splice_init);
+ let _ = get_event_msg_0_2!(nodes[1], MessageSendEvent_0_2::SendSpliceAck, node_id_0);
+
+ node_0_ser = nodes[0].node.encode();
+ node_1_ser = nodes[1].node.encode();
+ mon_0_ser = get_monitor_0_2!(nodes[0], channel_id).encode();
+ mon_1_ser = get_monitor_0_2!(nodes[1], channel_id).encode();
+ }
+
+ let mut chanmon_cfgs = create_chanmon_cfgs(2);
+ chanmon_cfgs[0].keys_manager.disable_all_state_policy_checks = true;
+ chanmon_cfgs[1].keys_manager.disable_all_state_policy_checks = true;
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let (persister_a, persister_b, chain_mon_a, chain_mon_b);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let (node_a, node_b);
+ let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+ let config = test_default_channel_config();
+ reload_node!(
+ nodes[0],
+ config.clone(),
+ &node_0_ser,
+ &[&mon_0_ser[..]],
+ persister_a,
+ chain_mon_a,
+ node_a
+ );
+ reload_node!(
+ nodes[1],
+ config,
+ &node_1_ser,
+ &[&mon_1_ser[..]],
+ persister_b,
+ chain_mon_b,
+ node_b
+ );
+
+ // The initiator's aborted negotiation surfaces as `SpliceNegotiationFailed`; 0.2 wrote
+ // neither a reason nor a contribution. The acceptor had no splice state to lose.
+ let channel_id = ChannelId(chan_id_bytes);
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 1, "{events:?}");
+ match &events[0] {
+ Event::SpliceNegotiationFailed { channel_id: chan_id, reason, contribution, .. } => {
+ assert_eq!(*chan_id, channel_id);
+ assert_eq!(*reason, NegotiationFailureReason::Unknown);
+ assert_eq!(*contribution, None);
+ },
+ ev => panic!("Expected SpliceNegotiationFailed, got {ev:?}"),
+ }
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
+
+ // The channel itself is unaffected.
+ let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
+ reconnect_args.send_channel_ready = (true, true);
+ reconnect_args.send_announcement_sigs = (true, true);
+ reconnect_nodes(reconnect_args);
+ send_payment(&nodes[0], &[&nodes[1]], 100_000);
+}
+
#[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
@@ -2553,12 +2553,14 @@ impl Writeable for Event {
ref contribution,
} => {
52u8.write(writer)?;
+ // Types 3, 9, 11, and 13 were `channel_type`, `abandoned_funding_txo`,
+ // `contributed_inputs`, and `contributed_outputs` in 0.2 and must not be reused.
write_tlv_fields!(writer, {
(1, channel_id, required),
(5, user_channel_id, required),
(7, counterparty_node_id, required),
- (11, reason, required),
- (13, contribution, option),
+ (15, reason, required),
+ (17, contribution, option),
});
},
// Note that, going forward, all new events must only write data inside of
@@ -3213,8 +3215,8 @@ impl MaybeReadable for Event {
(1, channel_id, required),
(5, user_channel_id, required),
(7, counterparty_node_id, required),
- (11, reason, upgradable_option),
- (13, contribution, option),
+ (15, reason, upgradable_option),
+ (17, contribution, option),
});
Ok(Some(Event::SpliceNegotiationFailed {
@@ -3289,6 +3291,124 @@ 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> {
+ 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: None,
+ };
+ 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!(contributed_inputs.unwrap().is_empty());
+ let contributed_outputs: Option<Vec<bitcoin::TxOut>> = contributed_outputs;
+ assert!(contributed_outputs.unwrap().is_empty());
+
+ 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 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: None,
+ };
+ let encoded = event.encode();
+ let decoded = Event::read(&mut &encoded[..]).unwrap().unwrap();
+ assert_eq!(event, decoded);
+ }
}
/// A trait indicating an object may generate events.
### pending_changelog/4919-splice-negotiation-failed-tlv-compat.txt
@@ -0,0 +1,6 @@
+# Backwards Compatibility (0.3)
+
+ * A pending `Event::SpliceFailed` written by 0.2 is read as `Event::SpliceNegotiationFailed` with
+ `reason` set to `NegotiationFailureReason::Unknown` and no `contribution` (#4919). Note that a
+ pending `Event::DiscardFunding` with `FundingInfo::Contribution` still prevents downgrade to
+ 0.2.Why this scored 39/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.