mempool: fixes

This commit is contained in:
nym21
2026-04-21 12:43:50 +02:00
parent 327873d010
commit 6afce0bbdc
20 changed files with 272 additions and 300 deletions
Generated
+14 -9
View File
@@ -1979,13 +1979,12 @@ dependencies = [
[[package]] [[package]]
name = "libmimalloc-sys" name = "libmimalloc-sys"
version = "0.1.44" version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870" checksum = "bc89deee4af0429081d2a518c0431ae068222a5a262a3bc6ff4d8535ec2e02fe"
dependencies = [ dependencies = [
"cc", "cc",
"cty", "cty",
"libc",
] ]
[[package]] [[package]]
@@ -2101,9 +2100,9 @@ dependencies = [
[[package]] [[package]]
name = "mimalloc" name = "mimalloc"
version = "0.1.48" version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8" checksum = "aca3c01a711f395b4257b81674c0e90e8dd1f1e62c4b7db45f684cc7a4fcb18a"
dependencies = [ dependencies = [
"libmimalloc-sys", "libmimalloc-sys",
] ]
@@ -3450,11 +3449,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasip2" name = "wasip2"
version = "1.0.2+wasi-0.2.9" version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [ dependencies = [
"wit-bindgen", "wit-bindgen 0.57.1",
] ]
[[package]] [[package]]
@@ -3463,7 +3462,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [ dependencies = [
"wit-bindgen", "wit-bindgen 0.51.0",
] ]
[[package]] [[package]]
@@ -3766,6 +3765,12 @@ dependencies = [
"wit-bindgen-rust-macro", "wit-bindgen-rust-macro",
] ]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]] [[package]]
name = "wit-bindgen-core" name = "wit-bindgen-core"
version = "0.51.0" version = "0.51.0"
+2 -2
View File
@@ -8,5 +8,5 @@ homepage.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
libmimalloc-sys = { version = "0.1.44", features = ["extended"] } libmimalloc-sys = { version = "0.1.46", features = ["extended"] }
mimalloc = { version = "0.1.48", features = ["v3"] } mimalloc = { version = "0.1.49" }
@@ -7,10 +7,13 @@ use crate::types::PoolIndex;
/// Entry in the priority heap for transaction selection. /// Entry in the priority heap for transaction selection.
/// ///
/// Stores a snapshot of the score at insertion time. /// Stores a snapshot of the score at insertion time. The `generation` field
/// lets the selector detect and skip stale entries after descendants are
/// re-pushed with updated ancestor totals.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct HeapEntry { pub struct HeapEntry {
pub pool_index: PoolIndex, pub pool_index: PoolIndex,
pub generation: u32,
ancestor_fee: Sats, ancestor_fee: Sats,
ancestor_vsize: VSize, ancestor_vsize: VSize,
} }
@@ -19,6 +22,7 @@ impl HeapEntry {
pub fn new(node: &TxNode) -> Self { pub fn new(node: &TxNode) -> Self {
Self { Self {
pool_index: node.pool_index, pool_index: node.pool_index,
generation: node.generation,
ancestor_fee: node.ancestor_fee, ancestor_fee: node.ancestor_fee,
ancestor_vsize: node.ancestor_vsize, ancestor_vsize: node.ancestor_vsize,
} }
@@ -39,7 +43,7 @@ impl HeapEntry {
impl PartialEq for HeapEntry { impl PartialEq for HeapEntry {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
self.pool_index == other.pool_index self.cmp(other).is_eq()
} }
} }
+10 -9
View File
@@ -5,28 +5,29 @@ mod partitioner;
mod selector; mod selector;
mod tx_node; mod tx_node;
use crate::{entry::Entry, types::SelectedTx}; pub use package::Package;
use crate::entry::Entry;
/// Target vsize per block (~1MB, derived from 4MW weight limit). /// Target vsize per block (~1MB, derived from 4MW weight limit).
const BLOCK_VSIZE: u64 = 1_000_000; const BLOCK_VSIZE: u64 = 1_000_000;
/// Number of projected blocks to build. /// Number of projected blocks to build (last one is a catch-all overflow).
const NUM_BLOCKS: usize = 8; const NUM_BLOCKS: usize = 8;
/// Build projected blocks from mempool entries. /// Build projected blocks from mempool entries.
/// ///
/// Returns transactions grouped by projected block, sorted by fee rate. /// Returns packages grouped by projected block. Blocks 1 through
pub fn build_projected_blocks(entries: &[Option<Entry>]) -> Vec<Vec<SelectedTx>> { /// `NUM_BLOCKS - 1` are standard ~1MB blocks sorted by placement rate
// Build dependency graph /// descending; the final block is a catch-all containing every remaining
/// package (matches mempool.space behavior).
pub fn build_projected_blocks(entries: &[Option<Entry>]) -> Vec<Vec<Package>> {
let mut graph = graph::build_graph(entries); let mut graph = graph::build_graph(entries);
if graph.is_empty() { if graph.is_empty() {
return Vec::new(); return Vec::new();
} }
// Select transactions into packages let packages = selector::select_packages(&mut graph);
let packages = selector::select_packages(&mut graph, NUM_BLOCKS);
// Partition packages into blocks
partitioner::partition_into_blocks(packages, NUM_BLOCKS) partitioner::partition_into_blocks(packages, NUM_BLOCKS)
} }
+17 -14
View File
@@ -1,20 +1,25 @@
use brk_types::FeeRate; use brk_types::FeeRate;
use crate::types::{SelectedTx, TxIndex}; use crate::types::TxIndex;
/// A CPFP package - transactions that must be included together. /// A CPFP package: transactions the selector decided to mine together
/// because a child pays for its parent.
/// ///
/// When a child pays for its parent (CPFP), both must be in the same block. /// Carries two rates:
/// The package fee rate is the combined rate of all transactions. /// - `fee_rate` is the package's own rate (sum of fees / sum of vsizes),
/// i.e. what a miner collects per vsize when the package is mined.
/// Used for per-tx fee stats and user-facing recommendations.
/// - `placement_rate` is the key the partitioner sorts by. It's the own
/// rate clamped below by the `placement_rate` of any ancestor packages,
/// so that sorting packages by this rate descending keeps dependent
/// packages in topological order even when a child's own rate exceeds
/// its parent's (possible in branching CPFP).
pub struct Package { pub struct Package {
/// Transactions in topological order (parents before children) /// Transactions in topological order (parents before children).
pub txs: Vec<SelectedTx>, pub txs: Vec<TxIndex>,
/// Combined vsize of all transactions
pub vsize: u64, pub vsize: u64,
/// Package fee rate
pub fee_rate: FeeRate, pub fee_rate: FeeRate,
pub placement_rate: FeeRate,
} }
impl Package { impl Package {
@@ -23,14 +28,12 @@ impl Package {
txs: Vec::new(), txs: Vec::new(),
vsize: 0, vsize: 0,
fee_rate, fee_rate,
placement_rate: fee_rate,
} }
} }
pub fn add_tx(&mut self, tx_index: TxIndex, vsize: u64) { pub fn add_tx(&mut self, tx_index: TxIndex, vsize: u64) {
self.txs.push(SelectedTx { self.txs.push(tx_index);
tx_index,
effective_fee_rate: self.fee_rate,
});
self.vsize += vsize; self.vsize += vsize;
} }
} }
@@ -1,103 +1,123 @@
use std::cmp::Reverse; use std::cmp::Reverse;
use super::{BLOCK_VSIZE, package::Package}; use super::{BLOCK_VSIZE, package::Package};
use crate::types::SelectedTx;
/// How many packages to look ahead when current doesn't fit. /// How many packages to look ahead when the current one doesn't fit.
const LOOK_AHEAD: usize = 100; const LOOK_AHEAD_COUNT: usize = 100;
/// Partition packages into blocks by fee rate. /// Partition packages into blocks by placement rate.
/// ///
/// Packages are sorted by fee rate descending, then placed into blocks. /// The first `num_blocks - 1` blocks are packed greedily into ~`BLOCK_VSIZE`
/// When a package doesn't fit, we look ahead for smaller packages that do. /// chunks. The final block is a catch-all containing every remaining
/// Atomic packages are never split across blocks. /// package, so no low-rate tx is silently dropped from the projection
/// (matches mempool.space's last-block behavior).
pub fn partition_into_blocks( pub fn partition_into_blocks(
mut packages: Vec<Package>, mut packages: Vec<Package>,
num_blocks: usize, num_blocks: usize,
) -> Vec<Vec<SelectedTx>> { ) -> Vec<Vec<Package>> {
packages.sort_unstable_by_key(|p| Reverse(p.fee_rate)); // Stable sort for deterministic output across equal placement rates.
// Topology across dependent packages is already enforced by the
// placement_rate cap in the selector.
packages.sort_by_key(|p| Reverse(p.placement_rate));
let mut blocks: Vec<Vec<SelectedTx>> = Vec::with_capacity(num_blocks); let mut slots: Vec<Option<Package>> = packages.into_iter().map(Some).collect();
let mut current_block: Vec<SelectedTx> = Vec::new(); let mut blocks: Vec<Vec<Package>> = Vec::with_capacity(num_blocks);
let mut current_vsize: u64 = 0; let normal_blocks = num_blocks.saturating_sub(1);
let mut used = vec![false; packages.len()];
let mut idx = 0; let mut idx = fill_normal_blocks(&mut slots, &mut blocks, normal_blocks);
while idx < packages.len() && blocks.len() < num_blocks {
if used[idx] { if blocks.len() < num_blocks {
let mut overflow: Vec<Package> = Vec::new();
while idx < slots.len() {
if let Some(pkg) = slots[idx].take() {
overflow.push(pkg);
}
idx += 1; idx += 1;
continue;
} }
if !overflow.is_empty() {
let remaining_space = BLOCK_VSIZE.saturating_sub(current_vsize); blocks.push(overflow);
let package = &packages[idx];
if package.vsize <= remaining_space {
current_block.extend(package.txs.iter().copied());
current_vsize += package.vsize;
used[idx] = true;
idx += 1;
continue;
} }
// Package doesn't fit
if current_block.is_empty() {
// Empty block: add oversized package anyway
current_block.extend(package.txs.iter().copied());
current_vsize += package.vsize;
used[idx] = true;
idx += 1;
continue;
}
// Look ahead for a smaller package that fits
let found_smaller = try_fill_with_smaller(
&packages,
&mut used,
idx,
remaining_space,
&mut current_block,
&mut current_vsize,
);
if !found_smaller {
// No package fits, finalize current block
blocks.push(std::mem::take(&mut current_block));
current_vsize = 0;
}
}
if !current_block.is_empty() && blocks.len() < num_blocks {
blocks.push(current_block);
} }
blocks blocks
} }
/// Try to find a smaller package in the look-ahead window that fits. /// Greedily pack packages into up to `target_blocks` chunks of `BLOCK_VSIZE`.
fn try_fill_with_smaller( /// Returns the first `slots` index we stopped at.
packages: &[Package], fn fill_normal_blocks(
used: &mut [bool], slots: &mut [Option<Package>],
start: usize, blocks: &mut Vec<Vec<Package>>,
remaining_space: u64, target_blocks: usize,
block: &mut Vec<SelectedTx>, ) -> usize {
block_vsize: &mut u64, let mut current_block: Vec<Package> = Vec::new();
) -> bool { let mut current_vsize: u64 = 0;
let end = (start + LOOK_AHEAD).min(packages.len()); let mut idx = 0;
for idx in (start + 1)..end { while idx < slots.len() && blocks.len() < target_blocks {
if used[idx] { let Some(pkg) = &slots[idx] else {
idx += 1;
continue;
};
let remaining_space = BLOCK_VSIZE.saturating_sub(current_vsize);
if pkg.vsize <= remaining_space {
let pkg = slots[idx].take().unwrap();
current_vsize += pkg.vsize;
current_block.push(pkg);
idx += 1;
continue; continue;
} }
let package = &packages[idx]; if current_block.is_empty() {
if package.vsize <= remaining_space { // Oversized package with no partial block to preserve; take it
block.extend(package.txs.iter().copied()); // anyway so we don't stall on a package larger than BLOCK_VSIZE.
*block_vsize += package.vsize; let pkg = slots[idx].take().unwrap();
used[idx] = true; current_vsize += pkg.vsize;
current_block.push(pkg);
idx += 1;
continue;
}
if try_fill_with_smaller(
slots,
idx,
remaining_space,
&mut current_block,
&mut current_vsize,
) {
continue;
}
blocks.push(std::mem::take(&mut current_block));
current_vsize = 0;
}
if !current_block.is_empty() && blocks.len() < target_blocks {
blocks.push(current_block);
}
idx
}
/// Scan the look-ahead window for a package small enough to fit in the
/// remaining space and move it into the current block.
fn try_fill_with_smaller(
slots: &mut [Option<Package>],
start: usize,
remaining_space: u64,
block: &mut Vec<Package>,
block_vsize: &mut u64,
) -> bool {
let end = (start + LOOK_AHEAD_COUNT).min(slots.len());
for idx in (start + 1)..end {
let Some(pkg) = &slots[idx] else { continue };
if pkg.vsize <= remaining_space {
let pkg = slots[idx].take().unwrap();
*block_vsize += pkg.vsize;
block.push(pkg);
return true; return true;
} }
} }
false false
} }
@@ -4,53 +4,61 @@ use brk_types::FeeRate;
use rustc_hash::FxHashSet; use rustc_hash::FxHashSet;
use smallvec::SmallVec; use smallvec::SmallVec;
use super::{BLOCK_VSIZE, graph::Graph, heap_entry::HeapEntry, package::Package}; use super::{graph::Graph, heap_entry::HeapEntry, package::Package};
use crate::types::PoolIndex; use crate::types::PoolIndex;
/// Select transactions from the graph and group into CPFP packages. /// Sentinel for `package_of` entries that haven't been placed in a package yet.
pub fn select_packages(graph: &mut Graph, num_blocks: usize) -> Vec<Package> { const UNASSIGNED: u32 = u32::MAX;
let target_vsize = BLOCK_VSIZE * num_blocks as u64;
let mut total_vsize: u64 = 0; /// Select transactions from the graph and group them into CPFP packages,
let mut packages: Vec<Package> = Vec::new(); /// running until every unselected tx has been placed into a package.
pub fn select_packages(graph: &mut Graph) -> Vec<Package> {
let mut packages: Vec<Package> = Vec::new();
let mut package_of: Vec<u32> = vec![UNASSIGNED; graph.len()];
// Initialize heap with all transactions
let mut heap: BinaryHeap<HeapEntry> = (0..graph.len()) let mut heap: BinaryHeap<HeapEntry> = (0..graph.len())
.map(|i| HeapEntry::new(&graph[PoolIndex::from(i)])) .map(|i| HeapEntry::new(&graph[PoolIndex::from(i)]))
.collect(); .collect();
while let Some(entry) = heap.pop() { while let Some(entry) = heap.pop() {
let node = &graph[entry.pool_index]; let node = &graph[entry.pool_index];
if node.selected || entry.generation != node.generation {
// Skip if already selected or entry is stale
if node.selected {
continue; continue;
} }
// Package fee rate at selection time let own_rate = FeeRate::from((node.ancestor_fee, node.ancestor_vsize));
let package_rate = FeeRate::from((node.ancestor_fee, node.ancestor_vsize)); let package_idx = packages.len() as u32;
let mut package = Package::new(own_rate);
// Select this tx and all unselected ancestors (parents first) for pool_idx in select_with_ancestors(graph, entry.pool_index) {
let ancestors = select_with_ancestors(graph, entry.pool_index); let tx = &graph[pool_idx];
package.add_tx(tx.tx_index, u64::from(tx.vsize));
package_of[pool_idx.as_usize()] = package_idx;
// Cap placement_rate by any ancestor packages this tx depends on.
// select_with_ancestors returns parents before children, so a
// parent sitting in this same package already has package_of
// set to package_idx; only parents in earlier packages matter.
for &parent in &tx.parents {
let parent_pkg = package_of[parent.as_usize()];
if parent_pkg != package_idx && parent_pkg != UNASSIGNED {
package.placement_rate = package
.placement_rate
.min(packages[parent_pkg as usize].placement_rate);
}
}
let mut package = Package::new(package_rate);
for pool_idx in ancestors {
let vsize = u64::from(graph[pool_idx].vsize);
package.add_tx(graph[pool_idx].tx_index, vsize);
update_descendants(graph, pool_idx, &mut heap); update_descendants(graph, pool_idx, &mut heap);
} }
total_vsize += package.vsize;
packages.push(package); packages.push(package);
if total_vsize >= target_vsize {
break;
}
} }
packages packages
} }
/// Select a tx and all its unselected ancestors in topological order. /// Return `pool_idx` and all its unselected ancestors in topological order
/// (parents before children), marking each one selected as we go.
fn select_with_ancestors(graph: &mut Graph, pool_idx: PoolIndex) -> SmallVec<[PoolIndex; 8]> { fn select_with_ancestors(graph: &mut Graph, pool_idx: PoolIndex) -> SmallVec<[PoolIndex; 8]> {
let mut result: SmallVec<[PoolIndex; 8]> = SmallVec::new(); let mut result: SmallVec<[PoolIndex; 8]> = SmallVec::new();
let mut stack: SmallVec<[(PoolIndex, bool); 16]> = smallvec::smallvec![(pool_idx, false)]; let mut stack: SmallVec<[(PoolIndex, bool); 16]> = smallvec::smallvec![(pool_idx, false)];
@@ -76,7 +84,8 @@ fn select_with_ancestors(graph: &mut Graph, pool_idx: PoolIndex) -> SmallVec<[Po
result result
} }
/// Update descendants' ancestor scores after selecting a tx. /// Subtract the selected tx's fee and vsize from every unselected
/// descendant's ancestor totals, and re-push updated entries to the heap.
fn update_descendants( fn update_descendants(
graph: &mut Graph, graph: &mut Graph,
selected_idx: PoolIndex, selected_idx: PoolIndex,
@@ -96,19 +105,17 @@ fn update_descendants(
} }
let child = &mut graph[child_idx]; let child = &mut graph[child_idx];
if child.selected {
continue; // Walk through selected intermediates: descendants behind them still
// need their ancestor totals reduced, otherwise CPFP chains with
// already-selected parents keep inflated scores and get split.
if !child.selected {
child.ancestor_fee -= selected_fee;
child.ancestor_vsize -= selected_vsize;
child.generation += 1;
heap.push(HeapEntry::new(child));
} }
// Update ancestor totals
child.ancestor_fee -= selected_fee;
child.ancestor_vsize -= selected_vsize;
// Increment generation and re-push to heap
child.generation += 1;
heap.push(HeapEntry::new(child));
// Continue to grandchildren
stack.extend(child.children.iter().copied()); stack.extend(child.children.iter().copied());
} }
} }
+3 -13
View File
@@ -1,4 +1,4 @@
use brk_types::{FeeRate, MempoolEntryInfo, Sats, Timestamp, Txid, TxidPrefix, VSize}; use brk_types::{FeeRate, Sats, Timestamp, Txid, TxidPrefix, VSize};
use smallvec::SmallVec; use smallvec::SmallVec;
/// A mempool transaction entry. /// A mempool transaction entry.
@@ -10,6 +10,8 @@ pub struct Entry {
pub txid: Txid, pub txid: Txid,
pub fee: Sats, pub fee: Sats,
pub vsize: VSize, pub vsize: VSize,
/// Serialized tx size in bytes (witness + non-witness), from the raw tx.
pub size: u64,
/// Pre-computed ancestor fee (self + all ancestors, no double-counting) /// Pre-computed ancestor fee (self + all ancestors, no double-counting)
pub ancestor_fee: Sats, pub ancestor_fee: Sats,
/// Pre-computed ancestor vsize (self + all ancestors, no double-counting) /// Pre-computed ancestor vsize (self + all ancestors, no double-counting)
@@ -21,18 +23,6 @@ pub struct Entry {
} }
impl Entry { impl Entry {
pub fn from_info(info: &MempoolEntryInfo) -> Self {
Self {
txid: info.txid.clone(),
fee: info.fee,
vsize: VSize::from(info.vsize),
ancestor_fee: info.ancestor_fee,
ancestor_vsize: VSize::from(info.ancestor_size),
depends: info.depends.iter().map(TxidPrefix::from).collect(),
first_seen: Timestamp::now(),
}
}
#[inline] #[inline]
pub fn fee_rate(&self) -> FeeRate { pub fn fee_rate(&self) -> FeeRate {
FeeRate::from((self.fee, self.vsize)) FeeRate::from((self.fee, self.vsize))
@@ -6,36 +6,31 @@ use super::{
fees, fees,
stats::{self, BlockStats}, stats::{self, BlockStats},
}; };
use crate::{ use crate::{block_builder::Package, entry::Entry, types::TxIndex};
entry::Entry,
types::{SelectedTx, TxIndex},
};
/// Immutable snapshot of projected blocks. /// Immutable snapshot of projected blocks.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct Snapshot { pub struct Snapshot {
/// Block structure: indices into entries Vec /// Block structure: indices into the mempool entries Vec, in the
/// order they'd appear in the block.
pub blocks: Vec<Vec<TxIndex>>, pub blocks: Vec<Vec<TxIndex>>,
/// Pre-computed stats per block
pub block_stats: Vec<BlockStats>, pub block_stats: Vec<BlockStats>,
/// Pre-computed fee recommendations
pub fees: RecommendedFees, pub fees: RecommendedFees,
} }
impl Snapshot { impl Snapshot {
/// Build snapshot from selected transactions and entries. /// Build a snapshot from packages grouped by projected block.
pub fn build(blocks: Vec<Vec<SelectedTx>>, entries: &[Option<Entry>]) -> Self { pub fn build(blocks: Vec<Vec<Package>>, entries: &[Option<Entry>]) -> Self {
let block_stats: Vec<BlockStats> = blocks let block_stats: Vec<BlockStats> = blocks
.iter() .iter()
.map(|selected| stats::compute_block_stats(selected, entries)) .map(|block| stats::compute_block_stats(block, entries))
.collect(); .collect();
let fees = fees::compute_recommended_fees(&block_stats); let fees = fees::compute_recommended_fees(&block_stats);
// Extract just the indices from selected transactions
let blocks = blocks let blocks = blocks
.into_iter() .into_iter()
.map(|selected| selected.into_iter().map(|s| s.tx_index).collect()) .map(|block| block.into_iter().flat_map(|pkg| pkg.txs).collect())
.collect(); .collect();
Self { Self {
@@ -1,11 +1,13 @@
use brk_types::{FeeRate, Sats, VSize}; use brk_types::{FeeRate, Sats, VSize};
use crate::{entry::Entry, types::SelectedTx}; use crate::{block_builder::Package, entry::Entry};
/// Statistics for a single projected block. /// Statistics for a single projected block.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct BlockStats { pub struct BlockStats {
pub tx_count: u32, pub tx_count: u32,
/// Total serialized size of all txs in bytes (witness + non-witness).
pub total_size: u64,
pub total_vsize: VSize, pub total_vsize: VSize,
pub total_fee: Sats, pub total_fee: Sats,
/// Fee rate percentiles: [0%, 10%, 25%, 50%, 75%, 90%, 100%] /// Fee rate percentiles: [0%, 10%, 25%, 50%, 75%, 90%, 100%]
@@ -26,28 +28,36 @@ impl BlockStats {
} }
} }
/// Compute statistics for a single block using effective fee rates from selection time. /// Compute statistics for a single block. Each tx contributes its
pub fn compute_block_stats(selected: &[SelectedTx], entries: &[Option<Entry>]) -> BlockStats { /// containing package's `fee_rate` to the percentile distribution,
if selected.is_empty() { /// since that's the rate the miner collects per vsize.
pub fn compute_block_stats(block: &[Package], entries: &[Option<Entry>]) -> BlockStats {
if block.is_empty() {
return BlockStats::default(); return BlockStats::default();
} }
let mut total_fee = Sats::default(); let mut total_fee = Sats::default();
let mut total_vsize = VSize::default(); let mut total_vsize = VSize::default();
let mut fee_rates: Vec<FeeRate> = Vec::with_capacity(selected.len()); let mut total_size: u64 = 0;
let mut fee_rates: Vec<FeeRate> = Vec::new();
for sel in selected { for pkg in block {
if let Some(entry) = &entries[sel.tx_index.as_usize()] { for &tx_index in &pkg.txs {
total_fee += entry.fee; if let Some(entry) = &entries[tx_index.as_usize()] {
total_vsize += entry.vsize; total_fee += entry.fee;
fee_rates.push(sel.effective_fee_rate); total_vsize += entry.vsize;
total_size += entry.size;
fee_rates.push(pkg.fee_rate);
}
} }
} }
let tx_count = fee_rates.len() as u32;
fee_rates.sort_unstable(); fee_rates.sort_unstable();
BlockStats { BlockStats {
tx_count: selected.len() as u32, tx_count,
total_size,
total_vsize, total_vsize,
total_fee, total_fee,
fee_range: [ fee_range: [
+15 -3
View File
@@ -13,8 +13,8 @@ use bitcoin::hex::DisplayHex;
use brk_error::Result; use brk_error::Result;
use brk_rpc::Client; use brk_rpc::Client;
use brk_types::{ use brk_types::{
AddrBytes, BlockHash, MempoolEntryInfo, MempoolInfo, Transaction, TxIn, TxOut, TxStatus, AddrBytes, BlockHash, MempoolEntryInfo, MempoolInfo, Timestamp, Transaction, TxIn, TxOut,
TxWithHex, Txid, TxidPrefix, Vout, TxStatus, TxWithHex, Txid, TxidPrefix, VSize, Vout,
}; };
use derive_more::Deref; use derive_more::Deref;
use parking_lot::{RwLock, RwLockReadGuard}; use parking_lot::{RwLock, RwLockReadGuard};
@@ -286,7 +286,19 @@ impl MempoolInner {
info.add(tx, entry_info.fee); info.add(tx, entry_info.fee);
addrs.add_tx(tx, txid); addrs.add_tx(tx, txid);
entries.insert(prefix, Entry::from_info(entry_info)); entries.insert(
prefix,
Entry {
txid: entry_info.txid.clone(),
fee: entry_info.fee,
vsize: VSize::from(entry_info.vsize),
size: tx.total_size as u64,
ancestor_fee: entry_info.ancestor_fee,
ancestor_vsize: VSize::from(entry_info.ancestor_size),
depends: entry_info.depends.iter().map(TxidPrefix::from).collect(),
first_seen: Timestamp::now(),
},
);
} }
txs.extend(new_txs); txs.extend(new_txs);
-2
View File
@@ -1,7 +1,5 @@
mod pool_index; mod pool_index;
mod selected_tx;
mod tx_index; mod tx_index;
pub use pool_index::PoolIndex; pub use pool_index::PoolIndex;
pub use selected_tx::SelectedTx;
pub use tx_index::TxIndex; pub use tx_index::TxIndex;
@@ -1,12 +0,0 @@
use brk_types::FeeRate;
use super::TxIndex;
/// A transaction selected for a projected block.
#[derive(Debug, Clone, Copy)]
pub struct SelectedTx {
/// Index into mempool entries
pub tx_index: TxIndex,
/// Fee rate at selection time (includes CPFP)
pub effective_fee_rate: FeeRate,
}
+1
View File
@@ -36,6 +36,7 @@ impl Query {
.map(|stats| { .map(|stats| {
MempoolBlock::new( MempoolBlock::new(
stats.tx_count, stats.tx_count,
stats.total_size,
stats.total_vsize, stats.total_vsize,
stats.total_fee, stats.total_fee,
stats.fee_range, stats.fee_range,
+3 -89
View File
@@ -9,12 +9,11 @@ use axum::{
}; };
use brk_traversable::TreeNode; use brk_traversable::TreeNode;
use brk_types::{ use brk_types::{
CostBasisFormatted, DataRangeFormat, Date, DetailedSeriesCount, Index, IndexInfo, DataRangeFormat, DetailedSeriesCount, Index, IndexInfo, PaginatedSeries, Pagination,
PaginatedSeries, Pagination, SearchQuery, SeriesData, SeriesInfo, SeriesList, SeriesName, SearchQuery, SeriesData, SeriesInfo, SeriesList, SeriesName, SeriesSelection,
SeriesSelection, SeriesSelectionLegacy, SeriesSelectionLegacy,
}; };
use crate::params::{CostBasisCohortParam, CostBasisParams, CostBasisQuery};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -347,91 +346,6 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
.not_found(), .not_found(),
), ),
) )
// --- Deprecated cost basis routes ---
.api_route(
"/api/metrics/cost-basis",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, |q| q.cost_basis_cohorts())
.await
},
|op| {
op.id("get_cost_basis_cohorts_deprecated")
.metrics_tag()
.deprecated()
.summary("Available cost basis cohorts (deprecated)")
.description(
"**DEPRECATED** - Use `/api/series/cost-basis` instead.\n\n\
Sunset date: 2027-01-01."
)
.json_response::<Vec<String>>()
.server_error()
},
),
)
.api_route(
"/api/metrics/cost-basis/{cohort}/dates",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisCohortParam>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| {
q.cost_basis_dates(&params.cohort)
})
.await
},
|op| {
op.id("get_cost_basis_dates_deprecated")
.metrics_tag()
.deprecated()
.summary("Available cost basis dates (deprecated)")
.description(
"**DEPRECATED** - Use `/api/series/cost-basis/{cohort}/dates` instead.\n\n\
Sunset date: 2027-01-01."
)
.json_response::<Vec<Date>>()
.not_found()
.server_error()
},
),
)
.api_route(
"/api/metrics/cost-basis/{cohort}/{date}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisParams>,
Query(query): Query<CostBasisQuery>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, move |q| {
q.cost_basis_formatted(
&params.cohort,
params.date,
query.bucket,
query.value,
)
})
.await
},
|op| {
op.id("get_cost_basis_deprecated")
.metrics_tag()
.deprecated()
.summary("Cost basis distribution (deprecated)")
.description(
"**DEPRECATED** - Use `/api/series/cost-basis/{cohort}/{date}` instead.\n\n\
Sunset date: 2027-01-01."
)
.json_response::<CostBasisFormatted>()
.not_found()
.server_error()
},
),
)
// --- Deprecated /api/vecs/ routes (moved from series module) --- // --- Deprecated /api/vecs/ routes (moved from series module) ---
.api_route( .api_route(
"/api/vecs/{variant}", "/api/vecs/{variant}",
+3 -2
View File
@@ -10,7 +10,7 @@ use axum::{
use brk_traversable::TreeNode; use brk_traversable::TreeNode;
use brk_types::{ use brk_types::{
CostBasisFormatted, DataRangeFormat, Date, IndexInfo, PaginatedSeries, Pagination, SearchQuery, CostBasisFormatted, DataRangeFormat, Date, IndexInfo, PaginatedSeries, Pagination, SearchQuery,
SeriesCount, SeriesData, SeriesInfo, SeriesNameWithIndex, SeriesSelection, SeriesCount, SeriesData, SeriesInfo, SeriesNameWithIndex, SeriesSelection, Version,
}; };
use crate::{ use crate::{
@@ -383,8 +383,9 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
Path(params): Path<CostBasisParams>, Path(params): Path<CostBasisParams>,
Query(query): Query<CostBasisQuery>, Query(query): Query<CostBasisQuery>,
State(state): State<AppState>| { State(state): State<AppState>| {
let strategy = state.date_cache(Version::ONE, params.date);
state state
.cached_json(&headers, CacheStrategy::Static, &uri, move |q| { .cached_json(&headers, strategy, &uri, move |q| {
q.cost_basis_formatted( q.cost_basis_formatted(
&params.cohort, &params.cohort,
params.date, params.date,
+25 -2
View File
@@ -14,13 +14,14 @@ use axum::{
}; };
use brk_query::AsyncQuery; use brk_query::AsyncQuery;
use brk_types::{ use brk_types::{
Addr, BlockHash, BlockHashPrefix, Height, ONE_HOUR_IN_SEC, Timestamp as BrkTimestamp, Txid, Addr, BlockHash, BlockHashPrefix, Date, Height, ONE_HOUR_IN_SEC, Timestamp as BrkTimestamp,
Version, Txid, Version,
}; };
use derive_more::Deref; use derive_more::Deref;
use jiff::Timestamp; use jiff::Timestamp;
use quick_cache::sync::{Cache, GuardResult}; use quick_cache::sync::{Cache, GuardResult};
use serde::Serialize; use serde::Serialize;
use vecdb::ReadableVec;
use crate::{ use crate::{
CacheParams, CacheStrategy, Error, Website, CacheParams, CacheStrategy, Error, Website,
@@ -59,6 +60,28 @@ impl AppState {
} }
} }
/// `Immutable` if `date` is strictly before the indexed tip's date, `Tip` otherwise.
/// For per-date files that keep being rewritten while the tip is still within the
/// date's day, then settle once the tip crosses the day boundary.
pub fn date_cache(&self, version: Version, date: Date) -> CacheStrategy {
self.sync(|q| {
let height = q.indexed_height();
q.indexer()
.vecs
.blocks
.timestamp
.collect_one(height)
.map(|ts| {
if date < Date::from(ts) {
CacheStrategy::Immutable(version)
} else {
CacheStrategy::Tip
}
})
.unwrap_or(CacheStrategy::Tip)
})
}
/// Smart address caching: checks mempool activity first (unless `chain_only`), then on-chain. /// Smart address caching: checks mempool activity first (unless `chain_only`), then on-chain.
/// - Address has mempool txs → `MempoolHash(addr_specific_hash)` /// - Address has mempool txs → `MempoolHash(addr_specific_hash)`
/// - No mempool, has on-chain activity → `BlockBound(last_activity_block)` /// - No mempool, has on-chain activity → `BlockBound(last_activity_block)`
+5 -5
View File
@@ -7,8 +7,8 @@ use crate::{FeeRate, Sats, VSize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MempoolBlock { pub struct MempoolBlock {
/// Total block size in weight units /// Total serialized block size in bytes (witness + non-witness).
#[schemars(example = 3993472)] #[schemars(example = 1604417)]
pub block_size: u64, pub block_size: u64,
/// Total block virtual size in vbytes /// Total block virtual size in vbytes
@@ -47,14 +47,14 @@ fn example_fee_range() -> [FeeRate; 7] {
impl MempoolBlock { impl MempoolBlock {
pub fn new( pub fn new(
tx_count: u32, tx_count: u32,
total_size: u64,
total_vsize: VSize, total_vsize: VSize,
total_fee: Sats, total_fee: Sats,
fee_range: [FeeRate; 7], fee_range: [FeeRate; 7],
) -> Self { ) -> Self {
let vsize_f64 = *total_vsize as f64;
Self { Self {
block_size: *total_vsize * 4, // weight = vsize * 4 block_size: total_size,
block_v_size: vsize_f64, block_v_size: *total_vsize as f64,
n_tx: tx_count, n_tx: tx_count,
total_fees: total_fee, total_fees: total_fee,
median_fee: fee_range[3], median_fee: fee_range[3],
+1 -1
View File
@@ -626,7 +626,7 @@ Matches mempool.space/bitcoin-cli behavior.
* Block info in a mempool.space like format for fee estimation. * Block info in a mempool.space like format for fee estimation.
* *
* @typedef {Object} MempoolBlock * @typedef {Object} MempoolBlock
* @property {number} blockSize - Total block size in weight units * @property {number} blockSize - Total serialized block size in bytes (witness + non-witness).
* @property {number} blockVSize - Total block virtual size in vbytes * @property {number} blockVSize - Total block virtual size in vbytes
* @property {number} nTx - Number of transactions in the projected block * @property {number} nTx - Number of transactions in the projected block
* @property {Sats} totalFees - Total fees in satoshis * @property {Sats} totalFees - Total fees in satoshis
+1 -1
View File
@@ -967,7 +967,7 @@ class MempoolBlock(TypedDict):
Block info in a mempool.space like format for fee estimation. Block info in a mempool.space like format for fee estimation.
Attributes: Attributes:
blockSize: Total block size in weight units blockSize: Total serialized block size in bytes (witness + non-witness).
blockVSize: Total block virtual size in vbytes blockVSize: Total block virtual size in vbytes
nTx: Number of transactions in the projected block nTx: Number of transactions in the projected block
totalFees: Total fees in satoshis totalFees: Total fees in satoshis