mempool: fixes

This commit is contained in:
nym21
2026-04-30 12:38:34 +02:00
parent 43f3be4924
commit 9b42b40a36
14 changed files with 408 additions and 79 deletions

View File

@@ -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),

View File

@@ -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<CpfpInfo> {
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<CpfpEntry> = Vec::new();
let mut descendants: Vec<CpfpEntry> = 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<TxidPrefix> = FxHashSet::default();
visited.insert(*prefix);
let mut stack: Vec<TxidPrefix> = 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<TxidPrefix> = 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,
}
}

View File

@@ -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};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<usize> for BlkIndex {
fn from(v: usize) -> Self {
debug_assert!(v < u8::MAX as usize, "BlkIndex overflow: {v}");
Self(v as u8)
}
}

View File

@@ -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<Vec<TxIndex>>,
/// 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<BlkIndex>,
pub block_stats: Vec<BlockStats>,
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<TxIndex>], entry_count: usize) -> Vec<BlkIndex> {
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<TxIndex>]) -> 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<BlkIndex> {
self.block_of
.get(idx.as_usize())
.copied()
.filter(|b| !b.is_not_in_projected())
}
}

View File

@@ -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<TxIndex> {
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<TxEntry> {

View File

@@ -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();

View File

@@ -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<CpfpInfo> {
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<TxidPrefix> = FxHashSet::default();
let mut package_fee = u64::from(entry.fee);
let mut package_vsize = u64::from(entry.vsize);
let mut stack: Vec<TxidPrefix> = 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<TxIndex> = FxHashSet::with_capacity_and_hasher(
2 * MAX + 1,
Default::default(),
);
visited.insert(seed_idx);
let mut ancestor_idxs: Vec<TxIndex> = Vec::with_capacity(MAX);
let mut queue: Vec<TxIndex> = 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<TxIndex> = Vec::with_capacity(MAX);
let mut queue: Vec<TxIndex> = 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<CpfpEntry> = ancestor_idxs
.iter()
.enumerate()
.map(|(k, &idx)| entry_at(1 + k, idx))
.collect();
let descendants: Vec<CpfpEntry> = 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<Vec<(Weight, Sats)>> {
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<usize> = (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

View File

@@ -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",

View File

@@ -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):

View File

@@ -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"},
)

View File

@@ -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})"
)