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]]
name = "cfg-if"
version = "1.0.4"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"

View File

@@ -10,7 +10,7 @@ use brk_cohort::{
OVER_AMOUNT_NAMES, PROFIT_NAMES, PROFITABILITY_RANGE_NAMES, SPENDABLE_TYPE_NAMES, TERM_NAMES,
UNDER_AGE_NAMES, UNDER_AMOUNT_NAMES,
};
use brk_types::{Index, PoolSlug, pools};
use brk_types::{Index, pools};
use serde::Serialize;
use serde_json::Value;
@@ -20,7 +20,7 @@ use crate::{VERSION, to_camel_case};
pub struct ClientConstants {
pub version: String,
pub indexes: Vec<&'static str>,
pub pool_map: BTreeMap<PoolSlug, &'static str>,
pub pool_map: BTreeMap<String, &'static str>,
}
impl ClientConstants {
@@ -32,8 +32,10 @@ impl ClientConstants {
let pools = pools();
let mut sorted_pools: Vec<_> = pools.iter().collect();
sorted_pools.sort_by_key(|p| p.name.to_lowercase());
let pool_map: BTreeMap<PoolSlug, &'static str> =
sorted_pools.iter().map(|p| (p.slug(), p.name)).collect();
let pool_map: BTreeMap<String, &'static str> = sorted_pools
.iter()
.map(|p| (p.slug().to_string(), p.name))
.collect();
Self {
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 desc = format_param_desc(param.description.as_deref());
let ty = jsdoc_normalize(&param.param_type);
let ident = sanitize_ident(&param.name);
let name_decl = if param.required {
ident
} else {
format!("[{}]", ident)
};
writeln!(
output,
" * @param {{{}{}}} [{}]{}",
ty, optional, param.name, desc
" * @param {{{}{}}} {}{}",
ty, optional, name_decl, desc
)
.unwrap();
}
@@ -67,7 +73,7 @@ fn generate_get_method(output: &mut String, endpoint: &Endpoint) {
} else if endpoint.returns_json() {
"this.getJson(path, { signal, onValue })".to_string()
} else if endpoint.response_kind.text_is_numeric() {
"Number(await this.getText(path, { signal, onValue }))".to_string()
"Number(await this.getText(path, { signal, onValue: onValue ? (v) => onValue(Number(v)) : undefined }))".to_string()
} else {
"this.getText(path, { signal, onValue })".to_string()
};
@@ -214,12 +220,21 @@ fn write_path_assignment(output: &mut String, endpoint: &Endpoint, path: &str) {
let ident = sanitize_ident(&param.name);
let is_array = param.param_type.ends_with("[]");
if is_array {
writeln!(
output,
" for (const _v of {}) params.append('{}', String(_v));",
ident, param.name
)
.unwrap();
if param.required {
writeln!(
output,
" for (const _v of {}) params.append('{}', String(_v));",
ident, param.name
)
.unwrap();
} else {
writeln!(
output,
" if ({}) for (const _v of {}) params.append('{}', String(_v));",
ident, ident, param.name
)
.unwrap();
}
} else if param.required {
writeln!(
output,

View File

@@ -496,7 +496,10 @@ class BrkClientBase {{
const value = await parse(res);
this._memSet(url, netEtag, value);
if (onValue) onValue(value);
if (cloned) _runIdle(() => browserCache.put(url, cloned));
if (cloned && browserCache) {{
const cache = browserCache;
_runIdle(() => cache.put(url, cloned));
}}
return value;
}} catch {{
return memHit.value;
@@ -527,7 +530,10 @@ class BrkClientBase {{
const value = await parse(res);
this._memSet(url, netEtag, value);
if (onValue) onValue(value);
if (cloned) _runIdle(() => browserCache.put(url, cloned));
if (cloned && browserCache) {{
const cache = browserCache;
_runIdle(() => cache.put(url, cloned));
}}
return value;
}} catch (e) {{
const stale = await stalePromise;

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
// Do not edit manually
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(dead_code)]
#![allow(unused_variables)]
#![allow(clippy::useless_format)]
@@ -9604,7 +9605,7 @@ impl BrkClient {
/// Recent blocks with extras
///
/// Retrieve the last 10 blocks with extended data including pool identification and fee statistics.
/// Retrieve the last 15 blocks with extended data including pool identification and fee statistics.
///
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*
///
@@ -9615,7 +9616,7 @@ impl BrkClient {
/// Blocks from height with extras
///
/// Retrieve up to 10 blocks with extended data going backwards from the given height.
/// Retrieve up to 15 blocks with extended data going backwards from the given height.
///
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*
///

View File

@@ -1,6 +1,6 @@
use brk_error::Result;
use brk_traversable::Traversable;
use brk_types::{Height, Indexes, Timestamp, Version};
use brk_types::{Height, Indexes, TimePeriod, Timestamp, Version};
use vecdb::{
AnyVec, CachedVec, Cursor, Database, EagerVec, Exit, ImportableVec, PcoVec, ReadableVec, Rw,
StorageMode, VecIndex,
@@ -58,6 +58,26 @@ pub struct Vecs<M: StorageMode = Rw> {
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 {
pub(crate) fn forced_import(db: &Database, version: Version) -> Result<Self> {
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
//! chain) lives in `brk_query`, since it reads indexer/computer vecs.
use brk_types::{CpfpEntry, CpfpInfo, FeeRate, Sats, TxidPrefix, VSize, Weight};
use rustc_hash::FxHashSet;
use brk_types::{
CpfpCluster, CpfpClusterChunk, CpfpClusterTx, CpfpClusterTxIndex, CpfpEntry, CpfpInfo, FeeRate,
TxidPrefix, VSize, Weight,
};
use rustc_hash::{FxHashMap, FxHashSet};
use smallvec::SmallVec;
use crate::steps::rebuilder::linearize::{
LocalIdx, cluster::Cluster, cluster_node::ClusterNode, sfl::Sfl,
};
use crate::stores::{EntryPool, TxIndex};
use crate::{Mempool, TxEntry};
/// Cap matches Bitcoin Core's default mempool ancestor/descendant
@@ -24,19 +32,16 @@ impl Mempool {
pub fn cpfp_info(&self, prefix: &TxidPrefix) -> Option<CpfpInfo> {
let snapshot = self.snapshot();
let entries = self.entries();
let txs = self.txs();
let seed_idx = entries.idx_of(prefix)?;
let seed = entries.slot(seed_idx)?;
let mut sum_fee = u64::from(seed.fee);
let mut sum_vsize = u64::from(seed.vsize);
let mut ancestor_idxs: Vec<TxIndex> = Vec::new();
let mut descendant_idxs: Vec<TxIndex> = Vec::new();
let mut ancestors: Vec<CpfpEntry> = Vec::new();
let mut descendants: Vec<CpfpEntry> = Vec::new();
if let Some(seed_block) = snapshot.block_of(seed_idx) {
// Ancestor BFS gated to the seed's projected block.
// `visited` dedupes the walk; stale parent prefixes
// (confirmed/evicted between snapshot and now) are skipped
// when `idx_of` returns None.
let mut visited: FxHashSet<TxidPrefix> = FxHashSet::default();
visited.insert(*prefix);
let mut stack: Vec<TxidPrefix> = seed.depends.iter().copied().collect();
@@ -52,17 +57,11 @@ impl Mempool {
continue;
}
let Some(anc) = entries.slot(idx) else { continue };
sum_fee += u64::from(anc.fee);
sum_vsize += u64::from(anc.vsize);
ancestor_idxs.push(idx);
ancestors.push(to_entry(anc));
stack.extend(anc.depends.iter().copied());
}
// Descendant sweep. `desc_set` starts with only the seed
// so siblings (txs sharing an ancestor with seed but not
// downstream of it) are excluded. The topological ordering
// of `Snapshot.blocks` guarantees that all in-block
// ancestors of any tx are visited before it.
let mut desc_set: FxHashSet<TxidPrefix> = FxHashSet::default();
desc_set.insert(*prefix);
for &i in &snapshot.blocks[seed_block.as_usize()] {
@@ -74,8 +73,7 @@ impl Mempool {
continue;
}
desc_set.insert(e.txid_prefix());
sum_fee += u64::from(e.fee);
sum_vsize += u64::from(e.vsize);
descendant_idxs.push(i);
descendants.push(to_entry(e));
}
}
@@ -85,16 +83,39 @@ impl Mempool {
.max_by_key(|e| FeeRate::from((e.fee, e.weight)))
.cloned();
let package_rate = FeeRate::from((Sats::from(sum_fee), VSize::from(sum_vsize)));
let effective = seed.fee_rate().max(package_rate);
let sigops = txs.get(&seed.txid).map(|tx| {
// Bitcoin Core's `total_sigop_cost` is the segwit-weighted sigop
// count (legacy * 4 + segwit * 1), divided by 5 to match
// mempool.space's reported `sigops`. Mempool.space converts
// back to count via `sigopcost / 5`.
u32::try_from(tx.total_sigop_cost / 5).unwrap_or(u32::MAX)
});
// mempool.space's adjustedVsize = max(vsize, sigops * 5).
let adjusted_vsize = match sigops {
Some(s) => VSize::from(u64::from(seed.vsize).max(u64::from(s) * 5)),
None => seed.vsize,
};
let cluster = build_cluster(seed_idx, &ancestor_idxs, &descendant_idxs, &entries);
// mempool.space sets effectiveFeePerVsize to the seed's chunk feerate
// when the cluster is known, falls back to the seed's own rate.
let effective = cluster
.as_ref()
.and_then(|c| c.chunks.get(c.chunk_index as usize))
.map(|chunk| chunk.feerate)
.unwrap_or_else(|| seed.fee_rate());
Some(CpfpInfo {
ancestors,
best_descendant,
descendants,
effective_fee_per_vsize: Some(effective),
sigops,
fee: Some(seed.fee),
adjusted_vsize: Some(seed.vsize),
adjusted_vsize: Some(adjusted_vsize),
cluster,
})
}
}
@@ -106,3 +127,120 @@ fn to_entry(e: &TxEntry) -> CpfpEntry {
fee: e.fee,
}
}
/// Build the cluster output: seed + ancestors + descendants in topological
/// order, with parent indexes inside the cluster, plus SFL-linearized chunks.
fn build_cluster(
seed_idx: TxIndex,
ancestor_idxs: &[TxIndex],
descendant_idxs: &[TxIndex],
entries: &EntryPool,
) -> Option<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
//! `Transaction` + `Entry`.
//! - **Revived** - tx in the graveyard. Rebuild the `Entry` only
//! (preserving `first_seen`, `rbf`, `size`). The Applier exhumes
//! the cached tx body. No raw decoding.
//! (preserving `rbf`, `size`). The Applier exhumes the cached tx
//! body. No raw decoding.
use std::mem;
use brk_rpc::RawTx;
use brk_types::{MempoolEntryInfo, Timestamp, Transaction, TxIn, TxOut, TxStatus, Txid, Vout};
use brk_types::{MempoolEntryInfo, Transaction, TxIn, TxOut, TxStatus, Txid, Vout};
use rustc_hash::FxHashMap;
use crate::{TxTombstone, stores::TxStore};
@@ -35,7 +35,7 @@ impl TxAddition {
let total_size = raw.hex.len() / 2;
let rbf = raw.tx.input.iter().any(|i| i.sequence.is_rbf());
let tx = Self::build_tx(info, raw, total_size, mempool_txs, parent_raws);
let entry = TxEntry::new(info, total_size as u64, rbf, Timestamp::now());
let entry = TxEntry::new(info, total_size as u64, rbf);
Self::Fresh { tx, entry }
}
@@ -68,7 +68,7 @@ impl TxAddition {
}
pub(super) fn revived(info: &MempoolEntryInfo, tomb: &TxTombstone) -> Self {
let entry = TxEntry::new(info, tomb.entry.size, tomb.entry.rbf, tomb.entry.first_seen);
let entry = TxEntry::new(info, tomb.entry.size, tomb.entry.rbf);
Self::Revived { entry }
}

View File

@@ -26,14 +26,14 @@ pub struct TxEntry {
}
impl TxEntry {
pub(super) fn new(info: &MempoolEntryInfo, size: u64, rbf: bool, first_seen: Timestamp) -> Self {
pub(super) fn new(info: &MempoolEntryInfo, size: u64, rbf: bool) -> Self {
Self {
txid: info.txid.clone(),
fee: info.fee,
vsize: VSize::from(info.vsize),
size,
depends: info.depends.iter().map(TxidPrefix::from).collect(),
first_seen,
first_seen: info.first_seen,
rbf,
}
}

View File

@@ -250,7 +250,7 @@ impl Query {
(first_seen, txid)
})
.collect();
ordered.sort_unstable_by(|a, b| b.0.cmp(&a.0));
ordered.sort_unstable_by_key(|b| std::cmp::Reverse(b.0));
let txs = mempool.txs();
Ok(ordered
.into_iter()

View File

@@ -11,62 +11,76 @@ use vecdb::{AnyVec, ReadableVec, VecIndex};
use crate::Query;
const DEFAULT_BLOCK_COUNT: u32 = 10;
const DEFAULT_V1_BLOCK_COUNT: u32 = 15;
const HEADER_SIZE: usize = 80;
impl Query {
/// Block by hash. Unknown hash → 404 via `height_by_hash`.
pub fn block(&self, hash: &BlockHash) -> Result<BlockInfo> {
let height = self.height_by_hash(hash)?;
self.block_by_height(height)
}
/// Block by height. Height > tip → `OutOfRange`.
pub fn block_by_height(&self, height: Height) -> Result<BlockInfo> {
let max_height = self.indexed_height();
if height > max_height {
if height > self.tip_height() {
return Err(Error::OutOfRange("Block height out of range".into()));
}
self.blocks_range(height.to_usize(), height.to_usize() + 1)?
let h = height.to_usize();
self.blocks_range(h, h + 1)?
.pop()
.ok_or(Error::NotFound("Block not found".into()))
}
/// V1 block by height. Ceiling is `min(indexed, computed)` because
/// `blocks_v1_range` reads computer-stamped series (pools, fees,
/// supply state). Anything past `computed_height` would short-read.
pub fn block_by_height_v1(&self, height: Height) -> Result<BlockInfoV1> {
let max_height = self.height();
if height > max_height {
if height > self.height() {
return Err(Error::OutOfRange("Block height out of range".into()));
}
self.blocks_v1_range(height.to_usize(), height.to_usize() + 1)?
let h = height.to_usize();
self.blocks_v1_range(h, h + 1)?
.pop()
.ok_or(Error::NotFound("Block not found".into()))
}
/// Hex-encoded 80-byte block header. Decode-then-encode roundtrip
/// doubles as a corruption check on the on-disk bytes.
pub fn block_header_hex(&self, hash: &BlockHash) -> Result<String> {
let height = self.height_by_hash(hash)?;
let header = self.read_block_header(height)?;
Ok(bitcoin::consensus::encode::serialize_hex(&header))
}
/// Block hash by height. Cheap typed-index read with a semantic
/// bounds gate (`OutOfRange` for past-tip, `Internal` if the data
/// is unexpectedly missing inside the gate).
pub fn block_hash_by_height(&self, height: Height) -> Result<BlockHash> {
let max_height = self.indexed_height();
if height > max_height {
if height > self.tip_height() {
return Err(Error::OutOfRange("Block height out of range".into()));
}
self.indexer().vecs.blocks.blockhash.get(height).data()
}
pub fn blocks(&self, start_height: Option<Height>) -> Result<Vec<BlockInfo>> {
let (begin, end) = self.resolve_block_range(start_height, DEFAULT_BLOCK_COUNT);
/// Most recent `count` blocks ending at `start_height` (default tip),
/// returned in descending-height order.
pub fn blocks(&self, start_height: Option<Height>, count: u32) -> Result<Vec<BlockInfo>> {
let (begin, end) = self.resolve_block_range(start_height, count);
self.blocks_range(begin, end)
}
pub fn blocks_v1(&self, start_height: Option<Height>) -> Result<Vec<BlockInfoV1>> {
let (begin, end) = self.resolve_block_range(start_height, DEFAULT_V1_BLOCK_COUNT);
/// V1 most recent `count` blocks with extras ending at `start_height`
/// (default tip), returned in descending-height order.
pub fn blocks_v1(&self, start_height: Option<Height>, count: u32) -> Result<Vec<BlockInfoV1>> {
let (begin, end) = self.resolve_block_range(start_height, count);
self.blocks_v1_range(begin, end)
}
// === Range queries (bulk reads) ===
/// Build `BlockInfo` rows for `[begin, end)` in descending-height order.
/// Caller must bounds-check `end <= tip + 1`. Returns `Internal` if any
/// bulk read short-returns under per-vec stamp races.
fn blocks_range(&self, begin: usize, end: usize) -> Result<Vec<BlockInfo>> {
if begin >= end {
return Ok(Vec::new());
@@ -75,6 +89,7 @@ impl Query {
let indexer = self.indexer();
let computer = self.computer();
let reader = self.reader();
let count = end - begin;
// Bulk read all indexed data
let blockhashes = indexer.vecs.blocks.blockhash.collect_range_at(begin, end);
@@ -83,6 +98,15 @@ impl Query {
let sizes = indexer.vecs.blocks.total.collect_range_at(begin, end);
let weights = indexer.vecs.blocks.weight.collect_range_at(begin, end);
let positions = indexer.vecs.blocks.position.collect_range_at(begin, end);
if blockhashes.len() != count
|| difficulties.len() != count
|| timestamps.len() != count
|| sizes.len() != count
|| weights.len() != count
|| positions.len() != count
{
return Err(Error::Internal("blocks_range: short read on per-block vecs"));
}
// Bulk read tx indexes for tx_count
let max_height = self.indexed_height();
@@ -96,6 +120,9 @@ impl Query {
.transactions
.first_tx_index
.collect_range_at(begin, tx_index_end);
if first_tx_indexes.len() < count {
return Err(Error::Internal("blocks_range: short read on first_tx_index"));
}
let total_txs = computer.indexes.tx_index.identity.len();
// Bulk read median time window
@@ -105,8 +132,10 @@ impl Query {
.blocks
.timestamp
.collect_range_at(median_start, end);
if median_timestamps.len() != end - median_start {
return Err(Error::Internal("blocks_range: short read on median window"));
}
let count = end - begin;
let mut blocks = Vec::with_capacity(count);
for i in (0..count).rev() {
@@ -431,6 +460,10 @@ impl Query {
// === Helper methods ===
pub fn tip_height(&self) -> Height {
Height::from(self.indexer().vecs.blocks.blockhash.len().saturating_sub(1))
}
pub fn height_by_hash(&self, hash: &BlockHash) -> Result<Height> {
let indexer = self.indexer();
let prefix = BlockHashPrefix::from(hash);

View File

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

View File

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

View File

@@ -1,27 +1,37 @@
use brk_error::{Error, OptionData, Result};
use brk_types::{BlockTimestamp, Date, Day1, Height, Timestamp};
use jiff::Timestamp as JiffTimestamp;
use vecdb::ReadableVec;
use vecdb::{AnyVec, ReadableVec};
use crate::Query;
/// Per BIP113, a block's timestamp must exceed the median of the previous 11
/// blocks. Eleven consecutive `ts > target` therefore prove no later block can
/// have `ts ≤ target` (its median floor would already exceed `target`).
const MTP_TERMINAL_STREAK: usize = 11;
impl Query {
/// Most recent block with `timestamp ≤ ts`. Backs mempool.space's
/// `GET /api/v1/mining/blocks/timestamp/{ts}`. Future timestamps return
/// the chain tip; pre-genesis timestamps return 404.
///
/// Uses `day1.first_height` for an O(1) seek to the target date, then a
/// linear scan bounded by the BIP113 MTP rule (see `MTP_TERMINAL_STREAK`).
/// Symmetric backward scan handles targets earlier than the seeded day's
/// first block.
pub fn block_by_timestamp(&self, timestamp: Timestamp) -> Result<BlockTimestamp> {
let indexer = self.indexer();
let computer = self.computer();
let max_height = self.indexed_height();
let max_height_usize: usize = max_height.into();
if max_height_usize == 0 {
if indexer.vecs.blocks.blockhash.len() == 0 {
return Err(Error::NotFound("No blocks indexed".into()));
}
let tip: usize = self.tip_height().into();
let target = timestamp;
let date = Date::from(target);
let day1 = Day1::try_from(date).unwrap_or_default();
// Get first height of the target date
let first_height_of_day = computer
.indexes
.day1
@@ -29,37 +39,46 @@ impl Query {
.collect_one(day1)
.unwrap_or(Height::from(0usize));
let start: usize = usize::from(first_height_of_day).min(max_height_usize);
let start: usize = usize::from(first_height_of_day).min(tip);
let mut ts_cursor = indexer.vecs.blocks.timestamp.cursor();
let mut best: Option<(usize, Timestamp)> = None;
// Search forward from start to find the last block <= target timestamp
let mut best_height = start;
let mut best_ts = ts_cursor.get(start).data()?;
for h in (start + 1)..=max_height_usize {
let mut above_streak = 0usize;
for h in start..=tip {
let block_ts = ts_cursor.get(h).data()?;
if block_ts <= target {
best_height = h;
best_ts = block_ts;
best = Some((h, block_ts));
above_streak = 0;
} else {
break;
above_streak += 1;
if above_streak >= MTP_TERMINAL_STREAK {
break;
}
}
}
// Check one block before start in case we need to go backward
if start > 0 && best_ts > target {
let prev_ts = ts_cursor.get(start - 1).data()?;
if prev_ts <= target {
best_height = start - 1;
best_ts = prev_ts;
if best.is_none() && start > 0 {
let mut above_streak = 0usize;
for h in (0..start).rev() {
let block_ts = ts_cursor.get(h).data()?;
if block_ts <= target {
best = Some((h, block_ts));
break;
}
above_streak += 1;
if above_streak >= MTP_TERMINAL_STREAK {
break;
}
}
}
let (best_height, best_ts) =
best.ok_or_else(|| Error::NotFound("No block at or before timestamp".into()))?;
let height = Height::from(best_height);
let blockhash = indexer.vecs.blocks.blockhash.collect_one(height).data()?;
// Convert timestamp to ISO 8601 format
let ts_secs: i64 = (*best_ts).into();
let iso_timestamp = JiffTimestamp::from_second(ts_secs)
.map(|t| t.strftime("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
use brk_error::Result;
use brk_types::{BlockFeesEntry, TimePeriod};
use brk_types::{BlockFeesEntry, Cents, Dollars, Sats, TimePeriod};
use super::block_window::BlockWindow;
use crate::Query;
@@ -7,15 +7,17 @@ use crate::Query;
impl Query {
pub fn block_fees(&self, time_period: TimePeriod) -> Result<Vec<BlockFeesEntry>> {
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
.cumulative_averages(self, cumulative)
.into_iter()
.map(|w| BlockFeesEntry {
avg_height: w.avg_height,
timestamp: w.timestamp,
avg_fees: w.avg_value,
usd: w.usd,
.buckets
.iter()
.map(|b| BlockFeesEntry {
avg_height: b.avg_height,
timestamp: b.avg_timestamp,
avg_fees: b.mean_rounded(&fees),
usd: Dollars::from(b.mean_rounded(&prices)),
})
.collect())
}

View File

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

View File

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

View File

@@ -1,155 +1,117 @@
use brk_types::{Cents, Dollars, Height, Sats, TimePeriod, Timestamp};
use vecdb::{ReadableVec, VecIndex};
use std::{
collections::BTreeMap,
iter::Sum,
ops::{Deref, Div},
};
use brk_types::{Height, TimePeriod, Timestamp};
use vecdb::{ReadableVec, VecValue};
use crate::Query;
/// Number of blocks per aggregation window, matching mempool.space's granularity.
fn block_window(period: TimePeriod) -> usize {
/// Mempool.space's `GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}` divisor in seconds.
/// `div = 1` puts each block in its own bucket.
fn time_div(period: TimePeriod) -> u32 {
match period {
TimePeriod::Day | TimePeriod::ThreeDays | TimePeriod::Week => 1,
TimePeriod::Month => 3,
TimePeriod::ThreeMonths => 12,
TimePeriod::SixMonths => 18,
TimePeriod::Year | TimePeriod::TwoYears => 48,
TimePeriod::ThreeYears => 72,
TimePeriod::All => 144,
TimePeriod::Day | TimePeriod::ThreeDays => 1,
TimePeriod::Week => 300,
TimePeriod::Month => 1800,
TimePeriod::ThreeMonths => 7200,
TimePeriod::SixMonths => 10800,
TimePeriod::Year | TimePeriod::TwoYears => 28800,
TimePeriod::ThreeYears => 43200,
TimePeriod::All => 86400,
}
}
/// Per-window average with metadata.
pub struct WindowAvg {
pub avg_height: Height,
pub timestamp: Timestamp,
pub avg_value: Sats,
pub usd: Dollars,
/// Round-half-up integer division, matching MySQL's `CAST(AVG(...) AS INT)`.
const fn round_half_up(sum: u64, n: u64) -> u64 {
(sum + n / 2) / n
}
/// Block range and window size for a time period.
/// One time-bucket of blocks in a `BlockWindow`.
pub struct BlockBucket {
pub avg_height: Height,
pub avg_timestamp: Timestamp,
/// Offsets into the parent `BlockWindow`'s prefetched `[start, end)` slice.
offsets: Vec<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 start: usize,
pub end: usize,
pub window: usize,
pub start: Height,
pub end: Height,
pub buckets: Vec<BlockBucket>,
}
impl BlockWindow {
pub fn new(query: &Query, time_period: TimePeriod) -> Self {
let current_height = query.height();
let computer = query.computer();
let lookback = &computer.blocks.lookback;
pub fn new(query: &Query, period: TimePeriod) -> Self {
let start = query.start_height(period);
let end = query.height() + 1usize;
let div = time_div(period);
// Use pre-computed timestamp-based lookback for accurate time boundaries.
// 24h, 1w, 1m, 1y use in-memory CachedVec; others fall back to PcoVec.
let start_height = match time_period {
TimePeriod::Day => lookback._24h.collect_one(current_height),
TimePeriod::ThreeDays => lookback._3d.collect_one(current_height),
TimePeriod::Week => lookback._1w.collect_one(current_height),
TimePeriod::Month => lookback._1m.collect_one(current_height),
TimePeriod::ThreeMonths => lookback._3m.collect_one(current_height),
TimePeriod::SixMonths => lookback._6m.collect_one(current_height),
TimePeriod::Year => lookback._1y.collect_one(current_height),
TimePeriod::TwoYears => lookback._2y.collect_one(current_height),
TimePeriod::ThreeYears => lookback._3y.collect_one(current_height),
TimePeriod::All => None,
}
.unwrap_or_default();
Self {
start: start_height.to_usize(),
end: current_height.to_usize() + 1,
window: block_window(time_period),
}
}
/// Compute per-window averages from a cumulative sats vec.
/// Batch-reads timestamps, prices, and the cumulative in one pass.
pub fn cumulative_averages(
&self,
query: &Query,
cumulative: &impl ReadableVec<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
let timestamps: Vec<Timestamp> = query
.indexer()
.vecs
.blocks
.timestamp
.collect_range_at(self.start, self.end);
let mut timestamps = Vec::with_capacity(self.count());
let mut pos = 0;
while pos < all_ts.len() {
let window_end = (pos + self.window).min(all_ts.len());
timestamps.push(all_ts[(pos + window_end) / 2]);
pos = window_end;
.collect_range(start, end);
let mut groups: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
for (i, ts) in timestamps.iter().enumerate() {
groups.entry(**ts / div).or_default().push(i);
}
let buckets = groups
.into_values()
.map(|offsets| {
let n = offsets.len() as u64;
let sum_h: u64 = offsets.iter().map(|&i| u64::from(start + i)).sum();
let sum_ts: u64 = offsets.iter().map(|&i| u64::from(timestamps[i])).sum();
BlockBucket {
avg_height: Height::from(round_half_up(sum_h, n)),
avg_timestamp: Timestamp::from(round_half_up(sum_ts, n) as u32),
offsets,
}
})
.collect();
Self {
start,
end,
buckets,
}
timestamps
}
/// Number of windows in this range.
fn count(&self) -> usize {
(self.end - self.start).div_ceil(self.window)
}
/// Iterate windows, yielding (avg_height, window_start, window_end) for each.
pub fn iter(&self) -> impl Iterator<Item = (Height, usize, usize)> + '_ {
let mut pos = self.start;
std::iter::from_fn(move || {
if pos >= self.end {
return None;
}
let window_end = (pos + self.window).min(self.end);
let avg_height = Height::from((pos + window_end) / 2);
let start = pos;
pos = window_end;
Some((avg_height, start, window_end))
})
/// Read a height-keyed vec over this window's `[start, end)` range.
pub fn read<V, T>(&self, vec: &V) -> Vec<T>
where
V: ReadableVec<Height, T>,
T: VecValue,
{
vec.collect_range(self.start, self.end)
}
}

View File

@@ -10,10 +10,11 @@ impl Query {
&self,
time_period: Option<TimePeriod>,
) -> Result<Vec<DifficultyAdjustmentEntry>> {
let current_height = self.height();
let end = current_height.to_usize();
let end = self.height().to_usize();
// Match mempool.space's wall-clock `time > NOW() - INTERVAL ${period}` cutoff
// by walking back through real block timestamps, not estimating via block count.
let start = match time_period {
Some(tp) => end.saturating_sub(tp.block_count()),
Some(tp) => self.start_height(tp).to_usize(),
None => 0,
};

View File

@@ -7,5 +7,6 @@ mod difficulty;
mod difficulty_adjustments;
mod epochs;
mod hashrate;
mod period_start;
mod pools;
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 brk_error::{Error, Result};
use brk_types::{Bitcoin, BlockHash, FeeRate, Height, MempoolEntryInfo, Sats, Txid, Vout};
use brk_types::{Bitcoin, BlockHash, FeeRate, Height, MempoolEntryInfo, Sats, Timestamp, Txid, Vout};
use corepc_jsonrpc::error::Error as JsonRpcError;
use corepc_types::v30::{
GetBlockCount, GetBlockHash, GetBlockHeader, GetBlockHeaderVerbose, GetBlockVerboseOne,
@@ -211,6 +211,7 @@ impl Client {
vsize: entry.vsize as u64,
weight: entry.weight as u64,
fee: Sats::from(Bitcoin::from(entry.fees.base)),
first_seen: Timestamp::from(entry.time),
ancestor_count: entry.ancestor_count as u64,
ancestor_size: entry.ancestor_size as u64,
ancestor_fee: Sats::from(Bitcoin::from(entry.fees.ancestor)),

View File

@@ -327,7 +327,7 @@ impl BlockRoutes for ApiRouter<AppState> {
get_with(
async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State<AppState>| {
state
.respond_json(&headers, CacheStrategy::Tip, &uri, move |q| q.blocks(None))
.respond_json(&headers, CacheStrategy::Tip, &uri, move |q| q.blocks(None, 10))
.await
},
|op| {
@@ -348,7 +348,7 @@ impl BlockRoutes for ApiRouter<AppState> {
headers: HeaderMap,
Path(path): Path<HeightParam>,
_: 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.id("get_blocks_from_height")
@@ -369,14 +369,14 @@ impl BlockRoutes for ApiRouter<AppState> {
get_with(
async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State<AppState>| {
state
.respond_json(&headers, CacheStrategy::Tip, &uri, move |q| q.blocks_v1(None))
.respond_json(&headers, CacheStrategy::Tip, &uri, move |q| q.blocks_v1(None, 15))
.await
},
|op| {
op.id("get_blocks_v1")
.blocks_tag()
.summary("Recent blocks with extras")
.description("Retrieve the last 10 blocks with extended data including pool identification and fee statistics.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*")
.description("Retrieve the last 15 blocks with extended data including pool identification and fee statistics.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*")
.json_response::<Vec<BlockInfoV1>>()
.not_modified()
.server_error()
@@ -390,13 +390,13 @@ impl BlockRoutes for ApiRouter<AppState> {
headers: HeaderMap,
Path(path): Path<HeightParam>,
_: 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.id("get_blocks_v1_from_height")
.blocks_tag()
.summary("Blocks from height with extras")
.description("Retrieve up to 10 blocks with extended data going backwards from the given height.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*")
.description("Retrieve up to 15 blocks with extended data going backwards from the given height.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*")
.json_response::<Vec<BlockInfoV1>>()
.not_modified()
.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 serde::{Deserialize, Serialize};
use vecdb::{CheckedSub, Formattable, Pco};
@@ -11,6 +15,7 @@ use super::{CentsSats, Dollars, Sats, StoredF64};
#[derive(
Debug,
Default,
Deref,
Clone,
Copy,
PartialEq,
@@ -187,6 +192,12 @@ impl AddAssign for Cents {
}
}
impl Sum for Cents {
fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
Self(iter.map(|c| c.0).sum())
}
}
impl Sub for Cents {
type Output = Self;
#[inline]

View File

@@ -1,8 +1,30 @@
use derive_more::Deref;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{FeeRate, Sats, Txid, VSize, Weight};
/// Position of a transaction inside a `CpfpCluster.txs` array. Cluster-local,
/// has no meaning outside the enclosing cluster.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash,
Default, Deref, Serialize, Deserialize, JsonSchema,
)]
#[serde(transparent)]
pub struct CpfpClusterTxIndex(u32);
impl From<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
#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
@@ -18,12 +40,19 @@ pub struct CpfpInfo {
/// Effective fee rate considering CPFP relationships (sat/vB)
#[serde(skip_serializing_if = "Option::is_none")]
pub effective_fee_per_vsize: Option<FeeRate>,
/// Total signature operation count for the seed tx
#[serde(skip_serializing_if = "Option::is_none")]
pub sigops: Option<u32>,
/// Transaction fee (sats)
#[serde(skip_serializing_if = "Option::is_none")]
pub fee: Option<Sats>,
/// Adjusted virtual size (accounting for sigops)
#[serde(skip_serializing_if = "Option::is_none")]
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
@@ -36,3 +65,35 @@ pub struct CpfpEntry {
/// Transaction fee (sats)
pub fee: Sats,
}
/// CPFP cluster output for an unconfirmed tx: the connected component
/// the seed belongs to, plus its SFL linearization.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CpfpCluster {
/// All txs in the cluster, in topological order (parents before children).
pub txs: Vec<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::{
cmp::Ordering,
iter::Sum,
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 {
type Output = Self;
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
#[derive(Debug, Clone)]
@@ -7,6 +7,7 @@ pub struct MempoolEntryInfo {
pub vsize: u64,
pub weight: u64,
pub fee: Sats,
pub first_seen: Timestamp,
pub ancestor_count: u64,
pub ancestor_size: u64,
pub ancestor_fee: Sats,

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 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 {
type Output = Self;
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 {
#[inline]
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 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 {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {

View File

@@ -366,6 +366,37 @@ Matches mempool.space/bitcoin-cli behavior.
*
* @typedef {("supply"|"realized"|"unrealized")} CostBasisValue
*/
/**
* CPFP cluster output for an unconfirmed tx: the connected component
* the seed belongs to, plus its SFL linearization.
*
* @typedef {Object} CpfpCluster
* @property {CpfpClusterTx[]} txs - All txs in the cluster, in topological order (parents before children).
* @property {CpfpClusterChunk[]} chunks - SFL-emitted chunks ordered by descending feerate.
* @property {number} chunkIndex - Index into `chunks` of the chunk containing the seed tx.
*/
/**
* One SFL chunk inside a `CpfpCluster`.
*
* @typedef {Object} CpfpClusterChunk
* @property {CpfpClusterTxIndex[]} txs - Txs in this chunk.
* @property {FeeRate} feerate - Combined feerate of the chunk (sat/vB).
*/
/**
* One entry in a `CpfpCluster.txs` array.
*
* @typedef {Object} CpfpClusterTx
* @property {Txid} txid
* @property {Sats} fee
* @property {Weight} weight
* @property {CpfpClusterTxIndex[]} parents - In-cluster parents of this tx.
*/
/**
* Position of a transaction inside a `CpfpCluster.txs` array. Cluster-local,
* has no meaning outside the enclosing cluster.
*
* @typedef {number} CpfpClusterTxIndex
*/
/**
* A transaction in a CPFP relationship
*
@@ -382,8 +413,11 @@ Matches mempool.space/bitcoin-cli behavior.
* @property {(CpfpEntry|null)=} bestDescendant - Best (highest fee rate) descendant, if any
* @property {CpfpEntry[]} descendants - Descendant transactions in the CPFP chain
* @property {(FeeRate|null)=} effectiveFeePerVsize - Effective fee rate considering CPFP relationships (sat/vB)
* @property {?number=} sigops - Total signature operation count for the seed tx
* @property {(Sats|null)=} fee - Transaction fee (sats)
* @property {(VSize|null)=} adjustedVsize - Adjusted virtual size (accounting for sigops)
* @property {(CpfpCluster|null)=} cluster - Mempool cluster the seed belongs to: full tx list, SFL-linearized
chunks, and the seed's chunk index. Only set for unconfirmed txs.
*/
/**
* Range parameters with output format for API query parameters.
@@ -1805,7 +1839,10 @@ class BrkClientBase {
const value = await parse(res);
this._memSet(url, netEtag, value);
if (onValue) onValue(value);
if (cloned) _runIdle(() => browserCache.put(url, cloned));
if (cloned && browserCache) {
const cache = browserCache;
_runIdle(() => cache.put(url, cloned));
}
return value;
} catch {
return memHit.value;
@@ -1836,7 +1873,10 @@ class BrkClientBase {
const value = await parse(res);
this._memSet(url, netEtag, value);
if (onValue) onValue(value);
if (cloned) _runIdle(() => browserCache.put(url, cloned));
if (cloned && browserCache) {
const cache = browserCache;
_runIdle(() => cache.put(url, cloned));
}
return value;
} catch (e) {
const stale = await stalePromise;
@@ -7476,171 +7516,171 @@ class BrkClient extends BrkClientBase {
]);
POOL_ID_TO_POOL_NAME = /** @type {const} */ ({
"unknown": "Unknown",
"blockfills": "BlockFills",
"ultimuspool": "ULTIMUSPOOL",
"terrapool": "Terra Pool",
"luxor": "Luxor",
"1thash": "1THash",
"btccom": "BTC.com",
"bitfarms": "Bitfarms",
"huobipool": "Huobi.pool",
"wayicn": "WAYI.CN",
"canoepool": "CanoePool",
"btctop": "BTC.TOP",
"bitcoincom": "Bitcoin.com",
"175btc": "175btc",
"gbminers": "GBMiners",
"axbt": "A-XBT",
"asicminer": "ASICMiner",
"bitminter": "BitMinter",
"bitcoinrussia": "BitcoinRussia",
"btcserv": "BTCServ",
"simplecoinus": "simplecoin.us",
"btcguild": "BTC Guild",
"eligius": "Eligius",
"ozcoin": "OzCoin",
"eclipsemc": "EclipseMC",
"maxbtc": "MaxBTC",
"triplemining": "TripleMining",
"coinlab": "CoinLab",
"50btc": "50BTC",
"ghashio": "GHash.IO",
"stminingcorp": "ST Mining Corp",
"bitparking": "Bitparking",
"mmpool": "mmpool",
"polmine": "Polmine",
"kncminer": "KnCMiner",
"bitalo": "Bitalo",
"f2pool": "F2Pool",
"hhtt": "HHTT",
"megabigpower": "MegaBigPower",
"mtred": "Mt Red",
"nmcbit": "NMCbit",
"yourbtcnet": "Yourbtc.net",
"givemecoins": "Give Me Coins",
"braiinspool": "Braiins Pool",
"aaopool": "AAO Pool",
"antpool": "AntPool",
"multicoinco": "MultiCoin.co",
"arkpool": "ArkPool",
"asicminer": "ASICMiner",
"axbt": "A-XBT",
"batpool": "BATPOOL",
"bcmonster": "BCMonster",
"bcpoolio": "bcpool.io",
"cointerra": "Cointerra",
"kanopool": "KanoPool",
"solock": "Solo CK",
"ckpool": "CKPool",
"nicehash": "NiceHash",
"binancepool": "Binance Pool",
"bitalo": "Bitalo",
"bitclub": "BitClub",
"bitcoinaffiliatenetwork": "Bitcoin Affiliate Network",
"btcc": "BTCC",
"bwpool": "BWPool",
"exxbw": "EXX&BW",
"bitsolo": "Bitsolo",
"bitfury": "BitFury",
"21inc": "21 Inc.",
"digitalbtc": "digitalBTC",
"8baochi": "8baochi",
"mybtccoinpool": "myBTCcoin Pool",
"tbdice": "TBDice",
"hashpool": "HASHPOOL",
"nexious": "Nexious",
"bravomining": "Bravo Mining",
"hotpool": "HotPool",
"okexpool": "OKExPool",
"bcmonster": "BCMonster",
"1hash": "1Hash",
"bixin": "Bixin",
"tatmaspool": "TATMAS Pool",
"viabtc": "ViaBTC",
"connectbtc": "ConnectBTC",
"batpool": "BATPOOL",
"waterhole": "Waterhole",
"dcexploration": "DCExploration",
"dcex": "DCEX",
"btpool": "BTPOOL",
"58coin": "58COIN",
"bitcoincom": "Bitcoin.com",
"bitcoinindia": "Bitcoin India",
"shawnp0wers": "shawnp0wers",
"phashio": "PHash.IO",
"rigpool": "RigPool",
"haozhuzhu": "HAOZHUZHU",
"7pool": "7pool",
"miningkings": "MiningKings",
"hashbx": "HashBX",
"dpool": "DPOOL",
"rawpool": "Rawpool",
"haominer": "haominer",
"helix": "Helix",
"bitcoinukraine": "Bitcoin-Ukraine",
"poolin": "Poolin",
"secretsuperstar": "SecretSuperstar",
"tigerpoolnet": "tigerpool.net",
"sigmapoolcom": "Sigmapool.com",
"okpooltop": "okpool.top",
"hummerpool": "Hummerpool",
"tangpool": "Tangpool",
"bytepool": "BytePool",
"spiderpool": "SpiderPool",
"novablock": "NovaBlock",
"miningcity": "MiningCity",
"binancepool": "Binance Pool",
"minerium": "Minerium",
"lubiancom": "Lubian.com",
"okkong": "OKKONG",
"aaopool": "AAO Pool",
"emcdpool": "EMCDPool",
"foundryusa": "Foundry USA",
"sbicrypto": "SBI Crypto",
"arkpool": "ArkPool",
"purebtccom": "PureBTC.COM",
"marapool": "MARA Pool",
"kucoinpool": "KuCoinPool",
"entrustcharitypool": "Entrust Charity Pool",
"okminer": "OKMINER",
"titan": "Titan",
"pegapool": "PEGA Pool",
"btcnuggets": "BTC Nuggets",
"cloudhashing": "CloudHashing",
"digitalxmintsy": "digitalX Mintsy",
"telco214": "Telco 214",
"btcpoolparty": "BTC Pool Party",
"multipool": "Multipool",
"transactioncoinmining": "transactioncoinmining",
"btcdig": "BTCDig",
"trickysbtcpool": "Tricky's BTC Pool",
"btcmp": "BTCMP",
"eobot": "Eobot",
"unomp": "UNOMP",
"patels": "Patels",
"gogreenlight": "GoGreenLight",
"bitcoinindiapool": "BitcoinIndia",
"ekanembtc": "EkanemBTC",
"bitcoinrussia": "BitcoinRussia",
"bitcoinukraine": "Bitcoin-Ukraine",
"bitfarms": "Bitfarms",
"bitfufupool": "BitFuFuPool",
"bitfury": "BitFury",
"bitminter": "BitMinter",
"bitparking": "Bitparking",
"bitsolo": "Bitsolo",
"bixin": "Bixin",
"blockfills": "BlockFills",
"braiinspool": "Braiins Pool",
"braiinssolo": "Braiins Solo",
"bravomining": "Bravo Mining",
"btcc": "BTCC",
"btccom": "BTC.com",
"btcdig": "BTCDig",
"btcguild": "BTC Guild",
"btclab": "BTCLab",
"btcmp": "BTCMP",
"btcnuggets": "BTC Nuggets",
"btcpoolparty": "BTC Pool Party",
"btcserv": "BTCServ",
"btctop": "BTC.TOP",
"btpool": "BTPOOL",
"bwpool": "BWPool",
"bytepool": "BytePool",
"canoe": "CANOE",
"tiger": "tiger",
"1m1x": "1M1X",
"zulupool": "Zulupool",
"secpool": "SECPOOL",
"canoepool": "CanoePool",
"carbonnegative": "Carbon Negative",
"ckpool": "CKPool",
"cloudhashing": "CloudHashing",
"coinlab": "CoinLab",
"cointerra": "Cointerra",
"connectbtc": "ConnectBTC",
"dcex": "DCEX",
"dcexploration": "DCExploration",
"digitalbtc": "digitalBTC",
"digitalxmintsy": "digitalX Mintsy",
"dpool": "DPOOL",
"eclipsemc": "EclipseMC",
"eightbaochi": "8baochi",
"ekanembtc": "EkanemBTC",
"eligius": "Eligius",
"emcdpool": "EMCDPool",
"entrustcharitypool": "Entrust Charity Pool",
"eobot": "Eobot",
"est3lar": "Est3lar",
"exxbw": "EXX&BW",
"f2pool": "F2Pool",
"fiftyeightcoin": "58COIN",
"foundryusa": "Foundry USA",
"futurebitapollosolo": "FutureBit Apollo Solo",
"gbminers": "GBMiners",
"gdpool": "GDPool",
"ghashio": "GHash.IO",
"givemecoins": "Give Me Coins",
"gogreenlight": "GoGreenLight",
"haominer": "haominer",
"haozhuzhu": "HAOZHUZHU",
"hashbx": "HashBX",
"hashpool": "HASHPOOL",
"helix": "Helix",
"hhtt": "HHTT",
"hotpool": "HotPool",
"hummerpool": "Hummerpool",
"huobipool": "Huobi.pool",
"innopolistech": "Innopolis Tech",
"kanopool": "KanoPool",
"kncminer": "KnCMiner",
"kucoinpool": "KuCoinPool",
"lubiancom": "Lubian.com",
"luxor": "Luxor",
"marapool": "MARA Pool",
"maxbtc": "MaxBTC",
"maxipool": "MaxiPool",
"megabigpower": "MegaBigPower",
"minerium": "Minerium",
"miningcity": "MiningCity",
"miningdutch": "Mining-Dutch",
"miningkings": "MiningKings",
"miningsquared": "Mining Squared",
"mmpool": "mmpool",
"mtred": "Mt Red",
"multicoinco": "MultiCoin.co",
"multipool": "Multipool",
"mybtccoinpool": "myBTCcoin Pool",
"neopool": "Neopool",
"nexious": "Nexious",
"nicehash": "NiceHash",
"nmcbit": "NMCbit",
"noderunners": "Noderunners",
"novablock": "NovaBlock",
"ocean": "OCEAN",
"okexpool": "OKExPool",
"okkong": "OKKONG",
"okminer": "OKMINER",
"okpooltop": "okpool.top",
"onehash": "1Hash",
"onem1x": "1M1X",
"onethash": "1THash",
"ozcoin": "OzCoin",
"parasite": "Parasite",
"patels": "Patels",
"pegapool": "PEGA Pool",
"phashio": "PHash.IO",
"phoenix": "Phoenix",
"polmine": "Polmine",
"pool175btc": "175btc",
"pool50btc": "50BTC",
"poolin": "Poolin",
"portlandhodl": "Portland.HODL",
"publicpool": "Public Pool",
"purebtccom": "PureBTC.COM",
"rawpool": "Rawpool",
"redrockpool": "RedRock Pool",
"rigpool": "RigPool",
"sbicrypto": "SBI Crypto",
"secpool": "SECPOOL",
"secretsuperstar": "SecretSuperstar",
"sevenpool": "7pool",
"shawnp0wers": "shawnp0wers",
"sigmapoolcom": "Sigmapool.com",
"simplecoinus": "simplecoin.us",
"solock": "Solo CK",
"solopool": "SoloPool.com",
"spiderpool": "SpiderPool",
"stminingcorp": "ST Mining Corp",
"tangpool": "Tangpool",
"tatmaspool": "TATMAS Pool",
"tbdice": "TBDice",
"telco214": "Telco 214",
"terrapool": "Terra Pool",
"tiger": "tiger",
"tigerpoolnet": "tigerpool.net",
"titan": "Titan",
"transactioncoinmining": "transactioncoinmining",
"trickysbtcpool": "Tricky's BTC Pool",
"triplemining": "TripleMining",
"twentyoneinc": "21 Inc.",
"ultimuspool": "ULTIMUSPOOL",
"unknown": "Unknown",
"unomp": "UNOMP",
"viabtc": "ViaBTC",
"waterhole": "Waterhole",
"wayicn": "WAYI.CN",
"whitepool": "WhitePool",
"wiz": "wiz",
"wk057": "wk057",
"futurebitapollosolo": "FutureBit Apollo Solo",
"carbonnegative": "Carbon Negative",
"portlandhodl": "Portland.HODL",
"phoenix": "Phoenix",
"neopool": "Neopool",
"maxipool": "MaxiPool",
"bitfufupool": "BitFuFuPool",
"gdpool": "GDPool",
"miningdutch": "Mining-Dutch",
"publicpool": "Public Pool",
"miningsquared": "Mining Squared",
"innopolistech": "Innopolis Tech",
"btclab": "BTCLab",
"parasite": "Parasite",
"redrockpool": "RedRock Pool",
"est3lar": "Est3lar",
"braiinssolo": "Braiins Solo",
"solopoolcom": "SoloPool.com",
"noderunners": "Noderunners"
"yourbtcnet": "Yourbtc.net",
"zulupool": "Zulupool"
});
TERM_NAMES = /** @type {const} */ ({
@@ -10739,7 +10779,7 @@ class BrkClient extends BrkClientBase {
*/
async getBlockTipHeight({ signal, onValue } = {}) {
const path = `/api/blocks/tip/height`;
return Number(await this.getText(path, { signal, onValue }));
return Number(await this.getText(path, { signal, onValue: onValue ? (v) => onValue(Number(v)) : undefined }));
}
/**
@@ -10843,8 +10883,8 @@ class BrkClient extends BrkClientBase {
*
* Endpoint: `GET /api/series/bulk`
*
* @param {SeriesList} [series] - Requested series
* @param {Index} [index] - Index to query
* @param {SeriesList} series - Requested series
* @param {Index} index - Index to query
* @param {RangeIndex=} [start] - Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s`
* @param {RangeIndex=} [end] - Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e`
* @param {Limit=} [limit] - Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l`
@@ -10922,7 +10962,7 @@ class BrkClient extends BrkClientBase {
*
* Endpoint: `GET /api/series/search`
*
* @param {SeriesName} [q] - Search query string
* @param {SeriesName} q - Search query string
* @param {Limit=} [limit] - Maximum number of results
* @param {{ signal?: AbortSignal, onValue?: (value: string[]) => void }} [options]
* @returns {Promise<string[]>}
@@ -11362,7 +11402,7 @@ class BrkClient extends BrkClientBase {
/**
* Recent blocks with extras
*
* Retrieve the last 10 blocks with extended data including pool identification and fee statistics.
* Retrieve the last 15 blocks with extended data including pool identification and fee statistics.
*
* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*
*
@@ -11378,7 +11418,7 @@ class BrkClient extends BrkClientBase {
/**
* Blocks from height with extras
*
* Retrieve up to 10 blocks with extended data going backwards from the given height.
* Retrieve up to 15 blocks with extended data going backwards from the given height.
*
* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*
*
@@ -11870,7 +11910,7 @@ class BrkClient extends BrkClientBase {
*
* Endpoint: `GET /api/v1/transaction-times`
*
* @param {Txid[]} [txId[]] - Transaction IDs to look up (max 250 per request).
* @param {Txid[]} txId - Transaction IDs to look up (max 250 per request).
* @param {{ signal?: AbortSignal, onValue?: (value: number[]) => void }} [options]
* @returns {Promise<number[]>}
*/

View File

@@ -102,6 +102,9 @@ CostBasisValue = Literal["supply", "realized", "unrealized"]
# Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000),
# log10/log50/log100/log200 (logarithmic with 10/50/100/200 buckets per decade).
UrpdAggregation = Literal["raw", "lin200", "lin500", "lin1000", "log10", "log50", "log100", "log200"]
# Position of a transaction inside a `CpfpCluster.txs` array. Cluster-local,
# has no meaning outside the enclosing cluster.
CpfpClusterTxIndex = int
# Virtual size in vbytes (weight / 4, rounded up). Max block vsize is ~1,000,000 vB.
VSize = int
# Date in YYYYMMDD format stored as u32
@@ -650,6 +653,43 @@ class CostBasisQuery(TypedDict):
bucket: UrpdAggregation
value: CostBasisValue
class CpfpClusterChunk(TypedDict):
"""
One SFL chunk inside a `CpfpCluster`.
Attributes:
txs: Txs in this chunk.
feerate: Combined feerate of the chunk (sat/vB).
"""
txs: List[CpfpClusterTxIndex]
feerate: FeeRate
class CpfpClusterTx(TypedDict):
"""
One entry in a `CpfpCluster.txs` array.
Attributes:
parents: In-cluster parents of this tx.
"""
txid: Txid
fee: Sats
weight: Weight
parents: List[CpfpClusterTxIndex]
class CpfpCluster(TypedDict):
"""
CPFP cluster output for an unconfirmed tx: the connected component
the seed belongs to, plus its SFL linearization.
Attributes:
txs: All txs in the cluster, in topological order (parents before children).
chunks: SFL-emitted chunks ordered by descending feerate.
chunkIndex: Index into `chunks` of the chunk containing the seed tx.
"""
txs: List[CpfpClusterTx]
chunks: List[CpfpClusterChunk]
chunkIndex: int
class CpfpEntry(TypedDict):
"""
A transaction in a CPFP relationship
@@ -672,15 +712,20 @@ class CpfpInfo(TypedDict):
bestDescendant: Best (highest fee rate) descendant, if any
descendants: Descendant transactions in the CPFP chain
effectiveFeePerVsize: Effective fee rate considering CPFP relationships (sat/vB)
sigops: Total signature operation count for the seed tx
fee: Transaction fee (sats)
adjustedVsize: Adjusted virtual size (accounting for sigops)
cluster: Mempool cluster the seed belongs to: full tx list, SFL-linearized
chunks, and the seed's chunk index. Only set for unconfirmed txs.
"""
ancestors: List[CpfpEntry]
bestDescendant: Union[CpfpEntry, None]
descendants: List[CpfpEntry]
effectiveFeePerVsize: Union[FeeRate, None]
sigops: Optional[int]
fee: Union[Sats, None]
adjustedVsize: Union[VSize, None]
cluster: Union[CpfpCluster, None]
class DataRangeFormat(TypedDict):
"""
@@ -8314,7 +8359,7 @@ class BrkClient(BrkClientBase):
def get_blocks_v1(self) -> List[BlockInfoV1]:
"""Recent blocks with extras.
Retrieve the last 10 blocks with extended data including pool identification and fee statistics.
Retrieve the last 15 blocks with extended data including pool identification and fee statistics.
*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*
@@ -8324,7 +8369,7 @@ class BrkClient(BrkClientBase):
def get_blocks_v1_from_height(self, height: Height) -> List[BlockInfoV1]:
"""Blocks from height with extras.
Retrieve up to 10 blocks with extended data going backwards from the given height.
Retrieve up to 15 blocks with extended data going backwards from the given height.
*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)*

View File

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

View File

@@ -28,8 +28,10 @@ def test_mempool_info_invariants(brk):
f"histogram entry {i} not a 2-element list: {entry}"
)
rate, bvs = entry
assert isinstance(rate, (int, float)) and rate > 0, (
f"non-positive rate at bin {i}: {rate}"
# Zero-rate bins are legitimate (CPFP/package-relay anchors with
# zero-fee parents); mempool.space's API returns them too.
assert isinstance(rate, (int, float)) and rate >= 0, (
f"negative rate at bin {i}: {rate}"
)
assert isinstance(bvs, int) and bvs > 0, f"non-positive vsize at bin {i}: {bvs}"
rates.append(rate)

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

View File

@@ -45,3 +45,30 @@ def test_mining_blocks_fees_malformed(brk, bad):
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)
@pytest.mark.parametrize("period", PERIODS)
def test_mining_blocks_fees_values_match(brk, mempool, period):
"""For shared buckets (keyed by timestamp), avgHeight and avgFees must equal mempool.space.
USD is sourced from different price oracles and is intentionally not compared."""
path = f"/api/v1/mining/blocks/fees/{period}"
b = brk.get_block_fees(period)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
m_by_ts = {e["timestamp"]: e for e in m}
matched = 0
for be in b:
me = m_by_ts.get(be["timestamp"])
if me is None:
continue
matched += 1
assert be["avgHeight"] == me["avgHeight"], (
f"avgHeight drift at timestamp {be['timestamp']}: "
f"brk={be['avgHeight']} mempool={me['avgHeight']}"
)
assert be["avgFees"] == me["avgFees"], (
f"avgFees mismatch at timestamp {be['timestamp']}: "
f"brk={be['avgFees']} mempool={me['avgFees']}"
)
assert matched > 0, "no overlapping bucket timestamps between brk and mempool"

View File

@@ -45,3 +45,29 @@ def test_mining_blocks_rewards_malformed(brk, bad):
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)
@pytest.mark.parametrize("period", PERIODS)
def test_mining_blocks_rewards_values_match(brk, mempool, period):
"""For shared buckets (keyed by timestamp), avgHeight and avgRewards must equal mempool.space."""
path = f"/api/v1/mining/blocks/rewards/{period}"
b = brk.get_block_rewards(period)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
m_by_ts = {e["timestamp"]: e for e in m}
matched = 0
for be in b:
me = m_by_ts.get(be["timestamp"])
if me is None:
continue
matched += 1
assert be["avgHeight"] == me["avgHeight"], (
f"avgHeight drift at timestamp {be['timestamp']}: "
f"brk={be['avgHeight']} mempool={me['avgHeight']}"
)
assert be["avgRewards"] == me["avgRewards"], (
f"avgRewards mismatch at timestamp {be['timestamp']}: "
f"brk={be['avgRewards']} mempool={me['avgRewards']}"
)
assert matched > 0, "no overlapping bucket timestamps between brk and mempool"

View File

@@ -59,3 +59,34 @@ def test_mining_blocks_sizes_weights_malformed(brk, bad):
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)
@pytest.mark.parametrize("period", PERIODS)
def test_mining_blocks_sizes_weights_values_match(brk, mempool, period):
"""For shared buckets (keyed by timestamp), avgSize and avgWeight must equal mempool.space."""
path = f"/api/v1/mining/blocks/sizes-weights/{period}"
b = brk.get_block_sizes_weights(period)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
sizes_by_ts = {e["timestamp"]: e for e in m["sizes"]}
weights_by_ts = {e["timestamp"]: e for e in m["weights"]}
matched = 0
for s, w in zip(b["sizes"], b["weights"]):
ts = s["timestamp"]
ms = sizes_by_ts.get(ts)
mw = weights_by_ts.get(ts)
if ms is None or mw is None:
continue
matched += 1
assert s["avgHeight"] == ms["avgHeight"], (
f"size avgHeight drift at timestamp {ts}: brk={s['avgHeight']} mempool={ms['avgHeight']}"
)
assert s["avgSize"] == ms["avgSize"], (
f"avgSize mismatch at timestamp {ts}: brk={s['avgSize']} mempool={ms['avgSize']}"
)
assert w["avgWeight"] == mw["avgWeight"], (
f"avgWeight mismatch at timestamp {ts}: brk={w['avgWeight']} mempool={mw['avgWeight']}"
)
assert matched > 0, "no overlapping bucket timestamps between brk and mempool"

View File

@@ -52,3 +52,30 @@ def test_mining_difficulty_adjustments_malformed(brk, bad):
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)
# `all`: mempool.space's `difficulty_adjustments` table begins from when their tracker
# started, not genesis, so series length and earliest entries diverge by construction.
@pytest.mark.parametrize("period", [p for p in PERIODS if p != "all"])
def test_mining_difficulty_adjustments_values_match(brk, mempool, period):
"""For every bounded period, every retarget entry must match mempool.space:
same height, same timestamp, and difficulty/change-ratio within float tolerance."""
path = f"/api/v1/mining/difficulty-adjustments/{period}"
b = brk.get_difficulty_adjustments_by_period(period)
m = mempool.get_json(path)
show("GET", path, summary(b), summary(m))
assert len(b) == len(m), f"length mismatch: brk={len(b)} mempool={len(m)}"
for be, me in zip(b, m):
bt, bh, bd, br = be
mt, mh, md, mr = me
assert bh == mh, f"height mismatch at retarget: brk={bh} mempool={mh}"
assert bt == mt, f"timestamp mismatch at height {bh}: brk={bt} mempool={mt}"
# mempool.space serializes difficulty/change_ratio with limited precision,
# so only require parity within mempool.space's ~6-decimal rounding window.
assert abs(bd - md) / max(md, 1.0) < 1e-5, (
f"difficulty drift at height {bh}: brk={bd} mempool={md}"
)
assert abs(br - mr) < 1e-5, (
f"change_ratio drift at height {bh}: brk={br} mempool={mr}"
)

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
from brk_client import BrkError
from _lib import assert_same_structure, assert_same_values, show
from _lib import assert_same_structure, show
COUNTS = [1, 10, 100, 500, 1000]
@@ -20,16 +25,6 @@ def test_mining_reward_stats_structure(brk, mempool, count):
assert_same_structure(b, m)
@pytest.mark.parametrize("count", [100, 1000])
def test_mining_reward_stats_values_match(brk, mempool, count):
"""brk and mempool must agree exactly on aggregated stats."""
path = f"/api/v1/mining/reward-stats/{count}"
b = brk.get_reward_stats(count)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_mining_reward_stats_invariants(brk):
"""Range alignment, reward >= fee, totalTx >= block count (count=1000)."""
count = 1000

View File

@@ -1,5 +1,7 @@
"""GET /api/v1/transaction-times?txId[]=..."""
import time
import pytest
from brk_client import BrkError
@@ -65,3 +67,39 @@ def test_transaction_times_malformed_short(brk):
with pytest.raises(BrkError) as exc_info:
brk.get_transaction_times(["abc"])
assert exc_info.value.status == 400
def test_transaction_times_mempool_unconfirmed(brk, mempool):
"""Unconfirmed mempool tx: first-seen timestamp must be a plausible
Unix-second value (post-genesis, not in the future). Cross-observer
agreement is not asserted: each server records when *it* first saw
the tx, and rebroadcasts/restarts can put two independent observers
days or weeks apart on the same txid."""
txids = mempool.get_json("/api/mempool/txids")
if not txids:
pytest.skip("mempool.space mempool currently empty")
GENESIS_TS = 1231006505
now = int(time.time())
skew = 5 * 60
for txid in txids[:25]:
try:
b = brk.get_transaction_times([txid])
except BrkError:
continue
if not b or b[0] == 0:
continue
try:
m = mempool.get_json(
"/api/v1/transaction-times", params=[("txId[]", txid)]
)
except Exception:
continue
if not m or m[0] == 0:
continue
show("GET", f"/api/v1/transaction-times?txId[]={txid[:16]}...", b, m)
assert GENESIS_TS <= b[0] <= now + skew, f"brk first-seen out of plausible range: {b[0]}"
assert GENESIS_TS <= m[0] <= now + skew, f"mempool first-seen out of plausible range: {m[0]}"
return
pytest.skip("no shared unconfirmed tx between brk and mempool.space")

View File

@@ -113,7 +113,9 @@ export async function update(address, signal) {
if (currentAddr !== address) return;
loading = true;
try {
const txs = await brk.getAddressTxs(address, afterTxid, { signal });
const txs = afterTxid
? await brk.getAddressConfirmedTxsAfter(address, afterTxid, { signal })
: await brk.getAddressTxs(address, { signal });
if (currentAddr !== address) return;
for (const tx of txs) txSection.append(renderTx(tx));
pageIndex++;