diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index ec85be58a..c1d85c89f 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -8198,7 +8198,7 @@ pub struct BrkClient { impl BrkClient { /// Client version. - pub const VERSION: &'static str = "v0.3.0-alpha.4"; + pub const VERSION: &'static str = "v0.3.0-alpha.5"; /// Create a new client with the given base URL. pub fn new(base_url: impl Into) -> Self { @@ -8907,15 +8907,15 @@ impl BrkClient { self.base.get_json(&path) } - /// Block fee rates (WIP) + /// Block fee rates /// - /// **Work in progress.** Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: 24h, 3d, 1w, 1m, 3m, 6m, 1y, 2y, 3y + /// Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: 24h, 3d, 1w, 1m, 3m, 6m, 1y, 2y, 3y /// /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)* /// /// Endpoint: `GET /api/v1/mining/blocks/fee-rates/{time_period}` - pub fn get_block_fee_rates(&self, time_period: TimePeriod) -> Result { - self.base.get_text(&format!("/api/v1/mining/blocks/fee-rates/{time_period}")) + pub fn get_block_fee_rates(&self, time_period: TimePeriod) -> Result> { + self.base.get_json(&format!("/api/v1/mining/blocks/fee-rates/{time_period}")) } /// Block fees diff --git a/crates/brk_query/src/impl/block/info.rs b/crates/brk_query/src/impl/block/info.rs index ae2d7c36d..a9c461d0c 100644 --- a/crates/brk_query/src/impl/block/info.rs +++ b/crates/brk_query/src/impl/block/info.rs @@ -206,13 +206,12 @@ impl Query { .total .sum .collect_range_at(begin, end); - let utxo_begin = begin.saturating_sub(1); let utxo_set_sizes = computer .outputs .count .unspent .height - .collect_range_at(utxo_begin, end); + .collect_range_at(begin, end); let input_volumes = computer .transactions .volume @@ -279,9 +278,6 @@ impl Query { let subsidy = subsidy_sats[i]; let total_inputs = (*input_counts[i]).saturating_sub(1); let total_outputs = *output_counts[i]; - let utxo_idx = begin + i - utxo_begin; - let utxo_set_size = *utxo_set_sizes[utxo_idx]; - let prev_utxo_set_size = if utxo_idx > 0 { *utxo_set_sizes[utxo_idx - 1] } else { 0 }; let vsize = weight.to_vbytes_ceil(); let total_fees_u64 = u64::from(total_fees); let non_coinbase = tx_count.saturating_sub(1) as u64; @@ -383,8 +379,8 @@ impl Query { segwit_total_size: *segwit_sizes[i], segwit_total_weight: segwit_weights[i], header: raw_header.to_lower_hex_string(), - utxo_set_change: utxo_set_size as i64 - prev_utxo_set_size as i64, - utxo_set_size, + utxo_set_change: total_outputs as i64 - total_inputs as i64, + utxo_set_size: *utxo_set_sizes[i], total_input_amt, virtual_size: vsize as f64, price: prices[i], @@ -552,7 +548,9 @@ impl Query { .ok() .map(|a| a.to_string()) }) - .collect(); + .collect::>(); + let mut coinbase_addresses = coinbase_addresses; + coinbase_addresses.dedup(); let coinbase_address = coinbase_addresses.first().cloned(); let coinbase_signature = tx diff --git a/crates/brk_query/src/impl/mempool.rs b/crates/brk_query/src/impl/mempool.rs index f6291767a..0d60ca64f 100644 --- a/crates/brk_query/src/impl/mempool.rs +++ b/crates/brk_query/src/impl/mempool.rs @@ -56,9 +56,9 @@ impl Query { let entries = mempool.get_entries(); let prefix = TxidPrefix::from(txid); - let entry = entries - .get(&prefix) - .ok_or(Error::NotFound("Transaction not in mempool".into()))?; + let Some(entry) = entries.get(&prefix) else { + return Ok(CpfpInfo::default()); + }; // Ancestors: walk up the depends chain let mut ancestors = Vec::new(); @@ -101,9 +101,9 @@ impl Query { ancestors, best_descendant, descendants, - effective_fee_per_vsize, - fee: entry.fee, - adjusted_vsize: entry.vsize, + effective_fee_per_vsize: Some(effective_fee_per_vsize), + fee: Some(entry.fee), + adjusted_vsize: Some(entry.vsize), }) } diff --git a/crates/brk_query/src/impl/mining/block_fee_rates.rs b/crates/brk_query/src/impl/mining/block_fee_rates.rs index cd1d25a30..444d0f0e6 100644 --- a/crates/brk_query/src/impl/mining/block_fee_rates.rs +++ b/crates/brk_query/src/impl/mining/block_fee_rates.rs @@ -1,59 +1,57 @@ -// TODO: INCOMPLETE - indexes_to_fee_rate.day1 doesn't have percentile fields -// because from_tx_index.rs calls remove_percentiles() before creating day1. -// Need to either: -// 1. Use .height instead and convert height to day1 for iteration -// 2. Fix from_tx_index.rs to preserve percentiles for day1 -// 3. Create a separate day1 computation path with percentiles - -#![allow(dead_code)] - use brk_error::Result; -use brk_types::{ - BlockFeeRatesEntry, - // FeeRatePercentiles, - TimePeriod, -}; -// use vecdb::{IterableVec, VecIndex}; +use brk_types::{BlockFeeRatesEntry, FeeRate, FeeRatePercentiles, TimePeriod}; +use vecdb::ReadableVec; +use super::block_window::BlockWindow; use crate::Query; impl Query { - pub fn block_fee_rates(&self, _time_period: TimePeriod) -> Result> { - // Disabled until percentile data is available at day1 level - Ok(Vec::new()) + pub fn block_fee_rates(&self, time_period: TimePeriod) -> Result> { + let bw = BlockWindow::new(self, time_period); + let computer = self.computer(); + let frd = &computer.transactions.fees.effective_fee_rate.distribution.block; - // Original implementation: - // let computer = self.computer(); - // let current_height = self.height(); - // let start = current_height - // .to_usize() - // .saturating_sub(time_period.block_count()); - // - // let iter = Day1Iter::new(computer, start, current_height.to_usize()); - // - // let vecs = &computer.transactions.transaction.indexes_to_fee_rate.day1; - // let mut min = vecs.unwrap_min().iter(); - // let mut pct10 = vecs.unwrap_pct10().iter(); - // let mut pct25 = vecs.unwrap_pct25().iter(); - // let mut median = vecs.unwrap_median().iter(); - // let mut pct75 = vecs.unwrap_pct75().iter(); - // let mut pct90 = vecs.unwrap_pct90().iter(); - // let mut max = vecs.unwrap_max().iter(); - // - // Ok(iter.collect(|di, ts, h| { - // Some(BlockFeeRatesEntry { - // avg_height: h, - // timestamp: ts, - // percentiles: FeeRatePercentiles::new( - // min.get(di).unwrap_or_default(), - // pct10.get(di).unwrap_or_default(), - // pct25.get(di).unwrap_or_default(), - // median.get(di).unwrap_or_default(), - // pct75.get(di).unwrap_or_default(), - // pct90.get(di).unwrap_or_default(), - // max.get(di).unwrap_or_default(), - // ), - // }) - // })) + let min = frd.min.height.collect_range_at(bw.start, bw.end); + let pct10 = frd.pct10.height.collect_range_at(bw.start, bw.end); + let pct25 = frd.pct25.height.collect_range_at(bw.start, bw.end); + let median = frd.median.height.collect_range_at(bw.start, bw.end); + let pct75 = frd.pct75.height.collect_range_at(bw.start, bw.end); + let pct90 = frd.pct90.height.collect_range_at(bw.start, bw.end); + let max = frd.max.height.collect_range_at(bw.start, bw.end); + + let timestamps = bw.timestamps(self); + + let mut results = Vec::with_capacity(timestamps.len()); + let mut pos = 0; + let total = min.len(); + + for ts in ×tamps { + let window_end = (pos + bw.window).min(total); + let count = window_end - pos; + if count > 0 { + let mid = (pos + window_end) / 2; + let avg = |vals: &[FeeRate]| -> FeeRate { + let sum: f64 = vals[pos..window_end].iter().map(|f| f64::from(*f)).sum(); + FeeRate::new(sum / count as f64) + }; + + results.push(BlockFeeRatesEntry { + avg_height: brk_types::Height::from(bw.start + mid), + timestamp: *ts, + percentiles: FeeRatePercentiles::new( + avg(&min), + avg(&pct10), + avg(&pct25), + avg(&median), + avg(&pct75), + avg(&pct90), + avg(&max), + ), + }); + } + pos = window_end; + } + + Ok(results) } } diff --git a/crates/brk_query/src/impl/mining/block_window.rs b/crates/brk_query/src/impl/mining/block_window.rs index a61345d9c..d13b7a495 100644 --- a/crates/brk_query/src/impl/mining/block_window.rs +++ b/crates/brk_query/src/impl/mining/block_window.rs @@ -12,6 +12,7 @@ fn block_window(period: TimePeriod) -> usize { TimePeriod::SixMonths => 18, TimePeriod::Year | TimePeriod::TwoYears => 48, TimePeriod::ThreeYears => 72, + TimePeriod::All => 144, } } @@ -49,6 +50,7 @@ impl BlockWindow { TimePeriod::Year => cached._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(); diff --git a/crates/brk_query/src/impl/mining/pools.rs b/crates/brk_query/src/impl/mining/pools.rs index e03984309..2c85e1e0a 100644 --- a/crates/brk_query/src/impl/mining/pools.rs +++ b/crates/brk_query/src/impl/mining/pools.rs @@ -65,6 +65,7 @@ impl Query { .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() .to_usize(); @@ -254,7 +255,25 @@ impl Query { day: share_24h, week: share_1w, }, - estimated_hashrate: 0, // TODO: Calculate from share and network hashrate + estimated_hashrate: { + let day = computer + .indexes + .height + .day1 + .collect_one(current_height) + .unwrap_or_default(); + let network_hr = computer + .mining + .hashrate + .rate + .base + .day1 + .collect_one(day) + .flatten() + .map(|v| *v as u128) + .unwrap_or(0); + (share_24h * network_hr as f64) as u128 + }, reported_hashrate: None, }) } @@ -338,6 +357,7 @@ impl Query { .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() .to_usize() diff --git a/crates/brk_query/src/impl/tx.rs b/crates/brk_query/src/impl/tx.rs index decb47d41..695e0ab12 100644 --- a/crates/brk_query/src/impl/tx.rs +++ b/crates/brk_query/src/impl/tx.rs @@ -109,9 +109,10 @@ impl Query { pub fn outspend(&self, txid: &Txid, vout: Vout) -> Result { let all = self.outspends(txid)?; - all.into_iter() + Ok(all + .into_iter() .nth(usize::from(vout)) - .ok_or(Error::OutOfRange("Output index out of range".into())) + .unwrap_or(TxOutspend::UNSPENT)) } pub fn outspends(&self, txid: &Txid) -> Result> { diff --git a/crates/brk_server/src/api/mempool_space/mining.rs b/crates/brk_server/src/api/mempool_space/mining.rs index e94b9a4c9..44c476f83 100644 --- a/crates/brk_server/src/api/mempool_space/mining.rs +++ b/crates/brk_server/src/api/mempool_space/mining.rs @@ -2,16 +2,17 @@ use aide::axum::{ApiRouter, routing::get_with}; use axum::{ extract::{Path, State}, http::{HeaderMap, Uri}, - response::{IntoResponse, Redirect, Response}, + response::Redirect, routing::get, }; use brk_types::{ - BlockFeesEntry, BlockInfoV1, BlockRewardsEntry, BlockSizesWeights, DifficultyAdjustmentEntry, - HashrateSummary, PoolDetail, PoolHashrateEntry, PoolInfo, PoolsSummary, RewardStats, + BlockFeeRatesEntry, BlockFeesEntry, BlockInfoV1, BlockRewardsEntry, BlockSizesWeights, + DifficultyAdjustmentEntry, HashrateSummary, PoolDetail, PoolHashrateEntry, PoolInfo, + PoolsSummary, RewardStats, }; use crate::{ - AppState, CacheStrategy, Error, + AppState, CacheStrategy, extended::TransformResponseExtended, params::{BlockCountParam, PoolSlugAndHeightParam, PoolSlugParam, TimePeriodParam}, }; @@ -289,14 +290,15 @@ impl MiningRoutes for ApiRouter { .api_route( "/api/v1/mining/blocks/fee-rates/{time_period}", get_with( - async |Path(_path): Path| -> Response { - Error::not_implemented("Fee rate percentiles are not yet available").into_response() + async |uri: Uri, headers: HeaderMap, Path(path): Path, State(state): State| { + state.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| q.block_fee_rates(path.time_period)).await }, |op| { op.id("get_block_fee_rates") .mining_tag() - .summary("Block fee rates (WIP)") - .description("**Work in progress.** Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: 24h, 3d, 1w, 1m, 3m, 6m, 1y, 2y, 3y\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)*") + .summary("Block fee rates") + .description("Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: 24h, 3d, 1w, 1m, 3m, 6m, 1y, 2y, 3y\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)*") + .json_response::>() .server_error() }, ), diff --git a/crates/brk_types/src/block_extras.rs b/crates/brk_types/src/block_extras.rs index 19081b6a0..a79cc9c01 100644 --- a/crates/brk_types/src/block_extras.rs +++ b/crates/brk_types/src/block_extras.rs @@ -91,11 +91,13 @@ pub struct BlockExtras { /// Raw 80-byte block header as hex pub header: String, - /// UTXO set change (outputs created minus inputs spent) + /// UTXO set change (total outputs - total inputs, includes unspendable like OP_RETURN). + /// Note: intentionally differs from utxo_set_size diff which excludes unspendable outputs. + /// Matches mempool.space/bitcoin-cli behavior. #[serde(rename = "utxoSetChange")] pub utxo_set_change: i64, - /// Total UTXO set size at this height + /// Total spendable UTXO set size at this height (excludes OP_RETURN and other unspendable outputs) #[serde(rename = "utxoSetSize")] pub utxo_set_size: u64, diff --git a/crates/brk_types/src/block_fee_rates_entry.rs b/crates/brk_types/src/block_fee_rates_entry.rs index 279d44ae6..66fecb303 100644 --- a/crates/brk_types/src/block_fee_rates_entry.rs +++ b/crates/brk_types/src/block_fee_rates_entry.rs @@ -1,12 +1,12 @@ use schemars::JsonSchema; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::{Height, Timestamp}; use super::FeeRatePercentiles; /// A single block fee rates data point with percentiles. -#[derive(Debug, Serialize, JsonSchema)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct BlockFeeRatesEntry { /// Average block height in this window diff --git a/crates/brk_types/src/cpfp.rs b/crates/brk_types/src/cpfp.rs index fdca68d9d..84a340bf3 100644 --- a/crates/brk_types/src/cpfp.rs +++ b/crates/brk_types/src/cpfp.rs @@ -4,23 +4,29 @@ use serde::{Deserialize, Serialize}; use crate::{FeeRate, Sats, Txid, VSize, Weight}; /// CPFP (Child Pays For Parent) information for a transaction -#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct CpfpInfo { /// Ancestor transactions in the CPFP chain pub ancestors: Vec, /// Best (highest fee rate) descendant, if any + #[serde(skip_serializing_if = "Option::is_none")] pub best_descendant: Option, /// Descendant transactions in the CPFP chain + #[serde(skip_serializing_if = "Vec::is_empty")] pub descendants: Vec, /// Effective fee rate considering CPFP relationships (sat/vB) - pub effective_fee_per_vsize: FeeRate, + #[serde(skip_serializing_if = "Option::is_none")] + pub effective_fee_per_vsize: Option, /// Transaction fee (sats) - pub fee: Sats, + #[serde(skip_serializing_if = "Option::is_none")] + pub fee: Option, /// Adjusted virtual size (accounting for sigops) - pub adjusted_vsize: VSize, + #[serde(skip_serializing_if = "Option::is_none")] + pub adjusted_vsize: Option, } + /// A transaction in a CPFP relationship #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct CpfpEntry { diff --git a/crates/brk_types/src/feerate_percentiles.rs b/crates/brk_types/src/feerate_percentiles.rs index 19ae3ec31..54ae57339 100644 --- a/crates/brk_types/src/feerate_percentiles.rs +++ b/crates/brk_types/src/feerate_percentiles.rs @@ -1,10 +1,10 @@ use schemars::JsonSchema; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use super::FeeRate; /// Fee rate percentiles (min, 10%, 25%, 50%, 75%, 90%, max). -#[derive(Debug, Default, Clone, Copy, Serialize, JsonSchema)] +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, JsonSchema)] pub struct FeeRatePercentiles { #[serde(rename = "avgFee_0")] pub min: FeeRate, diff --git a/crates/brk_types/src/time_period.rs b/crates/brk_types/src/time_period.rs index 7d37bec94..c9261ca6b 100644 --- a/crates/brk_types/src/time_period.rs +++ b/crates/brk_types/src/time_period.rs @@ -28,6 +28,8 @@ pub enum TimePeriod { TwoYears, #[serde(rename = "3y")] ThreeYears, + #[serde(rename = "all")] + All, } impl TimePeriod { @@ -43,6 +45,7 @@ impl TimePeriod { TimePeriod::Year => 52560, TimePeriod::TwoYears => 105120, TimePeriod::ThreeYears => 157680, + TimePeriod::All => usize::MAX, } } @@ -58,6 +61,7 @@ impl TimePeriod { "1y" => Some(TimePeriod::Year), "2y" => Some(TimePeriod::TwoYears), "3y" => Some(TimePeriod::ThreeYears), + "all" => Some(TimePeriod::All), _ => None, } } @@ -75,6 +79,7 @@ impl fmt::Display for TimePeriod { TimePeriod::Year => write!(f, "1y"), TimePeriod::TwoYears => write!(f, "2y"), TimePeriod::ThreeYears => write!(f, "3y"), + TimePeriod::All => write!(f, "all"), } } } diff --git a/crates/brk_types/src/txin.rs b/crates/brk_types/src/txin.rs index 7f7a525cc..73e647461 100644 --- a/crates/brk_types/src/txin.rs +++ b/crates/brk_types/src/txin.rs @@ -90,7 +90,7 @@ impl Serialize for TxIn { String::new() }; - let has_inner_redeem = !inner_redeem.is_empty(); + let has_inner_redeem = is_p2sh && !self.is_coinbase; let has_inner_witness = !inner_witness.is_empty(); let field_count = 7 + has_witness as usize + has_inner_redeem as usize + has_inner_witness as usize; diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 1a87eebb5..9c104e3f4 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -135,14 +135,30 @@ * @property {number} segwitTotalSize - Total size of segwit transactions in bytes * @property {Weight} segwitTotalWeight - Total weight of segwit transactions * @property {string} header - Raw 80-byte block header as hex - * @property {number} utxoSetChange - UTXO set change (outputs created minus inputs spent) - * @property {number} utxoSetSize - Total UTXO set size at this height + * @property {number} utxoSetChange - UTXO set change (total outputs - total inputs, includes unspendable like OP_RETURN). +Note: intentionally differs from utxo_set_size diff which excludes unspendable outputs. +Matches mempool.space/bitcoin-cli behavior. + * @property {number} utxoSetSize - Total spendable UTXO set size at this height (excludes OP_RETURN and other unspendable outputs) * @property {Sats} totalInputAmt - Total input amount in satoshis * @property {number} virtualSize - Virtual size in vbytes * @property {?number=} firstSeen - Timestamp when the block was first seen (always null, not yet supported) * @property {string[]} orphans - Orphaned blocks (always empty) * @property {Dollars} price - USD price at block height */ +/** + * A single block fee rates data point with percentiles. + * + * @typedef {Object} BlockFeeRatesEntry + * @property {Height} avgHeight - Average block height in this window + * @property {Timestamp} timestamp - Unix timestamp at the window midpoint + * @property {FeeRate} avgFee0 + * @property {FeeRate} avgFee10 + * @property {FeeRate} avgFee25 + * @property {FeeRate} avgFee50 + * @property {FeeRate} avgFee75 + * @property {FeeRate} avgFee90 + * @property {FeeRate} avgFee100 + */ /** * A single block fees data point. * @@ -359,9 +375,9 @@ * @property {CpfpEntry[]} ancestors - Ancestor transactions in the CPFP chain * @property {(CpfpEntry|null)=} bestDescendant - Best (highest fee rate) descendant, if any * @property {CpfpEntry[]} descendants - Descendant transactions in the CPFP chain - * @property {FeeRate} effectiveFeePerVsize - Effective fee rate considering CPFP relationships (sat/vB) - * @property {Sats} fee - Transaction fee (sats) - * @property {VSize} adjustedVsize - Adjusted virtual size (accounting for sigops) + * @property {(FeeRate|null)=} effectiveFeePerVsize - Effective fee rate considering CPFP relationships (sat/vB) + * @property {(Sats|null)=} fee - Transaction fee (sats) + * @property {(VSize|null)=} adjustedVsize - Adjusted virtual size (accounting for sigops) */ /** * Data range with output format for API query parameters @@ -983,7 +999,7 @@ * Used to specify the lookback window for pool statistics, hashrate calculations, * and other time-based mining series. * - * @typedef {("24h"|"3d"|"1w"|"1m"|"3m"|"6m"|"1y"|"2y"|"3y")} TimePeriod + * @typedef {("24h"|"3d"|"1w"|"1m"|"3m"|"6m"|"1y"|"2y"|"3y"|"all")} TimePeriod */ /** * @typedef {Object} TimePeriodParam @@ -6573,7 +6589,7 @@ function createTransferPattern(client, acc) { * @extends BrkClientBase */ class BrkClient extends BrkClientBase { - VERSION = "v0.3.0-alpha.4"; + VERSION = "v0.3.0-alpha.5"; INDEXES = /** @type {const} */ ([ "minute10", @@ -10363,16 +10379,16 @@ class BrkClient extends BrkClientBase { } /** - * Block fee rates (WIP) + * Block fee rates * - * **Work in progress.** Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: 24h, 3d, 1w, 1m, 3m, 6m, 1y, 2y, 3y + * Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: 24h, 3d, 1w, 1m, 3m, 6m, 1y, 2y, 3y * * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)* * * Endpoint: `GET /api/v1/mining/blocks/fee-rates/{time_period}` * * @param {TimePeriod} time_period - * @returns {Promise<*>} + * @returns {Promise} */ async getBlockFeeRates(time_period) { return this.getJson(`/api/v1/mining/blocks/fee-rates/${time_period}`); diff --git a/modules/brk-client/package.json b/modules/brk-client/package.json index 7ea33c9ea..f62fb909d 100644 --- a/modules/brk-client/package.json +++ b/modules/brk-client/package.json @@ -40,5 +40,5 @@ "url": "git+https://github.com/bitcoinresearchkit/brk.git" }, "type": "module", - "version": "0.3.0-alpha.4" + "version": "0.3.0-alpha.5" } diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index d18a0d9a6..a470a5940 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -195,7 +195,7 @@ StoredU64 = int # # Used to specify the lookback window for pool statistics, hashrate calculations, # and other time-based mining series. -TimePeriod = Literal["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y"] +TimePeriod = Literal["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"] # Index of the output being spent in the previous transaction Vout = int # Raw transaction version (i32) from Bitcoin protocol. @@ -354,8 +354,10 @@ class BlockExtras(TypedDict): segwitTotalSize: Total size of segwit transactions in bytes segwitTotalWeight: Total weight of segwit transactions header: Raw 80-byte block header as hex - utxoSetChange: UTXO set change (outputs created minus inputs spent) - utxoSetSize: Total UTXO set size at this height + utxoSetChange: UTXO set change (total outputs - total inputs, includes unspendable like OP_RETURN). +Note: intentionally differs from utxo_set_size diff which excludes unspendable outputs. +Matches mempool.space/bitcoin-cli behavior. + utxoSetSize: Total spendable UTXO set size at this height (excludes OP_RETURN and other unspendable outputs) totalInputAmt: Total input amount in satoshis virtualSize: Virtual size in vbytes firstSeen: Timestamp when the block was first seen (always null, not yet supported) @@ -392,6 +394,24 @@ class BlockExtras(TypedDict): orphans: List[str] price: Dollars +class BlockFeeRatesEntry(TypedDict): + """ + A single block fee rates data point with percentiles. + + Attributes: + avgHeight: Average block height in this window + timestamp: Unix timestamp at the window midpoint + """ + avgHeight: Height + timestamp: Timestamp + avgFee_0: FeeRate + avgFee_10: FeeRate + avgFee_25: FeeRate + avgFee_50: FeeRate + avgFee_75: FeeRate + avgFee_90: FeeRate + avgFee_100: FeeRate + class BlockFeesEntry(TypedDict): """ A single block fees data point. @@ -626,9 +646,9 @@ class CpfpInfo(TypedDict): ancestors: List[CpfpEntry] bestDescendant: Union[CpfpEntry, None] descendants: List[CpfpEntry] - effectiveFeePerVsize: FeeRate - fee: Sats - adjustedVsize: VSize + effectiveFeePerVsize: Union[FeeRate, None] + fee: Union[Sats, None] + adjustedVsize: Union[VSize, None] class DataRangeFormat(TypedDict): """ @@ -6011,7 +6031,7 @@ class SeriesTree: class BrkClient(BrkClientBase): """Main BRK client with series tree and API methods.""" - VERSION = "v0.3.0-alpha.4" + VERSION = "v0.3.0-alpha.5" INDEXES = [ "minute10", @@ -7777,15 +7797,15 @@ class BrkClient(BrkClientBase): path = f'/api/v1/historical-price{"?" + query if query else ""}' return self.get_json(path) - def get_block_fee_rates(self, time_period: TimePeriod) -> str: - """Block fee rates (WIP). + def get_block_fee_rates(self, time_period: TimePeriod) -> List[BlockFeeRatesEntry]: + """Block fee rates. - **Work in progress.** Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: 24h, 3d, 1w, 1m, 3m, 6m, 1y, 2y, 3y + Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: 24h, 3d, 1w, 1m, 3m, 6m, 1y, 2y, 3y *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)* Endpoint: `GET /api/v1/mining/blocks/fee-rates/{time_period}`""" - return self.get_text(f'/api/v1/mining/blocks/fee-rates/{time_period}') + return self.get_json(f'/api/v1/mining/blocks/fee-rates/{time_period}') def get_block_fees(self, time_period: TimePeriod) -> List[BlockFeesEntry]: """Block fees. diff --git a/packages/brk_client/pyproject.toml b/packages/brk_client/pyproject.toml index ad378c749..76984d947 100644 --- a/packages/brk_client/pyproject.toml +++ b/packages/brk_client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brk-client" -version = "0.3.0-alpha.4" +version = "0.3.0-alpha.5" description = "Bitcoin on-chain analytics client — thousands of metrics, block explorer, and address index" readme = "README.md" requires-python = ">=3.9" diff --git a/scripts/compare_bitcoin_cli.sh b/scripts/compare_bitcoin_cli.sh new file mode 100755 index 000000000..ce9d488ed --- /dev/null +++ b/scripts/compare_bitcoin_cli.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# +# Compare brk output against bitcoin-cli to find discrepancies. +# +set -euo pipefail + +BRK="http://localhost:3110" +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BOLD='\033[1m' +NC='\033[0m' + +pass=0 +fail=0 +warn=0 + +# Convert BTC decimal string to sats using python Decimal (no float loss) +btc_to_sats() { + python3 -c "from decimal import Decimal; print(int(Decimal('$1') * 100000000))" +} + +# Sum an array of BTC values to sats precisely +sum_btc_values_to_sats() { + python3 -c " +import sys, json +from decimal import Decimal +vals = json.load(sys.stdin) +print(int(sum(Decimal(str(v)) for v in vals) * 100000000)) +" +} + +compare() { + local label="$1" btc_val="$2" brk_val="$3" + if [[ "$btc_val" == "$brk_val" ]]; then + printf "${GREEN} ✓ %-30s${NC} %s\n" "$label" "$btc_val" + ((pass++)) || true + else + printf "${RED} ✗ %-30s${NC} bitcoin-cli: %-20s brk: %-20s\n" "$label" "$btc_val" "$brk_val" + ((fail++)) || true + fi +} + +compare_float() { + local label="$1" btc_val="$2" brk_val="$3" + local btc_short="${btc_val:0:15}" brk_short="${brk_val:0:15}" + if [[ "$btc_short" == "$brk_short" ]]; then + printf "${GREEN} ✓ %-30s${NC} %s\n" "$label" "$btc_val" + ((pass++)) || true + else + printf "${YELLOW} ~ %-30s${NC} bitcoin-cli: %-20s brk: %-20s\n" "$label" "$btc_val" "$brk_val" + ((warn++)) || true + fi +} + +section() { + printf "\n${BOLD}── %s ──${NC}\n" "$1" +} + +# ─── Get tip from brk ─── +brk_height=$(curl -sf "$BRK/api/blocks/tip/height") +brk_hash=$(curl -sf "$BRK/api/blocks/tip/hash") +btc_height=$(bitcoin-cli getblockcount) +btc_hash=$(bitcoin-cli getbestblockhash) + +section "Chain tip" +compare "height" "$btc_height" "$brk_height" +compare "hash" "$btc_hash" "$brk_hash" + +# If tips differ, use the lower height for comparison +if [[ "$btc_height" != "$brk_height" ]]; then + printf "${YELLOW} Tips differ — comparing at brk height %s${NC}\n" "$brk_height" + btc_hash=$(bitcoin-cli getblockhash "$brk_height") + brk_hash=$(curl -sf "$BRK/api/block-height/$brk_height") + compare "hash at $brk_height" "$btc_hash" "$brk_hash" +fi + +# ─── gettxoutsetinfo ─── +section "gettxoutsetinfo" +printf " Running gettxoutsetinfo (this can take a while)...\n" +txoutset=$(bitcoin-cli gettxoutsetinfo 2>/dev/null || echo '{}') + +if [[ "$txoutset" != '{}' ]]; then + btc_txouts=$(echo "$txoutset" | jq -r '.txouts') + btc_total_amount=$(echo "$txoutset" | jq -r '.total_amount') + btc_txoutset_height=$(echo "$txoutset" | jq -r '.height') + + # Get brk utxoSetSize from block extras at that height + txoutset_hash=$(bitcoin-cli getblockhash "$btc_txoutset_height") + brk_block=$(curl -sf "$BRK/api/v1/block/$txoutset_hash") + brk_utxo_set_size=$(echo "$brk_block" | jq -r '.extras.utxoSetSize') + + compare "txouts (UTXO count)" "$btc_txouts" "$brk_utxo_set_size" + + if [[ "$btc_txouts" != "$brk_utxo_set_size" ]]; then + diff=$((brk_utxo_set_size - btc_txouts)) + printf "${RED} → delta: %d${NC}\n" "$diff" + fi + + btc_supply_sats=$(btc_to_sats "$btc_total_amount") + printf " ${YELLOW}total_amount:${NC} bitcoin-cli: %s BTC (%s sats) — check brk supply series\n" "$btc_total_amount" "$btc_supply_sats" +else + printf " ${YELLOW}gettxoutsetinfo unavailable or timed out${NC}\n" +fi + +# ─── getblock comparison ─── +section "getblock vs /api/v1/block (at tip: $brk_hash)" + +btc_block=$(bitcoin-cli getblock "$brk_hash") +brk_v1=$(curl -sf "$BRK/api/v1/block/$brk_hash") + +btc_size=$(echo "$btc_block" | jq -r '.size') +btc_weight=$(echo "$btc_block" | jq -r '.weight') +btc_nTx=$(echo "$btc_block" | jq -r '.nTx') +btc_difficulty=$(echo "$btc_block" | jq -r '.difficulty') +btc_version=$(echo "$btc_block" | jq -r '.version') +# bitcoin-cli returns bits as hex string, brk as decimal — convert +btc_bits_hex=$(echo "$btc_block" | jq -r '.bits') +btc_bits=$(printf '%d' "0x$btc_bits_hex" 2>/dev/null || echo "$btc_bits_hex") +btc_nonce=$(echo "$btc_block" | jq -r '.nonce') +btc_timestamp=$(echo "$btc_block" | jq -r '.time') +btc_mediantime=$(echo "$btc_block" | jq -r '.mediantime') +btc_merkle=$(echo "$btc_block" | jq -r '.merkleroot') +btc_prevhash=$(echo "$btc_block" | jq -r '.previousblockhash') + +brk_size=$(echo "$brk_v1" | jq -r '.size') +brk_weight=$(echo "$brk_v1" | jq -r '.weight') +brk_nTx=$(echo "$brk_v1" | jq -r '.tx_count') +brk_difficulty=$(echo "$brk_v1" | jq -r '.difficulty') +brk_version=$(echo "$brk_v1" | jq -r '.version') +brk_bits=$(echo "$brk_v1" | jq -r '.bits') +brk_nonce=$(echo "$brk_v1" | jq -r '.nonce') +brk_timestamp=$(echo "$brk_v1" | jq -r '.timestamp') +brk_mediantime=$(echo "$brk_v1" | jq -r '.mediantime') +brk_merkle=$(echo "$brk_v1" | jq -r '.merkle_root') +brk_prevhash=$(echo "$brk_v1" | jq -r '.previousblockhash') + +compare "size" "$btc_size" "$brk_size" +compare "weight" "$btc_weight" "$brk_weight" +compare "nTx / tx_count" "$btc_nTx" "$brk_nTx" +compare_float "difficulty" "$btc_difficulty" "$brk_difficulty" +compare "version" "$btc_version" "$brk_version" +compare "bits" "$btc_bits" "$brk_bits" +compare "nonce" "$btc_nonce" "$brk_nonce" +compare "timestamp" "$btc_timestamp" "$brk_timestamp" +compare "mediantime" "$btc_mediantime" "$brk_mediantime" +compare "merkle_root" "$btc_merkle" "$brk_merkle" +compare "previousblockhash" "$btc_prevhash" "$brk_prevhash" + +# ─── Block extras sanity checks ─── +section "Block extras sanity (at tip)" + +btc_block_v2=$(bitcoin-cli getblock "$brk_hash" 2 2>/dev/null || echo '{}') + +if [[ "$btc_block_v2" != '{}' ]]; then + btc_total_outputs=$(echo "$btc_block_v2" | jq '[.tx[].vout | length] | add') + btc_total_inputs=$(echo "$btc_block_v2" | jq '[.tx[1:][].vin | length] | add // 0') + + brk_total_outputs=$(echo "$brk_v1" | jq -r '.extras.totalOutputs') + brk_total_inputs=$(echo "$brk_v1" | jq -r '.extras.totalInputs') + + compare "totalOutputs" "$btc_total_outputs" "$brk_total_outputs" + compare "totalInputs" "$btc_total_inputs" "$brk_total_inputs" + + # totalOutputAmt excludes coinbase (matches mempool.space), so compare non-coinbase outputs only + btc_total_output_amt=$(echo "$btc_block_v2" | jq '[.tx[1:][].vout[].value]' | sum_btc_values_to_sats) + brk_total_output_amt=$(echo "$brk_v1" | jq -r '.extras.totalOutputAmt') + compare "totalOutputAmt (sats)" "$btc_total_output_amt" "$brk_total_output_amt" + + if [[ "$btc_total_output_amt" != "$brk_total_output_amt" ]]; then + delta=$((brk_total_output_amt - btc_total_output_amt)) + printf "${RED} → delta: %d sats (%.8f BTC)${NC}\n" "$delta" "$(python3 -c "print($delta / 1e8)")" + fi + + # Reward = subsidy + fees. bitcoin-cli coinbase output sum = reward + btc_coinbase_value=$(echo "$btc_block_v2" | jq '[.tx[0].vout[].value]' | sum_btc_values_to_sats) + brk_reward=$(echo "$brk_v1" | jq -r '.extras.reward') + compare "reward (coinbase sats)" "$btc_coinbase_value" "$brk_reward" + + # Total input amount — needs verbosity 3 for prevout data + btc_block_v3=$(bitcoin-cli getblock "$brk_hash" 3 2>/dev/null || echo '{}') + if [[ "$btc_block_v3" != '{}' ]]; then + btc_total_input_amt=$(echo "$btc_block_v3" | jq '[.tx[1:][].vin[].prevout.value]' | sum_btc_values_to_sats) + brk_total_input_amt=$(echo "$brk_v1" | jq -r '.extras.totalInputAmt') + compare "totalInputAmt (sats)" "$btc_total_input_amt" "$brk_total_input_amt" + + if [[ "$btc_total_input_amt" != "$brk_total_input_amt" ]]; then + delta=$((brk_total_input_amt - btc_total_input_amt)) + printf "${RED} → delta: %d sats (%.8f BTC)${NC}\n" "$delta" "$(python3 -c "print($delta / 1e8)")" + fi + + # fees = non-coinbase inputs - non-coinbase outputs + btc_fees=$((btc_total_input_amt - btc_total_output_amt)) + brk_fees=$(echo "$brk_v1" | jq -r '.extras.totalFees') + compare "totalFees (sats)" "$btc_fees" "$brk_fees" + else + printf " ${YELLOW}getblock verbosity 3 unavailable — skipping totalInputAmt${NC}\n" + fi +else + printf " ${YELLOW}getblock verbosity 2 unavailable${NC}\n" +fi + +# ─── getmempoolinfo ─── +section "getmempoolinfo vs /api/mempool" + +btc_mempool=$(bitcoin-cli getmempoolinfo) +brk_mempool=$(curl -sf "$BRK/api/mempool") + +btc_mp_count=$(echo "$btc_mempool" | jq -r '.size') +btc_mp_vsize=$(echo "$btc_mempool" | jq -r '.bytes') +brk_mp_count=$(echo "$brk_mempool" | jq -r '.count') +brk_mp_vsize=$(echo "$brk_mempool" | jq -r '.vsize') + +printf " ${YELLOW}%-30s${NC} bitcoin-cli: %-12s brk: %-12s (live, may differ)\n" "tx count" "$btc_mp_count" "$brk_mp_count" +printf " ${YELLOW}%-30s${NC} bitcoin-cli: %-12s brk: %-12s (live, may differ)\n" "vsize" "$btc_mp_vsize" "$brk_mp_vsize" + +# ─── Difficulty adjustment ─── +section "Difficulty adjustment" + +compare_float "current difficulty" "$(echo "$btc_block" | jq -r '.difficulty')" "$(echo "$brk_v1" | jq -r '.difficulty')" + +# ─── Summary ─── +section "Summary" +printf " ${GREEN}Passed: %d${NC} ${RED}Failed: %d${NC} ${YELLOW}Approximate: %d${NC}\n" "$pass" "$fail" "$warn" + +if [[ $fail -gt 0 ]]; then + exit 1 +fi diff --git a/website/scripts/panes/explorer.js b/website/scripts/panes/explorer.js index 099a1d0de..60b972595 100644 --- a/website/scripts/panes/explorer.js +++ b/website/scripts/panes/explorer.js @@ -180,21 +180,31 @@ function renderDetails(block) { ["Pool", extras.pool.name], ["Pool ID", extras.pool.id.toString()], ["Pool Slug", extras.pool.slug], - ["Miner Names", extras.pool.minerNames || "N/A"], + ["Miner Names", extras.pool.minerNames?.join(", ") || "N/A"], ["Reward", `${(extras.reward / 1e8).toFixed(8)} BTC`], ["Total Fees", `${(extras.totalFees / 1e8).toFixed(8)} BTC`], ["Median Fee Rate", `${formatFeeRate(extras.medianFee)} sat/vB`], ["Avg Fee Rate", `${formatFeeRate(extras.avgFeeRate)} sat/vB`], ["Avg Fee", `${extras.avgFee.toLocaleString()} sat`], ["Median Fee", `${extras.medianFeeAmt.toLocaleString()} sat`], - ["Fee Range", extras.feeRange.map((f) => formatFeeRate(f)).join(", ") + " sat/vB"], - ["Fee Percentiles", extras.feePercentiles.map((f) => f.toLocaleString()).join(", ") + " sat"], + [ + "Fee Range", + extras.feeRange.map((f) => formatFeeRate(f)).join(", ") + " sat/vB", + ], + [ + "Fee Percentiles", + extras.feePercentiles.map((f) => f.toLocaleString()).join(", ") + + " sat", + ], ["Avg Tx Size", `${extras.avgTxSize.toLocaleString()} B`], ["Virtual Size", `${extras.virtualSize.toLocaleString()} vB`], ["Inputs", extras.totalInputs.toLocaleString()], ["Outputs", extras.totalOutputs.toLocaleString()], ["Total Input Amount", `${(extras.totalInputAmt / 1e8).toFixed(8)} BTC`], - ["Total Output Amount", `${(extras.totalOutputAmt / 1e8).toFixed(8)} BTC`], + [ + "Total Output Amount", + `${(extras.totalOutputAmt / 1e8).toFixed(8)} BTC`, + ], ["UTXO Set Change", extras.utxoSetChange.toLocaleString()], ["UTXO Set Size", extras.utxoSetSize.toLocaleString()], ["SegWit Txs", extras.segwitTotalTxs.toLocaleString()], diff --git a/website/styles/elements.css b/website/styles/elements.css index ddc242ecb..f6046ed81 100644 --- a/website/styles/elements.css +++ b/website/styles/elements.css @@ -47,7 +47,7 @@ a { } aside { - /*min-width: 0;*/ + min-width: 0; position: relative; /*height: 100%;*/ /*width: 100%;*/ diff --git a/website/styles/main.css b/website/styles/main.css index 5e97a9989..8644dbd32 100644 --- a/website/styles/main.css +++ b/website/styles/main.css @@ -25,7 +25,7 @@ main { &::after { bottom: 0; - height: 21rem; + height: 16rem; background-image: linear-gradient( to bottom, transparent, diff --git a/website/styles/panes/explorer.css b/website/styles/panes/explorer.css index 2700cb1e4..c74a6d65a 100644 --- a/website/styles/panes/explorer.css +++ b/website/styles/panes/explorer.css @@ -4,26 +4,81 @@ display: flex; overflow: hidden; + @media (max-width: 767px) { + overflow-y: auto; + padding: var(--main-padding) 0; + flex-direction: column; + + &::before, + &::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + width: var(--main-padding); + z-index: 1; + pointer-events: none; + } + + &::before { + left: 0; + background-image: linear-gradient( + to left, + transparent, + var(--background-color) + ); + } + + &::after { + right: 0; + background-image: linear-gradient( + to right, + transparent, + var(--background-color) + ); + } + } + --cube: 4.5rem; > * { - padding: var(--main-padding); + padding: 0 var(--main-padding); + + @media (min-width: 768px) { + padding: var(--main-padding); + } } #chain { flex-shrink: 0; - height: 100%; - overflow-y: auto; + + @media (max-width: 767px) { + overflow-x: auto; + } + + @media (min-width: 768px) { + height: 100%; + overflow-y: auto; + } .blocks { display: flex; flex-direction: column-reverse; - gap: calc(var(--cube) * 0.75); + --gap: 0.75; + gap: calc(var(--cube) * var(--gap)); margin-right: var(--cube); margin-top: calc(var(--cube) * -0.25); + + @media (max-width: 767px) { + --gap: 1.25; + flex-direction: row-reverse; + height: 11.5rem; + width: max-content; + } } .cube { + margin-top: -0.375rem; margin-left: calc(var(--cube) * -0.25); flex-shrink: 0; position: relative; @@ -126,10 +181,13 @@ #block-details { flex: 1; - overflow-y: auto; font-size: var(--font-size-sm); line-height: var(--line-height-sm); + @media (min-width: 768px) { + overflow-y: auto; + } + h1 { margin-bottom: 1rem;