Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/code_coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ jobs:
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install lcov tools
run: sudo apt-get install lcov -y
# Boost is for `bdk_script_verify`'s `bitcoinkernel` dev-dependency, which builds Bitcoin Core.
- name: Install lcov tools and Boost
run: sudo apt-get update && sudo apt-get install -y lcov libboost-dev
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/cont_integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,32 @@ jobs:
- name: Test (all features)
run: cargo test --all-features

build-script-verify:
needs: prepare
name: bdk_script_verify
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./crates/script_verify
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ needs.prepare.outputs.rust_version }}
cache: true
# The differential tests' `bitcoinkernel` dev-dependency builds Bitcoin Core, which needs Boost.
- name: Install Boost
run: sudo apt-get update && sudo apt-get install -y libboost-dev
- name: Build (default features)
run: cargo build
- name: Build (no default features)
run: cargo build --no-default-features
- name: Test (all features)
run: cargo test --all-features

build-chain:
needs: prepare
name: bdk_chain
Expand Down Expand Up @@ -316,6 +342,9 @@ jobs:
toolchain: ${{ needs.prepare.outputs.rust_version }}
cache: true
components: clippy
# `--all-targets` builds `bdk_script_verify`'s `bitcoinkernel` dev-dependency, which needs Boost.
- name: Install Boost
run: sudo apt-get update && sudo apt-get install -y libboost-dev
- name: Check clippy
run: cargo clippy --all-features --all-targets -- -D warnings

Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ resolver = "2"
members = [
"crates/chain",
"crates/core",
"crates/script_verify",
"crates/file_store",
"crates/electrum",
"crates/esplora",
Expand Down
3 changes: 2 additions & 1 deletion crates/chain/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ workspace = true
[dependencies]
bitcoin = { version = "0.32.0", default-features = false }
bdk_core = { path = "../core", version = "0.6.2", default-features = false }
bdk_script_verify = { path = "../script_verify", version = "0.1.0", default-features = false }
serde = { version = "1", optional = true, default-features = false, features = ["derive", "rc", "alloc"] }
miniscript = { version = "13.0.0", optional = true, default-features = false }

Expand All @@ -33,7 +34,7 @@ tempfile = "3"

[features]
default = ["std", "miniscript"]
std = ["bitcoin/std", "miniscript?/std", "bdk_core/std"]
std = ["bitcoin/std", "miniscript?/std", "bdk_core/std", "bdk_script_verify/std"]
serde = ["dep:serde", "bitcoin/serde", "miniscript?/serde", "bdk_core/serde"]
hashbrown = ["bdk_core/hashbrown"]
rusqlite = ["std", "dep:rusqlite", "serde"]
Expand Down
144 changes: 138 additions & 6 deletions crates/chain/src/canonical_task.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::collections::{HashMap, HashSet, VecDeque};
use crate::tx_graph::{TxAncestors, TxDescendants};
use crate::validation::TxValidity;
use crate::{Anchor, CanonicalTxs, TxGraph};
use alloc::boxed::Box;
use alloc::collections::BTreeSet;
Expand Down Expand Up @@ -46,6 +47,9 @@ pub struct CanonicalParams {
///
/// In case of conflicting transactions within `assume_canonical`, transactions that appear
/// later in the list (have higher index) have precedence.
///
/// This overrides script validation: these transactions and their ancestors are canonical
/// even if [`TxGraph::validate_tx`](crate::TxGraph::validate_tx) finds them invalid.
pub assume_canonical: Vec<Txid>,
}

Expand All @@ -56,6 +60,11 @@ pub struct CanonicalParams {
/// (via [`CanonicalReason`](crate::CanonicalReason)). The output is a [`CanonicalTxs`] which can
/// then be further processed by [`CanonicalViewTask`](crate::CanonicalViewTask) to resolve reasons
/// into [`ChainPosition`](crate::ChainPosition)s.
///
/// For unconfirmed candidates, retained copies are checked in the graph's witness preference
/// order. A Valid copy is preferred over Unknown; only an all-Invalid set is rejected. The output
/// carries the selected whole copy, which may differ from [`TxGraph::get_tx`]. Anchored and assumed
/// transactions retain the graph's preferred copy without script checks.
pub struct CanonicalTask<'g, A> {
tx_graph: &'g TxGraph<A>,
chain_tip: BlockId,
Expand All @@ -67,6 +76,11 @@ pub struct CanonicalTask<'g, A> {

canonical: CanonicalMap<A>,
not_canonical: NotCanonicalSet,
/// The selected eligible copy, or None if all copies are Invalid. The immutable graph makes
/// these results reusable even after the walk that computed them is undone.
selected_copies: HashMap<Txid, Option<Arc<Transaction>>>,
#[cfg(test)]
validations: usize,

// Store canonical transactions in order
canonical_order: Vec<Txid>,
Expand Down Expand Up @@ -253,6 +267,9 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> {

canonical: HashMap::new(),
not_canonical: HashSet::new(),
selected_copies: HashMap::new(),
#[cfg(test)]
validations: 0,

canonical_order: Vec::new(),
current_stage: CanonicalStage::default(),
Expand All @@ -267,9 +284,9 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> {
let starting_txid = txid;
let mut is_starting_tx = true;

// We keep track of changes made so far so that we can undo it later in case we detect that
// `tx` double spends itself.
let mut detected_self_double_spend = false;
// We keep track of changes made so far so that we can undo them if `tx` turns out not to be
// canonical: it double spends itself, or it or an ancestor has invalid scripts.
let mut rejected = false;
let mut undo_not_canonical = Vec::<Txid>::new();
let mut staged_canonical = Vec::<(Txid, Arc<Transaction>, CanonicalReason<A>)>::new();

Expand All @@ -294,6 +311,43 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> {
Entry::Vacant(entry) => entry,
};

// Validation guards against acting on unconfirmed transactions that can never
// confirm. An anchor in the best chain or an explicit assumption outranks it. An
// unknown verdict does not bar it: the graph may just lack a parent.
let tx = if this_reason.is_unconfirmed() {
let selected = self.selected_copies.entry(this_txid).or_insert_with(|| {
let mut copies = self.tx_graph.tx_copies(this_txid).collect::<Vec<_>>();
copies.sort_by_cached_key(|copy| {
(
crate::tx_graph::witness_preference(copy),
copy.compute_wtxid(),
)
});
let mut unknown = None;
for copy in copies {
#[cfg(test)]
{
self.validations += 1;
}
match self.tx_graph.validate_tx(&copy).validity() {
TxValidity::Valid => return Some(copy),
TxValidity::Unknown if unknown.is_none() => unknown = Some(copy),
TxValidity::Unknown | TxValidity::Invalid => {}
}
}
unknown
});
match selected {
Some(copy) => copy.clone(),
None => {
rejected = true;
return None;
}
}
} else {
tx
};

// Prune conflicts
//
// Any conflicts with a canonical tx can be added to `not_canonical`. Descendants
Expand All @@ -316,7 +370,7 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> {

if self.not_canonical.contains(&this_txid) {
// Early exit if self-double-spend is detected.
detected_self_double_spend = true;
rejected = true;
return None;
}

Expand All @@ -327,7 +381,7 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> {
)
.run_until_finished();

if detected_self_double_spend {
if rejected {
// Undo changes
for (txid, _, _) in staged_canonical {
self.canonical.remove(&txid);
Expand Down Expand Up @@ -443,6 +497,15 @@ impl<A: Clone> CanonicalReason<A> {
pub fn is_assumed(&self) -> bool {
matches!(self, CanonicalReason::Assumed { .. })
}

/// Whether this reason leaves the transaction unconfirmed, which is when script validity
/// decides it. Exhaustive so that a new reason has to make that choice.
fn is_unconfirmed(&self) -> bool {
match self {
CanonicalReason::Assumed { .. } | CanonicalReason::Anchor { .. } => false,
CanonicalReason::ObservedIn { .. } => true,
}
}
}

#[cfg(test)]
Expand All @@ -451,7 +514,7 @@ mod tests {
use super::*;
use crate::local_chain::LocalChain;
use crate::ChainPosition;
use bitcoin::{hashes::Hash, BlockHash, TxIn, TxOut};
use bitcoin::{hashes::Hash, opcodes, BlockHash, OutPoint, ScriptBuf, TxIn, TxOut};

#[test]
fn test_canonicalization_task_sans_io() {
Expand Down Expand Up @@ -503,4 +566,73 @@ mod tests {
// Should be confirmed (anchored)
assert!(matches!(canon_tx.pos, ChainPosition::Confirmed { .. }));
}

#[test]
fn a_valid_ancestor_of_a_rejected_candidate_is_checked_once() {
let chain = LocalChain::from_blocks([(0, BlockHash::all_zeros())].into()).unwrap();
let anyone = ScriptBuf::from_bytes(vec![opcodes::OP_TRUE.to_u8()]);
let unspendable = ScriptBuf::new_op_return([]);
let txout = |script_pubkey: &ScriptBuf| TxOut {
value: bitcoin::Amount::from_sat(1000),
script_pubkey: script_pubkey.clone(),
};
let spend = |prevouts: &[OutPoint], outputs: usize| bitcoin::Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::absolute::LockTime::ZERO,
input: prevouts
.iter()
.map(|&previous_output| TxIn {
previous_output,
..TxIn::default()
})
.collect(),
output: vec![txout(&anyone); outputs],
};

let mut tx_graph = TxGraph::<crate::ConfirmationBlockTime>::default();
let anyone_coin = OutPoint::new(Txid::from_byte_array([1; 32]), 0);
let unspendable_coin = OutPoint::new(Txid::from_byte_array([2; 32]), 0);
let _ = tx_graph.insert_txout(anyone_coin, txout(&anyone));
let _ = tx_graph.insert_txout(unspendable_coin, txout(&unspendable));
let shared = spend(&[anyone_coin], 2);
let invalid = spend(&[unspendable_coin], 1);
let rejected = spend(
&[
OutPoint::new(shared.compute_txid(), 0),
OutPoint::new(invalid.compute_txid(), 0),
],
1,
);
let later = spend(&[OutPoint::new(shared.compute_txid(), 1)], 1);
for tx in [&shared, &invalid, &rejected, &later] {
let _ = tx_graph.insert_tx(tx.clone());
}
let mut bogus_shared = shared.clone();
bogus_shared.input[0].witness = bitcoin::Witness::from_slice(&[vec![0]]);
let _ = tx_graph.insert_tx(bogus_shared);
assert_ne!(*tx_graph.get_tx(shared.compute_txid()).unwrap(), shared);
let _ = tx_graph.insert_seen_at(rejected.compute_txid(), 2);
let _ = tx_graph.insert_seen_at(later.compute_txid(), 1);

let mut task = CanonicalTask::new(
&tx_graph,
chain.tip().block_id(),
CanonicalParams::default(),
);
assert!(task.next_query().is_none(), "nothing is anchored");

assert_eq!(
task.validations, 5,
"each copy is checked once, including both copies of the shared ancestor"
);
assert_eq!(
task.selected_copies[&shared.compute_txid()].as_deref(),
Some(&shared)
);
let mut canonical = task.canonical.keys().copied().collect::<Vec<_>>();
canonical.sort();
let mut expected = vec![shared.compute_txid(), later.compute_txid()];
expected.sort();
assert_eq!(canonical, expected);
}
}
1 change: 1 addition & 0 deletions crates/chain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ mod canonical;
pub use canonical::*;
mod canonical_view_task;
pub use canonical_view_task::*;
pub mod validation;

#[doc(hidden)]
pub mod example_utils;
Expand Down
Loading
Loading