global: fixes

This commit is contained in:
nym21
2026-05-03 12:44:18 +02:00
parent 9cb5f2c880
commit 4663d13194
46 changed files with 1058 additions and 544 deletions

4
Cargo.lock generated
View File

@@ -773,9 +773,9 @@ dependencies = [
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]] [[package]]
name = "chrono" name = "chrono"

View File

@@ -10,7 +10,7 @@ use brk_cohort::{
OVER_AMOUNT_NAMES, PROFIT_NAMES, PROFITABILITY_RANGE_NAMES, SPENDABLE_TYPE_NAMES, TERM_NAMES, OVER_AMOUNT_NAMES, PROFIT_NAMES, PROFITABILITY_RANGE_NAMES, SPENDABLE_TYPE_NAMES, TERM_NAMES,
UNDER_AGE_NAMES, UNDER_AMOUNT_NAMES, UNDER_AGE_NAMES, UNDER_AMOUNT_NAMES,
}; };
use brk_types::{Index, PoolSlug, pools}; use brk_types::{Index, pools};
use serde::Serialize; use serde::Serialize;
use serde_json::Value; use serde_json::Value;
@@ -20,7 +20,7 @@ use crate::{VERSION, to_camel_case};
pub struct ClientConstants { pub struct ClientConstants {
pub version: String, pub version: String,
pub indexes: Vec<&'static str>, pub indexes: Vec<&'static str>,
pub pool_map: BTreeMap<PoolSlug, &'static str>, pub pool_map: BTreeMap<String, &'static str>,
} }
impl ClientConstants { impl ClientConstants {
@@ -32,8 +32,10 @@ impl ClientConstants {
let pools = pools(); let pools = pools();
let mut sorted_pools: Vec<_> = pools.iter().collect(); let mut sorted_pools: Vec<_> = pools.iter().collect();
sorted_pools.sort_by_key(|p| p.name.to_lowercase()); sorted_pools.sort_by_key(|p| p.name.to_lowercase());
let pool_map: BTreeMap<PoolSlug, &'static str> = let pool_map: BTreeMap<String, &'static str> = sorted_pools
sorted_pools.iter().map(|p| (p.slug(), p.name)).collect(); .iter()
.map(|p| (p.slug().to_string(), p.name))
.collect();
Self { Self {
version: format!("v{}", VERSION), version: format!("v{}", VERSION),

View File

@@ -36,10 +36,16 @@ fn generate_get_method(output: &mut String, endpoint: &Endpoint) {
let optional = if param.required { "" } else { "=" }; let optional = if param.required { "" } else { "=" };
let desc = format_param_desc(param.description.as_deref()); let desc = format_param_desc(param.description.as_deref());
let ty = jsdoc_normalize(&param.param_type); let ty = jsdoc_normalize(&param.param_type);
let ident = sanitize_ident(&param.name);
let name_decl = if param.required {
ident
} else {
format!("[{}]", ident)
};
writeln!( writeln!(
output, output,
" * @param {{{}{}}} [{}]{}", " * @param {{{}{}}} {}{}",
ty, optional, param.name, desc ty, optional, name_decl, desc
) )
.unwrap(); .unwrap();
} }
@@ -67,7 +73,7 @@ fn generate_get_method(output: &mut String, endpoint: &Endpoint) {
} else if endpoint.returns_json() { } else if endpoint.returns_json() {
"this.getJson(path, { signal, onValue })".to_string() "this.getJson(path, { signal, onValue })".to_string()
} else if endpoint.response_kind.text_is_numeric() { } 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 { } else {
"this.getText(path, { signal, onValue })".to_string() "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(&param.name); let ident = sanitize_ident(&param.name);
let is_array = param.param_type.ends_with("[]"); let is_array = param.param_type.ends_with("[]");
if is_array { if is_array {
writeln!( if param.required {
output, writeln!(
" for (const _v of {}) params.append('{}', String(_v));", output,
ident, param.name " for (const _v of {}) params.append('{}', String(_v));",
) ident, param.name
.unwrap(); )
.unwrap();
} else {
writeln!(
output,
" if ({}) for (const _v of {}) params.append('{}', String(_v));",
ident, ident, param.name
)
.unwrap();
}
} else if param.required { } else if param.required {
writeln!( writeln!(
output, output,

View File

@@ -496,7 +496,10 @@ class BrkClientBase {{
const value = await parse(res); const value = await parse(res);
this._memSet(url, netEtag, value); this._memSet(url, netEtag, value);
if (onValue) onValue(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; return value;
}} catch {{ }} catch {{
return memHit.value; return memHit.value;
@@ -527,7 +530,10 @@ class BrkClientBase {{
const value = await parse(res); const value = await parse(res);
this._memSet(url, netEtag, value); this._memSet(url, netEtag, value);
if (onValue) onValue(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; return value;
}} catch (e) {{ }} catch (e) {{
const stale = await stalePromise; const stale = await stalePromise;

View File

@@ -126,7 +126,7 @@ fn generate_get_method(output: &mut String, endpoint: &Endpoint) {
) )
.unwrap(); .unwrap();
} else { } else {
write_query_assembly(output, endpoint, &path, &index_arg); write_query_assembly(output, endpoint, &path, index_arg);
if endpoint.supports_csv { if endpoint.supports_csv {
writeln!(output, " if format == Some(Format::CSV) {{").unwrap(); writeln!(output, " if format == Some(Format::CSV) {{").unwrap();
@@ -190,7 +190,7 @@ fn generate_post_method(output: &mut String, endpoint: &Endpoint) {
) )
.unwrap(); .unwrap();
} else { } else {
write_query_assembly(output, endpoint, &path, &index_arg); write_query_assembly(output, endpoint, &path, index_arg);
writeln!( writeln!(
output, output,
" self.base.{}(&path, {})", " self.base.{}(&path, {})",

View File

@@ -25,6 +25,7 @@ pub fn generate_rust_client(
writeln!(output, "// Auto-generated BRK Rust client").unwrap(); writeln!(output, "// Auto-generated BRK Rust client").unwrap();
writeln!(output, "// Do not edit manually\n").unwrap(); writeln!(output, "// Do not edit manually\n").unwrap();
writeln!(output, "#![allow(non_camel_case_types)]").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(dead_code)]").unwrap();
writeln!(output, "#![allow(unused_variables)]").unwrap(); writeln!(output, "#![allow(unused_variables)]").unwrap();
writeln!(output, "#![allow(clippy::useless_format)]").unwrap(); writeln!(output, "#![allow(clippy::useless_format)]").unwrap();

View File

@@ -2,6 +2,7 @@
// Do not edit manually // Do not edit manually
#![allow(non_camel_case_types)] #![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(dead_code)] #![allow(dead_code)]
#![allow(unused_variables)] #![allow(unused_variables)]
#![allow(clippy::useless_format)] #![allow(clippy::useless_format)]
@@ -9604,7 +9605,7 @@ impl BrkClient {
/// Recent blocks with extras /// 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)* /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*
/// ///
@@ -9615,7 +9616,7 @@ impl BrkClient {
/// Blocks from height with extras /// 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)* /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*
/// ///

View File

@@ -1,6 +1,6 @@
use brk_error::Result; use brk_error::Result;
use brk_traversable::Traversable; use brk_traversable::Traversable;
use brk_types::{Height, Indexes, Timestamp, Version}; use brk_types::{Height, Indexes, TimePeriod, Timestamp, Version};
use vecdb::{ use vecdb::{
AnyVec, CachedVec, Cursor, Database, EagerVec, Exit, ImportableVec, PcoVec, ReadableVec, Rw, AnyVec, CachedVec, Cursor, Database, EagerVec, Exit, ImportableVec, PcoVec, ReadableVec, Rw,
StorageMode, VecIndex, StorageMode, VecIndex,
@@ -58,6 +58,26 @@ pub struct Vecs<M: StorageMode = Rw> {
pub _26y: M::Stored<EagerVec<PcoVec<Height, Height>>>, // 9490d pub _26y: M::Stored<EagerVec<PcoVec<Height, Height>>>, // 9490d
} }
impl<M: StorageMode> Vecs<M> {
/// 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<Height> {
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 { impl Vecs {
pub(crate) fn forced_import(db: &Database, version: Version) -> Result<Self> { pub(crate) fn forced_import(db: &Database, version: Version) -> Result<Self> {
let _1h = ImportableVec::forced_import(db, "height_1h_ago", version)?; let _1h = ImportableVec::forced_import(db, "height_1h_ago", version)?;

View File

@@ -6,9 +6,17 @@
//! Confirmed-tx CPFP (the same-block connected component on the //! Confirmed-tx CPFP (the same-block connected component on the
//! chain) lives in `brk_query`, since it reads indexer/computer vecs. //! chain) lives in `brk_query`, since it reads indexer/computer vecs.
use brk_types::{CpfpEntry, CpfpInfo, FeeRate, Sats, TxidPrefix, VSize, Weight}; use brk_types::{
use rustc_hash::FxHashSet; 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}; use crate::{Mempool, TxEntry};
/// Cap matches Bitcoin Core's default mempool ancestor/descendant /// Cap matches Bitcoin Core's default mempool ancestor/descendant
@@ -24,19 +32,16 @@ impl Mempool {
pub fn cpfp_info(&self, prefix: &TxidPrefix) -> Option<CpfpInfo> { pub fn cpfp_info(&self, prefix: &TxidPrefix) -> Option<CpfpInfo> {
let snapshot = self.snapshot(); let snapshot = self.snapshot();
let entries = self.entries(); let entries = self.entries();
let txs = self.txs();
let seed_idx = entries.idx_of(prefix)?; let seed_idx = entries.idx_of(prefix)?;
let seed = entries.slot(seed_idx)?; let seed = entries.slot(seed_idx)?;
let mut sum_fee = u64::from(seed.fee); let mut ancestor_idxs: Vec<TxIndex> = Vec::new();
let mut sum_vsize = u64::from(seed.vsize); let mut descendant_idxs: Vec<TxIndex> = Vec::new();
let mut ancestors: Vec<CpfpEntry> = Vec::new(); let mut ancestors: Vec<CpfpEntry> = Vec::new();
let mut descendants: Vec<CpfpEntry> = Vec::new(); let mut descendants: Vec<CpfpEntry> = Vec::new();
if let Some(seed_block) = snapshot.block_of(seed_idx) { 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<TxidPrefix> = FxHashSet::default(); let mut visited: FxHashSet<TxidPrefix> = FxHashSet::default();
visited.insert(*prefix); visited.insert(*prefix);
let mut stack: Vec<TxidPrefix> = seed.depends.iter().copied().collect(); let mut stack: Vec<TxidPrefix> = seed.depends.iter().copied().collect();
@@ -52,17 +57,11 @@ impl Mempool {
continue; continue;
} }
let Some(anc) = entries.slot(idx) else { continue }; let Some(anc) = entries.slot(idx) else { continue };
sum_fee += u64::from(anc.fee); ancestor_idxs.push(idx);
sum_vsize += u64::from(anc.vsize);
ancestors.push(to_entry(anc)); ancestors.push(to_entry(anc));
stack.extend(anc.depends.iter().copied()); 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<TxidPrefix> = FxHashSet::default(); let mut desc_set: FxHashSet<TxidPrefix> = FxHashSet::default();
desc_set.insert(*prefix); desc_set.insert(*prefix);
for &i in &snapshot.blocks[seed_block.as_usize()] { for &i in &snapshot.blocks[seed_block.as_usize()] {
@@ -74,8 +73,7 @@ impl Mempool {
continue; continue;
} }
desc_set.insert(e.txid_prefix()); desc_set.insert(e.txid_prefix());
sum_fee += u64::from(e.fee); descendant_idxs.push(i);
sum_vsize += u64::from(e.vsize);
descendants.push(to_entry(e)); descendants.push(to_entry(e));
} }
} }
@@ -85,16 +83,39 @@ impl Mempool {
.max_by_key(|e| FeeRate::from((e.fee, e.weight))) .max_by_key(|e| FeeRate::from((e.fee, e.weight)))
.cloned(); .cloned();
let package_rate = FeeRate::from((Sats::from(sum_fee), VSize::from(sum_vsize))); let sigops = txs.get(&seed.txid).map(|tx| {
let effective = seed.fee_rate().max(package_rate); // 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 { Some(CpfpInfo {
ancestors, ancestors,
best_descendant, best_descendant,
descendants, descendants,
effective_fee_per_vsize: Some(effective), effective_fee_per_vsize: Some(effective),
sigops,
fee: Some(seed.fee), 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, 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<CpfpCluster> {
let mut ordered: Vec<TxIndex> = 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<TxidPrefix, LocalIdx> = pool
.iter()
.enumerate()
.map(|(i, e)| (e.txid_prefix(), i as LocalIdx))
.collect();
let mut children_of: Vec<SmallVec<[LocalIdx; 2]>> = vec![SmallVec::new(); pool.len()];
let parents_of: Vec<SmallVec<[LocalIdx; 2]>> = 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<ClusterNode> = 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<usize> = (0..pool.len()).collect();
local_to_topo.sort_unstable_by_key(|&i| cluster.topo_rank[i]);
let topo_to_local: Vec<usize> = {
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<CpfpClusterTx> = local_to_topo
.iter()
.map(|&local| {
let e = pool[local];
let parents: Vec<CpfpClusterTxIndex> = 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<CpfpClusterChunk> = raw_chunks
.iter()
.map(|chunk| {
let mut chunk_txs: Vec<CpfpClusterTxIndex> = 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,
})
}

View File

@@ -4,13 +4,13 @@
//! prevouts against `known` or `parent_raws`, build a full //! prevouts against `known` or `parent_raws`, build a full
//! `Transaction` + `Entry`. //! `Transaction` + `Entry`.
//! - **Revived** - tx in the graveyard. Rebuild the `Entry` only //! - **Revived** - tx in the graveyard. Rebuild the `Entry` only
//! (preserving `first_seen`, `rbf`, `size`). The Applier exhumes //! (preserving `rbf`, `size`). The Applier exhumes the cached tx
//! the cached tx body. No raw decoding. //! body. No raw decoding.
use std::mem; use std::mem;
use brk_rpc::RawTx; 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 rustc_hash::FxHashMap;
use crate::{TxTombstone, stores::TxStore}; use crate::{TxTombstone, stores::TxStore};
@@ -35,7 +35,7 @@ impl TxAddition {
let total_size = raw.hex.len() / 2; let total_size = raw.hex.len() / 2;
let rbf = raw.tx.input.iter().any(|i| i.sequence.is_rbf()); 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 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 } Self::Fresh { tx, entry }
} }
@@ -68,7 +68,7 @@ impl TxAddition {
} }
pub(super) fn revived(info: &MempoolEntryInfo, tomb: &TxTombstone) -> Self { 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 } Self::Revived { entry }
} }

View File

@@ -26,14 +26,14 @@ pub struct TxEntry {
} }
impl 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 { Self {
txid: info.txid.clone(), txid: info.txid.clone(),
fee: info.fee, fee: info.fee,
vsize: VSize::from(info.vsize), vsize: VSize::from(info.vsize),
size, size,
depends: info.depends.iter().map(TxidPrefix::from).collect(), depends: info.depends.iter().map(TxidPrefix::from).collect(),
first_seen, first_seen: info.first_seen,
rbf, rbf,
} }
} }

View File

@@ -250,7 +250,7 @@ impl Query {
(first_seen, txid) (first_seen, txid)
}) })
.collect(); .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(); let txs = mempool.txs();
Ok(ordered Ok(ordered
.into_iter() .into_iter()

View File

@@ -11,62 +11,76 @@ use vecdb::{AnyVec, ReadableVec, VecIndex};
use crate::Query; use crate::Query;
const DEFAULT_BLOCK_COUNT: u32 = 10;
const DEFAULT_V1_BLOCK_COUNT: u32 = 15;
const HEADER_SIZE: usize = 80; const HEADER_SIZE: usize = 80;
impl Query { impl Query {
/// Block by hash. Unknown hash → 404 via `height_by_hash`.
pub fn block(&self, hash: &BlockHash) -> Result<BlockInfo> { pub fn block(&self, hash: &BlockHash) -> Result<BlockInfo> {
let height = self.height_by_hash(hash)?; let height = self.height_by_hash(hash)?;
self.block_by_height(height) self.block_by_height(height)
} }
/// Block by height. Height > tip → `OutOfRange`.
pub fn block_by_height(&self, height: Height) -> Result<BlockInfo> { pub fn block_by_height(&self, height: Height) -> Result<BlockInfo> {
let max_height = self.indexed_height(); if height > self.tip_height() {
if height > max_height {
return Err(Error::OutOfRange("Block height out of range".into())); 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() .pop()
.ok_or(Error::NotFound("Block not found".into())) .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<BlockInfoV1> { pub fn block_by_height_v1(&self, height: Height) -> Result<BlockInfoV1> {
let max_height = self.height(); if height > self.height() {
if height > max_height {
return Err(Error::OutOfRange("Block height out of range".into())); 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() .pop()
.ok_or(Error::NotFound("Block not found".into())) .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<String> { pub fn block_header_hex(&self, hash: &BlockHash) -> Result<String> {
let height = self.height_by_hash(hash)?; let height = self.height_by_hash(hash)?;
let header = self.read_block_header(height)?; let header = self.read_block_header(height)?;
Ok(bitcoin::consensus::encode::serialize_hex(&header)) 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<BlockHash> { pub fn block_hash_by_height(&self, height: Height) -> Result<BlockHash> {
let max_height = self.indexed_height(); if height > self.tip_height() {
if height > max_height {
return Err(Error::OutOfRange("Block height out of range".into())); return Err(Error::OutOfRange("Block height out of range".into()));
} }
self.indexer().vecs.blocks.blockhash.get(height).data() self.indexer().vecs.blocks.blockhash.get(height).data()
} }
pub fn blocks(&self, start_height: Option<Height>) -> Result<Vec<BlockInfo>> { /// Most recent `count` blocks ending at `start_height` (default tip),
let (begin, end) = self.resolve_block_range(start_height, DEFAULT_BLOCK_COUNT); /// returned in descending-height order.
pub fn blocks(&self, start_height: Option<Height>, count: u32) -> Result<Vec<BlockInfo>> {
let (begin, end) = self.resolve_block_range(start_height, count);
self.blocks_range(begin, end) self.blocks_range(begin, end)
} }
pub fn blocks_v1(&self, start_height: Option<Height>) -> Result<Vec<BlockInfoV1>> { /// V1 most recent `count` blocks with extras ending at `start_height`
let (begin, end) = self.resolve_block_range(start_height, DEFAULT_V1_BLOCK_COUNT); /// (default tip), returned in descending-height order.
pub fn blocks_v1(&self, start_height: Option<Height>, count: u32) -> Result<Vec<BlockInfoV1>> {
let (begin, end) = self.resolve_block_range(start_height, count);
self.blocks_v1_range(begin, end) self.blocks_v1_range(begin, end)
} }
// === Range queries (bulk reads) === // === 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<Vec<BlockInfo>> { fn blocks_range(&self, begin: usize, end: usize) -> Result<Vec<BlockInfo>> {
if begin >= end { if begin >= end {
return Ok(Vec::new()); return Ok(Vec::new());
@@ -75,6 +89,7 @@ impl Query {
let indexer = self.indexer(); let indexer = self.indexer();
let computer = self.computer(); let computer = self.computer();
let reader = self.reader(); let reader = self.reader();
let count = end - begin;
// Bulk read all indexed data // Bulk read all indexed data
let blockhashes = indexer.vecs.blocks.blockhash.collect_range_at(begin, end); 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 sizes = indexer.vecs.blocks.total.collect_range_at(begin, end);
let weights = indexer.vecs.blocks.weight.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); 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 // Bulk read tx indexes for tx_count
let max_height = self.indexed_height(); let max_height = self.indexed_height();
@@ -96,6 +120,9 @@ impl Query {
.transactions .transactions
.first_tx_index .first_tx_index
.collect_range_at(begin, tx_index_end); .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(); let total_txs = computer.indexes.tx_index.identity.len();
// Bulk read median time window // Bulk read median time window
@@ -105,8 +132,10 @@ impl Query {
.blocks .blocks
.timestamp .timestamp
.collect_range_at(median_start, end); .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); let mut blocks = Vec::with_capacity(count);
for i in (0..count).rev() { for i in (0..count).rev() {
@@ -431,6 +460,10 @@ impl Query {
// === Helper methods === // === 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<Height> { pub fn height_by_hash(&self, hash: &BlockHash) -> Result<Height> {
let indexer = self.indexer(); let indexer = self.indexer();
let prefix = BlockHashPrefix::from(hash); let prefix = BlockHashPrefix::from(hash);

View File

@@ -1,6 +1,6 @@
use brk_error::{Error, OptionData, Result}; use brk_error::{Error, OptionData, Result};
use brk_types::{BlockHash, Height}; use brk_types::{BlockHash, Height};
use vecdb::{AnyVec, ReadableVec}; use vecdb::ReadableVec;
use crate::Query; use crate::Query;
@@ -11,19 +11,17 @@ impl Query {
} }
fn block_raw_by_height(&self, height: Height) -> Result<Vec<u8>> { fn block_raw_by_height(&self, height: Height) -> Result<Vec<u8>> {
let indexer = self.indexer(); let max_height = self.tip_height();
let reader = self.reader();
let max_height = Height::from(indexer.vecs.blocks.blockhash.len().saturating_sub(1));
if height > max_height { if height > max_height {
return Err(Error::OutOfRange(format!( return Err(Error::OutOfRange(format!(
"Block height {height} out of range (tip {max_height})" "Block height {height} out of range (tip {max_height})"
))); )));
} }
let indexer = self.indexer();
let position = indexer.vecs.blocks.position.collect_one(height).data()?; let position = indexer.vecs.blocks.position.collect_one(height).data()?;
let size = indexer.vecs.blocks.total.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)
} }
} }

View File

@@ -1,6 +1,5 @@
use brk_error::{OptionData, Result}; use brk_error::{OptionData, Result};
use brk_types::{BlockHash, BlockStatus, Height}; use brk_types::{BlockHash, BlockStatus, Height};
use vecdb::AnyVec;
use crate::Query; use crate::Query;
@@ -11,9 +10,7 @@ impl Query {
} }
fn block_status_by_height(&self, height: Height) -> Result<BlockStatus> { fn block_status_by_height(&self, height: Height) -> Result<BlockStatus> {
let indexer = self.indexer(); let max_height = self.tip_height();
let max_height = Height::from(indexer.vecs.blocks.blockhash.len().saturating_sub(1));
if height > max_height { if height > max_height {
return Ok(BlockStatus::not_in_best_chain()); return Ok(BlockStatus::not_in_best_chain());
@@ -21,7 +18,7 @@ impl Query {
let next_best = if height < max_height { let next_best = if height < max_height {
Some( Some(
indexer self.indexer()
.vecs .vecs
.blocks .blocks
.blockhash .blockhash

View File

@@ -1,27 +1,37 @@
use brk_error::{Error, OptionData, Result}; use brk_error::{Error, OptionData, Result};
use brk_types::{BlockTimestamp, Date, Day1, Height, Timestamp}; use brk_types::{BlockTimestamp, Date, Day1, Height, Timestamp};
use jiff::Timestamp as JiffTimestamp; use jiff::Timestamp as JiffTimestamp;
use vecdb::ReadableVec; use vecdb::{AnyVec, ReadableVec};
use crate::Query; 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 { 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<BlockTimestamp> { pub fn block_by_timestamp(&self, timestamp: Timestamp) -> Result<BlockTimestamp> {
let indexer = self.indexer(); let indexer = self.indexer();
let computer = self.computer(); let computer = self.computer();
let max_height = self.indexed_height(); if indexer.vecs.blocks.blockhash.len() == 0 {
let max_height_usize: usize = max_height.into();
if max_height_usize == 0 {
return Err(Error::NotFound("No blocks indexed".into())); return Err(Error::NotFound("No blocks indexed".into()));
} }
let tip: usize = self.tip_height().into();
let target = timestamp; let target = timestamp;
let date = Date::from(target); let date = Date::from(target);
let day1 = Day1::try_from(date).unwrap_or_default(); let day1 = Day1::try_from(date).unwrap_or_default();
// Get first height of the target date
let first_height_of_day = computer let first_height_of_day = computer
.indexes .indexes
.day1 .day1
@@ -29,37 +39,46 @@ impl Query {
.collect_one(day1) .collect_one(day1)
.unwrap_or(Height::from(0usize)); .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 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 above_streak = 0usize;
let mut best_height = start; for h in start..=tip {
let mut best_ts = ts_cursor.get(start).data()?;
for h in (start + 1)..=max_height_usize {
let block_ts = ts_cursor.get(h).data()?; let block_ts = ts_cursor.get(h).data()?;
if block_ts <= target { if block_ts <= target {
best_height = h; best = Some((h, block_ts));
best_ts = block_ts; above_streak = 0;
} else { } 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 best.is_none() && start > 0 {
if start > 0 && best_ts > target { let mut above_streak = 0usize;
let prev_ts = ts_cursor.get(start - 1).data()?; for h in (0..start).rev() {
if prev_ts <= target { let block_ts = ts_cursor.get(h).data()?;
best_height = start - 1; if block_ts <= target {
best_ts = prev_ts; 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 height = Height::from(best_height);
let blockhash = indexer.vecs.blocks.blockhash.collect_one(height).data()?; let blockhash = indexer.vecs.blocks.blockhash.collect_one(height).data()?;
// Convert timestamp to ISO 8601 format
let ts_secs: i64 = (*best_ts).into(); let ts_secs: i64 = (*best_ts).into();
let iso_timestamp = JiffTimestamp::from_second(ts_secs) let iso_timestamp = JiffTimestamp::from_second(ts_secs)
.map(|t| t.strftime("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()) .map(|t| t.strftime("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())

View File

@@ -250,8 +250,10 @@ impl Query {
best_descendant, best_descendant,
descendants, descendants,
effective_fee_per_vsize: Some(effective), effective_fee_per_vsize: Some(effective),
sigops: None,
fee: Some(seed_fee), fee: Some(seed_fee),
adjusted_vsize: Some(seed_vsize), adjusted_vsize: Some(seed_vsize),
cluster: None,
} }
} }

View File

@@ -1,6 +1,5 @@
use brk_error::Result; use brk_error::Result;
use brk_types::{BlockFeeRatesEntry, FeeRate, FeeRatePercentiles, TimePeriod}; use brk_types::{BlockFeeRatesEntry, FeeRatePercentiles, TimePeriod};
use vecdb::ReadableVec;
use super::block_window::BlockWindow; use super::block_window::BlockWindow;
use crate::Query; use crate::Query;
@@ -8,55 +7,38 @@ use crate::Query;
impl Query { impl Query {
pub fn block_fee_rates(&self, time_period: TimePeriod) -> Result<Vec<BlockFeeRatesEntry>> { pub fn block_fee_rates(&self, time_period: TimePeriod) -> Result<Vec<BlockFeeRatesEntry>> {
let bw = BlockWindow::new(self, time_period); let bw = BlockWindow::new(self, time_period);
let computer = self.computer(); let frd = &self
let frd = &computer .computer()
.transactions .transactions
.fees .fees
.effective_fee_rate .effective_fee_rate
.distribution .distribution
.block; .block;
let min = frd.min.height.collect_range_at(bw.start, bw.end); let min = bw.read(&frd.min.height);
let pct10 = frd.pct10.height.collect_range_at(bw.start, bw.end); let pct10 = bw.read(&frd.pct10.height);
let pct25 = frd.pct25.height.collect_range_at(bw.start, bw.end); let pct25 = bw.read(&frd.pct25.height);
let median = frd.median.height.collect_range_at(bw.start, bw.end); let median = bw.read(&frd.median.height);
let pct75 = frd.pct75.height.collect_range_at(bw.start, bw.end); let pct75 = bw.read(&frd.pct75.height);
let pct90 = frd.pct90.height.collect_range_at(bw.start, bw.end); let pct90 = bw.read(&frd.pct90.height);
let max = frd.max.height.collect_range_at(bw.start, bw.end); let max = bw.read(&frd.max.height);
let timestamps = bw.timestamps(self); Ok(bw
.buckets
let mut results = Vec::with_capacity(timestamps.len()); .iter()
let mut pos = 0; .map(|b| BlockFeeRatesEntry {
let total = min.len(); avg_height: b.avg_height,
timestamp: b.avg_timestamp,
for ts in &timestamps { percentiles: FeeRatePercentiles::new(
let window_end = (pos + bw.window).min(total); b.mean(&min),
let count = window_end - pos; b.mean(&pct10),
if count > 0 { b.mean(&pct25),
let mid = (pos + window_end) / 2; b.mean(&median),
let avg = |vals: &[FeeRate]| -> FeeRate { b.mean(&pct75),
let sum: f64 = vals[pos..window_end].iter().map(|f| f64::from(*f)).sum(); b.mean(&pct90),
FeeRate::new(sum / count as f64) b.mean(&max),
}; ),
})
results.push(BlockFeeRatesEntry { .collect())
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)
} }
} }

View File

@@ -1,5 +1,5 @@
use brk_error::Result; use brk_error::Result;
use brk_types::{BlockFeesEntry, TimePeriod}; use brk_types::{BlockFeesEntry, Cents, Dollars, Sats, TimePeriod};
use super::block_window::BlockWindow; use super::block_window::BlockWindow;
use crate::Query; use crate::Query;
@@ -7,15 +7,17 @@ use crate::Query;
impl Query { impl Query {
pub fn block_fees(&self, time_period: TimePeriod) -> Result<Vec<BlockFeesEntry>> { pub fn block_fees(&self, time_period: TimePeriod) -> Result<Vec<BlockFeesEntry>> {
let bw = BlockWindow::new(self, time_period); let bw = BlockWindow::new(self, time_period);
let cumulative = &self.computer().mining.rewards.fees.cumulative.sats.height; let fees: Vec<Sats> = bw.read(&self.computer().mining.rewards.fees.block.sats);
let prices: Vec<Cents> = bw.read(&self.computer().prices.spot.cents.height);
Ok(bw Ok(bw
.cumulative_averages(self, cumulative) .buckets
.into_iter() .iter()
.map(|w| BlockFeesEntry { .map(|b| BlockFeesEntry {
avg_height: w.avg_height, avg_height: b.avg_height,
timestamp: w.timestamp, timestamp: b.avg_timestamp,
avg_fees: w.avg_value, avg_fees: b.mean_rounded(&fees),
usd: w.usd, usd: Dollars::from(b.mean_rounded(&prices)),
}) })
.collect()) .collect())
} }

View File

@@ -1,5 +1,5 @@
use brk_error::Result; use brk_error::Result;
use brk_types::{BlockRewardsEntry, TimePeriod}; use brk_types::{BlockRewardsEntry, Cents, Dollars, Sats, TimePeriod};
use super::block_window::BlockWindow; use super::block_window::BlockWindow;
use crate::Query; use crate::Query;
@@ -7,22 +7,17 @@ use crate::Query;
impl Query { impl Query {
pub fn block_rewards(&self, time_period: TimePeriod) -> Result<Vec<BlockRewardsEntry>> { pub fn block_rewards(&self, time_period: TimePeriod) -> Result<Vec<BlockRewardsEntry>> {
let bw = BlockWindow::new(self, time_period); let bw = BlockWindow::new(self, time_period);
let cumulative = &self let rewards: Vec<Sats> = bw.read(&self.computer().mining.rewards.coinbase.block.sats);
.computer() let prices: Vec<Cents> = bw.read(&self.computer().prices.spot.cents.height);
.mining
.rewards
.coinbase
.cumulative
.sats
.height;
Ok(bw Ok(bw
.cumulative_averages(self, cumulative) .buckets
.into_iter() .iter()
.map(|w| BlockRewardsEntry { .map(|b| BlockRewardsEntry {
avg_height: w.avg_height, avg_height: b.avg_height,
timestamp: w.timestamp, timestamp: b.avg_timestamp,
avg_rewards: w.avg_value, avg_rewards: b.mean_rounded(&rewards),
usd: w.usd, usd: Dollars::from(b.mean_rounded(&prices)),
}) })
.collect()) .collect())
} }

View File

@@ -1,59 +1,37 @@
use brk_error::Result; use brk_error::Result;
use brk_types::{BlockSizeEntry, BlockSizesWeights, BlockWeightEntry, TimePeriod, Weight}; use brk_types::{
use vecdb::ReadableVec; BlockSizeEntry, BlockSizesWeights, BlockWeightEntry, StoredU64, TimePeriod, Weight,
};
use super::block_window::BlockWindow; use super::block_window::BlockWindow;
use crate::Query; use crate::Query;
impl Query { impl Query {
pub fn block_sizes_weights(&self, time_period: TimePeriod) -> Result<BlockSizesWeights> { pub fn block_sizes_weights(&self, time_period: TimePeriod) -> Result<BlockSizesWeights> {
let computer = self.computer(); let blocks = &self.indexer().vecs.blocks;
let bw = BlockWindow::new(self, time_period); let bw = BlockWindow::new(self, time_period);
let timestamps = bw.timestamps(self);
// Batch read per-block rolling 24h medians for the range let block_sizes: Vec<StoredU64> = bw.read(&blocks.total);
let all_sizes = computer let block_weights: Vec<Weight> = bw.read(&blocks.weight);
.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);
// Sample at window midpoints let (sizes, weights) = bw
let mut sizes = Vec::with_capacity(timestamps.len()); .buckets
let mut weights = Vec::with_capacity(timestamps.len()); .iter()
.map(|b| {
for ((avg_height, start, _end), ts) in bw.iter().zip(&timestamps) { (
let mid = start - bw.start + (bw.window / 2).min(all_sizes.len().saturating_sub(1)); BlockSizeEntry {
if let Some(&size) = all_sizes.get(mid) { avg_height: b.avg_height,
sizes.push(BlockSizeEntry { timestamp: b.avg_timestamp,
avg_height, avg_size: u64::from(b.mean_rounded(&block_sizes)),
timestamp: *ts, },
avg_size: *size, BlockWeightEntry {
}); avg_height: b.avg_height,
} timestamp: b.avg_timestamp,
if let Some(&weight) = all_weights.get(mid) { avg_weight: b.mean_rounded(&block_weights),
weights.push(BlockWeightEntry { },
avg_height, )
timestamp: *ts, })
avg_weight: Weight::from(*weight), .unzip();
});
}
}
Ok(BlockSizesWeights { sizes, weights }) Ok(BlockSizesWeights { sizes, weights })
} }

View File

@@ -1,155 +1,117 @@
use brk_types::{Cents, Dollars, Height, Sats, TimePeriod, Timestamp}; use std::{
use vecdb::{ReadableVec, VecIndex}; collections::BTreeMap,
iter::Sum,
ops::{Deref, Div},
};
use brk_types::{Height, TimePeriod, Timestamp};
use vecdb::{ReadableVec, VecValue};
use crate::Query; use crate::Query;
/// Number of blocks per aggregation window, matching mempool.space's granularity. /// Mempool.space's `GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}` divisor in seconds.
fn block_window(period: TimePeriod) -> usize { /// `div = 1` puts each block in its own bucket.
fn time_div(period: TimePeriod) -> u32 {
match period { match period {
TimePeriod::Day | TimePeriod::ThreeDays | TimePeriod::Week => 1, TimePeriod::Day | TimePeriod::ThreeDays => 1,
TimePeriod::Month => 3, TimePeriod::Week => 300,
TimePeriod::ThreeMonths => 12, TimePeriod::Month => 1800,
TimePeriod::SixMonths => 18, TimePeriod::ThreeMonths => 7200,
TimePeriod::Year | TimePeriod::TwoYears => 48, TimePeriod::SixMonths => 10800,
TimePeriod::ThreeYears => 72, TimePeriod::Year | TimePeriod::TwoYears => 28800,
TimePeriod::All => 144, TimePeriod::ThreeYears => 43200,
TimePeriod::All => 86400,
} }
} }
/// Per-window average with metadata. /// Round-half-up integer division, matching MySQL's `CAST(AVG(...) AS INT)`.
pub struct WindowAvg { const fn round_half_up(sum: u64, n: u64) -> u64 {
pub avg_height: Height, (sum + n / 2) / n
pub timestamp: Timestamp,
pub avg_value: Sats,
pub usd: Dollars,
} }
/// 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<usize>,
}
impl BlockBucket {
/// Float arithmetic mean of `values[offset]` across this bucket's blocks.
/// Use for float-backed types like `FeeRate`.
pub fn mean<T>(&self, values: &[T]) -> T
where
T: Copy + Sum + Div<usize, Output = T>,
{
self.offsets.iter().map(|&i| values[i]).sum::<T>() / 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<T>(&self, values: &[T]) -> T
where
T: Copy + Deref<Target = u64> + From<u64>,
{
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 struct BlockWindow {
pub start: usize, pub start: Height,
pub end: usize, pub end: Height,
pub window: usize, pub buckets: Vec<BlockBucket>,
} }
impl BlockWindow { impl BlockWindow {
pub fn new(query: &Query, time_period: TimePeriod) -> Self { pub fn new(query: &Query, period: TimePeriod) -> Self {
let current_height = query.height(); let start = query.start_height(period);
let computer = query.computer(); let end = query.height() + 1usize;
let lookback = &computer.blocks.lookback; let div = time_div(period);
// Use pre-computed timestamp-based lookback for accurate time boundaries. let timestamps: Vec<Timestamp> = query
// 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<Height, Sats>,
) -> Vec<WindowAvg> {
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<Cents> = 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<Timestamp> {
let all_ts = query
.indexer() .indexer()
.vecs .vecs
.blocks .blocks
.timestamp .timestamp
.collect_range_at(self.start, self.end); .collect_range(start, end);
let mut timestamps = Vec::with_capacity(self.count());
let mut pos = 0; let mut groups: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
while pos < all_ts.len() { for (i, ts) in timestamps.iter().enumerate() {
let window_end = (pos + self.window).min(all_ts.len()); groups.entry(**ts / div).or_default().push(i);
timestamps.push(all_ts[(pos + window_end) / 2]); }
pos = window_end;
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. /// Read a height-keyed vec over this window's `[start, end)` range.
fn count(&self) -> usize { pub fn read<V, T>(&self, vec: &V) -> Vec<T>
(self.end - self.start).div_ceil(self.window) where
} V: ReadableVec<Height, T>,
T: VecValue,
/// Iterate windows, yielding (avg_height, window_start, window_end) for each. {
pub fn iter(&self) -> impl Iterator<Item = (Height, usize, usize)> + '_ { vec.collect_range(self.start, self.end)
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))
})
} }
} }

View File

@@ -10,10 +10,11 @@ impl Query {
&self, &self,
time_period: Option<TimePeriod>, time_period: Option<TimePeriod>,
) -> Result<Vec<DifficultyAdjustmentEntry>> { ) -> Result<Vec<DifficultyAdjustmentEntry>> {
let current_height = self.height(); let end = self.height().to_usize();
let end = current_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 { let start = match time_period {
Some(tp) => end.saturating_sub(tp.block_count()), Some(tp) => self.start_height(tp).to_usize(),
None => 0, None => 0,
}; };

View File

@@ -7,5 +7,6 @@ mod difficulty;
mod difficulty_adjustments; mod difficulty_adjustments;
mod epochs; mod epochs;
mod hashrate; mod hashrate;
mod period_start;
mod pools; mod pools;
mod reward_stats; mod reward_stats;

View File

@@ -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()
}
}

View File

@@ -2,7 +2,7 @@ use std::{thread::sleep, time::Duration};
use bitcoin::{consensus::encode, hex::FromHex}; use bitcoin::{consensus::encode, hex::FromHex};
use brk_error::{Error, Result}; 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_jsonrpc::error::Error as JsonRpcError;
use corepc_types::v30::{ use corepc_types::v30::{
GetBlockCount, GetBlockHash, GetBlockHeader, GetBlockHeaderVerbose, GetBlockVerboseOne, GetBlockCount, GetBlockHash, GetBlockHeader, GetBlockHeaderVerbose, GetBlockVerboseOne,
@@ -211,6 +211,7 @@ impl Client {
vsize: entry.vsize as u64, vsize: entry.vsize as u64,
weight: entry.weight as u64, weight: entry.weight as u64,
fee: Sats::from(Bitcoin::from(entry.fees.base)), fee: Sats::from(Bitcoin::from(entry.fees.base)),
first_seen: Timestamp::from(entry.time),
ancestor_count: entry.ancestor_count as u64, ancestor_count: entry.ancestor_count as u64,
ancestor_size: entry.ancestor_size as u64, ancestor_size: entry.ancestor_size as u64,
ancestor_fee: Sats::from(Bitcoin::from(entry.fees.ancestor)), ancestor_fee: Sats::from(Bitcoin::from(entry.fees.ancestor)),

View File

@@ -327,7 +327,7 @@ impl BlockRoutes for ApiRouter<AppState> {
get_with( get_with(
async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State<AppState>| { async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State<AppState>| {
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 .await
}, },
|op| { |op| {
@@ -348,7 +348,7 @@ impl BlockRoutes for ApiRouter<AppState> {
headers: HeaderMap, headers: HeaderMap,
Path(path): Path<HeightParam>, Path(path): Path<HeightParam>,
_: Empty, State(state): State<AppState>| { _: Empty, State(state): State<AppState>| {
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| {
op.id("get_blocks_from_height") op.id("get_blocks_from_height")
@@ -369,14 +369,14 @@ impl BlockRoutes for ApiRouter<AppState> {
get_with( get_with(
async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State<AppState>| { async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State<AppState>| {
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 .await
}, },
|op| { |op| {
op.id("get_blocks_v1") op.id("get_blocks_v1")
.blocks_tag() .blocks_tag()
.summary("Recent blocks with extras") .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::<Vec<BlockInfoV1>>() .json_response::<Vec<BlockInfoV1>>()
.not_modified() .not_modified()
.server_error() .server_error()
@@ -390,13 +390,13 @@ impl BlockRoutes for ApiRouter<AppState> {
headers: HeaderMap, headers: HeaderMap,
Path(path): Path<HeightParam>, Path(path): Path<HeightParam>,
_: Empty, State(state): State<AppState>| { _: Empty, State(state): State<AppState>| {
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| {
op.id("get_blocks_v1_from_height") op.id("get_blocks_v1_from_height")
.blocks_tag() .blocks_tag()
.summary("Blocks from height with extras") .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::<Vec<BlockInfoV1>>() .json_response::<Vec<BlockInfoV1>>()
.not_modified() .not_modified()
.bad_request() .bad_request()

View File

@@ -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 schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use vecdb::{CheckedSub, Formattable, Pco}; use vecdb::{CheckedSub, Formattable, Pco};
@@ -11,6 +15,7 @@ use super::{CentsSats, Dollars, Sats, StoredF64};
#[derive( #[derive(
Debug, Debug,
Default, Default,
Deref,
Clone, Clone,
Copy, Copy,
PartialEq, PartialEq,
@@ -187,6 +192,12 @@ impl AddAssign for Cents {
} }
} }
impl Sum for Cents {
fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
Self(iter.map(|c| c.0).sum())
}
}
impl Sub for Cents { impl Sub for Cents {
type Output = Self; type Output = Self;
#[inline] #[inline]

View File

@@ -1,8 +1,30 @@
use derive_more::Deref;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{FeeRate, Sats, Txid, VSize, Weight}; 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<u32> for CpfpClusterTxIndex {
fn from(v: u32) -> Self {
Self(v)
}
}
impl From<CpfpClusterTxIndex> for u32 {
fn from(v: CpfpClusterTxIndex) -> Self {
v.0
}
}
/// CPFP (Child Pays For Parent) information for a transaction /// CPFP (Child Pays For Parent) information for a transaction
#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -18,12 +40,19 @@ pub struct CpfpInfo {
/// Effective fee rate considering CPFP relationships (sat/vB) /// Effective fee rate considering CPFP relationships (sat/vB)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub effective_fee_per_vsize: Option<FeeRate>, pub effective_fee_per_vsize: Option<FeeRate>,
/// Total signature operation count for the seed tx
#[serde(skip_serializing_if = "Option::is_none")]
pub sigops: Option<u32>,
/// Transaction fee (sats) /// Transaction fee (sats)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub fee: Option<Sats>, pub fee: Option<Sats>,
/// Adjusted virtual size (accounting for sigops) /// Adjusted virtual size (accounting for sigops)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub adjusted_vsize: Option<VSize>, pub adjusted_vsize: Option<VSize>,
/// 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<CpfpCluster>,
} }
/// A transaction in a CPFP relationship /// A transaction in a CPFP relationship
@@ -36,3 +65,35 @@ pub struct CpfpEntry {
/// Transaction fee (sats) /// Transaction fee (sats)
pub 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<CpfpClusterTx>,
/// SFL-emitted chunks ordered by descending feerate.
pub chunks: Vec<CpfpClusterChunk>,
/// 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<CpfpClusterTxIndex>,
}
/// One SFL chunk inside a `CpfpCluster`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CpfpClusterChunk {
/// Txs in this chunk.
pub txs: Vec<CpfpClusterTxIndex>,
/// Combined feerate of the chunk (sat/vB).
pub feerate: FeeRate,
}

View File

@@ -1,5 +1,6 @@
use std::{ use std::{
cmp::Ordering, cmp::Ordering,
iter::Sum,
ops::{Add, AddAssign, Div, Mul}, ops::{Add, AddAssign, Div, Mul},
}; };
@@ -103,6 +104,12 @@ impl AddAssign for FeeRate {
} }
} }
impl Sum for FeeRate {
fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
Self(iter.map(|r| r.0).sum())
}
}
impl Div<usize> for FeeRate { impl Div<usize> for FeeRate {
type Output = Self; type Output = Self;
fn div(self, rhs: usize) -> Self::Output { fn div(self, rhs: usize) -> Self::Output {

View File

@@ -1,4 +1,4 @@
use crate::{Sats, Txid}; use crate::{Sats, Timestamp, Txid};
/// Mempool entry info from Bitcoin Core's getrawmempool verbose /// Mempool entry info from Bitcoin Core's getrawmempool verbose
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -7,6 +7,7 @@ pub struct MempoolEntryInfo {
pub vsize: u64, pub vsize: u64,
pub weight: u64, pub weight: u64,
pub fee: Sats, pub fee: Sats,
pub first_seen: Timestamp,
pub ancestor_count: u64, pub ancestor_count: u64,
pub ancestor_size: u64, pub ancestor_size: u64,
pub ancestor_fee: Sats, pub ancestor_fee: Sats,

View File

@@ -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 derive_more::Deref;
use schemars::JsonSchema; use schemars::JsonSchema;
@@ -99,6 +102,12 @@ impl AddAssign for StoredU64 {
} }
} }
impl Sum for StoredU64 {
fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
Self(iter.map(|v| v.0).sum())
}
}
impl Sub for StoredU64 { impl Sub for StoredU64 {
type Output = Self; type Output = Self;
fn sub(self, rhs: Self) -> Self::Output { fn sub(self, rhs: Self) -> Self::Output {

View File

@@ -102,6 +102,15 @@ impl From<u32> for Timestamp {
} }
} }
impl From<i64> 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<jiff::Timestamp> for Timestamp { impl From<jiff::Timestamp> for Timestamp {
#[inline] #[inline]
fn from(value: jiff::Timestamp) -> Self { fn from(value: jiff::Timestamp) -> Self {

View File

@@ -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 derive_more::Deref;
use schemars::JsonSchema; use schemars::JsonSchema;
@@ -125,6 +128,12 @@ impl AddAssign for Weight {
} }
} }
impl Sum for Weight {
fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
Self(iter.map(|w| w.0).sum())
}
}
impl Sub for Weight { impl Sub for Weight {
type Output = Self; type Output = Self;
fn sub(self, rhs: Self) -> Self::Output { fn sub(self, rhs: Self) -> Self::Output {

View File

@@ -366,6 +366,37 @@ Matches mempool.space/bitcoin-cli behavior.
* *
* @typedef {("supply"|"realized"|"unrealized")} CostBasisValue * @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 * 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|null)=} bestDescendant - Best (highest fee rate) descendant, if any
* @property {CpfpEntry[]} descendants - Descendant transactions in the CPFP chain * @property {CpfpEntry[]} descendants - Descendant transactions in the CPFP chain
* @property {(FeeRate|null)=} effectiveFeePerVsize - Effective fee rate considering CPFP relationships (sat/vB) * @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 {(Sats|null)=} fee - Transaction fee (sats)
* @property {(VSize|null)=} adjustedVsize - Adjusted virtual size (accounting for sigops) * @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. * Range parameters with output format for API query parameters.
@@ -1805,7 +1839,10 @@ class BrkClientBase {
const value = await parse(res); const value = await parse(res);
this._memSet(url, netEtag, value); this._memSet(url, netEtag, value);
if (onValue) onValue(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; return value;
} catch { } catch {
return memHit.value; return memHit.value;
@@ -1836,7 +1873,10 @@ class BrkClientBase {
const value = await parse(res); const value = await parse(res);
this._memSet(url, netEtag, value); this._memSet(url, netEtag, value);
if (onValue) onValue(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; return value;
} catch (e) { } catch (e) {
const stale = await stalePromise; const stale = await stalePromise;
@@ -7476,171 +7516,171 @@ class BrkClient extends BrkClientBase {
]); ]);
POOL_ID_TO_POOL_NAME = /** @type {const} */ ({ POOL_ID_TO_POOL_NAME = /** @type {const} */ ({
"unknown": "Unknown", "aaopool": "AAO Pool",
"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",
"antpool": "AntPool", "antpool": "AntPool",
"multicoinco": "MultiCoin.co", "arkpool": "ArkPool",
"asicminer": "ASICMiner",
"axbt": "A-XBT",
"batpool": "BATPOOL",
"bcmonster": "BCMonster",
"bcpoolio": "bcpool.io", "bcpoolio": "bcpool.io",
"cointerra": "Cointerra", "binancepool": "Binance Pool",
"kanopool": "KanoPool", "bitalo": "Bitalo",
"solock": "Solo CK",
"ckpool": "CKPool",
"nicehash": "NiceHash",
"bitclub": "BitClub", "bitclub": "BitClub",
"bitcoinaffiliatenetwork": "Bitcoin Affiliate Network", "bitcoinaffiliatenetwork": "Bitcoin Affiliate Network",
"btcc": "BTCC", "bitcoincom": "Bitcoin.com",
"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",
"bitcoinindia": "Bitcoin India", "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", "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", "canoe": "CANOE",
"tiger": "tiger", "canoepool": "CanoePool",
"1m1x": "1M1X", "carbonnegative": "Carbon Negative",
"zulupool": "Zulupool", "ckpool": "CKPool",
"secpool": "SECPOOL", "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", "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", "whitepool": "WhitePool",
"wiz": "wiz", "wiz": "wiz",
"wk057": "wk057", "wk057": "wk057",
"futurebitapollosolo": "FutureBit Apollo Solo", "yourbtcnet": "Yourbtc.net",
"carbonnegative": "Carbon Negative", "zulupool": "Zulupool"
"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"
}); });
TERM_NAMES = /** @type {const} */ ({ TERM_NAMES = /** @type {const} */ ({
@@ -10739,7 +10779,7 @@ class BrkClient extends BrkClientBase {
*/ */
async getBlockTipHeight({ signal, onValue } = {}) { async getBlockTipHeight({ signal, onValue } = {}) {
const path = `/api/blocks/tip/height`; 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` * Endpoint: `GET /api/series/bulk`
* *
* @param {SeriesList} [series] - Requested series * @param {SeriesList} series - Requested series
* @param {Index} [index] - Index to query * @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=} [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 {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` * @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` * 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 {Limit=} [limit] - Maximum number of results
* @param {{ signal?: AbortSignal, onValue?: (value: string[]) => void }} [options] * @param {{ signal?: AbortSignal, onValue?: (value: string[]) => void }} [options]
* @returns {Promise<string[]>} * @returns {Promise<string[]>}
@@ -11362,7 +11402,7 @@ class BrkClient extends BrkClientBase {
/** /**
* Recent blocks with extras * 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)* * *[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 * 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)* * *[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` * 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] * @param {{ signal?: AbortSignal, onValue?: (value: number[]) => void }} [options]
* @returns {Promise<number[]>} * @returns {Promise<number[]>}
*/ */

View File

@@ -102,6 +102,9 @@ CostBasisValue = Literal["supply", "realized", "unrealized"]
# Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000), # Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000),
# log10/log50/log100/log200 (logarithmic with 10/50/100/200 buckets per decade). # log10/log50/log100/log200 (logarithmic with 10/50/100/200 buckets per decade).
UrpdAggregation = Literal["raw", "lin200", "lin500", "lin1000", "log10", "log50", "log100", "log200"] 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. # Virtual size in vbytes (weight / 4, rounded up). Max block vsize is ~1,000,000 vB.
VSize = int VSize = int
# Date in YYYYMMDD format stored as u32 # Date in YYYYMMDD format stored as u32
@@ -650,6 +653,43 @@ class CostBasisQuery(TypedDict):
bucket: UrpdAggregation bucket: UrpdAggregation
value: CostBasisValue 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): class CpfpEntry(TypedDict):
""" """
A transaction in a CPFP relationship A transaction in a CPFP relationship
@@ -672,15 +712,20 @@ class CpfpInfo(TypedDict):
bestDescendant: Best (highest fee rate) descendant, if any bestDescendant: Best (highest fee rate) descendant, if any
descendants: Descendant transactions in the CPFP chain descendants: Descendant transactions in the CPFP chain
effectiveFeePerVsize: Effective fee rate considering CPFP relationships (sat/vB) effectiveFeePerVsize: Effective fee rate considering CPFP relationships (sat/vB)
sigops: Total signature operation count for the seed tx
fee: Transaction fee (sats) fee: Transaction fee (sats)
adjustedVsize: Adjusted virtual size (accounting for sigops) 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] ancestors: List[CpfpEntry]
bestDescendant: Union[CpfpEntry, None] bestDescendant: Union[CpfpEntry, None]
descendants: List[CpfpEntry] descendants: List[CpfpEntry]
effectiveFeePerVsize: Union[FeeRate, None] effectiveFeePerVsize: Union[FeeRate, None]
sigops: Optional[int]
fee: Union[Sats, None] fee: Union[Sats, None]
adjustedVsize: Union[VSize, None] adjustedVsize: Union[VSize, None]
cluster: Union[CpfpCluster, None]
class DataRangeFormat(TypedDict): class DataRangeFormat(TypedDict):
""" """
@@ -8314,7 +8359,7 @@ class BrkClient(BrkClientBase):
def get_blocks_v1(self) -> List[BlockInfoV1]: def get_blocks_v1(self) -> List[BlockInfoV1]:
"""Recent blocks with extras. """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)* *[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]: def get_blocks_v1_from_height(self, height: Height) -> List[BlockInfoV1]:
"""Blocks from height with extras. """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)* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*

View File

@@ -4,7 +4,7 @@ from _lib import assert_same_structure, show
MAX_PROJECTED_BLOCKS = 8 MAX_PROJECTED_BLOCKS = 8
BRK_FEE_RANGE_LEN = 7 FEE_RANGE_LEN = 7
def test_fees_mempool_blocks_structure(brk, mempool): 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["totalFees"] >= 0, f"block {i} has negative totalFees"
assert block["medianFee"] > 0, f"block {i} has non-positive medianFee" assert block["medianFee"] > 0, f"block {i} has non-positive medianFee"
fr = block["feeRange"] fr = block["feeRange"]
assert len(fr) == BRK_FEE_RANGE_LEN, ( assert len(fr) == FEE_RANGE_LEN, (
f"block {i} feeRange has {len(fr)} items, expected {BRK_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 == sorted(fr), f"block {i} feeRange not ascending: {fr}"
assert fr[0] <= block["medianFee"] <= fr[-1], ( assert fr[0] <= block["medianFee"] <= fr[-1], (

View File

@@ -28,8 +28,10 @@ def test_mempool_info_invariants(brk):
f"histogram entry {i} not a 2-element list: {entry}" f"histogram entry {i} not a 2-element list: {entry}"
) )
rate, bvs = entry rate, bvs = entry
assert isinstance(rate, (int, float)) and rate > 0, ( # Zero-rate bins are legitimate (CPFP/package-relay anchors with
f"non-positive rate at bin {i}: {rate}" # 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}" assert isinstance(bvs, int) and bvs > 0, f"non-positive vsize at bin {i}: {bvs}"
rates.append(rate) rates.append(rate)

View File

@@ -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 import pytest

View File

@@ -45,3 +45,30 @@ def test_mining_blocks_fees_malformed(brk, bad):
assert exc_info.value.status == 400, ( assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}" 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"

View File

@@ -45,3 +45,29 @@ def test_mining_blocks_rewards_malformed(brk, bad):
assert exc_info.value.status == 400, ( assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}" 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"

View File

@@ -59,3 +59,34 @@ def test_mining_blocks_sizes_weights_malformed(brk, bad):
assert exc_info.value.status == 400, ( assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}" 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"

View File

@@ -52,3 +52,30 @@ def test_mining_difficulty_adjustments_malformed(brk, bad):
assert exc_info.value.status == 400, ( assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}" 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}"
)

View File

@@ -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 import pytest
from brk_client import BrkError 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] COUNTS = [1, 10, 100, 500, 1000]
@@ -20,16 +25,6 @@ def test_mining_reward_stats_structure(brk, mempool, count):
assert_same_structure(b, m) 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): def test_mining_reward_stats_invariants(brk):
"""Range alignment, reward >= fee, totalTx >= block count (count=1000).""" """Range alignment, reward >= fee, totalTx >= block count (count=1000)."""
count = 1000 count = 1000

View File

@@ -1,5 +1,7 @@
"""GET /api/v1/transaction-times?txId[]=...""" """GET /api/v1/transaction-times?txId[]=..."""
import time
import pytest import pytest
from brk_client import BrkError from brk_client import BrkError
@@ -65,3 +67,39 @@ def test_transaction_times_malformed_short(brk):
with pytest.raises(BrkError) as exc_info: with pytest.raises(BrkError) as exc_info:
brk.get_transaction_times(["abc"]) brk.get_transaction_times(["abc"])
assert exc_info.value.status == 400 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")

View File

@@ -113,7 +113,9 @@ export async function update(address, signal) {
if (currentAddr !== address) return; if (currentAddr !== address) return;
loading = true; loading = true;
try { 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; if (currentAddr !== address) return;
for (const tx of txs) txSection.append(renderTx(tx)); for (const tx of txs) txSection.append(renderTx(tx));
pageIndex++; pageIndex++;