Queue a splice on a channel with an inherited splice until it locks
What changed, and why it matters
This commit fixes a compatibility edge case when users upgrade from an older version of LDK (0.2) while a channel splice is still pending. The older version did not save enough details about the splice, so after upgrading the software could not safely continue or replace that splice. The fix makes the newer code recognize the missing information and queue a fresh splice instead of refusing or crashing. It is a robustness improvement for cross-version upgrades, not a remotely exploitable vulnerability.
Review and merge as a defensive fix. No immediate security response required, but include in release notes for users who splice channels across LDK 0.2/0.3 upgrades.
Security signals we found
Removal of debug_assert that could panic on upgrade/downgrade data
Graceful handling of missing splice metadata from older LDK persistence
Prevention of refusal/crash when re-splicing a channel with an inherited splice
Queueing logic aligns with existing zero-conf splice path
Evidence from the diff
The change removes a debug_assert that assumed a pending splice always had a known previous feerate, and instead treats a missing feerate/contribution as a signal that the splice was last persisted by LDK 0.2. In that case it leaves min_rbf_feerate unset, causing splice_channel to return a fresh funding template and queue the new splice as WaitingOnLock until the inherited splice locks. Tests are added to verify upgrade/downgrade behavior for a single inherited splice and that a new splice is queued correctly.
Changed components
lightning/src/ln/channel.rslightning-tests/src/upgrade_downgrade_tests.rsLDK splice/RBF negotiation pathLDK upgrade/downgrade compatibility for splicingInspect captured patch +77 / −4
diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs
index 6b0969c..0cc643b 100644
--- a/lightning-tests/src/upgrade_downgrade_tests.rs
+++ b/lightning-tests/src/upgrade_downgrade_tests.rs
@@ -52,6 +52,7 @@ use lightning_0_0_125::util::ser::Writeable as _;
use lightning::blinded_path::message::NextMessageHop;
use lightning::chain::channelmonitor::{ANTI_REORG_DELAY, HTLC_FAIL_BACK_BUFFER};
use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType};
+use lightning::ln::channel_state::SpliceCandidateStatus;
use lightning::ln::functional_test_utils::*;
use lightning::ln::msgs;
use lightning::ln::msgs::BaseMessageHandler as _;
@@ -1026,4 +1027,75 @@ fn upgrade_single_splice_from_0_2() {
assert_eq!(splice.candidates.len(), 1);
assert_eq!(splice.candidates[0].contribution, None);
}
+
+ // The inherited splice cannot be RBF'd -- 0.2 persisted neither its feerate nor our contribution
+ // to reconstruct the prior request -- so splice_channel returns a fresh template with no RBF
+ // feerate floor rather than refusing. The new splice is queued to begin once the inherited
+ // splice locks.
+ let node_id_1 = nodes[1].node.get_our_node_id();
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert!(funding_template.min_rbf_feerate().is_none());
+}
+
+#[test]
+fn splice_inherited_across_0_2_queues_until_lock() {
+ // Negotiate a contributory splice on current, downgrade to LDK 0.2, then upgrade back. LDK 0.2
+ // persists neither our contribution nor the splice feerate and does not retain the odd TLVs that
+ // carry them, so the splice returns to current without either. It therefore cannot be RBF'd;
+ // splicing again instead queues a new splice that begins once the inherited splice locks.
+ // Same single-splice setup as the downgrade tests; we only need node 0 here.
+ let (v3_mgr, _, v3_mon, _, channel_id) = downgrade_setup_single_splice();
+ let chan_id_bytes = channel_id.0;
+
+ // Downgrade node 0 to LDK 0.2 and re-serialize there, stripping the contribution and feerate.
+ let (v2_mgr, v2_mon);
+ {
+ let mut chanmon_cfgs = lightning_0_2_utils::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 = 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();
+ config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true;
+ let mgr = lightning_0_2_utils::_reload_node(&nodes[0], config, &v3_mgr, &[&v3_mon[..]]);
+ assert_eq!(mgr.list_channels().len(), 1);
+ let v2_channel_id = lightning_0_2::ln::types::ChannelId(chan_id_bytes);
+ v2_mgr = mgr.encode();
+ v2_mon = get_monitor_0_2!(nodes[0], v2_channel_id).encode();
+ }
+
+ // Upgrade back to current and splice the channel carrying the inherited splice.
+ 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, 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, &v2_mgr, &[&v2_mon[..]], persister, chain_mon, new_node);
+
+ let channel_id = ChannelId(chan_id_bytes);
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ // splice_channel returns a fresh template with no RBF feerate floor rather than refusing.
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert!(funding_template.min_rbf_feerate().is_none());
+
+ // Contributing queues the splice as `WaitingOnLock`: it cannot replace the inherited splice via
+ // RBF (its feerate and our contribution are absent), so it will be spliced once that splice
+ // locks. A splice-out needs no wallet funds, letting us drive the queue without connecting
+ // blocks to the reloaded node.
+ let 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).unwrap();
+ let channels = nodes[0].node.list_channels();
+ let splice = channels[0].splice_details.as_ref().unwrap();
+ assert!(matches!(
+ splice.candidates.last().unwrap().status,
+ SpliceCandidateStatus::WaitingOnLock,
+ ));
}
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index ce35787..ff0500e 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -12948,10 +12948,11 @@ where
.as_ref()
.map(|n| n.funding_feerate_sat_per_1000_weight())
});
- debug_assert!(
- prev_feerate.is_some(),
- "pending_splice should have last_funding_feerate or funding_negotiation",
- );
+ // The feerate and our contribution are only persisted by LDK 0.3+, so their absence
+ // means this splice was last written by an older version (negotiated there, or
+ // round-tripped 0.3 -> 0.2 -> 0.3) and cannot be RBF'd. Leave the RBF feerate floor
+ // unset so the new splice is queued and begins as a fresh splice once the pending
+ // candidate locks, rather than attempting to replace it.
let min_rbf_feerate = prev_feerate.map(min_rbf_feerate);
let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() {
pending_splice.latest_contribution().cloned()
Why this scored 33/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.