mempool: fixes

This commit is contained in:
nym21
2026-05-07 11:21:37 +02:00
parent cb74087f27
commit 9347b42c9a
21 changed files with 518 additions and 239 deletions

View File

@@ -4,8 +4,8 @@ use bitcoin::{Network, PublicKey, ScriptBuf};
use brk_error::{Error, OptionData, Result};
use brk_types::{
Addr, AddrBytes, AddrChainStats, AddrHash, AddrIndexOutPoint, AddrIndexTxIndex, AddrStats,
AnyAddrDataIndexEnum, Dollars, Height, OutputType, Timestamp, Transaction, TxIndex, TxStatus,
Txid, TxidPrefix, TypeIndex, Unit, Utxo, Vout,
AnyAddrDataIndexEnum, Dollars, Height, OutputType, Transaction, TxIndex, TxStatus, Txid,
TypeIndex, Unit, Utxo, Vout,
};
use vecdb::VecIndex;
@@ -80,7 +80,7 @@ impl Query {
},
mempool_stats: self
.mempool()
.and_then(|m| m.addrs().get(&bytes).map(|e| e.stats.clone()))
.and_then(|m| m.addr_stats(&bytes))
.unwrap_or_default(),
})
}
@@ -233,29 +233,7 @@ impl Query {
pub fn addr_mempool_txs(&self, addr: &Addr, limit: usize) -> Result<Vec<Transaction>> {
let bytes = AddrBytes::from_str(addr)?;
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
let addrs = mempool.addrs();
let Some(entry) = addrs.get(&bytes) else {
return Ok(vec![]);
};
let entries = mempool.entries();
let mut ordered: Vec<(Timestamp, &Txid)> = entry
.txids
.iter()
.map(|txid| {
let first_seen = entries
.get(&TxidPrefix::from(txid))
.map(|e| e.first_seen)
.unwrap_or_default();
(first_seen, txid)
})
.collect();
ordered.sort_unstable_by_key(|b| std::cmp::Reverse(b.0));
let txs = mempool.txs();
Ok(ordered
.into_iter()
.filter_map(|(_, txid)| txs.get(txid).cloned())
.take(limit)
.collect())
Ok(mempool.addr_txs(&bytes, limit))
}
/// Height of the last on-chain activity for an address (last tx_index → height).

View File

@@ -62,16 +62,10 @@ impl Query {
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 Some(mempool) = self.mempool()
&& let Some(rate) = mempool.live_effective_fee_rate(&prefix)
{
return Ok(rate);
}
if let Ok(idx) = self.resolve_tx_index(txid)
@@ -87,9 +81,9 @@ impl Query {
}
if let Some(mempool) = self.mempool()
&& let Some(tomb) = mempool.graveyard().get(txid)
&& let Some(rate) = mempool.graveyard_fee_rate(txid)
{
return Ok(tomb.entry.fee_rate());
return Ok(rate);
}
Err(Error::UnknownTxid)

View File

@@ -1,11 +1,10 @@
use brk_error::{Error, Result};
use brk_mempool::{EntryPool, Mempool, TxEntry, TxGraveyard, TxRemoval, TxStore, TxTombstone};
use brk_mempool::{Mempool, RbfForTx, RbfNode};
use brk_types::{
CheckedSub, MempoolBlock, MempoolInfo, MempoolRecentTx, OutputType, RbfResponse, RbfTx,
RecommendedFees, ReplacementNode, Sats, Timestamp, Transaction, TxOut, TxOutIndex, Txid,
TxidPrefix, TypeIndex,
CheckedSub, FeeRate, MempoolBlock, MempoolInfo, MempoolRecentTx, OutputType, RbfResponse, RbfTx,
RecommendedFees, ReplacementNode, Sats, Timestamp, TxOut, TxOutIndex, Txid, TxidPrefix,
TypeIndex,
};
use rustc_hash::FxHashSet;
use vecdb::VecIndex;
use crate::Query;
@@ -22,8 +21,7 @@ impl Query {
}
pub fn mempool_txids(&self) -> Result<Vec<Txid>> {
let txs = self.require_mempool()?.txs();
Ok(txs.keys().cloned().collect())
Ok(self.require_mempool()?.txids())
}
pub fn recommended_fees(&self) -> Result<RecommendedFees> {
@@ -100,159 +98,89 @@ impl Query {
}
pub fn mempool_recent(&self) -> Result<Vec<MempoolRecentTx>> {
Ok(self.require_mempool()?.txs().recent().to_vec())
Ok(self.require_mempool()?.recent_txs())
}
/// RBF history for a tx, matching mempool.space's
/// `GET /api/v1/tx/:txid/rbf`. Walks forward through the graveyard
/// to find the latest known replacer (tree root), then recursively
/// walks `predecessors_of` backward to build the tree. `replaces`
/// is the requested tx's own direct predecessors.
/// `GET /api/v1/tx/:txid/rbf`. The mempool builds the owned
/// replacement tree (terminal replacer + recursive predecessors)
/// under one read-lock window; this method then enriches each node
/// with `mined` + effective fee rate, both of which need the
/// indexer/computer.
pub fn tx_rbf(&self, txid: &Txid) -> Result<RbfResponse> {
let mempool = self.require_mempool()?;
let txs = mempool.txs();
let entries = mempool.entries();
let graveyard = mempool.graveyard();
let root_txid = Self::walk_to_replacement_root(&graveyard, *txid);
let replaces_vec: Vec<Txid> = graveyard.predecessors_of(txid).map(|(p, _)| *p).collect();
let replaces = (!replaces_vec.is_empty()).then_some(replaces_vec);
let replacements = self.build_rbf_node(&root_txid, None, &txs, &entries, &graveyard);
let RbfForTx { root, replaces } = self.require_mempool()?.rbf_for_tx(txid);
let replacements = root.map(|n| self.enrich_rbf_node(n, None));
let replaces = (!replaces.is_empty()).then_some(replaces);
Ok(RbfResponse {
replacements,
replaces,
})
}
/// Walk forward through `Replaced { by }` links to the terminal
/// replacer of an RBF chain. Returns `txid` itself if it's already
/// the root.
fn walk_to_replacement_root(graveyard: &TxGraveyard, mut root: Txid) -> Txid {
while let Some(TxRemoval::Replaced { by }) = graveyard.get(&root).map(TxTombstone::reason) {
root = *by;
}
root
}
/// Resolve a txid to the data we need for an `RbfTx`. The live
/// pool takes priority; the graveyard is the fallback. Returns
/// `None` if the tx has no known data in either.
fn resolve_rbf_node_data<'a>(
txid: &Txid,
txs: &'a TxStore,
entries: &'a EntryPool,
graveyard: &'a TxGraveyard,
) -> Option<(&'a Transaction, &'a TxEntry)> {
if let (Some(tx), Some(entry)) = (txs.get(txid), entries.get(&TxidPrefix::from(txid))) {
return Some((tx, entry));
}
graveyard.get(txid).map(|tomb| (&tomb.tx, &tomb.entry))
}
/// Recursively build an RBF tree node rooted at `txid`.
/// Predecessors are always in the graveyard (that's where
/// `Removal::Replaced` lives), so the recursion only needs the
/// graveyard; the live pool is consulted for the root.
///
/// `rate` matches mempool.space's `tx.effectiveFeePerVsize` via
/// `Query::effective_fee_rate`, with a fall-back to the entry's
/// simple `fee/vsize` when the rate lookup fails.
fn build_rbf_node(
&self,
txid: &Txid,
successor_time: Option<Timestamp>,
txs: &TxStore,
entries: &EntryPool,
graveyard: &TxGraveyard,
) -> Option<ReplacementNode> {
let (tx, entry) = Self::resolve_rbf_node_data(txid, txs, entries, graveyard)?;
let replaces: Vec<ReplacementNode> = graveyard
.predecessors_of(txid)
.filter_map(|(pred_txid, _)| {
self.build_rbf_node(pred_txid, Some(entry.first_seen), txs, entries, graveyard)
})
.collect();
let full_rbf = replaces.iter().any(|c| !c.tx.rbf || c.full_rbf);
let interval = successor_time
.and_then(|st| st.checked_sub(entry.first_seen))
.map(|d| *d);
let value: Sats = tx.output.iter().map(|o| o.value).sum();
let mined = self.resolve_tx_index(txid).is_ok().then_some(true);
let rate = self
.effective_fee_rate(txid)
.unwrap_or_else(|_| entry.fee_rate());
Some(ReplacementNode {
tx: RbfTx {
txid: *txid,
fee: entry.fee,
vsize: entry.vsize,
value,
rate,
time: entry.first_seen,
rbf: entry.rbf,
full_rbf: Some(full_rbf),
mined,
},
time: entry.first_seen,
full_rbf,
interval,
mined,
replaces,
})
}
/// Recent RBF replacements across the whole mempool, matching
/// mempool.space's `GET /api/v1/replacements` and
/// `GET /api/v1/fullrbf/replacements`. Each entry is a complete
/// replacement tree rooted at the terminal replacer; same shape as
/// `tx_rbf().replacements`. Ordered by most-recent replacement
/// event first (matches mempool.space's reversed-`replacedBy`
/// iteration) and capped at 25 entries. When `full_rbf_only` is
/// event first and capped at 25 entries. When `full_rbf_only` is
/// true, only trees with at least one non-signaling predecessor
/// are returned.
pub fn recent_replacements(&self, full_rbf_only: bool) -> Result<Vec<ReplacementNode>> {
let mempool = self.require_mempool()?;
let txs = mempool.txs();
let entries = mempool.entries();
let graveyard = mempool.graveyard();
// A predecessor's `by` may itself be replaced; walk the chain
// forward to the terminal replacer for each tree, dedup so each
// tree is emitted once at its first (most recent) sighting.
let mut seen: FxHashSet<Txid> = FxHashSet::default();
Ok(graveyard
.replaced_iter_recent_first()
.filter_map(|(_, by)| {
let root = Self::walk_to_replacement_root(&graveyard, *by);
seen.insert(root).then_some(root)
})
.filter_map(|root| self.build_rbf_node(&root, None, &txs, &entries, &graveyard))
.filter(|node| !full_rbf_only || node.full_rbf)
.take(RECENT_REPLACEMENTS_LIMIT)
Ok(self
.require_mempool()?
.recent_rbf_trees(full_rbf_only, RECENT_REPLACEMENTS_LIMIT)
.into_iter()
.map(|n| self.enrich_rbf_node(n, None))
.collect())
}
/// Layer indexer-resident data (`mined`, effective fee rate) onto
/// a `RbfNode` tree. Runs after the mempool lock window has closed
/// because `effective_fee_rate` re-enters `Mempool` and would
/// recursively acquire the same read locks otherwise.
fn enrich_rbf_node(
&self,
node: RbfNode,
successor_time: Option<Timestamp>,
) -> ReplacementNode {
let interval = successor_time
.and_then(|st| st.checked_sub(node.first_seen))
.map(|d| *d);
let mined = self.resolve_tx_index(&node.txid).is_ok().then_some(true);
let rate = self
.effective_fee_rate(&node.txid)
.unwrap_or_else(|_| FeeRate::from((node.fee, node.vsize)));
let first_seen = node.first_seen;
let replaces = node
.replaces
.into_iter()
.map(|child| self.enrich_rbf_node(child, Some(first_seen)))
.collect();
ReplacementNode {
tx: RbfTx {
txid: node.txid,
fee: node.fee,
vsize: node.vsize,
value: node.value,
rate,
time: first_seen,
rbf: node.rbf,
full_rbf: Some(node.full_rbf),
mined,
},
time: first_seen,
full_rbf: node.full_rbf,
interval,
mined,
replaces,
}
}
/// `first_seen` Unix-second timestamps for each txid, matching
/// mempool.space's `POST /api/v1/transaction-times`. Returns 0 for
/// unknown txids, in input order.
pub fn transaction_times(&self, txids: &[Txid]) -> Result<Vec<u64>> {
let entries = self.require_mempool()?.entries();
Ok(txids
.iter()
.map(|txid| {
entries
.get(&TxidPrefix::from(txid))
.map_or(0, |e| u64::from(e.first_seen))
})
.collect())
Ok(self.require_mempool()?.transaction_times(txids))
}
/// Opaque content hash that changes whenever the projected next

View File

@@ -11,12 +11,7 @@ impl Query {
let mut oracle = self.computer().prices.live_oracle(self.indexer())?;
if let Some(mempool) = self.mempool() {
let txs = mempool.txs();
oracle.process_outputs(
txs.values()
.flat_map(|tx| &tx.output)
.map(|txout| (txout.value, txout.type_())),
);
mempool.process_live_outputs(|iter| oracle.process_outputs(iter));
}
Ok(oracle.price_dollars())

View File

@@ -97,21 +97,40 @@ impl Query {
// ── Transaction queries ────────────────────────────────────────
/// Map a mempool transaction by txid through `f`, returning `None`
/// if no mempool is attached or the txid is not in mempool.
fn map_mempool_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> {
self.mempool()?.txs().get(txid).map(f)
/// Resolve a tx body across the three sources in order: live mempool,
/// indexer (via `indexed`), then `Vanished` graveyard tombstone.
/// The graveyard fallback only fires when the indexer reports
/// `UnknownTxid`, covering the brief race where a mined tx has been
/// buried by `Applier` but `safe_lengths.tx_index` has not yet
/// advanced to cover it. `Replaced` tombstones are excluded — those
/// txs will never confirm.
fn lookup_tx<R>(
&self,
txid: &Txid,
f: impl Fn(&Transaction) -> R,
indexed: impl FnOnce(TxIndex) -> Result<R>,
) -> Result<R> {
if let Some(mempool) = self.mempool()
&& let Some(r) = mempool.with_tx(txid, &f)
{
return Ok(r);
}
match self.resolve_tx_index_bounded(txid) {
Ok(idx) => indexed(idx),
Err(Error::UnknownTxid) => self
.mempool()
.and_then(|m| m.with_vanished_tx(txid, &f))
.ok_or(Error::UnknownTxid),
Err(e) => Err(e),
}
}
pub fn transaction(&self, txid: &Txid) -> Result<Transaction> {
if let Some(tx) = self.map_mempool_tx(txid, Transaction::clone) {
return Ok(tx);
}
self.transaction_by_index(self.resolve_tx_index_bounded(txid)?)
self.lookup_tx(txid, Transaction::clone, |idx| self.transaction_by_index(idx))
}
pub fn transaction_status(&self, txid: &Txid) -> Result<TxStatus> {
if self.mempool().is_some_and(|m| m.txs().contains_key(txid)) {
if self.mempool().is_some_and(|m| m.contains_txid(txid)) {
return Ok(TxStatus::UNCONFIRMED);
}
let (_, height) = self.resolve_tx(txid)?;
@@ -119,23 +138,23 @@ impl Query {
}
pub fn transaction_raw(&self, txid: &Txid) -> Result<Vec<u8>> {
if let Some(bytes) = self.map_mempool_tx(txid, Transaction::encode_bytes) {
return Ok(bytes);
}
self.transaction_raw_by_index(self.resolve_tx_index_bounded(txid)?)
self.lookup_tx(txid, Transaction::encode_bytes, |idx| {
self.transaction_raw_by_index(idx)
})
}
pub fn transaction_hex(&self, txid: &Txid) -> Result<String> {
if let Some(hex) = self.map_mempool_tx(txid, |tx| tx.encode_bytes().to_lower_hex_string()) {
return Ok(hex);
}
self.transaction_hex_by_index(self.resolve_tx_index_bounded(txid)?)
self.lookup_tx(
txid,
|tx| tx.encode_bytes().to_lower_hex_string(),
|idx| self.transaction_hex_by_index(idx),
)
}
// ── Outspend queries ───────────────────────────────────────────
pub fn outspend(&self, txid: &Txid, vout: Vout) -> Result<TxOutspend> {
if self.mempool().is_some_and(|m| m.txs().contains_key(txid)) {
if self.mempool().is_some_and(|m| m.contains_txid(txid)) {
return Ok(self.mempool_outspend(txid, vout));
}
let (_, first_txout, output_count) = self.resolve_tx_outputs(txid)?;
@@ -151,7 +170,7 @@ impl Query {
pub fn outspends(&self, txid: &Txid) -> Result<Vec<TxOutspend>> {
if let Some(mempool) = self.mempool()
&& let Some(output_count) = mempool.txs().get(txid).map(|tx| tx.output.len())
&& let Some(output_count) = mempool.with_tx(txid, |tx| tx.output.len())
{
return Ok((0..output_count)
.map(|i| self.mempool_outspend(txid, Vout::from(i)))