mempool: polish/cleanup

This commit is contained in:
nym21
2026-05-14 23:29:10 +02:00
parent 90aca2e048
commit 68db22b9e8
55 changed files with 3240 additions and 1410 deletions

1
Cargo.lock generated
View File

@@ -532,7 +532,6 @@ dependencies = [
"brk_oracle",
"brk_rpc",
"brk_types",
"derive_more",
"parking_lot",
"rustc-hash",
"smallvec",

View File

@@ -14,7 +14,6 @@ brk_error = { workspace = true }
brk_oracle = { workspace = true }
brk_rpc = { workspace = true }
brk_types = { workspace = true }
derive_more = { workspace = true }
tracing = { workspace = true }
parking_lot = { workspace = true }
rustc-hash = { workspace = true }

View File

@@ -26,7 +26,7 @@ fn main() -> Result<()> {
let info_count = mempool.info().count;
let stats = mempool.stats();
let snapshot = mempool.snapshot();
let blocks_tx_total: usize = snapshot.blocks.iter().map(|b| b.len()).sum();
let blocks_tx_total: usize = snapshot.blocks.iter().map(Vec::len).sum();
println!(
"info.count={} txs={} unresolved={} addrs={} outpoints={} \

View File

@@ -0,0 +1,45 @@
//! Address-keyed reads.
use std::cmp::Reverse;
use brk_types::{AddrBytes, AddrMempoolStats, Timestamp, Transaction, TxidPrefix};
use crate::Mempool;
impl Mempool {
/// Hash of the address's mempool state, `None` if the address has
/// no live mempool activity. Used as an `ETag` for address-keyed
/// mempool responses. Route handlers may fall back to a 0 sentinel.
pub fn addr_state_hash(&self, addr: &AddrBytes) -> Option<u64> {
self.read().addrs.stats_hash(addr)
}
/// Per-address mempool stats. `None` if the address has no live mempool activity.
pub fn addr_stats(&self, addr: &AddrBytes) -> Option<AddrMempoolStats> {
self.read().addrs.get(addr).map(|e| e.stats.clone())
}
/// Live mempool txs touching `addr`, newest first by `first_seen`,
/// capped at `limit`. Returns owned `Transaction`s.
#[must_use]
pub fn addr_txs(&self, addr: &AddrBytes, limit: usize) -> Vec<Transaction> {
let state = self.read();
let Some(entry) = state.addrs.get(addr) else {
return vec![];
};
let mut ordered: Vec<(Timestamp, &Transaction)> = entry
.txids
.iter()
.filter_map(|txid| {
let record = state.txs.record_by_prefix(&TxidPrefix::from(txid))?;
Some((record.entry.first_seen, &record.tx))
})
.collect();
ordered.sort_unstable_by_key(|b| Reverse(b.0));
ordered
.into_iter()
.take(limit)
.map(|(_, tx)| tx.clone())
.collect()
}
}

View File

@@ -0,0 +1,199 @@
//! Projected next block: full template and incremental diff.
use brk_types::{
BlockTemplate, BlockTemplateDiff, BlockTemplateDiffEntry, MempoolBlock, NextBlockHash,
Transaction, Txid,
};
use rustc_hash::{FxHashMap, FxHashSet};
use tracing::warn;
use crate::{Mempool, state::State};
impl Mempool {
pub fn next_block_hash(&self) -> NextBlockHash {
self.snapshot().next_block_hash
}
/// Full projected next block: Core's `getblocktemplate` selection
/// (block 0) with aggregate stats and full tx bodies in GBT order.
#[must_use]
pub fn block_template(&self) -> BlockTemplate {
let snap = self.snapshot();
BlockTemplate {
hash: snap.next_block_hash,
stats: snap
.block_stats
.first()
.map(MempoolBlock::from)
.unwrap_or_default(),
transactions: self.collect_txs(snap.block0_txids()),
}
}
/// Delta of the projected next block since `since`. `None` when
/// `since` has aged out of the rebuilder's history (server should
/// 404 -> client falls back to `block_template`).
///
/// `order` walks the new template in template order. Each entry is
/// either a `Retained` index into the prior template (which the
/// client cached when it obtained `since`) or a `New` inline body.
/// `removed` is the convenience list of txids that left.
#[must_use]
pub fn block_template_diff(&self, since: NextBlockHash) -> Option<BlockTemplateDiff> {
let past = self.rebuilder().historical_block0(since)?;
let prior_index: FxHashMap<Txid, u32> = past
.iter()
.enumerate()
.map(|(idx, txid)| (*txid, idx as u32))
.collect();
let snap = self.snapshot();
let state = self.read();
let mut order = Vec::with_capacity(snap.blocks.first().map_or(0, Vec::len));
let mut current: FxHashSet<Txid> = FxHashSet::default();
for txid in snap.block0_txids() {
current.insert(txid);
match prior_index.get(&txid) {
Some(&idx) => order.push(BlockTemplateDiffEntry::Retained(idx)),
None => match Self::lookup_body(&state, &txid) {
Some(tx) => order.push(BlockTemplateDiffEntry::New(tx)),
None => warn!(?txid, "block_template_diff: snapshot tx body missing"),
},
}
}
drop(state);
let removed = past.into_iter().filter(|t| !current.contains(t)).collect();
Some(BlockTemplateDiff {
hash: snap.next_block_hash,
since,
order,
removed,
})
}
fn collect_txs(&self, txids: impl IntoIterator<Item = Txid>) -> Vec<Transaction> {
let state = self.read();
txids
.into_iter()
.filter_map(|txid| {
let body = Self::lookup_body(&state, &txid);
if body.is_none() {
warn!(?txid, "block_template: snapshot tx body missing");
}
body
})
.collect()
}
/// Body for a txid in a published snapshot. Graveyard fallback
/// covers the eviction race: an Applier may have buried the tx
/// after the snapshot was built. Burial retention (1h) >> snapshot
/// cycle (~1s), so the invariant holds in practice. A `None` here
/// is a soft anomaly the caller logs and drops.
fn lookup_body(state: &State, txid: &Txid) -> Option<Transaction> {
state
.txs
.get(txid)
.or_else(|| state.graveyard.get(txid).map(|t| &t.tx))
.cloned()
}
}
#[cfg(test)]
mod tests {
use brk_types::{BlockTemplateDiffEntry, FeeRate};
use super::*;
use crate::{
state::TxEntry,
test_support::{fake_entry_info, fake_tx, p2wpkh_script},
};
fn insert_tx(mempool: &Mempool, seed: u8, fee: u64, vsize: u64) -> Txid {
let tx = fake_tx(seed, &[None], &[(p2wpkh_script(seed + 1), 1_234)]);
let txid = tx.txid;
let info = fake_entry_info(txid, fee, vsize);
let entry = TxEntry::new(&info, vsize, false);
let mut state = mempool.test_state_lock().write();
state.txs.insert(tx, entry);
txid
}
#[test]
fn block_template_hash_matches_next_block_hash() {
let mempool = Mempool::for_test();
let txid = insert_tx(&mempool, 0xA0, 1_234, 100);
mempool.test_tick(&[txid], FeeRate::new(1.0));
let template = mempool.block_template();
assert_eq!(template.hash, mempool.next_block_hash());
assert_eq!(template.transactions.len(), 1);
assert_eq!(template.transactions[0].txid, txid);
}
#[test]
fn block_template_diff_round_trip_reconstructs_t1_from_t0() {
// T0: pool has two txs, both in gbt -> block 0.
let mempool = Mempool::for_test();
let txid_a = insert_tx(&mempool, 0xA1, 1_111, 100);
let txid_b = insert_tx(&mempool, 0xA2, 2_222, 100);
mempool.test_tick(&[txid_a, txid_b], FeeRate::new(1.0));
let t0 = mempool.block_template();
// T1: add a third tx, advance gbt. block_template_diff(t0.hash) must
// be reconstructible into the new block 0 ordering by combining the
// retained prior-indexed bodies from T0 with the New bodies inline.
let txid_c = insert_tx(&mempool, 0xA3, 3_333, 100);
mempool.test_tick(&[txid_a, txid_b, txid_c], FeeRate::new(1.0));
let t1 = mempool.block_template();
let diff = mempool
.block_template_diff(t0.hash)
.expect("t0 is still in history");
assert_eq!(diff.since, t0.hash);
assert_eq!(diff.hash, t1.hash);
let mut reconstructed = Vec::with_capacity(diff.order.len());
for entry in &diff.order {
match entry {
BlockTemplateDiffEntry::Retained(idx) => {
reconstructed.push(t0.transactions[*idx as usize].clone());
}
BlockTemplateDiffEntry::New(tx) => reconstructed.push(tx.clone()),
}
}
let expected: Vec<_> = t1.transactions.iter().map(|tx| tx.txid).collect();
let got: Vec<_> = reconstructed.iter().map(|tx| tx.txid).collect();
assert_eq!(got, expected, "diff round-trips back into T1 ordering");
assert!(diff.removed.is_empty());
}
#[test]
fn block_template_diff_removed_lists_evicted_txs() {
let mempool = Mempool::for_test();
let txid_a = insert_tx(&mempool, 0xA4, 1_111, 100);
let txid_b = insert_tx(&mempool, 0xA5, 2_222, 100);
mempool.test_tick(&[txid_a, txid_b], FeeRate::new(1.0));
let t0 = mempool.block_template();
// T1: txid_a no longer in gbt.
mempool.test_tick(&[txid_b], FeeRate::new(1.0));
let diff = mempool.block_template_diff(t0.hash).unwrap();
assert_eq!(diff.removed, vec![txid_a]);
}
#[test]
fn block_template_diff_unknown_since_returns_none() {
let mempool = Mempool::for_test();
mempool.test_tick(&[], FeeRate::new(1.0));
let bogus = NextBlockHash::new(0xDEAD_BEEF);
assert!(mempool.block_template_diff(bogus).is_none());
}
#[test]
fn block_template_empty_pool_has_no_transactions() {
let mempool = Mempool::for_test();
mempool.test_tick(&[], FeeRate::new(2.0));
let template = mempool.block_template();
assert!(template.transactions.is_empty());
}
}

View File

@@ -0,0 +1,38 @@
//! Fee reads: tier recommendations, projected-block stats, per-tx rates.
use brk_types::{FeeRate, RecommendedFees, TxidPrefix, Txid};
use crate::{Mempool, snapshot::BlockStats};
impl Mempool {
#[must_use]
pub fn fees(&self) -> RecommendedFees {
self.snapshot().fees.clone()
}
#[must_use]
pub fn block_stats(&self) -> Vec<BlockStats> {
self.snapshot().block_stats.clone()
}
/// Effective fee rate for a live tx: snapshot's linearized chunk
/// rate. Falls back to `fee/vsize` for txs added since the latest
/// snapshot was built (apply -> same-cycle tick gap).
pub fn live_effective_fee_rate(&self, prefix: &TxidPrefix) -> Option<FeeRate> {
if let Some(rate) = self.snapshot().chunk_rate_for(prefix) {
return Some(rate);
}
self.read()
.txs
.entry_by_prefix(prefix)
.map(|e| e.fee_rate())
}
/// Linearized chunk rate captured at burial - same value
/// `live_effective_fee_rate` returned while the tx was alive, so an
/// evicted RBF predecessor reports the package-effective rate it
/// had in the mempool, not a misleading isolated `fee/vsize`.
pub fn graveyard_fee_rate(&self, txid: &Txid) -> Option<FeeRate> {
self.read().graveyard.get(txid).map(|tomb| tomb.chunk_rate)
}
}

View File

@@ -0,0 +1,23 @@
//! Mempool info + price-blending output histogram.
use brk_oracle::Histogram;
use brk_types::MempoolInfo;
use crate::Mempool;
impl Mempool {
#[must_use]
pub fn info(&self) -> MempoolInfo {
self.read().info.clone()
}
/// Snapshot of pre-bucketed oracle bins across all live mempool tx
/// outputs. The total is maintained incrementally by `TxStore` on
/// every insert/remove, so this hot path is `O(NUM_BINS)` regardless
/// of pool size. Used by `live_price` to blend the mempool into the
/// committed oracle without re-parsing scripts per request.
#[must_use]
pub fn live_histogram(&self) -> Histogram {
self.read().txs.live_histogram()
}
}

View File

@@ -0,0 +1,11 @@
//! Read-side accessors on [`crate::Mempool`]. Each submodule groups a
//! cohesive method set. Types flow back through `pub use`.
mod addr;
mod block_template;
mod fees;
mod histogram;
mod rbf;
mod tx;
pub use rbf::{RbfForTx, RbfNode};

View File

@@ -0,0 +1,214 @@
//! RBF tree extraction. Returns owned trees so the caller can enrich
//! with indexer data (`mined`, effective fee rate) after the lock
//! drops: enriching under the lock re-enters `Mempool` and would
//! recursively acquire the same read lock.
use brk_types::{Sats, Timestamp, Transaction, Txid, TxidPrefix, VSize};
use rustc_hash::FxHashSet;
use crate::{
Mempool,
state::TxEntry,
stores::{TxGraveyard, TxStore},
};
#[derive(Debug, Clone)]
pub struct RbfNode {
pub txid: Txid,
pub fee: Sats,
pub vsize: VSize,
pub value: Sats,
pub first_seen: Timestamp,
/// BIP-125 signaling: at least one input has sequence < 0xffffffff-1.
pub rbf: bool,
/// `true` iff any predecessor in this subtree was non-signaling.
pub full_rbf: bool,
pub replaces: Vec<RbfNode>,
}
#[derive(Debug, Clone, Default)]
pub struct RbfForTx {
/// Tree rooted at the terminal replacer. `None` if `txid` is unknown.
pub root: Option<RbfNode>,
/// Direct predecessors of the requested tx (txids only).
pub replaces: Vec<Txid>,
}
impl Mempool {
/// Walk forward through `Replaced { by }` to the terminal replacer
/// and return its full predecessor tree, plus the requested tx's
/// direct predecessors. Single read-lock window.
#[must_use]
pub fn rbf_for_tx(&self, txid: &Txid) -> RbfForTx {
let state = self.read();
let root_txid = state.graveyard.replacement_root_of(*txid);
let replaces: Vec<Txid> = state
.graveyard
.predecessors_of(txid)
.map(|(p, _)| *p)
.collect();
let root = Self::build_rbf_node(&root_txid, &state.txs, &state.graveyard);
RbfForTx { root, replaces }
}
/// Recent terminal-replacer trees, most-recent first, deduplicated
/// by root, capped at `limit`. `full_rbf_only` drops trees with no
/// non-signaling predecessor.
#[must_use]
pub fn recent_rbf_trees(&self, full_rbf_only: bool, limit: usize) -> Vec<RbfNode> {
let state = self.read();
let mut seen: FxHashSet<Txid> = FxHashSet::default();
state
.graveyard
.replaced_iter_recent_first()
.filter_map(|(_, by)| {
let root = state.graveyard.replacement_root_of(*by);
seen.insert(root).then_some(root)
})
.filter_map(|root| Self::build_rbf_node(&root, &state.txs, &state.graveyard))
.filter(|n| !full_rbf_only || n.full_rbf)
.take(limit)
.collect()
}
fn build_rbf_node(txid: &Txid, txs: &TxStore, graveyard: &TxGraveyard) -> Option<RbfNode> {
let (tx, entry) = Self::resolve_rbf_node(txid, txs, graveyard)?;
let replaces: Vec<RbfNode> = graveyard
.predecessors_of(txid)
.filter_map(|(pred, _)| Self::build_rbf_node(pred, txs, graveyard))
.collect();
let full_rbf = replaces.iter().any(|c| !c.rbf || c.full_rbf);
let value: Sats = tx.output.iter().map(|o| o.value).sum();
Some(RbfNode {
txid: *txid,
fee: entry.fee,
vsize: entry.vsize,
value,
first_seen: entry.first_seen,
rbf: entry.rbf,
full_rbf,
replaces,
})
}
fn resolve_rbf_node<'a>(
txid: &Txid,
txs: &'a TxStore,
graveyard: &'a TxGraveyard,
) -> Option<(&'a Transaction, &'a TxEntry)> {
txs.record_by_prefix(&TxidPrefix::from(txid))
.map(|r| (&r.tx, &r.entry))
.or_else(|| graveyard.get(txid).map(|t| (&t.tx, &t.entry)))
}
}
#[cfg(test)]
mod tests {
use brk_types::FeeRate;
use super::*;
use crate::{
Mempool, TxRemoval,
state::TxEntry,
test_support::{fake_entry_info, fake_tx, fake_txid, p2wpkh_script},
};
/// Place a live tx (the replacer) and bury one or more predecessors
/// pointing at it. `bury_chain` carries `(seed, predecessor_of_next)`
/// pairs in oldest-first order. Each links forward to the next entry
/// or to `live_seed` when last.
fn build_rbf_world(live_seed: u8, predecessors: &[u8]) -> (Mempool, Txid, Vec<Txid>) {
let mempool = Mempool::for_test();
let live_tx = fake_tx(live_seed, &[None], &[(p2wpkh_script(live_seed + 1), 1_234)]);
let live_txid = live_tx.txid;
let live_entry = TxEntry::new(&fake_entry_info(live_txid, 5_000, 100), 100, true);
let mut pred_txids = Vec::with_capacity(predecessors.len());
let mut state = mempool.test_state_lock().write();
for (i, seed) in predecessors.iter().enumerate() {
let tx = fake_tx(*seed, &[None], &[(p2wpkh_script(seed + 1), 1_234)]);
let txid = tx.txid;
// Each predecessor signals BIP-125 (rbf=true) so full_rbf stays clear.
let entry = TxEntry::new(&fake_entry_info(txid, 1_000, 100), 100, true);
let by = predecessors
.get(i + 1)
.map(|next_seed| fake_txid(*next_seed))
.unwrap_or(live_txid);
let rate = FeeRate::from((entry.fee, entry.vsize));
state.graveyard.bury(tx, entry, rate, TxRemoval::Replaced { by });
pred_txids.push(txid);
}
state.txs.insert(live_tx, live_entry);
drop(state);
(mempool, live_txid, pred_txids)
}
#[test]
fn rbf_for_tx_single_replacement_returns_root_and_replaces() {
// pred -> live. rbf_for_tx(pred) walks forward to live and lists
// pred under its `replaces` tree.
let (mempool, live, preds) = build_rbf_world(0xC0, &[0xC1]);
let pred = preds[0];
let rbf = mempool.rbf_for_tx(&pred);
let root = rbf.root.expect("terminal replacer reachable");
assert_eq!(root.txid, live);
let replaced_txids: Vec<Txid> = root.replaces.iter().map(|n| n.txid).collect();
assert_eq!(replaced_txids, vec![pred]);
// Convenience list: direct predecessors of the requested tx.
assert!(rbf.replaces.is_empty(), "pred has no predecessors of its own");
}
#[test]
fn rbf_for_tx_chain_walks_to_terminal_root() {
// A -> B -> C(live). rbf_for_tx(A) walks A -> B -> C, root is C.
// root.replaces is B, B.replaces is A.
let (mempool, live, preds) = build_rbf_world(0xC2, &[0xC3, 0xC4]);
let a = preds[0];
let b = preds[1];
let rbf = mempool.rbf_for_tx(&a);
let root = rbf.root.expect("terminal replacer reachable");
assert_eq!(root.txid, live);
assert_eq!(root.replaces.len(), 1);
assert_eq!(root.replaces[0].txid, b);
assert_eq!(root.replaces[0].replaces.len(), 1);
assert_eq!(root.replaces[0].replaces[0].txid, a);
}
#[test]
fn rbf_for_tx_unknown_tx_returns_none_root() {
let mempool = Mempool::for_test();
let bogus = Txid::COINBASE;
let rbf = mempool.rbf_for_tx(&bogus);
assert!(rbf.root.is_none());
assert!(rbf.replaces.is_empty());
}
#[test]
fn recent_rbf_trees_dedup_by_root_and_respect_limit() {
// Chain 0xC6 -> 0xC7 -> live plus a sibling 0xC8 also replaced by
// live. All paths roll up to the same root, so the recent listing
// dedups them down to a single tree.
let (mempool, live, _preds) = build_rbf_world(0xC5, &[0xC6, 0xC7]);
{
let mut state = mempool.test_state_lock().write();
let extra = fake_tx(0xC8, &[None], &[(p2wpkh_script(0xC9), 1_234)]);
let extra_txid = extra.txid;
let entry = TxEntry::new(&fake_entry_info(extra_txid, 999, 100), 100, true);
let rate = FeeRate::from((entry.fee, entry.vsize));
state.graveyard.bury(extra, entry, rate, TxRemoval::Replaced { by: live });
}
let trees = mempool.recent_rbf_trees(false, 10);
assert_eq!(trees.len(), 1, "all paths roll up to one root");
assert_eq!(trees[0].txid, live);
let capped = mempool.recent_rbf_trees(false, 0);
assert!(capped.is_empty(), "limit honored");
}
}

View File

@@ -0,0 +1,68 @@
//! Tx-keyed reads.
use brk_types::{
MempoolRecentTx, OutpointPrefix, Transaction, Txid, TxidPrefix, Vin, Vout,
};
use crate::Mempool;
impl Mempool {
pub fn contains_txid(&self, txid: &Txid) -> bool {
self.read().txs.contains(txid)
}
/// Apply `f` to the live tx body if present.
pub fn with_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> {
self.read().txs.get(txid).map(f)
}
/// Apply `f` to a `Vanished` tombstone's tx body if present.
/// `Replaced` tombstones return `None` because the tx will not confirm.
pub fn with_vanished_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> {
self.read().graveyard.get_vanished(txid).map(|t| f(&t.tx))
}
/// Mempool tx spending `(txid, vout)`, or `None`. The spender's
/// input list is walked to rule out `TxidPrefix` collisions.
pub fn lookup_spender(&self, txid: &Txid, vout: Vout) -> Option<(Txid, Vin)> {
let key = OutpointPrefix::new(TxidPrefix::from(txid), vout);
let state = self.read();
let spender_prefix = state.outpoint_spends.get(&key)?;
let spender = state.txs.record_by_prefix(&spender_prefix)?;
let vin_pos = spender
.tx
.input
.iter()
.position(|inp| inp.txid == *txid && inp.vout == vout)?;
Some((spender.entry.txid, Vin::from(vin_pos)))
}
/// Snapshot of all live mempool txids.
///
/// Allocates `32 * len(mempool)` bytes under the read guard. Sized for
/// diagnostics. Route layers serving large pools should paginate at
/// their boundary rather than calling this per request.
#[must_use]
pub fn txids(&self) -> Vec<Txid> {
self.read().txs.txids().copied().collect()
}
/// Snapshot of recent live txs.
#[must_use]
pub fn recent_txs(&self) -> Vec<MempoolRecentTx> {
self.read().txs.recent().to_vec()
}
/// `first_seen` Unix-second timestamps for `txids`, in input order.
/// Returns 0 for unknown txids. `Vanished` tombstones fall back to
/// the buried entry's `first_seen` to avoid flicker between drop
/// and indexer catch-up.
#[must_use]
pub fn transaction_times(&self, txids: &[Txid]) -> Vec<u64> {
let state = self.read();
txids
.iter()
.map(|txid| state.first_seen(txid).map_or(0, u64::from))
.collect()
}
}

View File

@@ -1,138 +0,0 @@
//! Snapshot-side cluster primitives: connected-component discovery
//! over `SnapTx` adjacency, topological ordering, and the glue that
//! feeds the cluster into [`brk_types::linearize`] (Single Fee
//! Linearization).
//!
//! A *cluster* is the connected component of a tx in the dependency
//! graph (`parents children`), bounded by Core 31's
//! `MAX_CLUSTER_COUNT_LIMIT = 64`. The SFL algorithm itself lives in
//! `brk_types` since it has no mempool deps and is shared with the
//! confirmed-cpfp path in `brk_query`.
use std::collections::VecDeque;
use brk_types::{ChunkInput, CpfpClusterChunk, CpfpClusterTxIndex, linearize};
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
use smallvec::SmallVec;
use crate::steps::{SnapTx, TxIndex};
/// Cluster cap matches Bitcoin Core 31's `MAX_CLUSTER_COUNT_LIMIT`. Any
/// connected component above this size is malformed under Core's policy
/// and gets truncated.
pub(crate) const MAX_CLUSTER: usize = 64;
/// Capped DFS over the undirected dependency graph (`parents
/// children`) starting from `seed`. Returns the connected component
/// truncated to `MAX_CLUSTER`, with `seed` at index 0.
pub(crate) fn walk_cluster(txs: &[SnapTx], seed: TxIndex) -> Vec<TxIndex> {
if txs.get(seed.as_usize()).is_none() {
return Vec::new();
}
let mut visited: FxHashSet<TxIndex> =
FxHashSet::with_capacity_and_hasher(MAX_CLUSTER, FxBuildHasher);
visited.insert(seed);
let mut out: Vec<TxIndex> = Vec::with_capacity(MAX_CLUSTER);
out.push(seed);
let mut stack: Vec<TxIndex> = vec![seed];
while let Some(idx) = stack.pop() {
let Some(t) = txs.get(idx.as_usize()) else {
continue;
};
for &n in t.parents.iter().chain(t.children.iter()) {
if out.len() >= MAX_CLUSTER {
return out;
}
if visited.insert(n) {
out.push(n);
stack.push(n);
}
}
}
out
}
/// Linearize the connected component into chunks. Topo-sorts members,
/// remaps parent edges to cluster-local indices, and runs SFL. Returns
/// `(members, chunks)` where `members` is the topo-ordered `TxIndex`
/// list and `chunks[*].txs` are local indices into `members`. Callers
/// must filter singletons before calling - the singleton's `chunk_rate`
/// is `fee/vsize`, set elsewhere.
pub(crate) fn linearize_component(
txs: &[SnapTx],
component: &[TxIndex],
) -> (Vec<TxIndex>, Vec<CpfpClusterChunk>) {
let members = topo_sort(txs, component);
let local_of = build_local_index(&members);
let parents_local: Vec<SmallVec<[CpfpClusterTxIndex; 2]>> = members
.iter()
.map(|idx| {
txs[idx.as_usize()]
.parents
.iter()
.filter_map(|p| local_of.get(p).copied())
.collect()
})
.collect();
let inputs: Vec<ChunkInput<'_>> = members
.iter()
.zip(&parents_local)
.map(|(idx, ps)| {
let t = &txs[idx.as_usize()];
ChunkInput {
fee: t.fee,
vsize: t.vsize,
parents: ps.as_slice(),
}
})
.collect();
let chunks = linearize(&inputs);
(members, chunks)
}
/// Kahn's topological sort over the connected component, restricted to
/// in-cluster parent edges. Returns members in an order where every tx
/// follows all its in-cluster parents.
fn topo_sort(txs: &[SnapTx], component: &[TxIndex]) -> Vec<TxIndex> {
let n = component.len();
let pos: FxHashMap<TxIndex, usize> = component
.iter()
.enumerate()
.map(|(i, &x)| (x, i))
.collect();
let mut indeg: Vec<u32> = vec![0; n];
let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
for (i, &idx) in component.iter().enumerate() {
let Some(t) = txs.get(idx.as_usize()) else {
continue;
};
indeg[i] = t.parents.iter().filter(|p| pos.contains_key(p)).count() as u32;
for &c in t.children.iter() {
if let Some(&ci) = pos.get(&c) {
children[i].push(ci);
}
}
}
let mut queue: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
let mut out: Vec<TxIndex> = Vec::with_capacity(n);
while let Some(i) = queue.pop_front() {
out.push(component[i]);
for &c in &children[i] {
indeg[c] -= 1;
if indeg[c] == 0 {
queue.push_back(c);
}
}
}
out
}
/// `members[i]`'s wire index, keyed by snapshot `TxIndex`. Built once
/// so per-tx parent edges can be remapped without a linear scan.
pub(crate) fn build_local_index(members: &[TxIndex]) -> FxHashMap<TxIndex, CpfpClusterTxIndex> {
members
.iter()
.enumerate()
.map(|(i, &idx)| (idx, CpfpClusterTxIndex::from(i as u32)))
.collect()
}

View File

@@ -1,159 +0,0 @@
//! CPFP (Child Pays For Parent) walk over a `Snapshot`'s adjacency.
//!
//! Three independent walks:
//! - `ancestors`: capped DFS up `parents` only.
//! - `descendants`: capped DFS down `children` only.
//! - cluster: the connected component over `parents children`,
//! linearized via [`crate::cluster`] for the cluster wire shape and
//! the seed's chunk feerate.
use brk_types::{
CPFP_CHAIN_LIMIT, CpfpCluster, CpfpClusterTx, CpfpClusterTxIndex, CpfpEntry, CpfpInfo, FeeRate,
SigOps, TxidPrefix, VSize, find_seed_chunk,
};
use rustc_hash::{FxBuildHasher, FxHashSet};
use crate::{
Mempool,
cluster::{build_local_index, linearize_component, walk_cluster},
steps::{SnapTx, TxIndex},
};
impl Mempool {
/// CPFP info for a live mempool tx. Returns `None` only when the
/// tx isn't in the mempool, so callers can fall through to the
/// confirmed path.
pub fn cpfp_info(&self, prefix: &TxidPrefix) -> Option<CpfpInfo> {
let snapshot = self.snapshot();
let seed_idx = snapshot.idx_of(prefix)?;
let seed = snapshot.tx(seed_idx)?;
let sigops = self
.read()
.txs
.get(&seed.txid)
.map(|tx| tx.total_sigop_cost)
.unwrap_or(SigOps::ZERO);
Some(build_cpfp_info(&snapshot.txs, seed_idx, seed, sigops))
}
}
fn build_cpfp_info(
txs: &[SnapTx],
seed_idx: TxIndex,
seed: &SnapTx,
sigops: SigOps,
) -> CpfpInfo {
let ancestors = collect_entries(txs, seed_idx, |t| &t.parents);
let descendants = collect_entries(txs, seed_idx, |t| &t.children);
let best_descendant = descendants
.iter()
.max_by_key(|e| FeeRate::from((e.fee, e.weight)))
.cloned();
let (cluster, effective_fee_per_vsize) = build_cluster(txs, seed_idx, seed);
let vsize = VSize::from(seed.weight);
CpfpInfo {
ancestors,
best_descendant,
descendants,
effective_fee_per_vsize,
sigops,
fee: seed.fee,
vsize,
adjusted_vsize: sigops.adjust_vsize(vsize),
cluster,
}
}
/// Capped DFS from `seed` (exclusive) along `next`, lifted directly to
/// wire-shape `CpfpEntry`s. Used for both ancestor and descendant walks.
fn collect_entries(
txs: &[SnapTx],
seed: TxIndex,
next: impl Fn(&SnapTx) -> &[TxIndex],
) -> Vec<CpfpEntry> {
let Some(seed_node) = txs.get(seed.as_usize()) else {
return Vec::new();
};
let mut visited: FxHashSet<TxIndex> =
FxHashSet::with_capacity_and_hasher(CPFP_CHAIN_LIMIT + 1, FxBuildHasher);
visited.insert(seed);
let mut out: Vec<CpfpEntry> = Vec::with_capacity(CPFP_CHAIN_LIMIT);
let mut stack: Vec<TxIndex> = next(seed_node).to_vec();
while let Some(idx) = stack.pop() {
if out.len() >= CPFP_CHAIN_LIMIT {
break;
}
if !visited.insert(idx) {
continue;
}
if let Some(t) = txs.get(idx.as_usize()) {
out.push(CpfpEntry::from(t));
stack.extend(next(t).iter().copied());
}
}
out
}
/// Wire-shape `CpfpCluster` plus the seed's chunk feerate. Members are
/// the connected component of the seed in the dependency graph,
/// topologically ordered (parents before children) so wire indices and
/// chunk-internal ordering are valid for client-side reconstruction.
/// Returns `(None, seed_per_tx_rate)` for singletons (matches
/// mempool.space, which omits `cluster` when no relations exist).
fn build_cluster(
txs: &[SnapTx],
seed_idx: TxIndex,
seed: &SnapTx,
) -> (Option<CpfpCluster>, FeeRate) {
let seed_per_tx_rate = FeeRate::from((seed.fee, seed.vsize));
let component = walk_cluster(txs, seed_idx);
if component.len() <= 1 {
return (None, seed_per_tx_rate);
}
let (members, chunks) = linearize_component(txs, &component);
let cluster_txs = build_wire_members(txs, &members);
let seed_local = CpfpClusterTxIndex::from(
members
.iter()
.position(|&i| i == seed_idx)
.map_or(0, |p| p as u32),
);
let (chunk_index, seed_chunk_rate) = find_seed_chunk(&chunks, seed_local, seed_per_tx_rate);
(
Some(CpfpCluster {
txs: cluster_txs,
chunks,
chunk_index,
}),
seed_chunk_rate,
)
}
/// Materialize wire-shape `CpfpClusterTx`s for every topo-ordered
/// member with parent edges remapped to local indices.
fn build_wire_members(txs: &[SnapTx], members: &[TxIndex]) -> Vec<CpfpClusterTx> {
let local_of = build_local_index(members);
members
.iter()
.map(|&idx| {
let t = &txs[idx.as_usize()];
CpfpClusterTx {
txid: t.txid,
weight: t.weight,
fee: t.fee,
parents: t
.parents
.iter()
.filter_map(|p| local_of.get(p).copied())
.collect(),
}
})
.collect()
}

View File

@@ -1,57 +0,0 @@
//! Per-cycle event report returned by [`super::Mempool::tick`].
use std::{sync::Arc, time::Duration};
use brk_types::{AddrBytes, BlockHash, FeeRate, Height, MempoolInfo, Sats, Timestamp, Txid, VSize};
use crate::{Snapshot, TxRemoval};
/// One pull cycle's worth of changes. Produced by
/// [`super::Mempool::tick`] after fetch → prepare → apply → prevouts →
/// rebuild. The snapshot is always present (the rebuilder runs every
/// cycle); compare `next_block_hash` across cycles if you need to
/// detect whether the projection actually changed.
pub struct Cycle {
pub added: Vec<TxAdded>,
pub removed: Vec<TxRemoved>,
/// Addresses that went from 0 → 1+ live mempool txs this cycle.
/// Same-cycle enter-then-leave is collapsed (no event in either list).
pub addr_enters: Vec<AddrBytes>,
/// Addresses that went from 1+ → 0 live mempool txs this cycle.
pub addr_leaves: Vec<AddrBytes>,
/// Latest confirmed block. Compare to the prior cycle's `tip_hash`
/// to detect a new block.
pub tip_hash: BlockHash,
pub tip_height: Height,
pub info: MempoolInfo,
pub snapshot: Arc<Snapshot>,
pub took: Duration,
}
#[derive(Debug, Clone, Copy)]
pub struct TxAdded {
pub txid: Txid,
pub fee: Sats,
pub vsize: VSize,
pub fee_rate: FeeRate,
pub first_seen: Timestamp,
pub kind: AddedKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddedKind {
/// First time we've seen this txid.
Fresh,
/// Re-entered the pool after a prior removal still in the graveyard.
Revived,
}
#[derive(Debug, Clone, Copy)]
pub struct TxRemoved {
pub txid: Txid,
pub reason: TxRemoval,
/// Package-effective rate at burial. Same value the tx reported
/// while alive - RBF predecessors keep their package rate, not a
/// misleading isolated fee/vsize.
pub chunk_rate: FeeRate,
}

View File

@@ -0,0 +1,7 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddedKind {
/// First time we've seen this txid.
Fresh,
/// Re-entered the pool after a prior removal still in the graveyard.
Revived,
}

View File

@@ -0,0 +1,104 @@
//! Per-cycle 0↔1+ address transition buffer. Same-cycle cancellation
//! (enter→leave, leave→enter) is encapsulated on the recording methods.
use brk_types::AddrBytes;
use rustc_hash::FxHashSet;
#[derive(Default)]
pub struct AddrTransitions {
enters: FxHashSet<AddrBytes>,
leaves: FxHashSet<AddrBytes>,
}
impl AddrTransitions {
pub fn record_enter(&mut self, bytes: AddrBytes) {
if !self.leaves.remove(&bytes) {
self.enters.insert(bytes);
}
}
pub fn record_leave(&mut self, bytes: AddrBytes) {
if !self.enters.remove(&bytes) {
self.leaves.insert(bytes);
}
}
pub fn into_vecs(self) -> (Vec<AddrBytes>, Vec<AddrBytes>) {
(
self.enters.into_iter().collect(),
self.leaves.into_iter().collect(),
)
}
}
#[cfg(test)]
mod tests {
use bitcoin::{ScriptBuf, hashes::Hash};
use super::*;
fn addr(seed: u8) -> AddrBytes {
let mut bytes = [0u8; 20];
bytes[0] = seed;
let script = ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array(bytes));
AddrBytes::try_from(&script).expect("p2wpkh -> AddrBytes")
}
#[test]
fn enter_then_leave_cancels() {
let mut t = AddrTransitions::default();
t.record_enter(addr(1));
t.record_leave(addr(1));
let (enters, leaves) = t.into_vecs();
assert!(enters.is_empty());
assert!(leaves.is_empty());
}
#[test]
fn leave_then_enter_cancels() {
let mut t = AddrTransitions::default();
t.record_leave(addr(2));
t.record_enter(addr(2));
let (enters, leaves) = t.into_vecs();
assert!(enters.is_empty());
assert!(leaves.is_empty());
}
#[test]
fn enter_leave_enter_collapses_to_single_enter() {
let mut t = AddrTransitions::default();
let a = addr(3);
t.record_enter(a.clone());
t.record_leave(a.clone());
t.record_enter(a.clone());
let (enters, leaves) = t.into_vecs();
assert_eq!(enters, vec![a]);
assert!(leaves.is_empty());
}
#[test]
fn leave_enter_leave_collapses_to_single_leave() {
let mut t = AddrTransitions::default();
let a = addr(4);
t.record_leave(a.clone());
t.record_enter(a.clone());
t.record_leave(a.clone());
let (enters, leaves) = t.into_vecs();
assert!(enters.is_empty());
assert_eq!(leaves, vec![a]);
}
#[test]
fn distinct_addrs_dont_interfere() {
let mut t = AddrTransitions::default();
let a = addr(5);
let b = addr(6);
t.record_enter(a.clone());
t.record_leave(b.clone());
let (mut enters, mut leaves) = t.into_vecs();
enters.sort_by_key(|x| x.as_slice()[0]);
leaves.sort_by_key(|x| x.as_slice()[0]);
assert_eq!(enters, vec![a]);
assert_eq!(leaves, vec![b]);
}
}

View File

@@ -0,0 +1,10 @@
use crate::cycle::{AddrTransitions, TxAdded, TxRemoved};
/// Per-cycle accumulator threaded through the pipeline steps and
/// drained into the public [`crate::Cycle`] at end of cycle.
#[derive(Default)]
pub struct CycleDiff {
pub added: Vec<TxAdded>,
pub removed: Vec<TxRemoved>,
pub addrs: AddrTransitions,
}

View File

@@ -0,0 +1,30 @@
use std::{sync::Arc, time::Duration};
use brk_types::{AddrBytes, BlockHash, Height, MempoolInfo};
use crate::{
Snapshot,
cycle::{TxAdded, TxRemoved},
};
/// One pull cycle's worth of changes. Produced by
/// [`crate::Mempool::tick`] after fetch → prepare → apply → prevouts →
/// rebuild. The snapshot is always present (the rebuilder runs every
/// cycle). Compare `next_block_hash` across cycles if you need to
/// detect whether the projection actually changed.
pub struct Cycle {
pub added: Vec<TxAdded>,
pub removed: Vec<TxRemoved>,
/// Addresses that went from 0 → 1+ live mempool txs this cycle.
/// Same-cycle enter-then-leave is collapsed (no event in either list).
pub addr_enters: Vec<AddrBytes>,
/// Addresses that went from 1+ → 0 live mempool txs this cycle.
pub addr_leaves: Vec<AddrBytes>,
/// Latest confirmed block. Compare to the prior cycle's `tip_hash`
/// to detect a new block.
pub tip_hash: BlockHash,
pub tip_height: Height,
pub info: MempoolInfo,
pub snapshot: Arc<Snapshot>,
pub took: Duration,
}

View File

@@ -0,0 +1,15 @@
//! Per-cycle types. Every type here lives exactly one tick.
mod added_kind;
mod addr_transitions;
mod diff;
mod event;
mod tx_added;
mod tx_removed;
pub use added_kind::AddedKind;
pub use addr_transitions::AddrTransitions;
pub use diff::CycleDiff;
pub use event::Cycle;
pub use tx_added::TxAdded;
pub use tx_removed::TxRemoved;

View File

@@ -0,0 +1,13 @@
use brk_types::{FeeRate, Sats, Timestamp, Txid, VSize};
use crate::cycle::AddedKind;
#[derive(Debug, Clone, Copy)]
pub struct TxAdded {
pub txid: Txid,
pub fee: Sats,
pub vsize: VSize,
pub fee_rate: FeeRate,
pub first_seen: Timestamp,
pub kind: AddedKind,
}

View File

@@ -0,0 +1,13 @@
use brk_types::{FeeRate, Txid};
use crate::TxRemoval;
#[derive(Debug, Clone, Copy)]
pub struct TxRemoved {
pub txid: Txid,
pub reason: TxRemoval,
/// Package-effective rate at burial. Same value the tx reported
/// while alive - RBF predecessors keep their package rate, not a
/// misleading isolated fee/vsize.
pub chunk_rate: FeeRate,
}

View File

@@ -1,11 +0,0 @@
//! Per-cycle accumulator threaded through the pipeline steps and
//! drained into the public [`crate::Cycle`] at end of cycle.
use crate::{TxAdded, TxRemoved, stores::AddrTransitions};
#[derive(Default)]
pub struct CycleDiff {
pub added: Vec<TxAdded>,
pub removed: Vec<TxRemoved>,
pub addrs: AddrTransitions,
}

View File

@@ -0,0 +1,194 @@
//! Cycle loop. `start_with` drives [`Mempool::tick_with`] every
//! [`PERIOD`]. Each cycle is wrapped in `catch_unwind` so a panic
//! doesn't freeze the snapshot. `parking_lot` locks don't poison.
use std::{
any::Any,
panic::{AssertUnwindSafe, catch_unwind},
sync::atomic::Ordering,
thread,
time::{Duration, Instant},
};
use brk_error::Result;
use brk_types::{TxOut, Txid, Vout};
use rustc_hash::FxHashMap;
use tracing::error;
use crate::{
Inner, Mempool,
cycle::{Cycle, CycleDiff},
steps::{Applier, Fetched, Fetcher, Preparer, Prevouts},
};
const PERIOD: Duration = Duration::from_millis(1000);
impl Mempool {
/// Infinite update loop with a 1s interval. Resolves
/// confirmed-parent prevouts via the default `getrawtransaction`
/// resolver. Requires bitcoind started with `txindex=1`. Discards
/// per-cycle [`Cycle`] events - use [`Mempool::tick`] to consume them.
pub fn start(&self) {
self.start_with(Prevouts::rpc_resolver(self.0.client.clone()));
}
/// Variant of `start` that uses a caller-supplied resolver for
/// confirmed-parent prevouts (typically backed by an indexer).
///
/// Sleep is `PERIOD - work_duration`, so a 350ms cycle followed by
/// a 100ms cycle still ticks roughly every `PERIOD`. When work
/// overruns `PERIOD`, the next cycle starts immediately.
///
/// # Panics
///
/// Panics if a driver is already running on this `Mempool` instance.
/// One `Mempool` may host at most one driver. Spawn another instance
/// for additional loops.
pub fn start_with<F>(&self, resolver: F)
where
F: Fn(&[(Txid, Vout)]) -> FxHashMap<(Txid, Vout), TxOut> + Send,
{
if self
.0
.started
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
panic!("Mempool::start_with already running on this instance");
}
loop {
let started = Instant::now();
let outcome = catch_unwind(AssertUnwindSafe(|| {
if let Err(e) = self.tick_with(&resolver) {
error!("update failed: {e}");
}
}));
if let Err(payload) = outcome {
error!(
"mempool update panicked, continuing loop: {}",
Self::panic_msg(&payload)
);
}
if let Some(rest) = PERIOD.checked_sub(started.elapsed()) {
thread::sleep(rest);
}
}
}
/// One sync cycle: fetch, prepare, apply, fill prevouts, rebuild.
/// Returns a [`Cycle`] reporting everything that changed. Uses the
/// default `getrawtransaction` resolver for confirmed-parent
/// prevouts (requires `txindex=1`).
///
/// # Errors
///
/// Propagates any failure from the initial RPC fetch (network drop,
/// auth, bitcoind error). Steps after `Fetcher::fetch` are infallible
/// today. The resolver itself swallows its own errors and retries
/// next cycle.
pub fn tick(&self) -> Result<Cycle> {
self.tick_with(Prevouts::rpc_resolver(self.0.client.clone()))
}
/// Variant of [`Mempool::tick`] with a caller-supplied resolver for
/// confirmed-parent prevouts. The resolver MUST resolve confirmed
/// prevouts only. Mempool-to-mempool chains are wired internally
/// and the resolver is never called for them.
///
/// # Errors
///
/// Same as [`Mempool::tick`]: only the RPC fetch is fallible.
pub fn tick_with<F>(&self, resolver: F) -> Result<Cycle>
where
F: Fn(&[(Txid, Vout)]) -> FxHashMap<(Txid, Vout), TxOut>,
{
let started = Instant::now();
let Inner {
client,
state,
rebuilder,
..
} = &*self.0;
let Fetched {
state: rpc,
new_entries,
new_txs,
block_template_txids,
} = Fetcher::fetch(client, state)?;
let pulled = Preparer::prepare(&rpc.live_txids, new_entries, new_txs, state);
let mut diff = CycleDiff::default();
let prev_snapshot = rebuilder.snapshot();
Applier::apply(state, &prev_snapshot, pulled, &mut diff);
drop(prev_snapshot);
Prevouts::fill(state, &mut diff, resolver);
rebuilder.tick(state, &block_template_txids, rpc.min_fee);
let CycleDiff {
added,
removed,
addrs,
} = diff;
let (addr_enters, addr_leaves) = addrs.into_vecs();
Ok(Cycle {
added,
removed,
addr_enters,
addr_leaves,
tip_hash: rpc.tip_hash,
tip_height: rpc.tip_height,
info: self.info(),
snapshot: rebuilder.snapshot(),
took: started.elapsed(),
})
}
fn panic_msg(payload: &(dyn Any + Send)) -> &str {
payload
.downcast_ref::<&'static str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
.unwrap_or("<non-string panic payload>")
}
}
#[cfg(test)]
mod tests {
use std::panic::catch_unwind;
use rustc_hash::FxHashMap;
use super::*;
#[test]
#[should_panic(expected = "Mempool::start_with already running on this instance")]
fn double_start_panics_with_documented_message() {
let mempool = Mempool::for_test();
// Simulate a prior `start_with` having grabbed the latch. We
// can't actually call it first because the real call enters an
// infinite loop. Flipping the atomic is what the runtime check
// observes anyway.
mempool.0.started.store(true, Ordering::Release);
mempool.start_with(|_: &[(Txid, Vout)]| FxHashMap::default());
}
#[test]
fn panic_msg_extracts_static_str_payload() {
let payload = catch_unwind(|| panic!("boom static")).unwrap_err();
assert_eq!(Mempool::panic_msg(payload.as_ref()), "boom static");
}
#[test]
fn panic_msg_extracts_string_payload() {
let payload = catch_unwind(|| panic!("boom owned {}", 42)).unwrap_err();
assert_eq!(Mempool::panic_msg(payload.as_ref()), "boom owned 42");
}
#[test]
fn panic_msg_falls_back_for_non_string_payload() {
// Payload that isn't &str or String: the helper labels it
// explicitly instead of dropping it on the floor.
let payload = catch_unwind(|| std::panic::panic_any(42u32)).unwrap_err();
assert_eq!(Mempool::panic_msg(payload.as_ref()), "<non-string panic payload>");
}
}

View File

@@ -2,78 +2,84 @@
//!
//! One pull cycle, five steps:
//!
//! 1. [`steps::fetcher::Fetcher`] - one mixed batched RPC for
//! ```text
//! Fetcher -> Preparer -> Applier -> Prevouts -> Rebuilder
//! RPC decode & write to fill build
//! classify State missing Snapshot
//! prevouts
//! ```
//!
//! 1. [`steps::Fetcher`] - one mixed batched RPC for
//! `getblocktemplate` + `getrawmempool false` + `getmempoolinfo`,
//! then a single mixed `getmempoolentry`+`getrawtransaction` batch
//! on new txids only. GBT-only txs are synthesized inline from the
//! GBT payload so block 0 matches Core's selection exactly without
//! a follow-up entry fetch that could race the listing.
//! 2. [`steps::preparer::Preparer`] - decode and classify into
//! 2. [`steps::Preparer`] - decode and classify into
//! `TxsPulled { added, removed }`. Pure CPU.
//! 3. [`steps::applier::Applier`] - apply the diff to
//! [`state::State`] under a single write lock.
//! 3. [`steps::Applier`] - apply the diff to [`state::State`] under a
//! single write lock.
//! 4. [`steps::Prevouts::fill`] - fills `prevout: None` inputs in one
//! pass, using same-cycle in-mempool parents directly and the
//! caller-supplied resolver (default: `getrawtransaction`) for
//! confirmed parents.
//! 5. [`steps::rebuilder::Rebuilder`] - rebuild of the projected-blocks
//! `Snapshot` from the same-cycle GBT and min fee.
//! 5. [`snapshot::Rebuilder`] - rebuilds the projected-blocks
//! [`Snapshot`] from the same-cycle GBT and min fee.
//!
//! # Locking domains
//!
//! Two independent locks. No path holds both simultaneously.
//!
//! - `State` (`RwLock<State>`): the live mempool. Cycle steps 3 and 4
//! take the write guard. Every read-side accessor takes a read guard.
//! - `Rebuilder.{snapshot, history}` (two `RwLock`s, written in that
//! order each cycle): the published projection. Readers grab one or
//! the other. The cycle drops its `State` guard before touching them.
//!
//! # Usage
//!
//! Drive the loop on a worker thread and read from any clone:
//!
//! ```no_run
//! use brk_mempool::Mempool;
//! # fn make_client() -> brk_rpc::Client { unimplemented!() }
//! let client = make_client();
//! let mempool = Mempool::new(&client);
//! let reader = mempool.clone();
//! std::thread::spawn(move || mempool.start());
//! // `reader.snapshot()`, `reader.block_template()`, etc. on this thread.
//! # let _ = reader;
//! ```
//!
//! A `Mempool` hosts at most one driver. Calling `start` / `start_with`
//! a second time on the same instance panics. Spawn a separate
//! `Mempool::new` if you need more loops.
use std::{
any::Any,
cmp::Reverse,
panic::{AssertUnwindSafe, catch_unwind},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant},
};
use std::sync::{Arc, atomic::AtomicBool};
use brk_error::Result;
use brk_oracle::Histogram;
use brk_rpc::Client;
use brk_types::{
AddrBytes, AddrMempoolStats, BlockTemplate, BlockTemplateDiff, BlockTemplateDiffEntry, FeeRate,
MempoolBlock, MempoolInfo, MempoolRecentTx, NextBlockHash, OutpointPrefix, Timestamp,
Transaction, TxOut, Txid, TxidPrefix, Vin, Vout,
};
use parking_lot::{RwLock, RwLockReadGuard};
use rustc_hash::{FxHashMap, FxHashSet};
use tracing::error;
mod cluster;
mod cpfp;
mod api;
mod cycle;
mod cycle_diff;
mod diagnostics;
mod rbf;
mod driver;
mod snapshot;
mod state;
pub(crate) mod steps;
pub(crate) mod stores;
mod steps;
mod stores;
#[cfg(test)]
mod test_support;
pub use api::{RbfForTx, RbfNode};
pub use cycle::{AddedKind, Cycle, TxAdded, TxRemoved};
pub use diagnostics::MempoolStats;
pub use rbf::{RbfForTx, RbfNode};
pub use steps::{Snapshot, TxRemoval};
use steps::{Applier, Fetched, Fetcher, Preparer, Prevouts, Rebuilder};
pub(crate) use cycle_diff::CycleDiff;
pub(crate) use steps::{BlockStats, RecommendedFees, TxEntry};
pub(crate) use stores::{AddrTransitions, TxStore, TxTombstone};
pub use snapshot::Snapshot;
pub use steps::TxRemoval;
/// Confirmed-parent prevout resolver passed to [`Mempool::tick_with`] /
/// [`Mempool::start_with`]. Receives a slice of `(parent_txid, vout)`
/// holes and returns the subset that resolved. Unresolved holes are
/// simply omitted from the map; the next cycle retries automatically.
///
/// Batched so the RPC implementation can pack one round-trip per cycle
/// (deduping by parent txid so a tx with N inputs from one parent costs
/// one fetch); the indexer implementation just loops over local reads.
pub type PrevoutResolver =
Box<dyn Fn(&[(Txid, Vout)]) -> FxHashMap<(Txid, Vout), TxOut> + Send + Sync>;
pub(crate) use state::State;
use snapshot::Rebuilder;
use state::State;
/// Cheaply cloneable: clones share one live mempool via `Arc`.
#[derive(Clone)]
@@ -96,10 +102,6 @@ impl Mempool {
}))
}
pub fn info(&self) -> MempoolInfo {
self.read().info.clone()
}
pub fn snapshot(&self) -> Arc<Snapshot> {
self.0.rebuilder.snapshot()
}
@@ -109,330 +111,41 @@ impl Mempool {
MempoolStats::from(self)
}
pub(crate) fn rebuilder(&self) -> &Rebuilder {
fn rebuilder(&self) -> &Rebuilder {
&self.0.rebuilder
}
pub fn fees(&self) -> RecommendedFees {
self.snapshot().fees.clone()
}
pub fn block_stats(&self) -> Vec<BlockStats> {
self.snapshot().block_stats.clone()
}
pub fn next_block_hash(&self) -> NextBlockHash {
self.snapshot().next_block_hash
}
/// Full projected next block: Core's `getblocktemplate` selection
/// (block 0) with aggregate stats and full tx bodies in GBT order.
pub fn block_template(&self) -> BlockTemplate {
let snap = self.snapshot();
BlockTemplate {
hash: snap.next_block_hash,
stats: snap
.block_stats
.first()
.map(MempoolBlock::from)
.unwrap_or_default(),
transactions: self.collect_txs(snap.block0_txids()),
}
}
/// Delta of the projected next block since `since`. `None` when
/// `since` has aged out of the rebuilder's history (server should
/// 404 → client falls back to `block_template`).
///
/// `order` walks the new template in template order; each entry is
/// either a `Retained` index into the prior template (which the
/// client cached when it obtained `since`) or a `New` inline body.
/// `removed` is the convenience list of txids that left.
pub fn block_template_diff(&self, since: NextBlockHash) -> Option<BlockTemplateDiff> {
let past = self.0.rebuilder.historical_block0(since)?;
let prior_index: FxHashMap<Txid, u32> = past
.iter()
.enumerate()
.map(|(idx, txid)| (*txid, idx as u32))
.collect();
let snap = self.snapshot();
let state = self.read();
let mut order = Vec::with_capacity(snap.blocks.first().map_or(0, Vec::len));
let mut current: FxHashSet<Txid> = FxHashSet::default();
for txid in snap.block0_txids() {
current.insert(txid);
match prior_index.get(&txid) {
Some(&idx) => order.push(BlockTemplateDiffEntry::Retained(idx)),
None => {
let tx = Self::lookup_body(&state, &txid)
.expect("snapshot tx body must be in txs or graveyard");
order.push(BlockTemplateDiffEntry::New(tx));
}
}
}
drop(state);
let removed = past.into_iter().filter(|t| !current.contains(t)).collect();
Some(BlockTemplateDiff {
hash: snap.next_block_hash,
since,
order,
removed,
})
}
fn collect_txs(&self, txids: impl IntoIterator<Item = Txid>) -> Vec<Transaction> {
let state = self.read();
txids
.into_iter()
.map(|txid| {
Self::lookup_body(&state, &txid)
.expect("snapshot tx body must be in txs or graveyard")
})
.collect()
}
/// Body for a txid in a published snapshot. Graveyard fallback
/// covers the eviction race: an Applier may have buried the tx
/// after the snapshot was built. Burial retention (1h) >> snapshot
/// cycle (~1s), so reachability is guaranteed.
fn lookup_body(state: &State, txid: &Txid) -> Option<Transaction> {
state
.txs
.get(txid)
.or_else(|| state.graveyard.get(txid).map(|t| &t.tx))
.cloned()
}
pub fn addr_state_hash(&self, addr: &AddrBytes) -> u64 {
self.read().addrs.stats_hash(addr)
}
/// Mempool tx spending `(txid, vout)`, or `None`. The spender's
/// input list is walked to rule out `TxidPrefix` collisions.
pub fn lookup_spender(&self, txid: &Txid, vout: Vout) -> Option<(Txid, Vin)> {
let key = OutpointPrefix::new(TxidPrefix::from(txid), vout);
let state = self.read();
let spender_prefix = state.outpoint_spends.get(&key)?;
let spender = state.txs.record_by_prefix(&spender_prefix)?;
let vin_pos = spender
.tx
.input
.iter()
.position(|inp| inp.txid == *txid && inp.vout == vout)?;
Some((spender.entry.txid, Vin::from(vin_pos)))
}
pub(crate) fn read(&self) -> RwLockReadGuard<'_, State> {
fn read(&self) -> RwLockReadGuard<'_, State> {
self.0.state.read()
}
pub fn contains_txid(&self, txid: &Txid) -> bool {
self.read().txs.contains(txid)
}
/// Apply `f` to the live tx body if present.
pub fn with_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> {
self.read().txs.get(txid).map(f)
}
/// Apply `f` to a `Vanished` tombstone's tx body if present.
/// `Replaced` tombstones return `None` because the tx will not confirm.
pub fn with_vanished_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> {
self.read().graveyard.get_vanished(txid).map(|t| f(&t.tx))
}
/// Snapshot of all live mempool txids.
pub fn txids(&self) -> Vec<Txid> {
self.read().txs.txids().copied().collect()
}
/// Snapshot of recent live txs.
pub fn recent_txs(&self) -> Vec<MempoolRecentTx> {
self.read().txs.recent().to_vec()
}
/// Per-address mempool stats. `None` if the address has no live mempool activity.
pub fn addr_stats(&self, addr: &AddrBytes) -> Option<AddrMempoolStats> {
self.read().addrs.get(addr).map(|e| e.stats.clone())
}
/// Live mempool txs touching `addr`, newest first by `first_seen`,
/// capped at `limit`. Returns owned `Transaction`s.
pub fn addr_txs(&self, addr: &AddrBytes, limit: usize) -> Vec<Transaction> {
let state = self.read();
let Some(entry) = state.addrs.get(addr) else {
return vec![];
};
let mut ordered: Vec<(Timestamp, &Transaction)> = entry
.txids
.iter()
.filter_map(|txid| {
let record = state.txs.record_by_prefix(&TxidPrefix::from(txid))?;
Some((record.entry.first_seen, &record.tx))
})
.collect();
ordered.sort_unstable_by_key(|b| Reverse(b.0));
ordered
.into_iter()
.take(limit)
.map(|(_, tx)| tx.clone())
.collect()
}
/// Histogram of pre-bucketed oracle bins across all live mempool tx
/// outputs. Bins are computed once on insert (see `OutputBins`), so this
/// hot path is `O(eligible outputs)` of integer increments. Used by
/// `live_price` to blend the mempool into the committed oracle without
/// re-parsing scripts per request.
pub fn live_histogram(&self) -> Histogram {
let mut hist = Histogram::zeros();
let state = self.read();
for (_, record) in state.txs.records() {
for bin in record.output_bins.iter() {
hist.increment(bin as usize);
}
}
hist
}
/// Effective fee rate for a live tx: snapshot's linearized chunk
/// rate. Falls back to `fee/vsize` for txs added since the latest
/// snapshot was built (apply -> same-cycle tick gap).
pub fn live_effective_fee_rate(&self, prefix: &TxidPrefix) -> Option<FeeRate> {
if let Some(rate) = self.snapshot().chunk_rate_for(prefix) {
return Some(rate);
}
self.read()
.txs
.entry_by_prefix(prefix)
.map(|e| e.fee_rate())
}
/// Linearized chunk rate captured at burial - same value
/// `live_effective_fee_rate` returned while the tx was alive, so an
/// evicted RBF predecessor reports the package-effective rate it
/// had in the mempool, not a misleading isolated `fee/vsize`.
pub fn graveyard_fee_rate(&self, txid: &Txid) -> Option<FeeRate> {
self.read().graveyard.get(txid).map(|tomb| tomb.chunk_rate)
}
/// `first_seen` Unix-second timestamps for `txids`, in input order.
/// Returns 0 for unknown txids. `Vanished` tombstones fall back to
/// the buried entry's `first_seen` to avoid flicker between drop
/// and indexer catch-up.
pub fn transaction_times(&self, txids: &[Txid]) -> Vec<u64> {
let state = self.read();
txids
.iter()
.map(|txid| state.first_seen(txid).map_or(0, u64::from))
.collect()
}
/// Infinite update loop with a 500ms interval. Resolves
/// confirmed-parent prevouts via the default `getrawtransaction`
/// resolver; requires bitcoind started with `txindex=1`. Drops
/// per-cycle [`Cycle`] events on the floor - use [`Mempool::tick`]
/// to consume them.
pub fn start(&self) {
self.start_with(Prevouts::rpc_resolver(self.0.client.clone()));
}
/// Variant of `start` that uses a caller-supplied resolver for
/// confirmed-parent prevouts (typically backed by an indexer).
/// Each cycle is wrapped in `catch_unwind` so a panic doesn't
/// freeze the snapshot; `parking_lot` locks don't poison.
///
/// Sleep is `PERIOD - work_duration`, so a 350ms cycle followed by
/// a 100ms cycle still ticks roughly every `PERIOD`. When work
/// overruns `PERIOD`, the next cycle starts immediately.
pub fn start_with<F>(&self, resolver: F)
where
F: Fn(&[(Txid, Vout)]) -> FxHashMap<(Txid, Vout), TxOut>,
{
if self
.0
.started
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
panic!("Mempool::start_with already running on this instance");
}
const PERIOD: Duration = Duration::from_millis(500);
loop {
let started = Instant::now();
let outcome = catch_unwind(AssertUnwindSafe(|| {
if let Err(e) = self.tick_with(&resolver) {
error!("update failed: {e}");
}
}));
if let Err(payload) = outcome {
error!(
"mempool update panicked, continuing loop: {}",
panic_msg(&payload)
);
}
if let Some(rest) = PERIOD.checked_sub(started.elapsed()) {
thread::sleep(rest);
}
}
}
/// One sync cycle: fetch, prepare, apply, fill prevouts, rebuild.
/// Returns a [`Cycle`] reporting everything that changed. Uses the
/// default `getrawtransaction` resolver for confirmed-parent
/// prevouts (requires `txindex=1`).
pub fn tick(&self) -> Result<Cycle> {
self.tick_with(Prevouts::rpc_resolver(self.0.client.clone()))
}
/// Variant of [`Mempool::tick`] with a caller-supplied resolver for
/// confirmed-parent prevouts. The resolver MUST resolve confirmed
/// prevouts only; mempool-to-mempool chains are wired internally
/// and the resolver is never called for them.
pub fn tick_with<F>(&self, resolver: F) -> Result<Cycle>
where
F: Fn(&[(Txid, Vout)]) -> FxHashMap<(Txid, Vout), TxOut>,
{
let started = Instant::now();
let Inner {
client,
state,
rebuilder,
..
} = &*self.0;
let Fetched {
state: rpc,
new_entries,
new_txs,
block_template_txids,
} = Fetcher::fetch(client, state)?;
let pulled = Preparer::prepare(&rpc.live_txids, new_entries, new_txs, state);
let mut diff = CycleDiff::default();
Applier::apply(state, rebuilder, pulled, &mut diff);
Prevouts::fill(state, &mut diff, resolver);
rebuilder.tick(state, &block_template_txids, rpc.min_fee);
let CycleDiff { added, removed, addrs } = diff;
let (addr_enters, addr_leaves) = addrs.into_vecs();
Ok(Cycle {
added,
removed,
addr_enters,
addr_leaves,
tip_hash: rpc.tip_hash,
tip_height: rpc.tip_height,
info: self.info(),
snapshot: rebuilder.snapshot(),
took: started.elapsed(),
})
}
}
fn panic_msg(payload: &(dyn Any + Send)) -> &str {
payload
.downcast_ref::<&'static str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
.unwrap_or("<non-string panic payload>")
#[cfg(test)]
mod test_helpers {
use brk_rpc::Auth;
use brk_types::{FeeRate, Txid};
use super::*;
impl Mempool {
/// Test-only constructor that wires a Client at the default URL without
/// touching the network. `simple_http` only parses the URL on init.
pub(crate) fn for_test() -> Self {
let client = Client::new(Client::default_url(), Auth::None).unwrap();
Self(Arc::new(Inner {
client,
state: RwLock::new(State::default()),
rebuilder: Rebuilder::default(),
started: AtomicBool::new(false),
}))
}
pub(crate) fn test_state_lock(&self) -> &RwLock<State> {
&self.0.state
}
pub(crate) fn test_tick(&self, gbt_txids: &[Txid], min_fee: FeeRate) {
self.0.rebuilder.tick(&self.0.state, gbt_txids, min_fee);
}
}
}

View File

@@ -1,102 +0,0 @@
//! RBF tree extraction. Returns owned trees so the caller can enrich
//! with indexer data (`mined`, effective fee rate) after the lock
//! drops: enriching under the lock re-enters `Mempool` and would
//! recursively acquire the same read lock.
use brk_types::{Sats, Timestamp, Transaction, Txid, TxidPrefix, VSize};
use rustc_hash::FxHashSet;
use crate::{Mempool, TxEntry, TxStore, stores::TxGraveyard};
#[derive(Debug, Clone)]
pub struct RbfNode {
pub txid: Txid,
pub fee: Sats,
pub vsize: VSize,
pub value: Sats,
pub first_seen: Timestamp,
/// BIP-125 signaling: at least one input has sequence < 0xffffffff-1.
pub rbf: bool,
/// `true` iff any predecessor in this subtree was non-signaling.
pub full_rbf: bool,
pub replaces: Vec<RbfNode>,
}
#[derive(Debug, Clone, Default)]
pub struct RbfForTx {
/// Tree rooted at the terminal replacer. `None` if `txid` is unknown.
pub root: Option<RbfNode>,
/// Direct predecessors of the requested tx (txids only).
pub replaces: Vec<Txid>,
}
impl Mempool {
/// Walk forward through `Replaced { by }` to the terminal replacer
/// and return its full predecessor tree, plus the requested tx's
/// direct predecessors. Single read-lock window.
pub fn rbf_for_tx(&self, txid: &Txid) -> RbfForTx {
let state = self.read();
let root_txid = state.graveyard.replacement_root_of(*txid);
let replaces: Vec<Txid> = state
.graveyard
.predecessors_of(txid)
.map(|(p, _)| *p)
.collect();
let root = build_node(&root_txid, &state.txs, &state.graveyard);
RbfForTx { root, replaces }
}
/// Recent terminal-replacer trees, most-recent first, deduplicated
/// by root, capped at `limit`. `full_rbf_only` drops trees with no
/// non-signaling predecessor.
pub fn recent_rbf_trees(&self, full_rbf_only: bool, limit: usize) -> Vec<RbfNode> {
let state = self.read();
let mut seen: FxHashSet<Txid> = FxHashSet::default();
state
.graveyard
.replaced_iter_recent_first()
.filter_map(|(_, by)| {
let root = state.graveyard.replacement_root_of(*by);
seen.insert(root).then_some(root)
})
.filter_map(|root| build_node(&root, &state.txs, &state.graveyard))
.filter(|n| !full_rbf_only || n.full_rbf)
.take(limit)
.collect()
}
}
fn build_node(txid: &Txid, txs: &TxStore, graveyard: &TxGraveyard) -> Option<RbfNode> {
let (tx, entry) = resolve_node(txid, txs, graveyard)?;
let replaces: Vec<RbfNode> = graveyard
.predecessors_of(txid)
.filter_map(|(pred, _)| build_node(pred, txs, graveyard))
.collect();
let full_rbf = replaces.iter().any(|c| !c.rbf || c.full_rbf);
let value: Sats = tx.output.iter().map(|o| o.value).sum();
Some(RbfNode {
txid: *txid,
fee: entry.fee,
vsize: entry.vsize,
value,
first_seen: entry.first_seen,
rbf: entry.rbf,
full_rbf,
replaces,
})
}
fn resolve_node<'a>(
txid: &Txid,
txs: &'a TxStore,
graveyard: &'a TxGraveyard,
) -> Option<(&'a Transaction, &'a TxEntry)> {
txs.record_by_prefix(&TxidPrefix::from(txid))
.map(|r| (&r.tx, &r.entry))
.or_else(|| graveyard.get(txid).map(|t| (&t.tx, &t.entry)))
}

View File

@@ -33,7 +33,7 @@ pub struct BlockStats {
impl BlockStats {
/// Stats for every projected block in `blocks`, in order. `blocks[0]`
/// uses Core's exact 0..100 percentiles; the rest use the clipped
/// uses Core's exact 0..100 percentiles. The rest use the clipped
/// 5..95 range to hide CPFP / stale-GBT outliers.
pub fn for_blocks(blocks: &[Vec<TxIndex>], txs: &[SnapTx]) -> Vec<Self> {
blocks

View File

@@ -0,0 +1,233 @@
//! Build per-tx adjacency from the live `TxStore`, then run Single Fee
//! Linearization over every multi-tx cluster.
use std::mem;
use brk_types::TxidPrefix;
use rustc_hash::{FxBuildHasher, FxHashMap};
use smallvec::SmallVec;
use crate::{state::TxEntry, stores::TxStore};
use super::{Cluster, SnapTx, Snapshot, TxIndex};
pub type PrefixIndex = FxHashMap<TxidPrefix, TxIndex>;
impl Snapshot {
pub fn build_txs(txs: &TxStore) -> (Vec<SnapTx>, PrefixIndex) {
let n = txs.len();
let mut prefix_to_idx: PrefixIndex =
FxHashMap::with_capacity_and_hasher(n, FxBuildHasher);
for (i, (prefix, _)) in txs.records().enumerate() {
prefix_to_idx.insert(*prefix, TxIndex::from(i));
}
let mut snap_txs: Vec<SnapTx> = txs
.records()
.map(|(_, record)| Self::live_tx(&record.entry, &prefix_to_idx))
.collect();
Self::mirror_children(&mut snap_txs);
Self::refresh_chunk_rates(&mut snap_txs);
(snap_txs, prefix_to_idx)
}
fn live_tx(e: &TxEntry, prefix_to_idx: &PrefixIndex) -> SnapTx {
let parents: SmallVec<[TxIndex; 2]> = e
.depends
.iter()
.filter_map(|p| prefix_to_idx.get(p).copied())
.collect();
SnapTx {
txid: e.txid,
fee: e.fee,
vsize: e.vsize,
weight: e.weight,
size: e.size,
chunk_rate: e.fee_rate(),
parents,
children: SmallVec::new(),
}
}
fn mirror_children(txs: &mut [SnapTx]) {
for i in 0..txs.len() {
let child = TxIndex::from(i);
let parents = mem::take(&mut txs[i].parents);
for &p in &parents {
if let Some(t) = txs.get_mut(p.as_usize()) {
t.children.push(child);
}
}
txs[i].parents = parents;
}
}
/// Walk every multi-tx connected component once and overwrite each
/// member's `chunk_rate` with the linearized chunk's feerate.
/// Visited bitmap ensures each cluster is linearized exactly once.
fn refresh_chunk_rates(snap_txs: &mut [SnapTx]) {
let n = snap_txs.len();
let mut visited = vec![false; n];
for seed in 0..n {
if visited[seed] {
continue;
}
let t = &snap_txs[seed];
if t.parents.is_empty() && t.children.is_empty() {
visited[seed] = true;
continue;
}
let component = Cluster::walk(snap_txs, TxIndex::from(seed));
for &m in &component {
visited[m.as_usize()] = true;
}
if component.len() <= 1 {
continue;
}
let (members, chunks) = Cluster::linearize(snap_txs, &component);
for chunk in &chunks {
for &local in &chunk.txs {
let m = members[u32::from(local) as usize];
snap_txs[m.as_usize()].chunk_rate = chunk.feerate;
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
use bitcoin::hashes::Hash;
use brk_types::{FeeRate, Sats, Txid, VSize, Weight};
use super::*;
/// Build a `SnapTx` for tests. `txid` is auto-assigned from a
/// process-wide counter so each tx is distinguishable in
/// debug output. The cluster code itself keys off `TxIndex`,
/// not `txid`.
fn snap_tx(fee: Sats, vsize: VSize) -> SnapTx {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let mut bytes = [0u8; 32];
bytes[..4].copy_from_slice(&COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes());
SnapTx {
txid: Txid::from(bitcoin::Txid::from_byte_array(bytes)),
fee,
vsize,
weight: Weight::from(vsize),
size: u64::from(vsize),
chunk_rate: FeeRate::from((fee, vsize)),
parents: SmallVec::new(),
children: SmallVec::new(),
}
}
fn link(txs: &mut [SnapTx], parent: usize, child: usize) {
txs[child].parents.push(TxIndex::from(parent));
txs[parent].children.push(TxIndex::from(child));
}
fn sats(n: u64) -> Sats {
Sats::from(n)
}
fn vsize(n: u64) -> VSize {
VSize::from(n)
}
#[test]
fn singleton_keeps_fee_per_vsize() {
let mut txs = vec![snap_tx(sats(1000), vsize(100))];
let seed = txs[0].chunk_rate;
Snapshot::refresh_chunk_rates(&mut txs);
assert_eq!(txs[0].chunk_rate, seed);
}
#[test]
fn two_tx_cpfp_lift() {
let mut txs = vec![
snap_tx(sats(100), vsize(100)),
snap_tx(sats(1900), vsize(100)),
];
link(&mut txs, 0, 1);
let parent_seed = txs[0].chunk_rate;
Snapshot::refresh_chunk_rates(&mut txs);
assert!(txs[0].chunk_rate > parent_seed);
assert_eq!(txs[0].chunk_rate, txs[1].chunk_rate);
assert_eq!(txs[0].chunk_rate, FeeRate::from((sats(2000), vsize(200))));
}
#[test]
fn three_tx_chain_chunks_correctly() {
let mut txs = vec![
snap_tx(sats(100), vsize(100)),
snap_tx(sats(100), vsize(100)),
snap_tx(sats(5800), vsize(100)),
];
link(&mut txs, 0, 1);
link(&mut txs, 1, 2);
Snapshot::refresh_chunk_rates(&mut txs);
let combined = FeeRate::from((sats(6000), vsize(300)));
assert_eq!(txs[0].chunk_rate, combined);
assert_eq!(txs[1].chunk_rate, combined);
assert_eq!(txs[2].chunk_rate, combined);
}
#[test]
fn disjoint_clusters_linearized_independently() {
let mut txs = vec![
snap_tx(sats(100), vsize(100)),
snap_tx(sats(1900), vsize(100)),
snap_tx(sats(500), vsize(100)),
snap_tx(sats(4500), vsize(100)),
];
link(&mut txs, 0, 1);
link(&mut txs, 2, 3);
Snapshot::refresh_chunk_rates(&mut txs);
assert_eq!(txs[0].chunk_rate, txs[1].chunk_rate);
assert_eq!(txs[2].chunk_rate, txs[3].chunk_rate);
assert_ne!(txs[0].chunk_rate, txs[2].chunk_rate);
}
#[test]
fn cluster_cap_does_not_panic() {
let n = 100;
let mut txs: Vec<SnapTx> = (0..n).map(|_| snap_tx(sats(1000), vsize(100))).collect();
for i in 1..n {
link(&mut txs, i - 1, i);
}
Snapshot::refresh_chunk_rates(&mut txs);
}
#[test]
fn refresh_chunk_rates_is_order_independent_within_clusters() {
let mut a = vec![
snap_tx(sats(1_000), vsize(100)),
snap_tx(sats(100), vsize(100)),
snap_tx(sats(5_000), vsize(100)),
snap_tx(sats(200), vsize(100)),
];
link(&mut a, 0, 1);
link(&mut a, 2, 3);
Snapshot::refresh_chunk_rates(&mut a);
// Same pool, members of each cluster reordered.
let mut b = vec![
snap_tx(sats(100), vsize(100)),
snap_tx(sats(1_000), vsize(100)),
snap_tx(sats(200), vsize(100)),
snap_tx(sats(5_000), vsize(100)),
];
link(&mut b, 1, 0);
link(&mut b, 3, 2);
Snapshot::refresh_chunk_rates(&mut b);
let mut rates_a: Vec<f64> = a.iter().map(|t| f64::from(t.chunk_rate)).collect();
let mut rates_b: Vec<f64> = b.iter().map(|t| f64::from(t.chunk_rate)).collect();
rates_a.sort_by(|x, y| x.partial_cmp(y).unwrap());
rates_b.sort_by(|x, y| x.partial_cmp(y).unwrap());
assert_eq!(rates_a, rates_b);
}
}

View File

@@ -0,0 +1,133 @@
//! Cluster primitives over `SnapTx` adjacency: connected-component
//! discovery, topo-sort, and the glue to Single Fee Linearization
//! ([`brk_types::linearize`], shared with brk_query's confirmed-cpfp).
use std::collections::VecDeque;
use brk_types::{ChunkInput, CpfpClusterChunk, CpfpClusterTxIndex, linearize};
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
use smallvec::SmallVec;
use super::{SnapTx, TxIndex};
/// Matches Bitcoin Core 31's `MAX_CLUSTER_COUNT_LIMIT`.
pub const MAX_CLUSTER: usize = 64;
pub struct Cluster;
impl Cluster {
/// Capped DFS over the undirected dependency graph (`parents
/// children`) starting from `seed`. Returns the connected component
/// truncated to `MAX_CLUSTER`, with `seed` at index 0.
pub fn walk(txs: &[SnapTx], seed: TxIndex) -> Vec<TxIndex> {
if txs.get(seed.as_usize()).is_none() {
return Vec::new();
}
let mut visited: FxHashSet<TxIndex> =
FxHashSet::with_capacity_and_hasher(MAX_CLUSTER, FxBuildHasher);
visited.insert(seed);
let mut out: Vec<TxIndex> = Vec::with_capacity(MAX_CLUSTER);
out.push(seed);
let mut stack: Vec<TxIndex> = vec![seed];
while let Some(idx) = stack.pop() {
let Some(t) = txs.get(idx.as_usize()) else {
continue;
};
for &n in t.parents.iter().chain(t.children.iter()) {
if out.len() >= MAX_CLUSTER {
return out;
}
if visited.insert(n) {
out.push(n);
stack.push(n);
}
}
}
out
}
/// Linearize the connected component into chunks. Topo-sorts members,
/// remaps parent edges to cluster-local indices, and runs SFL. Returns
/// `(members, chunks)` where `members` is the topo-ordered `TxIndex`
/// list and `chunks[*].txs` are local indices into `members`. Callers
/// must filter singletons before calling - the singleton's `chunk_rate`
/// is `fee/vsize`, set elsewhere.
pub fn linearize(
txs: &[SnapTx],
component: &[TxIndex],
) -> (Vec<TxIndex>, Vec<CpfpClusterChunk>) {
let members = Self::topo_sort(txs, component);
let local_of = Self::local_index(&members);
let parents_local: Vec<SmallVec<[CpfpClusterTxIndex; 2]>> = members
.iter()
.map(|idx| {
txs[idx.as_usize()]
.parents
.iter()
.filter_map(|p| local_of.get(p).copied())
.collect()
})
.collect();
let inputs: Vec<ChunkInput<'_>> = members
.iter()
.zip(&parents_local)
.map(|(idx, ps)| {
let t = &txs[idx.as_usize()];
ChunkInput {
fee: t.fee,
vsize: t.vsize,
parents: ps.as_slice(),
}
})
.collect();
let chunks = linearize(&inputs);
(members, chunks)
}
/// `members[i]`'s wire index, keyed by snapshot `TxIndex`. Built once
/// so per-tx parent edges can be remapped without a linear scan.
pub fn local_index(members: &[TxIndex]) -> FxHashMap<TxIndex, CpfpClusterTxIndex> {
members
.iter()
.enumerate()
.map(|(i, &idx)| (idx, CpfpClusterTxIndex::from(i as u32)))
.collect()
}
/// Kahn's topological sort over the connected component, restricted to
/// in-cluster parent edges. Returns members in an order where every tx
/// follows all its in-cluster parents.
fn topo_sort(txs: &[SnapTx], component: &[TxIndex]) -> Vec<TxIndex> {
let n = component.len();
let pos: FxHashMap<TxIndex, usize> = component
.iter()
.enumerate()
.map(|(i, &x)| (x, i))
.collect();
let mut indeg: Vec<u32> = vec![0; n];
let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
for (i, &idx) in component.iter().enumerate() {
let Some(t) = txs.get(idx.as_usize()) else {
continue;
};
indeg[i] = t.parents.iter().filter(|p| pos.contains_key(p)).count() as u32;
for &c in t.children.iter() {
if let Some(&ci) = pos.get(&c) {
children[i].push(ci);
}
}
}
let mut queue: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
let mut out: Vec<TxIndex> = Vec::with_capacity(n);
while let Some(i) = queue.pop_front() {
out.push(component[i]);
for &c in &children[i] {
indeg[c] -= 1;
if indeg[c] == 0 {
queue.push_back(c);
}
}
}
out
}
}

View File

@@ -0,0 +1,248 @@
//! CPFP (Child Pays For Parent) walk over a `Snapshot`'s adjacency.
//!
//! Three independent walks:
//! - `ancestors`: capped DFS up `parents` only.
//! - `descendants`: capped DFS down `children` only.
//! - cluster: connected component over `parents children`,
//! linearized for wire shape and seed chunk feerate.
use brk_types::{
CPFP_CHAIN_LIMIT, CpfpCluster, CpfpClusterTx, CpfpClusterTxIndex, CpfpEntry, CpfpInfo, FeeRate,
SigOps, TxidPrefix, VSize, find_seed_chunk,
};
use rustc_hash::{FxBuildHasher, FxHashSet};
use crate::Mempool;
use super::{Cluster, SnapTx, Snapshot, TxIndex};
impl Mempool {
/// CPFP info for a live mempool tx. Returns `None` when the tx
/// isn't in the live pool, so callers can fall through to the
/// confirmed path. The snapshot can lag `state.txs` by up to one
/// cycle: if the seed is in the snapshot but no longer in live
/// state we return `None` rather than a half-stale report.
pub fn cpfp_info(&self, prefix: &TxidPrefix) -> Option<CpfpInfo> {
let snapshot = self.snapshot();
let seed_idx = snapshot.idx_of(prefix)?;
let seed = snapshot.tx(seed_idx)?;
let sigops = self.read().txs.get(&seed.txid)?.total_sigop_cost;
Some(snapshot.cpfp_info_at(seed_idx, seed, sigops))
}
}
impl Snapshot {
fn cpfp_info_at(&self, seed_idx: TxIndex, seed: &SnapTx, sigops: SigOps) -> CpfpInfo {
let ancestors = Self::collect_cpfp_entries(&self.txs, seed_idx, |t| &t.parents);
let descendants = Self::collect_cpfp_entries(&self.txs, seed_idx, |t| &t.children);
let best_descendant = descendants
.iter()
.max_by_key(|e| FeeRate::from((e.fee, e.weight)))
.cloned();
let (cluster, effective_fee_per_vsize) = Self::build_cpfp_cluster(&self.txs, seed_idx, seed);
let vsize = VSize::from(seed.weight);
CpfpInfo {
ancestors,
best_descendant,
descendants,
effective_fee_per_vsize,
sigops,
fee: seed.fee,
vsize,
adjusted_vsize: sigops.adjust_vsize(vsize),
cluster,
}
}
/// Capped DFS from `seed` (exclusive) along `next`, lifted directly
/// to wire-shape `CpfpEntry`s. Used for both ancestor and descendant
/// walks.
fn collect_cpfp_entries(
txs: &[SnapTx],
seed: TxIndex,
next: impl Fn(&SnapTx) -> &[TxIndex],
) -> Vec<CpfpEntry> {
let Some(seed_node) = txs.get(seed.as_usize()) else {
return Vec::new();
};
let mut visited: FxHashSet<TxIndex> =
FxHashSet::with_capacity_and_hasher(CPFP_CHAIN_LIMIT + 1, FxBuildHasher);
visited.insert(seed);
let mut out: Vec<CpfpEntry> = Vec::with_capacity(CPFP_CHAIN_LIMIT);
let mut stack: Vec<TxIndex> = next(seed_node).to_vec();
while let Some(idx) = stack.pop() {
if out.len() >= CPFP_CHAIN_LIMIT {
break;
}
if !visited.insert(idx) {
continue;
}
if let Some(t) = txs.get(idx.as_usize()) {
out.push(CpfpEntry::from(t));
stack.extend(next(t).iter().copied());
}
}
out
}
/// Wire-shape `CpfpCluster` plus the seed's chunk feerate. Members
/// are the connected component of the seed in the dependency graph,
/// topologically ordered (parents before children) so wire indices
/// and chunk-internal ordering are valid for client-side
/// reconstruction. Returns `(None, seed_per_tx_rate)` for singletons
/// (matches mempool.space, which omits `cluster` when no relations
/// exist).
fn build_cpfp_cluster(
txs: &[SnapTx],
seed_idx: TxIndex,
seed: &SnapTx,
) -> (Option<CpfpCluster>, FeeRate) {
let seed_per_tx_rate = FeeRate::from((seed.fee, seed.vsize));
let component = Cluster::walk(txs, seed_idx);
if component.len() <= 1 {
return (None, seed_per_tx_rate);
}
let (members, chunks) = Cluster::linearize(txs, &component);
let cluster_txs = Self::wire_cluster_members(txs, &members);
let seed_local = CpfpClusterTxIndex::from(
members
.iter()
.position(|&i| i == seed_idx)
.map_or(0, |p| p as u32),
);
let (chunk_index, seed_chunk_rate) = find_seed_chunk(&chunks, seed_local, seed_per_tx_rate);
(
Some(CpfpCluster {
txs: cluster_txs,
chunks,
chunk_index,
}),
seed_chunk_rate,
)
}
/// Materialize wire-shape `CpfpClusterTx`s for every topo-ordered
/// member with parent edges remapped to local indices.
fn wire_cluster_members(txs: &[SnapTx], members: &[TxIndex]) -> Vec<CpfpClusterTx> {
let local_of = Cluster::local_index(members);
members
.iter()
.map(|&idx| {
let t = &txs[idx.as_usize()];
CpfpClusterTx {
txid: t.txid,
weight: t.weight,
fee: t.fee,
parents: t
.parents
.iter()
.filter_map(|p| local_of.get(p).copied())
.collect(),
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use brk_types::{FeeRate, Txid};
use super::*;
use crate::{
state::TxEntry,
test_support::{fake_entry_info, fake_tx, p2wpkh_script},
};
/// Insert a tx, optionally declaring parent dependencies for the
/// snapshot builder's adjacency wire-up.
fn insert_with_depends(
mempool: &Mempool,
seed: u8,
fee: u64,
vsize: u64,
parents: &[Txid],
) -> Txid {
let tx = fake_tx(seed, &[None], &[(p2wpkh_script(seed + 1), 1_234)]);
let txid = tx.txid;
let mut info = fake_entry_info(txid, fee, vsize);
info.depends = parents.to_vec();
let entry = TxEntry::new(&info, vsize, false);
let mut state = mempool.test_state_lock().write();
state.txs.insert(tx, entry);
txid
}
#[test]
fn singleton_cpfp_info_has_no_cluster() {
let mempool = Mempool::for_test();
let txid = insert_with_depends(&mempool, 0xB0, 10_000, 100, &[]);
mempool.test_tick(&[txid], FeeRate::new(1.0));
let info = mempool
.cpfp_info(&TxidPrefix::from(&txid))
.expect("tx is in mempool");
assert!(info.cluster.is_none(), "singletons emit no cluster");
assert!(info.ancestors.is_empty());
assert!(info.descendants.is_empty());
// Effective rate equals isolated rate when there's no package lift.
let isolated = FeeRate::from((info.fee, info.vsize));
assert_eq!(info.effective_fee_per_vsize, isolated);
}
#[test]
fn two_tx_cpfp_cluster_has_both_members_and_lifted_rate() {
let mempool = Mempool::for_test();
let parent = insert_with_depends(&mempool, 0xB1, 100, 100, &[]);
let child = insert_with_depends(&mempool, 0xB2, 1_900, 100, &[parent]);
mempool.test_tick(&[parent, child], FeeRate::new(1.0));
let parent_info = mempool.cpfp_info(&TxidPrefix::from(&parent)).unwrap();
let cluster = parent_info.cluster.expect("two-tx cluster present");
assert_eq!(cluster.txs.len(), 2);
// Topological order: parent first.
assert_eq!(cluster.txs[0].txid, parent);
assert_eq!(cluster.txs[1].txid, child);
// Child reports the parent as its only local parent.
assert_eq!(cluster.txs[1].parents.len(), 1);
// CPFP lift: parent's effective rate exceeds its isolated rate.
let parent_isolated = FeeRate::from((parent_info.fee, parent_info.vsize));
assert!(parent_info.effective_fee_per_vsize > parent_isolated);
// Same package -> child's reported chunk rate matches parent's.
let child_info = mempool.cpfp_info(&TxidPrefix::from(&child)).unwrap();
assert_eq!(parent_info.effective_fee_per_vsize, child_info.effective_fee_per_vsize);
}
#[test]
fn cpfp_ancestor_and_descendant_walks_are_directional() {
// chain: A -> B -> C
let mempool = Mempool::for_test();
let a = insert_with_depends(&mempool, 0xB3, 100, 100, &[]);
let b = insert_with_depends(&mempool, 0xB4, 100, 100, &[a]);
let c = insert_with_depends(&mempool, 0xB5, 5_800, 100, &[b]);
mempool.test_tick(&[a, b, c], FeeRate::new(1.0));
// B sees A as an ancestor and C as a descendant.
let info_b = mempool.cpfp_info(&TxidPrefix::from(&b)).unwrap();
let ancestor_ids: Vec<_> = info_b.ancestors.iter().map(|e| e.txid).collect();
let descendant_ids: Vec<_> = info_b.descendants.iter().map(|e| e.txid).collect();
assert_eq!(ancestor_ids, vec![a]);
assert_eq!(descendant_ids, vec![c]);
// best_descendant picks the highest-rate descendant.
assert_eq!(info_b.best_descendant.as_ref().map(|e| e.txid), Some(c));
}
#[test]
fn cpfp_info_returns_none_for_unknown_txid() {
let mempool = Mempool::for_test();
mempool.test_tick(&[], FeeRate::new(1.0));
let bogus = TxidPrefix::from(&Txid::COINBASE);
assert!(mempool.cpfp_info(&bogus).is_none());
}
}

View File

@@ -1,6 +1,6 @@
use brk_types::{FeeRate, RecommendedFees};
use super::stats::BlockStats;
use super::block_stats::BlockStats;
/// Output rounding granularity in sat/vB. mempool.space's
/// `/api/v1/fees/recommended` uses `1.0`, their `/precise`
@@ -87,3 +87,79 @@ impl Fees {
use_fee.ceil_to(MIN_INCREMENT).max(min_fee)
}
}
#[cfg(test)]
mod tests {
use brk_types::{Sats, VSize};
use super::*;
fn block(vsize: u64, median_fee: f64) -> BlockStats {
let median = FeeRate::new(median_fee);
BlockStats {
tx_count: 1,
total_size: vsize,
total_vsize: VSize::from(vsize),
total_fee: Sats::from((vsize as f64 * median_fee) as u64),
fee_range: [
median, median, median, median, median, median, median,
],
}
}
#[test]
fn empty_stats_collapses_every_tier_to_min_fee() {
let min = FeeRate::new(2.0);
let fees = Fees::compute(&[], min);
let priority_fastest = FeeRate::new(2.5); // min + PRIORITY_FACTOR
let priority_half_hour = FeeRate::new(2.25); // min + PRIORITY_FACTOR/2
assert_eq!(fees.minimum_fee, min);
assert_eq!(fees.economy_fee, min);
assert_eq!(fees.hour_fee, min);
assert_eq!(fees.fastest_fee, priority_fastest);
assert_eq!(fees.half_hour_fee, priority_half_hour);
}
#[test]
fn min_fee_floor_lifts_below_one_sat_rates() {
// `mempoolminfee` below MIN_INCREMENT: result is clamped up.
let min = FeeRate::new(0.0);
let fees = Fees::compute(&[], min);
assert!(f64::from(fees.minimum_fee) >= 0.001);
// `fastest_fee` always at least MIN_FASTEST_FEE.
assert!(f64::from(fees.fastest_fee) >= 1.0);
}
#[test]
fn small_partial_final_block_collapses_to_min_fee() {
// vsize <= EMPTY_BLOCK_VSIZE: returns min_fee unconditionally.
let stats = vec![block(400_000, 12.5)];
let min = FeeRate::new(1.0);
let fees = Fees::compute(&stats, min);
assert_eq!(fees.hour_fee, min);
assert_eq!(fees.economy_fee, min);
}
#[test]
fn full_block_carries_signal_into_top_tier() {
let stats = vec![block(1_000_000, 25.0), block(1_000_000, 10.0)];
let min = FeeRate::new(1.0);
let fees = Fees::compute(&stats, min);
// fastest gets PRIORITY_FACTOR (0.5) added.
assert_eq!(fees.fastest_fee, FeeRate::new(25.5));
// hour comes from block[2], which doesn't exist -> collapses to min.
assert_eq!(fees.hour_fee, min);
}
#[test]
fn partial_final_block_tapers_linearly() {
// vsize in (EMPTY, FULL]: rate = use_fee * (vsize - EMPTY)/EMPTY.
// 725_000 vsize -> multiplier = 225_000 / 500_000 = 0.45.
let stats = vec![block(725_000, 10.0)];
let min = FeeRate::new(1.0);
let fees = Fees::compute(&stats, min);
// economy/hour come from the same (only) block, both tapered.
// 10.0 * 0.45 = 4.5, fastest = 4.5 + 0.5 = 5.0.
assert_eq!(fees.fastest_fee, FeeRate::new(5.0));
}
}

View File

@@ -0,0 +1,215 @@
mod block_stats;
mod builder;
mod cluster;
mod cpfp;
mod fees;
mod partition;
mod rebuilder;
mod snap_tx;
mod tx_index;
pub use block_stats::BlockStats;
pub use cluster::Cluster;
pub use rebuilder::Rebuilder;
pub use snap_tx::SnapTx;
pub use tx_index::TxIndex;
use builder::PrefixIndex;
use fees::Fees;
use partition::Partitioner;
use std::hash::{Hash, Hasher};
use brk_types::{FeeRate, NextBlockHash, RecommendedFees, Txid, TxidPrefix};
use rustc_hash::FxHasher;
#[derive(Default)]
pub struct Snapshot {
/// Dense per-tx data indexed by `TxIndex`. Each entry carries the
/// linearized chunk rate plus parent/child adjacency.
pub txs: Vec<SnapTx>,
/// Projected blocks. `blocks[0]` is Core's `getblocktemplate`
/// (Bitcoin Core's actual selection). The rest are greedy-packed
/// by descending chunk rate, with a final overflow block.
pub blocks: Vec<Vec<TxIndex>>,
pub block_stats: Vec<BlockStats>,
pub fees: RecommendedFees,
/// Content hash of the projected next block. Same value as the
/// mempool `ETag`.
pub next_block_hash: NextBlockHash,
prefix_to_idx: PrefixIndex,
}
impl Snapshot {
/// `min_fee` is bitcoind's live `mempoolminfee`, the floor for
/// every recommended-fee tier.
fn build(
txs: Vec<SnapTx>,
blocks: Vec<Vec<TxIndex>>,
prefix_to_idx: PrefixIndex,
min_fee: FeeRate,
) -> Self {
let block_stats = BlockStats::for_blocks(&blocks, &txs);
let fees = Fees::compute(&block_stats, min_fee);
let next_block_hash = Self::hash_next_block(&blocks, &txs);
Self {
txs,
blocks,
block_stats,
fees,
next_block_hash,
prefix_to_idx,
}
}
/// Content tag over block 0 in template order. Hashes txids, not
/// `TxIndex` slots, because slot assignment is per-cycle.
fn hash_next_block(blocks: &[Vec<TxIndex>], txs: &[SnapTx]) -> NextBlockHash {
let Some(block) = blocks.first() else {
return NextBlockHash::ZERO;
};
let mut hasher = FxHasher::default();
for idx in block {
txs[idx.as_usize()].txid.hash(&mut hasher);
}
NextBlockHash::new(hasher.finish())
}
pub fn tx(&self, idx: TxIndex) -> Option<&SnapTx> {
self.txs.get(idx.as_usize())
}
pub fn idx_of(&self, prefix: &TxidPrefix) -> Option<TxIndex> {
self.prefix_to_idx.get(prefix).copied()
}
/// Txids of `blocks[0]` (Core's `getblocktemplate` selection),
/// in template order. Empty for a default snapshot.
pub fn block0_txids(&self) -> impl Iterator<Item = Txid> + '_ {
self.blocks
.first()
.into_iter()
.flatten()
.map(|idx| self.txs[idx.as_usize()].txid)
}
/// Linearized chunk rate for a live tx by prefix. Recomputed each
/// snapshot, package-aware (CPFP lifts apply), equals `fee/vsize`
/// for singletons.
pub fn chunk_rate_for(&self, prefix: &TxidPrefix) -> Option<FeeRate> {
let idx = self.idx_of(prefix)?;
Some(self.txs[idx.as_usize()].chunk_rate)
}
/// Test-only: stitch a snapshot from `(prefix, chunk_rate)` pairs
/// without running the full builder.
#[cfg(test)]
pub(crate) fn for_test_with_chunk_rates(entries: &[(TxidPrefix, FeeRate, Txid)]) -> Self {
use brk_types::{Sats, VSize, Weight};
use smallvec::SmallVec;
let mut prefix_to_idx = PrefixIndex::default();
let mut txs = Vec::with_capacity(entries.len());
for (i, (prefix, rate, txid)) in entries.iter().enumerate() {
prefix_to_idx.insert(*prefix, TxIndex::from(i));
txs.push(SnapTx {
txid: *txid,
fee: Sats::ZERO,
vsize: VSize::from(0u64),
weight: Weight::from(0u64),
size: 0,
chunk_rate: *rate,
parents: SmallVec::new(),
children: SmallVec::new(),
});
}
Self {
txs,
blocks: vec![],
block_stats: vec![],
fees: RecommendedFees::default(),
next_block_hash: NextBlockHash::ZERO,
prefix_to_idx,
}
}
}
#[cfg(test)]
mod tests {
use bitcoin::hashes::Hash;
use brk_types::{Sats, VSize, Weight};
use smallvec::SmallVec;
use super::*;
fn snap_tx(seed: u8) -> SnapTx {
let mut bytes = [0u8; 32];
bytes[0] = seed;
SnapTx {
txid: Txid::from(bitcoin::Txid::from_byte_array(bytes)),
fee: Sats::from(1_234u64),
vsize: VSize::from(100u64),
weight: Weight::from(400u64),
size: 100,
chunk_rate: FeeRate::from((Sats::from(1_234u64), VSize::from(100u64))),
parents: SmallVec::new(),
children: SmallVec::new(),
}
}
#[test]
fn next_block_hash_is_deterministic_across_runs() {
let txs = vec![snap_tx(1), snap_tx(2), snap_tx(3)];
let blocks = vec![vec![
TxIndex::from(0usize),
TxIndex::from(1usize),
TxIndex::from(2usize),
]];
let h1 = Snapshot::hash_next_block(&blocks, &txs);
let h2 = Snapshot::hash_next_block(&blocks, &txs);
assert_eq!(h1, h2);
}
#[test]
fn next_block_hash_changes_with_block0_membership() {
let txs = vec![snap_tx(1), snap_tx(2), snap_tx(3)];
let two_member = vec![vec![TxIndex::from(0usize), TxIndex::from(1usize)]];
let three_member = vec![vec![
TxIndex::from(0usize),
TxIndex::from(1usize),
TxIndex::from(2usize),
]];
assert_ne!(
Snapshot::hash_next_block(&two_member, &txs),
Snapshot::hash_next_block(&three_member, &txs),
);
}
#[test]
fn next_block_hash_changes_with_block0_order() {
// hash_next_block hashes txids in template order: reordering
// block 0 must produce a different hash.
let txs = vec![snap_tx(1), snap_tx(2), snap_tx(3)];
let forward = vec![vec![
TxIndex::from(0usize),
TxIndex::from(1usize),
TxIndex::from(2usize),
]];
let reversed = vec![vec![
TxIndex::from(2usize),
TxIndex::from(1usize),
TxIndex::from(0usize),
]];
assert_ne!(
Snapshot::hash_next_block(&forward, &txs),
Snapshot::hash_next_block(&reversed, &txs),
);
}
#[test]
fn empty_blocks_hash_is_zero() {
let txs = vec![snap_tx(1)];
let blocks: Vec<Vec<TxIndex>> = vec![];
assert_eq!(Snapshot::hash_next_block(&blocks, &txs), NextBlockHash::ZERO);
}
}

View File

@@ -0,0 +1,142 @@
//! Pack live txs into projected blocks 1..N by descending `chunk_rate`.
//! Block 0 is filled by the caller from `getblocktemplate`. Final block
//! is a catch-all (no vsize cap).
use brk_types::{FeeRate, VSize};
use rustc_hash::FxHashSet;
use super::{SnapTx, TxIndex};
pub struct Partitioner;
impl Partitioner {
pub fn partition(
txs: &[SnapTx],
excluded: &FxHashSet<TxIndex>,
num_remaining_blocks: usize,
) -> Vec<Vec<TxIndex>> {
if num_remaining_blocks == 0 {
return Vec::new();
}
let sorted = Self::sorted_candidates(txs, excluded);
let mut blocks: Vec<Vec<TxIndex>> = (0..num_remaining_blocks).map(|_| Vec::new()).collect();
let mut block_vsize = VSize::default();
let mut current = 0;
let last = num_remaining_blocks - 1;
for (idx, vsize, _) in sorted {
let fits = vsize <= VSize::MAX_BLOCK.saturating_sub(block_vsize);
if !fits && current < last && !blocks[current].is_empty() {
current += 1;
block_vsize = VSize::default();
}
blocks[current].push(idx);
block_vsize += vsize;
}
blocks
}
fn sorted_candidates(
txs: &[SnapTx],
excluded: &FxHashSet<TxIndex>,
) -> Vec<(TxIndex, VSize, FeeRate)> {
let mut cands: Vec<(TxIndex, VSize, FeeRate)> = txs
.iter()
.enumerate()
.filter_map(|(i, t)| {
let idx = TxIndex::from(i);
(!excluded.contains(&idx)).then_some((idx, t.vsize, t.chunk_rate))
})
.collect();
cands.sort_by(|(a_idx, _, a_rate), (b_idx, _, b_rate)| {
b_rate
.cmp(a_rate)
.then_with(|| txs[a_idx.as_usize()].txid.cmp(&txs[b_idx.as_usize()].txid))
});
cands
}
}
#[cfg(test)]
mod tests {
use bitcoin::hashes::Hash;
use brk_types::{Sats, Txid, Weight};
use smallvec::SmallVec;
use super::*;
fn snap_tx(seed: u8, fee: u64, vsize: u64) -> SnapTx {
let mut bytes = [0u8; 32];
bytes[0] = seed;
SnapTx {
txid: Txid::from(bitcoin::Txid::from_byte_array(bytes)),
fee: Sats::from(fee),
vsize: VSize::from(vsize),
weight: Weight::from(vsize * 4),
size: vsize,
chunk_rate: FeeRate::from((Sats::from(fee), VSize::from(vsize))),
parents: SmallVec::new(),
children: SmallVec::new(),
}
}
#[test]
fn zero_blocks_returns_empty() {
let txs = vec![snap_tx(1, 100, 100)];
let blocks = Partitioner::partition(&txs, &FxHashSet::default(), 0);
assert!(blocks.is_empty());
}
#[test]
fn higher_chunk_rate_packs_first() {
let txs = vec![snap_tx(1, 100, 100), snap_tx(2, 1_000, 100)];
let blocks = Partitioner::partition(&txs, &FxHashSet::default(), 3);
assert_eq!(blocks[0][0], TxIndex::from(1usize));
assert_eq!(blocks[0][1], TxIndex::from(0usize));
}
#[test]
fn excluded_txs_are_skipped() {
let txs = vec![snap_tx(1, 100, 100), snap_tx(2, 1_000, 100)];
let mut excluded = FxHashSet::default();
excluded.insert(TxIndex::from(1usize));
let blocks = Partitioner::partition(&txs, &excluded, 3);
let flat: Vec<TxIndex> = blocks.into_iter().flatten().collect();
assert_eq!(flat, vec![TxIndex::from(0usize)]);
}
#[test]
fn vsize_cap_respected_except_for_last_block() {
let big = u64::from(VSize::MAX_BLOCK) - 100;
// Three "fills the rest of a block" sized txs, one block window.
// Final block has no cap, so all three end up in it when the
// request is one block deep.
let txs = vec![
snap_tx(1, 1_000, big),
snap_tx(2, 900, big),
snap_tx(3, 800, big),
];
let one_block = Partitioner::partition(&txs, &FxHashSet::default(), 1);
assert_eq!(one_block.len(), 1);
assert_eq!(one_block[0].len(), 3, "final block ignores vsize cap");
// With three slots, the first two get one tx each, last block
// soaks up the rest.
let three_blocks = Partitioner::partition(&txs, &FxHashSet::default(), 3);
assert_eq!(three_blocks[0].len(), 1);
assert_eq!(three_blocks[1].len(), 1);
assert_eq!(three_blocks[2].len(), 1);
}
#[test]
fn txid_breaks_ties_within_same_rate() {
// Identical rate, distinct txids: order must follow ascending txid.
let txs = vec![
snap_tx(0x20, 100, 100),
snap_tx(0x10, 100, 100),
snap_tx(0x30, 100, 100),
];
let blocks = Partitioner::partition(&txs, &FxHashSet::default(), 1);
let txids: Vec<u8> = blocks[0].iter().map(|i| txs[i.as_usize()].txid[0]).collect();
assert_eq!(txids, vec![0x10, 0x20, 0x30]);
}
}

View File

@@ -1,3 +1,13 @@
//! # Locking
//!
//! Two locks live on `Rebuilder`: `history` and `snapshot`. Writes always
//! land on `history` first, then `snapshot`, so any `next_block_hash` a
//! reader sees in the published snapshot is already recorded in
//! `historical_block0`. No read path ever holds both, and no path holds
//! a `State` guard together with either Rebuilder lock - the cycle reads
//! `State` once to build the snapshot, then drops it before touching
//! these locks.
use std::{
collections::VecDeque,
sync::{
@@ -12,14 +22,7 @@ use rustc_hash::FxHashSet;
use crate::State;
use partition::Partitioner;
use snapshot::build_txs;
mod partition;
mod snapshot;
pub use brk_types::RecommendedFees;
pub use snapshot::{BlockStats, SnapTx, Snapshot, TxIndex};
use super::{Partitioner, Snapshot, TxIndex};
const NUM_BLOCKS: usize = 8;
const HISTORY: usize = 10;
@@ -35,16 +38,18 @@ pub struct Rebuilder {
}
impl Rebuilder {
/// Rebuild the snapshot every cycle. The build is pure CPU on
/// already-fetched data and `min_fee` participates in the result,
/// so a "skip if no add/remove" gate would freeze the served fees
/// when Core's `mempoolminfee` drifts on a quiet pool. Cycle pacing
/// is the driver loop's job.
/// Rebuild every cycle. `min_fee` participates in the result, so a
/// "skip if no add/remove" gate would freeze served fees when Core's
/// `mempoolminfee` drifts on a quiet pool.
///
/// History is updated before the snapshot Arc is swapped so a reader
/// can never observe a `next_block_hash` that hasn't been recorded
/// yet. `block_template_diff(current_hash)` returning 404 in the
/// publish gap would force unnecessary client refetches.
pub fn tick(&self, lock: &RwLock<State>, gbt_txids: &[Txid], min_fee: FeeRate) {
let snap = Self::build_snapshot(lock, gbt_txids, min_fee);
let block0: Vec<Txid> = snap.block0_txids().collect();
let next_hash = snap.next_block_hash;
*self.snapshot.write() = Arc::new(snap);
let mut hist = self.history.write();
hist.retain(|(h, _)| *h != next_hash);
@@ -54,6 +59,8 @@ impl Rebuilder {
}
drop(hist);
*self.snapshot.write() = Arc::new(snap);
self.rebuild_count.fetch_add(1, Ordering::Relaxed);
}
@@ -79,16 +86,9 @@ impl Rebuilder {
) -> Snapshot {
let (txs, prefix_to_idx) = {
let state = lock.read();
build_txs(&state.txs)
Snapshot::build_txs(&state.txs)
};
// Block 0 from `getblocktemplate`: Core's actual selection.
// The Fetcher synthesizes pool entries for GBT txs that aren't
// already present (using GBT's inline body + stats), so this
// lookup always resolves and block 0 matches Core exactly.
// The `filter_map` only drops if a tx was concurrently evicted
// from `txs` between `build_txs` and the rebuild, which the
// partitioner backfills so callers still see `NUM_BLOCKS`.
let block0: Vec<TxIndex> = gbt_txids
.iter()
.filter_map(|txid| prefix_to_idx.get(&TxidPrefix::from(txid)).copied())

View File

@@ -5,7 +5,7 @@ use super::TxIndex;
/// Frozen per-tx view used by the snapshot. `chunk_rate` is the
/// linearized chunk feerate (local Single Fee Linearization, run fresh
/// every snapshot); singletons report `fee/vsize`. Parent/child
/// every snapshot). Singletons report `fee/vsize`. Parent/child
/// adjacency in `TxIndex` space, so CPFP queries are a pure walk over
/// `Snapshot.txs`.
#[derive(Clone, Debug)]
@@ -18,7 +18,7 @@ pub struct SnapTx {
pub size: u64,
pub chunk_rate: FeeRate,
/// Direct parents in the live pool (resolved against entry slots
/// at build time; cross-pool / confirmed parents are dropped).
/// at build time. Cross-pool / confirmed parents are dropped).
pub parents: SmallVec<[TxIndex; 2]>,
pub children: SmallVec<[TxIndex; 4]>,
}

View File

@@ -1,9 +1,17 @@
//! Single-locked container for the live mempool.
//! Single-locked container for the live mempool. All cycle steps and
//! read-side accessors take a guard on this one lock.
//!
//! All cycle steps and read-side accessors take a guard on this one
//! lock. The substructures are plain owned types — they used to each
//! own a RwLock, but the canonical lock-order discipline disappears
//! when there's nothing to order.
//! # Concurrency
//!
//! `State` is held under one `RwLock` at the crate root. The cycle
//! takes the write guard for `Applier` and `Prevouts`, then drops it
//! before the [`crate::snapshot::Rebuilder`] runs. No code path holds
//! a `State` guard at the same time as a `Rebuilder` lock, so the two
//! domains are independent and lock-ordering between them is moot.
mod tx_entry;
pub use tx_entry::TxEntry;
use brk_types::{MempoolInfo, Timestamp, Txid};
@@ -19,8 +27,7 @@ pub struct State {
}
impl State {
/// `first_seen` for a tx that's live or in a `Vanished` tombstone.
/// Smooths the flicker between drop and indexer catch-up; `Replaced`
/// Smooths the flicker between drop and indexer catch-up. `Replaced`
/// tombstones are excluded since the tx will not confirm.
pub fn first_seen(&self, txid: &Txid) -> Option<Timestamp> {
if let Some(e) = self.txs.entry(txid) {

View File

@@ -1,9 +1,8 @@
use brk_types::{FeeRate, MempoolEntryInfo, Sats, Timestamp, Txid, TxidPrefix, VSize, Weight};
use smallvec::SmallVec;
/// A mempool transaction entry. Carries the per-tx facts needed for
/// projection. Chunk rates live on the snapshot (linearized fresh each
/// cycle) - not stored here.
/// Per-tx facts needed for projection. Chunk rates live on the snapshot
/// (linearized fresh each cycle), not here.
#[derive(Debug, Clone)]
pub struct TxEntry {
pub txid: Txid,
@@ -19,7 +18,7 @@ pub struct TxEntry {
}
impl TxEntry {
pub(super) fn new(info: &MempoolEntryInfo, size: u64, rbf: bool) -> Self {
pub fn new(info: &MempoolEntryInfo, size: u64, rbf: bool) -> Self {
Self {
txid: info.txid,
fee: info.fee,

View File

@@ -2,12 +2,10 @@ use brk_types::{Transaction, TxidPrefix};
use parking_lot::RwLock;
use crate::{
AddrTransitions, CycleDiff, State, TxEntry, TxRemoval,
cycle::{TxAdded, TxRemoved},
steps::{
preparer::{TxAddition, TxsPulled},
rebuilder::{Rebuilder, Snapshot},
},
Snapshot, TxRemoval,
cycle::{AddrTransitions, CycleDiff, TxAdded, TxRemoved},
state::{State, TxEntry},
steps::preparer::{TxAddition, TxsPulled},
};
/// Applies a prepared diff to in-memory mempool state under one write
@@ -16,35 +14,34 @@ use crate::{
pub struct Applier;
impl Applier {
/// `rebuilder` supplies the previous cycle's snapshot. Burial reads
/// each tomb's `chunk_rate` from the snapshot (always-fresh,
/// `prev_snapshot` supplies the previous cycle's snapshot. Burial
/// reads each tomb's `chunk_rate` from it (always-fresh,
/// package-aware via local linearization). The fallback to
/// `entry.fee_rate()` is unreachable in steady state - every burial
/// target was alive at the previous tick, so the snapshot has it.
pub fn apply(
lock: &RwLock<State>,
rebuilder: &Rebuilder,
prev_snapshot: &Snapshot,
pulled: TxsPulled,
diff: &mut CycleDiff,
) {
let TxsPulled { added, removed } = pulled;
let mut state = lock.write();
Self::bury_removals(&mut state, rebuilder, &mut diff.addrs, &mut diff.removed, removed);
Self::bury_removals(&mut state, prev_snapshot, &mut diff.addrs, &mut diff.removed, removed);
Self::publish_additions(&mut state, &mut diff.addrs, &mut diff.added, added);
state.graveyard.evict_old();
}
fn bury_removals(
state: &mut State,
rebuilder: &Rebuilder,
snapshot: &Snapshot,
transitions: &mut AddrTransitions,
events: &mut Vec<TxRemoved>,
removed: Vec<(TxidPrefix, TxRemoval)>,
) {
let snapshot = rebuilder.snapshot();
events.reserve(removed.len());
for (prefix, reason) in removed {
if let Some(ev) = Self::bury_one(state, &snapshot, transitions, &prefix, reason) {
if let Some(ev) = Self::bury_one(state, snapshot, transitions, &prefix, reason) {
events.push(ev);
}
}
@@ -52,13 +49,13 @@ impl Applier {
fn bury_one(
state: &mut State,
snapshot: &Snapshot,
prev_snapshot: &Snapshot,
transitions: &mut AddrTransitions,
prefix: &TxidPrefix,
reason: TxRemoval,
) -> Option<TxRemoved> {
let record = state.txs.remove_by_prefix(prefix)?;
let chunk_rate = snapshot
let chunk_rate = prev_snapshot
.chunk_rate_for(prefix)
.unwrap_or_else(|| record.entry.fee_rate());
let txid = record.entry.txid;
@@ -117,3 +114,162 @@ impl Applier {
state.txs.insert(tx, entry);
}
}
#[cfg(test)]
mod tests {
use brk_types::{FeeRate, Sats, TxOut, Txid, VSize};
use super::*;
use crate::{
AddedKind,
cycle::CycleDiff,
steps::preparer::{TxAddition, TxsPulled},
test_support::{fake_entry_info, fake_tx, p2wpkh_script},
};
fn fresh_addition(seed: u8, fee: u64, vsize: u64) -> (TxAddition, Txid) {
let prev = Some(TxOut::from((p2wpkh_script(seed), Sats::from(2_500u64))));
let tx = fake_tx(seed, &[prev], &[(p2wpkh_script(seed + 1), 1_234)]);
let txid = tx.txid;
let info = fake_entry_info(txid, fee, vsize);
let entry = TxEntry::new(&info, vsize, false);
(TxAddition::Fresh { tx, entry }, txid)
}
fn fresh_pulled(addition: TxAddition) -> TxsPulled {
TxsPulled {
added: vec![addition],
removed: vec![],
}
}
#[test]
fn publish_one_inserts_into_all_stores() {
let lock = RwLock::new(State::default());
let snapshot = Snapshot::default();
let mut diff = CycleDiff::default();
let (addition, txid) = fresh_addition(0xC0, 200, 100);
Applier::apply(&lock, &snapshot, fresh_pulled(addition), &mut diff);
let state = lock.read();
assert!(state.txs.contains(&txid));
assert_eq!(diff.added.len(), 1);
assert_eq!(diff.added[0].txid, txid);
}
#[test]
fn revived_path_exhumes_body_from_graveyard() {
let lock = RwLock::new(State::default());
let snapshot = Snapshot::default();
let (addition, txid) = fresh_addition(0xC1, 300, 100);
let TxAddition::Fresh { tx, entry } = addition else {
unreachable!();
};
// Pre-load the graveyard with this tx, then submit a Revived
// addition that re-publishes it without a raw body.
let rate = FeeRate::from((entry.fee, entry.vsize));
lock.write()
.graveyard
.bury(tx, entry.clone(), rate, TxRemoval::Vanished);
let mut diff = CycleDiff::default();
Applier::apply(
&lock,
&snapshot,
fresh_pulled(TxAddition::Revived { entry }),
&mut diff,
);
let state = lock.read();
assert!(state.txs.contains(&txid), "revived tx republished");
assert!(state.graveyard.get(&txid).is_none(), "tomb consumed");
assert_eq!(diff.added.len(), 1);
assert!(matches!(diff.added[0].kind, AddedKind::Revived));
}
#[test]
fn revived_with_empty_graveyard_is_dropped() {
let lock = RwLock::new(State::default());
let snapshot = Snapshot::default();
let info = fake_entry_info(Txid::COINBASE, 100, 100);
let entry = TxEntry::new(&info, 100, false);
let mut diff = CycleDiff::default();
Applier::apply(
&lock,
&snapshot,
fresh_pulled(TxAddition::Revived { entry }),
&mut diff,
);
let state = lock.read();
assert!(!state.txs.contains(&Txid::COINBASE));
assert!(diff.added.is_empty(), "no body, no event");
}
#[test]
fn bury_preserves_chunk_rate_from_snapshot() {
let lock = RwLock::new(State::default());
let (addition, txid) = fresh_addition(0xC2, 100, 100);
// Publish first to plant the tx, with a fee-rate that differs
// from the snapshot's stub rate so we can tell them apart.
Applier::apply(
&lock,
&Snapshot::default(),
fresh_pulled(addition),
&mut CycleDiff::default(),
);
let isolated_rate = FeeRate::from((Sats::from(100u64), VSize::from(100u64)));
let cpfp_rate = FeeRate::from((Sats::from(500u64), VSize::from(100u64)));
let prefix = TxidPrefix::from(&txid);
let snapshot = Snapshot::for_test_with_chunk_rates(&[(prefix, cpfp_rate, txid)]);
let mut diff = CycleDiff::default();
Applier::apply(
&lock,
&snapshot,
TxsPulled {
added: vec![],
removed: vec![(prefix, TxRemoval::Vanished)],
},
&mut diff,
);
assert_eq!(diff.removed.len(), 1);
assert_eq!(diff.removed[0].chunk_rate, cpfp_rate);
assert_ne!(diff.removed[0].chunk_rate, isolated_rate);
let state = lock.read();
assert_eq!(state.graveyard.get(&txid).unwrap().chunk_rate, cpfp_rate);
}
#[test]
fn bury_falls_back_to_isolated_rate_when_snapshot_misses() {
let lock = RwLock::new(State::default());
let (addition, txid) = fresh_addition(0xC3, 700, 100);
Applier::apply(
&lock,
&Snapshot::default(),
fresh_pulled(addition),
&mut CycleDiff::default(),
);
let isolated_rate = FeeRate::from((Sats::from(700u64), VSize::from(100u64)));
let prefix = TxidPrefix::from(&txid);
let mut diff = CycleDiff::default();
Applier::apply(
&lock,
&Snapshot::default(),
TxsPulled {
added: vec![],
removed: vec![(prefix, TxRemoval::Vanished)],
},
&mut diff,
);
assert_eq!(diff.removed[0].chunk_rate, isolated_rate);
}
}

View File

@@ -14,7 +14,7 @@ pub struct Fetched {
pub new_txs: FxHashMap<Txid, bitcoin::Transaction>,
/// Block 0 ordering from `getblocktemplate`. Bodies and stats have
/// already been folded into `new_entries`/`new_txs` (or were already
/// in the pool); the Rebuilder only needs the txid sequence to
/// in the pool). The Rebuilder only needs the txid sequence to
/// project Core's exact selection.
pub block_template_txids: Vec<Txid>,
}

View File

@@ -7,17 +7,18 @@ use brk_rpc::Client;
use brk_types::{MempoolEntryInfo, Timestamp, Txid, VSize};
use parking_lot::RwLock;
use rustc_hash::FxHashSet;
use tracing::warn;
use crate::State;
/// Cap before the batch RPC so we never hand bitcoind an unbounded batch.
/// GBT-synthesized entries are not subject to this cap; they're bounded
/// GBT-synthesized entries are not subject to this cap: they're bounded
/// by the block weight limit Core enforces on its own template.
const MAX_TX_FETCHES_PER_CYCLE: usize = 10_000;
/// Two batched round-trips per cycle, scaling with churn rather than
/// mempool size: `getblocktemplate` + `getrawmempool false` +
/// `getmempoolinfo` in one mixed batch; then `getmempoolentry` +
/// `getmempoolinfo` in one mixed batch, then `getmempoolentry` +
/// `getrawtransaction` for *new* non-GBT txids in a second mixed batch.
///
/// GBT entries already carry the full tx body and stats, so any GBT tx
@@ -56,6 +57,12 @@ impl Fetcher {
.take(MAX_TX_FETCHES_PER_CYCLE)
.copied()
.collect();
if new_txids.len() == MAX_TX_FETCHES_PER_CYCLE {
warn!(
cap = MAX_TX_FETCHES_PER_CYCLE,
"Fetcher: new-tx batch hit the per-cycle cap; remainder defers to the next cycle"
);
}
(new_txids, gbt_synth_set)
};

View File

@@ -1,16 +1,11 @@
//! The five pipeline steps, in cycle order. See the crate-level docs
//! for the full cycle narrative.
//! Cycle stages in pipeline order.
mod applier;
mod fetcher;
mod preparer;
mod prevouts;
mod rebuilder;
pub(crate) use applier::Applier;
pub(crate) use fetcher::{Fetched, Fetcher};
pub(crate) use preparer::{Preparer, TxEntry};
pub use preparer::TxRemoval;
pub(crate) use prevouts::Prevouts;
pub(crate) use rebuilder::{BlockStats, RecommendedFees, Rebuilder, SnapTx, TxIndex};
pub use rebuilder::Snapshot;
pub use applier::Applier;
pub use fetcher::{Fetched, Fetcher};
pub use preparer::{Preparer, TxRemoval};
pub use prevouts::Prevouts;

View File

@@ -22,12 +22,10 @@ use crate::{
};
mod tx_addition;
mod tx_entry;
mod tx_removal;
mod txs_pulled;
pub use tx_addition::TxAddition;
pub use tx_entry::TxEntry;
pub use tx_removal::TxRemoval;
pub use txs_pulled::TxsPulled;
@@ -81,6 +79,11 @@ impl Preparer {
/// One `(prefix, reason)` per known tx that's gone from the live set,
/// in `known` iteration order.
///
/// Cost is `O(R * avg_inputs)` where R is the removed-tx count and
/// `avg_inputs` is small for non-pathological txs. Worst case is a
/// `mempoolminfee` jump dropping ~10k txs in one cycle - still well
/// under the cycle budget.
fn classify_removals(
live: &FxHashSet<TxidPrefix>,
added: &[TxAddition],
@@ -119,3 +122,223 @@ impl Preparer {
spent_by
}
}
#[cfg(test)]
mod tests {
use bitcoin::hashes::Hash;
use brk_types::{FeeRate, Sats, VSize};
use super::*;
use crate::{
AddedKind, TxRemoval,
state::TxEntry,
test_support::{fake_bitcoin_tx, fake_entry_info, fake_tx, fake_txid, p2wpkh_script},
};
fn empty_state() -> RwLock<State> {
RwLock::new(State::default())
}
fn seed_known(state: &RwLock<State>, txid: Txid) {
let tx = fake_tx(0xA0, &[None], &[(p2wpkh_script(50), 5_000)]);
let mut altered = tx;
altered.txid = txid;
for input in altered.input.iter_mut() {
input.prevout = Some(brk_types::TxOut::from((
p2wpkh_script(51),
Sats::from(1_000u64),
)));
}
let info = fake_entry_info(txid, 1_000, 100);
let entry = TxEntry::new(&info, 100, false);
state.write().txs.insert(altered, entry);
}
fn seed_graveyard(state: &RwLock<State>, txid: Txid) {
let tx = fake_tx(0xB0, &[None], &[(p2wpkh_script(60), 5_000)]);
let mut altered = tx;
altered.txid = txid;
let info = fake_entry_info(txid, 500, 100);
let entry = TxEntry::new(&info, 100, false);
let rate = FeeRate::from((Sats::from(500u64), VSize::from(100u64)));
state
.write()
.graveyard
.bury(altered, entry, rate, TxRemoval::Vanished);
}
#[test]
fn classify_addition_skips_already_known() {
let state = empty_state();
let known_txid = fake_txid(0x10);
seed_known(&state, known_txid);
let info = fake_entry_info(known_txid, 100, 100);
let mut new_txs: FxHashMap<Txid, bitcoin::Transaction> = FxHashMap::default();
new_txs.insert(known_txid, fake_bitcoin_tx(0x11, &[(p2wpkh_script(7), 1_234)]));
let pulled = Preparer::prepare(&[known_txid], vec![info], new_txs, &state);
assert!(pulled.added.is_empty(), "known tx must be filtered out");
assert!(pulled.removed.is_empty(), "still live, nothing removed");
}
#[test]
fn classify_addition_emits_revived_for_graveyard_hit() {
let state = empty_state();
let txid = fake_txid(0x20);
seed_graveyard(&state, txid);
let info = fake_entry_info(txid, 100, 100);
let pulled = Preparer::prepare(&[txid], vec![info], FxHashMap::default(), &state);
assert_eq!(pulled.added.len(), 1);
assert!(matches!(pulled.added[0].kind(), AddedKind::Revived));
}
#[test]
fn classify_addition_emits_fresh_with_raw_payload() {
let state = empty_state();
let txid = fake_txid(0x30);
// Make the bitcoin tx hash to `txid`: we instead key `new_txs`
// by the synthetic txid, since classify_addition keys lookup by
// info.txid, not by tx.compute_txid().
let info = fake_entry_info(txid, 200, 120);
let raw = fake_bitcoin_tx(0x31, &[(p2wpkh_script(8), 2_345)]);
let mut new_txs: FxHashMap<Txid, bitcoin::Transaction> = FxHashMap::default();
new_txs.insert(txid, raw);
let pulled = Preparer::prepare(&[txid], vec![info], new_txs, &state);
assert_eq!(pulled.added.len(), 1);
assert!(matches!(pulled.added[0].kind(), AddedKind::Fresh));
}
#[test]
fn classify_addition_drops_entry_with_no_raw_and_no_graveyard() {
let state = empty_state();
let txid = fake_txid(0x40);
let info = fake_entry_info(txid, 100, 100);
let pulled = Preparer::prepare(&[txid], vec![info], FxHashMap::default(), &state);
assert!(pulled.added.is_empty(), "no payload, no tomb -> filtered");
}
#[test]
fn classify_removal_marks_replaced_when_outpoint_is_spent_by_new_tx() {
let state = empty_state();
// Loser: spends (parent, vout=0). We arrange the new fresh tx
// to spend the same outpoint.
let parent_txid = fake_txid(0x50);
let loser_txid = fake_txid(0x51);
let replacer_txid = fake_txid(0x52);
{
let prev = Some(brk_types::TxOut::from((
p2wpkh_script(80),
Sats::from(10_000u64),
)));
let mut tx = fake_tx(0x51, &[prev], &[(p2wpkh_script(81), 5_000)]);
tx.txid = loser_txid;
tx.input[0].txid = parent_txid;
tx.input[0].vout = Vout::ZERO;
let info = fake_entry_info(loser_txid, 100, 100);
let entry = TxEntry::new(&info, 100, false);
state.write().txs.insert(tx, entry);
}
let info = fake_entry_info(replacer_txid, 200, 120);
let mut new_txs: FxHashMap<Txid, bitcoin::Transaction> = FxHashMap::default();
let mut raw = fake_bitcoin_tx(0x52, &[(p2wpkh_script(82), 4_321)]);
raw.input[0].previous_output = bitcoin::OutPoint {
txid: bitcoin::Txid::from_byte_array({
let mut b = [0u8; 32];
b[0] = 0x50;
b
}),
vout: 0,
};
new_txs.insert(replacer_txid, raw);
let pulled = Preparer::prepare(&[replacer_txid], vec![info], new_txs, &state);
assert_eq!(pulled.removed.len(), 1);
let (_, reason) = pulled.removed[0];
match reason {
TxRemoval::Replaced { by } => assert_eq!(by, replacer_txid),
TxRemoval::Vanished => panic!("expected Replaced, got Vanished"),
}
}
#[test]
fn classify_removal_marks_vanished_when_no_new_tx_spends_outpoint() {
let state = empty_state();
let gone_txid = fake_txid(0x60);
{
let prev = Some(brk_types::TxOut::from((
p2wpkh_script(90),
Sats::from(10_000u64),
)));
let mut tx = fake_tx(0x60, &[prev], &[(p2wpkh_script(91), 6_000)]);
tx.txid = gone_txid;
tx.input[0].txid = fake_txid(0xAA);
let info = fake_entry_info(gone_txid, 100, 100);
let entry = TxEntry::new(&info, 100, false);
state.write().txs.insert(tx, entry);
}
// No live txids in this cycle, no replacers staged.
let pulled = Preparer::prepare(&[], vec![], FxHashMap::default(), &state);
assert_eq!(pulled.removed.len(), 1);
assert!(matches!(pulled.removed[0].1, TxRemoval::Vanished));
}
#[test]
fn fresh_resolves_prevout_from_same_cycle_mempool_parent() {
// Same-cycle ordering: parent inserted first, then child whose
// input points at parent.vout=0. We exercise the path by
// putting the parent into the live store via a Fresh add, then
// a second Preparer call where the child's `info` references
// the parent's outpoint.
let state = empty_state();
let parent_txid = fake_txid(0x70);
let child_txid = fake_txid(0x71);
// Stage parent directly in the live store so resolve_prevout
// sees it when the child is decoded.
{
let mut parent = fake_tx(0x70, &[], &[(p2wpkh_script(100), 7_777)]);
parent.txid = parent_txid;
parent.input.clear();
let info = fake_entry_info(parent_txid, 100, 80);
let entry = TxEntry::new(&info, 80, false);
state.write().txs.insert(parent, entry);
}
let info = fake_entry_info(child_txid, 200, 120);
let mut raw = fake_bitcoin_tx(0x70, &[(p2wpkh_script(101), 6_000)]);
raw.input[0].previous_output = bitcoin::OutPoint {
txid: bitcoin::Txid::from_byte_array({
let mut b = [0u8; 32];
b[0] = 0x70;
b
}),
vout: 0,
};
let mut new_txs: FxHashMap<Txid, bitcoin::Transaction> = FxHashMap::default();
new_txs.insert(child_txid, raw);
let pulled = Preparer::prepare(
&[parent_txid, child_txid],
vec![info],
new_txs,
&state,
);
let TxAddition::Fresh { tx, .. } = &pulled.added[0] else {
panic!("expected Fresh classification");
};
let prevout = tx.input[0]
.prevout
.as_ref()
.expect("parent in same-cycle pool must resolve");
assert_eq!(prevout.value, Sats::from(7_777u64));
// No removal: parent + child both in live set.
assert!(pulled.removed.is_empty());
}
}

View File

@@ -11,9 +11,11 @@
use brk_types::{MempoolEntryInfo, SigOps, Transaction, TxIn, TxOut, TxStatus, Txid, Vout};
use crate::{TxTombstone, cycle::AddedKind, stores::TxStore};
use super::TxEntry;
use crate::{
cycle::AddedKind,
state::TxEntry,
stores::{TxStore, TxTombstone},
};
pub enum TxAddition {
Fresh { tx: Transaction, entry: TxEntry },
@@ -67,7 +69,7 @@ impl TxAddition {
output: tx.output.into_iter().map(TxOut::from).collect(),
status: TxStatus::UNCONFIRMED,
};
built.total_sigop_cost = built.total_sigop_cost();
built.refresh_sigops();
built
}

View File

@@ -5,7 +5,7 @@ use brk_types::Txid;
/// `Replaced` = at least one freshly added tx this cycle spends one of
/// its inputs (BIP-125 replacement inferred from conflicting outpoints).
/// `by` is the immediate successor; the chain extends if `by` is itself
/// `by` is the immediate successor. The chain extends if `by` is itself
/// later replaced. Walk it forward via `TxGraveyard::replacement_root_of`.
///
/// `Vanished` = any other reason we can't distinguish from the data at

View File

@@ -11,7 +11,7 @@
//! a fill directly (cheap, lock-local). Otherwise we record the
//! hole for external resolution.
//! 2. Drop the read guard. Call `resolver` on the remaining holes
//! (typically `getrawtransaction` or an indexer lookup); failures
//! (typically `getrawtransaction` or an indexer lookup). Failures
//! are simply skipped and retried next cycle.
//! 3. Take the write guard once and fold both fill batches into the
//! `TxStore` via `apply_fills` -> `add_input`. Idempotent: each
@@ -26,7 +26,7 @@ use parking_lot::RwLock;
use rustc_hash::{FxHashMap, FxHashSet};
use tracing::warn;
use crate::{CycleDiff, State, stores::TxStore};
use crate::{cycle::CycleDiff, state::State, stores::TxStore};
pub struct Prevouts;
@@ -38,10 +38,9 @@ type Resolved = FxHashMap<(Txid, Vout), TxOut>;
impl Prevouts {
/// Fill every unfilled prevout the cycle can resolve. Same-cycle
/// in-mempool parents are filled lock-locally; the remainder go
/// through `resolver` (one batched call) outside any lock. Returns
/// true iff anything was written.
pub fn fill<F>(lock: &RwLock<State>, diff: &mut CycleDiff, resolver: F) -> bool
/// in-mempool parents are filled lock-locally. The remainder go
/// through `resolver` (one batched call) outside any lock.
pub fn fill<F>(lock: &RwLock<State>, diff: &mut CycleDiff, resolver: F)
where
F: Fn(&[(Txid, Vout)]) -> Resolved,
{
@@ -52,7 +51,7 @@ impl Prevouts {
let external = Self::resolve_external(holes, resolver);
if in_mempool.is_empty() && external.is_empty() {
return false;
return;
}
let mut state = lock.write();
@@ -62,7 +61,6 @@ impl Prevouts {
state.addrs.add_input(&mut diff.addrs, &txid, &prevout);
}
}
true
}
/// Default resolver: one batched `getrawtransaction` per cycle,

View File

@@ -1,71 +0,0 @@
//! Pack live txs into projected blocks 1..N, sorted by descending
//! `chunk_rate`. Block 0 is filled by the caller from `getblocktemplate`
//! (Core's actual selection); blocks 1..N feed
//! `/api/v1/fees/mempool-blocks` as a coarse fee-tier gradient.
//!
//! No topological gate: a child can sit before its parent within a
//! tied-rate run, but every cluster member shares its chunk's
//! `chunk_rate` (linearized at snapshot time) so chunk-mates land in
//! the same block, and the only output is a per-block rate
//! distribution where intra-block order is invisible.
//!
//! The final block is a catch-all (no vsize cap) so leftover tail
//! vsize is accounted for instead of silently dropped.
//!
//! Walk sorted candidates once. For each, push into the current
//! block if it fits; otherwise advance to the next block (unless we
//! are already on the last one, which absorbs everything remaining).
use brk_types::{FeeRate, VSize};
use rustc_hash::FxHashSet;
use super::snapshot::{SnapTx, TxIndex};
pub struct Partitioner;
impl Partitioner {
pub fn partition(
txs: &[SnapTx],
excluded: &FxHashSet<TxIndex>,
num_remaining_blocks: usize,
) -> Vec<Vec<TxIndex>> {
if num_remaining_blocks == 0 {
return Vec::new();
}
let sorted = sorted_candidates(txs, excluded);
let mut blocks: Vec<Vec<TxIndex>> = (0..num_remaining_blocks).map(|_| Vec::new()).collect();
let mut block_vsize = VSize::default();
let mut current = 0;
let last = num_remaining_blocks - 1;
for (idx, vsize, _) in sorted {
let fits = vsize <= VSize::MAX_BLOCK.saturating_sub(block_vsize);
if !fits && current < last && !blocks[current].is_empty() {
current += 1;
block_vsize = VSize::default();
}
blocks[current].push(idx);
block_vsize += vsize;
}
blocks
}
}
fn sorted_candidates(
txs: &[SnapTx],
excluded: &FxHashSet<TxIndex>,
) -> Vec<(TxIndex, VSize, FeeRate)> {
let mut cands: Vec<(TxIndex, VSize, FeeRate)> = txs
.iter()
.enumerate()
.filter_map(|(i, t)| {
let idx = TxIndex::from(i);
(!excluded.contains(&idx)).then_some((idx, t.vsize, t.chunk_rate))
})
.collect();
cands.sort_by(|(a_idx, _, a_rate), (b_idx, _, b_rate)| {
b_rate
.cmp(a_rate)
.then_with(|| txs[a_idx.as_usize()].txid.cmp(&txs[b_idx.as_usize()].txid))
});
cands
}

View File

@@ -1,221 +0,0 @@
//! Build the per-tx adjacency for a snapshot from the live `TxStore`,
//! then linearize chunk rates over every multi-tx cluster.
//!
//! One pass over the live records to assign compact `TxIndex`es and a
//! `prefix -> TxIndex` map, then per entry resolve `depends` against
//! it to produce parent edges. Children are mirrored from parents in a
//! second pass. Cross-pool parents (confirmed or evicted) are dropped
//! silently - the live pool reflects what miners actually see, and any
//! stale `depends` entry is self-healing.
//!
//! Final pass: walk every connected component and run Single Fee
//! Linearization over it (see [`crate::cluster`]); each member's
//! `chunk_rate` is overwritten with its chunk's feerate. Singletons
//! keep the `fee/vsize` seed set in `live_tx`.
//!
//! The prefix map is returned alongside the txs so the rebuilder can
//! reuse it for GBT mapping and the final `Snapshot::build` step
//! without reconstructing it.
use std::mem;
use brk_types::TxidPrefix;
use rustc_hash::{FxBuildHasher, FxHashMap};
use smallvec::SmallVec;
use crate::{
TxEntry,
cluster::{linearize_component, walk_cluster},
stores::TxStore,
};
use super::{SnapTx, TxIndex};
pub type PrefixIndex = FxHashMap<TxidPrefix, TxIndex>;
pub fn build_txs(txs: &TxStore) -> (Vec<SnapTx>, PrefixIndex) {
let n = txs.len();
let mut prefix_to_idx: PrefixIndex =
FxHashMap::with_capacity_and_hasher(n, FxBuildHasher);
for (i, (prefix, _)) in txs.records().enumerate() {
prefix_to_idx.insert(*prefix, TxIndex::from(i));
}
let mut snap_txs: Vec<SnapTx> = txs
.records()
.map(|(_, record)| live_tx(&record.entry, &prefix_to_idx))
.collect();
mirror_children(&mut snap_txs);
refresh_chunk_rates(&mut snap_txs);
(snap_txs, prefix_to_idx)
}
fn live_tx(e: &TxEntry, prefix_to_idx: &PrefixIndex) -> SnapTx {
let parents: SmallVec<[TxIndex; 2]> = e
.depends
.iter()
.filter_map(|p| prefix_to_idx.get(p).copied())
.collect();
SnapTx {
txid: e.txid,
fee: e.fee,
vsize: e.vsize,
weight: e.weight,
size: e.size,
chunk_rate: e.fee_rate(),
parents,
children: SmallVec::new(),
}
}
fn mirror_children(txs: &mut [SnapTx]) {
for i in 0..txs.len() {
let child = TxIndex::from(i);
let parents = mem::take(&mut txs[i].parents);
for &p in &parents {
if let Some(t) = txs.get_mut(p.as_usize()) {
t.children.push(child);
}
}
txs[i].parents = parents;
}
}
/// Walk every multi-tx connected component once and overwrite each
/// member's `chunk_rate` with the linearized chunk's feerate. Visited
/// bitmap ensures each cluster is linearized exactly once.
fn refresh_chunk_rates(snap_txs: &mut [SnapTx]) {
let n = snap_txs.len();
let mut visited = vec![false; n];
for seed in 0..n {
if visited[seed] {
continue;
}
let t = &snap_txs[seed];
if t.parents.is_empty() && t.children.is_empty() {
visited[seed] = true;
continue;
}
let component = walk_cluster(snap_txs, TxIndex::from(seed));
for &m in &component {
visited[m.as_usize()] = true;
}
if component.len() <= 1 {
continue;
}
let (members, chunks) = linearize_component(snap_txs, &component);
for chunk in &chunks {
for &local in &chunk.txs {
let m = members[u32::from(local) as usize];
snap_txs[m.as_usize()].chunk_rate = chunk.feerate;
}
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
use bitcoin::hashes::Hash;
use brk_types::{FeeRate, Sats, Txid, VSize, Weight};
use super::*;
/// Build a `SnapTx` for tests. `txid` is auto-assigned from a
/// process-wide counter so each tx is distinguishable in
/// debug output; the cluster code itself keys off `TxIndex`,
/// not `txid`.
fn snap_tx(fee: Sats, vsize: VSize) -> SnapTx {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let mut bytes = [0u8; 32];
bytes[..4].copy_from_slice(&COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes());
SnapTx {
txid: Txid::from(bitcoin::Txid::from_byte_array(bytes)),
fee,
vsize,
weight: Weight::from(vsize),
size: u64::from(vsize),
chunk_rate: FeeRate::from((fee, vsize)),
parents: SmallVec::new(),
children: SmallVec::new(),
}
}
fn link(txs: &mut [SnapTx], parent: usize, child: usize) {
txs[child].parents.push(TxIndex::from(parent));
txs[parent].children.push(TxIndex::from(child));
}
fn sats(n: u64) -> Sats {
Sats::from(n)
}
fn vsize(n: u64) -> VSize {
VSize::from(n)
}
#[test]
fn singleton_keeps_fee_per_vsize() {
let mut txs = vec![snap_tx(sats(1000), vsize(100))];
let seed = txs[0].chunk_rate;
refresh_chunk_rates(&mut txs);
assert_eq!(txs[0].chunk_rate, seed);
}
#[test]
fn two_tx_cpfp_lift() {
let mut txs = vec![
snap_tx(sats(100), vsize(100)),
snap_tx(sats(1900), vsize(100)),
];
link(&mut txs, 0, 1);
let parent_seed = txs[0].chunk_rate;
refresh_chunk_rates(&mut txs);
assert!(txs[0].chunk_rate > parent_seed);
assert_eq!(txs[0].chunk_rate, txs[1].chunk_rate);
assert_eq!(txs[0].chunk_rate, FeeRate::from((sats(2000), vsize(200))));
}
#[test]
fn three_tx_chain_chunks_correctly() {
let mut txs = vec![
snap_tx(sats(100), vsize(100)),
snap_tx(sats(100), vsize(100)),
snap_tx(sats(5800), vsize(100)),
];
link(&mut txs, 0, 1);
link(&mut txs, 1, 2);
refresh_chunk_rates(&mut txs);
let combined = FeeRate::from((sats(6000), vsize(300)));
assert_eq!(txs[0].chunk_rate, combined);
assert_eq!(txs[1].chunk_rate, combined);
assert_eq!(txs[2].chunk_rate, combined);
}
#[test]
fn disjoint_clusters_linearized_independently() {
let mut txs = vec![
snap_tx(sats(100), vsize(100)),
snap_tx(sats(1900), vsize(100)),
snap_tx(sats(500), vsize(100)),
snap_tx(sats(4500), vsize(100)),
];
link(&mut txs, 0, 1);
link(&mut txs, 2, 3);
refresh_chunk_rates(&mut txs);
assert_eq!(txs[0].chunk_rate, txs[1].chunk_rate);
assert_eq!(txs[2].chunk_rate, txs[3].chunk_rate);
assert_ne!(txs[0].chunk_rate, txs[2].chunk_rate);
}
#[test]
fn cluster_cap_does_not_panic() {
let n = 100;
let mut txs: Vec<SnapTx> = (0..n).map(|_| snap_tx(sats(1000), vsize(100))).collect();
for i in 1..n {
link(&mut txs, i - 1, i);
}
refresh_chunk_rates(&mut txs);
}
}

View File

@@ -1,105 +0,0 @@
mod builder;
mod fees;
mod stats;
mod tx;
mod tx_index;
pub(crate) use builder::{PrefixIndex, build_txs};
pub use stats::BlockStats;
pub use tx::SnapTx;
pub use tx_index::TxIndex;
use std::hash::{Hash, Hasher};
use brk_types::{FeeRate, NextBlockHash, RecommendedFees, Txid, TxidPrefix};
use rustc_hash::FxHasher;
use fees::Fees;
#[derive(Default)]
pub struct Snapshot {
/// Dense per-tx data indexed by `TxIndex`. Each entry carries the
/// linearized chunk rate (computed locally at snapshot build time)
/// plus resolved parent/child adjacency, so CPFP queries don't
/// re-read any external state.
pub txs: Vec<SnapTx>,
/// Projected blocks. `blocks[0]` is Core's `getblocktemplate`
/// (Bitcoin Core's actual selection); the rest are greedy-packed
/// by descending chunk rate, with a final overflow block.
pub blocks: Vec<Vec<TxIndex>>,
pub block_stats: Vec<BlockStats>,
pub fees: RecommendedFees,
/// Content hash of the projected next block. Same value as the
/// mempool ETag.
pub next_block_hash: NextBlockHash,
/// Per-snapshot `TxidPrefix -> TxIndex` index, so live queries can
/// resolve a prefix to the snapshot's compact index without
/// re-walking `txs`. Built once by `build_txs` and reused by the
/// rebuilder for GBT mapping.
prefix_to_idx: PrefixIndex,
}
impl Snapshot {
/// `min_fee` is bitcoind's live `mempoolminfee`, used as the floor
/// for every recommended-fee tier.
pub fn build(
txs: Vec<SnapTx>,
blocks: Vec<Vec<TxIndex>>,
prefix_to_idx: PrefixIndex,
min_fee: FeeRate,
) -> Self {
let block_stats = BlockStats::for_blocks(&blocks, &txs);
let fees = Fees::compute(&block_stats, min_fee);
let next_block_hash = Self::hash_next_block(&blocks, &txs);
Self {
txs,
blocks,
block_stats,
fees,
next_block_hash,
prefix_to_idx,
}
}
/// Content tag over block 0 in template order. Hashes txids, not
/// `TxIndex` slots, because slot assignment is per-cycle and
/// unstable; the txid set is the actual identity of "what's in the
/// next block".
fn hash_next_block(blocks: &[Vec<TxIndex>], txs: &[SnapTx]) -> NextBlockHash {
let Some(block) = blocks.first() else {
return NextBlockHash::ZERO;
};
let mut hasher = FxHasher::default();
for idx in block {
txs[idx.as_usize()].txid.hash(&mut hasher);
}
NextBlockHash::new(hasher.finish())
}
pub fn tx(&self, idx: TxIndex) -> Option<&SnapTx> {
self.txs.get(idx.as_usize())
}
pub fn idx_of(&self, prefix: &TxidPrefix) -> Option<TxIndex> {
self.prefix_to_idx.get(prefix).copied()
}
/// Txids of `blocks[0]` (Core's `getblocktemplate` selection),
/// in template order. Empty for a default snapshot.
pub fn block0_txids(&self) -> impl Iterator<Item = Txid> + '_ {
self.blocks
.first()
.into_iter()
.flatten()
.map(|idx| self.txs[idx.as_usize()].txid)
}
/// Linearized chunk rate for a live tx by prefix. Always fresh
/// (recomputed each snapshot), package-aware (CPFP and ancestor
/// chains lift correctly), and equals `fee/vsize` for singletons.
/// Returns `None` if the tx isn't in this snapshot.
pub fn chunk_rate_for(&self, prefix: &TxidPrefix) -> Option<FeeRate> {
let idx = self.idx_of(prefix)?;
Some(self.txs[idx.as_usize()].chunk_rate)
}
}

View File

@@ -1,41 +0,0 @@
//! Per-cycle 0↔1+ address transition buffer.
//!
//! Lives on the stack inside [`crate::Mempool::tick_with`], not on a
//! long-lived store, so the set naturally resets between cycles.
//! Same-cycle cancellation (enter→leave, leave→enter, and the 3-step
//! enter→leave→enter / leave→enter→leave variants) is encapsulated on
//! the recording methods so callers just announce raw 0↔1+ flips.
use brk_types::AddrBytes;
use rustc_hash::FxHashSet;
#[derive(Default)]
pub struct AddrTransitions {
enters: FxHashSet<AddrBytes>,
leaves: FxHashSet<AddrBytes>,
}
impl AddrTransitions {
/// Address just went 0 → 1+ live mempool txs. Cancels a pending
/// `leave` for the same address in this cycle.
pub fn record_enter(&mut self, bytes: AddrBytes) {
if !self.leaves.remove(&bytes) {
self.enters.insert(bytes);
}
}
/// Address just went 1+ → 0 live mempool txs. Cancels a pending
/// `enter` for the same address in this cycle.
pub fn record_leave(&mut self, bytes: AddrBytes) {
if !self.enters.remove(&bytes) {
self.leaves.insert(bytes);
}
}
pub fn into_vecs(self) -> (Vec<AddrBytes>, Vec<AddrBytes>) {
(
self.enters.into_iter().collect(),
self.leaves.into_iter().collect(),
)
}
}

View File

@@ -4,19 +4,26 @@ use std::{
};
use brk_types::{AddrBytes, AddrMempoolStats, Transaction, TxOut, Txid};
use derive_more::Deref;
use rustc_hash::{FxHashMap, FxHasher};
use crate::cycle::AddrTransitions;
mod addr_entry;
mod addr_transitions;
use addr_entry::AddrEntry;
pub use addr_transitions::AddrTransitions;
pub use addr_entry::AddrEntry;
#[derive(Default, Deref)]
#[derive(Default)]
pub struct AddrTracker(FxHashMap<AddrBytes, AddrEntry>);
impl AddrTracker {
pub fn get(&self, addr: &AddrBytes) -> Option<&AddrEntry> {
self.0.get(addr)
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn add_tx(&mut self, transitions: &mut AddrTransitions, tx: &Transaction) {
let txid = &tx.txid;
for txin in &tx.input {
@@ -45,18 +52,14 @@ impl AddrTracker {
}
}
/// Hash of an address's per-mempool stats. Stable while the address
/// is unchanged. Cheaper to recompute than to track invalidation.
/// Returns 0 for unknown addresses (collision with a real hash is
/// astronomically unlikely and only costs one ETag false-hit if it
/// ever happens).
pub fn stats_hash(&self, addr: &AddrBytes) -> u64 {
let Some(entry) = self.0.get(addr) else {
return 0;
};
/// Hash of an address's per-mempool stats, `None` if the address
/// has no live mempool activity. Stable while the address is
/// unchanged. Cheaper to recompute than to track invalidation.
pub fn stats_hash(&self, addr: &AddrBytes) -> Option<u64> {
let entry = self.0.get(addr)?;
let mut hasher = FxHasher::default();
entry.stats.hash(&mut hasher);
hasher.finish()
Some(hasher.finish())
}
/// Fold a single newly-resolved input into the per-address stats.
@@ -135,3 +138,120 @@ impl AddrTracker {
}
}
}
#[cfg(test)]
mod tests {
use brk_types::{Sats, TxOut};
use super::*;
use crate::test_support::{fake_tx, p2wpkh_script};
fn addr_of(script: &bitcoin::ScriptBuf) -> AddrBytes {
AddrBytes::try_from(script).expect("p2wpkh script must yield AddrBytes")
}
#[test]
fn add_tx_records_enter_for_new_addr() {
let mut tracker = AddrTracker::default();
let mut transitions = AddrTransitions::default();
let out_script = p2wpkh_script(1);
let tx = fake_tx(1, &[], &[(out_script.clone(), 5_000)]);
let bytes = addr_of(&out_script);
tracker.add_tx(&mut transitions, &tx);
assert_eq!(tracker.len(), 1);
let entry = tracker.get(&bytes).expect("addr indexed");
assert_eq!(entry.stats.funded_txo_count, 1);
assert_eq!(entry.stats.funded_txo_sum, Sats::from(5_000u64));
assert_eq!(entry.stats.tx_count, 1);
let (enters, leaves) = transitions.into_vecs();
assert_eq!(enters, vec![bytes]);
assert!(leaves.is_empty());
}
#[test]
fn add_then_remove_tx_returns_to_zero_addrs() {
let mut tracker = AddrTracker::default();
let mut transitions = AddrTransitions::default();
let out_script = p2wpkh_script(2);
let prev_script = p2wpkh_script(3);
let tx = fake_tx(
2,
&[Some(TxOut::from((prev_script.clone(), Sats::from(4_000u64))))],
&[(out_script.clone(), 3_500)],
);
let recv = addr_of(&out_script);
let spend = addr_of(&prev_script);
tracker.add_tx(&mut transitions, &tx);
tracker.remove_tx(&mut transitions, &tx);
assert_eq!(tracker.len(), 0);
assert!(tracker.get(&recv).is_none());
assert!(tracker.get(&spend).is_none());
// add+remove in the same cycle: enter/leave cancel out.
let (enters, leaves) = transitions.into_vecs();
assert!(enters.is_empty(), "enter cancelled by same-cycle leave");
assert!(leaves.is_empty(), "leave cancelled by same-cycle enter");
}
#[test]
fn second_tx_touching_addr_does_not_re_enter() {
let mut tracker = AddrTracker::default();
let mut transitions = AddrTransitions::default();
let shared = p2wpkh_script(4);
let tx_a = fake_tx(3, &[], &[(shared.clone(), 2_500)]);
let tx_b = fake_tx(4, &[], &[(shared.clone(), 7_500)]);
tracker.add_tx(&mut transitions, &tx_a);
tracker.add_tx(&mut transitions, &tx_b);
let entry = tracker.get(&addr_of(&shared)).expect("addr indexed");
assert_eq!(entry.stats.funded_txo_count, 2);
assert_eq!(entry.stats.funded_txo_sum, Sats::from(10_000u64));
assert_eq!(entry.stats.tx_count, 2);
// Only one enter, even though two txs landed on the addr.
let (enters, _) = transitions.into_vecs();
assert_eq!(enters.len(), 1);
}
#[test]
fn stats_hash_is_none_for_untracked_addr() {
let tracker = AddrTracker::default();
let bytes = addr_of(&p2wpkh_script(5));
assert!(tracker.stats_hash(&bytes).is_none());
}
#[test]
fn stats_hash_stable_for_repeat_reads() {
let mut tracker = AddrTracker::default();
let mut transitions = AddrTransitions::default();
let script = p2wpkh_script(6);
let tx = fake_tx(5, &[], &[(script.clone(), 3_333)]);
tracker.add_tx(&mut transitions, &tx);
let bytes = addr_of(&script);
let first = tracker.stats_hash(&bytes).expect("addr tracked");
let second = tracker.stats_hash(&bytes).expect("addr tracked");
assert_eq!(first, second);
}
#[test]
fn stats_hash_changes_after_a_mutation() {
let mut tracker = AddrTracker::default();
let mut transitions = AddrTransitions::default();
let script = p2wpkh_script(7);
let bytes = addr_of(&script);
let tx_a = fake_tx(6, &[], &[(script.clone(), 1_111)]);
tracker.add_tx(&mut transitions, &tx_a);
let before = tracker.stats_hash(&bytes).expect("tracked after first add");
let tx_b = fake_tx(7, &[], &[(script, 2_222)]);
tracker.add_tx(&mut transitions, &tx_b);
let after = tracker.stats_hash(&bytes).expect("tracked after second add");
assert_ne!(before, after, "second funding tx must shift the hash");
}
}

View File

@@ -3,14 +3,14 @@
//! single `RwLock` so the cycle steps and read-side accessors share
//! one lock-order discipline.
pub(crate) mod addr_tracker;
pub(crate) mod outpoint_spends;
pub(crate) mod output_bins;
pub(crate) mod tx_graveyard;
pub(crate) mod tx_store;
mod addr_tracker;
mod outpoint_spends;
mod output_bins;
mod tx_graveyard;
mod tx_store;
pub(crate) use addr_tracker::{AddrTracker, AddrTransitions};
pub(crate) use outpoint_spends::OutpointSpends;
pub(crate) use output_bins::OutputBins;
pub(crate) use tx_graveyard::{TxGraveyard, TxTombstone};
pub(crate) use tx_store::TxStore;
pub use addr_tracker::AddrTracker;
pub use outpoint_spends::OutpointSpends;
pub use output_bins::OutputBins;
pub use tx_graveyard::{TxGraveyard, TxTombstone};
pub use tx_store::TxStore;

View File

@@ -1,19 +1,22 @@
use brk_types::{OutpointPrefix, Transaction, TxidPrefix};
use derive_more::Deref;
use rustc_hash::FxHashMap;
/// Mempool index from spent outpoint to spending mempool tx.
///
/// Keys are `OutpointPrefix` (8 bytes txid + 2 bytes vout); prefix
/// Keys are `OutpointPrefix` (8 bytes txid + 2 bytes vout). Prefix
/// collisions are possible, so callers must verify the candidate
/// spender's input list. Values are the spender's `TxidPrefix`,
/// looked up against `TxStore` to recover the full spender record.
#[derive(Default, Deref)]
#[derive(Default)]
pub struct OutpointSpends(FxHashMap<OutpointPrefix, TxidPrefix>);
impl OutpointSpends {
pub fn len(&self) -> usize {
self.0.len()
}
pub fn insert_spends(&mut self, tx: &Transaction, spender: TxidPrefix) {
for key in spent_outpoints(tx) {
for key in Self::spent_outpoints(tx) {
self.0.insert(key, spender);
}
}
@@ -21,7 +24,7 @@ impl OutpointSpends {
/// Only removes entries whose stored prefix still matches `spender`,
/// so an outpoint already re-claimed by a later spender is left alone.
pub fn remove_spends(&mut self, tx: &Transaction, spender: TxidPrefix) {
for key in spent_outpoints(tx) {
for key in Self::spent_outpoints(tx) {
if self.0.get(&key) == Some(&spender) {
self.0.remove(&key);
}
@@ -32,11 +35,11 @@ impl OutpointSpends {
pub fn get(&self, key: &OutpointPrefix) -> Option<TxidPrefix> {
self.0.get(key).copied()
}
}
fn spent_outpoints(tx: &Transaction) -> impl Iterator<Item = OutpointPrefix> + '_ {
tx.input
.iter()
.filter(|i| !i.is_coinbase)
.map(|i| OutpointPrefix::new(TxidPrefix::from(&i.txid), i.vout))
fn spent_outpoints(tx: &Transaction) -> impl Iterator<Item = OutpointPrefix> + '_ {
tx.input
.iter()
.filter(|i| !i.is_coinbase)
.map(|i| OutpointPrefix::new(TxidPrefix::from(&i.txid), i.vout))
}
}

View File

@@ -8,9 +8,9 @@ use rustc_hash::FxHashMap;
mod tombstone;
pub(crate) use tombstone::TxTombstone;
pub use tombstone::TxTombstone;
use crate::{TxEntry, TxRemoval};
use crate::{TxRemoval, state::TxEntry};
const RETENTION: Duration = Duration::from_hours(1);
@@ -68,12 +68,12 @@ impl TxGraveyard {
})
}
/// Every `Replaced` tombstone, yielded as (predecessor_txid,
/// replacer_txid) in reverse bury order (most recent replacement
/// Every `Replaced` tombstone, yielded as (`predecessor_txid`,
/// `replacer_txid`) in reverse bury order (most recent replacement
/// event first). Caller walks the replacer chain forward to find
/// each tree's terminal replacer.
///
/// `order` may carry stale entries (re-buries, prior exhumes); the
/// `order` may carry stale entries (re-buries, prior exhumes). The
/// `removed_at == t` check skips those.
pub fn replaced_iter_recent_first(&self) -> impl Iterator<Item = (&Txid, &Txid)> {
self.order.iter().rev().filter_map(|(t, txid)| {
@@ -130,4 +130,170 @@ impl TxGraveyard {
}
}
}
/// Test-only: force the oldest `order` entries to look older than
/// `RETENTION`. Splits `Instant::now()` arithmetic out of the test
/// bodies and avoids real-time sleeps.
#[cfg(test)]
fn shift_oldest_back(&mut self, count: usize) {
let bumped = Instant::now() - (RETENTION + Duration::from_secs(1));
for entry in self.order.iter_mut().take(count) {
let txid = entry.1;
entry.0 = bumped;
if let Some(ts) = self.tombstones.get_mut(&txid) {
ts.removed_at = bumped;
}
}
}
}
#[cfg(test)]
mod tests {
use brk_types::{FeeRate, MempoolEntryInfo, Sats, Timestamp, VSize, Weight};
use super::*;
use crate::test_support::{fake_tx, fake_txid};
fn tomb_inputs(seed: u8) -> (Transaction, TxEntry, FeeRate) {
let tx = fake_tx(seed, &[], &[]);
let info = MempoolEntryInfo {
txid: tx.txid,
vsize: VSize::from(100u64),
weight: Weight::from(400u64),
fee: Sats::from(100u64),
first_seen: Timestamp::from(0u32),
depends: vec![],
};
let entry = TxEntry::new(&info, 100, false);
let rate = FeeRate::from((Sats::from(100u64), VSize::from(100u64)));
(tx, entry, rate)
}
#[test]
fn bury_then_exhume_roundtrips_the_tombstone() {
let mut g = TxGraveyard::default();
let (tx, entry, rate) = tomb_inputs(1);
let txid = entry.txid;
g.bury(tx, entry, rate, TxRemoval::Vanished);
assert_eq!(g.tombstones_len(), 1);
assert!(g.get(&txid).is_some());
let resurrected = g.exhume(&txid).expect("tombstone present");
assert_eq!(resurrected.entry.txid, txid);
assert!(g.get(&txid).is_none());
assert_eq!(g.tombstones_len(), 0);
// `order` still references the exhumed entry until evict_old
// runs. The timestamp-match check on evict skips stale rows.
assert_eq!(g.order_len(), 1);
}
#[test]
fn get_vanished_filters_out_replaced_tombstones() {
let mut g = TxGraveyard::default();
let (tx_a, entry_a, rate) = tomb_inputs(2);
let (tx_b, entry_b, _) = tomb_inputs(3);
let txid_a = entry_a.txid;
let txid_b = entry_b.txid;
g.bury(tx_a, entry_a, rate, TxRemoval::Replaced { by: txid_b });
g.bury(tx_b, entry_b, rate, TxRemoval::Vanished);
assert!(g.get_vanished(&txid_a).is_none());
assert!(g.get_vanished(&txid_b).is_some());
}
#[test]
fn replacement_root_walks_replaced_chain() {
let mut g = TxGraveyard::default();
let (tx_a, entry_a, rate) = tomb_inputs(4);
let (tx_b, entry_b, _) = tomb_inputs(5);
let (tx_c, entry_c, _) = tomb_inputs(6);
let a = entry_a.txid;
let b = entry_b.txid;
let c = entry_c.txid;
g.bury(tx_a, entry_a, rate, TxRemoval::Replaced { by: b });
g.bury(tx_b, entry_b, rate, TxRemoval::Replaced { by: c });
g.bury(tx_c, entry_c, rate, TxRemoval::Vanished);
assert_eq!(g.replacement_root_of(a), c);
assert_eq!(g.replacement_root_of(c), c);
let unknown = fake_txid(99);
assert_eq!(g.replacement_root_of(unknown), unknown);
}
#[test]
fn predecessors_of_returns_direct_replacers() {
let mut g = TxGraveyard::default();
let (tx_a, entry_a, rate) = tomb_inputs(7);
let (tx_b, entry_b, _) = tomb_inputs(8);
let (tx_c, entry_c, _) = tomb_inputs(9);
let replacer = entry_c.txid;
let a = entry_a.txid;
let b = entry_b.txid;
g.bury(tx_a, entry_a, rate, TxRemoval::Replaced { by: replacer });
g.bury(tx_b, entry_b, rate, TxRemoval::Replaced { by: replacer });
g.bury(tx_c, entry_c, rate, TxRemoval::Vanished);
let mut preds: Vec<Txid> = g.predecessors_of(&replacer).map(|(t, _)| *t).collect();
preds.sort_unstable_by_key(|t| t.as_slice()[0]);
let mut expected = vec![a, b];
expected.sort_unstable_by_key(|t| t.as_slice()[0]);
assert_eq!(preds, expected);
assert_eq!(g.predecessors_of(&fake_txid(123)).count(), 0);
}
#[test]
fn replaced_iter_recent_first_skips_stale_order_entries() {
let mut g = TxGraveyard::default();
let (tx_a, entry_a, rate) = tomb_inputs(10);
let (tx_b, entry_b, _) = tomb_inputs(11);
let replacer = entry_b.txid;
let pred = entry_a.txid;
g.bury(tx_a.clone(), entry_a.clone(), rate, TxRemoval::Replaced { by: replacer });
g.bury(tx_b, entry_b, rate, TxRemoval::Vanished);
// Re-bury the predecessor: its `order` entry is now stale.
g.bury(tx_a, entry_a, rate, TxRemoval::Replaced { by: replacer });
let collected: Vec<(Txid, Txid)> = g
.replaced_iter_recent_first()
.map(|(p, by)| (*p, *by))
.collect();
assert_eq!(collected, vec![(pred, replacer)]);
}
#[test]
fn evict_old_drops_aged_tombstones() {
let mut g = TxGraveyard::default();
let (tx_a, entry_a, rate) = tomb_inputs(12);
let (tx_b, entry_b, _) = tomb_inputs(13);
let txid_a = entry_a.txid;
let txid_b = entry_b.txid;
g.bury(tx_a, entry_a, rate, TxRemoval::Vanished);
g.bury(tx_b, entry_b, rate, TxRemoval::Vanished);
g.shift_oldest_back(1);
g.evict_old();
assert!(g.get(&txid_a).is_none(), "aged tombstone evicted");
assert!(g.get(&txid_b).is_some(), "fresh tombstone retained");
}
#[test]
fn re_bury_mid_retention_resets_age() {
let mut g = TxGraveyard::default();
let (tx, entry, rate) = tomb_inputs(14);
let txid = entry.txid;
g.bury(tx.clone(), entry.clone(), rate, TxRemoval::Vanished);
g.shift_oldest_back(1);
// Re-bury: a stale order entry remains pointing at the old time,
// but `removed_at` on the tombstone is now fresh. evict_old's
// timestamp-match check should drop the stale order entry without
// touching the live tombstone.
g.bury(tx, entry, rate, TxRemoval::Vanished);
g.evict_old();
assert!(g.get(&txid).is_some());
}
}

View File

@@ -2,7 +2,7 @@ use std::time::Instant;
use brk_types::{FeeRate, Transaction, Txid};
use crate::{TxEntry, TxRemoval};
use crate::{TxRemoval, state::TxEntry};
/// A buried mempool tx, retained for reappearance detection and
/// post-mine analytics. `chunk_rate` is the linearized chunk feerate at
@@ -18,7 +18,7 @@ pub struct TxTombstone {
}
impl TxTombstone {
pub(crate) fn replaced_by(&self) -> Option<&Txid> {
pub fn replaced_by(&self) -> Option<&Txid> {
match &self.removal {
TxRemoval::Replaced { by } => Some(by),
TxRemoval::Vanished => None,

View File

@@ -1,7 +1,8 @@
use brk_oracle::Histogram;
use brk_types::{MempoolRecentTx, Transaction, TxOut, Txid, TxidPrefix, Vin};
use rustc_hash::{FxHashMap, FxHashSet};
use crate::{TxEntry, stores::OutputBins};
use crate::{state::TxEntry, stores::OutputBins};
const RECENT_CAP: usize = 10;
@@ -31,11 +32,15 @@ impl TxRecord {
/// set of prefixes whose tx still has at least one `prevout: None`,
/// maintained on every `insert` / `remove_by_prefix` / `apply_fills`
/// so the post-update prevout filler can early-exit when empty.
/// `live_histogram` mirrors the union of each record's `OutputBins`,
/// kept in sync on `insert` / `remove_by_prefix` so the oracle-blend
/// read path is a single array clone, not a full pool walk.
#[derive(Default)]
pub struct TxStore {
records: FxHashMap<TxidPrefix, TxRecord>,
recent: Vec<MempoolRecentTx>,
unresolved: FxHashSet<TxidPrefix>,
live_histogram: Histogram,
}
impl TxStore {
@@ -65,7 +70,7 @@ impl TxStore {
self.records.get(prefix)
}
/// `(prefix, record)` pairs in HashMap iteration order. Used by
/// `(prefix, record)` pairs in `HashMap` iteration order. Used by
/// the snapshot builder to assign a compact `TxIndex` to each
/// live tx in one pass.
pub fn records(&self) -> impl Iterator<Item = (&TxidPrefix, &TxRecord)> {
@@ -86,7 +91,11 @@ impl TxStore {
if tx.input.iter().any(|i| i.prevout.is_none()) {
self.unresolved.insert(prefix);
}
self.records.insert(prefix, TxRecord::new(tx, entry));
let record = TxRecord::new(tx, entry);
for bin in record.output_bins.iter() {
self.live_histogram[bin as usize] += 1;
}
self.records.insert(prefix, record);
}
fn sample_recent(&mut self, txid: &Txid, tx: &Transaction) {
@@ -103,9 +112,18 @@ impl TxStore {
pub fn remove_by_prefix(&mut self, prefix: &TxidPrefix) -> Option<TxRecord> {
let record = self.records.remove(prefix)?;
self.unresolved.remove(prefix);
for bin in record.output_bins.iter() {
self.live_histogram[bin as usize] -= 1;
}
Some(record)
}
/// Snapshot the live oracle-bin histogram. Maintained incrementally
/// on insert/remove, so this is `O(NUM_BINS)`, not `O(live_outputs)`.
pub fn live_histogram(&self) -> Histogram {
self.live_histogram.clone()
}
/// Set of prefixes with at least one unfilled prevout. Used by the
/// prevout filler as a cheap "is there any work?" gate.
pub fn unresolved(&self) -> &FxHashSet<TxidPrefix> {
@@ -115,8 +133,10 @@ impl TxStore {
/// Apply resolved prevouts to a tx in place. `fills` is `(vin, prevout)`.
/// Returns the prevouts actually written (so the caller can fold them
/// into `AddrTracker`). Updates `unresolved` if fully resolved after
/// the fill, and recomputes `total_sigop_cost` (P2SH and witness
/// components depend on prevouts).
/// the fill, and refreshes `total_sigop_cost` (P2SH and witness
/// components depend on prevouts). `entry.vsize` is Core's value from
/// `MempoolEntryInfo` and is not recomputed here - the sigops shift
/// belongs to the `Transaction`, not the entry.
pub fn apply_fills(&mut self, prefix: &TxidPrefix, fills: Vec<(Vin, TxOut)>) -> Vec<TxOut> {
let Some(record) = self.records.get_mut(prefix) else {
return Vec::new();
@@ -125,7 +145,7 @@ impl TxStore {
if applied.is_empty() {
return applied;
}
record.tx.total_sigop_cost = record.tx.total_sigop_cost();
record.tx.refresh_sigops();
if record.tx.input.iter().all(|i| i.prevout.is_some()) {
self.unresolved.remove(prefix);
}
@@ -145,3 +165,181 @@ impl TxStore {
applied
}
}
#[cfg(test)]
mod tests {
use bitcoin::ScriptBuf;
use brk_types::{MempoolEntryInfo, Sats, Timestamp, VSize, Weight};
use super::*;
use crate::test_support::{fake_tx, fake_txid, p2wpkh_script};
fn entry_for(tx: &Transaction, fee: u64, vsize: u64) -> TxEntry {
let info = MempoolEntryInfo {
txid: tx.txid,
vsize: VSize::from(vsize),
weight: Weight::from(VSize::from(vsize)),
fee: Sats::from(fee),
first_seen: Timestamp::from(0u32),
depends: vec![],
};
TxEntry::new(&info, vsize, false)
}
fn tx_without_prevouts(seed: u8) -> Transaction {
fake_tx(seed, &[None, None], &[(p2wpkh_script(1), 1_000)])
}
fn tx_with_prevouts(seed: u8) -> Transaction {
let prev = Some(TxOut::from((p2wpkh_script(2), Sats::from(2_000u64))));
fake_tx(seed, &[prev], &[(p2wpkh_script(3), 500)])
}
#[test]
fn insert_records_unresolved_when_prevouts_missing() {
let mut store = TxStore::default();
let tx = tx_without_prevouts(1);
let entry = entry_for(&tx, 100, 100);
let prefix = entry.txid_prefix();
store.insert(tx, entry);
assert!(store.unresolved().contains(&prefix));
assert_eq!(store.len(), 1);
}
#[test]
fn insert_skips_unresolved_when_all_prevouts_present() {
let mut store = TxStore::default();
let tx = tx_with_prevouts(2);
let entry = entry_for(&tx, 200, 150);
let prefix = entry.txid_prefix();
store.insert(tx, entry);
assert!(!store.unresolved().contains(&prefix));
assert_eq!(store.len(), 1);
}
#[test]
fn remove_by_prefix_clears_unresolved_and_returns_record() {
let mut store = TxStore::default();
let tx = tx_without_prevouts(3);
let entry = entry_for(&tx, 300, 200);
let prefix = entry.txid_prefix();
store.insert(tx, entry);
assert!(store.unresolved().contains(&prefix));
let removed = store.remove_by_prefix(&prefix).expect("record present");
assert_eq!(removed.entry.txid_prefix(), prefix);
assert!(!store.unresolved().contains(&prefix));
assert_eq!(store.len(), 0);
assert!(store.remove_by_prefix(&prefix).is_none());
}
#[test]
fn apply_fills_writes_only_missing_inputs_and_refreshes_sigops() {
let mut store = TxStore::default();
let prev_present = TxOut::from((p2wpkh_script(4), Sats::from(7_000u64)));
let tx = fake_tx(
4,
&[None, Some(prev_present.clone())],
&[(p2wpkh_script(5), 1_000)],
);
let entry = entry_for(&tx, 400, 250);
let prefix = entry.txid_prefix();
store.insert(tx, entry);
assert!(store.unresolved().contains(&prefix));
let new_prevout = TxOut::from((p2wpkh_script(6), Sats::from(9_000u64)));
let overwrite_attempt = TxOut::from((p2wpkh_script(99), Sats::from(1u64)));
let applied = store.apply_fills(
&prefix,
vec![
(Vin::from(0u32), new_prevout.clone()),
(Vin::from(1u32), overwrite_attempt),
],
);
assert_eq!(applied.len(), 1);
assert_eq!(applied[0].value, new_prevout.value);
let record = store.record_by_prefix(&prefix).expect("record present");
assert_eq!(record.tx.input[0].prevout.as_ref().unwrap().value, new_prevout.value);
assert_eq!(
record.tx.input[1].prevout.as_ref().unwrap().value,
prev_present.value
);
assert!(!store.unresolved().contains(&prefix));
}
#[test]
fn apply_fills_unknown_prefix_is_noop() {
let mut store = TxStore::default();
let stray_prefix = TxidPrefix::from(&fake_txid(0xFF));
let applied = store.apply_fills(
&stray_prefix,
vec![(Vin::from(0u32), TxOut::from((ScriptBuf::new(), Sats::from(1u64))))],
);
assert!(applied.is_empty());
}
#[test]
fn apply_fills_partial_keeps_unresolved() {
let mut store = TxStore::default();
let tx = tx_without_prevouts(5);
let entry = entry_for(&tx, 500, 300);
let prefix = entry.txid_prefix();
store.insert(tx, entry);
let one = TxOut::from((p2wpkh_script(7), Sats::from(3_000u64)));
let applied = store.apply_fills(&prefix, vec![(Vin::from(0u32), one)]);
assert_eq!(applied.len(), 1);
assert!(
store.unresolved().contains(&prefix),
"input 1 still has None prevout"
);
}
#[test]
fn recent_is_capped_and_newest_first() {
let mut store = TxStore::default();
for i in 0..(RECENT_CAP as u8 + 5) {
let tx = tx_with_prevouts(i + 10);
let entry = entry_for(&tx, 100, 100);
store.insert(tx, entry);
}
assert_eq!(store.recent().len(), RECENT_CAP);
let newest = store.recent().first().expect("at least one");
let last_inserted_txid = fake_txid(RECENT_CAP as u8 + 5 + 10 - 1);
assert_eq!(newest.txid, last_inserted_txid);
}
#[test]
fn live_histogram_total_tracks_inserts_and_removes() {
let mut store = TxStore::default();
let tx_a = fake_tx(
20,
&[Some(TxOut::from((p2wpkh_script(8), Sats::from(1_234u64))))],
&[
(p2wpkh_script(9), 2_345),
(p2wpkh_script(10), 3_456),
],
);
let tx_b = fake_tx(
21,
&[Some(TxOut::from((p2wpkh_script(11), Sats::from(4_567u64))))],
&[(p2wpkh_script(12), 7_891)],
);
let entry_a = entry_for(&tx_a, 100, 100);
let entry_b = entry_for(&tx_b, 100, 100);
let prefix_a = entry_a.txid_prefix();
store.insert(tx_a, entry_a);
store.insert(tx_b, entry_b);
let total_after_both: u32 = store.live_histogram().iter().sum();
assert_eq!(total_after_both, 3, "two outputs + one output");
store.remove_by_prefix(&prefix_a);
let total_after_remove: u32 = store.live_histogram().iter().sum();
assert_eq!(total_after_remove, 1);
}
}

View File

@@ -0,0 +1,119 @@
//! Tiny tx fixtures shared across the crate's unit tests. Keeps
//! constructor noise out of the test bodies so each test reads as
//! "set up, mutate, assert" without 20 lines of struct literals.
use bitcoin::{ScriptBuf, absolute::LockTime, hashes::Hash, transaction::Version};
use brk_types::{
MempoolEntryInfo, RawLockTime, Sats, SigOps, Timestamp, Transaction, TxIn, TxOut, TxStatus,
TxVersionRaw, Txid, VSize, Vout, Weight, Witness,
};
/// Deterministic `Txid` from a single seed byte. The first byte of the
/// hash is `seed`, the rest is zero, so tests can identify txs by eye
/// in debug output.
pub fn fake_txid(seed: u8) -> Txid {
let mut bytes = [0u8; 32];
bytes[0] = seed;
Txid::from(bitcoin::Txid::from_byte_array(bytes))
}
/// Minimal P2WPKH `script_pubkey` keyed off `seed` so distinct inputs
/// or outputs in the same test don't collide on `addr_bytes()`.
pub fn p2wpkh_script(seed: u8) -> ScriptBuf {
let mut bytes = [0u8; 20];
bytes[0] = seed;
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array(bytes))
}
/// Build a `Transaction` with one input per entry in `prevouts` (each
/// either resolved or `None`) and one output per `(script, value)`
/// pair. Counterparty txids on the inputs are derived from the seed so
/// `addr_bytes` extraction sees distinct prev txids per input.
pub fn fake_tx(seed: u8, prevouts: &[Option<TxOut>], outputs: &[(ScriptBuf, u64)]) -> Transaction {
let input = prevouts
.iter()
.enumerate()
.map(|(i, p)| TxIn {
is_coinbase: false,
prevout: p.clone(),
txid: fake_txid(seed.wrapping_add(100 + i as u8)),
vout: Vout::ZERO,
script_sig: ScriptBuf::new(),
script_sig_asm: (),
witness: Witness::default(),
sequence: 0xffff_fffe,
inner_redeem_script_asm: (),
inner_witness_script_asm: (),
})
.collect();
let output = outputs
.iter()
.map(|(script, value)| TxOut::from((script.clone(), Sats::from(*value))))
.collect();
let mut tx = Transaction {
index: None,
txid: fake_txid(seed),
version: TxVersionRaw::from(Version::TWO),
lock_time: RawLockTime::from(LockTime::ZERO),
input,
output,
total_size: 200,
weight: Weight::from(800u64),
total_sigop_cost: SigOps::ZERO,
fee: Sats::ZERO,
status: TxStatus::UNCONFIRMED,
};
tx.refresh_sigops();
tx
}
/// Plain `MempoolEntryInfo` keyed off `txid`. Test bodies usually
/// already have the txid from `fake_tx`, so this just fills in the
/// non-essential fields with deterministic placeholders.
pub fn fake_entry_info(txid: Txid, fee: u64, vsize: u64) -> MempoolEntryInfo {
MempoolEntryInfo {
txid,
vsize: VSize::from(vsize),
weight: Weight::from(vsize * 4),
fee: Sats::from(fee),
first_seen: Timestamp::from(0u32),
depends: vec![],
}
}
/// Bitcoin-protocol `Transaction` matching `fake_tx`. Round-trippable
/// against a brk `Transaction`, lets the Preparer's `Fresh` path decode
/// it without a real RPC payload.
pub fn fake_bitcoin_tx(
prev_txid_seed: u8,
outputs: &[(ScriptBuf, u64)],
) -> bitcoin::Transaction {
let input = vec![bitcoin::TxIn {
previous_output: bitcoin::OutPoint {
txid: bitcoin::Txid::from_byte_array({
let mut b = [0u8; 32];
b[0] = prev_txid_seed;
b
}),
vout: 0,
},
script_sig: ScriptBuf::new(),
sequence: bitcoin::Sequence(0xffff_fffe),
witness: bitcoin::Witness::new(),
}];
let output = outputs
.iter()
.map(|(script, value)| bitcoin::TxOut {
value: bitcoin::Amount::from_sat(*value),
script_pubkey: script.clone(),
})
.collect();
bitcoin::Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input,
output,
}
}