mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-19 06:14:47 -07:00
mempool: polish/cleanup
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -532,7 +532,6 @@ dependencies = [
|
||||
"brk_oracle",
|
||||
"brk_rpc",
|
||||
"brk_types",
|
||||
"derive_more",
|
||||
"parking_lot",
|
||||
"rustc-hash",
|
||||
"smallvec",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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={} \
|
||||
|
||||
45
crates/brk_mempool/src/api/addr.rs
Normal file
45
crates/brk_mempool/src/api/addr.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! Address-keyed reads.
|
||||
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use brk_types::{AddrBytes, AddrMempoolStats, Timestamp, Transaction, TxidPrefix};
|
||||
|
||||
use crate::Mempool;
|
||||
|
||||
impl Mempool {
|
||||
/// Hash of the address's mempool state, `None` if the address has
|
||||
/// no live mempool activity. Used as an `ETag` for address-keyed
|
||||
/// mempool responses. Route handlers may fall back to a 0 sentinel.
|
||||
pub fn addr_state_hash(&self, addr: &AddrBytes) -> Option<u64> {
|
||||
self.read().addrs.stats_hash(addr)
|
||||
}
|
||||
|
||||
/// Per-address mempool stats. `None` if the address has no live mempool activity.
|
||||
pub fn addr_stats(&self, addr: &AddrBytes) -> Option<AddrMempoolStats> {
|
||||
self.read().addrs.get(addr).map(|e| e.stats.clone())
|
||||
}
|
||||
|
||||
/// Live mempool txs touching `addr`, newest first by `first_seen`,
|
||||
/// capped at `limit`. Returns owned `Transaction`s.
|
||||
#[must_use]
|
||||
pub fn addr_txs(&self, addr: &AddrBytes, limit: usize) -> Vec<Transaction> {
|
||||
let state = self.read();
|
||||
let Some(entry) = state.addrs.get(addr) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut ordered: Vec<(Timestamp, &Transaction)> = entry
|
||||
.txids
|
||||
.iter()
|
||||
.filter_map(|txid| {
|
||||
let record = state.txs.record_by_prefix(&TxidPrefix::from(txid))?;
|
||||
Some((record.entry.first_seen, &record.tx))
|
||||
})
|
||||
.collect();
|
||||
ordered.sort_unstable_by_key(|b| Reverse(b.0));
|
||||
ordered
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(_, tx)| tx.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
199
crates/brk_mempool/src/api/block_template.rs
Normal file
199
crates/brk_mempool/src/api/block_template.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
//! Projected next block: full template and incremental diff.
|
||||
|
||||
use brk_types::{
|
||||
BlockTemplate, BlockTemplateDiff, BlockTemplateDiffEntry, MempoolBlock, NextBlockHash,
|
||||
Transaction, Txid,
|
||||
};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{Mempool, state::State};
|
||||
|
||||
impl Mempool {
|
||||
pub fn next_block_hash(&self) -> NextBlockHash {
|
||||
self.snapshot().next_block_hash
|
||||
}
|
||||
|
||||
/// Full projected next block: Core's `getblocktemplate` selection
|
||||
/// (block 0) with aggregate stats and full tx bodies in GBT order.
|
||||
#[must_use]
|
||||
pub fn block_template(&self) -> BlockTemplate {
|
||||
let snap = self.snapshot();
|
||||
BlockTemplate {
|
||||
hash: snap.next_block_hash,
|
||||
stats: snap
|
||||
.block_stats
|
||||
.first()
|
||||
.map(MempoolBlock::from)
|
||||
.unwrap_or_default(),
|
||||
transactions: self.collect_txs(snap.block0_txids()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delta of the projected next block since `since`. `None` when
|
||||
/// `since` has aged out of the rebuilder's history (server should
|
||||
/// 404 -> client falls back to `block_template`).
|
||||
///
|
||||
/// `order` walks the new template in template order. Each entry is
|
||||
/// either a `Retained` index into the prior template (which the
|
||||
/// client cached when it obtained `since`) or a `New` inline body.
|
||||
/// `removed` is the convenience list of txids that left.
|
||||
#[must_use]
|
||||
pub fn block_template_diff(&self, since: NextBlockHash) -> Option<BlockTemplateDiff> {
|
||||
let past = self.rebuilder().historical_block0(since)?;
|
||||
let prior_index: FxHashMap<Txid, u32> = past
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, txid)| (*txid, idx as u32))
|
||||
.collect();
|
||||
let snap = self.snapshot();
|
||||
let state = self.read();
|
||||
let mut order = Vec::with_capacity(snap.blocks.first().map_or(0, Vec::len));
|
||||
let mut current: FxHashSet<Txid> = FxHashSet::default();
|
||||
for txid in snap.block0_txids() {
|
||||
current.insert(txid);
|
||||
match prior_index.get(&txid) {
|
||||
Some(&idx) => order.push(BlockTemplateDiffEntry::Retained(idx)),
|
||||
None => match Self::lookup_body(&state, &txid) {
|
||||
Some(tx) => order.push(BlockTemplateDiffEntry::New(tx)),
|
||||
None => warn!(?txid, "block_template_diff: snapshot tx body missing"),
|
||||
},
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
let removed = past.into_iter().filter(|t| !current.contains(t)).collect();
|
||||
Some(BlockTemplateDiff {
|
||||
hash: snap.next_block_hash,
|
||||
since,
|
||||
order,
|
||||
removed,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_txs(&self, txids: impl IntoIterator<Item = Txid>) -> Vec<Transaction> {
|
||||
let state = self.read();
|
||||
txids
|
||||
.into_iter()
|
||||
.filter_map(|txid| {
|
||||
let body = Self::lookup_body(&state, &txid);
|
||||
if body.is_none() {
|
||||
warn!(?txid, "block_template: snapshot tx body missing");
|
||||
}
|
||||
body
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Body for a txid in a published snapshot. Graveyard fallback
|
||||
/// covers the eviction race: an Applier may have buried the tx
|
||||
/// after the snapshot was built. Burial retention (1h) >> snapshot
|
||||
/// cycle (~1s), so the invariant holds in practice. A `None` here
|
||||
/// is a soft anomaly the caller logs and drops.
|
||||
fn lookup_body(state: &State, txid: &Txid) -> Option<Transaction> {
|
||||
state
|
||||
.txs
|
||||
.get(txid)
|
||||
.or_else(|| state.graveyard.get(txid).map(|t| &t.tx))
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use brk_types::{BlockTemplateDiffEntry, FeeRate};
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
state::TxEntry,
|
||||
test_support::{fake_entry_info, fake_tx, p2wpkh_script},
|
||||
};
|
||||
|
||||
fn insert_tx(mempool: &Mempool, seed: u8, fee: u64, vsize: u64) -> Txid {
|
||||
let tx = fake_tx(seed, &[None], &[(p2wpkh_script(seed + 1), 1_234)]);
|
||||
let txid = tx.txid;
|
||||
let info = fake_entry_info(txid, fee, vsize);
|
||||
let entry = TxEntry::new(&info, vsize, false);
|
||||
let mut state = mempool.test_state_lock().write();
|
||||
state.txs.insert(tx, entry);
|
||||
txid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_template_hash_matches_next_block_hash() {
|
||||
let mempool = Mempool::for_test();
|
||||
let txid = insert_tx(&mempool, 0xA0, 1_234, 100);
|
||||
mempool.test_tick(&[txid], FeeRate::new(1.0));
|
||||
|
||||
let template = mempool.block_template();
|
||||
assert_eq!(template.hash, mempool.next_block_hash());
|
||||
assert_eq!(template.transactions.len(), 1);
|
||||
assert_eq!(template.transactions[0].txid, txid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_template_diff_round_trip_reconstructs_t1_from_t0() {
|
||||
// T0: pool has two txs, both in gbt -> block 0.
|
||||
let mempool = Mempool::for_test();
|
||||
let txid_a = insert_tx(&mempool, 0xA1, 1_111, 100);
|
||||
let txid_b = insert_tx(&mempool, 0xA2, 2_222, 100);
|
||||
mempool.test_tick(&[txid_a, txid_b], FeeRate::new(1.0));
|
||||
let t0 = mempool.block_template();
|
||||
|
||||
// T1: add a third tx, advance gbt. block_template_diff(t0.hash) must
|
||||
// be reconstructible into the new block 0 ordering by combining the
|
||||
// retained prior-indexed bodies from T0 with the New bodies inline.
|
||||
let txid_c = insert_tx(&mempool, 0xA3, 3_333, 100);
|
||||
mempool.test_tick(&[txid_a, txid_b, txid_c], FeeRate::new(1.0));
|
||||
let t1 = mempool.block_template();
|
||||
|
||||
let diff = mempool
|
||||
.block_template_diff(t0.hash)
|
||||
.expect("t0 is still in history");
|
||||
assert_eq!(diff.since, t0.hash);
|
||||
assert_eq!(diff.hash, t1.hash);
|
||||
|
||||
let mut reconstructed = Vec::with_capacity(diff.order.len());
|
||||
for entry in &diff.order {
|
||||
match entry {
|
||||
BlockTemplateDiffEntry::Retained(idx) => {
|
||||
reconstructed.push(t0.transactions[*idx as usize].clone());
|
||||
}
|
||||
BlockTemplateDiffEntry::New(tx) => reconstructed.push(tx.clone()),
|
||||
}
|
||||
}
|
||||
let expected: Vec<_> = t1.transactions.iter().map(|tx| tx.txid).collect();
|
||||
let got: Vec<_> = reconstructed.iter().map(|tx| tx.txid).collect();
|
||||
assert_eq!(got, expected, "diff round-trips back into T1 ordering");
|
||||
assert!(diff.removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_template_diff_removed_lists_evicted_txs() {
|
||||
let mempool = Mempool::for_test();
|
||||
let txid_a = insert_tx(&mempool, 0xA4, 1_111, 100);
|
||||
let txid_b = insert_tx(&mempool, 0xA5, 2_222, 100);
|
||||
mempool.test_tick(&[txid_a, txid_b], FeeRate::new(1.0));
|
||||
let t0 = mempool.block_template();
|
||||
|
||||
// T1: txid_a no longer in gbt.
|
||||
mempool.test_tick(&[txid_b], FeeRate::new(1.0));
|
||||
let diff = mempool.block_template_diff(t0.hash).unwrap();
|
||||
assert_eq!(diff.removed, vec![txid_a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_template_diff_unknown_since_returns_none() {
|
||||
let mempool = Mempool::for_test();
|
||||
mempool.test_tick(&[], FeeRate::new(1.0));
|
||||
let bogus = NextBlockHash::new(0xDEAD_BEEF);
|
||||
assert!(mempool.block_template_diff(bogus).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_template_empty_pool_has_no_transactions() {
|
||||
let mempool = Mempool::for_test();
|
||||
mempool.test_tick(&[], FeeRate::new(2.0));
|
||||
let template = mempool.block_template();
|
||||
assert!(template.transactions.is_empty());
|
||||
}
|
||||
}
|
||||
38
crates/brk_mempool/src/api/fees.rs
Normal file
38
crates/brk_mempool/src/api/fees.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
//! Fee reads: tier recommendations, projected-block stats, per-tx rates.
|
||||
|
||||
use brk_types::{FeeRate, RecommendedFees, TxidPrefix, Txid};
|
||||
|
||||
use crate::{Mempool, snapshot::BlockStats};
|
||||
|
||||
impl Mempool {
|
||||
#[must_use]
|
||||
pub fn fees(&self) -> RecommendedFees {
|
||||
self.snapshot().fees.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn block_stats(&self) -> Vec<BlockStats> {
|
||||
self.snapshot().block_stats.clone()
|
||||
}
|
||||
|
||||
/// Effective fee rate for a live tx: snapshot's linearized chunk
|
||||
/// rate. Falls back to `fee/vsize` for txs added since the latest
|
||||
/// snapshot was built (apply -> same-cycle tick gap).
|
||||
pub fn live_effective_fee_rate(&self, prefix: &TxidPrefix) -> Option<FeeRate> {
|
||||
if let Some(rate) = self.snapshot().chunk_rate_for(prefix) {
|
||||
return Some(rate);
|
||||
}
|
||||
self.read()
|
||||
.txs
|
||||
.entry_by_prefix(prefix)
|
||||
.map(|e| e.fee_rate())
|
||||
}
|
||||
|
||||
/// Linearized chunk rate captured at burial - same value
|
||||
/// `live_effective_fee_rate` returned while the tx was alive, so an
|
||||
/// evicted RBF predecessor reports the package-effective rate it
|
||||
/// had in the mempool, not a misleading isolated `fee/vsize`.
|
||||
pub fn graveyard_fee_rate(&self, txid: &Txid) -> Option<FeeRate> {
|
||||
self.read().graveyard.get(txid).map(|tomb| tomb.chunk_rate)
|
||||
}
|
||||
}
|
||||
23
crates/brk_mempool/src/api/histogram.rs
Normal file
23
crates/brk_mempool/src/api/histogram.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! Mempool info + price-blending output histogram.
|
||||
|
||||
use brk_oracle::Histogram;
|
||||
use brk_types::MempoolInfo;
|
||||
|
||||
use crate::Mempool;
|
||||
|
||||
impl Mempool {
|
||||
#[must_use]
|
||||
pub fn info(&self) -> MempoolInfo {
|
||||
self.read().info.clone()
|
||||
}
|
||||
|
||||
/// Snapshot of pre-bucketed oracle bins across all live mempool tx
|
||||
/// outputs. The total is maintained incrementally by `TxStore` on
|
||||
/// every insert/remove, so this hot path is `O(NUM_BINS)` regardless
|
||||
/// of pool size. Used by `live_price` to blend the mempool into the
|
||||
/// committed oracle without re-parsing scripts per request.
|
||||
#[must_use]
|
||||
pub fn live_histogram(&self) -> Histogram {
|
||||
self.read().txs.live_histogram()
|
||||
}
|
||||
}
|
||||
11
crates/brk_mempool/src/api/mod.rs
Normal file
11
crates/brk_mempool/src/api/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! Read-side accessors on [`crate::Mempool`]. Each submodule groups a
|
||||
//! cohesive method set. Types flow back through `pub use`.
|
||||
|
||||
mod addr;
|
||||
mod block_template;
|
||||
mod fees;
|
||||
mod histogram;
|
||||
mod rbf;
|
||||
mod tx;
|
||||
|
||||
pub use rbf::{RbfForTx, RbfNode};
|
||||
214
crates/brk_mempool/src/api/rbf.rs
Normal file
214
crates/brk_mempool/src/api/rbf.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
//! RBF tree extraction. Returns owned trees so the caller can enrich
|
||||
//! with indexer data (`mined`, effective fee rate) after the lock
|
||||
//! drops: enriching under the lock re-enters `Mempool` and would
|
||||
//! recursively acquire the same read lock.
|
||||
|
||||
use brk_types::{Sats, Timestamp, Transaction, Txid, TxidPrefix, VSize};
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
use crate::{
|
||||
Mempool,
|
||||
state::TxEntry,
|
||||
stores::{TxGraveyard, TxStore},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RbfNode {
|
||||
pub txid: Txid,
|
||||
pub fee: Sats,
|
||||
pub vsize: VSize,
|
||||
pub value: Sats,
|
||||
pub first_seen: Timestamp,
|
||||
/// BIP-125 signaling: at least one input has sequence < 0xffffffff-1.
|
||||
pub rbf: bool,
|
||||
/// `true` iff any predecessor in this subtree was non-signaling.
|
||||
pub full_rbf: bool,
|
||||
pub replaces: Vec<RbfNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RbfForTx {
|
||||
/// Tree rooted at the terminal replacer. `None` if `txid` is unknown.
|
||||
pub root: Option<RbfNode>,
|
||||
/// Direct predecessors of the requested tx (txids only).
|
||||
pub replaces: Vec<Txid>,
|
||||
}
|
||||
|
||||
impl Mempool {
|
||||
/// Walk forward through `Replaced { by }` to the terminal replacer
|
||||
/// and return its full predecessor tree, plus the requested tx's
|
||||
/// direct predecessors. Single read-lock window.
|
||||
#[must_use]
|
||||
pub fn rbf_for_tx(&self, txid: &Txid) -> RbfForTx {
|
||||
let state = self.read();
|
||||
|
||||
let root_txid = state.graveyard.replacement_root_of(*txid);
|
||||
let replaces: Vec<Txid> = state
|
||||
.graveyard
|
||||
.predecessors_of(txid)
|
||||
.map(|(p, _)| *p)
|
||||
.collect();
|
||||
let root = Self::build_rbf_node(&root_txid, &state.txs, &state.graveyard);
|
||||
RbfForTx { root, replaces }
|
||||
}
|
||||
|
||||
/// Recent terminal-replacer trees, most-recent first, deduplicated
|
||||
/// by root, capped at `limit`. `full_rbf_only` drops trees with no
|
||||
/// non-signaling predecessor.
|
||||
#[must_use]
|
||||
pub fn recent_rbf_trees(&self, full_rbf_only: bool, limit: usize) -> Vec<RbfNode> {
|
||||
let state = self.read();
|
||||
|
||||
let mut seen: FxHashSet<Txid> = FxHashSet::default();
|
||||
state
|
||||
.graveyard
|
||||
.replaced_iter_recent_first()
|
||||
.filter_map(|(_, by)| {
|
||||
let root = state.graveyard.replacement_root_of(*by);
|
||||
seen.insert(root).then_some(root)
|
||||
})
|
||||
.filter_map(|root| Self::build_rbf_node(&root, &state.txs, &state.graveyard))
|
||||
.filter(|n| !full_rbf_only || n.full_rbf)
|
||||
.take(limit)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_rbf_node(txid: &Txid, txs: &TxStore, graveyard: &TxGraveyard) -> Option<RbfNode> {
|
||||
let (tx, entry) = Self::resolve_rbf_node(txid, txs, graveyard)?;
|
||||
|
||||
let replaces: Vec<RbfNode> = graveyard
|
||||
.predecessors_of(txid)
|
||||
.filter_map(|(pred, _)| Self::build_rbf_node(pred, txs, graveyard))
|
||||
.collect();
|
||||
|
||||
let full_rbf = replaces.iter().any(|c| !c.rbf || c.full_rbf);
|
||||
let value: Sats = tx.output.iter().map(|o| o.value).sum();
|
||||
|
||||
Some(RbfNode {
|
||||
txid: *txid,
|
||||
fee: entry.fee,
|
||||
vsize: entry.vsize,
|
||||
value,
|
||||
first_seen: entry.first_seen,
|
||||
rbf: entry.rbf,
|
||||
full_rbf,
|
||||
replaces,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_rbf_node<'a>(
|
||||
txid: &Txid,
|
||||
txs: &'a TxStore,
|
||||
graveyard: &'a TxGraveyard,
|
||||
) -> Option<(&'a Transaction, &'a TxEntry)> {
|
||||
txs.record_by_prefix(&TxidPrefix::from(txid))
|
||||
.map(|r| (&r.tx, &r.entry))
|
||||
.or_else(|| graveyard.get(txid).map(|t| (&t.tx, &t.entry)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use brk_types::FeeRate;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
Mempool, TxRemoval,
|
||||
state::TxEntry,
|
||||
test_support::{fake_entry_info, fake_tx, fake_txid, p2wpkh_script},
|
||||
};
|
||||
|
||||
/// Place a live tx (the replacer) and bury one or more predecessors
|
||||
/// pointing at it. `bury_chain` carries `(seed, predecessor_of_next)`
|
||||
/// pairs in oldest-first order. Each links forward to the next entry
|
||||
/// or to `live_seed` when last.
|
||||
fn build_rbf_world(live_seed: u8, predecessors: &[u8]) -> (Mempool, Txid, Vec<Txid>) {
|
||||
let mempool = Mempool::for_test();
|
||||
let live_tx = fake_tx(live_seed, &[None], &[(p2wpkh_script(live_seed + 1), 1_234)]);
|
||||
let live_txid = live_tx.txid;
|
||||
let live_entry = TxEntry::new(&fake_entry_info(live_txid, 5_000, 100), 100, true);
|
||||
|
||||
let mut pred_txids = Vec::with_capacity(predecessors.len());
|
||||
let mut state = mempool.test_state_lock().write();
|
||||
for (i, seed) in predecessors.iter().enumerate() {
|
||||
let tx = fake_tx(*seed, &[None], &[(p2wpkh_script(seed + 1), 1_234)]);
|
||||
let txid = tx.txid;
|
||||
// Each predecessor signals BIP-125 (rbf=true) so full_rbf stays clear.
|
||||
let entry = TxEntry::new(&fake_entry_info(txid, 1_000, 100), 100, true);
|
||||
let by = predecessors
|
||||
.get(i + 1)
|
||||
.map(|next_seed| fake_txid(*next_seed))
|
||||
.unwrap_or(live_txid);
|
||||
let rate = FeeRate::from((entry.fee, entry.vsize));
|
||||
state.graveyard.bury(tx, entry, rate, TxRemoval::Replaced { by });
|
||||
pred_txids.push(txid);
|
||||
}
|
||||
state.txs.insert(live_tx, live_entry);
|
||||
drop(state);
|
||||
(mempool, live_txid, pred_txids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rbf_for_tx_single_replacement_returns_root_and_replaces() {
|
||||
// pred -> live. rbf_for_tx(pred) walks forward to live and lists
|
||||
// pred under its `replaces` tree.
|
||||
let (mempool, live, preds) = build_rbf_world(0xC0, &[0xC1]);
|
||||
let pred = preds[0];
|
||||
|
||||
let rbf = mempool.rbf_for_tx(&pred);
|
||||
let root = rbf.root.expect("terminal replacer reachable");
|
||||
assert_eq!(root.txid, live);
|
||||
let replaced_txids: Vec<Txid> = root.replaces.iter().map(|n| n.txid).collect();
|
||||
assert_eq!(replaced_txids, vec![pred]);
|
||||
// Convenience list: direct predecessors of the requested tx.
|
||||
assert!(rbf.replaces.is_empty(), "pred has no predecessors of its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rbf_for_tx_chain_walks_to_terminal_root() {
|
||||
// A -> B -> C(live). rbf_for_tx(A) walks A -> B -> C, root is C.
|
||||
// root.replaces is B, B.replaces is A.
|
||||
let (mempool, live, preds) = build_rbf_world(0xC2, &[0xC3, 0xC4]);
|
||||
let a = preds[0];
|
||||
let b = preds[1];
|
||||
|
||||
let rbf = mempool.rbf_for_tx(&a);
|
||||
let root = rbf.root.expect("terminal replacer reachable");
|
||||
assert_eq!(root.txid, live);
|
||||
assert_eq!(root.replaces.len(), 1);
|
||||
assert_eq!(root.replaces[0].txid, b);
|
||||
assert_eq!(root.replaces[0].replaces.len(), 1);
|
||||
assert_eq!(root.replaces[0].replaces[0].txid, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rbf_for_tx_unknown_tx_returns_none_root() {
|
||||
let mempool = Mempool::for_test();
|
||||
let bogus = Txid::COINBASE;
|
||||
let rbf = mempool.rbf_for_tx(&bogus);
|
||||
assert!(rbf.root.is_none());
|
||||
assert!(rbf.replaces.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_rbf_trees_dedup_by_root_and_respect_limit() {
|
||||
// Chain 0xC6 -> 0xC7 -> live plus a sibling 0xC8 also replaced by
|
||||
// live. All paths roll up to the same root, so the recent listing
|
||||
// dedups them down to a single tree.
|
||||
let (mempool, live, _preds) = build_rbf_world(0xC5, &[0xC6, 0xC7]);
|
||||
{
|
||||
let mut state = mempool.test_state_lock().write();
|
||||
let extra = fake_tx(0xC8, &[None], &[(p2wpkh_script(0xC9), 1_234)]);
|
||||
let extra_txid = extra.txid;
|
||||
let entry = TxEntry::new(&fake_entry_info(extra_txid, 999, 100), 100, true);
|
||||
let rate = FeeRate::from((entry.fee, entry.vsize));
|
||||
state.graveyard.bury(extra, entry, rate, TxRemoval::Replaced { by: live });
|
||||
}
|
||||
let trees = mempool.recent_rbf_trees(false, 10);
|
||||
assert_eq!(trees.len(), 1, "all paths roll up to one root");
|
||||
assert_eq!(trees[0].txid, live);
|
||||
|
||||
let capped = mempool.recent_rbf_trees(false, 0);
|
||||
assert!(capped.is_empty(), "limit honored");
|
||||
}
|
||||
}
|
||||
68
crates/brk_mempool/src/api/tx.rs
Normal file
68
crates/brk_mempool/src/api/tx.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! Tx-keyed reads.
|
||||
|
||||
use brk_types::{
|
||||
MempoolRecentTx, OutpointPrefix, Transaction, Txid, TxidPrefix, Vin, Vout,
|
||||
};
|
||||
|
||||
use crate::Mempool;
|
||||
|
||||
impl Mempool {
|
||||
pub fn contains_txid(&self, txid: &Txid) -> bool {
|
||||
self.read().txs.contains(txid)
|
||||
}
|
||||
|
||||
/// Apply `f` to the live tx body if present.
|
||||
pub fn with_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> {
|
||||
self.read().txs.get(txid).map(f)
|
||||
}
|
||||
|
||||
/// Apply `f` to a `Vanished` tombstone's tx body if present.
|
||||
/// `Replaced` tombstones return `None` because the tx will not confirm.
|
||||
pub fn with_vanished_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> {
|
||||
self.read().graveyard.get_vanished(txid).map(|t| f(&t.tx))
|
||||
}
|
||||
|
||||
/// Mempool tx spending `(txid, vout)`, or `None`. The spender's
|
||||
/// input list is walked to rule out `TxidPrefix` collisions.
|
||||
pub fn lookup_spender(&self, txid: &Txid, vout: Vout) -> Option<(Txid, Vin)> {
|
||||
let key = OutpointPrefix::new(TxidPrefix::from(txid), vout);
|
||||
let state = self.read();
|
||||
let spender_prefix = state.outpoint_spends.get(&key)?;
|
||||
let spender = state.txs.record_by_prefix(&spender_prefix)?;
|
||||
let vin_pos = spender
|
||||
.tx
|
||||
.input
|
||||
.iter()
|
||||
.position(|inp| inp.txid == *txid && inp.vout == vout)?;
|
||||
Some((spender.entry.txid, Vin::from(vin_pos)))
|
||||
}
|
||||
|
||||
/// Snapshot of all live mempool txids.
|
||||
///
|
||||
/// Allocates `32 * len(mempool)` bytes under the read guard. Sized for
|
||||
/// diagnostics. Route layers serving large pools should paginate at
|
||||
/// their boundary rather than calling this per request.
|
||||
#[must_use]
|
||||
pub fn txids(&self) -> Vec<Txid> {
|
||||
self.read().txs.txids().copied().collect()
|
||||
}
|
||||
|
||||
/// Snapshot of recent live txs.
|
||||
#[must_use]
|
||||
pub fn recent_txs(&self) -> Vec<MempoolRecentTx> {
|
||||
self.read().txs.recent().to_vec()
|
||||
}
|
||||
|
||||
/// `first_seen` Unix-second timestamps for `txids`, in input order.
|
||||
/// Returns 0 for unknown txids. `Vanished` tombstones fall back to
|
||||
/// the buried entry's `first_seen` to avoid flicker between drop
|
||||
/// and indexer catch-up.
|
||||
#[must_use]
|
||||
pub fn transaction_times(&self, txids: &[Txid]) -> Vec<u64> {
|
||||
let state = self.read();
|
||||
txids
|
||||
.iter()
|
||||
.map(|txid| state.first_seen(txid).map_or(0, u64::from))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
//! Snapshot-side cluster primitives: connected-component discovery
|
||||
//! over `SnapTx` adjacency, topological ordering, and the glue that
|
||||
//! feeds the cluster into [`brk_types::linearize`] (Single Fee
|
||||
//! Linearization).
|
||||
//!
|
||||
//! A *cluster* is the connected component of a tx in the dependency
|
||||
//! graph (`parents ∪ children`), bounded by Core 31's
|
||||
//! `MAX_CLUSTER_COUNT_LIMIT = 64`. The SFL algorithm itself lives in
|
||||
//! `brk_types` since it has no mempool deps and is shared with the
|
||||
//! confirmed-cpfp path in `brk_query`.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use brk_types::{ChunkInput, CpfpClusterChunk, CpfpClusterTxIndex, linearize};
|
||||
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::steps::{SnapTx, TxIndex};
|
||||
|
||||
/// Cluster cap matches Bitcoin Core 31's `MAX_CLUSTER_COUNT_LIMIT`. Any
|
||||
/// connected component above this size is malformed under Core's policy
|
||||
/// and gets truncated.
|
||||
pub(crate) const MAX_CLUSTER: usize = 64;
|
||||
|
||||
/// Capped DFS over the undirected dependency graph (`parents ∪
|
||||
/// children`) starting from `seed`. Returns the connected component
|
||||
/// truncated to `MAX_CLUSTER`, with `seed` at index 0.
|
||||
pub(crate) fn walk_cluster(txs: &[SnapTx], seed: TxIndex) -> Vec<TxIndex> {
|
||||
if txs.get(seed.as_usize()).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut visited: FxHashSet<TxIndex> =
|
||||
FxHashSet::with_capacity_and_hasher(MAX_CLUSTER, FxBuildHasher);
|
||||
visited.insert(seed);
|
||||
let mut out: Vec<TxIndex> = Vec::with_capacity(MAX_CLUSTER);
|
||||
out.push(seed);
|
||||
let mut stack: Vec<TxIndex> = vec![seed];
|
||||
while let Some(idx) = stack.pop() {
|
||||
let Some(t) = txs.get(idx.as_usize()) else {
|
||||
continue;
|
||||
};
|
||||
for &n in t.parents.iter().chain(t.children.iter()) {
|
||||
if out.len() >= MAX_CLUSTER {
|
||||
return out;
|
||||
}
|
||||
if visited.insert(n) {
|
||||
out.push(n);
|
||||
stack.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Linearize the connected component into chunks. Topo-sorts members,
|
||||
/// remaps parent edges to cluster-local indices, and runs SFL. Returns
|
||||
/// `(members, chunks)` where `members` is the topo-ordered `TxIndex`
|
||||
/// list and `chunks[*].txs` are local indices into `members`. Callers
|
||||
/// must filter singletons before calling - the singleton's `chunk_rate`
|
||||
/// is `fee/vsize`, set elsewhere.
|
||||
pub(crate) fn linearize_component(
|
||||
txs: &[SnapTx],
|
||||
component: &[TxIndex],
|
||||
) -> (Vec<TxIndex>, Vec<CpfpClusterChunk>) {
|
||||
let members = topo_sort(txs, component);
|
||||
let local_of = build_local_index(&members);
|
||||
let parents_local: Vec<SmallVec<[CpfpClusterTxIndex; 2]>> = members
|
||||
.iter()
|
||||
.map(|idx| {
|
||||
txs[idx.as_usize()]
|
||||
.parents
|
||||
.iter()
|
||||
.filter_map(|p| local_of.get(p).copied())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let inputs: Vec<ChunkInput<'_>> = members
|
||||
.iter()
|
||||
.zip(&parents_local)
|
||||
.map(|(idx, ps)| {
|
||||
let t = &txs[idx.as_usize()];
|
||||
ChunkInput {
|
||||
fee: t.fee,
|
||||
vsize: t.vsize,
|
||||
parents: ps.as_slice(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let chunks = linearize(&inputs);
|
||||
(members, chunks)
|
||||
}
|
||||
|
||||
/// Kahn's topological sort over the connected component, restricted to
|
||||
/// in-cluster parent edges. Returns members in an order where every tx
|
||||
/// follows all its in-cluster parents.
|
||||
fn topo_sort(txs: &[SnapTx], component: &[TxIndex]) -> Vec<TxIndex> {
|
||||
let n = component.len();
|
||||
let pos: FxHashMap<TxIndex, usize> = component
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &x)| (x, i))
|
||||
.collect();
|
||||
let mut indeg: Vec<u32> = vec![0; n];
|
||||
let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for (i, &idx) in component.iter().enumerate() {
|
||||
let Some(t) = txs.get(idx.as_usize()) else {
|
||||
continue;
|
||||
};
|
||||
indeg[i] = t.parents.iter().filter(|p| pos.contains_key(p)).count() as u32;
|
||||
for &c in t.children.iter() {
|
||||
if let Some(&ci) = pos.get(&c) {
|
||||
children[i].push(ci);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut queue: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
|
||||
let mut out: Vec<TxIndex> = Vec::with_capacity(n);
|
||||
while let Some(i) = queue.pop_front() {
|
||||
out.push(component[i]);
|
||||
for &c in &children[i] {
|
||||
indeg[c] -= 1;
|
||||
if indeg[c] == 0 {
|
||||
queue.push_back(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `members[i]`'s wire index, keyed by snapshot `TxIndex`. Built once
|
||||
/// so per-tx parent edges can be remapped without a linear scan.
|
||||
pub(crate) fn build_local_index(members: &[TxIndex]) -> FxHashMap<TxIndex, CpfpClusterTxIndex> {
|
||||
members
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &idx)| (idx, CpfpClusterTxIndex::from(i as u32)))
|
||||
.collect()
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
//! CPFP (Child Pays For Parent) walk over a `Snapshot`'s adjacency.
|
||||
//!
|
||||
//! Three independent walks:
|
||||
//! - `ancestors`: capped DFS up `parents` only.
|
||||
//! - `descendants`: capped DFS down `children` only.
|
||||
//! - cluster: the connected component over `parents ∪ children`,
|
||||
//! linearized via [`crate::cluster`] for the cluster wire shape and
|
||||
//! the seed's chunk feerate.
|
||||
|
||||
use brk_types::{
|
||||
CPFP_CHAIN_LIMIT, CpfpCluster, CpfpClusterTx, CpfpClusterTxIndex, CpfpEntry, CpfpInfo, FeeRate,
|
||||
SigOps, TxidPrefix, VSize, find_seed_chunk,
|
||||
};
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
|
||||
use crate::{
|
||||
Mempool,
|
||||
cluster::{build_local_index, linearize_component, walk_cluster},
|
||||
steps::{SnapTx, TxIndex},
|
||||
};
|
||||
|
||||
impl Mempool {
|
||||
/// CPFP info for a live mempool tx. Returns `None` only when the
|
||||
/// tx isn't in the mempool, so callers can fall through to the
|
||||
/// confirmed path.
|
||||
pub fn cpfp_info(&self, prefix: &TxidPrefix) -> Option<CpfpInfo> {
|
||||
let snapshot = self.snapshot();
|
||||
let seed_idx = snapshot.idx_of(prefix)?;
|
||||
let seed = snapshot.tx(seed_idx)?;
|
||||
|
||||
let sigops = self
|
||||
.read()
|
||||
.txs
|
||||
.get(&seed.txid)
|
||||
.map(|tx| tx.total_sigop_cost)
|
||||
.unwrap_or(SigOps::ZERO);
|
||||
|
||||
Some(build_cpfp_info(&snapshot.txs, seed_idx, seed, sigops))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_cpfp_info(
|
||||
txs: &[SnapTx],
|
||||
seed_idx: TxIndex,
|
||||
seed: &SnapTx,
|
||||
sigops: SigOps,
|
||||
) -> CpfpInfo {
|
||||
let ancestors = collect_entries(txs, seed_idx, |t| &t.parents);
|
||||
let descendants = collect_entries(txs, seed_idx, |t| &t.children);
|
||||
let best_descendant = descendants
|
||||
.iter()
|
||||
.max_by_key(|e| FeeRate::from((e.fee, e.weight)))
|
||||
.cloned();
|
||||
|
||||
let (cluster, effective_fee_per_vsize) = build_cluster(txs, seed_idx, seed);
|
||||
let vsize = VSize::from(seed.weight);
|
||||
|
||||
CpfpInfo {
|
||||
ancestors,
|
||||
best_descendant,
|
||||
descendants,
|
||||
effective_fee_per_vsize,
|
||||
sigops,
|
||||
fee: seed.fee,
|
||||
vsize,
|
||||
adjusted_vsize: sigops.adjust_vsize(vsize),
|
||||
cluster,
|
||||
}
|
||||
}
|
||||
|
||||
/// Capped DFS from `seed` (exclusive) along `next`, lifted directly to
|
||||
/// wire-shape `CpfpEntry`s. Used for both ancestor and descendant walks.
|
||||
fn collect_entries(
|
||||
txs: &[SnapTx],
|
||||
seed: TxIndex,
|
||||
next: impl Fn(&SnapTx) -> &[TxIndex],
|
||||
) -> Vec<CpfpEntry> {
|
||||
let Some(seed_node) = txs.get(seed.as_usize()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut visited: FxHashSet<TxIndex> =
|
||||
FxHashSet::with_capacity_and_hasher(CPFP_CHAIN_LIMIT + 1, FxBuildHasher);
|
||||
visited.insert(seed);
|
||||
let mut out: Vec<CpfpEntry> = Vec::with_capacity(CPFP_CHAIN_LIMIT);
|
||||
let mut stack: Vec<TxIndex> = next(seed_node).to_vec();
|
||||
while let Some(idx) = stack.pop() {
|
||||
if out.len() >= CPFP_CHAIN_LIMIT {
|
||||
break;
|
||||
}
|
||||
if !visited.insert(idx) {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = txs.get(idx.as_usize()) {
|
||||
out.push(CpfpEntry::from(t));
|
||||
stack.extend(next(t).iter().copied());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Wire-shape `CpfpCluster` plus the seed's chunk feerate. Members are
|
||||
/// the connected component of the seed in the dependency graph,
|
||||
/// topologically ordered (parents before children) so wire indices and
|
||||
/// chunk-internal ordering are valid for client-side reconstruction.
|
||||
/// Returns `(None, seed_per_tx_rate)` for singletons (matches
|
||||
/// mempool.space, which omits `cluster` when no relations exist).
|
||||
fn build_cluster(
|
||||
txs: &[SnapTx],
|
||||
seed_idx: TxIndex,
|
||||
seed: &SnapTx,
|
||||
) -> (Option<CpfpCluster>, FeeRate) {
|
||||
let seed_per_tx_rate = FeeRate::from((seed.fee, seed.vsize));
|
||||
let component = walk_cluster(txs, seed_idx);
|
||||
if component.len() <= 1 {
|
||||
return (None, seed_per_tx_rate);
|
||||
}
|
||||
|
||||
let (members, chunks) = linearize_component(txs, &component);
|
||||
let cluster_txs = build_wire_members(txs, &members);
|
||||
let seed_local = CpfpClusterTxIndex::from(
|
||||
members
|
||||
.iter()
|
||||
.position(|&i| i == seed_idx)
|
||||
.map_or(0, |p| p as u32),
|
||||
);
|
||||
let (chunk_index, seed_chunk_rate) = find_seed_chunk(&chunks, seed_local, seed_per_tx_rate);
|
||||
|
||||
(
|
||||
Some(CpfpCluster {
|
||||
txs: cluster_txs,
|
||||
chunks,
|
||||
chunk_index,
|
||||
}),
|
||||
seed_chunk_rate,
|
||||
)
|
||||
}
|
||||
|
||||
/// Materialize wire-shape `CpfpClusterTx`s for every topo-ordered
|
||||
/// member with parent edges remapped to local indices.
|
||||
fn build_wire_members(txs: &[SnapTx], members: &[TxIndex]) -> Vec<CpfpClusterTx> {
|
||||
let local_of = build_local_index(members);
|
||||
members
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
let t = &txs[idx.as_usize()];
|
||||
CpfpClusterTx {
|
||||
txid: t.txid,
|
||||
weight: t.weight,
|
||||
fee: t.fee,
|
||||
parents: t
|
||||
.parents
|
||||
.iter()
|
||||
.filter_map(|p| local_of.get(p).copied())
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
//! Per-cycle event report returned by [`super::Mempool::tick`].
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use brk_types::{AddrBytes, BlockHash, FeeRate, Height, MempoolInfo, Sats, Timestamp, Txid, VSize};
|
||||
|
||||
use crate::{Snapshot, TxRemoval};
|
||||
|
||||
/// One pull cycle's worth of changes. Produced by
|
||||
/// [`super::Mempool::tick`] after fetch → prepare → apply → prevouts →
|
||||
/// rebuild. The snapshot is always present (the rebuilder runs every
|
||||
/// cycle); compare `next_block_hash` across cycles if you need to
|
||||
/// detect whether the projection actually changed.
|
||||
pub struct Cycle {
|
||||
pub added: Vec<TxAdded>,
|
||||
pub removed: Vec<TxRemoved>,
|
||||
/// Addresses that went from 0 → 1+ live mempool txs this cycle.
|
||||
/// Same-cycle enter-then-leave is collapsed (no event in either list).
|
||||
pub addr_enters: Vec<AddrBytes>,
|
||||
/// Addresses that went from 1+ → 0 live mempool txs this cycle.
|
||||
pub addr_leaves: Vec<AddrBytes>,
|
||||
/// Latest confirmed block. Compare to the prior cycle's `tip_hash`
|
||||
/// to detect a new block.
|
||||
pub tip_hash: BlockHash,
|
||||
pub tip_height: Height,
|
||||
pub info: MempoolInfo,
|
||||
pub snapshot: Arc<Snapshot>,
|
||||
pub took: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TxAdded {
|
||||
pub txid: Txid,
|
||||
pub fee: Sats,
|
||||
pub vsize: VSize,
|
||||
pub fee_rate: FeeRate,
|
||||
pub first_seen: Timestamp,
|
||||
pub kind: AddedKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AddedKind {
|
||||
/// First time we've seen this txid.
|
||||
Fresh,
|
||||
/// Re-entered the pool after a prior removal still in the graveyard.
|
||||
Revived,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TxRemoved {
|
||||
pub txid: Txid,
|
||||
pub reason: TxRemoval,
|
||||
/// Package-effective rate at burial. Same value the tx reported
|
||||
/// while alive - RBF predecessors keep their package rate, not a
|
||||
/// misleading isolated fee/vsize.
|
||||
pub chunk_rate: FeeRate,
|
||||
}
|
||||
7
crates/brk_mempool/src/cycle/added_kind.rs
Normal file
7
crates/brk_mempool/src/cycle/added_kind.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AddedKind {
|
||||
/// First time we've seen this txid.
|
||||
Fresh,
|
||||
/// Re-entered the pool after a prior removal still in the graveyard.
|
||||
Revived,
|
||||
}
|
||||
104
crates/brk_mempool/src/cycle/addr_transitions.rs
Normal file
104
crates/brk_mempool/src/cycle/addr_transitions.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
//! Per-cycle 0↔1+ address transition buffer. Same-cycle cancellation
|
||||
//! (enter→leave, leave→enter) is encapsulated on the recording methods.
|
||||
|
||||
use brk_types::AddrBytes;
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AddrTransitions {
|
||||
enters: FxHashSet<AddrBytes>,
|
||||
leaves: FxHashSet<AddrBytes>,
|
||||
}
|
||||
|
||||
impl AddrTransitions {
|
||||
pub fn record_enter(&mut self, bytes: AddrBytes) {
|
||||
if !self.leaves.remove(&bytes) {
|
||||
self.enters.insert(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_leave(&mut self, bytes: AddrBytes) {
|
||||
if !self.enters.remove(&bytes) {
|
||||
self.leaves.insert(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_vecs(self) -> (Vec<AddrBytes>, Vec<AddrBytes>) {
|
||||
(
|
||||
self.enters.into_iter().collect(),
|
||||
self.leaves.into_iter().collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bitcoin::{ScriptBuf, hashes::Hash};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn addr(seed: u8) -> AddrBytes {
|
||||
let mut bytes = [0u8; 20];
|
||||
bytes[0] = seed;
|
||||
let script = ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array(bytes));
|
||||
AddrBytes::try_from(&script).expect("p2wpkh -> AddrBytes")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_then_leave_cancels() {
|
||||
let mut t = AddrTransitions::default();
|
||||
t.record_enter(addr(1));
|
||||
t.record_leave(addr(1));
|
||||
let (enters, leaves) = t.into_vecs();
|
||||
assert!(enters.is_empty());
|
||||
assert!(leaves.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leave_then_enter_cancels() {
|
||||
let mut t = AddrTransitions::default();
|
||||
t.record_leave(addr(2));
|
||||
t.record_enter(addr(2));
|
||||
let (enters, leaves) = t.into_vecs();
|
||||
assert!(enters.is_empty());
|
||||
assert!(leaves.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_leave_enter_collapses_to_single_enter() {
|
||||
let mut t = AddrTransitions::default();
|
||||
let a = addr(3);
|
||||
t.record_enter(a.clone());
|
||||
t.record_leave(a.clone());
|
||||
t.record_enter(a.clone());
|
||||
let (enters, leaves) = t.into_vecs();
|
||||
assert_eq!(enters, vec![a]);
|
||||
assert!(leaves.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leave_enter_leave_collapses_to_single_leave() {
|
||||
let mut t = AddrTransitions::default();
|
||||
let a = addr(4);
|
||||
t.record_leave(a.clone());
|
||||
t.record_enter(a.clone());
|
||||
t.record_leave(a.clone());
|
||||
let (enters, leaves) = t.into_vecs();
|
||||
assert!(enters.is_empty());
|
||||
assert_eq!(leaves, vec![a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_addrs_dont_interfere() {
|
||||
let mut t = AddrTransitions::default();
|
||||
let a = addr(5);
|
||||
let b = addr(6);
|
||||
t.record_enter(a.clone());
|
||||
t.record_leave(b.clone());
|
||||
let (mut enters, mut leaves) = t.into_vecs();
|
||||
enters.sort_by_key(|x| x.as_slice()[0]);
|
||||
leaves.sort_by_key(|x| x.as_slice()[0]);
|
||||
assert_eq!(enters, vec![a]);
|
||||
assert_eq!(leaves, vec![b]);
|
||||
}
|
||||
}
|
||||
10
crates/brk_mempool/src/cycle/diff.rs
Normal file
10
crates/brk_mempool/src/cycle/diff.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use crate::cycle::{AddrTransitions, TxAdded, TxRemoved};
|
||||
|
||||
/// Per-cycle accumulator threaded through the pipeline steps and
|
||||
/// drained into the public [`crate::Cycle`] at end of cycle.
|
||||
#[derive(Default)]
|
||||
pub struct CycleDiff {
|
||||
pub added: Vec<TxAdded>,
|
||||
pub removed: Vec<TxRemoved>,
|
||||
pub addrs: AddrTransitions,
|
||||
}
|
||||
30
crates/brk_mempool/src/cycle/event.rs
Normal file
30
crates/brk_mempool/src/cycle/event.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use brk_types::{AddrBytes, BlockHash, Height, MempoolInfo};
|
||||
|
||||
use crate::{
|
||||
Snapshot,
|
||||
cycle::{TxAdded, TxRemoved},
|
||||
};
|
||||
|
||||
/// One pull cycle's worth of changes. Produced by
|
||||
/// [`crate::Mempool::tick`] after fetch → prepare → apply → prevouts →
|
||||
/// rebuild. The snapshot is always present (the rebuilder runs every
|
||||
/// cycle). Compare `next_block_hash` across cycles if you need to
|
||||
/// detect whether the projection actually changed.
|
||||
pub struct Cycle {
|
||||
pub added: Vec<TxAdded>,
|
||||
pub removed: Vec<TxRemoved>,
|
||||
/// Addresses that went from 0 → 1+ live mempool txs this cycle.
|
||||
/// Same-cycle enter-then-leave is collapsed (no event in either list).
|
||||
pub addr_enters: Vec<AddrBytes>,
|
||||
/// Addresses that went from 1+ → 0 live mempool txs this cycle.
|
||||
pub addr_leaves: Vec<AddrBytes>,
|
||||
/// Latest confirmed block. Compare to the prior cycle's `tip_hash`
|
||||
/// to detect a new block.
|
||||
pub tip_hash: BlockHash,
|
||||
pub tip_height: Height,
|
||||
pub info: MempoolInfo,
|
||||
pub snapshot: Arc<Snapshot>,
|
||||
pub took: Duration,
|
||||
}
|
||||
15
crates/brk_mempool/src/cycle/mod.rs
Normal file
15
crates/brk_mempool/src/cycle/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Per-cycle types. Every type here lives exactly one tick.
|
||||
|
||||
mod added_kind;
|
||||
mod addr_transitions;
|
||||
mod diff;
|
||||
mod event;
|
||||
mod tx_added;
|
||||
mod tx_removed;
|
||||
|
||||
pub use added_kind::AddedKind;
|
||||
pub use addr_transitions::AddrTransitions;
|
||||
pub use diff::CycleDiff;
|
||||
pub use event::Cycle;
|
||||
pub use tx_added::TxAdded;
|
||||
pub use tx_removed::TxRemoved;
|
||||
13
crates/brk_mempool/src/cycle/tx_added.rs
Normal file
13
crates/brk_mempool/src/cycle/tx_added.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use brk_types::{FeeRate, Sats, Timestamp, Txid, VSize};
|
||||
|
||||
use crate::cycle::AddedKind;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TxAdded {
|
||||
pub txid: Txid,
|
||||
pub fee: Sats,
|
||||
pub vsize: VSize,
|
||||
pub fee_rate: FeeRate,
|
||||
pub first_seen: Timestamp,
|
||||
pub kind: AddedKind,
|
||||
}
|
||||
13
crates/brk_mempool/src/cycle/tx_removed.rs
Normal file
13
crates/brk_mempool/src/cycle/tx_removed.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use brk_types::{FeeRate, Txid};
|
||||
|
||||
use crate::TxRemoval;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TxRemoved {
|
||||
pub txid: Txid,
|
||||
pub reason: TxRemoval,
|
||||
/// Package-effective rate at burial. Same value the tx reported
|
||||
/// while alive - RBF predecessors keep their package rate, not a
|
||||
/// misleading isolated fee/vsize.
|
||||
pub chunk_rate: FeeRate,
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
//! Per-cycle accumulator threaded through the pipeline steps and
|
||||
//! drained into the public [`crate::Cycle`] at end of cycle.
|
||||
|
||||
use crate::{TxAdded, TxRemoved, stores::AddrTransitions};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CycleDiff {
|
||||
pub added: Vec<TxAdded>,
|
||||
pub removed: Vec<TxRemoved>,
|
||||
pub addrs: AddrTransitions,
|
||||
}
|
||||
194
crates/brk_mempool/src/driver.rs
Normal file
194
crates/brk_mempool/src/driver.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
//! Cycle loop. `start_with` drives [`Mempool::tick_with`] every
|
||||
//! [`PERIOD`]. Each cycle is wrapped in `catch_unwind` so a panic
|
||||
//! doesn't freeze the snapshot. `parking_lot` locks don't poison.
|
||||
|
||||
use std::{
|
||||
any::Any,
|
||||
panic::{AssertUnwindSafe, catch_unwind},
|
||||
sync::atomic::Ordering,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use brk_error::Result;
|
||||
use brk_types::{TxOut, Txid, Vout};
|
||||
use rustc_hash::FxHashMap;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
Inner, Mempool,
|
||||
cycle::{Cycle, CycleDiff},
|
||||
steps::{Applier, Fetched, Fetcher, Preparer, Prevouts},
|
||||
};
|
||||
|
||||
const PERIOD: Duration = Duration::from_millis(1000);
|
||||
|
||||
impl Mempool {
|
||||
/// Infinite update loop with a 1s interval. Resolves
|
||||
/// confirmed-parent prevouts via the default `getrawtransaction`
|
||||
/// resolver. Requires bitcoind started with `txindex=1`. Discards
|
||||
/// per-cycle [`Cycle`] events - use [`Mempool::tick`] to consume them.
|
||||
pub fn start(&self) {
|
||||
self.start_with(Prevouts::rpc_resolver(self.0.client.clone()));
|
||||
}
|
||||
|
||||
/// Variant of `start` that uses a caller-supplied resolver for
|
||||
/// confirmed-parent prevouts (typically backed by an indexer).
|
||||
///
|
||||
/// Sleep is `PERIOD - work_duration`, so a 350ms cycle followed by
|
||||
/// a 100ms cycle still ticks roughly every `PERIOD`. When work
|
||||
/// overruns `PERIOD`, the next cycle starts immediately.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if a driver is already running on this `Mempool` instance.
|
||||
/// One `Mempool` may host at most one driver. Spawn another instance
|
||||
/// for additional loops.
|
||||
pub fn start_with<F>(&self, resolver: F)
|
||||
where
|
||||
F: Fn(&[(Txid, Vout)]) -> FxHashMap<(Txid, Vout), TxOut> + Send,
|
||||
{
|
||||
if self
|
||||
.0
|
||||
.started
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
panic!("Mempool::start_with already running on this instance");
|
||||
}
|
||||
loop {
|
||||
let started = Instant::now();
|
||||
let outcome = catch_unwind(AssertUnwindSafe(|| {
|
||||
if let Err(e) = self.tick_with(&resolver) {
|
||||
error!("update failed: {e}");
|
||||
}
|
||||
}));
|
||||
if let Err(payload) = outcome {
|
||||
error!(
|
||||
"mempool update panicked, continuing loop: {}",
|
||||
Self::panic_msg(&payload)
|
||||
);
|
||||
}
|
||||
if let Some(rest) = PERIOD.checked_sub(started.elapsed()) {
|
||||
thread::sleep(rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One sync cycle: fetch, prepare, apply, fill prevouts, rebuild.
|
||||
/// Returns a [`Cycle`] reporting everything that changed. Uses the
|
||||
/// default `getrawtransaction` resolver for confirmed-parent
|
||||
/// prevouts (requires `txindex=1`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates any failure from the initial RPC fetch (network drop,
|
||||
/// auth, bitcoind error). Steps after `Fetcher::fetch` are infallible
|
||||
/// today. The resolver itself swallows its own errors and retries
|
||||
/// next cycle.
|
||||
pub fn tick(&self) -> Result<Cycle> {
|
||||
self.tick_with(Prevouts::rpc_resolver(self.0.client.clone()))
|
||||
}
|
||||
|
||||
/// Variant of [`Mempool::tick`] with a caller-supplied resolver for
|
||||
/// confirmed-parent prevouts. The resolver MUST resolve confirmed
|
||||
/// prevouts only. Mempool-to-mempool chains are wired internally
|
||||
/// and the resolver is never called for them.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same as [`Mempool::tick`]: only the RPC fetch is fallible.
|
||||
pub fn tick_with<F>(&self, resolver: F) -> Result<Cycle>
|
||||
where
|
||||
F: Fn(&[(Txid, Vout)]) -> FxHashMap<(Txid, Vout), TxOut>,
|
||||
{
|
||||
let started = Instant::now();
|
||||
let Inner {
|
||||
client,
|
||||
state,
|
||||
rebuilder,
|
||||
..
|
||||
} = &*self.0;
|
||||
|
||||
let Fetched {
|
||||
state: rpc,
|
||||
new_entries,
|
||||
new_txs,
|
||||
block_template_txids,
|
||||
} = Fetcher::fetch(client, state)?;
|
||||
let pulled = Preparer::prepare(&rpc.live_txids, new_entries, new_txs, state);
|
||||
let mut diff = CycleDiff::default();
|
||||
let prev_snapshot = rebuilder.snapshot();
|
||||
Applier::apply(state, &prev_snapshot, pulled, &mut diff);
|
||||
drop(prev_snapshot);
|
||||
Prevouts::fill(state, &mut diff, resolver);
|
||||
rebuilder.tick(state, &block_template_txids, rpc.min_fee);
|
||||
let CycleDiff {
|
||||
added,
|
||||
removed,
|
||||
addrs,
|
||||
} = diff;
|
||||
let (addr_enters, addr_leaves) = addrs.into_vecs();
|
||||
|
||||
Ok(Cycle {
|
||||
added,
|
||||
removed,
|
||||
addr_enters,
|
||||
addr_leaves,
|
||||
tip_hash: rpc.tip_hash,
|
||||
tip_height: rpc.tip_height,
|
||||
info: self.info(),
|
||||
snapshot: rebuilder.snapshot(),
|
||||
took: started.elapsed(),
|
||||
})
|
||||
}
|
||||
|
||||
fn panic_msg(payload: &(dyn Any + Send)) -> &str {
|
||||
payload
|
||||
.downcast_ref::<&'static str>()
|
||||
.copied()
|
||||
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
|
||||
.unwrap_or("<non-string panic payload>")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::panic::catch_unwind;
|
||||
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Mempool::start_with already running on this instance")]
|
||||
fn double_start_panics_with_documented_message() {
|
||||
let mempool = Mempool::for_test();
|
||||
// Simulate a prior `start_with` having grabbed the latch. We
|
||||
// can't actually call it first because the real call enters an
|
||||
// infinite loop. Flipping the atomic is what the runtime check
|
||||
// observes anyway.
|
||||
mempool.0.started.store(true, Ordering::Release);
|
||||
mempool.start_with(|_: &[(Txid, Vout)]| FxHashMap::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panic_msg_extracts_static_str_payload() {
|
||||
let payload = catch_unwind(|| panic!("boom static")).unwrap_err();
|
||||
assert_eq!(Mempool::panic_msg(payload.as_ref()), "boom static");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panic_msg_extracts_string_payload() {
|
||||
let payload = catch_unwind(|| panic!("boom owned {}", 42)).unwrap_err();
|
||||
assert_eq!(Mempool::panic_msg(payload.as_ref()), "boom owned 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panic_msg_falls_back_for_non_string_payload() {
|
||||
// Payload that isn't &str or String: the helper labels it
|
||||
// explicitly instead of dropping it on the floor.
|
||||
let payload = catch_unwind(|| std::panic::panic_any(42u32)).unwrap_err();
|
||||
assert_eq!(Mempool::panic_msg(payload.as_ref()), "<non-string panic payload>");
|
||||
}
|
||||
}
|
||||
@@ -2,78 +2,84 @@
|
||||
//!
|
||||
//! One pull cycle, five steps:
|
||||
//!
|
||||
//! 1. [`steps::fetcher::Fetcher`] - one mixed batched RPC for
|
||||
//! ```text
|
||||
//! Fetcher -> Preparer -> Applier -> Prevouts -> Rebuilder
|
||||
//! RPC decode & write to fill build
|
||||
//! classify State missing Snapshot
|
||||
//! prevouts
|
||||
//! ```
|
||||
//!
|
||||
//! 1. [`steps::Fetcher`] - one mixed batched RPC for
|
||||
//! `getblocktemplate` + `getrawmempool false` + `getmempoolinfo`,
|
||||
//! then a single mixed `getmempoolentry`+`getrawtransaction` batch
|
||||
//! on new txids only. GBT-only txs are synthesized inline from the
|
||||
//! GBT payload so block 0 matches Core's selection exactly without
|
||||
//! a follow-up entry fetch that could race the listing.
|
||||
//! 2. [`steps::preparer::Preparer`] - decode and classify into
|
||||
//! 2. [`steps::Preparer`] - decode and classify into
|
||||
//! `TxsPulled { added, removed }`. Pure CPU.
|
||||
//! 3. [`steps::applier::Applier`] - apply the diff to
|
||||
//! [`state::State`] under a single write lock.
|
||||
//! 3. [`steps::Applier`] - apply the diff to [`state::State`] under a
|
||||
//! single write lock.
|
||||
//! 4. [`steps::Prevouts::fill`] - fills `prevout: None` inputs in one
|
||||
//! pass, using same-cycle in-mempool parents directly and the
|
||||
//! caller-supplied resolver (default: `getrawtransaction`) for
|
||||
//! confirmed parents.
|
||||
//! 5. [`steps::rebuilder::Rebuilder`] - rebuild of the projected-blocks
|
||||
//! `Snapshot` from the same-cycle GBT and min fee.
|
||||
//! 5. [`snapshot::Rebuilder`] - rebuilds the projected-blocks
|
||||
//! [`Snapshot`] from the same-cycle GBT and min fee.
|
||||
//!
|
||||
//! # Locking domains
|
||||
//!
|
||||
//! Two independent locks. No path holds both simultaneously.
|
||||
//!
|
||||
//! - `State` (`RwLock<State>`): the live mempool. Cycle steps 3 and 4
|
||||
//! take the write guard. Every read-side accessor takes a read guard.
|
||||
//! - `Rebuilder.{snapshot, history}` (two `RwLock`s, written in that
|
||||
//! order each cycle): the published projection. Readers grab one or
|
||||
//! the other. The cycle drops its `State` guard before touching them.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! Drive the loop on a worker thread and read from any clone:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use brk_mempool::Mempool;
|
||||
//! # fn make_client() -> brk_rpc::Client { unimplemented!() }
|
||||
//! let client = make_client();
|
||||
//! let mempool = Mempool::new(&client);
|
||||
//! let reader = mempool.clone();
|
||||
//! std::thread::spawn(move || mempool.start());
|
||||
//! // `reader.snapshot()`, `reader.block_template()`, etc. on this thread.
|
||||
//! # let _ = reader;
|
||||
//! ```
|
||||
//!
|
||||
//! A `Mempool` hosts at most one driver. Calling `start` / `start_with`
|
||||
//! a second time on the same instance panics. Spawn a separate
|
||||
//! `Mempool::new` if you need more loops.
|
||||
|
||||
use std::{
|
||||
any::Any,
|
||||
cmp::Reverse,
|
||||
panic::{AssertUnwindSafe, catch_unwind},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::sync::{Arc, atomic::AtomicBool};
|
||||
|
||||
use brk_error::Result;
|
||||
use brk_oracle::Histogram;
|
||||
use brk_rpc::Client;
|
||||
use brk_types::{
|
||||
AddrBytes, AddrMempoolStats, BlockTemplate, BlockTemplateDiff, BlockTemplateDiffEntry, FeeRate,
|
||||
MempoolBlock, MempoolInfo, MempoolRecentTx, NextBlockHash, OutpointPrefix, Timestamp,
|
||||
Transaction, TxOut, Txid, TxidPrefix, Vin, Vout,
|
||||
};
|
||||
use parking_lot::{RwLock, RwLockReadGuard};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use tracing::error;
|
||||
|
||||
mod cluster;
|
||||
mod cpfp;
|
||||
mod api;
|
||||
mod cycle;
|
||||
mod cycle_diff;
|
||||
mod diagnostics;
|
||||
mod rbf;
|
||||
mod driver;
|
||||
mod snapshot;
|
||||
mod state;
|
||||
pub(crate) mod steps;
|
||||
pub(crate) mod stores;
|
||||
mod steps;
|
||||
mod stores;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
||||
pub use api::{RbfForTx, RbfNode};
|
||||
pub use cycle::{AddedKind, Cycle, TxAdded, TxRemoved};
|
||||
pub use diagnostics::MempoolStats;
|
||||
pub use rbf::{RbfForTx, RbfNode};
|
||||
pub use steps::{Snapshot, TxRemoval};
|
||||
use steps::{Applier, Fetched, Fetcher, Preparer, Prevouts, Rebuilder};
|
||||
pub(crate) use cycle_diff::CycleDiff;
|
||||
pub(crate) use steps::{BlockStats, RecommendedFees, TxEntry};
|
||||
pub(crate) use stores::{AddrTransitions, TxStore, TxTombstone};
|
||||
pub use snapshot::Snapshot;
|
||||
pub use steps::TxRemoval;
|
||||
|
||||
/// Confirmed-parent prevout resolver passed to [`Mempool::tick_with`] /
|
||||
/// [`Mempool::start_with`]. Receives a slice of `(parent_txid, vout)`
|
||||
/// holes and returns the subset that resolved. Unresolved holes are
|
||||
/// simply omitted from the map; the next cycle retries automatically.
|
||||
///
|
||||
/// Batched so the RPC implementation can pack one round-trip per cycle
|
||||
/// (deduping by parent txid so a tx with N inputs from one parent costs
|
||||
/// one fetch); the indexer implementation just loops over local reads.
|
||||
pub type PrevoutResolver =
|
||||
Box<dyn Fn(&[(Txid, Vout)]) -> FxHashMap<(Txid, Vout), TxOut> + Send + Sync>;
|
||||
|
||||
pub(crate) use state::State;
|
||||
use snapshot::Rebuilder;
|
||||
use state::State;
|
||||
|
||||
/// Cheaply cloneable: clones share one live mempool via `Arc`.
|
||||
#[derive(Clone)]
|
||||
@@ -96,10 +102,6 @@ impl Mempool {
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn info(&self) -> MempoolInfo {
|
||||
self.read().info.clone()
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Arc<Snapshot> {
|
||||
self.0.rebuilder.snapshot()
|
||||
}
|
||||
@@ -109,330 +111,41 @@ impl Mempool {
|
||||
MempoolStats::from(self)
|
||||
}
|
||||
|
||||
pub(crate) fn rebuilder(&self) -> &Rebuilder {
|
||||
fn rebuilder(&self) -> &Rebuilder {
|
||||
&self.0.rebuilder
|
||||
}
|
||||
|
||||
pub fn fees(&self) -> RecommendedFees {
|
||||
self.snapshot().fees.clone()
|
||||
}
|
||||
|
||||
pub fn block_stats(&self) -> Vec<BlockStats> {
|
||||
self.snapshot().block_stats.clone()
|
||||
}
|
||||
|
||||
pub fn next_block_hash(&self) -> NextBlockHash {
|
||||
self.snapshot().next_block_hash
|
||||
}
|
||||
|
||||
/// Full projected next block: Core's `getblocktemplate` selection
|
||||
/// (block 0) with aggregate stats and full tx bodies in GBT order.
|
||||
pub fn block_template(&self) -> BlockTemplate {
|
||||
let snap = self.snapshot();
|
||||
BlockTemplate {
|
||||
hash: snap.next_block_hash,
|
||||
stats: snap
|
||||
.block_stats
|
||||
.first()
|
||||
.map(MempoolBlock::from)
|
||||
.unwrap_or_default(),
|
||||
transactions: self.collect_txs(snap.block0_txids()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delta of the projected next block since `since`. `None` when
|
||||
/// `since` has aged out of the rebuilder's history (server should
|
||||
/// 404 → client falls back to `block_template`).
|
||||
///
|
||||
/// `order` walks the new template in template order; each entry is
|
||||
/// either a `Retained` index into the prior template (which the
|
||||
/// client cached when it obtained `since`) or a `New` inline body.
|
||||
/// `removed` is the convenience list of txids that left.
|
||||
pub fn block_template_diff(&self, since: NextBlockHash) -> Option<BlockTemplateDiff> {
|
||||
let past = self.0.rebuilder.historical_block0(since)?;
|
||||
let prior_index: FxHashMap<Txid, u32> = past
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, txid)| (*txid, idx as u32))
|
||||
.collect();
|
||||
let snap = self.snapshot();
|
||||
let state = self.read();
|
||||
let mut order = Vec::with_capacity(snap.blocks.first().map_or(0, Vec::len));
|
||||
let mut current: FxHashSet<Txid> = FxHashSet::default();
|
||||
for txid in snap.block0_txids() {
|
||||
current.insert(txid);
|
||||
match prior_index.get(&txid) {
|
||||
Some(&idx) => order.push(BlockTemplateDiffEntry::Retained(idx)),
|
||||
None => {
|
||||
let tx = Self::lookup_body(&state, &txid)
|
||||
.expect("snapshot tx body must be in txs or graveyard");
|
||||
order.push(BlockTemplateDiffEntry::New(tx));
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
let removed = past.into_iter().filter(|t| !current.contains(t)).collect();
|
||||
Some(BlockTemplateDiff {
|
||||
hash: snap.next_block_hash,
|
||||
since,
|
||||
order,
|
||||
removed,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_txs(&self, txids: impl IntoIterator<Item = Txid>) -> Vec<Transaction> {
|
||||
let state = self.read();
|
||||
txids
|
||||
.into_iter()
|
||||
.map(|txid| {
|
||||
Self::lookup_body(&state, &txid)
|
||||
.expect("snapshot tx body must be in txs or graveyard")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Body for a txid in a published snapshot. Graveyard fallback
|
||||
/// covers the eviction race: an Applier may have buried the tx
|
||||
/// after the snapshot was built. Burial retention (1h) >> snapshot
|
||||
/// cycle (~1s), so reachability is guaranteed.
|
||||
fn lookup_body(state: &State, txid: &Txid) -> Option<Transaction> {
|
||||
state
|
||||
.txs
|
||||
.get(txid)
|
||||
.or_else(|| state.graveyard.get(txid).map(|t| &t.tx))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn addr_state_hash(&self, addr: &AddrBytes) -> u64 {
|
||||
self.read().addrs.stats_hash(addr)
|
||||
}
|
||||
|
||||
/// Mempool tx spending `(txid, vout)`, or `None`. The spender's
|
||||
/// input list is walked to rule out `TxidPrefix` collisions.
|
||||
pub fn lookup_spender(&self, txid: &Txid, vout: Vout) -> Option<(Txid, Vin)> {
|
||||
let key = OutpointPrefix::new(TxidPrefix::from(txid), vout);
|
||||
let state = self.read();
|
||||
let spender_prefix = state.outpoint_spends.get(&key)?;
|
||||
let spender = state.txs.record_by_prefix(&spender_prefix)?;
|
||||
let vin_pos = spender
|
||||
.tx
|
||||
.input
|
||||
.iter()
|
||||
.position(|inp| inp.txid == *txid && inp.vout == vout)?;
|
||||
Some((spender.entry.txid, Vin::from(vin_pos)))
|
||||
}
|
||||
|
||||
pub(crate) fn read(&self) -> RwLockReadGuard<'_, State> {
|
||||
fn read(&self) -> RwLockReadGuard<'_, State> {
|
||||
self.0.state.read()
|
||||
}
|
||||
|
||||
pub fn contains_txid(&self, txid: &Txid) -> bool {
|
||||
self.read().txs.contains(txid)
|
||||
}
|
||||
|
||||
/// Apply `f` to the live tx body if present.
|
||||
pub fn with_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> {
|
||||
self.read().txs.get(txid).map(f)
|
||||
}
|
||||
|
||||
/// Apply `f` to a `Vanished` tombstone's tx body if present.
|
||||
/// `Replaced` tombstones return `None` because the tx will not confirm.
|
||||
pub fn with_vanished_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> {
|
||||
self.read().graveyard.get_vanished(txid).map(|t| f(&t.tx))
|
||||
}
|
||||
|
||||
/// Snapshot of all live mempool txids.
|
||||
pub fn txids(&self) -> Vec<Txid> {
|
||||
self.read().txs.txids().copied().collect()
|
||||
}
|
||||
|
||||
/// Snapshot of recent live txs.
|
||||
pub fn recent_txs(&self) -> Vec<MempoolRecentTx> {
|
||||
self.read().txs.recent().to_vec()
|
||||
}
|
||||
|
||||
/// Per-address mempool stats. `None` if the address has no live mempool activity.
|
||||
pub fn addr_stats(&self, addr: &AddrBytes) -> Option<AddrMempoolStats> {
|
||||
self.read().addrs.get(addr).map(|e| e.stats.clone())
|
||||
}
|
||||
|
||||
/// Live mempool txs touching `addr`, newest first by `first_seen`,
|
||||
/// capped at `limit`. Returns owned `Transaction`s.
|
||||
pub fn addr_txs(&self, addr: &AddrBytes, limit: usize) -> Vec<Transaction> {
|
||||
let state = self.read();
|
||||
let Some(entry) = state.addrs.get(addr) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut ordered: Vec<(Timestamp, &Transaction)> = entry
|
||||
.txids
|
||||
.iter()
|
||||
.filter_map(|txid| {
|
||||
let record = state.txs.record_by_prefix(&TxidPrefix::from(txid))?;
|
||||
Some((record.entry.first_seen, &record.tx))
|
||||
})
|
||||
.collect();
|
||||
ordered.sort_unstable_by_key(|b| Reverse(b.0));
|
||||
ordered
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(_, tx)| tx.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Histogram of pre-bucketed oracle bins across all live mempool tx
|
||||
/// outputs. Bins are computed once on insert (see `OutputBins`), so this
|
||||
/// hot path is `O(eligible outputs)` of integer increments. Used by
|
||||
/// `live_price` to blend the mempool into the committed oracle without
|
||||
/// re-parsing scripts per request.
|
||||
pub fn live_histogram(&self) -> Histogram {
|
||||
let mut hist = Histogram::zeros();
|
||||
let state = self.read();
|
||||
for (_, record) in state.txs.records() {
|
||||
for bin in record.output_bins.iter() {
|
||||
hist.increment(bin as usize);
|
||||
}
|
||||
}
|
||||
hist
|
||||
}
|
||||
|
||||
/// Effective fee rate for a live tx: snapshot's linearized chunk
|
||||
/// rate. Falls back to `fee/vsize` for txs added since the latest
|
||||
/// snapshot was built (apply -> same-cycle tick gap).
|
||||
pub fn live_effective_fee_rate(&self, prefix: &TxidPrefix) -> Option<FeeRate> {
|
||||
if let Some(rate) = self.snapshot().chunk_rate_for(prefix) {
|
||||
return Some(rate);
|
||||
}
|
||||
self.read()
|
||||
.txs
|
||||
.entry_by_prefix(prefix)
|
||||
.map(|e| e.fee_rate())
|
||||
}
|
||||
|
||||
/// Linearized chunk rate captured at burial - same value
|
||||
/// `live_effective_fee_rate` returned while the tx was alive, so an
|
||||
/// evicted RBF predecessor reports the package-effective rate it
|
||||
/// had in the mempool, not a misleading isolated `fee/vsize`.
|
||||
pub fn graveyard_fee_rate(&self, txid: &Txid) -> Option<FeeRate> {
|
||||
self.read().graveyard.get(txid).map(|tomb| tomb.chunk_rate)
|
||||
}
|
||||
|
||||
/// `first_seen` Unix-second timestamps for `txids`, in input order.
|
||||
/// Returns 0 for unknown txids. `Vanished` tombstones fall back to
|
||||
/// the buried entry's `first_seen` to avoid flicker between drop
|
||||
/// and indexer catch-up.
|
||||
pub fn transaction_times(&self, txids: &[Txid]) -> Vec<u64> {
|
||||
let state = self.read();
|
||||
txids
|
||||
.iter()
|
||||
.map(|txid| state.first_seen(txid).map_or(0, u64::from))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Infinite update loop with a 500ms interval. Resolves
|
||||
/// confirmed-parent prevouts via the default `getrawtransaction`
|
||||
/// resolver; requires bitcoind started with `txindex=1`. Drops
|
||||
/// per-cycle [`Cycle`] events on the floor - use [`Mempool::tick`]
|
||||
/// to consume them.
|
||||
pub fn start(&self) {
|
||||
self.start_with(Prevouts::rpc_resolver(self.0.client.clone()));
|
||||
}
|
||||
|
||||
/// Variant of `start` that uses a caller-supplied resolver for
|
||||
/// confirmed-parent prevouts (typically backed by an indexer).
|
||||
/// Each cycle is wrapped in `catch_unwind` so a panic doesn't
|
||||
/// freeze the snapshot; `parking_lot` locks don't poison.
|
||||
///
|
||||
/// Sleep is `PERIOD - work_duration`, so a 350ms cycle followed by
|
||||
/// a 100ms cycle still ticks roughly every `PERIOD`. When work
|
||||
/// overruns `PERIOD`, the next cycle starts immediately.
|
||||
pub fn start_with<F>(&self, resolver: F)
|
||||
where
|
||||
F: Fn(&[(Txid, Vout)]) -> FxHashMap<(Txid, Vout), TxOut>,
|
||||
{
|
||||
if self
|
||||
.0
|
||||
.started
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
panic!("Mempool::start_with already running on this instance");
|
||||
}
|
||||
const PERIOD: Duration = Duration::from_millis(500);
|
||||
loop {
|
||||
let started = Instant::now();
|
||||
let outcome = catch_unwind(AssertUnwindSafe(|| {
|
||||
if let Err(e) = self.tick_with(&resolver) {
|
||||
error!("update failed: {e}");
|
||||
}
|
||||
}));
|
||||
if let Err(payload) = outcome {
|
||||
error!(
|
||||
"mempool update panicked, continuing loop: {}",
|
||||
panic_msg(&payload)
|
||||
);
|
||||
}
|
||||
if let Some(rest) = PERIOD.checked_sub(started.elapsed()) {
|
||||
thread::sleep(rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One sync cycle: fetch, prepare, apply, fill prevouts, rebuild.
|
||||
/// Returns a [`Cycle`] reporting everything that changed. Uses the
|
||||
/// default `getrawtransaction` resolver for confirmed-parent
|
||||
/// prevouts (requires `txindex=1`).
|
||||
pub fn tick(&self) -> Result<Cycle> {
|
||||
self.tick_with(Prevouts::rpc_resolver(self.0.client.clone()))
|
||||
}
|
||||
|
||||
/// Variant of [`Mempool::tick`] with a caller-supplied resolver for
|
||||
/// confirmed-parent prevouts. The resolver MUST resolve confirmed
|
||||
/// prevouts only; mempool-to-mempool chains are wired internally
|
||||
/// and the resolver is never called for them.
|
||||
pub fn tick_with<F>(&self, resolver: F) -> Result<Cycle>
|
||||
where
|
||||
F: Fn(&[(Txid, Vout)]) -> FxHashMap<(Txid, Vout), TxOut>,
|
||||
{
|
||||
let started = Instant::now();
|
||||
let Inner {
|
||||
client,
|
||||
state,
|
||||
rebuilder,
|
||||
..
|
||||
} = &*self.0;
|
||||
|
||||
let Fetched {
|
||||
state: rpc,
|
||||
new_entries,
|
||||
new_txs,
|
||||
block_template_txids,
|
||||
} = Fetcher::fetch(client, state)?;
|
||||
let pulled = Preparer::prepare(&rpc.live_txids, new_entries, new_txs, state);
|
||||
let mut diff = CycleDiff::default();
|
||||
Applier::apply(state, rebuilder, pulled, &mut diff);
|
||||
Prevouts::fill(state, &mut diff, resolver);
|
||||
rebuilder.tick(state, &block_template_txids, rpc.min_fee);
|
||||
let CycleDiff { added, removed, addrs } = diff;
|
||||
let (addr_enters, addr_leaves) = addrs.into_vecs();
|
||||
|
||||
Ok(Cycle {
|
||||
added,
|
||||
removed,
|
||||
addr_enters,
|
||||
addr_leaves,
|
||||
tip_hash: rpc.tip_hash,
|
||||
tip_height: rpc.tip_height,
|
||||
info: self.info(),
|
||||
snapshot: rebuilder.snapshot(),
|
||||
took: started.elapsed(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn panic_msg(payload: &(dyn Any + Send)) -> &str {
|
||||
payload
|
||||
.downcast_ref::<&'static str>()
|
||||
.copied()
|
||||
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
|
||||
.unwrap_or("<non-string panic payload>")
|
||||
#[cfg(test)]
|
||||
mod test_helpers {
|
||||
use brk_rpc::Auth;
|
||||
use brk_types::{FeeRate, Txid};
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Mempool {
|
||||
/// Test-only constructor that wires a Client at the default URL without
|
||||
/// touching the network. `simple_http` only parses the URL on init.
|
||||
pub(crate) fn for_test() -> Self {
|
||||
let client = Client::new(Client::default_url(), Auth::None).unwrap();
|
||||
Self(Arc::new(Inner {
|
||||
client,
|
||||
state: RwLock::new(State::default()),
|
||||
rebuilder: Rebuilder::default(),
|
||||
started: AtomicBool::new(false),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn test_state_lock(&self) -> &RwLock<State> {
|
||||
&self.0.state
|
||||
}
|
||||
|
||||
pub(crate) fn test_tick(&self, gbt_txids: &[Txid], min_fee: FeeRate) {
|
||||
self.0.rebuilder.tick(&self.0.state, gbt_txids, min_fee);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
//! RBF tree extraction. Returns owned trees so the caller can enrich
|
||||
//! with indexer data (`mined`, effective fee rate) after the lock
|
||||
//! drops: enriching under the lock re-enters `Mempool` and would
|
||||
//! recursively acquire the same read lock.
|
||||
|
||||
use brk_types::{Sats, Timestamp, Transaction, Txid, TxidPrefix, VSize};
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
use crate::{Mempool, TxEntry, TxStore, stores::TxGraveyard};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RbfNode {
|
||||
pub txid: Txid,
|
||||
pub fee: Sats,
|
||||
pub vsize: VSize,
|
||||
pub value: Sats,
|
||||
pub first_seen: Timestamp,
|
||||
/// BIP-125 signaling: at least one input has sequence < 0xffffffff-1.
|
||||
pub rbf: bool,
|
||||
/// `true` iff any predecessor in this subtree was non-signaling.
|
||||
pub full_rbf: bool,
|
||||
pub replaces: Vec<RbfNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RbfForTx {
|
||||
/// Tree rooted at the terminal replacer. `None` if `txid` is unknown.
|
||||
pub root: Option<RbfNode>,
|
||||
/// Direct predecessors of the requested tx (txids only).
|
||||
pub replaces: Vec<Txid>,
|
||||
}
|
||||
|
||||
impl Mempool {
|
||||
/// Walk forward through `Replaced { by }` to the terminal replacer
|
||||
/// and return its full predecessor tree, plus the requested tx's
|
||||
/// direct predecessors. Single read-lock window.
|
||||
pub fn rbf_for_tx(&self, txid: &Txid) -> RbfForTx {
|
||||
let state = self.read();
|
||||
|
||||
let root_txid = state.graveyard.replacement_root_of(*txid);
|
||||
let replaces: Vec<Txid> = state
|
||||
.graveyard
|
||||
.predecessors_of(txid)
|
||||
.map(|(p, _)| *p)
|
||||
.collect();
|
||||
let root = build_node(&root_txid, &state.txs, &state.graveyard);
|
||||
RbfForTx { root, replaces }
|
||||
}
|
||||
|
||||
/// Recent terminal-replacer trees, most-recent first, deduplicated
|
||||
/// by root, capped at `limit`. `full_rbf_only` drops trees with no
|
||||
/// non-signaling predecessor.
|
||||
pub fn recent_rbf_trees(&self, full_rbf_only: bool, limit: usize) -> Vec<RbfNode> {
|
||||
let state = self.read();
|
||||
|
||||
let mut seen: FxHashSet<Txid> = FxHashSet::default();
|
||||
state
|
||||
.graveyard
|
||||
.replaced_iter_recent_first()
|
||||
.filter_map(|(_, by)| {
|
||||
let root = state.graveyard.replacement_root_of(*by);
|
||||
seen.insert(root).then_some(root)
|
||||
})
|
||||
.filter_map(|root| build_node(&root, &state.txs, &state.graveyard))
|
||||
.filter(|n| !full_rbf_only || n.full_rbf)
|
||||
.take(limit)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_node(txid: &Txid, txs: &TxStore, graveyard: &TxGraveyard) -> Option<RbfNode> {
|
||||
let (tx, entry) = resolve_node(txid, txs, graveyard)?;
|
||||
|
||||
let replaces: Vec<RbfNode> = graveyard
|
||||
.predecessors_of(txid)
|
||||
.filter_map(|(pred, _)| build_node(pred, txs, graveyard))
|
||||
.collect();
|
||||
|
||||
let full_rbf = replaces.iter().any(|c| !c.rbf || c.full_rbf);
|
||||
let value: Sats = tx.output.iter().map(|o| o.value).sum();
|
||||
|
||||
Some(RbfNode {
|
||||
txid: *txid,
|
||||
fee: entry.fee,
|
||||
vsize: entry.vsize,
|
||||
value,
|
||||
first_seen: entry.first_seen,
|
||||
rbf: entry.rbf,
|
||||
full_rbf,
|
||||
replaces,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_node<'a>(
|
||||
txid: &Txid,
|
||||
txs: &'a TxStore,
|
||||
graveyard: &'a TxGraveyard,
|
||||
) -> Option<(&'a Transaction, &'a TxEntry)> {
|
||||
txs.record_by_prefix(&TxidPrefix::from(txid))
|
||||
.map(|r| (&r.tx, &r.entry))
|
||||
.or_else(|| graveyard.get(txid).map(|t| (&t.tx, &t.entry)))
|
||||
}
|
||||
@@ -33,7 +33,7 @@ pub struct BlockStats {
|
||||
|
||||
impl BlockStats {
|
||||
/// Stats for every projected block in `blocks`, in order. `blocks[0]`
|
||||
/// uses Core's exact 0..100 percentiles; the rest use the clipped
|
||||
/// uses Core's exact 0..100 percentiles. The rest use the clipped
|
||||
/// 5..95 range to hide CPFP / stale-GBT outliers.
|
||||
pub fn for_blocks(blocks: &[Vec<TxIndex>], txs: &[SnapTx]) -> Vec<Self> {
|
||||
blocks
|
||||
233
crates/brk_mempool/src/snapshot/builder.rs
Normal file
233
crates/brk_mempool/src/snapshot/builder.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
//! Build per-tx adjacency from the live `TxStore`, then run Single Fee
|
||||
//! Linearization over every multi-tx cluster.
|
||||
|
||||
use std::mem;
|
||||
|
||||
use brk_types::TxidPrefix;
|
||||
use rustc_hash::{FxBuildHasher, FxHashMap};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{state::TxEntry, stores::TxStore};
|
||||
|
||||
use super::{Cluster, SnapTx, Snapshot, TxIndex};
|
||||
|
||||
pub type PrefixIndex = FxHashMap<TxidPrefix, TxIndex>;
|
||||
|
||||
impl Snapshot {
|
||||
pub fn build_txs(txs: &TxStore) -> (Vec<SnapTx>, PrefixIndex) {
|
||||
let n = txs.len();
|
||||
let mut prefix_to_idx: PrefixIndex =
|
||||
FxHashMap::with_capacity_and_hasher(n, FxBuildHasher);
|
||||
for (i, (prefix, _)) in txs.records().enumerate() {
|
||||
prefix_to_idx.insert(*prefix, TxIndex::from(i));
|
||||
}
|
||||
let mut snap_txs: Vec<SnapTx> = txs
|
||||
.records()
|
||||
.map(|(_, record)| Self::live_tx(&record.entry, &prefix_to_idx))
|
||||
.collect();
|
||||
|
||||
Self::mirror_children(&mut snap_txs);
|
||||
Self::refresh_chunk_rates(&mut snap_txs);
|
||||
(snap_txs, prefix_to_idx)
|
||||
}
|
||||
|
||||
fn live_tx(e: &TxEntry, prefix_to_idx: &PrefixIndex) -> SnapTx {
|
||||
let parents: SmallVec<[TxIndex; 2]> = e
|
||||
.depends
|
||||
.iter()
|
||||
.filter_map(|p| prefix_to_idx.get(p).copied())
|
||||
.collect();
|
||||
SnapTx {
|
||||
txid: e.txid,
|
||||
fee: e.fee,
|
||||
vsize: e.vsize,
|
||||
weight: e.weight,
|
||||
size: e.size,
|
||||
chunk_rate: e.fee_rate(),
|
||||
parents,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mirror_children(txs: &mut [SnapTx]) {
|
||||
for i in 0..txs.len() {
|
||||
let child = TxIndex::from(i);
|
||||
let parents = mem::take(&mut txs[i].parents);
|
||||
for &p in &parents {
|
||||
if let Some(t) = txs.get_mut(p.as_usize()) {
|
||||
t.children.push(child);
|
||||
}
|
||||
}
|
||||
txs[i].parents = parents;
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk every multi-tx connected component once and overwrite each
|
||||
/// member's `chunk_rate` with the linearized chunk's feerate.
|
||||
/// Visited bitmap ensures each cluster is linearized exactly once.
|
||||
fn refresh_chunk_rates(snap_txs: &mut [SnapTx]) {
|
||||
let n = snap_txs.len();
|
||||
let mut visited = vec![false; n];
|
||||
for seed in 0..n {
|
||||
if visited[seed] {
|
||||
continue;
|
||||
}
|
||||
let t = &snap_txs[seed];
|
||||
if t.parents.is_empty() && t.children.is_empty() {
|
||||
visited[seed] = true;
|
||||
continue;
|
||||
}
|
||||
let component = Cluster::walk(snap_txs, TxIndex::from(seed));
|
||||
for &m in &component {
|
||||
visited[m.as_usize()] = true;
|
||||
}
|
||||
if component.len() <= 1 {
|
||||
continue;
|
||||
}
|
||||
let (members, chunks) = Cluster::linearize(snap_txs, &component);
|
||||
for chunk in &chunks {
|
||||
for &local in &chunk.txs {
|
||||
let m = members[u32::from(local) as usize];
|
||||
snap_txs[m.as_usize()].chunk_rate = chunk.feerate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use bitcoin::hashes::Hash;
|
||||
use brk_types::{FeeRate, Sats, Txid, VSize, Weight};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Build a `SnapTx` for tests. `txid` is auto-assigned from a
|
||||
/// process-wide counter so each tx is distinguishable in
|
||||
/// debug output. The cluster code itself keys off `TxIndex`,
|
||||
/// not `txid`.
|
||||
fn snap_tx(fee: Sats, vsize: VSize) -> SnapTx {
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[..4].copy_from_slice(&COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes());
|
||||
SnapTx {
|
||||
txid: Txid::from(bitcoin::Txid::from_byte_array(bytes)),
|
||||
fee,
|
||||
vsize,
|
||||
weight: Weight::from(vsize),
|
||||
size: u64::from(vsize),
|
||||
chunk_rate: FeeRate::from((fee, vsize)),
|
||||
parents: SmallVec::new(),
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn link(txs: &mut [SnapTx], parent: usize, child: usize) {
|
||||
txs[child].parents.push(TxIndex::from(parent));
|
||||
txs[parent].children.push(TxIndex::from(child));
|
||||
}
|
||||
|
||||
fn sats(n: u64) -> Sats {
|
||||
Sats::from(n)
|
||||
}
|
||||
|
||||
fn vsize(n: u64) -> VSize {
|
||||
VSize::from(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_keeps_fee_per_vsize() {
|
||||
let mut txs = vec![snap_tx(sats(1000), vsize(100))];
|
||||
let seed = txs[0].chunk_rate;
|
||||
Snapshot::refresh_chunk_rates(&mut txs);
|
||||
assert_eq!(txs[0].chunk_rate, seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_tx_cpfp_lift() {
|
||||
let mut txs = vec![
|
||||
snap_tx(sats(100), vsize(100)),
|
||||
snap_tx(sats(1900), vsize(100)),
|
||||
];
|
||||
link(&mut txs, 0, 1);
|
||||
let parent_seed = txs[0].chunk_rate;
|
||||
Snapshot::refresh_chunk_rates(&mut txs);
|
||||
assert!(txs[0].chunk_rate > parent_seed);
|
||||
assert_eq!(txs[0].chunk_rate, txs[1].chunk_rate);
|
||||
assert_eq!(txs[0].chunk_rate, FeeRate::from((sats(2000), vsize(200))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_tx_chain_chunks_correctly() {
|
||||
let mut txs = vec![
|
||||
snap_tx(sats(100), vsize(100)),
|
||||
snap_tx(sats(100), vsize(100)),
|
||||
snap_tx(sats(5800), vsize(100)),
|
||||
];
|
||||
link(&mut txs, 0, 1);
|
||||
link(&mut txs, 1, 2);
|
||||
Snapshot::refresh_chunk_rates(&mut txs);
|
||||
let combined = FeeRate::from((sats(6000), vsize(300)));
|
||||
assert_eq!(txs[0].chunk_rate, combined);
|
||||
assert_eq!(txs[1].chunk_rate, combined);
|
||||
assert_eq!(txs[2].chunk_rate, combined);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_clusters_linearized_independently() {
|
||||
let mut txs = vec![
|
||||
snap_tx(sats(100), vsize(100)),
|
||||
snap_tx(sats(1900), vsize(100)),
|
||||
snap_tx(sats(500), vsize(100)),
|
||||
snap_tx(sats(4500), vsize(100)),
|
||||
];
|
||||
link(&mut txs, 0, 1);
|
||||
link(&mut txs, 2, 3);
|
||||
Snapshot::refresh_chunk_rates(&mut txs);
|
||||
assert_eq!(txs[0].chunk_rate, txs[1].chunk_rate);
|
||||
assert_eq!(txs[2].chunk_rate, txs[3].chunk_rate);
|
||||
assert_ne!(txs[0].chunk_rate, txs[2].chunk_rate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_cap_does_not_panic() {
|
||||
let n = 100;
|
||||
let mut txs: Vec<SnapTx> = (0..n).map(|_| snap_tx(sats(1000), vsize(100))).collect();
|
||||
for i in 1..n {
|
||||
link(&mut txs, i - 1, i);
|
||||
}
|
||||
Snapshot::refresh_chunk_rates(&mut txs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_chunk_rates_is_order_independent_within_clusters() {
|
||||
let mut a = vec![
|
||||
snap_tx(sats(1_000), vsize(100)),
|
||||
snap_tx(sats(100), vsize(100)),
|
||||
snap_tx(sats(5_000), vsize(100)),
|
||||
snap_tx(sats(200), vsize(100)),
|
||||
];
|
||||
link(&mut a, 0, 1);
|
||||
link(&mut a, 2, 3);
|
||||
Snapshot::refresh_chunk_rates(&mut a);
|
||||
|
||||
// Same pool, members of each cluster reordered.
|
||||
let mut b = vec![
|
||||
snap_tx(sats(100), vsize(100)),
|
||||
snap_tx(sats(1_000), vsize(100)),
|
||||
snap_tx(sats(200), vsize(100)),
|
||||
snap_tx(sats(5_000), vsize(100)),
|
||||
];
|
||||
link(&mut b, 1, 0);
|
||||
link(&mut b, 3, 2);
|
||||
Snapshot::refresh_chunk_rates(&mut b);
|
||||
|
||||
let mut rates_a: Vec<f64> = a.iter().map(|t| f64::from(t.chunk_rate)).collect();
|
||||
let mut rates_b: Vec<f64> = b.iter().map(|t| f64::from(t.chunk_rate)).collect();
|
||||
rates_a.sort_by(|x, y| x.partial_cmp(y).unwrap());
|
||||
rates_b.sort_by(|x, y| x.partial_cmp(y).unwrap());
|
||||
assert_eq!(rates_a, rates_b);
|
||||
}
|
||||
}
|
||||
133
crates/brk_mempool/src/snapshot/cluster.rs
Normal file
133
crates/brk_mempool/src/snapshot/cluster.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
//! Cluster primitives over `SnapTx` adjacency: connected-component
|
||||
//! discovery, topo-sort, and the glue to Single Fee Linearization
|
||||
//! ([`brk_types::linearize`], shared with brk_query's confirmed-cpfp).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use brk_types::{ChunkInput, CpfpClusterChunk, CpfpClusterTxIndex, linearize};
|
||||
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::{SnapTx, TxIndex};
|
||||
|
||||
/// Matches Bitcoin Core 31's `MAX_CLUSTER_COUNT_LIMIT`.
|
||||
pub const MAX_CLUSTER: usize = 64;
|
||||
|
||||
pub struct Cluster;
|
||||
|
||||
impl Cluster {
|
||||
/// Capped DFS over the undirected dependency graph (`parents ∪
|
||||
/// children`) starting from `seed`. Returns the connected component
|
||||
/// truncated to `MAX_CLUSTER`, with `seed` at index 0.
|
||||
pub fn walk(txs: &[SnapTx], seed: TxIndex) -> Vec<TxIndex> {
|
||||
if txs.get(seed.as_usize()).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut visited: FxHashSet<TxIndex> =
|
||||
FxHashSet::with_capacity_and_hasher(MAX_CLUSTER, FxBuildHasher);
|
||||
visited.insert(seed);
|
||||
let mut out: Vec<TxIndex> = Vec::with_capacity(MAX_CLUSTER);
|
||||
out.push(seed);
|
||||
let mut stack: Vec<TxIndex> = vec![seed];
|
||||
while let Some(idx) = stack.pop() {
|
||||
let Some(t) = txs.get(idx.as_usize()) else {
|
||||
continue;
|
||||
};
|
||||
for &n in t.parents.iter().chain(t.children.iter()) {
|
||||
if out.len() >= MAX_CLUSTER {
|
||||
return out;
|
||||
}
|
||||
if visited.insert(n) {
|
||||
out.push(n);
|
||||
stack.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Linearize the connected component into chunks. Topo-sorts members,
|
||||
/// remaps parent edges to cluster-local indices, and runs SFL. Returns
|
||||
/// `(members, chunks)` where `members` is the topo-ordered `TxIndex`
|
||||
/// list and `chunks[*].txs` are local indices into `members`. Callers
|
||||
/// must filter singletons before calling - the singleton's `chunk_rate`
|
||||
/// is `fee/vsize`, set elsewhere.
|
||||
pub fn linearize(
|
||||
txs: &[SnapTx],
|
||||
component: &[TxIndex],
|
||||
) -> (Vec<TxIndex>, Vec<CpfpClusterChunk>) {
|
||||
let members = Self::topo_sort(txs, component);
|
||||
let local_of = Self::local_index(&members);
|
||||
let parents_local: Vec<SmallVec<[CpfpClusterTxIndex; 2]>> = members
|
||||
.iter()
|
||||
.map(|idx| {
|
||||
txs[idx.as_usize()]
|
||||
.parents
|
||||
.iter()
|
||||
.filter_map(|p| local_of.get(p).copied())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let inputs: Vec<ChunkInput<'_>> = members
|
||||
.iter()
|
||||
.zip(&parents_local)
|
||||
.map(|(idx, ps)| {
|
||||
let t = &txs[idx.as_usize()];
|
||||
ChunkInput {
|
||||
fee: t.fee,
|
||||
vsize: t.vsize,
|
||||
parents: ps.as_slice(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let chunks = linearize(&inputs);
|
||||
(members, chunks)
|
||||
}
|
||||
|
||||
/// `members[i]`'s wire index, keyed by snapshot `TxIndex`. Built once
|
||||
/// so per-tx parent edges can be remapped without a linear scan.
|
||||
pub fn local_index(members: &[TxIndex]) -> FxHashMap<TxIndex, CpfpClusterTxIndex> {
|
||||
members
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &idx)| (idx, CpfpClusterTxIndex::from(i as u32)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Kahn's topological sort over the connected component, restricted to
|
||||
/// in-cluster parent edges. Returns members in an order where every tx
|
||||
/// follows all its in-cluster parents.
|
||||
fn topo_sort(txs: &[SnapTx], component: &[TxIndex]) -> Vec<TxIndex> {
|
||||
let n = component.len();
|
||||
let pos: FxHashMap<TxIndex, usize> = component
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &x)| (x, i))
|
||||
.collect();
|
||||
let mut indeg: Vec<u32> = vec![0; n];
|
||||
let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for (i, &idx) in component.iter().enumerate() {
|
||||
let Some(t) = txs.get(idx.as_usize()) else {
|
||||
continue;
|
||||
};
|
||||
indeg[i] = t.parents.iter().filter(|p| pos.contains_key(p)).count() as u32;
|
||||
for &c in t.children.iter() {
|
||||
if let Some(&ci) = pos.get(&c) {
|
||||
children[i].push(ci);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut queue: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
|
||||
let mut out: Vec<TxIndex> = Vec::with_capacity(n);
|
||||
while let Some(i) = queue.pop_front() {
|
||||
out.push(component[i]);
|
||||
for &c in &children[i] {
|
||||
indeg[c] -= 1;
|
||||
if indeg[c] == 0 {
|
||||
queue.push_back(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
248
crates/brk_mempool/src/snapshot/cpfp.rs
Normal file
248
crates/brk_mempool/src/snapshot/cpfp.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
//! CPFP (Child Pays For Parent) walk over a `Snapshot`'s adjacency.
|
||||
//!
|
||||
//! Three independent walks:
|
||||
//! - `ancestors`: capped DFS up `parents` only.
|
||||
//! - `descendants`: capped DFS down `children` only.
|
||||
//! - cluster: connected component over `parents ∪ children`,
|
||||
//! linearized for wire shape and seed chunk feerate.
|
||||
|
||||
use brk_types::{
|
||||
CPFP_CHAIN_LIMIT, CpfpCluster, CpfpClusterTx, CpfpClusterTxIndex, CpfpEntry, CpfpInfo, FeeRate,
|
||||
SigOps, TxidPrefix, VSize, find_seed_chunk,
|
||||
};
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
|
||||
use crate::Mempool;
|
||||
|
||||
use super::{Cluster, SnapTx, Snapshot, TxIndex};
|
||||
|
||||
impl Mempool {
|
||||
/// CPFP info for a live mempool tx. Returns `None` when the tx
|
||||
/// isn't in the live pool, so callers can fall through to the
|
||||
/// confirmed path. The snapshot can lag `state.txs` by up to one
|
||||
/// cycle: if the seed is in the snapshot but no longer in live
|
||||
/// state we return `None` rather than a half-stale report.
|
||||
pub fn cpfp_info(&self, prefix: &TxidPrefix) -> Option<CpfpInfo> {
|
||||
let snapshot = self.snapshot();
|
||||
let seed_idx = snapshot.idx_of(prefix)?;
|
||||
let seed = snapshot.tx(seed_idx)?;
|
||||
|
||||
let sigops = self.read().txs.get(&seed.txid)?.total_sigop_cost;
|
||||
|
||||
Some(snapshot.cpfp_info_at(seed_idx, seed, sigops))
|
||||
}
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
fn cpfp_info_at(&self, seed_idx: TxIndex, seed: &SnapTx, sigops: SigOps) -> CpfpInfo {
|
||||
let ancestors = Self::collect_cpfp_entries(&self.txs, seed_idx, |t| &t.parents);
|
||||
let descendants = Self::collect_cpfp_entries(&self.txs, seed_idx, |t| &t.children);
|
||||
let best_descendant = descendants
|
||||
.iter()
|
||||
.max_by_key(|e| FeeRate::from((e.fee, e.weight)))
|
||||
.cloned();
|
||||
|
||||
let (cluster, effective_fee_per_vsize) = Self::build_cpfp_cluster(&self.txs, seed_idx, seed);
|
||||
let vsize = VSize::from(seed.weight);
|
||||
|
||||
CpfpInfo {
|
||||
ancestors,
|
||||
best_descendant,
|
||||
descendants,
|
||||
effective_fee_per_vsize,
|
||||
sigops,
|
||||
fee: seed.fee,
|
||||
vsize,
|
||||
adjusted_vsize: sigops.adjust_vsize(vsize),
|
||||
cluster,
|
||||
}
|
||||
}
|
||||
|
||||
/// Capped DFS from `seed` (exclusive) along `next`, lifted directly
|
||||
/// to wire-shape `CpfpEntry`s. Used for both ancestor and descendant
|
||||
/// walks.
|
||||
fn collect_cpfp_entries(
|
||||
txs: &[SnapTx],
|
||||
seed: TxIndex,
|
||||
next: impl Fn(&SnapTx) -> &[TxIndex],
|
||||
) -> Vec<CpfpEntry> {
|
||||
let Some(seed_node) = txs.get(seed.as_usize()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut visited: FxHashSet<TxIndex> =
|
||||
FxHashSet::with_capacity_and_hasher(CPFP_CHAIN_LIMIT + 1, FxBuildHasher);
|
||||
visited.insert(seed);
|
||||
let mut out: Vec<CpfpEntry> = Vec::with_capacity(CPFP_CHAIN_LIMIT);
|
||||
let mut stack: Vec<TxIndex> = next(seed_node).to_vec();
|
||||
while let Some(idx) = stack.pop() {
|
||||
if out.len() >= CPFP_CHAIN_LIMIT {
|
||||
break;
|
||||
}
|
||||
if !visited.insert(idx) {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = txs.get(idx.as_usize()) {
|
||||
out.push(CpfpEntry::from(t));
|
||||
stack.extend(next(t).iter().copied());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Wire-shape `CpfpCluster` plus the seed's chunk feerate. Members
|
||||
/// are the connected component of the seed in the dependency graph,
|
||||
/// topologically ordered (parents before children) so wire indices
|
||||
/// and chunk-internal ordering are valid for client-side
|
||||
/// reconstruction. Returns `(None, seed_per_tx_rate)` for singletons
|
||||
/// (matches mempool.space, which omits `cluster` when no relations
|
||||
/// exist).
|
||||
fn build_cpfp_cluster(
|
||||
txs: &[SnapTx],
|
||||
seed_idx: TxIndex,
|
||||
seed: &SnapTx,
|
||||
) -> (Option<CpfpCluster>, FeeRate) {
|
||||
let seed_per_tx_rate = FeeRate::from((seed.fee, seed.vsize));
|
||||
let component = Cluster::walk(txs, seed_idx);
|
||||
if component.len() <= 1 {
|
||||
return (None, seed_per_tx_rate);
|
||||
}
|
||||
|
||||
let (members, chunks) = Cluster::linearize(txs, &component);
|
||||
let cluster_txs = Self::wire_cluster_members(txs, &members);
|
||||
let seed_local = CpfpClusterTxIndex::from(
|
||||
members
|
||||
.iter()
|
||||
.position(|&i| i == seed_idx)
|
||||
.map_or(0, |p| p as u32),
|
||||
);
|
||||
let (chunk_index, seed_chunk_rate) = find_seed_chunk(&chunks, seed_local, seed_per_tx_rate);
|
||||
|
||||
(
|
||||
Some(CpfpCluster {
|
||||
txs: cluster_txs,
|
||||
chunks,
|
||||
chunk_index,
|
||||
}),
|
||||
seed_chunk_rate,
|
||||
)
|
||||
}
|
||||
|
||||
/// Materialize wire-shape `CpfpClusterTx`s for every topo-ordered
|
||||
/// member with parent edges remapped to local indices.
|
||||
fn wire_cluster_members(txs: &[SnapTx], members: &[TxIndex]) -> Vec<CpfpClusterTx> {
|
||||
let local_of = Cluster::local_index(members);
|
||||
members
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
let t = &txs[idx.as_usize()];
|
||||
CpfpClusterTx {
|
||||
txid: t.txid,
|
||||
weight: t.weight,
|
||||
fee: t.fee,
|
||||
parents: t
|
||||
.parents
|
||||
.iter()
|
||||
.filter_map(|p| local_of.get(p).copied())
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use brk_types::{FeeRate, Txid};
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
state::TxEntry,
|
||||
test_support::{fake_entry_info, fake_tx, p2wpkh_script},
|
||||
};
|
||||
|
||||
/// Insert a tx, optionally declaring parent dependencies for the
|
||||
/// snapshot builder's adjacency wire-up.
|
||||
fn insert_with_depends(
|
||||
mempool: &Mempool,
|
||||
seed: u8,
|
||||
fee: u64,
|
||||
vsize: u64,
|
||||
parents: &[Txid],
|
||||
) -> Txid {
|
||||
let tx = fake_tx(seed, &[None], &[(p2wpkh_script(seed + 1), 1_234)]);
|
||||
let txid = tx.txid;
|
||||
let mut info = fake_entry_info(txid, fee, vsize);
|
||||
info.depends = parents.to_vec();
|
||||
let entry = TxEntry::new(&info, vsize, false);
|
||||
let mut state = mempool.test_state_lock().write();
|
||||
state.txs.insert(tx, entry);
|
||||
txid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_cpfp_info_has_no_cluster() {
|
||||
let mempool = Mempool::for_test();
|
||||
let txid = insert_with_depends(&mempool, 0xB0, 10_000, 100, &[]);
|
||||
mempool.test_tick(&[txid], FeeRate::new(1.0));
|
||||
|
||||
let info = mempool
|
||||
.cpfp_info(&TxidPrefix::from(&txid))
|
||||
.expect("tx is in mempool");
|
||||
assert!(info.cluster.is_none(), "singletons emit no cluster");
|
||||
assert!(info.ancestors.is_empty());
|
||||
assert!(info.descendants.is_empty());
|
||||
// Effective rate equals isolated rate when there's no package lift.
|
||||
let isolated = FeeRate::from((info.fee, info.vsize));
|
||||
assert_eq!(info.effective_fee_per_vsize, isolated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_tx_cpfp_cluster_has_both_members_and_lifted_rate() {
|
||||
let mempool = Mempool::for_test();
|
||||
let parent = insert_with_depends(&mempool, 0xB1, 100, 100, &[]);
|
||||
let child = insert_with_depends(&mempool, 0xB2, 1_900, 100, &[parent]);
|
||||
mempool.test_tick(&[parent, child], FeeRate::new(1.0));
|
||||
|
||||
let parent_info = mempool.cpfp_info(&TxidPrefix::from(&parent)).unwrap();
|
||||
let cluster = parent_info.cluster.expect("two-tx cluster present");
|
||||
assert_eq!(cluster.txs.len(), 2);
|
||||
// Topological order: parent first.
|
||||
assert_eq!(cluster.txs[0].txid, parent);
|
||||
assert_eq!(cluster.txs[1].txid, child);
|
||||
// Child reports the parent as its only local parent.
|
||||
assert_eq!(cluster.txs[1].parents.len(), 1);
|
||||
// CPFP lift: parent's effective rate exceeds its isolated rate.
|
||||
let parent_isolated = FeeRate::from((parent_info.fee, parent_info.vsize));
|
||||
assert!(parent_info.effective_fee_per_vsize > parent_isolated);
|
||||
// Same package -> child's reported chunk rate matches parent's.
|
||||
let child_info = mempool.cpfp_info(&TxidPrefix::from(&child)).unwrap();
|
||||
assert_eq!(parent_info.effective_fee_per_vsize, child_info.effective_fee_per_vsize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpfp_ancestor_and_descendant_walks_are_directional() {
|
||||
// chain: A -> B -> C
|
||||
let mempool = Mempool::for_test();
|
||||
let a = insert_with_depends(&mempool, 0xB3, 100, 100, &[]);
|
||||
let b = insert_with_depends(&mempool, 0xB4, 100, 100, &[a]);
|
||||
let c = insert_with_depends(&mempool, 0xB5, 5_800, 100, &[b]);
|
||||
mempool.test_tick(&[a, b, c], FeeRate::new(1.0));
|
||||
|
||||
// B sees A as an ancestor and C as a descendant.
|
||||
let info_b = mempool.cpfp_info(&TxidPrefix::from(&b)).unwrap();
|
||||
let ancestor_ids: Vec<_> = info_b.ancestors.iter().map(|e| e.txid).collect();
|
||||
let descendant_ids: Vec<_> = info_b.descendants.iter().map(|e| e.txid).collect();
|
||||
assert_eq!(ancestor_ids, vec![a]);
|
||||
assert_eq!(descendant_ids, vec![c]);
|
||||
// best_descendant picks the highest-rate descendant.
|
||||
assert_eq!(info_b.best_descendant.as_ref().map(|e| e.txid), Some(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpfp_info_returns_none_for_unknown_txid() {
|
||||
let mempool = Mempool::for_test();
|
||||
mempool.test_tick(&[], FeeRate::new(1.0));
|
||||
let bogus = TxidPrefix::from(&Txid::COINBASE);
|
||||
assert!(mempool.cpfp_info(&bogus).is_none());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
215
crates/brk_mempool/src/snapshot/mod.rs
Normal file
215
crates/brk_mempool/src/snapshot/mod.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
mod block_stats;
|
||||
mod builder;
|
||||
mod cluster;
|
||||
mod cpfp;
|
||||
mod fees;
|
||||
mod partition;
|
||||
mod rebuilder;
|
||||
mod snap_tx;
|
||||
mod tx_index;
|
||||
|
||||
pub use block_stats::BlockStats;
|
||||
pub use cluster::Cluster;
|
||||
pub use rebuilder::Rebuilder;
|
||||
pub use snap_tx::SnapTx;
|
||||
pub use tx_index::TxIndex;
|
||||
|
||||
use builder::PrefixIndex;
|
||||
use fees::Fees;
|
||||
use partition::Partitioner;
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use brk_types::{FeeRate, NextBlockHash, RecommendedFees, Txid, TxidPrefix};
|
||||
use rustc_hash::FxHasher;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Snapshot {
|
||||
/// Dense per-tx data indexed by `TxIndex`. Each entry carries the
|
||||
/// linearized chunk rate plus parent/child adjacency.
|
||||
pub txs: Vec<SnapTx>,
|
||||
/// Projected blocks. `blocks[0]` is Core's `getblocktemplate`
|
||||
/// (Bitcoin Core's actual selection). The rest are greedy-packed
|
||||
/// by descending chunk rate, with a final overflow block.
|
||||
pub blocks: Vec<Vec<TxIndex>>,
|
||||
pub block_stats: Vec<BlockStats>,
|
||||
pub fees: RecommendedFees,
|
||||
/// Content hash of the projected next block. Same value as the
|
||||
/// mempool `ETag`.
|
||||
pub next_block_hash: NextBlockHash,
|
||||
prefix_to_idx: PrefixIndex,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// `min_fee` is bitcoind's live `mempoolminfee`, the floor for
|
||||
/// every recommended-fee tier.
|
||||
fn build(
|
||||
txs: Vec<SnapTx>,
|
||||
blocks: Vec<Vec<TxIndex>>,
|
||||
prefix_to_idx: PrefixIndex,
|
||||
min_fee: FeeRate,
|
||||
) -> Self {
|
||||
let block_stats = BlockStats::for_blocks(&blocks, &txs);
|
||||
let fees = Fees::compute(&block_stats, min_fee);
|
||||
let next_block_hash = Self::hash_next_block(&blocks, &txs);
|
||||
Self {
|
||||
txs,
|
||||
blocks,
|
||||
block_stats,
|
||||
fees,
|
||||
next_block_hash,
|
||||
prefix_to_idx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Content tag over block 0 in template order. Hashes txids, not
|
||||
/// `TxIndex` slots, because slot assignment is per-cycle.
|
||||
fn hash_next_block(blocks: &[Vec<TxIndex>], txs: &[SnapTx]) -> NextBlockHash {
|
||||
let Some(block) = blocks.first() else {
|
||||
return NextBlockHash::ZERO;
|
||||
};
|
||||
let mut hasher = FxHasher::default();
|
||||
for idx in block {
|
||||
txs[idx.as_usize()].txid.hash(&mut hasher);
|
||||
}
|
||||
NextBlockHash::new(hasher.finish())
|
||||
}
|
||||
|
||||
pub fn tx(&self, idx: TxIndex) -> Option<&SnapTx> {
|
||||
self.txs.get(idx.as_usize())
|
||||
}
|
||||
|
||||
pub fn idx_of(&self, prefix: &TxidPrefix) -> Option<TxIndex> {
|
||||
self.prefix_to_idx.get(prefix).copied()
|
||||
}
|
||||
|
||||
/// Txids of `blocks[0]` (Core's `getblocktemplate` selection),
|
||||
/// in template order. Empty for a default snapshot.
|
||||
pub fn block0_txids(&self) -> impl Iterator<Item = Txid> + '_ {
|
||||
self.blocks
|
||||
.first()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|idx| self.txs[idx.as_usize()].txid)
|
||||
}
|
||||
|
||||
/// Linearized chunk rate for a live tx by prefix. Recomputed each
|
||||
/// snapshot, package-aware (CPFP lifts apply), equals `fee/vsize`
|
||||
/// for singletons.
|
||||
pub fn chunk_rate_for(&self, prefix: &TxidPrefix) -> Option<FeeRate> {
|
||||
let idx = self.idx_of(prefix)?;
|
||||
Some(self.txs[idx.as_usize()].chunk_rate)
|
||||
}
|
||||
|
||||
/// Test-only: stitch a snapshot from `(prefix, chunk_rate)` pairs
|
||||
/// without running the full builder.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_test_with_chunk_rates(entries: &[(TxidPrefix, FeeRate, Txid)]) -> Self {
|
||||
use brk_types::{Sats, VSize, Weight};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
let mut prefix_to_idx = PrefixIndex::default();
|
||||
let mut txs = Vec::with_capacity(entries.len());
|
||||
for (i, (prefix, rate, txid)) in entries.iter().enumerate() {
|
||||
prefix_to_idx.insert(*prefix, TxIndex::from(i));
|
||||
txs.push(SnapTx {
|
||||
txid: *txid,
|
||||
fee: Sats::ZERO,
|
||||
vsize: VSize::from(0u64),
|
||||
weight: Weight::from(0u64),
|
||||
size: 0,
|
||||
chunk_rate: *rate,
|
||||
parents: SmallVec::new(),
|
||||
children: SmallVec::new(),
|
||||
});
|
||||
}
|
||||
Self {
|
||||
txs,
|
||||
blocks: vec![],
|
||||
block_stats: vec![],
|
||||
fees: RecommendedFees::default(),
|
||||
next_block_hash: NextBlockHash::ZERO,
|
||||
prefix_to_idx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bitcoin::hashes::Hash;
|
||||
use brk_types::{Sats, VSize, Weight};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn snap_tx(seed: u8) -> SnapTx {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[0] = seed;
|
||||
SnapTx {
|
||||
txid: Txid::from(bitcoin::Txid::from_byte_array(bytes)),
|
||||
fee: Sats::from(1_234u64),
|
||||
vsize: VSize::from(100u64),
|
||||
weight: Weight::from(400u64),
|
||||
size: 100,
|
||||
chunk_rate: FeeRate::from((Sats::from(1_234u64), VSize::from(100u64))),
|
||||
parents: SmallVec::new(),
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_block_hash_is_deterministic_across_runs() {
|
||||
let txs = vec![snap_tx(1), snap_tx(2), snap_tx(3)];
|
||||
let blocks = vec![vec![
|
||||
TxIndex::from(0usize),
|
||||
TxIndex::from(1usize),
|
||||
TxIndex::from(2usize),
|
||||
]];
|
||||
let h1 = Snapshot::hash_next_block(&blocks, &txs);
|
||||
let h2 = Snapshot::hash_next_block(&blocks, &txs);
|
||||
assert_eq!(h1, h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_block_hash_changes_with_block0_membership() {
|
||||
let txs = vec![snap_tx(1), snap_tx(2), snap_tx(3)];
|
||||
let two_member = vec![vec![TxIndex::from(0usize), TxIndex::from(1usize)]];
|
||||
let three_member = vec![vec![
|
||||
TxIndex::from(0usize),
|
||||
TxIndex::from(1usize),
|
||||
TxIndex::from(2usize),
|
||||
]];
|
||||
assert_ne!(
|
||||
Snapshot::hash_next_block(&two_member, &txs),
|
||||
Snapshot::hash_next_block(&three_member, &txs),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_block_hash_changes_with_block0_order() {
|
||||
// hash_next_block hashes txids in template order: reordering
|
||||
// block 0 must produce a different hash.
|
||||
let txs = vec![snap_tx(1), snap_tx(2), snap_tx(3)];
|
||||
let forward = vec![vec![
|
||||
TxIndex::from(0usize),
|
||||
TxIndex::from(1usize),
|
||||
TxIndex::from(2usize),
|
||||
]];
|
||||
let reversed = vec![vec![
|
||||
TxIndex::from(2usize),
|
||||
TxIndex::from(1usize),
|
||||
TxIndex::from(0usize),
|
||||
]];
|
||||
assert_ne!(
|
||||
Snapshot::hash_next_block(&forward, &txs),
|
||||
Snapshot::hash_next_block(&reversed, &txs),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_blocks_hash_is_zero() {
|
||||
let txs = vec![snap_tx(1)];
|
||||
let blocks: Vec<Vec<TxIndex>> = vec![];
|
||||
assert_eq!(Snapshot::hash_next_block(&blocks, &txs), NextBlockHash::ZERO);
|
||||
}
|
||||
}
|
||||
142
crates/brk_mempool/src/snapshot/partition.rs
Normal file
142
crates/brk_mempool/src/snapshot/partition.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! Pack live txs into projected blocks 1..N by descending `chunk_rate`.
|
||||
//! Block 0 is filled by the caller from `getblocktemplate`. Final block
|
||||
//! is a catch-all (no vsize cap).
|
||||
|
||||
use brk_types::{FeeRate, VSize};
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
use super::{SnapTx, TxIndex};
|
||||
|
||||
pub struct Partitioner;
|
||||
|
||||
impl Partitioner {
|
||||
pub fn partition(
|
||||
txs: &[SnapTx],
|
||||
excluded: &FxHashSet<TxIndex>,
|
||||
num_remaining_blocks: usize,
|
||||
) -> Vec<Vec<TxIndex>> {
|
||||
if num_remaining_blocks == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let sorted = Self::sorted_candidates(txs, excluded);
|
||||
let mut blocks: Vec<Vec<TxIndex>> = (0..num_remaining_blocks).map(|_| Vec::new()).collect();
|
||||
let mut block_vsize = VSize::default();
|
||||
let mut current = 0;
|
||||
let last = num_remaining_blocks - 1;
|
||||
for (idx, vsize, _) in sorted {
|
||||
let fits = vsize <= VSize::MAX_BLOCK.saturating_sub(block_vsize);
|
||||
if !fits && current < last && !blocks[current].is_empty() {
|
||||
current += 1;
|
||||
block_vsize = VSize::default();
|
||||
}
|
||||
blocks[current].push(idx);
|
||||
block_vsize += vsize;
|
||||
}
|
||||
blocks
|
||||
}
|
||||
|
||||
fn sorted_candidates(
|
||||
txs: &[SnapTx],
|
||||
excluded: &FxHashSet<TxIndex>,
|
||||
) -> Vec<(TxIndex, VSize, FeeRate)> {
|
||||
let mut cands: Vec<(TxIndex, VSize, FeeRate)> = txs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, t)| {
|
||||
let idx = TxIndex::from(i);
|
||||
(!excluded.contains(&idx)).then_some((idx, t.vsize, t.chunk_rate))
|
||||
})
|
||||
.collect();
|
||||
cands.sort_by(|(a_idx, _, a_rate), (b_idx, _, b_rate)| {
|
||||
b_rate
|
||||
.cmp(a_rate)
|
||||
.then_with(|| txs[a_idx.as_usize()].txid.cmp(&txs[b_idx.as_usize()].txid))
|
||||
});
|
||||
cands
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bitcoin::hashes::Hash;
|
||||
use brk_types::{Sats, Txid, Weight};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn snap_tx(seed: u8, fee: u64, vsize: u64) -> SnapTx {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[0] = seed;
|
||||
SnapTx {
|
||||
txid: Txid::from(bitcoin::Txid::from_byte_array(bytes)),
|
||||
fee: Sats::from(fee),
|
||||
vsize: VSize::from(vsize),
|
||||
weight: Weight::from(vsize * 4),
|
||||
size: vsize,
|
||||
chunk_rate: FeeRate::from((Sats::from(fee), VSize::from(vsize))),
|
||||
parents: SmallVec::new(),
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_blocks_returns_empty() {
|
||||
let txs = vec![snap_tx(1, 100, 100)];
|
||||
let blocks = Partitioner::partition(&txs, &FxHashSet::default(), 0);
|
||||
assert!(blocks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_chunk_rate_packs_first() {
|
||||
let txs = vec![snap_tx(1, 100, 100), snap_tx(2, 1_000, 100)];
|
||||
let blocks = Partitioner::partition(&txs, &FxHashSet::default(), 3);
|
||||
assert_eq!(blocks[0][0], TxIndex::from(1usize));
|
||||
assert_eq!(blocks[0][1], TxIndex::from(0usize));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluded_txs_are_skipped() {
|
||||
let txs = vec![snap_tx(1, 100, 100), snap_tx(2, 1_000, 100)];
|
||||
let mut excluded = FxHashSet::default();
|
||||
excluded.insert(TxIndex::from(1usize));
|
||||
let blocks = Partitioner::partition(&txs, &excluded, 3);
|
||||
let flat: Vec<TxIndex> = blocks.into_iter().flatten().collect();
|
||||
assert_eq!(flat, vec![TxIndex::from(0usize)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vsize_cap_respected_except_for_last_block() {
|
||||
let big = u64::from(VSize::MAX_BLOCK) - 100;
|
||||
// Three "fills the rest of a block" sized txs, one block window.
|
||||
// Final block has no cap, so all three end up in it when the
|
||||
// request is one block deep.
|
||||
let txs = vec![
|
||||
snap_tx(1, 1_000, big),
|
||||
snap_tx(2, 900, big),
|
||||
snap_tx(3, 800, big),
|
||||
];
|
||||
let one_block = Partitioner::partition(&txs, &FxHashSet::default(), 1);
|
||||
assert_eq!(one_block.len(), 1);
|
||||
assert_eq!(one_block[0].len(), 3, "final block ignores vsize cap");
|
||||
|
||||
// With three slots, the first two get one tx each, last block
|
||||
// soaks up the rest.
|
||||
let three_blocks = Partitioner::partition(&txs, &FxHashSet::default(), 3);
|
||||
assert_eq!(three_blocks[0].len(), 1);
|
||||
assert_eq!(three_blocks[1].len(), 1);
|
||||
assert_eq!(three_blocks[2].len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txid_breaks_ties_within_same_rate() {
|
||||
// Identical rate, distinct txids: order must follow ascending txid.
|
||||
let txs = vec![
|
||||
snap_tx(0x20, 100, 100),
|
||||
snap_tx(0x10, 100, 100),
|
||||
snap_tx(0x30, 100, 100),
|
||||
];
|
||||
let blocks = Partitioner::partition(&txs, &FxHashSet::default(), 1);
|
||||
let txids: Vec<u8> = blocks[0].iter().map(|i| txs[i.as_usize()].txid[0]).collect();
|
||||
assert_eq!(txids, vec![0x10, 0x20, 0x30]);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,13 @@
|
||||
//! # Locking
|
||||
//!
|
||||
//! Two locks live on `Rebuilder`: `history` and `snapshot`. Writes always
|
||||
//! land on `history` first, then `snapshot`, so any `next_block_hash` a
|
||||
//! reader sees in the published snapshot is already recorded in
|
||||
//! `historical_block0`. No read path ever holds both, and no path holds
|
||||
//! a `State` guard together with either Rebuilder lock - the cycle reads
|
||||
//! `State` once to build the snapshot, then drops it before touching
|
||||
//! these locks.
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::{
|
||||
@@ -12,14 +22,7 @@ use rustc_hash::FxHashSet;
|
||||
|
||||
use crate::State;
|
||||
|
||||
use partition::Partitioner;
|
||||
use snapshot::build_txs;
|
||||
|
||||
mod partition;
|
||||
mod snapshot;
|
||||
|
||||
pub use brk_types::RecommendedFees;
|
||||
pub use snapshot::{BlockStats, SnapTx, Snapshot, TxIndex};
|
||||
use super::{Partitioner, Snapshot, TxIndex};
|
||||
|
||||
const NUM_BLOCKS: usize = 8;
|
||||
const HISTORY: usize = 10;
|
||||
@@ -35,16 +38,18 @@ pub struct Rebuilder {
|
||||
}
|
||||
|
||||
impl Rebuilder {
|
||||
/// Rebuild the snapshot every cycle. The build is pure CPU on
|
||||
/// already-fetched data and `min_fee` participates in the result,
|
||||
/// so a "skip if no add/remove" gate would freeze the served fees
|
||||
/// when Core's `mempoolminfee` drifts on a quiet pool. Cycle pacing
|
||||
/// is the driver loop's job.
|
||||
/// Rebuild every cycle. `min_fee` participates in the result, so a
|
||||
/// "skip if no add/remove" gate would freeze served fees when Core's
|
||||
/// `mempoolminfee` drifts on a quiet pool.
|
||||
///
|
||||
/// History is updated before the snapshot Arc is swapped so a reader
|
||||
/// can never observe a `next_block_hash` that hasn't been recorded
|
||||
/// yet. `block_template_diff(current_hash)` returning 404 in the
|
||||
/// publish gap would force unnecessary client refetches.
|
||||
pub fn tick(&self, lock: &RwLock<State>, gbt_txids: &[Txid], min_fee: FeeRate) {
|
||||
let snap = Self::build_snapshot(lock, gbt_txids, min_fee);
|
||||
let block0: Vec<Txid> = snap.block0_txids().collect();
|
||||
let next_hash = snap.next_block_hash;
|
||||
*self.snapshot.write() = Arc::new(snap);
|
||||
|
||||
let mut hist = self.history.write();
|
||||
hist.retain(|(h, _)| *h != next_hash);
|
||||
@@ -54,6 +59,8 @@ impl Rebuilder {
|
||||
}
|
||||
drop(hist);
|
||||
|
||||
*self.snapshot.write() = Arc::new(snap);
|
||||
|
||||
self.rebuild_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -79,16 +86,9 @@ impl Rebuilder {
|
||||
) -> Snapshot {
|
||||
let (txs, prefix_to_idx) = {
|
||||
let state = lock.read();
|
||||
build_txs(&state.txs)
|
||||
Snapshot::build_txs(&state.txs)
|
||||
};
|
||||
|
||||
// Block 0 from `getblocktemplate`: Core's actual selection.
|
||||
// The Fetcher synthesizes pool entries for GBT txs that aren't
|
||||
// already present (using GBT's inline body + stats), so this
|
||||
// lookup always resolves and block 0 matches Core exactly.
|
||||
// The `filter_map` only drops if a tx was concurrently evicted
|
||||
// from `txs` between `build_txs` and the rebuild, which the
|
||||
// partitioner backfills so callers still see `NUM_BLOCKS`.
|
||||
let block0: Vec<TxIndex> = gbt_txids
|
||||
.iter()
|
||||
.filter_map(|txid| prefix_to_idx.get(&TxidPrefix::from(txid)).copied())
|
||||
@@ -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]>,
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
//! Single-locked container for the live mempool.
|
||||
//! Single-locked container for the live mempool. All cycle steps and
|
||||
//! read-side accessors take a guard on this one lock.
|
||||
//!
|
||||
//! All cycle steps and read-side accessors take a guard on this one
|
||||
//! lock. The substructures are plain owned types — they used to each
|
||||
//! own a RwLock, but the canonical lock-order discipline disappears
|
||||
//! when there's nothing to order.
|
||||
//! # Concurrency
|
||||
//!
|
||||
//! `State` is held under one `RwLock` at the crate root. The cycle
|
||||
//! takes the write guard for `Applier` and `Prevouts`, then drops it
|
||||
//! before the [`crate::snapshot::Rebuilder`] runs. No code path holds
|
||||
//! a `State` guard at the same time as a `Rebuilder` lock, so the two
|
||||
//! domains are independent and lock-ordering between them is moot.
|
||||
|
||||
mod tx_entry;
|
||||
|
||||
pub use tx_entry::TxEntry;
|
||||
|
||||
use brk_types::{MempoolInfo, Timestamp, Txid};
|
||||
|
||||
@@ -19,8 +27,7 @@ pub struct State {
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// `first_seen` for a tx that's live or in a `Vanished` tombstone.
|
||||
/// Smooths the flicker between drop and indexer catch-up; `Replaced`
|
||||
/// Smooths the flicker between drop and indexer catch-up. `Replaced`
|
||||
/// tombstones are excluded since the tx will not confirm.
|
||||
pub fn first_seen(&self, txid: &Txid) -> Option<Timestamp> {
|
||||
if let Some(e) = self.txs.entry(txid) {
|
||||
@@ -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,
|
||||
@@ -2,12 +2,10 @@ use brk_types::{Transaction, TxidPrefix};
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::{
|
||||
AddrTransitions, CycleDiff, State, TxEntry, TxRemoval,
|
||||
cycle::{TxAdded, TxRemoved},
|
||||
steps::{
|
||||
preparer::{TxAddition, TxsPulled},
|
||||
rebuilder::{Rebuilder, Snapshot},
|
||||
},
|
||||
Snapshot, TxRemoval,
|
||||
cycle::{AddrTransitions, CycleDiff, TxAdded, TxRemoved},
|
||||
state::{State, TxEntry},
|
||||
steps::preparer::{TxAddition, TxsPulled},
|
||||
};
|
||||
|
||||
/// Applies a prepared diff to in-memory mempool state under one write
|
||||
@@ -16,35 +14,34 @@ use crate::{
|
||||
pub struct Applier;
|
||||
|
||||
impl Applier {
|
||||
/// `rebuilder` supplies the previous cycle's snapshot. Burial reads
|
||||
/// each tomb's `chunk_rate` from the snapshot (always-fresh,
|
||||
/// `prev_snapshot` supplies the previous cycle's snapshot. Burial
|
||||
/// reads each tomb's `chunk_rate` from it (always-fresh,
|
||||
/// package-aware via local linearization). The fallback to
|
||||
/// `entry.fee_rate()` is unreachable in steady state - every burial
|
||||
/// target was alive at the previous tick, so the snapshot has it.
|
||||
pub fn apply(
|
||||
lock: &RwLock<State>,
|
||||
rebuilder: &Rebuilder,
|
||||
prev_snapshot: &Snapshot,
|
||||
pulled: TxsPulled,
|
||||
diff: &mut CycleDiff,
|
||||
) {
|
||||
let TxsPulled { added, removed } = pulled;
|
||||
let mut state = lock.write();
|
||||
Self::bury_removals(&mut state, rebuilder, &mut diff.addrs, &mut diff.removed, removed);
|
||||
Self::bury_removals(&mut state, prev_snapshot, &mut diff.addrs, &mut diff.removed, removed);
|
||||
Self::publish_additions(&mut state, &mut diff.addrs, &mut diff.added, added);
|
||||
state.graveyard.evict_old();
|
||||
}
|
||||
|
||||
fn bury_removals(
|
||||
state: &mut State,
|
||||
rebuilder: &Rebuilder,
|
||||
snapshot: &Snapshot,
|
||||
transitions: &mut AddrTransitions,
|
||||
events: &mut Vec<TxRemoved>,
|
||||
removed: Vec<(TxidPrefix, TxRemoval)>,
|
||||
) {
|
||||
let snapshot = rebuilder.snapshot();
|
||||
events.reserve(removed.len());
|
||||
for (prefix, reason) in removed {
|
||||
if let Some(ev) = Self::bury_one(state, &snapshot, transitions, &prefix, reason) {
|
||||
if let Some(ev) = Self::bury_one(state, snapshot, transitions, &prefix, reason) {
|
||||
events.push(ev);
|
||||
}
|
||||
}
|
||||
@@ -52,13 +49,13 @@ impl Applier {
|
||||
|
||||
fn bury_one(
|
||||
state: &mut State,
|
||||
snapshot: &Snapshot,
|
||||
prev_snapshot: &Snapshot,
|
||||
transitions: &mut AddrTransitions,
|
||||
prefix: &TxidPrefix,
|
||||
reason: TxRemoval,
|
||||
) -> Option<TxRemoved> {
|
||||
let record = state.txs.remove_by_prefix(prefix)?;
|
||||
let chunk_rate = snapshot
|
||||
let chunk_rate = prev_snapshot
|
||||
.chunk_rate_for(prefix)
|
||||
.unwrap_or_else(|| record.entry.fee_rate());
|
||||
let txid = record.entry.txid;
|
||||
@@ -117,3 +114,162 @@ impl Applier {
|
||||
state.txs.insert(tx, entry);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use brk_types::{FeeRate, Sats, TxOut, Txid, VSize};
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
AddedKind,
|
||||
cycle::CycleDiff,
|
||||
steps::preparer::{TxAddition, TxsPulled},
|
||||
test_support::{fake_entry_info, fake_tx, p2wpkh_script},
|
||||
};
|
||||
|
||||
fn fresh_addition(seed: u8, fee: u64, vsize: u64) -> (TxAddition, Txid) {
|
||||
let prev = Some(TxOut::from((p2wpkh_script(seed), Sats::from(2_500u64))));
|
||||
let tx = fake_tx(seed, &[prev], &[(p2wpkh_script(seed + 1), 1_234)]);
|
||||
let txid = tx.txid;
|
||||
let info = fake_entry_info(txid, fee, vsize);
|
||||
let entry = TxEntry::new(&info, vsize, false);
|
||||
(TxAddition::Fresh { tx, entry }, txid)
|
||||
}
|
||||
|
||||
fn fresh_pulled(addition: TxAddition) -> TxsPulled {
|
||||
TxsPulled {
|
||||
added: vec![addition],
|
||||
removed: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_one_inserts_into_all_stores() {
|
||||
let lock = RwLock::new(State::default());
|
||||
let snapshot = Snapshot::default();
|
||||
let mut diff = CycleDiff::default();
|
||||
let (addition, txid) = fresh_addition(0xC0, 200, 100);
|
||||
|
||||
Applier::apply(&lock, &snapshot, fresh_pulled(addition), &mut diff);
|
||||
|
||||
let state = lock.read();
|
||||
assert!(state.txs.contains(&txid));
|
||||
assert_eq!(diff.added.len(), 1);
|
||||
assert_eq!(diff.added[0].txid, txid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revived_path_exhumes_body_from_graveyard() {
|
||||
let lock = RwLock::new(State::default());
|
||||
let snapshot = Snapshot::default();
|
||||
let (addition, txid) = fresh_addition(0xC1, 300, 100);
|
||||
let TxAddition::Fresh { tx, entry } = addition else {
|
||||
unreachable!();
|
||||
};
|
||||
// Pre-load the graveyard with this tx, then submit a Revived
|
||||
// addition that re-publishes it without a raw body.
|
||||
let rate = FeeRate::from((entry.fee, entry.vsize));
|
||||
lock.write()
|
||||
.graveyard
|
||||
.bury(tx, entry.clone(), rate, TxRemoval::Vanished);
|
||||
|
||||
let mut diff = CycleDiff::default();
|
||||
Applier::apply(
|
||||
&lock,
|
||||
&snapshot,
|
||||
fresh_pulled(TxAddition::Revived { entry }),
|
||||
&mut diff,
|
||||
);
|
||||
|
||||
let state = lock.read();
|
||||
assert!(state.txs.contains(&txid), "revived tx republished");
|
||||
assert!(state.graveyard.get(&txid).is_none(), "tomb consumed");
|
||||
assert_eq!(diff.added.len(), 1);
|
||||
assert!(matches!(diff.added[0].kind, AddedKind::Revived));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revived_with_empty_graveyard_is_dropped() {
|
||||
let lock = RwLock::new(State::default());
|
||||
let snapshot = Snapshot::default();
|
||||
let info = fake_entry_info(Txid::COINBASE, 100, 100);
|
||||
let entry = TxEntry::new(&info, 100, false);
|
||||
|
||||
let mut diff = CycleDiff::default();
|
||||
Applier::apply(
|
||||
&lock,
|
||||
&snapshot,
|
||||
fresh_pulled(TxAddition::Revived { entry }),
|
||||
&mut diff,
|
||||
);
|
||||
|
||||
let state = lock.read();
|
||||
assert!(!state.txs.contains(&Txid::COINBASE));
|
||||
assert!(diff.added.is_empty(), "no body, no event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bury_preserves_chunk_rate_from_snapshot() {
|
||||
let lock = RwLock::new(State::default());
|
||||
let (addition, txid) = fresh_addition(0xC2, 100, 100);
|
||||
|
||||
// Publish first to plant the tx, with a fee-rate that differs
|
||||
// from the snapshot's stub rate so we can tell them apart.
|
||||
Applier::apply(
|
||||
&lock,
|
||||
&Snapshot::default(),
|
||||
fresh_pulled(addition),
|
||||
&mut CycleDiff::default(),
|
||||
);
|
||||
let isolated_rate = FeeRate::from((Sats::from(100u64), VSize::from(100u64)));
|
||||
|
||||
let cpfp_rate = FeeRate::from((Sats::from(500u64), VSize::from(100u64)));
|
||||
let prefix = TxidPrefix::from(&txid);
|
||||
let snapshot = Snapshot::for_test_with_chunk_rates(&[(prefix, cpfp_rate, txid)]);
|
||||
|
||||
let mut diff = CycleDiff::default();
|
||||
Applier::apply(
|
||||
&lock,
|
||||
&snapshot,
|
||||
TxsPulled {
|
||||
added: vec![],
|
||||
removed: vec![(prefix, TxRemoval::Vanished)],
|
||||
},
|
||||
&mut diff,
|
||||
);
|
||||
|
||||
assert_eq!(diff.removed.len(), 1);
|
||||
assert_eq!(diff.removed[0].chunk_rate, cpfp_rate);
|
||||
assert_ne!(diff.removed[0].chunk_rate, isolated_rate);
|
||||
let state = lock.read();
|
||||
assert_eq!(state.graveyard.get(&txid).unwrap().chunk_rate, cpfp_rate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bury_falls_back_to_isolated_rate_when_snapshot_misses() {
|
||||
let lock = RwLock::new(State::default());
|
||||
let (addition, txid) = fresh_addition(0xC3, 700, 100);
|
||||
Applier::apply(
|
||||
&lock,
|
||||
&Snapshot::default(),
|
||||
fresh_pulled(addition),
|
||||
&mut CycleDiff::default(),
|
||||
);
|
||||
|
||||
let isolated_rate = FeeRate::from((Sats::from(700u64), VSize::from(100u64)));
|
||||
let prefix = TxidPrefix::from(&txid);
|
||||
|
||||
let mut diff = CycleDiff::default();
|
||||
Applier::apply(
|
||||
&lock,
|
||||
&Snapshot::default(),
|
||||
TxsPulled {
|
||||
added: vec![],
|
||||
removed: vec![(prefix, TxRemoval::Vanished)],
|
||||
},
|
||||
&mut diff,
|
||||
);
|
||||
|
||||
assert_eq!(diff.removed[0].chunk_rate, isolated_rate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ pub struct Fetched {
|
||||
pub new_txs: FxHashMap<Txid, bitcoin::Transaction>,
|
||||
/// Block 0 ordering from `getblocktemplate`. Bodies and stats have
|
||||
/// already been folded into `new_entries`/`new_txs` (or were already
|
||||
/// in the pool); the Rebuilder only needs the txid sequence to
|
||||
/// in the pool). The Rebuilder only needs the txid sequence to
|
||||
/// project Core's exact selection.
|
||||
pub block_template_txids: Vec<Txid>,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,12 +22,10 @@ use crate::{
|
||||
};
|
||||
|
||||
mod tx_addition;
|
||||
mod tx_entry;
|
||||
mod tx_removal;
|
||||
mod txs_pulled;
|
||||
|
||||
pub use tx_addition::TxAddition;
|
||||
pub use tx_entry::TxEntry;
|
||||
pub use tx_removal::TxRemoval;
|
||||
pub use txs_pulled::TxsPulled;
|
||||
|
||||
@@ -81,6 +79,11 @@ impl Preparer {
|
||||
|
||||
/// One `(prefix, reason)` per known tx that's gone from the live set,
|
||||
/// in `known` iteration order.
|
||||
///
|
||||
/// Cost is `O(R * avg_inputs)` where R is the removed-tx count and
|
||||
/// `avg_inputs` is small for non-pathological txs. Worst case is a
|
||||
/// `mempoolminfee` jump dropping ~10k txs in one cycle - still well
|
||||
/// under the cycle budget.
|
||||
fn classify_removals(
|
||||
live: &FxHashSet<TxidPrefix>,
|
||||
added: &[TxAddition],
|
||||
@@ -119,3 +122,223 @@ impl Preparer {
|
||||
spent_by
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bitcoin::hashes::Hash;
|
||||
use brk_types::{FeeRate, Sats, VSize};
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
AddedKind, TxRemoval,
|
||||
state::TxEntry,
|
||||
test_support::{fake_bitcoin_tx, fake_entry_info, fake_tx, fake_txid, p2wpkh_script},
|
||||
};
|
||||
|
||||
fn empty_state() -> RwLock<State> {
|
||||
RwLock::new(State::default())
|
||||
}
|
||||
|
||||
fn seed_known(state: &RwLock<State>, txid: Txid) {
|
||||
let tx = fake_tx(0xA0, &[None], &[(p2wpkh_script(50), 5_000)]);
|
||||
let mut altered = tx;
|
||||
altered.txid = txid;
|
||||
for input in altered.input.iter_mut() {
|
||||
input.prevout = Some(brk_types::TxOut::from((
|
||||
p2wpkh_script(51),
|
||||
Sats::from(1_000u64),
|
||||
)));
|
||||
}
|
||||
let info = fake_entry_info(txid, 1_000, 100);
|
||||
let entry = TxEntry::new(&info, 100, false);
|
||||
state.write().txs.insert(altered, entry);
|
||||
}
|
||||
|
||||
fn seed_graveyard(state: &RwLock<State>, txid: Txid) {
|
||||
let tx = fake_tx(0xB0, &[None], &[(p2wpkh_script(60), 5_000)]);
|
||||
let mut altered = tx;
|
||||
altered.txid = txid;
|
||||
let info = fake_entry_info(txid, 500, 100);
|
||||
let entry = TxEntry::new(&info, 100, false);
|
||||
let rate = FeeRate::from((Sats::from(500u64), VSize::from(100u64)));
|
||||
state
|
||||
.write()
|
||||
.graveyard
|
||||
.bury(altered, entry, rate, TxRemoval::Vanished);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_addition_skips_already_known() {
|
||||
let state = empty_state();
|
||||
let known_txid = fake_txid(0x10);
|
||||
seed_known(&state, known_txid);
|
||||
|
||||
let info = fake_entry_info(known_txid, 100, 100);
|
||||
let mut new_txs: FxHashMap<Txid, bitcoin::Transaction> = FxHashMap::default();
|
||||
new_txs.insert(known_txid, fake_bitcoin_tx(0x11, &[(p2wpkh_script(7), 1_234)]));
|
||||
|
||||
let pulled = Preparer::prepare(&[known_txid], vec![info], new_txs, &state);
|
||||
assert!(pulled.added.is_empty(), "known tx must be filtered out");
|
||||
assert!(pulled.removed.is_empty(), "still live, nothing removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_addition_emits_revived_for_graveyard_hit() {
|
||||
let state = empty_state();
|
||||
let txid = fake_txid(0x20);
|
||||
seed_graveyard(&state, txid);
|
||||
|
||||
let info = fake_entry_info(txid, 100, 100);
|
||||
let pulled = Preparer::prepare(&[txid], vec![info], FxHashMap::default(), &state);
|
||||
|
||||
assert_eq!(pulled.added.len(), 1);
|
||||
assert!(matches!(pulled.added[0].kind(), AddedKind::Revived));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_addition_emits_fresh_with_raw_payload() {
|
||||
let state = empty_state();
|
||||
let txid = fake_txid(0x30);
|
||||
// Make the bitcoin tx hash to `txid`: we instead key `new_txs`
|
||||
// by the synthetic txid, since classify_addition keys lookup by
|
||||
// info.txid, not by tx.compute_txid().
|
||||
let info = fake_entry_info(txid, 200, 120);
|
||||
let raw = fake_bitcoin_tx(0x31, &[(p2wpkh_script(8), 2_345)]);
|
||||
let mut new_txs: FxHashMap<Txid, bitcoin::Transaction> = FxHashMap::default();
|
||||
new_txs.insert(txid, raw);
|
||||
|
||||
let pulled = Preparer::prepare(&[txid], vec![info], new_txs, &state);
|
||||
assert_eq!(pulled.added.len(), 1);
|
||||
assert!(matches!(pulled.added[0].kind(), AddedKind::Fresh));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_addition_drops_entry_with_no_raw_and_no_graveyard() {
|
||||
let state = empty_state();
|
||||
let txid = fake_txid(0x40);
|
||||
let info = fake_entry_info(txid, 100, 100);
|
||||
|
||||
let pulled = Preparer::prepare(&[txid], vec![info], FxHashMap::default(), &state);
|
||||
assert!(pulled.added.is_empty(), "no payload, no tomb -> filtered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_removal_marks_replaced_when_outpoint_is_spent_by_new_tx() {
|
||||
let state = empty_state();
|
||||
// Loser: spends (parent, vout=0). We arrange the new fresh tx
|
||||
// to spend the same outpoint.
|
||||
let parent_txid = fake_txid(0x50);
|
||||
let loser_txid = fake_txid(0x51);
|
||||
let replacer_txid = fake_txid(0x52);
|
||||
{
|
||||
let prev = Some(brk_types::TxOut::from((
|
||||
p2wpkh_script(80),
|
||||
Sats::from(10_000u64),
|
||||
)));
|
||||
let mut tx = fake_tx(0x51, &[prev], &[(p2wpkh_script(81), 5_000)]);
|
||||
tx.txid = loser_txid;
|
||||
tx.input[0].txid = parent_txid;
|
||||
tx.input[0].vout = Vout::ZERO;
|
||||
let info = fake_entry_info(loser_txid, 100, 100);
|
||||
let entry = TxEntry::new(&info, 100, false);
|
||||
state.write().txs.insert(tx, entry);
|
||||
}
|
||||
|
||||
let info = fake_entry_info(replacer_txid, 200, 120);
|
||||
let mut new_txs: FxHashMap<Txid, bitcoin::Transaction> = FxHashMap::default();
|
||||
let mut raw = fake_bitcoin_tx(0x52, &[(p2wpkh_script(82), 4_321)]);
|
||||
raw.input[0].previous_output = bitcoin::OutPoint {
|
||||
txid: bitcoin::Txid::from_byte_array({
|
||||
let mut b = [0u8; 32];
|
||||
b[0] = 0x50;
|
||||
b
|
||||
}),
|
||||
vout: 0,
|
||||
};
|
||||
new_txs.insert(replacer_txid, raw);
|
||||
|
||||
let pulled = Preparer::prepare(&[replacer_txid], vec![info], new_txs, &state);
|
||||
assert_eq!(pulled.removed.len(), 1);
|
||||
let (_, reason) = pulled.removed[0];
|
||||
match reason {
|
||||
TxRemoval::Replaced { by } => assert_eq!(by, replacer_txid),
|
||||
TxRemoval::Vanished => panic!("expected Replaced, got Vanished"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_removal_marks_vanished_when_no_new_tx_spends_outpoint() {
|
||||
let state = empty_state();
|
||||
let gone_txid = fake_txid(0x60);
|
||||
{
|
||||
let prev = Some(brk_types::TxOut::from((
|
||||
p2wpkh_script(90),
|
||||
Sats::from(10_000u64),
|
||||
)));
|
||||
let mut tx = fake_tx(0x60, &[prev], &[(p2wpkh_script(91), 6_000)]);
|
||||
tx.txid = gone_txid;
|
||||
tx.input[0].txid = fake_txid(0xAA);
|
||||
let info = fake_entry_info(gone_txid, 100, 100);
|
||||
let entry = TxEntry::new(&info, 100, false);
|
||||
state.write().txs.insert(tx, entry);
|
||||
}
|
||||
|
||||
// No live txids in this cycle, no replacers staged.
|
||||
let pulled = Preparer::prepare(&[], vec![], FxHashMap::default(), &state);
|
||||
assert_eq!(pulled.removed.len(), 1);
|
||||
assert!(matches!(pulled.removed[0].1, TxRemoval::Vanished));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_resolves_prevout_from_same_cycle_mempool_parent() {
|
||||
// Same-cycle ordering: parent inserted first, then child whose
|
||||
// input points at parent.vout=0. We exercise the path by
|
||||
// putting the parent into the live store via a Fresh add, then
|
||||
// a second Preparer call where the child's `info` references
|
||||
// the parent's outpoint.
|
||||
let state = empty_state();
|
||||
let parent_txid = fake_txid(0x70);
|
||||
let child_txid = fake_txid(0x71);
|
||||
|
||||
// Stage parent directly in the live store so resolve_prevout
|
||||
// sees it when the child is decoded.
|
||||
{
|
||||
let mut parent = fake_tx(0x70, &[], &[(p2wpkh_script(100), 7_777)]);
|
||||
parent.txid = parent_txid;
|
||||
parent.input.clear();
|
||||
let info = fake_entry_info(parent_txid, 100, 80);
|
||||
let entry = TxEntry::new(&info, 80, false);
|
||||
state.write().txs.insert(parent, entry);
|
||||
}
|
||||
|
||||
let info = fake_entry_info(child_txid, 200, 120);
|
||||
let mut raw = fake_bitcoin_tx(0x70, &[(p2wpkh_script(101), 6_000)]);
|
||||
raw.input[0].previous_output = bitcoin::OutPoint {
|
||||
txid: bitcoin::Txid::from_byte_array({
|
||||
let mut b = [0u8; 32];
|
||||
b[0] = 0x70;
|
||||
b
|
||||
}),
|
||||
vout: 0,
|
||||
};
|
||||
let mut new_txs: FxHashMap<Txid, bitcoin::Transaction> = FxHashMap::default();
|
||||
new_txs.insert(child_txid, raw);
|
||||
|
||||
let pulled = Preparer::prepare(
|
||||
&[parent_txid, child_txid],
|
||||
vec![info],
|
||||
new_txs,
|
||||
&state,
|
||||
);
|
||||
let TxAddition::Fresh { tx, .. } = &pulled.added[0] else {
|
||||
panic!("expected Fresh classification");
|
||||
};
|
||||
let prevout = tx.input[0]
|
||||
.prevout
|
||||
.as_ref()
|
||||
.expect("parent in same-cycle pool must resolve");
|
||||
assert_eq!(prevout.value, Sats::from(7_777u64));
|
||||
// No removal: parent + child both in live set.
|
||||
assert!(pulled.removed.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! a fill directly (cheap, lock-local). Otherwise we record the
|
||||
//! hole for external resolution.
|
||||
//! 2. Drop the read guard. Call `resolver` on the remaining holes
|
||||
//! (typically `getrawtransaction` or an indexer lookup); failures
|
||||
//! (typically `getrawtransaction` or an indexer lookup). Failures
|
||||
//! are simply skipped and retried next cycle.
|
||||
//! 3. Take the write guard once and fold both fill batches into the
|
||||
//! `TxStore` via `apply_fills` -> `add_input`. Idempotent: each
|
||||
@@ -26,7 +26,7 @@ use parking_lot::RwLock;
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{CycleDiff, State, stores::TxStore};
|
||||
use crate::{cycle::CycleDiff, state::State, stores::TxStore};
|
||||
|
||||
pub struct Prevouts;
|
||||
|
||||
@@ -38,10 +38,9 @@ type Resolved = FxHashMap<(Txid, Vout), TxOut>;
|
||||
|
||||
impl Prevouts {
|
||||
/// Fill every unfilled prevout the cycle can resolve. Same-cycle
|
||||
/// in-mempool parents are filled lock-locally; the remainder go
|
||||
/// through `resolver` (one batched call) outside any lock. Returns
|
||||
/// true iff anything was written.
|
||||
pub fn fill<F>(lock: &RwLock<State>, diff: &mut CycleDiff, resolver: F) -> bool
|
||||
/// in-mempool parents are filled lock-locally. The remainder go
|
||||
/// through `resolver` (one batched call) outside any lock.
|
||||
pub fn fill<F>(lock: &RwLock<State>, diff: &mut CycleDiff, resolver: F)
|
||||
where
|
||||
F: Fn(&[(Txid, Vout)]) -> Resolved,
|
||||
{
|
||||
@@ -52,7 +51,7 @@ impl Prevouts {
|
||||
let external = Self::resolve_external(holes, resolver);
|
||||
|
||||
if in_mempool.is_empty() && external.is_empty() {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
let mut state = lock.write();
|
||||
@@ -62,7 +61,6 @@ impl Prevouts {
|
||||
state.addrs.add_input(&mut diff.addrs, &txid, &prevout);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Default resolver: one batched `getrawtransaction` per cycle,
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
//! Pack live txs into projected blocks 1..N, sorted by descending
|
||||
//! `chunk_rate`. Block 0 is filled by the caller from `getblocktemplate`
|
||||
//! (Core's actual selection); blocks 1..N feed
|
||||
//! `/api/v1/fees/mempool-blocks` as a coarse fee-tier gradient.
|
||||
//!
|
||||
//! No topological gate: a child can sit before its parent within a
|
||||
//! tied-rate run, but every cluster member shares its chunk's
|
||||
//! `chunk_rate` (linearized at snapshot time) so chunk-mates land in
|
||||
//! the same block, and the only output is a per-block rate
|
||||
//! distribution where intra-block order is invisible.
|
||||
//!
|
||||
//! The final block is a catch-all (no vsize cap) so leftover tail
|
||||
//! vsize is accounted for instead of silently dropped.
|
||||
//!
|
||||
//! Walk sorted candidates once. For each, push into the current
|
||||
//! block if it fits; otherwise advance to the next block (unless we
|
||||
//! are already on the last one, which absorbs everything remaining).
|
||||
|
||||
use brk_types::{FeeRate, VSize};
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
use super::snapshot::{SnapTx, TxIndex};
|
||||
|
||||
pub struct Partitioner;
|
||||
|
||||
impl Partitioner {
|
||||
pub fn partition(
|
||||
txs: &[SnapTx],
|
||||
excluded: &FxHashSet<TxIndex>,
|
||||
num_remaining_blocks: usize,
|
||||
) -> Vec<Vec<TxIndex>> {
|
||||
if num_remaining_blocks == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let sorted = sorted_candidates(txs, excluded);
|
||||
let mut blocks: Vec<Vec<TxIndex>> = (0..num_remaining_blocks).map(|_| Vec::new()).collect();
|
||||
let mut block_vsize = VSize::default();
|
||||
let mut current = 0;
|
||||
let last = num_remaining_blocks - 1;
|
||||
for (idx, vsize, _) in sorted {
|
||||
let fits = vsize <= VSize::MAX_BLOCK.saturating_sub(block_vsize);
|
||||
if !fits && current < last && !blocks[current].is_empty() {
|
||||
current += 1;
|
||||
block_vsize = VSize::default();
|
||||
}
|
||||
blocks[current].push(idx);
|
||||
block_vsize += vsize;
|
||||
}
|
||||
blocks
|
||||
}
|
||||
}
|
||||
|
||||
fn sorted_candidates(
|
||||
txs: &[SnapTx],
|
||||
excluded: &FxHashSet<TxIndex>,
|
||||
) -> Vec<(TxIndex, VSize, FeeRate)> {
|
||||
let mut cands: Vec<(TxIndex, VSize, FeeRate)> = txs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, t)| {
|
||||
let idx = TxIndex::from(i);
|
||||
(!excluded.contains(&idx)).then_some((idx, t.vsize, t.chunk_rate))
|
||||
})
|
||||
.collect();
|
||||
cands.sort_by(|(a_idx, _, a_rate), (b_idx, _, b_rate)| {
|
||||
b_rate
|
||||
.cmp(a_rate)
|
||||
.then_with(|| txs[a_idx.as_usize()].txid.cmp(&txs[b_idx.as_usize()].txid))
|
||||
});
|
||||
cands
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
//! Build the per-tx adjacency for a snapshot from the live `TxStore`,
|
||||
//! then linearize chunk rates over every multi-tx cluster.
|
||||
//!
|
||||
//! One pass over the live records to assign compact `TxIndex`es and a
|
||||
//! `prefix -> TxIndex` map, then per entry resolve `depends` against
|
||||
//! it to produce parent edges. Children are mirrored from parents in a
|
||||
//! second pass. Cross-pool parents (confirmed or evicted) are dropped
|
||||
//! silently - the live pool reflects what miners actually see, and any
|
||||
//! stale `depends` entry is self-healing.
|
||||
//!
|
||||
//! Final pass: walk every connected component and run Single Fee
|
||||
//! Linearization over it (see [`crate::cluster`]); each member's
|
||||
//! `chunk_rate` is overwritten with its chunk's feerate. Singletons
|
||||
//! keep the `fee/vsize` seed set in `live_tx`.
|
||||
//!
|
||||
//! The prefix map is returned alongside the txs so the rebuilder can
|
||||
//! reuse it for GBT mapping and the final `Snapshot::build` step
|
||||
//! without reconstructing it.
|
||||
|
||||
use std::mem;
|
||||
|
||||
use brk_types::TxidPrefix;
|
||||
use rustc_hash::{FxBuildHasher, FxHashMap};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{
|
||||
TxEntry,
|
||||
cluster::{linearize_component, walk_cluster},
|
||||
stores::TxStore,
|
||||
};
|
||||
|
||||
use super::{SnapTx, TxIndex};
|
||||
|
||||
pub type PrefixIndex = FxHashMap<TxidPrefix, TxIndex>;
|
||||
|
||||
pub fn build_txs(txs: &TxStore) -> (Vec<SnapTx>, PrefixIndex) {
|
||||
let n = txs.len();
|
||||
let mut prefix_to_idx: PrefixIndex =
|
||||
FxHashMap::with_capacity_and_hasher(n, FxBuildHasher);
|
||||
for (i, (prefix, _)) in txs.records().enumerate() {
|
||||
prefix_to_idx.insert(*prefix, TxIndex::from(i));
|
||||
}
|
||||
let mut snap_txs: Vec<SnapTx> = txs
|
||||
.records()
|
||||
.map(|(_, record)| live_tx(&record.entry, &prefix_to_idx))
|
||||
.collect();
|
||||
|
||||
mirror_children(&mut snap_txs);
|
||||
refresh_chunk_rates(&mut snap_txs);
|
||||
(snap_txs, prefix_to_idx)
|
||||
}
|
||||
|
||||
fn live_tx(e: &TxEntry, prefix_to_idx: &PrefixIndex) -> SnapTx {
|
||||
let parents: SmallVec<[TxIndex; 2]> = e
|
||||
.depends
|
||||
.iter()
|
||||
.filter_map(|p| prefix_to_idx.get(p).copied())
|
||||
.collect();
|
||||
SnapTx {
|
||||
txid: e.txid,
|
||||
fee: e.fee,
|
||||
vsize: e.vsize,
|
||||
weight: e.weight,
|
||||
size: e.size,
|
||||
chunk_rate: e.fee_rate(),
|
||||
parents,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mirror_children(txs: &mut [SnapTx]) {
|
||||
for i in 0..txs.len() {
|
||||
let child = TxIndex::from(i);
|
||||
let parents = mem::take(&mut txs[i].parents);
|
||||
for &p in &parents {
|
||||
if let Some(t) = txs.get_mut(p.as_usize()) {
|
||||
t.children.push(child);
|
||||
}
|
||||
}
|
||||
txs[i].parents = parents;
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk every multi-tx connected component once and overwrite each
|
||||
/// member's `chunk_rate` with the linearized chunk's feerate. Visited
|
||||
/// bitmap ensures each cluster is linearized exactly once.
|
||||
fn refresh_chunk_rates(snap_txs: &mut [SnapTx]) {
|
||||
let n = snap_txs.len();
|
||||
let mut visited = vec![false; n];
|
||||
for seed in 0..n {
|
||||
if visited[seed] {
|
||||
continue;
|
||||
}
|
||||
let t = &snap_txs[seed];
|
||||
if t.parents.is_empty() && t.children.is_empty() {
|
||||
visited[seed] = true;
|
||||
continue;
|
||||
}
|
||||
let component = walk_cluster(snap_txs, TxIndex::from(seed));
|
||||
for &m in &component {
|
||||
visited[m.as_usize()] = true;
|
||||
}
|
||||
if component.len() <= 1 {
|
||||
continue;
|
||||
}
|
||||
let (members, chunks) = linearize_component(snap_txs, &component);
|
||||
for chunk in &chunks {
|
||||
for &local in &chunk.txs {
|
||||
let m = members[u32::from(local) as usize];
|
||||
snap_txs[m.as_usize()].chunk_rate = chunk.feerate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use bitcoin::hashes::Hash;
|
||||
use brk_types::{FeeRate, Sats, Txid, VSize, Weight};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Build a `SnapTx` for tests. `txid` is auto-assigned from a
|
||||
/// process-wide counter so each tx is distinguishable in
|
||||
/// debug output; the cluster code itself keys off `TxIndex`,
|
||||
/// not `txid`.
|
||||
fn snap_tx(fee: Sats, vsize: VSize) -> SnapTx {
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[..4].copy_from_slice(&COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes());
|
||||
SnapTx {
|
||||
txid: Txid::from(bitcoin::Txid::from_byte_array(bytes)),
|
||||
fee,
|
||||
vsize,
|
||||
weight: Weight::from(vsize),
|
||||
size: u64::from(vsize),
|
||||
chunk_rate: FeeRate::from((fee, vsize)),
|
||||
parents: SmallVec::new(),
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn link(txs: &mut [SnapTx], parent: usize, child: usize) {
|
||||
txs[child].parents.push(TxIndex::from(parent));
|
||||
txs[parent].children.push(TxIndex::from(child));
|
||||
}
|
||||
|
||||
fn sats(n: u64) -> Sats {
|
||||
Sats::from(n)
|
||||
}
|
||||
|
||||
fn vsize(n: u64) -> VSize {
|
||||
VSize::from(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_keeps_fee_per_vsize() {
|
||||
let mut txs = vec![snap_tx(sats(1000), vsize(100))];
|
||||
let seed = txs[0].chunk_rate;
|
||||
refresh_chunk_rates(&mut txs);
|
||||
assert_eq!(txs[0].chunk_rate, seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_tx_cpfp_lift() {
|
||||
let mut txs = vec![
|
||||
snap_tx(sats(100), vsize(100)),
|
||||
snap_tx(sats(1900), vsize(100)),
|
||||
];
|
||||
link(&mut txs, 0, 1);
|
||||
let parent_seed = txs[0].chunk_rate;
|
||||
refresh_chunk_rates(&mut txs);
|
||||
assert!(txs[0].chunk_rate > parent_seed);
|
||||
assert_eq!(txs[0].chunk_rate, txs[1].chunk_rate);
|
||||
assert_eq!(txs[0].chunk_rate, FeeRate::from((sats(2000), vsize(200))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_tx_chain_chunks_correctly() {
|
||||
let mut txs = vec![
|
||||
snap_tx(sats(100), vsize(100)),
|
||||
snap_tx(sats(100), vsize(100)),
|
||||
snap_tx(sats(5800), vsize(100)),
|
||||
];
|
||||
link(&mut txs, 0, 1);
|
||||
link(&mut txs, 1, 2);
|
||||
refresh_chunk_rates(&mut txs);
|
||||
let combined = FeeRate::from((sats(6000), vsize(300)));
|
||||
assert_eq!(txs[0].chunk_rate, combined);
|
||||
assert_eq!(txs[1].chunk_rate, combined);
|
||||
assert_eq!(txs[2].chunk_rate, combined);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_clusters_linearized_independently() {
|
||||
let mut txs = vec![
|
||||
snap_tx(sats(100), vsize(100)),
|
||||
snap_tx(sats(1900), vsize(100)),
|
||||
snap_tx(sats(500), vsize(100)),
|
||||
snap_tx(sats(4500), vsize(100)),
|
||||
];
|
||||
link(&mut txs, 0, 1);
|
||||
link(&mut txs, 2, 3);
|
||||
refresh_chunk_rates(&mut txs);
|
||||
assert_eq!(txs[0].chunk_rate, txs[1].chunk_rate);
|
||||
assert_eq!(txs[2].chunk_rate, txs[3].chunk_rate);
|
||||
assert_ne!(txs[0].chunk_rate, txs[2].chunk_rate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_cap_does_not_panic() {
|
||||
let n = 100;
|
||||
let mut txs: Vec<SnapTx> = (0..n).map(|_| snap_tx(sats(1000), vsize(100))).collect();
|
||||
for i in 1..n {
|
||||
link(&mut txs, i - 1, i);
|
||||
}
|
||||
refresh_chunk_rates(&mut txs);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
mod builder;
|
||||
mod fees;
|
||||
mod stats;
|
||||
mod tx;
|
||||
mod tx_index;
|
||||
|
||||
pub(crate) use builder::{PrefixIndex, build_txs};
|
||||
pub use stats::BlockStats;
|
||||
pub use tx::SnapTx;
|
||||
pub use tx_index::TxIndex;
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use brk_types::{FeeRate, NextBlockHash, RecommendedFees, Txid, TxidPrefix};
|
||||
use rustc_hash::FxHasher;
|
||||
|
||||
use fees::Fees;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Snapshot {
|
||||
/// Dense per-tx data indexed by `TxIndex`. Each entry carries the
|
||||
/// linearized chunk rate (computed locally at snapshot build time)
|
||||
/// plus resolved parent/child adjacency, so CPFP queries don't
|
||||
/// re-read any external state.
|
||||
pub txs: Vec<SnapTx>,
|
||||
/// Projected blocks. `blocks[0]` is Core's `getblocktemplate`
|
||||
/// (Bitcoin Core's actual selection); the rest are greedy-packed
|
||||
/// by descending chunk rate, with a final overflow block.
|
||||
pub blocks: Vec<Vec<TxIndex>>,
|
||||
pub block_stats: Vec<BlockStats>,
|
||||
pub fees: RecommendedFees,
|
||||
/// Content hash of the projected next block. Same value as the
|
||||
/// mempool ETag.
|
||||
pub next_block_hash: NextBlockHash,
|
||||
/// Per-snapshot `TxidPrefix -> TxIndex` index, so live queries can
|
||||
/// resolve a prefix to the snapshot's compact index without
|
||||
/// re-walking `txs`. Built once by `build_txs` and reused by the
|
||||
/// rebuilder for GBT mapping.
|
||||
prefix_to_idx: PrefixIndex,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// `min_fee` is bitcoind's live `mempoolminfee`, used as the floor
|
||||
/// for every recommended-fee tier.
|
||||
pub fn build(
|
||||
txs: Vec<SnapTx>,
|
||||
blocks: Vec<Vec<TxIndex>>,
|
||||
prefix_to_idx: PrefixIndex,
|
||||
min_fee: FeeRate,
|
||||
) -> Self {
|
||||
let block_stats = BlockStats::for_blocks(&blocks, &txs);
|
||||
let fees = Fees::compute(&block_stats, min_fee);
|
||||
let next_block_hash = Self::hash_next_block(&blocks, &txs);
|
||||
Self {
|
||||
txs,
|
||||
blocks,
|
||||
block_stats,
|
||||
fees,
|
||||
next_block_hash,
|
||||
prefix_to_idx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Content tag over block 0 in template order. Hashes txids, not
|
||||
/// `TxIndex` slots, because slot assignment is per-cycle and
|
||||
/// unstable; the txid set is the actual identity of "what's in the
|
||||
/// next block".
|
||||
fn hash_next_block(blocks: &[Vec<TxIndex>], txs: &[SnapTx]) -> NextBlockHash {
|
||||
let Some(block) = blocks.first() else {
|
||||
return NextBlockHash::ZERO;
|
||||
};
|
||||
let mut hasher = FxHasher::default();
|
||||
for idx in block {
|
||||
txs[idx.as_usize()].txid.hash(&mut hasher);
|
||||
}
|
||||
NextBlockHash::new(hasher.finish())
|
||||
}
|
||||
|
||||
pub fn tx(&self, idx: TxIndex) -> Option<&SnapTx> {
|
||||
self.txs.get(idx.as_usize())
|
||||
}
|
||||
|
||||
pub fn idx_of(&self, prefix: &TxidPrefix) -> Option<TxIndex> {
|
||||
self.prefix_to_idx.get(prefix).copied()
|
||||
}
|
||||
|
||||
/// Txids of `blocks[0]` (Core's `getblocktemplate` selection),
|
||||
/// in template order. Empty for a default snapshot.
|
||||
pub fn block0_txids(&self) -> impl Iterator<Item = Txid> + '_ {
|
||||
self.blocks
|
||||
.first()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|idx| self.txs[idx.as_usize()].txid)
|
||||
}
|
||||
|
||||
/// Linearized chunk rate for a live tx by prefix. Always fresh
|
||||
/// (recomputed each snapshot), package-aware (CPFP and ancestor
|
||||
/// chains lift correctly), and equals `fee/vsize` for singletons.
|
||||
/// Returns `None` if the tx isn't in this snapshot.
|
||||
pub fn chunk_rate_for(&self, prefix: &TxidPrefix) -> Option<FeeRate> {
|
||||
let idx = self.idx_of(prefix)?;
|
||||
Some(self.txs[idx.as_usize()].chunk_rate)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Per-cycle 0↔1+ address transition buffer.
|
||||
//!
|
||||
//! Lives on the stack inside [`crate::Mempool::tick_with`], not on a
|
||||
//! long-lived store, so the set naturally resets between cycles.
|
||||
//! Same-cycle cancellation (enter→leave, leave→enter, and the 3-step
|
||||
//! enter→leave→enter / leave→enter→leave variants) is encapsulated on
|
||||
//! the recording methods so callers just announce raw 0↔1+ flips.
|
||||
|
||||
use brk_types::AddrBytes;
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AddrTransitions {
|
||||
enters: FxHashSet<AddrBytes>,
|
||||
leaves: FxHashSet<AddrBytes>,
|
||||
}
|
||||
|
||||
impl AddrTransitions {
|
||||
/// Address just went 0 → 1+ live mempool txs. Cancels a pending
|
||||
/// `leave` for the same address in this cycle.
|
||||
pub fn record_enter(&mut self, bytes: AddrBytes) {
|
||||
if !self.leaves.remove(&bytes) {
|
||||
self.enters.insert(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Address just went 1+ → 0 live mempool txs. Cancels a pending
|
||||
/// `enter` for the same address in this cycle.
|
||||
pub fn record_leave(&mut self, bytes: AddrBytes) {
|
||||
if !self.enters.remove(&bytes) {
|
||||
self.leaves.insert(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_vecs(self) -> (Vec<AddrBytes>, Vec<AddrBytes>) {
|
||||
(
|
||||
self.enters.into_iter().collect(),
|
||||
self.leaves.into_iter().collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,26 @@ use std::{
|
||||
};
|
||||
|
||||
use brk_types::{AddrBytes, AddrMempoolStats, Transaction, TxOut, Txid};
|
||||
use derive_more::Deref;
|
||||
use rustc_hash::{FxHashMap, FxHasher};
|
||||
|
||||
use crate::cycle::AddrTransitions;
|
||||
|
||||
mod addr_entry;
|
||||
mod addr_transitions;
|
||||
|
||||
use addr_entry::AddrEntry;
|
||||
pub use addr_transitions::AddrTransitions;
|
||||
pub use addr_entry::AddrEntry;
|
||||
|
||||
#[derive(Default, Deref)]
|
||||
#[derive(Default)]
|
||||
pub struct AddrTracker(FxHashMap<AddrBytes, AddrEntry>);
|
||||
|
||||
impl AddrTracker {
|
||||
pub fn get(&self, addr: &AddrBytes) -> Option<&AddrEntry> {
|
||||
self.0.get(addr)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
pub fn add_tx(&mut self, transitions: &mut AddrTransitions, tx: &Transaction) {
|
||||
let txid = &tx.txid;
|
||||
for txin in &tx.input {
|
||||
@@ -45,18 +52,14 @@ impl AddrTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash of an address's per-mempool stats. Stable while the address
|
||||
/// is unchanged. Cheaper to recompute than to track invalidation.
|
||||
/// Returns 0 for unknown addresses (collision with a real hash is
|
||||
/// astronomically unlikely and only costs one ETag false-hit if it
|
||||
/// ever happens).
|
||||
pub fn stats_hash(&self, addr: &AddrBytes) -> u64 {
|
||||
let Some(entry) = self.0.get(addr) else {
|
||||
return 0;
|
||||
};
|
||||
/// Hash of an address's per-mempool stats, `None` if the address
|
||||
/// has no live mempool activity. Stable while the address is
|
||||
/// unchanged. Cheaper to recompute than to track invalidation.
|
||||
pub fn stats_hash(&self, addr: &AddrBytes) -> Option<u64> {
|
||||
let entry = self.0.get(addr)?;
|
||||
let mut hasher = FxHasher::default();
|
||||
entry.stats.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
Some(hasher.finish())
|
||||
}
|
||||
|
||||
/// Fold a single newly-resolved input into the per-address stats.
|
||||
@@ -135,3 +138,120 @@ impl AddrTracker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use brk_types::{Sats, TxOut};
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::{fake_tx, p2wpkh_script};
|
||||
|
||||
fn addr_of(script: &bitcoin::ScriptBuf) -> AddrBytes {
|
||||
AddrBytes::try_from(script).expect("p2wpkh script must yield AddrBytes")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_tx_records_enter_for_new_addr() {
|
||||
let mut tracker = AddrTracker::default();
|
||||
let mut transitions = AddrTransitions::default();
|
||||
let out_script = p2wpkh_script(1);
|
||||
let tx = fake_tx(1, &[], &[(out_script.clone(), 5_000)]);
|
||||
let bytes = addr_of(&out_script);
|
||||
|
||||
tracker.add_tx(&mut transitions, &tx);
|
||||
|
||||
assert_eq!(tracker.len(), 1);
|
||||
let entry = tracker.get(&bytes).expect("addr indexed");
|
||||
assert_eq!(entry.stats.funded_txo_count, 1);
|
||||
assert_eq!(entry.stats.funded_txo_sum, Sats::from(5_000u64));
|
||||
assert_eq!(entry.stats.tx_count, 1);
|
||||
|
||||
let (enters, leaves) = transitions.into_vecs();
|
||||
assert_eq!(enters, vec![bytes]);
|
||||
assert!(leaves.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_then_remove_tx_returns_to_zero_addrs() {
|
||||
let mut tracker = AddrTracker::default();
|
||||
let mut transitions = AddrTransitions::default();
|
||||
let out_script = p2wpkh_script(2);
|
||||
let prev_script = p2wpkh_script(3);
|
||||
let tx = fake_tx(
|
||||
2,
|
||||
&[Some(TxOut::from((prev_script.clone(), Sats::from(4_000u64))))],
|
||||
&[(out_script.clone(), 3_500)],
|
||||
);
|
||||
let recv = addr_of(&out_script);
|
||||
let spend = addr_of(&prev_script);
|
||||
|
||||
tracker.add_tx(&mut transitions, &tx);
|
||||
tracker.remove_tx(&mut transitions, &tx);
|
||||
assert_eq!(tracker.len(), 0);
|
||||
assert!(tracker.get(&recv).is_none());
|
||||
assert!(tracker.get(&spend).is_none());
|
||||
|
||||
// add+remove in the same cycle: enter/leave cancel out.
|
||||
let (enters, leaves) = transitions.into_vecs();
|
||||
assert!(enters.is_empty(), "enter cancelled by same-cycle leave");
|
||||
assert!(leaves.is_empty(), "leave cancelled by same-cycle enter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_tx_touching_addr_does_not_re_enter() {
|
||||
let mut tracker = AddrTracker::default();
|
||||
let mut transitions = AddrTransitions::default();
|
||||
let shared = p2wpkh_script(4);
|
||||
let tx_a = fake_tx(3, &[], &[(shared.clone(), 2_500)]);
|
||||
let tx_b = fake_tx(4, &[], &[(shared.clone(), 7_500)]);
|
||||
|
||||
tracker.add_tx(&mut transitions, &tx_a);
|
||||
tracker.add_tx(&mut transitions, &tx_b);
|
||||
|
||||
let entry = tracker.get(&addr_of(&shared)).expect("addr indexed");
|
||||
assert_eq!(entry.stats.funded_txo_count, 2);
|
||||
assert_eq!(entry.stats.funded_txo_sum, Sats::from(10_000u64));
|
||||
assert_eq!(entry.stats.tx_count, 2);
|
||||
|
||||
// Only one enter, even though two txs landed on the addr.
|
||||
let (enters, _) = transitions.into_vecs();
|
||||
assert_eq!(enters.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_hash_is_none_for_untracked_addr() {
|
||||
let tracker = AddrTracker::default();
|
||||
let bytes = addr_of(&p2wpkh_script(5));
|
||||
assert!(tracker.stats_hash(&bytes).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_hash_stable_for_repeat_reads() {
|
||||
let mut tracker = AddrTracker::default();
|
||||
let mut transitions = AddrTransitions::default();
|
||||
let script = p2wpkh_script(6);
|
||||
let tx = fake_tx(5, &[], &[(script.clone(), 3_333)]);
|
||||
tracker.add_tx(&mut transitions, &tx);
|
||||
|
||||
let bytes = addr_of(&script);
|
||||
let first = tracker.stats_hash(&bytes).expect("addr tracked");
|
||||
let second = tracker.stats_hash(&bytes).expect("addr tracked");
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_hash_changes_after_a_mutation() {
|
||||
let mut tracker = AddrTracker::default();
|
||||
let mut transitions = AddrTransitions::default();
|
||||
let script = p2wpkh_script(7);
|
||||
let bytes = addr_of(&script);
|
||||
let tx_a = fake_tx(6, &[], &[(script.clone(), 1_111)]);
|
||||
tracker.add_tx(&mut transitions, &tx_a);
|
||||
let before = tracker.stats_hash(&bytes).expect("tracked after first add");
|
||||
|
||||
let tx_b = fake_tx(7, &[], &[(script, 2_222)]);
|
||||
tracker.add_tx(&mut transitions, &tx_b);
|
||||
let after = tracker.stats_hash(&bytes).expect("tracked after second add");
|
||||
assert_ne!(before, after, "second funding tx must shift the hash");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
use brk_types::{OutpointPrefix, Transaction, TxidPrefix};
|
||||
use derive_more::Deref;
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
/// Mempool index from spent outpoint to spending mempool tx.
|
||||
///
|
||||
/// Keys are `OutpointPrefix` (8 bytes txid + 2 bytes vout); prefix
|
||||
/// Keys are `OutpointPrefix` (8 bytes txid + 2 bytes vout). Prefix
|
||||
/// collisions are possible, so callers must verify the candidate
|
||||
/// spender's input list. Values are the spender's `TxidPrefix`,
|
||||
/// looked up against `TxStore` to recover the full spender record.
|
||||
#[derive(Default, Deref)]
|
||||
#[derive(Default)]
|
||||
pub struct OutpointSpends(FxHashMap<OutpointPrefix, TxidPrefix>);
|
||||
|
||||
impl OutpointSpends {
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
pub fn insert_spends(&mut self, tx: &Transaction, spender: TxidPrefix) {
|
||||
for key in spent_outpoints(tx) {
|
||||
for key in Self::spent_outpoints(tx) {
|
||||
self.0.insert(key, spender);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +24,7 @@ impl OutpointSpends {
|
||||
/// Only removes entries whose stored prefix still matches `spender`,
|
||||
/// so an outpoint already re-claimed by a later spender is left alone.
|
||||
pub fn remove_spends(&mut self, tx: &Transaction, spender: TxidPrefix) {
|
||||
for key in spent_outpoints(tx) {
|
||||
for key in Self::spent_outpoints(tx) {
|
||||
if self.0.get(&key) == Some(&spender) {
|
||||
self.0.remove(&key);
|
||||
}
|
||||
@@ -32,11 +35,11 @@ impl OutpointSpends {
|
||||
pub fn get(&self, key: &OutpointPrefix) -> Option<TxidPrefix> {
|
||||
self.0.get(key).copied()
|
||||
}
|
||||
}
|
||||
|
||||
fn spent_outpoints(tx: &Transaction) -> impl Iterator<Item = OutpointPrefix> + '_ {
|
||||
tx.input
|
||||
.iter()
|
||||
.filter(|i| !i.is_coinbase)
|
||||
.map(|i| OutpointPrefix::new(TxidPrefix::from(&i.txid), i.vout))
|
||||
fn spent_outpoints(tx: &Transaction) -> impl Iterator<Item = OutpointPrefix> + '_ {
|
||||
tx.input
|
||||
.iter()
|
||||
.filter(|i| !i.is_coinbase)
|
||||
.map(|i| OutpointPrefix::new(TxidPrefix::from(&i.txid), i.vout))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ use rustc_hash::FxHashMap;
|
||||
|
||||
mod tombstone;
|
||||
|
||||
pub(crate) use tombstone::TxTombstone;
|
||||
pub use tombstone::TxTombstone;
|
||||
|
||||
use crate::{TxEntry, TxRemoval};
|
||||
use crate::{TxRemoval, state::TxEntry};
|
||||
|
||||
const RETENTION: Duration = Duration::from_hours(1);
|
||||
|
||||
@@ -68,12 +68,12 @@ impl TxGraveyard {
|
||||
})
|
||||
}
|
||||
|
||||
/// Every `Replaced` tombstone, yielded as (predecessor_txid,
|
||||
/// replacer_txid) in reverse bury order (most recent replacement
|
||||
/// Every `Replaced` tombstone, yielded as (`predecessor_txid`,
|
||||
/// `replacer_txid`) in reverse bury order (most recent replacement
|
||||
/// event first). Caller walks the replacer chain forward to find
|
||||
/// each tree's terminal replacer.
|
||||
///
|
||||
/// `order` may carry stale entries (re-buries, prior exhumes); the
|
||||
/// `order` may carry stale entries (re-buries, prior exhumes). The
|
||||
/// `removed_at == t` check skips those.
|
||||
pub fn replaced_iter_recent_first(&self) -> impl Iterator<Item = (&Txid, &Txid)> {
|
||||
self.order.iter().rev().filter_map(|(t, txid)| {
|
||||
@@ -130,4 +130,170 @@ impl TxGraveyard {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only: force the oldest `order` entries to look older than
|
||||
/// `RETENTION`. Splits `Instant::now()` arithmetic out of the test
|
||||
/// bodies and avoids real-time sleeps.
|
||||
#[cfg(test)]
|
||||
fn shift_oldest_back(&mut self, count: usize) {
|
||||
let bumped = Instant::now() - (RETENTION + Duration::from_secs(1));
|
||||
for entry in self.order.iter_mut().take(count) {
|
||||
let txid = entry.1;
|
||||
entry.0 = bumped;
|
||||
if let Some(ts) = self.tombstones.get_mut(&txid) {
|
||||
ts.removed_at = bumped;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use brk_types::{FeeRate, MempoolEntryInfo, Sats, Timestamp, VSize, Weight};
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::{fake_tx, fake_txid};
|
||||
|
||||
fn tomb_inputs(seed: u8) -> (Transaction, TxEntry, FeeRate) {
|
||||
let tx = fake_tx(seed, &[], &[]);
|
||||
let info = MempoolEntryInfo {
|
||||
txid: tx.txid,
|
||||
vsize: VSize::from(100u64),
|
||||
weight: Weight::from(400u64),
|
||||
fee: Sats::from(100u64),
|
||||
first_seen: Timestamp::from(0u32),
|
||||
depends: vec![],
|
||||
};
|
||||
let entry = TxEntry::new(&info, 100, false);
|
||||
let rate = FeeRate::from((Sats::from(100u64), VSize::from(100u64)));
|
||||
(tx, entry, rate)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bury_then_exhume_roundtrips_the_tombstone() {
|
||||
let mut g = TxGraveyard::default();
|
||||
let (tx, entry, rate) = tomb_inputs(1);
|
||||
let txid = entry.txid;
|
||||
g.bury(tx, entry, rate, TxRemoval::Vanished);
|
||||
assert_eq!(g.tombstones_len(), 1);
|
||||
assert!(g.get(&txid).is_some());
|
||||
|
||||
let resurrected = g.exhume(&txid).expect("tombstone present");
|
||||
assert_eq!(resurrected.entry.txid, txid);
|
||||
assert!(g.get(&txid).is_none());
|
||||
assert_eq!(g.tombstones_len(), 0);
|
||||
// `order` still references the exhumed entry until evict_old
|
||||
// runs. The timestamp-match check on evict skips stale rows.
|
||||
assert_eq!(g.order_len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_vanished_filters_out_replaced_tombstones() {
|
||||
let mut g = TxGraveyard::default();
|
||||
let (tx_a, entry_a, rate) = tomb_inputs(2);
|
||||
let (tx_b, entry_b, _) = tomb_inputs(3);
|
||||
let txid_a = entry_a.txid;
|
||||
let txid_b = entry_b.txid;
|
||||
g.bury(tx_a, entry_a, rate, TxRemoval::Replaced { by: txid_b });
|
||||
g.bury(tx_b, entry_b, rate, TxRemoval::Vanished);
|
||||
|
||||
assert!(g.get_vanished(&txid_a).is_none());
|
||||
assert!(g.get_vanished(&txid_b).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_root_walks_replaced_chain() {
|
||||
let mut g = TxGraveyard::default();
|
||||
let (tx_a, entry_a, rate) = tomb_inputs(4);
|
||||
let (tx_b, entry_b, _) = tomb_inputs(5);
|
||||
let (tx_c, entry_c, _) = tomb_inputs(6);
|
||||
let a = entry_a.txid;
|
||||
let b = entry_b.txid;
|
||||
let c = entry_c.txid;
|
||||
g.bury(tx_a, entry_a, rate, TxRemoval::Replaced { by: b });
|
||||
g.bury(tx_b, entry_b, rate, TxRemoval::Replaced { by: c });
|
||||
g.bury(tx_c, entry_c, rate, TxRemoval::Vanished);
|
||||
|
||||
assert_eq!(g.replacement_root_of(a), c);
|
||||
assert_eq!(g.replacement_root_of(c), c);
|
||||
|
||||
let unknown = fake_txid(99);
|
||||
assert_eq!(g.replacement_root_of(unknown), unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predecessors_of_returns_direct_replacers() {
|
||||
let mut g = TxGraveyard::default();
|
||||
let (tx_a, entry_a, rate) = tomb_inputs(7);
|
||||
let (tx_b, entry_b, _) = tomb_inputs(8);
|
||||
let (tx_c, entry_c, _) = tomb_inputs(9);
|
||||
let replacer = entry_c.txid;
|
||||
let a = entry_a.txid;
|
||||
let b = entry_b.txid;
|
||||
g.bury(tx_a, entry_a, rate, TxRemoval::Replaced { by: replacer });
|
||||
g.bury(tx_b, entry_b, rate, TxRemoval::Replaced { by: replacer });
|
||||
g.bury(tx_c, entry_c, rate, TxRemoval::Vanished);
|
||||
|
||||
let mut preds: Vec<Txid> = g.predecessors_of(&replacer).map(|(t, _)| *t).collect();
|
||||
preds.sort_unstable_by_key(|t| t.as_slice()[0]);
|
||||
let mut expected = vec![a, b];
|
||||
expected.sort_unstable_by_key(|t| t.as_slice()[0]);
|
||||
assert_eq!(preds, expected);
|
||||
|
||||
assert_eq!(g.predecessors_of(&fake_txid(123)).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_iter_recent_first_skips_stale_order_entries() {
|
||||
let mut g = TxGraveyard::default();
|
||||
let (tx_a, entry_a, rate) = tomb_inputs(10);
|
||||
let (tx_b, entry_b, _) = tomb_inputs(11);
|
||||
let replacer = entry_b.txid;
|
||||
let pred = entry_a.txid;
|
||||
g.bury(tx_a.clone(), entry_a.clone(), rate, TxRemoval::Replaced { by: replacer });
|
||||
g.bury(tx_b, entry_b, rate, TxRemoval::Vanished);
|
||||
|
||||
// Re-bury the predecessor: its `order` entry is now stale.
|
||||
g.bury(tx_a, entry_a, rate, TxRemoval::Replaced { by: replacer });
|
||||
|
||||
let collected: Vec<(Txid, Txid)> = g
|
||||
.replaced_iter_recent_first()
|
||||
.map(|(p, by)| (*p, *by))
|
||||
.collect();
|
||||
assert_eq!(collected, vec![(pred, replacer)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evict_old_drops_aged_tombstones() {
|
||||
let mut g = TxGraveyard::default();
|
||||
let (tx_a, entry_a, rate) = tomb_inputs(12);
|
||||
let (tx_b, entry_b, _) = tomb_inputs(13);
|
||||
let txid_a = entry_a.txid;
|
||||
let txid_b = entry_b.txid;
|
||||
g.bury(tx_a, entry_a, rate, TxRemoval::Vanished);
|
||||
g.bury(tx_b, entry_b, rate, TxRemoval::Vanished);
|
||||
|
||||
g.shift_oldest_back(1);
|
||||
g.evict_old();
|
||||
|
||||
assert!(g.get(&txid_a).is_none(), "aged tombstone evicted");
|
||||
assert!(g.get(&txid_b).is_some(), "fresh tombstone retained");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_bury_mid_retention_resets_age() {
|
||||
let mut g = TxGraveyard::default();
|
||||
let (tx, entry, rate) = tomb_inputs(14);
|
||||
let txid = entry.txid;
|
||||
g.bury(tx.clone(), entry.clone(), rate, TxRemoval::Vanished);
|
||||
g.shift_oldest_back(1);
|
||||
|
||||
// Re-bury: a stale order entry remains pointing at the old time,
|
||||
// but `removed_at` on the tombstone is now fresh. evict_old's
|
||||
// timestamp-match check should drop the stale order entry without
|
||||
// touching the live tombstone.
|
||||
g.bury(tx, entry, rate, TxRemoval::Vanished);
|
||||
g.evict_old();
|
||||
assert!(g.get(&txid).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use brk_oracle::Histogram;
|
||||
use brk_types::{MempoolRecentTx, Transaction, TxOut, Txid, TxidPrefix, Vin};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
use crate::{TxEntry, stores::OutputBins};
|
||||
use crate::{state::TxEntry, stores::OutputBins};
|
||||
|
||||
const RECENT_CAP: usize = 10;
|
||||
|
||||
@@ -31,11 +32,15 @@ impl TxRecord {
|
||||
/// set of prefixes whose tx still has at least one `prevout: None`,
|
||||
/// maintained on every `insert` / `remove_by_prefix` / `apply_fills`
|
||||
/// so the post-update prevout filler can early-exit when empty.
|
||||
/// `live_histogram` mirrors the union of each record's `OutputBins`,
|
||||
/// kept in sync on `insert` / `remove_by_prefix` so the oracle-blend
|
||||
/// read path is a single array clone, not a full pool walk.
|
||||
#[derive(Default)]
|
||||
pub struct TxStore {
|
||||
records: FxHashMap<TxidPrefix, TxRecord>,
|
||||
recent: Vec<MempoolRecentTx>,
|
||||
unresolved: FxHashSet<TxidPrefix>,
|
||||
live_histogram: Histogram,
|
||||
}
|
||||
|
||||
impl TxStore {
|
||||
@@ -65,7 +70,7 @@ impl TxStore {
|
||||
self.records.get(prefix)
|
||||
}
|
||||
|
||||
/// `(prefix, record)` pairs in HashMap iteration order. Used by
|
||||
/// `(prefix, record)` pairs in `HashMap` iteration order. Used by
|
||||
/// the snapshot builder to assign a compact `TxIndex` to each
|
||||
/// live tx in one pass.
|
||||
pub fn records(&self) -> impl Iterator<Item = (&TxidPrefix, &TxRecord)> {
|
||||
@@ -86,7 +91,11 @@ impl TxStore {
|
||||
if tx.input.iter().any(|i| i.prevout.is_none()) {
|
||||
self.unresolved.insert(prefix);
|
||||
}
|
||||
self.records.insert(prefix, TxRecord::new(tx, entry));
|
||||
let record = TxRecord::new(tx, entry);
|
||||
for bin in record.output_bins.iter() {
|
||||
self.live_histogram[bin as usize] += 1;
|
||||
}
|
||||
self.records.insert(prefix, record);
|
||||
}
|
||||
|
||||
fn sample_recent(&mut self, txid: &Txid, tx: &Transaction) {
|
||||
@@ -103,9 +112,18 @@ impl TxStore {
|
||||
pub fn remove_by_prefix(&mut self, prefix: &TxidPrefix) -> Option<TxRecord> {
|
||||
let record = self.records.remove(prefix)?;
|
||||
self.unresolved.remove(prefix);
|
||||
for bin in record.output_bins.iter() {
|
||||
self.live_histogram[bin as usize] -= 1;
|
||||
}
|
||||
Some(record)
|
||||
}
|
||||
|
||||
/// Snapshot the live oracle-bin histogram. Maintained incrementally
|
||||
/// on insert/remove, so this is `O(NUM_BINS)`, not `O(live_outputs)`.
|
||||
pub fn live_histogram(&self) -> Histogram {
|
||||
self.live_histogram.clone()
|
||||
}
|
||||
|
||||
/// Set of prefixes with at least one unfilled prevout. Used by the
|
||||
/// prevout filler as a cheap "is there any work?" gate.
|
||||
pub fn unresolved(&self) -> &FxHashSet<TxidPrefix> {
|
||||
@@ -115,8 +133,10 @@ impl TxStore {
|
||||
/// Apply resolved prevouts to a tx in place. `fills` is `(vin, prevout)`.
|
||||
/// Returns the prevouts actually written (so the caller can fold them
|
||||
/// into `AddrTracker`). Updates `unresolved` if fully resolved after
|
||||
/// the fill, and recomputes `total_sigop_cost` (P2SH and witness
|
||||
/// components depend on prevouts).
|
||||
/// the fill, and refreshes `total_sigop_cost` (P2SH and witness
|
||||
/// components depend on prevouts). `entry.vsize` is Core's value from
|
||||
/// `MempoolEntryInfo` and is not recomputed here - the sigops shift
|
||||
/// belongs to the `Transaction`, not the entry.
|
||||
pub fn apply_fills(&mut self, prefix: &TxidPrefix, fills: Vec<(Vin, TxOut)>) -> Vec<TxOut> {
|
||||
let Some(record) = self.records.get_mut(prefix) else {
|
||||
return Vec::new();
|
||||
@@ -125,7 +145,7 @@ impl TxStore {
|
||||
if applied.is_empty() {
|
||||
return applied;
|
||||
}
|
||||
record.tx.total_sigop_cost = record.tx.total_sigop_cost();
|
||||
record.tx.refresh_sigops();
|
||||
if record.tx.input.iter().all(|i| i.prevout.is_some()) {
|
||||
self.unresolved.remove(prefix);
|
||||
}
|
||||
@@ -145,3 +165,181 @@ impl TxStore {
|
||||
applied
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bitcoin::ScriptBuf;
|
||||
use brk_types::{MempoolEntryInfo, Sats, Timestamp, VSize, Weight};
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::{fake_tx, fake_txid, p2wpkh_script};
|
||||
|
||||
fn entry_for(tx: &Transaction, fee: u64, vsize: u64) -> TxEntry {
|
||||
let info = MempoolEntryInfo {
|
||||
txid: tx.txid,
|
||||
vsize: VSize::from(vsize),
|
||||
weight: Weight::from(VSize::from(vsize)),
|
||||
fee: Sats::from(fee),
|
||||
first_seen: Timestamp::from(0u32),
|
||||
depends: vec![],
|
||||
};
|
||||
TxEntry::new(&info, vsize, false)
|
||||
}
|
||||
|
||||
fn tx_without_prevouts(seed: u8) -> Transaction {
|
||||
fake_tx(seed, &[None, None], &[(p2wpkh_script(1), 1_000)])
|
||||
}
|
||||
|
||||
fn tx_with_prevouts(seed: u8) -> Transaction {
|
||||
let prev = Some(TxOut::from((p2wpkh_script(2), Sats::from(2_000u64))));
|
||||
fake_tx(seed, &[prev], &[(p2wpkh_script(3), 500)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_records_unresolved_when_prevouts_missing() {
|
||||
let mut store = TxStore::default();
|
||||
let tx = tx_without_prevouts(1);
|
||||
let entry = entry_for(&tx, 100, 100);
|
||||
let prefix = entry.txid_prefix();
|
||||
store.insert(tx, entry);
|
||||
|
||||
assert!(store.unresolved().contains(&prefix));
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_skips_unresolved_when_all_prevouts_present() {
|
||||
let mut store = TxStore::default();
|
||||
let tx = tx_with_prevouts(2);
|
||||
let entry = entry_for(&tx, 200, 150);
|
||||
let prefix = entry.txid_prefix();
|
||||
store.insert(tx, entry);
|
||||
|
||||
assert!(!store.unresolved().contains(&prefix));
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_by_prefix_clears_unresolved_and_returns_record() {
|
||||
let mut store = TxStore::default();
|
||||
let tx = tx_without_prevouts(3);
|
||||
let entry = entry_for(&tx, 300, 200);
|
||||
let prefix = entry.txid_prefix();
|
||||
store.insert(tx, entry);
|
||||
assert!(store.unresolved().contains(&prefix));
|
||||
|
||||
let removed = store.remove_by_prefix(&prefix).expect("record present");
|
||||
assert_eq!(removed.entry.txid_prefix(), prefix);
|
||||
assert!(!store.unresolved().contains(&prefix));
|
||||
assert_eq!(store.len(), 0);
|
||||
assert!(store.remove_by_prefix(&prefix).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_fills_writes_only_missing_inputs_and_refreshes_sigops() {
|
||||
let mut store = TxStore::default();
|
||||
let prev_present = TxOut::from((p2wpkh_script(4), Sats::from(7_000u64)));
|
||||
let tx = fake_tx(
|
||||
4,
|
||||
&[None, Some(prev_present.clone())],
|
||||
&[(p2wpkh_script(5), 1_000)],
|
||||
);
|
||||
let entry = entry_for(&tx, 400, 250);
|
||||
let prefix = entry.txid_prefix();
|
||||
store.insert(tx, entry);
|
||||
assert!(store.unresolved().contains(&prefix));
|
||||
|
||||
let new_prevout = TxOut::from((p2wpkh_script(6), Sats::from(9_000u64)));
|
||||
let overwrite_attempt = TxOut::from((p2wpkh_script(99), Sats::from(1u64)));
|
||||
let applied = store.apply_fills(
|
||||
&prefix,
|
||||
vec![
|
||||
(Vin::from(0u32), new_prevout.clone()),
|
||||
(Vin::from(1u32), overwrite_attempt),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(applied.len(), 1);
|
||||
assert_eq!(applied[0].value, new_prevout.value);
|
||||
|
||||
let record = store.record_by_prefix(&prefix).expect("record present");
|
||||
assert_eq!(record.tx.input[0].prevout.as_ref().unwrap().value, new_prevout.value);
|
||||
assert_eq!(
|
||||
record.tx.input[1].prevout.as_ref().unwrap().value,
|
||||
prev_present.value
|
||||
);
|
||||
assert!(!store.unresolved().contains(&prefix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_fills_unknown_prefix_is_noop() {
|
||||
let mut store = TxStore::default();
|
||||
let stray_prefix = TxidPrefix::from(&fake_txid(0xFF));
|
||||
let applied = store.apply_fills(
|
||||
&stray_prefix,
|
||||
vec![(Vin::from(0u32), TxOut::from((ScriptBuf::new(), Sats::from(1u64))))],
|
||||
);
|
||||
assert!(applied.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_fills_partial_keeps_unresolved() {
|
||||
let mut store = TxStore::default();
|
||||
let tx = tx_without_prevouts(5);
|
||||
let entry = entry_for(&tx, 500, 300);
|
||||
let prefix = entry.txid_prefix();
|
||||
store.insert(tx, entry);
|
||||
|
||||
let one = TxOut::from((p2wpkh_script(7), Sats::from(3_000u64)));
|
||||
let applied = store.apply_fills(&prefix, vec![(Vin::from(0u32), one)]);
|
||||
assert_eq!(applied.len(), 1);
|
||||
assert!(
|
||||
store.unresolved().contains(&prefix),
|
||||
"input 1 still has None prevout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_is_capped_and_newest_first() {
|
||||
let mut store = TxStore::default();
|
||||
for i in 0..(RECENT_CAP as u8 + 5) {
|
||||
let tx = tx_with_prevouts(i + 10);
|
||||
let entry = entry_for(&tx, 100, 100);
|
||||
store.insert(tx, entry);
|
||||
}
|
||||
assert_eq!(store.recent().len(), RECENT_CAP);
|
||||
let newest = store.recent().first().expect("at least one");
|
||||
let last_inserted_txid = fake_txid(RECENT_CAP as u8 + 5 + 10 - 1);
|
||||
assert_eq!(newest.txid, last_inserted_txid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_histogram_total_tracks_inserts_and_removes() {
|
||||
let mut store = TxStore::default();
|
||||
let tx_a = fake_tx(
|
||||
20,
|
||||
&[Some(TxOut::from((p2wpkh_script(8), Sats::from(1_234u64))))],
|
||||
&[
|
||||
(p2wpkh_script(9), 2_345),
|
||||
(p2wpkh_script(10), 3_456),
|
||||
],
|
||||
);
|
||||
let tx_b = fake_tx(
|
||||
21,
|
||||
&[Some(TxOut::from((p2wpkh_script(11), Sats::from(4_567u64))))],
|
||||
&[(p2wpkh_script(12), 7_891)],
|
||||
);
|
||||
let entry_a = entry_for(&tx_a, 100, 100);
|
||||
let entry_b = entry_for(&tx_b, 100, 100);
|
||||
let prefix_a = entry_a.txid_prefix();
|
||||
store.insert(tx_a, entry_a);
|
||||
store.insert(tx_b, entry_b);
|
||||
|
||||
let total_after_both: u32 = store.live_histogram().iter().sum();
|
||||
assert_eq!(total_after_both, 3, "two outputs + one output");
|
||||
|
||||
store.remove_by_prefix(&prefix_a);
|
||||
let total_after_remove: u32 = store.live_histogram().iter().sum();
|
||||
assert_eq!(total_after_remove, 1);
|
||||
}
|
||||
}
|
||||
|
||||
119
crates/brk_mempool/src/test_support.rs
Normal file
119
crates/brk_mempool/src/test_support.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
//! Tiny tx fixtures shared across the crate's unit tests. Keeps
|
||||
//! constructor noise out of the test bodies so each test reads as
|
||||
//! "set up, mutate, assert" without 20 lines of struct literals.
|
||||
|
||||
use bitcoin::{ScriptBuf, absolute::LockTime, hashes::Hash, transaction::Version};
|
||||
use brk_types::{
|
||||
MempoolEntryInfo, RawLockTime, Sats, SigOps, Timestamp, Transaction, TxIn, TxOut, TxStatus,
|
||||
TxVersionRaw, Txid, VSize, Vout, Weight, Witness,
|
||||
};
|
||||
|
||||
/// Deterministic `Txid` from a single seed byte. The first byte of the
|
||||
/// hash is `seed`, the rest is zero, so tests can identify txs by eye
|
||||
/// in debug output.
|
||||
pub fn fake_txid(seed: u8) -> Txid {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[0] = seed;
|
||||
Txid::from(bitcoin::Txid::from_byte_array(bytes))
|
||||
}
|
||||
|
||||
/// Minimal P2WPKH `script_pubkey` keyed off `seed` so distinct inputs
|
||||
/// or outputs in the same test don't collide on `addr_bytes()`.
|
||||
pub fn p2wpkh_script(seed: u8) -> ScriptBuf {
|
||||
let mut bytes = [0u8; 20];
|
||||
bytes[0] = seed;
|
||||
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array(bytes))
|
||||
}
|
||||
|
||||
/// Build a `Transaction` with one input per entry in `prevouts` (each
|
||||
/// either resolved or `None`) and one output per `(script, value)`
|
||||
/// pair. Counterparty txids on the inputs are derived from the seed so
|
||||
/// `addr_bytes` extraction sees distinct prev txids per input.
|
||||
pub fn fake_tx(seed: u8, prevouts: &[Option<TxOut>], outputs: &[(ScriptBuf, u64)]) -> Transaction {
|
||||
let input = prevouts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| TxIn {
|
||||
is_coinbase: false,
|
||||
prevout: p.clone(),
|
||||
txid: fake_txid(seed.wrapping_add(100 + i as u8)),
|
||||
vout: Vout::ZERO,
|
||||
script_sig: ScriptBuf::new(),
|
||||
script_sig_asm: (),
|
||||
witness: Witness::default(),
|
||||
sequence: 0xffff_fffe,
|
||||
inner_redeem_script_asm: (),
|
||||
inner_witness_script_asm: (),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let output = outputs
|
||||
.iter()
|
||||
.map(|(script, value)| TxOut::from((script.clone(), Sats::from(*value))))
|
||||
.collect();
|
||||
|
||||
let mut tx = Transaction {
|
||||
index: None,
|
||||
txid: fake_txid(seed),
|
||||
version: TxVersionRaw::from(Version::TWO),
|
||||
lock_time: RawLockTime::from(LockTime::ZERO),
|
||||
input,
|
||||
output,
|
||||
total_size: 200,
|
||||
weight: Weight::from(800u64),
|
||||
total_sigop_cost: SigOps::ZERO,
|
||||
fee: Sats::ZERO,
|
||||
status: TxStatus::UNCONFIRMED,
|
||||
};
|
||||
tx.refresh_sigops();
|
||||
tx
|
||||
}
|
||||
|
||||
/// Plain `MempoolEntryInfo` keyed off `txid`. Test bodies usually
|
||||
/// already have the txid from `fake_tx`, so this just fills in the
|
||||
/// non-essential fields with deterministic placeholders.
|
||||
pub fn fake_entry_info(txid: Txid, fee: u64, vsize: u64) -> MempoolEntryInfo {
|
||||
MempoolEntryInfo {
|
||||
txid,
|
||||
vsize: VSize::from(vsize),
|
||||
weight: Weight::from(vsize * 4),
|
||||
fee: Sats::from(fee),
|
||||
first_seen: Timestamp::from(0u32),
|
||||
depends: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Bitcoin-protocol `Transaction` matching `fake_tx`. Round-trippable
|
||||
/// against a brk `Transaction`, lets the Preparer's `Fresh` path decode
|
||||
/// it without a real RPC payload.
|
||||
pub fn fake_bitcoin_tx(
|
||||
prev_txid_seed: u8,
|
||||
outputs: &[(ScriptBuf, u64)],
|
||||
) -> bitcoin::Transaction {
|
||||
let input = vec![bitcoin::TxIn {
|
||||
previous_output: bitcoin::OutPoint {
|
||||
txid: bitcoin::Txid::from_byte_array({
|
||||
let mut b = [0u8; 32];
|
||||
b[0] = prev_txid_seed;
|
||||
b
|
||||
}),
|
||||
vout: 0,
|
||||
},
|
||||
script_sig: ScriptBuf::new(),
|
||||
sequence: bitcoin::Sequence(0xffff_fffe),
|
||||
witness: bitcoin::Witness::new(),
|
||||
}];
|
||||
let output = outputs
|
||||
.iter()
|
||||
.map(|(script, value)| bitcoin::TxOut {
|
||||
value: bitcoin::Amount::from_sat(*value),
|
||||
script_pubkey: script.clone(),
|
||||
})
|
||||
.collect();
|
||||
bitcoin::Transaction {
|
||||
version: Version::TWO,
|
||||
lock_time: LockTime::ZERO,
|
||||
input,
|
||||
output,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user