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 642baa619d21fae8d5d8bc254b6fd5ba02285cbc Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 9 Apr 2026 11:57:01 -0500 Subject: [PATCH 3/4] f - Avoid fuzz hash collisions in outpoint spent checks Under fuzz hashing, txids have only 8 effective bits, so outpoints from unrelated transactions in old blocks frequently collide. This causes is_outpoint_spent to produce false positives, silently preventing legitimate transactions from being added to the pending pool or confirmed. Limit is_outpoint_spent to the last 6 blocks. Confirmed transactions are always followed by 5 empty blocks, so the scan window covers exactly one block with transactions. This catches real double-spends (same-channel splice RBF candidates) while avoiding false positives from older blocks. Also remove the is_outpoint_spent check from add_pending_tx so that filtering decisions are made at confirmation time (in confirm_pending_txs) rather than at add time. This ensures all candidates participate in the deterministic sort before any are rejected. Co-Authored-By: Claude Opus 4.6 (1M context) --- fuzz/src/chanmon_consistency.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 290310c47db..c183d883922 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -207,7 +207,11 @@ impl ChainState { } fn is_outpoint_spent(&self, outpoint: &bitcoin::OutPoint) -> bool { - self.blocks.iter().any(|(_, txs)| { + // Only check the last 6 blocks (1 confirmation block + 5 post-confirmation) to avoid + // false positives from hash collisions in older blocks. Under fuzz hashing, txids have + // only 8 effective bits, so unrelated outpoints in old blocks frequently collide. + let start = self.blocks.len().saturating_sub(6); + self.blocks[start..].iter().any(|(_, txs)| { txs.iter().any(|tx| { tx.input.iter().any(|input| input.previous_output == *outpoint) }) @@ -237,13 +241,8 @@ impl ChainState { } /// 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. + /// candidates) may coexist; `confirm_pending_txs` selects which one to confirm. 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); } From 0e90b5d76367c379a49971c1655f5e540419d431 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 9 Apr 2026 11:28:46 -0500 Subject: [PATCH 4/4] f - Batch pending tx confirmations into a single block --- fuzz/src/chanmon_consistency.rs | 40 ++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index c183d883922..09c2686981b 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -246,15 +246,45 @@ impl ChainState { 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. + /// 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(|tx| tx.compute_txid()); + + let mut confirmed = Vec::new(); + let mut spent_outpoints = Vec::new(); for tx in txs { - self.confirm_tx(tx); + let txid = tx.compute_txid(); + 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())); } }