From 64c5182bb264442b7f1b82aa4efff5184a6efd9c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 1 Apr 2026 13:40:36 -0500 Subject: [PATCH 1/2] 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 5b5c6391b4b..b205120e913 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -2037,11 +2037,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 e82d36e45eed07b2fed7b0a3775daa9f783804d2 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 1 Apr 2026 12:32:30 -0500 Subject: [PATCH 2/2] 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 pool instead of being confirmed immediately. At chain-sync time, pending transactions are sorted by txid and confirmed together in one block; candidates that double-spend an already-confirmed outpoint or another candidate earlier in the sort are dropped. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 111 ++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 12 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index b205120e913..9602fc9511a 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<(Txid, Transaction)>, } 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,53 @@ 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. + fn add_pending_tx(&mut self, tx: Transaction) { + self.pending_txs.push((tx.compute_txid(), tx)); + } + + /// Confirm pending transactions in a single block, selecting deterministically among + /// conflicting RBF candidates. Sorting by txid ensures the winner is determined by fuzz input + /// content. Transactions that double-spend an already-confirmed outpoint are skipped. + fn confirm_pending_txs(&mut self) { + let mut txs = std::mem::take(&mut self.pending_txs); + txs.sort_by_key(|(txid, _)| *txid); + + let mut confirmed = Vec::new(); + let mut spent_outpoints = Vec::new(); + for (txid, tx) in txs { + if self.confirmed_txids.contains(&txid) { + continue; + } + if tx.input.iter().any(|input| { + self.is_outpoint_spent(&input.previous_output) + || spent_outpoints.contains(&input.previous_output) + }) { + continue; + } + self.confirmed_txids.insert(txid); + for input in &tx.input { + spent_outpoints.push(input.previous_output); + } + confirmed.push(tx); + } + + if confirmed.is_empty() { + return; + } + + let prev_hash = self.blocks.last().unwrap().0.block_hash(); + let header = create_dummy_header(prev_hash, 42); + self.blocks.push((header, confirmed)); + + for _ in 0..5 { + let prev_hash = self.blocks.last().unwrap().0.block_hash(); + let header = create_dummy_header(prev_hash, 42); + self.blocks.push((header, Vec::new())); + } + } + fn block_at(&self, height: u32) -> &(Header, Vec) { &self.blocks[height as usize] } @@ -862,11 +927,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 { @@ -2033,7 +2102,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 { @@ -2506,13 +2575,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