diff --git a/Cargo.lock b/Cargo.lock index 695bd367e..febd9b188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -773,9 +773,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" diff --git a/crates/brk_bindgen/src/generate/constants.rs b/crates/brk_bindgen/src/generate/constants.rs index 7bd3bccc2..c76571fc5 100644 --- a/crates/brk_bindgen/src/generate/constants.rs +++ b/crates/brk_bindgen/src/generate/constants.rs @@ -10,7 +10,7 @@ use brk_cohort::{ OVER_AMOUNT_NAMES, PROFIT_NAMES, PROFITABILITY_RANGE_NAMES, SPENDABLE_TYPE_NAMES, TERM_NAMES, UNDER_AGE_NAMES, UNDER_AMOUNT_NAMES, }; -use brk_types::{Index, PoolSlug, pools}; +use brk_types::{Index, pools}; use serde::Serialize; use serde_json::Value; @@ -20,7 +20,7 @@ use crate::{VERSION, to_camel_case}; pub struct ClientConstants { pub version: String, pub indexes: Vec<&'static str>, - pub pool_map: BTreeMap, + pub pool_map: BTreeMap, } impl ClientConstants { @@ -32,8 +32,10 @@ impl ClientConstants { let pools = pools(); let mut sorted_pools: Vec<_> = pools.iter().collect(); sorted_pools.sort_by_key(|p| p.name.to_lowercase()); - let pool_map: BTreeMap = - sorted_pools.iter().map(|p| (p.slug(), p.name)).collect(); + let pool_map: BTreeMap = sorted_pools + .iter() + .map(|p| (p.slug().to_string(), p.name)) + .collect(); Self { version: format!("v{}", VERSION), diff --git a/crates/brk_bindgen/src/generators/javascript/api.rs b/crates/brk_bindgen/src/generators/javascript/api.rs index 78d0a2c82..bf32476c8 100644 --- a/crates/brk_bindgen/src/generators/javascript/api.rs +++ b/crates/brk_bindgen/src/generators/javascript/api.rs @@ -36,10 +36,16 @@ fn generate_get_method(output: &mut String, endpoint: &Endpoint) { let optional = if param.required { "" } else { "=" }; let desc = format_param_desc(param.description.as_deref()); let ty = jsdoc_normalize(¶m.param_type); + let ident = sanitize_ident(¶m.name); + let name_decl = if param.required { + ident + } else { + format!("[{}]", ident) + }; writeln!( output, - " * @param {{{}{}}} [{}]{}", - ty, optional, param.name, desc + " * @param {{{}{}}} {}{}", + ty, optional, name_decl, desc ) .unwrap(); } @@ -67,7 +73,7 @@ fn generate_get_method(output: &mut String, endpoint: &Endpoint) { } else if endpoint.returns_json() { "this.getJson(path, { signal, onValue })".to_string() } else if endpoint.response_kind.text_is_numeric() { - "Number(await this.getText(path, { signal, onValue }))".to_string() + "Number(await this.getText(path, { signal, onValue: onValue ? (v) => onValue(Number(v)) : undefined }))".to_string() } else { "this.getText(path, { signal, onValue })".to_string() }; @@ -214,12 +220,21 @@ fn write_path_assignment(output: &mut String, endpoint: &Endpoint, path: &str) { let ident = sanitize_ident(¶m.name); let is_array = param.param_type.ends_with("[]"); if is_array { - writeln!( - output, - " for (const _v of {}) params.append('{}', String(_v));", - ident, param.name - ) - .unwrap(); + if param.required { + writeln!( + output, + " for (const _v of {}) params.append('{}', String(_v));", + ident, param.name + ) + .unwrap(); + } else { + writeln!( + output, + " if ({}) for (const _v of {}) params.append('{}', String(_v));", + ident, ident, param.name + ) + .unwrap(); + } } else if param.required { writeln!( output, diff --git a/crates/brk_bindgen/src/generators/javascript/client.rs b/crates/brk_bindgen/src/generators/javascript/client.rs index f0c558b72..a08992121 100644 --- a/crates/brk_bindgen/src/generators/javascript/client.rs +++ b/crates/brk_bindgen/src/generators/javascript/client.rs @@ -496,7 +496,10 @@ class BrkClientBase {{ const value = await parse(res); this._memSet(url, netEtag, value); if (onValue) onValue(value); - if (cloned) _runIdle(() => browserCache.put(url, cloned)); + if (cloned && browserCache) {{ + const cache = browserCache; + _runIdle(() => cache.put(url, cloned)); + }} return value; }} catch {{ return memHit.value; @@ -527,7 +530,10 @@ class BrkClientBase {{ const value = await parse(res); this._memSet(url, netEtag, value); if (onValue) onValue(value); - if (cloned) _runIdle(() => browserCache.put(url, cloned)); + if (cloned && browserCache) {{ + const cache = browserCache; + _runIdle(() => cache.put(url, cloned)); + }} return value; }} catch (e) {{ const stale = await stalePromise; diff --git a/crates/brk_bindgen/src/generators/rust/api.rs b/crates/brk_bindgen/src/generators/rust/api.rs index a060938f0..4884476f2 100644 --- a/crates/brk_bindgen/src/generators/rust/api.rs +++ b/crates/brk_bindgen/src/generators/rust/api.rs @@ -126,7 +126,7 @@ fn generate_get_method(output: &mut String, endpoint: &Endpoint) { ) .unwrap(); } else { - write_query_assembly(output, endpoint, &path, &index_arg); + write_query_assembly(output, endpoint, &path, index_arg); if endpoint.supports_csv { writeln!(output, " if format == Some(Format::CSV) {{").unwrap(); @@ -190,7 +190,7 @@ fn generate_post_method(output: &mut String, endpoint: &Endpoint) { ) .unwrap(); } else { - write_query_assembly(output, endpoint, &path, &index_arg); + write_query_assembly(output, endpoint, &path, index_arg); writeln!( output, " self.base.{}(&path, {})", diff --git a/crates/brk_bindgen/src/generators/rust/mod.rs b/crates/brk_bindgen/src/generators/rust/mod.rs index d71ca5287..225fc3ba1 100644 --- a/crates/brk_bindgen/src/generators/rust/mod.rs +++ b/crates/brk_bindgen/src/generators/rust/mod.rs @@ -25,6 +25,7 @@ pub fn generate_rust_client( writeln!(output, "// Auto-generated BRK Rust client").unwrap(); writeln!(output, "// Do not edit manually\n").unwrap(); writeln!(output, "#![allow(non_camel_case_types)]").unwrap(); + writeln!(output, "#![allow(non_snake_case)]").unwrap(); writeln!(output, "#![allow(dead_code)]").unwrap(); writeln!(output, "#![allow(unused_variables)]").unwrap(); writeln!(output, "#![allow(clippy::useless_format)]").unwrap(); diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index c1ca8224a..c89864cfb 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -2,6 +2,7 @@ // Do not edit manually #![allow(non_camel_case_types)] +#![allow(non_snake_case)] #![allow(dead_code)] #![allow(unused_variables)] #![allow(clippy::useless_format)] @@ -9604,7 +9605,7 @@ impl BrkClient { /// Recent blocks with extras /// - /// Retrieve the last 10 blocks with extended data including pool identification and fee statistics. + /// Retrieve the last 15 blocks with extended data including pool identification and fee statistics. /// /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* /// @@ -9615,7 +9616,7 @@ impl BrkClient { /// Blocks from height with extras /// - /// Retrieve up to 10 blocks with extended data going backwards from the given height. + /// Retrieve up to 15 blocks with extended data going backwards from the given height. /// /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* /// diff --git a/crates/brk_computer/src/blocks/lookback.rs b/crates/brk_computer/src/blocks/lookback.rs index 73f11ac7e..6c1d0a6e2 100644 --- a/crates/brk_computer/src/blocks/lookback.rs +++ b/crates/brk_computer/src/blocks/lookback.rs @@ -1,6 +1,6 @@ use brk_error::Result; use brk_traversable::Traversable; -use brk_types::{Height, Indexes, Timestamp, Version}; +use brk_types::{Height, Indexes, TimePeriod, Timestamp, Version}; use vecdb::{ AnyVec, CachedVec, Cursor, Database, EagerVec, Exit, ImportableVec, PcoVec, ReadableVec, Rw, StorageMode, VecIndex, @@ -58,6 +58,26 @@ pub struct Vecs { pub _26y: M::Stored>>, // 9490d } +impl Vecs { + /// First block height inside `period` looking back from `tip`; `None` for `All`. + /// Walks real block timestamps, matching mempool.space's wall-clock + /// `time > NOW() - INTERVAL ${period}` cutoff. + pub fn start_height(&self, period: TimePeriod, tip: Height) -> Option { + match period { + TimePeriod::Day => self._24h.collect_one(tip), + TimePeriod::ThreeDays => self._3d.collect_one(tip), + TimePeriod::Week => self._1w.collect_one(tip), + TimePeriod::Month => self._1m.collect_one(tip), + TimePeriod::ThreeMonths => self._3m.collect_one(tip), + TimePeriod::SixMonths => self._6m.collect_one(tip), + TimePeriod::Year => self._1y.collect_one(tip), + TimePeriod::TwoYears => self._2y.collect_one(tip), + TimePeriod::ThreeYears => self._3y.collect_one(tip), + TimePeriod::All => None, + } + } +} + impl Vecs { pub(crate) fn forced_import(db: &Database, version: Version) -> Result { let _1h = ImportableVec::forced_import(db, "height_1h_ago", version)?; diff --git a/crates/brk_mempool/src/cpfp.rs b/crates/brk_mempool/src/cpfp.rs index 5d48b878b..1ec526478 100644 --- a/crates/brk_mempool/src/cpfp.rs +++ b/crates/brk_mempool/src/cpfp.rs @@ -6,9 +6,17 @@ //! Confirmed-tx CPFP (the same-block connected component on the //! chain) lives in `brk_query`, since it reads indexer/computer vecs. -use brk_types::{CpfpEntry, CpfpInfo, FeeRate, Sats, TxidPrefix, VSize, Weight}; -use rustc_hash::FxHashSet; +use brk_types::{ + CpfpCluster, CpfpClusterChunk, CpfpClusterTx, CpfpClusterTxIndex, CpfpEntry, CpfpInfo, FeeRate, + TxidPrefix, VSize, Weight, +}; +use rustc_hash::{FxHashMap, FxHashSet}; +use smallvec::SmallVec; +use crate::steps::rebuilder::linearize::{ + LocalIdx, cluster::Cluster, cluster_node::ClusterNode, sfl::Sfl, +}; +use crate::stores::{EntryPool, TxIndex}; use crate::{Mempool, TxEntry}; /// Cap matches Bitcoin Core's default mempool ancestor/descendant @@ -24,19 +32,16 @@ impl Mempool { pub fn cpfp_info(&self, prefix: &TxidPrefix) -> Option { let snapshot = self.snapshot(); let entries = self.entries(); + let txs = self.txs(); let seed_idx = entries.idx_of(prefix)?; let seed = entries.slot(seed_idx)?; - let mut sum_fee = u64::from(seed.fee); - let mut sum_vsize = u64::from(seed.vsize); + let mut ancestor_idxs: Vec = Vec::new(); + let mut descendant_idxs: Vec = Vec::new(); let mut ancestors: Vec = Vec::new(); let mut descendants: Vec = Vec::new(); if let Some(seed_block) = snapshot.block_of(seed_idx) { - // Ancestor BFS gated to the seed's projected block. - // `visited` dedupes the walk; stale parent prefixes - // (confirmed/evicted between snapshot and now) are skipped - // when `idx_of` returns None. let mut visited: FxHashSet = FxHashSet::default(); visited.insert(*prefix); let mut stack: Vec = seed.depends.iter().copied().collect(); @@ -52,17 +57,11 @@ impl Mempool { continue; } let Some(anc) = entries.slot(idx) else { continue }; - sum_fee += u64::from(anc.fee); - sum_vsize += u64::from(anc.vsize); + ancestor_idxs.push(idx); ancestors.push(to_entry(anc)); stack.extend(anc.depends.iter().copied()); } - // Descendant sweep. `desc_set` starts with only the seed - // so siblings (txs sharing an ancestor with seed but not - // downstream of it) are excluded. The topological ordering - // of `Snapshot.blocks` guarantees that all in-block - // ancestors of any tx are visited before it. let mut desc_set: FxHashSet = FxHashSet::default(); desc_set.insert(*prefix); for &i in &snapshot.blocks[seed_block.as_usize()] { @@ -74,8 +73,7 @@ impl Mempool { continue; } desc_set.insert(e.txid_prefix()); - sum_fee += u64::from(e.fee); - sum_vsize += u64::from(e.vsize); + descendant_idxs.push(i); descendants.push(to_entry(e)); } } @@ -85,16 +83,39 @@ impl Mempool { .max_by_key(|e| FeeRate::from((e.fee, e.weight))) .cloned(); - let package_rate = FeeRate::from((Sats::from(sum_fee), VSize::from(sum_vsize))); - let effective = seed.fee_rate().max(package_rate); + let sigops = txs.get(&seed.txid).map(|tx| { + // Bitcoin Core's `total_sigop_cost` is the segwit-weighted sigop + // count (legacy * 4 + segwit * 1), divided by 5 to match + // mempool.space's reported `sigops`. Mempool.space converts + // back to count via `sigopcost / 5`. + u32::try_from(tx.total_sigop_cost / 5).unwrap_or(u32::MAX) + }); + + // mempool.space's adjustedVsize = max(vsize, sigops * 5). + let adjusted_vsize = match sigops { + Some(s) => VSize::from(u64::from(seed.vsize).max(u64::from(s) * 5)), + None => seed.vsize, + }; + + let cluster = build_cluster(seed_idx, &ancestor_idxs, &descendant_idxs, &entries); + + // mempool.space sets effectiveFeePerVsize to the seed's chunk feerate + // when the cluster is known, falls back to the seed's own rate. + let effective = cluster + .as_ref() + .and_then(|c| c.chunks.get(c.chunk_index as usize)) + .map(|chunk| chunk.feerate) + .unwrap_or_else(|| seed.fee_rate()); Some(CpfpInfo { ancestors, best_descendant, descendants, effective_fee_per_vsize: Some(effective), + sigops, fee: Some(seed.fee), - adjusted_vsize: Some(seed.vsize), + adjusted_vsize: Some(adjusted_vsize), + cluster, }) } } @@ -106,3 +127,120 @@ fn to_entry(e: &TxEntry) -> CpfpEntry { fee: e.fee, } } + +/// Build the cluster output: seed + ancestors + descendants in topological +/// order, with parent indexes inside the cluster, plus SFL-linearized chunks. +fn build_cluster( + seed_idx: TxIndex, + ancestor_idxs: &[TxIndex], + descendant_idxs: &[TxIndex], + entries: &EntryPool, +) -> Option { + let mut ordered: Vec = Vec::with_capacity(ancestor_idxs.len() + 1 + descendant_idxs.len()); + ordered.extend(ancestor_idxs.iter().copied()); + ordered.push(seed_idx); + ordered.extend(descendant_idxs.iter().copied()); + + let pool: Vec<&TxEntry> = ordered.iter().filter_map(|&i| entries.slot(i)).collect(); + if pool.len() != ordered.len() { + return None; + } + + let prefix_to_local: FxHashMap = pool + .iter() + .enumerate() + .map(|(i, e)| (e.txid_prefix(), i as LocalIdx)) + .collect(); + + let mut children_of: Vec> = vec![SmallVec::new(); pool.len()]; + let parents_of: Vec> = pool + .iter() + .enumerate() + .map(|(i, e)| { + let parents: SmallVec<[LocalIdx; 2]> = e + .depends + .iter() + .filter_map(|p| prefix_to_local.get(p).copied()) + .collect(); + for &p in &parents { + children_of[p as usize].push(i as LocalIdx); + } + parents + }) + .collect(); + + let cluster_nodes: Vec = pool + .iter() + .enumerate() + .map(|(i, e)| ClusterNode { + tx_index: ordered[i], + fee: e.fee, + vsize: e.vsize, + parents: parents_of[i].clone(), + children: children_of[i].clone(), + }) + .collect(); + + let cluster = Cluster::new(cluster_nodes); + + // Re-order pool so parents come before children (mempool.space convention). + // `topo_rank[i]` gives the position of local index `i` in topological order. + let mut local_to_topo: Vec = (0..pool.len()).collect(); + local_to_topo.sort_unstable_by_key(|&i| cluster.topo_rank[i]); + let topo_to_local: Vec = { + let mut v = vec![0usize; pool.len()]; + for (topo_pos, &local) in local_to_topo.iter().enumerate() { + v[local] = topo_pos; + } + v + }; + + let topo_idx = |local: usize| CpfpClusterTxIndex::from(topo_to_local[local] as u32); + + let txs: Vec = local_to_topo + .iter() + .map(|&local| { + let e = pool[local]; + let parents: Vec = parents_of[local] + .iter() + .map(|&p| topo_idx(p as usize)) + .collect(); + CpfpClusterTx { + txid: e.txid.clone(), + fee: e.fee, + weight: Weight::from(e.vsize), + parents, + } + }) + .collect(); + + let raw_chunks = Sfl::linearize(&cluster); + let chunks: Vec = raw_chunks + .iter() + .map(|chunk| { + let mut chunk_txs: Vec = chunk + .nodes + .iter() + .map(|&local| topo_idx(local as usize)) + .collect(); + chunk_txs.sort_unstable(); + CpfpClusterChunk { + txs: chunk_txs, + feerate: chunk.fee_rate(), + } + }) + .collect(); + + let seed_local = *prefix_to_local.get(&entries.slot(seed_idx)?.txid_prefix())?; + let seed_topo = topo_idx(seed_local as usize); + let chunk_index = chunks + .iter() + .position(|c| c.txs.contains(&seed_topo)) + .unwrap_or(0) as u32; + + Some(CpfpCluster { + txs, + chunks, + chunk_index, + }) +} diff --git a/crates/brk_mempool/src/steps/preparer/tx_addition.rs b/crates/brk_mempool/src/steps/preparer/tx_addition.rs index db5e61b60..bca4d4a92 100644 --- a/crates/brk_mempool/src/steps/preparer/tx_addition.rs +++ b/crates/brk_mempool/src/steps/preparer/tx_addition.rs @@ -4,13 +4,13 @@ //! prevouts against `known` or `parent_raws`, build a full //! `Transaction` + `Entry`. //! - **Revived** - tx in the graveyard. Rebuild the `Entry` only -//! (preserving `first_seen`, `rbf`, `size`). The Applier exhumes -//! the cached tx body. No raw decoding. +//! (preserving `rbf`, `size`). The Applier exhumes the cached tx +//! body. No raw decoding. use std::mem; use brk_rpc::RawTx; -use brk_types::{MempoolEntryInfo, Timestamp, Transaction, TxIn, TxOut, TxStatus, Txid, Vout}; +use brk_types::{MempoolEntryInfo, Transaction, TxIn, TxOut, TxStatus, Txid, Vout}; use rustc_hash::FxHashMap; use crate::{TxTombstone, stores::TxStore}; @@ -35,7 +35,7 @@ impl TxAddition { let total_size = raw.hex.len() / 2; let rbf = raw.tx.input.iter().any(|i| i.sequence.is_rbf()); let tx = Self::build_tx(info, raw, total_size, mempool_txs, parent_raws); - let entry = TxEntry::new(info, total_size as u64, rbf, Timestamp::now()); + let entry = TxEntry::new(info, total_size as u64, rbf); Self::Fresh { tx, entry } } @@ -68,7 +68,7 @@ impl TxAddition { } pub(super) fn revived(info: &MempoolEntryInfo, tomb: &TxTombstone) -> Self { - let entry = TxEntry::new(info, tomb.entry.size, tomb.entry.rbf, tomb.entry.first_seen); + let entry = TxEntry::new(info, tomb.entry.size, tomb.entry.rbf); Self::Revived { entry } } diff --git a/crates/brk_mempool/src/steps/preparer/tx_entry.rs b/crates/brk_mempool/src/steps/preparer/tx_entry.rs index 8f4cb9485..6fe6b897a 100644 --- a/crates/brk_mempool/src/steps/preparer/tx_entry.rs +++ b/crates/brk_mempool/src/steps/preparer/tx_entry.rs @@ -26,14 +26,14 @@ pub struct TxEntry { } impl TxEntry { - pub(super) fn new(info: &MempoolEntryInfo, size: u64, rbf: bool, first_seen: Timestamp) -> Self { + pub(super) fn new(info: &MempoolEntryInfo, size: u64, rbf: bool) -> Self { Self { txid: info.txid.clone(), fee: info.fee, vsize: VSize::from(info.vsize), size, depends: info.depends.iter().map(TxidPrefix::from).collect(), - first_seen, + first_seen: info.first_seen, rbf, } } diff --git a/crates/brk_query/src/impl/addr.rs b/crates/brk_query/src/impl/addr.rs index fe2742baf..63524b37c 100644 --- a/crates/brk_query/src/impl/addr.rs +++ b/crates/brk_query/src/impl/addr.rs @@ -250,7 +250,7 @@ impl Query { (first_seen, txid) }) .collect(); - ordered.sort_unstable_by(|a, b| b.0.cmp(&a.0)); + ordered.sort_unstable_by_key(|b| std::cmp::Reverse(b.0)); let txs = mempool.txs(); Ok(ordered .into_iter() diff --git a/crates/brk_query/src/impl/block/info.rs b/crates/brk_query/src/impl/block/info.rs index 9eeaf45a4..c864eadbb 100644 --- a/crates/brk_query/src/impl/block/info.rs +++ b/crates/brk_query/src/impl/block/info.rs @@ -11,62 +11,76 @@ use vecdb::{AnyVec, ReadableVec, VecIndex}; use crate::Query; -const DEFAULT_BLOCK_COUNT: u32 = 10; -const DEFAULT_V1_BLOCK_COUNT: u32 = 15; const HEADER_SIZE: usize = 80; impl Query { + /// Block by hash. Unknown hash → 404 via `height_by_hash`. pub fn block(&self, hash: &BlockHash) -> Result { let height = self.height_by_hash(hash)?; self.block_by_height(height) } + /// Block by height. Height > tip → `OutOfRange`. pub fn block_by_height(&self, height: Height) -> Result { - let max_height = self.indexed_height(); - if height > max_height { + if height > self.tip_height() { return Err(Error::OutOfRange("Block height out of range".into())); } - self.blocks_range(height.to_usize(), height.to_usize() + 1)? + let h = height.to_usize(); + self.blocks_range(h, h + 1)? .pop() .ok_or(Error::NotFound("Block not found".into())) } + /// V1 block by height. Ceiling is `min(indexed, computed)` because + /// `blocks_v1_range` reads computer-stamped series (pools, fees, + /// supply state). Anything past `computed_height` would short-read. pub fn block_by_height_v1(&self, height: Height) -> Result { - let max_height = self.height(); - if height > max_height { + if height > self.height() { return Err(Error::OutOfRange("Block height out of range".into())); } - self.blocks_v1_range(height.to_usize(), height.to_usize() + 1)? + let h = height.to_usize(); + self.blocks_v1_range(h, h + 1)? .pop() .ok_or(Error::NotFound("Block not found".into())) } + /// Hex-encoded 80-byte block header. Decode-then-encode roundtrip + /// doubles as a corruption check on the on-disk bytes. pub fn block_header_hex(&self, hash: &BlockHash) -> Result { let height = self.height_by_hash(hash)?; let header = self.read_block_header(height)?; Ok(bitcoin::consensus::encode::serialize_hex(&header)) } + /// Block hash by height. Cheap typed-index read with a semantic + /// bounds gate (`OutOfRange` for past-tip, `Internal` if the data + /// is unexpectedly missing inside the gate). pub fn block_hash_by_height(&self, height: Height) -> Result { - let max_height = self.indexed_height(); - if height > max_height { + if height > self.tip_height() { return Err(Error::OutOfRange("Block height out of range".into())); } self.indexer().vecs.blocks.blockhash.get(height).data() } - pub fn blocks(&self, start_height: Option) -> Result> { - let (begin, end) = self.resolve_block_range(start_height, DEFAULT_BLOCK_COUNT); + /// Most recent `count` blocks ending at `start_height` (default tip), + /// returned in descending-height order. + pub fn blocks(&self, start_height: Option, count: u32) -> Result> { + let (begin, end) = self.resolve_block_range(start_height, count); self.blocks_range(begin, end) } - pub fn blocks_v1(&self, start_height: Option) -> Result> { - let (begin, end) = self.resolve_block_range(start_height, DEFAULT_V1_BLOCK_COUNT); + /// V1 most recent `count` blocks with extras ending at `start_height` + /// (default tip), returned in descending-height order. + pub fn blocks_v1(&self, start_height: Option, count: u32) -> Result> { + let (begin, end) = self.resolve_block_range(start_height, count); self.blocks_v1_range(begin, end) } // === Range queries (bulk reads) === + /// Build `BlockInfo` rows for `[begin, end)` in descending-height order. + /// Caller must bounds-check `end <= tip + 1`. Returns `Internal` if any + /// bulk read short-returns under per-vec stamp races. fn blocks_range(&self, begin: usize, end: usize) -> Result> { if begin >= end { return Ok(Vec::new()); @@ -75,6 +89,7 @@ impl Query { let indexer = self.indexer(); let computer = self.computer(); let reader = self.reader(); + let count = end - begin; // Bulk read all indexed data let blockhashes = indexer.vecs.blocks.blockhash.collect_range_at(begin, end); @@ -83,6 +98,15 @@ impl Query { let sizes = indexer.vecs.blocks.total.collect_range_at(begin, end); let weights = indexer.vecs.blocks.weight.collect_range_at(begin, end); let positions = indexer.vecs.blocks.position.collect_range_at(begin, end); + if blockhashes.len() != count + || difficulties.len() != count + || timestamps.len() != count + || sizes.len() != count + || weights.len() != count + || positions.len() != count + { + return Err(Error::Internal("blocks_range: short read on per-block vecs")); + } // Bulk read tx indexes for tx_count let max_height = self.indexed_height(); @@ -96,6 +120,9 @@ impl Query { .transactions .first_tx_index .collect_range_at(begin, tx_index_end); + if first_tx_indexes.len() < count { + return Err(Error::Internal("blocks_range: short read on first_tx_index")); + } let total_txs = computer.indexes.tx_index.identity.len(); // Bulk read median time window @@ -105,8 +132,10 @@ impl Query { .blocks .timestamp .collect_range_at(median_start, end); + if median_timestamps.len() != end - median_start { + return Err(Error::Internal("blocks_range: short read on median window")); + } - let count = end - begin; let mut blocks = Vec::with_capacity(count); for i in (0..count).rev() { @@ -431,6 +460,10 @@ impl Query { // === Helper methods === + pub fn tip_height(&self) -> Height { + Height::from(self.indexer().vecs.blocks.blockhash.len().saturating_sub(1)) + } + pub fn height_by_hash(&self, hash: &BlockHash) -> Result { let indexer = self.indexer(); let prefix = BlockHashPrefix::from(hash); diff --git a/crates/brk_query/src/impl/block/raw.rs b/crates/brk_query/src/impl/block/raw.rs index 2273c3466..39f13b98e 100644 --- a/crates/brk_query/src/impl/block/raw.rs +++ b/crates/brk_query/src/impl/block/raw.rs @@ -1,6 +1,6 @@ use brk_error::{Error, OptionData, Result}; use brk_types::{BlockHash, Height}; -use vecdb::{AnyVec, ReadableVec}; +use vecdb::ReadableVec; use crate::Query; @@ -11,19 +11,17 @@ impl Query { } fn block_raw_by_height(&self, height: Height) -> Result> { - let indexer = self.indexer(); - let reader = self.reader(); - - let max_height = Height::from(indexer.vecs.blocks.blockhash.len().saturating_sub(1)); + let max_height = self.tip_height(); if height > max_height { return Err(Error::OutOfRange(format!( "Block height {height} out of range (tip {max_height})" ))); } + let indexer = self.indexer(); let position = indexer.vecs.blocks.position.collect_one(height).data()?; let size = indexer.vecs.blocks.total.collect_one(height).data()?; - reader.read_raw_bytes(position, *size as usize) + self.reader().read_raw_bytes(position, *size as usize) } } diff --git a/crates/brk_query/src/impl/block/status.rs b/crates/brk_query/src/impl/block/status.rs index d474b1d10..6699c589c 100644 --- a/crates/brk_query/src/impl/block/status.rs +++ b/crates/brk_query/src/impl/block/status.rs @@ -1,6 +1,5 @@ use brk_error::{OptionData, Result}; use brk_types::{BlockHash, BlockStatus, Height}; -use vecdb::AnyVec; use crate::Query; @@ -11,9 +10,7 @@ impl Query { } fn block_status_by_height(&self, height: Height) -> Result { - let indexer = self.indexer(); - - let max_height = Height::from(indexer.vecs.blocks.blockhash.len().saturating_sub(1)); + let max_height = self.tip_height(); if height > max_height { return Ok(BlockStatus::not_in_best_chain()); @@ -21,7 +18,7 @@ impl Query { let next_best = if height < max_height { Some( - indexer + self.indexer() .vecs .blocks .blockhash diff --git a/crates/brk_query/src/impl/block/timestamp.rs b/crates/brk_query/src/impl/block/timestamp.rs index 7230e0389..8eb4d2452 100644 --- a/crates/brk_query/src/impl/block/timestamp.rs +++ b/crates/brk_query/src/impl/block/timestamp.rs @@ -1,27 +1,37 @@ use brk_error::{Error, OptionData, Result}; use brk_types::{BlockTimestamp, Date, Day1, Height, Timestamp}; use jiff::Timestamp as JiffTimestamp; -use vecdb::ReadableVec; +use vecdb::{AnyVec, ReadableVec}; use crate::Query; +/// Per BIP113, a block's timestamp must exceed the median of the previous 11 +/// blocks. Eleven consecutive `ts > target` therefore prove no later block can +/// have `ts ≤ target` (its median floor would already exceed `target`). +const MTP_TERMINAL_STREAK: usize = 11; + impl Query { + /// Most recent block with `timestamp ≤ ts`. Backs mempool.space's + /// `GET /api/v1/mining/blocks/timestamp/{ts}`. Future timestamps return + /// the chain tip; pre-genesis timestamps return 404. + /// + /// Uses `day1.first_height` for an O(1) seek to the target date, then a + /// linear scan bounded by the BIP113 MTP rule (see `MTP_TERMINAL_STREAK`). + /// Symmetric backward scan handles targets earlier than the seeded day's + /// first block. pub fn block_by_timestamp(&self, timestamp: Timestamp) -> Result { let indexer = self.indexer(); let computer = self.computer(); - let max_height = self.indexed_height(); - let max_height_usize: usize = max_height.into(); - - if max_height_usize == 0 { + if indexer.vecs.blocks.blockhash.len() == 0 { return Err(Error::NotFound("No blocks indexed".into())); } + let tip: usize = self.tip_height().into(); let target = timestamp; let date = Date::from(target); let day1 = Day1::try_from(date).unwrap_or_default(); - // Get first height of the target date let first_height_of_day = computer .indexes .day1 @@ -29,37 +39,46 @@ impl Query { .collect_one(day1) .unwrap_or(Height::from(0usize)); - let start: usize = usize::from(first_height_of_day).min(max_height_usize); + let start: usize = usize::from(first_height_of_day).min(tip); let mut ts_cursor = indexer.vecs.blocks.timestamp.cursor(); + let mut best: Option<(usize, Timestamp)> = None; - // Search forward from start to find the last block <= target timestamp - let mut best_height = start; - let mut best_ts = ts_cursor.get(start).data()?; - - for h in (start + 1)..=max_height_usize { + let mut above_streak = 0usize; + for h in start..=tip { let block_ts = ts_cursor.get(h).data()?; if block_ts <= target { - best_height = h; - best_ts = block_ts; + best = Some((h, block_ts)); + above_streak = 0; } else { - break; + above_streak += 1; + if above_streak >= MTP_TERMINAL_STREAK { + break; + } } } - // Check one block before start in case we need to go backward - if start > 0 && best_ts > target { - let prev_ts = ts_cursor.get(start - 1).data()?; - if prev_ts <= target { - best_height = start - 1; - best_ts = prev_ts; + if best.is_none() && start > 0 { + let mut above_streak = 0usize; + for h in (0..start).rev() { + let block_ts = ts_cursor.get(h).data()?; + if block_ts <= target { + best = Some((h, block_ts)); + break; + } + above_streak += 1; + if above_streak >= MTP_TERMINAL_STREAK { + break; + } } } + let (best_height, best_ts) = + best.ok_or_else(|| Error::NotFound("No block at or before timestamp".into()))?; + let height = Height::from(best_height); let blockhash = indexer.vecs.blocks.blockhash.collect_one(height).data()?; - // Convert timestamp to ISO 8601 format let ts_secs: i64 = (*best_ts).into(); let iso_timestamp = JiffTimestamp::from_second(ts_secs) .map(|t| t.strftime("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()) diff --git a/crates/brk_query/src/impl/mempool.rs b/crates/brk_query/src/impl/mempool.rs index ec71711a3..bf0d21193 100644 --- a/crates/brk_query/src/impl/mempool.rs +++ b/crates/brk_query/src/impl/mempool.rs @@ -250,8 +250,10 @@ impl Query { best_descendant, descendants, effective_fee_per_vsize: Some(effective), + sigops: None, fee: Some(seed_fee), adjusted_vsize: Some(seed_vsize), + cluster: None, } } diff --git a/crates/brk_query/src/impl/mining/block_fee_rates.rs b/crates/brk_query/src/impl/mining/block_fee_rates.rs index 055b22e07..a2b1364ec 100644 --- a/crates/brk_query/src/impl/mining/block_fee_rates.rs +++ b/crates/brk_query/src/impl/mining/block_fee_rates.rs @@ -1,6 +1,5 @@ use brk_error::Result; -use brk_types::{BlockFeeRatesEntry, FeeRate, FeeRatePercentiles, TimePeriod}; -use vecdb::ReadableVec; +use brk_types::{BlockFeeRatesEntry, FeeRatePercentiles, TimePeriod}; use super::block_window::BlockWindow; use crate::Query; @@ -8,55 +7,38 @@ use crate::Query; impl Query { pub fn block_fee_rates(&self, time_period: TimePeriod) -> Result> { let bw = BlockWindow::new(self, time_period); - let computer = self.computer(); - let frd = &computer + let frd = &self + .computer() .transactions .fees .effective_fee_rate .distribution .block; - let min = frd.min.height.collect_range_at(bw.start, bw.end); - let pct10 = frd.pct10.height.collect_range_at(bw.start, bw.end); - let pct25 = frd.pct25.height.collect_range_at(bw.start, bw.end); - let median = frd.median.height.collect_range_at(bw.start, bw.end); - let pct75 = frd.pct75.height.collect_range_at(bw.start, bw.end); - let pct90 = frd.pct90.height.collect_range_at(bw.start, bw.end); - let max = frd.max.height.collect_range_at(bw.start, bw.end); + let min = bw.read(&frd.min.height); + let pct10 = bw.read(&frd.pct10.height); + let pct25 = bw.read(&frd.pct25.height); + let median = bw.read(&frd.median.height); + let pct75 = bw.read(&frd.pct75.height); + let pct90 = bw.read(&frd.pct90.height); + let max = bw.read(&frd.max.height); - let timestamps = bw.timestamps(self); - - let mut results = Vec::with_capacity(timestamps.len()); - let mut pos = 0; - let total = min.len(); - - for ts in ×tamps { - let window_end = (pos + bw.window).min(total); - let count = window_end - pos; - if count > 0 { - let mid = (pos + window_end) / 2; - let avg = |vals: &[FeeRate]| -> FeeRate { - let sum: f64 = vals[pos..window_end].iter().map(|f| f64::from(*f)).sum(); - FeeRate::new(sum / count as f64) - }; - - results.push(BlockFeeRatesEntry { - avg_height: brk_types::Height::from(bw.start + mid), - timestamp: *ts, - percentiles: FeeRatePercentiles::new( - avg(&min), - avg(&pct10), - avg(&pct25), - avg(&median), - avg(&pct75), - avg(&pct90), - avg(&max), - ), - }); - } - pos = window_end; - } - - Ok(results) + Ok(bw + .buckets + .iter() + .map(|b| BlockFeeRatesEntry { + avg_height: b.avg_height, + timestamp: b.avg_timestamp, + percentiles: FeeRatePercentiles::new( + b.mean(&min), + b.mean(&pct10), + b.mean(&pct25), + b.mean(&median), + b.mean(&pct75), + b.mean(&pct90), + b.mean(&max), + ), + }) + .collect()) } } diff --git a/crates/brk_query/src/impl/mining/block_fees.rs b/crates/brk_query/src/impl/mining/block_fees.rs index 6b8eb8e58..d90bd8eee 100644 --- a/crates/brk_query/src/impl/mining/block_fees.rs +++ b/crates/brk_query/src/impl/mining/block_fees.rs @@ -1,5 +1,5 @@ use brk_error::Result; -use brk_types::{BlockFeesEntry, TimePeriod}; +use brk_types::{BlockFeesEntry, Cents, Dollars, Sats, TimePeriod}; use super::block_window::BlockWindow; use crate::Query; @@ -7,15 +7,17 @@ use crate::Query; impl Query { pub fn block_fees(&self, time_period: TimePeriod) -> Result> { let bw = BlockWindow::new(self, time_period); - let cumulative = &self.computer().mining.rewards.fees.cumulative.sats.height; + let fees: Vec = bw.read(&self.computer().mining.rewards.fees.block.sats); + let prices: Vec = bw.read(&self.computer().prices.spot.cents.height); + Ok(bw - .cumulative_averages(self, cumulative) - .into_iter() - .map(|w| BlockFeesEntry { - avg_height: w.avg_height, - timestamp: w.timestamp, - avg_fees: w.avg_value, - usd: w.usd, + .buckets + .iter() + .map(|b| BlockFeesEntry { + avg_height: b.avg_height, + timestamp: b.avg_timestamp, + avg_fees: b.mean_rounded(&fees), + usd: Dollars::from(b.mean_rounded(&prices)), }) .collect()) } diff --git a/crates/brk_query/src/impl/mining/block_rewards.rs b/crates/brk_query/src/impl/mining/block_rewards.rs index 27ab5446b..8374523f3 100644 --- a/crates/brk_query/src/impl/mining/block_rewards.rs +++ b/crates/brk_query/src/impl/mining/block_rewards.rs @@ -1,5 +1,5 @@ use brk_error::Result; -use brk_types::{BlockRewardsEntry, TimePeriod}; +use brk_types::{BlockRewardsEntry, Cents, Dollars, Sats, TimePeriod}; use super::block_window::BlockWindow; use crate::Query; @@ -7,22 +7,17 @@ use crate::Query; impl Query { pub fn block_rewards(&self, time_period: TimePeriod) -> Result> { let bw = BlockWindow::new(self, time_period); - let cumulative = &self - .computer() - .mining - .rewards - .coinbase - .cumulative - .sats - .height; + let rewards: Vec = bw.read(&self.computer().mining.rewards.coinbase.block.sats); + let prices: Vec = bw.read(&self.computer().prices.spot.cents.height); + Ok(bw - .cumulative_averages(self, cumulative) - .into_iter() - .map(|w| BlockRewardsEntry { - avg_height: w.avg_height, - timestamp: w.timestamp, - avg_rewards: w.avg_value, - usd: w.usd, + .buckets + .iter() + .map(|b| BlockRewardsEntry { + avg_height: b.avg_height, + timestamp: b.avg_timestamp, + avg_rewards: b.mean_rounded(&rewards), + usd: Dollars::from(b.mean_rounded(&prices)), }) .collect()) } diff --git a/crates/brk_query/src/impl/mining/block_sizes.rs b/crates/brk_query/src/impl/mining/block_sizes.rs index 8c47b83f2..9fa64daf5 100644 --- a/crates/brk_query/src/impl/mining/block_sizes.rs +++ b/crates/brk_query/src/impl/mining/block_sizes.rs @@ -1,59 +1,37 @@ use brk_error::Result; -use brk_types::{BlockSizeEntry, BlockSizesWeights, BlockWeightEntry, TimePeriod, Weight}; -use vecdb::ReadableVec; +use brk_types::{ + BlockSizeEntry, BlockSizesWeights, BlockWeightEntry, StoredU64, TimePeriod, Weight, +}; use super::block_window::BlockWindow; use crate::Query; impl Query { pub fn block_sizes_weights(&self, time_period: TimePeriod) -> Result { - let computer = self.computer(); + let blocks = &self.indexer().vecs.blocks; let bw = BlockWindow::new(self, time_period); - let timestamps = bw.timestamps(self); - // Batch read per-block rolling 24h medians for the range - let all_sizes = computer - .blocks - .size - .size - .rolling - .distribution - .median - ._24h - .height - .collect_range_at(bw.start, bw.end); - let all_weights = computer - .blocks - .weight - .weight - .rolling - .distribution - .median - ._24h - .height - .collect_range_at(bw.start, bw.end); + let block_sizes: Vec = bw.read(&blocks.total); + let block_weights: Vec = bw.read(&blocks.weight); - // Sample at window midpoints - let mut sizes = Vec::with_capacity(timestamps.len()); - let mut weights = Vec::with_capacity(timestamps.len()); - - for ((avg_height, start, _end), ts) in bw.iter().zip(×tamps) { - let mid = start - bw.start + (bw.window / 2).min(all_sizes.len().saturating_sub(1)); - if let Some(&size) = all_sizes.get(mid) { - sizes.push(BlockSizeEntry { - avg_height, - timestamp: *ts, - avg_size: *size, - }); - } - if let Some(&weight) = all_weights.get(mid) { - weights.push(BlockWeightEntry { - avg_height, - timestamp: *ts, - avg_weight: Weight::from(*weight), - }); - } - } + let (sizes, weights) = bw + .buckets + .iter() + .map(|b| { + ( + BlockSizeEntry { + avg_height: b.avg_height, + timestamp: b.avg_timestamp, + avg_size: u64::from(b.mean_rounded(&block_sizes)), + }, + BlockWeightEntry { + avg_height: b.avg_height, + timestamp: b.avg_timestamp, + avg_weight: b.mean_rounded(&block_weights), + }, + ) + }) + .unzip(); Ok(BlockSizesWeights { sizes, weights }) } diff --git a/crates/brk_query/src/impl/mining/block_window.rs b/crates/brk_query/src/impl/mining/block_window.rs index 65c39e959..c544e985f 100644 --- a/crates/brk_query/src/impl/mining/block_window.rs +++ b/crates/brk_query/src/impl/mining/block_window.rs @@ -1,155 +1,117 @@ -use brk_types::{Cents, Dollars, Height, Sats, TimePeriod, Timestamp}; -use vecdb::{ReadableVec, VecIndex}; +use std::{ + collections::BTreeMap, + iter::Sum, + ops::{Deref, Div}, +}; + +use brk_types::{Height, TimePeriod, Timestamp}; +use vecdb::{ReadableVec, VecValue}; use crate::Query; -/// Number of blocks per aggregation window, matching mempool.space's granularity. -fn block_window(period: TimePeriod) -> usize { +/// Mempool.space's `GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}` divisor in seconds. +/// `div = 1` puts each block in its own bucket. +fn time_div(period: TimePeriod) -> u32 { match period { - TimePeriod::Day | TimePeriod::ThreeDays | TimePeriod::Week => 1, - TimePeriod::Month => 3, - TimePeriod::ThreeMonths => 12, - TimePeriod::SixMonths => 18, - TimePeriod::Year | TimePeriod::TwoYears => 48, - TimePeriod::ThreeYears => 72, - TimePeriod::All => 144, + TimePeriod::Day | TimePeriod::ThreeDays => 1, + TimePeriod::Week => 300, + TimePeriod::Month => 1800, + TimePeriod::ThreeMonths => 7200, + TimePeriod::SixMonths => 10800, + TimePeriod::Year | TimePeriod::TwoYears => 28800, + TimePeriod::ThreeYears => 43200, + TimePeriod::All => 86400, } } -/// Per-window average with metadata. -pub struct WindowAvg { - pub avg_height: Height, - pub timestamp: Timestamp, - pub avg_value: Sats, - pub usd: Dollars, +/// Round-half-up integer division, matching MySQL's `CAST(AVG(...) AS INT)`. +const fn round_half_up(sum: u64, n: u64) -> u64 { + (sum + n / 2) / n } -/// Block range and window size for a time period. +/// One time-bucket of blocks in a `BlockWindow`. +pub struct BlockBucket { + pub avg_height: Height, + pub avg_timestamp: Timestamp, + /// Offsets into the parent `BlockWindow`'s prefetched `[start, end)` slice. + offsets: Vec, +} + +impl BlockBucket { + /// Float arithmetic mean of `values[offset]` across this bucket's blocks. + /// Use for float-backed types like `FeeRate`. + pub fn mean(&self, values: &[T]) -> T + where + T: Copy + Sum + Div, + { + self.offsets.iter().map(|&i| values[i]).sum::() / self.offsets.len() + } + + /// Round-half-up arithmetic mean for u64-backed integer types, matching + /// mempool.space's `CAST(AVG(...) AS INT)`. + pub fn mean_rounded(&self, values: &[T]) -> T + where + T: Copy + Deref + From, + { + let n = self.offsets.len() as u64; + let sum: u64 = self.offsets.iter().map(|&i| *values[i]).sum(); + T::from(round_half_up(sum, n)) + } +} + +/// Mempool-compatible time-bucketed block window. Groups blocks by +/// `block.timestamp / div` and exposes arithmetic means per bucket. pub struct BlockWindow { - pub start: usize, - pub end: usize, - pub window: usize, + pub start: Height, + pub end: Height, + pub buckets: Vec, } impl BlockWindow { - pub fn new(query: &Query, time_period: TimePeriod) -> Self { - let current_height = query.height(); - let computer = query.computer(); - let lookback = &computer.blocks.lookback; + pub fn new(query: &Query, period: TimePeriod) -> Self { + let start = query.start_height(period); + let end = query.height() + 1usize; + let div = time_div(period); - // Use pre-computed timestamp-based lookback for accurate time boundaries. - // 24h, 1w, 1m, 1y use in-memory CachedVec; others fall back to PcoVec. - let start_height = match time_period { - TimePeriod::Day => lookback._24h.collect_one(current_height), - TimePeriod::ThreeDays => lookback._3d.collect_one(current_height), - TimePeriod::Week => lookback._1w.collect_one(current_height), - TimePeriod::Month => lookback._1m.collect_one(current_height), - TimePeriod::ThreeMonths => lookback._3m.collect_one(current_height), - TimePeriod::SixMonths => lookback._6m.collect_one(current_height), - TimePeriod::Year => lookback._1y.collect_one(current_height), - TimePeriod::TwoYears => lookback._2y.collect_one(current_height), - TimePeriod::ThreeYears => lookback._3y.collect_one(current_height), - TimePeriod::All => None, - } - .unwrap_or_default(); - - Self { - start: start_height.to_usize(), - end: current_height.to_usize() + 1, - window: block_window(time_period), - } - } - - /// Compute per-window averages from a cumulative sats vec. - /// Batch-reads timestamps, prices, and the cumulative in one pass. - pub fn cumulative_averages( - &self, - query: &Query, - cumulative: &impl ReadableVec, - ) -> Vec { - let indexer = query.indexer(); - let computer = query.computer(); - - // Batch read all needed data for the range - let all_ts = indexer - .vecs - .blocks - .timestamp - .collect_range_at(self.start, self.end); - let all_prices: Vec = computer - .prices - .spot - .cents - .height - .collect_range_at(self.start, self.end); - let read_start = self.start.saturating_sub(1); - let all_cum = cumulative.collect_range_at(read_start, self.end); - let offset = if self.start > 0 { 1 } else { 0 }; - - let mut results = Vec::with_capacity(self.count()); - let mut pos = 0; - let total = all_ts.len(); - - while pos < total { - let window_end = (pos + self.window).min(total); - let block_count = (window_end - pos) as u64; - let mid = (pos + window_end) / 2; - let cum_end = all_cum[window_end - 1 + offset]; - let cum_start = if pos + offset > 0 { - all_cum[pos + offset - 1] - } else { - Sats::ZERO - }; - let total_sats = cum_end - cum_start; - if let Some(avg) = (*total_sats).checked_div(block_count) { - results.push(WindowAvg { - avg_height: Height::from(self.start + mid), - timestamp: all_ts[mid], - avg_value: Sats::from(avg), - usd: Dollars::from(all_prices[mid]), - }); - } - pos = window_end; - } - - results - } - - /// Batch-read timestamps for the midpoint of each window. - pub fn timestamps(&self, query: &Query) -> Vec { - let all_ts = query + let timestamps: Vec = query .indexer() .vecs .blocks .timestamp - .collect_range_at(self.start, self.end); - let mut timestamps = Vec::with_capacity(self.count()); - let mut pos = 0; - while pos < all_ts.len() { - let window_end = (pos + self.window).min(all_ts.len()); - timestamps.push(all_ts[(pos + window_end) / 2]); - pos = window_end; + .collect_range(start, end); + + let mut groups: BTreeMap> = BTreeMap::new(); + for (i, ts) in timestamps.iter().enumerate() { + groups.entry(**ts / div).or_default().push(i); + } + + let buckets = groups + .into_values() + .map(|offsets| { + let n = offsets.len() as u64; + let sum_h: u64 = offsets.iter().map(|&i| u64::from(start + i)).sum(); + let sum_ts: u64 = offsets.iter().map(|&i| u64::from(timestamps[i])).sum(); + BlockBucket { + avg_height: Height::from(round_half_up(sum_h, n)), + avg_timestamp: Timestamp::from(round_half_up(sum_ts, n) as u32), + offsets, + } + }) + .collect(); + + Self { + start, + end, + buckets, } - timestamps } - /// Number of windows in this range. - fn count(&self) -> usize { - (self.end - self.start).div_ceil(self.window) - } - - /// Iterate windows, yielding (avg_height, window_start, window_end) for each. - pub fn iter(&self) -> impl Iterator + '_ { - let mut pos = self.start; - std::iter::from_fn(move || { - if pos >= self.end { - return None; - } - let window_end = (pos + self.window).min(self.end); - let avg_height = Height::from((pos + window_end) / 2); - let start = pos; - pos = window_end; - Some((avg_height, start, window_end)) - }) + /// Read a height-keyed vec over this window's `[start, end)` range. + pub fn read(&self, vec: &V) -> Vec + where + V: ReadableVec, + T: VecValue, + { + vec.collect_range(self.start, self.end) } } diff --git a/crates/brk_query/src/impl/mining/difficulty_adjustments.rs b/crates/brk_query/src/impl/mining/difficulty_adjustments.rs index 69b6a585b..76eee4e49 100644 --- a/crates/brk_query/src/impl/mining/difficulty_adjustments.rs +++ b/crates/brk_query/src/impl/mining/difficulty_adjustments.rs @@ -10,10 +10,11 @@ impl Query { &self, time_period: Option, ) -> Result> { - let current_height = self.height(); - let end = current_height.to_usize(); + let end = self.height().to_usize(); + // Match mempool.space's wall-clock `time > NOW() - INTERVAL ${period}` cutoff + // by walking back through real block timestamps, not estimating via block count. let start = match time_period { - Some(tp) => end.saturating_sub(tp.block_count()), + Some(tp) => self.start_height(tp).to_usize(), None => 0, }; diff --git a/crates/brk_query/src/impl/mining/mod.rs b/crates/brk_query/src/impl/mining/mod.rs index 34c1863bf..971640827 100644 --- a/crates/brk_query/src/impl/mining/mod.rs +++ b/crates/brk_query/src/impl/mining/mod.rs @@ -7,5 +7,6 @@ mod difficulty; mod difficulty_adjustments; mod epochs; mod hashrate; +mod period_start; mod pools; mod reward_stats; diff --git a/crates/brk_query/src/impl/mining/period_start.rs b/crates/brk_query/src/impl/mining/period_start.rs new file mode 100644 index 000000000..c3ce1bb78 --- /dev/null +++ b/crates/brk_query/src/impl/mining/period_start.rs @@ -0,0 +1,14 @@ +use brk_types::{Height, TimePeriod}; + +use crate::Query; + +impl Query { + /// First block height inside `period` looking back from the tip; genesis (0) for `All`. + pub(super) fn start_height(&self, period: TimePeriod) -> Height { + self.computer() + .blocks + .lookback + .start_height(period, self.height()) + .unwrap_or_default() + } +} diff --git a/crates/brk_rpc/src/methods.rs b/crates/brk_rpc/src/methods.rs index 15b3a12f1..1a48aaead 100644 --- a/crates/brk_rpc/src/methods.rs +++ b/crates/brk_rpc/src/methods.rs @@ -2,7 +2,7 @@ use std::{thread::sleep, time::Duration}; use bitcoin::{consensus::encode, hex::FromHex}; use brk_error::{Error, Result}; -use brk_types::{Bitcoin, BlockHash, FeeRate, Height, MempoolEntryInfo, Sats, Txid, Vout}; +use brk_types::{Bitcoin, BlockHash, FeeRate, Height, MempoolEntryInfo, Sats, Timestamp, Txid, Vout}; use corepc_jsonrpc::error::Error as JsonRpcError; use corepc_types::v30::{ GetBlockCount, GetBlockHash, GetBlockHeader, GetBlockHeaderVerbose, GetBlockVerboseOne, @@ -211,6 +211,7 @@ impl Client { vsize: entry.vsize as u64, weight: entry.weight as u64, fee: Sats::from(Bitcoin::from(entry.fees.base)), + first_seen: Timestamp::from(entry.time), ancestor_count: entry.ancestor_count as u64, ancestor_size: entry.ancestor_size as u64, ancestor_fee: Sats::from(Bitcoin::from(entry.fees.ancestor)), diff --git a/crates/brk_server/src/api/blocks.rs b/crates/brk_server/src/api/blocks.rs index 004a0c150..db57d9f33 100644 --- a/crates/brk_server/src/api/blocks.rs +++ b/crates/brk_server/src/api/blocks.rs @@ -327,7 +327,7 @@ impl BlockRoutes for ApiRouter { get_with( async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State| { state - .respond_json(&headers, CacheStrategy::Tip, &uri, move |q| q.blocks(None)) + .respond_json(&headers, CacheStrategy::Tip, &uri, move |q| q.blocks(None, 10)) .await }, |op| { @@ -348,7 +348,7 @@ impl BlockRoutes for ApiRouter { headers: HeaderMap, Path(path): Path, _: Empty, State(state): State| { - state.respond_json(&headers, state.height_strategy(Version::ONE, path.height), &uri, move |q| q.blocks(Some(path.height))).await + state.respond_json(&headers, state.height_strategy(Version::ONE, path.height), &uri, move |q| q.blocks(Some(path.height), 10)).await }, |op| { op.id("get_blocks_from_height") @@ -369,14 +369,14 @@ impl BlockRoutes for ApiRouter { get_with( async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State| { state - .respond_json(&headers, CacheStrategy::Tip, &uri, move |q| q.blocks_v1(None)) + .respond_json(&headers, CacheStrategy::Tip, &uri, move |q| q.blocks_v1(None, 15)) .await }, |op| { op.id("get_blocks_v1") .blocks_tag() .summary("Recent blocks with extras") - .description("Retrieve the last 10 blocks with extended data including pool identification and fee statistics.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*") + .description("Retrieve the last 15 blocks with extended data including pool identification and fee statistics.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*") .json_response::>() .not_modified() .server_error() @@ -390,13 +390,13 @@ impl BlockRoutes for ApiRouter { headers: HeaderMap, Path(path): Path, _: Empty, State(state): State| { - state.respond_json(&headers, state.height_strategy(Version::ONE, path.height), &uri, move |q| q.blocks_v1(Some(path.height))).await + state.respond_json(&headers, state.height_strategy(Version::ONE, path.height), &uri, move |q| q.blocks_v1(Some(path.height), 15)).await }, |op| { op.id("get_blocks_v1_from_height") .blocks_tag() .summary("Blocks from height with extras") - .description("Retrieve up to 10 blocks with extended data going backwards from the given height.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*") + .description("Retrieve up to 15 blocks with extended data going backwards from the given height.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*") .json_response::>() .not_modified() .bad_request() diff --git a/crates/brk_types/src/cents.rs b/crates/brk_types/src/cents.rs index d11f4ae3d..b663c3a17 100644 --- a/crates/brk_types/src/cents.rs +++ b/crates/brk_types/src/cents.rs @@ -1,5 +1,9 @@ -use std::ops::{Add, AddAssign, Div, Mul, Sub, SubAssign}; +use std::{ + iter::Sum, + ops::{Add, AddAssign, Div, Mul, Sub, SubAssign}, +}; +use derive_more::Deref; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use vecdb::{CheckedSub, Formattable, Pco}; @@ -11,6 +15,7 @@ use super::{CentsSats, Dollars, Sats, StoredF64}; #[derive( Debug, Default, + Deref, Clone, Copy, PartialEq, @@ -187,6 +192,12 @@ impl AddAssign for Cents { } } +impl Sum for Cents { + fn sum>(iter: I) -> Self { + Self(iter.map(|c| c.0).sum()) + } +} + impl Sub for Cents { type Output = Self; #[inline] diff --git a/crates/brk_types/src/cpfp.rs b/crates/brk_types/src/cpfp.rs index d09592487..3b2513903 100644 --- a/crates/brk_types/src/cpfp.rs +++ b/crates/brk_types/src/cpfp.rs @@ -1,8 +1,30 @@ +use derive_more::Deref; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::{FeeRate, Sats, Txid, VSize, Weight}; +/// Position of a transaction inside a `CpfpCluster.txs` array. Cluster-local, +/// has no meaning outside the enclosing cluster. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, + Default, Deref, Serialize, Deserialize, JsonSchema, +)] +#[serde(transparent)] +pub struct CpfpClusterTxIndex(u32); + +impl From for CpfpClusterTxIndex { + fn from(v: u32) -> Self { + Self(v) + } +} + +impl From for u32 { + fn from(v: CpfpClusterTxIndex) -> Self { + v.0 + } +} + /// CPFP (Child Pays For Parent) information for a transaction #[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -18,12 +40,19 @@ pub struct CpfpInfo { /// Effective fee rate considering CPFP relationships (sat/vB) #[serde(skip_serializing_if = "Option::is_none")] pub effective_fee_per_vsize: Option, + /// Total signature operation count for the seed tx + #[serde(skip_serializing_if = "Option::is_none")] + pub sigops: Option, /// Transaction fee (sats) #[serde(skip_serializing_if = "Option::is_none")] pub fee: Option, /// Adjusted virtual size (accounting for sigops) #[serde(skip_serializing_if = "Option::is_none")] pub adjusted_vsize: Option, + /// Mempool cluster the seed belongs to: full tx list, SFL-linearized + /// chunks, and the seed's chunk index. Only set for unconfirmed txs. + #[serde(skip_serializing_if = "Option::is_none")] + pub cluster: Option, } /// A transaction in a CPFP relationship @@ -36,3 +65,35 @@ pub struct CpfpEntry { /// Transaction fee (sats) pub fee: Sats, } + +/// CPFP cluster output for an unconfirmed tx: the connected component +/// the seed belongs to, plus its SFL linearization. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CpfpCluster { + /// All txs in the cluster, in topological order (parents before children). + pub txs: Vec, + /// SFL-emitted chunks ordered by descending feerate. + pub chunks: Vec, + /// Index into `chunks` of the chunk containing the seed tx. + pub chunk_index: u32, +} + +/// One entry in a `CpfpCluster.txs` array. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CpfpClusterTx { + pub txid: Txid, + pub fee: Sats, + pub weight: Weight, + /// In-cluster parents of this tx. + pub parents: Vec, +} + +/// One SFL chunk inside a `CpfpCluster`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CpfpClusterChunk { + /// Txs in this chunk. + pub txs: Vec, + /// Combined feerate of the chunk (sat/vB). + pub feerate: FeeRate, +} diff --git a/crates/brk_types/src/feerate.rs b/crates/brk_types/src/feerate.rs index e5ce89df0..6369b756a 100644 --- a/crates/brk_types/src/feerate.rs +++ b/crates/brk_types/src/feerate.rs @@ -1,5 +1,6 @@ use std::{ cmp::Ordering, + iter::Sum, ops::{Add, AddAssign, Div, Mul}, }; @@ -103,6 +104,12 @@ impl AddAssign for FeeRate { } } +impl Sum for FeeRate { + fn sum>(iter: I) -> Self { + Self(iter.map(|r| r.0).sum()) + } +} + impl Div for FeeRate { type Output = Self; fn div(self, rhs: usize) -> Self::Output { diff --git a/crates/brk_types/src/mempool_entry_info.rs b/crates/brk_types/src/mempool_entry_info.rs index 017178b2f..f5937a468 100644 --- a/crates/brk_types/src/mempool_entry_info.rs +++ b/crates/brk_types/src/mempool_entry_info.rs @@ -1,4 +1,4 @@ -use crate::{Sats, Txid}; +use crate::{Sats, Timestamp, Txid}; /// Mempool entry info from Bitcoin Core's getrawmempool verbose #[derive(Debug, Clone)] @@ -7,6 +7,7 @@ pub struct MempoolEntryInfo { pub vsize: u64, pub weight: u64, pub fee: Sats, + pub first_seen: Timestamp, pub ancestor_count: u64, pub ancestor_size: u64, pub ancestor_fee: Sats, diff --git a/crates/brk_types/src/stored_u64.rs b/crates/brk_types/src/stored_u64.rs index 765e1bcb5..e4cceea74 100644 --- a/crates/brk_types/src/stored_u64.rs +++ b/crates/brk_types/src/stored_u64.rs @@ -1,4 +1,7 @@ -use std::ops::{Add, AddAssign, Div, Sub, SubAssign}; +use std::{ + iter::Sum, + ops::{Add, AddAssign, Div, Sub, SubAssign}, +}; use derive_more::Deref; use schemars::JsonSchema; @@ -99,6 +102,12 @@ impl AddAssign for StoredU64 { } } +impl Sum for StoredU64 { + fn sum>(iter: I) -> Self { + Self(iter.map(|v| v.0).sum()) + } +} + impl Sub for StoredU64 { type Output = Self; fn sub(self, rhs: Self) -> Self::Output { diff --git a/crates/brk_types/src/timestamp.rs b/crates/brk_types/src/timestamp.rs index 906dc6c6b..dc4cee3c3 100644 --- a/crates/brk_types/src/timestamp.rs +++ b/crates/brk_types/src/timestamp.rs @@ -102,6 +102,15 @@ impl From for Timestamp { } } +impl From for Timestamp { + #[inline] + fn from(value: i64) -> Self { + let value = value.max(0); + debug_assert!(value <= u32::MAX as i64); + Self(value as u32) + } +} + impl From for Timestamp { #[inline] fn from(value: jiff::Timestamp) -> Self { diff --git a/crates/brk_types/src/weight.rs b/crates/brk_types/src/weight.rs index 75f9eb0fe..f2297a72a 100644 --- a/crates/brk_types/src/weight.rs +++ b/crates/brk_types/src/weight.rs @@ -1,4 +1,7 @@ -use std::ops::{Add, AddAssign, Div, Sub, SubAssign}; +use std::{ + iter::Sum, + ops::{Add, AddAssign, Div, Sub, SubAssign}, +}; use derive_more::Deref; use schemars::JsonSchema; @@ -125,6 +128,12 @@ impl AddAssign for Weight { } } +impl Sum for Weight { + fn sum>(iter: I) -> Self { + Self(iter.map(|w| w.0).sum()) + } +} + impl Sub for Weight { type Output = Self; fn sub(self, rhs: Self) -> Self::Output { diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index bf4d76dbf..6cad0765d 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -366,6 +366,37 @@ Matches mempool.space/bitcoin-cli behavior. * * @typedef {("supply"|"realized"|"unrealized")} CostBasisValue */ +/** + * CPFP cluster output for an unconfirmed tx: the connected component + * the seed belongs to, plus its SFL linearization. + * + * @typedef {Object} CpfpCluster + * @property {CpfpClusterTx[]} txs - All txs in the cluster, in topological order (parents before children). + * @property {CpfpClusterChunk[]} chunks - SFL-emitted chunks ordered by descending feerate. + * @property {number} chunkIndex - Index into `chunks` of the chunk containing the seed tx. + */ +/** + * One SFL chunk inside a `CpfpCluster`. + * + * @typedef {Object} CpfpClusterChunk + * @property {CpfpClusterTxIndex[]} txs - Txs in this chunk. + * @property {FeeRate} feerate - Combined feerate of the chunk (sat/vB). + */ +/** + * One entry in a `CpfpCluster.txs` array. + * + * @typedef {Object} CpfpClusterTx + * @property {Txid} txid + * @property {Sats} fee + * @property {Weight} weight + * @property {CpfpClusterTxIndex[]} parents - In-cluster parents of this tx. + */ +/** + * Position of a transaction inside a `CpfpCluster.txs` array. Cluster-local, + * has no meaning outside the enclosing cluster. + * + * @typedef {number} CpfpClusterTxIndex + */ /** * A transaction in a CPFP relationship * @@ -382,8 +413,11 @@ Matches mempool.space/bitcoin-cli behavior. * @property {(CpfpEntry|null)=} bestDescendant - Best (highest fee rate) descendant, if any * @property {CpfpEntry[]} descendants - Descendant transactions in the CPFP chain * @property {(FeeRate|null)=} effectiveFeePerVsize - Effective fee rate considering CPFP relationships (sat/vB) + * @property {?number=} sigops - Total signature operation count for the seed tx * @property {(Sats|null)=} fee - Transaction fee (sats) * @property {(VSize|null)=} adjustedVsize - Adjusted virtual size (accounting for sigops) + * @property {(CpfpCluster|null)=} cluster - Mempool cluster the seed belongs to: full tx list, SFL-linearized +chunks, and the seed's chunk index. Only set for unconfirmed txs. */ /** * Range parameters with output format for API query parameters. @@ -1805,7 +1839,10 @@ class BrkClientBase { const value = await parse(res); this._memSet(url, netEtag, value); if (onValue) onValue(value); - if (cloned) _runIdle(() => browserCache.put(url, cloned)); + if (cloned && browserCache) { + const cache = browserCache; + _runIdle(() => cache.put(url, cloned)); + } return value; } catch { return memHit.value; @@ -1836,7 +1873,10 @@ class BrkClientBase { const value = await parse(res); this._memSet(url, netEtag, value); if (onValue) onValue(value); - if (cloned) _runIdle(() => browserCache.put(url, cloned)); + if (cloned && browserCache) { + const cache = browserCache; + _runIdle(() => cache.put(url, cloned)); + } return value; } catch (e) { const stale = await stalePromise; @@ -7476,171 +7516,171 @@ class BrkClient extends BrkClientBase { ]); POOL_ID_TO_POOL_NAME = /** @type {const} */ ({ - "unknown": "Unknown", - "blockfills": "BlockFills", - "ultimuspool": "ULTIMUSPOOL", - "terrapool": "Terra Pool", - "luxor": "Luxor", - "1thash": "1THash", - "btccom": "BTC.com", - "bitfarms": "Bitfarms", - "huobipool": "Huobi.pool", - "wayicn": "WAYI.CN", - "canoepool": "CanoePool", - "btctop": "BTC.TOP", - "bitcoincom": "Bitcoin.com", - "175btc": "175btc", - "gbminers": "GBMiners", - "axbt": "A-XBT", - "asicminer": "ASICMiner", - "bitminter": "BitMinter", - "bitcoinrussia": "BitcoinRussia", - "btcserv": "BTCServ", - "simplecoinus": "simplecoin.us", - "btcguild": "BTC Guild", - "eligius": "Eligius", - "ozcoin": "OzCoin", - "eclipsemc": "EclipseMC", - "maxbtc": "MaxBTC", - "triplemining": "TripleMining", - "coinlab": "CoinLab", - "50btc": "50BTC", - "ghashio": "GHash.IO", - "stminingcorp": "ST Mining Corp", - "bitparking": "Bitparking", - "mmpool": "mmpool", - "polmine": "Polmine", - "kncminer": "KnCMiner", - "bitalo": "Bitalo", - "f2pool": "F2Pool", - "hhtt": "HHTT", - "megabigpower": "MegaBigPower", - "mtred": "Mt Red", - "nmcbit": "NMCbit", - "yourbtcnet": "Yourbtc.net", - "givemecoins": "Give Me Coins", - "braiinspool": "Braiins Pool", + "aaopool": "AAO Pool", "antpool": "AntPool", - "multicoinco": "MultiCoin.co", + "arkpool": "ArkPool", + "asicminer": "ASICMiner", + "axbt": "A-XBT", + "batpool": "BATPOOL", + "bcmonster": "BCMonster", "bcpoolio": "bcpool.io", - "cointerra": "Cointerra", - "kanopool": "KanoPool", - "solock": "Solo CK", - "ckpool": "CKPool", - "nicehash": "NiceHash", + "binancepool": "Binance Pool", + "bitalo": "Bitalo", "bitclub": "BitClub", "bitcoinaffiliatenetwork": "Bitcoin Affiliate Network", - "btcc": "BTCC", - "bwpool": "BWPool", - "exxbw": "EXX&BW", - "bitsolo": "Bitsolo", - "bitfury": "BitFury", - "21inc": "21 Inc.", - "digitalbtc": "digitalBTC", - "8baochi": "8baochi", - "mybtccoinpool": "myBTCcoin Pool", - "tbdice": "TBDice", - "hashpool": "HASHPOOL", - "nexious": "Nexious", - "bravomining": "Bravo Mining", - "hotpool": "HotPool", - "okexpool": "OKExPool", - "bcmonster": "BCMonster", - "1hash": "1Hash", - "bixin": "Bixin", - "tatmaspool": "TATMAS Pool", - "viabtc": "ViaBTC", - "connectbtc": "ConnectBTC", - "batpool": "BATPOOL", - "waterhole": "Waterhole", - "dcexploration": "DCExploration", - "dcex": "DCEX", - "btpool": "BTPOOL", - "58coin": "58COIN", + "bitcoincom": "Bitcoin.com", "bitcoinindia": "Bitcoin India", - "shawnp0wers": "shawnp0wers", - "phashio": "PHash.IO", - "rigpool": "RigPool", - "haozhuzhu": "HAOZHUZHU", - "7pool": "7pool", - "miningkings": "MiningKings", - "hashbx": "HashBX", - "dpool": "DPOOL", - "rawpool": "Rawpool", - "haominer": "haominer", - "helix": "Helix", - "bitcoinukraine": "Bitcoin-Ukraine", - "poolin": "Poolin", - "secretsuperstar": "SecretSuperstar", - "tigerpoolnet": "tigerpool.net", - "sigmapoolcom": "Sigmapool.com", - "okpooltop": "okpool.top", - "hummerpool": "Hummerpool", - "tangpool": "Tangpool", - "bytepool": "BytePool", - "spiderpool": "SpiderPool", - "novablock": "NovaBlock", - "miningcity": "MiningCity", - "binancepool": "Binance Pool", - "minerium": "Minerium", - "lubiancom": "Lubian.com", - "okkong": "OKKONG", - "aaopool": "AAO Pool", - "emcdpool": "EMCDPool", - "foundryusa": "Foundry USA", - "sbicrypto": "SBI Crypto", - "arkpool": "ArkPool", - "purebtccom": "PureBTC.COM", - "marapool": "MARA Pool", - "kucoinpool": "KuCoinPool", - "entrustcharitypool": "Entrust Charity Pool", - "okminer": "OKMINER", - "titan": "Titan", - "pegapool": "PEGA Pool", - "btcnuggets": "BTC Nuggets", - "cloudhashing": "CloudHashing", - "digitalxmintsy": "digitalX Mintsy", - "telco214": "Telco 214", - "btcpoolparty": "BTC Pool Party", - "multipool": "Multipool", - "transactioncoinmining": "transactioncoinmining", - "btcdig": "BTCDig", - "trickysbtcpool": "Tricky's BTC Pool", - "btcmp": "BTCMP", - "eobot": "Eobot", - "unomp": "UNOMP", - "patels": "Patels", - "gogreenlight": "GoGreenLight", "bitcoinindiapool": "BitcoinIndia", - "ekanembtc": "EkanemBTC", + "bitcoinrussia": "BitcoinRussia", + "bitcoinukraine": "Bitcoin-Ukraine", + "bitfarms": "Bitfarms", + "bitfufupool": "BitFuFuPool", + "bitfury": "BitFury", + "bitminter": "BitMinter", + "bitparking": "Bitparking", + "bitsolo": "Bitsolo", + "bixin": "Bixin", + "blockfills": "BlockFills", + "braiinspool": "Braiins Pool", + "braiinssolo": "Braiins Solo", + "bravomining": "Bravo Mining", + "btcc": "BTCC", + "btccom": "BTC.com", + "btcdig": "BTCDig", + "btcguild": "BTC Guild", + "btclab": "BTCLab", + "btcmp": "BTCMP", + "btcnuggets": "BTC Nuggets", + "btcpoolparty": "BTC Pool Party", + "btcserv": "BTCServ", + "btctop": "BTC.TOP", + "btpool": "BTPOOL", + "bwpool": "BWPool", + "bytepool": "BytePool", "canoe": "CANOE", - "tiger": "tiger", - "1m1x": "1M1X", - "zulupool": "Zulupool", - "secpool": "SECPOOL", + "canoepool": "CanoePool", + "carbonnegative": "Carbon Negative", + "ckpool": "CKPool", + "cloudhashing": "CloudHashing", + "coinlab": "CoinLab", + "cointerra": "Cointerra", + "connectbtc": "ConnectBTC", + "dcex": "DCEX", + "dcexploration": "DCExploration", + "digitalbtc": "digitalBTC", + "digitalxmintsy": "digitalX Mintsy", + "dpool": "DPOOL", + "eclipsemc": "EclipseMC", + "eightbaochi": "8baochi", + "ekanembtc": "EkanemBTC", + "eligius": "Eligius", + "emcdpool": "EMCDPool", + "entrustcharitypool": "Entrust Charity Pool", + "eobot": "Eobot", + "est3lar": "Est3lar", + "exxbw": "EXX&BW", + "f2pool": "F2Pool", + "fiftyeightcoin": "58COIN", + "foundryusa": "Foundry USA", + "futurebitapollosolo": "FutureBit Apollo Solo", + "gbminers": "GBMiners", + "gdpool": "GDPool", + "ghashio": "GHash.IO", + "givemecoins": "Give Me Coins", + "gogreenlight": "GoGreenLight", + "haominer": "haominer", + "haozhuzhu": "HAOZHUZHU", + "hashbx": "HashBX", + "hashpool": "HASHPOOL", + "helix": "Helix", + "hhtt": "HHTT", + "hotpool": "HotPool", + "hummerpool": "Hummerpool", + "huobipool": "Huobi.pool", + "innopolistech": "Innopolis Tech", + "kanopool": "KanoPool", + "kncminer": "KnCMiner", + "kucoinpool": "KuCoinPool", + "lubiancom": "Lubian.com", + "luxor": "Luxor", + "marapool": "MARA Pool", + "maxbtc": "MaxBTC", + "maxipool": "MaxiPool", + "megabigpower": "MegaBigPower", + "minerium": "Minerium", + "miningcity": "MiningCity", + "miningdutch": "Mining-Dutch", + "miningkings": "MiningKings", + "miningsquared": "Mining Squared", + "mmpool": "mmpool", + "mtred": "Mt Red", + "multicoinco": "MultiCoin.co", + "multipool": "Multipool", + "mybtccoinpool": "myBTCcoin Pool", + "neopool": "Neopool", + "nexious": "Nexious", + "nicehash": "NiceHash", + "nmcbit": "NMCbit", + "noderunners": "Noderunners", + "novablock": "NovaBlock", "ocean": "OCEAN", + "okexpool": "OKExPool", + "okkong": "OKKONG", + "okminer": "OKMINER", + "okpooltop": "okpool.top", + "onehash": "1Hash", + "onem1x": "1M1X", + "onethash": "1THash", + "ozcoin": "OzCoin", + "parasite": "Parasite", + "patels": "Patels", + "pegapool": "PEGA Pool", + "phashio": "PHash.IO", + "phoenix": "Phoenix", + "polmine": "Polmine", + "pool175btc": "175btc", + "pool50btc": "50BTC", + "poolin": "Poolin", + "portlandhodl": "Portland.HODL", + "publicpool": "Public Pool", + "purebtccom": "PureBTC.COM", + "rawpool": "Rawpool", + "redrockpool": "RedRock Pool", + "rigpool": "RigPool", + "sbicrypto": "SBI Crypto", + "secpool": "SECPOOL", + "secretsuperstar": "SecretSuperstar", + "sevenpool": "7pool", + "shawnp0wers": "shawnp0wers", + "sigmapoolcom": "Sigmapool.com", + "simplecoinus": "simplecoin.us", + "solock": "Solo CK", + "solopool": "SoloPool.com", + "spiderpool": "SpiderPool", + "stminingcorp": "ST Mining Corp", + "tangpool": "Tangpool", + "tatmaspool": "TATMAS Pool", + "tbdice": "TBDice", + "telco214": "Telco 214", + "terrapool": "Terra Pool", + "tiger": "tiger", + "tigerpoolnet": "tigerpool.net", + "titan": "Titan", + "transactioncoinmining": "transactioncoinmining", + "trickysbtcpool": "Tricky's BTC Pool", + "triplemining": "TripleMining", + "twentyoneinc": "21 Inc.", + "ultimuspool": "ULTIMUSPOOL", + "unknown": "Unknown", + "unomp": "UNOMP", + "viabtc": "ViaBTC", + "waterhole": "Waterhole", + "wayicn": "WAYI.CN", "whitepool": "WhitePool", "wiz": "wiz", "wk057": "wk057", - "futurebitapollosolo": "FutureBit Apollo Solo", - "carbonnegative": "Carbon Negative", - "portlandhodl": "Portland.HODL", - "phoenix": "Phoenix", - "neopool": "Neopool", - "maxipool": "MaxiPool", - "bitfufupool": "BitFuFuPool", - "gdpool": "GDPool", - "miningdutch": "Mining-Dutch", - "publicpool": "Public Pool", - "miningsquared": "Mining Squared", - "innopolistech": "Innopolis Tech", - "btclab": "BTCLab", - "parasite": "Parasite", - "redrockpool": "RedRock Pool", - "est3lar": "Est3lar", - "braiinssolo": "Braiins Solo", - "solopoolcom": "SoloPool.com", - "noderunners": "Noderunners" + "yourbtcnet": "Yourbtc.net", + "zulupool": "Zulupool" }); TERM_NAMES = /** @type {const} */ ({ @@ -10739,7 +10779,7 @@ class BrkClient extends BrkClientBase { */ async getBlockTipHeight({ signal, onValue } = {}) { const path = `/api/blocks/tip/height`; - return Number(await this.getText(path, { signal, onValue })); + return Number(await this.getText(path, { signal, onValue: onValue ? (v) => onValue(Number(v)) : undefined })); } /** @@ -10843,8 +10883,8 @@ class BrkClient extends BrkClientBase { * * Endpoint: `GET /api/series/bulk` * - * @param {SeriesList} [series] - Requested series - * @param {Index} [index] - Index to query + * @param {SeriesList} series - Requested series + * @param {Index} index - Index to query * @param {RangeIndex=} [start] - Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` * @param {RangeIndex=} [end] - Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` * @param {Limit=} [limit] - Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` @@ -10922,7 +10962,7 @@ class BrkClient extends BrkClientBase { * * Endpoint: `GET /api/series/search` * - * @param {SeriesName} [q] - Search query string + * @param {SeriesName} q - Search query string * @param {Limit=} [limit] - Maximum number of results * @param {{ signal?: AbortSignal, onValue?: (value: string[]) => void }} [options] * @returns {Promise} @@ -11362,7 +11402,7 @@ class BrkClient extends BrkClientBase { /** * Recent blocks with extras * - * Retrieve the last 10 blocks with extended data including pool identification and fee statistics. + * Retrieve the last 15 blocks with extended data including pool identification and fee statistics. * * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* * @@ -11378,7 +11418,7 @@ class BrkClient extends BrkClientBase { /** * Blocks from height with extras * - * Retrieve up to 10 blocks with extended data going backwards from the given height. + * Retrieve up to 15 blocks with extended data going backwards from the given height. * * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* * @@ -11870,7 +11910,7 @@ class BrkClient extends BrkClientBase { * * Endpoint: `GET /api/v1/transaction-times` * - * @param {Txid[]} [txId[]] - Transaction IDs to look up (max 250 per request). + * @param {Txid[]} txId - Transaction IDs to look up (max 250 per request). * @param {{ signal?: AbortSignal, onValue?: (value: number[]) => void }} [options] * @returns {Promise} */ diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index 2c34a578a..efe06f109 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -102,6 +102,9 @@ CostBasisValue = Literal["supply", "realized", "unrealized"] # Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000), # log10/log50/log100/log200 (logarithmic with 10/50/100/200 buckets per decade). UrpdAggregation = Literal["raw", "lin200", "lin500", "lin1000", "log10", "log50", "log100", "log200"] +# Position of a transaction inside a `CpfpCluster.txs` array. Cluster-local, +# has no meaning outside the enclosing cluster. +CpfpClusterTxIndex = int # Virtual size in vbytes (weight / 4, rounded up). Max block vsize is ~1,000,000 vB. VSize = int # Date in YYYYMMDD format stored as u32 @@ -650,6 +653,43 @@ class CostBasisQuery(TypedDict): bucket: UrpdAggregation value: CostBasisValue +class CpfpClusterChunk(TypedDict): + """ + One SFL chunk inside a `CpfpCluster`. + + Attributes: + txs: Txs in this chunk. + feerate: Combined feerate of the chunk (sat/vB). + """ + txs: List[CpfpClusterTxIndex] + feerate: FeeRate + +class CpfpClusterTx(TypedDict): + """ + One entry in a `CpfpCluster.txs` array. + + Attributes: + parents: In-cluster parents of this tx. + """ + txid: Txid + fee: Sats + weight: Weight + parents: List[CpfpClusterTxIndex] + +class CpfpCluster(TypedDict): + """ + CPFP cluster output for an unconfirmed tx: the connected component + the seed belongs to, plus its SFL linearization. + + Attributes: + txs: All txs in the cluster, in topological order (parents before children). + chunks: SFL-emitted chunks ordered by descending feerate. + chunkIndex: Index into `chunks` of the chunk containing the seed tx. + """ + txs: List[CpfpClusterTx] + chunks: List[CpfpClusterChunk] + chunkIndex: int + class CpfpEntry(TypedDict): """ A transaction in a CPFP relationship @@ -672,15 +712,20 @@ class CpfpInfo(TypedDict): bestDescendant: Best (highest fee rate) descendant, if any descendants: Descendant transactions in the CPFP chain effectiveFeePerVsize: Effective fee rate considering CPFP relationships (sat/vB) + sigops: Total signature operation count for the seed tx fee: Transaction fee (sats) adjustedVsize: Adjusted virtual size (accounting for sigops) + cluster: Mempool cluster the seed belongs to: full tx list, SFL-linearized +chunks, and the seed's chunk index. Only set for unconfirmed txs. """ ancestors: List[CpfpEntry] bestDescendant: Union[CpfpEntry, None] descendants: List[CpfpEntry] effectiveFeePerVsize: Union[FeeRate, None] + sigops: Optional[int] fee: Union[Sats, None] adjustedVsize: Union[VSize, None] + cluster: Union[CpfpCluster, None] class DataRangeFormat(TypedDict): """ @@ -8314,7 +8359,7 @@ class BrkClient(BrkClientBase): def get_blocks_v1(self) -> List[BlockInfoV1]: """Recent blocks with extras. - Retrieve the last 10 blocks with extended data including pool identification and fee statistics. + Retrieve the last 15 blocks with extended data including pool identification and fee statistics. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* @@ -8324,7 +8369,7 @@ class BrkClient(BrkClientBase): def get_blocks_v1_from_height(self, height: Height) -> List[BlockInfoV1]: """Blocks from height with extras. - Retrieve up to 10 blocks with extended data going backwards from the given height. + Retrieve up to 15 blocks with extended data going backwards from the given height. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* diff --git a/packages/brk_client/tests/mempool_compat/fees/test_mempool_blocks.py b/packages/brk_client/tests/mempool_compat/fees/test_mempool_blocks.py index 2ce9756f7..45890514d 100644 --- a/packages/brk_client/tests/mempool_compat/fees/test_mempool_blocks.py +++ b/packages/brk_client/tests/mempool_compat/fees/test_mempool_blocks.py @@ -4,7 +4,7 @@ from _lib import assert_same_structure, show MAX_PROJECTED_BLOCKS = 8 -BRK_FEE_RANGE_LEN = 7 +FEE_RANGE_LEN = 7 def test_fees_mempool_blocks_structure(brk, mempool): @@ -36,8 +36,8 @@ def test_fees_mempool_blocks_invariants(brk): assert block["totalFees"] >= 0, f"block {i} has negative totalFees" assert block["medianFee"] > 0, f"block {i} has non-positive medianFee" fr = block["feeRange"] - assert len(fr) == BRK_FEE_RANGE_LEN, ( - f"block {i} feeRange has {len(fr)} items, expected {BRK_FEE_RANGE_LEN}" + assert len(fr) == FEE_RANGE_LEN, ( + f"block {i} feeRange has {len(fr)} items, expected {FEE_RANGE_LEN}" ) assert fr == sorted(fr), f"block {i} feeRange not ascending: {fr}" assert fr[0] <= block["medianFee"] <= fr[-1], ( diff --git a/packages/brk_client/tests/mempool_compat/mempool/test_mempool.py b/packages/brk_client/tests/mempool_compat/mempool/test_mempool.py index 5217cc363..11e087436 100644 --- a/packages/brk_client/tests/mempool_compat/mempool/test_mempool.py +++ b/packages/brk_client/tests/mempool_compat/mempool/test_mempool.py @@ -28,8 +28,10 @@ def test_mempool_info_invariants(brk): f"histogram entry {i} not a 2-element list: {entry}" ) rate, bvs = entry - assert isinstance(rate, (int, float)) and rate > 0, ( - f"non-positive rate at bin {i}: {rate}" + # Zero-rate bins are legitimate (CPFP/package-relay anchors with + # zero-fee parents); mempool.space's API returns them too. + assert isinstance(rate, (int, float)) and rate >= 0, ( + f"negative rate at bin {i}: {rate}" ) assert isinstance(bvs, int) and bvs > 0, f"non-positive vsize at bin {i}: {bvs}" rates.append(rate) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_blocks_fee_rates.py b/packages/brk_client/tests/mempool_compat/mining/test_blocks_fee_rates.py index 6944a3d20..f202da26a 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_blocks_fee_rates.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_blocks_fee_rates.py @@ -1,4 +1,10 @@ -"""GET /api/v1/mining/blocks/fee-rates/{time_period}""" +"""GET /api/v1/mining/blocks/fee-rates/{time_period} + +Note: there is no values_match test here. brk emits float bucket means; mempool +stores integer per-block percentiles, averages, then casts to INT. Beyond +rounding, the per-block max (avgFee_100) also drifts by several sat/vB, +indicating a real methodology difference (effective fee-rate definition, +RBF/ancestor-fee handling) that no test tolerance should hide.""" import pytest diff --git a/packages/brk_client/tests/mempool_compat/mining/test_blocks_fees.py b/packages/brk_client/tests/mempool_compat/mining/test_blocks_fees.py index b73629c68..10969a376 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_blocks_fees.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_blocks_fees.py @@ -45,3 +45,30 @@ def test_mining_blocks_fees_malformed(brk, bad): assert exc_info.value.status == 400, ( f"expected status=400 for {bad!r}, got {exc_info.value.status}" ) + + +@pytest.mark.parametrize("period", PERIODS) +def test_mining_blocks_fees_values_match(brk, mempool, period): + """For shared buckets (keyed by timestamp), avgHeight and avgFees must equal mempool.space. + USD is sourced from different price oracles and is intentionally not compared.""" + path = f"/api/v1/mining/blocks/fees/{period}" + b = brk.get_block_fees(period) + m = mempool.get_json(path) + show("GET", path, summary(b), summary(m)) + + m_by_ts = {e["timestamp"]: e for e in m} + matched = 0 + for be in b: + me = m_by_ts.get(be["timestamp"]) + if me is None: + continue + matched += 1 + assert be["avgHeight"] == me["avgHeight"], ( + f"avgHeight drift at timestamp {be['timestamp']}: " + f"brk={be['avgHeight']} mempool={me['avgHeight']}" + ) + assert be["avgFees"] == me["avgFees"], ( + f"avgFees mismatch at timestamp {be['timestamp']}: " + f"brk={be['avgFees']} mempool={me['avgFees']}" + ) + assert matched > 0, "no overlapping bucket timestamps between brk and mempool" diff --git a/packages/brk_client/tests/mempool_compat/mining/test_blocks_rewards.py b/packages/brk_client/tests/mempool_compat/mining/test_blocks_rewards.py index 2805765aa..2edc2f048 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_blocks_rewards.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_blocks_rewards.py @@ -45,3 +45,29 @@ def test_mining_blocks_rewards_malformed(brk, bad): assert exc_info.value.status == 400, ( f"expected status=400 for {bad!r}, got {exc_info.value.status}" ) + + +@pytest.mark.parametrize("period", PERIODS) +def test_mining_blocks_rewards_values_match(brk, mempool, period): + """For shared buckets (keyed by timestamp), avgHeight and avgRewards must equal mempool.space.""" + path = f"/api/v1/mining/blocks/rewards/{period}" + b = brk.get_block_rewards(period) + m = mempool.get_json(path) + show("GET", path, summary(b), summary(m)) + + m_by_ts = {e["timestamp"]: e for e in m} + matched = 0 + for be in b: + me = m_by_ts.get(be["timestamp"]) + if me is None: + continue + matched += 1 + assert be["avgHeight"] == me["avgHeight"], ( + f"avgHeight drift at timestamp {be['timestamp']}: " + f"brk={be['avgHeight']} mempool={me['avgHeight']}" + ) + assert be["avgRewards"] == me["avgRewards"], ( + f"avgRewards mismatch at timestamp {be['timestamp']}: " + f"brk={be['avgRewards']} mempool={me['avgRewards']}" + ) + assert matched > 0, "no overlapping bucket timestamps between brk and mempool" diff --git a/packages/brk_client/tests/mempool_compat/mining/test_blocks_sizes_weights.py b/packages/brk_client/tests/mempool_compat/mining/test_blocks_sizes_weights.py index 1ba0f3f41..7f1ffe925 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_blocks_sizes_weights.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_blocks_sizes_weights.py @@ -59,3 +59,34 @@ def test_mining_blocks_sizes_weights_malformed(brk, bad): assert exc_info.value.status == 400, ( f"expected status=400 for {bad!r}, got {exc_info.value.status}" ) + + +@pytest.mark.parametrize("period", PERIODS) +def test_mining_blocks_sizes_weights_values_match(brk, mempool, period): + """For shared buckets (keyed by timestamp), avgSize and avgWeight must equal mempool.space.""" + path = f"/api/v1/mining/blocks/sizes-weights/{period}" + b = brk.get_block_sizes_weights(period) + m = mempool.get_json(path) + show("GET", path, summary(b), summary(m)) + + sizes_by_ts = {e["timestamp"]: e for e in m["sizes"]} + weights_by_ts = {e["timestamp"]: e for e in m["weights"]} + + matched = 0 + for s, w in zip(b["sizes"], b["weights"]): + ts = s["timestamp"] + ms = sizes_by_ts.get(ts) + mw = weights_by_ts.get(ts) + if ms is None or mw is None: + continue + matched += 1 + assert s["avgHeight"] == ms["avgHeight"], ( + f"size avgHeight drift at timestamp {ts}: brk={s['avgHeight']} mempool={ms['avgHeight']}" + ) + assert s["avgSize"] == ms["avgSize"], ( + f"avgSize mismatch at timestamp {ts}: brk={s['avgSize']} mempool={ms['avgSize']}" + ) + assert w["avgWeight"] == mw["avgWeight"], ( + f"avgWeight mismatch at timestamp {ts}: brk={w['avgWeight']} mempool={mw['avgWeight']}" + ) + assert matched > 0, "no overlapping bucket timestamps between brk and mempool" diff --git a/packages/brk_client/tests/mempool_compat/mining/test_difficulty_adjustments.py b/packages/brk_client/tests/mempool_compat/mining/test_difficulty_adjustments.py index 1ab47ea98..9cab79572 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_difficulty_adjustments.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_difficulty_adjustments.py @@ -52,3 +52,30 @@ def test_mining_difficulty_adjustments_malformed(brk, bad): assert exc_info.value.status == 400, ( f"expected status=400 for {bad!r}, got {exc_info.value.status}" ) + + +# `all`: mempool.space's `difficulty_adjustments` table begins from when their tracker +# started, not genesis, so series length and earliest entries diverge by construction. +@pytest.mark.parametrize("period", [p for p in PERIODS if p != "all"]) +def test_mining_difficulty_adjustments_values_match(brk, mempool, period): + """For every bounded period, every retarget entry must match mempool.space: + same height, same timestamp, and difficulty/change-ratio within float tolerance.""" + path = f"/api/v1/mining/difficulty-adjustments/{period}" + b = brk.get_difficulty_adjustments_by_period(period) + m = mempool.get_json(path) + show("GET", path, summary(b), summary(m)) + assert len(b) == len(m), f"length mismatch: brk={len(b)} mempool={len(m)}" + + for be, me in zip(b, m): + bt, bh, bd, br = be + mt, mh, md, mr = me + assert bh == mh, f"height mismatch at retarget: brk={bh} mempool={mh}" + assert bt == mt, f"timestamp mismatch at height {bh}: brk={bt} mempool={mt}" + # mempool.space serializes difficulty/change_ratio with limited precision, + # so only require parity within mempool.space's ~6-decimal rounding window. + assert abs(bd - md) / max(md, 1.0) < 1e-5, ( + f"difficulty drift at height {bh}: brk={bd} mempool={md}" + ) + assert abs(br - mr) < 1e-5, ( + f"change_ratio drift at height {bh}: brk={br} mempool={mr}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_reward_stats.py b/packages/brk_client/tests/mempool_compat/mining/test_reward_stats.py index 2f71cb2f9..b722c8ae8 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_reward_stats.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_reward_stats.py @@ -1,10 +1,15 @@ -"""GET /api/v1/mining/reward-stats/{block_count}""" +"""GET /api/v1/mining/reward-stats/{block_count} + +Note: there is no values_match test here. mempool.space's reward-stats endpoint +serves results anchored to a cached/precomputed block that lags real-time tip +non-deterministically across counts, so any direct numeric comparison is flaky. +The invariants test below covers structural correctness.""" import pytest from brk_client import BrkError -from _lib import assert_same_structure, assert_same_values, show +from _lib import assert_same_structure, show COUNTS = [1, 10, 100, 500, 1000] @@ -20,16 +25,6 @@ def test_mining_reward_stats_structure(brk, mempool, count): assert_same_structure(b, m) -@pytest.mark.parametrize("count", [100, 1000]) -def test_mining_reward_stats_values_match(brk, mempool, count): - """brk and mempool must agree exactly on aggregated stats.""" - path = f"/api/v1/mining/reward-stats/{count}" - b = brk.get_reward_stats(count) - m = mempool.get_json(path) - show("GET", path, b, m) - assert_same_values(b, m) - - def test_mining_reward_stats_invariants(brk): """Range alignment, reward >= fee, totalTx >= block count (count=1000).""" count = 1000 diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_transaction_times.py b/packages/brk_client/tests/mempool_compat/transactions/test_transaction_times.py index 307629ceb..69bfc246d 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_transaction_times.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_transaction_times.py @@ -1,5 +1,7 @@ """GET /api/v1/transaction-times?txId[]=...""" +import time + import pytest from brk_client import BrkError @@ -65,3 +67,39 @@ def test_transaction_times_malformed_short(brk): with pytest.raises(BrkError) as exc_info: brk.get_transaction_times(["abc"]) assert exc_info.value.status == 400 + + +def test_transaction_times_mempool_unconfirmed(brk, mempool): + """Unconfirmed mempool tx: first-seen timestamp must be a plausible + Unix-second value (post-genesis, not in the future). Cross-observer + agreement is not asserted: each server records when *it* first saw + the tx, and rebroadcasts/restarts can put two independent observers + days or weeks apart on the same txid.""" + txids = mempool.get_json("/api/mempool/txids") + if not txids: + pytest.skip("mempool.space mempool currently empty") + + GENESIS_TS = 1231006505 + now = int(time.time()) + skew = 5 * 60 + + for txid in txids[:25]: + try: + b = brk.get_transaction_times([txid]) + except BrkError: + continue + if not b or b[0] == 0: + continue + try: + m = mempool.get_json( + "/api/v1/transaction-times", params=[("txId[]", txid)] + ) + except Exception: + continue + if not m or m[0] == 0: + continue + show("GET", f"/api/v1/transaction-times?txId[]={txid[:16]}...", b, m) + assert GENESIS_TS <= b[0] <= now + skew, f"brk first-seen out of plausible range: {b[0]}" + assert GENESIS_TS <= m[0] <= now + skew, f"mempool first-seen out of plausible range: {m[0]}" + return + pytest.skip("no shared unconfirmed tx between brk and mempool.space") diff --git a/website/scripts/explorer/address.js b/website/scripts/explorer/address.js index f9790666f..ea4a44f7b 100644 --- a/website/scripts/explorer/address.js +++ b/website/scripts/explorer/address.js @@ -113,7 +113,9 @@ export async function update(address, signal) { if (currentAddr !== address) return; loading = true; try { - const txs = await brk.getAddressTxs(address, afterTxid, { signal }); + const txs = afterTxid + ? await brk.getAddressConfirmedTxsAfter(address, afterTxid, { signal }) + : await brk.getAddressTxs(address, { signal }); if (currentAddr !== address) return; for (const tx of txs) txSection.append(renderTx(tx)); pageIndex++;