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

4
Cargo.lock generated
View File

@@ -293,6 +293,10 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "blk"
version = "0.3.0-beta.7"
[[package]] [[package]]
name = "brk" name = "brk"
version = "0.3.0-beta.7" version = "0.3.0-beta.7"

2
crates/blk/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.md
!README.md

14
crates/blk/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "blk"
description = "A command line tool to inspect Bitcoin Core blocks"
version.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
[dependencies]
[[bin]]
name = "blk"
path = "src/main.rs"

1
crates/blk/src/main.rs Normal file
View File

@@ -0,0 +1 @@
fn main() {}

View File

@@ -9236,6 +9236,15 @@ impl BrkClient {
self.base.get_json(&format!("/api/mempool")) self.base.get_json(&format!("/api/mempool"))
} }
/// Mempool content hash
///
/// Returns an opaque `u64` that changes whenever the projected next block changes. Same value as the mempool ETag. Useful as a freshness/liveness signal: if it stays constant for tens of seconds on a live network, the mempool sync loop has stalled.
///
/// Endpoint: `GET /api/mempool/hash`
pub fn get_mempool_hash(&self) -> Result<i64> {
self.base.get_json(&format!("/api/mempool/hash"))
}
/// Live BTC/USD price /// Live BTC/USD price
/// ///
/// Returns the current BTC/USD price in dollars, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool. /// Returns the current BTC/USD price in dollars, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool.

View File

@@ -1,7 +1,7 @@
use std::{thread, time::Duration}; use std::{thread, time::Duration};
use brk_error::Result; use brk_error::Result;
use brk_mempool::Mempool; use brk_mempool::{Mempool, MempoolStats};
use brk_rpc::{Auth, Client}; use brk_rpc::{Auth, Client};
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -23,12 +23,7 @@ fn main() -> Result<()> {
loop { loop {
thread::sleep(Duration::from_secs(5)); thread::sleep(Duration::from_secs(5));
let info = mempool.info(); let stats = MempoolStats::from(&mempool);
let entries = mempool.entries();
let txs = mempool.txs();
let addrs = mempool.addrs();
let graveyard = mempool.graveyard();
let outpoint_spends = mempool.state().outpoint_spends.read();
let snapshot = mempool.snapshot(); let snapshot = mempool.snapshot();
let cluster_nodes_total: usize = snapshot.clusters.iter().map(|c| c.nodes.len()).sum(); let cluster_nodes_total: usize = snapshot.clusters.iter().map(|c| c.nodes.len()).sum();
@@ -42,16 +37,16 @@ fn main() -> Result<()> {
snap.clusters={} snap.cluster_nodes={} snap.cluster_of.len={} snap.cluster_of.active={} \ snap.clusters={} snap.cluster_nodes={} snap.cluster_of.len={} snap.cluster_of.active={} \
snap.blocks={} snap.blocks_txs={} \ snap.blocks={} snap.blocks_txs={} \
rebuilds={} skip.clean={} skip.throttled={}", rebuilds={} skip.clean={} skip.throttled={}",
info.count, stats.info_count,
entries.entries().len(), stats.entry_slot_count,
entries.active_count(), stats.entry_active_count,
entries.free_slots_count(), stats.entry_free_count,
txs.len(), stats.tx_count,
txs.unresolved().len(), stats.unresolved_count,
addrs.len(), stats.addr_count,
outpoint_spends.len(), stats.outpoint_spend_count,
graveyard.tombstones_len(), stats.graveyard_tombstone_count,
graveyard.order_len(), stats.graveyard_order_count,
snapshot.clusters.len(), snapshot.clusters.len(),
cluster_nodes_total, cluster_nodes_total,
snapshot.cluster_of_len(), snapshot.cluster_of_len(),

View File

@@ -162,7 +162,7 @@ impl<I> Cluster<I> {
} }
} }
debug_assert_eq!(out.len(), n, "cluster contained a cycle"); assert_eq!(out.len(), n, "cluster contained a cycle");
out out
} }

View File

@@ -22,17 +22,24 @@ use std::{
use brk_error::Result; use brk_error::Result;
use brk_rpc::Client; use brk_rpc::Client;
use brk_types::{AddrBytes, MempoolInfo, OutpointPrefix, TxOut, Txid, TxidPrefix, Vin, Vout}; use brk_types::{
AddrBytes, AddrMempoolStats, FeeRate, MempoolInfo, MempoolRecentTx, OutpointPrefix, OutputType,
Sats, Timestamp, Transaction, TxOut, Txid, TxidPrefix, Vin, Vout,
};
use parking_lot::RwLockReadGuard; use parking_lot::RwLockReadGuard;
use tracing::error; use tracing::error;
pub mod cluster; pub mod cluster;
mod cpfp; mod cpfp;
mod rbf;
mod stats;
pub(crate) mod steps; pub(crate) mod steps;
pub(crate) mod stores; pub(crate) mod stores;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
pub use rbf::{RbfForTx, RbfNode};
pub use stats::MempoolStats;
use steps::{Applier, Fetcher, Preparer, Rebuilder, Resolver}; use steps::{Applier, Fetcher, Preparer, Rebuilder, Resolver};
pub use steps::{BlockStats, RecommendedFees, Snapshot, TxEntry, TxRemoval}; pub use steps::{BlockStats, RecommendedFees, Snapshot, TxEntry, TxRemoval};
use stores::{AddrTracker, MempoolState}; use stores::{AddrTracker, MempoolState};
@@ -108,22 +115,145 @@ impl Mempool {
Some((spender_txid, Vin::from(vin_pos))) Some((spender_txid, Vin::from(vin_pos)))
} }
pub fn txs(&self) -> RwLockReadGuard<'_, TxStore> { pub(crate) fn txs(&self) -> RwLockReadGuard<'_, TxStore> {
self.0.state.txs.read() self.0.state.txs.read()
} }
pub fn entries(&self) -> RwLockReadGuard<'_, EntryPool> { pub(crate) fn entries(&self) -> RwLockReadGuard<'_, EntryPool> {
self.0.state.entries.read() self.0.state.entries.read()
} }
pub fn addrs(&self) -> RwLockReadGuard<'_, AddrTracker> { pub(crate) fn addrs(&self) -> RwLockReadGuard<'_, AddrTracker> {
self.0.state.addrs.read() self.0.state.addrs.read()
} }
pub fn graveyard(&self) -> RwLockReadGuard<'_, TxGraveyard> { pub(crate) fn graveyard(&self) -> RwLockReadGuard<'_, TxGraveyard> {
self.0.state.graveyard.read() self.0.state.graveyard.read()
} }
pub fn contains_txid(&self, txid: &Txid) -> bool {
self.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.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> {
let graveyard = self.graveyard();
let tomb = graveyard.get(txid)?;
matches!(tomb.reason(), TxRemoval::Vanished).then(|| f(&tomb.tx))
}
/// Snapshot of all live mempool txids.
pub fn txids(&self) -> Vec<Txid> {
self.txs().keys().cloned().collect()
}
/// Snapshot of recent live txs.
pub fn recent_txs(&self) -> Vec<MempoolRecentTx> {
self.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.addrs().get(addr).map(|e| e.stats.clone())
}
/// Live mempool txs touching `addr`, newest first by `first_seen`,
/// capped at `limit`. Acquires `txs`, `addrs`, `entries` in canonical
/// order; returns owned `Transaction`s so the lock is released
/// before the caller does anything else with them.
pub fn addr_txs(&self, addr: &AddrBytes, limit: usize) -> Vec<Transaction> {
let txs = self.txs();
let addrs = self.addrs();
let entries = self.entries();
let Some(entry) = addrs.get(addr) else {
return vec![];
};
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));
ordered
.into_iter()
.filter_map(|(_, txid)| txs.get(txid).cloned())
.take(limit)
.collect()
}
/// Apply `f` to an iterator over `(value, output_type)` for every output
/// of every live mempool tx. The lock is held for the duration of the call.
pub fn process_live_outputs<R>(
&self,
f: impl FnOnce(&mut dyn Iterator<Item = (Sats, OutputType)>) -> R,
) -> R {
let txs = self.txs();
let mut iter = txs
.values()
.flat_map(|tx| &tx.output)
.map(|txout| (txout.value, txout.type_()));
f(&mut iter)
}
/// Effective fee rate for a live mempool tx: the seed's chunk rate from
/// the latest snapshot, with fall-back to the entry's simple `fee/vsize`
/// when the snapshot doesn't yet contain it.
pub fn live_effective_fee_rate(&self, prefix: &TxidPrefix) -> Option<FeeRate> {
let entries = self.entries();
if let Some(seed_idx) = entries.idx_of(prefix)
&& let Some(rate) = self.snapshot().chunk_rate_of(seed_idx)
{
return Some(rate);
}
entries.get(prefix).map(|e| e.fee_rate())
}
/// Fee rate snapshotted into a graveyard tomb at burial.
pub fn graveyard_fee_rate(&self, txid: &Txid) -> Option<FeeRate> {
self.graveyard()
.get(txid)
.map(|tomb| tomb.entry.fee_rate())
}
/// `first_seen` Unix-second timestamps for `txids`, in input order.
/// Returns 0 for unknown txids. `Vanished` graveyard tombstones fall
/// back to the buried entry's `first_seen` so a tx doesn't flicker
/// to 0 in the brief window between mempool drop and indexer catch-up.
pub fn transaction_times(&self, txids: &[Txid]) -> Vec<u64> {
let entries = self.entries();
let graveyard = self.graveyard();
txids
.iter()
.map(|txid| {
if let Some(e) = entries.get(&TxidPrefix::from(txid)) {
return u64::from(e.first_seen);
}
if let Some(tomb) = graveyard.get(txid)
&& matches!(tomb.reason(), TxRemoval::Vanished)
{
return u64::from(tomb.entry.first_seen);
}
0
})
.collect()
}
/// Infinite update loop with a 1 second interval. /// Infinite update loop with a 1 second interval.
pub fn start(&self) { pub fn start(&self) {
self.start_with(|| {}); self.start_with(|| {});
@@ -185,7 +315,7 @@ impl Mempool {
Ok(()) Ok(())
} }
pub fn state(&self) -> &MempoolState { pub(crate) fn state(&self) -> &MempoolState {
&self.0.state &self.0.state
} }
} }

View File

@@ -0,0 +1,132 @@
//! RBF (Replace-By-Fee) tree extraction from the live mempool +
//! graveyard.
//!
//! Both methods return owned, lock-free `RbfNode` trees so the caller
//! (typically `brk_query`) can enrich each node with indexer-resident
//! data (`mined`, effective fee rate) after the mempool lock window
//! closes. Doing the enrichment under the lock would re-enter
//! `Mempool` indirectly via `effective_fee_rate` and recursively
//! acquire the same `entries`/`graveyard` read locks, which can
//! deadlock against a queued writer in `parking_lot`.
use brk_types::{Sats, Timestamp, Transaction, Txid, TxidPrefix, VSize};
use rustc_hash::FxHashSet;
use crate::{
Mempool, TxEntry, TxRemoval, TxStore,
stores::{EntryPool, TxGraveyard},
};
/// One node in an RBF replacement tree, populated entirely from
/// mempool state. The caller layers on `mined` and effective fee rate
/// after the lock has been released.
#[derive(Debug, Clone)]
pub struct RbfNode {
pub txid: Txid,
pub fee: Sats,
pub vsize: VSize,
/// Sum of the tx's output amounts.
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>,
}
/// Result of [`Mempool::rbf_for_tx`].
#[derive(Debug, Clone, Default)]
pub struct RbfForTx {
/// Tree rooted at the requested tx's terminal replacer. `None` if
/// the tx is unknown to both the live pool and the graveyard.
pub root: Option<RbfNode>,
/// Direct predecessors of the requested tx (txids only).
pub replaces: Vec<Txid>,
}
impl Mempool {
/// Build the RBF tree relevant to `txid`: walk forward through
/// `Replaced { by }` links to the terminal replacer, return its
/// full predecessor tree, plus the requested tx's own direct
/// predecessors. Single read-lock window in canonical order.
pub fn rbf_for_tx(&self, txid: &Txid) -> RbfForTx {
let txs = self.txs();
let entries = self.entries();
let graveyard = self.graveyard();
let root_txid = walk_to_replacement_root(&graveyard, *txid);
let replaces: Vec<Txid> = graveyard.predecessors_of(txid).map(|(p, _)| *p).collect();
let root = build_node(&root_txid, &txs, &entries, &graveyard);
RbfForTx { root, replaces }
}
/// Recent terminal-replacer trees, most-recent replacement first,
/// deduplicated by tree root, capped at `limit`. When
/// `full_rbf_only` is true, drops trees with no non-signaling
/// predecessor anywhere.
pub fn recent_rbf_trees(&self, full_rbf_only: bool, limit: usize) -> Vec<RbfNode> {
let txs = self.txs();
let entries = self.entries();
let graveyard = self.graveyard();
let mut seen: FxHashSet<Txid> = FxHashSet::default();
graveyard
.replaced_iter_recent_first()
.filter_map(|(_, by)| {
let root = walk_to_replacement_root(&graveyard, *by);
seen.insert(root).then_some(root)
})
.filter_map(|root| build_node(&root, &txs, &entries, &graveyard))
.filter(|n| !full_rbf_only || n.full_rbf)
.take(limit)
.collect()
}
}
fn walk_to_replacement_root(graveyard: &TxGraveyard, mut root: Txid) -> Txid {
while let Some(TxRemoval::Replaced { by }) = graveyard.get(&root).map(|t| t.reason()) {
root = *by;
}
root
}
fn build_node(
txid: &Txid,
txs: &TxStore,
entries: &EntryPool,
graveyard: &TxGraveyard,
) -> Option<RbfNode> {
let (tx, entry) = resolve_node(txid, txs, entries, graveyard)?;
let replaces: Vec<RbfNode> = graveyard
.predecessors_of(txid)
.filter_map(|(pred, _)| build_node(pred, txs, entries, 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,
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))
}

View File

@@ -0,0 +1,43 @@
//! Owned snapshot of mempool in-memory counters for diagnostic display.
use crate::Mempool;
#[derive(Debug, Clone)]
pub struct MempoolStats {
pub info_count: usize,
pub tx_count: usize,
pub unresolved_count: usize,
pub addr_count: usize,
pub entry_slot_count: usize,
pub entry_active_count: usize,
pub entry_free_count: usize,
pub outpoint_spend_count: usize,
pub graveyard_tombstone_count: usize,
pub graveyard_order_count: usize,
}
impl From<&Mempool> for MempoolStats {
/// Acquires every sub-lock in canonical order to build a coherent
/// snapshot. Cheap; locks are released as soon as the counts are read.
fn from(mempool: &Mempool) -> Self {
let state = mempool.state();
let info = state.info.read();
let txs = state.txs.read();
let addrs = state.addrs.read();
let entries = state.entries.read();
let outpoint_spends = state.outpoint_spends.read();
let graveyard = state.graveyard.read();
Self {
info_count: info.count,
tx_count: txs.len(),
unresolved_count: txs.unresolved().len(),
addr_count: addrs.len(),
entry_slot_count: entries.entries().len(),
entry_active_count: entries.active_count(),
entry_free_count: entries.free_slots_count(),
outpoint_spend_count: outpoint_spends.len(),
graveyard_tombstone_count: graveyard.tombstones_len(),
graveyard_order_count: graveyard.order_len(),
}
}
}

View File

@@ -33,24 +33,25 @@ impl Applier {
} }
fn bury_one(s: &mut LockedState, prefix: &TxidPrefix, reason: TxRemoval) { fn bury_one(s: &mut LockedState, prefix: &TxidPrefix, reason: TxRemoval) {
let Some((idx, entry)) = s.entries.remove(prefix) else { let Some(txid) = s.entries.get(prefix).map(|e| e.txid) else {
return; return;
}; };
let txid = entry.txid; if !s.txs.contains(&txid) {
let Some(tx) = s.txs.remove(&txid) else {
// entries had this prefix but txs didn't — a state divergence // entries had this prefix but txs didn't — a state divergence
// that should be impossible: publish/bury both touch them // that should be impossible: publish/bury both touch them
// together under one write_all guard. Reaching this branch // together under one write_all guard. Reaching this branch
// means a prior cycle left the two stores out of sync (panic // means a prior cycle left the two stores out of sync (e.g.
// mid-publish, prefix collision, etc). The slot has been // panic mid-publish before `txs.extend` ran). Skip the bury
// freed by entries.remove, but addrs/outpoint_spends/info may // entirely: freeing the entries slot here would let
// still hold stale references that we can't repair without // outpoint_spends point at a slot the next insert recycles
// the tx body. Log loudly so the corruption is visible. // for an unrelated tx.
warn!( warn!(
"mempool bury: entry present but tx missing for txid={txid} - addr/outpoint state may be stale" "mempool bury: entry present but tx missing for txid={txid} - skipping bury to preserve outpoint_spends integrity"
); );
return; return;
}; }
let (idx, entry) = s.entries.remove(prefix).expect("entry present");
let tx = s.txs.remove(&txid).expect("tx present");
s.info.remove(&tx, entry.fee); s.info.remove(&tx, entry.fee);
s.addrs.remove_tx(&tx, &txid); s.addrs.remove_tx(&tx, &txid);
s.outpoint_spends.remove_spends(&tx, idx); s.outpoint_spends.remove_spends(&tx, idx);

View File

@@ -50,6 +50,7 @@ impl Rebuilder {
return; return;
} }
self.publish(Self::build_snapshot(client, state)); self.publish(Self::build_snapshot(client, state));
self.dirty.store(false, Ordering::Release);
self.rebuild_count.fetch_add(1, Ordering::Relaxed); self.rebuild_count.fetch_add(1, Ordering::Relaxed);
} }
@@ -89,9 +90,9 @@ impl Rebuilder {
} }
/// Returns true iff dirty and the throttle window has elapsed. On /// Returns true iff dirty and the throttle window has elapsed. On
/// success, clears the dirty bit and starts a new throttle window; /// success, starts a new throttle window. The dirty bit is cleared
/// on failure, leaves all state untouched so the next cycle can /// by `tick` only after `publish` returns, so a panic in
/// retry. /// `build_snapshot` leaves dirty set and the next cycle retries.
fn try_claim_rebuild(&self) -> bool { fn try_claim_rebuild(&self) -> bool {
if !self.dirty.load(Ordering::Acquire) { if !self.dirty.load(Ordering::Acquire) {
self.skip_clean.fetch_add(1, Ordering::Relaxed); self.skip_clean.fetch_add(1, Ordering::Relaxed);
@@ -103,7 +104,6 @@ impl Rebuilder {
return false; return false;
} }
*last = Some(Instant::now()); *last = Some(Instant::now());
self.dirty.store(false, Ordering::Release);
true true
} }

View File

@@ -6,8 +6,20 @@ use super::{AddrTracker, EntryPool, OutpointSpends, TxGraveyard, TxStore};
/// The six buckets making up live mempool state. /// The six buckets making up live mempool state.
/// ///
/// Each bucket has its own `RwLock` so readers of different buckets /// Each bucket has its own `RwLock` so readers of different buckets
/// don't contend with each other. The Applier takes all six write /// don't contend with each other. Any code that takes more than one
/// locks in a fixed order for a brief window once per cycle. /// lock must follow the canonical partial order
/// `info → txs → addrs → entries → outpoint_spends → graveyard`,
/// otherwise a reader-holds-A-wants-B / writer-holds-B-wants-A
/// circular wait can deadlock. The Applier takes all six write locks
/// in this order for a brief window once per cycle via
/// [`MempoolState::write_all`]; multi-lock readers inside the crate
/// take a (canonical-order) subset inline.
///
/// This discipline is *internal* to `brk_mempool`: external crates
/// only see `Mempool` methods that bundle each multi-lock operation
/// behind a single call (e.g. `Mempool::lookup_spender`,
/// `Mempool::addr_txs`, `Mempool::rbf_for_tx`), so callers can never
/// take the order wrong because they don't get to choose.
#[derive(Default)] #[derive(Default)]
pub struct MempoolState { pub struct MempoolState {
pub(crate) info: RwLock<MempoolInfo>, pub(crate) info: RwLock<MempoolInfo>,

View File

@@ -4,8 +4,8 @@ use bitcoin::{Network, PublicKey, ScriptBuf};
use brk_error::{Error, OptionData, Result}; use brk_error::{Error, OptionData, Result};
use brk_types::{ use brk_types::{
Addr, AddrBytes, AddrChainStats, AddrHash, AddrIndexOutPoint, AddrIndexTxIndex, AddrStats, Addr, AddrBytes, AddrChainStats, AddrHash, AddrIndexOutPoint, AddrIndexTxIndex, AddrStats,
AnyAddrDataIndexEnum, Dollars, Height, OutputType, Timestamp, Transaction, TxIndex, TxStatus, AnyAddrDataIndexEnum, Dollars, Height, OutputType, Transaction, TxIndex, TxStatus, Txid,
Txid, TxidPrefix, TypeIndex, Unit, Utxo, Vout, TypeIndex, Unit, Utxo, Vout,
}; };
use vecdb::VecIndex; use vecdb::VecIndex;
@@ -80,7 +80,7 @@ impl Query {
}, },
mempool_stats: self mempool_stats: self
.mempool() .mempool()
.and_then(|m| m.addrs().get(&bytes).map(|e| e.stats.clone())) .and_then(|m| m.addr_stats(&bytes))
.unwrap_or_default(), .unwrap_or_default(),
}) })
} }
@@ -233,29 +233,7 @@ impl Query {
pub fn addr_mempool_txs(&self, addr: &Addr, limit: usize) -> Result<Vec<Transaction>> { pub fn addr_mempool_txs(&self, addr: &Addr, limit: usize) -> Result<Vec<Transaction>> {
let bytes = AddrBytes::from_str(addr)?; let bytes = AddrBytes::from_str(addr)?;
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
let addrs = mempool.addrs(); Ok(mempool.addr_txs(&bytes, limit))
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())
} }
/// Height of the last on-chain activity for an address (last tx_index → height). /// Height of the last on-chain activity for an address (last tx_index → height).

View File

@@ -62,17 +62,11 @@ impl Query {
pub fn effective_fee_rate(&self, txid: &Txid) -> Result<FeeRate> { pub fn effective_fee_rate(&self, txid: &Txid) -> Result<FeeRate> {
let prefix = TxidPrefix::from(txid); let prefix = TxidPrefix::from(txid);
if let Some(mempool) = self.mempool() { if let Some(mempool) = self.mempool()
let entries = mempool.entries(); && let Some(rate) = mempool.live_effective_fee_rate(&prefix)
if let Some(seed_idx) = entries.idx_of(&prefix)
&& let Some(rate) = mempool.snapshot().chunk_rate_of(seed_idx)
{ {
return Ok(rate); return Ok(rate);
} }
if let Some(entry) = entries.get(&prefix) {
return Ok(entry.fee_rate());
}
}
if let Ok(idx) = self.resolve_tx_index(txid) if let Ok(idx) = self.resolve_tx_index(txid)
&& let Some(rate) = self && let Some(rate) = self
@@ -87,9 +81,9 @@ impl Query {
} }
if let Some(mempool) = self.mempool() 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) Err(Error::UnknownTxid)

View File

@@ -1,11 +1,10 @@
use brk_error::{Error, Result}; use brk_error::{Error, Result};
use brk_mempool::{EntryPool, Mempool, TxEntry, TxGraveyard, TxRemoval, TxStore, TxTombstone}; use brk_mempool::{Mempool, RbfForTx, RbfNode};
use brk_types::{ use brk_types::{
CheckedSub, MempoolBlock, MempoolInfo, MempoolRecentTx, OutputType, RbfResponse, RbfTx, CheckedSub, FeeRate, MempoolBlock, MempoolInfo, MempoolRecentTx, OutputType, RbfResponse, RbfTx,
RecommendedFees, ReplacementNode, Sats, Timestamp, Transaction, TxOut, TxOutIndex, Txid, RecommendedFees, ReplacementNode, Sats, Timestamp, TxOut, TxOutIndex, Txid, TxidPrefix,
TxidPrefix, TypeIndex, TypeIndex,
}; };
use rustc_hash::FxHashSet;
use vecdb::VecIndex; use vecdb::VecIndex;
use crate::Query; use crate::Query;
@@ -22,8 +21,7 @@ impl Query {
} }
pub fn mempool_txids(&self) -> Result<Vec<Txid>> { pub fn mempool_txids(&self) -> Result<Vec<Txid>> {
let txs = self.require_mempool()?.txs(); Ok(self.require_mempool()?.txids())
Ok(txs.keys().cloned().collect())
} }
pub fn recommended_fees(&self) -> Result<RecommendedFees> { pub fn recommended_fees(&self) -> Result<RecommendedFees> {
@@ -100,159 +98,89 @@ impl Query {
} }
pub fn mempool_recent(&self) -> Result<Vec<MempoolRecentTx>> { 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 /// RBF history for a tx, matching mempool.space's
/// `GET /api/v1/tx/:txid/rbf`. Walks forward through the graveyard /// `GET /api/v1/tx/:txid/rbf`. The mempool builds the owned
/// to find the latest known replacer (tree root), then recursively /// replacement tree (terminal replacer + recursive predecessors)
/// walks `predecessors_of` backward to build the tree. `replaces` /// under one read-lock window; this method then enriches each node
/// is the requested tx's own direct predecessors. /// with `mined` + effective fee rate, both of which need the
/// indexer/computer.
pub fn tx_rbf(&self, txid: &Txid) -> Result<RbfResponse> { pub fn tx_rbf(&self, txid: &Txid) -> Result<RbfResponse> {
let mempool = self.require_mempool()?; let RbfForTx { root, replaces } = self.require_mempool()?.rbf_for_tx(txid);
let txs = mempool.txs(); let replacements = root.map(|n| self.enrich_rbf_node(n, None));
let entries = mempool.entries(); let replaces = (!replaces.is_empty()).then_some(replaces);
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);
Ok(RbfResponse { Ok(RbfResponse {
replacements, replacements,
replaces, 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 /// Recent RBF replacements across the whole mempool, matching
/// mempool.space's `GET /api/v1/replacements` and /// mempool.space's `GET /api/v1/replacements` and
/// `GET /api/v1/fullrbf/replacements`. Each entry is a complete /// `GET /api/v1/fullrbf/replacements`. Each entry is a complete
/// replacement tree rooted at the terminal replacer; same shape as /// replacement tree rooted at the terminal replacer; same shape as
/// `tx_rbf().replacements`. Ordered by most-recent replacement /// `tx_rbf().replacements`. Ordered by most-recent replacement
/// event first (matches mempool.space's reversed-`replacedBy` /// event first and capped at 25 entries. When `full_rbf_only` is
/// iteration) and capped at 25 entries. When `full_rbf_only` is
/// true, only trees with at least one non-signaling predecessor /// true, only trees with at least one non-signaling predecessor
/// are returned. /// are returned.
pub fn recent_replacements(&self, full_rbf_only: bool) -> Result<Vec<ReplacementNode>> { pub fn recent_replacements(&self, full_rbf_only: bool) -> Result<Vec<ReplacementNode>> {
let mempool = self.require_mempool()?; Ok(self
let txs = mempool.txs(); .require_mempool()?
let entries = mempool.entries(); .recent_rbf_trees(full_rbf_only, RECENT_REPLACEMENTS_LIMIT)
let graveyard = mempool.graveyard(); .into_iter()
.map(|n| self.enrich_rbf_node(n, None))
// 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)
.collect()) .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 /// `first_seen` Unix-second timestamps for each txid, matching
/// mempool.space's `POST /api/v1/transaction-times`. Returns 0 for /// mempool.space's `POST /api/v1/transaction-times`. Returns 0 for
/// unknown txids, in input order. /// unknown txids, in input order.
pub fn transaction_times(&self, txids: &[Txid]) -> Result<Vec<u64>> { pub fn transaction_times(&self, txids: &[Txid]) -> Result<Vec<u64>> {
let entries = self.require_mempool()?.entries(); Ok(self.require_mempool()?.transaction_times(txids))
Ok(txids
.iter()
.map(|txid| {
entries
.get(&TxidPrefix::from(txid))
.map_or(0, |e| u64::from(e.first_seen))
})
.collect())
} }
/// Opaque content hash that changes whenever the projected next /// 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())?; let mut oracle = self.computer().prices.live_oracle(self.indexer())?;
if let Some(mempool) = self.mempool() { if let Some(mempool) = self.mempool() {
let txs = mempool.txs(); mempool.process_live_outputs(|iter| oracle.process_outputs(iter));
oracle.process_outputs(
txs.values()
.flat_map(|tx| &tx.output)
.map(|txout| (txout.value, txout.type_())),
);
} }
Ok(oracle.price_dollars()) Ok(oracle.price_dollars())

View File

@@ -97,21 +97,40 @@ impl Query {
// ── Transaction queries ──────────────────────────────────────── // ── Transaction queries ────────────────────────────────────────
/// Map a mempool transaction by txid through `f`, returning `None` /// Resolve a tx body across the three sources in order: live mempool,
/// if no mempool is attached or the txid is not in mempool. /// indexer (via `indexed`), then `Vanished` graveyard tombstone.
fn map_mempool_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> { /// The graveyard fallback only fires when the indexer reports
self.mempool()?.txs().get(txid).map(f) /// `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> { pub fn transaction(&self, txid: &Txid) -> Result<Transaction> {
if let Some(tx) = self.map_mempool_tx(txid, Transaction::clone) { self.lookup_tx(txid, Transaction::clone, |idx| self.transaction_by_index(idx))
return Ok(tx);
}
self.transaction_by_index(self.resolve_tx_index_bounded(txid)?)
} }
pub fn transaction_status(&self, txid: &Txid) -> Result<TxStatus> { 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); return Ok(TxStatus::UNCONFIRMED);
} }
let (_, height) = self.resolve_tx(txid)?; let (_, height) = self.resolve_tx(txid)?;
@@ -119,23 +138,23 @@ impl Query {
} }
pub fn transaction_raw(&self, txid: &Txid) -> Result<Vec<u8>> { pub fn transaction_raw(&self, txid: &Txid) -> Result<Vec<u8>> {
if let Some(bytes) = self.map_mempool_tx(txid, Transaction::encode_bytes) { self.lookup_tx(txid, Transaction::encode_bytes, |idx| {
return Ok(bytes); self.transaction_raw_by_index(idx)
} })
self.transaction_raw_by_index(self.resolve_tx_index_bounded(txid)?)
} }
pub fn transaction_hex(&self, txid: &Txid) -> Result<String> { 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()) { self.lookup_tx(
return Ok(hex); txid,
} |tx| tx.encode_bytes().to_lower_hex_string(),
self.transaction_hex_by_index(self.resolve_tx_index_bounded(txid)?) |idx| self.transaction_hex_by_index(idx),
)
} }
// ── Outspend queries ─────────────────────────────────────────── // ── Outspend queries ───────────────────────────────────────────
pub fn outspend(&self, txid: &Txid, vout: Vout) -> Result<TxOutspend> { 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)); return Ok(self.mempool_outspend(txid, vout));
} }
let (_, first_txout, output_count) = self.resolve_tx_outputs(txid)?; 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>> { pub fn outspends(&self, txid: &Txid) -> Result<Vec<TxOutspend>> {
if let Some(mempool) = self.mempool() 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) return Ok((0..output_count)
.map(|i| self.mempool_outspend(txid, Vout::from(i))) .map(|i| self.mempool_outspend(txid, Vout::from(i)))

View File

@@ -122,7 +122,7 @@ impl AppState {
pub fn tx_strategy(&self, version: Version, txid: &Txid) -> CacheStrategy { pub fn tx_strategy(&self, version: Version, txid: &Txid) -> CacheStrategy {
self.sync(|q| { self.sync(|q| {
if let Some(mempool) = q.mempool() if let Some(mempool) = q.mempool()
&& mempool.txs().contains(txid) && mempool.contains_txid(txid)
{ {
return CacheStrategy::MempoolHash(mempool.next_block_hash()); return CacheStrategy::MempoolHash(mempool.next_block_hash());
} }

View File

@@ -10839,6 +10839,20 @@ class BrkClient extends BrkClientBase {
return this.getJson(path, { signal, onValue }); return this.getJson(path, { signal, onValue });
} }
/**
* Mempool content hash
*
* Returns an opaque `u64` that changes whenever the projected next block changes. Same value as the mempool ETag. Useful as a freshness/liveness signal: if it stays constant for tens of seconds on a live network, the mempool sync loop has stalled.
*
* Endpoint: `GET /api/mempool/hash`
* @param {{ signal?: AbortSignal, onValue?: (value: number) => void }} [options]
* @returns {Promise<number>}
*/
async getMempoolHash({ signal, onValue } = {}) {
const path = `/api/mempool/hash`;
return this.getJson(path, { signal, onValue });
}
/** /**
* Live BTC/USD price * Live BTC/USD price
* *

View File

@@ -8033,6 +8033,14 @@ class BrkClient(BrkClientBase):
Endpoint: `GET /api/mempool`""" Endpoint: `GET /api/mempool`"""
return self.get_json('/api/mempool') return self.get_json('/api/mempool')
def get_mempool_hash(self) -> int:
"""Mempool content hash.
Returns an opaque `u64` that changes whenever the projected next block changes. Same value as the mempool ETag. Useful as a freshness/liveness signal: if it stays constant for tens of seconds on a live network, the mempool sync loop has stalled.
Endpoint: `GET /api/mempool/hash`"""
return self.get_json('/api/mempool/hash')
def get_live_price(self) -> Dollars: def get_live_price(self) -> Dollars:
"""Live BTC/USD price. """Live BTC/USD price.