mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-19 06:58:11 -07:00
global: fixes
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
//! CPFP queries: dispatches between the live mempool path (handled by
|
||||
//! `brk_mempool`) and the confirmed-tx path built here from indexer
|
||||
//! and computer vecs.
|
||||
//!
|
||||
//! Confirmed clusters are built on demand by walking the same-block
|
||||
//! parent/child edges in `TxIndex` space (no `Transaction`
|
||||
//! reconstruction, no `txid → tx_index` lookup), then handing the
|
||||
//! resulting `brk_mempool::cluster::Cluster` to `Cluster::to_cpfp_info`
|
||||
//! — the same wire converter the mempool path uses, so both produce
|
||||
//! identical `CpfpInfo` shapes.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use bitcoin::consensus::Decodable;
|
||||
use brk_error::{Error, OptionData, Result};
|
||||
use brk_mempool::cluster::{Cluster, ClusterNode, LocalIdx};
|
||||
use brk_types::{
|
||||
CpfpInfo, FeeRate, Height, OutPoint, OutputType, Sats, SigOps, TxIndex, TxInIndex, TypeIndex,
|
||||
Txid, TxidPrefix, VSize, Weight,
|
||||
};
|
||||
use rustc_hash::{FxBuildHasher, FxHashMap};
|
||||
use smallvec::SmallVec;
|
||||
use vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
use crate::Query;
|
||||
|
||||
/// Cap matches Bitcoin Core's default mempool ancestor/descendant
|
||||
/// chain limits and mempool.space's truncation.
|
||||
const MAX: usize = 25;
|
||||
|
||||
struct WalkResult {
|
||||
/// Cluster members in build order (`[seed, ancestors..., descendants...]`),
|
||||
/// each paired with its in-cluster parent edges already resolved to
|
||||
/// `LocalIdx`. Vec position equals the node's `LocalIdx`.
|
||||
nodes: Vec<(TxIndex, SmallVec<[LocalIdx; 2]>)>,
|
||||
/// Pre-permutation `LocalIdx` of the seed. Equals `ancestor_count`
|
||||
/// because all of seed's in-cluster ancestors topo-sort before it
|
||||
/// and only ancestors do, so after `Cluster::new` permutes nodes
|
||||
/// into topological order seed lands at this exact position.
|
||||
seed_local: LocalIdx,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
/// CPFP cluster for `txid`. Returns the mempool cluster when the
|
||||
/// txid is unconfirmed; otherwise reconstructs the confirmed
|
||||
/// same-block cluster from indexer state. Works even when the
|
||||
/// mempool feature is off.
|
||||
pub fn cpfp(&self, txid: &Txid) -> Result<CpfpInfo> {
|
||||
let prefix = TxidPrefix::from(txid);
|
||||
if let Some(info) = self.mempool().and_then(|m| m.cpfp_info(&prefix)) {
|
||||
return Ok(info);
|
||||
}
|
||||
self.confirmed_cpfp(txid)
|
||||
}
|
||||
|
||||
/// Effective fee rate for `txid` using the same SFL chunk-rate
|
||||
/// semantics across paths:
|
||||
///
|
||||
/// - Live mempool: snapshot `cluster_of` lookup → seed's chunk rate.
|
||||
/// If the tx is in the pool but not in the latest snapshot (e.g.
|
||||
/// just added), falls back to the entry's simple `fee/vsize`.
|
||||
/// - Confirmed: precomputed `effective_fee_rate.tx_index` (the same
|
||||
/// SFL chunk rate, computed at index time).
|
||||
/// - Graveyard-only RBF predecessor: simple `fee/vsize` snapshotted
|
||||
/// at burial.
|
||||
///
|
||||
/// Returns `Error::UnknownTxid` for txids not seen in any of those.
|
||||
pub fn effective_fee_rate(&self, txid: &Txid) -> Result<FeeRate> {
|
||||
let prefix = TxidPrefix::from(txid);
|
||||
|
||||
if let Some(mempool) = self.mempool() {
|
||||
let entries = mempool.entries();
|
||||
if let Some(seed_idx) = entries.idx_of(&prefix)
|
||||
&& let Some(rate) = mempool.snapshot().chunk_rate_of(seed_idx)
|
||||
{
|
||||
return Ok(rate);
|
||||
}
|
||||
if let Some(entry) = entries.get(&prefix) {
|
||||
return Ok(entry.fee_rate());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(idx) = self.resolve_tx_index(txid)
|
||||
&& let Some(rate) = self
|
||||
.computer()
|
||||
.transactions
|
||||
.fees
|
||||
.effective_fee_rate
|
||||
.tx_index
|
||||
.collect_one(idx)
|
||||
{
|
||||
return Ok(rate);
|
||||
}
|
||||
|
||||
if let Some(mempool) = self.mempool()
|
||||
&& let Some(tomb) = mempool.graveyard().get(txid)
|
||||
{
|
||||
return Ok(tomb.entry.fee_rate());
|
||||
}
|
||||
|
||||
Err(Error::UnknownTxid)
|
||||
}
|
||||
|
||||
/// CPFP cluster for a confirmed tx: the connected component of
|
||||
/// same-block parent/child edges, walked on demand. SFL runs on
|
||||
/// the result so `effectiveFeePerVsize` matches the live path's
|
||||
/// chunk-rate semantics.
|
||||
fn confirmed_cpfp(&self, txid: &Txid) -> Result<CpfpInfo> {
|
||||
let seed = self.resolve_tx_index(txid)?;
|
||||
let height = self.confirmed_status_height(seed)?;
|
||||
let (cluster, seed_local) = self.build_confirmed_cluster(seed, height)?;
|
||||
let sigops = self.seed_sigop_cost(seed)?;
|
||||
Ok(cluster.to_cpfp_info(seed_local, sigops))
|
||||
}
|
||||
|
||||
/// BIP-141 sigop cost for a single confirmed tx, computed on demand:
|
||||
/// re-decode the raw tx, rebuild its prevout map from `inputs.*` +
|
||||
/// addr vecs, then defer the actual count to `SigOps::of_bitcoin_tx`.
|
||||
/// Cost is one BLK read plus `n_inputs` cursor hops, so a few hundred
|
||||
/// microseconds per CPFP request.
|
||||
fn seed_sigop_cost(&self, tx_index: TxIndex) -> Result<SigOps> {
|
||||
let indexer = self.indexer();
|
||||
let total_size = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.total_size
|
||||
.collect_one(tx_index)
|
||||
.data()?;
|
||||
let position = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.position
|
||||
.collect_one(tx_index)
|
||||
.data()?;
|
||||
let buffer = self.reader().read_raw_bytes(position, *total_size as usize)?;
|
||||
let decoded = bitcoin::Transaction::consensus_decode(&mut Cursor::new(buffer))
|
||||
.map_err(|_| Error::Parse("Failed to decode transaction".into()))?;
|
||||
|
||||
let first_txin = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_txin_index
|
||||
.collect_one(tx_index)
|
||||
.data()?;
|
||||
let start = usize::from(first_txin);
|
||||
let count = decoded.input.len();
|
||||
|
||||
let mut outpoint_cursor = indexer.vecs.inputs.outpoint.cursor();
|
||||
let mut output_type_cursor = indexer.vecs.inputs.output_type.cursor();
|
||||
let mut type_index_cursor = indexer.vecs.inputs.type_index.cursor();
|
||||
let mut value_cursor = self.computer().inputs.spent.value.cursor();
|
||||
|
||||
let addr_readers = indexer.vecs.addrs.addr_readers();
|
||||
|
||||
let mut prevout_map: FxHashMap<bitcoin::OutPoint, bitcoin::TxOut> =
|
||||
FxHashMap::with_capacity_and_hasher(count, FxBuildHasher);
|
||||
|
||||
for (j, txin) in decoded.input.iter().enumerate() {
|
||||
let op: OutPoint = outpoint_cursor.get(start + j).data()?;
|
||||
if op.is_coinbase() {
|
||||
continue;
|
||||
}
|
||||
let ot: OutputType = output_type_cursor.get(start + j).data()?;
|
||||
let ti: TypeIndex = type_index_cursor.get(start + j).data()?;
|
||||
let val: Sats = value_cursor.get(start + j).data()?;
|
||||
let script_pubkey = addr_readers.script_pubkey(ot, ti);
|
||||
prevout_map.insert(
|
||||
txin.previous_output,
|
||||
bitcoin::TxOut {
|
||||
value: bitcoin::Amount::from_sat(u64::from(val)),
|
||||
script_pubkey,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(SigOps::of_bitcoin_tx(&decoded, |outpoint| {
|
||||
prevout_map.get(outpoint).cloned()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Walk the seed's same-block parent/child edges, materialize each
|
||||
/// member's `(txid, weight, fee)` from indexer/computer cursors,
|
||||
/// and build a `Cluster<TxIndex>`. The seed's `LocalIdx` comes
|
||||
/// straight from the walk (`ancestor_count`), since `Cluster::new`
|
||||
/// preserves the "ancestors before seed before descendants" ordering
|
||||
/// that defines that index.
|
||||
fn build_confirmed_cluster(
|
||||
&self,
|
||||
seed: TxIndex,
|
||||
height: Height,
|
||||
) -> Result<(Cluster<TxIndex>, LocalIdx)> {
|
||||
let indexer = self.indexer();
|
||||
let computer = self.computer();
|
||||
let block_first = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_tx_index
|
||||
.collect_one(height)
|
||||
.data()?;
|
||||
let block_end = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_tx_index
|
||||
.collect_one(height.incremented())
|
||||
.unwrap_or_else(|| TxIndex::from(indexer.vecs.transactions.txid.len()));
|
||||
let same_block = |idx: TxIndex| idx >= block_first && idx < block_end;
|
||||
|
||||
let WalkResult { nodes, seed_local } = self.walk_same_block_edges(seed, same_block);
|
||||
|
||||
let mut base_size = indexer.vecs.transactions.base_size.cursor();
|
||||
let mut total_size = indexer.vecs.transactions.total_size.cursor();
|
||||
let mut fee_cursor = computer.transactions.fees.fee.tx_index.cursor();
|
||||
let txid_reader = indexer.vecs.transactions.txid.reader();
|
||||
|
||||
let cluster_nodes: Vec<ClusterNode<TxIndex>> = nodes
|
||||
.into_iter()
|
||||
.map(|(tx_index, parents)| {
|
||||
let i = tx_index.to_usize();
|
||||
let weight = Weight::from_sizes(*base_size.get(i).data()?, *total_size.get(i).data()?);
|
||||
Ok(ClusterNode {
|
||||
id: tx_index,
|
||||
txid: txid_reader.get(i),
|
||||
fee: fee_cursor.get(i).data()?,
|
||||
vsize: VSize::from(weight),
|
||||
weight,
|
||||
parents,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
Ok((Cluster::new(cluster_nodes), seed_local))
|
||||
}
|
||||
|
||||
/// BFS the seed's same-block ancestors (via `outpoint`) and
|
||||
/// descendants (via `spent.txin_index` → `spending_tx`), capped
|
||||
/// at `MAX` each side to match Core/mempool.space. Each node is
|
||||
/// pushed in build order with its full parent-outpoint list, then
|
||||
/// at end of walk those lists are filtered against the membership
|
||||
/// map to keep only in-cluster parents (resolved to `LocalIdx`).
|
||||
fn walk_same_block_edges(
|
||||
&self,
|
||||
seed: TxIndex,
|
||||
same_block: impl Fn(TxIndex) -> bool,
|
||||
) -> WalkResult {
|
||||
let indexer = self.indexer();
|
||||
let computer = self.computer();
|
||||
let mut first_txin = indexer.vecs.transactions.first_txin_index.cursor();
|
||||
let mut first_txout = indexer.vecs.transactions.first_txout_index.cursor();
|
||||
let mut outpoint = indexer.vecs.inputs.outpoint.cursor();
|
||||
let mut spent = computer.outputs.spent.txin_index.cursor();
|
||||
let mut spending_tx = indexer.vecs.inputs.tx_index.cursor();
|
||||
|
||||
let mut walk_inputs = |tx: TxIndex| -> SmallVec<[TxIndex; 2]> {
|
||||
let mut out: SmallVec<[TxIndex; 2]> = SmallVec::new();
|
||||
let Ok(start) = first_txin.get(tx.to_usize()).data() else { return out };
|
||||
let Ok(end) = first_txin.get(tx.to_usize() + 1).data() else { return out };
|
||||
for i in usize::from(start)..usize::from(end) {
|
||||
let Ok(op) = outpoint.get(i).data() else { continue };
|
||||
if op.is_coinbase() {
|
||||
continue;
|
||||
}
|
||||
out.push(op.tx_index());
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
let mut raw: Vec<(TxIndex, SmallVec<[TxIndex; 2]>)> = Vec::with_capacity(2 * MAX + 1);
|
||||
let mut local_of: FxHashMap<TxIndex, LocalIdx> =
|
||||
FxHashMap::with_capacity_and_hasher(2 * MAX + 1, FxBuildHasher);
|
||||
raw.push((seed, walk_inputs(seed)));
|
||||
local_of.insert(seed, LocalIdx::ZERO);
|
||||
|
||||
// Ancestor BFS. Stack holds indices into `raw`; each pop reads
|
||||
// that node's already-recorded parents and explores any same-block
|
||||
// ones we haven't visited yet. `walk_inputs` runs at push time so
|
||||
// parents are ready for the post-walk filter.
|
||||
let mut stack: Vec<usize> = vec![0];
|
||||
let mut ancestor_count: usize = 0;
|
||||
'a: while let Some(idx) = stack.pop() {
|
||||
let parents = raw[idx].1.clone();
|
||||
for parent in parents {
|
||||
if ancestor_count >= MAX {
|
||||
break 'a;
|
||||
}
|
||||
if local_of.contains_key(&parent) || !same_block(parent) {
|
||||
continue;
|
||||
}
|
||||
let new_idx = raw.len();
|
||||
raw.push((parent, walk_inputs(parent)));
|
||||
local_of.insert(parent, LocalIdx::from(new_idx));
|
||||
stack.push(new_idx);
|
||||
ancestor_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Descendant BFS. Stack holds tx_indices since we look up each
|
||||
// tx's txouts via `first_txout`/`spent`/`spending_tx`. `local_of`
|
||||
// already contains the seed and every ancestor, so they're
|
||||
// skipped by the membership check.
|
||||
let mut stack: Vec<TxIndex> = vec![seed];
|
||||
let mut descendant_count = 0;
|
||||
'd: while let Some(cur) = stack.pop() {
|
||||
let Ok(start) = first_txout.get(cur.to_usize()).data() else { continue };
|
||||
let Ok(end) = first_txout.get(cur.to_usize() + 1).data() else { continue };
|
||||
for i in usize::from(start)..usize::from(end) {
|
||||
let Ok(txin_idx) = spent.get(i).data() else { continue };
|
||||
if txin_idx == TxInIndex::UNSPENT {
|
||||
continue;
|
||||
}
|
||||
let Ok(child) = spending_tx.get(usize::from(txin_idx)).data() else { continue };
|
||||
if local_of.contains_key(&child) || !same_block(child) {
|
||||
continue;
|
||||
}
|
||||
let new_idx = raw.len();
|
||||
raw.push((child, walk_inputs(child)));
|
||||
local_of.insert(child, LocalIdx::from(new_idx));
|
||||
stack.push(child);
|
||||
descendant_count += 1;
|
||||
if descendant_count >= MAX {
|
||||
break 'd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter each node's full input list against `local_of` to keep
|
||||
// only in-cluster parents, resolved to their `LocalIdx`.
|
||||
let nodes: Vec<(TxIndex, SmallVec<[LocalIdx; 2]>)> = raw
|
||||
.into_iter()
|
||||
.map(|(tx_index, full_inputs)| {
|
||||
let parents: SmallVec<[LocalIdx; 2]> = full_inputs
|
||||
.iter()
|
||||
.filter_map(|p| local_of.get(p).copied())
|
||||
.collect();
|
||||
(tx_index, parents)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Seed's pre-permutation index is 0; after `Cluster::new` topo-sorts
|
||||
// it lands at `ancestor_count` (all in-cluster ancestors come first,
|
||||
// and only ancestors do).
|
||||
WalkResult {
|
||||
nodes,
|
||||
seed_local: LocalIdx::from(ancestor_count),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user