From 89bdf95326253c5269445ea63c6c697852cf79c8 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 1 Jun 2026 15:08:04 -0700 Subject: [PATCH 1/4] Allow pending splice broadcasts at fuzz EOF If the input ends immediately after `tx_signatures`, the corresponding `SpliceNegotiated` event may still be pending, leaving the valid interactive funding transaction in the test broadcaster. --- fuzz/src/chanmon_consistency.rs | 80 +++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 9 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index a0aa7bbe7ef..5d61932ee13 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -39,7 +39,7 @@ use lightning::blinded_path::message::{BlindedMessagePath, MessageContext, Messa use lightning::blinded_path::payment::{BlindedPaymentPath, ReceiveTlvs}; use lightning::chain; use lightning::chain::chaininterface::{ - BroadcasterInterface, ConfirmationTarget, FeeEstimator, TransactionType, + BroadcasterInterface, ConfirmationTarget, FeeEstimator, FundingPurpose, TransactionType, }; use lightning::chain::channelmonitor::ChannelMonitor; use lightning::chain::{ @@ -170,12 +170,12 @@ impl MessageRouter for FuzzRouter { } pub struct TestBroadcaster { - txn_broadcasted: RefCell>, + txn_broadcasted: RefCell>, } impl BroadcasterInterface for TestBroadcaster { fn broadcast_transactions(&self, txs: &[(&Transaction, TransactionType)]) { - for (tx, _broadcast_type) in txs { - self.txn_broadcasted.borrow_mut().push((*tx).clone()); + for (tx, broadcast_type) in txs { + self.txn_broadcasted.borrow_mut().push(((*tx).clone(), broadcast_type.clone())); } } } @@ -2008,10 +2008,71 @@ fn assert_test_invariants(nodes: &[HarnessNode<'_>; 3]) { assert_eq!(nodes[1].list_channels().len(), 6); assert_eq!(nodes[2].list_channels().len(), 3); - // All broadcasters should be empty. Broadcast transactions are handled explicitly. - assert!(nodes[0].broadcaster.txn_broadcasted.borrow().is_empty()); - assert!(nodes[1].broadcaster.txn_broadcasted.borrow().is_empty()); - assert!(nodes[2].broadcaster.txn_broadcasted.borrow().is_empty()); + // Broadcast transactions are handled explicitly. If the input ends immediately after + // `tx_signatures`, however, the corresponding `SpliceNegotiated` event may still be pending, + // leaving the valid interactive funding transaction in the test broadcaster. + for (idx, node) in nodes.iter().enumerate() { + if node.broadcaster.txn_broadcasted.borrow().is_empty() { + continue; + } + + let pending_events = node.get_and_clear_pending_events(); + let expected_splice_events = { + let txs = node.broadcaster.txn_broadcasted.borrow(); + let mut expected_splice_events = Vec::new(); + for (tx, tx_type) in txs.iter() { + let txid = tx.compute_txid(); + let candidates = match tx_type { + TransactionType::InteractiveFunding { candidates } => candidates, + _ => panic!("node {} had unexpected broadcast transaction: {:?}", idx, tx_type), + }; + for funding in &candidates.last().unwrap().channels { + assert!( + matches!(&funding.purpose, FundingPurpose::Splice), + "node {} had leftover non-splice interactive funding broadcast: {:?}", + idx, + funding + ); + expected_splice_events.push(( + txid.clone(), + funding.counterparty_node_id.clone(), + funding.channel_id.clone(), + )); + } + } + expected_splice_events + }; + + let mut pending_splice_events = pending_events + .iter() + .filter_map(|event| match event { + events::Event::SpliceNegotiated { + new_funding_txo, + counterparty_node_id, + channel_id, + .. + } => Some(( + new_funding_txo.txid.clone(), + counterparty_node_id.clone(), + channel_id.clone(), + )), + _ => None, + }) + .collect::>(); + for expected_splice_event in expected_splice_events { + let pending_idx = + pending_splice_events.iter().position(|event| event == &expected_splice_event); + assert!( + pending_idx.is_some(), + "node {} had leftover interactive funding broadcast without matching \ + pending SpliceNegotiated event: {:?}; pending events: {:?}", + idx, + expected_splice_event, + pending_events + ); + pending_splice_events.remove(pending_idx.unwrap()); + } + } } fn connect_peers(source: &ChanMan<'_>, dest: &ChanMan<'_>) { @@ -2821,7 +2882,8 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { events::Event::SpliceNegotiated { new_funding_txo, .. } => { let mut txs = nodes[node_idx].broadcaster.txn_broadcasted.borrow_mut(); assert!(txs.len() >= 1); - let splice_tx = txs.remove(0); + let (splice_tx, tx_type) = txs.remove(0); + assert!(matches!(tx_type, TransactionType::InteractiveFunding { .. })); assert_eq!(new_funding_txo.txid, splice_tx.compute_txid()); chain_state.add_pending_tx(splice_tx); }, From 9397749760299f19e2a08f425796b25c8f468077 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 1 Jun 2026 15:08:10 -0700 Subject: [PATCH 2/4] Ignore stale splice signing fuzz events Now that the fuzz target supports canceling splice funding attempts, we may see failed signing attempts due to the cancellation. --- fuzz/src/chanmon_consistency.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 5d61932ee13..99c94b40ee1 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2875,9 +2875,20 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { .. } => { let signed_tx = nodes[node_idx].wallet.sign_tx(unsigned_transaction).unwrap(); - nodes[node_idx] - .funding_transaction_signed(&channel_id, &counterparty_node_id, signed_tx) - .unwrap(); + match nodes[node_idx].funding_transaction_signed( + &channel_id, + &counterparty_node_id, + signed_tx, + ) { + Ok(()) => {}, + Err(APIError::APIMisuseError { ref err }) + if err.contains("not expecting funding signatures") => + { + // A queued signing event can be invalidated by a later `tx_abort` + // before the application handles it. + }, + Err(e) => panic!("{e:?}"), + } }, events::Event::SpliceNegotiated { new_funding_txo, .. } => { let mut txs = nodes[node_idx].broadcaster.txn_broadcasted.borrow_mut(); From 561f3ab53a88090b4dd5a073714f00cc98a1809a Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 1 Jun 2026 15:08:15 -0700 Subject: [PATCH 3/4] Raise iteration capacity in chanmon consistency when settling state LDK and the chanmon_consistency fuzz target have grown in complexity recently and thus require more iterations than previously assumed to fully settle the state of all active channels. --- fuzz/src/chanmon_consistency.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 99c94b40ee1..80e1ac25098 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -102,6 +102,7 @@ use std::sync::atomic; use std::sync::{Arc, Mutex}; const MAX_FEE: u32 = 10_000; +const MAX_SETTLE_ITERATIONS: usize = 256; struct FuzzEstimator { ret_val: atomic::AtomicU32, } @@ -2927,9 +2928,9 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { fn process_all_events(&mut self) { let mut last_pass_no_updates = false; for i in 0..std::usize::MAX { - if i == 100 { + if i == MAX_SETTLE_ITERATIONS { panic!( - "It may take may iterations to settle the state, but it should not take forever" + "It may take many iterations to settle the state, but it should not take forever" ); } let mut made_progress = self.checkpoint_manager_persistences(); From baa5d0c0ab6df6b4f2923a2311ff4c4da8a0025b Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Mon, 1 Jun 2026 15:09:26 -0700 Subject: [PATCH 4/4] Replay seen blocks after node reload in chanmon_consistency This replicates what a node in production would see, and is necessary to replay certain actions within LDK that happened prior to the reload. --- fuzz/src/chanmon_consistency.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 80e1ac25098..7942396646e 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1165,6 +1165,7 @@ impl<'a> HarnessNode<'a> { let manager = <(BlockLocator, ChanMan)>::read(&mut &self.serialized_manager[..], read_args) .expect("Failed to read manager"); + self.height = manager.0.height; let expected_status = if self.deferred { ChannelMonitorUpdateStatus::InProgress } else { @@ -3001,6 +3002,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { if !self.nodes[node_idx].deferred { self.nodes[node_idx].checkpoint_manager_persistence(); } + let pre_reload_height = self.nodes[node_idx].height; match node_idx { 0 => { self.ab_link.disconnect_for_reload(0, &self.nodes, &mut self.queues); @@ -3021,6 +3023,8 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { for payment_hash in rolled_back_payment_hashes { self.payments.claimed_payment_hashes.remove(&payment_hash); } + let resync_blocks = pre_reload_height.saturating_sub(self.nodes[node_idx].height); + self.nodes[node_idx].sync_with_chain_state(&self.chain_state, Some(resync_blocks)); } fn settle_all(&mut self) {