From 055a3c50a608408e41356a21cc56e29074771121 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:19:15 -0700 Subject: [PATCH 01/12] primitives: one merkle root for store and consensus Store tip revalidation and consensus block checks both call the primitives helpers, including the CVE-2012-2459 mutation flag. --- Cargo.lock | 3 + crates/rbitcoin-consensus/src/block/mod.rs | 4 +- crates/rbitcoin-primitives/Cargo.toml | 1 + crates/rbitcoin-primitives/src/lib.rs | 4 + crates/rbitcoin-primitives/src/merkle.rs | 139 +++++++++++++++++++++ crates/rbitcoin-store/src/integrity.rs | 94 ++------------ crates/rbitcoin-store/src/lib.rs | 4 +- 7 files changed, 158 insertions(+), 91 deletions(-) create mode 100644 crates/rbitcoin-primitives/src/merkle.rs diff --git a/Cargo.lock b/Cargo.lock index b26a2d6fb0..3df18ed8ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,6 +1019,9 @@ dependencies = [ [[package]] name = "rbitcoin-primitives" version = "0.8.99" +dependencies = [ + "bitcoin_hashes 0.14.101", +] [[package]] name = "rbitcoin-query" diff --git a/crates/rbitcoin-consensus/src/block/mod.rs b/crates/rbitcoin-consensus/src/block/mod.rs index 6efc7d57c1..e97ef6945c 100644 --- a/crates/rbitcoin-consensus/src/block/mod.rs +++ b/crates/rbitcoin-consensus/src/block/mod.rs @@ -185,7 +185,7 @@ pub fn validate_block_structure_with_pres( // Core CheckMerkleRoot runs before any body rule. A body the header does // not commit to is the peer's fault, not the block hash's. let txids: Vec<[u8; 32]> = pres.iter().map(|p| p.txid).collect(); - let (merkle, mutated) = rbitcoin_store::merkle_root_mutated(&txids); + let (merkle, mutated) = rbitcoin_primitives::merkle_root_mutated(&txids); if merkle != block.header.merkle_root.to_byte_array() { return Err(ConsensusError::BadBlock("merkle root mismatch")); } @@ -583,7 +583,7 @@ fn check_witness_commitment_with_wtxids( /// Merkle root over 32-byte leaves (txid or wtxid tree). Public for tests. pub(crate) fn merkle_root_bytes(leaves: &[[u8; 32]]) -> [u8; 32] { - rbitcoin_store::merkle_root_from_txids(leaves) + rbitcoin_primitives::merkle_root_from_txids(leaves) } /// BIP34: coinbase scriptSig must start with the block height as a script integer diff --git a/crates/rbitcoin-primitives/Cargo.toml b/crates/rbitcoin-primitives/Cargo.toml index 76bd4e9883..0b783369af 100644 --- a/crates/rbitcoin-primitives/Cargo.toml +++ b/crates/rbitcoin-primitives/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true description = "Shared types for the rbitcoin node" [dependencies] +bitcoin_hashes = { workspace = true } [lints] workspace = true diff --git a/crates/rbitcoin-primitives/src/lib.rs b/crates/rbitcoin-primitives/src/lib.rs index 0337d2c314..e7d98252dd 100644 --- a/crates/rbitcoin-primitives/src/lib.rs +++ b/crates/rbitcoin-primitives/src/lib.rs @@ -7,6 +7,7 @@ mod compact; mod hex; mod loc_simd; mod median_time; +mod merkle; mod script_sigops; mod scriptnum; @@ -20,6 +21,9 @@ pub use hex::{ }; pub use loc_simd::{deinterleave_pairs_u8x8, inclusive_u8x8_times_8}; pub use median_time::median_time_past_times; +pub use merkle::{ + merkle_branch, merkle_root_from_branch, merkle_root_from_txids, merkle_root_mutated, +}; pub use script_sigops::script_sigop_count; pub use scriptnum::{ scriptnum_decode, scriptnum_decode_width, scriptnum_encode, scriptnum_is_minimal, diff --git a/crates/rbitcoin-primitives/src/merkle.rs b/crates/rbitcoin-primitives/src/merkle.rs new file mode 100644 index 0000000000..24c6d9e59b --- /dev/null +++ b/crates/rbitcoin-primitives/src/merkle.rs @@ -0,0 +1,139 @@ +//! Bitcoin block merkle root from leaf txids (internal byte order). +//! +//! The mutation flag is Core's `ComputeMerkleRoot` check (CVE-2012-2459): +//! some level held two equal hashes in one pair before odd padding, so a +//! shorter tx list has the same root. + +use bitcoin_hashes::{sha256, Hash, HashEngine}; + +/// Bitcoin block merkle root from leaf txids (internal byte order). +pub fn merkle_root_from_txids(leaves: &[[u8; 32]]) -> [u8; 32] { + merkle_root_mutated(leaves).0 +} + +/// [`merkle_root_from_txids`] plus Core's `ComputeMerkleRoot` mutation flag +/// (CVE-2012-2459). +pub fn merkle_root_mutated(leaves: &[[u8; 32]]) -> ([u8; 32], bool) { + if leaves.is_empty() { + return ([0u8; 32], false); + } + let mut mutated = false; + let mut level: Vec<[u8; 32]> = leaves.to_vec(); + while level.len() > 1 { + mutated |= level.chunks_exact(2).any(|pair| pair[0] == pair[1]); + level = merkle_parent_level(level); + } + (level[0], mutated) +} + +/// Sibling hashes from `leaves[index]` up to the root, deepest first. Empty for +/// one leaf. Folding the leaf with them (left when the index bit is 0) +/// reproduces [`merkle_root_from_txids`]. The leaf's own value feeds no entry. +pub fn merkle_branch(leaves: &[[u8; 32]], mut index: usize) -> Vec<[u8; 32]> { + let mut branch = Vec::new(); + let mut level: Vec<[u8; 32]> = leaves.to_vec(); + while level.len() > 1 { + let sibling = (index ^ 1).min(level.len() - 1); + branch.push(level[sibling]); + level = merkle_parent_level(level); + index /= 2; + } + branch +} + +/// Root reached by folding `leaf` at `index` with its [`merkle_branch`]. The +/// leaf is the left child when the index bit at that depth is 0. +pub fn merkle_root_from_branch(leaf: [u8; 32], branch: &[[u8; 32]], index: usize) -> [u8; 32] { + branch + .iter() + .enumerate() + .fold(leaf, |acc, (depth, sibling)| { + if (index >> depth) & 1 == 0 { + hash256_concat(&acc, sibling) + } else { + hash256_concat(sibling, &acc) + } + }) +} + +fn merkle_parent_level(mut level: Vec<[u8; 32]>) -> Vec<[u8; 32]> { + if level.len() % 2 == 1 { + level.push(level[level.len() - 1]); + } + level + .chunks_exact(2) + .map(|pair| hash256_concat(&pair[0], &pair[1])) + .collect() +} + +fn hash256_concat(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] { + let mut eng = sha256::HashEngine::default(); + eng.input(a); + eng.input(b); + let mid = sha256::Hash::from_engine(eng); + let mut eng2 = sha256::HashEngine::default(); + eng2.input(mid.as_byte_array()); + sha256::Hash::from_engine(eng2).to_byte_array() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merkle_root_single_and_pair() { + let a = [1u8; 32]; + assert_eq!(merkle_root_from_txids(&[a]), a); + let b = [2u8; 32]; + let root = merkle_root_from_txids(&[a, b]); + assert_eq!(root, hash256_concat(&a, &b)); + } + + /// CVE-2012-2459: a repeated tail at any level keeps the root and is + /// flagged. The shorter list and an unpaired repeat are not. + #[test] + fn merkle_mutation_flags_a_repeated_tail_at_any_level() { + let l: Vec<[u8; 32]> = (1..=6u8).map(|n| [n; 32]).collect(); + let (root3, mutated) = merkle_root_mutated(&l[..3]); + assert!(!mutated); + assert_eq!( + merkle_root_mutated(&[l[0], l[1], l[2], l[2]]), + (root3, true) + ); + assert!(!merkle_root_mutated(&[l[0], l[1], l[1]]).1); + + let (root6, mutated) = merkle_root_mutated(&l); + assert!(!mutated); + let mut tail = l.clone(); + tail.extend_from_slice(&l[4..]); + assert_eq!(merkle_root_mutated(&tail), (root6, true)); + assert_eq!(merkle_root_mutated(&[]), ([0u8; 32], false)); + } + + #[test] + fn merkle_path_folds_to_root_at_every_index() { + let (a, b, c) = ([1u8; 32], [2u8; 32], [3u8; 32]); + assert!(merkle_branch(&[a], 0).is_empty()); + assert_eq!(merkle_branch(&[a, b], 0), vec![b]); + assert_eq!( + merkle_branch(&[a, b, c], 0), + vec![b, hash256_concat(&c, &c)] + ); + assert_eq!( + merkle_branch(&[a, b, c], 2), + vec![c, hash256_concat(&a, &b)] + ); + for n in 1..=9u8 { + let leaves: Vec<[u8; 32]> = (0..n).map(|i| [i + 10; 32]).collect(); + let root = merkle_root_from_txids(&leaves); + for (index, leaf) in leaves.iter().enumerate() { + let branch = merkle_branch(&leaves, index); + assert_eq!( + merkle_root_from_branch(*leaf, &branch, index), + root, + "n={n} index={index}" + ); + } + } + } +} diff --git a/crates/rbitcoin-store/src/integrity.rs b/crates/rbitcoin-store/src/integrity.rs index 929ad4ac91..a1f16f68b9 100644 --- a/crates/rbitcoin-store/src/integrity.rs +++ b/crates/rbitcoin-store/src/integrity.rs @@ -11,8 +11,9 @@ use crate::error::StoreError; use crate::header_table::block_header_hash; use crate::store::Store; -use bitcoin_hashes::{sha256, Hash, HashEngine}; -use rbitcoin_primitives::{schema_file_openable, Fk, Height, SCHEMA_VERSION, STORE_MAGIC}; +use rbitcoin_primitives::{ + merkle_root_from_txids, schema_file_openable, Fk, Height, SCHEMA_VERSION, STORE_MAGIC, +}; use std::fs::OpenOptions; use std::io::{Read, Write}; use std::path::Path; @@ -171,78 +172,6 @@ impl TipRevalidateReport { } } -/// Bitcoin block merkle root from leaf txids (internal byte order). -pub fn merkle_root_from_txids(leaves: &[[u8; 32]]) -> [u8; 32] { - merkle_root_mutated(leaves).0 -} - -/// [`merkle_root_from_txids`] plus Core's `ComputeMerkleRoot` mutation flag -/// (CVE-2012-2459): some level held two equal hashes in one pair before odd -/// padding, so a shorter tx list has the same root. -pub fn merkle_root_mutated(leaves: &[[u8; 32]]) -> ([u8; 32], bool) { - if leaves.is_empty() { - return ([0u8; 32], false); - } - let mut mutated = false; - let mut level: Vec<[u8; 32]> = leaves.to_vec(); - while level.len() > 1 { - mutated |= level.chunks_exact(2).any(|pair| pair[0] == pair[1]); - level = merkle_parent_level(level); - } - (level[0], mutated) -} - -/// Sibling hashes from `leaves[index]` up to the root, deepest first. Empty for -/// one leaf. Folding the leaf with them (left when the index bit is 0) -/// reproduces [`merkle_root_from_txids`]. The leaf's own value feeds no entry. -pub fn merkle_branch(leaves: &[[u8; 32]], mut index: usize) -> Vec<[u8; 32]> { - let mut branch = Vec::new(); - let mut level: Vec<[u8; 32]> = leaves.to_vec(); - while level.len() > 1 { - let sibling = (index ^ 1).min(level.len() - 1); - branch.push(level[sibling]); - level = merkle_parent_level(level); - index /= 2; - } - branch -} - -/// Root reached by folding `leaf` at `index` with its [`merkle_branch`]. The -/// leaf is the left child when the index bit at that depth is 0. -pub fn merkle_root_from_branch(leaf: [u8; 32], branch: &[[u8; 32]], index: usize) -> [u8; 32] { - branch - .iter() - .enumerate() - .fold(leaf, |acc, (depth, sibling)| { - if (index >> depth) & 1 == 0 { - hash256_concat(&acc, sibling) - } else { - hash256_concat(sibling, &acc) - } - }) -} - -// Bitcoin pairs an odd last node with itself. -fn merkle_parent_level(mut level: Vec<[u8; 32]>) -> Vec<[u8; 32]> { - if level.len() % 2 == 1 { - level.push(level[level.len() - 1]); - } - level - .chunks_exact(2) - .map(|pair| hash256_concat(&pair[0], &pair[1])) - .collect() -} - -fn hash256_concat(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] { - let mut eng = sha256::HashEngine::default(); - eng.input(a); - eng.input(b); - let mid = sha256::Hash::from_engine(eng); - let mut eng2 = sha256::HashEngine::default(); - eng2.input(mid.as_byte_array()); - sha256::Hash::from_engine(eng2).to_byte_array() -} - impl Store { /// Persist tip seal after a complete Class C tip barrier (connect or disconnect). /// @@ -663,7 +592,7 @@ mod tests { use super::*; use crate::header_table::HeaderRecord; use crate::tx_table::{InputRecord, OutputRecord, TxRecord}; - use rbitcoin_primitives::Fk; + use rbitcoin_primitives::{merkle_branch, merkle_root_from_branch, merkle_root_mutated, Fk}; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -729,7 +658,7 @@ mod tests { assert_eq!(merkle_root_from_txids(&[a]), a); let b = [2u8; 32]; let root = merkle_root_from_txids(&[a, b]); - assert_eq!(root, hash256_concat(&a, &b)); + assert_eq!(root, merkle_root_from_branch(a, &[b], 0)); } /// CVE-2012-2459: a repeated tail at any level keeps the root and is @@ -760,26 +689,17 @@ mod tests { assert_eq!(merkle_branch(&[a, b], 0), vec![b]); assert_eq!( merkle_branch(&[a, b, c], 0), - vec![b, hash256_concat(&c, &c)] + vec![b, merkle_root_from_txids(&[c, c])] ); assert_eq!( merkle_branch(&[a, b, c], 2), - vec![c, hash256_concat(&a, &b)] + vec![c, merkle_root_from_txids(&[a, b])] ); for n in 1..=9u8 { let leaves: Vec<[u8; 32]> = (0..n).map(|i| [i + 10; 32]).collect(); let root = merkle_root_from_txids(&leaves); for (index, leaf) in leaves.iter().enumerate() { let branch = merkle_branch(&leaves, index); - let mut h = *leaf; - for (depth, sib) in branch.iter().enumerate() { - h = if (index >> depth) & 1 == 0 { - hash256_concat(&h, sib) - } else { - hash256_concat(sib, &h) - }; - } - assert_eq!(h, root, "n={n} index={index}"); assert_eq!( merkle_root_from_branch(*leaf, &branch, index), root, diff --git a/crates/rbitcoin-store/src/lib.rs b/crates/rbitcoin-store/src/lib.rs index 792666fb7d..7a53f62ffb 100644 --- a/crates/rbitcoin-store/src/lib.rs +++ b/crates/rbitcoin-store/src/lib.rs @@ -82,9 +82,9 @@ pub use idx_body_pipeline::{BodyMode as IdxBodyMode, IdxBodyJob, SpendSlotRun}; pub use index_build_uring::{read_index_window, IndexBlock, IndexHeight, IndexWindow}; pub use input::InputEdge; pub use int_map::{FkMap, FkSet, U32Map, U64IdentityHasher, U64Map, U64Set}; -pub use integrity::{ +pub use integrity::VERIFY_TIP_BLOCKS; +pub use rbitcoin_primitives::{ merkle_branch, merkle_root_from_branch, merkle_root_from_txids, merkle_root_mutated, - VERIFY_TIP_BLOCKS, }; pub use io_backend::{ReadIoBackend, WriteIoBackend}; pub use point_table::PointRecord; From 4c51e2e1002a4f0a73d244067feb749660d9ed5a Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:19:16 -0700 Subject: [PATCH 02/12] consensus: header rules take parent context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_header decides linkage, MTP, bits, and proof-of-work from caller facts. The read façade still returns the two confirm retry strings when a parent plan or confirmed header is missing. --- crates/rbitcoin-consensus/src/header.rs | 274 +++++++++++++++++++++--- crates/rbitcoin-consensus/src/lib.rs | 4 +- 2 files changed, 243 insertions(+), 35 deletions(-) diff --git a/crates/rbitcoin-consensus/src/header.rs b/crates/rbitcoin-consensus/src/header.rs index 8eb7f8f367..8913d3f1b2 100644 --- a/crates/rbitcoin-consensus/src/header.rs +++ b/crates/rbitcoin-consensus/src/header.rs @@ -6,6 +6,77 @@ use bitcoin::{CompactTarget, Target}; use rbitcoin_primitives::Height; use rbitcoin_query::Query; +/// Parent header facts for [`check_header`]. Copied so a height above zero can +/// use the same parent for linkage and for the work calculation. +#[derive(Clone, Copy)] +pub struct ParentHeader { + pub hash: [u8; 32], + pub time: u32, + pub bits: CompactTarget, +} + +/// Header rules from the header bytes plus caller facts. No datadir. +/// +/// `mtp_times` ends at the parent (up to 11 timestamps). `period_first_time` +/// is the header `interval` blocks before a retarget height. `bits_at` supplies +/// earlier `nBits` for the testnet min-difficulty walk. +pub fn check_header( + params: &ChainParams, + height: Height, + header: &Header, + hash: [u8; 32], + parent: Option, + mtp_times: &[u32], + period_first_time: Option, + mut bits_at: impl FnMut(u32) -> Option, +) -> Result<(), ConsensusError> { + let hash_bh = bitcoin::BlockHash::from_byte_array(hash); + if height.0 == 0 { + if !check_genesis_hash(params, hash_bh) { + return Err(ConsensusError::BadHeader("genesis hash mismatch")); + } + let expected = crate::params::genesis_block(params).header.bits; + if header.bits != expected { + return Err(ConsensusError::BadHeader("incorrect proof of work bits")); + } + return pow_hash_meets_target(hash, header.bits, params.pow_limit); + } + + let parent = parent.ok_or(ConsensusError::BadPrev)?; + if parent.hash != header.prev_blockhash.to_byte_array() { + return Err(ConsensusError::BadPrev); + } + let mtp = median_time_past_times(mtp_times) + .map_err(|_| ConsensusError::BadHeader("empty median time"))?; + if header.time <= mtp { + return Err(ConsensusError::BadHeader("timestamp <= median-time-past")); + } + check_header_version_and_future_time(params, height, header)?; + + let interval = params.difficulty_adjustment_interval(); + if interval > 0 + && height.0.is_multiple_of(interval) + && !params.no_pow_retargeting() + && period_first_time.is_none() + { + return Err(ConsensusError::BadHeader("missing retarget first header")); + } + let expected = next_work_bits( + params, + height.0, + parent.bits, + parent.time, + header.time, + period_first_time, + &mut bits_at, + ) + .ok_or(ConsensusError::BadPrev)?; + if header.bits != expected { + return Err(ConsensusError::BadHeader("incorrect proof of work bits")); + } + pow_hash_meets_target(hash, header.bits, params.pow_limit) +} + /// Validate header linkage, MTP, difficulty bits, and proof-of-work. pub fn validate_header( query: &Query, @@ -30,34 +101,41 @@ pub(crate) fn validate_header_hashed( header: &Header, hash: [u8; 32], ) -> Result<(), ConsensusError> { - let hash_bh = bitcoin::BlockHash::from_byte_array(hash); - - if height.0 == 0 { - if !check_genesis_hash(params, hash_bh) { - return Err(ConsensusError::BadHeader("genesis hash mismatch")); - } + let parent = if height.0 == 0 { + None } else { - let prev_height = Height(height.0 - 1); - let (_prev_fk, prev_rec) = query - .header_at_height(prev_height)? - .ok_or(ConsensusError::BadPrev)?; - if prev_rec.hash != header.prev_blockhash.to_byte_array() { - return Err(ConsensusError::BadPrev); - } - - let mtp = median_time_past(query, prev_height)?; - if header.time <= mtp { - return Err(ConsensusError::BadHeader("timestamp <= median-time-past")); - } - check_header_version_and_future_time(params, height, header)?; - } - - let expected_bits = expected_next_bits(query, params, height, header.time)?; - if header.bits != expected_bits { - return Err(ConsensusError::BadHeader("incorrect proof of work bits")); - } - - pow_hash_meets_target(hash, header.bits, params.pow_limit) + query + .header_at_height(Height(height.0 - 1))? + .map(|(_fk, prev)| ParentHeader { + hash: prev.hash, + time: prev.timestamp, + bits: CompactTarget::from_consensus(prev.bits), + }) + }; + let times = if parent.is_some() { + mtp_times_ending_at(query, Height(height.0 - 1))? + } else { + Vec::new() + }; + let period_first = if parent.is_some() { + period_first_time(query, params, height.0)? + } else { + None + }; + check_header( + params, + height, + header, + hash, + parent, + ×, + period_first, + |h| { + header_bits_at(query, Height(h)) + .ok() + .map(CompactTarget::from_consensus) + }, + ) } /// Contextual checks when the parent is a known header, not necessarily on the @@ -131,9 +209,16 @@ pub(crate) fn check_header_version_and_future_time( /// header MTP). Heights still above tip with no plan are retryable load /// incomplete — not permanent `BadPrev` (that silently split batches to n=1). pub fn median_time_past(query: &Query, height: Height) -> Result { + let times = mtp_times_ending_at(query, height)?; + median_time_past_times(×).map_err(|_| ConsensusError::BadHeader("empty median time")) +} + +/// Timestamps ending at `height` for [`median_time_past`]. A missing plan above +/// the confirmed tip stays a retryable load fault. A gap at or below the tip +/// is [`ConsensusError::BadPrev`]. +fn mtp_times_ending_at(query: &Query, height: Height) -> Result, ConsensusError> { if let Some((n, buf)) = query.store().mtp_times_at(height) { - return median_time_past_times(&buf[..n as usize]) - .map_err(|_| ConsensusError::BadHeader("empty median time")); + return Ok(buf[..n as usize].to_vec()); } let mut times = Vec::with_capacity(11); let start = height.0.saturating_sub(10); @@ -154,7 +239,7 @@ pub fn median_time_past(query: &Query, height: Height) -> Result Date: Sat, 17 Oct 2026 13:19:17 -0700 Subject: [PATCH 03/12] consensus: spend rules take borrowed facts BIP30 overwrite and coinbase maturity decide from heights the caller already loaded. Assemble no longer reads the milestone gate or flushes confirm stats; the confirm pipeline does both. --- crates/rbitcoin-consensus/src/block/mod.rs | 97 ++++++++++++++----- .../src/block/structure_rule_tests.rs | 56 ++++++++--- .../src/confirm_run/phases.rs | 9 +- 3 files changed, 118 insertions(+), 44 deletions(-) diff --git a/crates/rbitcoin-consensus/src/block/mod.rs b/crates/rbitcoin-consensus/src/block/mod.rs index e97ef6945c..ddd808d1b8 100644 --- a/crates/rbitcoin-consensus/src/block/mod.rs +++ b/crates/rbitcoin-consensus/src/block/mod.rs @@ -1065,9 +1065,9 @@ pub(crate) type TxidMap = std::collections::HashMap<[u8; 32], V, BuildHasherD /// Pack-local create fk by parent txid (not per-vout — fk is per tx). pub(crate) type PendingCreates = TxidMap; -/// Block-local prevout path counts; flush to [`Query::confirm_stats`] once. +/// Block-local prevout path counts; the confirm pipeline flushes once. #[derive(Default)] -struct AsmPrevoutAcc { +pub(crate) struct AsmPrevoutAcc { in_n: u64, same_n: u64, batch_n: u64, @@ -1079,7 +1079,7 @@ struct AsmPrevoutAcc { } impl AsmPrevoutAcc { - fn flush(&self, stats: &rbitcoin_query::ConfirmStats) { + pub(crate) fn flush(&self, stats: &rbitcoin_query::ConfirmStats) { rbitcoin_query::note_confirm(&stats.asm_in_n, self.in_n); rbitcoin_query::note_confirm(&stats.asm_prev_same_n, self.same_n); rbitcoin_query::note_confirm(&stats.asm_prev_batch_n, self.batch_n); @@ -1094,6 +1094,12 @@ impl AsmPrevoutAcc { } } +pub(crate) struct AssembleMeters { + pub prev_ns: u64, + pub job_ns: u64, + pub acc: AsmPrevoutAcc, +} + #[allow(clippy::too_many_arguments)] // call-site args stay unbundled #[allow(clippy::type_complexity)] // packed row / pin / script-hash tuple is the on-disk shape /// Sequential assemble: resolve prevout **content**, build script jobs, collect spends. @@ -1111,17 +1117,17 @@ impl AsmPrevoutAcc { /// Prevouts resolve from per-batch [`rbitcoin_query::BatchParents`] + /// [`rbitcoin_query::SpendEdges`]. Optimistic miss is `invariant:` (no head recover). /// -/// Returns `(script_jobs, spends, fees, tx_fees)`. `fees` is the block sum +/// Returns `(script_jobs, spends, fees, tx_fees, meters)`. `fees` is the block sum /// for the coinbase subsidy check. `tx_fees` is one satoshi fee per tx -/// (coinbase is 0) for the `txstat` stamp. +/// (coinbase is 0) for the `txstat` stamp. `meters` is flushed by the caller. /// /// `prev_mtp` / `block_hash` / `bip16_active` must be computed **once** by the /// caller (assemble_run header window) — no re-walk of headers and no rehash. /// /// `wire`: when `Some`, script jobs share that Arc (no `Transaction` clone). /// When `None` (unit-test connect), jobs own a deep clone of each non-cb tx. +/// `build_script_jobs` is the caller's milestone decision. pub(crate) fn assemble_block_prevouts( - query: &Query, block: &Block, ctx: &ValidationContext<'_>, archived_tx_fks: Option<&[rbitcoin_primitives::Fk]>, @@ -1135,6 +1141,7 @@ pub(crate) fn assemble_block_prevouts( bip16_active: bool, wire: Option<&Arc>, pres: Option<&Arc<[rbitcoin_query::TxPrecompute]>>, + build_script_jobs: bool, ) -> Result< ( Vec, @@ -1147,6 +1154,7 @@ pub(crate) fn assemble_block_prevouts( )>, i64, Vec, + AssembleMeters, ), ConsensusError, > { @@ -1168,8 +1176,6 @@ pub(crate) fn assemble_block_prevouts( let mut clk_job = 0u64; let mut fees = 0i64; let mut tx_fees = vec![0u64; n_tx]; - let build_script_jobs = - !crate::milestone::skips_on_query(ctx.milestone, query, ctx.height.0, block_hash); let mut script_jobs: Vec = if build_script_jobs { Vec::with_capacity(n_tx.saturating_sub(1)) } else { @@ -1235,10 +1241,17 @@ pub(crate) fn assemble_block_prevouts( } let clk_prev = t_loop.elapsed().as_nanos() as u64; - acc.flush(query.confirm_stats()); - rbitcoin_query::note_confirm(&query.confirm_stats().asm_prevout_ns, clk_prev); - rbitcoin_query::note_confirm(&query.confirm_stats().asm_job_ns, clk_job); - Ok((script_jobs, spends, fees, tx_fees)) + Ok(( + script_jobs, + spends, + fees, + tx_fees, + AssembleMeters { + prev_ns: clk_prev, + job_ns: clk_job, + acc, + }, + )) } fn assemble_prevout_guards( @@ -2147,13 +2160,8 @@ fn structural_create_heights( continue; }; let Some(&durable_h) = scratch.height_by_id.get(&id) else { - return Err(ConsensusError::BadTx("bad-txns-inputs-missingorspent")); + return reject_one_create(spend_height, maturity, None, false); }; - // Core connects a run one block at a time: a create from a later - // block of this batch is not yet a coin when this block spends it. - if durable_h > spend_height { - return Err(ConsensusError::MissingPrevout); - } let pin_cb = batch_parents.get_parent_coinbase(create_fk); let is_cb = match (pin_cb, run_create_height.create(create_fk)) { (Some(cb), _) => cb, @@ -2167,9 +2175,9 @@ fn structural_create_heights( *cb == create_fk } }; - if is_cb && spend_height < durable_h.saturating_add(maturity) { - return Err(ConsensusError::BadTx("coinbase immature")); - } + // Core connects a run one block at a time: a create from a later + // block of this batch is not yet a coin when this block spends it. + reject_one_create(spend_height, maturity, Some(durable_h), is_cb)?; scratch.create_height_by_fk.insert(create_fk, durable_h); } Ok(()) @@ -2284,12 +2292,11 @@ fn reject_bip30_unspent_overwrite( }; // Connected instance at *this* height is ourselves (re-validate / // already-confirmed fixture). BIP30 is an earlier unspent sibling. - if query + let prior_height = query .store() .tx_height_get(old_fk) - .map_err(ConsensusError::from)? - == Some(ctx.height.0) - { + .map_err(ConsensusError::from)?; + if prior_height == Some(ctx.height.0) { continue; } let (_, outs) = query @@ -2313,12 +2320,50 @@ fn reject_bip30_unspent_overwrite( } } if unspent { - return Err(ConsensusError::BadTx("bad-txns-BIP30")); + reject_bip30_unspent_prior( + ctx.height.0, + prior_height.unwrap_or(u32::MAX), + true, + )?; } } Ok(()) } +/// BIP30: an earlier connected instance with an unspent spendable output may +/// not be overwritten. The same height is this block. +pub(crate) fn reject_bip30_unspent_prior( + spend_height: u32, + prior_height: u32, + unspent_spendable: bool, +) -> Result<(), ConsensusError> { + if prior_height != spend_height && unspent_spendable { + return Err(ConsensusError::BadTx("bad-txns-BIP30")); + } + Ok(()) +} + +/// Create-height facts for one spent output. `None` is not a coin. A later +/// height in this batch is not yet a coin. A coinbase inside maturity is not +/// spendable. +pub(crate) fn reject_one_create( + spend_height: u32, + maturity: u32, + create_height: Option, + is_coinbase: bool, +) -> Result<(), ConsensusError> { + let Some(create_height) = create_height else { + return Err(ConsensusError::BadTx("bad-txns-inputs-missingorspent")); + }; + if create_height > spend_height { + return Err(ConsensusError::MissingPrevout); + } + if is_coinbase && spend_height < create_height.saturating_add(maturity) { + return Err(ConsensusError::BadTx("coinbase immature")); + } + Ok(()) +} + fn bip34_ancestry_skips_bip30(query: &Query, ctx: &ValidationContext<'_>) -> bool { let height = ctx.height.0; if ctx.params.bip34_hash.is_none() { diff --git a/crates/rbitcoin-consensus/src/block/structure_rule_tests.rs b/crates/rbitcoin-consensus/src/block/structure_rule_tests.rs index 496b3cb972..0acf4ef478 100644 --- a/crates/rbitcoin-consensus/src/block/structure_rule_tests.rs +++ b/crates/rbitcoin-consensus/src/block/structure_rule_tests.rs @@ -1210,7 +1210,6 @@ fn bip34_wrong_push_encoding_rejected_after_activation() { fn assemble_rejects_empty_and_fk_mismatch() { use super::assemble_block_prevouts; use rbitcoin_query::{BatchParents, OutPointSet, SpendEdges}; - let (path, q) = rbitcoin_query::testutil::tiny_query_labeled("assemble"); let ctx = ctx_h(1); let empty = block_with(vec![]); let parents = BatchParents::new(); @@ -1219,7 +1218,6 @@ fn assemble_rejects_empty_and_fk_mismatch() { let mut creates = super::PendingCreates::default(); let zero = [0u8; 32]; let err = assemble_block_prevouts( - &q, &empty, &ctx, None, @@ -1233,6 +1231,7 @@ fn assemble_rejects_empty_and_fk_mismatch() { false, None, None, + true, ) .err() .expect("empty"); @@ -1248,7 +1247,6 @@ fn assemble_rejects_empty_and_fk_mismatch() { .collect(); let bh = b.header.block_hash().to_byte_array(); let err2 = assemble_block_prevouts( - &q, &b, &ctx, Some(&[]), @@ -1262,6 +1260,7 @@ fn assemble_rejects_empty_and_fk_mismatch() { false, None, None, + true, ) .err() .expect("fk mismatch"); @@ -1277,7 +1276,6 @@ fn assemble_rejects_empty_and_fk_mismatch() { .collect(); let bh3 = bad.header.block_hash().to_byte_array(); let err3 = assemble_block_prevouts( - &q, &bad, &ctx, None, @@ -1291,11 +1289,11 @@ fn assemble_rejects_empty_and_fk_mismatch() { false, None, None, + true, ) .err() .expect("not coinbase"); assert_bad_block(err3, "coinbase"); - let _ = std::fs::remove_dir_all(&path); } /// Pack creates are `txid → fk` (one insert). Meters flush once (in_n = spends). @@ -1319,8 +1317,7 @@ fn assemble_pending_creates_is_txid_map_and_meters_flush() { let bh = b.header.block_hash().to_byte_array(); let bip16 = bip16_active_from_prev_mtp(ctx.params, ctx.height.0, &bh, 0); let _ = q.confirm_stats().take_window(); - assemble_block_prevouts( - &q, + let (_, _, _, _, meters) = assemble_block_prevouts( &b, &ctx, Some(&[Fk(1)]), @@ -1334,8 +1331,10 @@ fn assemble_pending_creates_is_txid_map_and_meters_flush() { bip16, None, None, + true, ) .expect("coinbase-only assemble"); + meters.acc.flush(q.confirm_stats()); assert_eq!(creates.len(), 1, "one create fk per tx, not per vout"); assert_eq!(creates.get(&tids[0]), Some(&Fk(1))); let w = q.confirm_stats().take_window(); @@ -1353,7 +1352,6 @@ fn script_job_shares_prevout_script() { use rbitcoin_query::{BatchParents, OutPointSet, SpendEdge, SpendEdges}; use rbitcoin_store::{OutputRecord, TxRecord}; - let (path, q) = rbitcoin_query::testutil::tiny_query_labeled("share-prevout-script"); let ctx = ctx_h(1); let parent_txid = [0x42u8; 32]; let hist_script = vec![0x51, 0x11, 0x22]; @@ -1437,8 +1435,7 @@ fn script_job_shares_prevout_script() { ); let mut spent = OutPointSet::default(); let mut creates = super::PendingCreates::default(); - let (jobs, _, _, _) = assemble_block_prevouts( - &q, + let (jobs, _, _, _, _) = assemble_block_prevouts( &wire, &ctx, Some(&[Fk(1), Fk(2), Fk(3)]), @@ -1452,6 +1449,7 @@ fn script_job_shares_prevout_script() { bip16, Some(&wire), None, + true, ) .expect("assemble shared prevouts"); assert_eq!(jobs.len(), 2); @@ -1469,7 +1467,6 @@ fn script_job_shares_prevout_script() { wire.txdata[1].output[0].script_pubkey.as_bytes().as_ptr(), "same-block prevout script is the wire output" ); - let _ = std::fs::remove_dir_all(&path); } /// Optimistic IBD: unstamped parent must not recover via `tx_fk_by_txid_tip`. @@ -1529,7 +1526,6 @@ fn optimistic_assemble_unstamped_parent_is_invariant() { let bh = block.header.block_hash().to_byte_array(); let spend_fks = [Fk(3), Fk(4)]; let err = assemble_block_prevouts( - &q, &block, &ctx, Some(&spend_fks), @@ -1543,6 +1539,7 @@ fn optimistic_assemble_unstamped_parent_is_invariant() { bip16_active_from_prev_mtp(ctx.params, ctx.height.0, &bh, b1.header.time), None, None, + true, ) .err() .expect("unstamped parent must not head-recover"); @@ -2023,7 +2020,6 @@ fn assemble_milestone_pin_still_rejects_bad_blk_sigops() { use rbitcoin_primitives::Fk; use rbitcoin_query::{BatchParents, OutPointSet, SpendEdge, SpendEdges}; use rbitcoin_store::{OutputRecord, TxRecord}; - let (path, q) = rbitcoin_query::testutil::tiny_query_labeled("assemble-ms-sigops"); let ws = vec![0xacu8; 80_001]; let h = sha256::Hash::hash(&ws); let mut spk = vec![0x00, 0x20]; @@ -2091,7 +2087,6 @@ fn assemble_milestone_pin_still_rejects_bad_blk_sigops() { .collect(); let bh = b.header.block_hash().to_byte_array(); let err = assemble_block_prevouts( - &q, &b, &ctx, Some(&[Fk::NULL, spend_fk]), @@ -2105,11 +2100,11 @@ fn assemble_milestone_pin_still_rejects_bad_blk_sigops() { true, None, None, + true, ) .err() .expect("over-budget pin P2WSH must reject"); assert_bad_block(err, "bad-blk-sigops"); - let _ = std::fs::remove_dir_all(&path); } /// Anchored skip uses the header path. A height match with a different hash @@ -2194,7 +2189,6 @@ fn signet_low_work_fork_runs_scripts(q: &rbitcoin_query::Query) { let mut spent = OutPointSet::default(); let mut creates = super::PendingCreates::default(); assemble_block_prevouts( - q, &b, &ctx, Some(&[Fk::NULL, spend_fk]), @@ -2208,6 +2202,7 @@ fn signet_low_work_fork_runs_scripts(q: &rbitcoin_query::Query) { true, None, None, + crate::milestone::check_scripts(ms, q, 1, &bh), ) .expect("op_true spend assembles") .0 @@ -2285,6 +2280,35 @@ fn plant_confirmed_spend( .unwrap(); } +#[test] +fn bip30_and_immature_coinbase_reject_from_facts() { + use super::{reject_bip30_unspent_prior, reject_one_create}; + assert!(reject_bip30_unspent_prior(2, 2, true).is_ok()); + let bip30 = reject_bip30_unspent_prior(5, 1, true).unwrap_err(); + assert!( + matches!(bip30, ConsensusError::BadTx(s) if s.contains("BIP30")), + "{bip30:?}" + ); + assert!(reject_bip30_unspent_prior(5, 1, false).is_ok()); + + let missing = reject_one_create(10, 100, None, false).unwrap_err(); + assert!( + matches!(missing, ConsensusError::BadTx(s) if s.contains("missingorspent")), + "{missing:?}" + ); + let forward = reject_one_create(10, 100, Some(11), false).unwrap_err(); + assert!( + matches!(forward, ConsensusError::MissingPrevout), + "{forward:?}" + ); + let immature = reject_one_create(10, 100, Some(1), true).unwrap_err(); + assert!( + matches!(immature, ConsensusError::BadTx(s) if s.contains("immature")), + "{immature:?}" + ); + assert!(reject_one_create(101, 100, Some(1), true).is_ok()); +} + /// Core never adds `IsUnspendable` outputs (leading `OP_RETURN`, or a script /// over 10_000 bytes) to the coin view, so they do not block a BIP30 /// overwrite. A spendable unspent output still does. diff --git a/crates/rbitcoin-consensus/src/confirm_run/phases.rs b/crates/rbitcoin-consensus/src/confirm_run/phases.rs index eff71757e2..53557b7bb8 100644 --- a/crates/rbitcoin-consensus/src/confirm_run/phases.rs +++ b/crates/rbitcoin-consensus/src/confirm_run/phases.rs @@ -164,8 +164,9 @@ pub(super) fn assemble_run( crate::block::bip16_active_from_prev_mtp(params, height.0, &block_hash, prev_mtp); let t_connect = Instant::now(); - let (script_jobs, spends, fees, block_fees) = assemble_block_prevouts( - query, + let build_script_jobs = + !crate::milestone::skips_on_query(milestone, query, height.0, &block_hash); + let (script_jobs, spends, fees, block_fees, meters) = assemble_block_prevouts( block.as_ref(), &ctx, Some(&meta.tx_fks), @@ -179,7 +180,11 @@ pub(super) fn assemble_run( bip16_active, Some(block), Some(&meta.pres), + build_script_jobs, )?; + meters.acc.flush(query.confirm_stats()); + rbitcoin_query::note_confirm(&query.confirm_stats().asm_prevout_ns, meters.prev_ns); + rbitcoin_query::note_confirm(&query.confirm_stats().asm_job_ns, meters.job_ns); if meta.pres.len() != block.txdata.len() { return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( "invariant: txstat size pres", From 385e13fb49a77f874f2e32abcb7973d5d3f7d136 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:19:17 -0700 Subject: [PATCH 04/12] consensus: query owns the confirm pipeline Rule accepts and rejects stay in rbitcoin-consensus, decided from bytes and caller facts. Query loads rows and writes Class A, Class C, and spend annotations, then calls those functions. Consensus no longer depends on query, store, or log. --- Cargo.lock | 4 +- changelog.d/consensus-off-store.md | 3 + crates/rbitcoin-consensus/AGENTS.md | 9 +- crates/rbitcoin-consensus/Cargo.toml | 3 - crates/rbitcoin-consensus/src/block/facts.rs | 318 ++++ .../src/block/finality_tests.rs | 233 +-- crates/rbitcoin-consensus/src/block/mod.rs | 1636 ++--------------- crates/rbitcoin-consensus/src/error.rs | 66 +- crates/rbitcoin-consensus/src/header.rs | 417 +---- .../src/id_map.rs | 0 crates/rbitcoin-consensus/src/lib.rs | 410 +---- crates/rbitcoin-consensus/src/milestone.rs | 25 - crates/rbitcoin-consensus/src/params.rs | 172 +- crates/rbitcoin-consensus/src/regtest_pad.rs | 63 +- .../src/script/interpreter.rs | 6 +- crates/rbitcoin-consensus/src/script_pool.rs | 28 +- .../rbitcoin-consensus/src/silent_payments.rs | 753 +------- .../src/tx_precompute.rs | 49 - .../src/electrum_sh_journey.rs | 5 +- crates/rbitcoin-electrum/src/server_tests.rs | 5 +- crates/rbitcoin-electrum/src/silent_scan.rs | 3 +- crates/rbitcoin-electrum/src/tweaks.rs | 3 +- crates/rbitcoin-esplora/src/internal.rs | 5 +- crates/rbitcoin-net/src/chain.rs | 94 +- crates/rbitcoin-net/src/error.rs | 10 +- crates/rbitcoin-net/src/ibd/archive.rs | 2 +- crates/rbitcoin-net/src/ibd/confirm/mod.rs | 91 +- crates/rbitcoin-net/src/ibd/confirm/tests.rs | 34 +- .../src/ibd/events/confirm_reject_tests.rs | 28 +- crates/rbitcoin-net/src/ibd/path.rs | 2 +- crates/rbitcoin-net/src/peer_tests.rs | 2 +- crates/rbitcoin-net/src/tip_accept.rs | 2 +- crates/rbitcoin-net/src/tx_relay.rs | 37 +- crates/rbitcoin-node/src/run.rs | 8 +- crates/rbitcoin-query/Cargo.toml | 1 + crates/rbitcoin-query/src/batch_parents.rs | 18 + .../rbitcoin-query/src/bip68_store_tests.rs | 273 +++ crates/rbitcoin-query/src/block_pipeline.rs | 1483 +++++++++++++++ crates/rbitcoin-query/src/confirm_error.rs | 123 ++ .../src/confirm_run/bq_resolve.rs | 39 +- .../src/confirm_run/head_drain.rs | 0 .../src/confirm_run/index.rs | 62 +- .../src/confirm_run/lookup.rs | 235 ++- .../src/confirm_run/mod.rs | 64 +- .../src/confirm_run/phases.rs | 333 ++-- .../src/confirm_run/pin.rs | 132 +- .../src/confirm_run/scripts.rs | 34 +- .../src/confirm_run/spend_durable_tests.rs | 88 +- .../src/confirm_run/write.rs | 125 +- .../src/confirm_run/write_idempotent_tests.rs | 380 ++-- crates/rbitcoin-query/src/connect_facade.rs | 343 ++++ .../src/convert.rs | 45 +- crates/rbitcoin-query/src/header_read.rs | 193 ++ .../rbitcoin-query/src/header_store_tests.rs | 241 +++ crates/rbitcoin-query/src/in_flight.rs | 2 +- .../src/index_rows.rs | 15 +- .../src/index_writebehind.rs | 75 +- crates/rbitcoin-query/src/lib.rs | 47 +- crates/rbitcoin-query/src/milestone_gate.rs | 20 + .../rbitcoin-query/src/overlay_csv_tests.rs | 152 ++ crates/rbitcoin-query/src/regtest_pad.rs | 34 + crates/rbitcoin-query/src/sp_read.rs | 262 +++ crates/rbitcoin-query/src/sp_store_tests.rs | 492 +++++ crates/rbitcoin-query/src/stamp.rs | 2 +- .../src}/structure_rule_tests.rs | 309 ++-- crates/rbitcoin-query/src/wire_agree_tests.rs | 85 + crates/rbitcoin-rpc/src/blockstats.rs | 6 +- crates/rbitcoin-rpc/src/methods/chain.rs | 10 +- crates/rbitcoin-rpc/src/methods/mine.rs | 16 +- crates/rbitcoin-rpc/src/methods_tests.rs | 26 +- crates/rbitcoin-store/src/lib.rs | 4 +- crates/rbitcoin-sv2/Cargo.toml | 1 + crates/rbitcoin-sv2/src/template.rs | 4 +- crates/rbitcoin-sv2/src/template_tests.rs | 6 +- crates/rbitcoin-sv2/src/test_chain.rs | 3 +- crates/rbitcoin-test/src/chain_fixture.rs | 7 +- crates/rbitcoin-test/tests/consensus_rules.rs | 9 +- crates/rbitcoin-test/tests/core_analogs.rs | 9 +- crates/rbitcoin-test/tests/cross_surface.rs | 3 +- .../rbitcoin-test/tests/electrum_protocol.rs | 25 +- .../tests/integration_multinode.rs | 15 +- crates/rbitcoin-test/tests/scenarios.rs | 67 +- docs/CRATES.md | 4 +- docs/invariants.md | 4 +- fuzz/src/chain_review.rs | 5 +- 85 files changed, 5535 insertions(+), 4922 deletions(-) create mode 100644 changelog.d/consensus-off-store.md create mode 100644 crates/rbitcoin-consensus/src/block/facts.rs rename crates/{rbitcoin-query => rbitcoin-consensus}/src/id_map.rs (100%) rename crates/{rbitcoin-query => rbitcoin-consensus}/src/tx_precompute.rs (95%) create mode 100644 crates/rbitcoin-query/src/bip68_store_tests.rs create mode 100644 crates/rbitcoin-query/src/block_pipeline.rs create mode 100644 crates/rbitcoin-query/src/confirm_error.rs rename crates/{rbitcoin-consensus => rbitcoin-query}/src/confirm_run/bq_resolve.rs (98%) rename crates/{rbitcoin-consensus => rbitcoin-query}/src/confirm_run/head_drain.rs (100%) rename crates/{rbitcoin-consensus => rbitcoin-query}/src/confirm_run/index.rs (95%) rename crates/{rbitcoin-consensus => rbitcoin-query}/src/confirm_run/lookup.rs (84%) rename crates/{rbitcoin-consensus => rbitcoin-query}/src/confirm_run/mod.rs (90%) rename crates/{rbitcoin-consensus => rbitcoin-query}/src/confirm_run/phases.rs (63%) rename crates/{rbitcoin-consensus => rbitcoin-query}/src/confirm_run/pin.rs (80%) rename crates/{rbitcoin-consensus => rbitcoin-query}/src/confirm_run/scripts.rs (91%) rename crates/{rbitcoin-consensus => rbitcoin-query}/src/confirm_run/spend_durable_tests.rs (92%) rename crates/{rbitcoin-consensus => rbitcoin-query}/src/confirm_run/write.rs (85%) rename crates/{rbitcoin-consensus => rbitcoin-query}/src/confirm_run/write_idempotent_tests.rs (91%) create mode 100644 crates/rbitcoin-query/src/connect_facade.rs rename crates/{rbitcoin-consensus => rbitcoin-query}/src/convert.rs (88%) create mode 100644 crates/rbitcoin-query/src/header_read.rs create mode 100644 crates/rbitcoin-query/src/header_store_tests.rs rename crates/{rbitcoin-consensus => rbitcoin-query}/src/index_rows.rs (92%) rename crates/{rbitcoin-consensus => rbitcoin-query}/src/index_writebehind.rs (95%) create mode 100644 crates/rbitcoin-query/src/milestone_gate.rs create mode 100644 crates/rbitcoin-query/src/overlay_csv_tests.rs create mode 100644 crates/rbitcoin-query/src/regtest_pad.rs create mode 100644 crates/rbitcoin-query/src/sp_read.rs create mode 100644 crates/rbitcoin-query/src/sp_store_tests.rs rename crates/{rbitcoin-consensus/src/block => rbitcoin-query/src}/structure_rule_tests.rs (93%) create mode 100644 crates/rbitcoin-query/src/wire_agree_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 3df18ed8ad..13f3f50b33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -909,10 +909,7 @@ dependencies = [ "bitcoin", "bitcoin_hashes 0.14.101", "mutants", - "rbitcoin-log", "rbitcoin-primitives", - "rbitcoin-query", - "rbitcoin-store", "ruzstd", "serde_json", ] @@ -1030,6 +1027,7 @@ dependencies = [ "arc-swap", "bitcoin", "mutants", + "rbitcoin-consensus", "rbitcoin-log", "rbitcoin-primitives", "rbitcoin-store", diff --git a/changelog.d/consensus-off-store.md b/changelog.d/consensus-off-store.md new file mode 100644 index 0000000000..53ad5cad57 --- /dev/null +++ b/changelog.d/consensus-off-store.md @@ -0,0 +1,3 @@ +Changed + +- **Consensus rules no longer open the store.** `rbitcoin-consensus` decides header, block, and transaction accepts and rejects from bytes and caller facts. `rbitcoin-query` loads rows, writes Class A, Class C, and spend annotations, and calls those functions. A disk, io_uring, or cancel fault stays a pipeline error and is not cached as an invalid block. diff --git a/crates/rbitcoin-consensus/AGENTS.md b/crates/rbitcoin-consensus/AGENTS.md index 7e6da2c2ad..0d8c18ec4b 100644 --- a/crates/rbitcoin-consensus/AGENTS.md +++ b/crates/rbitcoin-consensus/AGENTS.md @@ -1,7 +1,8 @@ # rbitcoin-consensus -Header, block, and script validation. Depends on primitives, query, and store. -Peer and RPC script checks use the detached worker. The thread rule is +Header, block, and script rules from bytes and caller facts. Depends on +primitives. It does not open a store or write Class A. Peer and RPC script +checks use the detached worker. The thread rule is [`docs/concurrency.md`](../../docs/concurrency.md). ## Read first @@ -17,9 +18,9 @@ Open the row that matches the change. Leave the other owners closed. ## Where - Scripts: `src/script/` (`interpreter.rs` is the opcode match) -- Confirm: `src/confirm_run/` -- Headers and blocks: `src/header.rs`, `src/block/` +- Headers and blocks: `src/header.rs`, `src/block/` (`facts.rs` is the spend and assemble decisions) - Worker pool: `src/script_pool.rs` +- Confirm pipeline (lookup, load, pin, write, index): `rbitcoin-query` `src/confirm_run/` and `src/block_pipeline.rs` ## Verify diff --git a/crates/rbitcoin-consensus/Cargo.toml b/crates/rbitcoin-consensus/Cargo.toml index f29d058684..523600d270 100644 --- a/crates/rbitcoin-consensus/Cargo.toml +++ b/crates/rbitcoin-consensus/Cargo.toml @@ -9,9 +9,6 @@ description = "Block and transaction validation / confirmability" [dependencies] rbitcoin-primitives = { workspace = true } -rbitcoin-query = { workspace = true } -rbitcoin-store = { workspace = true } -rbitcoin-log = { workspace = true } arc-swap = { workspace = true } bitcoin = { workspace = true } bitcoin_hashes = { workspace = true } diff --git a/crates/rbitcoin-consensus/src/block/facts.rs b/crates/rbitcoin-consensus/src/block/facts.rs new file mode 100644 index 0000000000..dcd992b4ab --- /dev/null +++ b/crates/rbitcoin-consensus/src/block/facts.rs @@ -0,0 +1,318 @@ +//! Rule decisions the confirm pipeline used to construct inline. +//! +//! Query loads rows and then calls these. A missing store row stays a store +//! error in the caller. The `if` that chooses the reject lives here. + +use super::{exceeds_sigops_limit, is_final_tx, sequence_locks_satisfied}; +use crate::ConsensusError; +use bitcoin::block::Header; +use bitcoin::Transaction; + +pub fn reject_non_final( + tx: &Transaction, + block_height: u32, + lock_time_cutoff: u32, +) -> Result<(), ConsensusError> { + if is_final_tx(tx, block_height, lock_time_cutoff) { + Ok(()) + } else { + Err(ConsensusError::BadTx("bad-txns-nonfinal")) + } +} + +pub fn reject_tx_without_outputs(tx: &Transaction) -> Result<(), ConsensusError> { + if tx.output.is_empty() { + Err(ConsensusError::BadTx("no outputs")) + } else { + Ok(()) + } +} + +pub fn reject_tx_without_inputs(tx: &Transaction) -> Result<(), ConsensusError> { + if tx.input.is_empty() { + Err(ConsensusError::BadTx("no inputs")) + } else { + Ok(()) + } +} + +pub fn reject_assemble_block_shape( + n_tx: usize, + n_txid: usize, + n_archived_fk: Option, + first_is_coinbase: bool, +) -> Result<(), ConsensusError> { + if let Some(n_fk) = n_archived_fk { + if n_fk != n_tx { + return Err(ConsensusError::BadBlock("archived tx fk count mismatch")); + } + } + if n_txid != n_tx { + return Err(ConsensusError::BadBlock( + "invariant: create_txids length must match block.txdata (no assemble re-hash)", + )); + } + if n_tx == 0 { + return Err(ConsensusError::BadBlock("empty block")); + } + if !first_is_coinbase { + return Err(ConsensusError::BadBlock("first tx not coinbase")); + } + Ok(()) +} + +pub fn add_sigops(cost: u64, legacy: u64, input: u64) -> Result { + let next = cost + .checked_add(legacy) + .and_then(|c| c.checked_add(input)) + .ok_or(ConsensusError::BadBlock("bad-blk-sigops"))?; + if exceeds_sigops_limit(next) { + return Err(ConsensusError::BadBlock("bad-blk-sigops")); + } + Ok(next) +} + +pub fn fee_from_values(value_in: i64, value_out: i64) -> Result { + if value_out > value_in { + return Err(ConsensusError::BadTx("in < out")); + } + value_in + .checked_sub(value_out) + .ok_or(ConsensusError::BadTx("fee overflow")) +} + +pub fn fee_sat(fee: i64) -> Result { + u64::try_from(fee).map_err(|_| ConsensusError::BadTx("fee overflow")) +} + +pub fn add_fee(fees: i64, fee: i64) -> Result { + fees.checked_add(fee) + .ok_or(ConsensusError::BadTx("fee overflow")) +} + +pub fn add_value_in(value_in: i64, sat: u64) -> Result { + value_in + .checked_add(sat as i64) + .ok_or(ConsensusError::BadTx("value in overflow")) +} + +pub fn reject_genesis_coinbase_spend(is_genesis: bool) -> Result<(), ConsensusError> { + if is_genesis { + Err(ConsensusError::MissingPrevout) + } else { + Ok(()) + } +} + +pub fn reject_duplicate_input(inserted: bool) -> Result<(), ConsensusError> { + if inserted { + Ok(()) + } else { + Err(ConsensusError::BadTx("double spend in block")) + } +} + +/// Same-block prevout. A later create is not yet a coin. The coinbase of this +/// block is immature. +pub fn reject_same_block_create( + prev_index: usize, + spend_index: usize, +) -> Result<(), ConsensusError> { + if prev_index >= spend_index { + return Err(ConsensusError::MissingPrevout); + } + if prev_index == 0 { + return Err(ConsensusError::BadTx("coinbase immature")); + } + Ok(()) +} + +pub fn reject_unresolved_spend(missing: bool) -> Result<(), ConsensusError> { + if missing { + Err(ConsensusError::MissingPrevout) + } else { + Ok(()) + } +} + +pub fn reject_unspendable_prevout(unspendable: bool) -> Result<(), ConsensusError> { + if unspendable { + Err(ConsensusError::MissingPrevout) + } else { + Ok(()) + } +} + +pub fn reject_prevout_spent(spent: bool) -> Result<(), ConsensusError> { + if spent { + Err(ConsensusError::PrevoutSpent) + } else { + Ok(()) + } +} + +pub fn reject_spend_cursor(in_range: bool) -> Result<(), ConsensusError> { + if in_range { + Ok(()) + } else { + Err(ConsensusError::BadBlock( + "structural spends/tx input mismatch", + )) + } +} + +pub fn reject_csv_create_height(height: Option) -> Result { + height.ok_or(ConsensusError::BadTx("bad-txns-nonfinal")) +} + +pub fn reject_sequence_locks( + tx: &Transaction, + prev_heights: &[u32], + coin_mtps: &[u32], + block_height: u32, + prev_mtp: u32, +) -> Result<(), ConsensusError> { + if sequence_locks_satisfied(tx, prev_heights, coin_mtps, block_height, prev_mtp) { + Ok(()) + } else { + Err(ConsensusError::BadTx("bad-txns-nonfinal")) + } +} + +pub fn reject_witness_before_segwit( + has_witness: bool, + segwit_active: bool, +) -> Result<(), ConsensusError> { + if has_witness && !segwit_active { + Err(ConsensusError::BadBlock("unexpected witness before segwit")) + } else { + Ok(()) + } +} + +pub fn reject_empty_confirm_batch(empty: bool) -> Result<(), ConsensusError> { + if empty { + Err(ConsensusError::BadBlock("empty confirm batch")) + } else { + Ok(()) + } +} + +pub fn require_retarget_first( + on_boundary: bool, + no_retarget: bool, + first: Option, +) -> Result, ConsensusError> { + if on_boundary && !no_retarget && first.is_none() { + Err(ConsensusError::BadHeader("missing retarget first header")) + } else { + Ok(first) + } +} + +pub fn reject_confirm_batch(heights: &[u32]) -> Result<(), ConsensusError> { + if heights.is_empty() { + return Err(ConsensusError::BadBlock("empty confirm batch")); + } + for w in heights.windows(2) { + if w[1] != w[0].saturating_add(1) { + return Err(ConsensusError::BadBlock("confirm run not contiguous")); + } + } + Ok(()) +} + +pub fn reject_txid_count(n_tx: usize, n_txid: usize) -> Result<(), ConsensusError> { + if n_tx == n_txid { + Ok(()) + } else { + Err(ConsensusError::BadBlock("txid count mismatch")) + } +} + +pub fn require_linked_parent(row: Option) -> Result { + row.ok_or(ConsensusError::BadPrev) +} + +pub fn reject_prev_link(prev: &[u8; 32], expect: &[u8; 32]) -> Result<(), ConsensusError> { + if prev == expect { + Ok(()) + } else { + Err(ConsensusError::BadPrev) + } +} + +pub fn reject_carried_header_hash( + hash: &[u8; 32], + expect: &[u8; 32], +) -> Result<(), ConsensusError> { + if hash == expect { + Ok(()) + } else { + Err(ConsensusError::BadBlock("carried header hash mismatch")) + } +} + +pub fn reject_batch_starts_at(height: u32, path_lo: u32) -> Result<(), ConsensusError> { + if height == path_lo { + Ok(()) + } else { + Err(ConsensusError::BadPrev) + } +} + +/// A header window with no timestamps is not a median. +pub fn median_or_bad_header(times: &[u32]) -> Result { + crate::header::median_time_past_times(times) + .map_err(|_| ConsensusError::BadHeader("empty median time")) +} + +/// `None` from the work walk is a missing ancestor, which is `BadPrev`. +pub fn require_next_work( + bits: Option, +) -> Result { + bits.ok_or(ConsensusError::BadPrev) +} + +pub fn reject_vout_past_n_out(max_vout: u32, n_out: Option) -> Result<(), ConsensusError> { + if n_out.is_some_and(|n| u64::from(max_vout) >= n) { + Err(ConsensusError::MissingPrevout) + } else { + Ok(()) + } +} + +pub fn require_same_block_output<'a>( + tx: Option<&'a Transaction>, + vout: usize, +) -> Result<&'a bitcoin::TxOut, ConsensusError> { + let tx = tx.ok_or(ConsensusError::MissingPrevout)?; + tx.output.get(vout).ok_or(ConsensusError::MissingPrevout) +} + +/// Gap at or below the confirmed tip is a bad prev. Above the tip the caller +/// keeps the retryable load error. +pub fn header_gap_on_chain(height: u32, tip: u32) -> Result<(), ConsensusError> { + if height > tip { + Ok(()) + } else { + Err(ConsensusError::BadPrev) + } +} + +pub fn reject_bits( + header: &Header, + expected: bitcoin::CompactTarget, +) -> Result<(), ConsensusError> { + if header.bits == expected { + Ok(()) + } else { + Err(ConsensusError::BadHeader("incorrect proof of work bits")) + } +} + +/// Parent create_fk that the store could not resolve is a missing prevout, +/// not a corrupt row. +pub fn unresolved_parent_create() -> ConsensusError { + ConsensusError::MissingPrevout +} diff --git a/crates/rbitcoin-consensus/src/block/finality_tests.rs b/crates/rbitcoin-consensus/src/block/finality_tests.rs index 62f705ce7c..e0621e3dbd 100644 --- a/crates/rbitcoin-consensus/src/block/finality_tests.rs +++ b/crates/rbitcoin-consensus/src/block/finality_tests.rs @@ -141,79 +141,6 @@ fn bip68_height_zero_time_lock_uses_the_median() { 1, mtp.saturating_sub(1) )); - - // The median comes from the confirmed genesis header through - // `structural_bip68`, not from a caller-supplied MTP slice. - let (_dir, query) = rbitcoin_query::testutil::tiny_query_labeled("bip68-h0"); - let header_fk = query - .store() - .put_header(&rbitcoin_store::HeaderRecord { - timestamp: mtp, - hash: [0x11; 32], - ..Default::default() - }) - .unwrap(); - query - .store() - .confirmed - .set(rbitcoin_primitives::Height(0), header_fk) - .unwrap(); - query.store().rebuild_height_fence().unwrap(); - let params = Box::leak(Box::new(crate::params::ChainParams::regtest())); - let ctx = super::ValidationContext::at( - params, - rbitcoin_primitives::Height(1), - crate::milestone::Milestone::NONE, - ); - let create = rbitcoin_primitives::Fk(1); - let mut create_heights = rbitcoin_query::FkMap::default(); - create_heights.insert(create, 0); - let spends = [([0u8; 32], 0u32, rbitcoin_primitives::Fk::NULL, create, 0u32)]; - let block = height_zero_timelock_block(type_flag); - super::structural_bip68( - &query, - &block, - &ctx, - &spends, - &create_heights, - &mut rbitcoin_query::U32Map::default(), - ) - .expect("height 0 time lock uses the genesis median"); - let too_far = height_zero_timelock_block(type_flag | 1); - let err = super::structural_bip68( - &query, - &too_far, - &ctx, - &spends, - &create_heights, - &mut rbitcoin_query::U32Map::default(), - ) - .expect_err("512s relative to the genesis median is not final at height 1"); - assert!( - format!("{err}").contains("nonfinal"), - "structural path must apply the median, got {err}" - ); -} - -fn height_zero_timelock_block(sequence: u32) -> bitcoin::Block { - use bitcoin::block::{Header, Version}; - use bitcoin::hashes::Hash; - use bitcoin::{Block, BlockHash, CompactTarget, TxMerkleNode}; - let spend = bare_tx(2, LockTime::ZERO, Sequence::from_consensus(sequence)); - let coinbase = bare_tx(1, LockTime::ZERO, Sequence::MAX); - let mut block = Block { - header: Header { - version: Version::from_consensus(1), - prev_blockhash: BlockHash::from_byte_array([0; 32]), - merkle_root: TxMerkleNode::from_byte_array([0; 32]), - time: 1_231_006_505, - bits: CompactTarget::from_consensus(0x1d00_ffff), - nonce: 0, - }, - txdata: vec![coinbase, spend], - }; - block.header.merkle_root = block.compute_merkle_root().unwrap(); - block } /// Exactly 500_000_000 is a timestamp. One below the threshold is a height. @@ -237,168 +164,12 @@ fn bip68_height_type_ignores_zero_mtp() { assert!(!sequence_locks_satisfied(&tx, &[100], &[0], 109, 0)); } -fn seq_tx(seqs: &[u32]) -> Transaction { - Transaction { - version: bitcoin::transaction::Version(2), - lock_time: LockTime::ZERO, - input: seqs - .iter() - .map(|seq| TxIn { - previous_output: OutPoint::null(), - script_sig: ScriptBuf::new(), - sequence: Sequence::from_consensus(*seq), - witness: Witness::new(), - }) - .collect(), - output: vec![TxOut { - value: Amount::from_sat(1), - script_pubkey: ScriptBuf::from_bytes(vec![0x51]), - }], - } -} - -fn csv_block(txs: Vec) -> bitcoin::Block { - use bitcoin::block::{Header, Version}; - use bitcoin::hashes::Hash; - use bitcoin::{Block, BlockHash, CompactTarget, TxMerkleNode}; - let mut block = Block { - header: Header { - version: Version::from_consensus(1), - prev_blockhash: BlockHash::from_byte_array([0; 32]), - merkle_root: TxMerkleNode::from_byte_array([0; 32]), - time: 1_231_006_505, - bits: CompactTarget::from_consensus(0x1d00_ffff), - nonce: 0, - }, - txdata: txs, - }; - block.header.merkle_root = block.compute_merkle_root().unwrap(); - block -} - -/// Three two-input txs must connect. `si * n_in` false-rejects the third. -#[test] -fn structural_bip68_multi_input_block_connects() { - let params = Box::leak(Box::new(crate::params::ChainParams::regtest())); - let ctx = super::ValidationContext::at( - params, - rbitcoin_primitives::Height(1), - crate::milestone::Milestone::NONE, - ); - let (_dir, query) = rbitcoin_query::testutil::tiny_query_labeled("bip68-multi"); - let coinbase = bare_tx(1, LockTime::ZERO, Sequence::MAX); - let spend = |seq: u32| seq_tx(&[seq, seq]); - let block = csv_block(vec![ - coinbase, - spend(u32::MAX), - spend(u32::MAX), - spend(u32::MAX), - ]); - let spends = vec![ - ( - [0u8; 32], - 0u32, - rbitcoin_primitives::Fk::NULL, - rbitcoin_primitives::Fk::NULL, - 0u32 - ); - 6 - ]; - let mut cache = rbitcoin_query::U32Map::default(); - cache.insert(0, 1_000); - super::structural_bip68( - &query, - &block, - &ctx, - &spends, - &rbitcoin_query::FkMap::default(), - &mut cache, - ) - .expect("a well-formed multi-input block passes the spend cursor"); -} - -/// Height-type CSV does not prefetch a median. A missing parent header must -/// still pass when the block's own previous median is already cached. +/// The coinbase relative lock in the connect pin fails when that tx is checked as a spend. #[test] -fn structural_bip68_height_lock_skips_missing_coin_mtp() { - let params = Box::leak(Box::new(crate::params::ChainParams::regtest())); - let ctx = super::ValidationContext::at( - params, - rbitcoin_primitives::Height(6), - crate::milestone::Milestone::NONE, - ); - let (_dir, query) = rbitcoin_query::testutil::tiny_query_labeled("bip68-height-mtp"); - let create = rbitcoin_primitives::Fk(3); - let mut create_heights = rbitcoin_query::FkMap::default(); - create_heights.insert(create, 5); - let block = csv_block(vec![ - bare_tx(1, LockTime::ZERO, Sequence::MAX), - seq_tx(&[0]), - ]); - let spends = [([0u8; 32], 0u32, rbitcoin_primitives::Fk::NULL, create, 0u32)]; - let mut cache = rbitcoin_query::U32Map::default(); - cache.insert(5, 1_500_000); - super::structural_bip68(&query, &block, &ctx, &spends, &create_heights, &mut cache) - .expect("height-type lock must not read the coin median"); -} - -/// BIP68 walks `txdata[1..]`. A coinbase whose relative lock would fail still connects. -#[test] -fn bip68_skips_a_coinbase_whose_relative_lock_would_fail() { - use crate::{accept_and_connect_block, prepare_regtest_candidate, ConsensusError}; - +fn coinbase_relative_lock_fails_as_a_normal_tx() { let coinbase = bare_tx(2, LockTime::ZERO, Sequence::from_consensus(200)); assert!( !sequence_locks_satisfied(&coinbase, &[], &[], 1, 0), "the same lock fails closed when the coinbase is treated as a normal tx" ); - - let (_dir, q) = rbitcoin_query::testutil::tiny_query_labeled("bip68-coinbase"); - let params = crate::params::ChainParams::regtest(); - let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); - accept_and_connect_block( - &q, - ¶ms, - rbitcoin_primitives::Height::GENESIS, - &genesis, - crate::Milestone::NONE, - ) - .unwrap(); - let mut block = crate::mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); - block.txdata[0].version = bitcoin::transaction::Version::TWO; - block.txdata[0].input[0].sequence = Sequence::from_consensus(200); - prepare_regtest_candidate(&mut block, genesis.block_hash(), genesis.header.time + 600); - accept_and_connect_block( - &q, - ¶ms, - rbitcoin_primitives::Height(1), - &block, - crate::Milestone::NONE, - ) - .expect("coinbase sequence is not a BIP68 input"); - assert_eq!(q.tip_height(), Some(rbitcoin_primitives::Height(1))); - - let params = Box::leak(Box::new(crate::params::ChainParams::regtest())); - let ctx = super::ValidationContext::at( - params, - rbitcoin_primitives::Height(6), - crate::milestone::Milestone::NONE, - ); - let (_dir, query) = rbitcoin_query::testutil::tiny_query_labeled("bip68-noncb"); - let create = rbitcoin_primitives::Fk(3); - let mut create_heights = rbitcoin_query::FkMap::default(); - create_heights.insert(create, 5); - let block = csv_block(vec![ - bare_tx(1, LockTime::ZERO, Sequence::MAX), - bare_tx(2, LockTime::ZERO, Sequence::from_consensus(200)), - ]); - let spends = [([0u8; 32], 0u32, rbitcoin_primitives::Fk::NULL, create, 0u32)]; - let mut cache = rbitcoin_query::U32Map::default(); - cache.insert(5, 1_500_000); - let err = super::structural_bip68(&query, &block, &ctx, &spends, &create_heights, &mut cache) - .expect_err("the same lock rejects a non-coinbase"); - assert!( - matches!(err, ConsensusError::BadTx(s) if s.contains("nonfinal")), - "{err:?}" - ); } diff --git a/crates/rbitcoin-consensus/src/block/mod.rs b/crates/rbitcoin-consensus/src/block/mod.rs index ddd808d1b8..0109b6fd45 100644 --- a/crates/rbitcoin-consensus/src/block/mod.rs +++ b/crates/rbitcoin-consensus/src/block/mod.rs @@ -1,14 +1,12 @@ -#[cfg(test)] -use crate::confirm_phase_stats; use crate::error::ConsensusError; use crate::milestone::Milestone; use crate::params::ChainParams; +use crate::TxidHasher; use bitcoin::block::Block; use bitcoin::hashes::{sha256d, Hash}; use bitcoin::script::{Script, ScriptBuf}; -use bitcoin::{Amount, OutPoint, Transaction, TxOut, Witness}; +use bitcoin::{Amount, Transaction, TxOut, Witness}; use rbitcoin_primitives::Height; -use rbitcoin_query::{FkMap, Query, TxidHasher, U32Map, U64Map}; use std::borrow::Borrow; use std::hash::BuildHasherDefault; use std::ops::{Deref, DerefMut}; @@ -55,7 +53,7 @@ impl<'a> ValidationContext<'a> { } } -const MAX_BLOCK_STRIPPED_SIZE: usize = 1_000_000; +pub const MAX_BLOCK_STRIPPED_SIZE: usize = 1_000_000; /// Consensus block weight limit. Compact-block tx count is this divided by /// [`MIN_TX_WEIGHT`]. pub const MAX_BLOCK_WEIGHT: u64 = 4_000_000; @@ -64,7 +62,7 @@ pub const MIN_TX_WEIGHT: u64 = 10 * 4; /// Most transactions a valid block can contain. pub const MAX_BLOCK_TX_COUNT: usize = (MAX_BLOCK_WEIGHT / MIN_TX_WEIGHT) as usize; -fn check_tx_local(tx: &Transaction, base_size: usize) -> Result<(), ConsensusError> { +pub fn check_tx_local(tx: &Transaction, base_size: usize) -> Result<(), ConsensusError> { if tx.input.is_empty() { return Err(ConsensusError::BadTx("no inputs")); } @@ -74,8 +72,8 @@ fn check_tx_local(tx: &Transaction, base_size: usize) -> Result<(), ConsensusErr if base_size > MAX_BLOCK_STRIPPED_SIZE { return Err(ConsensusError::BadTx("bad-txns-oversize")); } - let mut seen: rbitcoin_query::OutPointSet = - rbitcoin_query::OutPointSet::with_capacity_and_hasher(tx.input.len(), Default::default()); + let mut seen: crate::OutPointSet = + crate::OutPointSet::with_capacity_and_hasher(tx.input.len(), Default::default()); for inp in &tx.input { if !tx.is_coinbase() && inp.previous_output.is_null() { return Err(ConsensusError::BadTx("bad-txns-prevout-null")); @@ -167,7 +165,7 @@ pub fn validate_block_structure_with_pres( block: &Block, ctx: &ValidationContext<'_>, pres: Option>, - stats: Option<&rbitcoin_query::ConfirmStats>, + mut note: Option<&mut dyn FnMut(u64, u64, u64)>, ) -> Result, ConsensusError> { let n = block.txdata.len(); let (pres, txid_ns) = if let Some(stashed) = pres { @@ -201,8 +199,7 @@ pub fn validate_block_structure_with_pres( return Err(ConsensusError::BadBlock("bad-cb-length")); } - let mut seen: rbitcoin_query::TxidSet = - rbitcoin_query::TxidSet::with_capacity_and_hasher(n, Default::default()); + let mut seen: crate::TxidSet = crate::TxidSet::with_capacity_and_hasher(n, Default::default()); for p in pres.iter() { if !seen.insert(p.txid) { return Err(ConsensusError::BadBlock("duplicate txid")); @@ -264,8 +261,8 @@ pub fn validate_block_structure_with_pres( } let walk_ns = t_walk.elapsed().as_nanos() as u64; - if let Some(stats) = stats { - stats.note_struct_parts(txid_ns, 0, walk_ns); + if let Some(note) = note.as_mut() { + note(txid_ns, 0, walk_ns); } // BIP325 signet solution is not checked here — tip confirm only. @@ -385,13 +382,13 @@ pub fn legacy_sigop_count(tx: &Transaction) -> u64 { n } -pub(crate) use rbitcoin_primitives::script_sigop_count; +pub use rbitcoin_primitives::script_sigop_count; /// Last push in a script for P2SH/BIP141 sigops. /// /// Opcode `> OP_16` or a truncated push → no redeem (0 sigops). OP_N / OP_1NEGATE /// count as an empty push. -fn last_script_push(script: &[u8]) -> Option<&[u8]> { +pub fn last_script_push(script: &[u8]) -> Option<&[u8]> { let mut i = 0usize; let mut last: &[u8] = &[]; while i < script.len() { @@ -447,15 +444,15 @@ fn last_script_push(script: &[u8]) -> Option<&[u8]> { Some(last) } -fn is_p2sh_script(spk: &[u8]) -> bool { +pub fn is_p2sh_script(spk: &[u8]) -> bool { spk.len() == 23 && spk[0] == 0xa9 && spk[1] == 0x14 && spk[22] == 0x87 } -fn is_p2wpkh_program(prog: &[u8]) -> bool { +pub fn is_p2wpkh_program(prog: &[u8]) -> bool { prog.len() == 22 && prog[0] == 0x00 && prog[1] == 0x14 } -fn is_p2wsh_program(prog: &[u8]) -> bool { +pub fn is_p2wsh_program(prog: &[u8]) -> bool { prog.len() == 34 && prog[0] == 0x00 && prog[1] == 0x20 } @@ -515,7 +512,7 @@ fn witness_sigop_count(tx: &Transaction, prev_spks: &[&[u8]]) -> u64 { n } -fn prevout_spk_sigops(inp: &bitcoin::TxIn, spk: &[u8], bip16: bool, witness: bool) -> u64 { +pub fn prevout_spk_sigops(inp: &bitcoin::TxIn, spk: &[u8], bip16: bool, witness: bool) -> u64 { const WITNESS_SCALE: u64 = 4; let mut n = 0u64; if bip16 { @@ -582,16 +579,13 @@ fn check_witness_commitment_with_wtxids( } /// Merkle root over 32-byte leaves (txid or wtxid tree). Public for tests. -pub(crate) fn merkle_root_bytes(leaves: &[[u8; 32]]) -> [u8; 32] { +pub fn merkle_root_bytes(leaves: &[[u8; 32]]) -> [u8; 32] { rbitcoin_primitives::merkle_root_from_txids(leaves) } /// BIP34: coinbase scriptSig must start with the block height as a script integer /// push: 0 → `OP_0`; 1..=16 → `OP_1`..=`OP_16`; else minimal sign-aware little-endian. -pub(crate) fn check_bip34_coinbase( - coinbase: &Transaction, - height: u32, -) -> Result<(), ConsensusError> { +pub fn check_bip34_coinbase(coinbase: &Transaction, height: u32) -> Result<(), ConsensusError> { let script = &coinbase.input[0].script_sig; let bytes = script.as_bytes(); if bytes.is_empty() { @@ -661,7 +655,7 @@ const BIP16_EXCEPTION_TESTNET3: [u8; 32] = [ /// Callers must pass the same prev-block MTP used for BIP113 / header MTP checks /// and the once-computed block hash (plan `meta.hash` / structure). #[inline] -pub(crate) fn bip16_active_from_prev_mtp( +pub fn bip16_active_from_prev_mtp( params: &ChainParams, height: u32, block_hash: &[u8; 32], @@ -691,7 +685,7 @@ pub(crate) fn bip16_active_from_prev_mtp( /// /// Deref to [`Transaction`] so script paths keep `job.tx.input` / `&job.tx` ergonomics. #[derive(Clone)] -pub(crate) struct JobTx { +pub struct JobTx { inner: JobTxInner, } @@ -703,14 +697,14 @@ enum JobTxInner { impl JobTx { #[inline] - pub(crate) fn owned(tx: Transaction) -> Self { + pub fn owned(tx: Transaction) -> Self { Self { inner: JobTxInner::Owned(tx), } } #[inline] - pub(crate) fn shared(block: Arc, index: usize) -> Self { + pub fn shared(block: Arc, index: usize) -> Self { debug_assert!(index < block.txdata.len()); Self { inner: JobTxInner::Shared { block, index }, @@ -878,23 +872,39 @@ impl ScriptVerifyFlags { } } +/// Bytes of one pinned prevout. The `Arc` is the pin allocation; `parts` +/// borrows the script inside it (no second copy). +pub trait PinParts: Send + Sync { + fn parts(&self, vout: u32) -> Option<(u64, &[u8])>; +} + +/// Prevout a confirm job reads without copying script bytes. +/// +/// `Wire` is `(tx_index, vout)` in the job's wire block. `Pinned` is one vout +/// of the pin outs `Arc`. +#[derive(Clone)] +pub enum SharedPrevout { + Wire { tx_index: u32, vout: u32 }, + Pinned { outs: Arc, vout: u32 }, +} + /// Prevout scripts for one script job. /// /// Owned jobs (tests, non-wire connect) store [`TxOut`]. Confirm jobs store -/// [`rbitcoin_query::SharedPrevoutScript`]: same-block bytes stay in the wire -/// block, historical bytes stay in the pin outs `Arc`. -#[derive(Clone, Debug)] -pub(crate) enum JobPrevouts { +/// [`SharedPrevout`]: same-block bytes stay in the wire block, historical +/// bytes stay in the pin outs `Arc`. +#[derive(Clone)] +pub enum JobPrevouts { Owned(Vec), - Shared(Vec), + Shared(Vec), } impl JobPrevouts { - pub(crate) fn owned(v: Vec) -> Self { + pub fn owned(v: Vec) -> Self { Self::Owned(v) } - pub(crate) fn len(&self) -> usize { + pub fn len(&self) -> usize { match self { Self::Owned(v) => v.len(), Self::Shared(v) => v.len(), @@ -909,10 +919,10 @@ impl JobPrevouts { /// re-hashing. pub struct ScriptCheckJob { /// Wire txid (assemble / [`Self::new`]); used for mempool preverified skip. - pub(crate) txid: [u8; 32], - pub(crate) prevouts: JobPrevouts, + pub txid: [u8; 32], + pub prevouts: JobPrevouts, /// Owned (tests) or shared wire block + index (confirm path). - pub(crate) tx: JobTx, + pub tx: JobTx, pub(crate) flags: ScriptVerifyFlags, /// Lookup/structure `TxPrecompute`. Set on the confirm path; tests lazy-`from_tx`. pub(crate) pre: std::sync::OnceLock, @@ -933,9 +943,9 @@ impl DerefMut for ScriptCheckJob { /// Confirm jobs borrow the lookup/structure slice; tests own an `Arc`. pub(crate) enum JobPre { - Owned(std::sync::Arc), + Owned(std::sync::Arc), Slice { - slice: std::sync::Arc<[rbitcoin_query::TxPrecompute]>, + slice: std::sync::Arc<[crate::TxPrecompute]>, idx: usize, }, } @@ -951,7 +961,7 @@ impl ScriptCheckJob { /// Owned-tx path (tests / benches / unit connect): reuse precomputed txid. #[inline] - pub(crate) fn with_txid( + pub fn with_txid( txid: [u8; 32], prevouts: Vec, tx: Transaction, @@ -962,7 +972,7 @@ impl ScriptCheckJob { /// Single construction site for activation + production standardness defaults. #[inline] - pub(crate) fn from_parts( + pub fn from_parts( txid: [u8; 32], prevouts: JobPrevouts, tx: JobTx, @@ -979,20 +989,14 @@ impl ScriptCheckJob { /// Confirm assemble: `slice[idx]` by refcount only (no `TxPrecompute` clone). #[inline] - pub(crate) fn with_pre_slice( - self, - slice: std::sync::Arc<[rbitcoin_query::TxPrecompute]>, - idx: usize, - ) -> Self { + pub fn with_pre_slice(self, slice: std::sync::Arc<[crate::TxPrecompute]>, idx: usize) -> Self { let _ = self.pre.set(JobPre::Slice { slice, idx }); self } fn job_pre(&self) -> &JobPre { self.pre.get_or_init(|| { - JobPre::Owned(std::sync::Arc::new(rbitcoin_query::TxPrecompute::from_tx( - &self.tx, - ))) + JobPre::Owned(std::sync::Arc::new(crate::TxPrecompute::from_tx(&self.tx))) }) } @@ -1000,32 +1004,26 @@ impl ScriptCheckJob { /// /// Shared wire prevouts read the job's block. Pinned prevouts read the outs /// `Arc`. A missing vout is `Corrupt`, not an empty script. - pub(crate) fn prevout_parts(&self, i: usize) -> Result<(u64, &[u8]), ConsensusError> { + pub fn prevout_parts(&self, i: usize) -> Result<(u64, &[u8]), ConsensusError> { match &self.prevouts { JobPrevouts::Owned(v) => { - let o = - v.get(i) - .ok_or(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: prevout index", - )))?; + let o = v + .get(i) + .ok_or(ConsensusError::Internal("invariant: prevout index"))?; Ok((o.value.to_sat(), o.script_pubkey.as_bytes())) } JobPrevouts::Shared(v) => { - let slot = - v.get(i) - .ok_or(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: prevout index", - )))?; + let slot = v + .get(i) + .ok_or(ConsensusError::Internal("invariant: prevout index"))?; match slot { - rbitcoin_query::SharedPrevoutScript::Wire { tx_index, vout } => { + SharedPrevout::Wire { tx_index, vout } => { let o = self.wire_output(*tx_index, *vout)?; Ok((o.value.to_sat(), o.script_pubkey.as_bytes())) } - rbitcoin_query::SharedPrevoutScript::Pinned(_) => { - slot.parts().ok_or(ConsensusError::Store( - rbitcoin_store::StoreError::Corrupt("invariant: pinned prevout vout"), - )) - } + SharedPrevout::Pinned { outs, vout } => outs + .parts(*vout) + .ok_or(ConsensusError::Internal("invariant: pinned prevout vout")), } } } @@ -1034,13 +1032,11 @@ impl ScriptCheckJob { fn wire_output(&self, tx_index: u32, vout: u32) -> Result<&TxOut, ConsensusError> { self.tx .output_at(tx_index, vout) - .ok_or(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: wire prevout", - ))) + .ok_or(ConsensusError::Internal("invariant: wire prevout")) } #[inline] - pub(crate) fn prevout_script(&self, i: usize) -> Result<&[u8], ConsensusError> { + pub fn prevout_script(&self, i: usize) -> Result<&[u8], ConsensusError> { self.prevout_parts(i).map(|(_, script)| script) } @@ -1051,7 +1047,7 @@ impl ScriptCheckJob { } #[inline] - pub(crate) fn pre(&self) -> &rbitcoin_query::TxPrecompute { + pub fn pre(&self) -> &crate::TxPrecompute { match self.job_pre() { JobPre::Owned(a) => a.as_ref(), JobPre::Slice { slice, idx } => &slice[*idx], @@ -1060,244 +1056,21 @@ impl ScriptCheckJob { } /// Identity-hashed `txid → V` (assemble index / pack creates). -pub(crate) type TxidMap = std::collections::HashMap<[u8; 32], V, BuildHasherDefault>; +pub type TxidMap = std::collections::HashMap<[u8; 32], V, BuildHasherDefault>; /// Pack-local create fk by parent txid (not per-vout — fk is per tx). -pub(crate) type PendingCreates = TxidMap; - -/// Block-local prevout path counts; the confirm pipeline flushes once. -#[derive(Default)] -pub(crate) struct AsmPrevoutAcc { - in_n: u64, - same_n: u64, - batch_n: u64, - cold_n: u64, - cold_null_fk_n: u64, - cold_not_pin_n: u64, - cold_txid_mismatch_n: u64, - cold_vout_miss_n: u64, -} - -impl AsmPrevoutAcc { - pub(crate) fn flush(&self, stats: &rbitcoin_query::ConfirmStats) { - rbitcoin_query::note_confirm(&stats.asm_in_n, self.in_n); - rbitcoin_query::note_confirm(&stats.asm_prev_same_n, self.same_n); - rbitcoin_query::note_confirm(&stats.asm_prev_batch_n, self.batch_n); - rbitcoin_query::note_confirm(&stats.asm_prev_cold_n, self.cold_n); - rbitcoin_query::note_confirm(&stats.asm_prev_cold_null_fk_n, self.cold_null_fk_n); - rbitcoin_query::note_confirm(&stats.asm_prev_cold_not_pin_n, self.cold_not_pin_n); - rbitcoin_query::note_confirm( - &stats.asm_prev_cold_txid_mismatch_n, - self.cold_txid_mismatch_n, - ); - rbitcoin_query::note_confirm(&stats.asm_prev_cold_vout_miss_n, self.cold_vout_miss_n); - } -} - -pub(crate) struct AssembleMeters { - pub prev_ns: u64, - pub job_ns: u64, - pub acc: AsmPrevoutAcc, -} - -#[allow(clippy::too_many_arguments)] // call-site args stay unbundled -#[allow(clippy::type_complexity)] // packed row / pin / script-hash tuple is the on-disk shape -/// Sequential assemble: resolve prevout **content**, build script jobs, collect spends. -/// -/// Confirm IBD path: no durable spentness / maturity / BIP68 create-height -/// resolution — those run in [`structural_validate_spends`] after scripts (load -/// must not walk create height per parent). Absolute nLockTime finality -/// (BIP113 MTP of prev block) still runs here — it only needs header MTP. -/// -/// `pending_spent`: pack-local double-spend (early reject before scripts). -/// `pending_creates`: pack-local `txid → create_fk` (not per-vout). -/// Same-block outs use `txid_index` (this block, `pj < ti`); meters flush -/// once per block (no per-input Instant / atomics). -/// -/// Prevouts resolve from per-batch [`rbitcoin_query::BatchParents`] + -/// [`rbitcoin_query::SpendEdges`]. Optimistic miss is `invariant:` (no head recover). -/// -/// Returns `(script_jobs, spends, fees, tx_fees, meters)`. `fees` is the block sum -/// for the coinbase subsidy check. `tx_fees` is one satoshi fee per tx -/// (coinbase is 0) for the `txstat` stamp. `meters` is flushed by the caller. -/// -/// `prev_mtp` / `block_hash` / `bip16_active` must be computed **once** by the -/// caller (assemble_run header window) — no re-walk of headers and no rehash. -/// -/// `wire`: when `Some`, script jobs share that Arc (no `Transaction` clone). -/// When `None` (unit-test connect), jobs own a deep clone of each non-cb tx. -/// `build_script_jobs` is the caller's milestone decision. -pub(crate) fn assemble_block_prevouts( - block: &Block, - ctx: &ValidationContext<'_>, - archived_tx_fks: Option<&[rbitcoin_primitives::Fk]>, - pending_spent: &mut rbitcoin_query::OutPointSet, - pending_creates: &mut PendingCreates, - batch_parents: &rbitcoin_query::BatchParents, - spend_edges: &rbitcoin_query::SpendEdges, - create_txids: &[[u8; 32]], - prev_mtp: u32, - block_hash: &[u8; 32], - bip16_active: bool, - wire: Option<&Arc>, - pres: Option<&Arc<[rbitcoin_query::TxPrecompute]>>, - build_script_jobs: bool, -) -> Result< - ( - Vec, - Vec<( - [u8; 32], - u32, - rbitcoin_primitives::Fk, - rbitcoin_primitives::Fk, - u32, - )>, - i64, - Vec, - AssembleMeters, - ), - ConsensusError, -> { - assemble_prevout_guards(block, archived_tx_fks, create_txids)?; - // Caller-supplied BIP16 must match hash+prev_mtp (no silent re-resolve). - debug_assert_eq!( - bip16_active, - bip16_active_from_prev_mtp(ctx.params, ctx.height.0, block_hash, prev_mtp) - ); - let flags = ScriptVerifyFlags::consensus_at(ctx, block_hash, bip16_active); - - let n_tx = block.txdata.len(); - let mut txid_index: TxidMap = - TxidMap::with_capacity_and_hasher(n_tx, Default::default()); - for (i, id) in create_txids.iter().enumerate() { - txid_index.insert(*id, i); - } - let mut acc = AsmPrevoutAcc::default(); - let mut clk_job = 0u64; - let mut fees = 0i64; - let mut tx_fees = vec![0u64; n_tx]; - let mut script_jobs: Vec = if build_script_jobs { - Vec::with_capacity(n_tx.saturating_sub(1)) - } else { - Vec::new() - }; - let mut block_sigops_cost = match pres.and_then(|p| p.first()) { - Some(p) => p.sigops.saturating_mul(4), - None => legacy_sigop_count(&block.txdata[0]).saturating_mul(4), - }; - let mut spends: Vec<( - [u8; 32], - u32, - rbitcoin_primitives::Fk, - rbitcoin_primitives::Fk, - u32, - )> = Vec::with_capacity(n_tx.saturating_mul(2)); - - let t_loop = Instant::now(); - - let lock_time_cutoff = assemble_lock_time_cutoff(ctx, block, prev_mtp); - - for (ti, tx) in block.txdata.iter().enumerate() { - let spend_fk = archived_tx_fks.map(|fks| fks[ti]); - let txid = create_txids[ti]; - - if !is_final_tx(tx, ctx.height.0, lock_time_cutoff) { - return Err(ConsensusError::BadTx("bad-txns-nonfinal")); - } - if tx.output.is_empty() { - return Err(ConsensusError::BadTx("no outputs")); - } - if ti > 0 { - tx_fees[ti] = assemble_non_cb_tx( - ctx.params, - block, - tx, - ti, - txid, - spend_fk, - flags, - bip16_active, - wire, - pres, - &txid_index, - pending_spent, - pending_creates, - batch_parents, - spend_edges, - &mut acc, - &mut script_jobs, - &mut spends, - &mut fees, - &mut block_sigops_cost, - build_script_jobs, - &mut clk_job, - )?; - } - - let create_fk = spend_fk.unwrap_or(rbitcoin_primitives::Fk::NULL); - if !create_fk.is_null() { - pending_creates.insert(txid, create_fk); - } - } - - let clk_prev = t_loop.elapsed().as_nanos() as u64; - Ok(( - script_jobs, - spends, - fees, - tx_fees, - AssembleMeters { - prev_ns: clk_prev, - job_ns: clk_job, - acc, - }, - )) -} - -fn assemble_prevout_guards( - block: &Block, - archived_tx_fks: Option<&[rbitcoin_primitives::Fk]>, - create_txids: &[[u8; 32]], -) -> Result<(), ConsensusError> { - if let Some(fks) = archived_tx_fks { - if fks.len() != block.txdata.len() { - return Err(ConsensusError::BadBlock("archived tx fk count mismatch")); - } - } - if create_txids.len() != block.txdata.len() { - return Err(ConsensusError::BadBlock( - "invariant: create_txids length must match block.txdata (no assemble re-hash)", - )); - } - if block.txdata.is_empty() { - return Err(ConsensusError::BadBlock("empty block")); - } - if !block.txdata[0].is_coinbase() { - return Err(ConsensusError::BadBlock("first tx not coinbase")); - } - Ok(()) -} - -fn assemble_lock_time_cutoff(ctx: &ValidationContext<'_>, block: &Block, prev_mtp: u32) -> u32 { - // Height 0 has no MTP history, so the cutoff is the block time even - // when CSV is active. - if ctx.height.0 == 0 || !ctx.params.csv_active_at(ctx.height.0) { - block.header.time - } else { - prev_mtp - } -} +pub type PendingCreates = TxidMap; /// `MAX_BLOCK_SIGOPS_COST` (20_000 legacy sigops × witness scale 4). #[inline] -fn exceeds_sigops_limit(cost: u64) -> bool { +pub fn exceeds_sigops_limit(cost: u64) -> bool { const MAX_BLOCK_SIGOPS_COST: u64 = 80_000; cost > MAX_BLOCK_SIGOPS_COST } /// Attach the precompute slice only when `ti` is inside it. #[inline] -fn should_use_pres(ti: usize, len: usize) -> bool { +pub fn should_use_pres(ti: usize, len: usize) -> bool { ti < len } @@ -1309,13 +1082,13 @@ fn should_use_pres(ti: usize, len: usize) -> bool { /// cannot become negative when `money_range_out_sum` or /// `assemble_tx_value_out` casts it. #[inline] -fn exceeds_max_money(sats: u64) -> bool { +pub fn exceeds_max_money(sats: u64) -> bool { sats > Amount::MAX_MONEY.to_sat() } /// Signed satoshis for an `out_sum` that already passed [`exceeds_max_money`]. #[inline] -fn money_range_out_sum(out_sum: u64) -> i64 { +pub fn money_range_out_sum(out_sum: u64) -> i64 { debug_assert!( !exceeds_max_money(out_sum), "out_sum above MAX_MONEY must be rejected in validate_block_structure_with_pres" @@ -1323,111 +1096,10 @@ fn money_range_out_sum(out_sum: u64) -> i64 { out_sum as i64 } -#[allow(clippy::too_many_arguments)] // one non-coinbase assemble; call site stays unbundled -fn assemble_non_cb_tx( - params: &ChainParams, - block: &Block, - tx: &Transaction, - ti: usize, - txid: [u8; 32], - spend_fk: Option, - flags: ScriptVerifyFlags, - bip16_active: bool, - wire: Option<&Arc>, - pres: Option<&Arc<[rbitcoin_query::TxPrecompute]>>, - txid_index: &TxidMap, - pending_spent: &mut rbitcoin_query::OutPointSet, - pending_creates: &PendingCreates, - batch_parents: &rbitcoin_query::BatchParents, - spend_edges: &rbitcoin_query::SpendEdges, - acc: &mut AsmPrevoutAcc, - script_jobs: &mut Vec, - spends: &mut Vec<( - [u8; 32], - u32, - rbitcoin_primitives::Fk, - rbitcoin_primitives::Fk, - u32, - )>, - fees: &mut i64, - block_sigops_cost: &mut u64, - build_script_jobs: bool, - clk_job: &mut u64, -) -> Result { - if tx.input.is_empty() { - return Err(ConsensusError::BadTx("no inputs")); - } - let (value_in, prevouts, tx_in_sigops) = assemble_non_cb_inputs( - params, - block, - tx, - ti, - spend_fk, - flags, - bip16_active, - txid_index, - pending_spent, - pending_creates, - batch_parents, - spend_edges, - wire, - acc, - spends, - build_script_jobs, - )?; - let tx_legacy_sigops = match pres.and_then(|p| p.get(ti)) { - Some(p) => p.sigops.saturating_mul(4), - None => legacy_sigop_count(tx).saturating_mul(4), - }; - *block_sigops_cost = block_sigops_cost - .checked_add(tx_legacy_sigops) - .and_then(|c| c.checked_add(tx_in_sigops)) - .ok_or(ConsensusError::BadBlock("bad-blk-sigops"))?; - if exceeds_sigops_limit(*block_sigops_cost) { - return Err(ConsensusError::BadBlock("bad-blk-sigops")); - } - let value_out = assemble_tx_value_out(tx, ti, pres)?; - if value_out > value_in { - return Err(ConsensusError::BadTx("in < out")); - } - let fee = value_in - .checked_sub(value_out) - .ok_or(ConsensusError::BadTx("fee overflow"))?; - let fee_sat = u64::try_from(fee).map_err(|_| ConsensusError::BadTx("fee overflow"))?; - *fees = fees - .checked_add(fee) - .ok_or(ConsensusError::BadTx("fee overflow"))?; - if build_script_jobs { - let t_job = Instant::now(); - let mut job = match (wire, prevouts) { - (Some(w), JobPrevouts::Shared(v)) => ScriptCheckJob::from_parts( - txid, - JobPrevouts::Shared(v), - JobTx::shared(Arc::clone(w), ti), - flags, - ), - (None, JobPrevouts::Owned(v)) => ScriptCheckJob::with_txid(txid, v, tx.clone(), flags), - _ => { - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: script job prevouts", - ))); - } - }; - if let Some(ps) = pres { - if should_use_pres(ti, ps.len()) { - job = job.with_pre_slice(Arc::clone(ps), ti); - } - } - script_jobs.push(job); - *clk_job = clk_job.saturating_add(t_job.elapsed().as_nanos() as u64); - } - Ok(fee_sat) -} - -fn assemble_tx_value_out( +pub fn assemble_tx_value_out( tx: &Transaction, ti: usize, - pres: Option<&Arc<[rbitcoin_query::TxPrecompute]>>, + pres: Option<&Arc<[crate::TxPrecompute]>>, ) -> Result { match pres.and_then(|p| p.get(ti)) { Some(p) => Ok(money_range_out_sum(p.out_sum)), @@ -1447,111 +1119,7 @@ fn assemble_tx_value_out( } } -#[allow(clippy::too_many_arguments)] // one non-coinbase input walk; call site stays unbundled -fn assemble_non_cb_inputs( - params: &ChainParams, - block: &Block, - tx: &Transaction, - ti: usize, - spend_fk: Option, - flags: ScriptVerifyFlags, - bip16_active: bool, - txid_index: &TxidMap, - pending_spent: &mut rbitcoin_query::OutPointSet, - pending_creates: &PendingCreates, - batch_parents: &rbitcoin_query::BatchParents, - spend_edges: &rbitcoin_query::SpendEdges, - wire: Option<&Arc>, - acc: &mut AsmPrevoutAcc, - spends: &mut Vec<( - [u8; 32], - u32, - rbitcoin_primitives::Fk, - rbitcoin_primitives::Fk, - u32, - )>, - build_script_jobs: bool, -) -> Result<(i64, JobPrevouts, u64), ConsensusError> { - let mut value_in = 0i64; - let mut prevouts = if build_script_jobs && wire.is_some() { - JobPrevouts::Shared(Vec::with_capacity(tx.input.len())) - } else { - JobPrevouts::owned(Vec::with_capacity(if build_script_jobs { - tx.input.len() - } else { - 0 - })) - }; - let edges = spend_fk.and_then(|fk| fk.get().and_then(|id| spend_edges.get(&id))); - let mut tx_in_sigops = 0u64; - for (ii, input) in tx.input.iter().enumerate() { - let op = input.previous_output; - let key = (op.txid.to_byte_array(), op.vout); - // The genesis coinbase stays indexed for RPC and Electrum, but it is - // not a coin (Core `ConnectBlock` genesis early return). - if params.is_genesis_coinbase(&key.0) { - return Err(ConsensusError::MissingPrevout); - } - if !pending_spent.insert(key) { - return Err(ConsensusError::BadTx("double spend in block")); - } - if let Some(&pj) = txid_index.get(&key.0) { - if pj >= ti { - return Err(ConsensusError::MissingPrevout); - } - if pj == 0 { - return Err(ConsensusError::BadTx("coinbase immature")); - } - } - let prev_fk = edges - .as_ref() - .and_then(|t| t.get(ii)) - .and_then(|e| e.create_fk.get().map(|_| e.create_fk)) - .or_else(|| pending_creates.get(&key.0).copied()); - let prev_out = resolve_prevout( - block, - op, - input, - prev_fk, - txid_index, - ti, - batch_parents, - bip16_active, - flags.witness_active, - build_script_jobs, - wire, - acc, - )?; - let create_fk = prev_out.create_fk; - tx_in_sigops = tx_in_sigops.saturating_add(prev_out.input_sigops); - spends.push(( - key.0, - key.1, - spend_fk.unwrap_or(rbitcoin_primitives::Fk::NULL), - create_fk, - ii as u32, - )); - value_in = value_in - .checked_add(prev_out.txout.value.to_sat() as i64) - .ok_or(ConsensusError::BadTx("value in overflow"))?; - if build_script_jobs { - match &mut prevouts { - JobPrevouts::Shared(v) => { - let shared = prev_out.shared.ok_or(ConsensusError::Store( - rbitcoin_store::StoreError::Corrupt( - "invariant: wire confirm prevout shares script bytes", - ), - ))?; - v.push(shared); - } - JobPrevouts::Owned(v) => v.push(prev_out.txout), - } - } - } - Ok((value_in, prevouts, tx_in_sigops)) -} - -fn check_coinbase_subsidy( +pub fn check_coinbase_subsidy( block: &Block, ctx: &ValidationContext<'_>, fees: i64, @@ -1572,767 +1140,9 @@ fn check_coinbase_subsidy( Ok(()) } -/// Local wall times for one block's structural pass (write path diagnostics). -/// -/// Measured with `Instant` — **not** deltas of window atomics (those race with -/// `sample_and_reset` mid-batch and produced false `spent=0` on slow writes). -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct StructuralPhaseNs { - pub spent_ns: u64, - /// Pin abs collect + bulk on-disk 8-byte spender meta pread. - pub spent_abs_ns: u64, - /// `is_confirmed_strong_at` on non-null fields (still durable authority). - pub spent_strong_ns: u64, - /// Cold unspent_create_vouts / null-create probes. - pub spent_cold_ns: u64, - /// pending_spent order gate (CPU). - pub spent_pending_ns: u64, - pub create_h_ns: u64, - pub bip68_ns: u64, -} - -/// Post-script structural checks: durable spentness, maturity, BIP68, coinbase subsidy. -/// -/// Runs in height order on the write path (after scripts). `pending_spent` is -/// write-local across a multi-height run. -/// -/// **BIP68** create-height lives here (not optimistic load assemble) so confirm -/// load does not walk create height for every parent. Heights: bulk fence. -/// Coin MTP only for time-type relative locks on version ≥2 txs (v1 skipped). -/// -/// Write-path spend annotate slots: abs edge plus the meta structural already read. -/// -/// Filled during spentness and passed to annotate. No second edge copy. -#[derive(Clone, Debug, Default)] -pub(crate) struct AnnotateSlots { - pub abs_edges: Vec<( - u64, - rbitcoin_primitives::Fk, - u32, - rbitcoin_primitives::Fk, - u32, - )>, - pub known: Vec<(rbitcoin_primitives::Fk, u8, u32)>, -} - -impl AnnotateSlots { - pub(crate) fn push( - &mut self, - edge: ( - u64, - rbitcoin_primitives::Fk, - u32, - rbitcoin_primitives::Fk, - u32, - ), - known: (rbitcoin_primitives::Fk, u8, u32), - ) { - self.abs_edges.push(edge); - self.known.push(known); - } -} - -/// Per-block spentness scratch. Capacity stays across blocks in one write batch. -/// [`AnnotateSlots`] accumulate for the whole batch. -pub(crate) struct StructuralScratch { - abs_jobs: Vec, - unique_fks: Vec, - height_by_id: U64Map, - skip_n: std::collections::HashMap< - (u64, u32), - u32, - BuildHasherDefault, - >, - skip: OverlayMetaSkip, - disk_jobs: Vec, - abs_offs: Vec, - field_fks: Vec, - field_seen: rbitcoin_query::U64Set, - field_h_by_id: U64Map, - durable_spent: DurableSpentSet, - height_list: Vec, - create_height_by_fk: FkMap, - pub slots: AnnotateSlots, -} - -impl Default for StructuralScratch { - fn default() -> Self { - Self { - abs_jobs: Vec::new(), - unique_fks: Vec::new(), - height_by_id: U64Map::default(), - skip_n: std::collections::HashMap::with_hasher(Default::default()), - skip: OverlayMetaSkip::with_hasher(Default::default()), - disk_jobs: Vec::new(), - abs_offs: Vec::new(), - field_fks: Vec::new(), - field_seen: rbitcoin_query::U64Set::default(), - field_h_by_id: U64Map::default(), - durable_spent: DurableSpentSet::with_hasher(Default::default()), - height_list: Vec::new(), - create_height_by_fk: FkMap::default(), - slots: AnnotateSlots::default(), - } - } -} - -impl StructuralScratch { - /// Drop annotate slots from the previous write batch. Per-block buffers - /// stay for [`Self::begin_block`]; slots accumulate inside one batch. - pub(crate) fn begin_batch(&mut self) { - self.slots.abs_edges.clear(); - self.slots.known.clear(); - } - - fn begin_block(&mut self) { - self.abs_jobs.clear(); - self.unique_fks.clear(); - self.height_by_id.clear(); - self.skip_n.clear(); - self.skip.clear(); - self.disk_jobs.clear(); - self.abs_offs.clear(); - self.field_fks.clear(); - self.field_seen.clear(); - self.field_h_by_id.clear(); - self.durable_spent.clear(); - self.height_list.clear(); - self.create_height_by_fk.clear(); - } -} - -#[allow(clippy::too_many_arguments)] // call-site args stay unbundled -/// **Spentness:** pin denserels → abs + bulk 8-byte meta. Sparse durable-**spent** -/// set (not unspent). Missing abs / short meta is hard `Err`. **Multi-list** after -/// reorg annotate is a protocol cold walk (`has_confirmed_strong_spender_create`) -/// — not a hard fail (tip-follow reorgs leave multi flags by design). Emits -/// Annotate slots on `scratch` for pure-write annotate. -pub(crate) fn structural_validate_spends( - query: &Query, - block: &Block, - ctx: &ValidationContext<'_>, - archived_tx_fks: Option<&[rbitcoin_primitives::Fk]>, - spends: &[( - [u8; 32], - u32, - rbitcoin_primitives::Fk, - rbitcoin_primitives::Fk, - u32, - )], - fees: i64, - pending_spent: &mut rbitcoin_query::OutPointSet, - batch_parents: &rbitcoin_query::BatchParents, - mtp_cache: &mut U32Map, - run_create_height: &RunCreateHeight, - class_a_wave: &ClassAWave, - scratch: &mut StructuralScratch, - abs_jobs: &[StructuralAbsJob], -) -> Result { - use std::time::Instant; - - scratch.begin_block(); - let maturity = ctx.params.coinbase_maturity(); - // BIP30's txid batch is inside `spent_ns` (signet runs it on every block). - let t_spent = Instant::now(); - reject_bip30_unspent_overwrite(query, block, ctx)?; - let t_abs = Instant::now(); - structural_abs_heights(query, run_create_height, scratch, abs_jobs)?; - let tip = query.tip_height().map(|h| h.0); - let mut spent_strong_ns = 0u64; - let mut multi_list_ns = 0u64; - if !scratch.abs_jobs.is_empty() { - fill_overlay_skip(spends, class_a_wave, scratch); - let loaded = structural_load_durable_spent(query, tip, scratch)?; - multi_list_ns = loaded.0; - spent_strong_ns = loaded.1; - } - let spent_abs_ns = (t_abs.elapsed().as_nanos() as u64).saturating_sub(spent_strong_ns); - let spent_cold_ns = multi_list_ns; - let t_pending = Instant::now(); - structural_mark_pending(spends, pending_spent, &scratch.durable_spent)?; - let spent_pending_ns = t_pending.elapsed().as_nanos() as u64; - let spent_ns = t_spent.elapsed().as_nanos() as u64; - let t_create = Instant::now(); - structural_create_heights( - query, - batch_parents, - run_create_height, - ctx.height.0, - maturity, - scratch, - )?; - let create_h_ns = t_create.elapsed().as_nanos() as u64; - let t_bip68 = Instant::now(); - structural_bip68( - query, - block, - ctx, - spends, - &scratch.create_height_by_fk, - mtp_cache, - )?; - let bip68_ns = t_bip68.elapsed().as_nanos() as u64; - let _ = archived_tx_fks; - check_coinbase_subsidy(block, ctx, fees)?; - Ok(StructuralPhaseNs { - spent_ns, - spent_abs_ns, - spent_strong_ns, - spent_cold_ns, - spent_pending_ns, - create_h_ns, - bip68_ns, - }) -} - -pub(crate) type StructuralAbsJob = (u64, u32, u64, rbitcoin_primitives::Fk, u32); - -/// Creates this write batch's Class A append committed. -/// -/// Only these spent slots carry the same-batch sole-spender overlay. A run -/// create archived by an earlier wave has whatever that wave and later -/// annotates left on disk, so structural reads and annotates it. -#[derive(Default)] -pub(crate) struct ClassAWave(Vec); - -impl ClassAWave { - pub(crate) fn new(mut fks: Vec) -> Self { - fks.sort_unstable_by_key(|f| f.0); - Self(fks) - } - - fn contains(&self, fk: rbitcoin_primitives::Fk) -> bool { - self.0.binary_search_by_key(&fk.0, |f| f.0).is_ok() - } -} - -/// Create heights for one write batch. -/// -/// Contiguous per-block fk spans are the IBD shape (`first` + count). A gap -/// or overlap uses the map so a fk between spans does not inherit a height. -/// Each block's first fk is its coinbase: a span's `first`, or the map's flag. -pub(crate) enum RunCreateHeight { - Spans(Vec<(u64, u64, u32)>), - Map(FkMap<(u32, bool)>), -} - -impl RunCreateHeight { - pub(crate) fn from_blocks<'a>( - blocks: impl IntoIterator, - ) -> Self { - let blocks: Vec<(u32, &'a [rbitcoin_primitives::Fk])> = blocks.into_iter().collect(); - if let Some(spans) = contiguous_create_spans(&blocks) { - Self::Spans(spans) - } else { - let mut map = FkMap::default(); - for (height, fks) in blocks { - for (i, fk) in fks.iter().enumerate() { - map.insert(*fk, (height, i == 0)); - } - } - Self::Map(map) - } - } - - pub(crate) fn get(&self, fk: rbitcoin_primitives::Fk) -> Option { - self.create(fk).map(|(height, _)| height) - } - - /// Height of a create in this batch and whether it is its block's coinbase. - pub(crate) fn create(&self, fk: rbitcoin_primitives::Fk) -> Option<(u32, bool)> { - match self { - Self::Map(map) => map.get(&fk).copied(), - Self::Spans(spans) => { - let id = fk.get()?; - let i = spans.partition_point(|span| span.0 <= id); - let (first, end, height) = spans.get(i.checked_sub(1)?)?; - (*end > id).then_some((*height, *first == id)) - } - } - } -} - -/// `Some` when every non-empty block is `[first, first+n)` and spans do not overlap. -/// -/// A null fk, a gap, or an id that would wrap is `None` so the caller uses the map. -/// A wrapped end must not cover fks that are not in the block. -fn contiguous_create_spans( - blocks: &[(u32, &[rbitcoin_primitives::Fk])], -) -> Option> { - let mut spans = Vec::with_capacity(blocks.len()); - for &(height, fks) in blocks { - if fks.is_empty() { - continue; - } - let mut ids = fks.iter().map(|fk| fk.get()); - let first = ids.next().flatten()?; - let mut prev = first; - for id in ids { - let id = id?; - let next = prev.checked_add(1)?; - if id != next { - return None; - } - prev = id; - } - let end = prev.checked_add(1)?; - spans.push((first, end, height)); - } - spans.sort_unstable_by_key(|span| span.0); - for pair in spans.windows(2) { - if pair[0].1 > pair[1].0 { - return None; - } - } - Some(spans) -} -type DurableSpentSet = - std::collections::HashSet<(u64, u32), BuildHasherDefault>; -type OverlayMetaSkip = std::collections::HashMap< - (u64, u32), - (rbitcoin_primitives::Fk, u32), - BuildHasherDefault, ->; - -fn fill_overlay_skip( - spends: &[( - [u8; 32], - u32, - rbitcoin_primitives::Fk, - rbitcoin_primitives::Fk, - u32, - )], - class_a_wave: &ClassAWave, - scratch: &mut StructuralScratch, -) { - for &(_, vout, sfk, cfk, vin) in spends { - if !class_a_wave.contains(cfk) { - continue; - } - let Some(id) = cfk.get() else { - continue; - }; - let key = (id, vout); - *scratch.skip_n.entry(key).or_insert(0) += 1; - scratch.skip.entry(key).or_insert((sfk, vin)); - } - let skip_n = &scratch.skip_n; - scratch - .skip - .retain(|k, _| skip_n.get(k).copied() == Some(1)); -} - -fn overlay_meta_is_skip( - id: u64, - vout: u32, - sfk: rbitcoin_primitives::Fk, - vin: u32, - skip: &OverlayMetaSkip, -) -> bool { - skip.get(&(id, vout)) == Some(&(sfk, vin)) -} - -fn structural_abs_heights( - query: &Query, - run_create_height: &RunCreateHeight, - scratch: &mut StructuralScratch, - abs_jobs: &[StructuralAbsJob], -) -> Result<(), ConsensusError> { - scratch.abs_jobs.extend_from_slice(abs_jobs); - scratch.unique_fks.extend( - scratch - .abs_jobs - .iter() - .map(|(id, _, _, _, _)| rbitcoin_primitives::Fk(*id)), - ); - scratch.unique_fks.sort_unstable_by_key(|f| f.0); - scratch.unique_fks.dedup(); - let durable_heights = query - .store() - .tx_height_get_batch(&scratch.unique_fks) - .map_err(ConsensusError::from)?; - for (fk, h) in scratch.unique_fks.iter().zip(durable_heights) { - let Some(id) = fk.get() else { - continue; - }; - let Some(h) = h.or_else(|| run_create_height.get(*fk)) else { - continue; - }; - scratch.height_by_id.insert(id, h); - } - Ok(()) -} - -fn structural_load_durable_spent( - query: &Query, - tip: Option, - scratch: &mut StructuralScratch, -) -> Result<(u64, u64), ConsensusError> { - use std::time::Instant; - let mut ovl_n = 0u64; - for &job in &scratch.abs_jobs { - let (id, vout, abs, sfk, vin) = job; - if overlay_meta_is_skip(id, vout, sfk, vin, &scratch.skip) { - ovl_n = ovl_n.saturating_add(1); - continue; - } - scratch.disk_jobs.push(job); - scratch.abs_offs.push(abs); - } - rbitcoin_query::note_confirm(&query.confirm_stats().spend_overlay_skip_n, ovl_n); - if scratch.abs_offs.is_empty() { - return Ok((0, 0)); - } - let meta_backend = rbitcoin_store::spend_meta_backend(); - let t_meta = Instant::now(); - let metas = query - .store() - .get_spender_meta_at_abs_batch_backend(&scratch.abs_offs, meta_backend) - .map_err(ConsensusError::from)?; - let meta_ns = t_meta.elapsed().as_nanos() as u64; - rbitcoin_query::note_confirm(&query.confirm_stats().spend_meta_ns, meta_ns); - rbitcoin_query::note_confirm( - &query.confirm_stats().spend_meta_n, - scratch.abs_offs.len() as u64, - ); - let _ = meta_backend; - if metas.len() != scratch.disk_jobs.len() { - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: structural meta batch length", - ))); - } - let t_strong = Instant::now(); - for row in &metas { - let Some((field, _, _)) = row else { - continue; - }; - if field.is_null() { - continue; - } - if let Some(fid) = field.get() { - if scratch.field_seen.insert(fid) { - scratch.field_fks.push(*field); - } - } - } - let field_heights = query - .store() - .tx_height_get_batch(&scratch.field_fks) - .map_err(ConsensusError::from)?; - for (fk, h) in scratch.field_fks.iter().zip(field_heights) { - if let Some((id, h)) = fk.get().zip(h) { - scratch.field_h_by_id.insert(id, h); - } - } - let jobs = std::mem::take(&mut scratch.disk_jobs); - let applied = jobs - .iter() - .copied() - .zip(metas) - .try_fold(0u64, |acc, (job, meta)| { - let (id, vout, abs, sfk, vin) = job; - let ns = structural_apply_one_meta(query, meta, id, vout, abs, sfk, vin, tip, scratch)?; - Ok::(acc.saturating_add(ns)) - }); - scratch.disk_jobs = jobs; - let multi_list_ns = applied?; - let spent_strong_ns = t_strong - .elapsed() - .as_nanos() - .saturating_sub(multi_list_ns as u128) as u64; - Ok((multi_list_ns, spent_strong_ns)) -} - -#[allow(clippy::too_many_arguments)] // one spent-slot apply; call site stays unbundled -fn structural_apply_one_meta( - query: &Query, - meta: Option<(rbitcoin_primitives::Fk, u8, u32)>, - id: u64, - vout: u32, - abs: u64, - sfk: rbitcoin_primitives::Fk, - vin: u32, - tip: Option, - scratch: &mut StructuralScratch, -) -> Result { - use std::time::Instant; - let Some((field, flags, field_vin)) = meta else { - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: structural spender meta short/OOB (cold forbidden)", - ))); - }; - scratch.slots.push( - (abs, rbitcoin_primitives::Fk(id), vout, sfk, vin), - (field, flags, field_vin), - ); - let multi = flags & rbitcoin_store::output_flags::MULTI_SPENDER != 0; - if multi { - let t_m = Instant::now(); - let spent = query - .store() - .has_confirmed_strong_spender_create(rbitcoin_primitives::Fk(id), vout, None) - .map_err(ConsensusError::from)?; - let ns = t_m.elapsed().as_nanos() as u64; - if spent { - scratch.durable_spent.insert((id, vout)); - } - return Ok(ns); - } - if field.is_null() { - return Ok(0); - } - let strong = query - .store() - .is_confirmed_strong_at(field, tip) - .map_err(ConsensusError::from)?; - if !strong { - return Ok(0); - } - let create_h = scratch.height_by_id.get(&id).copied(); - let spend_h = field - .get() - .and_then(|fid| scratch.field_h_by_id.get(&fid).copied()); - if let (Some(ch), Some(sh)) = (create_h, spend_h) { - if sh < ch { - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: confirmed spender below its create height", - ))); - } - } - scratch.durable_spent.insert((id, vout)); - Ok(0) -} - -fn structural_mark_pending( - spends: &[( - [u8; 32], - u32, - rbitcoin_primitives::Fk, - rbitcoin_primitives::Fk, - u32, - )], - pending_spent: &mut rbitcoin_query::OutPointSet, - durable_spent: &DurableSpentSet, -) -> Result<(), ConsensusError> { - for &(prev_txid, vout, _spend_fk, create_fk, _vin) in spends { - let key = (prev_txid, vout); - let spent = if create_fk.is_null() { - false - } else if let Some(id) = create_fk.get() { - durable_spent.contains(&(id, vout)) - } else { - false - }; - if spent || !pending_spent.insert(key) { - return Err(ConsensusError::PrevoutSpent); - } - } - Ok(()) -} - -fn structural_create_heights( - query: &Query, - batch_parents: &rbitcoin_query::BatchParents, - run_create_height: &RunCreateHeight, - spend_height: u32, - maturity: u32, - scratch: &mut StructuralScratch, -) -> Result<(), ConsensusError> { - // `confirmed[h]` for a height in this batch is written at Class C, after - // structural; the batch index owns coinbase identity there. - scratch.height_list.extend( - scratch - .height_by_id - .iter() - .filter(|&(&id, &h)| run_create_height.get(rbitcoin_primitives::Fk(id)) != Some(h)) - .map(|(_, &h)| h), - ); - scratch.height_list.sort_unstable(); - scratch.height_list.dedup(); - let coinbase_fk_by_height = query - .store() - .coinbase_fk_at_heights(&scratch.height_list) - .map_err(ConsensusError::from)?; - let n_unique = scratch.unique_fks.len(); - for i in 0..n_unique { - let create_fk = scratch.unique_fks[i]; - let Some(id) = create_fk.get() else { - continue; - }; - let Some(&durable_h) = scratch.height_by_id.get(&id) else { - return reject_one_create(spend_height, maturity, None, false); - }; - let pin_cb = batch_parents.get_parent_coinbase(create_fk); - let is_cb = match (pin_cb, run_create_height.create(create_fk)) { - (Some(cb), _) => cb, - (None, Some((h, cb))) if h == durable_h => cb, - (None, _) => { - let cb = coinbase_fk_by_height - .get(&durable_h) - .ok_or(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: no coinbase fk at confirmed create height", - )))?; - *cb == create_fk - } - }; - // Core connects a run one block at a time: a create from a later - // block of this batch is not yet a coin when this block spends it. - reject_one_create(spend_height, maturity, Some(durable_h), is_cb)?; - scratch.create_height_by_fk.insert(create_fk, durable_h); - } - Ok(()) -} - -fn structural_bip68( - query: &Query, - block: &Block, - ctx: &ValidationContext<'_>, - spends: &[( - [u8; 32], - u32, - rbitcoin_primitives::Fk, - rbitcoin_primitives::Fk, - u32, - )], - create_height_by_fk: &FkMap, - mtp_cache: &mut U32Map, -) -> Result<(), ConsensusError> { - if !ctx.params.csv_active_at(ctx.height.0) { - return Ok(()); - } - const DISABLE: u32 = 1 << 31; - const TYPE_FLAG: u32 = 1 << 22; - let prev_mtp = if ctx.height.0 == 0 { - 0 - } else { - mtp_at(query, Height(ctx.height.0 - 1), mtp_cache)? - }; - let mut si = 0usize; - let mut prev_heights: Vec = Vec::new(); - let mut coin_mtps: Vec = Vec::new(); - for tx in block.txdata.iter().skip(1) { - let n_in = tx.input.len(); - if si + n_in > spends.len() { - return Err(ConsensusError::BadBlock( - "structural spends/tx input mismatch", - )); - } - let tx_spends = &spends[si..si + n_in]; - si += n_in; - if !bip68_active_for_tx(tx) { - continue; - } - prev_heights.clear(); - coin_mtps.clear(); - prev_heights.reserve(n_in); - coin_mtps.reserve(n_in); - for (inp, &(_ptid, _vout, _sfk, create_fk, _vin)) in tx.input.iter().zip(tx_spends.iter()) { - let ch = if create_fk.is_null() { - ctx.height.0 - } else { - match create_height_by_fk.get(&create_fk) { - Some(&h) => h, - None => return Err(ConsensusError::BadTx("bad-txns-nonfinal")), - } - }; - prev_heights.push(ch); - let seq = inp.sequence.to_consensus_u32(); - let need_mtp = seq & DISABLE == 0 && seq & TYPE_FLAG != 0; - // Height 0 is the genesis coin: one median lookup of block 0. - // A missing create height is not height 0. - let mtp = if !need_mtp { - 0 - } else { - mtp_at(query, Height(ch.saturating_sub(1)), mtp_cache)? - }; - coin_mtps.push(mtp); - } - if !sequence_locks_satisfied(tx, &prev_heights, &coin_mtps, ctx.height.0, prev_mtp) { - return Err(ConsensusError::BadTx("bad-txns-nonfinal")); - } - } - if si != spends.len() { - return Err(ConsensusError::BadBlock( - "structural spends/tx input mismatch", - )); - } - Ok(()) -} - -/// MTP for write structural. Prefers assemble-carried `prev_mtp` (seeded into -/// `cache`). Misses go to durable headers only — never `get_header_plan`. -/// BIP30: a connected instance with any unspent spendable output may not be -/// overwritten. -/// Skipped for the two mainnet repeats, and when the header at BIP34 height -/// is this network's BIP34 hash and the block is below -/// [`crate::params::BIP34_IMPLIES_BIP30_LIMIT`]. Signet and regtest have no -/// BIP34 hash, so every block is checked. Just-archived self is unconnected. -fn reject_bip30_unspent_overwrite( - query: &Query, - block: &Block, - ctx: &ValidationContext<'_>, -) -> Result<(), ConsensusError> { - if ctx.params.is_bip30_repeat(ctx.height.0, block.block_hash()) - || bip34_ancestry_skips_bip30(query, ctx) - { - return Ok(()); - } - let create_txids: Vec<[u8; 32]> = block - .txdata - .iter() - .map(|tx| tx.compute_txid().to_byte_array()) - .collect(); - let hits = query - .store() - .get_fk_by_txid_batch(&create_txids) - .map_err(ConsensusError::from)?; - for (_txid, row) in hits { - let Some((old_fk, _)) = row else { - continue; - }; - // Connected instance at *this* height is ourselves (re-validate / - // already-confirmed fixture). BIP30 is an earlier unspent sibling. - let prior_height = query - .store() - .tx_height_get(old_fk) - .map_err(ConsensusError::from)?; - if prior_height == Some(ctx.height.0) { - continue; - } - let (_, outs) = query - .store() - .get_tx_meta_and_outputs(old_fk) - .map_err(ConsensusError::from)?; - let mut unspent = false; - for (v, out) in (0u32..).zip(&outs) { - // Core's AddCoins never stores an unspendable output, so it is - // not a coin that the overwrite could clobber. - if crate::policy::is_unspendable(&out.script) { - continue; - } - let spent = query - .store() - .has_confirmed_strong_spender_create(old_fk, v, None) - .map_err(ConsensusError::from)?; - if !spent { - unspent = true; - break; - } - } - if unspent { - reject_bip30_unspent_prior( - ctx.height.0, - prior_height.unwrap_or(u32::MAX), - true, - )?; - } - } - Ok(()) -} - /// BIP30: an earlier connected instance with an unspent spendable output may /// not be overwritten. The same height is this block. -pub(crate) fn reject_bip30_unspent_prior( +pub fn reject_bip30_unspent_prior( spend_height: u32, prior_height: u32, unspent_spendable: bool, @@ -2346,7 +1156,7 @@ pub(crate) fn reject_bip30_unspent_prior( /// Create-height facts for one spent output. `None` is not a coin. A later /// height in this batch is not yet a coin. A coinbase inside maturity is not /// spendable. -pub(crate) fn reject_one_create( +pub fn reject_one_create( spend_height: u32, maturity: u32, create_height: Option, @@ -2364,32 +1174,6 @@ pub(crate) fn reject_one_create( Ok(()) } -fn bip34_ancestry_skips_bip30(query: &Query, ctx: &ValidationContext<'_>) -> bool { - let height = ctx.height.0; - if ctx.params.bip34_hash.is_none() { - return false; - } - if height <= ctx.params.btc.bip34_height { - return false; - } - let ancestor = query - .header_at_height(Height(ctx.params.btc.bip34_height)) - .ok() - .flatten() - .map(|(_, rec)| bitcoin::BlockHash::from_byte_array(rec.hash)); - ctx.params - .bip30_skipped_for_bip34_ancestry(height, ancestor) -} - -fn mtp_at(query: &Query, height: Height, cache: &mut U32Map) -> Result { - if let Some(&t) = cache.get(&height.0) { - return Ok(t); - } - let t = crate::header::median_time_past_store(query, height)?; - cache.insert(height.0, t); - Ok(t) -} - /// Whether this job can skip `verify_job_all_inputs`. /// /// OP_TRUE scriptPubKey alone is **not** sufficient: scriptSig still runs @@ -2416,7 +1200,7 @@ fn job_needs_script_check(job: &ScriptCheckJob) -> bool { } #[inline] -pub(crate) fn verify_one_script_job(job: &ScriptCheckJob) -> Result<(), ConsensusError> { +pub fn verify_one_script_job(job: &ScriptCheckJob) -> Result<(), ConsensusError> { if job_needs_script_check(job) { crate::script::verify_job_all_inputs(job) } else { @@ -2434,17 +1218,6 @@ pub fn block_subsidy(height: u32, params: &ChainParams) -> i64 { 50_0000_0000i64 >> halvings } -struct ResolvedPrevout { - txout: TxOut, - /// Set when the script bytes stay in a block or pin `Arc`. - shared: Option, - /// P2SH+witness sigop cost for this input's prevout script (not legacy). - input_sigops: u64, - /// Class A create fk for this prevout (or `NULL` for same-block). Load pin - /// denserels must carry identity matching wire `prev_txid` for this fk. - create_fk: rbitcoin_primitives::Fk, -} - /// BIP65/113 nLockTime threshold: values below are block heights, above are unix times. pub const LOCKTIME_THRESHOLD: u32 = 500_000_000; @@ -2533,222 +1306,57 @@ pub fn sequence_locks_satisfied( true } -#[allow(clippy::too_many_arguments)] // call-site args stay unbundled -fn resolve_prevout( - block: &Block, - op: OutPoint, - inp: &bitcoin::TxIn, - prev_fk_hint: Option, - txid_index: &TxidMap, - spend_ti: usize, - batch_parents: &rbitcoin_query::BatchParents, - bip16: bool, - witness: bool, - need_script_buf: bool, - wire: Option<&Arc>, - acc: &mut AsmPrevoutAcc, -) -> Result { - let prev_txid = op.txid.to_byte_array(); - - if let Some(&pj) = txid_index.get(&prev_txid) { - if pj < spend_ti { - let tx = block.txdata.get(pj).ok_or(ConsensusError::MissingPrevout)?; - let v = op.vout as usize; - let o = tx.output.get(v).ok_or(ConsensusError::MissingPrevout)?; - // Core never puts this script in the coin view. The returned - // `txout` is often empty; the wire output holds the real bytes. - if crate::policy::is_unspendable(o.script_pubkey.as_bytes()) { - return Err(ConsensusError::MissingPrevout); - } - acc.in_n = acc.in_n.saturating_add(1); - acc.same_n = acc.same_n.saturating_add(1); - let shared = if need_script_buf { - wire.map(|_| rbitcoin_query::SharedPrevoutScript::Wire { - tx_index: pj as u32, - vout: op.vout, - }) - } else { - None - }; - return Ok(ResolvedPrevout { - txout: if shared.is_some() || !need_script_buf { - TxOut { - value: o.value, - script_pubkey: ScriptBuf::new(), - } - } else { - o.clone() - }, - shared, - input_sigops: prevout_spk_sigops(inp, o.script_pubkey.as_bytes(), bip16, witness), - create_fk: rbitcoin_primitives::Fk::NULL, - }); - } - } - - // Batch pin first (no TxRecord clone — A3). Pin identity/vout misses are - // hard invariants (load must fill schema-13 identity + denserels). - if let Some(prev_fk) = prev_fk_hint { - if let Some((pinned, parent_txid)) = batch_parents.share_parent_prevout(prev_fk, op.vout) { - if parent_txid != prev_txid { - acc.cold_txid_mismatch_n = acc.cold_txid_mismatch_n.saturating_add(1); - #[cfg(test)] - confirm_phase_stats::tl_note_cold_why_txid_mismatch(); - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: pin parent create identity mismatch wire prev_txid", - ))); - } - let (value, script) = pinned.parts().ok_or(ConsensusError::Store( - rbitcoin_store::StoreError::Corrupt( - "invariant: pin incomplete outs for spent parent vout", - ), - ))?; - // Same rule as the same-block arm. `txout.script_pubkey` below is - // empty; `script` is the archived bytes, including when scripts - // are not executed. - if crate::policy::is_unspendable(script) { - return Err(ConsensusError::MissingPrevout); - } - let input_sigops = prevout_spk_sigops(inp, script, bip16, witness); - acc.in_n = acc.in_n.saturating_add(1); - acc.batch_n = acc.batch_n.saturating_add(1); - #[cfg(test)] - confirm_phase_stats::tl_note_batch_hit(); - return Ok(ResolvedPrevout { - txout: TxOut { - value: Amount::from_sat(value), - script_pubkey: ScriptBuf::new(), - }, - shared: need_script_buf.then_some(pinned), - input_sigops, - create_fk: prev_fk, - }); - } else if batch_parents.contains(prev_fk) { - acc.cold_vout_miss_n = acc.cold_vout_miss_n.saturating_add(1); - #[cfg(test)] - confirm_phase_stats::tl_note_cold_why_vout_miss(); - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: pin incomplete outs for spent parent vout", - ))); - } - } - - Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: lookup stage miss (assemble parent create_fk)", - ))) -} - fn is_anyone_can_spend(script: &Script) -> bool { crate::script::is_anyone_can_spend(script) } -pub use rbitcoin_query::TxPrecompute; - -#[cfg(test)] -mod overlay_meta_skip_tests { - use super::*; - use rbitcoin_primitives::Fk; - - fn spends(rows: &[(u32, Fk, Fk, u32)]) -> Vec<([u8; 32], u32, Fk, Fk, u32)> { - rows.iter() - .map(|&(vout, sfk, cfk, vin)| ([0u8; 32], vout, sfk, cfk, vin)) - .collect() - } - - #[test] - fn overlay_meta_skip_omits_matching_abs() { - let wave = ClassAWave::new(vec![Fk(10)]); - let mut map = FkMap::default(); - map.insert(Fk(10), (5, false)); - let run = RunCreateHeight::Map(map); - assert_eq!(run.create(Fk(10)), Some((5, false))); - let spends = spends(&[(0, Fk(11), Fk(10), 0)]); - let mut scratch = StructuralScratch::default(); - fill_overlay_skip(&spends, &wave, &mut scratch); - assert!(overlay_meta_is_skip(10, 0, Fk(11), 0, &scratch.skip)); - assert!(!overlay_meta_is_skip(10, 0, Fk(11), 1, &scratch.skip)); - assert!(!overlay_meta_is_skip(10, 1, Fk(11), 0, &scratch.skip)); - assert!(!overlay_meta_is_skip(99, 0, Fk(11), 0, &scratch.skip)); - } +mod facts; +pub use facts::{ + add_fee, add_sigops, add_value_in, fee_from_values, fee_sat, header_gap_on_chain, + median_or_bad_header, reject_assemble_block_shape, reject_batch_starts_at, reject_bits, + reject_carried_header_hash, reject_confirm_batch, reject_csv_create_height, + reject_duplicate_input, reject_empty_confirm_batch, reject_genesis_coinbase_spend, + reject_non_final, reject_prev_link, reject_prevout_spent, reject_same_block_create, + reject_sequence_locks, reject_spend_cursor, reject_tx_without_inputs, + reject_tx_without_outputs, reject_txid_count, reject_unresolved_spend, + reject_unspendable_prevout, reject_vout_past_n_out, reject_witness_before_segwit, + require_linked_parent, require_next_work, require_retarget_first, require_same_block_output, + unresolved_parent_create, +}; - #[test] - fn overlay_meta_skip_keeps_conflicting_spender_on_disk_list() { - let wave = ClassAWave::new(vec![Fk(10)]); - let mut map = FkMap::default(); - map.insert(Fk(10), (5, false)); - let run = RunCreateHeight::Map(map); - assert_eq!(run.create(Fk(10)), Some((5, false))); - let spends = spends(&[(0, Fk(11), Fk(10), 0), (0, Fk(12), Fk(10), 0)]); - let mut scratch = StructuralScratch::default(); - fill_overlay_skip(&spends, &wave, &mut scratch); - assert!(!overlay_meta_is_skip(10, 0, Fk(11), 0, &scratch.skip)); - assert!(!overlay_meta_is_skip(10, 0, Fk(12), 0, &scratch.skip)); - } - - #[test] - fn overlay_meta_skip_historical_create_stays_on_disk() { - let wave = ClassAWave::default(); - let spends = spends(&[(0, Fk(11), Fk(10), 0)]); - let mut scratch = StructuralScratch::default(); - fill_overlay_skip(&spends, &wave, &mut scratch); - assert!(!overlay_meta_is_skip(10, 0, Fk(11), 0, &scratch.skip)); - } -} +pub use crate::TxPrecompute; #[cfg(test)] -mod run_create_height_tests { - use super::RunCreateHeight; - use rbitcoin_primitives::Fk; - - fn fk(id: u64) -> Fk { - Fk(id) - } +mod fact_reject_tests { + use super::{reject_bip30_unspent_prior, reject_one_create}; + use crate::ConsensusError; #[test] - fn run_create_height_span_misses_gaps() { - let one = [fk(10), fk(11)]; - let idx = RunCreateHeight::from_blocks([(7u32, one.as_slice())]); - assert!(matches!(idx, RunCreateHeight::Spans(_))); - assert_eq!(idx.get(fk(10)), Some(7)); - assert_eq!(idx.get(fk(11)), Some(7)); - assert_eq!(idx.get(fk(9)), None); - assert_eq!(idx.get(fk(12)), None); - - let low = [fk(10), fk(11)]; - let high = [fk(20), fk(21)]; - let idx = RunCreateHeight::from_blocks([(1u32, low.as_slice()), (2, high.as_slice())]); - assert_eq!(idx.get(fk(15)), None); - assert_eq!(idx.get(fk(10)), Some(1)); - assert_eq!(idx.get(fk(21)), Some(2)); - - let gapped = [fk(1), fk(2), fk(4)]; - let idx = RunCreateHeight::from_blocks([(3u32, gapped.as_slice())]); - assert!(matches!(idx, RunCreateHeight::Map(_))); - assert_eq!(idx.get(fk(3)), None); - assert_eq!(idx.get(fk(1)), Some(3)); - assert_eq!(idx.get(fk(2)), Some(3)); - assert_eq!(idx.get(fk(4)), Some(3)); - } + fn bip30_and_immature_coinbase_reject_from_facts() { + assert!(reject_bip30_unspent_prior(2, 2, true).is_ok()); + let bip30 = reject_bip30_unspent_prior(5, 1, true).unwrap_err(); + assert!( + matches!(bip30, ConsensusError::BadTx(s) if s.contains("BIP30")), + "{bip30:?}" + ); + assert!(reject_bip30_unspent_prior(5, 1, false).is_ok()); - #[test] - fn run_create_height_marks_each_block_first_fk_coinbase() { - let low = [fk(10), fk(11)]; - let high = [fk(12), fk(13)]; - let idx = RunCreateHeight::from_blocks([(1u32, low.as_slice()), (2, high.as_slice())]); - assert!(matches!(idx, RunCreateHeight::Spans(_))); - assert_eq!(idx.create(fk(10)), Some((1, true))); - assert_eq!(idx.create(fk(11)), Some((1, false))); - assert_eq!(idx.create(fk(12)), Some((2, true))); - assert_eq!(idx.create(fk(13)), Some((2, false))); - - let gapped = [fk(20), fk(22)]; - let idx = RunCreateHeight::from_blocks([(1u32, low.as_slice()), (2, gapped.as_slice())]); - assert!(matches!(idx, RunCreateHeight::Map(_))); - assert_eq!(idx.create(fk(10)), Some((1, true))); - assert_eq!(idx.create(fk(11)), Some((1, false))); - assert_eq!(idx.create(fk(20)), Some((2, true))); - assert_eq!(idx.create(fk(22)), Some((2, false))); - assert_eq!(idx.create(fk(21)), None); + let missing = reject_one_create(10, 100, None, false).unwrap_err(); + assert!( + matches!(missing, ConsensusError::BadTx(s) if s.contains("missingorspent")), + "{missing:?}" + ); + let forward = reject_one_create(10, 100, Some(11), false).unwrap_err(); + assert!( + matches!(forward, ConsensusError::MissingPrevout), + "{forward:?}" + ); + let immature = reject_one_create(10, 100, Some(1), true).unwrap_err(); + assert!( + matches!(immature, ConsensusError::BadTx(s) if s.contains("immature")), + "{immature:?}" + ); + assert!(reject_one_create(101, 100, Some(1), true).is_ok()); } } @@ -2760,5 +1368,3 @@ mod block_866342; mod finality_tests; #[cfg(test)] mod sigop_cost_tests; -#[cfg(test)] -mod structure_rule_tests; diff --git a/crates/rbitcoin-consensus/src/error.rs b/crates/rbitcoin-consensus/src/error.rs index 4ce4acf58e..7b45cece20 100644 --- a/crates/rbitcoin-consensus/src/error.rs +++ b/crates/rbitcoin-consensus/src/error.rs @@ -1,10 +1,11 @@ use rbitcoin_primitives::ScriptNumError; -use rbitcoin_store::StoreError; use std::fmt; #[derive(Debug)] pub enum ConsensusError { - Store(StoreError), + /// Programmer invariant inside a rule helper. Not a block reject and not a + /// store fault. The confirm pipeline must not cache it as a failed block. + Internal(&'static str), BadHeader(&'static str), BadBlock(&'static str), BadTx(&'static str), @@ -22,7 +23,7 @@ pub enum ConsensusError { impl fmt::Display for ConsensusError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ConsensusError::Store(e) => write!(f, "store: {e}"), + ConsensusError::Internal(s) => write!(f, "{s}"), ConsensusError::BadHeader(s) => write!(f, "bad header: {s}"), ConsensusError::BadBlock(s) => write!(f, "bad block: {s}"), ConsensusError::BadTx(s) => write!(f, "bad transaction: {s}"), @@ -37,14 +38,7 @@ impl fmt::Display for ConsensusError { } } -impl std::error::Error for ConsensusError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - ConsensusError::Store(e) => Some(e), - _ => None, - } - } -} +impl std::error::Error for ConsensusError {} impl From for ConsensusError { fn from(e: ScriptNumError) -> Self { @@ -52,27 +46,6 @@ impl From for ConsensusError { } } -impl From for ConsensusError { - fn from(e: StoreError) -> Self { - // Peer / untrusted blocks can hit archive stamp when a parent is simply - // missing (invalid block). That is consensus MissingPrevout, not store - // corruption — see docs/external_findings/002-store-corrupt-record-on-invalid-block.md. - match e { - StoreError::Cancelled(_) => ConsensusError::Cancelled, - StoreError::Corrupt(m) if m.contains("parent create_fk unresolved") => { - ConsensusError::MissingPrevout - } - other => ConsensusError::Store(other), - } - } -} - -impl ConsensusError { - pub fn is_uring_session_fault(&self) -> bool { - matches!(self, ConsensusError::Store(se) if se.is_uring_session_fault()) - } -} - /// Core `SCRIPT_VERIFY_*` parenthetical for `*-script-verify-flag-failed (…)`. pub fn script_flag_paren(token: &str) -> &str { let inner = token.split(" txid=").next().unwrap_or(token); @@ -147,35 +120,6 @@ pub fn block_reject_log_line(hash: impl std::fmt::Display, reason: &str) -> Stri mod tests { use super::*; - #[test] - fn uring_session_fault_covers_leftover_and_submit() { - for m in [ - "invariant: io_uring leftover cqe", - "io_uring submit failed", - "invariant: io_uring undrained", - ] { - let e = ConsensusError::Store(StoreError::Corrupt(m)); - assert!(e.is_uring_session_fault(), "{m}"); - } - assert!(!ConsensusError::Store(StoreError::NotFound).is_uring_session_fault()); - assert!(!ConsensusError::PrevoutSpent.is_uring_session_fault()); - } - - #[test] - fn archive_unresolved_parent_is_missing_prevout_not_corrupt() { - let e = ConsensusError::from(StoreError::Corrupt( - "archive: parent create_fk unresolved (contiguous batch required)", - )); - assert!(matches!(e, ConsensusError::MissingPrevout), "got {e:?}"); - assert!(!e.to_string().contains("corrupt")); - } - - #[test] - fn from_store_error() { - let e: ConsensusError = StoreError::NotFound.into(); - assert!(matches!(e, ConsensusError::Store(StoreError::NotFound))); - } - #[test] fn block_reject_reason_bip113_and_bip112_needles() { assert_eq!( diff --git a/crates/rbitcoin-consensus/src/header.rs b/crates/rbitcoin-consensus/src/header.rs index 8913d3f1b2..997109c9fb 100644 --- a/crates/rbitcoin-consensus/src/header.rs +++ b/crates/rbitcoin-consensus/src/header.rs @@ -3,8 +3,8 @@ use crate::params::{check_genesis_hash, ChainParams}; use bitcoin::block::Header; use bitcoin::hashes::Hash; use bitcoin::{CompactTarget, Target}; +pub use rbitcoin_primitives::median_time_past_times; use rbitcoin_primitives::Height; -use rbitcoin_query::Query; /// Parent header facts for [`check_header`]. Copied so a height above zero can /// use the same parent for linkage and for the work calculation. @@ -78,68 +78,6 @@ pub fn check_header( } /// Validate header linkage, MTP, difficulty bits, and proof-of-work. -pub fn validate_header( - query: &Query, - params: &ChainParams, - height: Height, - header: &Header, -) -> Result<(), ConsensusError> { - validate_header_hashed( - query, - params, - height, - header, - header.block_hash().to_byte_array(), - ) -} - -/// [`validate_header`] using a caller-computed header hash (lookup already hashed). -pub(crate) fn validate_header_hashed( - query: &Query, - params: &ChainParams, - height: Height, - header: &Header, - hash: [u8; 32], -) -> Result<(), ConsensusError> { - let parent = if height.0 == 0 { - None - } else { - query - .header_at_height(Height(height.0 - 1))? - .map(|(_fk, prev)| ParentHeader { - hash: prev.hash, - time: prev.timestamp, - bits: CompactTarget::from_consensus(prev.bits), - }) - }; - let times = if parent.is_some() { - mtp_times_ending_at(query, Height(height.0 - 1))? - } else { - Vec::new() - }; - let period_first = if parent.is_some() { - period_first_time(query, params, height.0)? - } else { - None - }; - check_header( - params, - height, - header, - hash, - parent, - ×, - period_first, - |h| { - header_bits_at(query, Height(h)) - .ok() - .map(CompactTarget::from_consensus) - }, - ) -} - -/// Contextual checks when the parent is a known header, not necessarily on the -/// best chain (`header_at_height` would miss). pub fn validate_header_on_parent( params: &ChainParams, height: Height, @@ -162,7 +100,7 @@ pub fn validate_header_on_parent( } /// POW vs a **caller-computed** header hash (no second SHA256d). -pub(crate) fn pow_hash_meets_target( +pub fn pow_hash_meets_target( hash: [u8; 32], bits: CompactTarget, pow_limit: Target, @@ -181,7 +119,7 @@ pub(crate) fn pow_hash_meets_target( /// /// Core `ContextualCheckBlockHeader`. Assemble uses this instead of a second /// [`validate_header`] (MTP walk + header rehash). -pub(crate) fn check_header_version_and_future_time( +pub fn check_header_version_and_future_time( params: &ChainParams, height: Height, header: &Header, @@ -208,104 +146,6 @@ pub(crate) fn check_header_version_and_future_time( /// chain, then load-stage header plans (same hybrid as [`crate::confirm_run`] /// header MTP). Heights still above tip with no plan are retryable load /// incomplete — not permanent `BadPrev` (that silently split batches to n=1). -pub fn median_time_past(query: &Query, height: Height) -> Result { - let times = mtp_times_ending_at(query, height)?; - median_time_past_times(×).map_err(|_| ConsensusError::BadHeader("empty median time")) -} - -/// Timestamps ending at `height` for [`median_time_past`]. A missing plan above -/// the confirmed tip stays a retryable load fault. A gap at or below the tip -/// is [`ConsensusError::BadPrev`]. -fn mtp_times_ending_at(query: &Query, height: Height) -> Result, ConsensusError> { - if let Some((n, buf)) = query.store().mtp_times_at(height) { - return Ok(buf[..n as usize].to_vec()); - } - let mut times = Vec::with_capacity(11); - let start = height.0.saturating_sub(10); - let tip = query.tip_height().map(|h| h.0).unwrap_or(0); - for h in start..=height.0 { - if let Some((_fk, rec)) = query.header_at_height(Height(h))? { - times.push(rec.timestamp); - continue; - } - if let Some(plan) = query.confirm_parent_cache().get_header_plan(h) { - times.push(plan.header_rec.timestamp); - continue; - } - if h > tip { - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "confirm: load incomplete (parent header plan missing above tip)", - ))); - } - return Err(ConsensusError::BadPrev); - } - Ok(times) -} - -/// MTP from the confirmed chain only (write structural). Tip-ahead heights -/// must be carried on [`crate::confirm_run`] `Prepared::prev_mtp`. -pub fn median_time_past_store(query: &Query, height: Height) -> Result { - if let Some((n, buf)) = query.store().mtp_times_at(height) { - return median_time_past_times(&buf[..n as usize]) - .map_err(|_| ConsensusError::BadHeader("empty median time")); - } - let mut times = Vec::with_capacity(11); - let start = height.0.saturating_sub(10); - for h in start..=height.0 { - if let Some((_fk, rec)) = query.header_at_height(Height(h))? { - times.push(rec.timestamp); - continue; - } - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "confirm: write MTP missing confirmed header (carry prev_mtp)", - ))); - } - median_time_past_times(×).map_err(|_| ConsensusError::BadHeader("empty median time")) -} - -pub use rbitcoin_primitives::median_time_past_times; - -/// Expected `nBits` for a new header at `height`. -/// -/// `header_time` is the candidate block's timestamp (Core `pblock->GetBlockTime()`). -pub fn expected_next_bits( - query: &Query, - params: &ChainParams, - height: Height, - header_time: u32, -) -> Result { - if height.0 == 0 { - let g = crate::params::genesis_block(params); - return Ok(g.header.bits); - } - - let prev_height = Height(height.0 - 1); - let (_fk, prev_rec) = query - .header_at_height(prev_height)? - .ok_or(ConsensusError::BadPrev)?; - let prev_bits = CompactTarget::from_consensus(prev_rec.bits); - let period_first = period_first_time(query, params, height.0)?; - next_work_bits( - params, - height.0, - prev_bits, - prev_rec.timestamp, - header_time, - period_first, - |h| { - header_bits_at(query, Height(h)) - .ok() - .map(CompactTarget::from_consensus) - }, - ) - .ok_or(ConsensusError::BadPrev) -} - -/// Difficulty for the header at `height`, from the parent and the period start. -/// -/// `period_first_time` is the timestamp of the header at `height - interval` -/// when `height` is a retarget boundary. `bits_at` supplies earlier `nBits` -/// for the testnet min-difficulty walk. `None` means an ancestor is missing. pub fn next_work_bits( params: &ChainParams, height: u32, @@ -341,20 +181,6 @@ pub fn next_work_bits( )) } -fn period_first_time( - query: &Query, - params: &ChainParams, - height: u32, -) -> Result, ConsensusError> { - let interval = params.difficulty_adjustment_interval(); - if interval == 0 || !height.is_multiple_of(interval) || params.no_pow_retargeting() { - return Ok(None); - } - Ok(query - .header_at_height(Height(height - interval))? - .map(|(_fk, first_rec)| first_rec.timestamp)) -} - fn min_diff_bits( params: &ChainParams, height: u32, @@ -400,25 +226,10 @@ pub fn difficulty_from_bits(bits: u32) -> f64 { ddiff } -fn header_bits_at(query: &Query, height: Height) -> Result { - if let Some((_fk, rec)) = query.header_at_height(height)? { - return Ok(rec.bits); - } - if let Some(plan) = query.confirm_parent_cache().get_header_plan(height.0) { - return Ok(plan.header_rec.bits); - } - Err(ConsensusError::BadPrev) -} - #[cfg(test)] mod median_time_past_tests { use super::*; use bitcoin::block::Version; - use rbitcoin_primitives::{Fk, Height}; - use rbitcoin_query::testutil::FixtureChain; - use rbitcoin_query::{Query, TxApply}; - use rbitcoin_store::{HeaderRecord, InputRecord, OutputRecord, TxRecord}; - use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn max_target_bits_are_difficulty_one() { @@ -515,228 +326,6 @@ mod median_time_past_tests { assert_eq!(median_time_past_times(&[1, 2, 3, 4]).unwrap(), 3); } - fn temp_q() -> (std::path::PathBuf, Query) { - let n = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-hdr-mtp-{n}")); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let q = Query::open_or_create_tiny(dir.join("store")).unwrap(); - (dir, q) - } - - /// Write-gate-safe synthetic coinbase: non-null `prev_fk` must commit `parent_hash`. - fn coinbase(h: u32, prev: Fk, parent_hash: Option<[u8; 32]>) -> (HeaderRecord, TxApply) { - let version = 1; - let timestamp = 1_000 + h * 10; - let bits = 0x207fffff; - let nonce = h; - let mut merkle = [0u8; 32]; - merkle[0..4].copy_from_slice(&h.to_le_bytes()); - merkle[4] = 0xcd; - let hash = match parent_hash { - None => merkle, - Some(ph) => { - rbitcoin_store::block_header_hash(version, &ph, &merkle, timestamp, bits, nonce) - } - }; - let header = HeaderRecord { - prev_fk: prev, - version, - timestamp, - bits, - nonce, - merkle_root: merkle, - hash, - size: 0, - weight: 0, - }; - let mut txid = [0u8; 32]; - txid[0..4].copy_from_slice(&h.to_le_bytes()); - txid[31] = 0xcb; - let ta = TxApply { - tx: TxRecord { - txid, - version: 1, - locktime: 0, - input_start_fk: Fk::NULL, - input_count: 1, - output_start_fk: Fk::NULL, - output_count: 1, - }, - inputs: vec![InputRecord { - prev_txid: [0u8; 32], - parent: rbitcoin_store::InputParent::Coinbase, - sequence: u32::MAX, - script_sig: vec![h as u8], - witness: vec![], - }], - outputs: vec![OutputRecord::unspent(50, vec![0x51])], - }; - (header, ta) - } - - #[test] - fn mtp_from_confirmed_chain_and_missing_above_tip() { - let (dir, q) = temp_q(); - let mut prev = Fk::NULL; - let mut parent_hash: Option<[u8; 32]> = None; - for h in 0..3u32 { - let (hdr, ta) = coinbase(h, prev, parent_hash); - parent_hash = Some(hdr.hash); - prev = q.connect_block(Height(h), &hdr, &[ta]).unwrap(); - } - let mtp = median_time_past(&q, Height(2)).unwrap(); - // times: 1000, 1010, 1020 → middle 1010 - assert_eq!(mtp, 1010); - let (n, buf) = q - .store() - .mtp_times_at(Height(2)) - .expect("ring covers confirmed tip"); - assert_eq!(n, 3); - assert_eq!(median_time_past_times(&buf[..3]).unwrap(), 1010); - - // Height above tip with no plan → incomplete load error (not BadPrev). - let err = median_time_past(&q, Height(5)).unwrap_err(); - assert!( - format!("{err}").contains("load incomplete"), - "above tip must be the retryable load error, got {err:?}" - ); - - let (empty_dir, empty) = temp_q(); - let err = median_time_past(&empty, Height(0)).unwrap_err(); - assert!( - matches!(err, ConsensusError::BadPrev), - "a missing header at the tip is BadPrev, got {err:?}" - ); - let _ = std::fs::remove_dir_all(&empty_dir); - - // expected_next_bits: height 0 + regtest no-retarget. - let params = ChainParams::regtest(); - let gbits = expected_next_bits(&q, ¶ms, Height(0), 0).unwrap(); - assert_eq!(gbits, crate::params::genesis_block(¶ms).header.bits); - let b1 = expected_next_bits(&q, ¶ms, Height(1), 0).unwrap(); - let (_fk, rec0) = q.header_at_height(Height(0)).unwrap().unwrap(); - assert_eq!(b1.to_consensus(), rec0.bits); - - // Bad prev header height. - assert!(expected_next_bits(&q, ¶ms, Height(99), 0).is_err()); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn sequential_tip_mtp_matches_ring_and_survives_pop() { - let (dir, q) = temp_q(); - let mut prev = Fk::NULL; - let mut parent_hash: Option<[u8; 32]> = None; - let mut times = Vec::new(); - for h in 0..12u32 { - let (hdr, ta) = coinbase(h, prev, parent_hash); - times.push(hdr.timestamp); - parent_hash = Some(hdr.hash); - prev = q.connect_block(Height(h), &hdr, &[ta]).unwrap(); - } - let want11 = median_time_past_times(×[1..]).unwrap(); - assert_eq!(median_time_past_store(&q, Height(11)).unwrap(), want11); - assert_eq!(median_time_past(&q, Height(11)).unwrap(), want11); - let (n, buf) = q.store().mtp_times_at(Height(11)).expect("ring at tip"); - assert_eq!(n, 11); - assert_eq!(median_time_past_times(&buf[..11]).unwrap(), want11); - assert!( - q.store().mtp_times_at(Height(5)).is_none(), - "historical MTP is not the tip ring" - ); - assert_eq!( - median_time_past_store(&q, Height(5)).unwrap(), - median_time_past_times(×[0..=5]).unwrap() - ); - - q.disconnect_tip().unwrap(); - let want10 = median_time_past_times(×[0..=10]).unwrap(); - assert_eq!(median_time_past_store(&q, Height(10)).unwrap(), want10); - let (n, buf) = q - .store() - .mtp_times_at(Height(10)) - .expect("ring rebuilt after pop"); - assert_eq!(median_time_past_times(&buf[..n as usize]).unwrap(), want10); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn validate_header_genesis_and_bad_prev() { - let (dir, q) = temp_q(); - let params = ChainParams::regtest(); - let g = crate::params::genesis_block(¶ms); - // Wrong genesis hash at height 0. - let mut bad = g.header; - bad.nonce ^= 1; - let err = validate_header(&q, ¶ms, Height(0), &bad).unwrap_err(); - assert!(matches!(err, ConsensusError::BadHeader(_)), "{err:?}"); - - // Synthetic tip for prev linkage (child hash commits to parent). - let (h0, ta0) = coinbase(0, Fk::NULL, None); - let prev = q.connect_block(Height(0), &h0, &[ta0]).unwrap(); - let (h1, ta1) = coinbase(1, prev, Some(h0.hash)); - q.connect_block(Height(1), &h1, &[ta1]).unwrap(); - - // Header with wrong prev hash at height 1. - let mut hdr = g.header; - hdr.prev_blockhash = bitcoin::BlockHash::from_byte_array([0xee; 32]); - hdr.time = 2_000; - hdr.bits = bitcoin::CompactTarget::from_consensus(0x207fffff); - let err = validate_header(&q, ¶ms, Height(1), &hdr).unwrap_err(); - assert!( - matches!( - err, - ConsensusError::BadPrev - | ConsensusError::BadHeader(_) - | ConsensusError::BadVersion(_) - ), - "{err:?}" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn testnet_min_difficulty_after_20_minute_gap() { - let (dir, q) = temp_q(); - let params = ChainParams::testnet(); - assert!(params.allow_min_difficulty_blocks()); - let (h0, ta0) = coinbase(0, Fk::NULL, None); - let prev_fk = q.connect_block(Height(0), &h0, &[ta0]).unwrap(); - let limit = params.pow_limit.to_compact_lossy(); - assert_ne!(CompactTarget::from_consensus(h0.bits), limit); - - let spacing = params.btc.pow_target_spacing as u32; - let gap = - expected_next_bits(&q, ¶ms, Height(1), h0.timestamp + 2 * spacing + 1).unwrap(); - assert_eq!(gap, limit); - let eq_boundary = - expected_next_bits(&q, ¶ms, Height(1), h0.timestamp + 2 * spacing).unwrap(); - assert_eq!(eq_boundary.to_consensus(), h0.bits); - - let (mut h1, ta1) = coinbase(1, prev_fk, Some(h0.hash)); - h1.timestamp = h0.timestamp + 2 * spacing + 1; - h1.bits = limit.to_consensus(); - h1.hash = rbitcoin_store::block_header_hash( - h1.version, - &h0.hash, - &h1.merkle_root, - h1.timestamp, - h1.bits, - h1.nonce, - ); - q.connect_block(Height(1), &h1, &[ta1]).unwrap(); - let walked = expected_next_bits(&q, ¶ms, Height(2), h1.timestamp + 100).unwrap(); - assert_eq!(walked.to_consensus(), h0.bits); - - let _ = std::fs::remove_dir_all(&dir); - } - #[test] fn next_work_bits_walks_back_past_min_difficulty() { let params = ChainParams::testnet(); diff --git a/crates/rbitcoin-query/src/id_map.rs b/crates/rbitcoin-consensus/src/id_map.rs similarity index 100% rename from crates/rbitcoin-query/src/id_map.rs rename to crates/rbitcoin-consensus/src/id_map.rs diff --git a/crates/rbitcoin-consensus/src/lib.rs b/crates/rbitcoin-consensus/src/lib.rs index 43b102ecdf..e6143c11ca 100644 --- a/crates/rbitcoin-consensus/src/lib.rs +++ b/crates/rbitcoin-consensus/src/lib.rs @@ -1,21 +1,19 @@ //! Block and transaction validation / confirmability. -mod block; +pub mod block; mod clock; -mod confirm_run; -mod convert; mod error; -mod header; -mod index_rows; -mod index_writebehind; +pub mod header; +mod id_map; mod milestone; mod params; pub mod policy; mod regtest_pad; mod script; -mod script_pool; +pub mod script_pool; mod signet; pub mod silent_payments; +mod tx_precompute; pub(crate) use block::ScriptCheckJob; @@ -75,6 +73,7 @@ pub fn verify_tx_scripts_with_flags( .unwrap_or(Err(ConsensusError::BadBlock("script worker disconnected"))) } +pub use block::validate_block_structure_hashed; pub use block::{ bip34_height_script, bip68_active_for_tx, block_has_witness, block_mutated_without_coinbase, block_subsidy, check_block_wire, is_final_tx, sequence_locks_satisfied, tx_sigop_cost, @@ -82,409 +81,28 @@ pub use block::{ ScriptVerifyFlags, ValidationContext, BIP16_EXCEPTION_MAINNET, MAX_BLOCK_TX_COUNT, MAX_BLOCK_WEIGHT, MIN_TX_WEIGHT, TAPROOT_EXCEPTION_MAINNET, }; -pub(crate) use block::{validate_block_structure_hashed, TxPrecompute}; pub use clock::{with_now, NodeClock}; -pub use convert::header_to_record; -pub(crate) use convert::{block_to_apply, block_to_apply_with_txids_prev}; pub use error::{block_reject_log_line, block_reject_reason, script_flag_paren, ConsensusError}; pub use header::{ - check_header, difficulty_from_bits, expected_next_bits, median_time_past, next_work_bits, - validate_header, validate_header_on_parent, ParentHeader, -}; -pub use index_writebehind::{ - build_indexes_released, index_tip_entry, prepare_live_indexes, spawn_index_writebehind, - IndexTipEntry, INDEX_STARTUP_GAP_HEIGHTS, + check_header, difficulty_from_bits, next_work_bits, validate_header_on_parent, ParentHeader, }; +pub use id_map::{IdMap, OutPointHasher, OutPointSet, TxidHasher, TxidMap, TxidSet}; pub use milestone::{Milestone, MilestoneAnchor}; pub use params::{ default_milestone_anchor, default_milestone_height, default_min_chain_work_be, genesis_block, - ChainParams, + ChainParams, MAINNET_MILESTONE_HASH, TESTNET_MILESTONE_HASH, }; pub use policy::PolicyResult; pub use regtest_pad::{ grind_regtest_pow, mine_empty_regtest, mine_op_true_signet_paying, mine_regtest_paying, - pad_empty_from, prepare_regtest_candidate, regtest_coinbase, REGTEST_BLOCK_SPACING, - REGTEST_POW_BITS, + prepare_regtest_candidate, regtest_coinbase, REGTEST_BLOCK_SPACING, REGTEST_POW_BITS, }; +pub use script_pool::unpark_script_publisher; pub use signet::{ default_signet_challenge, signet_challenge_transactions, signet_magic, validate_signet_block_solution, }; -pub use silent_payments::{ - taproot_matches_scan, tweak_from_tx, tweaks_for_height, TaprootOut, TxTweak, -}; - -use bitcoin::hashes::Hash; -use bitcoin::Block; -use rbitcoin_primitives::{Fk, Height}; -use rbitcoin_query::{Query, TxApply, WirePlanNeed}; -use rbitcoin_store::HeaderRecord; -use std::sync::Arc; - -/// Test-only assemble cold-why / batch TLS (window meters live on Query). -#[cfg(test)] -pub mod confirm_phase_stats { - use std::cell::Cell; - - thread_local! { - static TL_COLD_WHY: Cell<(u64, u64, u64, u64)> = - const { Cell::new((0, 0, 0, 0)) }; - static TL_BATCH_N: Cell = const { Cell::new(0) }; - static TL_COLD_N: Cell = const { Cell::new(0) }; - } - - #[inline] - pub fn tl_note_cold_why_txid_mismatch() { - TL_COLD_WHY.with(|c| { - let (a, b, c2, d) = c.get(); - c.set((a, b, c2 + 1, d)); - }); - TL_COLD_N.with(|c| c.set(c.get() + 1)); - } - #[inline] - pub fn tl_note_cold_why_vout_miss() { - TL_COLD_WHY.with(|c| { - let (a, b, c2, d) = c.get(); - c.set((a, b, c2, d + 1)); - }); - TL_COLD_N.with(|c| c.set(c.get() + 1)); - } - #[inline] - pub fn tl_note_batch_hit() { - TL_BATCH_N.with(|c| c.set(c.get() + 1)); - } - #[inline] - pub fn sample_tl_assemble_cold_why_and_reset() -> (u64, u64, u64, u64) { - TL_COLD_WHY.with(|c| c.replace((0, 0, 0, 0))) - } - #[inline] - pub fn sample_tl_batch_cold_n_and_reset() -> (u64, u64) { - let b = TL_BATCH_N.with(|c| c.replace(0)); - let cold = TL_COLD_N.with(|c| c.replace(0)); - (b, cold) - } -} - -/// Confirm a contiguous tip-extension run of wire blocks (sync all stages). -/// -/// See [`confirm_wire_run`]: lookup → load → scripts → write. IBD uses the split -/// phases for pipeline overlap. -pub use confirm_run::{ - confirm_bq_resolve_wave_capped, confirm_scripts_phase, confirm_wire_load_from_plan, - confirm_wire_load_phase, confirm_wire_load_phase_pipelined, confirm_wire_lookup_stamp, - confirm_wire_run, confirm_wire_run_preverified, confirm_write_phase, - confirm_write_phase_or_return, drive_script_waves_with, finish_post_commit_hashes, - parent_missing_from_chain, replay_spend_annotations, take_wave_items_for_load, BqResolveWave, - ConfirmLoadOutcome, ConfirmScriptOutcome, LoadedBatch, PlanStampOutcome, ScriptOkBatch, - ScriptPreverified, WireBlockIn, WireLoadPipeline, BQ_RESOLVE_WAVE_MAX_BLOCKS, - BQ_RESOLVE_WAVE_MAX_INPUTS, +pub use silent_payments::{taproot_matches_scan, tweak_from_tx, TaprootOut, TxTweak}; +pub use tx_precompute::{ + block_wire_len_from_pres, decode_block_precomputes, pres_for_tip, TxPrecompute, }; - -/// Wake the IBD scripts publisher (`ibd-confirm`) after `scriptq` send or close. -pub use script_pool::unpark_script_publisher; - -/// Accept + archive + confirm in one step (genesis / tip extension / tests). -/// -/// **Same path as IBD confirm:** structure + header checks, then Class A -/// archive, then [`confirm_wire_run`] (lookup → load pin denserels → scripts → -/// structural → Class C → abs spend annotate). No separate -/// `put_spend_batch_by_create`. -/// -/// Idempotent when `height` is already confirmed for this block hash. -/// Full script verify (no mempool skip) — use -/// [`accept_and_connect_block_preverified`] on tip follow with a live mempool. -pub fn accept_and_connect_block( - query: &Query, - params: &ChainParams, - height: Height, - block: &Block, - milestone: Milestone, -) -> Result { - accept_and_connect_block_preverified( - query, - params, - height, - Arc::new(block.clone()), - milestone, - &ScriptPreverified::new(), - None, - ) -} - -/// Like [`accept_and_connect_block`], skipping script verify for `preverified` -/// txids (tip follow: live mempool after accept). Reorg disconnect stays outside. -pub fn accept_and_connect_block_preverified( - query: &Query, - params: &ChainParams, - height: Height, - block: Arc, - milestone: Milestone, - preverified: &ScriptPreverified, - pres: Option>, -) -> Result { - let hash = block.block_hash().to_byte_array(); - if let Some(h) = query.height_of_hash(&hash).map_err(ConsensusError::from)? { - if h == height { - if let Some((fk, _)) = query - .get_header_by_hash(&hash) - .map_err(ConsensusError::from)? - { - confirm_run::finish_post_commit(query, height.0, &hash)?; - return Ok(fk); - } - } - } - - // Unified height-ordered path: wire → lookup → load (pin+assemble) → scripts → - // write (Class A + structural + Class C + annotate). No archive-then-reload. - let fks = confirm_wire_run_preverified( - query, - params, - milestone, - &[(height, block, pres)], - preverified, - )?; - if let Some(fk) = fks.into_iter().next() { - return Ok(fk); - } - // Write skipped heights ≤ tip (idempotent race). A body we just ran - // through lookup/load must have a header — missing is an invariant, not - // a soft NotFound (inflated confirmed[] used to hit this on tip+1). - query - .get_header_by_hash(&hash) - .map_err(ConsensusError::from)? - .map(|(fk, _)| fk) - .ok_or(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: confirm write skipped but header missing", - ))) -} - -fn class_a_header_and_txids( - query: &Query, - params: &ChainParams, - block: &Block, -) -> Result<(HeaderRecord, Vec<[u8; 32]>), ConsensusError> { - let ctx = ValidationContext::archive_structure(params); - let txids = validate_block_structure_hashed(block, &ctx)?; - let hash = block.header.block_hash().to_byte_array(); - crate::header::pow_hash_meets_target(hash, block.header.bits, params.pow_limit)?; - let prev = block.header.prev_blockhash; - let prev_fk = if prev.to_byte_array() == [0u8; 32] { - Fk::NULL - } else { - query - .get_header_by_hash(prev.as_byte_array()) - .map_err(ConsensusError::from)? - .map(|(fk, _)| fk) - .ok_or(ConsensusError::BadPrev)? - }; - Ok((header_to_record(prev_fk, &block.header, hash), txids)) -} - -/// Class A only (no tip / Class C). Crash and `plan=None` tests. -/// -/// Not a production IBD API — confirm write uses `archive_plan_batch_from_wire` -/// + commit (wire `seqsigwit` is encoded from the block and plan edges). -pub fn commit_class_a_block( - query: &Query, - params: &ChainParams, - height: Height, - block: &Block, - milestone: Milestone, -) -> Result<(), ConsensusError> { - let _ = (height, milestone); - let (header, txids) = class_a_header_and_txids(query, params, block)?; - let fk = query.ensure_header(&header).map_err(ConsensusError::from)?; - let arc = Arc::new(block.clone()); - query - .archive_class_a_from_wire(&[(fk, &arc, txids.as_slice())]) - .map_err(ConsensusError::from)?; - Ok(()) -} - -/// Class A for a contiguous run in one plan (same-batch parent stamp). -/// -/// Use this instead of N×[`commit_class_a_block`] when later blocks spend -/// earlier unconfirmed creates. -pub fn commit_class_a_run( - query: &Query, - params: &ChainParams, - blocks: &[(Height, Block)], - milestone: Milestone, -) -> Result<(), ConsensusError> { - let _ = milestone; - let mut owned = Vec::with_capacity(blocks.len()); - for (_, block) in blocks { - let (header, txids) = class_a_header_and_txids(query, params, block)?; - let fk = query.ensure_header(&header).map_err(ConsensusError::from)?; - owned.push((fk, Arc::new(block.clone()), txids)); - } - let refs: Vec> = owned - .iter() - .map(|(fk, b, ids)| (*fk, b, ids.as_slice())) - .collect(); - query - .archive_class_a_from_wire(&refs) - .map_err(ConsensusError::from)?; - Ok(()) -} - -/// CPU-side prep for Class A archive. -pub fn prepare_block_for_archive( - query: &Query, - params: &ChainParams, - block: &Block, -) -> Result<(HeaderRecord, Vec), ConsensusError> { - let hash = block.block_hash().to_byte_array(); - if query - .is_block_archived(&hash) - .map_err(ConsensusError::from)? - { - // Standalone archive helper (not confirm pipeline): one hash pass here. - return block_to_apply(query, &block.header, &block.txdata); - } - prepare_block_for_archive_new(query, params, block) -} - -fn prepare_block_for_archive_new( - query: &Query, - params: &ChainParams, - block: &Block, -) -> Result<(HeaderRecord, Vec), ConsensusError> { - let (header, txids) = class_a_header_and_txids(query, params, block)?; - let (_, txs) = - block_to_apply_with_txids_prev(header.prev_fk, &block.header, &block.txdata, &txids)?; - Ok((header, txs)) -} - -#[cfg(test)] -mod coverage_tests { - use super::*; - use bitcoin::absolute::LockTime; - use bitcoin::block::Version; - use bitcoin::hashes::Hash; - use bitcoin::script::ScriptBuf; - use bitcoin::transaction::Version as TxVersion; - use bitcoin::{Amount, OutPoint, Sequence, Target, Transaction, TxIn, TxOut, Witness}; - use rbitcoin_primitives::Height; - use rbitcoin_query::Query; - use std::path::PathBuf; - fn temp_store() -> (PathBuf, Query) { - let path = std::env::temp_dir().join(format!( - "rbitcoin-consensus-cov-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - std::fs::create_dir_all(&path).unwrap(); - let q = Query::open_or_create_tiny(&path).expect("open store"); - (path, q) - } - - #[test] - fn verify_tx_scripts_detached_acs_job() { - let tx = Transaction { - version: TxVersion::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint { - txid: bitcoin::Txid::from_byte_array([1; 32]), - vout: 0, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::MAX, - witness: Witness::new(), - }], - output: vec![TxOut { - value: Amount::from_sat(1), - script_pubkey: ScriptBuf::from_bytes(vec![0x51]), - }], - }; - let prevouts = vec![TxOut { - value: Amount::from_sat(10), - script_pubkey: ScriptBuf::from_bytes(vec![0x51]), - }]; - crate::verify_tx_scripts_detached(prevouts.clone(), tx.clone()).unwrap(); - let job = ScriptCheckJob::new( - prevouts, - tx, - crate::block::ScriptVerifyFlags::buried(true, true, true, true, true), - ); - crate::block::verify_one_script_job(&job).unwrap(); - } - - #[test] - fn verify_tx_scripts_detached_forks_op_true_and_op_return() { - let spend = |spk: Vec| { - let tx = Transaction { - version: TxVersion::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint { - txid: bitcoin::Txid::from_byte_array([1; 32]), - vout: 0, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::MAX, - witness: Witness::new(), - }], - output: vec![TxOut { - value: Amount::from_sat(1), - script_pubkey: ScriptBuf::from_bytes(vec![0x51]), - }], - }; - let prevouts = vec![TxOut { - value: Amount::from_sat(10), - script_pubkey: ScriptBuf::from_bytes(spk), - }]; - crate::verify_tx_scripts_detached_forks(prevouts, tx, true, true, true, true, true) - }; - spend(vec![0x51]).unwrap(); - assert!(spend(vec![0x6a]).is_err()); - } - - #[test] - fn regtest_connect_archive_and_confirm_path() { - let (path, q) = temp_store(); - let params = ChainParams::regtest(); - let ms = Milestone::height(1_000_000); - let genesis = genesis_block(¶ms); - let gen_fk = accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, ms).unwrap(); - - let b1 = mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); - // prepare helpers stay (CPU-side); confirm is sole Class A. - let (_hr, _txs) = prepare_block_for_archive(&q, ¶ms, &b1).unwrap(); - accept_and_connect_block(&q, ¶ms, Height(1), &b1, ms).unwrap(); - // already-have prepare after connect keeps the parent fk - let (hr, _) = prepare_block_for_archive(&q, ¶ms, &b1).unwrap(); - assert_eq!(hr.prev_fk, gen_fk); - - let _ = std::fs::remove_dir_all(&path); - } - - #[test] - fn assemble_second_block_rejects_stale_nversion() { - let (path, q) = temp_store(); - let params = ChainParams::regtest(); - let ms = Milestone::height(1_000_000); - let genesis = genesis_block(¶ms); - accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, ms).unwrap(); - - let b1 = mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); - let mut b2 = mine_empty_regtest(b1.block_hash(), b1.header.time + 600, 2); - b2.header.version = Version::from_consensus(1); - let target = Target::from_compact(b2.header.bits); - for nonce in 0..u32::MAX { - b2.header.nonce = nonce; - if b2.header.validate_pow(target).is_ok() { - break; - } - } - let err = - confirm_wire_run(&q, ¶ms, ms, &[(Height(1), b1), (Height(2), b2)]).unwrap_err(); - assert!(matches!(err, ConsensusError::BadVersion(1)), "{err:?}"); - let _ = std::fs::remove_dir_all(&path); - } -} diff --git a/crates/rbitcoin-consensus/src/milestone.rs b/crates/rbitcoin-consensus/src/milestone.rs index fae3b4ac8f..6356b5cf04 100644 --- a/crates/rbitcoin-consensus/src/milestone.rs +++ b/crates/rbitcoin-consensus/src/milestone.rs @@ -67,31 +67,6 @@ impl Milestone { } } -/// Confirm-path gate. Notes `milestone_gate_ns` (`ibd: perf_dbg milestone_us`). -pub(crate) fn skips_on_query( - milestone: Milestone, - query: &rbitcoin_query::Query, - height: u32, - block: &[u8; 32], -) -> bool { - let t = std::time::Instant::now(); - let skip = milestone.skips_scripts(height, block, |h| query.milestone_header_at(h)); - rbitcoin_query::note_confirm( - &query.confirm_stats().milestone_gate_ns, - t.elapsed().as_nanos() as u64, - ); - skip -} - -pub(crate) fn check_scripts( - milestone: Milestone, - query: &rbitcoin_query::Query, - height: u32, - block: &[u8; 32], -) -> bool { - !skips_on_query(milestone, query, height, block) -} - impl Default for Milestone { fn default() -> Self { Self::NONE diff --git a/crates/rbitcoin-consensus/src/params.rs b/crates/rbitcoin-consensus/src/params.rs index 4493e430f4..59682f2b8b 100644 --- a/crates/rbitcoin-consensus/src/params.rs +++ b/crates/rbitcoin-consensus/src/params.rs @@ -138,7 +138,7 @@ impl ChainParams { /// True for the genesis coinbase txid, which is never a coin. #[inline] - pub(crate) fn is_genesis_coinbase(&self, txid: &[u8; 32]) -> bool { + pub fn is_genesis_coinbase(&self, txid: &[u8; 32]) -> bool { *txid == self.genesis_coinbase_txid } @@ -435,7 +435,6 @@ pub fn genesis_block(params: &ChainParams) -> bitcoin::block::Block { #[cfg(test)] mod tests { use super::*; - use rbitcoin_primitives::Height; /// Core `SigNetParams` / Inquisition signet: soft forks at height 1 (not 0). #[test] @@ -603,173 +602,4 @@ mod tests { let mut main = ChainParams::mainnet(); assert!(main.apply_test_activation_height("csv", 102).is_err()); } - - /// Overlay must change **confirm** BIP68, not just the getter. - #[test] - fn overlay_csv_102_is_lax_at_101_strict_at_102() { - use bitcoin::absolute::LockTime; - use bitcoin::script::ScriptBuf; - use bitcoin::transaction::Version as TxVersion; - use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; - use rbitcoin_query::Query; - use std::time::{SystemTime, UNIX_EPOCH}; - - let n = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbtc-overlay-csv-{n}")); - let q = Query::open_or_create_tiny(&dir).unwrap(); - let mut params = ChainParams::regtest(); - params.apply_test_activation_height("csv", 102).unwrap(); - let genesis = constants::genesis_block(Network::Regtest); - crate::accept_and_connect_block( - &q, - ¶ms, - Height::GENESIS, - &genesis, - crate::Milestone::NONE, - ) - .unwrap(); - let (tip, tip_time, cbs) = crate::pad_empty_from( - &q, - ¶ms, - genesis.block_hash(), - genesis.header.time, - 1, - 100, - 1, - ); - let spk = ScriptBuf::from_bytes(vec![0x51]); - let parent = Transaction { - version: TxVersion::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint { - txid: cbs[0], - vout: 0, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::MAX, - witness: Witness::new(), - }], - output: vec![TxOut { - value: Amount::from_sat(50_0000_0000 - 1_000), - script_pubkey: spk.clone(), - }], - }; - let b101 = - crate::mine_regtest_paying(tip, tip_time + 600, 101, spk.clone(), vec![parent.clone()]); - crate::accept_and_connect_block(&q, ¶ms, Height(101), &b101, crate::Milestone::NONE) - .expect("parent spend at 101"); - - let child = Transaction { - version: TxVersion::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint { - txid: parent.compute_txid(), - vout: 0, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::from_consensus(10), - witness: Witness::new(), - }], - output: vec![TxOut { - value: Amount::from_sat(50_0000_0000 - 2_000), - script_pubkey: spk.clone(), - }], - }; - let b102 = crate::mine_regtest_paying( - b101.block_hash(), - b101.header.time + 600, - 102, - spk.clone(), - vec![child], - ); - let err = crate::accept_and_connect_block( - &q, - ¶ms, - Height(102), - &b102, - crate::Milestone::NONE, - ) - .unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("not final") || msg.contains("nonfinal") || msg.contains("BIP68"), - "csv@102 must reject BIP68-locked spend at height 102: {msg}" - ); - - let dir2 = std::env::temp_dir().join(format!("rbtc-overlay-csv-lax-{n}")); - let q2 = Query::open_or_create_tiny(&dir2).unwrap(); - let mut lax = ChainParams::regtest(); - lax.apply_test_activation_height("csv", 200).unwrap(); - crate::accept_and_connect_block( - &q2, - &lax, - Height::GENESIS, - &genesis, - crate::Milestone::NONE, - ) - .unwrap(); - let (tip2, time2, cbs2) = crate::pad_empty_from( - &q2, - &lax, - genesis.block_hash(), - genesis.header.time, - 1, - 100, - 1, - ); - let p_lax = Transaction { - version: TxVersion::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint { - txid: cbs2[0], - vout: 0, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::MAX, - witness: Witness::new(), - }], - output: vec![TxOut { - value: Amount::from_sat(50_0000_0000 - 1_000), - script_pubkey: spk.clone(), - }], - }; - let b101l = - crate::mine_regtest_paying(tip2, time2 + 600, 101, spk.clone(), vec![p_lax.clone()]); - crate::accept_and_connect_block(&q2, &lax, Height(101), &b101l, crate::Milestone::NONE) - .unwrap(); - let c_lax = Transaction { - version: TxVersion::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint { - txid: p_lax.compute_txid(), - vout: 0, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::from_consensus(10), - witness: Witness::new(), - }], - output: vec![TxOut { - value: Amount::from_sat(50_0000_0000 - 2_000), - script_pubkey: spk, - }], - }; - let b102l = crate::mine_regtest_paying( - b101l.block_hash(), - b101l.header.time + 600, - 102, - ScriptBuf::from_bytes(vec![0x51]), - vec![c_lax], - ); - crate::accept_and_connect_block(&q2, &lax, Height(102), &b102l, crate::Milestone::NONE) - .expect("csv@200: BIP68 not consensus at height 102"); - let _ = std::fs::remove_dir_all(&dir); - let _ = std::fs::remove_dir_all(&dir2); - } } diff --git a/crates/rbitcoin-consensus/src/regtest_pad.rs b/crates/rbitcoin-consensus/src/regtest_pad.rs index 8c4f161ef2..e35ae1783e 100644 --- a/crates/rbitcoin-consensus/src/regtest_pad.rs +++ b/crates/rbitcoin-consensus/src/regtest_pad.rs @@ -2,21 +2,15 @@ //! //! Prefer this over a local `for h in 1..=100 { mine_pow; connect }` loop. +use crate::block::apply_witness_commitment; +use crate::{bip34_height_script, block_has_witness, block_subsidy, ChainParams}; use bitcoin::absolute::LockTime; use bitcoin::block::{Header, Version}; use bitcoin::hashes::Hash; use bitcoin::transaction::Version as TxVersion; use bitcoin::{ Amount, Block, BlockHash, CompactTarget, OutPoint, ScriptBuf, Sequence, Target, Transaction, - TxIn, TxMerkleNode, TxOut, Txid, Witness, -}; -use rbitcoin_primitives::Height; -use rbitcoin_query::Query; - -use crate::block::apply_witness_commitment; -use crate::{ - accept_and_connect_block, bip34_height_script, block_has_witness, block_subsidy, ChainParams, - Milestone, + TxIn, TxMerkleNode, TxOut, Witness, }; pub const REGTEST_POW_BITS: u32 = 0x207f_ffff; @@ -291,60 +285,9 @@ pub fn regtest_coinbase(height: u32, value: Amount, script_pubkey: ScriptBuf) -> } } -/// Connect empty blocks `from_h..=last`. Returns tip hash/time and coinbase txids -/// for heights `1..=collect_coinbases` (empty if `collect_coinbases == 0`). -pub fn pad_empty_from( - query: &Query, - params: &ChainParams, - mut tip: BlockHash, - mut tip_time: u32, - from_h: u32, - last: u32, - collect_coinbases: u32, -) -> (BlockHash, u32, Vec) { - let ms = Milestone::NONE; - let mut cbs = Vec::new(); - for h in from_h..=last { - let b = mine_empty_regtest(tip, tip_time + 600, h); - if h >= 1 && h <= collect_coinbases { - cbs.push(b.txdata[0].compute_txid()); - } - accept_and_connect_block(query, params, Height(h), &b, ms).unwrap(); - tip = b.block_hash(); - tip_time = b.header.time; - } - query.apply_sh_pending().unwrap(); - (tip, tip_time, cbs) -} - #[cfg(test)] mod tests { use super::*; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[test] - fn pad_collects_early_coinbases() { - let n = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbtc-pad-{n}")); - let q = Query::open_or_create_tiny(&dir).unwrap(); - let params = ChainParams::regtest(); - let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); - accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); - let (_tip, _t, cbs) = pad_empty_from( - &q, - ¶ms, - genesis.block_hash(), - genesis.header.time, - 1, - 3, - 2, - ); - assert_eq!(cbs.len(), 2); - let _ = std::fs::remove_dir_all(&dir); - } #[test] fn mine_regtest_paying_sets_coinbase_script() { diff --git a/crates/rbitcoin-consensus/src/script/interpreter.rs b/crates/rbitcoin-consensus/src/script/interpreter.rs index d1816ecfb0..b721698d13 100644 --- a/crates/rbitcoin-consensus/src/script/interpreter.rs +++ b/crates/rbitcoin-consensus/src/script/interpreter.rs @@ -201,7 +201,7 @@ pub(crate) struct EvalContext<'a> { /// Legacy midstate. Created on first use (WitnessV0 uses `pre`, tapscript `tapscript`). cache: RefCell>>, /// Structure/lookup midstates (WitnessV0 BIP143). - pre: Cow<'a, rbitcoin_query::TxPrecompute>, + pre: Cow<'a, crate::TxPrecompute>, /// BIP342 remaining validation weight (`50 + witness serialized size`). validation_weight_left: Cell, /// Tapscript only: per-input sighash facts from the script-path spend. @@ -250,7 +250,7 @@ impl<'a> EvalContext<'a> { bip65_active, bip112_active, bip66_active, - Cow::Owned(rbitcoin_query::TxPrecompute::from_tx(tx)), + Cow::Owned(crate::TxPrecompute::from_tx(tx)), ) } @@ -264,7 +264,7 @@ impl<'a> EvalContext<'a> { bip65_active: bool, bip112_active: bool, bip66_active: bool, - pre: Cow<'a, rbitcoin_query::TxPrecompute>, + pre: Cow<'a, crate::TxPrecompute>, ) -> Self { Self { tx, diff --git a/crates/rbitcoin-consensus/src/script_pool.rs b/crates/rbitcoin-consensus/src/script_pool.rs index ab27f99c44..bfe83f0692 100644 --- a/crates/rbitcoin-consensus/src/script_pool.rs +++ b/crates/rbitcoin-consensus/src/script_pool.rs @@ -23,7 +23,7 @@ use std::thread; /// Jobs claimed per steal. Amortizes `next` / `in_wave` / `Arc` traffic /// without a megachunk on mixed P2WPKH/P2WSH waves. -pub(crate) const STEAL_CHUNK: usize = 32; +pub const STEAL_CHUNK: usize = 32; use crate::error::ConsensusError; @@ -143,7 +143,7 @@ static PUBLISHERS: Mutex> = Mutex::new(Vec::new()); /// /// `None` removes the calling thread. Two stages in one process each /// register; a single slot dropped the first stage's wakes. -pub(crate) fn set_script_publisher(t: Option) { +pub fn set_script_publisher(t: Option) { let mut g = PUBLISHERS.lock().unwrap_or_else(|p| p.into_inner()); if let Some(t) = t { g.retain(|p| p.id() != t.id()); @@ -172,14 +172,14 @@ static STEAL_CLAIMS: AtomicUsize = AtomicUsize::new(0); /// so parallel wave tests do not inflate the counter. #[cfg(test)] static STEAL_CLAIMS_ON: AtomicBool = AtomicBool::new(false); -/// Serialize the two 256-job steal tests so they do not share [`STEAL_CLAIMS`]. -#[cfg(test)] +/// Serialize steal-wave tests so they do not interleave. Lives outside +/// `cfg(test)` because query tests link this crate without that cfg. static STEAL_TEST: Mutex<()> = Mutex::new(()); /// Hold across a test that publishes a steal wave, so pool tests do not /// interleave waves. -#[cfg(test)] -pub(crate) fn steal_test_gate() -> std::sync::MutexGuard<'static, ()> { +#[doc(hidden)] +pub fn steal_test_gate() -> std::sync::MutexGuard<'static, ()> { STEAL_TEST.lock().unwrap_or_else(|p| p.into_inner()) } @@ -205,7 +205,7 @@ fn steal_chunk() -> Option<(Arc, Range)> { } /// True when steal workers can still claim a foreground job. -pub(crate) fn fg_has_unclaimed() -> bool { +pub fn fg_has_unclaimed() -> bool { waves_snap().load().iter().any(|w| w.has_unclaimed()) } @@ -213,7 +213,7 @@ pub(crate) fn fg_has_unclaimed() -> bool { /// scripts stage thread to finish a wave tail instead of parking. /// /// A one-job index claim can run on `ibd-confirm` when the workers are busy. -pub(crate) fn help_steal() -> bool { +pub fn help_steal() -> bool { if on_steal_worker() { return false; } @@ -233,14 +233,14 @@ unsafe impl Send for ApplyCtx {} unsafe impl Sync for ApplyCtx {} /// Publisher-owned job list + live wave. Safe to move; heap allocation is stable. -pub(crate) struct OwnedWave { +pub struct OwnedWave { _items: Box<[T]>, _ctx: Box>, wave: Arc, } impl OwnedWave { - pub(crate) fn is_complete(&self) -> bool { + pub fn is_complete(&self) -> bool { self.wave.is_complete() } @@ -249,7 +249,7 @@ impl OwnedWave { self.wave.has_unclaimed() } - pub(crate) fn finish(self) -> Result<(), ConsensusError> { + pub fn finish(self) -> Result<(), ConsensusError> { self.wave.wait_done(); match self .wave @@ -279,7 +279,7 @@ fn unpublish_fg(wave: &Arc) { /// Publish `items` for steal workers without waiting. `None` = already done /// (empty or single-item ran inline). Claims [`STEAL_CHUNK`] jobs at a time. -pub(crate) fn start_for_each_owned( +pub fn start_for_each_owned( items: Vec, f: fn(&T) -> Result<(), ConsensusError>, ) -> Result>, ConsensusError> { @@ -288,7 +288,7 @@ pub(crate) fn start_for_each_owned( /// Same as [`start_for_each_owned`] with an explicit claim size. `chunk` of 0 /// is treated as 1. Empty and single-item lists still run inline. -pub(crate) fn start_for_each_owned_chunk( +pub fn start_for_each_owned_chunk( items: Vec, f: fn(&T) -> Result<(), ConsensusError>, chunk: usize, @@ -301,7 +301,7 @@ pub(crate) fn start_for_each_owned_chunk( /// [`start_for_each_owned`] runs a single item on the caller. The index wave /// must not do that: one filter would block `ibd-confirm` inside `start`. /// `chunk` of 0 is treated as 1. -pub(crate) fn start_for_each_pooled( +pub fn start_for_each_pooled( items: Vec, f: fn(&T) -> Result<(), ConsensusError>, chunk: usize, diff --git a/crates/rbitcoin-consensus/src/silent_payments.rs b/crates/rbitcoin-consensus/src/silent_payments.rs index 0745a51b39..17085c5882 100644 --- a/crates/rbitcoin-consensus/src/silent_payments.rs +++ b/crates/rbitcoin-consensus/src/silent_payments.rs @@ -11,15 +11,8 @@ use bitcoin::key::{Parity, XOnlyPublicKey}; use bitcoin::script::{Instruction, Script}; use bitcoin::secp256k1::{All, PublicKey, Scalar, Secp256k1, SecretKey}; use bitcoin::{OutPoint, Transaction, TxOut, Witness}; -use rbitcoin_primitives::{Fk, Height}; -use rbitcoin_query::Query; -use rbitcoin_store::{IndexWindow, InputRecord, LoadedTweakTx, OutputRecord, StoreError}; -use std::collections::{BTreeMap, HashMap}; use std::sync::OnceLock; -use crate::error::ConsensusError; -use crate::params::ChainParams; - /// BIP341 NUMS internal key *H* (SHA256 of uncompressed *G* as x-only). const NUMS_H: [u8; 32] = [ 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, 0x5e, @@ -88,217 +81,6 @@ pub fn tweak_from_tx(tx: &Transaction, prevouts: &[TxOut]) -> Option { }) } -/// Prefer the thin index (no parent peeks). Hole / no table → [`tweaks_for_height`]. -pub fn tweaks_at_height( - query: &Query, - params: &ChainParams, - height: Height, -) -> Result, ConsensusError> { - if !params.taproot_active_at(height.0) { - return Ok(BTreeMap::new()); - } - match query.load_thin_tweaks(height) { - Ok(Some(rows)) => { - let mut out = BTreeMap::new(); - for r in rows { - out.insert( - r.txid, - TxTweak { - tweak: r.tweak, - output_pubkeys: r - .p2tr - .into_iter() - .map(|(vout, xonly, value)| TaprootOut { vout, xonly, value }) - .collect(), - }, - ); - } - return Ok(out); - } - Ok(None) => { - rbitcoin_log::debug!( - "sp_tweaks: naive fallback h={} (hole or index off)", - height.0 - ); - } - Err(e) => return Err(e.into()), - } - tweaks_for_height(query, params, height) -} - -/// Confirmed height → eligible txid (internal order) → tweak. -/// -/// Pre-Taproot and missing heights return an empty map (no error). Does not -/// reconstruct a wire block. Walks packed bodies **and parent outs**. -pub fn tweaks_for_height( - query: &Query, - params: &ChainParams, - height: Height, -) -> Result, ConsensusError> { - query.require_sp_tweaks_unpruned()?; - if !params.taproot_active_at(height.0) { - return Ok(BTreeMap::new()); - } - let fks = match query.block_tx_fks(height) { - Ok(f) => f, - Err(StoreError::NotFound) => return Ok(BTreeMap::new()), - Err(e) => return Err(e.into()), - }; - if fks.is_empty() { - return Ok(BTreeMap::new()); - } - - let wave = rbitcoin_store::load_tweak_wave(&query.store().txs, &fks)?; - let mut by_fk: HashMap = HashMap::new(); - for t in &wave.txs { - by_fk.insert(t.fk, (t.rec.txid, &t.outs)); - } - for (fk, (txid, outs)) in &wave.parents { - by_fk.entry(Fk(*fk)).or_insert((*txid, outs)); - } - let txs: Vec<&LoadedTweakTx> = wave.txs.iter().collect(); - let tweaks = tweaks_for_txs(&txs, &|fk| by_fk.get(&fk).copied())?; - Ok(txs - .iter() - .zip(tweaks) - .filter_map(|(t, tw)| Some((t.rec.txid, tw?))) - .collect()) -} - -/// Tweak records of `window.blocks[i]` in block order (`None` = ineligible). -pub fn tweak_records_from_window( - window: &IndexWindow, - i: usize, -) -> Result>, ConsensusError> { - let txs: Vec<&LoadedTweakTx> = window.blocks[i].txs.iter().collect(); - let tweaks = tweaks_for_txs(&txs, &|fk| Some((window.txid(fk)?, window.outs(fk)?)))?; - Ok(tweaks.into_iter().map(|t| t.map(|t| t.tweak)).collect()) -} - -/// Txid and outputs of a spent create. -type ParentLookup<'a> = dyn Fn(Fk) -> Option<([u8; 32], &'a [OutputRecord])> + 'a; - -/// Per-tx tweak (`None` = ineligible) for `txs` in order. Only P2TR-output -/// txs with loaded inputs are candidates; EC math runs on the caller. -fn tweaks_for_txs<'a>( - txs: &[&'a LoadedTweakTx], - parent: &ParentLookup<'a>, -) -> Result>, ConsensusError> { - let mut out = vec![None; txs.len()]; - for (i, t) in txs.iter().enumerate() { - if !t.need_seqsigwit { - continue; - } - let Some(inputs) = t.inputs.as_ref() else { - continue; - }; - let (tx, prevouts) = build_tx_and_prevouts(inputs, &t.outs, parent)?; - out[i] = tweak_from_tx(&tx, &prevouts); - } - Ok(out) -} - -#[allow(clippy::type_complexity)] // packed row / pin / script-hash tuple is the on-disk shape -/// Tweaks-wire `TxTweak` from a stored 33-byte tweak + this tx’s packed outs. -/// -/// No parent IO. `Some(tweak)` with missing packed outs is corrupt. -pub fn tweaks_from_thin_and_body( - rows: &[([u8; 32], Option<[u8; 33]>, Option<&[OutputRecord]>)], -) -> Result, StoreError> { - let mut out = BTreeMap::new(); - for (txid, tweak, outs) in rows { - let Some(tweak) = tweak else { - continue; - }; - let Some(outs) = outs else { - return Err(StoreError::Corrupt( - "invariant: thin tweak missing packed body", - )); - }; - out.insert( - *txid, - TxTweak { - tweak: *tweak, - output_pubkeys: taproot_outs_from_records(outs), - }, - ); - } - Ok(out) -} - -fn taproot_outs_from_records(outputs: &[OutputRecord]) -> Vec { - let mut out = Vec::new(); - for (i, o) in outputs.iter().enumerate() { - if !is_p2tr(&o.script) { - continue; - } - let mut xonly = [0u8; 32]; - xonly.copy_from_slice(&o.script[2..34]); - let value = o.value; - out.push(TaprootOut { - vout: i as u32, - xonly, - value, - }); - } - out -} - -/// A spent parent missing from the lookup is an invariant break, not an -/// ineligible tx. -fn build_tx_and_prevouts( - inputs: &[InputRecord], - outputs: &[OutputRecord], - parent: &ParentLookup<'_>, -) -> Result<(Transaction, Vec), StoreError> { - let mut prevouts = Vec::with_capacity(inputs.len()); - let mut txins = Vec::with_capacity(inputs.len()); - for inp in inputs { - let (prev_txid, prev_script, prev_value) = if inp.is_coinbase() { - ([0u8; 32], Vec::new(), 0u64) - } else { - const MISSING: &str = "invariant: sp_tweaks spent parent missing"; - let (tid, outs) = parent(inp.create_fk()).ok_or(StoreError::Corrupt(MISSING))?; - let o = outs - .get(inp.prev_index() as usize) - .ok_or(StoreError::Corrupt(MISSING))?; - (tid, o.script.clone(), o.value) - }; - let wit_refs: Vec<&[u8]> = inp.witness.iter().map(|w| w.as_slice()).collect(); - txins.push(bitcoin::TxIn { - previous_output: OutPoint { - txid: bitcoin::Txid::from_byte_array(prev_txid), - vout: inp.prev_index(), - }, - script_sig: bitcoin::ScriptBuf::from_bytes(inp.script_sig.clone()), - sequence: bitcoin::Sequence::from_consensus(inp.sequence), - witness: Witness::from_slice(&wit_refs), - }); - let value = bitcoin::Amount::from_sat(prev_value); - prevouts.push(TxOut { - value, - script_pubkey: bitcoin::ScriptBuf::from_bytes(prev_script), - }); - } - let mut txouts = Vec::with_capacity(outputs.len()); - for o in outputs { - let value = bitcoin::Amount::from_sat(o.value); - txouts.push(TxOut { - value, - script_pubkey: bitcoin::ScriptBuf::from_bytes(o.script.clone()), - }); - } - Ok(( - Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: bitcoin::absolute::LockTime::ZERO, - input: txins, - output: txouts, - }, - prevouts, - )) -} - fn input_hash_mul_a(tx: &Transaction, a: &PublicKey) -> Option<[u8; 33]> { let mut smallest = outpoint_bytes(&tx.input[0].previous_output); for vin in tx.input.iter().skip(1) { @@ -382,7 +164,7 @@ fn taproot_outs(tx: &Transaction) -> Vec { out } -pub(crate) fn is_p2tr(spk: &[u8]) -> bool { +pub fn is_p2tr(spk: &[u8]) -> bool { spk.len() == 34 && spk[0] == 0x51 && spk[1] == 0x20 } @@ -537,7 +319,6 @@ fn is_p2sh_p2wpkh_redeem(script_sig: &Script) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::Milestone; use bitcoin::absolute::LockTime; use bitcoin::consensus::encode::deserialize; use bitcoin::hashes::Hash; @@ -545,9 +326,6 @@ mod tests { use bitcoin::secp256k1::SecretKey; use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, Sequence, TxIn}; - use rbitcoin_query::testutil::FixtureChain; - use rbitcoin_query::TxApply; - use rbitcoin_store::{HeaderRecord, TxRecord}; use serde_json::Value; use std::str::FromStr; #[test] @@ -591,37 +369,29 @@ mod tests { not_prog[1] = 0x15; assert!(!is_p2sh_p2wpkh_redeem(push(¬_prog).as_script())); - let zero = OutputRecord::unspent(0, { - let mut s = vec![0x51, 0x20]; - s.extend(std::iter::repeat_n(0xab, 32)); - s - }); - assert_eq!( - taproot_outs_from_records(std::slice::from_ref(&zero))[0].value, - 0 - ); - - let parent_outs = [OutputRecord::unspent(7, vec![0x51])]; - let inp = InputRecord { - prev_txid: [2u8; 32], - parent: rbitcoin_store::InputParent::Prevout { - create_fk: Fk(1), - vout: 0, - }, - sequence: 0, - script_sig: Vec::new(), - witness: Vec::new(), - }; - let (tx, prevouts) = build_tx_and_prevouts(&[inp], std::slice::from_ref(&zero), &|_| { - Some(([9u8; 32], parent_outs.as_slice())) - }) - .unwrap(); - assert_eq!(prevouts[0].value.to_sat(), 7); - assert_eq!(tx.output[0].value.to_sat(), 0); - let sk = SecretKey::from_slice(&[3u8; 32]).unwrap(); let pk = PublicKey::from_secret_key(secp(), &sk); - let mut tx = tx; + let mut tx = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: bitcoin::Txid::from_byte_array([9u8; 32]), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ZERO, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes({ + let mut s = vec![0x51, 0x20]; + s.extend(std::iter::repeat_n(0xab, 32)); + s + }), + }], + }; tx.input.push(TxIn { previous_output: OutPoint { txid: bitcoin::Txid::from_byte_array([0u8; 32]), @@ -824,483 +594,4 @@ mod tests { }]; assert!(tweak_from_tx(&tx, &prev).is_none()); } - - fn tmp_store() -> (rbitcoin_query::testutil::TempDir, Query) { - rbitcoin_query::testutil::tiny_query_labeled("sp-tweaks") - } - - fn header(h: u32, prev_fk: Fk, prev_hash: Option<[u8; 32]>) -> HeaderRecord { - rbitcoin_query::testutil::header_with_merkle_marker(h, prev_fk, prev_hash, 0xec) - } - - #[test] - fn thin_compose_matches_engine_outs_without_parents() { - let (dir, q) = tmp_store(); - let params = ChainParams::regtest(); - // Reuse the P2WPKH→P2TR spend from the walk test via a tiny local chain. - use bitcoin::hashes::hash160; - use bitcoin::secp256k1::SecretKey; - let secp_ctx = Secp256k1::new(); - let sk = SecretKey::from_slice(&[2u8; 32]).unwrap(); - let pk = bitcoin::secp256k1::PublicKey::from_secret_key(&secp_ctx, &sk); - let ser = pk.serialize(); - let h160 = hash160::Hash::hash(&ser); - let mut p2wpkh = vec![0x00, 0x14]; - p2wpkh.extend_from_slice(h160.as_ref()); - let (xonly, _) = pk.x_only_public_key(); - let mut p2tr = vec![0x51, 0x20]; - p2tr.extend_from_slice(&xonly.serialize()); - let mut genesis_txid = [0u8; 32]; - genesis_txid[31] = 0xcb; - let h0 = header(0, Fk::NULL, None); - let fk0 = q - .connect_block( - Height(0), - &h0, - &[TxApply { - tx: TxRecord { - txid: genesis_txid, - version: 1, - locktime: 0, - input_start_fk: Fk::NULL, - input_count: 1, - output_start_fk: Fk::NULL, - output_count: 1, - }, - inputs: vec![InputRecord::coinbase(u32::MAX, vec![0x00], vec![])], - outputs: vec![OutputRecord::unspent(50_0000_0000, p2wpkh.clone())], - }], - ) - .unwrap(); - let create_fk = q.block_tx_fks(Height(0)).unwrap()[0]; - let mut spend_txid = [0u8; 32]; - spend_txid[0] = 0x11; - spend_txid[31] = 0xcd; - let h1 = header(1, fk0, Some(h0.hash)); - q.connect_block( - Height(1), - &h1, - &[TxApply { - tx: TxRecord { - txid: spend_txid, - version: 2, - locktime: 0, - input_start_fk: Fk::NULL, - input_count: 1, - output_start_fk: Fk::NULL, - output_count: 1, - }, - inputs: vec![InputRecord { - prev_txid: genesis_txid, - parent: rbitcoin_store::InputParent::Prevout { create_fk, vout: 0 }, - sequence: u32::MAX, - script_sig: vec![], - witness: vec![vec![0u8; 64], ser.to_vec()], - }], - outputs: vec![OutputRecord::unspent(49_0000_0000, p2tr.clone())], - }], - ) - .unwrap(); - - let naive = tweaks_for_height(&q, ¶ms, Height(1)).unwrap(); - let t = naive.get(&spend_txid).unwrap(); - let spend_fk = q.block_tx_fks(Height(1)).unwrap()[0]; - let (_rec, _ins, outs) = q.store().get_tx_full(spend_fk).unwrap(); - let composed = - tweaks_from_thin_and_body(&[(spend_txid, Some(t.tweak), Some(outs.as_slice()))]) - .unwrap(); - let c = composed.get(&spend_txid).unwrap(); - assert_eq!(c.tweak, t.tweak); - assert_eq!(c.output_pubkeys, t.output_pubkeys); - - let miss = tweaks_from_thin_and_body(&[(spend_txid, Some(t.tweak), None)]); - assert!( - matches!(miss, Err(StoreError::Corrupt(m)) if m.contains("invariant")), - "got {miss:?}" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn tweaks_for_height_unknown_is_empty() { - let (dir, q) = tmp_store(); - let params = ChainParams::regtest(); - assert!(tweaks_for_height(&q, ¶ms, Height(0)) - .unwrap() - .is_empty()); - let main = ChainParams::mainnet(); - assert!(tweaks_for_height(&q, &main, Height(100)) - .unwrap() - .is_empty()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A connect with tweaks on seals that height. Disconnect truncates, and - /// the replacement block is sealed on the next connect. - #[test] - fn live_connect_seals_tweaks_and_reorg_truncates() { - let (dir, q) = tmp_store(); - let params = ChainParams::regtest(); - q.set_sptweaks_enabled(true, Height(0)).unwrap(); - crate::prepare_live_indexes(&q).unwrap(); - let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); - crate::accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE) - .unwrap(); - assert_eq!(q.sptweaks_next_height(), Some(Height(1))); - let thin0 = q.load_thin_tweaks(Height(0)).unwrap().expect("indexed"); - assert!( - thin0.is_empty(), - "coinbase is not eligible — no Class A join" - ); - - let b1 = crate::mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); - crate::accept_and_connect_block(&q, ¶ms, Height(1), &b1, Milestone::NONE).unwrap(); - assert_eq!(q.sptweaks_next_height(), Some(Height(2))); - - q.disconnect_tip().unwrap(); - assert_eq!(q.sptweaks_next_height(), Some(Height(1))); - assert!(q.load_thin_tweaks(Height(1)).unwrap().is_none()); - - let b1b = crate::mine_empty_regtest(genesis.block_hash(), genesis.header.time + 601, 1); - crate::accept_and_connect_block(&q, ¶ms, Height(1), &b1b, Milestone::NONE).unwrap(); - assert_eq!(q.sptweaks_next_height(), Some(Height(2))); - assert!(q.load_thin_tweaks(Height(1)).unwrap().is_some()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Contiguous connects seal tweaks. The materialize does not rewrite them. - #[test] - fn live_connect_seals_tweaks_builder_leaves_them() { - let (dir, q) = tmp_store(); - let params = ChainParams::regtest(); - q.set_sptweaks_enabled(true, Height(0)).unwrap(); - crate::prepare_live_indexes(&q).unwrap(); - let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); - crate::accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE) - .unwrap(); - let b1 = crate::mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); - crate::accept_and_connect_block(&q, ¶ms, Height(1), &b1, Milestone::NONE).unwrap(); - let b2 = crate::mine_empty_regtest(b1.block_hash(), b1.header.time + 600, 2); - crate::accept_and_connect_block(&q, ¶ms, Height(2), &b2, Milestone::NONE).unwrap(); - assert_eq!(q.sptweaks_next_height(), Some(Height(3))); - - q.release_index_writebehind(Height(2)); - crate::build_indexes_released(&q).unwrap(); - assert_eq!(q.sptweaks_next_height(), Some(Height(3))); - for h in 0..=2 { - assert!( - q.load_thin_tweaks(Height(h)).unwrap().is_some(), - "height {h}" - ); - } - let _ = std::fs::remove_dir_all(&dir); - } - - struct SpKeys { - p2wpkh: Vec, - p2tr: Vec, - pubkey: Vec, - } - - fn sp_keys() -> SpKeys { - use bitcoin::hashes::hash160; - let sk = SecretKey::from_slice(&[2u8; 32]).unwrap(); - let pk = PublicKey::from_secret_key(secp(), &sk); - let ser = pk.serialize(); - let mut p2wpkh = vec![0x00, 0x14]; - p2wpkh.extend_from_slice(hash160::Hash::hash(&ser).as_ref()); - let mut p2tr = vec![0x51, 0x20]; - p2tr.extend_from_slice(&pk.x_only_public_key().0.serialize()); - SpKeys { - p2wpkh, - p2tr, - pubkey: ser.to_vec(), - } - } - - fn sp_spend( - tag: u8, - prev: ([u8; 32], Fk, u32), - witness: Vec>, - outs: Vec, - ) -> TxApply { - let mut txid = [0u8; 32]; - txid[0] = tag; - TxApply { - tx: TxRecord { - txid, - version: 2, - locktime: 0, - input_start_fk: Fk::NULL, - input_count: 1, - output_start_fk: Fk::NULL, - output_count: outs.len() as u32, - }, - inputs: vec![InputRecord { - prev_txid: prev.0, - parent: rbitcoin_store::InputParent::Prevout { - create_fk: prev.1, - vout: prev.2, - }, - sequence: u32::MAX, - script_sig: vec![], - witness, - }], - outputs: outs, - } - } - - fn seal_tweaks(q: &Query, through: u32) { - q.release_index_writebehind(Height(through)); - crate::build_indexes_released(q).unwrap(); - assert_eq!(q.sptweaks_next_height(), Some(Height(through + 1))); - } - - /// Height 1 carries two eligible P2WPKH→P2TR spends around a spend with - /// fat seqsigwit and a fat output script. The naive walk, the index - /// window reader, the engine on the rebuilt wire tx, and the served - /// index all give the same tweaks, and the served read spans the - /// middle txout but not its seqsigwit. - fn pin_tweaks_agree_and_thin_serve_skips_fat_witness( - q: &Query, - params: &ChainParams, - keys: &SpKeys, - a: [u8; 32], - b: [u8; 32], - ) { - let naive = tweaks_for_height(q, params, Height(1)).unwrap(); - assert_eq!(naive.len(), 2, "the OP_TRUE spend is not eligible"); - let fks = q.block_tx_fks(Height(1)).unwrap(); - let wire_a = q.reconstruct_tx(fks[0]).unwrap(); - let engine_prev = [TxOut { - value: Amount::from_sat(20_0000_0000), - script_pubkey: ScriptBuf::from_bytes(keys.p2wpkh.clone()), - }]; - let engine = tweak_from_tx(&wire_a, &engine_prev).unwrap(); - assert_eq!(naive[&a].tweak, engine.tweak); - assert_eq!(naive[&a].output_pubkeys, engine.output_pubkeys); - - let want = vec![Some(naive[&a].tweak), None, Some(naive[&b].tweak)]; - for start in [0, 1] { - let heights = q.index_heights(start, 1, Some(0)).unwrap(); - let window = q.read_index_window(&heights).unwrap(); - let i = window.blocks.len() - 1; - assert_eq!( - tweak_records_from_window(&window, i).unwrap(), - want, - "window from {start}: the parent inside or outside the window" - ); - } - - let mid_seqsigwit = q.store().tx_seqsigwit_range(fks[1]).unwrap().1; - let mid_txout = q.store().txs.body_range(fks[1]).unwrap().1; - let _ = q.sample_reset_thin_tweak_body_bytes(); - let rows = q.load_thin_tweaks(Height(1)).unwrap().expect("indexed"); - let read = q.sample_reset_thin_tweak_body_bytes(); - assert_eq!( - rows.iter().map(|r| r.txid).collect::>(), - [a, b], - "ineligible txs are not joined from Class A" - ); - assert_eq!((rows[0].p2tr.len(), rows[1].p2tr.len()), (2, 1)); - assert!( - read >= mid_txout, - "the txout span covers the ineligible hole (read={read} txout={mid_txout})" - ); - assert!( - read < mid_seqsigwit, - "the fat seqsigwit stays out (read={read} seqsigwit={mid_seqsigwit})" - ); - let indexed = tweaks_at_height(q, params, Height(1)).unwrap(); - for txid in [a, b] { - assert_eq!(indexed[&txid].tweak, naive[&txid].tweak); - assert_eq!(indexed[&txid].output_pubkeys, naive[&txid].output_pubkeys); - } - } - - fn pin_thin_range_limits_and_hole(q: &Query, a: [u8; 32], c: [u8; 32]) { - use rbitcoin_query::ThinTweakRangeLimits; - - let all = q - .load_thin_tweaks_range(Height(0), ThinTweakRangeLimits::default()) - .unwrap(); - assert_eq!( - all.iter() - .map(|(h, rows)| (h.0, rows.len())) - .collect::>(), - [(0, 0), (1, 2), (2, 1)] - ); - assert_eq!(all[2].1[0].txid, c); - for (h, rows) in &all { - let single = q.load_thin_tweaks(*h).unwrap().expect("indexed"); - assert_eq!(single.len(), rows.len()); - for (s, r) in single.iter().zip(rows) { - assert_eq!((s.txid, s.tweak, &s.p2tr), (r.txid, r.tweak, &r.p2tr)); - } - } - let limited = |max_heights, max_eligible| { - q.load_thin_tweaks_range( - Height(1), - ThinTweakRangeLimits { - max_heights, - max_eligible, - ..ThinTweakRangeLimits::default() - }, - ) - .unwrap() - }; - assert_eq!(limited(1, 8192).len(), 1); - assert_eq!( - limited(10, 1).len(), - 1, - "the eligible budget stops after the first height that spends it" - ); - assert!(q - .load_thin_tweaks_range(Height(3), ThinTweakRangeLimits::default()) - .unwrap() - .is_empty()); - let only2 = q - .load_thin_tweaks_range(Height(2), ThinTweakRangeLimits::default()) - .unwrap(); - assert_eq!(only2.len(), 1); - assert_eq!(only2[0].0, Height(2)); - - let cut = |cut_through| { - q.load_thin_tweaks_range( - Height(1), - ThinTweakRangeLimits { - cut_through, - ..ThinTweakRangeLimits::default() - }, - ) - .unwrap() - }; - let kept = cut(true); - let row_a = kept[0].1.iter().find(|r| r.txid == a).unwrap(); - assert_eq!( - row_a.p2tr.iter().map(|o| o.0).collect::>(), - [1], - "cut-through drops the P2TR output spent at height 2" - ); - let hist = cut(false); - assert_eq!( - hist[0].1.iter().find(|r| r.txid == a).unwrap().p2tr.len(), - 2 - ); - } - - /// One synthetic chain through the index builder: coinbase at 0, two - /// eligible spends around a fat ineligible one at 1, a spend of one of - /// those P2TR outputs plus a third eligible spend at 2 (sealed with 1 in - /// one window), and at 3 the spend that leaves the first tx all spent. - #[test] - fn sp_tweaks_confirm_life() { - let (dir, q) = tmp_store(); - let params = ChainParams::regtest(); - let keys = sp_keys(); - q.set_sptweaks_enabled(true, Height(0)).unwrap(); - let sig_wit = || vec![vec![0u8; 64], keys.pubkey.clone()]; - let p2tr = |sat| OutputRecord::unspent(sat, keys.p2tr.clone()); - - let mut cb_txid = [0u8; 32]; - cb_txid[31] = 0xcb; - let h0 = header(0, Fk::NULL, None); - let fk0 = q - .connect_block( - Height(0), - &h0, - &[TxApply { - tx: TxRecord { - txid: cb_txid, - version: 1, - locktime: 0, - input_start_fk: Fk::NULL, - input_count: 1, - output_start_fk: Fk::NULL, - output_count: 4, - }, - inputs: vec![InputRecord::coinbase(u32::MAX, vec![0x00], vec![])], - outputs: vec![ - OutputRecord::unspent(20_0000_0000, keys.p2wpkh.clone()), - OutputRecord::unspent(20_0000_0000, keys.p2wpkh.clone()), - OutputRecord::unspent(5_0000_0000, vec![0x51]), - OutputRecord::unspent(5_0000_0000, keys.p2wpkh.clone()), - ], - }], - ) - .unwrap(); - seal_tweaks(&q, 0); - assert!( - q.load_thin_tweaks(Height(0)).unwrap().unwrap().is_empty(), - "a coinbase is not eligible" - ); - let cb = q.block_tx_fks(Height(0)).unwrap()[0]; - - let spend_a = sp_spend( - 0xaa, - (cb_txid, cb, 0), - sig_wit(), - vec![p2tr(12_0000_0000), p2tr(7_0000_0000)], - ); - let fat = sp_spend( - 0xfe, - (cb_txid, cb, 2), - vec![vec![0u8; 16_384]], - vec![OutputRecord::unspent(4_0000_0000, vec![0x51; 4096])], - ); - let spend_b = sp_spend(0xbb, (cb_txid, cb, 1), sig_wit(), vec![p2tr(19_0000_0000)]); - let (a, b) = (spend_a.tx.txid, spend_b.tx.txid); - let h1 = header(1, fk0, Some(h0.hash)); - let fk1 = q - .connect_block(Height(1), &h1, &[spend_a, fat, spend_b]) - .unwrap(); - let a_fk = q.block_tx_fks(Height(1)).unwrap()[0]; - - let spend_c = sp_spend(0xcc, (cb_txid, cb, 3), sig_wit(), vec![p2tr(4_0000_0000)]); - let c = spend_c.tx.txid; - let spend_a0 = sp_spend( - 0xd0, - (a, a_fk, 0), - vec![vec![0u8; 64]], - vec![OutputRecord::unspent(11_0000_0000, keys.p2wpkh.clone())], - ); - let h2 = header(2, fk1, Some(h1.hash)); - let fk2 = q - .connect_block(Height(2), &h2, &[spend_c, spend_a0]) - .unwrap(); - assert_eq!( - q.sptweaks_next_height(), - Some(Height(1)), - "confirm writes none" - ); - seal_tweaks(&q, 2); - - pin_tweaks_agree_and_thin_serve_skips_fat_witness(&q, ¶ms, &keys, a, b); - pin_thin_range_limits_and_hole(&q, a, c); - - let spend_a1 = sp_spend( - 0xd1, - (a, a_fk, 1), - vec![vec![0u8; 64]], - vec![OutputRecord::unspent(6_0000_0000, vec![0x51])], - ); - let h3 = header(3, fk2, Some(h2.hash)); - q.connect_block(Height(3), &h3, &[spend_a1]).unwrap(); - seal_tweaks(&q, 3); - let gone = q - .load_thin_tweaks_range( - Height(1), - rbitcoin_query::ThinTweakRangeLimits { - cut_through: true, - ..rbitcoin_query::ThinTweakRangeLimits::default() - }, - ) - .unwrap(); - assert_eq!( - gone[0].1.iter().map(|r| r.txid).collect::>(), - [b], - "an all-spent eligible tx is omitted" - ); - let _ = std::fs::remove_dir_all(&dir); - } } diff --git a/crates/rbitcoin-query/src/tx_precompute.rs b/crates/rbitcoin-consensus/src/tx_precompute.rs similarity index 95% rename from crates/rbitcoin-query/src/tx_precompute.rs rename to crates/rbitcoin-consensus/src/tx_precompute.rs index 50c582d047..ea75a38977 100644 --- a/crates/rbitcoin-query/src/tx_precompute.rs +++ b/crates/rbitcoin-consensus/src/tx_precompute.rs @@ -965,55 +965,6 @@ mod tests { assert!(block.txdata[0].output.is_empty()); } - /// Peer intake walks the wire and lookup decodes it. A payload one side - /// accepts and the other refuses either strands a queued body or drops an - /// honest one, so every truncation and single-byte rewrite must agree. - #[test] - fn block_wire_walk_agrees_with_decode() { - use bitcoin::consensus::encode::serialize; - let zero_in = Transaction { - version: Version::TWO, - lock_time: LockTime::ZERO, - input: vec![], - output: vec![TxOut { - value: Amount::from_sat(1), - script_pubkey: ScriptBuf::from_bytes(vec![0x51]), - }], - }; - // Segwit with one empty and one non-empty witness decodes; rewriting - // the non-empty stack count to 0 hits the all-empty refusal. - let mut mixed = p2wpkh_like(); - mixed.input.insert(0, legacy_1in().input[0].clone()); - mixed.input[1].witness = Witness::from_slice(&[[0x01]]); - let mut raw = vec![0u8; 80]; - raw.push(5); - for tx in [legacy_1in(), p2wpkh_like(), zero_in, mixed] { - raw.extend_from_slice(&serialize(&tx)); - } - raw.extend_from_slice(&FLAG_ZERO_TX); - let agree = |p: &[u8]| { - let decoded = super::decode_block_precomputes(p, false) - .map(|(b, _, _)| b.txdata.iter().map(|t| t.input.len() as u32).sum()); - assert_eq!( - rbitcoin_store::block_wire_input_count(p), - decoded, - "payload {p:02x?}" - ); - decoded - }; - assert_eq!(agree(&raw), Some(4)); - for n in 0..raw.len() { - agree(&raw[..n]); - } - for i in 80..raw.len() { - for v in [0x00, 0x01, 0x02, 0xfd, 0xfe, 0xff] { - let mut p = raw.clone(); - p[i] = v; - agree(&p); - } - } - } - /// Core's `UnserializeTransaction` reads an empty vin and flag 0 as a tx /// with no inputs and no outputs; the locktime follows. const FLAG_ZERO_TX: [u8; 10] = [2, 0, 0, 0, 0x00, 0x00, 7, 0, 0, 0]; diff --git a/crates/rbitcoin-electrum/src/electrum_sh_journey.rs b/crates/rbitcoin-electrum/src/electrum_sh_journey.rs index 2e268f3649..7e8a7add06 100644 --- a/crates/rbitcoin-electrum/src/electrum_sh_journey.rs +++ b/crates/rbitcoin-electrum/src/electrum_sh_journey.rs @@ -2,7 +2,8 @@ use bitcoin::absolute::LockTime; use bitcoin::script::ScriptBuf; use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; -use rbitcoin_consensus::{accept_and_connect_block, Milestone}; +use rbitcoin_consensus::{Milestone}; +use rbitcoin_query::{accept_and_connect_block}; use rbitcoin_query::TxApply; use rbitcoin_store::{HeaderRecord, InputRecord, OutputRecord, TxRecord}; @@ -503,7 +504,7 @@ async fn electrum_sh_join() { let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); const N_SPENDS: u32 = 4; - let (tip, _tip_time, coinbase_txids) = rbitcoin_consensus::pad_empty_from( + let (tip, _tip_time, coinbase_txids) = rbitcoin_query::pad_empty_from( &q, ¶ms, genesis.block_hash(), diff --git a/crates/rbitcoin-electrum/src/server_tests.rs b/crates/rbitcoin-electrum/src/server_tests.rs index 40dacc977b..b4ac92d4fd 100644 --- a/crates/rbitcoin-electrum/src/server_tests.rs +++ b/crates/rbitcoin-electrum/src/server_tests.rs @@ -543,8 +543,9 @@ async fn electrum_fee_reports_follow_configured_and_enforced_floor() { /// a real blocking store query (`blockchain.block.headers`). #[tokio::test(flavor = "current_thread")] async fn ping_overlaps_blocking_headers_on_one_worker() { - use rbitcoin_consensus::{accept_and_connect_block, Milestone}; + use rbitcoin_consensus::Milestone; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; use std::sync::atomic::AtomicU64; let (dir, q) = tmp_store(); @@ -552,7 +553,7 @@ async fn ping_overlaps_blocking_headers_on_one_worker() { let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); // Enough headers that a serial walk stays in-flight after ping is scheduled. - let _ = rbitcoin_consensus::pad_empty_from( + let _ = rbitcoin_query::pad_empty_from( &q, ¶ms, genesis.block_hash(), diff --git a/crates/rbitcoin-electrum/src/silent_scan.rs b/crates/rbitcoin-electrum/src/silent_scan.rs index 9f36107f31..7d9205e97b 100644 --- a/crates/rbitcoin-electrum/src/silent_scan.rs +++ b/crates/rbitcoin-electrum/src/silent_scan.rs @@ -2,8 +2,9 @@ use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::Network; -use rbitcoin_consensus::{taproot_matches_scan, tweaks_for_height, ChainParams}; +use rbitcoin_consensus::{taproot_matches_scan, ChainParams}; use rbitcoin_primitives::{hex_decode, Height}; +use rbitcoin_query::tweaks_for_height; use rbitcoin_query::Query; use serde_json::{json, Value}; diff --git a/crates/rbitcoin-electrum/src/tweaks.rs b/crates/rbitcoin-electrum/src/tweaks.rs index f8774df11a..deefd8ffd0 100644 --- a/crates/rbitcoin-electrum/src/tweaks.rs +++ b/crates/rbitcoin-electrum/src/tweaks.rs @@ -8,10 +8,11 @@ //! `historicalMode=false` omits confirmed-spent P2TR outs. //! Serve-time `--sptweaks-dust` omits P2TR outs with `value <=` the floor. -use rbitcoin_consensus::{tweaks_for_height, ChainParams, TxTweak}; +use rbitcoin_consensus::{ChainParams, TxTweak}; #[cfg(test)] use rbitcoin_primitives::hex_encode; use rbitcoin_primitives::Height; +use rbitcoin_query::tweaks_for_height; use rbitcoin_query::{Query, ThinTweakRangeLimits, ThinTweakRow}; #[cfg(test)] use serde_json::Map; diff --git a/crates/rbitcoin-esplora/src/internal.rs b/crates/rbitcoin-esplora/src/internal.rs index 333377e3f7..31d1befbcc 100644 --- a/crates/rbitcoin-esplora/src/internal.rs +++ b/crates/rbitcoin-esplora/src/internal.rs @@ -416,9 +416,10 @@ mod tests { use crate::server::{run_esplora, EsploraConfig, EsploraListen}; use bitcoin::absolute::LockTime; use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; - use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_net::MempoolHub; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; use rbitcoin_query::Query; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -475,7 +476,7 @@ mod tests { let params = ChainParams::regtest(); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); - let (_tip, _time, cbs) = rbitcoin_consensus::pad_empty_from( + let (_tip, _time, cbs) = rbitcoin_query::pad_empty_from( &q, ¶ms, genesis.block_hash(), diff --git a/crates/rbitcoin-net/src/chain.rs b/crates/rbitcoin-net/src/chain.rs index 945a61d2af..532df49175 100644 --- a/crates/rbitcoin-net/src/chain.rs +++ b/crates/rbitcoin-net/src/chain.rs @@ -12,14 +12,17 @@ use bitcoin::{ Work, }; use rbitcoin_consensus::{ - accept_and_connect_block_preverified, confirm_wire_load_from_plan as consensus_load_from_plan, - confirm_wire_load_phase_pipelined, confirm_write_phase, genesis_block, header_to_record, - mine_op_true_signet_paying, mine_regtest_paying, validate_header, validate_header_on_parent, - ChainParams, Milestone, PlanStampOutcome, ScriptOkBatch, ScriptPreverified, WireLoadPipeline, + genesis_block, mine_op_true_signet_paying, mine_regtest_paying, validate_header_on_parent, + ChainParams, Milestone, }; use rbitcoin_log::{debug, info, warn}; use rbitcoin_primitives::{Fk, Height}; use rbitcoin_query::Query; +use rbitcoin_query::{ + accept_and_connect_block_preverified, confirm_wire_load_from_plan as consensus_load_from_plan, + confirm_wire_load_phase_pipelined, confirm_write_phase, header_to_record, validate_header, + PlanStampOutcome, ScriptOkBatch, ScriptPreverified, WireLoadPipeline, +}; use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, Ordering}; @@ -288,16 +291,20 @@ fn connect_failed_for_header(hash: [u8; 32], e: NetError) -> NetError { } } -fn header_reject(header: &Header, e: &rbitcoin_consensus::ConsensusError) -> NetError { - if let rbitcoin_consensus::ConsensusError::Store(se) = e { - return NetError::store(se); - } - let reason = rbitcoin_consensus::block_reject_reason(e); +fn header_reject(header: &Header, e: &rbitcoin_query::ConfirmError) -> NetError { + let rules = match e { + rbitcoin_query::ConfirmError::Store(se) => return NetError::store(se), + rbitcoin_query::ConfirmError::Rules(rbitcoin_consensus::ConsensusError::Internal(m)) => { + return NetError::store(m); + } + rbitcoin_query::ConfirmError::Rules(r) => r, + }; + let reason = rbitcoin_consensus::block_reject_reason(rules); rbitcoin_log::info!( "{}", rbitcoin_consensus::block_reject_log_line(header.block_hash(), &reason) ); - NetError::Consensus(e.to_string()) + NetError::Consensus(rules.to_string()) } /// Inputs a connected block spends, excluding the coinbase null prevout. @@ -1349,7 +1356,7 @@ impl ChainHub { return Err("bad-prevblk".into()); } if let Some(ph) = self.query.height_of_hash(&prev_bytes).ok().flatten() { - if let Ok(mtp) = rbitcoin_consensus::median_time_past(self.query.as_ref(), ph) { + if let Ok(mtp) = rbitcoin_query::median_time_past(self.query.as_ref(), ph) { if header.time <= mtp { return Err("time-too-old".into()); } @@ -1500,7 +1507,7 @@ impl ChainHub { mtp, expected, ) - .map_err(|e| header_reject(header, &e)) + .map_err(|e| header_reject(header, &rbitcoin_query::ConfirmError::from(e))) } fn sync_parent_header( @@ -1689,7 +1696,7 @@ impl ChainHub { &self, stamped: PlanStampOutcome, pipeline: Option<&WireLoadPipeline>, - ) -> Result { + ) -> Result { consensus_load_from_plan( &self.query, &self.params, @@ -1698,7 +1705,7 @@ impl ChainHub { pipeline, &ScriptPreverified::new(), ) - .map_err(NetError::from_consensus) + .map_err(NetError::from_confirm) } /// Unified lookup+load from raw wire blocks (no Class-A wire rebuild). @@ -1713,7 +1720,7 @@ impl ChainHub { pub fn confirm_wire_load_phase( &self, blocks: &[(Height, Block)], - ) -> Result, NetError> { + ) -> Result, NetError> { self.confirm_wire_load_phase_pipelined(blocks, None) } @@ -1724,7 +1731,7 @@ impl ChainHub { &self, blocks: &[(Height, Block)], pipeline: Option<&WireLoadPipeline>, - ) -> Result, NetError> { + ) -> Result, NetError> { let Some(contig) = self.confirm_wire_contig(blocks, pipeline) else { return Ok(None); }; @@ -1736,20 +1743,20 @@ impl ChainHub { &ScriptPreverified::new(), pipeline, ) - .map_err(NetError::from_consensus)?; + .map_err(NetError::from_confirm)?; Ok(Some(ok)) } /// WRITE stage: structural + Class C + spend annotate (ordered). pub fn confirm_write(&self, batch: ScriptOkBatch) -> Result, NetError> { - let headers = batch.wire_headers().map_err(NetError::from_consensus)?; + let headers = batch.wire_headers().map_err(NetError::from_confirm)?; let meta: Vec<(u32, BlockHash)> = batch .heights_hashes() .into_iter() .map(|(h, raw)| (h, BlockHash::from_byte_array(raw))) .collect(); confirm_write_phase(&self.query, &self.params, self.milestone, batch) - .map_err(NetError::from_consensus)?; + .map_err(NetError::from_confirm)?; self.note_confirmed_tip(&meta, &headers)?; Ok(meta .iter() @@ -1763,11 +1770,9 @@ impl ChainHub { headers: &[Header], ) -> Result<(), NetError> { if headers.len() != need_meta.len() { - return Err(NetError::from_consensus( - rbitcoin_consensus::ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( - "invariant: wire headers length", - )), - )); + return Err(NetError::store(rbitcoin_store::StoreError::Corrupt( + "invariant: wire headers length", + ))); } if let Some(mp) = self.mempool() { mp.clear_recent_rejects(); @@ -1807,7 +1812,7 @@ impl ChainHub { /// Block time is `max(MTP+1, now)`. fn generate_block_time(&self, tip_h: u32, tip_time: u32) -> u32 { let now = self.clock.now_secs() as u32; - let mtp = rbitcoin_consensus::median_time_past(self.query.as_ref(), Height(tip_h)) + let mtp = rbitcoin_query::median_time_past(self.query.as_ref(), Height(tip_h)) .unwrap_or(tip_time); now.max(mtp.saturating_add(1)) } @@ -1951,7 +1956,7 @@ impl ChainHub { if let Err(e) = self.query.apply_sh_pending() { rbitcoin_log::warn!("generate: SH write-behind drain: {e}"); } - if let Err(e) = rbitcoin_consensus::build_indexes_released(&self.query) { + if let Err(e) = rbitcoin_query::build_indexes_released(&self.query) { rbitcoin_log::warn!("generate: index write-behind drain: {e}"); } Ok(hashes) @@ -2516,7 +2521,7 @@ impl ChainHub { &batch, )?; validate_header_on_parent(&self.params, Height(height), &b.header, mtp, expected) - .map_err(|e| header_reject(&b.header, &e)) + .map_err(|e| header_reject(&b.header, &rbitcoin_query::ConfirmError::from(e))) }; if let Err(e) = checked { return Err(connect_failed_for_header(b.block_hash().to_byte_array(), e)); @@ -3028,12 +3033,16 @@ impl ChainHub { } }) .map_err(|e| match e { - rbitcoin_consensus::ConsensusError::Cancelled => NetError::Cancelled, - rbitcoin_consensus::ConsensusError::Store(se) => { - rbitcoin_log::warn!("tip connect {hash} @ {height}: store fault: {se}"); - NetError::store(se) + rbitcoin_query::ConfirmError::Rules( + rbitcoin_consensus::ConsensusError::Cancelled, + ) => NetError::Cancelled, + rbitcoin_query::ConfirmError::Rules( + rbitcoin_consensus::ConsensusError::Internal(m), + ) => { + rbitcoin_log::warn!("tip connect {hash} @ {height}: pipeline fault: {m}"); + NetError::store(m) } - e => { + rbitcoin_query::ConfirmError::Rules(e) => { let reason = rbitcoin_consensus::block_reject_reason(&e); rbitcoin_log::info!( "{}", @@ -3045,6 +3054,10 @@ impl ChainHub { NetError::Consensus(reason) } } + rbitcoin_query::ConfirmError::Store(se) => { + rbitcoin_log::warn!("tip connect {hash} @ {height}: store fault: {se}"); + NetError::store(se) + } })?; self.header_tips.write().unwrap().remove(&hash); let t_mp = std::time::Instant::now(); @@ -3326,13 +3339,13 @@ pub fn check_block_proposal_with( } let height = tip_h.0.saturating_add(1); let expected = - rbitcoin_consensus::expected_next_bits(query, params, Height(height), block.header.time) + rbitcoin_query::expected_next_bits(query, params, Height(height), block.header.time) .map(|c| c.to_consensus()) .unwrap_or(tip_rec.bits); if block.header.bits.to_consensus() != expected { return Err("bad-diffbits".into()); } - let mtp = rbitcoin_consensus::median_time_past(query, tip_h).unwrap_or(tip_rec.timestamp); + let mtp = rbitcoin_query::median_time_past(query, tip_h).unwrap_or(tip_rec.timestamp); // Core is `<=` MTP. Proposal uses `<` so a template stamped at the // parent's mediantime+1 still validates after that parent is submitted // (new MTP often equals that stamp on an incrementing cache). @@ -3907,8 +3920,9 @@ mod tests { use bitcoin::{ Amount, CompactTarget, OutPoint, Sequence, Target, Transaction, TxIn, TxOut, Witness, }; - use rbitcoin_consensus::{confirm_scripts_phase, ChainParams, Milestone}; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_mempool::UtxoProvider; + use rbitcoin_query::confirm_scripts_phase; fn tmp_hub() -> (rbitcoin_query::testutil::TempDir, ChainHub) { super::tiny_regtest_hub_labeled("chain") @@ -4289,7 +4303,7 @@ mod tests { #[test] fn wire_load_skips_a_block_below_the_pipeline_floor() { - use rbitcoin_consensus::WireLoadPipeline; + use rbitcoin_query::WireLoadPipeline; let (dir, hub) = tmp_hub(); hub.ensure_genesis().unwrap(); @@ -5902,7 +5916,7 @@ mod tests { /// Regression: hub used store tip+1 only → Ok(None) "empty outcome" thrash. #[test] fn wire_prep_ahead_of_store_tip_with_pipeline() { - use rbitcoin_consensus::WireLoadPipeline; + use rbitcoin_query::WireLoadPipeline; let (dir, hub) = tmp_hub(); hub.ensure_genesis().unwrap(); @@ -6214,7 +6228,7 @@ mod tests { .block_at_height(0) .unwrap() .expect("genesis after ensure"); - rbitcoin_consensus::pad_empty_from( + rbitcoin_query::pad_empty_from( hub.query.as_ref(), &hub.params, genesis.block_hash(), @@ -6296,7 +6310,7 @@ mod tests { .block_at_height(0) .unwrap() .expect("genesis after ensure"); - let (_tip, _time, cbs) = rbitcoin_consensus::pad_empty_from( + let (_tip, _time, cbs) = rbitcoin_query::pad_empty_from( hub.query.as_ref(), &hub.params, genesis.block_hash(), @@ -6746,7 +6760,7 @@ mod tests { )); assert!(hub.held_body(&stale.block_hash()).is_some()); - rbitcoin_consensus::pad_empty_from( + rbitcoin_query::pad_empty_from( hub.query.as_ref(), &hub.params, b1.block_hash(), diff --git a/crates/rbitcoin-net/src/error.rs b/crates/rbitcoin-net/src/error.rs index aaf9ec7b6e..c242e7aabe 100644 --- a/crates/rbitcoin-net/src/error.rs +++ b/crates/rbitcoin-net/src/error.rs @@ -87,11 +87,19 @@ impl NetError { match e { rbitcoin_consensus::ConsensusError::Cancelled => NetError::Cancelled, rbitcoin_consensus::ConsensusError::BadPrev => NetError::BadPrev, - rbitcoin_consensus::ConsensusError::Store(se) => NetError::store(se), + // Pool-adapted store text. Not a block verdict. + rbitcoin_consensus::ConsensusError::Internal(m) => NetError::store(m), other => NetError::Consensus(other.to_string()), } } + pub fn from_confirm(e: rbitcoin_query::ConfirmError) -> Self { + match e { + rbitcoin_query::ConfirmError::Rules(r) => Self::from_consensus(r), + rbitcoin_query::ConfirmError::Store(se) => NetError::store(se), + } + } + /// `BLOCK_MUTATED`: the body may not be the one the header commits to, /// so the block hash must not be cached as failed. pub fn is_mutated(&self) -> bool { diff --git a/crates/rbitcoin-net/src/ibd/archive.rs b/crates/rbitcoin-net/src/ibd/archive.rs index 477b8f1547..f18ac7ec20 100644 --- a/crates/rbitcoin-net/src/ibd/archive.rs +++ b/crates/rbitcoin-net/src/ibd/archive.rs @@ -442,7 +442,7 @@ mod class_a_rehydrate_tests { let b = mine(tip, time + h * 600, h); hub.ensure_header(&b.header).unwrap(); let (rec, txs) = - rbitcoin_consensus::prepare_block_for_archive(&hub.query, &hub.params, &b).unwrap(); + rbitcoin_query::prepare_block_for_archive(&hub.query, &hub.params, &b).unwrap(); hub.query.commit_class_a_only(&rec, &txs).unwrap(); hashes.push(b.block_hash()); tip = b.block_hash(); diff --git a/crates/rbitcoin-net/src/ibd/confirm/mod.rs b/crates/rbitcoin-net/src/ibd/confirm/mod.rs index 7a15ed865c..efa91ff1c7 100644 --- a/crates/rbitcoin-net/src/ibd/confirm/mod.rs +++ b/crates/rbitcoin-net/src/ibd/confirm/mod.rs @@ -5,8 +5,8 @@ use super::status::LoopStats; use crate::chain::ChainHub; use bitcoin::hashes::Hash; use bitcoin::BlockHash; -use rbitcoin_consensus::WireLoadPipeline; use rbitcoin_log::{debug, info, warn}; +use rbitcoin_query::WireLoadPipeline; use std::cell::Cell; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}; @@ -392,12 +392,12 @@ impl ConfirmRejectClass { pub(crate) fn from_consensus(err: &rbitcoin_consensus::ConsensusError) -> Self { use rbitcoin_consensus::ConsensusError; - use rbitcoin_store::StoreError; match err { ConsensusError::Cancelled => Self::Cancelled, ConsensusError::BadPrev => Self::SoftWire, ConsensusError::BadBlock(r) if crate::chain::reject_is_mutated(r) => Self::SoftWire, ConsensusError::BadHeader("missing retarget first header") => Self::SoftWire, + ConsensusError::Internal(m) => Self::from_err_str(m), ConsensusError::BadBlock(_) | ConsensusError::BadTx(_) | ConsensusError::Script(_) @@ -406,11 +406,18 @@ impl ConfirmRejectClass { | ConsensusError::BadVersion(_) | ConsensusError::BadHeader(_) | ConsensusError::MissingPrevout => Self::ConsensusInvalid, - ConsensusError::Store(StoreError::Cancelled(_)) => Self::Cancelled, - ConsensusError::Store(e) if e.is_uring_session_fault() => Self::EngineFault, - ConsensusError::Store(StoreError::Io { .. }) => Self::EngineFault, - ConsensusError::Store(StoreError::Corrupt(m)) => Self::from_err_str(m), - ConsensusError::Store(_) => Self::EngineFault, + } + } + + pub(crate) fn from_confirm(err: &rbitcoin_query::ConfirmError) -> Self { + use rbitcoin_query::ConfirmError; + use rbitcoin_store::StoreError; + match err { + ConfirmError::Rules(e) => Self::from_consensus(e), + ConfirmError::Store(e) if e.is_uring_session_fault() => Self::EngineFault, + ConfirmError::Store(StoreError::Io { .. }) => Self::EngineFault, + ConfirmError::Store(StoreError::Corrupt(m)) => Self::from_err_str(m), + ConfirmError::Store(_) => Self::EngineFault, } } @@ -463,10 +470,10 @@ impl ConfirmRejectClass { fn finish_connected_write_after_session_fault( query: &rbitcoin_query::Query, items: &[(u32, [u8; 32])], -) -> Result<(), rbitcoin_consensus::ConsensusError> { +) -> Result<(), rbitcoin_query::ConfirmError> { query.uring_recover_or_abort("ibd-confirm-write"); loop { - match rbitcoin_consensus::finish_post_commit_hashes(query, items) { + match rbitcoin_query::finish_post_commit_hashes(query, items) { Ok(()) => return Ok(()), Err(e) if e.is_uring_session_fault() => { query.uring_recover_or_abort("ibd-confirm-write"); @@ -749,9 +756,11 @@ impl LookupFaultPolicy { self.consecutive = 0; } - pub(crate) fn on_err(&mut self, e: &rbitcoin_consensus::ConsensusError) -> LookupFaultAction { - use rbitcoin_consensus::ConsensusError; - let backpressure = matches!(e, ConsensusError::Store(se) if se.is_io_backpressure()); + pub(crate) fn on_err(&mut self, e: &rbitcoin_query::ConfirmError) -> LookupFaultAction { + let backpressure = matches!( + e, + rbitcoin_query::ConfirmError::Store(se) if se.is_io_backpressure() + ); if backpressure { return LookupFaultAction::Ignore; } @@ -1637,7 +1646,7 @@ fn check_missing_parent(hub: &ChainHub, feed: &ConfirmFeed, block: &bitcoin::Blo } // Same HDD gate as lookup and load: this read scans `tx.head`. hub.query.store().spindle_wait(); - let read = rbitcoin_consensus::parent_missing_from_chain(&hub.query, block, stop); + let read = rbitcoin_query::parent_missing_from_chain(&hub.query, block, stop); if stop() || !ready() || hub.query.take_disconnect(&mut disconnects).is_some() { return ParentCheck::OffTip; } @@ -1655,14 +1664,14 @@ fn check_missing_parent(hub: &ChainHub, feed: &ConfirmFeed, block: &bitcoin::Blo /// to one block at a time. A lone block's miss takes the class of its /// [`ParentCheck`]; an engine fault goes back once, and a repeat halts IBD. fn stamp_reject_class( - e: &rbitcoin_consensus::ConsensusError, + e: &rbitcoin_query::ConfirmError, check: Option, ) -> ConfirmRejectClass { match check { Some(ParentCheck::Missing(_)) => ConfirmRejectClass::ConsensusInvalid, Some(ParentCheck::OffTip) => ConfirmRejectClass::Cascade, Some(ParentCheck::Engine) => ConfirmRejectClass::EngineFault, - None => ConfirmRejectClass::from_consensus(e), + None => ConfirmRejectClass::from_confirm(e), } } @@ -1692,14 +1701,14 @@ pub(crate) fn stamp_reject_operator_msg(err: &str, stats: &rbitcoin_query::Confi /// leftover is returned for the next write iteration (ordered scripts should /// never hit that path). fn drain_script_ok_write_queue( - first: rbitcoin_consensus::ScriptOkBatch, - rx: &std::sync::mpsc::Receiver, + first: rbitcoin_query::ScriptOkBatch, + rx: &std::sync::mpsc::Receiver, max_parts: usize, - mut on_extra: impl FnMut(&rbitcoin_consensus::ScriptOkBatch), + mut on_extra: impl FnMut(&rbitcoin_query::ScriptOkBatch), ) -> ( - rbitcoin_consensus::ScriptOkBatch, + rbitcoin_query::ScriptOkBatch, usize, - Option, + Option, ) { let mut batch = first; let mut parts = 1usize; @@ -1870,10 +1879,10 @@ pub(crate) fn spawn_confirm_engine( ) -> (std::thread::JoinHandle<()>, Arc) { let queues = ConfirmQueueDepths::new(); let caps = confirm_queue_caps(); - type ScriptsIn = (rbitcoin_consensus::LoadedBatch, u64); + type ScriptsIn = (rbitcoin_query::LoadedBatch, u64); let (mat_tx, mat_rx) = std::sync::mpsc::sync_channel::(caps.script); let (write_tx, write_rx) = - std::sync::mpsc::sync_channel::(caps.write); + std::sync::mpsc::sync_channel::(caps.write); let (load_tx, load_rx) = std::sync::mpsc::sync_channel::(caps.load); // A write fault that does not re-arm lookup: plan drops reserved fks + // last_loaded so re-lookup after Class A partial commit does not drift @@ -1895,7 +1904,7 @@ pub(crate) fn spawn_confirm_engine( info!("ibd: confirm write on dedicated OS thread"); let stats = hub_wb.query.confirm_stats_arc(); // Non-contig leftover already note_write_recv'd; write it next iter. - let mut leftover: Option = None; + let mut leftover: Option = None; loop { let t_recv = Instant::now(); let first = match leftover.take() { @@ -1951,7 +1960,7 @@ pub(crate) fn spawn_confirm_engine( .iter() .map(|&(h, raw)| (h, BlockHash::from_byte_array(raw))) .collect(); - match rbitcoin_consensus::confirm_write_phase_or_return( + match rbitcoin_query::confirm_write_phase_or_return( &hub_wb.query, &hub_wb.params, hub_wb.milestone, @@ -2011,7 +2020,7 @@ pub(crate) fn spawn_confirm_engine( confirm_thr_stats::add_write_work(&stats, t0.elapsed()); let mut err = e; let msg = err.to_string(); - if matches!(err, rbitcoin_consensus::ConsensusError::Cancelled) + if matches!(err, rbitcoin_query::ConfirmError::Rules(rbitcoin_consensus::ConsensusError::Cancelled)) || feed_wb.stopped() { info!("ibd: confirm write aborted: {msg}"); @@ -2124,7 +2133,7 @@ pub(crate) fn spawn_confirm_engine( warn!( "ibd: confirm write reject @ {height} batch_parts={parts}: {err}" ); - let class = ConfirmRejectClass::from_consensus(&err); + let class = ConfirmRejectClass::from_confirm(&err); let t_rearm = Instant::now(); let wave: Vec<(u32, BlockHash, &bitcoin::Block)> = meta .iter() @@ -2161,7 +2170,7 @@ pub(crate) fn spawn_confirm_engine( .spawn(move || { info!("ibd: confirm scripts on dedicated OS thread (publish waves; steal pool verifies)"); let stats = hub_sc.query.confirm_stats_arc(); - rbitcoin_consensus::drive_script_waves_with( + rbitcoin_query::drive_script_waves_with( &mat_rx, |batch, wait| { confirm_thr_stats::add_script_recv_wait(&stats, wait); @@ -2208,14 +2217,14 @@ pub(crate) fn spawn_confirm_engine( |e, meta, dropped| { confirm_thr_stats::add_script_work(&stats, Duration::ZERO); let msg = e.to_string(); - if matches!(e, rbitcoin_consensus::ConsensusError::Cancelled) + if matches!(e, rbitcoin_query::ConfirmError::Rules(rbitcoin_consensus::ConsensusError::Cancelled)) || feed_sc.stopped() { info!("ibd: confirm scripts aborted: {msg}"); return false; } let session_fault = e.is_uring_session_fault(); - let class = ConfirmRejectClass::from_consensus(&e); + let class = ConfirmRejectClass::from_confirm(&e); // A retried, engine-faulted, or session-faulted wave goes // back with the batches dropped behind it; a one-block // verdict drops its block. @@ -2447,7 +2456,7 @@ pub(crate) fn spawn_confirm_engine( Ok(s) => s, Err(e) => { let msg = e.to_string(); - if matches!(e, rbitcoin_consensus::ConsensusError::Cancelled) + if matches!(e, rbitcoin_query::ConfirmError::Rules(rbitcoin_consensus::ConsensusError::Cancelled)) || feed_load.stopped() { drop(mat_tx); @@ -2458,7 +2467,9 @@ pub(crate) fn spawn_confirm_engine( let session_fault = e.is_uring_session_fault(); let parent_check = match (&e, wire_batch.as_slice()) { ( - rbitcoin_consensus::ConsensusError::MissingPrevout, + rbitcoin_query::ConfirmError::Rules( + rbitcoin_consensus::ConsensusError::MissingPrevout, + ), [(_, _, w)], ) => { let t_check = Instant::now(); @@ -2778,11 +2789,11 @@ pub(crate) fn spawn_confirm_engine( } else { remaining .saturating_mul(run_max) - .min(rbitcoin_consensus::BQ_RESOLVE_WAVE_MAX_BLOCKS) + .min(rbitcoin_query::BQ_RESOLVE_WAVE_MAX_BLOCKS) }; let max_inputs = (remaining as u32) .saturating_mul(confirm_batch_max_inputs()) - .min(rbitcoin_consensus::BQ_RESOLVE_WAVE_MAX_INPUTS); + .min(rbitcoin_query::BQ_RESOLVE_WAVE_MAX_INPUTS); let wave_h = hub .query .block_queue_unresolved_heights(path_lo, &skip, max_blocks); @@ -2882,7 +2893,7 @@ pub(crate) fn spawn_confirm_engine( // Take before the send: a take that loses to a // re-arm leaves its rows queued and is not sent. if !matches!( - rbitcoin_consensus::take_wave_items_for_load( + rbitcoin_query::take_wave_items_for_load( &hub.query, &batch.items, taken_gen, @@ -3057,10 +3068,10 @@ pub(super) fn lookup_bulk_wave( heights: &[u32], max_blocks: usize, max_inputs: u32, -) -> Result { +) -> Result { query.store().spindle_wait(); note_confirm_bulk_read(); - rbitcoin_consensus::confirm_bq_resolve_wave_capped( + rbitcoin_query::confirm_bq_resolve_wave_capped( query, params, milestone, heights, max_blocks, max_inputs, ) } @@ -3070,12 +3081,12 @@ pub(super) fn load_bulk_stamp( query: &rbitcoin_query::Query, params: &rbitcoin_consensus::ChainParams, milestone: rbitcoin_consensus::Milestone, - blocks: &[rbitcoin_consensus::WireBlockIn], - pipeline: Option<&rbitcoin_consensus::WireLoadPipeline>, -) -> Result { + blocks: &[rbitcoin_query::WireBlockIn], + pipeline: Option<&rbitcoin_query::WireLoadPipeline>, +) -> Result { query.store().spindle_wait(); note_confirm_bulk_read(); - rbitcoin_consensus::confirm_wire_lookup_stamp(query, params, milestone, blocks, pipeline) + rbitcoin_query::confirm_wire_lookup_stamp(query, params, milestone, blocks, pipeline) } #[cfg(test)] diff --git a/crates/rbitcoin-net/src/ibd/confirm/tests.rs b/crates/rbitcoin-net/src/ibd/confirm/tests.rs index 3dc24e036b..46dbee5cf3 100644 --- a/crates/rbitcoin-net/src/ibd/confirm/tests.rs +++ b/crates/rbitcoin-net/src/ibd/confirm/tests.rs @@ -71,7 +71,7 @@ fn spindle_hold_blocks_lookup_and_load_bulk_reads() { #[test] fn index_startup_gap_matches_one_write_drain() { assert_eq!( - rbitcoin_consensus::INDEX_STARTUP_GAP_HEIGHTS as usize, + rbitcoin_query::INDEX_STARTUP_GAP_HEIGHTS as usize, write_drain_max_parts(write_queue_cap()) * CONFIRM_RUN_MAX_BLOCKS ); } @@ -1527,22 +1527,27 @@ fn plan_epoch_stale_after_clear() { #[test] fn write_session_fault_is_engine_fault() { - use rbitcoin_consensus::ConsensusError; use rbitcoin_store::StoreError; - let undrained = ConsensusError::Store(StoreError::Corrupt("invariant: io_uring undrained")); + let undrained = + rbitcoin_query::ConfirmError::Store(StoreError::Corrupt("invariant: io_uring undrained")); assert_eq!( - super::ConfirmRejectClass::from_consensus(&undrained), + super::ConfirmRejectClass::from_confirm(&undrained), super::ConfirmRejectClass::EngineFault ); - let leftover = ConsensusError::Store(StoreError::Corrupt("invariant: io_uring leftover cqe")); + let leftover = rbitcoin_query::ConfirmError::Store(StoreError::Corrupt( + "invariant: io_uring leftover cqe", + )); assert_eq!( - super::ConfirmRejectClass::from_consensus(&leftover), + super::ConfirmRejectClass::from_confirm(&leftover), super::ConfirmRejectClass::EngineFault ); - let io = ConsensusError::Store(StoreError::io("/tmp/x", std::io::Error::other("disk"))); + let io = rbitcoin_query::ConfirmError::Store(StoreError::io( + "/tmp/x", + std::io::Error::other("disk"), + )); assert_eq!( - super::ConfirmRejectClass::from_consensus(&io), + super::ConfirmRejectClass::from_confirm(&io), super::ConfirmRejectClass::EngineFault ); } @@ -1626,13 +1631,15 @@ fn requeue_on_uring_recover_rearms_lookup_and_offers_the_wave_back() { #[test] fn lookup_fault_policy_io_halt() { use super::{LookupFaultAction, LookupFaultPolicy}; - use rbitcoin_consensus::ConsensusError; use rbitcoin_store::StoreError; let mut p = LookupFaultPolicy::default(); - let bp = ConsensusError::Store(StoreError::BudgetFull("io_uring SQ")); + let bp = rbitcoin_query::ConfirmError::Store(StoreError::BudgetFull("io_uring SQ")); assert_eq!(p.on_err(&bp), LookupFaultAction::Ignore); - let io = ConsensusError::Store(StoreError::io("/tmp/x", std::io::Error::other("disk"))); + let io = rbitcoin_query::ConfirmError::Store(StoreError::io( + "/tmp/x", + std::io::Error::other("disk"), + )); for _ in 0..7 { assert_eq!(p.on_err(&io), LookupFaultAction::Warn); } @@ -1697,10 +1704,9 @@ fn ibd_confirm_pin_fault() { use bitcoin::script::ScriptBuf; use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; - use rbitcoin_consensus::{ - mine_empty_regtest, mine_regtest_paying, pad_empty_from, ChainParams, - }; + use rbitcoin_consensus::{mine_empty_regtest, mine_regtest_paying, ChainParams}; use rbitcoin_primitives::Height; + use rbitcoin_query::pad_empty_from; use rbitcoin_query::testutil::FixtureChain as _; use rbitcoin_query::{ArchiveWritePlan, TxApply}; use rbitcoin_store::{HeaderRecord, InputRecord, OutputRecord, TxRecord}; diff --git a/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs b/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs index 1dfcbf6a13..7a5ef0b38f 100644 --- a/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs +++ b/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs @@ -144,7 +144,9 @@ fn confirm_reject_class_matches_substring_table() { ConfirmRejectClass::Cancelled ); assert_eq!( - ConfirmRejectClass::from_consensus(&ConsensusError::from(StoreError::Cancelled("stop"))), + ConfirmRejectClass::from_confirm(&rbitcoin_query::ConfirmError::from( + StoreError::Cancelled("stop"), + )), ConfirmRejectClass::Cancelled ); assert_eq!( @@ -152,21 +154,21 @@ fn confirm_reject_class_matches_substring_table() { ConfirmRejectClass::ConsensusInvalid ); assert_eq!( - ConfirmRejectClass::from_consensus(&ConsensusError::Store(StoreError::Corrupt( - "tx put_full_batch fk mismatch (plan not committed in order)" - ))), + ConfirmRejectClass::from_confirm(&rbitcoin_query::ConfirmError::Store( + StoreError::Corrupt("tx put_full_batch fk mismatch (plan not committed in order)") + )), ConfirmRejectClass::Cascade ); assert_eq!( - ConfirmRejectClass::from_consensus(&ConsensusError::Store(StoreError::Corrupt( - "invariant: spend annotate missing pin denserels/abs" - ))), + ConfirmRejectClass::from_confirm(&rbitcoin_query::ConfirmError::Store( + StoreError::Corrupt("invariant: spend annotate missing pin denserels/abs") + )), ConfirmRejectClass::EngineFault ); assert_eq!( - ConfirmRejectClass::from_consensus(&ConsensusError::Store(StoreError::Corrupt( - "invariant: io_uring undrained" - ))), + ConfirmRejectClass::from_confirm(&rbitcoin_query::ConfirmError::Store( + StoreError::Corrupt("invariant: io_uring undrained") + )), ConfirmRejectClass::EngineFault ); assert_eq!( @@ -1875,7 +1877,8 @@ fn wire_pad() -> &'static WirePad { use std::sync::OnceLock; static PAD: OnceLock = OnceLock::new(); PAD.get_or_init(|| { - use rbitcoin_consensus::{pad_empty_from, ChainParams}; + use rbitcoin_consensus::ChainParams; + use rbitcoin_query::pad_empty_from; let (dir, hub0) = crate::chain::tiny_regtest_hub_labeled("wire-pad"); hub0.query.enter_direct_index_mode().unwrap(); let hub = std::sync::Arc::new(hub0); @@ -1931,7 +1934,8 @@ impl WireRig { /// A fresh regtest pad like [`wire_pad`], on a store whose `tx.head` /// segments roll every 204 creates. fn new_small_head(label: &str, peers: usize) -> Self { - use rbitcoin_consensus::{pad_empty_from, ChainParams}; + use rbitcoin_consensus::ChainParams; + use rbitcoin_query::pad_empty_from; let dir = rbitcoin_query::testutil::TempDir::labeled(label).expect("small-head dir"); drop(rbitcoin_store::testutil::create_tiny_with_head_bits( dir.path(), diff --git a/crates/rbitcoin-net/src/ibd/path.rs b/crates/rbitcoin-net/src/ibd/path.rs index 9f67dd74fe..f9d95caa44 100644 --- a/crates/rbitcoin-net/src/ibd/path.rs +++ b/crates/rbitcoin-net/src/ibd/path.rs @@ -670,7 +670,7 @@ mod tests { Amount, Block, CompactTarget, OutPoint, Sequence, Target, Transaction, TxIn, TxOut, Witness, }; - use rbitcoin_consensus::prepare_block_for_archive; + use rbitcoin_query::prepare_block_for_archive; let (dir, hub) = crate::chain::tiny_regtest_hub_labeled("path-seed-arch"); hub.ensure_genesis().unwrap(); diff --git a/crates/rbitcoin-net/src/peer_tests.rs b/crates/rbitcoin-net/src/peer_tests.rs index c89d22fc3c..2238e8d0b4 100644 --- a/crates/rbitcoin-net/src/peer_tests.rs +++ b/crates/rbitcoin-net/src/peer_tests.rs @@ -4711,7 +4711,7 @@ fn headers_poll_expires_a_stale_tx_off_the_tokio_worker() { let (dir, hub) = crate::chain::tiny_regtest_hub_labeled("hdr-poll-expire"); hub.ensure_genesis().unwrap(); let tip = hub.tip_header().unwrap(); - let (_tip, _time, cbs) = rbitcoin_consensus::pad_empty_from( + let (_tip, _time, cbs) = rbitcoin_query::pad_empty_from( &hub.query, &ChainParams::regtest(), hub.tip_hash().unwrap(), diff --git a/crates/rbitcoin-net/src/tip_accept.rs b/crates/rbitcoin-net/src/tip_accept.rs index 3d877c4839..23611c3f73 100644 --- a/crates/rbitcoin-net/src/tip_accept.rs +++ b/crates/rbitcoin-net/src/tip_accept.rs @@ -1,7 +1,7 @@ //! Process-wide `tip-accept` thread: P2P/RPC connect off tokio workers. //! //! Jobs are 1-block (or one `accept_branch` run). Confirm still uses -//! [`rbitcoin_consensus::confirm_wire_run_preverified`] (lookup → load → +//! [`rbitcoin_query::confirm_wire_run_preverified`] (lookup → load → //! `rbtc-scripts-*` steal → write). Not the IBD body-queue pipeline. use std::future::Future; diff --git a/crates/rbitcoin-net/src/tx_relay.rs b/crates/rbitcoin-net/src/tx_relay.rs index 0afb058a74..2c3ae31f22 100644 --- a/crates/rbitcoin-net/src/tx_relay.rs +++ b/crates/rbitcoin-net/src/tx_relay.rs @@ -310,11 +310,8 @@ impl UtxoProvider for QueryUtxoProvider<'_> { if let Some(m) = self.meter_create_mtp { m.fetch_add(1, Ordering::Relaxed); } - rbitcoin_consensus::median_time_past( - self.query, - Height(create_height.saturating_sub(1)), - ) - .unwrap_or(0) + rbitcoin_query::median_time_past(self.query, Height(create_height.saturating_sub(1))) + .unwrap_or(0) }; ChainPrevout::Unspent(Coin { txout: TxOut { @@ -1767,7 +1764,7 @@ impl MempoolHub { /// Confirmed tip snapshot for mempool structural checks (height + BIP113 MTP). fn chain_tip_ctx(&self) -> ChainTipCtx { - use rbitcoin_consensus::median_time_past; + use rbitcoin_query::median_time_past; let height = self.query.tip_height().map(|h| h.0).unwrap_or(0); let fk = self.query.tip_header_fk().ok().flatten(); if let Some(fk) = fk { @@ -5325,9 +5322,10 @@ mod tests { /// prevout unavailable. `Ok(false)` on height 0 is not a non-coinbase coin. #[test] fn coinbase_table_error_makes_the_prevout_unavailable() { - use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_mempool::{ChainPrevout, UtxoProvider}; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; let dir = tmp(); let q = Query::open_or_create_tiny(&dir).unwrap(); @@ -5369,8 +5367,9 @@ mod tests { let (store, owned_q, owned_cbs) = copy_maturity_pad(3); let q = &owned_q; let cbs = owned_cbs.as_slice(); - use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; use rbitcoin_store::script_hash; let params = ChainParams::regtest(); @@ -6062,10 +6061,9 @@ mod tests { /// drops it. The graph filter is what decides, including a tx that is gone. #[test] fn purge_confirmed_drops_relay_maps() { - use rbitcoin_consensus::{ - accept_and_connect_block, mine_regtest_paying, ChainParams, Milestone, - }; + use rbitcoin_consensus::{mine_regtest_paying, ChainParams, Milestone}; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; let (store, q, cbs) = copy_maturity_pad(3); let params = ChainParams::regtest(); @@ -6828,15 +6826,16 @@ mod tests { } fn build_maturity_pad(n: u32) -> MaturityPad { - use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; let dir = tmp(); let q = Query::open_or_create_tiny(&dir).unwrap(); let params = ChainParams::regtest(); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); let last = n + params.coinbase_maturity(); - let (_tip, _t, cbs) = rbitcoin_consensus::pad_empty_from( + let (_tip, _t, cbs) = rbitcoin_query::pad_empty_from( &q, ¶ms, genesis.block_hash(), @@ -7824,14 +7823,15 @@ mod tests { #[test] fn fee_history_preload_reuses_heights_it_already_holds() { - use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; + use rbitcoin_consensus::{ChainParams, Milestone}; + use rbitcoin_query::accept_and_connect_block; let store_dir = tmp(); let q = Query::open_or_create_tiny(&store_dir).unwrap(); let params = ChainParams::regtest(); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); - rbitcoin_consensus::pad_empty_from( + rbitcoin_query::pad_empty_from( &q, ¶ms, genesis.block_hash(), @@ -7879,14 +7879,15 @@ mod tests { #[test] fn fee_history_file_survives_a_restart_and_drops_heights_off_the_chain() { - use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; + use rbitcoin_consensus::{ChainParams, Milestone}; + use rbitcoin_query::accept_and_connect_block; let store_dir = tmp(); let q = Query::open_or_create_tiny(&store_dir).unwrap(); let params = ChainParams::regtest(); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); - let (tip, tip_time, _) = rbitcoin_consensus::pad_empty_from( + let (tip, tip_time, _) = rbitcoin_query::pad_empty_from( &q, ¶ms, genesis.block_hash(), @@ -7904,7 +7905,7 @@ mod tests { assert_eq!(first.total_targets, 11); assert_eq!(first.ready_targets, 0); // a connect after the preload goes to the journal - rbitcoin_consensus::pad_empty_from(&q, ¶ms, tip, tip_time, 6, 6, 0); + rbitcoin_query::pad_empty_from(&q, ¶ms, tip, tip_time, 6, 6, 0); hub.note_block_fee_history(Height(6)); let journaled = fee_history_file::load(&mp_dir).unwrap().unwrap(); assert_eq!( diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index d0b4c7855c..53fa2a8399 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -277,7 +277,7 @@ pub fn run_node(config: NodeConfig) -> Result { config.store_layout(), config.check_blocks_window(), )?; - rbitcoin_consensus::replay_spend_annotations(&query) + rbitcoin_query::replay_spend_annotations(&query) .map_err(|e| NodeError::Init(format!("spend annotation replay: {e}")))?; Ok(NodeHandle { config, @@ -321,7 +321,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { ); } apply_startup_index_mode(&handle.query, &config, params.taproot_height())?; - rbitcoin_consensus::prepare_live_indexes(&handle.query) + rbitcoin_query::prepare_live_indexes(&handle.query) .map_err(|e| crate::error::NodeError::Init(format!("index startup repair failed: {e}")))?; let bind = config.listen.start_p2p_bind(config.network); @@ -724,14 +724,14 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { tip_follow_ready = gates.tip_follow_ready; sh_tip_ready = gates.sh_tip_ready; if tip_follow_ready && !shutdown.requested() { - let index_entry = rbitcoin_consensus::index_tip_entry(&node.hub.query); + let index_entry = rbitcoin_query::index_tip_entry(&node.hub.query); if index_entry.advertise_filters { info!("blockfilter: already at tip; advertising NODE_COMPACT_FILTERS"); rbitcoin_net::set_compact_filters_service(true); } if index_entry.spawn { let advertise_later = !index_entry.advertise_filters; - index_writebehind = Some(rbitcoin_consensus::spawn_index_writebehind( + index_writebehind = Some(rbitcoin_query::spawn_index_writebehind( Arc::clone(&node.hub.query), Arc::clone(&shutdown.flag), { diff --git a/crates/rbitcoin-query/Cargo.toml b/crates/rbitcoin-query/Cargo.toml index 7ab42549e3..519346bec8 100644 --- a/crates/rbitcoin-query/Cargo.toml +++ b/crates/rbitcoin-query/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true description = "Query API over the rbitcoin store" [dependencies] +rbitcoin-consensus = { workspace = true } rbitcoin-primitives = { workspace = true } rbitcoin-store = { workspace = true } rbitcoin-log = { workspace = true } diff --git a/crates/rbitcoin-query/src/batch_parents.rs b/crates/rbitcoin-query/src/batch_parents.rs index d5ccb531b4..9c0f65d7ce 100644 --- a/crates/rbitcoin-query/src/batch_parents.rs +++ b/crates/rbitcoin-query/src/batch_parents.rs @@ -52,6 +52,12 @@ pub struct PinnedOuts { vout: u32, } +impl rbitcoin_consensus::block::PinParts for PinOuts { + fn parts(&self, vout: u32) -> Option<(u64, &[u8])> { + self.get_parts(vout) + } +} + impl SharedPrevoutScript { /// Amount and script for a pinned vout. `None` for [`Self::Wire`] (the /// block lives on the script job) and when the vout is absent. @@ -61,6 +67,18 @@ impl SharedPrevoutScript { Self::Pinned(p) => p.outs.get_parts(p.vout), } } + + /// Same allocation the pin published. The script bytes are not copied. + pub fn into_shared_prevout(self) -> rbitcoin_consensus::block::SharedPrevout { + use rbitcoin_consensus::block::SharedPrevout; + match self { + Self::Wire { tx_index, vout } => SharedPrevout::Wire { tx_index, vout }, + Self::Pinned(p) => SharedPrevout::Pinned { + outs: p.outs, + vout: p.vout, + }, + } + } } const CB_UNKNOWN: u8 = 0; diff --git a/crates/rbitcoin-query/src/bip68_store_tests.rs b/crates/rbitcoin-query/src/bip68_store_tests.rs new file mode 100644 index 0000000000..ffec276188 --- /dev/null +++ b/crates/rbitcoin-query/src/bip68_store_tests.rs @@ -0,0 +1,273 @@ +//! BIP68 checks that read a confirmed median from the store. +#![cfg(test)] + +use bitcoin::absolute::LockTime; +use bitcoin::script::ScriptBuf; +use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; +use rbitcoin_consensus::ValidationContext; + +fn bare_tx(version: i32, lock_time: LockTime, sequence: Sequence) -> Transaction { + Transaction { + version: bitcoin::transaction::Version(version), + lock_time, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + } +} + +#[test] +fn bip68_height_zero_time_lock_uses_the_median() { + let type_flag = 1u32 << 22; + let mtp = 1_231_006_505u32; + // The median comes from the confirmed genesis header through + // `structural_bip68`, not from a caller-supplied MTP slice. + let (_dir, query) = crate::testutil::tiny_query_labeled("bip68-h0"); + let header_fk = query + .store() + .put_header(&rbitcoin_store::HeaderRecord { + timestamp: mtp, + hash: [0x11; 32], + ..Default::default() + }) + .unwrap(); + query + .store() + .confirmed + .set(rbitcoin_primitives::Height(0), header_fk) + .unwrap(); + query.store().rebuild_height_fence().unwrap(); + let params = Box::leak(Box::new(rbitcoin_consensus::ChainParams::regtest())); + let ctx = ValidationContext::at( + params, + rbitcoin_primitives::Height(1), + rbitcoin_consensus::Milestone::NONE, + ); + let create = rbitcoin_primitives::Fk(1); + let mut create_heights = crate::FkMap::default(); + create_heights.insert(create, 0); + let spends = [([0u8; 32], 0u32, rbitcoin_primitives::Fk::NULL, create, 0u32)]; + let block = height_zero_timelock_block(type_flag); + crate::block_pipeline::structural_bip68( + &query, + &block, + &ctx, + &spends, + &create_heights, + &mut crate::U32Map::default(), + ) + .expect("height 0 time lock uses the genesis median"); + let too_far = height_zero_timelock_block(type_flag | 1); + let err = crate::block_pipeline::structural_bip68( + &query, + &too_far, + &ctx, + &spends, + &create_heights, + &mut crate::U32Map::default(), + ) + .expect_err("512s relative to the genesis median is not final at height 1"); + assert!( + format!("{err}").contains("nonfinal"), + "structural path must apply the median, got {err}" + ); +} + +fn height_zero_timelock_block(sequence: u32) -> bitcoin::Block { + use bitcoin::block::{Header, Version}; + use bitcoin::hashes::Hash; + use bitcoin::{Block, BlockHash, CompactTarget, TxMerkleNode}; + let spend = bare_tx(2, LockTime::ZERO, Sequence::from_consensus(sequence)); + let coinbase = bare_tx(1, LockTime::ZERO, Sequence::MAX); + let mut block = Block { + header: Header { + version: Version::from_consensus(1), + prev_blockhash: BlockHash::from_byte_array([0; 32]), + merkle_root: TxMerkleNode::from_byte_array([0; 32]), + time: 1_231_006_505, + bits: CompactTarget::from_consensus(0x1d00_ffff), + nonce: 0, + }, + txdata: vec![coinbase, spend], + }; + block.header.merkle_root = block.compute_merkle_root().unwrap(); + block +} + +fn seq_tx(seqs: &[u32]) -> Transaction { + Transaction { + version: bitcoin::transaction::Version(2), + lock_time: LockTime::ZERO, + input: seqs + .iter() + .map(|seq| TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::from_consensus(*seq), + witness: Witness::new(), + }) + .collect(), + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + } +} + +fn csv_block(txs: Vec) -> bitcoin::Block { + use bitcoin::block::{Header, Version}; + use bitcoin::hashes::Hash; + use bitcoin::{Block, BlockHash, CompactTarget, TxMerkleNode}; + let mut block = Block { + header: Header { + version: Version::from_consensus(1), + prev_blockhash: BlockHash::from_byte_array([0; 32]), + merkle_root: TxMerkleNode::from_byte_array([0; 32]), + time: 1_231_006_505, + bits: CompactTarget::from_consensus(0x1d00_ffff), + nonce: 0, + }, + txdata: txs, + }; + block.header.merkle_root = block.compute_merkle_root().unwrap(); + block +} + +/// Three two-input txs must connect. `si * n_in` false-rejects the third. +#[test] +fn structural_bip68_multi_input_block_connects() { + let params = Box::leak(Box::new(rbitcoin_consensus::ChainParams::regtest())); + let ctx = ValidationContext::at( + params, + rbitcoin_primitives::Height(1), + rbitcoin_consensus::Milestone::NONE, + ); + let (_dir, query) = crate::testutil::tiny_query_labeled("bip68-multi"); + let coinbase = bare_tx(1, LockTime::ZERO, Sequence::MAX); + let spend = |seq: u32| seq_tx(&[seq, seq]); + let block = csv_block(vec![ + coinbase, + spend(u32::MAX), + spend(u32::MAX), + spend(u32::MAX), + ]); + let spends = vec![ + ( + [0u8; 32], + 0u32, + rbitcoin_primitives::Fk::NULL, + rbitcoin_primitives::Fk::NULL, + 0u32 + ); + 6 + ]; + let mut cache = crate::U32Map::default(); + cache.insert(0, 1_000); + crate::block_pipeline::structural_bip68( + &query, + &block, + &ctx, + &spends, + &crate::FkMap::default(), + &mut cache, + ) + .expect("a well-formed multi-input block passes the spend cursor"); +} + +/// Height-type CSV does not prefetch a median. A missing parent header must +/// still pass when the block's own previous median is already cached. +#[test] +fn structural_bip68_height_lock_skips_missing_coin_mtp() { + let params = Box::leak(Box::new(rbitcoin_consensus::ChainParams::regtest())); + let ctx = ValidationContext::at( + params, + rbitcoin_primitives::Height(6), + rbitcoin_consensus::Milestone::NONE, + ); + let (_dir, query) = crate::testutil::tiny_query_labeled("bip68-height-mtp"); + let create = rbitcoin_primitives::Fk(3); + let mut create_heights = crate::FkMap::default(); + create_heights.insert(create, 5); + let block = csv_block(vec![ + bare_tx(1, LockTime::ZERO, Sequence::MAX), + seq_tx(&[0]), + ]); + let spends = [([0u8; 32], 0u32, rbitcoin_primitives::Fk::NULL, create, 0u32)]; + let mut cache = crate::U32Map::default(); + cache.insert(5, 1_500_000); + crate::block_pipeline::structural_bip68( + &query, + &block, + &ctx, + &spends, + &create_heights, + &mut cache, + ) + .expect("height-type lock must not read the coin median"); +} + +/// BIP68 walks `txdata[1..]`. A coinbase whose relative lock would fail still connects. +#[test] +fn bip68_skips_a_coinbase_whose_relative_lock_would_fail() { + use bitcoin::hashes::Hash; + use rbitcoin_consensus::{ + mine_empty_regtest, prepare_regtest_candidate, ConsensusError, Milestone, + }; + use rbitcoin_primitives::Height; + + let (_dir, q) = crate::testutil::tiny_query_labeled("bip68-coinbase"); + let params = rbitcoin_consensus::ChainParams::regtest(); + let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); + crate::accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE) + .unwrap(); + let mut block = mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); + block.txdata[0].version = bitcoin::transaction::Version::TWO; + block.txdata[0].input[0].sequence = Sequence::from_consensus(200); + prepare_regtest_candidate(&mut block, genesis.block_hash(), genesis.header.time + 600); + crate::accept_and_connect_block(&q, ¶ms, Height(1), &block, Milestone::NONE) + .expect("coinbase sequence is not a BIP68 input"); + assert_eq!(q.tip_height(), Some(Height(1))); + + let params = Box::leak(Box::new(rbitcoin_consensus::ChainParams::regtest())); + let ctx = ValidationContext::at(params, Height(6), Milestone::NONE); + let (_dir, query) = crate::testutil::tiny_query_labeled("bip68-noncb"); + let create = rbitcoin_primitives::Fk(3); + let mut create_heights = crate::FkMap::default(); + create_heights.insert(create, 5); + let block = csv_block(vec![ + bare_tx(1, LockTime::ZERO, Sequence::MAX), + bare_tx(2, LockTime::ZERO, Sequence::from_consensus(200)), + ]); + let spends = [( + [0u8; 32], + 0u32, + rbitcoin_primitives::Fk::NULL, + create, + 0u32, + )]; + let mut cache = crate::U32Map::default(); + cache.insert(5, 1_500_000); + let err = crate::block_pipeline::structural_bip68( + &query, + &block, + &ctx, + &spends, + &create_heights, + &mut cache, + ) + .expect_err("the same lock rejects a non-coinbase"); + assert!( + matches!( + err, + crate::ConfirmError::Rules(ConsensusError::BadTx(s)) if s.contains("nonfinal") + ), + "{err:?}" + ); +} diff --git a/crates/rbitcoin-query/src/block_pipeline.rs b/crates/rbitcoin-query/src/block_pipeline.rs new file mode 100644 index 0000000000..0458e0f6ca --- /dev/null +++ b/crates/rbitcoin-query/src/block_pipeline.rs @@ -0,0 +1,1483 @@ +//! Assemble and spentness for confirm. This module loads rows and calls the +//! consensus rule functions. It does not decide a header, script, or spend +//! rule on its own. +use bitcoin::block::Block; +use bitcoin::hashes::Hash; +use bitcoin::ScriptBuf; +use bitcoin::{Amount, OutPoint, Transaction, TxOut}; +use rbitcoin_consensus::block::{ + JobPrevouts, PendingCreates, ScriptCheckJob, ScriptVerifyFlags, TxidMap, +}; +use rbitcoin_consensus::ChainParams; +use rbitcoin_primitives::Height; +use std::hash::BuildHasherDefault; +use std::sync::Arc; +use std::time::Instant; + +use crate::{ConfirmError, FkMap, Query, U32Map, U64Map}; + +#[cfg(test)] +pub(crate) mod test_phase { + use std::cell::Cell; + thread_local! { + static TL_COLD_WHY: Cell<(u64, u64, u64, u64)> = const { Cell::new((0, 0, 0, 0)) }; + static TL_BATCH_N: Cell = const { Cell::new(0) }; + static TL_COLD_N: Cell = const { Cell::new(0) }; + } + #[inline] + pub fn tl_note_cold_why_txid_mismatch() { + TL_COLD_WHY.with(|c| { + let (a, b, c2, d) = c.get(); + c.set((a, b, c2 + 1, d)); + }); + TL_COLD_N.with(|c| c.set(c.get() + 1)); + } + #[inline] + pub fn tl_note_cold_why_vout_miss() { + TL_COLD_WHY.with(|c| { + let (a, b, c2, d) = c.get(); + c.set((a, b, c2, d + 1)); + }); + TL_COLD_N.with(|c| c.set(c.get() + 1)); + } + #[inline] + pub fn tl_note_batch_hit() { + TL_BATCH_N.with(|c| c.set(c.get() + 1)); + } + #[inline] + pub fn sample_tl_assemble_cold_why_and_reset() -> (u64, u64, u64, u64) { + TL_COLD_WHY.with(|c| c.replace((0, 0, 0, 0))) + } + #[inline] + pub fn sample_tl_batch_cold_n_and_reset() -> (u64, u64) { + let batch = TL_BATCH_N.with(|c| c.replace(0)); + let cold = TL_COLD_N.with(|c| c.replace(0)); + (batch, cold) + } +} + +/// Block-local prevout path counts; the confirm pipeline flushes once. +#[derive(Default)] +pub(crate) struct AsmPrevoutAcc { + in_n: u64, + same_n: u64, + batch_n: u64, + cold_n: u64, + cold_null_fk_n: u64, + cold_not_pin_n: u64, + cold_txid_mismatch_n: u64, + cold_vout_miss_n: u64, +} + +impl AsmPrevoutAcc { + pub(crate) fn flush(&self, stats: &crate::ConfirmStats) { + crate::note_confirm(&stats.asm_in_n, self.in_n); + crate::note_confirm(&stats.asm_prev_same_n, self.same_n); + crate::note_confirm(&stats.asm_prev_batch_n, self.batch_n); + crate::note_confirm(&stats.asm_prev_cold_n, self.cold_n); + crate::note_confirm(&stats.asm_prev_cold_null_fk_n, self.cold_null_fk_n); + crate::note_confirm(&stats.asm_prev_cold_not_pin_n, self.cold_not_pin_n); + crate::note_confirm( + &stats.asm_prev_cold_txid_mismatch_n, + self.cold_txid_mismatch_n, + ); + crate::note_confirm(&stats.asm_prev_cold_vout_miss_n, self.cold_vout_miss_n); + } +} + +pub(crate) struct AssembleMeters { + pub prev_ns: u64, + pub job_ns: u64, + pub acc: AsmPrevoutAcc, +} + +#[allow(clippy::too_many_arguments)] // call-site args stay unbundled +#[allow(clippy::type_complexity)] // packed row / pin / script-hash tuple is the on-disk shape +/// Sequential assemble: resolve prevout **content**, build script jobs, collect spends. +/// +/// Confirm IBD path: no durable spentness / maturity / BIP68 create-height +/// resolution — those run in [`structural_validate_spends`] after scripts (load +/// must not walk create height per parent). Absolute nLockTime finality +/// (BIP113 MTP of prev block) still runs here — it only needs header MTP. +/// +/// `pending_spent`: pack-local double-spend (early reject before scripts). +/// `pending_creates`: pack-local `txid → create_fk` (not per-vout). +/// Same-block outs use `txid_index` (this block, `pj < ti`); meters flush +/// once per block (no per-input Instant / atomics). +/// +/// Prevouts resolve from per-batch [`crate::BatchParents`] + +/// [`crate::SpendEdges`]. Optimistic miss is `invariant:` (no head recover). +/// +/// Returns `(script_jobs, spends, fees, tx_fees, meters)`. `fees` is the block sum +/// for the coinbase subsidy check. `tx_fees` is one satoshi fee per tx +/// (coinbase is 0) for the `txstat` stamp. `meters` is flushed by the caller. +/// +/// `prev_mtp` / `block_hash` / `bip16_active` must be computed **once** by the +/// caller (assemble_run header window) — no re-walk of headers and no rehash. +/// +/// `wire`: when `Some`, script jobs share that Arc (no `Transaction` clone). +/// When `None` (unit-test connect), jobs own a deep clone of each non-cb tx. +/// `build_script_jobs` is the caller's milestone decision. +pub fn assemble_block_prevouts( + block: &Block, + ctx: &rbitcoin_consensus::ValidationContext<'_>, + archived_tx_fks: Option<&[rbitcoin_primitives::Fk]>, + pending_spent: &mut rbitcoin_consensus::OutPointSet, + pending_creates: &mut PendingCreates, + batch_parents: &crate::BatchParents, + spend_edges: &crate::SpendEdges, + create_txids: &[[u8; 32]], + prev_mtp: u32, + block_hash: &[u8; 32], + bip16_active: bool, + wire: Option<&Arc>, + pres: Option<&Arc<[rbitcoin_consensus::TxPrecompute]>>, + build_script_jobs: bool, +) -> Result< + ( + Vec, + Vec<( + [u8; 32], + u32, + rbitcoin_primitives::Fk, + rbitcoin_primitives::Fk, + u32, + )>, + i64, + Vec, + AssembleMeters, + ), + ConfirmError, +> { + assemble_prevout_guards(block, archived_tx_fks, create_txids)?; + // Caller-supplied BIP16 must match hash+prev_mtp (no silent re-resolve). + debug_assert_eq!( + bip16_active, + rbitcoin_consensus::block::bip16_active_from_prev_mtp( + ctx.params, + ctx.height.0, + block_hash, + prev_mtp + ) + ); + let flags = + rbitcoin_consensus::block::ScriptVerifyFlags::consensus_at(ctx, block_hash, bip16_active); + + let n_tx = block.txdata.len(); + let mut txid_index: TxidMap = + TxidMap::with_capacity_and_hasher(n_tx, Default::default()); + for (i, id) in create_txids.iter().enumerate() { + txid_index.insert(*id, i); + } + let mut acc = AsmPrevoutAcc::default(); + let mut clk_job = 0u64; + let mut fees = 0i64; + let mut tx_fees = vec![0u64; n_tx]; + let mut script_jobs: Vec = if build_script_jobs { + Vec::with_capacity(n_tx.saturating_sub(1)) + } else { + Vec::new() + }; + let mut block_sigops_cost = match pres.and_then(|p| p.first()) { + Some(p) => p.sigops.saturating_mul(4), + None => rbitcoin_consensus::block::legacy_sigop_count(&block.txdata[0]).saturating_mul(4), + }; + let mut spends: Vec<( + [u8; 32], + u32, + rbitcoin_primitives::Fk, + rbitcoin_primitives::Fk, + u32, + )> = Vec::with_capacity(n_tx.saturating_mul(2)); + + let t_loop = Instant::now(); + + let lock_time_cutoff = assemble_lock_time_cutoff(ctx, block, prev_mtp); + + for (ti, tx) in block.txdata.iter().enumerate() { + let spend_fk = archived_tx_fks.map(|fks| fks[ti]); + let txid = create_txids[ti]; + + rbitcoin_consensus::block::reject_non_final(tx, ctx.height.0, lock_time_cutoff)?; + rbitcoin_consensus::block::reject_tx_without_outputs(tx)?; + if ti > 0 { + tx_fees[ti] = assemble_non_cb_tx( + ctx.params, + block, + tx, + ti, + txid, + spend_fk, + flags, + bip16_active, + wire, + pres, + &txid_index, + pending_spent, + pending_creates, + batch_parents, + spend_edges, + &mut acc, + &mut script_jobs, + &mut spends, + &mut fees, + &mut block_sigops_cost, + build_script_jobs, + &mut clk_job, + )?; + } + + let create_fk = spend_fk.unwrap_or(rbitcoin_primitives::Fk::NULL); + if !create_fk.is_null() { + pending_creates.insert(txid, create_fk); + } + } + + let clk_prev = t_loop.elapsed().as_nanos() as u64; + Ok(( + script_jobs, + spends, + fees, + tx_fees, + AssembleMeters { + prev_ns: clk_prev, + job_ns: clk_job, + acc, + }, + )) +} + +fn assemble_prevout_guards( + block: &Block, + archived_tx_fks: Option<&[rbitcoin_primitives::Fk]>, + create_txids: &[[u8; 32]], +) -> Result<(), ConfirmError> { + rbitcoin_consensus::block::reject_assemble_block_shape( + block.txdata.len(), + create_txids.len(), + archived_tx_fks.map(|fks| fks.len()), + block.txdata.first().is_some_and(|tx| tx.is_coinbase()), + )?; + Ok(()) +} + +pub(crate) fn assemble_lock_time_cutoff( + ctx: &rbitcoin_consensus::ValidationContext<'_>, + block: &Block, + prev_mtp: u32, +) -> u32 { + // Height 0 has no MTP history, so the cutoff is the block time even + // when CSV is active. + if ctx.height.0 == 0 || !ctx.params.csv_active_at(ctx.height.0) { + block.header.time + } else { + prev_mtp + } +} + +#[allow(clippy::too_many_arguments)] // one non-coinbase assemble; call site stays unbundled +fn assemble_non_cb_tx( + params: &ChainParams, + block: &Block, + tx: &Transaction, + ti: usize, + txid: [u8; 32], + spend_fk: Option, + flags: ScriptVerifyFlags, + bip16_active: bool, + wire: Option<&Arc>, + pres: Option<&Arc<[rbitcoin_consensus::TxPrecompute]>>, + txid_index: &TxidMap, + pending_spent: &mut rbitcoin_consensus::OutPointSet, + pending_creates: &PendingCreates, + batch_parents: &crate::BatchParents, + spend_edges: &crate::SpendEdges, + acc: &mut AsmPrevoutAcc, + script_jobs: &mut Vec, + spends: &mut Vec<( + [u8; 32], + u32, + rbitcoin_primitives::Fk, + rbitcoin_primitives::Fk, + u32, + )>, + fees: &mut i64, + block_sigops_cost: &mut u64, + build_script_jobs: bool, + clk_job: &mut u64, +) -> Result { + rbitcoin_consensus::block::reject_tx_without_inputs(tx)?; + let (value_in, prevouts, tx_in_sigops) = assemble_non_cb_inputs( + params, + block, + tx, + ti, + spend_fk, + flags, + bip16_active, + txid_index, + pending_spent, + pending_creates, + batch_parents, + spend_edges, + wire, + acc, + spends, + build_script_jobs, + )?; + let tx_legacy_sigops = match pres.and_then(|p| p.get(ti)) { + Some(p) => p.sigops.saturating_mul(4), + None => rbitcoin_consensus::block::legacy_sigop_count(tx).saturating_mul(4), + }; + *block_sigops_cost = + rbitcoin_consensus::block::add_sigops(*block_sigops_cost, tx_legacy_sigops, tx_in_sigops)?; + let value_out = rbitcoin_consensus::block::assemble_tx_value_out(tx, ti, pres)?; + let fee = rbitcoin_consensus::block::fee_from_values(value_in, value_out)?; + let fee_sat = rbitcoin_consensus::block::fee_sat(fee)?; + *fees = rbitcoin_consensus::block::add_fee(*fees, fee)?; + if build_script_jobs { + let t_job = Instant::now(); + let mut job = match (wire, prevouts) { + (Some(w), rbitcoin_consensus::block::JobPrevouts::Shared(v)) => { + rbitcoin_consensus::block::ScriptCheckJob::from_parts( + txid, + rbitcoin_consensus::block::JobPrevouts::Shared(v), + rbitcoin_consensus::block::JobTx::shared(Arc::clone(w), ti), + flags, + ) + } + (None, rbitcoin_consensus::block::JobPrevouts::Owned(v)) => { + rbitcoin_consensus::block::ScriptCheckJob::with_txid(txid, v, tx.clone(), flags) + } + _ => { + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( + "invariant: script job prevouts", + ))); + } + }; + if let Some(ps) = pres { + if rbitcoin_consensus::block::should_use_pres(ti, ps.len()) { + job = job.with_pre_slice(Arc::clone(ps), ti); + } + } + script_jobs.push(job); + *clk_job = clk_job.saturating_add(t_job.elapsed().as_nanos() as u64); + } + Ok(fee_sat) +} + +#[allow(clippy::too_many_arguments)] // one non-coinbase input walk; call site stays unbundled +fn assemble_non_cb_inputs( + params: &ChainParams, + block: &Block, + tx: &Transaction, + ti: usize, + spend_fk: Option, + flags: ScriptVerifyFlags, + bip16_active: bool, + txid_index: &TxidMap, + pending_spent: &mut rbitcoin_consensus::OutPointSet, + pending_creates: &PendingCreates, + batch_parents: &crate::BatchParents, + spend_edges: &crate::SpendEdges, + wire: Option<&Arc>, + acc: &mut AsmPrevoutAcc, + spends: &mut Vec<( + [u8; 32], + u32, + rbitcoin_primitives::Fk, + rbitcoin_primitives::Fk, + u32, + )>, + build_script_jobs: bool, +) -> Result<(i64, JobPrevouts, u64), ConfirmError> { + let mut value_in = 0i64; + let mut prevouts = + if build_script_jobs && wire.is_some() { + rbitcoin_consensus::block::JobPrevouts::Shared(Vec::with_capacity(tx.input.len())) + } else { + rbitcoin_consensus::block::JobPrevouts::owned(Vec::with_capacity( + if build_script_jobs { tx.input.len() } else { 0 }, + )) + }; + let edges = spend_fk.and_then(|fk| fk.get().and_then(|id| spend_edges.get(&id))); + let mut tx_in_sigops = 0u64; + for (ii, input) in tx.input.iter().enumerate() { + let op = input.previous_output; + let key = (op.txid.to_byte_array(), op.vout); + // The genesis coinbase stays indexed for RPC and Electrum, but it is + // not a coin (Core `ConnectBlock` genesis early return). + rbitcoin_consensus::block::reject_genesis_coinbase_spend( + params.is_genesis_coinbase(&key.0), + )?; + rbitcoin_consensus::block::reject_duplicate_input(pending_spent.insert(key))?; + if let Some(&pj) = txid_index.get(&key.0) { + rbitcoin_consensus::block::reject_same_block_create(pj, ti)?; + } + let prev_fk = edges + .as_ref() + .and_then(|t| t.get(ii)) + .and_then(|e| e.create_fk.get().map(|_| e.create_fk)) + .or_else(|| pending_creates.get(&key.0).copied()); + let prev_out = resolve_prevout( + block, + op, + input, + prev_fk, + txid_index, + ti, + batch_parents, + bip16_active, + flags.witness_active, + build_script_jobs, + wire, + acc, + )?; + let create_fk = prev_out.create_fk; + tx_in_sigops = tx_in_sigops.saturating_add(prev_out.input_sigops); + spends.push(( + key.0, + key.1, + spend_fk.unwrap_or(rbitcoin_primitives::Fk::NULL), + create_fk, + ii as u32, + )); + value_in = + rbitcoin_consensus::block::add_value_in(value_in, prev_out.txout.value.to_sat())?; + if build_script_jobs { + match &mut prevouts { + rbitcoin_consensus::block::JobPrevouts::Shared(v) => { + let shared = prev_out.shared.ok_or(ConfirmError::Store( + rbitcoin_store::StoreError::Corrupt( + "invariant: wire confirm prevout shares script bytes", + ), + ))?; + v.push(shared); + } + rbitcoin_consensus::block::JobPrevouts::Owned(v) => v.push(prev_out.txout), + } + } + } + Ok((value_in, prevouts, tx_in_sigops)) +} + +/// Local wall times for one block's structural pass (write path diagnostics). +/// +/// Measured with `Instant` — **not** deltas of window atomics (those race with +/// `sample_and_reset` mid-batch and produced false `spent=0` on slow writes). +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct StructuralPhaseNs { + pub spent_ns: u64, + /// Pin abs collect + bulk on-disk 8-byte spender meta pread. + pub spent_abs_ns: u64, + /// `is_confirmed_strong_at` on non-null fields (still durable authority). + pub spent_strong_ns: u64, + /// Cold unspent_create_vouts / null-create probes. + pub spent_cold_ns: u64, + /// pending_spent order gate (CPU). + pub spent_pending_ns: u64, + pub create_h_ns: u64, + pub bip68_ns: u64, +} + +/// Post-script structural checks: durable spentness, maturity, BIP68, coinbase subsidy. +/// +/// Runs in height order on the write path (after scripts). `pending_spent` is +/// write-local across a multi-height run. +/// +/// **BIP68** create-height lives here (not optimistic load assemble) so confirm +/// load does not walk create height for every parent. Heights: bulk fence. +/// Coin MTP only for time-type relative locks on version ≥2 txs (v1 skipped). +/// +/// Write-path spend annotate slots: abs edge plus the meta structural already read. +/// +/// Filled during spentness and passed to annotate. No second edge copy. +#[derive(Clone, Debug, Default)] +pub(crate) struct AnnotateSlots { + pub abs_edges: Vec<( + u64, + rbitcoin_primitives::Fk, + u32, + rbitcoin_primitives::Fk, + u32, + )>, + pub known: Vec<(rbitcoin_primitives::Fk, u8, u32)>, +} + +impl AnnotateSlots { + pub(crate) fn push( + &mut self, + edge: ( + u64, + rbitcoin_primitives::Fk, + u32, + rbitcoin_primitives::Fk, + u32, + ), + known: (rbitcoin_primitives::Fk, u8, u32), + ) { + self.abs_edges.push(edge); + self.known.push(known); + } +} + +/// Per-block spentness scratch. Capacity stays across blocks in one write batch. +/// [`AnnotateSlots`] accumulate for the whole batch. +pub(crate) struct StructuralScratch { + abs_jobs: Vec, + unique_fks: Vec, + pub(crate) height_by_id: U64Map, + skip_n: std::collections::HashMap< + (u64, u32), + u32, + BuildHasherDefault, + >, + skip: OverlayMetaSkip, + disk_jobs: Vec, + abs_offs: Vec, + field_fks: Vec, + field_seen: crate::U64Set, + pub(crate) field_h_by_id: U64Map, + durable_spent: DurableSpentSet, + height_list: Vec, + create_height_by_fk: FkMap, + pub slots: AnnotateSlots, +} + +impl Default for StructuralScratch { + fn default() -> Self { + Self { + abs_jobs: Vec::new(), + unique_fks: Vec::new(), + height_by_id: U64Map::default(), + skip_n: std::collections::HashMap::with_hasher(Default::default()), + skip: OverlayMetaSkip::with_hasher(Default::default()), + disk_jobs: Vec::new(), + abs_offs: Vec::new(), + field_fks: Vec::new(), + field_seen: crate::U64Set::default(), + field_h_by_id: U64Map::default(), + durable_spent: DurableSpentSet::with_hasher(Default::default()), + height_list: Vec::new(), + create_height_by_fk: FkMap::default(), + slots: AnnotateSlots::default(), + } + } +} + +impl StructuralScratch { + /// Drop annotate slots from the previous write batch. Per-block buffers + /// stay for [`Self::begin_block`]; slots accumulate inside one batch. + pub(crate) fn begin_batch(&mut self) { + self.slots.abs_edges.clear(); + self.slots.known.clear(); + } + + fn begin_block(&mut self) { + self.abs_jobs.clear(); + self.unique_fks.clear(); + self.height_by_id.clear(); + self.skip_n.clear(); + self.skip.clear(); + self.disk_jobs.clear(); + self.abs_offs.clear(); + self.field_fks.clear(); + self.field_seen.clear(); + self.field_h_by_id.clear(); + self.durable_spent.clear(); + self.height_list.clear(); + self.create_height_by_fk.clear(); + } +} + +#[allow(clippy::too_many_arguments)] // call-site args stay unbundled +/// **Spentness:** pin denserels → abs + bulk 8-byte meta. Sparse durable-**spent** +/// set (not unspent). Missing abs / short meta is hard `Err`. **Multi-list** after +/// reorg annotate is a protocol cold walk (`has_confirmed_strong_spender_create`) +/// — not a hard fail (tip-follow reorgs leave multi flags by design). Emits +/// Annotate slots on `scratch` for pure-write annotate. +pub fn structural_validate_spends( + query: &Query, + block: &Block, + ctx: &rbitcoin_consensus::ValidationContext<'_>, + archived_tx_fks: Option<&[rbitcoin_primitives::Fk]>, + spends: &[( + [u8; 32], + u32, + rbitcoin_primitives::Fk, + rbitcoin_primitives::Fk, + u32, + )], + fees: i64, + pending_spent: &mut rbitcoin_consensus::OutPointSet, + batch_parents: &crate::BatchParents, + mtp_cache: &mut U32Map, + run_create_height: &RunCreateHeight, + class_a_wave: &ClassAWave, + scratch: &mut StructuralScratch, + abs_jobs: &[StructuralAbsJob], +) -> Result { + use std::time::Instant; + + scratch.begin_block(); + let maturity = ctx.params.coinbase_maturity(); + // BIP30's txid batch is inside `spent_ns` (signet runs it on every block). + let t_spent = Instant::now(); + reject_bip30_unspent_overwrite(query, block, ctx)?; + let t_abs = Instant::now(); + structural_abs_heights(query, run_create_height, scratch, abs_jobs)?; + let tip = query.tip_height().map(|h| h.0); + let mut spent_strong_ns = 0u64; + let mut multi_list_ns = 0u64; + if !scratch.abs_jobs.is_empty() { + fill_overlay_skip(spends, class_a_wave, scratch); + let loaded = structural_load_durable_spent(query, tip, scratch)?; + multi_list_ns = loaded.0; + spent_strong_ns = loaded.1; + } + let spent_abs_ns = (t_abs.elapsed().as_nanos() as u64).saturating_sub(spent_strong_ns); + let spent_cold_ns = multi_list_ns; + let t_pending = Instant::now(); + structural_mark_pending(spends, pending_spent, &scratch.durable_spent)?; + let spent_pending_ns = t_pending.elapsed().as_nanos() as u64; + let spent_ns = t_spent.elapsed().as_nanos() as u64; + let t_create = Instant::now(); + structural_create_heights( + query, + batch_parents, + run_create_height, + ctx.height.0, + maturity, + scratch, + )?; + let create_h_ns = t_create.elapsed().as_nanos() as u64; + let t_bip68 = Instant::now(); + structural_bip68( + query, + block, + ctx, + spends, + &scratch.create_height_by_fk, + mtp_cache, + )?; + let bip68_ns = t_bip68.elapsed().as_nanos() as u64; + let _ = archived_tx_fks; + rbitcoin_consensus::block::check_coinbase_subsidy(block, ctx, fees)?; + Ok(StructuralPhaseNs { + spent_ns, + spent_abs_ns, + spent_strong_ns, + spent_cold_ns, + spent_pending_ns, + create_h_ns, + bip68_ns, + }) +} + +pub(crate) type StructuralAbsJob = (u64, u32, u64, rbitcoin_primitives::Fk, u32); + +/// Creates this write batch's Class A append committed. +/// +/// Only these spent slots carry the same-batch sole-spender overlay. A run +/// create archived by an earlier wave has whatever that wave and later +/// annotates left on disk, so structural reads and annotates it. +#[derive(Default)] +pub(crate) struct ClassAWave(Vec); + +impl ClassAWave { + pub(crate) fn new(mut fks: Vec) -> Self { + fks.sort_unstable_by_key(|f| f.0); + Self(fks) + } + + fn contains(&self, fk: rbitcoin_primitives::Fk) -> bool { + self.0.binary_search_by_key(&fk.0, |f| f.0).is_ok() + } +} + +/// Create heights for one write batch. +/// +/// Contiguous per-block fk spans are the IBD shape (`first` + count). A gap +/// or overlap uses the map so a fk between spans does not inherit a height. +/// Each block's first fk is its coinbase: a span's `first`, or the map's flag. +pub(crate) enum RunCreateHeight { + Spans(Vec<(u64, u64, u32)>), + Map(FkMap<(u32, bool)>), +} + +impl RunCreateHeight { + pub(crate) fn from_blocks<'a>( + blocks: impl IntoIterator, + ) -> Self { + let blocks: Vec<(u32, &'a [rbitcoin_primitives::Fk])> = blocks.into_iter().collect(); + if let Some(spans) = contiguous_create_spans(&blocks) { + Self::Spans(spans) + } else { + let mut map = FkMap::default(); + for (height, fks) in blocks { + for (i, fk) in fks.iter().enumerate() { + map.insert(*fk, (height, i == 0)); + } + } + Self::Map(map) + } + } + + pub(crate) fn get(&self, fk: rbitcoin_primitives::Fk) -> Option { + self.create(fk).map(|(height, _)| height) + } + + /// Height of a create in this batch and whether it is its block's coinbase. + pub(crate) fn create(&self, fk: rbitcoin_primitives::Fk) -> Option<(u32, bool)> { + match self { + Self::Map(map) => map.get(&fk).copied(), + Self::Spans(spans) => { + let id = fk.get()?; + let i = spans.partition_point(|span| span.0 <= id); + let (first, end, height) = spans.get(i.checked_sub(1)?)?; + (*end > id).then_some((*height, *first == id)) + } + } + } +} + +/// `Some` when every non-empty block is `[first, first+n)` and spans do not overlap. +/// +/// A null fk, a gap, or an id that would wrap is `None` so the caller uses the map. +/// A wrapped end must not cover fks that are not in the block. +fn contiguous_create_spans( + blocks: &[(u32, &[rbitcoin_primitives::Fk])], +) -> Option> { + let mut spans = Vec::with_capacity(blocks.len()); + for &(height, fks) in blocks { + if fks.is_empty() { + continue; + } + let mut ids = fks.iter().map(|fk| fk.get()); + let first = ids.next().flatten()?; + let mut prev = first; + for id in ids { + let id = id?; + let next = prev.checked_add(1)?; + if id != next { + return None; + } + prev = id; + } + let end = prev.checked_add(1)?; + spans.push((first, end, height)); + } + spans.sort_unstable_by_key(|span| span.0); + for pair in spans.windows(2) { + if pair[0].1 > pair[1].0 { + return None; + } + } + Some(spans) +} +type DurableSpentSet = + std::collections::HashSet<(u64, u32), BuildHasherDefault>; +type OverlayMetaSkip = std::collections::HashMap< + (u64, u32), + (rbitcoin_primitives::Fk, u32), + BuildHasherDefault, +>; + +fn fill_overlay_skip( + spends: &[( + [u8; 32], + u32, + rbitcoin_primitives::Fk, + rbitcoin_primitives::Fk, + u32, + )], + class_a_wave: &ClassAWave, + scratch: &mut StructuralScratch, +) { + for &(_, vout, sfk, cfk, vin) in spends { + if !class_a_wave.contains(cfk) { + continue; + } + let Some(id) = cfk.get() else { + continue; + }; + let key = (id, vout); + *scratch.skip_n.entry(key).or_insert(0) += 1; + scratch.skip.entry(key).or_insert((sfk, vin)); + } + let skip_n = &scratch.skip_n; + scratch + .skip + .retain(|k, _| skip_n.get(k).copied() == Some(1)); +} + +fn overlay_meta_is_skip( + id: u64, + vout: u32, + sfk: rbitcoin_primitives::Fk, + vin: u32, + skip: &OverlayMetaSkip, +) -> bool { + skip.get(&(id, vout)) == Some(&(sfk, vin)) +} + +fn structural_abs_heights( + query: &Query, + run_create_height: &RunCreateHeight, + scratch: &mut StructuralScratch, + abs_jobs: &[StructuralAbsJob], +) -> Result<(), ConfirmError> { + scratch.abs_jobs.extend_from_slice(abs_jobs); + scratch.unique_fks.extend( + scratch + .abs_jobs + .iter() + .map(|(id, _, _, _, _)| rbitcoin_primitives::Fk(*id)), + ); + scratch.unique_fks.sort_unstable_by_key(|f| f.0); + scratch.unique_fks.dedup(); + let durable_heights = query + .store() + .tx_height_get_batch(&scratch.unique_fks) + .map_err(ConfirmError::from)?; + for (fk, h) in scratch.unique_fks.iter().zip(durable_heights) { + let Some(id) = fk.get() else { + continue; + }; + let Some(h) = h.or_else(|| run_create_height.get(*fk)) else { + continue; + }; + scratch.height_by_id.insert(id, h); + } + Ok(()) +} + +fn structural_load_durable_spent( + query: &Query, + tip: Option, + scratch: &mut StructuralScratch, +) -> Result<(u64, u64), ConfirmError> { + use std::time::Instant; + let mut ovl_n = 0u64; + for &job in &scratch.abs_jobs { + let (id, vout, abs, sfk, vin) = job; + if overlay_meta_is_skip(id, vout, sfk, vin, &scratch.skip) { + ovl_n = ovl_n.saturating_add(1); + continue; + } + scratch.disk_jobs.push(job); + scratch.abs_offs.push(abs); + } + crate::note_confirm(&query.confirm_stats().spend_overlay_skip_n, ovl_n); + if scratch.abs_offs.is_empty() { + return Ok((0, 0)); + } + let meta_backend = rbitcoin_store::spend_meta_backend(); + let t_meta = Instant::now(); + let metas = query + .store() + .get_spender_meta_at_abs_batch_backend(&scratch.abs_offs, meta_backend) + .map_err(ConfirmError::from)?; + let meta_ns = t_meta.elapsed().as_nanos() as u64; + crate::note_confirm(&query.confirm_stats().spend_meta_ns, meta_ns); + crate::note_confirm( + &query.confirm_stats().spend_meta_n, + scratch.abs_offs.len() as u64, + ); + let _ = meta_backend; + if metas.len() != scratch.disk_jobs.len() { + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( + "invariant: structural meta batch length", + ))); + } + let t_strong = Instant::now(); + for row in &metas { + let Some((field, _, _)) = row else { + continue; + }; + if field.is_null() { + continue; + } + if let Some(fid) = field.get() { + if scratch.field_seen.insert(fid) { + scratch.field_fks.push(*field); + } + } + } + let field_heights = query + .store() + .tx_height_get_batch(&scratch.field_fks) + .map_err(ConfirmError::from)?; + for (fk, h) in scratch.field_fks.iter().zip(field_heights) { + if let Some((id, h)) = fk.get().zip(h) { + scratch.field_h_by_id.insert(id, h); + } + } + let jobs = std::mem::take(&mut scratch.disk_jobs); + let applied = jobs + .iter() + .copied() + .zip(metas) + .try_fold(0u64, |acc, (job, meta)| { + let (id, vout, abs, sfk, vin) = job; + let ns = structural_apply_one_meta(query, meta, id, vout, abs, sfk, vin, tip, scratch)?; + Ok::(acc.saturating_add(ns)) + }); + scratch.disk_jobs = jobs; + let multi_list_ns = applied?; + let spent_strong_ns = t_strong + .elapsed() + .as_nanos() + .saturating_sub(multi_list_ns as u128) as u64; + Ok((multi_list_ns, spent_strong_ns)) +} + +#[allow(clippy::too_many_arguments)] // one spent-slot apply; call site stays unbundled +pub(crate) fn structural_apply_one_meta( + query: &Query, + meta: Option<(rbitcoin_primitives::Fk, u8, u32)>, + id: u64, + vout: u32, + abs: u64, + sfk: rbitcoin_primitives::Fk, + vin: u32, + tip: Option, + scratch: &mut StructuralScratch, +) -> Result { + use std::time::Instant; + let Some((field, flags, field_vin)) = meta else { + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( + "invariant: structural spender meta short/OOB (cold forbidden)", + ))); + }; + scratch.slots.push( + (abs, rbitcoin_primitives::Fk(id), vout, sfk, vin), + (field, flags, field_vin), + ); + let multi = flags & rbitcoin_store::output_flags::MULTI_SPENDER != 0; + if multi { + let t_m = Instant::now(); + let spent = query + .store() + .has_confirmed_strong_spender_create(rbitcoin_primitives::Fk(id), vout, None) + .map_err(ConfirmError::from)?; + let ns = t_m.elapsed().as_nanos() as u64; + if spent { + scratch.durable_spent.insert((id, vout)); + } + return Ok(ns); + } + if field.is_null() { + return Ok(0); + } + let strong = query + .store() + .is_confirmed_strong_at(field, tip) + .map_err(ConfirmError::from)?; + if !strong { + return Ok(0); + } + let create_h = scratch.height_by_id.get(&id).copied(); + let spend_h = field + .get() + .and_then(|fid| scratch.field_h_by_id.get(&fid).copied()); + if let (Some(ch), Some(sh)) = (create_h, spend_h) { + if sh < ch { + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( + "invariant: confirmed spender below its create height", + ))); + } + } + scratch.durable_spent.insert((id, vout)); + Ok(0) +} + +fn structural_mark_pending( + spends: &[( + [u8; 32], + u32, + rbitcoin_primitives::Fk, + rbitcoin_primitives::Fk, + u32, + )], + pending_spent: &mut rbitcoin_consensus::OutPointSet, + durable_spent: &DurableSpentSet, +) -> Result<(), ConfirmError> { + for &(prev_txid, vout, _spend_fk, create_fk, _vin) in spends { + let key = (prev_txid, vout); + let spent = if create_fk.is_null() { + false + } else if let Some(id) = create_fk.get() { + durable_spent.contains(&(id, vout)) + } else { + false + }; + rbitcoin_consensus::block::reject_prevout_spent(spent || !pending_spent.insert(key))?; + } + Ok(()) +} + +fn structural_create_heights( + query: &Query, + batch_parents: &crate::BatchParents, + run_create_height: &RunCreateHeight, + spend_height: u32, + maturity: u32, + scratch: &mut StructuralScratch, +) -> Result<(), ConfirmError> { + // `confirmed[h]` for a height in this batch is written at Class C, after + // structural; the batch index owns coinbase identity there. + scratch.height_list.extend( + scratch + .height_by_id + .iter() + .filter(|&(&id, &h)| run_create_height.get(rbitcoin_primitives::Fk(id)) != Some(h)) + .map(|(_, &h)| h), + ); + scratch.height_list.sort_unstable(); + scratch.height_list.dedup(); + let coinbase_fk_by_height = query + .store() + .coinbase_fk_at_heights(&scratch.height_list) + .map_err(ConfirmError::from)?; + let n_unique = scratch.unique_fks.len(); + for i in 0..n_unique { + let create_fk = scratch.unique_fks[i]; + let Some(id) = create_fk.get() else { + continue; + }; + let Some(&durable_h) = scratch.height_by_id.get(&id) else { + return rbitcoin_consensus::block::reject_one_create( + spend_height, + maturity, + None, + false, + ) + .map_err(ConfirmError::from); + }; + let pin_cb = batch_parents.get_parent_coinbase(create_fk); + let is_cb = match (pin_cb, run_create_height.create(create_fk)) { + (Some(cb), _) => cb, + (None, Some((h, cb))) if h == durable_h => cb, + (None, _) => { + let cb = coinbase_fk_by_height + .get(&durable_h) + .ok_or(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( + "invariant: no coinbase fk at confirmed create height", + )))?; + *cb == create_fk + } + }; + // Core connects a run one block at a time: a create from a later + // block of this batch is not yet a coin when this block spends it. + rbitcoin_consensus::block::reject_one_create( + spend_height, + maturity, + Some(durable_h), + is_cb, + )?; + scratch.create_height_by_fk.insert(create_fk, durable_h); + } + Ok(()) +} + +pub(crate) fn structural_bip68( + query: &Query, + block: &Block, + ctx: &rbitcoin_consensus::ValidationContext<'_>, + spends: &[( + [u8; 32], + u32, + rbitcoin_primitives::Fk, + rbitcoin_primitives::Fk, + u32, + )], + create_height_by_fk: &FkMap, + mtp_cache: &mut U32Map, +) -> Result<(), ConfirmError> { + if !ctx.params.csv_active_at(ctx.height.0) { + return Ok(()); + } + const DISABLE: u32 = 1 << 31; + const TYPE_FLAG: u32 = 1 << 22; + let prev_mtp = if ctx.height.0 == 0 { + 0 + } else { + mtp_at(query, Height(ctx.height.0 - 1), mtp_cache)? + }; + let mut si = 0usize; + let mut prev_heights: Vec = Vec::new(); + let mut coin_mtps: Vec = Vec::new(); + for tx in block.txdata.iter().skip(1) { + let n_in = tx.input.len(); + rbitcoin_consensus::block::reject_spend_cursor(si + n_in <= spends.len())?; + let tx_spends = &spends[si..si + n_in]; + si += n_in; + if !rbitcoin_consensus::block::bip68_active_for_tx(tx) { + continue; + } + prev_heights.clear(); + coin_mtps.clear(); + prev_heights.reserve(n_in); + coin_mtps.reserve(n_in); + for (inp, &(_ptid, _vout, _sfk, create_fk, _vin)) in tx.input.iter().zip(tx_spends.iter()) { + let ch = if create_fk.is_null() { + ctx.height.0 + } else { + rbitcoin_consensus::block::reject_csv_create_height( + create_height_by_fk.get(&create_fk).copied(), + )? + }; + prev_heights.push(ch); + let seq = inp.sequence.to_consensus_u32(); + let need_mtp = seq & DISABLE == 0 && seq & TYPE_FLAG != 0; + // Height 0 is the genesis coin: one median lookup of block 0. + // A missing create height is not height 0. + let mtp = if !need_mtp { + 0 + } else { + mtp_at(query, Height(ch.saturating_sub(1)), mtp_cache)? + }; + coin_mtps.push(mtp); + } + rbitcoin_consensus::block::reject_sequence_locks( + tx, + &prev_heights, + &coin_mtps, + ctx.height.0, + prev_mtp, + )?; + } + rbitcoin_consensus::block::reject_spend_cursor(si == spends.len())?; + Ok(()) +} + +/// MTP for write structural. Prefers assemble-carried `prev_mtp` (seeded into +/// `cache`). Misses go to durable headers only — never `get_header_plan`. +/// BIP30: a connected instance with any unspent spendable output may not be +/// overwritten. +/// Skipped for the two mainnet repeats, and when the header at BIP34 height +/// is this network's BIP34 hash and the block is below +/// [`crate::params::BIP34_IMPLIES_BIP30_LIMIT`]. Signet and regtest have no +/// BIP34 hash, so every block is checked. Just-archived self is unconnected. +fn reject_bip30_unspent_overwrite( + query: &Query, + block: &Block, + ctx: &rbitcoin_consensus::ValidationContext<'_>, +) -> Result<(), ConfirmError> { + if ctx.params.is_bip30_repeat(ctx.height.0, block.block_hash()) + || bip34_ancestry_skips_bip30(query, ctx) + { + return Ok(()); + } + let create_txids: Vec<[u8; 32]> = block + .txdata + .iter() + .map(|tx| tx.compute_txid().to_byte_array()) + .collect(); + let hits = query + .store() + .get_fk_by_txid_batch(&create_txids) + .map_err(ConfirmError::from)?; + for (_txid, row) in hits { + let Some((old_fk, _)) = row else { + continue; + }; + // Connected instance at *this* height is ourselves (re-validate / + // already-confirmed fixture). BIP30 is an earlier unspent sibling. + let prior_height = query + .store() + .tx_height_get(old_fk) + .map_err(ConfirmError::from)?; + if prior_height == Some(ctx.height.0) { + continue; + } + let (_, outs) = query + .store() + .get_tx_meta_and_outputs(old_fk) + .map_err(ConfirmError::from)?; + let mut unspent = false; + for (v, out) in (0u32..).zip(&outs) { + // Core's AddCoins never stores an unspendable output, so it is + // not a coin that the overwrite could clobber. + if rbitcoin_consensus::policy::is_unspendable(&out.script) { + continue; + } + let spent = query + .store() + .has_confirmed_strong_spender_create(old_fk, v, None) + .map_err(ConfirmError::from)?; + if !spent { + unspent = true; + break; + } + } + if unspent { + rbitcoin_consensus::block::reject_bip30_unspent_prior( + ctx.height.0, + prior_height.unwrap_or(u32::MAX), + true, + )?; + } + } + Ok(()) +} + +fn bip34_ancestry_skips_bip30( + query: &Query, + ctx: &rbitcoin_consensus::ValidationContext<'_>, +) -> bool { + let height = ctx.height.0; + if ctx.params.bip34_hash.is_none() { + return false; + } + if height <= ctx.params.btc.bip34_height { + return false; + } + let ancestor = query + .header_at_height(Height(ctx.params.btc.bip34_height)) + .ok() + .flatten() + .map(|(_, rec)| bitcoin::BlockHash::from_byte_array(rec.hash)); + ctx.params + .bip30_skipped_for_bip34_ancestry(height, ancestor) +} + +fn mtp_at(query: &Query, height: Height, cache: &mut U32Map) -> Result { + if let Some(&t) = cache.get(&height.0) { + return Ok(t); + } + let t = crate::median_time_past_store(query, height)?; + cache.insert(height.0, t); + Ok(t) +} + +pub(crate) struct ResolvedPrevout { + txout: TxOut, + /// Set when the script bytes stay in a block or pin `Arc`. + shared: Option, + /// P2SH+witness sigop cost for this input's prevout script (not legacy). + input_sigops: u64, + /// Class A create fk for this prevout (or `NULL` for same-block). Load pin + /// denserels must carry identity matching wire `prev_txid` for this fk. + create_fk: rbitcoin_primitives::Fk, +} + +#[allow(clippy::too_many_arguments)] // call-site args stay unbundled +pub(crate) fn resolve_prevout( + block: &Block, + op: OutPoint, + inp: &bitcoin::TxIn, + prev_fk_hint: Option, + txid_index: &TxidMap, + spend_ti: usize, + batch_parents: &crate::BatchParents, + bip16: bool, + witness: bool, + need_script_buf: bool, + wire: Option<&Arc>, + acc: &mut AsmPrevoutAcc, +) -> Result { + let prev_txid = op.txid.to_byte_array(); + + if let Some(&pj) = txid_index.get(&prev_txid) { + if pj < spend_ti { + let v = op.vout as usize; + let o = rbitcoin_consensus::block::require_same_block_output(block.txdata.get(pj), v)?; + // Core never puts this script in the coin view. The returned + // `txout` is often empty; the wire output holds the real bytes. + rbitcoin_consensus::block::reject_unspendable_prevout( + rbitcoin_consensus::policy::is_unspendable(o.script_pubkey.as_bytes()), + )?; + acc.in_n = acc.in_n.saturating_add(1); + acc.same_n = acc.same_n.saturating_add(1); + let shared = if need_script_buf { + wire.map(|_| rbitcoin_consensus::block::SharedPrevout::Wire { + tx_index: pj as u32, + vout: op.vout, + }) + } else { + None + }; + return Ok(ResolvedPrevout { + txout: if shared.is_some() || !need_script_buf { + TxOut { + value: o.value, + script_pubkey: ScriptBuf::new(), + } + } else { + o.clone() + }, + shared, + input_sigops: rbitcoin_consensus::block::prevout_spk_sigops( + inp, + o.script_pubkey.as_bytes(), + bip16, + witness, + ), + create_fk: rbitcoin_primitives::Fk::NULL, + }); + } + } + + // Batch pin first (no TxRecord clone — A3). Pin identity/vout misses are + // hard invariants (load must fill schema-13 identity + denserels). + if let Some(prev_fk) = prev_fk_hint { + if let Some((pinned, parent_txid)) = batch_parents.share_parent_prevout(prev_fk, op.vout) { + if parent_txid != prev_txid { + acc.cold_txid_mismatch_n = acc.cold_txid_mismatch_n.saturating_add(1); + #[cfg(test)] + crate::block_pipeline::test_phase::tl_note_cold_why_txid_mismatch(); + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( + "invariant: pin parent create identity mismatch wire prev_txid", + ))); + } + let (value, script) = + pinned + .parts() + .ok_or(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( + "invariant: pin incomplete outs for spent parent vout", + )))?; + // Same rule as the same-block arm. `txout.script_pubkey` below is + // empty; `script` is the archived bytes, including when scripts + // are not executed. + rbitcoin_consensus::block::reject_unspendable_prevout( + rbitcoin_consensus::policy::is_unspendable(script), + )?; + let input_sigops = + rbitcoin_consensus::block::prevout_spk_sigops(inp, script, bip16, witness); + acc.in_n = acc.in_n.saturating_add(1); + acc.batch_n = acc.batch_n.saturating_add(1); + #[cfg(test)] + crate::block_pipeline::test_phase::tl_note_batch_hit(); + return Ok(ResolvedPrevout { + txout: TxOut { + value: Amount::from_sat(value), + script_pubkey: ScriptBuf::new(), + }, + shared: need_script_buf.then(|| pinned.into_shared_prevout()), + input_sigops, + create_fk: prev_fk, + }); + } else if batch_parents.contains(prev_fk) { + acc.cold_vout_miss_n = acc.cold_vout_miss_n.saturating_add(1); + #[cfg(test)] + crate::block_pipeline::test_phase::tl_note_cold_why_vout_miss(); + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( + "invariant: pin incomplete outs for spent parent vout", + ))); + } + } + + Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( + "invariant: lookup stage miss (assemble parent create_fk)", + ))) +} + +#[cfg(test)] +mod overlay_meta_skip_tests { + use super::*; + use rbitcoin_primitives::Fk; + + fn spends(rows: &[(u32, Fk, Fk, u32)]) -> Vec<([u8; 32], u32, Fk, Fk, u32)> { + rows.iter() + .map(|&(vout, sfk, cfk, vin)| ([0u8; 32], vout, sfk, cfk, vin)) + .collect() + } + + #[test] + fn overlay_meta_skip_omits_matching_abs() { + let wave = ClassAWave::new(vec![Fk(10)]); + let mut map = FkMap::default(); + map.insert(Fk(10), (5, false)); + let run = RunCreateHeight::Map(map); + assert_eq!(run.create(Fk(10)), Some((5, false))); + let spends = spends(&[(0, Fk(11), Fk(10), 0)]); + let mut scratch = StructuralScratch::default(); + fill_overlay_skip(&spends, &wave, &mut scratch); + assert!(overlay_meta_is_skip(10, 0, Fk(11), 0, &scratch.skip)); + assert!(!overlay_meta_is_skip(10, 0, Fk(11), 1, &scratch.skip)); + assert!(!overlay_meta_is_skip(10, 1, Fk(11), 0, &scratch.skip)); + assert!(!overlay_meta_is_skip(99, 0, Fk(11), 0, &scratch.skip)); + } + + #[test] + fn overlay_meta_skip_keeps_conflicting_spender_on_disk_list() { + let wave = ClassAWave::new(vec![Fk(10)]); + let mut map = FkMap::default(); + map.insert(Fk(10), (5, false)); + let run = RunCreateHeight::Map(map); + assert_eq!(run.create(Fk(10)), Some((5, false))); + let spends = spends(&[(0, Fk(11), Fk(10), 0), (0, Fk(12), Fk(10), 0)]); + let mut scratch = StructuralScratch::default(); + fill_overlay_skip(&spends, &wave, &mut scratch); + assert!(!overlay_meta_is_skip(10, 0, Fk(11), 0, &scratch.skip)); + assert!(!overlay_meta_is_skip(10, 0, Fk(12), 0, &scratch.skip)); + } + + #[test] + fn overlay_meta_skip_historical_create_stays_on_disk() { + let wave = ClassAWave::default(); + let spends = spends(&[(0, Fk(11), Fk(10), 0)]); + let mut scratch = StructuralScratch::default(); + fill_overlay_skip(&spends, &wave, &mut scratch); + assert!(!overlay_meta_is_skip(10, 0, Fk(11), 0, &scratch.skip)); + } +} + +#[cfg(test)] +mod run_create_height_tests { + use super::RunCreateHeight; + use rbitcoin_primitives::Fk; + + fn fk(id: u64) -> Fk { + Fk(id) + } + + #[test] + fn run_create_height_span_misses_gaps() { + let one = [fk(10), fk(11)]; + let idx = RunCreateHeight::from_blocks([(7u32, one.as_slice())]); + assert!(matches!(idx, RunCreateHeight::Spans(_))); + assert_eq!(idx.get(fk(10)), Some(7)); + assert_eq!(idx.get(fk(11)), Some(7)); + assert_eq!(idx.get(fk(9)), None); + assert_eq!(idx.get(fk(12)), None); + + let low = [fk(10), fk(11)]; + let high = [fk(20), fk(21)]; + let idx = RunCreateHeight::from_blocks([(1u32, low.as_slice()), (2, high.as_slice())]); + assert_eq!(idx.get(fk(15)), None); + assert_eq!(idx.get(fk(10)), Some(1)); + assert_eq!(idx.get(fk(21)), Some(2)); + + let gapped = [fk(1), fk(2), fk(4)]; + let idx = RunCreateHeight::from_blocks([(3u32, gapped.as_slice())]); + assert!(matches!(idx, RunCreateHeight::Map(_))); + assert_eq!(idx.get(fk(3)), None); + assert_eq!(idx.get(fk(1)), Some(3)); + assert_eq!(idx.get(fk(2)), Some(3)); + assert_eq!(idx.get(fk(4)), Some(3)); + } + + #[test] + fn run_create_height_marks_each_block_first_fk_coinbase() { + let low = [fk(10), fk(11)]; + let high = [fk(12), fk(13)]; + let idx = RunCreateHeight::from_blocks([(1u32, low.as_slice()), (2, high.as_slice())]); + assert!(matches!(idx, RunCreateHeight::Spans(_))); + assert_eq!(idx.create(fk(10)), Some((1, true))); + assert_eq!(idx.create(fk(11)), Some((1, false))); + assert_eq!(idx.create(fk(12)), Some((2, true))); + assert_eq!(idx.create(fk(13)), Some((2, false))); + + let gapped = [fk(20), fk(22)]; + let idx = RunCreateHeight::from_blocks([(1u32, low.as_slice()), (2, gapped.as_slice())]); + assert!(matches!(idx, RunCreateHeight::Map(_))); + assert_eq!(idx.create(fk(10)), Some((1, true))); + assert_eq!(idx.create(fk(11)), Some((1, false))); + assert_eq!(idx.create(fk(20)), Some((2, true))); + assert_eq!(idx.create(fk(22)), Some((2, false))); + assert_eq!(idx.create(fk(21)), None); + } +} diff --git a/crates/rbitcoin-query/src/confirm_error.rs b/crates/rbitcoin-query/src/confirm_error.rs new file mode 100644 index 0000000000..3d33f5b9e8 --- /dev/null +++ b/crates/rbitcoin-query/src/confirm_error.rs @@ -0,0 +1,123 @@ +//! Confirm pipeline error. Rule rejects stay [`ConsensusError`]. Disk, io_uring, +//! and cancellation stay [`StoreError`] and must not be cached as a failed block. + +use rbitcoin_consensus::ConsensusError; +use rbitcoin_store::StoreError; +use std::fmt; + +#[derive(Debug)] +pub enum ConfirmError { + Rules(ConsensusError), + Store(StoreError), +} + +impl fmt::Display for ConfirmError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfirmError::Rules(e) => write!(f, "{e}"), + ConfirmError::Store(e) => write!(f, "store: {e}"), + } + } +} + +impl std::error::Error for ConfirmError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ConfirmError::Rules(e) => Some(e), + ConfirmError::Store(e) => Some(e), + } + } +} + +impl From for ConfirmError { + fn from(e: ConsensusError) -> Self { + ConfirmError::Rules(e) + } +} + +impl From for ConfirmError { + fn from(e: StoreError) -> Self { + // A missing parent on an untrusted block is not store corruption. + // Cancellation is a rule-side abort so it is not cached as a bad block. + match e { + StoreError::Cancelled(_) => ConfirmError::Rules(ConsensusError::Cancelled), + StoreError::Corrupt(m) if m.contains("parent create_fk unresolved") => { + ConfirmError::from(rbitcoin_consensus::block::unresolved_parent_create()) + } + other => ConfirmError::Store(other), + } + } +} + +impl ConfirmError { + pub fn is_uring_session_fault(&self) -> bool { + matches!(self, ConfirmError::Store(se) if se.is_uring_session_fault()) + } + + pub fn rules(&self) -> Option<&ConsensusError> { + match self { + ConfirmError::Rules(e) => Some(e), + ConfirmError::Store(_) => None, + } + } + + /// Script-pool jobs only return [`ConsensusError`]. A store fault becomes + /// [`ConsensusError::Internal`] so the pool does not grow a second taxonomy. + /// io_uring text stays recognizable to [`StoreError::is_uring_session_fault_msg`]. + pub(crate) fn into_pool(self) -> ConsensusError { + match self { + ConfirmError::Rules(e) => e, + ConfirmError::Store(StoreError::Corrupt(m)) + if StoreError::is_uring_session_fault_msg(m) => + { + ConsensusError::Internal("io_uring submit failed") + } + ConfirmError::Store(StoreError::Corrupt(m)) => ConsensusError::Internal(m), + ConfirmError::Store(_) => ConsensusError::Internal("index assemble store fault"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uring_session_fault_covers_leftover_and_submit() { + for m in [ + "invariant: io_uring leftover cqe", + "io_uring submit failed", + "invariant: io_uring undrained", + ] { + let e = ConfirmError::Store(StoreError::Corrupt(m)); + assert!(e.is_uring_session_fault(), "{m}"); + } + assert!(!ConfirmError::Store(StoreError::NotFound).is_uring_session_fault()); + assert!(!ConfirmError::Rules(ConsensusError::PrevoutSpent).is_uring_session_fault()); + } + + #[test] + fn archive_unresolved_parent_is_missing_prevout_not_corrupt() { + let e = ConfirmError::from(StoreError::Corrupt( + "archive: parent create_fk unresolved (contiguous batch required)", + )); + assert!( + matches!(e, ConfirmError::Rules(ConsensusError::MissingPrevout)), + "got {e:?}" + ); + assert!(!e.to_string().contains("corrupt")); + } + + #[test] + fn from_store_error_keeps_not_found() { + let e = ConfirmError::from(StoreError::NotFound); + assert!(matches!(e, ConfirmError::Store(StoreError::NotFound))); + } + + #[test] + fn cancelled_is_not_a_store_fault_string() { + let e = ConfirmError::from(StoreError::Cancelled("stop")); + assert!(matches!(e, ConfirmError::Rules(ConsensusError::Cancelled))); + assert_eq!(e.to_string(), "confirm cancelled"); + } +} diff --git a/crates/rbitcoin-consensus/src/confirm_run/bq_resolve.rs b/crates/rbitcoin-query/src/confirm_run/bq_resolve.rs similarity index 98% rename from crates/rbitcoin-consensus/src/confirm_run/bq_resolve.rs rename to crates/rbitcoin-query/src/confirm_run/bq_resolve.rs index 7c49b6e84b..392eaab095 100644 --- a/crates/rbitcoin-consensus/src/confirm_run/bq_resolve.rs +++ b/crates/rbitcoin-query/src/confirm_run/bq_resolve.rs @@ -3,14 +3,11 @@ //! Pack/hold uses enqueue-stamped Σ inputs (no `Block` decode). Emit runs one //! [`Store::get_fk_by_txid_batch`] (TipOnly) across the selected heights //! (soft **64000** inputs / hard **1080** blocks). Hits ride the wave as -//! [`rbitcoin_query::BatchParentIds`] (fk + body_range + spent_range). Does not +//! [`crate::BatchParentIds`] (fk + body_range + spent_range). Does not //! claim, structure, or stamp. Same-wave creates are omitted from TipOnly need. use super::*; -use crate::milestone::Milestone; -use rbitcoin_query::{ - BatchParentIds, IdMap, ResolvedWire, TxPrecompute, TxidHasher, U32Map, U64Map, -}; +use crate::{BatchParentIds, IdMap, ResolvedWire, TxPrecompute, TxidHasher, U32Map, U64Map}; use std::collections::{HashMap, HashSet}; use std::hash::BuildHasherDefault; use std::sync::Arc; @@ -194,7 +191,7 @@ pub fn confirm_bq_resolve_wave_capped( heights: &[u32], max_blocks: usize, max_inputs: u32, -) -> Result { +) -> Result { let t0 = Instant::now(); let mut stats = BqResolveWaveStats::default(); let mut done: Vec = Vec::new(); @@ -291,9 +288,9 @@ pub fn confirm_bq_resolve_wave_capped( continue; }; let t_dec = Instant::now(); - let decoded = rbitcoin_query::decode_block_precomputes( + let decoded = crate::decode_block_precomputes( &payload[..], - !crate::milestone::skips_on_query(milestone, query, h, &hash), + !crate::milestone_gate::skips_on_query(milestone, query, h, &hash), ); let wall = t_dec.elapsed().as_nanos() as u64; let Some((block, pres_vec, pre_ns)) = decoded else { @@ -362,7 +359,7 @@ pub fn confirm_bq_resolve_wave_capped( let rows = query .store() .get_fk_by_txid_batch(&need) - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; for (txid, row) in rows { if let Some((fk, pair)) = row { layer.insert(txid, (fk, pair.txout)); @@ -393,7 +390,7 @@ pub fn confirm_bq_resolve_wave_capped( if !promote.is_empty() { query .block_queue_promote_wave(promote) - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; } stats.heights = items.len() as u32; stats.work_ns = t0.elapsed().as_nanos() as u64; @@ -422,7 +419,7 @@ pub fn take_wave_items_for_load( query: &Query, items: &[(u32, [u8; 32], ResolvedWire)], gen: u32, -) -> Result { +) -> Result { let Some(&(last, _, _)) = items.last() else { return Ok(true); }; @@ -432,7 +429,7 @@ pub fn take_wave_items_for_load( for (h, _, _) in items { query .block_queue_dequeue_height(*h) - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; } Ok(true) } @@ -441,7 +438,6 @@ pub fn take_wave_items_for_load( mod tests { use super::*; use crate::accept_and_connect_block; - use crate::regtest_pad::mine_empty_regtest; use bitcoin::absolute::LockTime; use bitcoin::block::{Header, Version}; use bitcoin::consensus::encode::serialize; @@ -451,8 +447,9 @@ mod tests { Amount, BlockHash, CompactTarget, OutPoint, ScriptBuf, Sequence, Target, Transaction, TxIn, TxMerkleNode, TxOut, Txid, Witness, }; - fn tmp_query() -> (rbitcoin_query::testutil::TempDir, Query) { - rbitcoin_query::testutil::tiny_query_labeled("bq-resolve") + use rbitcoin_consensus::mine_empty_regtest; + fn tmp_query() -> (crate::testutil::TempDir, Query) { + crate::testutil::tiny_query_labeled("bq-resolve") } fn take_emitted(q: &Query, wave: &BqResolveWave) { @@ -503,7 +500,7 @@ mod tests { let mut ss = if height == 0 { vec![0x00] } else { - crate::bip34_height_script(height) + rbitcoin_consensus::bip34_height_script(height) }; while ss.len() < 2 { ss.push(0x00); @@ -1072,7 +1069,7 @@ mod tests { #[test] fn resolve_wave_emits_a_promoted_height() { - use rbitcoin_query::{ResolvedWire, TxPrecompute}; + use crate::{ResolvedWire, TxPrecompute}; use std::sync::Arc; let (path, q) = tmp_query(); @@ -1133,7 +1130,7 @@ mod tests { /// height is confirmed so stamp does not MissingPrevout (931147 / 933474). #[test] fn stamp_uses_inflight_until_tip_covers_parent_height() { - use rbitcoin_query::InFlight; + use crate::InFlight; use rbitcoin_store::{OutputRecord, TxRecord}; let (path, q) = tmp_query(); let params = ChainParams::regtest(); @@ -1141,7 +1138,7 @@ mod tests { accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); let parent_txid = [0x22u8; 32]; let parent_fk = rbitcoin_primitives::Fk(99); - let pin = rbitcoin_query::CreatePinInner::records( + let pin = crate::CreatePinInner::records( TxRecord { txid: parent_txid, version: 1, @@ -1207,7 +1204,7 @@ mod tests { /// would drop the parent (mainnet 945952 leftover_n=3546 hit=2811). #[test] fn stamp_uses_inflight_when_confirmed_tip_leads_fence() { - use rbitcoin_query::InFlight; + use crate::InFlight; use rbitcoin_store::{OutputRecord, TxRecord}; let (path, q) = tmp_query(); let params = ChainParams::regtest(); @@ -1227,7 +1224,7 @@ mod tests { let parent_txid = [0x33u8; 32]; let parent_fk = rbitcoin_primitives::Fk(99); - let pin = rbitcoin_query::CreatePinInner::records( + let pin = crate::CreatePinInner::records( TxRecord { txid: parent_txid, version: 1, diff --git a/crates/rbitcoin-consensus/src/confirm_run/head_drain.rs b/crates/rbitcoin-query/src/confirm_run/head_drain.rs similarity index 100% rename from crates/rbitcoin-consensus/src/confirm_run/head_drain.rs rename to crates/rbitcoin-query/src/confirm_run/head_drain.rs diff --git a/crates/rbitcoin-consensus/src/confirm_run/index.rs b/crates/rbitcoin-query/src/confirm_run/index.rs similarity index 95% rename from crates/rbitcoin-consensus/src/confirm_run/index.rs rename to crates/rbitcoin-query/src/confirm_run/index.rs index 2dc6f3a0d6..a85c1b8c79 100644 --- a/crates/rbitcoin-consensus/src/confirm_run/index.rs +++ b/crates/rbitcoin-query/src/confirm_run/index.rs @@ -11,15 +11,14 @@ use std::collections::HashMap; use std::sync::{Arc, OnceLock}; use std::time::Instant; +use crate::{ConfirmError, ParentPinView, Query}; use bitcoin::hashes::Hash; use bitcoin::{Amount, Block, ScriptBuf, Transaction, TxOut}; use rbitcoin_primitives::{Fk, Height}; -use rbitcoin_query::{ParentPinView, Query}; -use rbitcoin_store::StoreError; use crate::index_rows::{self, IndexRows}; -use crate::silent_payments::{is_p2tr, tweak_from_tx}; -use crate::ConsensusError; +use rbitcoin_consensus::silent_payments::{is_p2tr, tweak_from_tx}; +use rbitcoin_consensus::ConsensusError; use super::{LoadedBatch, Prepared}; @@ -93,7 +92,7 @@ pub(super) fn index_want(query: &Query) -> IndexWant { } } -pub(super) fn prepare_index_jobs(batch: &LoadedBatch) -> Result { +pub(super) fn prepare_index_jobs(batch: &LoadedBatch) -> Result { let want = &batch.index_want; if !want.filters && want.tweak_origin.is_none() { return Ok(IndexJobs { @@ -181,7 +180,7 @@ pub(super) fn prepare_index_jobs(batch: &LoadedBatch) -> Result Result<(), ConsensusError> { - match job { + let result = match job { IndexJob::Filter(work) => { let _ = work.started.set(Instant::now()); apply_filter(work) @@ -190,10 +189,11 @@ pub(super) fn apply_index_job(job: &IndexJob) -> Result<(), ConsensusError> { let _ = work.started.set(Instant::now()); apply_tweak_range(work, txs) } - } + }; + result.map_err(ConfirmError::into_pool) } -fn apply_tweak_range(work: &BlockWork, txs: &[usize]) -> Result<(), ConsensusError> { +fn apply_tweak_range(work: &BlockWork, txs: &[usize]) -> Result<(), ConfirmError> { for &ti in txs { let tx = &work.block.txdata[ti]; let prevouts = prevouts_cached(work, ti)?; @@ -205,7 +205,7 @@ fn apply_tweak_range(work: &BlockWork, txs: &[usize]) -> Result<(), ConsensusErr Ok(()) } -fn apply_filter(work: &BlockWork) -> Result<(), ConsensusError> { +fn apply_filter(work: &BlockWork) -> Result<(), ConfirmError> { let mut prevouts = Vec::with_capacity(work.block.txdata.len()); for ti in 0..work.block.txdata.len() { prevouts.push(prevouts_cached(work, ti)?.clone()); @@ -220,7 +220,7 @@ fn apply_filter(work: &BlockWork) -> Result<(), ConsensusError> { Ok(()) } -pub(super) fn rows_from_heights(slots: &[HeightSlots]) -> Result { +pub(super) fn rows_from_heights(slots: &[HeightSlots]) -> Result { let mut rows = IndexRows::default(); for height in slots { if let Some(slot) = &height.filter { @@ -250,7 +250,7 @@ fn tx_has_taproot_out(tx: &Transaction) -> bool { .any(|out| is_p2tr(out.script_pubkey.as_bytes())) } -fn group_spends(prep: &Prepared, n_tx: usize) -> Result>, ConsensusError> { +fn group_spends(prep: &Prepared, n_tx: usize) -> Result>, ConfirmError> { let mut index_of: HashMap = HashMap::with_capacity(prep.tx_fks.len()); for (i, fk) in prep.tx_fks.iter().copied().enumerate() { if i >= n_tx { @@ -283,7 +283,7 @@ fn basic_filter_from_wire( hash: &[u8; 32], block: &Block, prevouts: &[Vec], -) -> Result { +) -> Result { let outputs = block .txdata .iter() @@ -292,10 +292,10 @@ fn basic_filter_from_wire( .iter() .flatten() .map(|prev| prev.script_pubkey.as_bytes()); - rbitcoin_query::basic_filter_from_scripts(hash, outputs, spent).map_err(ConsensusError::from) + crate::basic_filter_from_scripts(hash, outputs, spent).map_err(ConfirmError::from) } -fn prevouts_cached(work: &BlockWork, ti: usize) -> Result<&Vec, ConsensusError> { +fn prevouts_cached(work: &BlockWork, ti: usize) -> Result<&Vec, ConfirmError> { if let Some(got) = work.prevouts[ti].get() { return Ok(got); } @@ -310,7 +310,7 @@ fn prevouts_for_tx( work: &BlockWork, ti: usize, tx: &Transaction, -) -> Result, ConsensusError> { +) -> Result, ConfirmError> { if tx.is_coinbase() { return Ok(Vec::new()); } @@ -325,7 +325,7 @@ fn prevouts_for_tx( Ok(out) } -fn resolve_prevout(work: &BlockWork, spend: &SpendRef) -> Result { +fn resolve_prevout(work: &BlockWork, spend: &SpendRef) -> Result { if spend.create_fk.is_null() { let same_block = work.same_block.get_or_init(|| { let mut map = HashMap::with_capacity(work.block.txdata.len()); @@ -356,7 +356,7 @@ fn resolve_prevout(work: &BlockWork, spend: &SpendRef) -> Result Result { +pub(super) fn seal_live_indexes(query: &Query, seal: IndexSeal) -> Result { if seal.filters.is_empty() && seal.tweaks.is_empty() { return Ok(0); } @@ -367,25 +367,25 @@ pub(super) fn seal_live_indexes(query: &Query, seal: IndexSeal) -> Result ConsensusError { - ConsensusError::Store(StoreError::Corrupt(msg)) +fn corrupt(msg: &'static str) -> ConfirmError { + ConfirmError::Rules(ConsensusError::Internal(msg)) } #[cfg(test)] mod tests { use super::*; + use crate::testutil::tiny_query_labeled; + use crate::Query; use bitcoin::absolute::LockTime; use bitcoin::hashes::{hash160, Hash}; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, OutPoint, Sequence, TxIn, TxOut, Witness}; - use rbitcoin_query::testutil::tiny_query_labeled; - use rbitcoin_query::Query; - use crate::silent_payments::tweak_records_from_window; - use crate::{ - accept_and_connect_block, confirm_wire_run, genesis_block, mine_empty_regtest, - mine_regtest_paying, ChainParams, Milestone, + use crate::sp_read::tweak_records_from_window; + use crate::{accept_and_connect_block, confirm_wire_run}; + use rbitcoin_consensus::{ + genesis_block, mine_empty_regtest, mine_regtest_paying, ChainParams, Milestone, }; fn keys() -> (Vec, bitcoin::ScriptBuf, bitcoin::ScriptBuf) { @@ -436,7 +436,7 @@ mod tests { /// P2TR once that coinbase is mature, and the blocks between them. #[test] fn live_batch_matches_window_and_reaches_tip() { - let _gate = crate::script_pool::steal_test_gate(); + let _gate = rbitcoin_consensus::script_pool::steal_test_gate(); let (_dir, q) = tiny_query_labeled("idx-live"); enable(&q); go_live(&q); @@ -510,7 +510,7 @@ mod tests { #[test] fn far_watermark_does_not_advance() { - let _gate = crate::script_pool::steal_test_gate(); + let _gate = rbitcoin_consensus::script_pool::steal_test_gate(); let (_dir, q) = tiny_query_labeled("idx-gap"); let params = ChainParams::regtest(); let ms = Milestone::height(u32::MAX); @@ -539,7 +539,7 @@ mod tests { #[test] fn startup_seals_a_short_gap_then_the_batch_appends() { - let _gate = crate::script_pool::steal_test_gate(); + let _gate = rbitcoin_consensus::script_pool::steal_test_gate(); let (_dir, q) = tiny_query_labeled("idx-repair"); let params = ChainParams::regtest(); let ms = Milestone::height(u32::MAX); @@ -560,7 +560,7 @@ mod tests { /// A limit that fits only the tweak hole does not turn live append on. #[test] fn startup_repairs_each_fitting_index() { - let _gate = crate::script_pool::steal_test_gate(); + let _gate = rbitcoin_consensus::script_pool::steal_test_gate(); let (_dir, q) = tiny_query_labeled("idx-split"); let params = ChainParams::regtest(); let ms = Milestone::height(u32::MAX); @@ -759,14 +759,14 @@ mod tests { LoadedBatch { prepared: vec![p0, p1], wire_blocks: vec![Arc::new(b0), Arc::new(b1)], - batch_parents: rbitcoin_query::BatchParents::new(), + batch_parents: crate::BatchParents::new(), script_preverified: super::super::ScriptPreverified::new(), archive_plan: None, index_want: IndexWant { filters: true, tweak_origin: Some(0), }, - stats: Arc::new(rbitcoin_query::ConfirmStats::default()), + stats: Arc::new(crate::ConfirmStats::default()), } } diff --git a/crates/rbitcoin-consensus/src/confirm_run/lookup.rs b/crates/rbitcoin-query/src/confirm_run/lookup.rs similarity index 84% rename from crates/rbitcoin-consensus/src/confirm_run/lookup.rs rename to crates/rbitcoin-query/src/confirm_run/lookup.rs index c25b8aff0d..11705f3d6d 100644 --- a/crates/rbitcoin-consensus/src/confirm_run/lookup.rs +++ b/crates/rbitcoin-query/src/confirm_run/lookup.rs @@ -5,14 +5,14 @@ use super::*; /// Lookup-stamped external parent material for load body denserels. /// /// **Lookup** fills this via `tx.head` / `create.loc` / `txid.body` (never `txout.body`). -/// **Load** denserels by range using only [`rbitcoin_query::ParentIdent`] (+ plan offline pins). +/// **Load** denserels by range using only [`crate::ParentIdent`] (+ plan offline pins). /// Integer create_fk map uses [`U64Map`] (identity hasher) — pack-scale win over SipHash. #[derive(Debug, Default, Clone)] pub struct ParentPinStamp { /// prev_txid → create_fk_id (plan=None edges; empty after `take_from_plan`). - pub resolved: rbitcoin_query::TxidMap, + pub resolved: crate::TxidMap, /// create_fk_id → identity (txid + optional body/spent/pin). - pub idents: rbitcoin_query::U64Map, + pub idents: crate::U64Map, /// create_fk_id → spent need-vouts (packed at lookup; load pin reuses). pub parent_vouts: U64Map>, } @@ -21,9 +21,9 @@ impl ParentPinStamp { /// Move plan stamp identity into the load stamp (no 100k-entry clone). /// /// `resolved` stays empty: plan path pins from packed `create_fk`. - pub(crate) fn take_from_plan(plan: &mut rbitcoin_query::ArchiveWritePlan) -> Self { + pub(crate) fn take_from_plan(plan: &mut crate::ArchiveWritePlan) -> Self { Self { - resolved: rbitcoin_query::TxidMap::with_hasher(Default::default()), + resolved: crate::TxidMap::with_hasher(Default::default()), idents: std::mem::take(&mut plan.external_parents), parent_vouts: std::mem::take(&mut plan.external_parent_vouts), } @@ -53,18 +53,14 @@ impl ParentPinStamp { } #[inline] - pub(super) fn create_pin(&self, create_fk_id: u64) -> Option<&rbitcoin_query::CreatePin> { + pub(super) fn create_pin(&self, create_fk_id: u64) -> Option<&crate::CreatePin> { self.idents.get(&create_fk_id).and_then(|p| p.pin.as_ref()) } } -pub type WireBlockIn = ( - Height, - Arc, - Option>, -); +pub type WireBlockIn = (Height, Arc, Option>); type LookupPhaseOut = ( - Option, + Option, Vec, Vec>, u64, @@ -75,7 +71,7 @@ type LookupPhaseOut = ( /// **No `tx.body` denserels on lookup.** Load denserels by range from /// [`ParentPinStamp`] / plan ranges. Handoff is owned plan + parent pin stamp. pub struct PlanStampOutcome { - pub plan: Option, + pub plan: Option, /// External parent fk/range/txid stamped at lookup (always; including plan=None). pub parent_pin: ParentPinStamp, /// Wall ns for structure + plan_batch (head stamp). @@ -100,14 +96,14 @@ impl PlanStampOutcome { pub(super) fn archived_create_pairs( metas: &[BodyMeta], -) -> Result, ConsensusError> { +) -> Result, ConfirmError> { let mut pairs = Vec::new(); for m in metas { if m.tx_fks.is_empty() { continue; } if m.tx_fks.len() != m.txids.len() { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: archived stamp tx_fks/txids length", ))); } @@ -134,9 +130,9 @@ pub fn confirm_wire_lookup_stamp( milestone: Milestone, blocks: &[WireBlockIn], pipeline: Option<&WireLoadPipeline>, -) -> Result { +) -> Result { let t0 = Instant::now(); - query.on_load_pack().map_err(ConsensusError::from)?; + query.on_load_pack().map_err(ConfirmError::from)?; let (mut plan, metas, wire_blocks, plan_ns) = wire_lookup_phase(query, params, milestone, blocks, pipeline)?; let ifo = pipeline.map(|p| p.in_flight); @@ -156,10 +152,10 @@ pub fn confirm_wire_lookup_stamp( (pin, pairs) } }; - rbitcoin_query::note_confirm(&query.confirm_stats().lookup_blocks, blocks.len() as u64); - rbitcoin_query::note_confirm(&query.confirm_stats().lookup_head_ns, plan_ns); + crate::note_confirm(&query.confirm_stats().lookup_blocks, blocks.len() as u64); + crate::note_confirm(&query.confirm_stats().lookup_head_ns, plan_ns); let work_ns = t0.elapsed().as_nanos() as u64; - rbitcoin_query::note_confirm(&query.confirm_stats().lookup_total_ns, work_ns); + crate::note_confirm(&query.confirm_stats().lookup_total_ns, work_ns); Ok(PlanStampOutcome { plan, parent_pin, @@ -180,11 +176,11 @@ pub(super) fn stamp_parent_pin_archived( params: &ChainParams, metas: &[BodyMeta], wire_blocks: &[Arc], - in_flight: Option<&rbitcoin_query::InFlight>, - skeleton: Option<&rbitcoin_query::BatchParentIds>, + in_flight: Option<&crate::InFlight>, + skeleton: Option<&crate::BatchParentIds>, carried_need: Option<&[[u8; 32]]>, -) -> Result { - let mut same_batch: rbitcoin_query::TxidMap = rbitcoin_query::TxidMap::default(); +) -> Result { + let mut same_batch: crate::TxidMap = crate::TxidMap::default(); for m in metas { for (tid, fk) in m.txids.iter().zip(m.tx_fks.iter()) { if let Some(id) = fk.get() { @@ -193,7 +189,7 @@ pub(super) fn stamp_parent_pin_archived( } } // Value: a wire input spends this txid (BIP30 probes are `false`). - let mut need_external: rbitcoin_query::TxidMap = rbitcoin_query::TxidMap::default(); + let mut need_external: crate::TxidMap = crate::TxidMap::default(); if skeleton.is_some() { for &prev in carried_need.unwrap_or(&[]) { if same_batch.contains_key(&prev) { @@ -228,25 +224,25 @@ pub(super) fn stamp_parent_pin_archived( } } } - let empty = rbitcoin_query::InFlight::new(); + let empty = crate::InFlight::new(); let ifo = in_flight.unwrap_or(&empty); let need_vec: Vec<[u8; 32]> = need_external.keys().copied().collect(); - let ext = rbitcoin_query::stamp_external_parents( + let ext = crate::stamp_external_parents( query.store(), &need_vec, ifo, skeleton, query.confirm_stats(), ) - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; if need_external .iter() .any(|(txid, &spent)| spent && !ext.resolved.contains_key(txid)) { - return Err(ConsensusError::MissingPrevout); + rbitcoin_consensus::block::reject_unresolved_spend(true)?; } let mut stamp = ParentPinStamp { - resolved: rbitcoin_query::TxidMap::with_capacity_and_hasher( + resolved: crate::TxidMap::with_capacity_and_hasher( ext.resolved.len().saturating_add(same_batch.len()), Default::default(), ), @@ -263,23 +259,23 @@ pub(super) fn stamp_parent_pin_archived( stamp .idents .entry(id) - .or_insert_with(|| rbitcoin_query::ParentIdent::new(tid)) + .or_insert_with(|| crate::ParentIdent::new(tid)) .txid = tid; } // Identities are stamped from wire prev_txid at insert time — never soft-fill // from txid.body here (that would be a dual path after lookup promised identity). if skeleton.is_none() { - rbitcoin_query::fill_missing_parent_ranges( + crate::fill_missing_parent_ranges( query.store(), ifo, &mut stamp.idents, query.confirm_stats(), ) - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; } for ident in stamp.idents.values() { if ident.txid == [0u8; 32] { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: plan=None parent stamp zero create identity", ))); } @@ -300,8 +296,8 @@ pub fn parent_missing_from_chain( query: &Query, block: &Block, stop: impl Fn() -> bool, -) -> Result, ConsensusError> { - let created: rbitcoin_query::TxidSet = block +) -> Result, ConfirmError> { + let created: crate::TxidSet = block .txdata .iter() .map(|tx| tx.compute_txid().to_byte_array()) @@ -317,10 +313,10 @@ pub fn parent_missing_from_chain( need.sort_unstable(); need.dedup(); let unsynced_from = query.store().head_unsynced_first_fk(); - let hits: rbitcoin_query::TxidSet = query + let hits: crate::TxidSet = query .store() .get_fk_by_txid_batch(&need) - .map_err(ConsensusError::from)? + .map_err(ConfirmError::from)? .into_iter() .filter_map(|(txid, row)| row.map(|_| txid)) .collect(); @@ -333,7 +329,7 @@ pub fn parent_missing_from_chain( .connected_in_unsynced_head(unsynced_from, &need, stop)? .is_empty() { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: tx.head misses a connected create", ))); } @@ -352,7 +348,7 @@ pub fn confirm_wire_load_from_plan( stamped: PlanStampOutcome, pipeline: Option<&WireLoadPipeline>, preverified: &ScriptPreverified, -) -> Result { +) -> Result { let t_work = Instant::now(); let t_load = Instant::now(); let PlanStampOutcome { @@ -376,7 +372,7 @@ pub fn confirm_wire_load_from_plan( ifo, )?; - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().load_ns, t_load.elapsed().as_nanos() as u64, ); @@ -398,12 +394,12 @@ pub fn confirm_wire_load_from_plan( if let Some(ref mut plan) = plan { if !plan.packed.is_empty() { if tx_fees.len() != plan.packed.len() { - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( "invariant: assemble tx fees length", ))); } if tx_sizes.len() != plan.packed.len() { - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( "invariant: txstat size length", ))); } @@ -434,7 +430,7 @@ pub(super) fn wire_lookup_phase( milestone: Milestone, blocks: &[WireBlockIn], pipeline: Option<&WireLoadPipeline>, -) -> Result { +) -> Result { lookup_require_contiguous(blocks)?; let mut wire_blocks: Vec> = Vec::with_capacity(blocks.len()); @@ -457,11 +453,15 @@ pub(super) fn wire_lookup_phase( let hash = block.block_hash().to_byte_array(); let ctx = ValidationContext::at(params, *height, milestone); let t_struct = Instant::now(); - let pres = crate::block::validate_block_structure_with_pres( + let stats = query.confirm_stats(); + let mut note = |txid_ns, wtxid_ns, walk_ns| { + stats.note_struct_parts(txid_ns, wtxid_ns, walk_ns); + }; + let pres = rbitcoin_consensus::block::validate_block_structure_with_pres( block.as_ref(), &ctx, caller_pres.as_ref().map(Arc::clone), - Some(query.confirm_stats()), + Some(&mut note), )?; let txids: Vec<[u8; 32]> = pres.iter().map(|p| p.txid).collect(); struct_ns = struct_ns.saturating_add(t_struct.elapsed().as_nanos() as u64); @@ -487,16 +487,17 @@ pub(super) fn wire_lookup_phase( if block.header.prev_blockhash.to_byte_array() == [0u8; 32] { rbitcoin_primitives::Fk::NULL } else { - query - .get_header_by_hash(block.header.prev_blockhash.as_byte_array()) - .map_err(ConsensusError::from)? - .map(|(fk, _)| fk) - .ok_or(ConsensusError::BadPrev)? + rbitcoin_consensus::block::require_linked_parent( + query + .get_header_by_hash(block.header.prev_blockhash.as_byte_array()) + .map_err(ConfirmError::from)? + .map(|(fk, _)| fk), + )? } } else { metas[i - 1].header_fk }; - let header_rec = crate::header_to_record(prev_fk, &block.header, hash); + let header_rec = crate::convert::header_to_record(prev_fk, &block.header, hash); prepare_ns = prepare_ns.saturating_add(t_prep.elapsed().as_nanos() as u64); let t_put = Instant::now(); let header_fk = stamp_header_fk(query, &header_rec, carried.map(|(fk, _)| fk))?; @@ -525,14 +526,14 @@ pub(super) fn wire_lookup_phase( fn check_carried_header_lens( pipeline: Option<&WireLoadPipeline>, n_blocks: usize, -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { let Some(p) = pipeline else { return Ok(()); }; if (!p.carried_header_fks.is_empty() || !p.carried_header_hashes.is_empty()) && (p.carried_header_fks.len() != n_blocks || p.carried_header_hashes.len() != n_blocks) { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: carried header length", ))); } @@ -565,24 +566,23 @@ fn stamp_check_header_link( hash: [u8; 32], carried: Option<(rbitcoin_primitives::Fk, [u8; 32])>, prev_meta_hash: Option<[u8; 32]>, -) -> Result<(), ConsensusError> { - if i == 0 && height.0 != path_lo { - return Err(ConsensusError::BadPrev); +) -> Result<(), ConfirmError> { + if i == 0 { + rbitcoin_consensus::block::reject_batch_starts_at(height.0, path_lo)?; } let prev_bytes = header.prev_blockhash.to_byte_array(); if let Some((_, expect)) = carried { - if hash != expect { - return Err(ConsensusError::BadBlock("carried header hash mismatch")); - } + rbitcoin_consensus::block::reject_carried_header_hash(&hash, &expect)?; if i == 0 { if path_lo != store_path_lo { let expect_prev = pipeline.and_then(|p| p.parent_hash).unwrap_or([0u8; 32]); - if prev_bytes != expect_prev { - return Err(ConsensusError::BadPrev); - } + rbitcoin_consensus::block::reject_prev_link(&prev_bytes, &expect_prev)?; } - } else if prev_bytes != prev_meta_hash.unwrap_or([0u8; 32]) { - return Err(ConsensusError::BadPrev); + } else { + rbitcoin_consensus::block::reject_prev_link( + &prev_bytes, + &prev_meta_hash.unwrap_or([0u8; 32]), + )?; } return Ok(()); } @@ -591,16 +591,12 @@ fn stamp_check_header_link( validate_header_hashed(query, params, height, header, hash)?; } else { let expect_prev = pipeline.and_then(|p| p.parent_hash).unwrap_or([0u8; 32]); - if prev_bytes != expect_prev { - return Err(ConsensusError::BadPrev); - } + rbitcoin_consensus::block::reject_prev_link(&prev_bytes, &expect_prev)?; pow_hash_meets_target(hash, header.bits, params.pow_limit)?; } return Ok(()); } - if prev_bytes != prev_meta_hash.unwrap_or([0u8; 32]) { - return Err(ConsensusError::BadPrev); - } + rbitcoin_consensus::block::reject_prev_link(&prev_bytes, &prev_meta_hash.unwrap_or([0u8; 32]))?; pow_hash_meets_target(hash, header.bits, params.pow_limit)?; Ok(()) } @@ -609,40 +605,35 @@ fn stamp_header_fk( query: &Query, header_rec: &rbitcoin_store::HeaderRecord, carried: Option, -) -> Result { +) -> Result { if let Some(fk) = carried { match query.store().get_header(fk) { Ok(rec) if rec.hash == header_rec.hash => { - rbitcoin_query::note_confirm(&query.confirm_stats().phase_prep_header_skip_n, 1); + crate::note_confirm(&query.confirm_stats().phase_prep_header_skip_n, 1); return Ok(fk); } Ok(_) | Err(StoreError::NotFound) => {} - Err(e) => return Err(ConsensusError::from(e)), + Err(e) => return Err(ConfirmError::from(e)), } } if let Some((fk, _)) = query .get_header_by_hash(&header_rec.hash) - .map_err(ConsensusError::from)? + .map_err(ConfirmError::from)? { Ok(fk) } else { query .store() .put_header(header_rec) - .map_err(ConsensusError::from) + .map_err(ConfirmError::from) } } -fn lookup_require_contiguous(blocks: &[WireBlockIn]) -> Result<(), ConsensusError> { - if blocks.is_empty() { - return Err(ConsensusError::BadBlock("empty confirm batch")); - } - for w in blocks.windows(2) { - if w[1].0 .0 != w[0].0 .0.saturating_add(1) { - return Err(ConsensusError::BadBlock("confirm run not contiguous")); - } - } - Ok(()) +fn lookup_require_contiguous(blocks: &[WireBlockIn]) -> Result<(), ConfirmError> { + rbitcoin_consensus::block::reject_confirm_batch( + &blocks.iter().map(|(h, _, _)| h.0).collect::>(), + ) + .map_err(ConfirmError::from) } fn lookup_note_stamp( @@ -659,23 +650,23 @@ fn lookup_note_stamp( .note_stamp(struct_ns, prepare_ns, filter_ns, batch_ns); // `note_confirm` already ignores a zero add. A `> 0` gate here is the // same call, and `>` versus `==` would drop a real sample. - rbitcoin_query::note_confirm(&query.confirm_stats().phase_prep_struct_ns, struct_ns); - rbitcoin_query::note_confirm(&query.confirm_stats().phase_prep_header_ns, header_ns); - rbitcoin_query::note_confirm(&query.confirm_stats().phase_prep_prepare_ns, prepare_ns); - rbitcoin_query::note_confirm(&query.confirm_stats().phase_prep_filter_plan_ns, plan_ns); + crate::note_confirm(&query.confirm_stats().phase_prep_struct_ns, struct_ns); + crate::note_confirm(&query.confirm_stats().phase_prep_header_ns, header_ns); + crate::note_confirm(&query.confirm_stats().phase_prep_prepare_ns, prepare_ns); + crate::note_confirm(&query.confirm_stats().phase_prep_filter_plan_ns, plan_ns); } fn lookup_bind_have_body( query: &Query, metas: &mut [BodyMeta], wire_blocks: &[Arc], -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { for (i, m) in metas.iter_mut().enumerate() { if let Some(list) = query .store() .header_txs .get_list(m.header_fk) - .map_err(ConsensusError::from)? + .map_err(ConfirmError::from)? { m.tx_fks = list; } @@ -697,13 +688,13 @@ fn lookup_bind_need_body<'a>( metas: &'a mut [BodyMeta], wire_blocks: &'a [Arc], need_fks: &[rbitcoin_primitives::Fk], -) -> Result { +) -> Result { let mut need = Vec::with_capacity(need_fks.len()); for fk in need_fks { let i = metas .iter() .position(|m| m.header_fk == *fk) - .ok_or(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( + .ok_or(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( "invariant: need-body header_fk not in batch", )))?; need.push((*fk, &wire_blocks[i], metas[i].txids.as_slice())); @@ -717,16 +708,16 @@ fn lookup_bind_need_body<'a>( p.skeleton.as_ref(), p.skeleton.is_some().then_some(p.carried_need.as_slice()), ) - .map_err(ConsensusError::from)?, + .map_err(ConfirmError::from)?, None => query .archive_plan_batch_from_wire( &need, query.tx_body_count().saturating_add(1).max(1), - &rbitcoin_query::InFlight::new(), + &crate::InFlight::new(), None, None, ) - .map_err(ConsensusError::from)?, + .map_err(ConfirmError::from)?, }; let by_header = create_fks_from_header_ranges(&plan.per_header_ranges); for (i, m) in metas.iter_mut().enumerate() { @@ -752,12 +743,12 @@ fn lookup_bind_archive_plan( pipeline: Option<&WireLoadPipeline>, metas: &mut [BodyMeta], wire_blocks: &[Arc], -) -> Result<(Option, u64, u64), ConsensusError> { +) -> Result<(Option, u64, u64), ConfirmError> { let t_filter = Instant::now(); let header_fks: Vec = metas.iter().map(|m| m.header_fk).collect(); let need_fks = query .archive_filter_need_header_fks(&header_fks) - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; confirm_archive_kind(header_fks.len(), need_fks.len())?; let filter_ns = t_filter.elapsed().as_nanos() as u64; let t_batch = Instant::now(); @@ -786,13 +777,13 @@ pub(super) enum ConfirmArchiveKind { pub(super) fn confirm_archive_kind( n_headers: usize, n_need: usize, -) -> Result { +) -> Result { if n_need == 0 { Ok(ConfirmArchiveKind::AllHaveBody) } else if n_need == n_headers { Ok(ConfirmArchiveKind::AllNeedBody) } else { - Err(ConsensusError::Store(StoreError::Corrupt( + Err(ConfirmError::Store(StoreError::Corrupt( "invariant: confirm batch mixed archived", ))) } @@ -818,6 +809,7 @@ pub(super) fn create_fks_from_header_ranges( #[cfg(test)] mod tests { use super::*; + use crate::IdMap; use bitcoin::absolute::LockTime; use bitcoin::hashes::Hash; use bitcoin::transaction::Version as TxVersion; @@ -825,10 +817,9 @@ mod tests { Amount, Block, CompactTarget, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxMerkleNode, TxOut, Txid, Witness, }; - use rbitcoin_query::IdMap; use rbitcoin_store::HeaderRecord; - fn tmp_query() -> (rbitcoin_query::testutil::TempDir, Query) { - rbitcoin_query::testutil::tiny_query_labeled("stamp-archived") + fn tmp_query() -> (crate::testutil::TempDir, Query) { + crate::testutil::tiny_query_labeled("stamp-archived") } fn spend_block(prev: [u8; 32]) -> Block { @@ -864,7 +855,7 @@ mod tests { /// plan=None rehydrate and the query helper stamp the same skeleton parent. #[test] fn archived_stamp_matches_shared_helper_on_skeleton() { - use rbitcoin_query::BatchParentIds; + use crate::BatchParentIds; let (path, q) = tmp_query(); let params = ChainParams::regtest(); let parent_txid = { @@ -876,15 +867,15 @@ mod tests { m.insert(parent_txid, (rbitcoin_primitives::Fk(88), (4000, 32))); let skel = BatchParentIds { ids: std::sync::Arc::new(m), - spent: std::sync::Arc::new(rbitcoin_query::U64Map::default()), + spent: std::sync::Arc::new(crate::U64Map::default()), n_out: Default::default(), - need_vouts: rbitcoin_query::U64Map::default(), + need_vouts: crate::U64Map::default(), }; - let helper = rbitcoin_query::stamp_external_parents( + let helper = crate::stamp_external_parents( q.store(), &[parent_txid], - &rbitcoin_query::InFlight::new(), + &crate::InFlight::new(), Some(&skel), q.confirm_stats(), ) @@ -949,8 +940,8 @@ mod tests { #[test] fn stamp_uses_caller_pres() { use crate::accept_and_connect_block; - use crate::regtest_pad::mine_empty_regtest; - use rbitcoin_query::TxPrecompute; + use crate::TxPrecompute; + use rbitcoin_consensus::mine_empty_regtest; let (path, q) = tmp_query(); let params = ChainParams::regtest(); @@ -1003,7 +994,7 @@ mod tests { #[test] fn stamp_skips_header_ensure_when_pipeline_carries_fk() { use crate::accept_and_connect_block; - use crate::regtest_pad::mine_empty_regtest; + use rbitcoin_consensus::mine_empty_regtest; use std::sync::atomic::Ordering; let (path, q) = tmp_query(); @@ -1020,7 +1011,7 @@ mod tests { let rec = crate::header_to_record(genesis_fk, &b1.header, hash); let hfk = q.store().put_header(&rec).unwrap(); let n_headers = q.store().headers.count(); - let inflight = rbitcoin_query::InFlight::new(); + let inflight = crate::InFlight::new(); let pipe = WireLoadPipeline { path_lo: 1, parent_hash: None, @@ -1060,7 +1051,7 @@ mod tests { carried_header_hashes: vec![[0x11; 32]], }; match confirm_wire_lookup_stamp(&q, ¶ms, Milestone::NONE, &items, Some(&pipe_bad)) { - Err(ConsensusError::BadBlock("carried header hash mismatch")) => {} + Err(ConfirmError::Rules(ConsensusError::BadBlock("carried header hash mismatch"))) => {} Err(e) => panic!("expected hash mismatch, got {e}"), Ok(_) => panic!("expected hash mismatch, got Ok"), } @@ -1070,7 +1061,7 @@ mod tests { #[test] fn stamp_does_not_bind_carried_fk_when_store_hash_differs() { use crate::accept_and_connect_block; - use crate::regtest_pad::mine_empty_regtest; + use rbitcoin_consensus::mine_empty_regtest; use std::sync::atomic::Ordering; let (path, q) = tmp_query(); @@ -1085,7 +1076,7 @@ mod tests { let b1 = mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); let hash = b1.block_hash().to_byte_array(); let n_headers = q.store().headers.count(); - let inflight = rbitcoin_query::InFlight::new(); + let inflight = crate::InFlight::new(); let pipe = WireLoadPipeline { path_lo: 1, parent_hash: None, @@ -1117,7 +1108,7 @@ mod tests { #[test] fn stamp_rejects_carried_header_length_mismatch() { use crate::accept_and_connect_block; - use crate::regtest_pad::mine_empty_regtest; + use rbitcoin_consensus::mine_empty_regtest; let (path, q) = tmp_query(); let params = ChainParams::regtest(); @@ -1125,7 +1116,7 @@ mod tests { accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); let b1 = mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); let hash = b1.block_hash().to_byte_array(); - let inflight = rbitcoin_query::InFlight::new(); + let inflight = crate::InFlight::new(); let pipe = WireLoadPipeline { path_lo: 1, parent_hash: None, @@ -1148,14 +1139,12 @@ mod tests { }; let items = [(Height(1), Arc::new(b1), None)]; match confirm_wire_lookup_stamp(&q, ¶ms, Milestone::NONE, &items, Some(&pipe)) { - Err(ConsensusError::Store(StoreError::Corrupt("invariant: carried header length"))) => { - } + Err(ConfirmError::Store(StoreError::Corrupt("invariant: carried header length"))) => {} Err(e) => panic!("expected carried header length, got {e}"), Ok(_) => panic!("expected carried header length, got Ok"), } match confirm_wire_lookup_stamp(&q, ¶ms, Milestone::NONE, &items, Some(&fks_only)) { - Err(ConsensusError::Store(StoreError::Corrupt("invariant: carried header length"))) => { - } + Err(ConfirmError::Store(StoreError::Corrupt("invariant: carried header length"))) => {} Err(e) => panic!("one carried side must still be checked, got {e}"), Ok(_) => panic!("empty fks with a hash must fail the length check"), } @@ -1192,7 +1181,7 @@ mod tests { fn archived_create_pairs_len_mismatch_is_corrupt() { let meta = meta_with(vec![[1u8; 32], [2u8; 32]], vec![rbitcoin_primitives::Fk(1)]); match archived_create_pairs(std::slice::from_ref(&meta)) { - Err(ConsensusError::Store(StoreError::Corrupt(m))) => { + Err(ConfirmError::Store(StoreError::Corrupt(m))) => { assert_eq!(m, "invariant: archived stamp tx_fks/txids length"); } other => panic!("expected length Corrupt, got {other:?}"), diff --git a/crates/rbitcoin-consensus/src/confirm_run/mod.rs b/crates/rbitcoin-query/src/confirm_run/mod.rs similarity index 90% rename from crates/rbitcoin-consensus/src/confirm_run/mod.rs rename to crates/rbitcoin-query/src/confirm_run/mod.rs index 2f209da7f4..d7e5f4027a 100644 --- a/crates/rbitcoin-consensus/src/confirm_run/mod.rs +++ b/crates/rbitcoin-query/src/confirm_run/mod.rs @@ -26,21 +26,16 @@ //! in height order once both waves have finished. Steal workers unpark the //! publisher when a wave completes. -use crate::block::{ - assemble_block_prevouts, block_has_witness_from_pres, structural_validate_spends, - ScriptCheckJob, ValidationContext, -}; -use crate::error::ConsensusError; -use crate::header::{ - check_header_version_and_future_time, median_time_past_times, pow_hash_meets_target, - validate_header_hashed, -}; -use crate::milestone::Milestone; -use crate::params::{genesis_block, ChainParams}; +use crate::block_pipeline::{assemble_block_prevouts, structural_validate_spends}; +use crate::header_read::validate_header_hashed; +use crate::ConfirmError; +use crate::{FkMap, Query, U32Map, U64Map}; use bitcoin::hashes::Hash; use bitcoin::Block; +use rbitcoin_consensus::block::{block_has_witness_from_pres, ScriptCheckJob, ValidationContext}; +use rbitcoin_consensus::header::pow_hash_meets_target; +use rbitcoin_consensus::{genesis_block, ChainParams, ConsensusError, Milestone}; use rbitcoin_primitives::Height; -use rbitcoin_query::{FkMap, Query, U32Map, U64Map}; use rbitcoin_store::{StoreError, WriteIoBackend}; use std::sync::atomic::Ordering; use std::sync::Arc; @@ -104,7 +99,7 @@ struct BodyMeta { /// at structure/entry (plan or archived load). Assemble must use these only. txids: Vec<[u8; 32]>, /// Same walk as `txids` (lookup/structure). Script jobs reuse these. - pres: std::sync::Arc<[rbitcoin_query::TxPrecompute]>, + pres: std::sync::Arc<[crate::TxPrecompute]>, } /// Assemble output for one height (held through scripts → write). @@ -151,9 +146,9 @@ pub struct WireLoadPipeline<'a> { /// Inclusive create-fk start for [`Query::archive_plan_batch_from_wire`]. pub next_tx_start: u64, /// Prior uncommitted packs (load-thread map; stamp/pin borrow). - pub in_flight: &'a rbitcoin_query::InFlight, + pub in_flight: &'a crate::InFlight, /// Lookup-filled parent identity for this load batch (IBD skeleton). - pub skeleton: Option, + pub skeleton: Option, /// External prev_txids from the BQ input walk (IBD skeleton stamp; no second wire collect). pub carried_need: Vec<[u8; 32]>, /// BQ `header_fk` per block (empty = stamp ensure/put). Length matches `carried_header_hashes`. @@ -174,14 +169,14 @@ pub struct LoadedBatch { /// Shared wire (Arc) so load→scripts→write does not deep-clone full blocks. wire_blocks: Vec>, /// Per-batch pin map: load → assemble → write structural, then drop. - batch_parents: rbitcoin_query::BatchParents, + batch_parents: crate::BatchParents, /// Mempool preverified txids for scripts stage (tip follow); empty on IBD. script_preverified: ScriptPreverified, /// Planned Class A write from wire lookup/load (committed in write stage). - pub archive_plan: Option, + pub archive_plan: Option, /// Indexes to assemble. Copied from `index_live` at load. index_want: index::IndexWant, - stats: Arc, + stats: Arc, } /// Script-verified batch ready for ordered commit (Class A + structural + C). @@ -190,8 +185,8 @@ pub struct LoadedBatch { pub struct ScriptOkBatch { prepared: Vec, wire_blocks: Vec>, - batch_parents: rbitcoin_query::BatchParents, - pub archive_plan: Option, + batch_parents: crate::BatchParents, + pub archive_plan: Option, /// Filter bytes and tweak vecs for this batch. Empty when the indexes are off. index_seal: index::IndexSeal, } @@ -234,7 +229,7 @@ pub fn confirm_wire_load_phase( milestone: Milestone, blocks: &[(Height, Block)], preverified: &ScriptPreverified, -) -> Result { +) -> Result { confirm_wire_load_phase_pipelined(query, params, milestone, blocks, preverified, None) } @@ -249,7 +244,7 @@ fn wire_blocks_to_arcs(query: &Query, blocks: &[(Height, Block)]) -> Vec 0 { - rbitcoin_query::note_confirm(&query.confirm_stats().phase_prep_wire_arc_ns, ns); + crate::note_confirm(&query.confirm_stats().phase_prep_wire_arc_ns, ns); } arcs } @@ -265,15 +260,10 @@ pub fn confirm_wire_load_phase_pipelined( blocks: &[(Height, Block)], preverified: &ScriptPreverified, pipeline: Option<&WireLoadPipeline>, -) -> Result { - if blocks.is_empty() { - return Err(ConsensusError::BadBlock("empty confirm batch")); - } - for w in blocks.windows(2) { - if w[1].0 .0 != w[0].0 .0.saturating_add(1) { - return Err(ConsensusError::BadBlock("confirm run not contiguous")); - } - } +) -> Result { + rbitcoin_consensus::block::reject_confirm_batch( + &blocks.iter().map(|(h, _)| h.0).collect::>(), + )?; let arcs = wire_blocks_to_arcs(query, blocks); let stamped = confirm_wire_lookup_stamp(query, params, milestone, &arcs, pipeline)?; confirm_wire_load_from_plan(query, params, milestone, stamped, pipeline, preverified) @@ -285,7 +275,7 @@ pub fn confirm_wire_run( params: &ChainParams, milestone: Milestone, blocks: &[(Height, Block)], -) -> Result, ConsensusError> { +) -> Result, ConfirmError> { let arcs: Vec = blocks .iter() .map(|(h, b)| (*h, Arc::new(b.clone()), None)) @@ -307,10 +297,8 @@ pub fn confirm_wire_run_preverified( milestone: Milestone, blocks: &[WireBlockIn], preverified: &ScriptPreverified, -) -> Result, ConsensusError> { - if blocks.is_empty() { - return Err(ConsensusError::BadBlock("empty confirm batch")); - } +) -> Result, ConfirmError> { + rbitcoin_consensus::block::reject_empty_confirm_batch(blocks.is_empty())?; let stamped = confirm_wire_lookup_stamp(query, params, milestone, blocks, None)?; let mat = confirm_wire_load_from_plan(query, params, milestone, stamped, None, preverified)?; let ok = confirm_scripts_phase(mat.batch)?; @@ -372,9 +360,9 @@ impl ScriptOkBatch { } /// Wire headers in the same order as [`Self::heights_hashes`]. - pub fn wire_headers(&self) -> Result, ConsensusError> { + pub fn wire_headers(&self) -> Result, ConfirmError> { if self.wire_blocks.len() != self.prepared.len() { - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( "invariant: wire headers length", ))); } diff --git a/crates/rbitcoin-consensus/src/confirm_run/phases.rs b/crates/rbitcoin-query/src/confirm_run/phases.rs similarity index 63% rename from crates/rbitcoin-consensus/src/confirm_run/phases.rs rename to crates/rbitcoin-query/src/confirm_run/phases.rs index 53557b7bb8..9e84f8a38a 100644 --- a/crates/rbitcoin-consensus/src/confirm_run/phases.rs +++ b/crates/rbitcoin-query/src/confirm_run/phases.rs @@ -1,66 +1,66 @@ //! Shared confirm phase helpers (load/assemble/structural/script/commit). use super::*; -use rbitcoin_query::FkMap; +use crate::FkMap; fn assemble_parent_mtp_and_bits( query: &Query, params: &ChainParams, height: Height, header: &bitcoin::block::Header, -) -> Result<(u32, Vec), ConsensusError> { + hash: [u8; 32], +) -> Result<(u32, Vec), ConfirmError> { let prev_h = Height(height.0 - 1); let start = prev_h.0.saturating_sub(10); - let prev_hash = header.prev_blockhash.to_byte_array(); let mut times = Vec::with_capacity(11); - let mut prev_bits_raw: Option = None; - let mut prev_time: Option = None; + let mut parent: Option = None; for h in start..=prev_h.0 { - if let Some(plan) = query.confirm_parent_cache().get_header_plan(h) { - times.push(plan.header_rec.timestamp); - if h == prev_h.0 { - if plan.header_rec.hash != prev_hash { - return Err(ConsensusError::BadPrev); - } - prev_bits_raw = Some(plan.header_rec.bits); - prev_time = Some(plan.header_rec.timestamp); - } - } else if let Some((_fk, rec)) = query - .header_at_height(Height(h)) - .map_err(ConsensusError::from)? - { - times.push(rec.timestamp); - if h == prev_h.0 { - if rec.hash != prev_hash { - return Err(ConsensusError::BadPrev); - } - prev_bits_raw = Some(rec.bits); - prev_time = Some(rec.timestamp); - } - } else { - return Err(ConsensusError::Store(StoreError::Corrupt( - "confirm: load incomplete (parent header plan missing above tip)", - ))); + let (rec_hash, timestamp, bits) = + if let Some(plan) = query.confirm_parent_cache().get_header_plan(h) { + ( + plan.header_rec.hash, + plan.header_rec.timestamp, + plan.header_rec.bits, + ) + } else if let Some((_fk, rec)) = query + .header_at_height(Height(h)) + .map_err(ConfirmError::from)? + { + (rec.hash, rec.timestamp, rec.bits) + } else { + return Err(ConfirmError::Store(StoreError::Corrupt( + "confirm: load incomplete (parent header plan missing above tip)", + ))); + }; + times.push(timestamp); + if h == prev_h.0 { + parent = Some(rbitcoin_consensus::ParentHeader { + hash: rec_hash, + time: timestamp, + bits: bitcoin::CompactTarget::from_consensus(bits), + }); } } - let mtp = median_time_past_times(×) - .map_err(|_| ConsensusError::BadHeader("empty median time"))?; - if header.time <= mtp { - return Err(ConsensusError::BadHeader("timestamp <= median-time-past")); - } - check_header_version_and_future_time(params, height, header)?; - let (Some(prev_bits_raw), Some(prev_time)) = (prev_bits_raw, prev_time) else { - return Err(ConsensusError::Store(StoreError::Corrupt( + let parent = parent.ok_or_else(|| { + ConfirmError::Store(StoreError::Corrupt( "confirm: load incomplete (parent header plan missing above tip)", - ))); - }; - let prev_bits = bitcoin::CompactTarget::from_consensus(prev_bits_raw); - let expected = - expected_bits_extending(query, params, height, prev_bits, prev_time, header.time)?; - if header.bits != expected { - return Err(ConsensusError::BadHeader("incorrect proof of work bits")); - } - Ok((mtp, times)) + )) + })?; + let period_first = retarget_first_time(query, params, height.0)?; + rbitcoin_consensus::check_header( + params, + height, + header, + hash, + Some(parent), + ×, + period_first, + |h| header_bits_plan_or_store(query, h), + )?; + Ok(( + rbitcoin_consensus::block::median_or_bad_header(×)?, + times, + )) } fn assemble_chained_header( @@ -68,24 +68,63 @@ fn assemble_chained_header( params: &ChainParams, height: Height, header: &bitcoin::block::Header, + hash: [u8; 32], prev: &Prepared, time_window: &[u32], -) -> Result { - if header.prev_blockhash.to_byte_array() != prev.hash { - return Err(ConsensusError::BadPrev); - } - let mtp = median_time_past_times(time_window) - .map_err(|_| ConsensusError::BadHeader("empty median time"))?; - if header.time <= mtp { - return Err(ConsensusError::BadHeader("timestamp <= median-time-past")); +) -> Result { + let period_first = retarget_first_time(query, params, height.0)?; + rbitcoin_consensus::check_header( + params, + height, + header, + hash, + Some(rbitcoin_consensus::ParentHeader { + hash: prev.hash, + time: prev.time, + bits: prev.bits, + }), + time_window, + period_first, + |h| header_bits_plan_or_store(query, h), + )?; + Ok(rbitcoin_consensus::block::median_or_bad_header( + time_window, + )?) +} + +fn header_bits_plan_or_store(query: &Query, h: u32) -> Option { + if let Some((_fk, rec)) = query.header_at_height(Height(h)).ok().flatten() { + return Some(bitcoin::CompactTarget::from_consensus(rec.bits)); } - check_header_version_and_future_time(params, height, header)?; - let expected = - expected_bits_extending(query, params, height, prev.bits, prev.time, header.time)?; - if header.bits != expected { - return Err(ConsensusError::BadHeader("incorrect proof of work bits")); + query + .confirm_parent_cache() + .get_header_plan(h) + .map(|plan| bitcoin::CompactTarget::from_consensus(plan.header_rec.bits)) +} + +fn retarget_first_time( + query: &Query, + params: &ChainParams, + height: u32, +) -> Result, ConfirmError> { + let interval = params.difficulty_adjustment_interval(); + let on_boundary = interval > 0 && height.is_multiple_of(interval); + if !on_boundary || params.no_pow_retargeting() { + return Ok(None); } - Ok(mtp) + let first_height = Height(height - interval); + let first = if let Some((_fk, rec)) = query.header_at_height(first_height)? { + Some(rec.timestamp) + } else { + query + .confirm_parent_cache() + .get_header_plan(first_height.0) + .map(|plan| plan.header_rec.timestamp) + }; + // Missing period-start is the header rule, decided in consensus. + Ok(rbitcoin_consensus::block::require_retarget_first( + true, false, first, + )?) } /// Median-time-past uses the last 11 timestamps. Older ones drop. @@ -107,12 +146,12 @@ pub(super) fn assemble_run( milestone: Milestone, metas: Vec, wire_blocks: &[Arc], - batch_parents: &rbitcoin_query::BatchParents, - spend_edges: &rbitcoin_query::SpendEdges, -) -> Result { + batch_parents: &crate::BatchParents, + spend_edges: &crate::SpendEdges, +) -> Result { // Provisional same-run double-spend only (not durable spentness). - let mut pending_spent: rbitcoin_query::OutPointSet = Default::default(); - let mut pending_creates = crate::block::PendingCreates::default(); + let mut pending_spent: crate::OutPointSet = Default::default(); + let mut pending_creates = rbitcoin_consensus::block::PendingCreates::default(); let mut time_window: Vec = Vec::with_capacity(11); let mut prepared: Vec = Vec::with_capacity(metas.len()); let mut tx_fees: Vec = Vec::new(); @@ -131,7 +170,7 @@ pub(super) fn assemble_run( if i == 0 { if height.0 >= 1 { let (mtp, times) = - assemble_parent_mtp_and_bits(query, params, height, &block.header)?; + assemble_parent_mtp_and_bits(query, params, height, &block.header, block_hash)?; prev_mtp = mtp; time_window = times; } else { @@ -144,28 +183,34 @@ pub(super) fn assemble_run( params, height, &block.header, + block_hash, &prepared[i - 1], &time_window, )?; } - if block_has_witness_from_pres(&meta.pres) && !params.segwit_active_at(height.0) { - return Err(ConsensusError::BadBlock("unexpected witness before segwit")); - } + rbitcoin_consensus::block::reject_witness_before_segwit( + block_has_witness_from_pres(&meta.pres), + params.segwit_active_at(height.0), + )?; // BIP325: full signet challenge on tip confirm only. if height.0 > 0 { if let Some(challenge) = params.signet_challenge.as_ref() { - crate::signet::validate_signet_block_solution(block, challenge.as_script())?; + rbitcoin_consensus::validate_signet_block_solution(block, challenge.as_script())?; } } - let bip16_active = - crate::block::bip16_active_from_prev_mtp(params, height.0, &block_hash, prev_mtp); + let bip16_active = rbitcoin_consensus::block::bip16_active_from_prev_mtp( + params, + height.0, + &block_hash, + prev_mtp, + ); let t_connect = Instant::now(); let build_script_jobs = - !crate::milestone::skips_on_query(milestone, query, height.0, &block_hash); + !crate::milestone_gate::skips_on_query(milestone, query, height.0, &block_hash); let (script_jobs, spends, fees, block_fees, meters) = assemble_block_prevouts( block.as_ref(), &ctx, @@ -183,10 +228,10 @@ pub(super) fn assemble_run( build_script_jobs, )?; meters.acc.flush(query.confirm_stats()); - rbitcoin_query::note_confirm(&query.confirm_stats().asm_prevout_ns, meters.prev_ns); - rbitcoin_query::note_confirm(&query.confirm_stats().asm_job_ns, meters.job_ns); + crate::note_confirm(&query.confirm_stats().asm_prevout_ns, meters.prev_ns); + crate::note_confirm(&query.confirm_stats().asm_job_ns, meters.job_ns); if meta.pres.len() != block.txdata.len() { - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( "invariant: txstat size pres", ))); } @@ -198,7 +243,7 @@ pub(super) fn assemble_run( tx_sizes.push((base, total)); } tx_fees.extend(block_fees); - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().connect_ns, t_connect.elapsed().as_nanos() as u64, ); @@ -213,7 +258,12 @@ pub(super) fn assemble_run( jobs: script_jobs, spends, fees, - check_scripts: crate::milestone::check_scripts(milestone, query, height.0, &block_hash), + check_scripts: crate::milestone_gate::check_scripts( + milestone, + query, + height.0, + &block_hash, + ), time: block.header.time, bits: block.header.bits, hash: block_hash, @@ -230,8 +280,8 @@ pub(super) fn assemble_run( /// Scratch reused by the confirm write thread. Cleared at each batch. #[derive(Default)] pub(super) struct StructuralReuse { - pub scratch: crate::block::StructuralScratch, - pub pending: rbitcoin_query::OutPointSet, + pub scratch: crate::block_pipeline::StructuralScratch, + pub pending: crate::OutPointSet, } /// Durable spentness + maturity + subsidy after scripts (height order). @@ -242,14 +292,20 @@ pub(super) fn structural_run( milestone: Milestone, prepared: &[Prepared], wire_blocks: &[Arc], - batch_parents: &rbitcoin_query::BatchParents, - abs_jobs: &[Vec], - class_a_wave: &crate::block::ClassAWave, + batch_parents: &crate::BatchParents, + abs_jobs: &[Vec], + class_a_wave: &crate::block_pipeline::ClassAWave, reuse: &mut StructuralReuse, -) -> Result<(crate::block::StructuralPhaseNs, crate::block::AnnotateSlots), ConsensusError> { - use crate::block::StructuralPhaseNs; +) -> Result< + ( + crate::block_pipeline::StructuralPhaseNs, + crate::block_pipeline::AnnotateSlots, + ), + ConfirmError, +> { + use crate::block_pipeline::StructuralPhaseNs; if abs_jobs.len() != prepared.len() { - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( "invariant: spend abs jobs length", ))); } @@ -257,7 +313,7 @@ pub(super) fn structural_run( // Slots and the pack-local double-spend set are this batch only. reuse.scratch.begin_batch(); reuse.pending.clear(); - let index = crate::block::RunCreateHeight::from_blocks( + let index = crate::block_pipeline::RunCreateHeight::from_blocks( prepared.iter().map(|p| (p.height.0, p.tx_fks.as_slice())), ); let mut mtp_cache: U32Map = U32Map::default(); @@ -293,47 +349,47 @@ pub(super) fn structural_run( tot.bip68_ns = tot.bip68_ns.saturating_add(ph.bip68_ns); } // Window counters (may race with sampler; last-write uses `tot` instead). - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().structural_ns, t0.elapsed().as_nanos() as u64, ); - rbitcoin_query::note_confirm(&query.confirm_stats().structural_spent_ns, tot.spent_ns); - rbitcoin_query::note_confirm( + crate::note_confirm(&query.confirm_stats().structural_spent_ns, tot.spent_ns); + crate::note_confirm( &query.confirm_stats().structural_spent_abs_ns, tot.spent_abs_ns, ); - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().structural_spent_strong_ns, tot.spent_strong_ns, ); - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().structural_spent_cold_ns, tot.spent_cold_ns, ); - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().structural_spent_pending_ns, tot.spent_pending_ns, ); - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().structural_create_h_ns, tot.create_h_ns, ); - rbitcoin_query::note_confirm(&query.confirm_stats().structural_bip68_ns, tot.bip68_ns); + crate::note_confirm(&query.confirm_stats().structural_bip68_ns, tot.bip68_ns); Ok((tot, std::mem::take(&mut reuse.scratch.slots))) } pub(super) fn class_c_commit( query: &Query, prepared: &mut [Prepared], - write_create_pins: &FkMap, -) -> Result, ConsensusError> { + write_create_pins: &FkMap, +) -> Result, ConfirmError> { use std::sync::atomic::Ordering as QOrd; let strong0 = query.confirm_stats().strong_ns.load(QOrd::Relaxed); let tip0 = query.confirm_stats().tip_ns.load(QOrd::Relaxed); - let items: Vec = prepared + let items: Vec = prepared .iter_mut() - .map(|p| rbitcoin_query::ConfirmPrepared { + .map(|p| crate::ConfirmPrepared { height: p.height, header_fk: p.header_fk, tx_fks: std::mem::take(&mut p.tx_fks), @@ -347,7 +403,7 @@ pub(super) fn class_c_commit( }; let out = query .confirm_blocks_run_with_create_pins(&items, pins) - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; let strong_d = query .confirm_stats() .strong_ns @@ -358,7 +414,7 @@ pub(super) fn class_c_commit( .tip_ns .load(QOrd::Relaxed) .saturating_sub(tip0); - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().class_c_ns, strong_d.saturating_add(tip_d), ); @@ -370,8 +426,8 @@ pub(super) fn class_c_commit( /// Pure-write annotate from structural slots (no pin `get_spender_abs`). pub(super) fn post_commit( query: &Query, - slots: &crate::block::AnnotateSlots, -) -> Result { + slots: &crate::block_pipeline::AnnotateSlots, +) -> Result { let t_spent = Instant::now(); if query.spend_index_enabled() && !slots.abs_edges.is_empty() { let _utxo_view = query.store().hold_utxo_view(); @@ -380,30 +436,30 @@ pub(super) fn post_commit( let cold = query .store() .put_spend_batch_by_abs_meta_known(&slots.abs_edges, &slots.known, backend) - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; if !cold.is_empty() { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: spend annotate abs cold (OOB or IO); load/layout bug", ))); } let ann_ns = t_ann.elapsed().as_nanos() as u64; - rbitcoin_query::note_confirm(&query.confirm_stats().spend_ann_ns, ann_ns); - rbitcoin_query::note_confirm( + crate::note_confirm(&query.confirm_stats().spend_ann_ns, ann_ns); + crate::note_confirm( &query.confirm_stats().spend_ann_n, slots.abs_edges.len() as u64, ); let _ = backend; - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().spend_annotate_ranged, slots.abs_edges.len() as u64, ); - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().spend_ann_pread_skip, slots.abs_edges.len() as u64, ); } let spend_ann_ns = t_spent.elapsed().as_nanos() as u64; - rbitcoin_query::note_confirm(&query.confirm_stats().utxo_apply_ns, spend_ann_ns); + crate::note_confirm(&query.confirm_stats().utxo_apply_ns, spend_ann_ns); Ok(spend_ann_ns) } @@ -414,60 +470,33 @@ pub(super) fn expected_bits_extending( prev_bits: bitcoin::CompactTarget, prev_time: u32, header_time: u32, -) -> Result { - use bitcoin::CompactTarget; +) -> Result { if height.0 == 0 { return Ok(genesis_block(params).header.bits); } - let interval = params.difficulty_adjustment_interval(); - let on_boundary = interval > 0 && height.0.is_multiple_of(interval); - let period_first = if on_boundary && !params.no_pow_retargeting() { - // Period-start may still be above confirmed tip during tip-ahead - // multi-block load (i>0). Lookup/load already put_header_plan. - let first_height = Height(height.0 - interval); - let first_ts = if let Some((_fk, rec)) = query - .header_at_height(first_height) - .map_err(ConsensusError::from)? - { - rec.timestamp - } else if let Some(plan) = query.confirm_parent_cache().get_header_plan(first_height.0) { - plan.header_rec.timestamp - } else { - return Err(ConsensusError::BadHeader("missing retarget first header")); - }; - Some(first_ts) - } else { - None - }; - crate::header::next_work_bits( + let period_first = retarget_first_time(query, params, height.0)?; + rbitcoin_consensus::block::require_next_work(rbitcoin_consensus::header::next_work_bits( params, height.0, prev_bits, prev_time, header_time, period_first, - |h| { - if let Some((_fk, rec)) = query.header_at_height(Height(h)).ok().flatten() { - return Some(CompactTarget::from_consensus(rec.bits)); - } - query - .confirm_parent_cache() - .get_header_plan(h) - .map(|plan| CompactTarget::from_consensus(plan.header_rec.bits)) - }, - ) - .ok_or(ConsensusError::BadPrev) + |h| header_bits_plan_or_store(query, h), + )) + .map_err(ConfirmError::from) } #[cfg(test)] mod tests { use super::*; - use crate::regtest_pad::mine_empty_regtest; - use crate::{accept_and_connect_block, ChainParams, Milestone}; + use crate::accept_and_connect_block; + use crate::TxPrecompute; use bitcoin::blockdata::constants; use bitcoin::ScriptBuf; + use rbitcoin_consensus::mine_empty_regtest; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::{Fk, Height}; - use rbitcoin_query::TxPrecompute; use rbitcoin_store::HeaderRecord; use std::sync::Arc; @@ -485,8 +514,8 @@ mod tests { assert_eq!(window, (4..=14).collect::>()); } - fn tiny() -> (rbitcoin_query::testutil::TempDir, rbitcoin_query::Query) { - rbitcoin_query::testutil::tiny_query_labeled("phases-pin") + fn tiny() -> (crate::testutil::TempDir, crate::Query) { + crate::testutil::tiny_query_labeled("phases-pin") } #[test] @@ -533,8 +562,8 @@ mod tests { Milestone::NONE, vec![meta], std::slice::from_ref(&block), - &rbitcoin_query::BatchParents::new(), - &rbitcoin_query::SpendEdges::default(), + &crate::BatchParents::new(), + &crate::SpendEdges::default(), ) { Ok(_) => panic!("height 1 with a challenge must reject a block that has no solution"), Err(e) => e, diff --git a/crates/rbitcoin-consensus/src/confirm_run/pin.rs b/crates/rbitcoin-query/src/confirm_run/pin.rs similarity index 80% rename from crates/rbitcoin-consensus/src/confirm_run/pin.rs rename to crates/rbitcoin-query/src/confirm_run/pin.rs index baf7e40f52..1f491d3f89 100644 --- a/crates/rbitcoin-consensus/src/confirm_run/pin.rs +++ b/crates/rbitcoin-query/src/confirm_run/pin.rs @@ -2,19 +2,19 @@ use super::*; -type CreatePin = rbitcoin_query::CreatePin; +type CreatePin = crate::CreatePin; struct PlanSpend<'a> { - spend_edges: std::borrow::Cow<'a, rbitcoin_query::SpendEdges>, + spend_edges: std::borrow::Cow<'a, crate::SpendEdges>, parent_vouts: U64Map>, vouts_from_stamp: bool, batch_pin_by_id: U64Map<&'a CreatePin>, } fn spend_edges_from_plan<'a>( - plan: &'a rbitcoin_query::ArchiveWritePlan, + plan: &'a crate::ArchiveWritePlan, parent_pin: &mut ParentPinStamp, -) -> Result, ConsensusError> { +) -> Result, ConfirmError> { let mut batch_pin_by_id: U64Map<&CreatePin> = U64Map::default(); // Reachable plans store the same pin Arc in `packed` and `batch_pin`. // Indexing `packed` is that pin either way. @@ -24,7 +24,7 @@ fn spend_edges_from_plan<'a>( } } if plan.edges.is_empty() && !plan.planned_fks.is_empty() { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: plan spend edges empty", ))); } @@ -59,8 +59,8 @@ fn spend_edges_from_stamp( parent_pin: &ParentPinStamp, metas: &[BodyMeta], wire_blocks: &[Arc], -) -> (rbitcoin_query::SpendEdges, U64Map>) { - let mut spend_edges = rbitcoin_query::SpendEdges::default(); +) -> (crate::SpendEdges, U64Map>) { + let mut spend_edges = crate::SpendEdges::default(); let mut parent_vouts: U64Map> = U64Map::default(); for (m, block) in metas.iter().zip(wire_blocks.iter()) { for (ti, tx) in block.txdata.iter().enumerate() { @@ -71,7 +71,7 @@ fn spend_edges_from_stamp( for (vin, inp) in tx.input.iter().enumerate() { let vin = vin as u32; if inp.previous_output.is_null() { - edges.push(rbitcoin_query::SpendEdge { + edges.push(crate::SpendEdge { prev_txid: [0u8; 32], vout: u32::MAX, spend_fk: rbitcoin_primitives::Fk(sfk), @@ -83,7 +83,7 @@ fn spend_edges_from_stamp( let prev_txid = inp.previous_output.txid.to_byte_array(); let vout = inp.previous_output.vout; if let Some(&pid) = parent_pin.resolved.get(&prev_txid) { - edges.push(rbitcoin_query::SpendEdge { + edges.push(crate::SpendEdge { prev_txid, vout, spend_fk: rbitcoin_primitives::Fk(sfk), @@ -93,7 +93,7 @@ fn spend_edges_from_stamp( parent_vouts.entry(pid).or_default().push(vout); continue; } - edges.push(rbitcoin_query::SpendEdge { + edges.push(crate::SpendEdge { prev_txid, vout, spend_fk: rbitcoin_primitives::Fk(sfk), @@ -111,8 +111,8 @@ fn fill_pins( parent_vouts: &U64Map>, batch_pin_by_id: &U64Map<&CreatePin>, parent_pin: &ParentPinStamp, - in_flight: Option<&rbitcoin_query::InFlight>, - stats: &rbitcoin_query::ConfirmStats, + in_flight: Option<&crate::InFlight>, + stats: &crate::ConfirmStats, ) -> U64Map { let mut plan_by_id: U64Map = U64Map::default(); if let Some(ifo) = in_flight { @@ -148,7 +148,7 @@ fn fill_pins( plan_by_id.insert(*id, pin); } let recent_outs_ns = t_recent.elapsed().as_nanos() as u64; - rbitcoin_query::note_confirm(&stats.pin_recent_outs_ns, recent_outs_ns); + crate::note_confirm(&stats.pin_recent_outs_ns, recent_outs_ns); plan_by_id } @@ -166,7 +166,7 @@ fn apply_plan_pins( parent_vouts: &U64Map>, plan_by_id: &U64Map, parent_pin: &ParentPinStamp, - batch_parents: &mut rbitcoin_query::BatchParents, + batch_parents: &mut crate::BatchParents, still_need: &mut U64Map>, ) -> u64 { let mut n_plan_pin = 0u64; @@ -217,13 +217,13 @@ fn reject_vout_past_n_out( parent_vouts: &U64Map>, parent_pin: &ParentPinStamp, plan_by_id: &U64Map, -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { for (id, need) in parent_vouts { let Some(&max_vout) = need.iter().max() else { continue; }; let wire_n_out = || match plan_by_id.get(id).map(|p| &***p) { - Some(rbitcoin_query::CreatePinInner::Wire { + Some(crate::CreatePinInner::Wire { block, tx_index, .. }) => block .txdata @@ -232,9 +232,7 @@ fn reject_vout_past_n_out( _ => None, }; let n_out = parent_pin.n_out(*id).map(u64::from).or_else(wire_n_out); - if n_out.is_some_and(|n| u64::from(max_vout) >= n) { - return Err(ConsensusError::MissingPrevout); - } + rbitcoin_consensus::block::reject_vout_past_n_out(max_vout, n_out)?; } Ok(()) } @@ -244,8 +242,8 @@ fn denserels_by_stamped_range( query: &Query, parent_pin: &ParentPinStamp, still_need: &mut U64Map>, - batch_parents: &mut rbitcoin_query::BatchParents, -) -> Result<(u64, u64), ConsensusError> { + batch_parents: &mut crate::BatchParents, +) -> Result<(u64, u64), ConfirmError> { let mut range_jobs: Vec<(rbitcoin_primitives::Fk, (u64, u64), [u8; 32], u32, Vec)> = Vec::new(); let pending = std::mem::take(still_need); @@ -260,7 +258,7 @@ fn denserels_by_stamped_range( }; let tid = parent_pin.create_txid(id); let Some(tid) = tid else { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: lookup stage miss (load parent create identity not stamped)", ))); }; @@ -273,31 +271,31 @@ fn denserels_by_stamped_range( let (decoded, body_ns, dec_ns, extend_n, body_sqe_n, guess_full_n) = query .store() .get_outs_by_range_batch(&range_jobs) - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; let rng_ns = body_ns.saturating_add(dec_ns); // Zero adds are a no-op inside `note_confirm`. - rbitcoin_query::note_confirm(&query.confirm_stats().cold_io_ns, rng_ns); - rbitcoin_query::note_confirm(&query.confirm_stats().cold_range_ns, rng_ns); - rbitcoin_query::note_confirm(&query.confirm_stats().cold_range_body_ns, body_ns); - rbitcoin_query::note_confirm(&query.confirm_stats().cold_range_decode_ns, dec_ns); - rbitcoin_query::note_confirm(&query.confirm_stats().cold_range_n, n_range); - rbitcoin_query::note_confirm(&query.confirm_stats().cold_range_extend_n, extend_n); - rbitcoin_query::note_confirm(&query.confirm_stats().cold_range_body_sqe_n, body_sqe_n); - rbitcoin_query::note_confirm(&query.confirm_stats().cold_range_guess_full_n, guess_full_n); - rbitcoin_query::note_confirm(&query.confirm_stats().body_tx_reads, n_range); - rbitcoin_query::note_confirm(&query.confirm_stats().pin_new, n_range); + crate::note_confirm(&query.confirm_stats().cold_io_ns, rng_ns); + crate::note_confirm(&query.confirm_stats().cold_range_ns, rng_ns); + crate::note_confirm(&query.confirm_stats().cold_range_body_ns, body_ns); + crate::note_confirm(&query.confirm_stats().cold_range_decode_ns, dec_ns); + crate::note_confirm(&query.confirm_stats().cold_range_n, n_range); + crate::note_confirm(&query.confirm_stats().cold_range_extend_n, extend_n); + crate::note_confirm(&query.confirm_stats().cold_range_body_sqe_n, body_sqe_n); + crate::note_confirm(&query.confirm_stats().cold_range_guess_full_n, guess_full_n); + crate::note_confirm(&query.confirm_stats().body_tx_reads, n_range); + crate::note_confirm(&query.confirm_stats().pin_new, n_range); let t_range_fill = Instant::now(); for ((fk, range, _tid, _n_out, need), row) in range_jobs.into_iter().zip(decoded) { let Some(id) = fk.get() else { continue; }; let Some((mut tx, live, sparse)) = row else { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: load denserels by range returned none for stamped parent", ))); }; if live.len() != need.len() { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: load denserels by range incomplete outs for need_vouts", ))); } @@ -305,7 +303,7 @@ fn denserels_by_stamped_range( tx.txid = parent_pin .create_txid(id) - .ok_or(ConsensusError::Store(StoreError::Corrupt( + .ok_or(ConfirmError::Store(StoreError::Corrupt( "invariant: lookup stage miss (load parent create identity not stamped)", )))?; } @@ -318,7 +316,7 @@ fn denserels_by_stamped_range( } let range_fill_ns = t_range_fill.elapsed().as_nanos() as u64; // `note_confirm` ignores a zero add, so a `> 0` gate is the same call. - rbitcoin_query::note_confirm(&query.confirm_stats().pin_range_fill_ns, range_fill_ns); + crate::note_confirm(&query.confirm_stats().pin_range_fill_ns, range_fill_ns); Ok((n_range, rng_ns)) } @@ -330,18 +328,12 @@ fn denserels_by_stamped_range( /// `spent_range` onto pins. Write [`collect_spend_abs_after_fill`] is abs-or-Corrupt. pub(super) fn pin_for_wire_batch<'a>( query: &Query, - plan: Option<&'a rbitcoin_query::ArchiveWritePlan>, + plan: Option<&'a crate::ArchiveWritePlan>, parent_pin: &mut ParentPinStamp, metas: &[BodyMeta], wire_blocks: &[Arc], - in_flight: Option<&rbitcoin_query::InFlight>, -) -> Result< - ( - rbitcoin_query::BatchParents, - std::borrow::Cow<'a, rbitcoin_query::SpendEdges>, - ), - ConsensusError, -> { + in_flight: Option<&crate::InFlight>, +) -> Result<(crate::BatchParents, std::borrow::Cow<'a, crate::SpendEdges>), ConfirmError> { let t_pin = Instant::now(); let t_thin = Instant::now(); @@ -379,9 +371,9 @@ pub(super) fn pin_for_wire_batch<'a>( ); reject_vout_past_n_out(&parent_vouts, parent_pin, &plan_by_id)?; - let mut batch_parents = rbitcoin_query::BatchParents::with_capacity(parent_vouts.len()); + let mut batch_parents = crate::BatchParents::with_capacity(parent_vouts.len()); let thin_ns = t_thin.elapsed().as_nanos() as u64; - rbitcoin_query::note_confirm(&query.confirm_stats().thin_ns, thin_ns); + crate::note_confirm(&query.confirm_stats().thin_ns, thin_ns); let mut still_need: U64Map> = U64Map::default(); let t_plan = Instant::now(); @@ -404,7 +396,7 @@ pub(super) fn pin_for_wire_batch<'a>( } if !still_need.is_empty() { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: lookup stage miss (load parent without body_range denserels)", ))); } @@ -427,12 +419,12 @@ pub(super) fn pin_for_wire_batch<'a>( let contract_ns = t_contract.elapsed().as_nanos() as u64; let n_unique = parent_vouts.len() as u64; - rbitcoin_query::note_confirm(&query.confirm_stats().parent_unique, n_unique); - rbitcoin_query::note_confirm(&query.confirm_stats().utxo_parents, n_unique); - rbitcoin_query::note_confirm(&query.confirm_stats().pin_plan, n_plan_pin); - rbitcoin_query::note_confirm(&query.confirm_stats().pin_cache_body, n_plan_pin); - rbitcoin_query::note_confirm(&query.confirm_stats().plan_pin_ns, plan_pin_ns); - rbitcoin_query::note_confirm(&query.confirm_stats().pin_contract_ns, contract_ns); + crate::note_confirm(&query.confirm_stats().parent_unique, n_unique); + crate::note_confirm(&query.confirm_stats().utxo_parents, n_unique); + crate::note_confirm(&query.confirm_stats().pin_plan, n_plan_pin); + crate::note_confirm(&query.confirm_stats().pin_cache_body, n_plan_pin); + crate::note_confirm(&query.confirm_stats().plan_pin_ns, plan_pin_ns); + crate::note_confirm(&query.confirm_stats().pin_contract_ns, contract_ns); query.confirm_stats().note_last_pin( plan_pin_ns, cold_range_batch_ns, @@ -441,11 +433,11 @@ pub(super) fn pin_for_wire_batch<'a>( n_range_new, ); let pin_ns = t_pin.elapsed().as_nanos() as u64; - rbitcoin_query::note_confirm(&query.confirm_stats().parent_pin_ns, pin_ns); - rbitcoin_query::note_confirm(&query.confirm_stats().pin_body_ns, pin_ns); - rbitcoin_query::note_confirm(&query.confirm_stats().load_win_ns, pin_ns); + crate::note_confirm(&query.confirm_stats().parent_pin_ns, pin_ns); + crate::note_confirm(&query.confirm_stats().pin_body_ns, pin_ns); + crate::note_confirm(&query.confirm_stats().load_win_ns, pin_ns); let n_blks = metas.len() as u64; - rbitcoin_query::note_confirm(&query.confirm_stats().load_blocks, n_blks); + crate::note_confirm(&query.confirm_stats().load_blocks, n_blks); Ok((batch_parents, spend_edges)) } @@ -456,9 +448,9 @@ pub(super) fn pin_for_wire_batch<'a>( /// before `tx.head` insert and passes the jobs to structural, which does not /// look them up again. Null create fk is skipped; duplicate abs is one job. pub(super) fn collect_spend_abs_after_fill( - batch_parents: &rbitcoin_query::BatchParents, + batch_parents: &crate::BatchParents, prepared: &[Prepared], -) -> Result>, ConsensusError> { +) -> Result>, ConfirmError> { let mut out = Vec::with_capacity(prepared.len()); for p in prepared { let jobs = batch_parents @@ -468,7 +460,7 @@ pub(super) fn collect_spend_abs_after_fill( .map(|&(_txid, vout, sfk, cfk, vin)| (cfk, vout, sfk, vin)), ) .map_err(|_| { - ConsensusError::Store(StoreError::Corrupt( + ConfirmError::Store(StoreError::Corrupt( "invariant: ensure denserels/abs incomplete for spend edge", )) })?; @@ -487,16 +479,16 @@ pub(super) fn collect_spend_abs_after_fill( /// [`collect_spend_abs_after_fill`] is the write-phase post-condition. #[cfg(test)] pub(super) fn ensure_spend_abs_layouts( - batch_parents: &rbitcoin_query::BatchParents, + batch_parents: &crate::BatchParents, prepared: &[Prepared], -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { for p in prepared { for &(_txid, vout, sfk, cfk, _vin) in &p.spends { if sfk.is_null() || cfk.is_null() { continue; } if batch_parents.get_spender_abs(cfk, vout).is_none() { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: ensure denserels/abs incomplete for spend edge", ))); } @@ -508,12 +500,12 @@ pub(super) fn ensure_spend_abs_layouts( #[cfg(test)] mod tests { use super::*; + use crate::{CreatePin, ParentIdent, U64Map}; use rbitcoin_primitives::Fk; - use rbitcoin_query::{CreatePin, ParentIdent, U64Map}; use rbitcoin_store::{OutputRecord, TxRecord}; fn pin_with(txid: [u8; 32], inputs: u32) -> CreatePin { - rbitcoin_query::CreatePinInner::records( + crate::CreatePinInner::records( TxRecord { txid, version: 1, @@ -531,7 +523,7 @@ mod tests { fn plan_pin_coinbase_flag_follows_input_count() { let one = pin_with([0x31; 32], 1); let two = pin_with([0x32; 32], 2); - let mut parents = rbitcoin_query::BatchParents::new(); + let mut parents = crate::BatchParents::new(); parents.insert_owned( Fk(1), one.tx().clone(), @@ -566,7 +558,7 @@ mod tests { "a one-input pin still publishes a body range" ); - let mut fresh = rbitcoin_query::BatchParents::new(); + let mut fresh = crate::BatchParents::new(); let mut by_two: U64Map = U64Map::default(); by_two.insert(2, two); let mut need: U64Map> = U64Map::default(); @@ -585,7 +577,7 @@ mod tests { empty_need.insert(3, Vec::new()); let mut by_empty: U64Map = U64Map::default(); by_empty.insert(3, pin_with([0x33; 32], 1)); - let mut dest = rbitcoin_query::BatchParents::new(); + let mut dest = crate::BatchParents::new(); let mut still = U64Map::default(); apply_plan_pins( &empty_need, diff --git a/crates/rbitcoin-consensus/src/confirm_run/scripts.rs b/crates/rbitcoin-query/src/confirm_run/scripts.rs similarity index 91% rename from crates/rbitcoin-consensus/src/confirm_run/scripts.rs rename to crates/rbitcoin-query/src/confirm_run/scripts.rs index e87216cbc8..7ff5a9f985 100644 --- a/crates/rbitcoin-consensus/src/confirm_run/scripts.rs +++ b/crates/rbitcoin-query/src/confirm_run/scripts.rs @@ -2,8 +2,8 @@ use super::index::{self, IndexJob}; use super::*; -use crate::block::{verify_one_script_job, ScriptCheckJob}; -use crate::script_pool::{ +use rbitcoin_consensus::block::{verify_one_script_job, ScriptCheckJob}; +use rbitcoin_consensus::script_pool::{ fg_has_unclaimed, help_steal, set_script_publisher, start_for_each_owned, start_for_each_pooled, OwnedWave, }; @@ -20,7 +20,7 @@ const SCRIPT_WAVES_MAX: usize = 4; fn take_script_jobs( prepared: &mut [Prepared], preverified: &ScriptPreverified, - stats: &rbitcoin_query::ConfirmStats, + stats: &crate::ConfirmStats, ) -> Vec { let mut jobs = Vec::new(); let mut n_skip = 0u64; @@ -40,9 +40,9 @@ fn take_script_jobs( p.jobs.shrink_to_fit(); } if n_skip > 0 { - rbitcoin_query::note_confirm(&stats.script_skip_mempool, n_skip); + crate::note_confirm(&stats.script_skip_mempool, n_skip); } - rbitcoin_query::note_confirm(&stats.script_jobs, jobs.len() as u64); + crate::note_confirm(&stats.script_jobs, jobs.len() as u64); jobs } @@ -53,10 +53,10 @@ fn outcome_from( seal: super::index::IndexSeal, idx_asm_ns: u64, ) -> ConfirmScriptOutcome { - rbitcoin_query::note_confirm(&batch.stats.script_ns, work_ns); + crate::note_confirm(&batch.stats.script_ns, work_ns); if idx_asm_ns > 0 { - rbitcoin_query::note_confirm(&batch.stats.idx_asm_ns, idx_asm_ns); - rbitcoin_query::note_confirm(&batch.stats.blockfilter_ns, idx_asm_ns); + crate::note_confirm(&batch.stats.idx_asm_ns, idx_asm_ns); + crate::note_confirm(&batch.stats.blockfilter_ns, idx_asm_ns); } ConfirmScriptOutcome { batch: ScriptOkBatch { @@ -94,7 +94,7 @@ impl Inflight { fn start( mut batch: LoadedBatch, mat_ns: u64, - ) -> Result { + ) -> Result { let t0 = Instant::now(); let meta = ScriptsBatchMeta::from_batch(&batch, mat_ns); let scripts = @@ -106,11 +106,11 @@ impl Inflight { let index_started = index_jobs.started; let script_wave = match start_for_each_owned(scripts, apply_script) { Ok(w) => w, - Err(e) => return Err((e, meta)), + Err(e) => return Err((ConfirmError::from(e), meta)), }; let index_wave = match start_for_each_pooled(index_jobs.jobs, index::apply_index_job, 1) { Ok(w) => w, - Err(e) => return Err((e, meta)), + Err(e) => return Err((ConfirmError::from(e), meta)), }; let inf = Self { batch, @@ -158,7 +158,7 @@ impl Inflight { scripts_done && index_done } - fn finish(mut self) -> Result<(ConfirmScriptOutcome, ScriptsBatchMeta), ConsensusError> { + fn finish(mut self) -> Result<(ConfirmScriptOutcome, ScriptsBatchMeta), ConfirmError> { let script_res = match self.script_wave.take() { Some(w) => w.finish(), None => Ok(()), @@ -189,7 +189,7 @@ impl Inflight { } } -pub fn confirm_scripts_phase(batch: LoadedBatch) -> Result { +pub fn confirm_scripts_phase(batch: LoadedBatch) -> Result { match Inflight::start(batch, 0) { Ok(inf) => inf.finish().map(|(o, _)| o), Err((e, _)) => Err(e), @@ -206,7 +206,7 @@ pub fn drive_script_waves_with( mat_rx: &std::sync::mpsc::Receiver<(LoadedBatch, u64)>, mut on_take: impl FnMut(&LoadedBatch, Duration), mut on_ok: impl FnMut(ConfirmScriptOutcome, ScriptsBatchMeta) -> bool, - mut on_err: impl FnMut(ConsensusError, ScriptsBatchMeta, &[ScriptsBatchMeta]) -> bool, + mut on_err: impl FnMut(ConfirmError, ScriptsBatchMeta, &[ScriptsBatchMeta]) -> bool, mut should_stop: impl FnMut() -> bool, ) { struct ClearPublisher; @@ -263,7 +263,7 @@ fn drop_inflight_tail(inflight: &mut VecDeque) -> Vec, on_ok: &mut impl FnMut(ConfirmScriptOutcome, ScriptsBatchMeta) -> bool, - on_err: &mut impl FnMut(ConsensusError, ScriptsBatchMeta, &[ScriptsBatchMeta]) -> bool, + on_err: &mut impl FnMut(ConfirmError, ScriptsBatchMeta, &[ScriptsBatchMeta]) -> bool, ) -> bool { while inflight.front().is_some_and(Inflight::is_complete) { let front = inflight.pop_front().expect("front"); @@ -289,7 +289,7 @@ fn drive_try_start( inflight: &mut VecDeque, mat_rx: &std::sync::mpsc::Receiver<(LoadedBatch, u64)>, on_take: &mut impl FnMut(&LoadedBatch, Duration), - on_err: &mut impl FnMut(ConsensusError, ScriptsBatchMeta, &[ScriptsBatchMeta]) -> bool, + on_err: &mut impl FnMut(ConfirmError, ScriptsBatchMeta, &[ScriptsBatchMeta]) -> bool, ) -> DriveStart { if fg_has_unclaimed() || inflight.len() >= SCRIPT_WAVES_MAX { return DriveStart::Idle; @@ -327,7 +327,7 @@ fn drive_blocking_start( inflight: &mut VecDeque, mat_rx: &std::sync::mpsc::Receiver<(LoadedBatch, u64)>, on_take: &mut impl FnMut(&LoadedBatch, Duration), - on_err: &mut impl FnMut(ConsensusError, ScriptsBatchMeta, &[ScriptsBatchMeta]) -> bool, + on_err: &mut impl FnMut(ConfirmError, ScriptsBatchMeta, &[ScriptsBatchMeta]) -> bool, ) -> DriveStart { let t_recv = Instant::now(); match mat_rx.recv() { diff --git a/crates/rbitcoin-consensus/src/confirm_run/spend_durable_tests.rs b/crates/rbitcoin-query/src/confirm_run/spend_durable_tests.rs similarity index 92% rename from crates/rbitcoin-consensus/src/confirm_run/spend_durable_tests.rs rename to crates/rbitcoin-query/src/confirm_run/spend_durable_tests.rs index e0f04fe614..4a3d8cf719 100644 --- a/crates/rbitcoin-consensus/src/confirm_run/spend_durable_tests.rs +++ b/crates/rbitcoin-query/src/confirm_run/spend_durable_tests.rs @@ -11,8 +11,9 @@ use bitcoin::{ Txid, Witness, }; -use crate::block::bip34_height_script; -use crate::{accept_and_connect_block, ChainParams, ConsensusError, Milestone}; +use crate::{accept_and_connect_block, ConfirmError}; +use rbitcoin_consensus::block::bip34_height_script; +use rbitcoin_consensus::{ChainParams, ConsensusError, Milestone}; use rbitcoin_primitives::Height; use rbitcoin_store::spent_abs; @@ -89,7 +90,7 @@ fn mine(prev: bitcoin::BlockHash, time: u32, height: u32, extra: Vec 0, @@ -508,8 +518,8 @@ fn failed_post_tip_stage_does_not_let_the_next_block_respend() { /// over the reconnected height, or the next open does not replay its spends. #[test] fn checkpoint_after_reorg_does_not_skip_the_reconnected_spends() { - let _gate = crate::script_pool::steal_test_gate(); - let (dir, q) = rbitcoin_query::testutil::tiny_query_labeled("spend-reorg-snapshot"); + let _gate = rbitcoin_consensus::script_pool::steal_test_gate(); + let (dir, q) = crate::testutil::tiny_query_labeled("spend-reorg-snapshot"); let params = ChainParams::regtest(); let ms = Milestone::NONE; let maturity = params.coinbase_maturity(); @@ -549,7 +559,7 @@ fn checkpoint_after_reorg_does_not_skip_the_reconnected_spends() { assert!( matches!( err, - ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( + ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( "invariant: live index commit" )) ), @@ -558,11 +568,11 @@ fn checkpoint_after_reorg_does_not_skip_the_reconnected_spends() { assert_eq!(q.tip_height(), Some(Height(hy)), "Class C committed Y"); let q = std::sync::Arc::new(q); - rbitcoin_query::SpendSync::spawn(std::sync::Arc::clone(&q)).shutdown(); + crate::SpendSync::spawn(std::sync::Arc::clone(&q)).shutdown(); let store = q.store().path().to_path_buf(); drop(q); - let q = rbitcoin_query::Query::open_or_create_tiny(&store).unwrap(); + let q = crate::Query::open_or_create_tiny(&store).unwrap(); crate::replay_spend_annotations(&q).unwrap(); assert!( q.is_outpoint_spent(c1.as_byte_array(), 0).unwrap(), @@ -577,7 +587,10 @@ fn checkpoint_after_reorg_does_not_skip_the_reconnected_spends() { let err = accept_and_connect_block(&q, ¶ms, Height(hy + 1), &respend, ms) .expect_err("an output Y spent must not be spent again"); assert_eq!(q.tip_height(), Some(Height(hy)), "{err}"); - assert!(matches!(err, ConsensusError::PrevoutSpent), "{err}"); + assert!( + matches!(err, ConfirmError::Rules(ConsensusError::PrevoutSpent)), + "{err}" + ); let _ = dir; } @@ -594,7 +607,7 @@ fn marker_heights(store: &std::path::Path) -> Option<(u32, u32)> { } fn connect_until( - q: &rbitcoin_query::Query, + q: &crate::Query, params: &ChainParams, ms: Milestone, tip: &mut bitcoin::BlockHash, @@ -622,7 +635,7 @@ fn shrink_input_loc_hwm(store: &std::path::Path, keep: u64) { f.write_all(&logical.to_le_bytes()).unwrap(); } -fn input_body_off(q: &rbitcoin_query::Query, spend_fk: rbitcoin_primitives::Fk) -> u64 { +fn input_body_off(q: &crate::Query, spend_fk: rbitcoin_primitives::Fk) -> u64 { let mut before = 0u64; for id in 1..spend_fk.0 { let (_, ins, _) = q.store().get_tx_full(rbitcoin_primitives::Fk(id)).unwrap(); @@ -632,8 +645,8 @@ fn input_body_off(q: &rbitcoin_query::Query, spend_fk: rbitcoin_primitives::Fk) } fn sealed_spend_chain() -> ( - rbitcoin_query::testutil::TempDir, - rbitcoin_query::Query, + crate::testutil::TempDir, + crate::Query, ChainParams, Milestone, u32, @@ -641,7 +654,7 @@ fn sealed_spend_chain() -> ( Block, u32, ) { - let (dir, q) = rbitcoin_query::testutil::tiny_query_labeled("spend-edge-loss"); + let (dir, q) = crate::testutil::tiny_query_labeled("spend-edge-loss"); q.set_spend_index(true); let params = ChainParams::regtest(); let ms = Milestone::NONE; @@ -672,7 +685,7 @@ fn short_input_hwm_shrinks_below_the_edgeless_create() { drop(q); shrink_input_loc_hwm(&store, keep); - let q = rbitcoin_query::Query::open_or_create_tiny(&store).unwrap(); + let q = crate::Query::open_or_create_tiny(&store).unwrap(); q.set_spend_index(true); let tip_h = q.tip_height().expect("a prefix of the chain stays"); assert!( @@ -709,7 +722,7 @@ fn short_input_hwm_shrinks_below_the_edgeless_create() { let err = accept_and_connect_block(&q, ¶ms, Height(tip_h.0 + 2), &respend, ms) .expect_err("reconnecting the spend keeps the output spent"); assert!( - matches!(err, ConsensusError::PrevoutSpent), + matches!(err, ConfirmError::Rules(ConsensusError::PrevoutSpent)), "respend after reconnect: {err}" ); let _ = dir; @@ -735,7 +748,7 @@ fn zeroed_input_body_shrinks_and_is_not_a_coinbase() { f.write_all(&[0u8; 8]).unwrap(); } - let q = rbitcoin_query::Query::open_or_create_tiny(&store).unwrap(); + let q = crate::Query::open_or_create_tiny(&store).unwrap(); q.set_spend_index(true); let tip_h = q.tip_height().unwrap(); assert!( @@ -778,7 +791,7 @@ fn replay_rejects_coinbase_edge_on_a_non_coinbase() { assert!( matches!( err, - ConsensusError::Store(rbitcoin_store::StoreError::Corrupt(_)) + ConfirmError::Store(rbitcoin_store::StoreError::Corrupt(_)) ), "{err}" ); @@ -788,11 +801,8 @@ fn replay_rejects_coinbase_edge_on_a_non_coinbase() { let _ = dir; } -fn chain_at_height( - label: &str, - height: u32, -) -> (rbitcoin_query::testutil::TempDir, rbitcoin_query::Query) { - let (dir, q) = rbitcoin_query::testutil::tiny_query_labeled(label); +fn chain_at_height(label: &str, height: u32) -> (crate::testutil::TempDir, crate::Query) { + let (dir, q) = crate::testutil::tiny_query_labeled(label); let params = ChainParams::regtest(); let ms = Milestone::NONE; let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); diff --git a/crates/rbitcoin-consensus/src/confirm_run/write.rs b/crates/rbitcoin-query/src/confirm_run/write.rs similarity index 85% rename from crates/rbitcoin-consensus/src/confirm_run/write.rs rename to crates/rbitcoin-query/src/confirm_run/write.rs index 903ec4e87f..7a4959c80f 100644 --- a/crates/rbitcoin-consensus/src/confirm_run/write.rs +++ b/crates/rbitcoin-query/src/confirm_run/write.rs @@ -63,7 +63,7 @@ fn submit_queued<'a>( let drain = super::head_drain::submit_head_insert(query.store(), queued); let head_sub_ns = t_head.elapsed().as_nanos() as u64; if head_sub_ns > 0 { - rbitcoin_query::note_confirm(&query.confirm_stats().write_head_sub_ns, head_sub_ns); + crate::note_confirm(&query.confirm_stats().write_head_sub_ns, head_sub_ns); } drain } @@ -108,7 +108,7 @@ pub(super) fn write_batch_vs_tip( fn finish_already_committed_write( query: &Query, batch: &ScriptOkBatch, -) -> Result, ConsensusError> { +) -> Result, ConfirmError> { let items: Vec<(u32, [u8; 32])> = batch .prepared .iter() @@ -122,8 +122,8 @@ fn finish_already_committed_write( } struct ArchivePlanNs { - pins: FkMap, - class_a_wave: crate::block::ClassAWave, + pins: FkMap, + class_a_wave: crate::block_pipeline::ClassAWave, class_a_ns: u64, ensure_ns: u64, plan_take_ns: u64, @@ -133,10 +133,10 @@ struct ArchivePlanNs { fn apply_archive_plan( query: &Query, batch: &mut ScriptOkBatch, -) -> Result { +) -> Result { let mut ns = ArchivePlanNs { pins: FkMap::default(), - class_a_wave: crate::block::ClassAWave::default(), + class_a_wave: crate::block_pipeline::ClassAWave::default(), class_a_ns: 0, ensure_ns: 0, plan_take_ns: 0, @@ -151,11 +151,11 @@ fn apply_archive_plan( let t_take = Instant::now(); let planned_fks = plan.planned_fks.clone(); if plan.batch_pin.len() != plan.planned_fks.len() { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: batch_pin length", ))); } - let packed_pins: Vec = std::mem::take(&mut plan.batch_pin); + let packed_pins: Vec = std::mem::take(&mut plan.batch_pin); ns.plan_take_ns = t_take.elapsed().as_nanos() as u64; let t_ca = Instant::now(); let (committed, loc) = { @@ -163,7 +163,7 @@ fn apply_archive_plan( spindle_mark("class-a"); query .archive_commit_plan_defer_head_parents(plan) - .map_err(ConsensusError::from)? + .map_err(ConfirmError::from)? }; ns.class_a_ns = t_ca.elapsed().as_nanos() as u64; if !committed { @@ -195,7 +195,7 @@ fn apply_archive_plan( // The overlay is proven only for rows this commit wrote. A plan trimmed // at commit wrote fewer rows than `planned_fks` names. if loc.len() == planned_fks.len() { - ns.class_a_wave = crate::block::ClassAWave::new(planned_fks); + ns.class_a_wave = crate::block_pipeline::ClassAWave::new(planned_fks); } Ok(ns) } @@ -208,13 +208,13 @@ fn apply_archive_plan( /// **Class A never leads tip** (no dual-track archive-ahead / body DONTNEED lead). /// /// Accrues window timers on [`Query::confirm_stats`] and snapshots the last batch -/// for slow-write logs via [`rbitcoin_query::ConfirmStats::last_write_phases`]. +/// for slow-write logs via [`crate::ConfirmStats::last_write_phases`]. pub fn confirm_write_phase( query: &Query, params: &ChainParams, milestone: Milestone, batch: ScriptOkBatch, -) -> Result, ConsensusError> { +) -> Result, ConfirmError> { confirm_write_phase_or_return(query, params, milestone, batch).map_err(|(e, _)| e) } @@ -226,7 +226,7 @@ pub fn confirm_write_phase_or_return( params: &ChainParams, milestone: Milestone, mut batch: ScriptOkBatch, -) -> Result, (ConsensusError, ScriptOkBatch)> { +) -> Result, (ConfirmError, ScriptOkBatch)> { write_phase(query, params, milestone, &mut batch).map_err(|e| (e, batch)) } @@ -235,12 +235,12 @@ fn write_phase( params: &ChainParams, milestone: Milestone, batch: &mut ScriptOkBatch, -) -> Result, ConsensusError> { +) -> Result, ConfirmError> { let tip = query.tip_height().map(|h| h.0); match write_batch_vs_tip(tip, batch.prepared.iter().map(|p| p.height.0)) { WriteBatchVsTip::AllOld => return finish_already_committed_write(query, batch), WriteBatchVsTip::SpansTip => { - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( "invariant: write batch spans tip", ))); } @@ -262,16 +262,16 @@ fn write_phase( let abs_jobs = super::collect_spend_abs_after_fill(&batch.batch_parents, &batch.prepared)?; ensure_ns = ensure_ns.saturating_add(t_ens.elapsed().as_nanos() as u64); if class_a_ns > 0 { - rbitcoin_query::note_confirm(&query.confirm_stats().class_a_ns, class_a_ns); + crate::note_confirm(&query.confirm_stats().class_a_ns, class_a_ns); } if ensure_ns > 0 { - rbitcoin_query::note_confirm(&query.confirm_stats().ensure_layout_ns, ensure_ns); + crate::note_confirm(&query.confirm_stats().ensure_layout_ns, ensure_ns); } if plan_take_ns > 0 { - rbitcoin_query::note_confirm(&query.confirm_stats().write_plan_take_ns, plan_take_ns); + crate::note_confirm(&query.confirm_stats().write_plan_take_ns, plan_take_ns); } if create_map_ns > 0 { - rbitcoin_query::note_confirm(&query.confirm_stats().write_create_map_ns, create_map_ns); + crate::note_confirm(&query.confirm_stats().write_create_map_ns, create_map_ns); } // Flag off: tx.head insert overlaps structural, Class C, and annotate. @@ -285,7 +285,7 @@ fn write_phase( Some(submit_queued(query, queued.take().unwrap())) }; - let overlap = (|| -> Result<_, ConsensusError> { + let overlap = (|| -> Result<_, ConfirmError> { // Local Instant totals (not atomic deltas) — sample_and_reset races mid-batch. let t_struct = Instant::now(); let (struct_ph, slots) = WRITE_REUSE.with(|reuse| { @@ -319,7 +319,7 @@ fn write_phase( .saturating_sub(cc0); let class_c_join_ns = class_c_wall_ns.saturating_sub(class_c_ns); if class_c_join_ns > 0 { - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().write_class_c_join_ns, class_c_join_ns, ); @@ -328,8 +328,8 @@ fn write_phase( let seal = std::mem::take(&mut batch.index_seal); let idx_put_ns = super::index::seal_live_indexes(query, seal)?; if idx_put_ns > 0 { - rbitcoin_query::note_confirm(&query.confirm_stats().idx_put_ns, idx_put_ns); - rbitcoin_query::note_confirm(&query.confirm_stats().blockfilter_ns, idx_put_ns); + crate::note_confirm(&query.confirm_stats().idx_put_ns, idx_put_ns); + crate::note_confirm(&query.confirm_stats().blockfilter_ns, idx_put_ns); } let spend_ann_ns = { @@ -365,13 +365,13 @@ fn write_phase( let (drain_res, restore) = drain.join_restore(); let drain_join_ns = t_join.elapsed().as_nanos() as u64; if drain_join_ns > 0 { - rbitcoin_query::note_confirm(&query.confirm_stats().write_drain_join_ns, drain_join_ns); + crate::note_confirm(&query.confirm_stats().write_drain_join_ns, drain_join_ns); } if drain_res.is_err() { query.store().txs.head_note_pending(&restore); } let (out, n_blocks, structural_ns, struct_ph, class_c_ns, spend_ann_ns) = overlap?; - drain_res.map_err(ConsensusError::from)?; + drain_res.map_err(ConfirmError::from)?; if let Some(fk) = drain_max_fk { query.note_head_drain_fk(fk); } @@ -380,10 +380,10 @@ fn write_phase( if let Some(h) = batch.prepared.iter().map(|p| p.height.0).max() { query.prune_write_create_loc(h); } - rbitcoin_query::note_confirm(&query.confirm_stats().phase_blocks, n_blocks as u64); + crate::note_confirm(&query.confirm_stats().phase_blocks, n_blocks as u64); query .confirm_stats() - .note_last_write(rbitcoin_query::LastWritePhases { + .note_last_write(crate::LastWritePhases { n_blocks: n_blocks as u32, wall_ns: t_wall.elapsed().as_nanos() as u64, class_a_ns, @@ -406,7 +406,7 @@ pub(crate) fn finish_post_commit( query: &Query, height: u32, hash: &[u8; 32], -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { finish_post_commit_hashes(query, &[(height, *hash)]) } @@ -415,7 +415,7 @@ pub(crate) fn finish_post_commit( pub fn finish_post_commit_hashes( query: &Query, items: &[(u32, [u8; 32])], -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { let queued = query.store().txs.take_pending_queued(); if queued.is_empty() && !query.spend_index_enabled() { return Ok(()); @@ -431,11 +431,11 @@ pub fn finish_post_commit_hashes( let annotate_res = { let _gate = hold_spindle(query); spindle_mark("ann-begin"); - let res = (|| -> Result<(), ConsensusError> { + let res = (|| -> Result<(), ConfirmError> { if !query.spend_index_enabled() { return Ok(()); } - let mut slots = crate::block::AnnotateSlots::default(); + let mut slots = crate::block_pipeline::AnnotateSlots::default(); for &(height, hash) in items { annotate_slots_from_connected_hash(query, height, &hash, &mut slots)?; } @@ -464,7 +464,7 @@ pub fn finish_post_commit_hashes( query.store().txs.head_note_pending(&restore); } annotate_res?; - drain_res.map_err(ConsensusError::from)?; + drain_res.map_err(ConfirmError::from)?; if let Some(fk) = drain_max_fk { query.note_head_drain_fk(fk); } @@ -483,7 +483,7 @@ const REPLAY_STATUS_MS: u64 = 10_000; /// above genesis is rewritten before the marker is published at the surviving tip. /// Returns how many heights were rewritten. Call this on process open after /// tip-window revalidation. -pub fn replay_spend_annotations(query: &Query) -> Result { +pub fn replay_spend_annotations(query: &Query) -> Result { let Some(tip) = query.tip_height().map(|h| h.0) else { return Ok(0); }; @@ -494,17 +494,17 @@ pub fn replay_spend_annotations(query: &Query) -> Result { let annotated = query .store() .spend_annotated_through() - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; if annotated.is_some_and(|h| h > tip) { query .store() .clamp_spend_durable() - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; } let annotated = query .store() .spend_annotated_through() - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; let a = match annotated { Some(h) => h.min(tip), None => { @@ -518,16 +518,16 @@ pub fn replay_spend_annotations(query: &Query) -> Result { rewrite_spend_heights(query, a, tip) } -fn publish_spend_marker(query: &Query, tip: u32) -> Result<(), ConsensusError> { +fn publish_spend_marker(query: &Query, tip: u32) -> Result<(), ConfirmError> { let sync_ns = query .store() .sync_spend_durable(tip) - .map_err(ConsensusError::from)?; - rbitcoin_query::note_confirm(&query.confirm_stats().spend_durable_ns, sync_ns); + .map_err(ConfirmError::from)?; + crate::note_confirm(&query.confirm_stats().spend_durable_ns, sync_ns); Ok(()) } -fn rewrite_spend_heights(query: &Query, annotated: u32, tip: u32) -> Result { +fn rewrite_spend_heights(query: &Query, annotated: u32, tip: u32) -> Result { rbitcoin_log::info!("store: replay spend annotations ({annotated}, {tip}]"); let replayed = annotate_spend_heights(query, annotated + 1, tip)?; publish_spend_marker(query, tip)?; @@ -538,7 +538,7 @@ fn rewrite_spend_heights(query: &Query, annotated: u32, tip: u32) -> Result Result<(), ConsensusError> { +fn annotate_pending_spends(query: &Query) -> Result<(), ConfirmError> { let token = query.store().spend_annotate_token(); let Some(from) = query.store().spend_annotate_pending() else { return Ok(()); @@ -549,7 +549,7 @@ fn annotate_pending_spends(query: &Query) -> Result<(), ConsensusError> { ); let t = Instant::now(); annotate_spend_heights(query, from, tip)?; - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().spend_replay_ns, t.elapsed().as_nanos() as u64, ); @@ -558,7 +558,7 @@ fn annotate_pending_spends(query: &Query) -> Result<(), ConsensusError> { Ok(()) } -fn annotate_spend_heights(query: &Query, start: u32, tip: u32) -> Result { +fn annotate_spend_heights(query: &Query, start: u32, tip: u32) -> Result { let heights: Vec = (start..=tip).collect(); let replayed = heights.len() as u32; let started = std::time::Instant::now(); @@ -569,9 +569,9 @@ fn annotate_spend_heights(query: &Query, start: u32, tip: u32) -> Result Result<(), ConsensusError> { - match query.height_of_hash(hash).map_err(ConsensusError::from)? { + slots: &mut crate::block_pipeline::AnnotateSlots, +) -> Result<(), ConfirmError> { + match query.height_of_hash(hash).map_err(ConfirmError::from)? { Some(h) if h.0 == height => {} _ => return Ok(()), } - let Some((hfk, _)) = query - .get_header_by_hash(hash) - .map_err(ConsensusError::from)? - else { + let Some((hfk, _)) = query.get_header_by_hash(hash).map_err(ConfirmError::from)? else { return Ok(()); }; let Some(tx_fks) = query .store() .header_txs .get_list(hfk) - .map_err(ConsensusError::from)? + .map_err(ConfirmError::from)? else { return Ok(()); }; @@ -617,11 +614,11 @@ fn annotate_slots_from_connected_hash( let (_meta, ins, _outs) = query .store() .get_tx_full(spend_fk) - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; if tx_i == 0 { let coinbase = ins.len() == 1 && ins[0].is_coinbase(); if !coinbase { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: coinbase input edge", ))); } @@ -629,19 +626,19 @@ fn annotate_slots_from_connected_hash( } for (inp_i, inp) in ins.into_iter().enumerate() { let rbitcoin_store::InputParent::Prevout { create_fk, vout } = inp.parent else { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: non-coinbase input edge", ))); }; let (off, len) = query .store() .tx_spent_range(create_fk) - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; let abs = rbitcoin_store::spent_abs(off, vout); if abs.saturating_add(rbitcoin_store::OutputRecord::SPENT_SLOT_LEN as u64) > off.saturating_add(len) { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: finish_post_commit spent slot OOB", ))); } @@ -649,7 +646,7 @@ fn annotate_slots_from_connected_hash( .store() .txs .get_output_spender_meta(create_fk, vout) - .map_err(ConsensusError::from)?; + .map_err(ConfirmError::from)?; let flags = if multi { rbitcoin_store::output_flags::MULTI_SPENDER } else { @@ -669,12 +666,12 @@ fn annotate_slots_from_connected_hash( /// Write never preads `create.loc`. pub(super) fn fill_planned_create_layout_after_commit( query: &Query, - batch_parents: &mut rbitcoin_query::BatchParents, + batch_parents: &mut crate::BatchParents, planned_fks: &[rbitcoin_primitives::Fk], loc: &[rbitcoin_store::CreateLocPair], - packed: &[rbitcoin_query::CreatePin], + packed: &[crate::CreatePin], prepared: &[Prepared], -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { let mut need: U64Map> = U64Map::default(); for p in prepared { for &(_txid, vout, sfk, cfk, _vin) in &p.spends { @@ -694,7 +691,7 @@ pub(super) fn fill_planned_create_layout_after_commit( } if !planned_fks.is_empty() { if loc.len() != planned_fks.len() || packed.len() != planned_fks.len() { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: append loc length", ))); } @@ -702,7 +699,7 @@ pub(super) fn fill_planned_create_layout_after_commit( let Some(id) = fk.get() else { continue }; let Some(vouts) = need.get(&id) else { continue }; if pair.n_out != pin.n_out() as u32 { - return Err(ConsensusError::Store(StoreError::Corrupt( + return Err(ConfirmError::Store(StoreError::Corrupt( "invariant: append loc length", ))); } diff --git a/crates/rbitcoin-consensus/src/confirm_run/write_idempotent_tests.rs b/crates/rbitcoin-query/src/confirm_run/write_idempotent_tests.rs similarity index 91% rename from crates/rbitcoin-consensus/src/confirm_run/write_idempotent_tests.rs rename to crates/rbitcoin-query/src/confirm_run/write_idempotent_tests.rs index aafb08b416..d3dffdb263 100644 --- a/crates/rbitcoin-consensus/src/confirm_run/write_idempotent_tests.rs +++ b/crates/rbitcoin-query/src/confirm_run/write_idempotent_tests.rs @@ -4,10 +4,10 @@ use super::{ confirm_archive_kind, write_batch_vs_tip, write_height_needed, ConfirmArchiveKind, WriteBatchVsTip, }; -use rbitcoin_query::testutil::FixtureChain; +use crate::testutil::FixtureChain; -fn tmp_query() -> (rbitcoin_query::testutil::TempDir, rbitcoin_query::Query) { - rbitcoin_query::testutil::tiny_query_labeled("write-idemp") +fn tmp_query() -> (crate::testutil::TempDir, crate::Query) { + crate::testutil::tiny_query_labeled("write-idemp") } #[test] @@ -85,10 +85,10 @@ fn script_ok_append_contiguous_and_gap() { fn batch_one(h: u32) -> ScriptOkBatch { ScriptOkBatch { prepared: vec![empty_prepared(h, h as u8)], - wire_blocks: vec![Arc::new(crate::params::genesis_block( - &crate::params::ChainParams::regtest(), + wire_blocks: vec![Arc::new(rbitcoin_consensus::genesis_block( + &rbitcoin_consensus::ChainParams::regtest(), ))], - batch_parents: rbitcoin_query::BatchParents::new(), + batch_parents: crate::BatchParents::new(), archive_plan: None, index_seal: super::index::IndexSeal::default(), } @@ -111,7 +111,7 @@ fn script_ok_append_contiguous_and_gap() { .append_contiguous(ScriptOkBatch { prepared: vec![], wire_blocks: vec![], - batch_parents: rbitcoin_query::BatchParents::new(), + batch_parents: crate::BatchParents::new(), archive_plan: None, index_seal: super::index::IndexSeal::default(), }) @@ -122,7 +122,7 @@ fn script_ok_append_contiguous_and_gap() { let mut empty = ScriptOkBatch { prepared: vec![], wire_blocks: vec![], - batch_parents: rbitcoin_query::BatchParents::new(), + batch_parents: crate::BatchParents::new(), archive_plan: None, index_seal: super::index::IndexSeal::default(), }; @@ -142,9 +142,9 @@ fn script_ok_append_contiguous_and_gap() { // archive_plan merge: Some+Some concatenates; mixed polarity is leftover. let mut with_plan = batch_one(70); - with_plan.archive_plan = Some(rbitcoin_query::ArchiveWritePlan::empty()); + with_plan.archive_plan = Some(crate::ArchiveWritePlan::empty()); let mut next = batch_one(71); - next.archive_plan = Some(rbitcoin_query::ArchiveWritePlan::empty()); + next.archive_plan = Some(crate::ArchiveWritePlan::empty()); assert!(with_plan.append_contiguous(next).is_ok()); assert!(with_plan.archive_plan.is_some()); let mut only_other = batch_one(72); @@ -157,7 +157,7 @@ fn script_ok_append_contiguous_and_gap() { assert!(with_plan.archive_plan.is_some()); let mut no_plan = batch_one(80); let mut has = batch_one(81); - has.archive_plan = Some(rbitcoin_query::ArchiveWritePlan::empty()); + has.archive_plan = Some(crate::ArchiveWritePlan::empty()); let err = no_plan .append_contiguous(has) .expect_err("None+Some polarity"); @@ -201,11 +201,11 @@ fn three_stage_write_filter_and_scripts_surface() { let batch = LoadedBatch { prepared: Vec::new(), wire_blocks: Vec::new(), - batch_parents: rbitcoin_query::BatchParents::new(), + batch_parents: crate::BatchParents::new(), script_preverified: ScriptPreverified::new(), archive_plan: None, index_want: super::index::IndexWant::default(), - stats: std::sync::Arc::new(rbitcoin_query::ConfirmStats::default()), + stats: std::sync::Arc::new(crate::ConfirmStats::default()), }; assert!(batch.is_empty()); assert_eq!(batch.approx_wire_bytes(), 0); @@ -214,11 +214,11 @@ fn three_stage_write_filter_and_scripts_surface() { let mut metered = LoadedBatch { prepared: Vec::new(), wire_blocks: vec![std::sync::Arc::new(genesis)], - batch_parents: rbitcoin_query::BatchParents::new(), + batch_parents: crate::BatchParents::new(), script_preverified: ScriptPreverified::new(), archive_plan: None, index_want: super::index::IndexWant::default(), - stats: std::sync::Arc::new(rbitcoin_query::ConfirmStats::default()), + stats: std::sync::Arc::new(crate::ConfirmStats::default()), }; assert!(metered.approx_wire_bytes() > 0); let mut txid = [0u8; 32]; @@ -280,7 +280,7 @@ fn confirm_archive_kind_refuses_mixed() { ); let err = confirm_archive_kind(3, 2).unwrap_err(); match err { - crate::error::ConsensusError::Store(rbitcoin_store::StoreError::Corrupt(m)) => { + crate::ConfirmError::Store(rbitcoin_store::StoreError::Corrupt(m)) => { assert_eq!(m, "invariant: confirm batch mixed archived"); } other => panic!("expected mixed archived, got {other:?}"), @@ -293,11 +293,11 @@ fn empty_loaded_batch() -> super::LoadedBatch { super::LoadedBatch { prepared: Vec::new(), wire_blocks: Vec::new(), - batch_parents: rbitcoin_query::BatchParents::new(), + batch_parents: crate::BatchParents::new(), script_preverified: super::ScriptPreverified::new(), archive_plan: None, index_want: super::index::IndexWant::default(), - stats: std::sync::Arc::new(rbitcoin_query::ConfirmStats::default()), + stats: std::sync::Arc::new(crate::ConfirmStats::default()), } } @@ -365,7 +365,7 @@ fn drive_script_waves_ordered_without_coordinator_threads() { fn prepared_at( height: u32, hash: [u8; 32], - jobs: Vec, + jobs: Vec, check_scripts: bool, ) -> super::Prepared { use bitcoin::CompactTarget; @@ -388,21 +388,21 @@ fn prepared_at( fn loaded_at( height: u32, hash: [u8; 32], - jobs: Vec, + jobs: Vec, check_scripts: bool, ) -> super::LoadedBatch { super::LoadedBatch { prepared: vec![prepared_at(height, hash, jobs, check_scripts)], wire_blocks: Vec::new(), - batch_parents: rbitcoin_query::BatchParents::new(), + batch_parents: crate::BatchParents::new(), script_preverified: super::ScriptPreverified::new(), archive_plan: None, index_want: super::index::IndexWant::default(), - stats: std::sync::Arc::new(rbitcoin_query::ConfirmStats::default()), + stats: std::sync::Arc::new(crate::ConfirmStats::default()), } } -fn bad_p2pkh_job() -> crate::block::ScriptCheckJob { +fn bad_p2pkh_job() -> rbitcoin_consensus::block::ScriptCheckJob { use bitcoin::absolute::LockTime; use bitcoin::hashes::Hash; use bitcoin::script::ScriptBuf; @@ -433,11 +433,11 @@ fn bad_p2pkh_job() -> crate::block::ScriptCheckJob { }], }; let tid = tx.compute_txid().to_byte_array(); - crate::block::ScriptCheckJob::with_txid( + rbitcoin_consensus::block::ScriptCheckJob::with_txid( tid, prevouts, tx, - crate::block::ScriptVerifyFlags::buried(true, true, true, true, true), + rbitcoin_consensus::block::ScriptVerifyFlags::buried(true, true, true, true, true), ) } @@ -517,9 +517,10 @@ fn script_jobs_shrink_after_take() { #[test] fn tip_plus_one_after_trailing_null_heal_is_not_notfound() { use crate::accept_and_connect_block; - use crate::milestone::Milestone; - use crate::params::ChainParams; - use crate::regtest_pad::{mine_empty_regtest, pad_empty_from}; + use crate::pad_empty_from; + use rbitcoin_consensus::mine_empty_regtest; + use rbitcoin_consensus::ChainParams; + use rbitcoin_consensus::Milestone; use rbitcoin_primitives::Height; let (path, q) = tmp_query(); let params = ChainParams::regtest(); @@ -548,7 +549,7 @@ fn tip_plus_one_after_trailing_null_heal_is_not_notfound() { raw[8..16].copy_from_slice(&new_logical.to_le_bytes()); std::fs::write(&conf, &raw).unwrap(); - let q = rbitcoin_query::Query::open_or_create_tiny(&path).unwrap(); + let q = crate::Query::open_or_create_tiny(&path).unwrap(); assert_eq!(q.tip_height().map(|h| h.0), Some(3)); let nxt = mine_empty_regtest(tip, tip_time + 600, 4); let r = accept_and_connect_block(&q, ¶ms, Height(4), &nxt, Milestone::NONE); @@ -570,8 +571,8 @@ fn tip_plus_one_after_trailing_null_heal_is_not_notfound() { #[test] fn expected_bits_extending_height0_and_no_retarget() { use super::expected_bits_extending; - use crate::params::ChainParams; use bitcoin::CompactTarget; + use rbitcoin_consensus::ChainParams; use rbitcoin_primitives::Height; let (path, q) = tmp_query(); let params = ChainParams::regtest(); @@ -584,7 +585,10 @@ fn expected_bits_extending_height0_and_no_retarget() { 0, ) .unwrap(); - assert_eq!(gbits, crate::params::genesis_block(¶ms).header.bits); + assert_eq!( + gbits, + rbitcoin_consensus::genesis_block(¶ms).header.bits + ); // No-pow-retargeting: any height returns prev_bits. let prev = CompactTarget::from_consensus(0x207f_ffff); let b = expected_bits_extending(&q, ¶ms, Height(2016), prev, 100, 0).unwrap(); @@ -595,11 +599,11 @@ fn expected_bits_extending_height0_and_no_retarget() { let loaded = LoadedBatch { prepared: Vec::new(), wire_blocks: Vec::new(), - batch_parents: rbitcoin_query::BatchParents::new(), + batch_parents: crate::BatchParents::new(), script_preverified: ScriptPreverified::new(), archive_plan: None, index_want: super::index::IndexWant::default(), - stats: std::sync::Arc::new(rbitcoin_query::ConfirmStats::default()), + stats: std::sync::Arc::new(crate::ConfirmStats::default()), }; let ok = confirm_scripts_phase(loaded).unwrap(); assert!(ok.batch.is_empty()); @@ -642,7 +646,7 @@ fn expected_bits_extending_height0_and_no_retarget() { }, txdata: vec![cb], }; - assert!(crate::block::check_bip34_coinbase(&block.txdata[0], 17).is_err()); + assert!(rbitcoin_consensus::block::check_bip34_coinbase(&block.txdata[0], 17).is_err()); let _ = std::fs::remove_dir_all(&path); } @@ -660,8 +664,8 @@ fn expected_bits_extending_height0_and_no_retarget() { #[test] fn expected_bits_extending_uses_header_plan_when_period_start_above_tip() { use super::expected_bits_extending; - use crate::params::ChainParams; use bitcoin::CompactTarget; + use rbitcoin_consensus::ChainParams; use rbitcoin_primitives::{Fk, Height}; use rbitcoin_store::HeaderRecord; let (path, q) = tmp_query(); @@ -727,12 +731,12 @@ fn expected_bits_extending_uses_header_plan_when_period_start_above_tip() { #[test] fn script_wave_skips_preverified_txids() { use super::{confirm_scripts_phase, LoadedBatch, Prepared, ScriptPreverified}; - use crate::block::ScriptCheckJob; use bitcoin::absolute::LockTime; use bitcoin::hashes::Hash; use bitcoin::script::ScriptBuf; use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, CompactTarget, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; + use rbitcoin_consensus::block::ScriptCheckJob; use rbitcoin_primitives::{Fk, Height}; let prevouts = vec![TxOut { @@ -769,7 +773,7 @@ fn script_wave_skips_preverified_txids() { tid, prevouts, tx, - crate::block::ScriptVerifyFlags::buried(true, true, true, true, true), + rbitcoin_consensus::block::ScriptVerifyFlags::buried(true, true, true, true, true), ); let prepared = Prepared { height: Height(1), @@ -787,24 +791,24 @@ fn script_wave_skips_preverified_txids() { let batch = LoadedBatch { prepared: vec![prepared], wire_blocks: vec![], - batch_parents: rbitcoin_query::BatchParents::new(), + batch_parents: crate::BatchParents::new(), script_preverified: pre, archive_plan: None, index_want: super::index::IndexWant::default(), - stats: std::sync::Arc::new(rbitcoin_query::ConfirmStats::default()), + stats: std::sync::Arc::new(crate::ConfirmStats::default()), }; confirm_scripts_phase(batch).expect("preverified skip avoids bad script fail"); } -fn tiny_query() -> (rbitcoin_query::testutil::TempDir, rbitcoin_query::Query) { +fn tiny_query() -> (crate::testutil::TempDir, crate::Query) { let (path, q) = tmp_query(); q.enter_direct_index_mode().unwrap(); (path, q) } -fn fill_edges_from_packed(plan: &mut rbitcoin_query::ArchiveWritePlan) { +fn fill_edges_from_packed(plan: &mut crate::ArchiveWritePlan) { + use crate::SpendEdge; use rbitcoin_primitives::Fk; - use rbitcoin_query::SpendEdge; if !plan.edges.is_empty() { return; } @@ -854,8 +858,8 @@ fn pin_and_ensure_journey() { use super::{ ensure_spend_abs_layouts, pin_for_wire_batch, post_commit, ParentPinStamp, Prepared, }; + use crate::{ArchiveWritePlan, BatchParents}; use rbitcoin_primitives::{Fk, Height}; - use rbitcoin_query::{ArchiveWritePlan, BatchParents}; use rbitcoin_store::{InputRecord, OutputRecord}; let (path, q) = tiny_query(); @@ -863,10 +867,7 @@ fn pin_and_ensure_journey() { let missing_parent = Fk(999_999); let mut plan = ArchiveWritePlan::empty(); plan.packed = vec![( - rbitcoin_query::CreatePinInner::records( - rec_tx(0xAA, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ), + crate::CreatePinInner::records(rec_tx(0xAA, 1), vec![OutputRecord::unspent(1, vec![0x51])]), vec![InputRecord { prev_txid: [0xBB; 32], parent: rbitcoin_store::InputParent::Prevout { @@ -914,7 +915,7 @@ fn pin_and_ensure_journey() { "unexpected err: {msg}" ); - post_commit(&q, &crate::block::AnnotateSlots::default()) + post_commit(&q, &crate::block_pipeline::AnnotateSlots::default()) .expect("empty annotate list does not consult BatchParents"); let parent_tx = rec_tx(0x11, 1); @@ -943,16 +944,13 @@ fn pin_and_ensure_journey() { }]; let mut plan = ArchiveWritePlan::empty(); plan.packed = vec![( - rbitcoin_query::CreatePinInner::records( - rec_tx(0x22, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ), + crate::CreatePinInner::records(rec_tx(0x22, 1), vec![OutputRecord::unspent(1, vec![0x51])]), spend_ins.clone(), )]; plan.planned_fks = vec![Fk(2)]; plan.external_parents.insert( parent_id, - rbitcoin_query::ParentIdent::with_loc(parent_tx.txid, range, (spent_off, spent_len), 1), + crate::ParentIdent::with_loc(parent_tx.txid, range, (spent_off, spent_len), 1), ); let mut stamp = ParentPinStamp::take_from_plan(&mut plan); fill_edges_from_packed(&mut plan); @@ -968,16 +966,13 @@ fn pin_and_ensure_journey() { let mut plan2 = ArchiveWritePlan::empty(); plan2.packed = vec![( - rbitcoin_query::CreatePinInner::records( - rec_tx(0x22, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ), + crate::CreatePinInner::records(rec_tx(0x22, 1), vec![OutputRecord::unspent(1, vec![0x51])]), spend_ins.clone(), )]; plan2.planned_fks = vec![Fk(2)]; plan2.external_parents.insert( parent_id, - rbitcoin_query::ParentIdent::with_body(parent_tx.txid, range), + crate::ParentIdent::with_body(parent_tx.txid, range), ); let mut empty_stamp = ParentPinStamp::default(); fill_edges_from_packed(&mut plan2); @@ -1052,16 +1047,13 @@ fn pin_and_ensure_journey() { let mut plan3 = ArchiveWritePlan::empty(); plan3.packed = vec![( - rbitcoin_query::CreatePinInner::records( - rec_tx(0x22, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ), + crate::CreatePinInner::records(rec_tx(0x22, 1), vec![OutputRecord::unspent(1, vec![0x51])]), spend_ins, )]; plan3.planned_fks = vec![Fk(2)]; plan3.external_parents.insert( parent_id, - rbitcoin_query::ParentIdent { + crate::ParentIdent { txid: parent_tx.txid, body: Some(range), spent: Some((spent_off, spent_len)), @@ -1087,14 +1079,14 @@ fn pin_and_ensure_journey() { let mut plan4 = ArchiveWritePlan::empty(); plan4.packed = vec![ ( - rbitcoin_query::CreatePinInner::records( + crate::CreatePinInner::records( rec_tx(0x32, 1), vec![OutputRecord::unspent(1, vec![0x51])], ), vec![InputRecord::coinbase(u32::MAX, vec![0x01], vec![])], ), ( - rbitcoin_query::CreatePinInner::records( + crate::CreatePinInner::records( rec_tx(0x33, 1), vec![OutputRecord::unspent(1, vec![0x51])], ), @@ -1162,19 +1154,15 @@ fn pin_and_ensure_journey() { #[test] fn fill_same_batch_abs_from_append_loc_ram() { use super::{ensure_spend_abs_layouts, fill_planned_create_layout_after_commit, Prepared}; + use crate::BatchParents; use rbitcoin_primitives::{Fk, Height}; - use rbitcoin_query::BatchParents; use rbitcoin_store::{InputRecord, OutputRecord}; let (path, q) = tiny_query(); - let parent_pin = rbitcoin_query::CreatePinInner::records( - rec_tx(0x32, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ); - let child_pin = rbitcoin_query::CreatePinInner::records( - rec_tx(0x33, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ); + let parent_pin = + crate::CreatePinInner::records(rec_tx(0x32, 1), vec![OutputRecord::unspent(1, vec![0x51])]); + let child_pin = + crate::CreatePinInner::records(rec_tx(0x33, 1), vec![OutputRecord::unspent(1, vec![0x51])]); let parent_ins = vec![InputRecord::coinbase(u32::MAX, vec![0x01], vec![])]; let child_ins = vec![InputRecord { prev_txid: [0x32; 32], @@ -1243,10 +1231,8 @@ fn fill_same_batch_abs_from_append_loc_ram() { let mut multi_tx = rec_tx(0x44, 1); multi_tx.input_count = 2; - let multi_pin = rbitcoin_query::CreatePinInner::records( - multi_tx, - vec![OutputRecord::unspent(1, vec![0x51])], - ); + let multi_pin = + crate::CreatePinInner::records(multi_tx, vec![OutputRecord::unspent(1, vec![0x51])]); let multi_fk = Fk(50); let multi_loc = rbitcoin_store::CreateLocPair { txout: (10, 8), @@ -1352,9 +1338,8 @@ fn fill_same_batch_abs_from_append_loc_ram() { /// Overlay slot already in Class A: structural must not meta-pread it. #[test] fn structural_same_batch_overlay_skips_meta_pread() { - use crate::block::{structural_validate_spends, RunCreateHeight}; - use crate::milestone::Milestone; - use crate::params::ChainParams; + use crate::block_pipeline::{structural_validate_spends, RunCreateHeight}; + use crate::{BatchParents, FkMap, OutPointSet}; use bitcoin::absolute::LockTime; use bitcoin::block::{Header, Version}; use bitcoin::hashes::Hash; @@ -1364,23 +1349,22 @@ fn structural_same_batch_overlay_skips_meta_pread() { Amount, Block, BlockHash, CompactTarget, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness, }; + use rbitcoin_consensus::ChainParams; + use rbitcoin_consensus::Milestone; use rbitcoin_primitives::{Fk, Height}; - use rbitcoin_query::{BatchParents, FkMap, OutPointSet}; use rbitcoin_store::{InputRecord, OutputRecord}; use std::sync::atomic::Ordering; let (path, q) = tiny_query(); - let parent_pin = rbitcoin_query::CreatePinInner::records( + let parent_pin = crate::CreatePinInner::records( rec_tx(0x32, 2), vec![ OutputRecord::unspent(7, vec![0x51]), OutputRecord::unspent(8, vec![0x52]), ], ); - let child_pin = rbitcoin_query::CreatePinInner::records( - rec_tx(0x33, 1), - vec![OutputRecord::unspent(5, vec![0x51])], - ); + let child_pin = + crate::CreatePinInner::records(rec_tx(0x33, 1), vec![OutputRecord::unspent(5, vec![0x51])]); let parent_ins = vec![InputRecord::coinbase(u32::MAX, vec![0x01], vec![])]; let child_ins = vec![InputRecord { prev_txid: [0x32; 32], @@ -1474,11 +1458,11 @@ fn structural_same_batch_overlay_skips_meta_pread() { map.insert(fks[1], (1, false)); let run = RunCreateHeight::Map(map); let params = ChainParams::regtest(); - let ctx = crate::block::ValidationContext::at(¶ms, Height(1), Milestone::NONE); + let ctx = rbitcoin_consensus::block::ValidationContext::at(¶ms, Height(1), Milestone::NONE); let mut pending = OutPointSet::default(); - let mut mtp = rbitcoin_query::U32Map::::default(); + let mut mtp = crate::U32Map::::default(); mtp.insert(0, 1_300_000_000); - let mut scratch = crate::block::StructuralScratch::default(); + let mut scratch = crate::block_pipeline::StructuralScratch::default(); let meta0 = q.confirm_stats().spend_meta_n.load(Ordering::Relaxed); let ovl0 = q .confirm_stats() @@ -1495,7 +1479,7 @@ fn structural_same_batch_overlay_skips_meta_pread() { &bp, &mut mtp, &run, - &crate::block::ClassAWave::new(fks.clone()), + &crate::block_pipeline::ClassAWave::new(fks.clone()), &mut scratch, &abs_jobs, ) @@ -1530,9 +1514,8 @@ fn structural_same_batch_overlay_skips_meta_pread() { #[test] fn structural_scratch_second_block_does_not_replay_first_slots() { - use crate::block::{structural_validate_spends, RunCreateHeight, StructuralScratch}; - use crate::milestone::Milestone; - use crate::params::ChainParams; + use crate::block_pipeline::{structural_validate_spends, RunCreateHeight, StructuralScratch}; + use crate::{BatchParents, OutPointSet}; use bitcoin::absolute::LockTime; use bitcoin::block::{Header, Version}; use bitcoin::hashes::Hash; @@ -1542,12 +1525,13 @@ fn structural_scratch_second_block_does_not_replay_first_slots() { Amount, Block, BlockHash, CompactTarget, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness, }; + use rbitcoin_consensus::ChainParams; + use rbitcoin_consensus::Milestone; use rbitcoin_primitives::{Fk, Height}; - use rbitcoin_query::{BatchParents, OutPointSet}; use rbitcoin_store::{InputRecord, OutputRecord}; let (path, q) = tiny_query(); - let parent_pin = rbitcoin_query::CreatePinInner::records( + let parent_pin = crate::CreatePinInner::records( rec_tx(0x41, 2), vec![ OutputRecord::unspent(1, vec![0x51]), @@ -1624,9 +1608,9 @@ fn structural_scratch_second_block_does_not_replay_first_slots() { }; block.header.merkle_root = block.compute_merkle_root().unwrap(); let params = ChainParams::regtest(); - let ctx = crate::block::ValidationContext::at(¶ms, Height(1), Milestone::NONE); + let ctx = rbitcoin_consensus::block::ValidationContext::at(¶ms, Height(1), Milestone::NONE); let mut pending = OutPointSet::default(); - let mut mtp = rbitcoin_query::U32Map::::default(); + let mut mtp = crate::U32Map::::default(); mtp.insert(0, 1_300_000_000); let mut scratch = StructuralScratch::default(); let run = RunCreateHeight::Spans(Vec::new()); @@ -1650,7 +1634,7 @@ fn structural_scratch_second_block_does_not_replay_first_slots() { &bp, &mut mtp, &run, - &crate::block::ClassAWave::default(), + &crate::block_pipeline::ClassAWave::default(), &mut scratch, &abs_jobs, ) @@ -1675,16 +1659,14 @@ fn structural_scratch_second_block_does_not_replay_first_slots() { #[test] fn post_commit_spend_index_off_leaves_slots_unwritten() { use super::post_commit; - use crate::block::AnnotateSlots; + use crate::block_pipeline::AnnotateSlots; use rbitcoin_primitives::Fk; use rbitcoin_store::OutputRecord; use std::sync::atomic::Ordering; let (path, q) = tiny_query(); - let parent_pin = rbitcoin_query::CreatePinInner::records( - rec_tx(0x61, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ); + let parent_pin = + crate::CreatePinInner::records(rec_tx(0x61, 1), vec![OutputRecord::unspent(1, vec![0x51])]); let (fks, _loc) = q .store() .put_tx_full_batch_from_pins( @@ -1722,15 +1704,13 @@ fn post_commit_spend_index_off_leaves_slots_unwritten() { #[test] fn fill_just_written_survives_until_last_started_write() { use super::{ensure_spend_abs_layouts, fill_planned_create_layout_after_commit, Prepared}; + use crate::BatchParents; use rbitcoin_primitives::{Fk, Height}; - use rbitcoin_query::BatchParents; use rbitcoin_store::OutputRecord; let (path, q) = tiny_query(); - let parent_pin = rbitcoin_query::CreatePinInner::records( - rec_tx(0x32, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ); + let parent_pin = + crate::CreatePinInner::records(rec_tx(0x32, 1), vec![OutputRecord::unspent(1, vec![0x51])]); let (fks, loc) = q .store() @@ -1796,16 +1776,14 @@ fn fill_just_written_survives_until_last_started_write() { #[test] fn fill_stamp_spent_hole_from_write_tls() { use super::{ensure_spend_abs_layouts, fill_planned_create_layout_after_commit, Prepared}; + use crate::{stamp_external_parents, BatchParentIds, BatchParents, InFlight}; use rbitcoin_primitives::{Fk, Height}; - use rbitcoin_query::{stamp_external_parents, BatchParentIds, BatchParents, InFlight}; use rbitcoin_store::OutputRecord; use std::sync::atomic::Ordering; let (path, q) = tiny_query(); - let parent_pin = rbitcoin_query::CreatePinInner::records( - rec_tx(0x32, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ); + let parent_pin = + crate::CreatePinInner::records(rec_tx(0x32, 1), vec![OutputRecord::unspent(1, vec![0x51])]); let (fks, loc) = q .store() @@ -1875,15 +1853,13 @@ fn fill_stamp_spent_hole_from_write_tls() { #[test] fn pin_and_ensure_from_pin_loc_without_tls() { use super::{ensure_spend_abs_layouts, pin_for_wire_batch, ParentPinStamp, Prepared}; + use crate::{stamp_external_parents, ArchiveWritePlan, InFlight}; use rbitcoin_primitives::{Fk, Height}; - use rbitcoin_query::{stamp_external_parents, ArchiveWritePlan, InFlight}; use rbitcoin_store::{InputRecord, OutputRecord}; let (path, q) = tiny_query(); - let parent_pin = rbitcoin_query::CreatePinInner::records( - rec_tx(0x41, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ); + let parent_pin = + crate::CreatePinInner::records(rec_tx(0x41, 1), vec![OutputRecord::unspent(1, vec![0x51])]); let (fks, loc) = q .store() .put_tx_full_batch_from_pins( @@ -1908,7 +1884,7 @@ fn pin_and_ensure_from_pin_loc_without_tls() { q.store(), &[parent_pin.tx().txid], &inflight, - Some(&rbitcoin_query::BatchParentIds::default()), + Some(&crate::BatchParentIds::default()), q.confirm_stats(), ) .expect("pin loc without disk"); @@ -1928,10 +1904,7 @@ fn pin_and_ensure_from_pin_loc_without_tls() { }]; let mut plan = ArchiveWritePlan::empty(); plan.packed = vec![( - rbitcoin_query::CreatePinInner::records( - rec_tx(0x42, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ), + crate::CreatePinInner::records(rec_tx(0x42, 1), vec![OutputRecord::unspent(1, vec![0x51])]), spend_ins, )]; plan.planned_fks = vec![Fk(2)]; @@ -1964,8 +1937,8 @@ fn pin_and_ensure_from_pin_loc_without_tls() { #[test] fn pin_creates_only_ibd_skeleton_miss_is_lookup_stage_miss() { use super::{pin_for_wire_batch, ParentPinStamp}; + use crate::{ArchiveWritePlan, ParentIdent}; use rbitcoin_primitives::Fk; - use rbitcoin_query::{ArchiveWritePlan, ParentIdent}; use rbitcoin_store::{InputRecord, OutputRecord}; let (path, q) = tiny_query(); @@ -1982,10 +1955,7 @@ fn pin_creates_only_ibd_skeleton_miss_is_lookup_stage_miss() { .unwrap(); let mut plan = ArchiveWritePlan::empty(); plan.packed = vec![( - rbitcoin_query::CreatePinInner::records( - rec_tx(0x52, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ), + crate::CreatePinInner::records(rec_tx(0x52, 1), vec![OutputRecord::unspent(1, vec![0x51])]), vec![InputRecord { prev_txid: parent_tx.txid, parent: rbitcoin_store::InputParent::Prevout { @@ -2015,8 +1985,8 @@ fn pin_creates_only_ibd_skeleton_miss_is_lookup_stage_miss() { #[test] fn pin_for_wire_incomplete_outs_is_invariant_error() { use super::{pin_for_wire_batch, ParentPinStamp}; + use crate::ArchiveWritePlan; use rbitcoin_primitives::Fk; - use rbitcoin_query::ArchiveWritePlan; use rbitcoin_store::{InputRecord, OutputRecord, TxRecord}; let (path, q) = tmp_query(); q.enter_direct_index_mode().unwrap(); @@ -2045,10 +2015,7 @@ fn pin_for_wire_incomplete_outs_is_invariant_error() { }]; let mut plan = ArchiveWritePlan { packed: vec![( - rbitcoin_query::CreatePinInner::records( - spend_tx, - vec![OutputRecord::unspent(1, vec![0x51])], - ), + crate::CreatePinInner::records(spend_tx, vec![OutputRecord::unspent(1, vec![0x51])]), spend_ins, )], planned_fks: vec![Fk(2)], @@ -2075,8 +2042,8 @@ fn pin_for_wire_incomplete_outs_is_invariant_error() { output_start_fk: Fk::NULL, output_count: 0, }; - let pin = rbitcoin_query::CreatePinInner::records(parent_tx, Vec::new()); - let mut inflight = rbitcoin_query::InFlight::new(); + let pin = crate::CreatePinInner::records(parent_tx, Vec::new()); + let mut inflight = crate::InFlight::new(); inflight.note_pins([(Fk(parent_id), &pin)], None); let mut parent_pin = ParentPinStamp::take_from_plan(&mut plan); @@ -2096,7 +2063,7 @@ fn pin_for_wire_incomplete_outs_is_invariant_error() { #[test] fn parent_pin_stamp_take_from_plan_moves_maps() { use super::ParentPinStamp; - use rbitcoin_query::{ArchiveWritePlan, ParentIdent, U64Map}; + use crate::{ArchiveWritePlan, ParentIdent, U64Map}; let mut idents = U64Map::default(); idents.insert( @@ -2139,8 +2106,8 @@ fn parent_pin_stamp_take_from_plan_moves_maps() { #[test] fn pin_takes_stamp_parent_vouts() { use super::{pin_for_wire_batch, ParentPinStamp}; + use crate::ArchiveWritePlan; use rbitcoin_primitives::Fk; - use rbitcoin_query::ArchiveWritePlan; use rbitcoin_store::{InputRecord, OutputRecord, TxRecord}; let (path, q) = tmp_query(); let parent_tx = TxRecord { @@ -2167,7 +2134,7 @@ fn pin_takes_stamp_parent_vouts() { let range = q.store().txs.body_range(pfk).unwrap(); let mut plan = ArchiveWritePlan::empty(); plan.packed = vec![( - rbitcoin_query::CreatePinInner::records( + crate::CreatePinInner::records( TxRecord { txid: [0x22u8; 32], version: 1, @@ -2194,7 +2161,7 @@ fn pin_takes_stamp_parent_vouts() { let spent = q.store().tx_spent_range(pfk).unwrap(); plan.external_parents.insert( parent_id, - rbitcoin_query::ParentIdent::with_loc(parent_tx.txid, range, spent, 1), + crate::ParentIdent::with_loc(parent_tx.txid, range, spent, 1), ); plan.external_parent_vouts.insert(parent_id, vec![0]); let mut stamp = ParentPinStamp::take_from_plan(&mut plan); @@ -2215,8 +2182,8 @@ fn pin_takes_stamp_parent_vouts() { #[test] fn pin_for_wire_create_pin_shares_script_bytes() { use super::{pin_for_wire_batch, ParentPinStamp}; + use crate::{ArchiveWritePlan, CreatePin}; use rbitcoin_primitives::Fk; - use rbitcoin_query::{ArchiveWritePlan, CreatePin}; use rbitcoin_store::{InputRecord, OutputRecord, TxRecord}; use std::sync::Arc; @@ -2231,10 +2198,8 @@ fn pin_for_wire_create_pin_shares_script_bytes() { output_start_fk: Fk::NULL, output_count: 1, }; - let pin: CreatePin = rbitcoin_query::CreatePinInner::records( - parent_tx.clone(), - vec![OutputRecord::unspent(50, script)], - ); + let pin: CreatePin = + crate::CreatePinInner::records(parent_tx.clone(), vec![OutputRecord::unspent(50, script)]); let expect = pin.out_parts(0).expect("vout 0").1.as_ptr(); let child_tx = TxRecord { txid: [0x42u8; 32], @@ -2252,10 +2217,7 @@ fn pin_for_wire_create_pin_shares_script_bytes() { vec![InputRecord::coinbase(u32::MAX, vec![0x01], vec![])], ), ( - rbitcoin_query::CreatePinInner::records( - child_tx, - vec![OutputRecord::unspent(1, vec![0x51])], - ), + crate::CreatePinInner::records(child_tx, vec![OutputRecord::unspent(1, vec![0x51])]), vec![InputRecord { prev_txid: parent_tx.txid, parent: rbitcoin_store::InputParent::Prevout { @@ -2300,9 +2262,9 @@ fn write_encodes_seqsigwit_from_wire() { confirm_write_phase, ScriptPreverified, }; use crate::accept_and_connect_block; - use crate::milestone::Milestone; - use crate::params::ChainParams; - use crate::regtest_pad::mine_empty_regtest; + use rbitcoin_consensus::mine_empty_regtest; + use rbitcoin_consensus::ChainParams; + use rbitcoin_consensus::Milestone; use rbitcoin_primitives::Height; use std::sync::Arc; @@ -2345,8 +2307,8 @@ fn write_encodes_seqsigwit_from_wire() { #[test] fn pin_plan_edges_without_packed_ins() { use super::{pin_for_wire_batch, ParentPinStamp}; + use crate::{ArchiveWritePlan, CreatePin, SpendEdge}; use rbitcoin_primitives::Fk; - use rbitcoin_query::{ArchiveWritePlan, CreatePin, SpendEdge}; use rbitcoin_store::{OutputRecord, TxRecord}; use std::sync::Arc; let (path, q) = tmp_query(); @@ -2361,7 +2323,7 @@ fn pin_plan_edges_without_packed_ins() { output_start_fk: Fk::NULL, output_count: 1, }; - let pin: CreatePin = rbitcoin_query::CreatePinInner::records( + let pin: CreatePin = crate::CreatePinInner::records( parent_tx.clone(), vec![OutputRecord::unspent(50, vec![0x51])], ); @@ -2378,10 +2340,7 @@ fn pin_plan_edges_without_packed_ins() { plan.packed = vec![ (Arc::clone(&pin), vec![]), ( - rbitcoin_query::CreatePinInner::records( - child_tx, - vec![OutputRecord::unspent(1, vec![0x51])], - ), + crate::CreatePinInner::records(child_tx, vec![OutputRecord::unspent(1, vec![0x51])]), vec![], ), ]; @@ -2413,13 +2372,13 @@ fn pin_plan_edges_without_packed_ins() { #[test] fn pin_plan_empty_edges_is_invariant() { use super::{pin_for_wire_batch, ParentPinStamp}; + use crate::ArchiveWritePlan; use rbitcoin_primitives::Fk; - use rbitcoin_query::ArchiveWritePlan; use rbitcoin_store::{OutputRecord, TxRecord}; use std::sync::Arc; let (path, q) = tmp_query(); q.enter_direct_index_mode().unwrap(); - let pin = rbitcoin_query::CreatePinInner::records( + let pin = crate::CreatePinInner::records( TxRecord { txid: [0x11u8; 32], version: 1, @@ -2450,8 +2409,8 @@ fn pin_plan_empty_edges_is_invariant() { #[test] fn pin_sparse_need_high_vout_only() { use super::{pin_for_wire_batch, ParentPinStamp}; + use crate::{ArchiveWritePlan, CreatePin}; use rbitcoin_primitives::Fk; - use rbitcoin_query::{ArchiveWritePlan, CreatePin}; use rbitcoin_store::{InputRecord, OutputRecord, TxRecord}; use std::sync::Arc; @@ -2501,10 +2460,8 @@ fn pin_sparse_need_high_vout_only() { script_sig: vec![], witness: vec![], }]; - let spend_pin: CreatePin = rbitcoin_query::CreatePinInner::records( - spend_tx, - vec![OutputRecord::unspent(1, vec![0x51])], - ); + let spend_pin: CreatePin = + crate::CreatePinInner::records(spend_tx, vec![OutputRecord::unspent(1, vec![0x51])]); let mut plan = ArchiveWritePlan { packed: vec![(Arc::clone(&spend_pin), spend_ins)], planned_fks: vec![Fk(2)], @@ -2513,11 +2470,11 @@ fn pin_sparse_need_high_vout_only() { edges: Default::default(), batch_creates: vec![], external_parents: { - let mut m = rbitcoin_query::U64Map::default(); + let mut m = crate::U64Map::default(); let spent = q.store().tx_spent_range(pfk).unwrap(); m.insert( parent_id, - rbitcoin_query::ParentIdent::with_loc(parent_tx.txid, range, spent, 4), + crate::ParentIdent::with_loc(parent_tx.txid, range, spent, 4), ); m }, @@ -2549,8 +2506,8 @@ fn pin_sparse_need_high_vout_only() { #[test] fn pin_range_fill_does_not_count_as_cache_hit() { use super::{pin_for_wire_batch, ParentPinStamp}; + use crate::ArchiveWritePlan; use rbitcoin_primitives::Fk; - use rbitcoin_query::ArchiveWritePlan; use rbitcoin_store::{InputRecord, OutputRecord, TxRecord}; let (path, q) = tmp_query(); @@ -2605,10 +2562,7 @@ fn pin_range_fill_does_not_count_as_cache_hit() { .collect(); let mut plan = ArchiveWritePlan::empty(); plan.packed = vec![( - rbitcoin_query::CreatePinInner::records( - spend_tx, - vec![OutputRecord::unspent(1, vec![0x51])], - ), + crate::CreatePinInner::records(spend_tx, vec![OutputRecord::unspent(1, vec![0x51])]), spend_ins, )]; plan.planned_fks = vec![Fk(100)]; @@ -2616,7 +2570,7 @@ fn pin_range_fill_does_not_count_as_cache_hit() { if let Some(id) = fks[i].get() { plan.external_parents.insert( id, - rbitcoin_query::ParentIdent::with_loc( + crate::ParentIdent::with_loc( items[i].0.txid, ranges[i], q.store().tx_spent_range(fks[i]).unwrap(), @@ -2644,8 +2598,8 @@ fn pin_range_fill_does_not_count_as_cache_hit() { #[test] fn pin_stamp_outs_is_cache_not_new() { use super::{pin_for_wire_batch, ParentPinStamp}; + use crate::{ArchiveWritePlan, CreatePin}; use rbitcoin_primitives::Fk; - use rbitcoin_query::{ArchiveWritePlan, CreatePin}; use rbitcoin_store::{InputRecord, OutputRecord, TxRecord}; use std::sync::Arc; @@ -2664,7 +2618,7 @@ fn pin_stamp_outs_is_cache_not_new() { }; let parent_out = OutputRecord::unspent(50, vec![0x51, 0xaa]); let pin: CreatePin = - rbitcoin_query::CreatePinInner::records(parent_tx.clone(), vec![parent_out.clone()]); + crate::CreatePinInner::records(parent_tx.clone(), vec![parent_out.clone()]); let pfk = Fk(7); let spend_tx = TxRecord { @@ -2688,16 +2642,13 @@ fn pin_stamp_outs_is_cache_not_new() { }]; let mut plan = ArchiveWritePlan::empty(); plan.packed = vec![( - rbitcoin_query::CreatePinInner::records( - spend_tx, - vec![OutputRecord::unspent(1, vec![0x51])], - ), + crate::CreatePinInner::records(spend_tx, vec![OutputRecord::unspent(1, vec![0x51])]), spend_ins, )]; plan.planned_fks = vec![Fk(100)]; plan.external_parents.insert( 7, - rbitcoin_query::ParentIdent { + crate::ParentIdent { txid: tid, body: Some((99, 1)), spent: None, @@ -2727,8 +2678,8 @@ fn pin_stamp_outs_is_cache_not_new() { #[test] fn pin_recent_identity_without_outs_still_range_fills() { use super::{pin_for_wire_batch, ParentPinStamp}; + use crate::ArchiveWritePlan; use rbitcoin_primitives::Fk; - use rbitcoin_query::ArchiveWritePlan; use rbitcoin_store::{InputRecord, OutputRecord, TxRecord}; let (path, q) = tmp_query(); @@ -2777,22 +2728,14 @@ fn pin_recent_identity_without_outs_still_range_fills() { }]; let mut plan = ArchiveWritePlan::empty(); plan.packed = vec![( - rbitcoin_query::CreatePinInner::records( - spend_tx, - vec![OutputRecord::unspent(1, vec![0x51])], - ), + crate::CreatePinInner::records(spend_tx, vec![OutputRecord::unspent(1, vec![0x51])]), spend_ins, )]; plan.planned_fks = vec![Fk(100)]; if let Some(id) = fks[0].get() { plan.external_parents.insert( id, - rbitcoin_query::ParentIdent::with_loc( - tid, - range, - q.store().tx_spent_range(fks[0]).unwrap(), - 1, - ), + crate::ParentIdent::with_loc(tid, range, q.store().tx_spent_range(fks[0]).unwrap(), 1), ); } @@ -2817,8 +2760,6 @@ fn store_start_states_lookup_load_confirm() { confirm_scripts_phase, confirm_wire_load_from_plan, confirm_wire_lookup_stamp, confirm_write_phase, ScriptPreverified, }; - use crate::milestone::Milestone; - use crate::params::ChainParams; use crate::{accept_and_connect_block, prepare_block_for_archive}; use bitcoin::block::{Header, Version}; use bitcoin::blockdata::transaction::{ @@ -2828,6 +2769,8 @@ fn store_start_states_lookup_load_confirm() { use bitcoin::locktime::absolute::LockTime; use bitcoin::CompactTarget; use bitcoin::{Amount, Block, BlockHash, ScriptBuf, Sequence, TxMerkleNode, Witness}; + use rbitcoin_consensus::ChainParams; + use rbitcoin_consensus::Milestone; use rbitcoin_primitives::Height; use std::sync::Arc; @@ -2838,7 +2781,7 @@ fn store_start_states_lookup_load_confirm() { let maturity = params.coinbase_maturity(); fn coinbase(height: u32) -> Transaction { - let mut ss = crate::bip34_height_script(height); + let mut ss = rbitcoin_consensus::bip34_height_script(height); ss.push(bitcoin::opcodes::all::OP_CHECKSIG.to_u8()); let script = ScriptBuf::from_bytes(ss); Transaction { @@ -3012,7 +2955,7 @@ fn store_start_states_lookup_load_confirm() { (Height(h_need), Arc::new(b_need.clone()), None), ]; match confirm_wire_lookup_stamp(&q, ¶ms, ms, &mixed_arcs, None) { - Err(crate::error::ConsensusError::Store(rbitcoin_store::StoreError::Corrupt(m))) => { + Err(crate::ConfirmError::Store(rbitcoin_store::StoreError::Corrupt(m))) => { assert_eq!(m, "invariant: confirm batch mixed archived"); } Ok(_) => panic!("mixed lookup stamp must fail closed"), @@ -3020,7 +2963,7 @@ fn store_start_states_lookup_load_confirm() { } let mixed_run = [(Height(h_have), b_have), (Height(h_need), b_need)]; match super::confirm_wire_load_phase(&q, ¶ms, ms, &mixed_run, &ScriptPreverified::new()) { - Err(crate::error::ConsensusError::Store(rbitcoin_store::StoreError::Corrupt(m))) => { + Err(crate::ConfirmError::Store(rbitcoin_store::StoreError::Corrupt(m))) => { assert_eq!(m, "invariant: confirm batch mixed archived"); } Ok(_) => panic!("mixed one-shot load must fail closed"), @@ -3033,16 +2976,17 @@ fn store_start_states_lookup_load_confirm() { #[test] fn wire_lookup_empty_and_noncontiguous() { use super::confirm_wire_lookup_stamp; - use crate::error::ConsensusError; - use crate::milestone::Milestone; - use crate::params::{genesis_block, ChainParams}; + use crate::ConfirmError; + use rbitcoin_consensus::ConsensusError; + use rbitcoin_consensus::Milestone; + use rbitcoin_consensus::{genesis_block, ChainParams}; use rbitcoin_primitives::Height; use std::sync::Arc; let (path, q) = tmp_query(); let params = ChainParams::regtest(); match confirm_wire_lookup_stamp(&q, ¶ms, Milestone::NONE, &[], None) { - Err(ConsensusError::BadBlock("empty confirm batch")) => {} + Err(ConfirmError::Rules(ConsensusError::BadBlock("empty confirm batch"))) => {} Ok(_) => panic!("empty lookup must refuse"), Err(e) => panic!("empty lookup must refuse, got {e}"), } @@ -3050,7 +2994,7 @@ fn wire_lookup_empty_and_noncontiguous() { let a = (Height(0), Arc::new(g.clone()), None); let b = (Height(2), Arc::new(g), None); match confirm_wire_lookup_stamp(&q, ¶ms, Milestone::NONE, &[a, b], None) { - Err(ConsensusError::BadBlock("confirm run not contiguous")) => {} + Err(ConfirmError::Rules(ConsensusError::BadBlock("confirm run not contiguous"))) => {} Ok(_) => panic!("gap lookup must refuse"), Err(e) => panic!("gap lookup must refuse, got {e}"), } @@ -3060,9 +3004,10 @@ fn wire_lookup_empty_and_noncontiguous() { /// Direct write skips SH FkMap; Class A idx holds the body range. #[test] fn direct_write_skips_create_pin_map_idx_without_recent() { - use crate::regtest_pad::mine_empty_regtest; - use crate::{accept_and_connect_block, ChainParams, Milestone}; + use crate::accept_and_connect_block; use bitcoin::hashes::Hash; + use rbitcoin_consensus::mine_empty_regtest; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::Height; let (path, q) = tmp_query(); q.enter_direct_index_mode().unwrap(); @@ -3090,12 +3035,13 @@ fn one_shot_load_matches_stamp_then_load_from_plan() { confirm_wire_load_from_plan, confirm_wire_load_phase, confirm_wire_lookup_stamp, ScriptPreverified, }; - use crate::regtest_pad::mine_empty_regtest; - use crate::{accept_and_connect_block, ChainParams, Milestone}; + use crate::accept_and_connect_block; + use rbitcoin_consensus::mine_empty_regtest; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::Height; use std::sync::Arc; - fn open_q(_tag: &str) -> (rbitcoin_query::testutil::TempDir, rbitcoin_query::Query) { + fn open_q(_tag: &str) -> (crate::testutil::TempDir, crate::Query) { tmp_query() } @@ -3185,8 +3131,8 @@ fn one_shot_load_matches_stamp_then_load_from_plan() { #[test] fn collect_spend_abs_after_fill_is_one_walk() { use super::{collect_spend_abs_after_fill, Prepared}; + use crate::BatchParents; use rbitcoin_primitives::{Fk, Height}; - use rbitcoin_query::BatchParents; use rbitcoin_store::OutputRecord; let mut bp = BatchParents::new(); @@ -3263,8 +3209,7 @@ fn collect_spend_abs_after_fill_is_one_walk() { fn structural_run_clears_reused_scratch_between_batches() { use super::phases::{structural_run, StructuralReuse}; use super::{collect_spend_abs_after_fill, Prepared}; - use crate::milestone::Milestone; - use crate::params::ChainParams; + use crate::BatchParents; use bitcoin::absolute::LockTime; use bitcoin::block::{Header, Version}; use bitcoin::hashes::Hash; @@ -3274,16 +3219,15 @@ fn structural_run_clears_reused_scratch_between_batches() { Amount, Block, BlockHash, CompactTarget, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness, }; + use rbitcoin_consensus::ChainParams; + use rbitcoin_consensus::Milestone; use rbitcoin_primitives::{Fk, Height}; - use rbitcoin_query::BatchParents; use rbitcoin_store::{InputRecord, OutputRecord}; use std::sync::Arc; let (path, q) = tiny_query(); - let parent_pin = rbitcoin_query::CreatePinInner::records( - rec_tx(0x41, 1), - vec![OutputRecord::unspent(1, vec![0x51])], - ); + let parent_pin = + crate::CreatePinInner::records(rec_tx(0x41, 1), vec![OutputRecord::unspent(1, vec![0x51])]); let (fks, loc) = q .store() .put_tx_full_batch_from_pins( @@ -3388,7 +3332,7 @@ fn structural_run_clears_reused_scratch_between_batches() { &wire, &bp, &jobs, - &crate::block::ClassAWave::default(), + &crate::block_pipeline::ClassAWave::default(), reuse, ) .expect("reused scratch still connects") @@ -3454,10 +3398,10 @@ fn datadir_hdd_submits_head_after_annotate() { confirm_write_phase, finish_post_commit_hashes, ScriptPreverified, }; use crate::accept_and_connect_block; - use crate::milestone::Milestone; - use crate::params::ChainParams; - use crate::regtest_pad::mine_empty_regtest; use bitcoin::hashes::Hash; + use rbitcoin_consensus::mine_empty_regtest; + use rbitcoin_consensus::ChainParams; + use rbitcoin_consensus::Milestone; use rbitcoin_primitives::{Fk, Height}; use std::sync::Arc; diff --git a/crates/rbitcoin-query/src/connect_facade.rs b/crates/rbitcoin-query/src/connect_facade.rs new file mode 100644 index 0000000000..b5a39f4f47 --- /dev/null +++ b/crates/rbitcoin-query/src/connect_facade.rs @@ -0,0 +1,343 @@ +//! Connect façade. Query loads and writes; consensus decides the block. + +use crate::ConfirmError; +use bitcoin::hashes::Hash; +use bitcoin::Block; +use rbitcoin_consensus::block::{validate_block_structure_hashed, ValidationContext}; +use rbitcoin_consensus::{ChainParams, ConsensusError, Milestone}; +use rbitcoin_primitives::{Fk, Height}; +use rbitcoin_store::HeaderRecord; +use std::sync::Arc; + +use crate::confirm_run; +use crate::convert::{block_to_apply, block_to_apply_with_txids_prev, header_to_record}; +use crate::{Query, TxApply, TxPrecompute, WirePlanNeed}; + +/// Confirm a contiguous tip-extension run of wire blocks (sync all stages). +/// +/// See [`confirm_wire_run`]: lookup → load → scripts → write. IBD uses the split +/// phases for pipeline overlap. +pub use crate::confirm_run::{ + confirm_bq_resolve_wave_capped, confirm_scripts_phase, confirm_wire_load_from_plan, + confirm_wire_load_phase, confirm_wire_load_phase_pipelined, confirm_wire_lookup_stamp, + confirm_wire_run, confirm_wire_run_preverified, confirm_write_phase, + confirm_write_phase_or_return, drive_script_waves_with, finish_post_commit_hashes, + parent_missing_from_chain, replay_spend_annotations, take_wave_items_for_load, BqResolveWave, + ConfirmLoadOutcome, ConfirmScriptOutcome, LoadedBatch, PlanStampOutcome, ScriptOkBatch, + ScriptPreverified, WireBlockIn, WireLoadPipeline, BQ_RESOLVE_WAVE_MAX_BLOCKS, + BQ_RESOLVE_WAVE_MAX_INPUTS, +}; + +/// Wake the IBD scripts publisher (`ibd-confirm`) after `scriptq` send or close. +pub use rbitcoin_consensus::unpark_script_publisher; + +/// Accept + archive + confirm in one step (genesis / tip extension / tests). +/// +/// **Same path as IBD confirm:** structure + header checks, then Class A +/// archive, then [`confirm_wire_run`] (lookup → load pin denserels → scripts → +/// structural → Class C → abs spend annotate). No separate +/// `put_spend_batch_by_create`. +/// +/// Idempotent when `height` is already confirmed for this block hash. +/// Full script verify (no mempool skip) — use +/// [`accept_and_connect_block_preverified`] on tip follow with a live mempool. +pub fn accept_and_connect_block( + query: &Query, + params: &ChainParams, + height: Height, + block: &Block, + milestone: Milestone, +) -> Result { + accept_and_connect_block_preverified( + query, + params, + height, + Arc::new(block.clone()), + milestone, + &ScriptPreverified::new(), + None, + ) +} + +/// Like [`accept_and_connect_block`], skipping script verify for `preverified` +/// txids (tip follow: live mempool after accept). Reorg disconnect stays outside. +pub fn accept_and_connect_block_preverified( + query: &Query, + params: &ChainParams, + height: Height, + block: Arc, + milestone: Milestone, + preverified: &ScriptPreverified, + pres: Option>, +) -> Result { + let hash = block.block_hash().to_byte_array(); + if let Some(h) = query.height_of_hash(&hash).map_err(ConfirmError::from)? { + if h == height { + if let Some((fk, _)) = query + .get_header_by_hash(&hash) + .map_err(ConfirmError::from)? + { + confirm_run::finish_post_commit(query, height.0, &hash)?; + return Ok(fk); + } + } + } + + // Unified height-ordered path: wire → lookup → load (pin+assemble) → scripts → + // write (Class A + structural + Class C + annotate). No archive-then-reload. + let fks = confirm_wire_run_preverified( + query, + params, + milestone, + &[(height, block, pres)], + preverified, + )?; + if let Some(fk) = fks.into_iter().next() { + return Ok(fk); + } + // Write skipped heights ≤ tip (idempotent race). A body we just ran + // through lookup/load must have a header — missing is an invariant, not + // a soft NotFound (inflated confirmed[] used to hit this on tip+1). + query + .get_header_by_hash(&hash) + .map_err(ConfirmError::from)? + .map(|(fk, _)| fk) + .ok_or(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( + "invariant: confirm write skipped but header missing", + ))) +} + +fn class_a_header_and_txids( + query: &Query, + params: &ChainParams, + block: &Block, +) -> Result<(HeaderRecord, Vec<[u8; 32]>), ConfirmError> { + let ctx = ValidationContext::archive_structure(params); + let txids = validate_block_structure_hashed(block, &ctx)?; + let hash = block.header.block_hash().to_byte_array(); + rbitcoin_consensus::header::pow_hash_meets_target(hash, block.header.bits, params.pow_limit)?; + let prev = block.header.prev_blockhash; + let prev_fk = if prev.to_byte_array() == [0u8; 32] { + Fk::NULL + } else { + rbitcoin_consensus::block::require_linked_parent( + query + .get_header_by_hash(prev.as_byte_array()) + .map_err(ConfirmError::from)? + .map(|(fk, _)| fk), + )? + }; + Ok((header_to_record(prev_fk, &block.header, hash), txids)) +} + +/// Class A only (no tip / Class C). Crash and `plan=None` tests. +/// +/// Not a production IBD API — confirm write uses `archive_plan_batch_from_wire` +/// + commit (wire `seqsigwit` is encoded from the block and plan edges). +pub fn commit_class_a_block( + query: &Query, + params: &ChainParams, + height: Height, + block: &Block, + milestone: Milestone, +) -> Result<(), ConfirmError> { + let _ = (height, milestone); + let (header, txids) = class_a_header_and_txids(query, params, block)?; + let fk = query.ensure_header(&header).map_err(ConfirmError::from)?; + let arc = Arc::new(block.clone()); + query + .archive_class_a_from_wire(&[(fk, &arc, txids.as_slice())]) + .map_err(ConfirmError::from)?; + Ok(()) +} + +/// Class A for a contiguous run in one plan (same-batch parent stamp). +/// +/// Use this instead of N×[`commit_class_a_block`] when later blocks spend +/// earlier unconfirmed creates. +pub fn commit_class_a_run( + query: &Query, + params: &ChainParams, + blocks: &[(Height, Block)], + milestone: Milestone, +) -> Result<(), ConfirmError> { + let _ = milestone; + let mut owned = Vec::with_capacity(blocks.len()); + for (_, block) in blocks { + let (header, txids) = class_a_header_and_txids(query, params, block)?; + let fk = query.ensure_header(&header).map_err(ConfirmError::from)?; + owned.push((fk, Arc::new(block.clone()), txids)); + } + let refs: Vec> = owned + .iter() + .map(|(fk, b, ids)| (*fk, b, ids.as_slice())) + .collect(); + query + .archive_class_a_from_wire(&refs) + .map_err(ConfirmError::from)?; + Ok(()) +} + +/// CPU-side prep for Class A archive. +pub fn prepare_block_for_archive( + query: &Query, + params: &ChainParams, + block: &Block, +) -> Result<(HeaderRecord, Vec), ConfirmError> { + let hash = block.block_hash().to_byte_array(); + if query.is_block_archived(&hash).map_err(ConfirmError::from)? { + // Standalone archive helper (not confirm pipeline): one hash pass here. + return block_to_apply(query, &block.header, &block.txdata); + } + prepare_block_for_archive_new(query, params, block) +} + +fn prepare_block_for_archive_new( + query: &Query, + params: &ChainParams, + block: &Block, +) -> Result<(HeaderRecord, Vec), ConfirmError> { + let (header, txids) = class_a_header_and_txids(query, params, block)?; + let (_, txs) = + block_to_apply_with_txids_prev(header.prev_fk, &block.header, &block.txdata, &txids)?; + Ok((header, txs)) +} + +#[cfg(test)] +mod coverage_tests { + use super::*; + use crate::Query; + use bitcoin::absolute::LockTime; + use bitcoin::block::Version; + use bitcoin::hashes::Hash; + use bitcoin::script::ScriptBuf; + use bitcoin::transaction::Version as TxVersion; + use bitcoin::{Amount, OutPoint, Sequence, Target, Transaction, TxIn, TxOut, Witness}; + use rbitcoin_consensus::block::ScriptCheckJob; + use rbitcoin_consensus::{genesis_block, mine_empty_regtest, ChainParams, Milestone}; + use rbitcoin_primitives::Height; + use std::path::PathBuf; + fn temp_store() -> (PathBuf, Query) { + let path = std::env::temp_dir().join(format!( + "rbitcoin-consensus-cov-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&path).unwrap(); + let q = Query::open_or_create_tiny(&path).expect("open store"); + (path, q) + } + + #[test] + fn verify_tx_scripts_detached_acs_job() { + let tx = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: bitcoin::Txid::from_byte_array([1; 32]), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + let prevouts = vec![TxOut { + value: Amount::from_sat(10), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }]; + rbitcoin_consensus::verify_tx_scripts_detached(prevouts.clone(), tx.clone()).unwrap(); + let job = ScriptCheckJob::new( + prevouts, + tx, + rbitcoin_consensus::block::ScriptVerifyFlags::buried(true, true, true, true, true), + ); + rbitcoin_consensus::block::verify_one_script_job(&job).unwrap(); + } + + #[test] + fn verify_tx_scripts_detached_forks_op_true_and_op_return() { + let spend = |spk: Vec| { + let tx = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: bitcoin::Txid::from_byte_array([1; 32]), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + let prevouts = vec![TxOut { + value: Amount::from_sat(10), + script_pubkey: ScriptBuf::from_bytes(spk), + }]; + rbitcoin_consensus::verify_tx_scripts_detached_forks( + prevouts, tx, true, true, true, true, true, + ) + }; + spend(vec![0x51]).unwrap(); + assert!(spend(vec![0x6a]).is_err()); + } + + #[test] + fn regtest_connect_archive_and_confirm_path() { + let (path, q) = temp_store(); + let params = ChainParams::regtest(); + let ms = Milestone::height(1_000_000); + let genesis = genesis_block(¶ms); + let gen_fk = accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, ms).unwrap(); + + let b1 = mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); + // prepare helpers stay (CPU-side); confirm is sole Class A. + let (_hr, _txs) = prepare_block_for_archive(&q, ¶ms, &b1).unwrap(); + accept_and_connect_block(&q, ¶ms, Height(1), &b1, ms).unwrap(); + // already-have prepare after connect keeps the parent fk + let (hr, _) = prepare_block_for_archive(&q, ¶ms, &b1).unwrap(); + assert_eq!(hr.prev_fk, gen_fk); + + let _ = std::fs::remove_dir_all(&path); + } + + #[test] + fn assemble_second_block_rejects_stale_nversion() { + let (path, q) = temp_store(); + let params = ChainParams::regtest(); + let ms = Milestone::height(1_000_000); + let genesis = genesis_block(¶ms); + accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, ms).unwrap(); + + let b1 = mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); + let mut b2 = mine_empty_regtest(b1.block_hash(), b1.header.time + 600, 2); + b2.header.version = Version::from_consensus(1); + let target = Target::from_compact(b2.header.bits); + for nonce in 0..u32::MAX { + b2.header.nonce = nonce; + if b2.header.validate_pow(target).is_ok() { + break; + } + } + let err = + confirm_wire_run(&q, ¶ms, ms, &[(Height(1), b1), (Height(2), b2)]).unwrap_err(); + assert!( + matches!(err, ConfirmError::Rules(ConsensusError::BadVersion(1))), + "{err:?}" + ); + let _ = std::fs::remove_dir_all(&path); + } +} diff --git a/crates/rbitcoin-consensus/src/convert.rs b/crates/rbitcoin-query/src/convert.rs similarity index 88% rename from crates/rbitcoin-consensus/src/convert.rs rename to crates/rbitcoin-query/src/convert.rs index dde49b3e7e..ff2e9c1d14 100644 --- a/crates/rbitcoin-consensus/src/convert.rs +++ b/crates/rbitcoin-query/src/convert.rs @@ -1,9 +1,10 @@ -use crate::error::ConsensusError; +use crate::ConfirmError; +use crate::{Query, TxApply}; use bitcoin::block::Header; use bitcoin::hashes::Hash; use bitcoin::Transaction; +use rbitcoin_consensus::ConsensusError; use rbitcoin_primitives::Fk; -use rbitcoin_query::{Query, TxApply}; use rbitcoin_store::{HeaderRecord, InputRecord, OutputRecord, TxRecord}; /// Header row from wire fields. `hash` is the caller-computed `header.block_hash()`. @@ -25,7 +26,7 @@ pub fn block_to_apply( query: &Query, header: &Header, txs: &[Transaction], -) -> Result<(HeaderRecord, Vec), ConsensusError> { +) -> Result<(HeaderRecord, Vec), ConfirmError> { let txids: Vec<[u8; 32]> = txs .iter() .map(|t| t.compute_txid().to_byte_array()) @@ -42,17 +43,16 @@ pub fn block_to_apply_with_txids( header: &Header, txs: &[Transaction], txids: &[[u8; 32]], -) -> Result<(HeaderRecord, Vec), ConsensusError> { - if txs.len() != txids.len() { - return Err(ConsensusError::BadBlock("txid count mismatch")); - } +) -> Result<(HeaderRecord, Vec), ConfirmError> { + rbitcoin_consensus::block::reject_txid_count(txs.len(), txids.len())?; let prev_fk = if header.prev_blockhash.to_byte_array() == [0u8; 32] { Fk::NULL } else { - query - .get_header_by_hash(header.prev_blockhash.as_byte_array())? - .map(|(fk, _)| fk) - .ok_or(ConsensusError::BadPrev)? + rbitcoin_consensus::block::require_linked_parent( + query + .get_header_by_hash(header.prev_blockhash.as_byte_array())? + .map(|(fk, _)| fk), + )? }; block_to_apply_with_txids_prev(prev_fk, header, txs, txids) } @@ -64,10 +64,8 @@ pub fn block_to_apply_with_txids_prev( header: &Header, txs: &[Transaction], txids: &[[u8; 32]], -) -> Result<(HeaderRecord, Vec), ConsensusError> { - if txs.len() != txids.len() { - return Err(ConsensusError::BadBlock("txid count mismatch")); - } +) -> Result<(HeaderRecord, Vec), ConfirmError> { + rbitcoin_consensus::block::reject_txid_count(txs.len(), txids.len())?; let hash = header.block_hash().to_byte_array(); let header_rec = header_to_record(prev_fk, header, hash); let mut out = Vec::with_capacity(txs.len()); @@ -77,7 +75,7 @@ pub fn block_to_apply_with_txids_prev( Ok((header_rec, out)) } -fn tx_to_apply(tx: &Transaction, txid: [u8; 32]) -> Result { +fn tx_to_apply(tx: &Transaction, txid: [u8; 32]) -> Result { let inputs: Vec = tx .input .iter() @@ -173,14 +171,14 @@ mod tests { assert_eq!(apply.tx.txid, want); assert_eq!(apply.inputs.len(), 1); assert_eq!(apply.outputs[0].value, 50_0000_0000); - let edges = [rbitcoin_query::SpendEdge { + let edges = [crate::SpendEdge { prev_txid: [0u8; 32], vout: u32::MAX, spend_fk: Fk(9), create_fk: Fk::NULL, vin: 0, }]; - let ins = rbitcoin_query::input_records_from_wire(&tx, Fk(9), &edges).unwrap(); + let ins = crate::input_records_from_wire(&tx, Fk(9), &edges).unwrap(); assert_eq!(ins, apply.inputs); } @@ -215,11 +213,11 @@ mod tests { // Mismatch paths. assert!(matches!( block_to_apply_with_txids_prev(Fk::NULL, &header, std::slice::from_ref(&tx), &[]), - Err(ConsensusError::BadBlock(_)) + Err(ConfirmError::Rules(ConsensusError::BadBlock(_))) )); assert!(matches!( block_to_apply_with_txids_prev(Fk::NULL, &header, &[], &[[0u8; 32]]), - Err(ConsensusError::BadBlock(_)) + Err(ConfirmError::Rules(ConsensusError::BadBlock(_))) )); let (rec, apps) = block_to_apply_with_txids_prev(Fk::NULL, &header, &[tx], &[txid]).unwrap(); @@ -268,19 +266,18 @@ mod tests { script_pubkey: ScriptBuf::from_bytes(vec![0x51]), }], }; - let edge = |prev_txid: [u8; 32], vout: u32| rbitcoin_query::SpendEdge { + let edge = |prev_txid: [u8; 32], vout: u32| crate::SpendEdge { prev_txid, vout, spend_fk: Fk(9), create_fk: Fk(5), vin: 0, }; - let ok = - rbitcoin_query::input_records_from_wire(&tx, Fk(9), &[edge([1u8; 32], 3)]).unwrap(); + let ok = crate::input_records_from_wire(&tx, Fk(9), &[edge([1u8; 32], 3)]).unwrap(); assert_eq!(ok[0].prev_txid, [1u8; 32]); assert_eq!(ok[0].prev_index(), 3); for bad in [edge([2u8; 32], 3), edge([1u8; 32], 4)] { - let err = rbitcoin_query::input_records_from_wire(&tx, Fk(9), &[bad]) + let err = crate::input_records_from_wire(&tx, Fk(9), &[bad]) .expect_err("mismatched edge must not encode"); assert!( format!("{err}").contains("edge/wire prevout"), diff --git a/crates/rbitcoin-query/src/header_read.rs b/crates/rbitcoin-query/src/header_read.rs new file mode 100644 index 0000000000..2dc9c1c7e6 --- /dev/null +++ b/crates/rbitcoin-query/src/header_read.rs @@ -0,0 +1,193 @@ +//! Header reads. Decisions are [`rbitcoin_consensus::check_header`]. + +use crate::ConfirmError; +use bitcoin::block::Header; +use bitcoin::hashes::Hash; +use bitcoin::CompactTarget; +use rbitcoin_consensus::header::{check_header, next_work_bits, ParentHeader}; +use rbitcoin_consensus::ChainParams; +use rbitcoin_primitives::Height; + +use crate::Query; + +pub fn validate_header( + query: &Query, + params: &ChainParams, + height: Height, + header: &Header, +) -> Result<(), ConfirmError> { + validate_header_hashed( + query, + params, + height, + header, + header.block_hash().to_byte_array(), + ) +} + +/// [`validate_header`] using a caller-computed header hash (lookup already hashed). +pub fn validate_header_hashed( + query: &Query, + params: &ChainParams, + height: Height, + header: &Header, + hash: [u8; 32], +) -> Result<(), ConfirmError> { + let parent = if height.0 == 0 { + None + } else { + query + .header_at_height(Height(height.0 - 1))? + .map(|(_fk, prev)| ParentHeader { + hash: prev.hash, + time: prev.timestamp, + bits: CompactTarget::from_consensus(prev.bits), + }) + }; + let times = if parent.is_some() { + mtp_times_ending_at(query, Height(height.0 - 1))? + } else { + Vec::new() + }; + let period_first = if parent.is_some() { + period_first_time(query, params, height.0)? + } else { + None + }; + check_header( + params, + height, + header, + hash, + parent, + ×, + period_first, + |h| { + header_bits_at(query, Height(h)) + .ok() + .map(CompactTarget::from_consensus) + }, + ) + .map_err(ConfirmError::from) +} + +/// Contextual checks when the parent is a known header, not necessarily on the +/// best chain (`header_at_height` would miss). + +pub fn median_time_past(query: &Query, height: Height) -> Result { + let times = mtp_times_ending_at(query, height)?; + rbitcoin_consensus::block::median_or_bad_header(×).map_err(ConfirmError::from) +} + +/// Timestamps ending at `height` for [`median_time_past`]. A missing plan above +/// the confirmed tip stays a retryable load fault. A gap at or below the tip +/// is [`ConsensusError::BadPrev`]. +pub fn mtp_times_ending_at(query: &Query, height: Height) -> Result, ConfirmError> { + if let Some((n, buf)) = query.store().mtp_times_at(height) { + return Ok(buf[..n as usize].to_vec()); + } + let mut times = Vec::with_capacity(11); + let start = height.0.saturating_sub(10); + let tip = query.tip_height().map(|h| h.0).unwrap_or(0); + for h in start..=height.0 { + if let Some((_fk, rec)) = query.header_at_height(Height(h))? { + times.push(rec.timestamp); + continue; + } + if let Some(plan) = query.confirm_parent_cache().get_header_plan(h) { + times.push(plan.header_rec.timestamp); + continue; + } + rbitcoin_consensus::block::header_gap_on_chain(h, tip)?; + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( + "confirm: load incomplete (parent header plan missing above tip)", + ))); + } + Ok(times) +} + +/// MTP from the confirmed chain only (write structural). Tip-ahead heights +/// must be carried on [`crate::confirm_run`] `Prepared::prev_mtp`. +pub fn median_time_past_store(query: &Query, height: Height) -> Result { + if let Some((n, buf)) = query.store().mtp_times_at(height) { + return rbitcoin_consensus::block::median_or_bad_header(&buf[..n as usize]) + .map_err(ConfirmError::from); + } + let mut times = Vec::with_capacity(11); + let start = height.0.saturating_sub(10); + for h in start..=height.0 { + if let Some((_fk, rec)) = query.header_at_height(Height(h))? { + times.push(rec.timestamp); + continue; + } + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( + "confirm: write MTP missing confirmed header (carry prev_mtp)", + ))); + } + rbitcoin_consensus::block::median_or_bad_header(×).map_err(ConfirmError::from) +} + +/// Expected `nBits` for a new header at `height`. +/// +/// `header_time` is the candidate block's timestamp (Core `pblock->GetBlockTime()`). +pub fn expected_next_bits( + query: &Query, + params: &ChainParams, + height: Height, + header_time: u32, +) -> Result { + if height.0 == 0 { + let g = rbitcoin_consensus::genesis_block(params); + return Ok(g.header.bits); + } + + let prev_height = Height(height.0 - 1); + let (_fk, prev_rec) = + rbitcoin_consensus::block::require_linked_parent(query.header_at_height(prev_height)?)?; + let prev_bits = CompactTarget::from_consensus(prev_rec.bits); + let period_first = period_first_time(query, params, height.0)?; + rbitcoin_consensus::block::require_next_work(next_work_bits( + params, + height.0, + prev_bits, + prev_rec.timestamp, + header_time, + period_first, + |h| { + header_bits_at(query, Height(h)) + .ok() + .map(CompactTarget::from_consensus) + }, + )) + .map_err(ConfirmError::from) +} + +/// Difficulty for the header at `height`, from the parent and the period start. +/// +/// `period_first_time` is the timestamp of the header at `height - interval` +/// when `height` is a retarget boundary. `bits_at` supplies earlier `nBits` +/// for the testnet min-difficulty walk. `None` means an ancestor is missing. + +pub fn period_first_time( + query: &Query, + params: &ChainParams, + height: u32, +) -> Result, ConfirmError> { + let interval = params.difficulty_adjustment_interval(); + if interval == 0 || !height.is_multiple_of(interval) || params.no_pow_retargeting() { + return Ok(None); + } + Ok(query + .header_at_height(Height(height - interval))? + .map(|(_fk, first_rec)| first_rec.timestamp)) +} + +pub fn header_bits_at(query: &Query, height: Height) -> Result { + if let Some((_fk, rec)) = query.header_at_height(height)? { + return Ok(rec.bits); + } + if let Some(plan) = query.confirm_parent_cache().get_header_plan(height.0) { + return Ok(plan.header_rec.bits); + } + rbitcoin_consensus::block::require_linked_parent(None).map_err(ConfirmError::from) +} diff --git a/crates/rbitcoin-query/src/header_store_tests.rs b/crates/rbitcoin-query/src/header_store_tests.rs new file mode 100644 index 0000000000..67bd89d0d6 --- /dev/null +++ b/crates/rbitcoin-query/src/header_store_tests.rs @@ -0,0 +1,241 @@ +//! Store-backed header reads. Rule rejects stay in `rbitcoin_consensus::check_header`. +#![cfg(test)] + +use crate::testutil::FixtureChain; +use bitcoin::hashes::Hash; +use bitcoin::CompactTarget; +use rbitcoin_consensus::{genesis_block, ChainParams, ConsensusError}; +use rbitcoin_primitives::{median_time_past_times, Fk, Height}; +use rbitcoin_store::{HeaderRecord, InputRecord, OutputRecord, TxRecord}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::{ + expected_next_bits, median_time_past, median_time_past_store, validate_header, ConfirmError, + Query, TxApply, +}; + +fn temp_q() -> (std::path::PathBuf, Query) { + let n = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("rbitcoin-hdr-mtp-{n}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let q = Query::open_or_create_tiny(dir.join("store")).unwrap(); + (dir, q) +} + +/// Write-gate-safe synthetic coinbase: non-null `prev_fk` must commit `parent_hash`. +fn coinbase(h: u32, prev: Fk, parent_hash: Option<[u8; 32]>) -> (HeaderRecord, TxApply) { + let version = 1; + let timestamp = 1_000 + h * 10; + let bits = 0x207fffff; + let nonce = h; + let mut merkle = [0u8; 32]; + merkle[0..4].copy_from_slice(&h.to_le_bytes()); + merkle[4] = 0xcd; + let hash = match parent_hash { + None => merkle, + Some(ph) => { + rbitcoin_store::block_header_hash(version, &ph, &merkle, timestamp, bits, nonce) + } + }; + let header = HeaderRecord { + prev_fk: prev, + version, + timestamp, + bits, + nonce, + merkle_root: merkle, + hash, + size: 0, + weight: 0, + }; + let mut txid = [0u8; 32]; + txid[0..4].copy_from_slice(&h.to_le_bytes()); + txid[31] = 0xcb; + let ta = TxApply { + tx: TxRecord { + txid, + version: 1, + locktime: 0, + input_start_fk: Fk::NULL, + input_count: 1, + output_start_fk: Fk::NULL, + output_count: 1, + }, + inputs: vec![InputRecord { + prev_txid: [0u8; 32], + parent: rbitcoin_store::InputParent::Coinbase, + sequence: u32::MAX, + script_sig: vec![h as u8], + witness: vec![], + }], + outputs: vec![OutputRecord::unspent(50, vec![0x51])], + }; + (header, ta) +} + +#[test] +fn mtp_from_confirmed_chain_and_missing_above_tip() { + let (dir, q) = temp_q(); + let mut prev = Fk::NULL; + let mut parent_hash: Option<[u8; 32]> = None; + for h in 0..3u32 { + let (hdr, ta) = coinbase(h, prev, parent_hash); + parent_hash = Some(hdr.hash); + prev = q.connect_block(Height(h), &hdr, &[ta]).unwrap(); + } + let mtp = median_time_past(&q, Height(2)).unwrap(); + // times: 1000, 1010, 1020 → middle 1010 + assert_eq!(mtp, 1010); + let (n, buf) = q + .store() + .mtp_times_at(Height(2)) + .expect("ring covers confirmed tip"); + assert_eq!(n, 3); + assert_eq!(median_time_past_times(&buf[..3]).unwrap(), 1010); + + // Height above tip with no plan → incomplete load error (not BadPrev). + let err = median_time_past(&q, Height(5)).unwrap_err(); + assert!( + format!("{err}").contains("load incomplete"), + "above tip must be the retryable load error, got {err:?}" + ); + + let (empty_dir, empty) = temp_q(); + let err = median_time_past(&empty, Height(0)).unwrap_err(); + assert!( + matches!(err, ConfirmError::Rules(ConsensusError::BadPrev)), + "a missing header at the tip is BadPrev, got {err:?}" + ); + let _ = std::fs::remove_dir_all(&empty_dir); + + // expected_next_bits: height 0 + regtest no-retarget. + let params = ChainParams::regtest(); + let gbits = expected_next_bits(&q, ¶ms, Height(0), 0).unwrap(); + assert_eq!(gbits, genesis_block(¶ms).header.bits); + let b1 = expected_next_bits(&q, ¶ms, Height(1), 0).unwrap(); + let (_fk, rec0) = q.header_at_height(Height(0)).unwrap().unwrap(); + assert_eq!(b1.to_consensus(), rec0.bits); + + // Bad prev header height. + assert!(expected_next_bits(&q, ¶ms, Height(99), 0).is_err()); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn sequential_tip_mtp_matches_ring_and_survives_pop() { + let (dir, q) = temp_q(); + let mut prev = Fk::NULL; + let mut parent_hash: Option<[u8; 32]> = None; + let mut times = Vec::new(); + for h in 0..12u32 { + let (hdr, ta) = coinbase(h, prev, parent_hash); + times.push(hdr.timestamp); + parent_hash = Some(hdr.hash); + prev = q.connect_block(Height(h), &hdr, &[ta]).unwrap(); + } + let want11 = median_time_past_times(×[1..]).unwrap(); + assert_eq!(median_time_past_store(&q, Height(11)).unwrap(), want11); + assert_eq!(median_time_past(&q, Height(11)).unwrap(), want11); + let (n, buf) = q.store().mtp_times_at(Height(11)).expect("ring at tip"); + assert_eq!(n, 11); + assert_eq!(median_time_past_times(&buf[..11]).unwrap(), want11); + assert!( + q.store().mtp_times_at(Height(5)).is_none(), + "historical MTP is not the tip ring" + ); + assert_eq!( + median_time_past_store(&q, Height(5)).unwrap(), + median_time_past_times(×[0..=5]).unwrap() + ); + + q.disconnect_tip().unwrap(); + let want10 = median_time_past_times(×[0..=10]).unwrap(); + assert_eq!(median_time_past_store(&q, Height(10)).unwrap(), want10); + let (n, buf) = q + .store() + .mtp_times_at(Height(10)) + .expect("ring rebuilt after pop"); + assert_eq!(median_time_past_times(&buf[..n as usize]).unwrap(), want10); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn validate_header_genesis_and_bad_prev() { + let (dir, q) = temp_q(); + let params = ChainParams::regtest(); + let g = genesis_block(¶ms); + // Wrong genesis hash at height 0. + let mut bad = g.header; + bad.nonce ^= 1; + let err = validate_header(&q, ¶ms, Height(0), &bad).unwrap_err(); + assert!( + matches!(err, ConfirmError::Rules(ConsensusError::BadHeader(_))), + "{err:?}" + ); + + // Synthetic tip for prev linkage (child hash commits to parent). + let (h0, ta0) = coinbase(0, Fk::NULL, None); + let prev = q.connect_block(Height(0), &h0, &[ta0]).unwrap(); + let (h1, ta1) = coinbase(1, prev, Some(h0.hash)); + q.connect_block(Height(1), &h1, &[ta1]).unwrap(); + + // Header with wrong prev hash at height 1. + let mut hdr = g.header; + hdr.prev_blockhash = bitcoin::BlockHash::from_byte_array([0xee; 32]); + hdr.time = 2_000; + hdr.bits = bitcoin::CompactTarget::from_consensus(0x207fffff); + let err = validate_header(&q, ¶ms, Height(1), &hdr).unwrap_err(); + assert!( + matches!( + err, + ConfirmError::Rules( + ConsensusError::BadPrev + | ConsensusError::BadHeader(_) + | ConsensusError::BadVersion(_) + ) + ), + "{err:?}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn testnet_min_difficulty_after_20_minute_gap() { + let (dir, q) = temp_q(); + let params = ChainParams::testnet(); + assert!(params.allow_min_difficulty_blocks()); + let (h0, ta0) = coinbase(0, Fk::NULL, None); + let prev_fk = q.connect_block(Height(0), &h0, &[ta0]).unwrap(); + let limit = params.pow_limit.to_compact_lossy(); + assert_ne!(CompactTarget::from_consensus(h0.bits), limit); + + let spacing = params.btc.pow_target_spacing as u32; + let gap = expected_next_bits(&q, ¶ms, Height(1), h0.timestamp + 2 * spacing + 1).unwrap(); + assert_eq!(gap, limit); + let eq_boundary = + expected_next_bits(&q, ¶ms, Height(1), h0.timestamp + 2 * spacing).unwrap(); + assert_eq!(eq_boundary.to_consensus(), h0.bits); + + let (mut h1, ta1) = coinbase(1, prev_fk, Some(h0.hash)); + h1.timestamp = h0.timestamp + 2 * spacing + 1; + h1.bits = limit.to_consensus(); + h1.hash = rbitcoin_store::block_header_hash( + h1.version, + &h0.hash, + &h1.merkle_root, + h1.timestamp, + h1.bits, + h1.nonce, + ); + q.connect_block(Height(1), &h1, &[ta1]).unwrap(); + let walked = expected_next_bits(&q, ¶ms, Height(2), h1.timestamp + 100).unwrap(); + assert_eq!(walked.to_consensus(), h0.bits); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/rbitcoin-query/src/in_flight.rs b/crates/rbitcoin-query/src/in_flight.rs index 0c016e353e..8c799826b3 100644 --- a/crates/rbitcoin-query/src/in_flight.rs +++ b/crates/rbitcoin-query/src/in_flight.rs @@ -15,7 +15,7 @@ //! [`InFlight::drop_from_height`] on pack height. use crate::archive::CreatePin; -use crate::id_map::TxidHasher; +use crate::TxidHasher; use crate::U64Map; use rbitcoin_primitives::Fk; use std::collections::{BTreeMap, HashMap}; diff --git a/crates/rbitcoin-consensus/src/index_rows.rs b/crates/rbitcoin-query/src/index_rows.rs similarity index 92% rename from crates/rbitcoin-consensus/src/index_rows.rs rename to crates/rbitcoin-query/src/index_rows.rs index 8d1c19c40d..8cc6f1edbe 100644 --- a/crates/rbitcoin-consensus/src/index_rows.rs +++ b/crates/rbitcoin-query/src/index_rows.rs @@ -8,12 +8,13 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; +use crate::Query; use bitcoin::bip158::BlockFilter; use rbitcoin_primitives::{Fk, Height}; -use rbitcoin_query::Query; -use crate::script_pool::start_for_each_owned_chunk; -use crate::ConsensusError; +use crate::ConfirmError; +use rbitcoin_consensus::script_pool::start_for_each_owned_chunk; +use rbitcoin_consensus::ConsensusError; /// Per-tx tweak (`None` = ineligible), block order. pub(crate) type HeightTweaks = Vec>; @@ -41,7 +42,7 @@ pub(crate) struct IndexCommit { pub(crate) fn finish_wave( jobs: Vec, f: fn(&T) -> Result<(), ConsensusError>, -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { if let Some(wave) = start_for_each_owned_chunk(jobs, f, 1)? { wave.finish()?; } @@ -68,7 +69,7 @@ pub(crate) fn rows_from_slots(slots: &[Arc>>]) -> I pub(crate) fn commit_index_rows( query: &Query, rows: IndexRows, -) -> Result { +) -> Result { let t0 = Instant::now(); let moved = commit_filters(query, rows.filters)? || commit_tweaks(query, rows.tweaks)?; Ok(IndexCommit { @@ -81,7 +82,7 @@ pub(crate) fn commit_index_rows( fn commit_filters( query: &Query, filters: Vec<(Height, BlockFilter, Fk)>, -) -> Result { +) -> Result { let Some(next) = query.filter_index_next() else { return Ok(false); }; @@ -106,7 +107,7 @@ fn commit_filters( fn commit_tweaks( query: &Query, tweaks: Vec<(Height, Fk, HeightTweaks)>, -) -> Result { +) -> Result { let Some(next) = query.tweak_index_next() else { return Ok(false); }; diff --git a/crates/rbitcoin-consensus/src/index_writebehind.rs b/crates/rbitcoin-query/src/index_writebehind.rs similarity index 95% rename from crates/rbitcoin-consensus/src/index_writebehind.rs rename to crates/rbitcoin-query/src/index_writebehind.rs index e9f1f057c9..642b0840a7 100644 --- a/crates/rbitcoin-consensus/src/index_writebehind.rs +++ b/crates/rbitcoin-query/src/index_writebehind.rs @@ -16,13 +16,14 @@ //! a one-height window. use crate::index_rows::{self, IndexHeightOut, IndexRows}; -use crate::silent_payments::tweak_records_from_window; -use crate::ConsensusError; +use crate::sp_read::tweak_records_from_window; +use crate::ConfirmError; +use crate::Query; +use rbitcoin_consensus::ConsensusError; use rbitcoin_log::progress; #[cfg(test)] use rbitcoin_primitives::Fk; use rbitcoin_primitives::Height; -use rbitcoin_query::Query; use rbitcoin_store::IndexWindow; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{sync_channel, Receiver}; @@ -143,14 +144,14 @@ pub fn index_tip_entry(query: &Query) -> IndexTipEntry { /// Before confirm: seal each enabled index whose gap through the tip fits /// [`INDEX_STARTUP_GAP_HEIGHTS`], then set [`Query::index_live`] when every /// enabled index is contiguous with the tip. -pub fn prepare_live_indexes(query: &Query) -> Result<(), ConsensusError> { +pub fn prepare_live_indexes(query: &Query) -> Result<(), ConfirmError> { prepare_live_indexes_limited(query, INDEX_STARTUP_GAP_HEIGHTS) } pub(crate) fn prepare_live_indexes_limited( query: &Query, max_heights: u32, -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { let tip_h = query.tip_height().map(|h| h.0); if let Some(tip) = tip_h { let repair_f = repair_from(query.filter_index_next(), tip, max_heights); @@ -193,7 +194,7 @@ fn seal_through( filter_from: Option, tweak_from: Option, tip: u32, -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { let mut start = match (filter_from, tweak_from) { (Some(f), Some(t)) => f.min(t), (Some(f), None) => f, @@ -215,7 +216,7 @@ fn seal_through( let window = Arc::new(read_window_from(query, start, end, tweak_from)?); let rows = assemble_window_from(&window, filter_from, tweak_from)?; if index_rows::commit_index_rows(query, rows)?.moved { - return Err(ConsensusError::Store(rbitcoin_store::StoreError::Corrupt( + return Err(ConfirmError::Store(rbitcoin_store::StoreError::Corrupt( "invariant: startup index repair", ))); } @@ -228,7 +229,7 @@ fn seal_through( Ok(()) } -fn plan_window(query: &Query, start: u32, target: u32) -> Result { +fn plan_window(query: &Query, start: u32, target: u32) -> Result { let mut end = start; let mut txs = 0u64; while end < target && end - start + 1 < WINDOW_HEIGHTS { @@ -249,7 +250,7 @@ fn plan_window(query: &Query, start: u32, target: u32) -> Result Result { +fn read_window(query: &Query, start: u32, end: u32) -> Result { read_window_from(query, start, end, query.tweak_index_next()) } @@ -258,7 +259,7 @@ fn read_window_from( start: u32, end: u32, tweaks_from: Option, -) -> Result { +) -> Result { let heights = query.index_heights(start, end, tweaks_from)?; let mut window = query.read_index_window(&heights)?; for block in &mut window.blocks { @@ -275,12 +276,16 @@ struct IndexHeightJob { out: Arc>>, } -fn assemble_index_height(job: &IndexHeightJob) -> Result<(), ConsensusError> { +fn assemble_index_height_pool(job: &IndexHeightJob) -> Result<(), ConsensusError> { + assemble_index_height(job).map_err(ConfirmError::into_pool) +} + +fn assemble_index_height(job: &IndexHeightJob) -> Result<(), ConfirmError> { let block = &job.window.blocks[job.index]; let filter = if job.want_filter { Some(( block.height, - rbitcoin_query::basic_filter_of(&block.hash, &job.window, job.index)?, + crate::basic_filter_of(&block.hash, &job.window, job.index)?, block.header_fk, )) } else { @@ -301,7 +306,7 @@ fn assemble_index_height(job: &IndexHeightJob) -> Result<(), ConsensusError> { /// One job per height that still needs a filter or tweaks. The wave joins /// before return, so the `Arc` is only shared for that call. -fn assemble_window(query: &Query, window: &Arc) -> Result { +fn assemble_window(query: &Query, window: &Arc) -> Result { assemble_window_from(window, query.filter_index_next(), query.tweak_index_next()) } @@ -309,7 +314,7 @@ fn assemble_window_from( window: &Arc, filter_from: Option, tweak_from: Option, -) -> Result { +) -> Result { let mut slots = Vec::with_capacity(window.blocks.len()); let mut jobs = Vec::new(); for (index, block) in window.blocks.iter().enumerate() { @@ -328,7 +333,7 @@ fn assemble_window_from( } slots.push(out); } - index_rows::finish_wave(jobs, assemble_index_height)?; + index_rows::finish_wave(jobs, assemble_index_height_pool)?; Ok(index_rows::rows_from_slots(&slots)) } @@ -420,7 +425,7 @@ fn commit_window( query: &Query, window: &Arc, stages: &IndexStageMs, -) -> Result { +) -> Result { if window.blocks.is_empty() { return Ok(CommitTimes { ok: true, @@ -435,7 +440,7 @@ fn commit_window( let commit_ns = committed.put_ns; stages.add_build(build_ns); stages.add_commit(commit_ns); - rbitcoin_query::note_confirm( + crate::note_confirm( &query.confirm_stats().blockfilter_ns, build_ns.saturating_add(commit_ns), ); @@ -447,7 +452,7 @@ fn commit_window( } /// Seal every released height on this thread (regtest `generate`, tests). -pub fn build_indexes_released(query: &Query) -> Result<(), ConsensusError> { +pub fn build_indexes_released(query: &Query) -> Result<(), ConfirmError> { let mut live = IndexStages::default(); live.observe(query, query.index_target(), true, true); while let (Some(start), Some(target)) = (next_needed(query), query.index_target()) { @@ -480,7 +485,7 @@ fn cpu_worker( rx: Receiver, resync: &AtomicBool, stages: &IndexStageMs, -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { for ready in rx { if resync.load(Ordering::Acquire) { continue; @@ -533,7 +538,7 @@ fn run_writebehind( query: &Query, stop: &AtomicBool, on_filters_caught_up: impl FnOnce(), -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { // Spawned after catch-up: the current tip was already announced. if let Some(tip) = query.tip_height() { query.release_index_writebehind(tip); @@ -575,7 +580,7 @@ fn io_loop( tx: &std::sync::mpsc::SyncSender, progress_every: Duration, on_filters_caught_up: impl FnOnce(), -) -> Result<(), ConsensusError> { +) -> Result<(), ConfirmError> { let mut on_filters_caught_up = Some(on_filters_caught_up); let mut cursor: Option = None; let mut pass: Option = None; @@ -779,7 +784,7 @@ mod tests { prev_fk: &mut Fk, prev_hash: &mut Option<[u8; 32]>, ) { - use rbitcoin_query::testutil::FixtureChain; + use crate::testutil::FixtureChain; use rbitcoin_store::{InputRecord, OutputRecord}; for height in from..=to { @@ -856,13 +861,13 @@ mod tests { /// the serial window walk. #[test] fn per_height_jobs_match_serial_window() { + use crate::testutil::FixtureChain; use bitcoin::hashes::{hash160, Hash}; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; - use rbitcoin_query::testutil::FixtureChain; use rbitcoin_store::{InputRecord, OutputRecord}; - let _gate = crate::script_pool::steal_test_gate(); - let (dir, q) = rbitcoin_query::testutil::tiny_query_labeled("idx-jobs"); + let _gate = rbitcoin_consensus::script_pool::steal_test_gate(); + let (dir, q) = crate::testutil::tiny_query_labeled("idx-jobs"); q.set_block_filter_index(true).unwrap(); q.set_sptweaks_enabled(true, Height(0)).unwrap(); @@ -938,7 +943,7 @@ mod tests { let serial_f = q.basic_filter_from_window(&window, i).unwrap(); assert_eq!(got.filters[i].1.content, serial_f.content, "filter {i}"); assert_eq!(got.filters[i].2, block.header_fk); - let serial_t = crate::silent_payments::tweak_records_from_window(&window, i).unwrap(); + let serial_t = crate::sp_read::tweak_records_from_window(&window, i).unwrap(); assert_eq!(got.tweaks[i].2, serial_t, "tweaks {i}"); } assert!(got.tweaks[0].2.iter().all(|t| t.is_none()), "coinbase"); @@ -982,10 +987,10 @@ mod tests { #[test] fn released_builder_crosses_a_full_window_and_success_does_not_resync() { - use rbitcoin_query::testutil::FixtureChain; + use crate::testutil::FixtureChain; use rbitcoin_store::{InputRecord, OutputRecord}; - let (dir, q) = rbitcoin_query::testutil::tiny_query_labeled("idx-full-window"); + let (dir, q) = crate::testutil::tiny_query_labeled("idx-full-window"); q.set_block_filter_index(true).unwrap(); q.set_sptweaks_enabled(true, Height(0)).unwrap(); @@ -1090,10 +1095,10 @@ mod tests { #[test] fn filter_catchup_callback_fires_only_after_the_released_tip() { - use rbitcoin_query::testutil::FixtureChain; + use crate::testutil::FixtureChain; use rbitcoin_store::{InputRecord, OutputRecord}; - let (dir, q) = rbitcoin_query::testutil::tiny_query_labeled("idx-filter-catchup"); + let (dir, q) = crate::testutil::tiny_query_labeled("idx-filter-catchup"); q.set_block_filter_index(true).unwrap(); let mut prev_fk = Fk::NULL; let mut prev_hash = None; @@ -1239,7 +1244,7 @@ mod tests { /// counts from its own start and ends at its own total. #[test] fn released_builder_reports_each_index_from_its_start() { - let (dir, q) = rbitcoin_query::testutil::tiny_query_labeled("idx-progress-released"); + let (dir, q) = crate::testutil::tiny_query_labeled("idx-progress-released"); q.set_block_filter_index(true).unwrap(); let tip = WINDOW_HEIGHTS + 1; coinbase_chain(&q, tip); @@ -1269,7 +1274,7 @@ mod tests { #[test] fn startup_repair_reports_only_the_indexes_it_seals() { - let (dir, q) = rbitcoin_query::testutil::tiny_query_labeled("idx-progress-repair"); + let (dir, q) = crate::testutil::tiny_query_labeled("idx-progress-repair"); q.set_block_filter_index(true).unwrap(); q.set_sptweaks_enabled(true, Height(0)).unwrap(); let tip = WINDOW_HEIGHTS + 1; @@ -1297,7 +1302,7 @@ mod tests { /// it began. #[test] fn writebehind_registers_a_stage_for_a_wide_gap() { - let (dir, q) = rbitcoin_query::testutil::tiny_query_labeled("idx-progress-wb"); + let (dir, q) = crate::testutil::tiny_query_labeled("idx-progress-wb"); q.set_block_filter_index(true).unwrap(); let tip = WINDOW_HEIGHTS + 1; coinbase_chain(&q, tip); @@ -1345,8 +1350,8 @@ mod tests { txid: [u8; 32], inputs: Vec, outputs: Vec, - ) -> rbitcoin_query::TxApply { - rbitcoin_query::TxApply { + ) -> crate::TxApply { + crate::TxApply { tx: rbitcoin_store::TxRecord { txid, version: 2, diff --git a/crates/rbitcoin-query/src/lib.rs b/crates/rbitcoin-query/src/lib.rs index eceb9412a5..45c3d6479a 100644 --- a/crates/rbitcoin-query/src/lib.rs +++ b/crates/rbitcoin-query/src/lib.rs @@ -12,7 +12,6 @@ mod confirm_load; mod confirm_parent_cache; mod confirm_stats; mod connect; -mod id_map; mod in_flight; mod reconstruct; mod resolved_wire; @@ -24,22 +23,59 @@ mod sp_tweaks; mod spend_sync; mod stamp; pub mod testutil; -mod tx_precompute; mod wave_prevout; mod write_create_loc; +#[cfg(test)] +mod bip68_store_tests; +mod block_pipeline; +mod confirm_error; +mod confirm_run; +mod connect_facade; +mod convert; +mod header_read; +#[cfg(test)] +mod header_store_tests; +mod index_rows; +mod index_writebehind; +mod milestone_gate; +#[cfg(test)] +mod overlay_csv_tests; +mod regtest_pad; +mod sp_read; +#[cfg(test)] +mod sp_store_tests; +#[cfg(test)] +mod structure_rule_tests; +#[cfg(test)] +mod wire_agree_tests; + pub use combined_stage::{load_creates_once, CombinedCreate}; +pub use confirm_error::ConfirmError; +pub use connect_facade::*; +pub use convert::header_to_record; +pub use header_read::{ + expected_next_bits, median_time_past, median_time_past_store, validate_header, + validate_header_hashed, +}; +pub use index_writebehind::{ + build_indexes_released, index_tip_entry, prepare_live_indexes, spawn_index_writebehind, + IndexTipEntry, INDEX_STARTUP_GAP_HEIGHTS, +}; +pub use rbitcoin_consensus::{ + block_wire_len_from_pres, decode_block_precomputes, pres_for_tip, IdMap, OutPointHasher, + OutPointSet, TxPrecompute, TxidHasher, TxidMap, TxidSet, +}; pub use reconstruct::{BlockTxStatRows, StampedTxstatBlock}; +pub use regtest_pad::pad_empty_from; pub use resolved_wire::{BlockQueueWaveIntake, ResolvedWire}; pub use soft_densify::{ bq_assign_stop_bytes, soft_ahead_quarter_full, soft_assign_restricted, soft_confirm_window_covered, soft_confirm_window_n, soft_densify_band_hi, BQ_SOFT_FREE_BYTES, TIP_HOLE_MIN_AHEAD_BLOCKS, }; +pub use sp_read::{tweaks_at_height, tweaks_for_height, tweaks_from_thin_and_body}; pub use sp_tweaks::{ThinTweakRangeLimits, ThinTweakRow}; -pub use tx_precompute::{ - block_wire_len_from_pres, decode_block_precomputes, pres_for_tip, TxPrecompute, -}; use bitcoin::absolute::LockTime; use bitcoin::block::{Header as BlockHeader, Version as BlockVersion}; @@ -226,7 +262,6 @@ pub use catchup::IndexMode; pub use chain_view::{header_lookup_count, ChainView, ChainViewKind}; pub use confirm_load::SpendEdges; pub use connect::{spawn_sh_writebehind, ConfirmPrepared}; -pub use id_map::{IdMap, OutPointHasher, OutPointSet, TxidHasher, TxidMap, TxidSet}; pub use in_flight::InFlight; pub use scripthash::{ BlockTouch, FilteredHistory, HistoryFilter, HistoryOrder, ScriptHashBalance, diff --git a/crates/rbitcoin-query/src/milestone_gate.rs b/crates/rbitcoin-query/src/milestone_gate.rs new file mode 100644 index 0000000000..34fc6d289f --- /dev/null +++ b/crates/rbitcoin-query/src/milestone_gate.rs @@ -0,0 +1,20 @@ +//! Confirm-path milestone gate. The predicate is [`rbitcoin_consensus::Milestone::skips_scripts`]. + +use rbitcoin_consensus::Milestone; + +use crate::Query; + +/// Notes `milestone_gate_ns` (`ibd: perf_dbg milestone_us`). +pub fn skips_on_query(milestone: Milestone, query: &Query, height: u32, block: &[u8; 32]) -> bool { + let t = std::time::Instant::now(); + let skip = milestone.skips_scripts(height, block, |h| query.milestone_header_at(h)); + crate::note_confirm( + &query.confirm_stats().milestone_gate_ns, + t.elapsed().as_nanos() as u64, + ); + skip +} + +pub fn check_scripts(milestone: Milestone, query: &Query, height: u32, block: &[u8; 32]) -> bool { + !skips_on_query(milestone, query, height, block) +} diff --git a/crates/rbitcoin-query/src/overlay_csv_tests.rs b/crates/rbitcoin-query/src/overlay_csv_tests.rs new file mode 100644 index 0000000000..b1f9bacc65 --- /dev/null +++ b/crates/rbitcoin-query/src/overlay_csv_tests.rs @@ -0,0 +1,152 @@ +//! CSV activation overlay changes confirm, not only the height getter. +#![cfg(test)] + +use bitcoin::absolute::LockTime; +use bitcoin::blockdata::constants; +use bitcoin::script::ScriptBuf; +use bitcoin::transaction::Version as TxVersion; +use bitcoin::{Amount, Network, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; +use rbitcoin_consensus::{mine_regtest_paying, ChainParams, Milestone}; +use rbitcoin_primitives::Height; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::{accept_and_connect_block, pad_empty_from, Query}; + +/// Overlay must change **confirm** BIP68, not just the getter. +#[test] +fn overlay_csv_102_is_lax_at_101_strict_at_102() { + let n = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("rbtc-overlay-csv-{n}")); + let q = Query::open_or_create_tiny(&dir).unwrap(); + let mut params = ChainParams::regtest(); + params.apply_test_activation_height("csv", 102).unwrap(); + let genesis = constants::genesis_block(Network::Regtest); + accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); + let (tip, tip_time, cbs) = pad_empty_from( + &q, + ¶ms, + genesis.block_hash(), + genesis.header.time, + 1, + 100, + 1, + ); + let spk = ScriptBuf::from_bytes(vec![0x51]); + let parent = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: cbs[0], + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(50_0000_0000 - 1_000), + script_pubkey: spk.clone(), + }], + }; + let b101 = mine_regtest_paying(tip, tip_time + 600, 101, spk.clone(), vec![parent.clone()]); + accept_and_connect_block(&q, ¶ms, Height(101), &b101, Milestone::NONE) + .expect("parent spend at 101"); + + let child = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: parent.compute_txid(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::from_consensus(10), + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(50_0000_0000 - 2_000), + script_pubkey: spk.clone(), + }], + }; + let b102 = mine_regtest_paying( + b101.block_hash(), + b101.header.time + 600, + 102, + spk.clone(), + vec![child], + ); + let err = + accept_and_connect_block(&q, ¶ms, Height(102), &b102, Milestone::NONE).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("not final") || msg.contains("nonfinal") || msg.contains("BIP68"), + "csv@102 must reject BIP68-locked spend at height 102: {msg}" + ); + + let dir2 = std::env::temp_dir().join(format!("rbtc-overlay-csv-lax-{n}")); + let q2 = Query::open_or_create_tiny(&dir2).unwrap(); + let mut lax = ChainParams::regtest(); + lax.apply_test_activation_height("csv", 200).unwrap(); + accept_and_connect_block(&q2, &lax, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); + let (tip2, time2, cbs2) = pad_empty_from( + &q2, + &lax, + genesis.block_hash(), + genesis.header.time, + 1, + 100, + 1, + ); + let p_lax = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: cbs2[0], + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(50_0000_0000 - 1_000), + script_pubkey: spk.clone(), + }], + }; + let b101l = mine_regtest_paying(tip2, time2 + 600, 101, spk.clone(), vec![p_lax.clone()]); + accept_and_connect_block(&q2, &lax, Height(101), &b101l, Milestone::NONE).unwrap(); + let c_lax = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: p_lax.compute_txid(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::from_consensus(10), + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(50_0000_0000 - 2_000), + script_pubkey: spk, + }], + }; + let b102l = mine_regtest_paying( + b101l.block_hash(), + b101l.header.time + 600, + 102, + ScriptBuf::from_bytes(vec![0x51]), + vec![c_lax], + ); + accept_and_connect_block(&q2, &lax, Height(102), &b102l, Milestone::NONE) + .expect("csv@200: BIP68 not consensus at height 102"); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dir2); +} diff --git a/crates/rbitcoin-query/src/regtest_pad.rs b/crates/rbitcoin-query/src/regtest_pad.rs new file mode 100644 index 0000000000..2b2ceb87e8 --- /dev/null +++ b/crates/rbitcoin-query/src/regtest_pad.rs @@ -0,0 +1,34 @@ +//! Regtest helpers that open a [`Query`]. + +use rbitcoin_consensus::{mine_empty_regtest, ChainParams, Milestone}; + +use crate::accept_and_connect_block; +use rbitcoin_primitives::Height; + +use crate::Query; + +/// Connect empty blocks `from_h..=last`. Returns tip hash/time and coinbase txids +/// for heights `1..=collect_coinbases` (empty if `collect_coinbases == 0`). +pub fn pad_empty_from( + query: &Query, + params: &ChainParams, + mut tip: bitcoin::BlockHash, + mut tip_time: u32, + from_h: u32, + last: u32, + collect_coinbases: u32, +) -> (bitcoin::BlockHash, u32, Vec) { + let ms = Milestone::NONE; + let mut cbs = Vec::new(); + for h in from_h..=last { + let b = mine_empty_regtest(tip, tip_time + 600, h); + if h >= 1 && h <= collect_coinbases { + cbs.push(b.txdata[0].compute_txid()); + } + accept_and_connect_block(query, params, Height(h), &b, ms).unwrap(); + tip = b.block_hash(); + tip_time = b.header.time; + } + query.apply_sh_pending().unwrap(); + (tip, tip_time, cbs) +} diff --git a/crates/rbitcoin-query/src/sp_read.rs b/crates/rbitcoin-query/src/sp_read.rs new file mode 100644 index 0000000000..f1d95b7907 --- /dev/null +++ b/crates/rbitcoin-query/src/sp_read.rs @@ -0,0 +1,262 @@ +//! Store-backed BIP-352 tweak readers. Pure tweak math stays in consensus. +use bitcoin::hashes::Hash; +use bitcoin::{OutPoint, Transaction, TxOut, Witness}; +use rbitcoin_consensus::silent_payments::{is_p2tr, tweak_from_tx, TaprootOut, TxTweak}; +use rbitcoin_consensus::ChainParams; +use rbitcoin_primitives::{Fk, Height}; +use rbitcoin_store::{IndexWindow, InputRecord, LoadedTweakTx, OutputRecord, StoreError}; +use std::collections::{BTreeMap, HashMap}; + +use crate::{ConfirmError, Query}; + +/// Txid and outputs of a spent create. +type ParentLookup<'a> = dyn Fn(Fk) -> Option<([u8; 32], &'a [OutputRecord])> + 'a; + +/// Prefer the thin index (no parent peeks). Hole / no table → [`tweaks_for_height`]. +pub fn tweaks_at_height( + query: &Query, + params: &ChainParams, + height: Height, +) -> Result, ConfirmError> { + if !params.taproot_active_at(height.0) { + return Ok(BTreeMap::new()); + } + match query.load_thin_tweaks(height) { + Ok(Some(rows)) => { + let mut out = BTreeMap::new(); + for r in rows { + out.insert( + r.txid, + TxTweak { + tweak: r.tweak, + output_pubkeys: r + .p2tr + .into_iter() + .map(|(vout, xonly, value)| TaprootOut { vout, xonly, value }) + .collect(), + }, + ); + } + return Ok(out); + } + Ok(None) => { + rbitcoin_log::debug!( + "sp_tweaks: naive fallback h={} (hole or index off)", + height.0 + ); + } + Err(e) => return Err(e.into()), + } + tweaks_for_height(query, params, height) +} + +/// Confirmed height → eligible txid (internal order) → tweak. +/// +/// Pre-Taproot and missing heights return an empty map (no error). Does not +/// reconstruct a wire block. Walks packed bodies **and parent outs**. +pub fn tweaks_for_height( + query: &Query, + params: &ChainParams, + height: Height, +) -> Result, ConfirmError> { + query.require_sp_tweaks_unpruned()?; + if !params.taproot_active_at(height.0) { + return Ok(BTreeMap::new()); + } + let fks = match query.block_tx_fks(height) { + Ok(f) => f, + Err(StoreError::NotFound) => return Ok(BTreeMap::new()), + Err(e) => return Err(e.into()), + }; + if fks.is_empty() { + return Ok(BTreeMap::new()); + } + + let wave = rbitcoin_store::load_tweak_wave(&query.store().txs, &fks)?; + let mut by_fk: HashMap = HashMap::new(); + for t in &wave.txs { + by_fk.insert(t.fk, (t.rec.txid, &t.outs)); + } + for (fk, (txid, outs)) in &wave.parents { + by_fk.entry(Fk(*fk)).or_insert((*txid, outs)); + } + let txs: Vec<&LoadedTweakTx> = wave.txs.iter().collect(); + let tweaks = tweaks_for_txs(&txs, &|fk| by_fk.get(&fk).copied())?; + Ok(txs + .iter() + .zip(tweaks) + .filter_map(|(t, tw)| Some((t.rec.txid, tw?))) + .collect()) +} + +/// Tweak records of `window.blocks[i]` in block order (`None` = ineligible). +pub fn tweak_records_from_window( + window: &IndexWindow, + i: usize, +) -> Result>, ConfirmError> { + let txs: Vec<&LoadedTweakTx> = window.blocks[i].txs.iter().collect(); + let tweaks = tweaks_for_txs(&txs, &|fk| Some((window.txid(fk)?, window.outs(fk)?)))?; + Ok(tweaks.into_iter().map(|t| t.map(|t| t.tweak)).collect()) +} + +/// Per-tx tweak (`None` = ineligible) for `txs` in order. Only P2TR-output +/// txs with loaded inputs are candidates; EC math runs on the caller. +fn tweaks_for_txs<'a>( + txs: &[&'a LoadedTweakTx], + parent: &ParentLookup<'a>, +) -> Result>, ConfirmError> { + let mut out = vec![None; txs.len()]; + for (i, t) in txs.iter().enumerate() { + if !t.need_seqsigwit { + continue; + } + let Some(inputs) = t.inputs.as_ref() else { + continue; + }; + let (tx, prevouts) = build_tx_and_prevouts(inputs, &t.outs, parent)?; + out[i] = tweak_from_tx(&tx, &prevouts); + } + Ok(out) +} + +#[allow(clippy::type_complexity)] // packed row / pin / script-hash tuple is the on-disk shape +/// Tweaks-wire `TxTweak` from a stored 33-byte tweak + this tx’s packed outs. +/// +/// No parent IO. `Some(tweak)` with missing packed outs is corrupt. +pub fn tweaks_from_thin_and_body( + rows: &[([u8; 32], Option<[u8; 33]>, Option<&[OutputRecord]>)], +) -> Result, StoreError> { + let mut out = BTreeMap::new(); + for (txid, tweak, outs) in rows { + let Some(tweak) = tweak else { + continue; + }; + let Some(outs) = outs else { + return Err(StoreError::Corrupt( + "invariant: thin tweak missing packed body", + )); + }; + out.insert( + *txid, + TxTweak { + tweak: *tweak, + output_pubkeys: taproot_outs_from_records(outs), + }, + ); + } + Ok(out) +} + +fn taproot_outs_from_records(outputs: &[OutputRecord]) -> Vec { + let mut out = Vec::new(); + for (i, o) in outputs.iter().enumerate() { + if !is_p2tr(&o.script) { + continue; + } + let mut xonly = [0u8; 32]; + xonly.copy_from_slice(&o.script[2..34]); + let value = o.value; + out.push(TaprootOut { + vout: i as u32, + xonly, + value, + }); + } + out +} + +/// A spent parent missing from the lookup is an invariant break, not an +/// ineligible tx. +fn build_tx_and_prevouts( + inputs: &[InputRecord], + outputs: &[OutputRecord], + parent: &ParentLookup<'_>, +) -> Result<(Transaction, Vec), StoreError> { + let mut prevouts = Vec::with_capacity(inputs.len()); + let mut txins = Vec::with_capacity(inputs.len()); + for inp in inputs { + let (prev_txid, prev_script, prev_value) = if inp.is_coinbase() { + ([0u8; 32], Vec::new(), 0u64) + } else { + const MISSING: &str = "invariant: sp_tweaks spent parent missing"; + let (tid, outs) = parent(inp.create_fk()).ok_or(StoreError::Corrupt(MISSING))?; + let o = outs + .get(inp.prev_index() as usize) + .ok_or(StoreError::Corrupt(MISSING))?; + (tid, o.script.clone(), o.value) + }; + let wit_refs: Vec<&[u8]> = inp.witness.iter().map(|w| w.as_slice()).collect(); + txins.push(bitcoin::TxIn { + previous_output: OutPoint { + txid: bitcoin::Txid::from_byte_array(prev_txid), + vout: inp.prev_index(), + }, + script_sig: bitcoin::ScriptBuf::from_bytes(inp.script_sig.clone()), + sequence: bitcoin::Sequence::from_consensus(inp.sequence), + witness: Witness::from_slice(&wit_refs), + }); + let value = bitcoin::Amount::from_sat(prev_value); + prevouts.push(TxOut { + value, + script_pubkey: bitcoin::ScriptBuf::from_bytes(prev_script), + }); + } + let mut txouts = Vec::with_capacity(outputs.len()); + for o in outputs { + let value = bitcoin::Amount::from_sat(o.value); + txouts.push(TxOut { + value, + script_pubkey: bitcoin::ScriptBuf::from_bytes(o.script.clone()), + }); + } + Ok(( + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: txins, + output: txouts, + }, + prevouts, + )) +} + +#[cfg(test)] +mod classifier_record_tests { + use super::{build_tx_and_prevouts, taproot_outs_from_records}; + use rbitcoin_primitives::Fk; + use rbitcoin_store::{InputRecord, OutputRecord}; + + #[test] + fn taproot_record_keeps_a_zero_value_and_skips_other_scripts() { + let mut p2tr = vec![0x51, 0x20]; + p2tr.extend_from_slice(&[0xab; 32]); + let outs = vec![ + OutputRecord::unspent(0, p2tr), + OutputRecord::unspent(7, vec![0x51]), + ]; + let got = taproot_outs_from_records(&outs); + assert_eq!(got.len(), 1); + assert_eq!(got[0].vout, 0); + assert_eq!(got[0].value, 0); + assert_eq!(got[0].xonly, [0xab; 32]); + } + + #[test] + fn build_tx_uses_the_parent_output_value() { + let parent_txid = [0x11; 32]; + let parent_outs = [OutputRecord::unspent(7, vec![0x51])]; + let inp = InputRecord::prevout(parent_txid, Fk(3), 0, 0xffff_ffff, Vec::new(), Vec::new()); + let spend_out = OutputRecord::unspent(1, vec![0x51]); + let parent = |fk: Fk| { + if fk == Fk(3) { + Some((parent_txid, parent_outs.as_slice())) + } else { + None + } + }; + let (tx, prevouts) = build_tx_and_prevouts(&[inp], &[spend_out], &parent).unwrap(); + assert_eq!(prevouts[0].value.to_sat(), 7); + assert_eq!(tx.output[0].value.to_sat(), 1); + assert_eq!(tx.input[0].previous_output.vout, 0); + } +} diff --git a/crates/rbitcoin-query/src/sp_store_tests.rs b/crates/rbitcoin-query/src/sp_store_tests.rs new file mode 100644 index 0000000000..4d1c2ea67f --- /dev/null +++ b/crates/rbitcoin-query/src/sp_store_tests.rs @@ -0,0 +1,492 @@ +//! Store-backed silent-payment tweak reads. +#![cfg(test)] + +use crate::sp_read::tweak_records_from_window; +use crate::testutil::FixtureChain; +use bitcoin::hashes::Hash; +use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; +use bitcoin::{Amount, ScriptBuf, TxOut}; +use rbitcoin_consensus::{mine_empty_regtest, tweak_from_tx, ChainParams, Milestone}; +use rbitcoin_primitives::{Fk, Height}; +use rbitcoin_store::{HeaderRecord, InputRecord, OutputRecord, StoreError, TxRecord}; + +use crate::{ + accept_and_connect_block, build_indexes_released, prepare_live_indexes, Query, TxApply, +}; + +fn tmp_store() -> (crate::testutil::TempDir, Query) { + crate::testutil::tiny_query_labeled("sp-tweaks") +} + +fn header(h: u32, prev_fk: Fk, prev_hash: Option<[u8; 32]>) -> HeaderRecord { + crate::testutil::header_with_merkle_marker(h, prev_fk, prev_hash, 0xec) +} + +#[test] +fn thin_compose_matches_engine_outs_without_parents() { + let (dir, q) = tmp_store(); + let params = ChainParams::regtest(); + // Reuse the P2WPKH→P2TR spend from the walk test via a tiny local chain. + use bitcoin::hashes::hash160; + use bitcoin::secp256k1::SecretKey; + let secp_ctx = Secp256k1::new(); + let sk = SecretKey::from_slice(&[2u8; 32]).unwrap(); + let pk = bitcoin::secp256k1::PublicKey::from_secret_key(&secp_ctx, &sk); + let ser = pk.serialize(); + let h160 = hash160::Hash::hash(&ser); + let mut p2wpkh = vec![0x00, 0x14]; + p2wpkh.extend_from_slice(h160.as_ref()); + let (xonly, _) = pk.x_only_public_key(); + let mut p2tr = vec![0x51, 0x20]; + p2tr.extend_from_slice(&xonly.serialize()); + let mut genesis_txid = [0u8; 32]; + genesis_txid[31] = 0xcb; + let h0 = header(0, Fk::NULL, None); + let fk0 = q + .connect_block( + Height(0), + &h0, + &[TxApply { + tx: TxRecord { + txid: genesis_txid, + version: 1, + locktime: 0, + input_start_fk: Fk::NULL, + input_count: 1, + output_start_fk: Fk::NULL, + output_count: 1, + }, + inputs: vec![InputRecord::coinbase(u32::MAX, vec![0x00], vec![])], + outputs: vec![OutputRecord::unspent(50_0000_0000, p2wpkh.clone())], + }], + ) + .unwrap(); + let create_fk = q.block_tx_fks(Height(0)).unwrap()[0]; + let mut spend_txid = [0u8; 32]; + spend_txid[0] = 0x11; + spend_txid[31] = 0xcd; + let h1 = header(1, fk0, Some(h0.hash)); + q.connect_block( + Height(1), + &h1, + &[TxApply { + tx: TxRecord { + txid: spend_txid, + version: 2, + locktime: 0, + input_start_fk: Fk::NULL, + input_count: 1, + output_start_fk: Fk::NULL, + output_count: 1, + }, + inputs: vec![InputRecord { + prev_txid: genesis_txid, + parent: rbitcoin_store::InputParent::Prevout { create_fk, vout: 0 }, + sequence: u32::MAX, + script_sig: vec![], + witness: vec![vec![0u8; 64], ser.to_vec()], + }], + outputs: vec![OutputRecord::unspent(49_0000_0000, p2tr.clone())], + }], + ) + .unwrap(); + + let naive = crate::tweaks_for_height(&q, ¶ms, Height(1)).unwrap(); + let t = naive.get(&spend_txid).unwrap(); + let spend_fk = q.block_tx_fks(Height(1)).unwrap()[0]; + let (_rec, _ins, outs) = q.store().get_tx_full(spend_fk).unwrap(); + let composed = + crate::tweaks_from_thin_and_body(&[(spend_txid, Some(t.tweak), Some(outs.as_slice()))]) + .unwrap(); + let c = composed.get(&spend_txid).unwrap(); + assert_eq!(c.tweak, t.tweak); + assert_eq!(c.output_pubkeys, t.output_pubkeys); + + let miss = crate::tweaks_from_thin_and_body(&[(spend_txid, Some(t.tweak), None)]); + assert!( + matches!(miss, Err(StoreError::Corrupt(m)) if m.contains("invariant")), + "got {miss:?}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn tweaks_for_height_unknown_is_empty() { + let (dir, q) = tmp_store(); + let params = ChainParams::regtest(); + assert!(crate::tweaks_for_height(&q, ¶ms, Height(0)) + .unwrap() + .is_empty()); + let main = ChainParams::mainnet(); + assert!(crate::tweaks_for_height(&q, &main, Height(100)) + .unwrap() + .is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A connect with tweaks on seals that height. Disconnect truncates, and +/// the replacement block is sealed on the next connect. +#[test] +fn live_connect_seals_tweaks_and_reorg_truncates() { + let (dir, q) = tmp_store(); + let params = ChainParams::regtest(); + q.set_sptweaks_enabled(true, Height(0)).unwrap(); + prepare_live_indexes(&q).unwrap(); + let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); + accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); + assert_eq!(q.sptweaks_next_height(), Some(Height(1))); + let thin0 = q.load_thin_tweaks(Height(0)).unwrap().expect("indexed"); + assert!( + thin0.is_empty(), + "coinbase is not eligible — no Class A join" + ); + + let b1 = mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); + accept_and_connect_block(&q, ¶ms, Height(1), &b1, Milestone::NONE).unwrap(); + assert_eq!(q.sptweaks_next_height(), Some(Height(2))); + + q.disconnect_tip().unwrap(); + assert_eq!(q.sptweaks_next_height(), Some(Height(1))); + assert!(q.load_thin_tweaks(Height(1)).unwrap().is_none()); + + let b1b = mine_empty_regtest(genesis.block_hash(), genesis.header.time + 601, 1); + accept_and_connect_block(&q, ¶ms, Height(1), &b1b, Milestone::NONE).unwrap(); + assert_eq!(q.sptweaks_next_height(), Some(Height(2))); + assert!(q.load_thin_tweaks(Height(1)).unwrap().is_some()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Contiguous connects seal tweaks. The materialize does not rewrite them. +#[test] +fn live_connect_seals_tweaks_builder_leaves_them() { + let (dir, q) = tmp_store(); + let params = ChainParams::regtest(); + q.set_sptweaks_enabled(true, Height(0)).unwrap(); + prepare_live_indexes(&q).unwrap(); + let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); + accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); + let b1 = mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); + accept_and_connect_block(&q, ¶ms, Height(1), &b1, Milestone::NONE).unwrap(); + let b2 = mine_empty_regtest(b1.block_hash(), b1.header.time + 600, 2); + accept_and_connect_block(&q, ¶ms, Height(2), &b2, Milestone::NONE).unwrap(); + assert_eq!(q.sptweaks_next_height(), Some(Height(3))); + + q.release_index_writebehind(Height(2)); + build_indexes_released(&q).unwrap(); + assert_eq!(q.sptweaks_next_height(), Some(Height(3))); + for h in 0..=2 { + assert!( + q.load_thin_tweaks(Height(h)).unwrap().is_some(), + "height {h}" + ); + } + let _ = std::fs::remove_dir_all(&dir); +} + +struct SpKeys { + p2wpkh: Vec, + p2tr: Vec, + pubkey: Vec, +} + +fn sp_keys() -> SpKeys { + use bitcoin::hashes::hash160; + let sk = SecretKey::from_slice(&[2u8; 32]).unwrap(); + let pk = PublicKey::from_secret_key(&secp256k1::Secp256k1::new(), &sk); + let ser = pk.serialize(); + let mut p2wpkh = vec![0x00, 0x14]; + p2wpkh.extend_from_slice(hash160::Hash::hash(&ser).as_ref()); + let mut p2tr = vec![0x51, 0x20]; + p2tr.extend_from_slice(&pk.x_only_public_key().0.serialize()); + SpKeys { + p2wpkh, + p2tr, + pubkey: ser.to_vec(), + } +} + +fn sp_spend( + tag: u8, + prev: ([u8; 32], Fk, u32), + witness: Vec>, + outs: Vec, +) -> TxApply { + let mut txid = [0u8; 32]; + txid[0] = tag; + TxApply { + tx: TxRecord { + txid, + version: 2, + locktime: 0, + input_start_fk: Fk::NULL, + input_count: 1, + output_start_fk: Fk::NULL, + output_count: outs.len() as u32, + }, + inputs: vec![InputRecord { + prev_txid: prev.0, + parent: rbitcoin_store::InputParent::Prevout { + create_fk: prev.1, + vout: prev.2, + }, + sequence: u32::MAX, + script_sig: vec![], + witness, + }], + outputs: outs, + } +} + +fn seal_tweaks(q: &Query, through: u32) { + q.release_index_writebehind(Height(through)); + build_indexes_released(q).unwrap(); + assert_eq!(q.sptweaks_next_height(), Some(Height(through + 1))); +} + +/// Height 1 carries two eligible P2WPKH→P2TR spends around a spend with +/// fat seqsigwit and a fat output script. The naive walk, the index +/// window reader, the engine on the rebuilt wire tx, and the served +/// index all give the same tweaks, and the served read spans the +/// middle txout but not its seqsigwit. +fn pin_tweaks_agree_and_thin_serve_skips_fat_witness( + q: &Query, + params: &ChainParams, + keys: &SpKeys, + a: [u8; 32], + b: [u8; 32], +) { + let naive = crate::tweaks_for_height(q, params, Height(1)).unwrap(); + assert_eq!(naive.len(), 2, "the OP_TRUE spend is not eligible"); + let fks = q.block_tx_fks(Height(1)).unwrap(); + let wire_a = q.reconstruct_tx(fks[0]).unwrap(); + let engine_prev = [TxOut { + value: Amount::from_sat(20_0000_0000), + script_pubkey: ScriptBuf::from_bytes(keys.p2wpkh.clone()), + }]; + let engine = tweak_from_tx(&wire_a, &engine_prev).unwrap(); + assert_eq!(naive[&a].tweak, engine.tweak); + assert_eq!(naive[&a].output_pubkeys, engine.output_pubkeys); + + let want = vec![Some(naive[&a].tweak), None, Some(naive[&b].tweak)]; + for start in [0, 1] { + let heights = q.index_heights(start, 1, Some(0)).unwrap(); + let window = q.read_index_window(&heights).unwrap(); + let i = window.blocks.len() - 1; + assert_eq!( + tweak_records_from_window(&window, i).unwrap(), + want, + "window from {start}: the parent inside or outside the window" + ); + } + + let mid_seqsigwit = q.store().tx_seqsigwit_range(fks[1]).unwrap().1; + let mid_txout = q.store().txs.body_range(fks[1]).unwrap().1; + let _ = q.sample_reset_thin_tweak_body_bytes(); + let rows = q.load_thin_tweaks(Height(1)).unwrap().expect("indexed"); + let read = q.sample_reset_thin_tweak_body_bytes(); + assert_eq!( + rows.iter().map(|r| r.txid).collect::>(), + [a, b], + "ineligible txs are not joined from Class A" + ); + assert_eq!((rows[0].p2tr.len(), rows[1].p2tr.len()), (2, 1)); + assert!( + read >= mid_txout, + "the txout span covers the ineligible hole (read={read} txout={mid_txout})" + ); + assert!( + read < mid_seqsigwit, + "the fat seqsigwit stays out (read={read} seqsigwit={mid_seqsigwit})" + ); + let indexed = crate::tweaks_at_height(q, params, Height(1)).unwrap(); + for txid in [a, b] { + assert_eq!(indexed[&txid].tweak, naive[&txid].tweak); + assert_eq!(indexed[&txid].output_pubkeys, naive[&txid].output_pubkeys); + } +} + +fn pin_thin_range_limits_and_hole(q: &Query, a: [u8; 32], c: [u8; 32]) { + use crate::ThinTweakRangeLimits; + + let all = q + .load_thin_tweaks_range(Height(0), ThinTweakRangeLimits::default()) + .unwrap(); + assert_eq!( + all.iter() + .map(|(h, rows)| (h.0, rows.len())) + .collect::>(), + [(0, 0), (1, 2), (2, 1)] + ); + assert_eq!(all[2].1[0].txid, c); + for (h, rows) in &all { + let single = q.load_thin_tweaks(*h).unwrap().expect("indexed"); + assert_eq!(single.len(), rows.len()); + for (s, r) in single.iter().zip(rows) { + assert_eq!((s.txid, s.tweak, &s.p2tr), (r.txid, r.tweak, &r.p2tr)); + } + } + let limited = |max_heights, max_eligible| { + q.load_thin_tweaks_range( + Height(1), + ThinTweakRangeLimits { + max_heights, + max_eligible, + ..ThinTweakRangeLimits::default() + }, + ) + .unwrap() + }; + assert_eq!(limited(1, 8192).len(), 1); + assert_eq!( + limited(10, 1).len(), + 1, + "the eligible budget stops after the first height that spends it" + ); + assert!(q + .load_thin_tweaks_range(Height(3), ThinTweakRangeLimits::default()) + .unwrap() + .is_empty()); + let only2 = q + .load_thin_tweaks_range(Height(2), ThinTweakRangeLimits::default()) + .unwrap(); + assert_eq!(only2.len(), 1); + assert_eq!(only2[0].0, Height(2)); + + let cut = |cut_through| { + q.load_thin_tweaks_range( + Height(1), + ThinTweakRangeLimits { + cut_through, + ..ThinTweakRangeLimits::default() + }, + ) + .unwrap() + }; + let kept = cut(true); + let row_a = kept[0].1.iter().find(|r| r.txid == a).unwrap(); + assert_eq!( + row_a.p2tr.iter().map(|o| o.0).collect::>(), + [1], + "cut-through drops the P2TR output spent at height 2" + ); + let hist = cut(false); + assert_eq!( + hist[0].1.iter().find(|r| r.txid == a).unwrap().p2tr.len(), + 2 + ); +} + +/// One synthetic chain through the index builder: coinbase at 0, two +/// eligible spends around a fat ineligible one at 1, a spend of one of +/// those P2TR outputs plus a third eligible spend at 2 (sealed with 1 in +/// one window), and at 3 the spend that leaves the first tx all spent. +#[test] +fn sp_tweaks_confirm_life() { + let (dir, q) = tmp_store(); + let params = ChainParams::regtest(); + let keys = sp_keys(); + q.set_sptweaks_enabled(true, Height(0)).unwrap(); + let sig_wit = || vec![vec![0u8; 64], keys.pubkey.clone()]; + let p2tr = |sat| OutputRecord::unspent(sat, keys.p2tr.clone()); + + let mut cb_txid = [0u8; 32]; + cb_txid[31] = 0xcb; + let h0 = header(0, Fk::NULL, None); + let fk0 = q + .connect_block( + Height(0), + &h0, + &[TxApply { + tx: TxRecord { + txid: cb_txid, + version: 1, + locktime: 0, + input_start_fk: Fk::NULL, + input_count: 1, + output_start_fk: Fk::NULL, + output_count: 4, + }, + inputs: vec![InputRecord::coinbase(u32::MAX, vec![0x00], vec![])], + outputs: vec![ + OutputRecord::unspent(20_0000_0000, keys.p2wpkh.clone()), + OutputRecord::unspent(20_0000_0000, keys.p2wpkh.clone()), + OutputRecord::unspent(5_0000_0000, vec![0x51]), + OutputRecord::unspent(5_0000_0000, keys.p2wpkh.clone()), + ], + }], + ) + .unwrap(); + seal_tweaks(&q, 0); + assert!( + q.load_thin_tweaks(Height(0)).unwrap().unwrap().is_empty(), + "a coinbase is not eligible" + ); + let cb = q.block_tx_fks(Height(0)).unwrap()[0]; + + let spend_a = sp_spend( + 0xaa, + (cb_txid, cb, 0), + sig_wit(), + vec![p2tr(12_0000_0000), p2tr(7_0000_0000)], + ); + let fat = sp_spend( + 0xfe, + (cb_txid, cb, 2), + vec![vec![0u8; 16_384]], + vec![OutputRecord::unspent(4_0000_0000, vec![0x51; 4096])], + ); + let spend_b = sp_spend(0xbb, (cb_txid, cb, 1), sig_wit(), vec![p2tr(19_0000_0000)]); + let (a, b) = (spend_a.tx.txid, spend_b.tx.txid); + let h1 = header(1, fk0, Some(h0.hash)); + let fk1 = q + .connect_block(Height(1), &h1, &[spend_a, fat, spend_b]) + .unwrap(); + let a_fk = q.block_tx_fks(Height(1)).unwrap()[0]; + + let spend_c = sp_spend(0xcc, (cb_txid, cb, 3), sig_wit(), vec![p2tr(4_0000_0000)]); + let c = spend_c.tx.txid; + let spend_a0 = sp_spend( + 0xd0, + (a, a_fk, 0), + vec![vec![0u8; 64]], + vec![OutputRecord::unspent(11_0000_0000, keys.p2wpkh.clone())], + ); + let h2 = header(2, fk1, Some(h1.hash)); + let fk2 = q + .connect_block(Height(2), &h2, &[spend_c, spend_a0]) + .unwrap(); + assert_eq!( + q.sptweaks_next_height(), + Some(Height(1)), + "confirm writes none" + ); + seal_tweaks(&q, 2); + + pin_tweaks_agree_and_thin_serve_skips_fat_witness(&q, ¶ms, &keys, a, b); + pin_thin_range_limits_and_hole(&q, a, c); + + let spend_a1 = sp_spend( + 0xd1, + (a, a_fk, 1), + vec![vec![0u8; 64]], + vec![OutputRecord::unspent(6_0000_0000, vec![0x51])], + ); + let h3 = header(3, fk2, Some(h2.hash)); + q.connect_block(Height(3), &h3, &[spend_a1]).unwrap(); + seal_tweaks(&q, 3); + let gone = q + .load_thin_tweaks_range( + Height(1), + crate::ThinTweakRangeLimits { + cut_through: true, + ..crate::ThinTweakRangeLimits::default() + }, + ) + .unwrap(); + assert_eq!( + gone[0].1.iter().map(|r| r.txid).collect::>(), + [b], + "an all-spent eligible tx is omitted" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/rbitcoin-query/src/stamp.rs b/crates/rbitcoin-query/src/stamp.rs index 8b9fbb11bf..bb7153a212 100644 --- a/crates/rbitcoin-query/src/stamp.rs +++ b/crates/rbitcoin-query/src/stamp.rs @@ -9,8 +9,8 @@ //! later-wave stamp reads pin loc. IBD (`skeleton = Some`) never loc-by-fk: //! spent is pin loc, skeleton TipOnly loc, or unset for write fill. -use crate::id_map::{IdMap, TxidHasher}; use crate::{CreatePin, InFlight, QueryError, U64Map}; +use crate::{IdMap, TxidHasher}; use rbitcoin_primitives::Fk; use rbitcoin_store::Store; use std::collections::HashMap; diff --git a/crates/rbitcoin-consensus/src/block/structure_rule_tests.rs b/crates/rbitcoin-query/src/structure_rule_tests.rs similarity index 93% rename from crates/rbitcoin-consensus/src/block/structure_rule_tests.rs rename to crates/rbitcoin-query/src/structure_rule_tests.rs index 0acf4ef478..c26b5e2edf 100644 --- a/crates/rbitcoin-consensus/src/block/structure_rule_tests.rs +++ b/crates/rbitcoin-query/src/structure_rule_tests.rs @@ -1,17 +1,10 @@ //! structure_rule_tests (peeled from block.rs). -use super::{ - apply_witness_commitment, bip16_active_from_prev_mtp, bip34_height_script, block_has_witness, - block_has_witness_from_pres, block_subsidy, check_tx_local, is_p2sh_script, is_p2wpkh_program, - is_p2wsh_program, last_script_push, merkle_root_bytes, script_sigop_count, - validate_block_structure, validate_block_structure_with_pres, witness_commitment_script, - ScriptCheckJob, ScriptVerifyFlags, TxPrecompute, ValidationContext, BIP16_EXCEPTION_MAINNET, - MAX_BLOCK_STRIPPED_SIZE, MAX_BLOCK_TX_COUNT, MAX_BLOCK_WEIGHT, MIN_TX_WEIGHT, - TAPROOT_EXCEPTION_MAINNET, +use crate::block_pipeline::{ + assemble_block_prevouts, resolve_prevout, structural_validate_spends, AsmPrevoutAcc, + ClassAWave, RunCreateHeight, StructuralScratch, }; -use crate::error::ConsensusError; -use crate::milestone::Milestone; -use crate::params::ChainParams; +use crate::ConfirmError; use bitcoin::absolute::LockTime; use bitcoin::block::{Header, Version}; use bitcoin::consensus::encode::VarInt; @@ -22,6 +15,16 @@ use bitcoin::{ Amount, Block, BlockHash, CompactTarget, OutPoint, Sequence, Transaction, TxIn, TxMerkleNode, TxOut, Witness, }; +use rbitcoin_consensus::block::{ + apply_witness_commitment, bip16_active_from_prev_mtp, bip34_height_script, block_has_witness, + block_has_witness_from_pres, block_subsidy, check_tx_local, is_p2sh_script, is_p2wpkh_program, + is_p2wsh_program, last_script_push, merkle_root_bytes, script_sigop_count, + validate_block_structure, validate_block_structure_with_pres, witness_commitment_script, + PendingCreates, ScriptCheckJob, ScriptVerifyFlags, ValidationContext, BIP16_EXCEPTION_MAINNET, + MAX_BLOCK_STRIPPED_SIZE, MAX_BLOCK_TX_COUNT, MAX_BLOCK_WEIGHT, MIN_TX_WEIGHT, + TAPROOT_EXCEPTION_MAINNET, +}; +use rbitcoin_consensus::{ChainParams, ConsensusError, Milestone, TxPrecompute}; use rbitcoin_primitives::Height; fn params() -> ChainParams { @@ -37,11 +40,11 @@ fn ctx_h(height: u32) -> ValidationContext<'static> { #[test] fn check_block_wire_junk_does_not_panic() { for data in [b"".as_slice(), &[0u8; 1], &[0xff; 80], b"not-a-block"] { - let _ = super::check_block_wire(data); + let _ = rbitcoin_consensus::block::check_block_wire(data); } let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); let raw = bitcoin::consensus::encode::serialize(&genesis); - let _ = super::check_block_wire(&raw); + let _ = rbitcoin_consensus::block::check_block_wire(&raw); } fn coinbase(height: u32) -> Transaction { @@ -112,12 +115,15 @@ fn block_with(txs: Vec) -> Block { /// `stamp_sub struct=` split: hash-encode vs extra walks (no algorithm change). #[test] fn structure_meters_split_txid_wtxid_walk() { - let stats = rbitcoin_query::ConfirmStats::default(); + let stats = crate::ConfirmStats::default(); let mut spend = non_coinbase_spend(1); spend.input[0].witness = Witness::from_slice(&[&[0x01]]); let mut b = block_with(vec![coinbase(1), spend]); apply_witness_commitment(&mut b); - validate_block_structure_with_pres(&b, &ctx_h(1), None, Some(&stats)) + let mut note = |txid_ns, wtxid_ns, walk_ns| { + stats.note_struct_parts(txid_ns, wtxid_ns, walk_ns); + }; + validate_block_structure_with_pres(&b, &ctx_h(1), None, Some(&mut note)) .expect("witness block structure"); let s = stats.take_window(); assert!( @@ -132,8 +138,8 @@ fn structure_meters_split_txid_wtxid_walk() { #[test] fn structure_with_pres_skips_from_tx() { - use rbitcoin_query::TxPrecompute; - let stats = rbitcoin_query::ConfirmStats::default(); + use TxPrecompute; + let stats = crate::ConfirmStats::default(); let mut spend = non_coinbase_spend(1); spend.input[0].witness = Witness::from_slice(&[&[0x01]]); let mut b = block_with(vec![coinbase(1), spend]); @@ -144,11 +150,14 @@ fn structure_with_pres_skips_from_tx() { .map(TxPrecompute::from_tx) .collect::>() .into(); + let mut note = |txid_ns, wtxid_ns, walk_ns| { + stats.note_struct_parts(txid_ns, wtxid_ns, walk_ns); + }; let out = validate_block_structure_with_pres( &b, &ctx_h(1), Some(std::sync::Arc::clone(&pres)), - Some(&stats), + Some(&mut note), ) .expect("stashed pres must still enforce merkle"); assert_eq!(out.len(), pres.len()); @@ -168,7 +177,7 @@ fn structure_with_pres_skips_from_tx() { /// Confirm assemble must Arc-share lookup/structure pres, not `Arc::new(p.clone())`. #[test] fn script_jobs_from_same_pres_slice_share_pre() { - use rbitcoin_query::TxPrecompute; + use TxPrecompute; let spend = non_coinbase_spend(1); let pres: std::sync::Arc<[TxPrecompute]> = std::sync::Arc::from([TxPrecompute::from_tx(&spend)]); @@ -176,14 +185,14 @@ fn script_jobs_from_same_pres_slice_share_pre() { pres[0].txid, vec![], spend.clone(), - crate::block::ScriptVerifyFlags::buried(true, true, true, true, true), + ScriptVerifyFlags::buried(true, true, true, true, true), ) .with_pre_slice(std::sync::Arc::clone(&pres), 0); let job_b = ScriptCheckJob::with_txid( pres[0].txid, vec![], spend, - crate::block::ScriptVerifyFlags::buried(true, true, true, true, true), + ScriptVerifyFlags::buried(true, true, true, true, true), ) .with_pre_slice(std::sync::Arc::clone(&pres), 0); assert!( @@ -196,6 +205,13 @@ fn script_jobs_from_same_pres_slice_share_pre() { ); } +fn rule_err(err: ConfirmError) -> ConsensusError { + match err { + ConfirmError::Rules(e) => e, + other => panic!("expected a consensus rule reject, got {other}"), + } +} + fn assert_bad_block(err: ConsensusError, needle: &str) { match err { ConsensusError::BadBlock(s) => { @@ -345,10 +361,9 @@ fn padded_spend(data_len: usize) -> Transaction { /// An unspent connected coinbase with the same txid is BIP30 on regtest (no /// 91842/91880 grandfather). Signet activates BIP34 at height 1, and Core's /// empty BIP34 hash still enforces BIP30 on every signet block. -fn rejects_unspent_overwrite(q: &rbitcoin_query::Query, first: &Transaction) { - use crate::block::{structural_validate_spends, RunCreateHeight}; +fn rejects_unspent_overwrite(q: &crate::Query, first: &Transaction) { + use crate::{BatchParents, OutPointSet, U32Map}; use rbitcoin_primitives::Fk; - use rbitcoin_query::{BatchParents, OutPointSet, U32Map}; let dup = block_with(vec![first.clone()]); let signet = Box::leak(Box::new(ChainParams::signet())); for (ctx, net) in [ @@ -374,8 +389,8 @@ fn rejects_unspent_overwrite(q: &rbitcoin_query::Query, first: &Transaction) { &parents, &mut U32Map::default(), &heights, - &crate::block::ClassAWave::default(), - &mut crate::block::StructuralScratch::default(), + &ClassAWave::default(), + &mut StructuralScratch::default(), &abs_jobs, ) .expect_err("an unspent overwrite must trip BIP30"); @@ -389,11 +404,7 @@ fn rejects_unspent_overwrite(q: &rbitcoin_query::Query, first: &Transaction) { fn plant_unspent_coinbase( label: &str, -) -> ( - rbitcoin_store::testutil::TempDir, - rbitcoin_query::Query, - Transaction, -) { +) -> (rbitcoin_store::testutil::TempDir, crate::Query, Transaction) { let (path, q, first, _fk) = plant_coinbase_with_outputs(label, coinbase(1).output); (path, q, first) } @@ -404,13 +415,13 @@ fn plant_coinbase_with_outputs( outputs: Vec, ) -> ( rbitcoin_store::testutil::TempDir, - rbitcoin_query::Query, + crate::Query, Transaction, rbitcoin_primitives::Fk, ) { use rbitcoin_primitives::Fk; use rbitcoin_store::{InputRecord, OutputRecord, TxRecord}; - let (path, q) = rbitcoin_query::testutil::tiny_query_labeled(label); + let (path, q) = crate::testutil::tiny_query_labeled(label); q.enter_direct_index_mode().unwrap(); let mut first = coinbase(1); first.output = outputs; @@ -450,7 +461,7 @@ fn plant_coinbase_with_outputs( (path, q, first, fk) } -fn plant_bip34_ancestor(q: &rbitcoin_query::Query, hash: [u8; 32]) { +fn plant_bip34_ancestor(q: &crate::Query, hash: [u8; 32]) { use rbitcoin_store::HeaderRecord; let main = ChainParams::mainnet(); let hfk = q @@ -467,12 +478,11 @@ fn plant_bip34_ancestor(q: &rbitcoin_query::Query, hash: [u8; 32]) { } fn bip30_message_at_mainnet_above_bip34( - q: &rbitcoin_query::Query, + q: &crate::Query, block: &Block, -) -> Result<(), ConsensusError> { - use crate::block::{structural_validate_spends, RunCreateHeight}; +) -> Result<(), ConfirmError> { + use crate::{BatchParents, OutPointSet, U32Map}; use rbitcoin_primitives::Fk; - use rbitcoin_query::{BatchParents, OutPointSet, U32Map}; let main = Box::leak(Box::new(ChainParams::mainnet())); let ctx = ValidationContext::at(main, Height(main.btc.bip34_height + 1), Milestone::NONE); let heights = RunCreateHeight::Spans(Vec::new()); @@ -491,8 +501,8 @@ fn bip30_message_at_mainnet_above_bip34( &parents, &mut U32Map::default(), &heights, - &crate::block::ClassAWave::default(), - &mut crate::block::StructuralScratch::default(), + &ClassAWave::default(), + &mut StructuralScratch::default(), &abs_jobs, ) .map(|_| ()) @@ -500,7 +510,7 @@ fn bip30_message_at_mainnet_above_bip34( /// Mainnet skips BIP30 only when the header at BIP34 height is the real hash. /// A wrong ancestor still rejects an unspent overwrite. -fn mainnet_ancestor_skip_then_miss(q: &rbitcoin_query::Query, first: &Transaction) { +fn mainnet_ancestor_skip_then_miss(q: &crate::Query, first: &Transaction) { let main = ChainParams::mainnet(); let real = main.bip34_hash.expect("mainnet BIP34 hash").to_byte_array(); plant_bip34_ancestor(q, real); @@ -682,7 +692,10 @@ fn no_coinbase_with_a_64_byte_tx_is_mutated() { ), "{err:?}" ); - assert_eq!(crate::error::block_reject_reason(&err), "bad-cb-missing"); + assert_eq!( + rbitcoin_consensus::block_reject_reason(&err), + "bad-cb-missing" + ); } let err = validate_block_structure(&block_with(vec![a, b]), &ctx_h(1)).unwrap_err(); assert!( @@ -909,7 +922,7 @@ fn rejects_coinbase_excess_value_fast() { let mut cb = coinbase(1); cb.output[0].value = Amount::from_sat(50_0000_0000 + 1); let block = block_with(vec![cb]); - let err = super::check_coinbase_subsidy(&block, &ctx_h(1), 0).unwrap_err(); + let err = rbitcoin_consensus::block::check_coinbase_subsidy(&block, &ctx_h(1), 0).unwrap_err(); assert_bad_block(err, "coinbase excess value"); } @@ -986,25 +999,25 @@ fn last_script_push_rejects_a_non_push_and_a_short_pushdata() { #[test] fn p3_default_milestone_heights() { - use crate::params::default_milestone_height; + use rbitcoin_consensus::default_milestone_height; use rbitcoin_primitives::Network; assert_eq!(default_milestone_height(Network::Regtest), 0); assert_eq!(default_milestone_height(Network::Mainnet), 840_000); assert_eq!(default_milestone_height(Network::Testnet), 2_500_000); assert_eq!(default_milestone_height(Network::Signet), 0); - let anchor = crate::default_milestone_anchor(Network::Mainnet).unwrap(); + let anchor = rbitcoin_consensus::default_milestone_anchor(Network::Mainnet).unwrap(); assert_eq!( anchor.hash.to_string(), - crate::params::MAINNET_MILESTONE_HASH + rbitcoin_consensus::MAINNET_MILESTONE_HASH ); assert_ne!(anchor.min_work_be, [0u8; 32]); - let testnet = crate::default_milestone_anchor(Network::Testnet).unwrap(); + let testnet = rbitcoin_consensus::default_milestone_anchor(Network::Testnet).unwrap(); assert_eq!( testnet.hash.to_string(), - crate::params::TESTNET_MILESTONE_HASH + rbitcoin_consensus::TESTNET_MILESTONE_HASH ); - assert!(crate::default_milestone_anchor(Network::Signet).is_none()); - assert!(crate::default_milestone_anchor(Network::Regtest).is_none()); + assert!(rbitcoin_consensus::default_milestone_anchor(Network::Signet).is_none()); + assert!(rbitcoin_consensus::default_milestone_anchor(Network::Regtest).is_none()); } #[test] @@ -1208,14 +1221,14 @@ fn bip34_wrong_push_encoding_rejected_after_activation() { #[test] fn assemble_rejects_empty_and_fk_mismatch() { - use super::assemble_block_prevouts; - use rbitcoin_query::{BatchParents, OutPointSet, SpendEdges}; + use crate::{BatchParents, OutPointSet, SpendEdges}; + use assemble_block_prevouts; let ctx = ctx_h(1); let empty = block_with(vec![]); let parents = BatchParents::new(); let thin = SpendEdges::default(); let mut spent = OutPointSet::default(); - let mut creates = super::PendingCreates::default(); + let mut creates = PendingCreates::default(); let zero = [0u8; 32]; let err = assemble_block_prevouts( &empty, @@ -1235,7 +1248,7 @@ fn assemble_rejects_empty_and_fk_mismatch() { ) .err() .expect("empty"); - assert_bad_block(err, "empty"); + assert_bad_block(rule_err(err), "empty"); // Wrong archived fk count: coinbase alone needs 1 fk; pass empty slice. let b = block_with(vec![coinbase(1)]); spent.clear(); @@ -1264,7 +1277,7 @@ fn assemble_rejects_empty_and_fk_mismatch() { ) .err() .expect("fk mismatch"); - assert_bad_block(err2, "archived tx fk"); + assert_bad_block(rule_err(err2), "archived tx fk"); // first tx not coinbase let bad = block_with(vec![non_coinbase_spend(1)]); spent.clear(); @@ -1293,21 +1306,21 @@ fn assemble_rejects_empty_and_fk_mismatch() { ) .err() .expect("not coinbase"); - assert_bad_block(err3, "coinbase"); + assert_bad_block(rule_err(err3), "coinbase"); } /// Pack creates are `txid → fk` (one insert). Meters flush once (in_n = spends). #[test] fn assemble_pending_creates_is_txid_map_and_meters_flush() { - use super::assemble_block_prevouts; + use crate::{BatchParents, OutPointSet, SpendEdges}; + use assemble_block_prevouts; use rbitcoin_primitives::Fk; - use rbitcoin_query::{BatchParents, OutPointSet, SpendEdges}; - let (path, q) = rbitcoin_query::testutil::tiny_query_labeled("assemble-creates"); + let (path, q) = crate::testutil::tiny_query_labeled("assemble-creates"); let ctx = ctx_h(1); let parents = BatchParents::new(); let thin = SpendEdges::default(); let mut spent = OutPointSet::default(); - let mut creates = super::PendingCreates::default(); + let mut creates = PendingCreates::default(); let b = block_with(vec![coinbase(1)]); let tids: Vec<[u8; 32]> = b .txdata @@ -1347,9 +1360,9 @@ fn assemble_pending_creates_is_txid_map_and_meters_flush() { /// Confirm jobs keep prevout scripts in the wire block or the pin Arc. #[test] fn script_job_shares_prevout_script() { - use super::assemble_block_prevouts; + use crate::{BatchParents, OutPointSet, SpendEdge, SpendEdges}; + use assemble_block_prevouts; use rbitcoin_primitives::Fk; - use rbitcoin_query::{BatchParents, OutPointSet, SpendEdge, SpendEdges}; use rbitcoin_store::{OutputRecord, TxRecord}; let ctx = ctx_h(1); @@ -1434,7 +1447,7 @@ fn script_job_shares_prevout_script() { }], ); let mut spent = OutPointSet::default(); - let mut creates = super::PendingCreates::default(); + let mut creates = PendingCreates::default(); let (jobs, _, _, _, _) = assemble_block_prevouts( &wire, &ctx, @@ -1472,11 +1485,12 @@ fn script_job_shares_prevout_script() { /// Optimistic IBD: unstamped parent must not recover via `tx_fk_by_txid_tip`. #[test] fn optimistic_assemble_unstamped_parent_is_invariant() { - use super::assemble_block_prevouts; - use crate::{accept_and_connect_block, mine_empty_regtest}; + use crate::accept_and_connect_block; + use crate::{BatchParents, OutPointSet, SpendEdges}; + use assemble_block_prevouts; + use rbitcoin_consensus::mine_empty_regtest; use rbitcoin_primitives::Fk; - use rbitcoin_query::{BatchParents, OutPointSet, SpendEdges}; - let (path, q) = rbitcoin_query::testutil::tiny_query_labeled("assemble-unstamped"); + let (path, q) = crate::testutil::tiny_query_labeled("assemble-unstamped"); let params = ChainParams::regtest(); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); @@ -1517,7 +1531,7 @@ fn optimistic_assemble_unstamped_parent_is_invariant() { let parents = BatchParents::new(); let thin = SpendEdges::default(); let mut spent = OutPointSet::default(); - let mut creates = super::PendingCreates::default(); + let mut creates = PendingCreates::default(); let create_txids: Vec<[u8; 32]> = block .txdata .iter() @@ -1556,10 +1570,9 @@ fn optimistic_assemble_unstamped_parent_is_invariant() { /// coinbase still immature 4 deep. #[test] fn accept_rejects_connect_spend_rules() { - use crate::{ - accept_and_connect_block, mine_empty_regtest, pad_empty_from, prepare_regtest_candidate, - }; - let (_dir, q) = rbitcoin_query::testutil::tiny_query_labeled("connect-spend-rules"); + use crate::{accept_and_connect_block, pad_empty_from}; + use rbitcoin_consensus::{mine_empty_regtest, prepare_regtest_candidate}; + let (_dir, q) = crate::testutil::tiny_query_labeled("connect-spend-rules"); let params = ChainParams::regtest(); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); @@ -1597,7 +1610,7 @@ fn accept_rejects_connect_spend_rules() { let err = accept_and_connect_block(&q, ¶ms, Height(1), &same_cb, Milestone::NONE) .expect_err("same-block coinbase spend must be immature"); match err { - ConsensusError::BadTx("coinbase immature") => {} + ConfirmError::Rules(ConsensusError::BadTx("coinbase immature")) => {} other => panic!("expected coinbase immature, got {other}"), } @@ -1615,7 +1628,7 @@ fn accept_rejects_connect_spend_rules() { let err = accept_and_connect_block(&q, ¶ms, Height(2), &dup, Milestone::NONE) .expect_err("same-block double spend"); assert!( - matches!(err, ConsensusError::BadTx(s) if s.contains("double spend")), + matches!(err, ConfirmError::Rules(ConsensusError::BadTx(s)) if s.contains("double spend")), "same-block double spend: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(1))); @@ -1635,7 +1648,7 @@ fn accept_rejects_connect_spend_rules() { let err = accept_and_connect_block(&q, ¶ms, Height(2), &bad_order, Milestone::NONE) .expect_err("child-before-parent"); assert!( - matches!(err, ConsensusError::MissingPrevout), + matches!(err, ConfirmError::Rules(ConsensusError::MissingPrevout)), "child-before-parent: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(1))); @@ -1646,7 +1659,7 @@ fn accept_rejects_connect_spend_rules() { let err = accept_and_connect_block(&q, ¶ms, Height(2), &over, Milestone::NONE) .expect_err("in < out"); assert!( - matches!(err, ConsensusError::BadTx(s) if s.contains("in < out")), + matches!(err, ConfirmError::Rules(ConsensusError::BadTx(s)) if s.contains("in < out")), "in < out: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(1))); @@ -1659,7 +1672,7 @@ fn accept_rejects_connect_spend_rules() { let err = accept_and_connect_block(&q, ¶ms, Height(5), &b5, Milestone::NONE) .expect_err("height-1 coinbase still immature 4 deep"); assert!( - matches!(err, ConsensusError::BadTx(s) if s.contains("immature")), + matches!(err, ConfirmError::Rules(ConsensusError::BadTx(s)) if s.contains("immature")), "4-deep immature: {err:?}" ); } @@ -1668,10 +1681,9 @@ fn accept_rejects_connect_spend_rules() { /// missing prevout at any depth, with or without script checks. #[test] fn genesis_coinbase_is_never_a_coin() { - use crate::{ - accept_and_connect_block, mine_empty_regtest, pad_empty_from, prepare_regtest_candidate, - }; - let (_dir, q) = rbitcoin_query::testutil::tiny_query_labeled("genesis-cb-spend"); + use crate::{accept_and_connect_block, pad_empty_from}; + use rbitcoin_consensus::{mine_empty_regtest, prepare_regtest_candidate}; + let (_dir, q) = crate::testutil::tiny_query_labeled("genesis-cb-spend"); let params = ChainParams::regtest(); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); @@ -1703,7 +1715,7 @@ fn genesis_coinbase_is_never_a_coin() { let err = accept_and_connect_block(&q, ¶ms, Height(1), &b1, Milestone::NONE) .expect_err("genesis coinbase spend at height 1"); assert!( - matches!(err, ConsensusError::MissingPrevout), + matches!(err, ConfirmError::Rules(ConsensusError::MissingPrevout)), "height 1: {err:?}" ); @@ -1723,7 +1735,7 @@ fn genesis_coinbase_is_never_a_coin() { let err = accept_and_connect_block(&q, ¶ms, Height(h), &block, ms) .expect_err("mature genesis coinbase spend"); assert!( - matches!(err, ConsensusError::MissingPrevout), + matches!(err, ConfirmError::Rules(ConsensusError::MissingPrevout)), "{ms:?}: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(maturity))); @@ -1845,10 +1857,9 @@ fn anchored_milestone_skips_a_failing_p2pkh_script() { /// connects, and the archive keeps the script bytes. #[test] fn spending_an_unspendable_output_is_missing_prevout() { - use crate::{ - accept_and_connect_block, mine_empty_regtest, pad_empty_from, prepare_regtest_candidate, - }; - let (_dir, q) = rbitcoin_query::testutil::tiny_query_labeled("unspendable-not-a-coin"); + use crate::{accept_and_connect_block, pad_empty_from}; + use rbitcoin_consensus::{mine_empty_regtest, prepare_regtest_candidate}; + let (_dir, q) = crate::testutil::tiny_query_labeled("unspendable-not-a-coin"); let params = ChainParams::regtest(); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); @@ -1929,7 +1940,7 @@ fn spending_an_unspendable_output_is_missing_prevout() { ); assert_eq!(q.tip_height(), Some(Height(maturity))); - let reject = |q: &rbitcoin_query::Query, + let reject = |q: &crate::Query, tip: BlockHash, time: u32, height: u32, @@ -1941,12 +1952,12 @@ fn spending_an_unspendable_output_is_missing_prevout() { let err = accept_and_connect_block(q, ¶ms, Height(height), &block, ms) .expect_err("unspendable output is not a coin"); assert!( - matches!(err, ConsensusError::MissingPrevout), + matches!(err, ConfirmError::Rules(ConsensusError::MissingPrevout)), "{ms:?} h={height}: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(height - 1))); }; - let connect = |q: &rbitcoin_query::Query, + let connect = |q: &crate::Query, tip: BlockHash, time: u32, height: u32, @@ -2015,10 +2026,10 @@ fn spending_an_unspendable_output_is_missing_prevout() { #[test] fn assemble_milestone_pin_still_rejects_bad_blk_sigops() { - use super::assemble_block_prevouts; + use crate::{BatchParents, OutPointSet, SpendEdge, SpendEdges}; + use assemble_block_prevouts; use bitcoin::hashes::{sha256, Hash}; use rbitcoin_primitives::Fk; - use rbitcoin_query::{BatchParents, OutPointSet, SpendEdge, SpendEdges}; use rbitcoin_store::{OutputRecord, TxRecord}; let ws = vec![0xacu8; 80_001]; let h = sha256::Hash::hash(&ws); @@ -2079,7 +2090,7 @@ fn assemble_milestone_pin_still_rejects_bad_blk_sigops() { }], ); let mut spent = OutPointSet::default(); - let mut creates = super::PendingCreates::default(); + let mut creates = PendingCreates::default(); let tids: Vec<[u8; 32]> = b .txdata .iter() @@ -2104,17 +2115,17 @@ fn assemble_milestone_pin_still_rejects_bad_blk_sigops() { ) .err() .expect("over-budget pin P2WSH must reject"); - assert_bad_block(err, "bad-blk-sigops"); + assert_bad_block(rule_err(err), "bad-blk-sigops"); } /// Anchored skip uses the header path. A height match with a different hash /// still builds script jobs. -fn signet_low_work_fork_runs_scripts(q: &rbitcoin_query::Query) { - use super::assemble_block_prevouts; - use crate::milestone::MilestoneAnchor; +fn signet_low_work_fork_runs_scripts(q: &crate::Query) { + use crate::{BatchParents, OutPointSet, SpendEdge, SpendEdges}; + use assemble_block_prevouts; use bitcoin::hashes::Hash; + use rbitcoin_consensus::MilestoneAnchor; use rbitcoin_primitives::Fk; - use rbitcoin_query::{BatchParents, OutPointSet, SpendEdge, SpendEdges}; use rbitcoin_store::{OutputRecord, TxRecord}; let mut parent_txid = [0u8; 32]; parent_txid[0] = 0x42; @@ -2185,9 +2196,9 @@ fn signet_low_work_fork_runs_scripts(q: &rbitcoin_query::Query) { .map(|t| t.compute_txid().to_byte_array()) .collect(); let bh = b.header.block_hash().to_byte_array(); - let run = |q: &rbitcoin_query::Query| { + let run = |q: &crate::Query| { let mut spent = OutPointSet::default(); - let mut creates = super::PendingCreates::default(); + let mut creates = PendingCreates::default(); assemble_block_prevouts( &b, &ctx, @@ -2202,13 +2213,13 @@ fn signet_low_work_fork_runs_scripts(q: &rbitcoin_query::Query) { true, None, None, - crate::milestone::check_scripts(ms, q, 1, &bh), + crate::milestone_gate::check_scripts(ms, q, 1, &bh), ) .expect("op_true spend assembles") .0 }; assert_eq!(run(q).len(), 1, "missing anchor path still checks scripts"); - assert!(crate::milestone::check_scripts(ms, q, 1, &bh)); + assert!(crate::milestone_gate::check_scripts(ms, q, 1, &bh)); let mut base = [0u8; 32]; base[1] = 2; let mut one = [0u8; 32]; @@ -2231,14 +2242,14 @@ fn signet_low_work_fork_runs_scripts(q: &rbitcoin_query::Query) { run(q).is_empty(), "header path through the anchor skips scripts" ); - assert!(!crate::milestone::check_scripts(ms, q, 1, &bh)); + assert!(!crate::milestone_gate::check_scripts(ms, q, 1, &bh)); q.clear_milestone_path_above(0); assert_eq!(run(q).len(), 1, "cleared path checks scripts again"); } /// Confirms a height-1 tx that spends `create_fk:0`. fn plant_confirmed_spend( - q: &rbitcoin_query::Query, + q: &crate::Query, create_txid: [u8; 32], create_fk: rbitcoin_primitives::Fk, ) { @@ -2282,7 +2293,7 @@ fn plant_confirmed_spend( #[test] fn bip30_and_immature_coinbase_reject_from_facts() { - use super::{reject_bip30_unspent_prior, reject_one_create}; + use rbitcoin_consensus::block::{reject_bip30_unspent_prior, reject_one_create}; assert!(reject_bip30_unspent_prior(2, 2, true).is_ok()); let bip30 = reject_bip30_unspent_prior(5, 1, true).unwrap_err(); assert!( @@ -2314,8 +2325,7 @@ fn bip30_and_immature_coinbase_reject_from_facts() { /// overwrite. A spendable unspent output still does. #[test] fn bip30_ignores_unspendable_outputs() { - use crate::block::{structural_validate_spends, RunCreateHeight}; - use rbitcoin_query::{BatchParents, OutPointSet, U32Map}; + use crate::{BatchParents, OutPointSet, U32Map}; let outputs = vec![ TxOut { value: Amount::from_sat(50_0000_0000), @@ -2332,7 +2342,7 @@ fn bip30_ignores_unspendable_outputs() { ]; let (path, q, first, fk) = plant_coinbase_with_outputs("bip30-unspendable", outputs); let dup = block_with(vec![first.clone()]); - let check = |q: &rbitcoin_query::Query| { + let check = |q: &crate::Query| { let mut mtp_cache = U32Map::default(); mtp_cache.insert(9, 0); let parents = BatchParents::new(); @@ -2355,8 +2365,8 @@ fn bip30_ignores_unspendable_outputs() { &parents, &mut mtp_cache, &RunCreateHeight::Spans(Vec::new()), - &crate::block::ClassAWave::default(), - &mut crate::block::StructuralScratch::default(), + &ClassAWave::default(), + &mut StructuralScratch::default(), &abs_jobs, ) .err() @@ -2381,9 +2391,9 @@ fn bip30_ignores_unspendable_outputs() { /// with and without a script job. #[test] fn resolve_prevout_rejects_an_unspendable_script() { - use super::resolve_prevout; + use crate::block_pipeline::resolve_prevout; + use crate::BatchParents; use rbitcoin_primitives::Fk; - use rbitcoin_query::BatchParents; use rbitcoin_store::{OutputRecord, TxRecord}; let txid = [0x42u8; 32]; let fk = Fk(7); @@ -2407,7 +2417,7 @@ fn resolve_prevout_rejects_an_unspendable_script() { witness: Witness::new(), }; let block = block_with(vec![coinbase(1)]); - let txid_index = super::TxidMap::::default(); + let txid_index = rbitcoin_consensus::block::TxidMap::::default(); let try_spend = |script: Vec, need_script_buf: bool| { let mut parents = BatchParents::new(); parents.put_resolved( @@ -2429,23 +2439,29 @@ fn resolve_prevout_rejects_an_unspendable_script() { true, need_script_buf, None, - &mut super::AsmPrevoutAcc::default(), + &mut AsmPrevoutAcc::default(), ) }; - let show = |r: &Result<_, ConsensusError>| match r { + let show = |r: &Result<_, ConfirmError>| match r { Ok(_) => "resolved as a coin".to_string(), Err(e) => format!("{e:?}"), }; for need_script_buf in [false, true] { let oversized = try_spend(vec![0x51; 10_001], need_script_buf); assert!( - matches!(oversized, Err(ConsensusError::MissingPrevout)), + matches!( + oversized, + Err(ConfirmError::Rules(ConsensusError::MissingPrevout)) + ), "need_script_buf={need_script_buf} oversized: {}", show(&oversized) ); let op_return = try_spend(vec![0x6a, 0x01, 0x42], need_script_buf); assert!( - matches!(op_return, Err(ConsensusError::MissingPrevout)), + matches!( + op_return, + Err(ConfirmError::Rules(ConsensusError::MissingPrevout)) + ), "need_script_buf={need_script_buf} OP_RETURN: {}", show(&op_return) ); @@ -2474,13 +2490,13 @@ fn buried_rules_and_a_lying_header_path() { /// N1: Optimistic miss is lookup invariant; pin hit / identity / vout still classified. #[test] fn n1_assemble_cold_why_reasons() { - use super::resolve_prevout; use crate::accept_and_connect_block; - use crate::confirm_phase_stats; + use crate::block_pipeline::resolve_prevout; + use crate::block_pipeline::test_phase as confirm_phase_stats; + use crate::BatchParents; use rbitcoin_primitives::Fk; - use rbitcoin_query::BatchParents; use rbitcoin_store::OutputRecord; - let (path, q) = rbitcoin_query::testutil::tiny_query_labeled("n1-cold-why"); + let (path, q) = crate::testutil::tiny_query_labeled("n1-cold-why"); let params = ChainParams::regtest(); let ms = Milestone::NONE; let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); @@ -2531,13 +2547,13 @@ fn n1_assemble_cold_why_reasons() { witness: Witness::new(), }; let empty_block = block_with(vec![coinbase(4)]); - let txid_index = super::TxidMap::::default(); + let txid_index = rbitcoin_consensus::block::TxidMap::::default(); // Thread-local N1 counters (process-global atomics race under parallel cargo test). let _ = confirm_phase_stats::sample_tl_assemble_cold_why_and_reset(); let _ = confirm_phase_stats::sample_tl_batch_cold_n_and_reset(); - fn assert_lookup_miss(err: ConsensusError) { + fn assert_lookup_miss(err: ConfirmError) { let msg = format!("{err}"); assert!( msg.contains("invariant") && msg.contains("lookup stage miss"), @@ -2560,7 +2576,7 @@ fn n1_assemble_cold_why_reasons() { false, true, None, - &mut super::AsmPrevoutAcc::default(), + &mut AsmPrevoutAcc::default(), ) .err() .expect("null_fk optimistic"); @@ -2582,7 +2598,7 @@ fn n1_assemble_cold_why_reasons() { false, true, None, - &mut super::AsmPrevoutAcc::default(), + &mut AsmPrevoutAcc::default(), ) .err() .expect("not_pin optimistic"); @@ -2614,7 +2630,7 @@ fn n1_assemble_cold_why_reasons() { false, true, None, - &mut super::AsmPrevoutAcc::default(), + &mut AsmPrevoutAcc::default(), ) .expect("batch hit"); let why = confirm_phase_stats::sample_tl_assemble_cold_why_and_reset(); @@ -2643,7 +2659,7 @@ fn n1_assemble_cold_why_reasons() { false, true, None, - &mut super::AsmPrevoutAcc::default(), + &mut AsmPrevoutAcc::default(), ) { Ok(_) => panic!("mismatch must hard-fail"), Err(e) => e, @@ -2680,7 +2696,7 @@ fn n1_assemble_cold_why_reasons() { false, true, None, - &mut super::AsmPrevoutAcc::default(), + &mut AsmPrevoutAcc::default(), ) { Ok(_) => panic!("vout_miss must hard-fail"), Err(e) => e, @@ -2716,7 +2732,7 @@ fn already_archived_schema13_pin_identity_tip_follow() { }; use std::sync::Arc; - let (path, q) = rbitcoin_query::testutil::tiny_query_labeled("plan-none-pin-id"); + let (path, q) = crate::testutil::tiny_query_labeled("plan-none-pin-id"); q.set_spend_index(true); let params = ChainParams::regtest(); let ms = Milestone::NONE; @@ -2887,7 +2903,7 @@ fn already_archived_schema13_pin_identity_tip_follow() { let err = accept_and_connect_block(&q, ¶ms, Height(h_n2 + 1), &b_ds, ms) .expect_err("double-spend of C1 must fail"); assert!( - matches!(err, ConsensusError::PrevoutSpent) + matches!(err, ConfirmError::Rules(ConsensusError::PrevoutSpent)) || format!("{err}").contains("spent") || format!("{err}").contains("prevout"), "got {err}" @@ -3120,12 +3136,12 @@ fn script_job_shared_tx_is_wire_pointer() { let tid = block.txdata[1].compute_txid().to_byte_array(); let job = ScriptCheckJob::from_parts( tid, - super::JobPrevouts::owned(vec![TxOut { + rbitcoin_consensus::block::JobPrevouts::owned(vec![TxOut { value: Amount::from_sat(50_0000_0000), script_pubkey: ScriptBuf::from_bytes(vec![0x51]), }]), - super::JobTx::shared(Arc::clone(&block), 1), - crate::block::ScriptVerifyFlags::buried(true, true, true, true, true), + rbitcoin_consensus::block::JobTx::shared(Arc::clone(&block), 1), + ScriptVerifyFlags::buried(true, true, true, true, true), ); assert!(std::ptr::eq( &*job.tx as *const Transaction, @@ -3258,7 +3274,7 @@ fn pres_has_witness_matches_block_walk() { #[test] fn exceeds_sigops_limit_matches_the_block_cap() { - use super::exceeds_sigops_limit; + use rbitcoin_consensus::block::exceeds_sigops_limit; assert!(!exceeds_sigops_limit(0)); assert!(!exceeds_sigops_limit(79_999)); assert!(!exceeds_sigops_limit(80_000)); @@ -3268,7 +3284,7 @@ fn exceeds_sigops_limit_matches_the_block_cap() { #[test] fn should_use_pres_requires_an_index_inside_the_slice() { - use super::should_use_pres; + use rbitcoin_consensus::block::should_use_pres; assert!(should_use_pres(0, 1)); assert!(!should_use_pres(1, 1)); assert!(!should_use_pres(0, 0)); @@ -3278,7 +3294,7 @@ fn should_use_pres_requires_an_index_inside_the_slice() { #[test] fn lock_time_cutoff_uses_block_time_at_height_zero_even_when_csv_is_active() { - use super::assemble_lock_time_cutoff; + use crate::block_pipeline::assemble_lock_time_cutoff; let mut block = block_with(vec![coinbase(0)]); block.header.time = 100; let mtp = 200; @@ -3305,7 +3321,7 @@ fn lock_time_cutoff_uses_block_time_at_height_zero_even_when_csv_is_active() { #[test] fn max_money_fits_in_i64_so_the_assemble_cast_is_in_range() { - use super::{exceeds_max_money, money_range_out_sum}; + use rbitcoin_consensus::block::{exceeds_max_money, money_range_out_sum}; let max_money = Amount::MAX_MONEY.to_sat(); assert!(!exceeds_max_money(0)); assert!(!exceeds_max_money(max_money)); @@ -3334,14 +3350,17 @@ fn assemble_without_pres_sums_zero_and_rejects_over_max() { }], } } - assert_eq!(super::assemble_tx_value_out(&one(0), 0, None).unwrap(), 0); + assert_eq!( + rbitcoin_consensus::block::assemble_tx_value_out(&one(0), 0, None).unwrap(), + 0 + ); let max_money = Amount::MAX_MONEY.to_sat(); assert_eq!( - super::assemble_tx_value_out(&one(max_money), 0, None).unwrap(), + rbitcoin_consensus::block::assemble_tx_value_out(&one(max_money), 0, None).unwrap(), max_money as i64 ); assert!(matches!( - super::assemble_tx_value_out(&one(max_money + 1), 0, None), + rbitcoin_consensus::block::assemble_tx_value_out(&one(max_money + 1), 0, None), Err(ConsensusError::BadBlock("bad-txns-vout-toolarge")) )); } @@ -3357,7 +3376,7 @@ fn witness_commitment_leaves_a_legacy_block_unchanged() { #[test] fn resolve_prevout_rejects_a_same_index_parent() { - use super::{resolve_prevout, AsmPrevoutAcc, TxidMap}; + use rbitcoin_consensus::block::TxidMap; let parent = coinbase(1); let txid = parent.compute_txid().to_byte_array(); let spend = Transaction { @@ -3387,7 +3406,7 @@ fn resolve_prevout_rejects_a_same_index_parent() { None, &index, 1, - &rbitcoin_query::BatchParents::new(), + &crate::BatchParents::new(), true, true, false, @@ -3401,9 +3420,9 @@ fn resolve_prevout_rejects_a_same_index_parent() { #[test] fn same_height_confirmed_spender_is_not_below_its_create() { - use super::structural_apply_one_meta; + use crate::block_pipeline::structural_apply_one_meta; use rbitcoin_primitives::Fk; - let (_dir, query) = rbitcoin_query::testutil::tiny_query_labeled("spend-same-h"); + let (_dir, query) = crate::testutil::tiny_query_labeled("spend-same-h"); let store = query.store(); store .confirmed @@ -3412,7 +3431,7 @@ fn same_height_confirmed_spender_is_not_below_its_create() { store.header_txs.put_range(Fk(1), Fk(1), 1).unwrap(); store.strong_tx.set_strong(Fk(1), Fk(1)).unwrap(); store.rebuild_height_fence().unwrap(); - let mut scratch = super::StructuralScratch::default(); + let mut scratch = crate::block_pipeline::StructuralScratch::default(); scratch.height_by_id.insert(9, 0); scratch.field_h_by_id.insert(1, 0); structural_apply_one_meta( diff --git a/crates/rbitcoin-query/src/wire_agree_tests.rs b/crates/rbitcoin-query/src/wire_agree_tests.rs new file mode 100644 index 0000000000..9c209889f4 --- /dev/null +++ b/crates/rbitcoin-query/src/wire_agree_tests.rs @@ -0,0 +1,85 @@ +//! Store wire walk and consensus decode agree on every truncation. +#![cfg(test)] + +use bitcoin::absolute::LockTime; +use bitcoin::hashes::Hash; +use bitcoin::transaction::Version; +use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; + +fn legacy_1in() -> Transaction { + Transaction { + version: Version::ONE, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: bitcoin::Txid::from_byte_array([0x11; 32]), + vout: 0, + }, + script_sig: ScriptBuf::from_bytes(vec![0x51, 0x51]), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + } +} + +fn p2wpkh_like() -> Transaction { + let mut tx = legacy_1in(); + tx.input[0].script_sig = ScriptBuf::new(); + tx.input[0].witness = Witness::from_slice(&[vec![0x30; 71], vec![0x02; 33]]); + tx +} + +const FLAG_ZERO_TX: [u8; 10] = [2, 0, 0, 0, 0x00, 0x00, 7, 0, 0, 0]; + +/// Peer intake walks the wire and lookup decodes it. A payload one side +/// accepts and the other refuses either strands a queued body or drops an +/// honest one, so every truncation and single-byte rewrite must agree. +#[test] +fn block_wire_walk_agrees_with_decode() { + use bitcoin::consensus::encode::serialize; + let zero_in = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + // Segwit with one empty and one non-empty witness decodes; rewriting + // the non-empty stack count to 0 hits the all-empty refusal. + let mut mixed = p2wpkh_like(); + mixed.input.insert(0, legacy_1in().input[0].clone()); + mixed.input[1].witness = Witness::from_slice(&[[0x01]]); + let mut raw = vec![0u8; 80]; + raw.push(5); + for tx in [legacy_1in(), p2wpkh_like(), zero_in, mixed] { + raw.extend_from_slice(&serialize(&tx)); + } + raw.extend_from_slice(&FLAG_ZERO_TX); + let agree = |p: &[u8]| { + let decoded = rbitcoin_consensus::decode_block_precomputes(p, false) + .map(|(b, _, _)| b.txdata.iter().map(|t| t.input.len() as u32).sum()); + assert_eq!( + rbitcoin_store::block_wire_input_count(p), + decoded, + "payload {p:02x?}" + ); + decoded + }; + assert_eq!(agree(&raw), Some(4)); + for n in 0..raw.len() { + agree(&raw[..n]); + } + for i in 80..raw.len() { + for v in [0x00, 0x01, 0x02, 0xfd, 0xfe, 0xff] { + let mut p = raw.clone(); + p[i] = v; + agree(&p); + } + } +} diff --git a/crates/rbitcoin-rpc/src/blockstats.rs b/crates/rbitcoin-rpc/src/blockstats.rs index 74c90ad1c5..76649b6786 100644 --- a/crates/rbitcoin-rpc/src/blockstats.rs +++ b/crates/rbitcoin-rpc/src/blockstats.rs @@ -571,7 +571,7 @@ fn stats_for_stamped( height: Height, stamped: &StampedTxstatBlock, ) -> Result { - let mediantime = rbitcoin_consensus::median_time_past(ctx.query.as_ref(), height) + let mediantime = rbitcoin_query::median_time_past(ctx.query.as_ref(), height) .unwrap_or(stamped.rec.timestamp); let params = match ctx.chain.as_ref() { Some(c) => c.params.clone(), @@ -621,8 +621,8 @@ fn stats_for_connected( block: &Block, parents: &PrevoutMap, ) -> Result { - let mediantime = rbitcoin_consensus::median_time_past(ctx.query.as_ref(), height) - .unwrap_or(block.header.time); + let mediantime = + rbitcoin_query::median_time_past(ctx.query.as_ref(), height).unwrap_or(block.header.time); let params = match ctx.chain.as_ref() { Some(c) => c.params.clone(), None => rbitcoin_consensus::ChainParams::for_network(ctx.network), diff --git a/crates/rbitcoin-rpc/src/methods/chain.rs b/crates/rbitcoin-rpc/src/methods/chain.rs index b9535aea3f..b4c0c5505a 100644 --- a/crates/rbitcoin-rpc/src/methods/chain.rs +++ b/crates/rbitcoin-rpc/src/methods/chain.rs @@ -74,8 +74,8 @@ pub(crate) fn getblockchaininfo(ctx: &RpcContext) -> Result { }; let (time, mediantime) = if let Some(h) = ctx.query.tip_height() { if let Ok(Some((_, rec))) = ctx.query.header_at_height(h) { - let mtp = rbitcoin_consensus::median_time_past(ctx.query.as_ref(), h) - .unwrap_or(rec.timestamp); + let mtp = + rbitcoin_query::median_time_past(ctx.query.as_ref(), h).unwrap_or(rec.timestamp); (rec.timestamp, mtp) } else { (0u32, 0u32) @@ -285,7 +285,7 @@ fn active_block_header(ctx: &RpcContext, height: Height, verbose: bool) -> Resul "versionHex": format!("{:08x}", rec.version), "merkleroot": hash_hex_display(&rec.merkle_root), "time": rec.timestamp, - "mediantime": rbitcoin_consensus::median_time_past(ctx.query.as_ref(), height) + "mediantime": rbitcoin_query::median_time_past(ctx.query.as_ref(), height) .unwrap_or(rec.timestamp), "nonce": rec.nonce, "bits": format!("{:08x}", rec.bits), @@ -715,7 +715,7 @@ pub(crate) fn getblock(ctx: &RpcContext, params: &RpcParams) -> Result Result Result { .unwrap_or(0) }); let curtime = tip_time.saturating_add(1).max(now); - let bits = rbitcoin_consensus::expected_next_bits( - ctx.query.as_ref(), - ¶ms, - Height(next_h), - curtime, - ) - .map(|c| c.to_consensus()) - .unwrap_or(tip_bits); + let bits = + rbitcoin_query::expected_next_bits(ctx.query.as_ref(), ¶ms, Height(next_h), curtime) + .map(|c| c.to_consensus()) + .unwrap_or(tip_bits); let mintime = if tip_h == 0 { tip_time } else { - rbitcoin_consensus::median_time_past(ctx.query.as_ref(), Height(tip_h)).unwrap_or(tip_time) + rbitcoin_query::median_time_past(ctx.query.as_ref(), Height(tip_h)).unwrap_or(tip_time) }; let selected = mempool_block_txs(ctx); let mut fees = 0u64; @@ -1182,14 +1178,12 @@ fn submit_checkblock_reason( params: &rbitcoin_consensus::ChainParams, milestone: rbitcoin_consensus::Milestone, ) -> Result, rbitcoin_store::StoreError> { - use rbitcoin_consensus::ConsensusError; let vctx = match spend_height { Some(h) => rbitcoin_consensus::ValidationContext::at(params, Height(h), milestone), None => rbitcoin_consensus::ValidationContext::archive_structure(params), }; match rbitcoin_consensus::validate_block_structure(block, &vctx) { Ok(()) => Ok(None), - Err(ConsensusError::Store(e)) => Err(e), Err(e) => Ok(Some(rbitcoin_consensus::block_reject_reason(&e))), } } diff --git a/crates/rbitcoin-rpc/src/methods_tests.rs b/crates/rbitcoin-rpc/src/methods_tests.rs index f6921a4308..c25fa6c6b7 100644 --- a/crates/rbitcoin-rpc/src/methods_tests.rs +++ b/crates/rbitcoin-rpc/src/methods_tests.rs @@ -60,7 +60,7 @@ fn empty_102_pad() -> &'static Empty102Pad { let (ctx, dir) = ctx_empty(); let params = rbitcoin_consensus::ChainParams::regtest(); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); - rbitcoin_consensus::accept_and_connect_block( + rbitcoin_query::accept_and_connect_block( &ctx.query, ¶ms, Height::GENESIS, @@ -68,7 +68,7 @@ fn empty_102_pad() -> &'static Empty102Pad { rbitcoin_consensus::Milestone::NONE, ) .unwrap(); - let (_tip, _time, coinbases) = rbitcoin_consensus::pad_empty_from( + let (_tip, _time, coinbases) = rbitcoin_query::pad_empty_from( &ctx.query, ¶ms, genesis.block_hash(), @@ -98,9 +98,8 @@ fn ctx_from_empty_102() -> (RpcContext, TempDir, Vec) { #[test] fn empty_102_pad_copy_keeps_the_other_tip() { - use rbitcoin_consensus::{ - accept_and_connect_block, mine_empty_regtest, ChainParams, Milestone, - }; + use rbitcoin_consensus::{mine_empty_regtest, ChainParams, Milestone}; + use rbitcoin_query::accept_and_connect_block; let (a, dir_a, cbs_a) = ctx_from_empty_102(); let (b, dir_b, cbs_b) = ctx_from_empty_102(); @@ -795,8 +794,9 @@ fn mempool_txid_lookups_do_not_list_live() { use bitcoin::script::ScriptBuf; use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; - use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; let (ctx, dir) = ctx_empty(); let params = ChainParams::regtest(); @@ -810,7 +810,7 @@ fn mempool_txid_lookups_do_not_list_live() { ) .unwrap(); const N_SPENDS: u32 = 3; - let (_tip, _tip_time, coinbase_txids) = rbitcoin_consensus::pad_empty_from( + let (_tip, _tip_time, coinbase_txids) = rbitcoin_query::pad_empty_from( &ctx.query, ¶ms, genesis.block_hash(), @@ -2529,7 +2529,8 @@ fn generate_selects_chained_mempool_parent_first() { use bitcoin::consensus::encode::serialize; use bitcoin::transaction::Version as TxVersion; use bitcoin::{OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; - use rbitcoin_consensus::{pad_empty_from, ChainParams}; + use rbitcoin_consensus::ChainParams; + use rbitcoin_query::pad_empty_from; let (ctx, dir, _hub) = ctx_regtest_hub(); let params = ChainParams::regtest(); @@ -2646,7 +2647,7 @@ fn getblockstats_coinbase_only_and_op_return_match_helper() { let want = crate::blockstats::compute_block_stats( 1, &block, - rbitcoin_consensus::median_time_past(hub.query.as_ref(), Height(1)) + rbitcoin_query::median_time_past(hub.query.as_ref(), Height(1)) .unwrap_or(block.header.time), rbitcoin_consensus::block_subsidy(1, &hub.params), |op| { @@ -2729,7 +2730,7 @@ fn getblockstats_coinbase_only_and_op_return_match_helper() { let want = crate::blockstats::compute_block_stats( h, &block, - rbitcoin_consensus::median_time_past(hub.query.as_ref(), Height(h)) + rbitcoin_query::median_time_past(hub.query.as_ref(), Height(h)) .unwrap_or(block.header.time), rbitcoin_consensus::block_subsidy(h, &hub.params), |op| { @@ -3538,8 +3539,9 @@ fn testmempoolaccept_rbf_does_not_evict_conflict() { use bitcoin::script::ScriptBuf; use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; - use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; let (ctx, dir) = ctx_empty(); let params = ChainParams::regtest(); @@ -3552,7 +3554,7 @@ fn testmempoolaccept_rbf_does_not_evict_conflict() { Milestone::NONE, ) .unwrap(); - let (_tip, _tip_time, coinbase_txids) = rbitcoin_consensus::pad_empty_from( + let (_tip, _tip_time, coinbase_txids) = rbitcoin_query::pad_empty_from( &ctx.query, ¶ms, genesis.block_hash(), diff --git a/crates/rbitcoin-store/src/lib.rs b/crates/rbitcoin-store/src/lib.rs index 7a53f62ffb..aedcb31f2c 100644 --- a/crates/rbitcoin-store/src/lib.rs +++ b/crates/rbitcoin-store/src/lib.rs @@ -83,11 +83,11 @@ pub use index_build_uring::{read_index_window, IndexBlock, IndexHeight, IndexWin pub use input::InputEdge; pub use int_map::{FkMap, FkSet, U32Map, U64IdentityHasher, U64Map, U64Set}; pub use integrity::VERIFY_TIP_BLOCKS; +pub use io_backend::{ReadIoBackend, WriteIoBackend}; +pub use point_table::PointRecord; pub use rbitcoin_primitives::{ merkle_branch, merkle_root_from_branch, merkle_root_from_txids, merkle_root_mutated, }; -pub use io_backend::{ReadIoBackend, WriteIoBackend}; -pub use point_table::PointRecord; pub use scripthash::{ script_hash, sh_heads_insert_capped, ColdProgress, ScriptHashRecord, INCLUDE_HWM_NAME, SH_HEADS_CAP, diff --git a/crates/rbitcoin-sv2/Cargo.toml b/crates/rbitcoin-sv2/Cargo.toml index fb0c3c53e6..3127751c00 100644 --- a/crates/rbitcoin-sv2/Cargo.toml +++ b/crates/rbitcoin-sv2/Cargo.toml @@ -10,6 +10,7 @@ description = "Stratum v2 Template Distribution Protocol server (Noise over TCP) [dependencies] rbitcoin-log = { workspace = true } rbitcoin-consensus = { workspace = true } +rbitcoin-query = { workspace = true } rbitcoin-net = { workspace = true } rbitcoin-primitives = { workspace = true } rbitcoin-store = { workspace = true } diff --git a/crates/rbitcoin-sv2/src/template.rs b/crates/rbitcoin-sv2/src/template.rs index 80f5fa8bd5..e2a1ecf3f9 100644 --- a/crates/rbitcoin-sv2/src/template.rs +++ b/crates/rbitcoin-sv2/src/template.rs @@ -6,11 +6,11 @@ use bitcoin::consensus::encode::serialize; use bitcoin::hashes::Hash; use bitcoin::{Amount, ScriptBuf, Target, Transaction, TxOut}; use rbitcoin_consensus::{ - bip34_height_script, block_subsidy, expected_next_bits, median_time_past, - witness_commitment_script, MAX_BLOCK_WEIGHT, + bip34_height_script, block_subsidy, witness_commitment_script, MAX_BLOCK_WEIGHT, }; use rbitcoin_net::{ChainHub, SelectBudget}; use rbitcoin_primitives::Height; +use rbitcoin_query::{expected_next_bits, median_time_past}; use std::io; use std::sync::Arc; use template_distribution_sv2::{NewTemplate, SetNewPrevHash}; diff --git a/crates/rbitcoin-sv2/src/template_tests.rs b/crates/rbitcoin-sv2/src/template_tests.rs index 01ca38ffc4..58374d8eec 100644 --- a/crates/rbitcoin-sv2/src/template_tests.rs +++ b/crates/rbitcoin-sv2/src/template_tests.rs @@ -12,11 +12,11 @@ use bitcoin::{ }; use bitcoin::{CompactTarget, Target}; use rbitcoin_consensus::{ - bip34_height_script, block_subsidy, confirm_scripts_phase, expected_next_bits, - median_time_past, mine_empty_regtest, mine_regtest_paying, witness_commitment_script, - ChainParams, + bip34_height_script, block_subsidy, mine_empty_regtest, mine_regtest_paying, + witness_commitment_script, ChainParams, }; use rbitcoin_primitives::Height; +use rbitcoin_query::{confirm_scripts_phase, expected_next_bits, median_time_past}; use rbitcoin_store::merkle_root_from_txids; use std::sync::Arc; use std::time::Duration; diff --git a/crates/rbitcoin-sv2/src/test_chain.rs b/crates/rbitcoin-sv2/src/test_chain.rs index 352323311b..898ec31ed3 100644 --- a/crates/rbitcoin-sv2/src/test_chain.rs +++ b/crates/rbitcoin-sv2/src/test_chain.rs @@ -1,8 +1,9 @@ use bitcoin::{Network, Txid}; -use rbitcoin_consensus::{accept_and_connect_block, pad_empty_from, ChainParams, Milestone}; +use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_net::{ChainHub, MempoolHub}; use rbitcoin_primitives::Height; use rbitcoin_query::testutil::{tiny_query_labeled, TempDir}; +use rbitcoin_query::{accept_and_connect_block, pad_empty_from}; use std::path::Path; use std::sync::Arc; diff --git a/crates/rbitcoin-test/src/chain_fixture.rs b/crates/rbitcoin-test/src/chain_fixture.rs index 4e5271bf87..7ece3abf1a 100644 --- a/crates/rbitcoin-test/src/chain_fixture.rs +++ b/crates/rbitcoin-test/src/chain_fixture.rs @@ -5,11 +5,10 @@ use std::sync::OnceLock; use bitcoin::hashes::Hash; use bitcoin::{Amount, Block, BlockHash, Txid}; -use rbitcoin_consensus::{ - accept_and_connect_block, pad_empty_from as consensus_pad, ChainParams, Milestone, -}; +use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::Height; use rbitcoin_query::Query; +use rbitcoin_query::{accept_and_connect_block, pad_empty_from as consensus_pad}; use crate::mine::{mine_regtest_block, regtest_genesis, spend_anyone_can_spend}; @@ -158,7 +157,7 @@ fn mine_mature_regtest_with_spend(query: &Query, params: &ChainParams) -> Mature } } -/// Fast empty pad via [`rbitcoin_consensus::pad_empty_from`]. +/// Fast empty pad via [`rbitcoin_query::pad_empty_from`]. /// /// Mines heights `from_h..=last` on top of `tip` / `tip_time`. Prefer this for /// coinbase-maturity padding instead of looping `confirm_wire_run`. diff --git a/crates/rbitcoin-test/tests/consensus_rules.rs b/crates/rbitcoin-test/tests/consensus_rules.rs index 4ba7180ae2..0da9b5761f 100644 --- a/crates/rbitcoin-test/tests/consensus_rules.rs +++ b/crates/rbitcoin-test/tests/consensus_rules.rs @@ -4,12 +4,13 @@ use bitcoin::hashes::Hash; use bitcoin::{Amount, BlockHash, CompactTarget}; -use rbitcoin_consensus::{ - accept_and_connect_block, confirm_wire_run, expected_next_bits, genesis_block, - median_time_past, validate_header, ChainParams, ConsensusError, Milestone, -}; +use rbitcoin_consensus::{genesis_block, ChainParams, ConsensusError, Milestone}; use rbitcoin_primitives::Height; use rbitcoin_query::Query; +use rbitcoin_query::{ + accept_and_connect_block, confirm_wire_run, expected_next_bits, median_time_past, + validate_header, +}; use rbitcoin_test::mine::{mine_regtest_block, regtest_genesis, spend_anyone_can_spend}; use rbitcoin_test::{pad_empty_from, TestDatadir}; diff --git a/crates/rbitcoin-test/tests/core_analogs.rs b/crates/rbitcoin-test/tests/core_analogs.rs index 9bdf93f7ff..47cfa20105 100644 --- a/crates/rbitcoin-test/tests/core_analogs.rs +++ b/crates/rbitcoin-test/tests/core_analogs.rs @@ -16,11 +16,10 @@ use bitcoin::hashes::Hash; use bitcoin::{ Amount, BlockHash, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, }; -use rbitcoin_consensus::{ - accept_and_connect_block, grind_regtest_pow, ChainParams, ConsensusError, Milestone, -}; +use rbitcoin_consensus::{grind_regtest_pow, ChainParams, ConsensusError, Milestone}; use rbitcoin_net::MempoolHub; use rbitcoin_primitives::Height; +use rbitcoin_query::accept_and_connect_block; use rbitcoin_query::Query; use rbitcoin_test::mine::{mine_regtest_block, regtest_genesis, spend_anyone_can_spend}; use rbitcoin_test::{ @@ -740,7 +739,7 @@ fn analog_block_filters_from_class_a() { let q = Arc::new(q); let run_appender = |until: &dyn Fn(Option) -> bool| { let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let bf = rbitcoin_consensus::spawn_index_writebehind( + let bf = rbitcoin_query::spawn_index_writebehind( Arc::clone(&q), Arc::clone(&stop), || {}, @@ -811,7 +810,7 @@ fn analog_block_filters_from_class_a() { "open drops the slot whose block left the best chain" ); q.release_index_writebehind(Height(last)); - rbitcoin_consensus::build_indexes_released(&q).unwrap(); + rbitcoin_query::build_indexes_released(&q).unwrap(); let (bytes, _) = q.basic_filter_at(last).unwrap().unwrap(); assert_eq!(bytes, reference(&alt).content); } diff --git a/crates/rbitcoin-test/tests/cross_surface.rs b/crates/rbitcoin-test/tests/cross_surface.rs index 97c9e73d21..d5f3660eda 100644 --- a/crates/rbitcoin-test/tests/cross_surface.rs +++ b/crates/rbitcoin-test/tests/cross_surface.rs @@ -6,11 +6,12 @@ use bitcoin::hashes::Hash; use bitcoin::script::ScriptBuf; use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; -use rbitcoin_consensus::{accept_and_connect_block, pad_empty_from, ChainParams, Milestone}; +use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_electrum::electrum_scripthash_hex; use rbitcoin_node::{run_p2p, NodeConfig, Sv2AuthoritySecret}; use rbitcoin_primitives::{Height, Network}; use rbitcoin_query::Query; +use rbitcoin_query::{accept_and_connect_block, pad_empty_from}; use rbitcoin_test::{open_mature_regtest_with_spend, TestDatadir}; use serde_json::{json, Value}; use std::collections::HashMap; diff --git a/crates/rbitcoin-test/tests/electrum_protocol.rs b/crates/rbitcoin-test/tests/electrum_protocol.rs index 83fa14d752..90847abc3f 100644 --- a/crates/rbitcoin-test/tests/electrum_protocol.rs +++ b/crates/rbitcoin-test/tests/electrum_protocol.rs @@ -45,7 +45,7 @@ fn electrum_maturity() -> &'static ElectrumMaturity { let q = Query::open_or_create_tiny(&store).unwrap(); let params = ChainParams::regtest(); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); - rbitcoin_consensus::accept_and_connect_block( + rbitcoin_query::accept_and_connect_block( &q, ¶ms, rbitcoin_primitives::Height::GENESIS, @@ -53,7 +53,7 @@ fn electrum_maturity() -> &'static ElectrumMaturity { Milestone::NONE, ) .unwrap(); - let (tip, tip_time, coinbases) = rbitcoin_consensus::pad_empty_from( + let (tip, tip_time, coinbases) = rbitcoin_query::pad_empty_from( &q, ¶ms, genesis.block_hash(), @@ -687,7 +687,7 @@ async fn electrum_server_version_history_balance() { next_h, vec![], ); - rbitcoin_consensus::accept_and_connect_block( + rbitcoin_query::accept_and_connect_block( q.as_ref(), ¶ms, rbitcoin_primitives::Height(next_h), @@ -739,7 +739,7 @@ async fn electrum_server_version_history_balance() { bitcoin::script::ScriptBuf::from_bytes(vec![0x00]), vec![], ); - rbitcoin_consensus::accept_and_connect_block( + rbitcoin_query::accept_and_connect_block( q.as_ref(), ¶ms, rbitcoin_primitives::Height(miss_h), @@ -1026,8 +1026,8 @@ fn block_touch_sees_spend_only_heights() { use bitcoin::script::ScriptBuf; use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; - use rbitcoin_consensus::accept_and_connect_block; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; use rbitcoin_store::script_hash; let dir = TempDir::new().unwrap(); @@ -1382,9 +1382,10 @@ async fn electrum_leftover_mempool_does_not_double_count() { use bitcoin::script::ScriptBuf; use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; - use rbitcoin_consensus::{accept_and_connect_block, Milestone}; + use rbitcoin_consensus::Milestone; use rbitcoin_net::MempoolHub; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; let dir = TempDir::new().unwrap(); let params = ChainParams::regtest(); @@ -1670,8 +1671,9 @@ async fn electrum_and_esplora_asof_hides_later_spend() { use bitcoin::script::ScriptBuf; use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; - use rbitcoin_consensus::{accept_and_connect_block, Milestone}; + use rbitcoin_consensus::Milestone; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; const ASOF: &str = "1.4.2-asof"; let dir = TempDir::new().unwrap(); @@ -2230,8 +2232,9 @@ async fn electrum_tweaks_subscribe_streams_then_done() { use bitcoin::hashes::{hash160, Hash}; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; - use rbitcoin_consensus::{accept_and_connect_block, tweak_from_tx, Milestone}; + use rbitcoin_consensus::{tweak_from_tx, Milestone}; use rbitcoin_primitives::{Fk, Height}; + use rbitcoin_query::accept_and_connect_block; use rbitcoin_query::testutil::FixtureChain; use rbitcoin_query::TxApply; use rbitcoin_store::{HeaderRecord, InputRecord, OutputRecord, TxRecord}; @@ -2587,8 +2590,9 @@ fn pin_tip_follow_after_materialize( tip: bitcoin::BlockHash, time: u32, ) { - use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; use rbitcoin_test::mine::mine_regtest_block; let params = ChainParams::regtest(); @@ -2628,8 +2632,9 @@ fn pin_tip_follow_after_materialize( #[test] fn direct_indexes_then_sh_bulk_at_tip() { use bitcoin::hashes::Hash; - use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::Height; + use rbitcoin_query::accept_and_connect_block; use rbitcoin_test::mine::{mine_regtest_block, regtest_genesis}; let dir = TempDir::new().unwrap(); diff --git a/crates/rbitcoin-test/tests/integration_multinode.rs b/crates/rbitcoin-test/tests/integration_multinode.rs index a2e8575ce3..90f9fafe61 100644 --- a/crates/rbitcoin-test/tests/integration_multinode.rs +++ b/crates/rbitcoin-test/tests/integration_multinode.rs @@ -66,7 +66,7 @@ fn padded_query_pad() -> &'static PaddedQueryPad { use std::sync::OnceLock; static PAD: OnceLock = OnceLock::new(); PAD.get_or_init(|| { - use rbitcoin_consensus::accept_and_connect_block; + use rbitcoin_query::accept_and_connect_block; use rbitcoin_test::pad_empty_from; let keep = TempDir::labeled("padded-query").expect("padded query pad"); @@ -877,7 +877,7 @@ fn mine_on(node: &P2PNode, height: u32) -> BlockHash { #[test] fn padded_query_copy_keeps_the_other_tip_and_filter_hwm() { - use rbitcoin_consensus::accept_and_connect_block; + use rbitcoin_query::accept_and_connect_block; let a = TempDir::new().unwrap(); let b = TempDir::new().unwrap(); @@ -1920,7 +1920,7 @@ async fn tip_follow_after_ibd() { peer.query .set_sptweaks_enabled(true, Height(ChainParams::regtest().taproot_height())) .unwrap(); - rbitcoin_consensus::prepare_live_indexes(&peer.query).unwrap(); + rbitcoin_query::prepare_live_indexes(&peer.query).unwrap(); sync_ibd(&peer, seed.local_addr).await; peer.wait_height(5, Duration::from_secs(10)) .await @@ -1941,7 +1941,7 @@ async fn tip_follow_after_ibd() { let spawn_builder = || { let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); let (q, at) = (Arc::clone(&pq), Arc::clone(&caught_up_at)); - let h = rbitcoin_consensus::spawn_index_writebehind( + let h = rbitcoin_query::spawn_index_writebehind( Arc::clone(&pq), Arc::clone(&stop), || {}, @@ -2103,7 +2103,7 @@ async fn reorg_to_longer_branch() { hub.accept_block(b).unwrap(); } assert_eq!(hub.tip_height(), Some(4)); - rbitcoin_consensus::build_indexes_released(&hub.query).unwrap(); + rbitcoin_query::build_indexes_released(&hub.query).unwrap(); assert_eq!(hub.query.basic_filter_hwm().unwrap(), Some(4)); let old_3 = hub.query.basic_filter_at(3).unwrap().unwrap().0; @@ -2136,7 +2136,7 @@ async fn reorg_to_longer_branch() { assert!(matches!(outcome, AcceptOutcome::Accepted { height: 6 })); assert_eq!(hub.tip_height(), Some(6)); assert_eq!(hub.tip_hash().unwrap(), branch.last().unwrap().block_hash()); - rbitcoin_consensus::build_indexes_released(&hub.query).unwrap(); + rbitcoin_query::build_indexes_released(&hub.query).unwrap(); assert_eq!(hub.query.basic_filter_hwm().unwrap(), Some(6)); for (h, b) in (3..).zip(&branch) { assert_eq!( @@ -2664,8 +2664,9 @@ fn pin_held_sixteen_vs_seventeen( /// longer side branch (tip-mode accept path, not IBD body-queue). #[test] fn reorg_same_height_then_multi_block_branch() { - use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; + use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_net::{AcceptOutcome, ChainHub}; + use rbitcoin_query::accept_and_connect_block; use rbitcoin_test::pad_empty_from; let dir = TempDir::new().unwrap(); diff --git a/crates/rbitcoin-test/tests/scenarios.rs b/crates/rbitcoin-test/tests/scenarios.rs index a225f53a19..41a6e7b38d 100644 --- a/crates/rbitcoin-test/tests/scenarios.rs +++ b/crates/rbitcoin-test/tests/scenarios.rs @@ -7,9 +7,10 @@ use bitcoin::consensus::encode::deserialize; use bitcoin::hashes::Hash; use bitcoin::{Amount, Block, BlockHash}; use rbitcoin_cli::cli_main as cli_cli_main; -use rbitcoin_consensus::{accept_and_connect_block, genesis_block, ChainParams, Milestone}; +use rbitcoin_consensus::{genesis_block, ChainParams, Milestone}; use rbitcoin_node::{cli_main as node_cli_main, run_node, NodeConfig}; use rbitcoin_primitives::{Fk, Height, Network, VERSION}; +use rbitcoin_query::accept_and_connect_block; use rbitcoin_query::testutil::FixtureChain; use rbitcoin_query::{stamp_external_parents, InFlight, Query}; use rbitcoin_store::{HeaderRecord, Store, StoreError, TxRecord}; @@ -1165,9 +1166,8 @@ fn chain_connect_reorg_and_growth() { /// false-positive PrevoutSpent (tip is the Class C commit point). #[test] fn confirm_survives_partial_class_c_without_tip_advance() { - use rbitcoin_consensus::{ - accept_and_connect_block, commit_class_a_block, confirm_wire_run, ChainParams, Milestone, - }; + use rbitcoin_consensus::{ChainParams, Milestone}; + use rbitcoin_query::{accept_and_connect_block, commit_class_a_block, confirm_wire_run}; use rbitcoin_test::mine::{mine_regtest_block, regtest_genesis, spend_anyone_can_spend}; let td = TestDatadir::new().unwrap(); @@ -1317,7 +1317,7 @@ fn pin_head_parent_via_waves_and_leftover_stamp( spend_h: u32, ) { use bitcoin::consensus::encode::serialize; - use rbitcoin_consensus::{ + use rbitcoin_query::{ confirm_bq_resolve_wave_capped, confirm_wire_lookup_stamp, take_wave_items_for_load, WireLoadPipeline, BQ_RESOLVE_WAVE_MAX_BLOCKS, BQ_RESOLVE_WAVE_MAX_INPUTS, }; @@ -1422,7 +1422,8 @@ fn pin_plan_none_stamp_of_archived_spend( b_spend: &Block, spend_h: u32, ) { - use rbitcoin_consensus::{confirm_wire_lookup_stamp, ConsensusError}; + use rbitcoin_consensus::ConsensusError; + use rbitcoin_query::confirm_wire_lookup_stamp; let hash = b_spend.block_hash().to_byte_array(); let (hfk, _) = q.get_header_by_hash(&hash).unwrap().unwrap(); @@ -1449,7 +1450,7 @@ fn pin_plan_none_stamp_of_archived_spend( let n = fks.len() as u32; q.store().header_txs.put_range(hfk, fks[0], n + 1).unwrap(); match confirm_wire_lookup_stamp(q, params, ms, &items, None) { - Err(ConsensusError::Store(StoreError::Corrupt(m))) => { + Err(rbitcoin_query::ConfirmError::Store(StoreError::Corrupt(m))) => { assert_eq!(m, "invariant: archived stamp tx_fks/txids length"); } Err(e) => panic!("list/wire length mismatch must be Corrupt, got {e}"), @@ -1461,9 +1462,8 @@ fn pin_plan_none_stamp_of_archived_spend( /// Resume: spend archived with create_fk (archive sticky/head); confirm spends. #[test] fn resume_tx_head_resolves_external_prev() { - use rbitcoin_consensus::{ - accept_and_connect_block, commit_class_a_block, confirm_wire_run, ChainParams, Milestone, - }; + use rbitcoin_consensus::{ChainParams, Milestone}; + use rbitcoin_query::{accept_and_connect_block, commit_class_a_block, confirm_wire_run}; use rbitcoin_test::mine::{mine_regtest_block, regtest_genesis, spend_anyone_can_spend}; let td = TestDatadir::new().unwrap(); @@ -1562,8 +1562,8 @@ fn resume_tx_head_resolves_external_prev() { #[test] fn consensus_mature_chain_spend_reconstruct_and_scripthash() { use bitcoin::p2p::ServiceFlags; - use rbitcoin_consensus::{commit_class_a_block, confirm_wire_run}; use rbitcoin_net::local_service_flags; + use rbitcoin_query::{commit_class_a_block, confirm_wire_run}; use rbitcoin_store::script_hash; let td = TestDatadir::new().unwrap(); @@ -1846,10 +1846,9 @@ fn consensus_mature_chain_spend_reconstruct_and_scripthash() { assert!(flags.has(ServiceFlags::WITNESS)); // Consensus header helpers on the same pad (no second open). - let mtp = rbitcoin_consensus::median_time_past(&q, Height(sample_tip)).unwrap(); + let mtp = rbitcoin_query::median_time_past(&q, Height(sample_tip)).unwrap(); assert!(mtp > 0, "mtp={mtp}"); - let bits = - rbitcoin_consensus::expected_next_bits(&q, ¶ms, Height(sample_tip + 1), 0).unwrap(); + let bits = rbitcoin_query::expected_next_bits(&q, ¶ms, Height(sample_tip + 1), 0).unwrap(); let tip_bits = q .header_at_height(Height(sample_tip)) .unwrap() @@ -1879,7 +1878,7 @@ fn pin_abandoned_fork_is_not_a_parent( chain: &rbitcoin_test::MatureRegtestChain, ) { use bitcoin::consensus::encode::serialize; - use rbitcoin_consensus::{ + use rbitcoin_query::{ confirm_bq_resolve_wave_capped, take_wave_items_for_load, BQ_RESOLVE_WAVE_MAX_BLOCKS, BQ_RESOLVE_WAVE_MAX_INPUTS, }; @@ -1953,7 +1952,7 @@ fn pin_archived_sibling_bodies( ) { use bitcoin::consensus::encode::serialize; use bitcoin::{ScriptBuf, TxOut}; - use rbitcoin_consensus::commit_class_a_block; + use rbitcoin_query::commit_class_a_block; let tip_h = chain.tip_height(); let bad_hash = double_spend.block_hash().to_byte_array(); @@ -2010,7 +2009,7 @@ fn pin_reconnect_archived_run_extends_height_index( blocks: &[Block], tip_h: u32, ) { - use rbitcoin_consensus::confirm_wire_run; + use rbitcoin_query::confirm_wire_run; let from_h = q.tip_height().unwrap().0; assert_eq!( @@ -2213,7 +2212,7 @@ fn pin_scripthash_views_on_pad( #[allow(clippy::cognitive_complexity)] // one pad, many block/tx surface arms fn pin_block_and_tx_surface_on_pad(q: &Query, chain: &rbitcoin_test::MatureRegtestChain) { use bitcoin::consensus::encode::serialize; - use rbitcoin_consensus::header_to_record; + use rbitcoin_query::header_to_record; use rbitcoin_query::ConfirmPrepared; let spend_h = Height(chain.spend_height); @@ -2347,7 +2346,7 @@ fn pin_same_run_create_then_spend( parent_cb: bitcoin::Txid, create_h: u32, ) -> (BlockHash, u32) { - use rbitcoin_consensus::{commit_class_a_run, confirm_wire_run}; + use rbitcoin_query::{commit_class_a_run, confirm_wire_run}; let mk_parent = spend_anyone_can_spend(parent_cb, 0, Amount::from_sat(49_0000_0000)); let b_create = mine_regtest_block(tip, tip_time + 600, create_h, vec![mk_parent]); @@ -2387,7 +2386,7 @@ fn pin_both_vouts_of_one_input_parent( use bitcoin::absolute::LockTime; use bitcoin::transaction::Version as TxVersion; use bitcoin::{OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; - use rbitcoin_consensus::{commit_class_a_block, commit_class_a_run, confirm_wire_run}; + use rbitcoin_query::{commit_class_a_block, commit_class_a_run, confirm_wire_run}; use rbitcoin_test::mine::{spend_many_anyone_can_spend, split_anyone_can_spend}; let split = split_anyone_can_spend( @@ -2577,9 +2576,10 @@ fn pin_already_at_height_retry_annotates_spend( /// Also exercises parent pin stats + tip advance, and load ready timeout/cancel. #[test] fn three_stage_confirm_and_parent_pin_surface() { - use rbitcoin_consensus::{ + use rbitcoin_consensus::{ChainParams, Milestone}; + use rbitcoin_query::{ accept_and_connect_block, commit_class_a_run, confirm_scripts_phase, - confirm_wire_load_phase, confirm_write_phase, ChainParams, Milestone, ScriptPreverified, + confirm_wire_load_phase, confirm_write_phase, ScriptPreverified, }; use rbitcoin_test::mine::{mine_regtest_block, regtest_genesis, spend_anyone_can_spend}; @@ -2711,10 +2711,11 @@ fn three_stage_confirm_and_parent_pin_surface() { /// from the prior load batch. Mixed store(≤tip)+plan(>tip) must succeed. #[test] fn confirm_load_ahead_of_write_does_not_badprev() { - use rbitcoin_consensus::{ + use rbitcoin_consensus::{ChainParams, Milestone}; + use rbitcoin_query::{ accept_and_connect_block, commit_class_a_block, confirm_scripts_phase, confirm_wire_load_phase, confirm_wire_load_phase_pipelined, confirm_write_phase, - ChainParams, Milestone, ScriptPreverified, WireLoadPipeline, + ScriptPreverified, WireLoadPipeline, }; use rbitcoin_test::mine::{mine_regtest_block, regtest_genesis}; @@ -2928,9 +2929,10 @@ fn block_cache_and_mempool_hub_surface() { /// Multi-height raw wire → tip via split load/scripts/commit (no pre-archive reload). #[test] fn unified_wire_pipeline_multi_block_to_tip() { - use rbitcoin_consensus::{ + use rbitcoin_consensus::{ChainParams, Milestone}; + use rbitcoin_query::{ commit_class_a_block, confirm_scripts_phase, confirm_wire_load_phase, confirm_wire_run, - confirm_write_phase, ChainParams, Milestone, ScriptPreverified, + confirm_write_phase, ScriptPreverified, }; let td = TestDatadir::new().unwrap(); @@ -3049,8 +3051,8 @@ fn pin_confirm_refuses_without_advancing_tip( ms: Milestone, b1: &Block, ) { - use rbitcoin_consensus::{confirm_wire_load_phase, header_to_record, ScriptPreverified}; use rbitcoin_query::ConfirmPrepared; + use rbitcoin_query::{confirm_wire_load_phase, header_to_record, ScriptPreverified}; let none = ScriptPreverified::new(); let g = regtest_genesis(); @@ -3102,7 +3104,7 @@ fn pin_bq_wave_then_stamp_confirms_empty_block( tip_time: u32, ) { use bitcoin::consensus::encode::serialize; - use rbitcoin_consensus::{ + use rbitcoin_query::{ confirm_bq_resolve_wave_capped, confirm_scripts_phase, confirm_wire_load_from_plan, confirm_wire_lookup_stamp, confirm_write_phase, take_wave_items_for_load, ScriptPreverified, BQ_RESOLVE_WAVE_MAX_BLOCKS, BQ_RESOLVE_WAVE_MAX_INPUTS, @@ -3147,7 +3149,7 @@ fn pin_wire_prep_ahead_cross_batch( cb: bitcoin::Txid, ha: u32, ) -> (BlockHash, u32) { - use rbitcoin_consensus::{ + use rbitcoin_query::{ confirm_scripts_phase, confirm_wire_load_phase_pipelined, confirm_write_phase, ScriptPreverified, WireLoadPipeline, }; @@ -3294,7 +3296,7 @@ fn pin_wire_prep_already_archived( cb: bitcoin::Txid, ha: u32, ) -> (BlockHash, u32) { - use rbitcoin_consensus::{ + use rbitcoin_query::{ commit_class_a_run, confirm_scripts_phase, confirm_wire_load_phase, confirm_write_phase, ScriptPreverified, }; @@ -3346,7 +3348,7 @@ fn pin_wire_prep_cold_class_a_denserels( cb: bitcoin::Txid, h_split: u32, ) { - use rbitcoin_consensus::{ + use rbitcoin_query::{ confirm_scripts_phase, confirm_wire_load_phase, confirm_write_phase, ScriptPreverified, }; use rbitcoin_test::mine::split_anyone_can_spend; @@ -3361,8 +3363,7 @@ fn pin_wire_prep_cold_class_a_denserels( ); let b_split = mine_regtest_block(tip, tip_time + 600, h_split, vec![split]); let parent_txid = b_split.txdata[1].compute_txid(); - rbitcoin_consensus::confirm_wire_run(q, params, ms, &[(Height(h_split), b_split.clone())]) - .unwrap(); + rbitcoin_query::confirm_wire_run(q, params, ms, &[(Height(h_split), b_split.clone())]).unwrap(); assert!( q.store() .get_fk_by_txid(parent_txid.as_byte_array()) diff --git a/docs/CRATES.md b/docs/CRATES.md index 7c5e592cfc..e7652c68da 100644 --- a/docs/CRATES.md +++ b/docs/CRATES.md @@ -9,8 +9,8 @@ shared libraries through storage and runtime crates to composition and tools; | `rbitcoin-primitives` | Shared Bitcoin types and newtypes used across the workspace | | `rbitcoin-log` | Leveled stderr logging; live progress of long stages (`progress`) | | `rbitcoin-store` | Relational archive and Class A/B/C on-disk tables | -| `rbitcoin-query` | Archive, confirm, reconstruction, and query APIs over the store | -| `rbitcoin-consensus` | Header, block, and script validation | +| `rbitcoin-query` | Archive, confirm pipeline, reconstruction, and query APIs over the store. Loads rows, writes Class A / Class C / spend annotations, and calls `rbitcoin-consensus` for rule decisions | +| `rbitcoin-consensus` | Header, block, and script rules from bytes and caller facts. Does not open a store | | `rbitcoin-mempool` | Live transaction graph and admission policy | | `rbitcoin-net` | P2P, IBD, tip follow, and transaction relay | | `rbitcoin-rpc` | Core-class JSON-RPC subset | diff --git a/docs/invariants.md b/docs/invariants.md index 4af30cb22a..935d17a425 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -153,8 +153,8 @@ packs at/above the leaving **pack** height **before** the next bind. ## Related code -- Confirm write annotate / ensure: `rbitcoin-consensus` `confirm_run::{post_commit,collect_spend_abs_after_fill,ensure_spend_abs_layouts,pin_for_wire_batch}` -- Structural: `rbitcoin-consensus` `block::structural_validate_spends` +- Confirm write annotate / ensure: `rbitcoin-query` `confirm_run::{post_commit,collect_spend_abs_after_fill,ensure_spend_abs_layouts,pin_for_wire_batch}` +- Structural: `rbitcoin-query` `block_pipeline::structural_validate_spends` (rule decisions it calls live in `rbitcoin-consensus` `block::facts`) - Pin / denserels: `rbitcoin-query` `confirm_load`, `BatchParents`, `pin_for_wire_batch` (cold range / adopt) - Abs annotate: `rbitcoin-store` `put_spend_batch_by_abs_meta` diff --git a/fuzz/src/chain_review.rs b/fuzz/src/chain_review.rs index b6bc919cb7..7780dc1f3f 100644 --- a/fuzz/src/chain_review.rs +++ b/fuzz/src/chain_review.rs @@ -14,11 +14,12 @@ use bitcoin::{ TxOut, Witness, }; use rbitcoin_consensus::{ - block_subsidy, confirm_scripts_phase, genesis_block, grind_regtest_pow, mine_empty_regtest, - mine_regtest_paying, ChainParams, Milestone, REGTEST_BLOCK_SPACING, REGTEST_POW_BITS, + block_subsidy, genesis_block, grind_regtest_pow, mine_empty_regtest, mine_regtest_paying, + ChainParams, Milestone, REGTEST_BLOCK_SPACING, REGTEST_POW_BITS, }; use rbitcoin_net::{AcceptOutcome, ChainHub, NetError}; use rbitcoin_primitives::Height; +use rbitcoin_query::confirm_scripts_phase; use crate::block_diff::{BlockOracle, BlockStanding, OracleReply}; From 6bdd79e85c19601558200bc68396e1713e4fed71 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:19:17 -0700 Subject: [PATCH 05/12] consensus: drop test-only symbols from the query lib expected_bits_extending is only called from confirm tests. ConsensusError imports that tests match on stay inside those test modules so a non-test build of query stays warning-clean. --- crates/rbitcoin-query/src/confirm_run/mod.rs | 2 +- crates/rbitcoin-query/src/confirm_run/phases.rs | 2 ++ crates/rbitcoin-query/src/connect_facade.rs | 6 ++++-- crates/rbitcoin-query/src/convert.rs | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/rbitcoin-query/src/confirm_run/mod.rs b/crates/rbitcoin-query/src/confirm_run/mod.rs index d7e5f4027a..6dfd8bdec0 100644 --- a/crates/rbitcoin-query/src/confirm_run/mod.rs +++ b/crates/rbitcoin-query/src/confirm_run/mod.rs @@ -34,7 +34,7 @@ use bitcoin::hashes::Hash; use bitcoin::Block; use rbitcoin_consensus::block::{block_has_witness_from_pres, ScriptCheckJob, ValidationContext}; use rbitcoin_consensus::header::pow_hash_meets_target; -use rbitcoin_consensus::{genesis_block, ChainParams, ConsensusError, Milestone}; +use rbitcoin_consensus::{ChainParams, ConsensusError, Milestone}; use rbitcoin_primitives::Height; use rbitcoin_store::{StoreError, WriteIoBackend}; use std::sync::atomic::Ordering; diff --git a/crates/rbitcoin-query/src/confirm_run/phases.rs b/crates/rbitcoin-query/src/confirm_run/phases.rs index 9e84f8a38a..ac084abb4a 100644 --- a/crates/rbitcoin-query/src/confirm_run/phases.rs +++ b/crates/rbitcoin-query/src/confirm_run/phases.rs @@ -463,6 +463,7 @@ pub(super) fn post_commit( Ok(spend_ann_ns) } +#[cfg(test)] pub(super) fn expected_bits_extending( query: &Query, params: &ChainParams, @@ -471,6 +472,7 @@ pub(super) fn expected_bits_extending( prev_time: u32, header_time: u32, ) -> Result { + use rbitcoin_consensus::genesis_block; if height.0 == 0 { return Ok(genesis_block(params).header.bits); } diff --git a/crates/rbitcoin-query/src/connect_facade.rs b/crates/rbitcoin-query/src/connect_facade.rs index b5a39f4f47..6d28e0b2d8 100644 --- a/crates/rbitcoin-query/src/connect_facade.rs +++ b/crates/rbitcoin-query/src/connect_facade.rs @@ -4,7 +4,7 @@ use crate::ConfirmError; use bitcoin::hashes::Hash; use bitcoin::Block; use rbitcoin_consensus::block::{validate_block_structure_hashed, ValidationContext}; -use rbitcoin_consensus::{ChainParams, ConsensusError, Milestone}; +use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::{Fk, Height}; use rbitcoin_store::HeaderRecord; use std::sync::Arc; @@ -214,7 +214,9 @@ mod coverage_tests { use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, OutPoint, Sequence, Target, Transaction, TxIn, TxOut, Witness}; use rbitcoin_consensus::block::ScriptCheckJob; - use rbitcoin_consensus::{genesis_block, mine_empty_regtest, ChainParams, Milestone}; + use rbitcoin_consensus::{ + genesis_block, mine_empty_regtest, ChainParams, ConsensusError, Milestone, + }; use rbitcoin_primitives::Height; use std::path::PathBuf; fn temp_store() -> (PathBuf, Query) { diff --git a/crates/rbitcoin-query/src/convert.rs b/crates/rbitcoin-query/src/convert.rs index ff2e9c1d14..f19cf16e67 100644 --- a/crates/rbitcoin-query/src/convert.rs +++ b/crates/rbitcoin-query/src/convert.rs @@ -3,7 +3,6 @@ use crate::{Query, TxApply}; use bitcoin::block::Header; use bitcoin::hashes::Hash; use bitcoin::Transaction; -use rbitcoin_consensus::ConsensusError; use rbitcoin_primitives::Fk; use rbitcoin_store::{HeaderRecord, InputRecord, OutputRecord, TxRecord}; @@ -128,6 +127,7 @@ mod tests { Amount, BlockHash, CompactTarget, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxMerkleNode, TxOut, Witness, }; + use rbitcoin_consensus::ConsensusError; #[test] fn header_to_record_stores_caller_hash() { From c97502ea5519d99b7b5fd85fbee5e405747c2a8d Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:19:18 -0700 Subject: [PATCH 06/12] consensus: keep pipeline faults on ConfirmError Query callers match ConfirmError::Rules around a rule reject. A live index invariant stays StoreError::Corrupt, so it is not a cached block failure. Clippy: eight caller facts on check_header, an elided prevout lifetime, and JobPrevouts::is_empty. --- Cargo.lock | 1 + crates/rbitcoin-consensus/src/block/facts.rs | 6 +- crates/rbitcoin-consensus/src/block/mod.rs | 4 + crates/rbitcoin-consensus/src/header.rs | 1 + .../rbitcoin-query/src/confirm_run/index.rs | 3 +- .../rbitcoin-query/src/confirm_run/phases.rs | 8 +- crates/rbitcoin-query/src/header_read.rs | 2 - crates/rbitcoin-test/tests/consensus_rules.rs | 89 ++++++++++++------- crates/rbitcoin-test/tests/core_analogs.rs | 8 +- crates/rbitcoin-test/tests/scenarios.rs | 5 +- 10 files changed, 80 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13f3f50b33..c2f0819795 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -910,6 +910,7 @@ dependencies = [ "bitcoin_hashes 0.14.101", "mutants", "rbitcoin-primitives", + "rbitcoin-store", "ruzstd", "serde_json", ] diff --git a/crates/rbitcoin-consensus/src/block/facts.rs b/crates/rbitcoin-consensus/src/block/facts.rs index dcd992b4ab..c2b45fac85 100644 --- a/crates/rbitcoin-consensus/src/block/facts.rs +++ b/crates/rbitcoin-consensus/src/block/facts.rs @@ -282,10 +282,10 @@ pub fn reject_vout_past_n_out(max_vout: u32, n_out: Option) -> Result<(), C } } -pub fn require_same_block_output<'a>( - tx: Option<&'a Transaction>, +pub fn require_same_block_output( + tx: Option<&Transaction>, vout: usize, -) -> Result<&'a bitcoin::TxOut, ConsensusError> { +) -> Result<&bitcoin::TxOut, ConsensusError> { let tx = tx.ok_or(ConsensusError::MissingPrevout)?; tx.output.get(vout).ok_or(ConsensusError::MissingPrevout) } diff --git a/crates/rbitcoin-consensus/src/block/mod.rs b/crates/rbitcoin-consensus/src/block/mod.rs index 0109b6fd45..7143cd0e9f 100644 --- a/crates/rbitcoin-consensus/src/block/mod.rs +++ b/crates/rbitcoin-consensus/src/block/mod.rs @@ -910,6 +910,10 @@ impl JobPrevouts { Self::Shared(v) => v.len(), } } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } } /// Script-verify job for one non-coinbase create. diff --git a/crates/rbitcoin-consensus/src/header.rs b/crates/rbitcoin-consensus/src/header.rs index 997109c9fb..be6520f17f 100644 --- a/crates/rbitcoin-consensus/src/header.rs +++ b/crates/rbitcoin-consensus/src/header.rs @@ -20,6 +20,7 @@ pub struct ParentHeader { /// `mtp_times` ends at the parent (up to 11 timestamps). `period_first_time` /// is the header `interval` blocks before a retarget height. `bits_at` supplies /// earlier `nBits` for the testnet min-difficulty walk. +#[allow(clippy::too_many_arguments)] // each argument is one caller fact pub fn check_header( params: &ChainParams, height: Height, diff --git a/crates/rbitcoin-query/src/confirm_run/index.rs b/crates/rbitcoin-query/src/confirm_run/index.rs index a85c1b8c79..04a9f45d8d 100644 --- a/crates/rbitcoin-query/src/confirm_run/index.rs +++ b/crates/rbitcoin-query/src/confirm_run/index.rs @@ -19,6 +19,7 @@ use rbitcoin_primitives::{Fk, Height}; use crate::index_rows::{self, IndexRows}; use rbitcoin_consensus::silent_payments::{is_p2tr, tweak_from_tx}; use rbitcoin_consensus::ConsensusError; +use rbitcoin_store::StoreError; use super::{LoadedBatch, Prepared}; @@ -368,7 +369,7 @@ pub(super) fn seal_live_indexes(query: &Query, seal: IndexSeal) -> Result ConfirmError { - ConfirmError::Rules(ConsensusError::Internal(msg)) + ConfirmError::Store(StoreError::Corrupt(msg)) } #[cfg(test)] diff --git a/crates/rbitcoin-query/src/confirm_run/phases.rs b/crates/rbitcoin-query/src/confirm_run/phases.rs index ac084abb4a..6be5ea96d7 100644 --- a/crates/rbitcoin-query/src/confirm_run/phases.rs +++ b/crates/rbitcoin-query/src/confirm_run/phases.rs @@ -41,11 +41,9 @@ fn assemble_parent_mtp_and_bits( }); } } - let parent = parent.ok_or_else(|| { - ConfirmError::Store(StoreError::Corrupt( - "confirm: load incomplete (parent header plan missing above tip)", - )) - })?; + let parent = parent.ok_or(ConfirmError::Store(StoreError::Corrupt( + "confirm: load incomplete (parent header plan missing above tip)", + )))?; let period_first = retarget_first_time(query, params, height.0)?; rbitcoin_consensus::check_header( params, diff --git a/crates/rbitcoin-query/src/header_read.rs b/crates/rbitcoin-query/src/header_read.rs index 2dc9c1c7e6..8495b4cfba 100644 --- a/crates/rbitcoin-query/src/header_read.rs +++ b/crates/rbitcoin-query/src/header_read.rs @@ -73,7 +73,6 @@ pub fn validate_header_hashed( /// Contextual checks when the parent is a known header, not necessarily on the /// best chain (`header_at_height` would miss). - pub fn median_time_past(query: &Query, height: Height) -> Result { let times = mtp_times_ending_at(query, height)?; rbitcoin_consensus::block::median_or_bad_header(×).map_err(ConfirmError::from) @@ -167,7 +166,6 @@ pub fn expected_next_bits( /// `period_first_time` is the timestamp of the header at `height - interval` /// when `height` is a retarget boundary. `bits_at` supplies earlier `nBits` /// for the testnet min-difficulty walk. `None` means an ancestor is missing. - pub fn period_first_time( query: &Query, params: &ChainParams, diff --git a/crates/rbitcoin-test/tests/consensus_rules.rs b/crates/rbitcoin-test/tests/consensus_rules.rs index 0da9b5761f..02aa46d84b 100644 --- a/crates/rbitcoin-test/tests/consensus_rules.rs +++ b/crates/rbitcoin-test/tests/consensus_rules.rs @@ -6,10 +6,9 @@ use bitcoin::hashes::Hash; use bitcoin::{Amount, BlockHash, CompactTarget}; use rbitcoin_consensus::{genesis_block, ChainParams, ConsensusError, Milestone}; use rbitcoin_primitives::Height; -use rbitcoin_query::Query; use rbitcoin_query::{ accept_and_connect_block, confirm_wire_run, expected_next_bits, median_time_past, - validate_header, + validate_header, ConfirmError, Query, }; use rbitcoin_test::mine::{mine_regtest_block, regtest_genesis, spend_anyone_can_spend}; use rbitcoin_test::{pad_empty_from, TestDatadir}; @@ -158,7 +157,7 @@ fn pin_h6_pow_limit(q: &Query, params: &ChainParams, genesis: &bitcoin::Block) { tight.pow_limit = ChainParams::mainnet().pow_limit; let err = validate_header(q, &tight, Height(1), &near.header).unwrap_err(); assert!( - matches!(err, ConsensusError::BadHeader(s) if s.contains("pow limit")), + matches!(err, ConfirmError::Rules(ConsensusError::BadHeader(s)) if s.contains("pow limit")), "h6: {err:?}" ); } @@ -192,7 +191,7 @@ fn pin_bip68_time_lock( let early = mine_regtest_block(tip, time + 600, early_h, vec![csv_time.clone()]); let err = accept_and_connect_block(q, params, Height(early_h), &early, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::BadTx(s)) if s.contains("nonfinal")), + matches!(err, Err(ConfirmError::Rules(ConsensusError::BadTx(s))) if s.contains("nonfinal")), "bip68 time just short: {err:?}" ); @@ -237,7 +236,7 @@ fn h8_rejects_timestamp_too_far_in_future() { } let err = validate_header(&q, ¶ms, Height(1), &bad.header).unwrap_err(); assert!( - matches!(err, ConsensusError::BadHeader(s) if s.contains("future")), + matches!(err, ConfirmError::Rules(ConsensusError::BadHeader(s)) if s.contains("future")), "{err:?}" ); } @@ -266,7 +265,7 @@ fn pin_subsidy_interval_two_overlay() { let old_floor = mine_regtest_block(h1.block_hash(), h1.header.time + 599, 2, vec![]); let err = accept_and_connect_block(&q, ¶ms, Height(2), &old_floor, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::BadBlock(s)) if s.contains("coinbase excess")), + matches!(err, Err(ConfirmError::Rules(ConsensusError::BadBlock(s))) if s.contains("coinbase excess")), "50 BTC at interval must exceed the new 25 BTC floor: {err:?}" ); @@ -276,7 +275,7 @@ fn pin_subsidy_interval_two_overlay() { grind_pow(&mut excess); let err = accept_and_connect_block(&q, ¶ms, Height(2), &excess, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::BadBlock(s)) if s.contains("coinbase excess")), + matches!(err, Err(ConfirmError::Rules(ConsensusError::BadBlock(s))) if s.contains("coinbase excess")), "subsidy+1 at new floor: {err:?}" ); @@ -313,7 +312,7 @@ fn h7_rejects_header_hash_above_target() { } let err = validate_header(&q, ¶ms, Height(1), &h).unwrap_err(); assert!( - matches!(err, ConsensusError::InvalidPow), + matches!(err, ConfirmError::Rules(ConsensusError::InvalidPow)), "expected InvalidPow, got {err:?}" ); } @@ -358,7 +357,7 @@ fn coinbase_maturity_holds_inside_one_confirm_batch() { assert_eq!(q.tip_height(), Some(Height(spend_h))); } else { assert!( - matches!(res, Err(ConsensusError::BadTx(s)) if s.contains("immature")), + matches!(res, Err(ConfirmError::Rules(ConsensusError::BadTx(s))) if s.contains("immature")), "coinbase spent at created+99 in the same batch: {res:?}" ); assert_eq!(q.tip_height(), Some(Height::GENESIS)); @@ -403,7 +402,10 @@ fn same_batch_spend_of_a_later_block_is_missing() { ], ); assert!( - matches!(err, Err(ConsensusError::MissingPrevout)), + matches!( + err, + Err(ConfirmError::Rules(ConsensusError::MissingPrevout)) + ), "spend of an output created one block later in the batch: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(100))); @@ -411,7 +413,10 @@ fn same_batch_spend_of_a_later_block_is_missing() { // Both bodies are archived now, and the parent is not connected. let err = confirm_wire_run(&q, ¶ms, Milestone::NONE, &[(Height(101), child_first)]); assert!( - matches!(err, Err(ConsensusError::MissingPrevout)), + matches!( + err, + Err(ConfirmError::Rules(ConsensusError::MissingPrevout)) + ), "the spending block retried alone: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(100))); @@ -432,7 +437,7 @@ fn same_batch_spend_of_a_later_block_is_missing() { let b103 = mine_regtest_block(b102_hash, time + 1800, 103, vec![again]); let err = confirm_wire_run(&q, ¶ms, Milestone::NONE, &[(Height(103), b103)]); assert!( - matches!(err, Err(ConsensusError::PrevoutSpent)), + matches!(err, Err(ConfirmError::Rules(ConsensusError::PrevoutSpent))), "second spend of the parent output: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(102))); @@ -452,7 +457,7 @@ fn header_and_spending_boundaries() { bad_g.header.nonce = g.header.nonce.wrapping_add(1); let err = validate_header(&q, ¶ms, Height::GENESIS, &bad_g.header).unwrap_err(); assert!( - matches!(err, ConsensusError::BadHeader(s) if s.contains("genesis")), + matches!(err, ConfirmError::Rules(ConsensusError::BadHeader(s)) if s.contains("genesis")), "h1: {err:?}" ); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &g, Milestone::NONE).unwrap(); @@ -473,7 +478,9 @@ fn header_and_spending_boundaries() { assert!( matches!( err, - Err(ConsensusError::BadBlock("unexpected witness before segwit")) + Err(ConfirmError::Rules(ConsensusError::BadBlock( + "unexpected witness before segwit" + ))) ), "pre-activation witness block must hit the SegWit gate: {err:?}" ); @@ -482,14 +489,17 @@ fn header_and_spending_boundaries() { bad_prev.header.prev_blockhash = BlockHash::from_byte_array([0xee; 32]); grind_pow(&mut bad_prev); let err = validate_header(&q, ¶ms, Height(1), &bad_prev.header).unwrap_err(); - assert!(matches!(err, ConsensusError::BadPrev), "h2: {err:?}"); + assert!( + matches!(err, ConfirmError::Rules(ConsensusError::BadPrev)), + "h2: {err:?}" + ); let mut bad_bits = mine_regtest_block(g.block_hash(), g.header.time + 600, 1, vec![]); bad_bits.header.bits = CompactTarget::from_consensus(0x207f_fffe); grind_pow(&mut bad_bits); let err = validate_header(&q, ¶ms, Height(1), &bad_bits.header).unwrap_err(); assert!( - matches!(err, ConsensusError::BadHeader(s) if s.contains("bits") || s.contains("proof")), + matches!(err, ConfirmError::Rules(ConsensusError::BadHeader(s)) if s.contains("bits") || s.contains("proof")), "h5: {err:?}" ); @@ -514,7 +524,7 @@ fn header_and_spending_boundaries() { grind_pow(&mut eq); let err = validate_header(&q11, ¶ms, Height(12), &eq.header).unwrap_err(); assert!( - matches!(err, ConsensusError::BadHeader(s) if s.contains("median-time")), + matches!(err, ConfirmError::Rules(ConsensusError::BadHeader(s)) if s.contains("median-time")), "time==mtp: {err:?}" ); @@ -535,7 +545,7 @@ fn header_and_spending_boundaries() { ); let err = accept_and_connect_block(&q99, ¶ms, Height(100), &bad100, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::BadTx(s)) if s.contains("immature")), + matches!(err, Err(ConfirmError::Rules(ConsensusError::BadTx(s))) if s.contains("immature")), "immature at created+99: {err:?}" ); assert_eq!(q99.tip_height(), Some(Height(99))); @@ -559,7 +569,10 @@ fn header_and_spending_boundaries() { let miss_block = mine_regtest_block(tip, time, 101, vec![missing]); let err = accept_and_connect_block(&q, ¶ms, Height(101), &miss_block, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::MissingPrevout)), + matches!( + err, + Err(ConfirmError::Rules(ConsensusError::MissingPrevout)) + ), "{case} must reject as MissingPrevout: {err:?}" ); } @@ -572,7 +585,10 @@ fn header_and_spending_boundaries() { let oob_block = mine_regtest_block(tip, time, 101, vec![oob]); let err = accept_and_connect_block(&q, ¶ms, Height(101), &oob_block, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::MissingPrevout)), + matches!( + err, + Err(ConfirmError::Rules(ConsensusError::MissingPrevout)) + ), "confirmed txid vout {vout} past n_out must reject as MissingPrevout: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(100))); @@ -590,7 +606,10 @@ fn header_and_spending_boundaries() { &[(Height(101), b101), (Height(102), b102)], ); assert!( - matches!(err, Err(ConsensusError::MissingPrevout)), + matches!( + err, + Err(ConfirmError::Rules(ConsensusError::MissingPrevout)) + ), "same-batch parent vout past n_out must reject as MissingPrevout: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(100))); @@ -601,7 +620,7 @@ fn header_and_spending_boundaries() { let nf_block = mine_regtest_block(tip, time, 101, vec![nonfinal]); let err = accept_and_connect_block(&q, ¶ms, Height(101), &nf_block, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::BadTx(s)) if s.contains("nonfinal") || s.contains("not final")), + matches!(err, Err(ConfirmError::Rules(ConsensusError::BadTx(s))) if s.contains("nonfinal") || s.contains("not final")), "locktime==height: {err:?}" ); @@ -611,7 +630,12 @@ fn header_and_spending_boundaries() { let csv_block = mine_regtest_block(tip, time, 101, vec![csv_early]); let err = accept_and_connect_block(&q, ¶ms, Height(101), &csv_block, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::BadTx("bad-txns-nonfinal"))), + matches!( + err, + Err(ConfirmError::Rules(ConsensusError::BadTx( + "bad-txns-nonfinal" + ))) + ), "relative lock 200 at height 101 must reject, got {err:?}" ); @@ -619,7 +643,7 @@ fn header_and_spending_boundaries() { let over_block = mine_regtest_block(tip, time, 101, vec![over]); let err = accept_and_connect_block(&q, ¶ms, Height(101), &over_block, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::BadTx(s)) if s.contains("in < out")), + matches!(err, Err(ConfirmError::Rules(ConsensusError::BadTx(s))) if s.contains("in < out")), "in < out: {err:?}" ); @@ -629,7 +653,7 @@ fn header_and_spending_boundaries() { grind_pow(&mut excess); let err = accept_and_connect_block(&q, ¶ms, Height(101), &excess, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::BadBlock(s)) if s.contains("coinbase excess")), + matches!(err, Err(ConfirmError::Rules(ConsensusError::BadBlock(s))) if s.contains("coinbase excess")), "subsidy+1: {err:?}" ); @@ -638,7 +662,7 @@ fn header_and_spending_boundaries() { let dup = mine_regtest_block(tip, time, 101, vec![s1, s2]); let err = accept_and_connect_block(&q, ¶ms, Height(101), &dup, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::BadTx(s)) if s.contains("double spend")), + matches!(err, Err(ConfirmError::Rules(ConsensusError::BadTx(s))) if s.contains("double spend")), "same-block double spend: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(100))); @@ -648,7 +672,10 @@ fn header_and_spending_boundaries() { let bad_order = mine_regtest_block(tip, time, 101, vec![child, parent]); let err = accept_and_connect_block(&q, ¶ms, Height(101), &bad_order, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::MissingPrevout)), + matches!( + err, + Err(ConfirmError::Rules(ConsensusError::MissingPrevout)) + ), "child-before-parent must not become tip: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(100))); @@ -658,7 +685,7 @@ fn header_and_spending_boundaries() { let empty_block = mine_regtest_block(tip, time, 101, vec![empty_out]); let err = accept_and_connect_block(&q, ¶ms, Height(101), &empty_block, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::BadTx(s)) if s.contains("no outputs")), + matches!(err, Err(ConfirmError::Rules(ConsensusError::BadTx(s))) if s.contains("no outputs")), "non-coinbase empty vout: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(100))); @@ -679,10 +706,10 @@ fn header_and_spending_boundaries() { let again = mine_regtest_block(good.block_hash(), time + 600, 102, vec![spent_again]); let err = accept_and_connect_block(&q, ¶ms, Height(102), &again, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::PrevoutSpent)) + matches!(err, Err(ConfirmError::Rules(ConsensusError::PrevoutSpent))) || matches!( err, - Err(ConsensusError::BadTx(s)) if s.contains("double spend") || s.contains("spent") + Err(ConfirmError::Rules(ConsensusError::BadTx(s))) if s.contains("double spend") || s.contains("spent") ), "already spent: {err:?}" ); @@ -741,7 +768,7 @@ fn witness_program_rules_bind_below_segwit_height() { let block = mine_regtest_block(tip, time + 600, 101, vec![fund, spend]); let err = accept_and_connect_block(&q, ¶ms, Height(101), &block, Milestone::NONE); assert!( - matches!(err, Err(ConsensusError::Script(_))), + matches!(err, Err(ConfirmError::Rules(ConsensusError::Script(_)))), "{case} below segwit height: {err:?}" ); assert_eq!(q.tip_height(), Some(Height(100))); diff --git a/crates/rbitcoin-test/tests/core_analogs.rs b/crates/rbitcoin-test/tests/core_analogs.rs index 47cfa20105..81b0aa9120 100644 --- a/crates/rbitcoin-test/tests/core_analogs.rs +++ b/crates/rbitcoin-test/tests/core_analogs.rs @@ -20,7 +20,7 @@ use rbitcoin_consensus::{grind_regtest_pow, ChainParams, ConsensusError, Milesto use rbitcoin_net::MempoolHub; use rbitcoin_primitives::Height; use rbitcoin_query::accept_and_connect_block; -use rbitcoin_query::Query; +use rbitcoin_query::{ConfirmError, Query}; use rbitcoin_test::mine::{mine_regtest_block, regtest_genesis, spend_anyone_can_spend}; use rbitcoin_test::{ assert_reconstruct_eq, open_mature_regtest_with_spend, pad_empty_from, MatureRegtestChain, @@ -156,8 +156,10 @@ fn analog_milestone_and_mempool_persist() { assert!( matches!( err, - ConsensusError::MissingPrevout - | ConsensusError::BadTx("bad-txns-inputs-missingorspent") + ConfirmError::Rules( + ConsensusError::MissingPrevout + | ConsensusError::BadTx("bad-txns-inputs-missingorspent"), + ) ), "expected missing-prevout class under milestone, got: {err}" ); diff --git a/crates/rbitcoin-test/tests/scenarios.rs b/crates/rbitcoin-test/tests/scenarios.rs index 41a6e7b38d..2fe03c61d8 100644 --- a/crates/rbitcoin-test/tests/scenarios.rs +++ b/crates/rbitcoin-test/tests/scenarios.rs @@ -1422,7 +1422,6 @@ fn pin_plan_none_stamp_of_archived_spend( b_spend: &Block, spend_h: u32, ) { - use rbitcoin_consensus::ConsensusError; use rbitcoin_query::confirm_wire_lookup_stamp; let hash = b_spend.block_hash().to_byte_array(); @@ -3059,7 +3058,9 @@ fn pin_confirm_refuses_without_advancing_tip( let non_contiguous = [(Height(1), g.clone()), (Height(3), g)]; for batch in [&[][..], &non_contiguous[..]] { match confirm_wire_load_phase(q, params, ms, batch, &none) { - Err(rbitcoin_consensus::ConsensusError::BadBlock(_)) => {} + Err(rbitcoin_query::ConfirmError::Rules( + rbitcoin_consensus::ConsensusError::BadBlock(_), + )) => {} Err(e) => panic!("expected BadBlock, got {e}"), Ok(_) => panic!("load of {} blocks must refuse", batch.len()), } From c3f71423df8de2732aab797686a3f1785754467a Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:34:18 -0700 Subject: [PATCH 07/12] consensus: retarget #1054 pins after the pipeline move The rebase onto the witness, sigop, and BIP68 coinbase pins left store-backed tests calling consensus paths that now live on query. The exception-hash pin stays in consensus because the job verifier is crate-private. Connect results match ConfirmError::Rules. --- .../src/script/tests_verify.rs | 86 ++++++++++++++++ .../rbitcoin-query/src/bip68_store_tests.rs | 9 +- .../src/structure_rule_tests.rs | 97 ++----------------- 3 files changed, 95 insertions(+), 97 deletions(-) diff --git a/crates/rbitcoin-consensus/src/script/tests_verify.rs b/crates/rbitcoin-consensus/src/script/tests_verify.rs index 2c4195517a..325640c66c 100644 --- a/crates/rbitcoin-consensus/src/script/tests_verify.rs +++ b/crates/rbitcoin-consensus/src/script/tests_verify.rs @@ -1514,3 +1514,89 @@ fn legacy_sighash_single_past_last_output_is_uint256_one() { .unwrap(); assert_ne!(in_hash.to_byte_array(), one); } + +/// A taproot key-path signature with hashtype byte `0x00` is invalid when +/// taproot is on and ignored when the mainnet exception turns taproot off. +/// A P2SH redeem of `OP_0` matches the template hash either way. On the BIP16 +/// exception the redeem is not executed. On the next hash it is, and fails. +#[test] +fn exception_hashes_change_which_scripts_run() { + use crate::block::{ScriptVerifyFlags, BIP16_EXCEPTION_MAINNET, TAPROOT_EXCEPTION_MAINNET}; + use crate::params::ChainParams; + use bitcoin::transaction::Version as TxVersion; + + let main = Box::leak(Box::new(ChainParams::mainnet())); + + let mut program = vec![0x51, 0x20]; + program.extend_from_slice(&[0xff; 32]); + let mut sig = vec![0x11u8; 65]; + sig[64] = 0x00; + let tap_tx = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::from_slice(&[sig.as_slice()]), + }], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + let tap_prev = vec![TxOut { + value: Amount::from_sat(2), + script_pubkey: ScriptBuf::from_bytes(program), + }]; + let tap_flags = |hash: &[u8; 32]| ScriptVerifyFlags::for_block(main, 692_261, hash, 0); + let accept = ScriptCheckJob::new( + tap_prev.clone(), + tap_tx.clone(), + tap_flags(&TAPROOT_EXCEPTION_MAINNET), + ); + script::verify_job_all_inputs(&accept).expect("taproot exception does not run the key path"); + let reject = ScriptCheckJob::new(tap_prev, tap_tx, tap_flags(&[1u8; 32])); + let err = script::verify_job_all_inputs(&reject).expect_err("neighbor enforces taproot"); + assert!( + format!("{err}").contains("sighash"), + "expected hashtype 0x00 reject, got {err}" + ); + + let redeem = [0x00u8]; + let redeem_hash = hash160::Hash::hash(&redeem); + let mut p2sh = vec![0xa9, 0x14]; + p2sh.extend_from_slice(redeem_hash.as_byte_array()); + p2sh.push(0x87); + let p2sh_tx = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::from_bytes(vec![0x01, 0x00]), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + let p2sh_prev = vec![TxOut { + value: Amount::from_sat(2), + script_pubkey: ScriptBuf::from_bytes(p2sh), + }]; + let p2sh_flags = |hash: &[u8; 32]| ScriptVerifyFlags::for_block(main, 170_060, hash, 0); + let bare = ScriptCheckJob::new( + p2sh_prev.clone(), + p2sh_tx.clone(), + p2sh_flags(&BIP16_EXCEPTION_MAINNET), + ); + script::verify_job_all_inputs(&bare) + .expect("BIP16 exception checks the hash and does not run the redeem"); + let enforced = ScriptCheckJob::new(p2sh_prev, p2sh_tx, p2sh_flags(&[2u8; 32])); + assert!( + script::verify_job_all_inputs(&enforced).is_err(), + "neighbor executes the OP_0 redeem" + ); +} diff --git a/crates/rbitcoin-query/src/bip68_store_tests.rs b/crates/rbitcoin-query/src/bip68_store_tests.rs index ffec276188..e1e2a25318 100644 --- a/crates/rbitcoin-query/src/bip68_store_tests.rs +++ b/crates/rbitcoin-query/src/bip68_store_tests.rs @@ -216,7 +216,6 @@ fn structural_bip68_height_lock_skips_missing_coin_mtp() { /// BIP68 walks `txdata[1..]`. A coinbase whose relative lock would fail still connects. #[test] fn bip68_skips_a_coinbase_whose_relative_lock_would_fail() { - use bitcoin::hashes::Hash; use rbitcoin_consensus::{ mine_empty_regtest, prepare_regtest_candidate, ConsensusError, Milestone, }; @@ -245,13 +244,7 @@ fn bip68_skips_a_coinbase_whose_relative_lock_would_fail() { bare_tx(1, LockTime::ZERO, Sequence::MAX), bare_tx(2, LockTime::ZERO, Sequence::from_consensus(200)), ]); - let spends = [( - [0u8; 32], - 0u32, - rbitcoin_primitives::Fk::NULL, - create, - 0u32, - )]; + let spends = [([0u8; 32], 0u32, rbitcoin_primitives::Fk::NULL, create, 0u32)]; let mut cache = crate::U32Map::default(); cache.insert(5, 1_500_000); let err = crate::block_pipeline::structural_bip68( diff --git a/crates/rbitcoin-query/src/structure_rule_tests.rs b/crates/rbitcoin-query/src/structure_rule_tests.rs index c26b5e2edf..a00e67e51a 100644 --- a/crates/rbitcoin-query/src/structure_rule_tests.rs +++ b/crates/rbitcoin-query/src/structure_rule_tests.rs @@ -1748,11 +1748,10 @@ fn genesis_coinbase_is_never_a_coin() { /// rejects it. `OP_TRUE` and `OP_RETURN` are not this case. #[test] fn anchored_milestone_skips_a_failing_p2pkh_script() { - use crate::milestone::MilestoneAnchor; - use crate::{accept_and_connect_block, mine_regtest_paying, pad_empty_from}; - use bitcoin::hashes::Hash; + use crate::{accept_and_connect_block, pad_empty_from}; + use rbitcoin_consensus::{mine_regtest_paying, MilestoneAnchor}; - let (_dir, q) = rbitcoin_query::testutil::tiny_query_labeled("anchor-skip-p2pkh"); + let (_dir, q) = crate::testutil::tiny_query_labeled("anchor-skip-p2pkh"); let params = ChainParams::regtest(); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); @@ -1829,7 +1828,7 @@ fn anchored_milestone_skips_a_failing_p2pkh_script() { let err = accept_and_connect_block(&q, ¶ms, Height(h), &block, above) .expect_err("one height above the anchor still runs the failing script"); assert!( - matches!(err, ConsensusError::Script(_)), + matches!(err, ConfirmError::Rules(ConsensusError::Script(_))), "expected a script failure, got {err:?}" ); assert_eq!(q.tip_height(), Some(Height(maturity))); @@ -1840,7 +1839,10 @@ fn anchored_milestone_skips_a_failing_p2pkh_script() { note(h, wrong); let err = accept_and_connect_block(&q, ¶ms, Height(h), &block, anchored(h, block_hash)) .expect_err("a different hash at the anchor height does not skip"); - assert!(matches!(err, ConsensusError::Script(_)), "{err:?}"); + assert!( + matches!(err, ConfirmError::Rules(ConsensusError::Script(_))), + "{err:?}" + ); assert_eq!(q.tip_height(), Some(Height(maturity))); q.clear_milestone_path_above(0); @@ -3028,89 +3030,6 @@ fn script_flags_follow_core_exception_table() { /// The exception hashes change execution, not only the flag bits. /// -/// A taproot key-path signature with hashtype byte `0x00` is invalid when -/// taproot is on and ignored when the mainnet exception turns taproot off. -/// A P2SH redeem of `OP_0` matches the template hash either way. On the BIP16 -/// exception the redeem is not executed. On the next hash it is, and fails. -#[test] -fn exception_hashes_change_which_scripts_run() { - let main = Box::leak(Box::new(ChainParams::mainnet())); - - let mut program = vec![0x51, 0x20]; - program.extend_from_slice(&[0xff; 32]); - let mut sig = vec![0x11u8; 65]; - sig[64] = 0x00; - let tap_tx = Transaction { - version: TxVersion::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint::null(), - script_sig: ScriptBuf::new(), - sequence: Sequence::MAX, - witness: Witness::from_slice(&[sig.as_slice()]), - }], - output: vec![TxOut { - value: Amount::from_sat(1), - script_pubkey: ScriptBuf::from_bytes(vec![0x51]), - }], - }; - let tap_prev = vec![TxOut { - value: Amount::from_sat(2), - script_pubkey: ScriptBuf::from_bytes(program), - }]; - let tap_flags = |hash: &[u8; 32]| ScriptVerifyFlags::for_block(main, 692_261, hash, 0); - let accept = ScriptCheckJob::new( - tap_prev.clone(), - tap_tx.clone(), - tap_flags(&TAPROOT_EXCEPTION_MAINNET), - ); - crate::script::verify_job_all_inputs(&accept) - .expect("taproot exception does not run the key path"); - let reject = ScriptCheckJob::new(tap_prev, tap_tx, tap_flags(&[1u8; 32])); - let err = crate::script::verify_job_all_inputs(&reject).expect_err("neighbor enforces taproot"); - assert!( - format!("{err}").contains("sighash"), - "expected hashtype 0x00 reject, got {err}" - ); - - let redeem = [0x00u8]; - let redeem_hash = bitcoin::hashes::hash160::Hash::hash(&redeem); - let mut p2sh = vec![0xa9, 0x14]; - p2sh.extend_from_slice(redeem_hash.as_byte_array()); - p2sh.push(0x87); - let p2sh_tx = Transaction { - version: TxVersion::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint::null(), - script_sig: ScriptBuf::from_bytes(vec![0x01, 0x00]), - sequence: Sequence::MAX, - witness: Witness::new(), - }], - output: vec![TxOut { - value: Amount::from_sat(1), - script_pubkey: ScriptBuf::from_bytes(vec![0x51]), - }], - }; - let p2sh_prev = vec![TxOut { - value: Amount::from_sat(2), - script_pubkey: ScriptBuf::from_bytes(p2sh), - }]; - let p2sh_flags = |hash: &[u8; 32]| ScriptVerifyFlags::for_block(main, 170_060, hash, 0); - let bare = ScriptCheckJob::new( - p2sh_prev.clone(), - p2sh_tx.clone(), - p2sh_flags(&BIP16_EXCEPTION_MAINNET), - ); - crate::script::verify_job_all_inputs(&bare) - .expect("BIP16 exception checks the hash and does not run the redeem"); - let enforced = ScriptCheckJob::new(p2sh_prev, p2sh_tx, p2sh_flags(&[2u8; 32])); - assert!( - crate::script::verify_job_all_inputs(&enforced).is_err(), - "neighbor executes the OP_0 redeem" - ); -} - /// Confirm jobs share wire Arc — same Transaction address, no deep clone. #[test] fn script_job_shared_tx_is_wire_pointer() { From ebf89a554ec7d75d9ed0fb9ef9606861bd280c1f Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:13:20 -0700 Subject: [PATCH 08/12] consensus: keep a cancelled header read off the failed-block cache StoreError::Cancelled stays ConfirmError::Store. header_reject maps that abort to NetError::Cancelled and does not call block_reject_reason. A side block whose parent header read is cancelled is not BLOCK_FAILED. --- crates/rbitcoin-net/src/chain.rs | 32 ++++++++++++++++++++-- crates/rbitcoin-net/src/ibd/confirm/mod.rs | 10 +++---- crates/rbitcoin-query/src/chain_view.rs | 5 ++++ crates/rbitcoin-query/src/confirm_error.rs | 24 ++++++++++++---- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/crates/rbitcoin-net/src/chain.rs b/crates/rbitcoin-net/src/chain.rs index 532df49175..db32bfa415 100644 --- a/crates/rbitcoin-net/src/chain.rs +++ b/crates/rbitcoin-net/src/chain.rs @@ -292,6 +292,9 @@ fn connect_failed_for_header(hash: [u8; 32], e: NetError) -> NetError { } fn header_reject(header: &Header, e: &rbitcoin_query::ConfirmError) -> NetError { + if e.is_cancelled() { + return NetError::Cancelled; + } let rules = match e { rbitcoin_query::ConfirmError::Store(se) => return NetError::store(se), rbitcoin_query::ConfirmError::Rules(rbitcoin_consensus::ConsensusError::Internal(m)) => { @@ -3033,9 +3036,7 @@ impl ChainHub { } }) .map_err(|e| match e { - rbitcoin_query::ConfirmError::Rules( - rbitcoin_consensus::ConsensusError::Cancelled, - ) => NetError::Cancelled, + e if e.is_cancelled() => NetError::Cancelled, rbitcoin_query::ConfirmError::Rules( rbitcoin_consensus::ConsensusError::Internal(m), ) => { @@ -7619,6 +7620,31 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + /// A side block whose parent header read is cancelled is not `BLOCK_FAILED`. + #[test] + fn cancelled_header_read_on_a_side_block_is_not_cached_invalid() { + let (dir, hub) = tmp_hub(); + hub.ensure_genesis().unwrap(); + let gen = hub.tip_hash().unwrap(); + let b1 = mine(gen, 1_300_050_000, 1); + hub.accept_block(b1.clone()).unwrap(); + let side = mine_distinct(gen, 1_300_050_001, 1, &[b1.block_hash()]); + hub.query.request_confirm_cancel(); + let err = hub + .accept_received_block(side.clone()) + .expect_err("a cancelled header read is not an accept"); + assert!( + err.is_local_fault(), + "cancel must stay a store/engine result, got {err}" + ); + assert!( + !hub.is_block_invalid(&side.block_hash()), + "a cancelled header read must not be BLOCK_FAILED, err={err}" + ); + hub.query.clear_confirm_cancel(); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn store_fault_during_header_check_is_not_a_failed_block() { let hash = [0x11; 32]; diff --git a/crates/rbitcoin-net/src/ibd/confirm/mod.rs b/crates/rbitcoin-net/src/ibd/confirm/mod.rs index efa91ff1c7..2dd2975446 100644 --- a/crates/rbitcoin-net/src/ibd/confirm/mod.rs +++ b/crates/rbitcoin-net/src/ibd/confirm/mod.rs @@ -414,6 +414,7 @@ impl ConfirmRejectClass { use rbitcoin_store::StoreError; match err { ConfirmError::Rules(e) => Self::from_consensus(e), + ConfirmError::Store(StoreError::Cancelled(_)) => Self::Cancelled, ConfirmError::Store(e) if e.is_uring_session_fault() => Self::EngineFault, ConfirmError::Store(StoreError::Io { .. }) => Self::EngineFault, ConfirmError::Store(StoreError::Corrupt(m)) => Self::from_err_str(m), @@ -2020,8 +2021,7 @@ pub(crate) fn spawn_confirm_engine( confirm_thr_stats::add_write_work(&stats, t0.elapsed()); let mut err = e; let msg = err.to_string(); - if matches!(err, rbitcoin_query::ConfirmError::Rules(rbitcoin_consensus::ConsensusError::Cancelled)) - || feed_wb.stopped() + if err.is_cancelled() || feed_wb.stopped() { info!("ibd: confirm write aborted: {msg}"); break; @@ -2217,8 +2217,7 @@ pub(crate) fn spawn_confirm_engine( |e, meta, dropped| { confirm_thr_stats::add_script_work(&stats, Duration::ZERO); let msg = e.to_string(); - if matches!(e, rbitcoin_query::ConfirmError::Rules(rbitcoin_consensus::ConsensusError::Cancelled)) - || feed_sc.stopped() + if e.is_cancelled() || feed_sc.stopped() { info!("ibd: confirm scripts aborted: {msg}"); return false; @@ -2456,8 +2455,7 @@ pub(crate) fn spawn_confirm_engine( Ok(s) => s, Err(e) => { let msg = e.to_string(); - if matches!(e, rbitcoin_query::ConfirmError::Rules(rbitcoin_consensus::ConsensusError::Cancelled)) - || feed_load.stopped() + if e.is_cancelled() || feed_load.stopped() { drop(mat_tx); rbitcoin_consensus::unpark_script_publisher(); diff --git a/crates/rbitcoin-query/src/chain_view.rs b/crates/rbitcoin-query/src/chain_view.rs index 3a0c32f241..68fd44ebaa 100644 --- a/crates/rbitcoin-query/src/chain_view.rs +++ b/crates/rbitcoin-query/src/chain_view.rs @@ -214,6 +214,11 @@ impl Query { &self, height: Height, ) -> Result, QueryError> { + // IBD SIGINT sets this flag. The read must fail as cancel, not as a + // missing header the caller can cache. + if self.confirm_cancelled() { + return Err(StoreError::Cancelled("header read")); + } note_header_lookup(); #[cfg(test)] note_header_io_under_index_lock(); diff --git a/crates/rbitcoin-query/src/confirm_error.rs b/crates/rbitcoin-query/src/confirm_error.rs index 3d33f5b9e8..211c1511d2 100644 --- a/crates/rbitcoin-query/src/confirm_error.rs +++ b/crates/rbitcoin-query/src/confirm_error.rs @@ -38,9 +38,8 @@ impl From for ConfirmError { impl From for ConfirmError { fn from(e: StoreError) -> Self { // A missing parent on an untrusted block is not store corruption. - // Cancellation is a rule-side abort so it is not cached as a bad block. + // Cancellation stays a store result. Callers must not cache it. match e { - StoreError::Cancelled(_) => ConfirmError::Rules(ConsensusError::Cancelled), StoreError::Corrupt(m) if m.contains("parent create_fk unresolved") => { ConfirmError::from(rbitcoin_consensus::block::unresolved_parent_create()) } @@ -61,12 +60,22 @@ impl ConfirmError { } } + /// Cooperative abort. Not a verdict on the block. + pub fn is_cancelled(&self) -> bool { + matches!( + self, + ConfirmError::Store(StoreError::Cancelled(_)) + | ConfirmError::Rules(ConsensusError::Cancelled) + ) + } + /// Script-pool jobs only return [`ConsensusError`]. A store fault becomes /// [`ConsensusError::Internal`] so the pool does not grow a second taxonomy. /// io_uring text stays recognizable to [`StoreError::is_uring_session_fault_msg`]. pub(crate) fn into_pool(self) -> ConsensusError { match self { ConfirmError::Rules(e) => e, + ConfirmError::Store(StoreError::Cancelled(_)) => ConsensusError::Cancelled, ConfirmError::Store(StoreError::Corrupt(m)) if StoreError::is_uring_session_fault_msg(m) => { @@ -115,9 +124,14 @@ mod tests { } #[test] - fn cancelled_is_not_a_store_fault_string() { + fn cancelled_stays_a_store_error() { let e = ConfirmError::from(StoreError::Cancelled("stop")); - assert!(matches!(e, ConfirmError::Rules(ConsensusError::Cancelled))); - assert_eq!(e.to_string(), "confirm cancelled"); + assert!(matches!( + e, + ConfirmError::Store(StoreError::Cancelled("stop")) + )); + assert!(e.is_cancelled()); + assert_eq!(e.to_string(), "store: cancelled: stop"); + assert!(matches!(e.into_pool(), ConsensusError::Cancelled)); } } From 07b036826ed2b1be6a8be1284ed640e3a6ba47ce Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:16:08 -0700 Subject: [PATCH 09/12] consensus: restore the nBits contract on next_work_bits The median-time note stayed behind when median_time_past left this crate, and it linked confirm_run. Median time is median_time_past_times. --- crates/rbitcoin-consensus/src/header.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/rbitcoin-consensus/src/header.rs b/crates/rbitcoin-consensus/src/header.rs index be6520f17f..68ffbba7b8 100644 --- a/crates/rbitcoin-consensus/src/header.rs +++ b/crates/rbitcoin-consensus/src/header.rs @@ -140,13 +140,12 @@ pub fn check_header_version_and_future_time( Ok(()) } -/// Median timestamp of up to 11 blocks ending at `height` (inclusive). +/// Expected `nBits` for the header at `height`, from the parent and the period start. /// -/// **Confirm load path:** BIP68/BIP113 run during multi-block assemble while -/// mid-batch heights are not yet in `confirmed[]`. Prefer the durable confirmed -/// chain, then load-stage header plans (same hybrid as [`crate::confirm_run`] -/// header MTP). Heights still above tip with no plan are retryable load -/// incomplete — not permanent `BadPrev` (that silently split batches to n=1). +/// `period_first_time` is the timestamp `interval` blocks earlier when `height` +/// is a retarget boundary. `bits_at` supplies earlier `nBits` for the testnet +/// min-difficulty walk. `None` means an ancestor is missing. Median time is +/// [`median_time_past_times`], not this function. pub fn next_work_bits( params: &ChainParams, height: u32, From 63b4d7a4484a61ce021b0314d14315e9fe822edc Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:24:36 -0700 Subject: [PATCH 10/12] consensus: do not fail later header reads after confirm cancel The IBD stop flag stays set after teardown. A header read must not turn that into a cancel. Tests arm one cancelled read instead. --- crates/rbitcoin-net/Cargo.toml | 2 ++ crates/rbitcoin-net/src/chain.rs | 3 +-- crates/rbitcoin-query/Cargo.toml | 3 +++ crates/rbitcoin-query/src/chain_view.rs | 9 ++++++--- crates/rbitcoin-query/src/lib.rs | 13 +++++++++++++ 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/rbitcoin-net/Cargo.toml b/crates/rbitcoin-net/Cargo.toml index d8b995b8b7..86d35fefeb 100644 --- a/crates/rbitcoin-net/Cargo.toml +++ b/crates/rbitcoin-net/Cargo.toml @@ -27,6 +27,8 @@ sha3 = "0.12" # Relay tests pin a full mempool slot table. Dependents of this crate do # not turn the feature on. rbitcoin-mempool = { workspace = true, features = ["test-hooks"] } +# Side-block accept arms one cancelled header read. Dependents do not. +rbitcoin-query = { workspace = true, features = ["test-hooks"] } [target.'cfg(target_os = "linux")'.dependencies] # POLLRDHUP: peer FIN with unread bytes still in the receive buffer. diff --git a/crates/rbitcoin-net/src/chain.rs b/crates/rbitcoin-net/src/chain.rs index db32bfa415..6878e42d31 100644 --- a/crates/rbitcoin-net/src/chain.rs +++ b/crates/rbitcoin-net/src/chain.rs @@ -7629,7 +7629,7 @@ mod tests { let b1 = mine(gen, 1_300_050_000, 1); hub.accept_block(b1.clone()).unwrap(); let side = mine_distinct(gen, 1_300_050_001, 1, &[b1.block_hash()]); - hub.query.request_confirm_cancel(); + hub.query.fail_next_header_read_cancelled(); let err = hub .accept_received_block(side.clone()) .expect_err("a cancelled header read is not an accept"); @@ -7641,7 +7641,6 @@ mod tests { !hub.is_block_invalid(&side.block_hash()), "a cancelled header read must not be BLOCK_FAILED, err={err}" ); - hub.query.clear_confirm_cancel(); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/rbitcoin-query/Cargo.toml b/crates/rbitcoin-query/Cargo.toml index 519346bec8..42f15acea8 100644 --- a/crates/rbitcoin-query/Cargo.toml +++ b/crates/rbitcoin-query/Cargo.toml @@ -17,6 +17,9 @@ arc-swap = { workspace = true } # Attribute only. cargo-mutants reads it; it does not change compiled code. mutants = "0.0.4" +[features] +test-hooks = [] + [dev-dependencies] # Height-index tests write a raw confirmed slot. Dependents of this crate # do not turn the feature on. diff --git a/crates/rbitcoin-query/src/chain_view.rs b/crates/rbitcoin-query/src/chain_view.rs index 68fd44ebaa..e19b216fd0 100644 --- a/crates/rbitcoin-query/src/chain_view.rs +++ b/crates/rbitcoin-query/src/chain_view.rs @@ -214,9 +214,12 @@ impl Query { &self, height: Height, ) -> Result, QueryError> { - // IBD SIGINT sets this flag. The read must fail as cancel, not as a - // missing header the caller can cache. - if self.confirm_cancelled() { + // One-shot armed by tests. The IBD stop flag must not fail later reads. + #[cfg(feature = "test-hooks")] + if self + .header_read_cancel + .swap(false, std::sync::atomic::Ordering::SeqCst) + { return Err(StoreError::Cancelled("header read")); } note_header_lookup(); diff --git a/crates/rbitcoin-query/src/lib.rs b/crates/rbitcoin-query/src/lib.rs index 45c3d6479a..77f78db86f 100644 --- a/crates/rbitcoin-query/src/lib.rs +++ b/crates/rbitcoin-query/src/lib.rs @@ -409,6 +409,9 @@ pub struct Query { /// Cooperative cancel for in-flight confirm load. Set on IBD SIGINT /// teardown so the confirm load thread aborts before process exit. confirm_cancel: std::sync::atomic::AtomicBool, + /// One header read returns [`StoreError::Cancelled`]. Tests only. + #[cfg(feature = "test-hooks")] + header_read_cancel: std::sync::atomic::AtomicBool, /// Confirmed best-chain `hash → height` (kept for process life; ~60 MiB mainnet). /// /// Avoids O(tip) header walks on Esplora/P2P `height_of_hash`. @@ -571,6 +574,8 @@ impl Query { sptweaks_origin: AtomicU32::new(sptweaks_origin), index_mode_cell: std::sync::atomic::AtomicU8::new(IndexMode::Tip as u8), confirm_cancel: std::sync::atomic::AtomicBool::new(false), + #[cfg(feature = "test-hooks")] + header_read_cancel: std::sync::atomic::AtomicBool::new(false), height_by_hash: Mutex::new(HeightByHashIndex::default()), reconstruct_archived: AtomicU64::new(0), max_sh_creates: AtomicU32::new(DEFAULT_MAX_SH_CREATES), @@ -1275,6 +1280,14 @@ impl Query { .load(std::sync::atomic::Ordering::SeqCst) } + /// The next [`Self::header_at_height`] returns [`StoreError::Cancelled`]. + #[cfg(feature = "test-hooks")] + #[doc(hidden)] + pub fn fail_next_header_read_cancelled(&self) { + self.header_read_cancel + .store(true, std::sync::atomic::Ordering::SeqCst); + } + /// Last height with SH creates applied (after tip). `None` if empty chain. pub fn sh_indexed_through_height(&self) -> Option { let v = self.sh.indexed_through.load(AtomicOrdering::Acquire); From 13e7be4868797d70c101f332b8f7f087b0eb7055 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:53:09 -0700 Subject: [PATCH 11/12] node: open Electrum after the follow header round is indexed Scripthash materialize returns before a block mined in that window is connected. The wallet listener waits for that getheaders round and, when the scripthash index is on, for the head to cover the new tip. --- changelog.d/consensus-off-store.md | 1 + crates/rbitcoin-net/src/chain.rs | 22 +++++++++++++++++ crates/rbitcoin-net/src/peer.rs | 2 ++ crates/rbitcoin-node/src/run.rs | 39 ++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/changelog.d/consensus-off-store.md b/changelog.d/consensus-off-store.md index 53ad5cad57..fbfa6207c4 100644 --- a/changelog.d/consensus-off-store.md +++ b/changelog.d/consensus-off-store.md @@ -1,3 +1,4 @@ Changed - **Consensus rules no longer open the store.** `rbitcoin-consensus` decides header, block, and transaction accepts and rejects from bytes and caller facts. `rbitcoin-query` loads rows, writes Class A, Class C, and spend annotations, and calls those functions. A disk, io_uring, or cancel fault stays a pipeline error and is not cached as an invalid block. +- **Electrum opens after the follow header round.** A block mined while the scripthash index materializes is connected and indexed before the wallet port opens. diff --git a/crates/rbitcoin-net/src/chain.rs b/crates/rbitcoin-net/src/chain.rs index 6878e42d31..7bfcabc55e 100644 --- a/crates/rbitcoin-net/src/chain.rs +++ b/crates/rbitcoin-net/src/chain.rs @@ -388,6 +388,10 @@ pub struct ChainHub { chain_work_prefix: RwLock>, /// Once the relay-inhibited latch clears, stay out. finished_ibd: AtomicBool, + /// Initial `getheaders` queued by follow sessions since process start. + initial_getheaders_sent: AtomicU64, + /// `headers` messages applied since process start (including empty). + headers_replies: AtomicU64, /// A follow-session block inv during IBD. The walk asks its header peer. header_inv_challenge: AtomicBool, #[cfg(test)] @@ -503,6 +507,8 @@ impl ChainHub { asked_blocks: RwLock::new(HashSet::new()), chain_work_prefix: RwLock::new(Vec::new()), finished_ibd: AtomicBool::new(false), + initial_getheaders_sent: AtomicU64::new(0), + headers_replies: AtomicU64::new(0), header_inv_challenge: AtomicBool::new(false), #[cfg(test)] block_at_height_calls: AtomicU64::new(0), @@ -1164,6 +1170,22 @@ impl ChainHub { best } + pub fn note_initial_getheaders_sent(&self) { + self.initial_getheaders_sent.fetch_add(1, Ordering::Relaxed); + } + + pub fn initial_getheaders_sent(&self) -> u64 { + self.initial_getheaders_sent.load(Ordering::Relaxed) + } + + pub fn note_headers_reply(&self) { + self.headers_replies.fetch_add(1, Ordering::Relaxed); + } + + pub fn headers_reply_count(&self) -> u64 { + self.headers_replies.load(Ordering::Relaxed) + } + fn note_header_tip(&self, header: &Header) { let hash = header.block_hash(); if self.is_connected(&hash) { diff --git a/crates/rbitcoin-net/src/peer.rs b/crates/rbitcoin-net/src/peer.rs index e94c6c2fe4..58170cdc1e 100644 --- a/crates/rbitcoin-net/src/peer.rs +++ b/crates/rbitcoin-net/src/peer.rs @@ -2111,6 +2111,7 @@ fn maybe_queue_initial_getheaders( if started { let h = hub.tip_height().unwrap_or(0); rbitcoin_log::info!("{}", crate::chain::initial_getheaders_log(h, session.id)); + hub.note_initial_getheaders_sent(); let _ = queue_getheaders(out, hub, Some(session), true, None); } started @@ -3660,6 +3661,7 @@ fn on_headers( headers: &[bitcoin::block::Header], ) -> Result<(), NetError> { let n = headers.len(); + hub.note_headers_reply(); let _ = session.is_some_and(|s| s.take_awaiting_headers()); let mut ignored_below_floor = false; if n == 0 { diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 53fa2a8399..edcc944e78 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -795,6 +795,10 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { } } if tip_follow_ready && !shutdown.requested() { + let header_mark = ( + node.hub.initial_getheaders_sent(), + node.hub.headers_reply_count(), + ); let follow_n = targets.len().min(max_out.min(3)); if catch_up.dial_failed_all() { for peer in targets.iter().take(follow_n) { @@ -839,6 +843,9 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { // without a live session, including ones past `follow_n`. Hostnames // were registered before catch-up (`set_connect_hosts`). node.peers.set_connect_addrs(config.listen.connect.clone()); + // Materialize can outlast the miner. Do not publish Electrum until + // the follow `getheaders` round is connected and indexed. + wait_wallet_history(&node, config.shindex, &shutdown, header_mark).await; } let (mut electrum_handles, mut electrum_bridge, electrum_onion) = start_electrum_if_ready( @@ -1937,6 +1944,38 @@ async fn start_i2p_named_forward( Ok(sam) } +/// Wallet bind waits out one follow `getheaders` round. +/// +/// Scripthash materialize holds the process before dial. The miner can add +/// a block in that window. Opening Electrum on the pre-dial tip serves a +/// history that does not include it. +async fn wait_wallet_history(node: &P2PNode, shindex: bool, shutdown: &Shutdown, mark: (u64, u64)) { + if node.follow_live_count() == 0 { + return; + } + let (sent0, reply0) = mark; + let started = Instant::now(); + let deadline = Duration::from_secs(20); + let unsent_limit = Duration::from_secs(2); + loop { + if shutdown.requested() || started.elapsed() >= deadline { + return; + } + let sent = node.hub.initial_getheaders_sent().saturating_sub(sent0); + let replies = node.hub.headers_reply_count().saturating_sub(reply0); + let tip = node.tip_height().unwrap_or(0); + let best = node.hub.best_header_height(); + let sh_ok = !shindex || node.hub.query.sh_is_tip_ready(); + if sent > 0 && replies >= sent && best <= tip && sh_ok { + return; + } + if sent == 0 && started.elapsed() >= unsent_limit { + return; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + #[allow(clippy::too_many_arguments)] // call-site args stay unbundled async fn start_electrum_if_ready( sh_tip_ready: bool, From 54e4930f804e95dc18c9b2c1ab9d781e20bc37fb Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:52:29 -0700 Subject: [PATCH 12/12] consensus: return the header median from check_header Assemble used that median again for BIP113. The second sort of the same eleven timestamps is the value check_header already computed. --- crates/rbitcoin-consensus/src/header.rs | 51 +++++++++++++++++-- .../rbitcoin-query/src/confirm_run/phases.rs | 12 ++--- crates/rbitcoin-query/src/header_read.rs | 1 + 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/crates/rbitcoin-consensus/src/header.rs b/crates/rbitcoin-consensus/src/header.rs index 68ffbba7b8..45c67bc0f2 100644 --- a/crates/rbitcoin-consensus/src/header.rs +++ b/crates/rbitcoin-consensus/src/header.rs @@ -20,6 +20,9 @@ pub struct ParentHeader { /// `mtp_times` ends at the parent (up to 11 timestamps). `period_first_time` /// is the header `interval` blocks before a retarget height. `bits_at` supplies /// earlier `nBits` for the testnet min-difficulty walk. +/// +/// On success the value is that parent median, sorted once. Genesis has no +/// parent window and returns `0`. #[allow(clippy::too_many_arguments)] // each argument is one caller fact pub fn check_header( params: &ChainParams, @@ -30,7 +33,7 @@ pub fn check_header( mtp_times: &[u32], period_first_time: Option, mut bits_at: impl FnMut(u32) -> Option, -) -> Result<(), ConsensusError> { +) -> Result { let hash_bh = bitcoin::BlockHash::from_byte_array(hash); if height.0 == 0 { if !check_genesis_hash(params, hash_bh) { @@ -40,7 +43,8 @@ pub fn check_header( if header.bits != expected { return Err(ConsensusError::BadHeader("incorrect proof of work bits")); } - return pow_hash_meets_target(hash, header.bits, params.pow_limit); + pow_hash_meets_target(hash, header.bits, params.pow_limit)?; + return Ok(0); } let parent = parent.ok_or(ConsensusError::BadPrev)?; @@ -75,7 +79,8 @@ pub fn check_header( if header.bits != expected { return Err(ConsensusError::BadHeader("incorrect proof of work bits")); } - pow_hash_meets_target(hash, header.bits, params.pow_limit) + pow_hash_meets_target(hash, header.bits, params.pow_limit)?; + Ok(mtp) } /// Validate header linkage, MTP, difficulty bits, and proof-of-work. @@ -500,6 +505,46 @@ mod median_time_past_tests { ); } + #[test] + fn check_header_returns_the_median_it_checked() { + let params = ChainParams::regtest(); + let genesis = crate::params::genesis_block(¶ms); + let genesis_mtp = check_header( + ¶ms, + Height(0), + &genesis.header, + genesis.block_hash().to_byte_array(), + None, + &[], + None, + |_| None, + ) + .unwrap(); + assert_eq!(genesis_mtp, 0); + + let parent_time = genesis.header.time; + let block = + crate::regtest_pad::mine_empty_regtest(genesis.block_hash(), parent_time + 1, 1); + let times = [parent_time, parent_time - 1, parent_time + 2]; + let mtp = check_header( + ¶ms, + Height(1), + &block.header, + block.block_hash().to_byte_array(), + Some(ParentHeader { + hash: genesis.block_hash().to_byte_array(), + time: parent_time, + bits: genesis.header.bits, + }), + ×, + None, + |_| None, + ) + .unwrap(); + assert_eq!(mtp, median_time_past_times(×).unwrap()); + assert_eq!(mtp, parent_time); + } + #[test] fn check_header_version_and_future_time_regtest() { let params = ChainParams::regtest(); diff --git a/crates/rbitcoin-query/src/confirm_run/phases.rs b/crates/rbitcoin-query/src/confirm_run/phases.rs index 6be5ea96d7..8c5ee2f826 100644 --- a/crates/rbitcoin-query/src/confirm_run/phases.rs +++ b/crates/rbitcoin-query/src/confirm_run/phases.rs @@ -45,7 +45,7 @@ fn assemble_parent_mtp_and_bits( "confirm: load incomplete (parent header plan missing above tip)", )))?; let period_first = retarget_first_time(query, params, height.0)?; - rbitcoin_consensus::check_header( + let mtp = rbitcoin_consensus::check_header( params, height, header, @@ -55,10 +55,7 @@ fn assemble_parent_mtp_and_bits( period_first, |h| header_bits_plan_or_store(query, h), )?; - Ok(( - rbitcoin_consensus::block::median_or_bad_header(×)?, - times, - )) + Ok((mtp, times)) } fn assemble_chained_header( @@ -71,7 +68,7 @@ fn assemble_chained_header( time_window: &[u32], ) -> Result { let period_first = retarget_first_time(query, params, height.0)?; - rbitcoin_consensus::check_header( + Ok(rbitcoin_consensus::check_header( params, height, header, @@ -84,9 +81,6 @@ fn assemble_chained_header( time_window, period_first, |h| header_bits_plan_or_store(query, h), - )?; - Ok(rbitcoin_consensus::block::median_or_bad_header( - time_window, )?) } diff --git a/crates/rbitcoin-query/src/header_read.rs b/crates/rbitcoin-query/src/header_read.rs index 8495b4cfba..a81c381ba2 100644 --- a/crates/rbitcoin-query/src/header_read.rs +++ b/crates/rbitcoin-query/src/header_read.rs @@ -68,6 +68,7 @@ pub fn validate_header_hashed( .map(CompactTarget::from_consensus) }, ) + .map(|_| ()) .map_err(ConfirmError::from) }