Skip to content

Commit df49980

Browse files
authored
Merge pull request #4536 from jkczyz/2026-04-splice-rbf-fuzz-fixes
Fix `chanmon_consistency` fuzz test for splice RBF
2 parents 23b620a + e82d36e commit df49980

1 file changed

Lines changed: 102 additions & 14 deletions

File tree

fuzz/src/chanmon_consistency.rs

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -186,24 +186,42 @@ impl BroadcasterInterface for TestBroadcaster {
186186
struct ChainState {
187187
blocks: Vec<(Header, Vec<Transaction>)>,
188188
confirmed_txids: HashSet<Txid>,
189+
/// Unconfirmed transactions (e.g., splice txs). Conflicting RBF candidates may coexist;
190+
/// `confirm_pending_txs` determines which one confirms.
191+
pending_txs: Vec<(Txid, Transaction)>,
189192
}
190193

191194
impl ChainState {
192195
fn new() -> Self {
193196
let genesis_hash = genesis_block(Network::Bitcoin).block_hash();
194197
let genesis_header = create_dummy_header(genesis_hash, 42);
195-
Self { blocks: vec![(genesis_header, Vec::new())], confirmed_txids: HashSet::new() }
198+
Self {
199+
blocks: vec![(genesis_header, Vec::new())],
200+
confirmed_txids: HashSet::new(),
201+
pending_txs: Vec::new(),
202+
}
196203
}
197204

198205
fn tip_height(&self) -> u32 {
199206
(self.blocks.len() - 1) as u32
200207
}
201208

209+
fn is_outpoint_spent(&self, outpoint: &bitcoin::OutPoint) -> bool {
210+
self.blocks.iter().any(|(_, txs)| {
211+
txs.iter().any(|tx| {
212+
tx.input.iter().any(|input| input.previous_output == *outpoint)
213+
})
214+
})
215+
}
216+
202217
fn confirm_tx(&mut self, tx: Transaction) -> bool {
203218
let txid = tx.compute_txid();
204219
if self.confirmed_txids.contains(&txid) {
205220
return false;
206221
}
222+
if tx.input.iter().any(|input| self.is_outpoint_spent(&input.previous_output)) {
223+
return false;
224+
}
207225
self.confirmed_txids.insert(txid);
208226

209227
let prev_hash = self.blocks.last().unwrap().0.block_hash();
@@ -218,6 +236,53 @@ impl ChainState {
218236
true
219237
}
220238

239+
/// Add a transaction to the pending pool (mempool). Multiple conflicting transactions (RBF
240+
/// candidates) may coexist; `confirm_pending_txs` selects which one to confirm.
241+
fn add_pending_tx(&mut self, tx: Transaction) {
242+
self.pending_txs.push((tx.compute_txid(), tx));
243+
}
244+
245+
/// Confirm pending transactions in a single block, selecting deterministically among
246+
/// conflicting RBF candidates. Sorting by txid ensures the winner is determined by fuzz input
247+
/// content. Transactions that double-spend an already-confirmed outpoint are skipped.
248+
fn confirm_pending_txs(&mut self) {
249+
let mut txs = std::mem::take(&mut self.pending_txs);
250+
txs.sort_by_key(|(txid, _)| *txid);
251+
252+
let mut confirmed = Vec::new();
253+
let mut spent_outpoints = Vec::new();
254+
for (txid, tx) in txs {
255+
if self.confirmed_txids.contains(&txid) {
256+
continue;
257+
}
258+
if tx.input.iter().any(|input| {
259+
self.is_outpoint_spent(&input.previous_output)
260+
|| spent_outpoints.contains(&input.previous_output)
261+
}) {
262+
continue;
263+
}
264+
self.confirmed_txids.insert(txid);
265+
for input in &tx.input {
266+
spent_outpoints.push(input.previous_output);
267+
}
268+
confirmed.push(tx);
269+
}
270+
271+
if confirmed.is_empty() {
272+
return;
273+
}
274+
275+
let prev_hash = self.blocks.last().unwrap().0.block_hash();
276+
let header = create_dummy_header(prev_hash, 42);
277+
self.blocks.push((header, confirmed));
278+
279+
for _ in 0..5 {
280+
let prev_hash = self.blocks.last().unwrap().0.block_hash();
281+
let header = create_dummy_header(prev_hash, 42);
282+
self.blocks.push((header, Vec::new()));
283+
}
284+
}
285+
221286
fn block_at(&self, height: u32) -> &(Header, Vec<Transaction>) {
222287
&self.blocks[height as usize]
223288
}
@@ -862,11 +927,15 @@ fn send_mpp_hop_payment(
862927
fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) {
863928
// Since sending/receiving messages may be delayed, `timer_tick_occurred` may cause a node to
864929
// disconnect their counterparty if they're expecting a timely response.
865-
assert!(matches!(
930+
assert!(
931+
matches!(
932+
action,
933+
msgs::ErrorAction::DisconnectPeerWithWarning { msg }
934+
if msg.data.contains("Disconnecting due to timeout awaiting response")
935+
),
936+
"Expected timeout disconnect, got: {:?}",
866937
action,
867-
msgs::ErrorAction::DisconnectPeerWithWarning { msg }
868-
if msg.data.contains("Disconnecting due to timeout awaiting response")
869-
));
938+
);
870939
}
871940

872941
enum ChanType {
@@ -2033,15 +2102,16 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
20332102
assert!(txs.len() >= 1);
20342103
let splice_tx = txs.remove(0);
20352104
assert_eq!(new_funding_txo.txid, splice_tx.compute_txid());
2036-
chain_state.confirm_tx(splice_tx);
2105+
chain_state.add_pending_tx(splice_tx);
20372106
},
20382107
events::Event::SpliceFailed { .. } => {},
20392108
events::Event::DiscardFunding {
2040-
funding_info: events::FundingInfo::Contribution { .. },
2109+
funding_info: events::FundingInfo::Contribution { .. }
2110+
| events::FundingInfo::Tx { .. },
20412111
..
20422112
} => {},
20432113

2044-
_ => panic!("Unhandled event"),
2114+
_ => panic!("Unhandled event: {:?}", event),
20452115
}
20462116
}
20472117
while nodes[$node].needs_pending_htlc_processing() {
@@ -2505,13 +2575,31 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
25052575
},
25062576

25072577
// Sync node by 1 block to cover confirmation of a transaction.
2508-
0xa8 => sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, Some(1)),
2509-
0xa9 => sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, Some(1)),
2510-
0xaa => sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, Some(1)),
2578+
0xa8 => {
2579+
chain_state.confirm_pending_txs();
2580+
sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, Some(1));
2581+
},
2582+
0xa9 => {
2583+
chain_state.confirm_pending_txs();
2584+
sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, Some(1));
2585+
},
2586+
0xaa => {
2587+
chain_state.confirm_pending_txs();
2588+
sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, Some(1));
2589+
},
25112590
// Sync node to chain tip to cover confirmation of a transaction post-reorg-risk.
2512-
0xab => sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, None),
2513-
0xac => sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, None),
2514-
0xad => sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, None),
2591+
0xab => {
2592+
chain_state.confirm_pending_txs();
2593+
sync_with_chain_state(&mut chain_state, &nodes[0], &mut node_height_a, None);
2594+
},
2595+
0xac => {
2596+
chain_state.confirm_pending_txs();
2597+
sync_with_chain_state(&mut chain_state, &nodes[1], &mut node_height_b, None);
2598+
},
2599+
0xad => {
2600+
chain_state.confirm_pending_txs();
2601+
sync_with_chain_state(&mut chain_state, &nodes[2], &mut node_height_c, None);
2602+
},
25152603

25162604
0xb0 | 0xb1 | 0xb2 => {
25172605
// Restart node A, picking among the in-flight `ChannelMonitor`s to use based on

0 commit comments

Comments
 (0)