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

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 {