From 5dfe3035491da8192662373bc67f6e0574d0d00e Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 1 Apr 2026 13:40:36 -0500 Subject: [PATCH 1/4] Handle DiscardFunding with FundingInfo::Tx variant in chanmon_consistency The process_events! macro only handled DiscardFunding events with FundingInfo::Contribution, but splice RBF replacements can produce DiscardFunding with FundingInfo::Tx when the original splice transaction is discarded. Co-Authored-By: Claude Opus 4.6 (1M context) --- 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 f20f93c789c..717ec8e346c 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2029,11 +2029,12 @@ pub fn do_test(data: &[u8], out: Out) { }, events::Event::SpliceFailed { .. } => {}, events::Event::DiscardFunding { - funding_info: events::FundingInfo::Contribution { .. }, + funding_info: events::FundingInfo::Contribution { .. } + | events::FundingInfo::Tx { .. }, .. } => {}, - _ => panic!("Unhandled event"), + _ => panic!("Unhandled event: {:?}", event), } } while nodes[$node].needs_pending_htlc_processing() { From c972bf2ad96f998fd9c091bf67636ff456b2f093 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 1 Apr 2026 12:32:30 -0500 Subject: [PATCH 2/4] Model RBF splice tx replacement in chanmon_consistency The SplicePending event handler was immediately confirming splice transactions, which caused force-closes when RBF splice replacements were also confirmed for the same channel. Since both transactions spend the same funding UTXO, only one can exist on a real chain. Model this properly by adding a mempool-like pending pool to ChainState. Splice transactions are added to the pending pool instead of being confirmed immediately. Conflicting RBF candidates coexist in the pool until chain-sync time, when one is selected deterministically (by txid sort order) and the rest are rejected as double-spends. If a conflicting transaction was already confirmed, new candidates are dropped. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 87 ++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 12 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 717ec8e346c..290310c47db 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -186,24 +186,42 @@ impl BroadcasterInterface for TestBroadcaster { struct ChainState { blocks: Vec<(Header, Vec)>, confirmed_txids: HashSet, + /// Unconfirmed transactions (e.g., splice txs). Conflicting RBF candidates may coexist; + /// `confirm_pending_txs` determines which one confirms. + pending_txs: Vec, } impl ChainState { fn new() -> Self { let genesis_hash = genesis_block(Network::Bitcoin).block_hash(); let genesis_header = create_dummy_header(genesis_hash, 42); - Self { blocks: vec![(genesis_header, Vec::new())], confirmed_txids: HashSet::new() } + Self { + blocks: vec![(genesis_header, Vec::new())], + confirmed_txids: HashSet::new(), + pending_txs: Vec::new(), + } } fn tip_height(&self) -> u32 { (self.blocks.len() - 1) as u32 } + fn is_outpoint_spent(&self, outpoint: &bitcoin::OutPoint) -> bool { + self.blocks.iter().any(|(_, txs)| { + txs.iter().any(|tx| { + tx.input.iter().any(|input| input.previous_output == *outpoint) + }) + }) + } + fn confirm_tx(&mut self, tx: Transaction) -> bool { let txid = tx.compute_txid(); if self.confirmed_txids.contains(&txid) { return false; } + if tx.input.iter().any(|input| self.is_outpoint_spent(&input.previous_output)) { + return false; + } self.confirmed_txids.insert(txid); let prev_hash = self.blocks.last().unwrap().0.block_hash(); @@ -218,6 +236,29 @@ impl ChainState { true } + /// Add a transaction to the pending pool (mempool). Multiple conflicting transactions (RBF + /// candidates) may coexist; `confirm_pending_txs` selects which one to confirm. If the + /// conflicting transaction was already confirmed, the new transaction is dropped since a + /// confirmed transaction cannot be replaced on chain. + fn add_pending_tx(&mut self, tx: Transaction) { + if tx.input.iter().any(|i| self.is_outpoint_spent(&i.previous_output)) { + return; + } + self.pending_txs.push(tx); + } + + /// Confirm pending transactions, selecting deterministically among conflicting RBF candidates. + /// Sorting by txid before confirming means the winner depends on the fuzz input (which + /// determines tx content and thus txid), while `confirm_tx` rejects double-spends so only one + /// conflicting tx confirms. + fn confirm_pending_txs(&mut self) { + let mut txs = std::mem::take(&mut self.pending_txs); + txs.sort_by_key(|tx| tx.compute_txid()); + for tx in txs { + self.confirm_tx(tx); + } + } + fn block_at(&self, height: u32) -> &(Header, Vec) { &self.blocks[height as usize] } @@ -856,11 +897,15 @@ fn send_mpp_hop_payment( fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) { // Since sending/receiving messages may be delayed, `timer_tick_occurred` may cause a node to // disconnect their counterparty if they're expecting a timely response. - assert!(matches!( + assert!( + matches!( + action, + msgs::ErrorAction::DisconnectPeerWithWarning { msg } + if msg.data.contains("Disconnecting due to timeout awaiting response") + ), + "Expected timeout disconnect, got: {:?}", action, - msgs::ErrorAction::DisconnectPeerWithWarning { msg } - if msg.data.contains("Disconnecting due to timeout awaiting response") - )); + ); } enum ChanType { @@ -2025,7 +2070,7 @@ pub fn do_test(data: &[u8], out: Out) { assert!(txs.len() >= 1); let splice_tx = txs.remove(0); assert_eq!(new_funding_txo.txid, splice_tx.compute_txid()); - chain_state.confirm_tx(splice_tx); + chain_state.add_pending_tx(splice_tx); }, events::Event::SpliceFailed { .. } => {}, events::Event::DiscardFunding { @@ -2478,13 +2523,31 @@ pub fn do_test(data: &[u8], out: Out) { }, // Sync node by 1 block to cover confirmation of a transaction. - 0xa8 => sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, Some(1)), - 0xa9 => sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, Some(1)), - 0xaa => sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, Some(1)), + 0xa8 => { + chain_state.confirm_pending_txs(); + sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, Some(1)); + }, + 0xa9 => { + chain_state.confirm_pending_txs(); + sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, Some(1)); + }, + 0xaa => { + chain_state.confirm_pending_txs(); + sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, Some(1)); + }, // Sync node to chain tip to cover confirmation of a transaction post-reorg-risk. - 0xab => sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, None), - 0xac => sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, None), - 0xad => sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, None), + 0xab => { + chain_state.confirm_pending_txs(); + sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, None); + }, + 0xac => { + chain_state.confirm_pending_txs(); + sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, None); + }, + 0xad => { + chain_state.confirm_pending_txs(); + sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, None); + }, 0xb0 | 0xb1 | 0xb2 => { // Restart node A, picking among the in-flight `ChannelMonitor`s to use based on From c8958212ab1bd0226194f6b61f23e7604b2b9017 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 1 Apr 2026 16:37:45 -0500 Subject: [PATCH 3/4] Accept splice and RBF warning disconnects in chanmon_consistency The HandleError assertion only accepted "timeout awaiting response" warnings. However, the fuzzer can deliver messages in unusual orders that trigger legitimate splice and RBF validation failures (e.g., attempting RBF after splice_locked, splicing before quiescence, or splicing a channel that is not live). These produce DisconnectPeerWithWarning with different messages that the assertion needs to accept. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 37 ++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 290310c47db..33b7dede1e1 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -894,18 +894,25 @@ fn send_mpp_hop_payment( } #[inline] -fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) { - // Since sending/receiving messages may be delayed, `timer_tick_occurred` may cause a node to - // disconnect their counterparty if they're expecting a timely response. - assert!( - matches!( - action, - msgs::ErrorAction::DisconnectPeerWithWarning { msg } +fn assert_action_disconnect_with_warning(action: &msgs::ErrorAction) { + match action { + msgs::ErrorAction::DisconnectPeerWithWarning { msg } if msg.data.contains("Disconnecting due to timeout awaiting response") - ), - "Expected timeout disconnect, got: {:?}", - action, - ); + || msg.data.contains("cannot RBF") + || msg.data.contains("before RBF") + || msg.data.contains("needed for RBF") + || msg.data.contains("splice to RBF") + || msg.data.contains("candidates to RBF") + || msg.data.contains("candidates for RBF") + || msg.data.contains("before spliced") + || msg.data.contains("needed to splice") + || msg.data.contains("a splice pending") + || msg.data.contains("cannot be spliced") + || msg.data.contains("Splicing requested on a channel that is not live") + || msg.data.contains("funding negotiation already in progress") => + {}, + _ => panic!("Unexpected error action: {:?}", action), + } } enum ChanType { @@ -1658,7 +1665,7 @@ pub fn do_test(data: &[u8], out: Out) { *node_id == a_id }, MessageSendEvent::HandleError { ref action, ref node_id } => { - assert_action_timeout_awaiting_response(action); + assert_action_disconnect_with_warning(action); if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } *node_id == a_id }, @@ -1888,7 +1895,7 @@ pub fn do_test(data: &[u8], out: Out) { } }, MessageSendEvent::HandleError { ref action, .. } => { - assert_action_timeout_awaiting_response(action); + assert_action_disconnect_with_warning(action); }, MessageSendEvent::SendChannelReady { .. } => { // Can be generated as a reestablish response @@ -1941,7 +1948,7 @@ pub fn do_test(data: &[u8], out: Out) { MessageSendEvent::SendAnnouncementSignatures { .. } => {}, MessageSendEvent::SendChannelUpdate { .. } => {}, MessageSendEvent::HandleError { ref action, .. } => { - assert_action_timeout_awaiting_response(action); + assert_action_disconnect_with_warning(action); }, _ => panic!("Unhandled message event"), } @@ -1963,7 +1970,7 @@ pub fn do_test(data: &[u8], out: Out) { MessageSendEvent::SendAnnouncementSignatures { .. } => {}, MessageSendEvent::SendChannelUpdate { .. } => {}, MessageSendEvent::HandleError { ref action, .. } => { - assert_action_timeout_awaiting_response(action); + assert_action_disconnect_with_warning(action); }, _ => panic!("Unhandled message event"), } From 64205b8e550e77570c20d3d221fb7916bfc9507b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 1 Apr 2026 16:51:14 -0500 Subject: [PATCH 4/4] Handle BroadcastChannelUpdate in push_excess_b_events When process_msg_events processes a single message for node B (via OneMessage or OnePendingMessage mode), remaining events are passed to push_excess_b_events for routing. Timer ticks can generate BroadcastChannelUpdate events from marking channels disabled on disconnect, which may be left over after the single processed message. Since it is a broadcast not directed at a specific peer, it can be safely skipped. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 33b7dede1e1..3afc73941f9 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1660,6 +1660,7 @@ pub fn do_test(data: &[u8], out: Out) { }, MessageSendEvent::SendChannelReady { .. } => continue, MessageSendEvent::SendAnnouncementSignatures { .. } => continue, + MessageSendEvent::BroadcastChannelUpdate { .. } => continue, MessageSendEvent::SendChannelUpdate { ref node_id, .. } => { if Some(*node_id) == expect_drop_id { panic!("peer_disconnected should drop msgs bound for the disconnected peer"); } *node_id == a_id