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