diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index ed468153b..7c8bfb67d 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -8953,7 +8953,7 @@ pub struct BrkClient { impl BrkClient { /// Client version. - pub const VERSION: &'static str = "v0.3.0-beta.7"; + pub const VERSION: &'static str = "v0.3.0-beta.8"; /// Create a new client with the given base URL. pub fn new(base_url: impl Into) -> Self { diff --git a/crates/brk_mempool/src/chunking.rs b/crates/brk_mempool/src/chunking.rs index 3ca28c029..73b8981aa 100644 --- a/crates/brk_mempool/src/chunking.rs +++ b/crates/brk_mempool/src/chunking.rs @@ -41,49 +41,122 @@ pub fn linearize(items: &[ChunkInput<'_>]) -> Vec { } let mut remaining: Vec = vec![true; n]; let mut chunks: Vec = Vec::new(); + let empty: FxHashSet = FxHashSet::default(); while remaining.iter().any(|&r| r) { - let mut best: Option<(FeeRate, FxHashSet)> = None; + // Pick the top single-anchored ancestor-closed set. On rate + // ties the larger set wins so a uniform-rate chain emits one + // chunk instead of n singletons. The extension loop below + // catches the same case at zero extra cost, but starting big + // shaves iterations. + let mut best: Option<(FeeRate, FxHashSet, Sats, VSize)> = None; for i in 0..n { if !remaining[i] { continue; } - let mut anc: FxHashSet = - FxHashSet::with_capacity_and_hasher(8, FxBuildHasher); - let mut stack: Vec = vec![i as u32]; - while let Some(x) = stack.pop() { - if !anc.insert(x) { - continue; - } - for &p in items[x as usize].parents { - let pu: u32 = u32::from(p); - if remaining[pu as usize] && !anc.contains(&pu) { - stack.push(pu); - } - } - } - let mut fee = Sats::ZERO; - let mut vsize = VSize::from(0u64); - for &x in &anc { - fee += items[x as usize].fee; - vsize += items[x as usize].vsize; - } + let anc = closure(items, &remaining, &empty, i as u32); + let (fee, vsize) = sum_fee_vsize(items, &anc); let rate = FeeRate::from((fee, vsize)); - match &best { - Some((br, _)) if *br >= rate => {} - _ => best = Some((rate, anc)), + let replace = match &best { + None => true, + Some((br, ba, _, _)) => rate > *br || (rate == *br && anc.len() > ba.len()), + }; + if replace { + best = Some((rate, anc, fee, vsize)); } } - let (rate, set) = best.expect("at least one remaining tx"); - let mut indices: Vec = set.into_iter().collect(); + let (mut chunk_rate, mut anc, mut chunk_fee, mut chunk_vsize) = + best.expect("at least one remaining tx"); + + // Extend the chunk with any other remaining ancestor-closed + // subset whose union keeps the chunk rate >= current rate. + // SFL chunks are the *maximum* ancestor-closed set at the top + // rate, but a single anchor only sees one connected component + // up to the cluster root: a parent with one long chain plus + // additional same-rate sibling children leaves the siblings + // stranded as same-rate singleton chunks - which can even + // appear "above" the main chunk under integer-vsize rounding, + // breaking the descending-rate invariant. + loop { + let mut best_ext: Option<(FeeRate, FxHashSet, Sats, VSize)> = None; + for i in 0..n { + if !remaining[i] || anc.contains(&(i as u32)) { + continue; + } + let extra = closure(items, &remaining, &anc, i as u32); + if extra.is_empty() { + continue; + } + let (ef, ev) = sum_fee_vsize(items, &extra); + let new_fee = chunk_fee + ef; + let new_vsize = chunk_vsize + ev; + let new_rate = FeeRate::from((new_fee, new_vsize)); + if new_rate < chunk_rate { + continue; + } + let replace = match &best_ext { + None => true, + Some((br, _, _, _)) => new_rate > *br, + }; + if replace { + best_ext = Some((new_rate, extra, new_fee, new_vsize)); + } + } + match best_ext { + Some((r, e, f, v)) => { + anc.extend(&e); + chunk_fee = f; + chunk_vsize = v; + chunk_rate = r; + } + None => break, + } + } + + let mut indices: Vec = anc.into_iter().collect(); indices.sort_unstable(); for &x in &indices { remaining[x as usize] = false; } let txs: Vec = indices.into_iter().map(CpfpClusterTxIndex::from).collect(); - chunks.push(CpfpClusterChunk { txs, feerate: rate }); + chunks.push(CpfpClusterChunk { txs, feerate: chunk_rate }); } chunks } + +fn closure( + items: &[ChunkInput<'_>], + remaining: &[bool], + excluded: &FxHashSet, + start: u32, +) -> FxHashSet { + let mut set: FxHashSet = FxHashSet::with_capacity_and_hasher(8, FxBuildHasher); + if !remaining[start as usize] || excluded.contains(&start) { + return set; + } + let mut stack: Vec = vec![start]; + while let Some(x) = stack.pop() { + if !set.insert(x) { + continue; + } + for &p in items[x as usize].parents { + let pu: u32 = u32::from(p); + if remaining[pu as usize] && !excluded.contains(&pu) && !set.contains(&pu) { + stack.push(pu); + } + } + } + set +} + +fn sum_fee_vsize(items: &[ChunkInput<'_>], set: &FxHashSet) -> (Sats, VSize) { + let mut fee = Sats::ZERO; + let mut vsize = VSize::from(0u64); + for &x in set { + fee += items[x as usize].fee; + vsize += items[x as usize].vsize; + } + (fee, vsize) +} diff --git a/crates/brk_mempool/src/lib.rs b/crates/brk_mempool/src/lib.rs index 7d94eeda8..27c0bc2f5 100644 --- a/crates/brk_mempool/src/lib.rs +++ b/crates/brk_mempool/src/lib.rs @@ -212,12 +212,16 @@ impl Mempool { .map(|e| e.fee_rate()) } - /// Fee rate snapshotted into a graveyard tomb at burial. + /// Effective fee rate (Core's chunk rate) snapshotted into the + /// tomb's entry at burial - same value `live_effective_fee_rate` + /// returns while the tx is alive, so an evicted RBF predecessor + /// reports the package-effective rate it had in the mempool, not a + /// misleading isolated `fee/vsize`. pub fn graveyard_fee_rate(&self, txid: &Txid) -> Option { self.read() .graveyard .get(txid) - .map(|tomb| tomb.entry.fee_rate()) + .map(|tomb| tomb.entry.chunk_rate) } /// `first_seen` Unix-second timestamps for `txids`, in input order. diff --git a/crates/brk_query/src/impl/mempool.rs b/crates/brk_query/src/impl/mempool.rs index 747b024ce..885dd4ed1 100644 --- a/crates/brk_query/src/impl/mempool.rs +++ b/crates/brk_query/src/impl/mempool.rs @@ -155,7 +155,6 @@ impl Query { time: first_seen, rbf: node.rbf, full_rbf: Some(node.full_rbf), - mined, }, time: first_seen, full_rbf: node.full_rbf, diff --git a/crates/brk_rpc/src/lib.rs b/crates/brk_rpc/src/lib.rs index 93272c393..fa947df4a 100644 --- a/crates/brk_rpc/src/lib.rs +++ b/crates/brk_rpc/src/lib.rs @@ -15,12 +15,6 @@ mod methods; use client::ClientInner; pub use methods::MempoolState; -#[derive(Debug, Clone)] -pub struct BlockchainInfo { - pub headers: u64, - pub blocks: u64, -} - #[derive(Debug, Clone)] pub struct BlockInfo { pub height: usize, diff --git a/crates/brk_rpc/src/methods.rs b/crates/brk_rpc/src/methods.rs index 940d3a767..4c13bad75 100644 --- a/crates/brk_rpc/src/methods.rs +++ b/crates/brk_rpc/src/methods.rs @@ -7,9 +7,12 @@ use brk_types::{ Weight, }; use corepc_jsonrpc::error::Error as JsonRpcError; -use corepc_types::v30::{ - GetBlockCount, GetBlockHash, GetBlockHeader, GetBlockHeaderVerbose, GetBlockVerboseOne, - GetBlockVerboseZero, GetBlockchainInfo, GetMempoolInfo, GetRawMempool, GetTxOut, +use corepc_types::{ + v17::{ + GetBlockCount, GetBlockHash, GetBlockHeader, GetBlockHeaderVerbose, GetBlockVerboseOne, + GetBlockVerboseZero, GetRawMempool, GetTxOut, + }, + v24::GetMempoolInfo, }; use rustc_hash::FxHashMap; use serde::Deserialize; @@ -21,9 +24,7 @@ use tracing::{debug, info}; /// The mempool fetcher tolerates these per-item failures silently. const RPC_NOT_FOUND: i32 = -5; -use crate::{ - BlockHeaderInfo, BlockInfo, BlockTemplateTx, BlockchainInfo, Client, RawTx, TxOutInfo, -}; +use crate::{BlockHeaderInfo, BlockInfo, BlockTemplateTx, Client, RawTx, TxOutInfo}; /// Per-batch request count for `get_block_hashes_range`. Sized so the /// JSON request body stays well under a megabyte and bitcoind doesn't @@ -119,14 +120,6 @@ fn build_min_fee(raw: GetMempoolInfo) -> FeeRate { } impl Client { - pub fn get_blockchain_info(&self) -> Result { - let r: GetBlockchainInfo = self.0.call_with_retry("getblockchaininfo", &[])?; - Ok(BlockchainInfo { - headers: r.headers as u64, - blocks: r.blocks as u64, - }) - } - /// Returns the numbers of block in the longest chain. pub fn get_block_count(&self) -> Result { let r: GetBlockCount = self.0.call_with_retry("getblockcount", &[])?; @@ -440,9 +433,14 @@ impl Client { } pub fn wait_for_synced_node(&self) -> Result<()> { + #[derive(Deserialize)] + struct SyncProgress { + headers: u64, + blocks: u64, + } let is_synced = || -> Result { - let info = self.get_blockchain_info()?; - Ok(info.headers == info.blocks) + let p: SyncProgress = self.0.call_with_retry("getblockchaininfo", &[])?; + Ok(p.headers == p.blocks) }; if !is_synced()? { diff --git a/crates/brk_types/src/rbf.rs b/crates/brk_types/src/rbf.rs index aa36171f5..e9685c062 100644 --- a/crates/brk_types/src/rbf.rs +++ b/crates/brk_types/src/rbf.rs @@ -21,11 +21,6 @@ pub struct RbfTx { /// this tx displaced at least one non-signaling predecessor. #[serde(rename = "fullRbf", skip_serializing_if = "Option::is_none", default)] pub full_rbf: Option, - /// `Some(true)` iff the tx is currently confirmed in the indexed - /// chain. Absent on serialization when the tx is still pending or - /// has been evicted without confirming. - #[serde(skip_serializing_if = "Option::is_none", default)] - pub mined: Option, } /// One node in an RBF replacement tree. The node's `tx` replaced each diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 73fa532d8..22bc2c41b 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -930,9 +930,6 @@ ancestors and no descendants (matches mempool.space). * @property {boolean} rbf - BIP-125 signaling: at least one input has sequence < 0xffffffff-1. * @property {?boolean=} fullRbf - Only populated on the root `tx` of an RBF response. `true` iff this tx displaced at least one non-signaling predecessor. - * @property {?boolean=} mined - `Some(true)` iff the tx is currently confirmed in the indexed -chain. Absent on serialization when the tx is still pending or -has been evicted without confirming. */ /** * Recommended fee rates in sat/vB @@ -7502,7 +7499,7 @@ function createTransferPattern(client, acc) { * @extends BrkClientBase */ class BrkClient extends BrkClientBase { - VERSION = "v0.3.0-beta.7"; + VERSION = "v0.3.0-beta.8"; INDEXES = /** @type {const} */ ([ "minute10", diff --git a/modules/brk-client/package.json b/modules/brk-client/package.json index ac0dfafe6..2afd5ed39 100644 --- a/modules/brk-client/package.json +++ b/modules/brk-client/package.json @@ -40,5 +40,5 @@ "url": "git+https://github.com/bitcoinresearchkit/brk.git" }, "type": "module", - "version": "0.3.0-beta.7" + "version": "0.3.0-beta.8" } diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index 560b95fc9..ca82299e4 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -1318,9 +1318,6 @@ class RbfTx(TypedDict): rbf: BIP-125 signaling: at least one input has sequence < 0xffffffff-1. fullRbf: Only populated on the root `tx` of an RBF response. `true` iff this tx displaced at least one non-signaling predecessor. - mined: `Some(true)` iff the tx is currently confirmed in the indexed -chain. Absent on serialization when the tx is still pending or -has been evicted without confirming. """ txid: Txid fee: Sats @@ -1330,7 +1327,6 @@ has been evicted without confirming. time: Timestamp rbf: bool fullRbf: Optional[bool] - mined: Optional[bool] class ReplacementNode(TypedDict): """ @@ -6653,7 +6649,7 @@ class SeriesTree: class BrkClient(BrkClientBase): """Main BRK client with series tree and API methods.""" - VERSION = "v0.3.0-beta.7" + VERSION = "v0.3.0-beta.8" INDEXES = [ "minute10", diff --git a/packages/brk_client/pyproject.toml b/packages/brk_client/pyproject.toml index 151fd3071..4fd2d7bad 100644 --- a/packages/brk_client/pyproject.toml +++ b/packages/brk_client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brk-client" -version = "0.3.0-beta.7" +version = "0.3.0-beta.8" description = "Bitcoin on-chain analytics client — thousands of metrics, block explorer, and address index" readme = "README.md" requires-python = ">=3.9"