Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3d855ac
Store splice contributions with their negotiated candidates
jkczyz Jun 12, 2026
fa5e506
Expose pending splice details in ChannelDetails
jkczyz Jun 12, 2026
9283e00
Test cross-version serialization of pending splices
jkczyz Jun 16, 2026
8ac6fae
Reject splicing a channel with a splice inherited from a prior LDK ve…
jkczyz Jun 17, 2026
0c00583
Add pending changelog entry for PR 4687
jkczyz Jun 18, 2026
2c5f92c
Only emit Event::SpliceNegotiated when contributing
wpaulino Jun 17, 2026
72563af
Always emit SpliceNegotiationFailed when contributing
wpaulino Jun 16, 2026
175b58d
Allow invalid contribution error upon quiescence
wpaulino Jun 17, 2026
1313db2
Prefer tx_abort over disconnection for splice negotiation errors
wpaulino Jun 16, 2026
b04dab4
Prefer tx_abort over disconnection for inability to RBF
wpaulino Jun 17, 2026
0df779c
Check channel is live while handling counterparty tx_init_rbf
wpaulino Jun 17, 2026
efff2f8
Add splice confirmation fuzz checks
joostjager Jun 18, 2026
96cc8f2
Add stale splice negotiation fuzz invariant
joostjager Jun 19, 2026
cf7867b
lightning: introduce singular claim requests
joostjager Apr 30, 2026
7ef4332
lightning: clarify channelmonitor event thresholds
joostjager May 4, 2026
bbd33ee
lightning: refactor onchain tx handler tests
joostjager Apr 30, 2026
5f1cdad
lightning: cover delayed preimage claim balance
joostjager May 4, 2026
fe79d97
lightning: resolve HTLC spends at anti-reorg finality
joostjager May 4, 2026
70eea5c
f: assert delayed output for HTLC spends
joostjager May 6, 2026
d911223
f: pin HTLC spend assert to the delayed output index
joostjager Jun 10, 2026
9ec4872
lightning: dedupe delayed claims by outpoint coverage
joostjager Apr 30, 2026
05c08d2
lightning: ignore claims for pending spent outpoints
joostjager Apr 30, 2026
96960d8
f: fold timelocked outpoint claim check
joostjager May 6, 2026
227855c
f: cover claim replay after reorg resurrection
joostjager Jun 10, 2026
fff3b06
lightning: skip resolved HTLC claim replays
joostjager Apr 30, 2026
e4f9b38
f: log resolved HTLC preimage losses
joostjager May 6, 2026
0d26648
lightning: canonicalize htlc claim ids
joostjager Apr 30, 2026
1707857
fuzz: add chanmon holder signer fuzz ops
joostjager Jun 4, 2026
d883aa1
fuzz: factor chanmon_consistency node loops
joostjager May 19, 2026
c506e58
fuzz: factor chanmon consistency cleanup helpers
joostjager Jun 5, 2026
f73890b
fuzz: cover chanmon force-close settlement
joostjager Jun 4, 2026
7c40ba0
f: simplifications
joostjager Jun 11, 2026
8b3cf71
f: fix chanmon fuzz failure accounting
joostjager Jun 19, 2026
fb3bff2
Fix chanmon force-close settlement accounting
joostjager Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,095 changes: 1,618 additions & 477 deletions fuzz/src/chanmon_consistency.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions fuzz/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ pub fn do_test<Out: test_logger::Output>(data: &[u8], out: Out) {
pending_inbound_htlcs: Vec::new(),
pending_outbound_htlcs: Vec::new(),
current_dust_exposure_msat: None,
splice_details: None,
});
}
Some(&$first_hops_vec[..])
Expand Down
283 changes: 283 additions & 0 deletions lightning-tests/src/upgrade_downgrade_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//! LDK.

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_monitor as get_monitor_0_2;
use lightning_0_2::ln::channelmanager::PaymentId as PaymentId_0_2;
Expand Down Expand Up @@ -60,6 +61,7 @@ use lightning::ln::splicing_tests::*;
use lightning::ln::types::ChannelId;
use lightning::onion_message::packet::Packet;
use lightning::sign::OutputSpender;
use lightning::util::errors::APIError;
use lightning::util::ser::{MaybeReadable, Writeable};
use lightning::util::wallet_utils::WalletSourceSync;

Expand Down Expand Up @@ -819,3 +821,284 @@ fn test_onion_message_intercepted_scid_downgrade_to_0_2() {
let result = <Event_0_2 as MaybeReadable_0_2>::read(&mut reader);
assert!(result.is_err(), "LDK 0.2 should fail to decode a ShortChannelId variant");
}

fn downgrade_setup_single_splice() -> (Vec<u8>, Vec<u8>, Vec<u8>, Vec<u8>) {
// Build a current node with a single pending (negotiated, not yet locked) splice that node 0
// funded (so node 0 is contributory, node 1 is a non-contributory acceptor). Return both
// nodes' serialized ChannelManager + ChannelMonitor.
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 (_, _, channel_id, _) =
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);

let added_value = Amount::from_sat(50_000);
provide_utxo_reserves(&nodes, 2, added_value * 2);
let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
mine_transaction(&nodes[0], &splice_tx);
mine_transaction(&nodes[1], &splice_tx);

let node_0_ser = nodes[0].node.encode();
let node_1_ser = nodes[1].node.encode();
let mon_0_ser = get_monitor!(nodes[0], channel_id).encode();
let mon_1_ser = get_monitor!(nodes[1], channel_id).encode();
(node_0_ser, node_1_ser, mon_0_ser, mon_1_ser)
}

#[test]
fn downgrade_single_splice_loads_on_0_2() {
// A current node with a single pending splice serializes in a form LDK 0.2 can still read,
// whether or not we funded it: only odd TLVs are written (the even RBF gate is omitted for a
// single round), so 0.2 skips the contribution it can't track and loads the channel. RBF is
// the only state that blocks downgrade (see downgrade_rbf_refused_by_0_2).
let (node_0_ser, node_1_ser, mon_0_ser, mon_1_ser) = downgrade_setup_single_splice();

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();
// The current side uses the anchors channel type by default; 0.2 only accepts a channel whose
// type it advertises support for, so enable anchors here too (otherwise the read is refused on
// the channel type, before the splice serialization is ever exercised).
config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true;

// Node 0 (contributory initiator): the contribution lives in an odd TLV that 0.2 skips.
let mgr_0 = lightning_0_2_utils::_reload_node(
&nodes[0],
config.clone(),
&node_0_ser,
&[&mon_0_ser[..]],
);
assert_eq!(mgr_0.list_channels().len(), 1);
// Node 1 (non-contributory acceptor): nothing 0.2 can't represent.
let mgr_1 =
lightning_0_2_utils::_reload_node(&nodes[1], config, &node_1_ser, &[&mon_1_ser[..]]);
assert_eq!(mgr_1.list_channels().len(), 1);
}

#[test]
#[should_panic(expected = "UnknownRequiredFeature")]
fn downgrade_rbf_refused_by_0_2() {
// RBF (more than one negotiation round) is the one splice state LDK 0.2 cannot operate. Current
// writes the even RBF-gate TLV for it, which 0.2 rejects as an unknown even (required) field,
// so reading the ChannelManager fails rather than silently mishandling the extra candidate.
let (node_0_ser, mon_0_ser);
{
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();
let (_, _, channel_id, _) =
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);

let added_value = Amount::from_sat(50_000);
provide_utxo_reserves(&nodes, 2, added_value * 2);
let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
let (first_splice_tx, new_funding_script) =
splice_channel(&nodes[0], &nodes[1], channel_id, contribution);

// RBF the splice, producing a second negotiated candidate.
provide_utxo_reserves(&nodes, 2, added_value * 2);
let rbf_feerate = bitcoin::FeeRate::from_sat_per_kwu(1000);
let rbf_contribution =
do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate);
complete_rbf_handshake(&nodes[0], &nodes[1]);
complete_interactive_funding_negotiation(
&nodes[0],
&nodes[1],
channel_id,
rbf_contribution,
new_funding_script,
);
let _ = sign_interactive_funding_tx(
&nodes[0],
&nodes[1],
false,
Some(first_splice_tx.compute_txid()),
);
expect_splice_pending_event(&nodes[0], &node_id_1);
expect_splice_pending_event(&nodes[1], &node_id_0);

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;
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();
// Match the anchors channel type used on the current side, so the manager read reaches (and
// fails on) the even RBF-gate TLV rather than refusing the channel type itself.
config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true;
// _reload_node unwraps the manager read, which fails on the even RBF-gate TLV → panic.
let _ = lightning_0_2_utils::_reload_node(&nodes[0], config, &node_0_ser, &[&mon_0_ser[..]]);
}

#[test]
fn upgrade_single_splice_from_0_2() {
// A pending single splice written by LDK 0.2 — which never tracked our contribution — is read
// by current: the candidate comes back via the TLV-3 fallback with `contribution: None`.
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 channel_id = lightning_0_2_utils::create_announced_chan_between_nodes_with_value(
&nodes, 0, 1, 100_000, 0,
)
.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(),
}],
};
// 0.2 drives the splice through tx_signatures, leaving one negotiated (unlocked) candidate.
let _ = lightning_0_2::ln::splicing_tests::splice_channel(
&nodes[0],
&nodes[1],
channel_id,
contribution,
);

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
);

// Current reads the 0.2 splice: one negotiated candidate, no contribution recorded.
let channel_id = ChannelId(chan_id_bytes);
for node in nodes.iter() {
let channels = node.node.list_channels();
let details = channels.iter().find(|c| c.channel_id == channel_id).unwrap();
let splice = details.splice_details.as_ref().expect("pending splice");
assert_eq!(splice.candidates.len(), 1);
assert_eq!(splice.candidates[0].contribution, None);
}

// The splice cannot be RBF'd: 0.2 persisted no feerate, so splice_channel refuses it.
let node_id_1 = nodes[1].node.get_our_node_id();
let err = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap_err();
let expected = format!(
"Channel {} has a pending splice from a prior LDK version and cannot be spliced again",
channel_id,
);
match err {
APIError::APIMisuseError { err } => assert_eq!(err, expected),
_ => panic!("unexpected error: {:?}", err),
}
}

#[test]
fn splice_inherited_across_0_2_cannot_be_rbfed() {
// 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. `splice_channel` needs the prior
// feerate to derive the RBF floor, so it refuses such a channel with a clean error rather than
// operating on incomplete state (which would otherwise trip a debug assertion that a pending
// splice always has a known feerate).
let chan_id_bytes;
let (v3_mgr, v3_mon);
{
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 (_, _, channel_id, _) =
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
chan_id_bytes = channel_id.0;

let added_value = Amount::from_sat(50_000);
provide_utxo_reserves(&nodes, 2, added_value * 2);
let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
mine_transaction(&nodes[0], &splice_tx);
mine_transaction(&nodes[1], &splice_tx);

v3_mgr = nodes[0].node.encode();
v3_mon = get_monitor!(nodes[0], channel_id).encode();
}

// 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 attempt to RBF 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();
let err = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap_err();
let expected = format!(
"Channel {} has a pending splice from a prior LDK version and cannot be spliced again",
channel_id,
);
match err {
APIError::APIMisuseError { err } => assert_eq!(err, expected),
_ => panic!("unexpected error: {:?}", err),
}
}
Loading