From 30017ce154bc98cc67a596982719e1dfda2e801d Mon Sep 17 00:00:00 2001 From: Sparrow Date: Fri, 23 Oct 2026 16:07:41 +0530 Subject: [PATCH 1/2] fix(chain): track spends and conflicts on null prevout for non-coinbase txs TxGraph::insert_tx previously skipped every null previous output unconditionally when indexing spends, under the assumption that any null prevout implied the transaction was a coinbase. However, under Bitcoin consensus rules, a transaction is only a coinbase if it has exactly one input and that input has a null outpoint (tx.is_coinbase()). A malformed transaction with a null prevout and another input is not a coinbase, yet its null input was skipped. Two such transactions sharing only the null prevout were therefore not recorded in spends, missed by direct_conflicts, and both erroneously admitted to CanonicalView. This change: - Updates TxGraph::insert_tx to skip indexing spends only if tx.is_coinbase(). Non-coinbase transactions have all their inputs indexed, including null prevouts. - Updates TxGraph::direct_conflicts to ignore coinbases, as valid coinbases have no previous outputs and cannot conflict on inputs. - Adds regression tests covering null prevout conflicts on non-coinbase txs, conflict pruning in CanonicalView, and isolation from valid coinbases. Fixes #2286 Signed-off-by: Sparrow --- crates/chain/src/tx_graph.rs | 17 +-- crates/chain/tests/test_null_outpoint.rs | 155 +++++++++++++++++++++++ 2 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 crates/chain/tests/test_null_outpoint.rs diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 3c6ab69e5..570db2459 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -567,10 +567,12 @@ impl TxGraph { &'g self, tx: &'g Transaction, ) -> impl Iterator + 'g { + let is_coinbase = tx.is_coinbase(); let txid = tx.compute_txid(); tx.input .iter() .enumerate() + .filter(move |_| !is_coinbase) .filter_map(move |(vin, txin)| self.spends.get(&txin.previous_output).zip(Some(vin))) .flat_map(|(spends, vin)| core::iter::repeat(vin).zip(spends.iter().cloned())) .filter(move |(_, conflicting_txid)| *conflicting_txid != txid) @@ -721,15 +723,14 @@ impl TxGraph { } } partial_tx => { - for txin in &tx.input { - // this means the tx is coinbase so there is no previous output - if txin.previous_output.is_null() { - continue; + // Coinbase transactions have no previous outputs to spend. + if !tx.is_coinbase() { + for txin in &tx.input { + self.spends + .entry(txin.previous_output) + .or_default() + .insert(txid); } - self.spends - .entry(txin.previous_output) - .or_default() - .insert(txid); } *partial_tx = TxNodeInternal::Whole(tx.clone()); changeset.txs.insert(tx); diff --git a/crates/chain/tests/test_null_outpoint.rs b/crates/chain/tests/test_null_outpoint.rs new file mode 100644 index 000000000..6bb3cebfd --- /dev/null +++ b/crates/chain/tests/test_null_outpoint.rs @@ -0,0 +1,155 @@ +use bdk_chain::{local_chain::LocalChain, BlockId, TxGraph}; +use bitcoin::{ + absolute, hashes::Hash, transaction, Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, + TxOut, Txid, +}; + +#[test] +fn null_prevout_conflicts_remain_canonical() { + let mut graph = TxGraph::::default(); + for tag in [1u8, 2] { + let tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: [ + OutPoint::null(), + OutPoint::new(Txid::from_byte_array([tag; 32]), 0), + ] + .map(|previous_output| TxIn { + previous_output, + ..Default::default() + }) + .to_vec(), + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::new(), + }], + }; + assert!(!tx.is_coinbase()); + let _ = graph.insert_seen_at(tx.compute_txid(), u64::from(tag)); + let _ = graph.insert_tx(tx); + } + let chain = + LocalChain::from_blocks([(0, BlockHash::all_zeros())].into_iter().collect()).unwrap(); + let view = chain.canonical_view(&graph, chain.tip().block_id(), Default::default()); + let count = view.txs().len(); + assert!(count <= 1, "canonical spenders of null outpoint: {count}"); +} + +#[test] +fn test_null_prevout_direct_conflicts() { + let mut graph = TxGraph::::default(); + + let tx1 = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![ + TxIn { + previous_output: OutPoint::null(), + ..Default::default() + }, + TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([1; 32]), 0), + ..Default::default() + }, + ], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::new(), + }], + }; + let tx2 = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![ + TxIn { + previous_output: OutPoint::null(), + ..Default::default() + }, + TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([2; 32]), 0), + ..Default::default() + }, + ], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::new(), + }], + }; + + assert!(!tx1.is_coinbase()); + assert!(!tx2.is_coinbase()); + + let txid1 = tx1.compute_txid(); + let txid2 = tx2.compute_txid(); + + let _ = graph.insert_tx(tx1.clone()); + let _ = graph.insert_tx(tx2.clone()); + + // Both non-coinbase transactions spend OutPoint::null(), so they must conflict with each other + let conflicts1: Vec<_> = graph.direct_conflicts(&tx1).collect(); + let conflicts2: Vec<_> = graph.direct_conflicts(&tx2).collect(); + + assert_eq!(conflicts1, vec![(0, txid2)]); + assert_eq!(conflicts2, vec![(0, txid1)]); + assert_eq!(graph.outspends(OutPoint::null()).len(), 2); +} + +#[test] +fn test_coinbase_isolated_from_null_prevout_conflicts() { + let mut graph = TxGraph::::default(); + + // Valid coinbase transaction + let coinbase = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new(), + }], + }; + assert!(coinbase.is_coinbase()); + let cb_txid = coinbase.compute_txid(); + let _ = graph.insert_tx(coinbase.clone()); + + // Coinbase does not count as spending OutPoint::null() + assert!(graph.outspends(OutPoint::null()).is_empty()); + assert_eq!(graph.direct_conflicts(&coinbase).count(), 0); + + // Malformed non-coinbase transaction spending OutPoint::null() + let malformed = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![ + TxIn { + previous_output: OutPoint::null(), + ..Default::default() + }, + TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([3; 32]), 0), + ..Default::default() + }, + ], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::new(), + }], + }; + assert!(!malformed.is_coinbase()); + let malformed_txid = malformed.compute_txid(); + let _ = graph.insert_tx(malformed.clone()); + + // The coinbase still has no conflicts and is not considered in conflict with the malformed tx + assert_eq!(graph.direct_conflicts(&coinbase).count(), 0); + // The malformed tx does not conflict with the coinbase either + assert_eq!(graph.direct_conflicts(&malformed).count(), 0); + // Only the malformed tx is listed under outspends of null outpoint + let null_outspends = graph.outspends(OutPoint::null()); + assert_eq!(null_outspends.len(), 1); + assert!(null_outspends.contains(&malformed_txid)); + assert!(!null_outspends.contains(&cb_txid)); +} From 7e8d62fd1cc9d3dab2bda7d22287bccbcf25d27a Mon Sep 17 00:00:00 2001 From: Sparrow Date: Fri, 23 Oct 2026 17:16:31 +0530 Subject: [PATCH 2/2] ci: drop obsolete yoke-derive pin for MSRV and pin ubuntu-22.04 for check-wasm Drop yoke-derive pin from ci/pin-msrv.sh now that yoke-derive 0.8.4 has been released with rust-version 1.82 and fixes compatibility with Rust 1.85.0. Pin check-wasm runner to ubuntu-22.04 to prevent apt-key missing error and package restart hangs on newer Ubuntu releases. Signed-off-by: Sparrow --- .github/workflows/cont_integration.yml | 2 +- ci/pin-msrv.sh | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index e4e788f06..2ea618c4a 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -238,7 +238,7 @@ jobs: check-wasm: needs: prepare name: Check WASM - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 env: CC: clang-14 CFLAGS: -I/usr/include diff --git a/ci/pin-msrv.sh b/ci/pin-msrv.sh index 703558e41..265ccbc52 100755 --- a/ci/pin-msrv.sh +++ b/ci/pin-msrv.sh @@ -14,4 +14,3 @@ cargo update -p idna_adapter --precise "1.2.1" cargo update -p icu_normalizer --precise "2.1.1" cargo update -p icu_provider --precise "2.1.1" cargo update -p icu_locale_core --precise "2.1.1" -cargo update -p yoke-derive --precise "0.8.2"