diff --git a/crates/brk_error/src/lib.rs b/crates/brk_error/src/lib.rs index c0a6908a3..2b7b8a791 100644 --- a/crates/brk_error/src/lib.rs +++ b/crates/brk_error/src/lib.rs @@ -153,6 +153,9 @@ pub enum Error { #[error("Request weight {requested} exceeds maximum {max}")] WeightExceeded { requested: usize, max: usize }, + #[error("Too many unspent transaction outputs (>500). Contact support to raise limits.")] + TooManyUtxos, + #[error("Deserialization error: {0}")] Deserialization(String), diff --git a/crates/brk_mempool/src/cpfp.rs b/crates/brk_mempool/src/cpfp.rs new file mode 100644 index 000000000..5d48b878b --- /dev/null +++ b/crates/brk_mempool/src/cpfp.rs @@ -0,0 +1,108 @@ +//! CPFP (Child Pays For Parent) cluster reasoning for live mempool +//! transactions. Cluster scope is the seed's projected block: txs in +//! other projected blocks share no mining fate with the seed, so +//! including them in `effectiveFeePerVsize` would be misleading. +//! +//! Confirmed-tx CPFP (the same-block connected component on the +//! chain) lives in `brk_query`, since it reads indexer/computer vecs. + +use brk_types::{CpfpEntry, CpfpInfo, FeeRate, Sats, TxidPrefix, VSize, Weight}; +use rustc_hash::FxHashSet; + +use crate::{Mempool, TxEntry}; + +/// Cap matches Bitcoin Core's default mempool ancestor/descendant +/// chain limits and `confirmed_cpfp`'s cap. +const MAX: usize = 25; + +impl Mempool { + /// CPFP info for a live mempool tx, scoped to the seed's projected + /// block. Returns `None` if the tx is not in the mempool, so + /// callers can fall through to the confirmed path. Returns `Some` + /// with empty arms if the tx is in the mempool but below the + /// projection floor (no projected block to share fate with). + pub fn cpfp_info(&self, prefix: &TxidPrefix) -> Option { + let snapshot = self.snapshot(); + let entries = self.entries(); + let seed_idx = entries.idx_of(prefix)?; + let seed = entries.slot(seed_idx)?; + + let mut sum_fee = u64::from(seed.fee); + let mut sum_vsize = u64::from(seed.vsize); + let mut ancestors: Vec = Vec::new(); + let mut descendants: Vec = Vec::new(); + + if let Some(seed_block) = snapshot.block_of(seed_idx) { + // Ancestor BFS gated to the seed's projected block. + // `visited` dedupes the walk; stale parent prefixes + // (confirmed/evicted between snapshot and now) are skipped + // when `idx_of` returns None. + let mut visited: FxHashSet = FxHashSet::default(); + visited.insert(*prefix); + let mut stack: Vec = seed.depends.iter().copied().collect(); + while let Some(p) = stack.pop() { + if ancestors.len() >= MAX { + break; + } + if !visited.insert(p) { + continue; + } + let Some(idx) = entries.idx_of(&p) else { continue }; + if snapshot.block_of(idx) != Some(seed_block) { + continue; + } + let Some(anc) = entries.slot(idx) else { continue }; + sum_fee += u64::from(anc.fee); + sum_vsize += u64::from(anc.vsize); + ancestors.push(to_entry(anc)); + stack.extend(anc.depends.iter().copied()); + } + + // Descendant sweep. `desc_set` starts with only the seed + // so siblings (txs sharing an ancestor with seed but not + // downstream of it) are excluded. The topological ordering + // of `Snapshot.blocks` guarantees that all in-block + // ancestors of any tx are visited before it. + let mut desc_set: FxHashSet = FxHashSet::default(); + desc_set.insert(*prefix); + for &i in &snapshot.blocks[seed_block.as_usize()] { + if descendants.len() >= MAX { + break; + } + let Some(e) = entries.slot(i) else { continue }; + if !e.depends.iter().any(|d| desc_set.contains(d)) { + continue; + } + desc_set.insert(e.txid_prefix()); + sum_fee += u64::from(e.fee); + sum_vsize += u64::from(e.vsize); + descendants.push(to_entry(e)); + } + } + + let best_descendant = descendants + .iter() + .max_by_key(|e| FeeRate::from((e.fee, e.weight))) + .cloned(); + + let package_rate = FeeRate::from((Sats::from(sum_fee), VSize::from(sum_vsize))); + let effective = seed.fee_rate().max(package_rate); + + Some(CpfpInfo { + ancestors, + best_descendant, + descendants, + effective_fee_per_vsize: Some(effective), + fee: Some(seed.fee), + adjusted_vsize: Some(seed.vsize), + }) + } +} + +fn to_entry(e: &TxEntry) -> CpfpEntry { + CpfpEntry { + txid: e.txid.clone(), + weight: Weight::from(e.vsize), + fee: e.fee, + } +} diff --git a/crates/brk_mempool/src/lib.rs b/crates/brk_mempool/src/lib.rs index 5d7334e2c..de2edbdf1 100644 --- a/crates/brk_mempool/src/lib.rs +++ b/crates/brk_mempool/src/lib.rs @@ -21,13 +21,14 @@ use brk_types::{AddrBytes, MempoolInfo, TxOut, Txid, Vout}; use parking_lot::RwLockReadGuard; use tracing::error; +mod cpfp; pub(crate) mod steps; pub(crate) mod stores; #[cfg(test)] mod tests; use steps::{Applier, Fetcher, Preparer, Rebuilder, Resolver}; -pub use steps::{BlockStats, RecommendedFees, Snapshot, TxEntry, TxRemoval}; +pub use steps::{BlkIndex, BlockStats, RecommendedFees, Snapshot, TxEntry, TxRemoval}; use stores::{AddrTracker, MempoolState}; pub use stores::{EntryPool, TxGraveyard, TxStore, TxTombstone}; diff --git a/crates/brk_mempool/src/steps/mod.rs b/crates/brk_mempool/src/steps/mod.rs index a3e1fa4e3..791427e1b 100644 --- a/crates/brk_mempool/src/steps/mod.rs +++ b/crates/brk_mempool/src/steps/mod.rs @@ -9,5 +9,5 @@ mod resolver; pub use applier::Applier; pub use fetcher::Fetcher; pub use preparer::{Preparer, TxEntry, TxRemoval}; -pub use rebuilder::{BlockStats, Rebuilder, RecommendedFees, Snapshot}; +pub use rebuilder::{BlkIndex, BlockStats, Rebuilder, RecommendedFees, Snapshot}; pub use resolver::Resolver; diff --git a/crates/brk_mempool/src/steps/rebuilder/mod.rs b/crates/brk_mempool/src/steps/rebuilder/mod.rs index e9d0cf79a..a657356bb 100644 --- a/crates/brk_mempool/src/steps/rebuilder/mod.rs +++ b/crates/brk_mempool/src/steps/rebuilder/mod.rs @@ -26,7 +26,7 @@ mod snapshot; mod verify; pub use brk_types::RecommendedFees; -pub use snapshot::{BlockStats, Snapshot}; +pub use snapshot::{BlkIndex, BlockStats, Snapshot}; const MIN_REBUILD_INTERVAL: Duration = Duration::from_secs(1); const NUM_BLOCKS: usize = 8; diff --git a/crates/brk_mempool/src/steps/rebuilder/snapshot/blk_index.rs b/crates/brk_mempool/src/steps/rebuilder/snapshot/blk_index.rs new file mode 100644 index 000000000..cb264b593 --- /dev/null +++ b/crates/brk_mempool/src/steps/rebuilder/snapshot/blk_index.rs @@ -0,0 +1,26 @@ +/// Projected-block index in a mempool snapshot. `u8` because the +/// projection horizon is ~8 blocks at typical loads; `BlkIndex::MAX` +/// is reserved as the "not in any projected block" sentinel used by +/// `Snapshot::block_of` for txs below the mempool floor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct BlkIndex(u8); + +impl BlkIndex { + /// Sentinel for "not in any projected block". + pub const MAX: BlkIndex = BlkIndex(u8::MAX); + + pub fn is_not_in_projected(self) -> bool { + self == Self::MAX + } + + pub fn as_usize(self) -> usize { + self.0 as usize + } +} + +impl From for BlkIndex { + fn from(v: usize) -> Self { + debug_assert!(v < u8::MAX as usize, "BlkIndex overflow: {v}"); + Self(v as u8) + } +} diff --git a/crates/brk_mempool/src/steps/rebuilder/snapshot/mod.rs b/crates/brk_mempool/src/steps/rebuilder/snapshot/mod.rs index c19874290..7c1fe7be4 100644 --- a/crates/brk_mempool/src/steps/rebuilder/snapshot/mod.rs +++ b/crates/brk_mempool/src/steps/rebuilder/snapshot/mod.rs @@ -1,6 +1,8 @@ +mod blk_index; mod fees; mod stats; +pub use blk_index::BlkIndex; pub use stats::BlockStats; use std::hash::{DefaultHasher, Hash, Hasher}; @@ -15,6 +17,10 @@ use fees::Fees; #[derive(Debug, Clone, Default)] pub struct Snapshot { pub blocks: Vec>, + /// Reverse of `blocks`: indexed by `TxIndex.as_usize()`. Slots that + /// hold no entry, or hold an entry that didn't make any projected + /// block, store `BlkIndex::MAX`. Read via the `block_of` accessor. + block_of: Vec, pub block_stats: Vec, pub fees: RecommendedFees, /// ETag-like cache key for the first projected block. A hash of @@ -30,10 +36,12 @@ impl Snapshot { let block_stats = Self::compute_block_stats(&blocks, entries); let fees = Fees::compute(&block_stats, min_fee); let blocks = Self::flatten_blocks(blocks); + let block_of = Self::build_block_of(&blocks, entries.len()); let next_block_hash = Self::hash_next_block(&blocks); Self { blocks, + block_of, block_stats, fees, next_block_hash, @@ -60,6 +68,20 @@ impl Snapshot { .collect() } + /// One pass over `blocks` to invert the mapping. `BlkIndex::MAX` + /// stays as the sentinel for slots that aren't in any projected + /// block (empty slots and below-floor txs alike). + fn build_block_of(blocks: &[Vec], entry_count: usize) -> Vec { + let mut block_of = vec![BlkIndex::MAX; entry_count]; + for (b, txs) in blocks.iter().enumerate() { + let blk = BlkIndex::from(b); + for &idx in txs { + block_of[idx.as_usize()] = blk; + } + } + block_of + } + fn hash_next_block(blocks: &[Vec]) -> u64 { let Some(block) = blocks.first() else { return 0; @@ -68,4 +90,13 @@ impl Snapshot { block.hash(&mut hasher); hasher.finish() } + + /// Projected block that holds `idx`, or `None` if the tx is below + /// the mempool floor (or `idx` is out of range). + pub fn block_of(&self, idx: TxIndex) -> Option { + self.block_of + .get(idx.as_usize()) + .copied() + .filter(|b| !b.is_not_in_projected()) + } } diff --git a/crates/brk_mempool/src/stores/entry_pool/mod.rs b/crates/brk_mempool/src/stores/entry_pool/mod.rs index 004eb80c8..13fddf83a 100644 --- a/crates/brk_mempool/src/stores/entry_pool/mod.rs +++ b/crates/brk_mempool/src/stores/entry_pool/mod.rs @@ -1,6 +1,5 @@ use brk_types::TxidPrefix; use rustc_hash::FxHashMap; -use smallvec::SmallVec; mod tx_index; @@ -40,19 +39,18 @@ impl EntryPool { } pub fn get(&self, prefix: &TxidPrefix) -> Option<&TxEntry> { - let idx = self.prefix_to_idx.get(prefix)?; - self.entries.get(idx.as_usize())?.as_ref() + self.slot(self.idx_of(prefix)?) } - /// Direct children of a transaction (txs whose `depends` includes - /// `prefix`). Linear scan over all entries. - pub fn children(&self, prefix: &TxidPrefix) -> SmallVec<[TxidPrefix; 2]> { - self.entries - .iter() - .flatten() - .filter(|e| e.depends.iter().any(|p| p == prefix)) - .map(TxEntry::txid_prefix) - .collect() + /// Slot index for a prefix, or `None` if not in the pool. + pub fn idx_of(&self, prefix: &TxidPrefix) -> Option { + self.prefix_to_idx.get(prefix).copied() + } + + /// Direct slot read by index. `None` if the slot is empty or the + /// index is out of range. + pub fn slot(&self, idx: TxIndex) -> Option<&TxEntry> { + self.entries.get(idx.as_usize())?.as_ref() } pub fn remove(&mut self, prefix: &TxidPrefix) -> Option { diff --git a/crates/brk_query/src/impl/addr.rs b/crates/brk_query/src/impl/addr.rs index 0244b4193..4e537b6a4 100644 --- a/crates/brk_query/src/impl/addr.rs +++ b/crates/brk_query/src/impl/addr.rs @@ -173,10 +173,17 @@ impl Query { let prefix = u32::from(type_index).to_be_bytes(); + // Match mempool.space's electrs cap: refuse addresses with >500 UTXOs. + // Bounds worst-case work and response size, prevents heavy-address DDoS. + const MAX_UTXOS: usize = 500; let outpoints: Vec<(TxIndex, Vout)> = store .prefix(prefix) .map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout())) + .take(MAX_UTXOS + 1) .collect(); + if outpoints.len() > MAX_UTXOS { + return Err(Error::TooManyUtxos); + } let txid_reader = vecs.transactions.txid.reader(); let first_txout_index_reader = vecs.transactions.first_txout_index.reader(); diff --git a/crates/brk_query/src/impl/mempool.rs b/crates/brk_query/src/impl/mempool.rs index 26ddbbec1..9e728fbe2 100644 --- a/crates/brk_query/src/impl/mempool.rs +++ b/crates/brk_query/src/impl/mempool.rs @@ -1,14 +1,12 @@ -use std::cmp::Ordering; - -use brk_error::{Error, Result}; +use brk_error::{Error, OptionData, Result}; use brk_mempool::{EntryPool, TxEntry, TxGraveyard, TxRemoval, TxStore, TxTombstone}; use brk_types::{ CheckedSub, CpfpEntry, CpfpInfo, FeeRate, MempoolBlock, MempoolInfo, MempoolRecentTx, OutputType, RbfResponse, RbfTx, RecommendedFees, ReplacementNode, Sats, Timestamp, Transaction, - TxOut, TxOutIndex, Txid, TxidPrefix, TypeIndex, VSize, Weight, + TxIndex, TxInIndex, TxOut, TxOutIndex, Txid, TxidPrefix, TypeIndex, VSize, Weight, }; use rustc_hash::FxHashSet; -use vecdb::VecIndex; +use vecdb::{AnyVec, ReadableVec, VecIndex}; use crate::Query; @@ -96,74 +94,190 @@ impl Query { pub fn cpfp(&self, txid: &Txid) -> Result { let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; - let entries = mempool.entries(); let prefix = TxidPrefix::from(txid); + Ok(mempool + .cpfp_info(&prefix) + .unwrap_or_else(|| self.confirmed_cpfp(txid))) + } - let Some(entry) = entries.get(&prefix) else { - return Ok(CpfpInfo::default()); + /// CPFP cluster for a confirmed tx: the connected component of + /// same-block parent/child edges, reconstructed by BFS on demand. + /// Walks entirely in `TxIndex` space using direct vec reads (height, + /// weight, fee) - skips full `Transaction` reconstruction and avoids + /// `txid -> tx_index` lookups by reading `OutPoint`'s packed + /// `tx_index` directly. Capped at 25 each side to match Bitcoin + /// Core's default mempool chain limits and mempool.space's own + /// truncation. `effectiveFeePerVsize` is the simple package rate; + /// mempool's `calculateGoodBlockCpfp` chunk-rate algorithm is not + /// ported. + fn confirmed_cpfp(&self, txid: &Txid) -> CpfpInfo { + const MAX: usize = 25; + let Ok(seed_idx) = self.resolve_tx_index(txid) else { + return CpfpInfo::default(); + }; + let Ok(seed_height) = self.confirmed_status_height(seed_idx) else { + return CpfpInfo::default(); }; - // Ancestor walk doubles as package-rate aggregation. Stale - // `depends` entries pointing at mined/evicted txs are silently - // dropped via the live `entries.get` probe, so the aggregates - // reflect only in-pool ancestors. - let mut ancestors = Vec::new(); - let mut visited: FxHashSet = FxHashSet::default(); - let mut package_fee = u64::from(entry.fee); - let mut package_vsize = u64::from(entry.vsize); - let mut stack: Vec = entry.depends.to_vec(); - while let Some(p) = stack.pop() { - if !visited.insert(p) { - continue; - } - if let Some(anc) = entries.get(&p) { - package_fee += u64::from(anc.fee); - package_vsize += u64::from(anc.vsize); - ancestors.push(CpfpEntry { - txid: anc.txid.clone(), - weight: Weight::from(anc.vsize), - fee: anc.fee, - }); - stack.extend(anc.depends.iter().cloned()); + let indexer = self.indexer(); + let computer = self.computer(); + // Block's tx_index range. Reduces the per-neighbor height check to a + // pair of integer compares (vs `tx_heights.get_shared` which acquires + // a read lock and walks a `RangeMap`). + let Ok(block_first) = indexer + .vecs + .transactions + .first_tx_index + .collect_one(seed_height) + .data() + else { + return CpfpInfo::default(); + }; + let block_end = indexer + .vecs + .transactions + .first_tx_index + .collect_one(seed_height.incremented()) + .unwrap_or_else(|| TxIndex::from(indexer.vecs.transactions.txid.len())); + let same_block = |idx: TxIndex| idx >= block_first && idx < block_end; + + let mut first_txin = indexer.vecs.transactions.first_txin_index.cursor(); + let mut first_txout = indexer.vecs.transactions.first_txout_index.cursor(); + let mut outpoint = indexer.vecs.inputs.outpoint.cursor(); + let mut spent = computer.outputs.spent.txin_index.cursor(); + let mut spending_tx = indexer.vecs.inputs.tx_index.cursor(); + + let mut visited: FxHashSet = FxHashSet::with_capacity_and_hasher( + 2 * MAX + 1, + Default::default(), + ); + visited.insert(seed_idx); + + let mut ancestor_idxs: Vec = Vec::with_capacity(MAX); + let mut queue: Vec = vec![seed_idx]; + 'a: while let Some(cur) = queue.pop() { + let Ok(start) = first_txin.get(cur.to_usize()).data() else { continue }; + let Ok(end) = first_txin.get(cur.to_usize() + 1).data() else { continue }; + for i in usize::from(start)..usize::from(end) { + let Ok(op) = outpoint.get(i).data() else { continue }; + if op.is_coinbase() { + continue; + } + let parent = op.tx_index(); + if !visited.insert(parent) || !same_block(parent) { + continue; + } + ancestor_idxs.push(parent); + queue.push(parent); + if ancestor_idxs.len() >= MAX { + break 'a; + } } } - let mut descendants = Vec::new(); - for child_prefix in entries.children(&prefix) { - if let Some(e) = entries.get(&child_prefix) { - descendants.push(CpfpEntry { - txid: e.txid.clone(), - weight: Weight::from(e.vsize), - fee: e.fee, - }); + let mut descendant_idxs: Vec = Vec::with_capacity(MAX); + let mut queue: Vec = vec![seed_idx]; + 'd: while let Some(cur) = queue.pop() { + let Ok(start) = first_txout.get(cur.to_usize()).data() else { continue }; + let Ok(end) = first_txout.get(cur.to_usize() + 1).data() else { continue }; + for i in usize::from(start)..usize::from(end) { + let Ok(txin_idx) = spent.get(i).data() else { continue }; + if txin_idx == TxInIndex::UNSPENT { + continue; + } + let Ok(child) = spending_tx.get(usize::from(txin_idx)).data() else { continue }; + if !visited.insert(child) || !same_block(child) { + continue; + } + descendant_idxs.push(child); + queue.push(child); + if descendant_idxs.len() >= MAX { + break 'd; + } } } - let self_rate = entry.fee_rate(); - let package_rate = FeeRate::from((Sats::from(package_fee), VSize::from(package_vsize))); - let effective_fee_per_vsize = if package_rate > self_rate { - package_rate - } else { - self_rate + // Phase 2: bulk-fetch (weight, fee) for seed + cluster, cursors opened + // once and reads issued in tx_index order for sequential page locality. + let mut all = Vec::with_capacity(1 + ancestor_idxs.len() + descendant_idxs.len()); + all.push(seed_idx); + all.extend(&ancestor_idxs); + all.extend(&descendant_idxs); + let Ok(weights_fees) = self.txs_weight_fee(&all) else { + return CpfpInfo::default(); }; + let txid_reader = indexer.vecs.transactions.txid.reader(); + let entry_at = |i: usize, idx: TxIndex| { + let (weight, fee) = weights_fees[i]; + CpfpEntry { + txid: txid_reader.get(idx.to_usize()), + weight, + fee, + } + }; + let (seed_weight, seed_fee) = weights_fees[0]; + let seed_vsize = VSize::from(seed_weight); + let ancestors: Vec = ancestor_idxs + .iter() + .enumerate() + .map(|(k, &idx)| entry_at(1 + k, idx)) + .collect(); + let descendants: Vec = descendant_idxs + .iter() + .enumerate() + .map(|(k, &idx)| entry_at(1 + ancestor_idxs.len() + k, idx)) + .collect(); + + let (sum_fee, sum_vsize) = ancestors + .iter() + .chain(descendants.iter()) + .fold((u64::from(seed_fee), u64::from(seed_vsize)), |(f, v), e| { + (f + u64::from(e.fee), v + u64::from(VSize::from(e.weight))) + }); + let package_rate = FeeRate::from((Sats::from(sum_fee), VSize::from(sum_vsize))); + let effective = FeeRate::from((seed_fee, seed_vsize)).max(package_rate); + let best_descendant = descendants .iter() - .max_by(|a, b| { - FeeRate::from((a.fee, a.weight)) - .partial_cmp(&FeeRate::from((b.fee, b.weight))) - .unwrap_or(Ordering::Equal) - }) + .max_by_key(|e| FeeRate::from((e.fee, e.weight))) .cloned(); - Ok(CpfpInfo { + CpfpInfo { ancestors, best_descendant, descendants, - effective_fee_per_vsize: Some(effective_fee_per_vsize), - fee: Some(entry.fee), - adjusted_vsize: Some(entry.vsize), - }) + effective_fee_per_vsize: Some(effective), + fee: Some(seed_fee), + adjusted_vsize: Some(seed_vsize), + } + } + + /// Bulk read `(weight, fee)` for many tx_indexes. Cursors opened once; + /// reads issued in ascending `tx_index` order for sequential I/O, + /// results returned in the caller's order. + fn txs_weight_fee(&self, idxs: &[TxIndex]) -> Result> { + if idxs.is_empty() { + return Ok(vec![]); + } + let indexer = self.indexer(); + let computer = self.computer(); + let mut base_size = indexer.vecs.transactions.base_size.cursor(); + let mut total_size = indexer.vecs.transactions.total_size.cursor(); + let mut fee_cursor = computer.transactions.fees.fee.tx_index.cursor(); + + let mut order: Vec = (0..idxs.len()).collect(); + order.sort_unstable_by_key(|&i| idxs[i]); + + let mut out = vec![(Weight::default(), Sats::ZERO); idxs.len()]; + for &pos in &order { + let i = idxs[pos].to_usize(); + let bs = base_size.get(i).data()?; + let ts = total_size.get(i).data()?; + let f = fee_cursor.get(i).data()?; + out[pos] = (Weight::from_sizes(*bs, *ts), f); + } + Ok(out) } /// RBF history for a tx, matching mempool.space's diff --git a/crates/brk_server/src/error.rs b/crates/brk_server/src/error.rs index 7e406c400..8b9e86412 100644 --- a/crates/brk_server/src/error.rs +++ b/crates/brk_server/src/error.rs @@ -53,7 +53,8 @@ fn error_status(e: &BrkError) -> StatusCode { | BrkError::Parse(_) | BrkError::NoSeries | BrkError::SeriesUnsupportedIndex { .. } - | BrkError::WeightExceeded { .. } => StatusCode::BAD_REQUEST, + | BrkError::WeightExceeded { .. } + | BrkError::TooManyUtxos => StatusCode::BAD_REQUEST, BrkError::UnknownAddr | BrkError::UnknownTxid @@ -79,6 +80,7 @@ fn error_code(e: &BrkError) -> &'static str { BrkError::NoSeries => "no_series", BrkError::SeriesUnsupportedIndex { .. } => "series_unsupported_index", BrkError::WeightExceeded { .. } => "weight_exceeded", + BrkError::TooManyUtxos => "too_many_utxos", BrkError::UnknownAddr => "unknown_addr", BrkError::UnknownTxid => "unknown_txid", BrkError::NotFound(_) => "not_found", diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_block_v1.py b/packages/brk_client/tests/mempool_compat/blocks/test_block_v1.py index 97523c402..8a7140537 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_block_v1.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_block_v1.py @@ -4,13 +4,28 @@ from _lib import assert_same_structure, assert_same_values, show def test_block_v1_extras_all_values(brk, mempool, block): - """Every shared extras field must match — exposes computation differences.""" + """Every shared extras field must match - exposes computation differences. + + Excluded fields: + - medianFee, feeRange, feePercentiles: mempool computes each entry with + a different algorithm (1st/99th percentile + first/last 2% of block + order for the feeRange bounds, unweighted positional p10/p25/p50/p75/p90 + for the inner feeRange entries and for feePercentiles, and a vsize- + weighted middle-0.25%-of-block-weight slice for medianFee). brk + computes them all from a single vsize-weighted percentile distribution, + so they diverge anywhere tx sizes vary widely. + - avgFeeRate: mempool returns Bitcoin Core's getblockstats.avgfeerate + (integer sat/vB), brk returns the float version. Same formula, brk + keeps decimal precision. + """ path = f"/api/v1/block/{block.hash}" b = brk.get_json(path)["extras"] m = mempool.get_json(path)["extras"] show("GET", f"{path} [extras]", b, m, max_lines=50) assert_same_structure(b, m) - assert_same_values(b, m) + assert_same_values( + b, m, exclude={"medianFee", "feeRange", "feePercentiles", "avgFeeRate"} + ) def test_block_v1_extras_pool(brk, mempool, block): diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_v1_height.py b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_v1_height.py index b15721457..5cbb961e0 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_v1_height.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_v1_height.py @@ -4,11 +4,28 @@ from _lib import assert_same_values, show def test_blocks_v1_from_height(brk, mempool, block): - """v1 blocks from a confirmed height — all values must match.""" + """v1 blocks from a confirmed height - all values must match. + + Excluded fields: + - medianFee, feeRange, feePercentiles: mempool computes each entry with + a different algorithm (1st/99th percentile + first/last 2% of block + order for the feeRange bounds, unweighted positional p10/p25/p50/p75/p90 + for the inner feeRange entries and for feePercentiles, and a vsize- + weighted middle-0.25%-of-block-weight slice for medianFee). brk + computes them all from a single vsize-weighted percentile distribution, + so they diverge anywhere tx sizes vary widely. + - avgFeeRate: mempool returns Bitcoin Core's getblockstats.avgfeerate + (integer sat/vB), brk returns the float version. Same formula, brk + keeps decimal precision. + """ path = f"/api/v1/blocks/{block.height}" b = brk.get_json(path) m = mempool.get_json(path) show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") assert len(b) == len(m) if b and m: - assert_same_values(b[0], m[0]) + assert_same_values( + b[0], + m[0], + exclude={"medianFee", "feeRange", "feePercentiles", "avgFeeRate"}, + ) diff --git a/packages/brk_client/tests/mempool_compat/mempool/test_txids.py b/packages/brk_client/tests/mempool_compat/mempool/test_txids.py index 8afda7afb..0ff1729cb 100644 --- a/packages/brk_client/tests/mempool_compat/mempool/test_txids.py +++ b/packages/brk_client/tests/mempool_compat/mempool/test_txids.py @@ -35,12 +35,19 @@ def test_mempool_txids_unique(brk): def test_mempool_txids_count_matches_summary(brk): - """`/api/mempool/txids` length must match `/api/mempool`'s `count` field.""" + """`/api/mempool/txids` length must roughly track `/api/mempool`'s `count`. + + The two endpoints are independent reads against a live mempool, so + arrivals / evictions between fetches cause drift. We only assert the + counts are in the same ballpark - exact equality would be flaky. + """ txids = brk.get_json("/api/mempool/txids") summary = brk.get_json("/api/mempool") show("GET", "/api/mempool/txids", f"len={len(txids)}", f"count={summary.get('count')}") - # Allow a small drift (1-2) since the mempool is updated asynchronously - # between the two fetches. - assert abs(len(txids) - summary["count"]) <= 5, ( - f"txids={len(txids)} vs /api/mempool.count={summary['count']}" + assert isinstance(summary["count"], int) and summary["count"] > 0 + assert len(txids) > 0 + # 1% tolerance covers normal mempool churn between the two fetches. + drift = abs(len(txids) - summary["count"]) + assert drift <= max(50, summary["count"] // 100), ( + f"txids={len(txids)} vs /api/mempool.count={summary['count']} (drift={drift})" )