mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-01 01:50:00 -07:00
mempool: fixes
This commit is contained in:
23
Cargo.lock
generated
23
Cargo.lock
generated
@@ -1979,13 +1979,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.44"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870"
|
||||
checksum = "bc89deee4af0429081d2a518c0431ae068222a5a262a3bc6ff4d8535ec2e02fe"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cty",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2101,9 +2100,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.48"
|
||||
version = "0.1.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8"
|
||||
checksum = "aca3c01a711f395b4257b81674c0e90e8dd1f1e62c4b7db45f684cc7a4fcb18a"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
@@ -3450,11 +3449,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
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"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[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"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3766,6 +3765,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -8,5 +8,5 @@ homepage.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
libmimalloc-sys = { version = "0.1.44", features = ["extended"] }
|
||||
mimalloc = { version = "0.1.48", features = ["v3"] }
|
||||
libmimalloc-sys = { version = "0.1.46", features = ["extended"] }
|
||||
mimalloc = { version = "0.1.49" }
|
||||
|
||||
@@ -7,10 +7,13 @@ use crate::types::PoolIndex;
|
||||
|
||||
/// 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)]
|
||||
pub struct HeapEntry {
|
||||
pub pool_index: PoolIndex,
|
||||
pub generation: u32,
|
||||
ancestor_fee: Sats,
|
||||
ancestor_vsize: VSize,
|
||||
}
|
||||
@@ -19,6 +22,7 @@ impl HeapEntry {
|
||||
pub fn new(node: &TxNode) -> Self {
|
||||
Self {
|
||||
pool_index: node.pool_index,
|
||||
generation: node.generation,
|
||||
ancestor_fee: node.ancestor_fee,
|
||||
ancestor_vsize: node.ancestor_vsize,
|
||||
}
|
||||
@@ -39,7 +43,7 @@ impl HeapEntry {
|
||||
|
||||
impl PartialEq for HeapEntry {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.pool_index == other.pool_index
|
||||
self.cmp(other).is_eq()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,28 +5,29 @@ mod partitioner;
|
||||
mod selector;
|
||||
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).
|
||||
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;
|
||||
|
||||
/// Build projected blocks from mempool entries.
|
||||
///
|
||||
/// Returns transactions grouped by projected block, sorted by fee rate.
|
||||
pub fn build_projected_blocks(entries: &[Option<Entry>]) -> Vec<Vec<SelectedTx>> {
|
||||
// Build dependency graph
|
||||
/// Returns packages grouped by projected block. Blocks 1 through
|
||||
/// `NUM_BLOCKS - 1` are standard ~1MB blocks sorted by placement rate
|
||||
/// 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);
|
||||
|
||||
if graph.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Select transactions into packages
|
||||
let packages = selector::select_packages(&mut graph, NUM_BLOCKS);
|
||||
|
||||
// Partition packages into blocks
|
||||
let packages = selector::select_packages(&mut graph);
|
||||
partitioner::partition_into_blocks(packages, NUM_BLOCKS)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
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.
|
||||
/// The package fee rate is the combined rate of all transactions.
|
||||
/// Carries two rates:
|
||||
/// - `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 {
|
||||
/// Transactions in topological order (parents before children)
|
||||
pub txs: Vec<SelectedTx>,
|
||||
|
||||
/// Combined vsize of all transactions
|
||||
/// Transactions in topological order (parents before children).
|
||||
pub txs: Vec<TxIndex>,
|
||||
pub vsize: u64,
|
||||
|
||||
/// Package fee rate
|
||||
pub fee_rate: FeeRate,
|
||||
pub placement_rate: FeeRate,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
@@ -23,14 +28,12 @@ impl Package {
|
||||
txs: Vec::new(),
|
||||
vsize: 0,
|
||||
fee_rate,
|
||||
placement_rate: fee_rate,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_tx(&mut self, tx_index: TxIndex, vsize: u64) {
|
||||
self.txs.push(SelectedTx {
|
||||
tx_index,
|
||||
effective_fee_rate: self.fee_rate,
|
||||
});
|
||||
self.txs.push(tx_index);
|
||||
self.vsize += vsize;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,103 +1,123 @@
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use super::{BLOCK_VSIZE, package::Package};
|
||||
use crate::types::SelectedTx;
|
||||
|
||||
/// How many packages to look ahead when current doesn't fit.
|
||||
const LOOK_AHEAD: usize = 100;
|
||||
/// How many packages to look ahead when the current one doesn't fit.
|
||||
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.
|
||||
/// When a package doesn't fit, we look ahead for smaller packages that do.
|
||||
/// Atomic packages are never split across blocks.
|
||||
/// The first `num_blocks - 1` blocks are packed greedily into ~`BLOCK_VSIZE`
|
||||
/// chunks. The final block is a catch-all containing every remaining
|
||||
/// package, so no low-rate tx is silently dropped from the projection
|
||||
/// (matches mempool.space's last-block behavior).
|
||||
pub fn partition_into_blocks(
|
||||
mut packages: Vec<Package>,
|
||||
num_blocks: usize,
|
||||
) -> Vec<Vec<SelectedTx>> {
|
||||
packages.sort_unstable_by_key(|p| Reverse(p.fee_rate));
|
||||
) -> Vec<Vec<Package>> {
|
||||
// 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 current_block: Vec<SelectedTx> = Vec::new();
|
||||
let mut current_vsize: u64 = 0;
|
||||
let mut used = vec![false; packages.len()];
|
||||
let mut slots: Vec<Option<Package>> = packages.into_iter().map(Some).collect();
|
||||
let mut blocks: Vec<Vec<Package>> = Vec::with_capacity(num_blocks);
|
||||
let normal_blocks = num_blocks.saturating_sub(1);
|
||||
|
||||
let mut idx = 0;
|
||||
while idx < packages.len() && blocks.len() < num_blocks {
|
||||
if used[idx] {
|
||||
let mut idx = fill_normal_blocks(&mut slots, &mut blocks, normal_blocks);
|
||||
|
||||
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;
|
||||
continue;
|
||||
}
|
||||
|
||||
let remaining_space = BLOCK_VSIZE.saturating_sub(current_vsize);
|
||||
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;
|
||||
if !overflow.is_empty() {
|
||||
blocks.push(overflow);
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Try to find a smaller package in the look-ahead window that fits.
|
||||
fn try_fill_with_smaller(
|
||||
packages: &[Package],
|
||||
used: &mut [bool],
|
||||
start: usize,
|
||||
remaining_space: u64,
|
||||
block: &mut Vec<SelectedTx>,
|
||||
block_vsize: &mut u64,
|
||||
) -> bool {
|
||||
let end = (start + LOOK_AHEAD).min(packages.len());
|
||||
/// Greedily pack packages into up to `target_blocks` chunks of `BLOCK_VSIZE`.
|
||||
/// Returns the first `slots` index we stopped at.
|
||||
fn fill_normal_blocks(
|
||||
slots: &mut [Option<Package>],
|
||||
blocks: &mut Vec<Vec<Package>>,
|
||||
target_blocks: usize,
|
||||
) -> usize {
|
||||
let mut current_block: Vec<Package> = Vec::new();
|
||||
let mut current_vsize: u64 = 0;
|
||||
let mut idx = 0;
|
||||
|
||||
for idx in (start + 1)..end {
|
||||
if used[idx] {
|
||||
while idx < slots.len() && blocks.len() < target_blocks {
|
||||
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;
|
||||
}
|
||||
|
||||
let package = &packages[idx];
|
||||
if package.vsize <= remaining_space {
|
||||
block.extend(package.txs.iter().copied());
|
||||
*block_vsize += package.vsize;
|
||||
used[idx] = true;
|
||||
if current_block.is_empty() {
|
||||
// Oversized package with no partial block to preserve; take it
|
||||
// anyway so we don't stall on a package larger than BLOCK_VSIZE.
|
||||
let pkg = slots[idx].take().unwrap();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -4,53 +4,61 @@ use brk_types::FeeRate;
|
||||
use rustc_hash::FxHashSet;
|
||||
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;
|
||||
|
||||
/// Select transactions from the graph and group into CPFP packages.
|
||||
pub fn select_packages(graph: &mut Graph, num_blocks: usize) -> Vec<Package> {
|
||||
let target_vsize = BLOCK_VSIZE * num_blocks as u64;
|
||||
let mut total_vsize: u64 = 0;
|
||||
let mut packages: Vec<Package> = Vec::new();
|
||||
/// Sentinel for `package_of` entries that haven't been placed in a package yet.
|
||||
const UNASSIGNED: u32 = u32::MAX;
|
||||
|
||||
/// Select transactions from the graph and group them into CPFP packages,
|
||||
/// 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())
|
||||
.map(|i| HeapEntry::new(&graph[PoolIndex::from(i)]))
|
||||
.collect();
|
||||
|
||||
while let Some(entry) = heap.pop() {
|
||||
let node = &graph[entry.pool_index];
|
||||
|
||||
// Skip if already selected or entry is stale
|
||||
if node.selected {
|
||||
if node.selected || entry.generation != node.generation {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Package fee rate at selection time
|
||||
let package_rate = FeeRate::from((node.ancestor_fee, node.ancestor_vsize));
|
||||
let own_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)
|
||||
let ancestors = select_with_ancestors(graph, entry.pool_index);
|
||||
for pool_idx in 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);
|
||||
}
|
||||
|
||||
total_vsize += package.vsize;
|
||||
packages.push(package);
|
||||
|
||||
if total_vsize >= target_vsize {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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]> {
|
||||
let mut result: SmallVec<[PoolIndex; 8]> = SmallVec::new();
|
||||
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
|
||||
}
|
||||
|
||||
/// 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(
|
||||
graph: &mut Graph,
|
||||
selected_idx: PoolIndex,
|
||||
@@ -96,19 +105,17 @@ fn update_descendants(
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// A mempool transaction entry.
|
||||
@@ -10,6 +10,8 @@ pub struct Entry {
|
||||
pub txid: Txid,
|
||||
pub fee: Sats,
|
||||
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)
|
||||
pub ancestor_fee: Sats,
|
||||
/// Pre-computed ancestor vsize (self + all ancestors, no double-counting)
|
||||
@@ -21,18 +23,6 @@ pub struct 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]
|
||||
pub fn fee_rate(&self) -> FeeRate {
|
||||
FeeRate::from((self.fee, self.vsize))
|
||||
|
||||
@@ -6,36 +6,31 @@ use super::{
|
||||
fees,
|
||||
stats::{self, BlockStats},
|
||||
};
|
||||
use crate::{
|
||||
entry::Entry,
|
||||
types::{SelectedTx, TxIndex},
|
||||
};
|
||||
use crate::{block_builder::Package, entry::Entry, types::TxIndex};
|
||||
|
||||
/// Immutable snapshot of projected blocks.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
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>>,
|
||||
/// Pre-computed stats per block
|
||||
pub block_stats: Vec<BlockStats>,
|
||||
/// Pre-computed fee recommendations
|
||||
pub fees: RecommendedFees,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// Build snapshot from selected transactions and entries.
|
||||
pub fn build(blocks: Vec<Vec<SelectedTx>>, entries: &[Option<Entry>]) -> Self {
|
||||
/// Build a snapshot from packages grouped by projected block.
|
||||
pub fn build(blocks: Vec<Vec<Package>>, entries: &[Option<Entry>]) -> Self {
|
||||
let block_stats: Vec<BlockStats> = blocks
|
||||
.iter()
|
||||
.map(|selected| stats::compute_block_stats(selected, entries))
|
||||
.map(|block| stats::compute_block_stats(block, entries))
|
||||
.collect();
|
||||
|
||||
let fees = fees::compute_recommended_fees(&block_stats);
|
||||
|
||||
// Extract just the indices from selected transactions
|
||||
let blocks = blocks
|
||||
.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();
|
||||
|
||||
Self {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BlockStats {
|
||||
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_fee: Sats,
|
||||
/// 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.
|
||||
pub fn compute_block_stats(selected: &[SelectedTx], entries: &[Option<Entry>]) -> BlockStats {
|
||||
if selected.is_empty() {
|
||||
/// Compute statistics for a single block. Each tx contributes its
|
||||
/// containing package's `fee_rate` to the percentile distribution,
|
||||
/// 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();
|
||||
}
|
||||
|
||||
let mut total_fee = Sats::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 {
|
||||
if let Some(entry) = &entries[sel.tx_index.as_usize()] {
|
||||
total_fee += entry.fee;
|
||||
total_vsize += entry.vsize;
|
||||
fee_rates.push(sel.effective_fee_rate);
|
||||
for pkg in block {
|
||||
for &tx_index in &pkg.txs {
|
||||
if let Some(entry) = &entries[tx_index.as_usize()] {
|
||||
total_fee += entry.fee;
|
||||
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();
|
||||
|
||||
BlockStats {
|
||||
tx_count: selected.len() as u32,
|
||||
tx_count,
|
||||
total_size,
|
||||
total_vsize,
|
||||
total_fee,
|
||||
fee_range: [
|
||||
|
||||
@@ -13,8 +13,8 @@ use bitcoin::hex::DisplayHex;
|
||||
use brk_error::Result;
|
||||
use brk_rpc::Client;
|
||||
use brk_types::{
|
||||
AddrBytes, BlockHash, MempoolEntryInfo, MempoolInfo, Transaction, TxIn, TxOut, TxStatus,
|
||||
TxWithHex, Txid, TxidPrefix, Vout,
|
||||
AddrBytes, BlockHash, MempoolEntryInfo, MempoolInfo, Timestamp, Transaction, TxIn, TxOut,
|
||||
TxStatus, TxWithHex, Txid, TxidPrefix, VSize, Vout,
|
||||
};
|
||||
use derive_more::Deref;
|
||||
use parking_lot::{RwLock, RwLockReadGuard};
|
||||
@@ -286,7 +286,19 @@ impl MempoolInner {
|
||||
|
||||
info.add(tx, entry_info.fee);
|
||||
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);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
mod pool_index;
|
||||
mod selected_tx;
|
||||
mod tx_index;
|
||||
|
||||
pub use pool_index::PoolIndex;
|
||||
pub use selected_tx::SelectedTx;
|
||||
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,
|
||||
}
|
||||
@@ -36,6 +36,7 @@ impl Query {
|
||||
.map(|stats| {
|
||||
MempoolBlock::new(
|
||||
stats.tx_count,
|
||||
stats.total_size,
|
||||
stats.total_vsize,
|
||||
stats.total_fee,
|
||||
stats.fee_range,
|
||||
|
||||
@@ -9,12 +9,11 @@ use axum::{
|
||||
};
|
||||
use brk_traversable::TreeNode;
|
||||
use brk_types::{
|
||||
CostBasisFormatted, DataRangeFormat, Date, DetailedSeriesCount, Index, IndexInfo,
|
||||
PaginatedSeries, Pagination, SearchQuery, SeriesData, SeriesInfo, SeriesList, SeriesName,
|
||||
SeriesSelection, SeriesSelectionLegacy,
|
||||
DataRangeFormat, DetailedSeriesCount, Index, IndexInfo, PaginatedSeries, Pagination,
|
||||
SearchQuery, SeriesData, SeriesInfo, SeriesList, SeriesName, SeriesSelection,
|
||||
SeriesSelectionLegacy,
|
||||
};
|
||||
|
||||
use crate::params::{CostBasisCohortParam, CostBasisParams, CostBasisQuery};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -347,91 +346,6 @@ impl ApiMetricsLegacyRoutes for ApiRouter<AppState> {
|
||||
.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(¶ms.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(
|
||||
¶ms.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) ---
|
||||
.api_route(
|
||||
"/api/vecs/{variant}",
|
||||
|
||||
@@ -10,7 +10,7 @@ use axum::{
|
||||
use brk_traversable::TreeNode;
|
||||
use brk_types::{
|
||||
CostBasisFormatted, DataRangeFormat, Date, IndexInfo, PaginatedSeries, Pagination, SearchQuery,
|
||||
SeriesCount, SeriesData, SeriesInfo, SeriesNameWithIndex, SeriesSelection,
|
||||
SeriesCount, SeriesData, SeriesInfo, SeriesNameWithIndex, SeriesSelection, Version,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -383,8 +383,9 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
|
||||
Path(params): Path<CostBasisParams>,
|
||||
Query(query): Query<CostBasisQuery>,
|
||||
State(state): State<AppState>| {
|
||||
let strategy = state.date_cache(Version::ONE, params.date);
|
||||
state
|
||||
.cached_json(&headers, CacheStrategy::Static, &uri, move |q| {
|
||||
.cached_json(&headers, strategy, &uri, move |q| {
|
||||
q.cost_basis_formatted(
|
||||
¶ms.cohort,
|
||||
params.date,
|
||||
|
||||
@@ -14,13 +14,14 @@ use axum::{
|
||||
};
|
||||
use brk_query::AsyncQuery;
|
||||
use brk_types::{
|
||||
Addr, BlockHash, BlockHashPrefix, Height, ONE_HOUR_IN_SEC, Timestamp as BrkTimestamp, Txid,
|
||||
Version,
|
||||
Addr, BlockHash, BlockHashPrefix, Date, Height, ONE_HOUR_IN_SEC, Timestamp as BrkTimestamp,
|
||||
Txid, Version,
|
||||
};
|
||||
use derive_more::Deref;
|
||||
use jiff::Timestamp;
|
||||
use quick_cache::sync::{Cache, GuardResult};
|
||||
use serde::Serialize;
|
||||
use vecdb::ReadableVec;
|
||||
|
||||
use crate::{
|
||||
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.
|
||||
/// - Address has mempool txs → `MempoolHash(addr_specific_hash)`
|
||||
/// - No mempool, has on-chain activity → `BlockBound(last_activity_block)`
|
||||
|
||||
@@ -7,8 +7,8 @@ use crate::{FeeRate, Sats, VSize};
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MempoolBlock {
|
||||
/// Total block size in weight units
|
||||
#[schemars(example = 3993472)]
|
||||
/// Total serialized block size in bytes (witness + non-witness).
|
||||
#[schemars(example = 1604417)]
|
||||
pub block_size: u64,
|
||||
|
||||
/// Total block virtual size in vbytes
|
||||
@@ -47,14 +47,14 @@ fn example_fee_range() -> [FeeRate; 7] {
|
||||
impl MempoolBlock {
|
||||
pub fn new(
|
||||
tx_count: u32,
|
||||
total_size: u64,
|
||||
total_vsize: VSize,
|
||||
total_fee: Sats,
|
||||
fee_range: [FeeRate; 7],
|
||||
) -> Self {
|
||||
let vsize_f64 = *total_vsize as f64;
|
||||
Self {
|
||||
block_size: *total_vsize * 4, // weight = vsize * 4
|
||||
block_v_size: vsize_f64,
|
||||
block_size: total_size,
|
||||
block_v_size: *total_vsize as f64,
|
||||
n_tx: tx_count,
|
||||
total_fees: total_fee,
|
||||
median_fee: fee_range[3],
|
||||
|
||||
@@ -626,7 +626,7 @@ Matches mempool.space/bitcoin-cli behavior.
|
||||
* Block info in a mempool.space like format for fee estimation.
|
||||
*
|
||||
* @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} nTx - Number of transactions in the projected block
|
||||
* @property {Sats} totalFees - Total fees in satoshis
|
||||
|
||||
@@ -967,7 +967,7 @@ class MempoolBlock(TypedDict):
|
||||
Block info in a mempool.space like format for fee estimation.
|
||||
|
||||
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
|
||||
nTx: Number of transactions in the projected block
|
||||
totalFees: Total fees in satoshis
|
||||
|
||||
Reference in New Issue
Block a user