From 68db22b9e8d9c66249586169c77923dd592721e9 Mon Sep 17 00:00:00 2001 From: nym21 Date: Thu, 14 May 2026 23:29:10 +0200 Subject: [PATCH] mempool: polish/cleanup --- Cargo.lock | 1 - crates/brk_mempool/Cargo.toml | 1 - crates/brk_mempool/examples/mempool.rs | 2 +- crates/brk_mempool/src/api/addr.rs | 45 ++ crates/brk_mempool/src/api/block_template.rs | 199 ++++++++ crates/brk_mempool/src/api/fees.rs | 38 ++ crates/brk_mempool/src/api/histogram.rs | 23 + crates/brk_mempool/src/api/mod.rs | 11 + crates/brk_mempool/src/api/rbf.rs | 214 ++++++++ crates/brk_mempool/src/api/tx.rs | 68 +++ crates/brk_mempool/src/cluster.rs | 138 ------ crates/brk_mempool/src/cpfp.rs | 159 ------ crates/brk_mempool/src/cycle.rs | 57 --- crates/brk_mempool/src/cycle/added_kind.rs | 7 + .../brk_mempool/src/cycle/addr_transitions.rs | 104 ++++ crates/brk_mempool/src/cycle/diff.rs | 10 + crates/brk_mempool/src/cycle/event.rs | 30 ++ crates/brk_mempool/src/cycle/mod.rs | 15 + crates/brk_mempool/src/cycle/tx_added.rs | 13 + crates/brk_mempool/src/cycle/tx_removed.rs | 13 + crates/brk_mempool/src/cycle_diff.rs | 11 - crates/brk_mempool/src/driver.rs | 194 ++++++++ crates/brk_mempool/src/lib.rs | 459 ++++-------------- crates/brk_mempool/src/rbf.rs | 102 ---- .../stats.rs => snapshot/block_stats.rs} | 2 +- crates/brk_mempool/src/snapshot/builder.rs | 233 +++++++++ crates/brk_mempool/src/snapshot/cluster.rs | 133 +++++ crates/brk_mempool/src/snapshot/cpfp.rs | 248 ++++++++++ .../{steps/rebuilder => }/snapshot/fees.rs | 78 ++- crates/brk_mempool/src/snapshot/mod.rs | 215 ++++++++ crates/brk_mempool/src/snapshot/partition.rs | 142 ++++++ .../mod.rs => snapshot/rebuilder.rs} | 44 +- .../snapshot/tx.rs => snapshot/snap_tx.rs} | 4 +- .../rebuilder => }/snapshot/tx_index.rs | 0 .../src/{state.rs => state/mod.rs} | 21 +- .../src/{steps/preparer => state}/tx_entry.rs | 7 +- crates/brk_mempool/src/steps/applier.rs | 186 ++++++- .../brk_mempool/src/steps/fetcher/fetched.rs | 2 +- crates/brk_mempool/src/steps/fetcher/mod.rs | 11 +- crates/brk_mempool/src/steps/mod.rs | 15 +- crates/brk_mempool/src/steps/preparer/mod.rs | 227 ++++++++- .../src/steps/preparer/tx_addition.rs | 10 +- .../src/steps/preparer/tx_removal.rs | 2 +- crates/brk_mempool/src/steps/prevouts.rs | 14 +- .../src/steps/rebuilder/partition.rs | 71 --- .../src/steps/rebuilder/snapshot/builder.rs | 221 --------- .../src/steps/rebuilder/snapshot/mod.rs | 105 ---- .../stores/addr_tracker/addr_transitions.rs | 41 -- .../src/stores/addr_tracker/mod.rs | 150 +++++- crates/brk_mempool/src/stores/mod.rs | 20 +- .../brk_mempool/src/stores/outpoint_spends.rs | 25 +- .../src/stores/tx_graveyard/mod.rs | 176 ++++++- .../src/stores/tx_graveyard/tombstone.rs | 4 +- crates/brk_mempool/src/stores/tx_store.rs | 210 +++++++- crates/brk_mempool/src/test_support.rs | 119 +++++ 55 files changed, 3240 insertions(+), 1410 deletions(-) create mode 100644 crates/brk_mempool/src/api/addr.rs create mode 100644 crates/brk_mempool/src/api/block_template.rs create mode 100644 crates/brk_mempool/src/api/fees.rs create mode 100644 crates/brk_mempool/src/api/histogram.rs create mode 100644 crates/brk_mempool/src/api/mod.rs create mode 100644 crates/brk_mempool/src/api/rbf.rs create mode 100644 crates/brk_mempool/src/api/tx.rs delete mode 100644 crates/brk_mempool/src/cluster.rs delete mode 100644 crates/brk_mempool/src/cpfp.rs delete mode 100644 crates/brk_mempool/src/cycle.rs create mode 100644 crates/brk_mempool/src/cycle/added_kind.rs create mode 100644 crates/brk_mempool/src/cycle/addr_transitions.rs create mode 100644 crates/brk_mempool/src/cycle/diff.rs create mode 100644 crates/brk_mempool/src/cycle/event.rs create mode 100644 crates/brk_mempool/src/cycle/mod.rs create mode 100644 crates/brk_mempool/src/cycle/tx_added.rs create mode 100644 crates/brk_mempool/src/cycle/tx_removed.rs delete mode 100644 crates/brk_mempool/src/cycle_diff.rs create mode 100644 crates/brk_mempool/src/driver.rs delete mode 100644 crates/brk_mempool/src/rbf.rs rename crates/brk_mempool/src/{steps/rebuilder/snapshot/stats.rs => snapshot/block_stats.rs} (98%) create mode 100644 crates/brk_mempool/src/snapshot/builder.rs create mode 100644 crates/brk_mempool/src/snapshot/cluster.rs create mode 100644 crates/brk_mempool/src/snapshot/cpfp.rs rename crates/brk_mempool/src/{steps/rebuilder => }/snapshot/fees.rs (56%) create mode 100644 crates/brk_mempool/src/snapshot/mod.rs create mode 100644 crates/brk_mempool/src/snapshot/partition.rs rename crates/brk_mempool/src/{steps/rebuilder/mod.rs => snapshot/rebuilder.rs} (72%) rename crates/brk_mempool/src/{steps/rebuilder/snapshot/tx.rs => snapshot/snap_tx.rs} (87%) rename crates/brk_mempool/src/{steps/rebuilder => }/snapshot/tx_index.rs (100%) rename crates/brk_mempool/src/{state.rs => state/mod.rs} (51%) rename crates/brk_mempool/src/{steps/preparer => state}/tx_entry.rs (80%) delete mode 100644 crates/brk_mempool/src/steps/rebuilder/partition.rs delete mode 100644 crates/brk_mempool/src/steps/rebuilder/snapshot/builder.rs delete mode 100644 crates/brk_mempool/src/steps/rebuilder/snapshot/mod.rs delete mode 100644 crates/brk_mempool/src/stores/addr_tracker/addr_transitions.rs create mode 100644 crates/brk_mempool/src/test_support.rs diff --git a/Cargo.lock b/Cargo.lock index 3f374b02e..c19e362fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -532,7 +532,6 @@ dependencies = [ "brk_oracle", "brk_rpc", "brk_types", - "derive_more", "parking_lot", "rustc-hash", "smallvec", diff --git a/crates/brk_mempool/Cargo.toml b/crates/brk_mempool/Cargo.toml index ad72c0c80..860b3512a 100644 --- a/crates/brk_mempool/Cargo.toml +++ b/crates/brk_mempool/Cargo.toml @@ -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 } diff --git a/crates/brk_mempool/examples/mempool.rs b/crates/brk_mempool/examples/mempool.rs index 7a5e9bbd8..934ca4d25 100644 --- a/crates/brk_mempool/examples/mempool.rs +++ b/crates/brk_mempool/examples/mempool.rs @@ -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={} \ diff --git a/crates/brk_mempool/src/api/addr.rs b/crates/brk_mempool/src/api/addr.rs new file mode 100644 index 000000000..6a83c58be --- /dev/null +++ b/crates/brk_mempool/src/api/addr.rs @@ -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 { + 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 { + 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 { + 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() + } +} diff --git a/crates/brk_mempool/src/api/block_template.rs b/crates/brk_mempool/src/api/block_template.rs new file mode 100644 index 000000000..495a47eae --- /dev/null +++ b/crates/brk_mempool/src/api/block_template.rs @@ -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 { + let past = self.rebuilder().historical_block0(since)?; + let prior_index: FxHashMap = 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 = 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) -> Vec { + 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 { + 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()); + } +} diff --git a/crates/brk_mempool/src/api/fees.rs b/crates/brk_mempool/src/api/fees.rs new file mode 100644 index 000000000..1266e3575 --- /dev/null +++ b/crates/brk_mempool/src/api/fees.rs @@ -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 { + 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 { + 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 { + self.read().graveyard.get(txid).map(|tomb| tomb.chunk_rate) + } +} diff --git a/crates/brk_mempool/src/api/histogram.rs b/crates/brk_mempool/src/api/histogram.rs new file mode 100644 index 000000000..a20d7f676 --- /dev/null +++ b/crates/brk_mempool/src/api/histogram.rs @@ -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() + } +} diff --git a/crates/brk_mempool/src/api/mod.rs b/crates/brk_mempool/src/api/mod.rs new file mode 100644 index 000000000..35be24593 --- /dev/null +++ b/crates/brk_mempool/src/api/mod.rs @@ -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}; diff --git a/crates/brk_mempool/src/api/rbf.rs b/crates/brk_mempool/src/api/rbf.rs new file mode 100644 index 000000000..00031e371 --- /dev/null +++ b/crates/brk_mempool/src/api/rbf.rs @@ -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, +} + +#[derive(Debug, Clone, Default)] +pub struct RbfForTx { + /// Tree rooted at the terminal replacer. `None` if `txid` is unknown. + pub root: Option, + /// Direct predecessors of the requested tx (txids only). + pub replaces: Vec, +} + +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 = 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 { + let state = self.read(); + + let mut seen: FxHashSet = 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 { + let (tx, entry) = Self::resolve_rbf_node(txid, txs, graveyard)?; + + let replaces: Vec = 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) { + 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 = 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"); + } +} diff --git a/crates/brk_mempool/src/api/tx.rs b/crates/brk_mempool/src/api/tx.rs new file mode 100644 index 000000000..e3924aab1 --- /dev/null +++ b/crates/brk_mempool/src/api/tx.rs @@ -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(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option { + 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(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option { + 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 { + self.read().txs.txids().copied().collect() + } + + /// Snapshot of recent live txs. + #[must_use] + pub fn recent_txs(&self) -> Vec { + 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 { + let state = self.read(); + txids + .iter() + .map(|txid| state.first_seen(txid).map_or(0, u64::from)) + .collect() + } +} diff --git a/crates/brk_mempool/src/cluster.rs b/crates/brk_mempool/src/cluster.rs deleted file mode 100644 index 131b75ea2..000000000 --- a/crates/brk_mempool/src/cluster.rs +++ /dev/null @@ -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 { - if txs.get(seed.as_usize()).is_none() { - return Vec::new(); - } - let mut visited: FxHashSet = - FxHashSet::with_capacity_and_hasher(MAX_CLUSTER, FxBuildHasher); - visited.insert(seed); - let mut out: Vec = Vec::with_capacity(MAX_CLUSTER); - out.push(seed); - let mut stack: Vec = 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, Vec) { - let members = topo_sort(txs, component); - let local_of = build_local_index(&members); - let parents_local: Vec> = members - .iter() - .map(|idx| { - txs[idx.as_usize()] - .parents - .iter() - .filter_map(|p| local_of.get(p).copied()) - .collect() - }) - .collect(); - let inputs: Vec> = 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 { - let n = component.len(); - let pos: FxHashMap = component - .iter() - .enumerate() - .map(|(i, &x)| (x, i)) - .collect(); - let mut indeg: Vec = vec![0; n]; - let mut children: Vec> = 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 = (0..n).filter(|&i| indeg[i] == 0).collect(); - let mut out: Vec = 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 { - members - .iter() - .enumerate() - .map(|(i, &idx)| (idx, CpfpClusterTxIndex::from(i as u32))) - .collect() -} diff --git a/crates/brk_mempool/src/cpfp.rs b/crates/brk_mempool/src/cpfp.rs deleted file mode 100644 index 7505a558b..000000000 --- a/crates/brk_mempool/src/cpfp.rs +++ /dev/null @@ -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 { - 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 { - let Some(seed_node) = txs.get(seed.as_usize()) else { - return Vec::new(); - }; - let mut visited: FxHashSet = - FxHashSet::with_capacity_and_hasher(CPFP_CHAIN_LIMIT + 1, FxBuildHasher); - visited.insert(seed); - let mut out: Vec = Vec::with_capacity(CPFP_CHAIN_LIMIT); - let mut stack: Vec = 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, 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 { - 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() -} - diff --git a/crates/brk_mempool/src/cycle.rs b/crates/brk_mempool/src/cycle.rs deleted file mode 100644 index 9a660eaca..000000000 --- a/crates/brk_mempool/src/cycle.rs +++ /dev/null @@ -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, - pub removed: Vec, - /// 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, - /// Addresses that went from 1+ → 0 live mempool txs this cycle. - pub addr_leaves: Vec, - /// 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, - 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, -} diff --git a/crates/brk_mempool/src/cycle/added_kind.rs b/crates/brk_mempool/src/cycle/added_kind.rs new file mode 100644 index 000000000..7aced0c1a --- /dev/null +++ b/crates/brk_mempool/src/cycle/added_kind.rs @@ -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, +} diff --git a/crates/brk_mempool/src/cycle/addr_transitions.rs b/crates/brk_mempool/src/cycle/addr_transitions.rs new file mode 100644 index 000000000..edd6a2aa2 --- /dev/null +++ b/crates/brk_mempool/src/cycle/addr_transitions.rs @@ -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, + leaves: FxHashSet, +} + +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, Vec) { + ( + 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]); + } +} diff --git a/crates/brk_mempool/src/cycle/diff.rs b/crates/brk_mempool/src/cycle/diff.rs new file mode 100644 index 000000000..a14bce510 --- /dev/null +++ b/crates/brk_mempool/src/cycle/diff.rs @@ -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, + pub removed: Vec, + pub addrs: AddrTransitions, +} diff --git a/crates/brk_mempool/src/cycle/event.rs b/crates/brk_mempool/src/cycle/event.rs new file mode 100644 index 000000000..d6653a3fc --- /dev/null +++ b/crates/brk_mempool/src/cycle/event.rs @@ -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, + pub removed: Vec, + /// 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, + /// Addresses that went from 1+ → 0 live mempool txs this cycle. + pub addr_leaves: Vec, + /// 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, + pub took: Duration, +} diff --git a/crates/brk_mempool/src/cycle/mod.rs b/crates/brk_mempool/src/cycle/mod.rs new file mode 100644 index 000000000..058e380a9 --- /dev/null +++ b/crates/brk_mempool/src/cycle/mod.rs @@ -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; diff --git a/crates/brk_mempool/src/cycle/tx_added.rs b/crates/brk_mempool/src/cycle/tx_added.rs new file mode 100644 index 000000000..d72f5d659 --- /dev/null +++ b/crates/brk_mempool/src/cycle/tx_added.rs @@ -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, +} diff --git a/crates/brk_mempool/src/cycle/tx_removed.rs b/crates/brk_mempool/src/cycle/tx_removed.rs new file mode 100644 index 000000000..74ffb1059 --- /dev/null +++ b/crates/brk_mempool/src/cycle/tx_removed.rs @@ -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, +} diff --git a/crates/brk_mempool/src/cycle_diff.rs b/crates/brk_mempool/src/cycle_diff.rs deleted file mode 100644 index e922f79e5..000000000 --- a/crates/brk_mempool/src/cycle_diff.rs +++ /dev/null @@ -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, - pub removed: Vec, - pub addrs: AddrTransitions, -} diff --git a/crates/brk_mempool/src/driver.rs b/crates/brk_mempool/src/driver.rs new file mode 100644 index 000000000..cbae8bce5 --- /dev/null +++ b/crates/brk_mempool/src/driver.rs @@ -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(&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 { + 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(&self, resolver: F) -> Result + 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::().map(String::as_str)) + .unwrap_or("") + } +} + +#[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()), ""); + } +} diff --git a/crates/brk_mempool/src/lib.rs b/crates/brk_mempool/src/lib.rs index ce861b8cd..6f0eb1dc2 100644 --- a/crates/brk_mempool/src/lib.rs +++ b/crates/brk_mempool/src/lib.rs @@ -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`): 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 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 { 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 { - 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 { - let past = self.0.rebuilder.historical_block0(since)?; - let prior_index: FxHashMap = 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 = 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) -> Vec { - 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 { - 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(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option { - 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(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option { - self.read().graveyard.get_vanished(txid).map(|t| f(&t.tx)) - } - - /// Snapshot of all live mempool txids. - pub fn txids(&self) -> Vec { - self.read().txs.txids().copied().collect() - } - - /// Snapshot of recent live txs. - pub fn recent_txs(&self) -> Vec { - 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 { - 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 { - 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 { - 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 { - 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 { - 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(&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 { - 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(&self, resolver: F) -> Result - 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::().map(String::as_str)) - .unwrap_or("") +#[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 { + &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); + } + } } diff --git a/crates/brk_mempool/src/rbf.rs b/crates/brk_mempool/src/rbf.rs deleted file mode 100644 index 57cc36ae4..000000000 --- a/crates/brk_mempool/src/rbf.rs +++ /dev/null @@ -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, -} - -#[derive(Debug, Clone, Default)] -pub struct RbfForTx { - /// Tree rooted at the terminal replacer. `None` if `txid` is unknown. - pub root: Option, - /// Direct predecessors of the requested tx (txids only). - pub replaces: Vec, -} - -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 = 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 { - let state = self.read(); - - let mut seen: FxHashSet = 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 { - let (tx, entry) = resolve_node(txid, txs, graveyard)?; - - let replaces: Vec = 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))) -} diff --git a/crates/brk_mempool/src/steps/rebuilder/snapshot/stats.rs b/crates/brk_mempool/src/snapshot/block_stats.rs similarity index 98% rename from crates/brk_mempool/src/steps/rebuilder/snapshot/stats.rs rename to crates/brk_mempool/src/snapshot/block_stats.rs index 4821db98f..9644e86b0 100644 --- a/crates/brk_mempool/src/steps/rebuilder/snapshot/stats.rs +++ b/crates/brk_mempool/src/snapshot/block_stats.rs @@ -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], txs: &[SnapTx]) -> Vec { blocks diff --git a/crates/brk_mempool/src/snapshot/builder.rs b/crates/brk_mempool/src/snapshot/builder.rs new file mode 100644 index 000000000..4ba9c4af2 --- /dev/null +++ b/crates/brk_mempool/src/snapshot/builder.rs @@ -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; + +impl Snapshot { + pub fn build_txs(txs: &TxStore) -> (Vec, 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 = 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 = (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 = a.iter().map(|t| f64::from(t.chunk_rate)).collect(); + let mut rates_b: Vec = 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); + } +} diff --git a/crates/brk_mempool/src/snapshot/cluster.rs b/crates/brk_mempool/src/snapshot/cluster.rs new file mode 100644 index 000000000..1d0fb9ed1 --- /dev/null +++ b/crates/brk_mempool/src/snapshot/cluster.rs @@ -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 { + if txs.get(seed.as_usize()).is_none() { + return Vec::new(); + } + let mut visited: FxHashSet = + FxHashSet::with_capacity_and_hasher(MAX_CLUSTER, FxBuildHasher); + visited.insert(seed); + let mut out: Vec = Vec::with_capacity(MAX_CLUSTER); + out.push(seed); + let mut stack: Vec = 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, Vec) { + let members = Self::topo_sort(txs, component); + let local_of = Self::local_index(&members); + let parents_local: Vec> = members + .iter() + .map(|idx| { + txs[idx.as_usize()] + .parents + .iter() + .filter_map(|p| local_of.get(p).copied()) + .collect() + }) + .collect(); + let inputs: Vec> = 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 { + 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 { + let n = component.len(); + let pos: FxHashMap = component + .iter() + .enumerate() + .map(|(i, &x)| (x, i)) + .collect(); + let mut indeg: Vec = vec![0; n]; + let mut children: Vec> = 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 = (0..n).filter(|&i| indeg[i] == 0).collect(); + let mut out: Vec = 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 + } +} diff --git a/crates/brk_mempool/src/snapshot/cpfp.rs b/crates/brk_mempool/src/snapshot/cpfp.rs new file mode 100644 index 000000000..eeec41af0 --- /dev/null +++ b/crates/brk_mempool/src/snapshot/cpfp.rs @@ -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 { + 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 { + let Some(seed_node) = txs.get(seed.as_usize()) else { + return Vec::new(); + }; + let mut visited: FxHashSet = + FxHashSet::with_capacity_and_hasher(CPFP_CHAIN_LIMIT + 1, FxBuildHasher); + visited.insert(seed); + let mut out: Vec = Vec::with_capacity(CPFP_CHAIN_LIMIT); + let mut stack: Vec = 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, 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 { + 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()); + } +} diff --git a/crates/brk_mempool/src/steps/rebuilder/snapshot/fees.rs b/crates/brk_mempool/src/snapshot/fees.rs similarity index 56% rename from crates/brk_mempool/src/steps/rebuilder/snapshot/fees.rs rename to crates/brk_mempool/src/snapshot/fees.rs index 7be8b5533..c6c0fe021 100644 --- a/crates/brk_mempool/src/steps/rebuilder/snapshot/fees.rs +++ b/crates/brk_mempool/src/snapshot/fees.rs @@ -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)); + } +} diff --git a/crates/brk_mempool/src/snapshot/mod.rs b/crates/brk_mempool/src/snapshot/mod.rs new file mode 100644 index 000000000..8071ec40a --- /dev/null +++ b/crates/brk_mempool/src/snapshot/mod.rs @@ -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, + /// 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>, + pub block_stats: Vec, + 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, + blocks: Vec>, + 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], 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 { + 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 + '_ { + 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 { + 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![]; + assert_eq!(Snapshot::hash_next_block(&blocks, &txs), NextBlockHash::ZERO); + } +} diff --git a/crates/brk_mempool/src/snapshot/partition.rs b/crates/brk_mempool/src/snapshot/partition.rs new file mode 100644 index 000000000..b3fd8c2af --- /dev/null +++ b/crates/brk_mempool/src/snapshot/partition.rs @@ -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, + num_remaining_blocks: usize, + ) -> Vec> { + if num_remaining_blocks == 0 { + return Vec::new(); + } + let sorted = Self::sorted_candidates(txs, excluded); + let mut blocks: Vec> = (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, + ) -> 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 = 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 = blocks[0].iter().map(|i| txs[i.as_usize()].txid[0]).collect(); + assert_eq!(txids, vec![0x10, 0x20, 0x30]); + } +} diff --git a/crates/brk_mempool/src/steps/rebuilder/mod.rs b/crates/brk_mempool/src/snapshot/rebuilder.rs similarity index 72% rename from crates/brk_mempool/src/steps/rebuilder/mod.rs rename to crates/brk_mempool/src/snapshot/rebuilder.rs index e6439a655..4450c7298 100644 --- a/crates/brk_mempool/src/steps/rebuilder/mod.rs +++ b/crates/brk_mempool/src/snapshot/rebuilder.rs @@ -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, gbt_txids: &[Txid], min_fee: FeeRate) { let snap = Self::build_snapshot(lock, gbt_txids, min_fee); let block0: Vec = 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 = gbt_txids .iter() .filter_map(|txid| prefix_to_idx.get(&TxidPrefix::from(txid)).copied()) diff --git a/crates/brk_mempool/src/steps/rebuilder/snapshot/tx.rs b/crates/brk_mempool/src/snapshot/snap_tx.rs similarity index 87% rename from crates/brk_mempool/src/steps/rebuilder/snapshot/tx.rs rename to crates/brk_mempool/src/snapshot/snap_tx.rs index e3b4c7d23..5052c585b 100644 --- a/crates/brk_mempool/src/steps/rebuilder/snapshot/tx.rs +++ b/crates/brk_mempool/src/snapshot/snap_tx.rs @@ -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]>, } diff --git a/crates/brk_mempool/src/steps/rebuilder/snapshot/tx_index.rs b/crates/brk_mempool/src/snapshot/tx_index.rs similarity index 100% rename from crates/brk_mempool/src/steps/rebuilder/snapshot/tx_index.rs rename to crates/brk_mempool/src/snapshot/tx_index.rs diff --git a/crates/brk_mempool/src/state.rs b/crates/brk_mempool/src/state/mod.rs similarity index 51% rename from crates/brk_mempool/src/state.rs rename to crates/brk_mempool/src/state/mod.rs index b2bcf1018..25ebeb1c2 100644 --- a/crates/brk_mempool/src/state.rs +++ b/crates/brk_mempool/src/state/mod.rs @@ -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 { if let Some(e) = self.txs.entry(txid) { diff --git a/crates/brk_mempool/src/steps/preparer/tx_entry.rs b/crates/brk_mempool/src/state/tx_entry.rs similarity index 80% rename from crates/brk_mempool/src/steps/preparer/tx_entry.rs rename to crates/brk_mempool/src/state/tx_entry.rs index 49c9ac935..284164735 100644 --- a/crates/brk_mempool/src/steps/preparer/tx_entry.rs +++ b/crates/brk_mempool/src/state/tx_entry.rs @@ -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, diff --git a/crates/brk_mempool/src/steps/applier.rs b/crates/brk_mempool/src/steps/applier.rs index 3fa498193..1aef84280 100644 --- a/crates/brk_mempool/src/steps/applier.rs +++ b/crates/brk_mempool/src/steps/applier.rs @@ -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, - 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, 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 { 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); + } +} diff --git a/crates/brk_mempool/src/steps/fetcher/fetched.rs b/crates/brk_mempool/src/steps/fetcher/fetched.rs index 8c1a78060..3954de091 100644 --- a/crates/brk_mempool/src/steps/fetcher/fetched.rs +++ b/crates/brk_mempool/src/steps/fetcher/fetched.rs @@ -14,7 +14,7 @@ pub struct Fetched { pub new_txs: FxHashMap, /// 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, } diff --git a/crates/brk_mempool/src/steps/fetcher/mod.rs b/crates/brk_mempool/src/steps/fetcher/mod.rs index f45820b88..661fb2976 100644 --- a/crates/brk_mempool/src/steps/fetcher/mod.rs +++ b/crates/brk_mempool/src/steps/fetcher/mod.rs @@ -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) }; diff --git a/crates/brk_mempool/src/steps/mod.rs b/crates/brk_mempool/src/steps/mod.rs index 4fdfe5c71..d0bdf7fa1 100644 --- a/crates/brk_mempool/src/steps/mod.rs +++ b/crates/brk_mempool/src/steps/mod.rs @@ -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; diff --git a/crates/brk_mempool/src/steps/preparer/mod.rs b/crates/brk_mempool/src/steps/preparer/mod.rs index 7e2207c93..c281fe076 100644 --- a/crates/brk_mempool/src/steps/preparer/mod.rs +++ b/crates/brk_mempool/src/steps/preparer/mod.rs @@ -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, 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 { + RwLock::new(State::default()) + } + + fn seed_known(state: &RwLock, 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, 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 = 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 = 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 = 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 = 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()); + } +} diff --git a/crates/brk_mempool/src/steps/preparer/tx_addition.rs b/crates/brk_mempool/src/steps/preparer/tx_addition.rs index 8957db248..643e14d7e 100644 --- a/crates/brk_mempool/src/steps/preparer/tx_addition.rs +++ b/crates/brk_mempool/src/steps/preparer/tx_addition.rs @@ -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 } diff --git a/crates/brk_mempool/src/steps/preparer/tx_removal.rs b/crates/brk_mempool/src/steps/preparer/tx_removal.rs index 4242fc878..8dd6569e1 100644 --- a/crates/brk_mempool/src/steps/preparer/tx_removal.rs +++ b/crates/brk_mempool/src/steps/preparer/tx_removal.rs @@ -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 diff --git a/crates/brk_mempool/src/steps/prevouts.rs b/crates/brk_mempool/src/steps/prevouts.rs index 2569677ff..ed63ca753 100644 --- a/crates/brk_mempool/src/steps/prevouts.rs +++ b/crates/brk_mempool/src/steps/prevouts.rs @@ -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(lock: &RwLock, 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(lock: &RwLock, 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, diff --git a/crates/brk_mempool/src/steps/rebuilder/partition.rs b/crates/brk_mempool/src/steps/rebuilder/partition.rs deleted file mode 100644 index 135ebaf87..000000000 --- a/crates/brk_mempool/src/steps/rebuilder/partition.rs +++ /dev/null @@ -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, - num_remaining_blocks: usize, - ) -> Vec> { - if num_remaining_blocks == 0 { - return Vec::new(); - } - let sorted = sorted_candidates(txs, excluded); - let mut blocks: Vec> = (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, -) -> 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 -} diff --git a/crates/brk_mempool/src/steps/rebuilder/snapshot/builder.rs b/crates/brk_mempool/src/steps/rebuilder/snapshot/builder.rs deleted file mode 100644 index cd8948e0c..000000000 --- a/crates/brk_mempool/src/steps/rebuilder/snapshot/builder.rs +++ /dev/null @@ -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; - -pub fn build_txs(txs: &TxStore) -> (Vec, 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 = 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 = (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); - } -} diff --git a/crates/brk_mempool/src/steps/rebuilder/snapshot/mod.rs b/crates/brk_mempool/src/steps/rebuilder/snapshot/mod.rs deleted file mode 100644 index be4d26699..000000000 --- a/crates/brk_mempool/src/steps/rebuilder/snapshot/mod.rs +++ /dev/null @@ -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, - /// 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>, - pub block_stats: Vec, - 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, - blocks: Vec>, - 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], 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 { - 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 + '_ { - 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 { - let idx = self.idx_of(prefix)?; - Some(self.txs[idx.as_usize()].chunk_rate) - } -} diff --git a/crates/brk_mempool/src/stores/addr_tracker/addr_transitions.rs b/crates/brk_mempool/src/stores/addr_tracker/addr_transitions.rs deleted file mode 100644 index 5a7aaa626..000000000 --- a/crates/brk_mempool/src/stores/addr_tracker/addr_transitions.rs +++ /dev/null @@ -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, - leaves: FxHashSet, -} - -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, Vec) { - ( - self.enters.into_iter().collect(), - self.leaves.into_iter().collect(), - ) - } -} diff --git a/crates/brk_mempool/src/stores/addr_tracker/mod.rs b/crates/brk_mempool/src/stores/addr_tracker/mod.rs index 14d4b2a76..f46cad02b 100644 --- a/crates/brk_mempool/src/stores/addr_tracker/mod.rs +++ b/crates/brk_mempool/src/stores/addr_tracker/mod.rs @@ -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); 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 { + 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"); + } +} diff --git a/crates/brk_mempool/src/stores/mod.rs b/crates/brk_mempool/src/stores/mod.rs index a5ece6154..74d31a8fa 100644 --- a/crates/brk_mempool/src/stores/mod.rs +++ b/crates/brk_mempool/src/stores/mod.rs @@ -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; diff --git a/crates/brk_mempool/src/stores/outpoint_spends.rs b/crates/brk_mempool/src/stores/outpoint_spends.rs index 3707989df..c19085ec2 100644 --- a/crates/brk_mempool/src/stores/outpoint_spends.rs +++ b/crates/brk_mempool/src/stores/outpoint_spends.rs @@ -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); 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 { self.0.get(key).copied() } -} -fn spent_outpoints(tx: &Transaction) -> impl Iterator + '_ { - 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 + '_ { + tx.input + .iter() + .filter(|i| !i.is_coinbase) + .map(|i| OutpointPrefix::new(TxidPrefix::from(&i.txid), i.vout)) + } } diff --git a/crates/brk_mempool/src/stores/tx_graveyard/mod.rs b/crates/brk_mempool/src/stores/tx_graveyard/mod.rs index 3fa858b6e..5c91e581b 100644 --- a/crates/brk_mempool/src/stores/tx_graveyard/mod.rs +++ b/crates/brk_mempool/src/stores/tx_graveyard/mod.rs @@ -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 { 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 = 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()); + } } diff --git a/crates/brk_mempool/src/stores/tx_graveyard/tombstone.rs b/crates/brk_mempool/src/stores/tx_graveyard/tombstone.rs index fefc26921..95345862c 100644 --- a/crates/brk_mempool/src/stores/tx_graveyard/tombstone.rs +++ b/crates/brk_mempool/src/stores/tx_graveyard/tombstone.rs @@ -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, diff --git a/crates/brk_mempool/src/stores/tx_store.rs b/crates/brk_mempool/src/stores/tx_store.rs index be99b8359..acaab2d80 100644 --- a/crates/brk_mempool/src/stores/tx_store.rs +++ b/crates/brk_mempool/src/stores/tx_store.rs @@ -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, recent: Vec, unresolved: FxHashSet, + 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 { @@ -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 { 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 { @@ -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 { 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); + } +} diff --git a/crates/brk_mempool/src/test_support.rs b/crates/brk_mempool/src/test_support.rs new file mode 100644 index 000000000..fd7699417 --- /dev/null +++ b/crates/brk_mempool/src/test_support.rs @@ -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], 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, + } +}