use std::{borrow::Cow, cmp::Reverse}; use brk_error::{Error, OptionData, Result}; use brk_types::{ BlockInfoV1, Day1, Height, Pool, PoolBlockCounts, PoolBlockShares, PoolDetail, PoolDetailInfo, PoolHashrateEntry, PoolInfo, PoolSlug, PoolStats, PoolsSummary, StoredF64, StoredU64, TimePeriod, pools, }; use vecdb::{AnyVec, ReadableVec, VecIndex}; use crate::Query; /// 7-day lookback for share computation. const LOOKBACK_DAYS: usize = 7; /// Weekly sample interval (~604800s). const SAMPLE_WEEKLY: usize = 7; /// Pre-read shared data for hashrate computation. struct HashrateSharedData { start_day: usize, end_day: usize, daily_hashrate: Vec>, first_heights: Vec, } impl Query { /// Mining-pool leaderboard for `time_period`. For each pool, computes /// block count over the window via `cumulative(end) - cumulative(start - 1)` /// (tip-cumulative minus pre-window-cumulative), sorts pools by count /// descending, assigns ranks, and emits the per-pool share. Also bundles /// current / 3d / 1w network hashrate snapshots. Returns zeros early /// when no blocks have been indexed. The window start uses the /// timestamp-based lookback vecs (`_24h`, `_3d`, ...) rather than /// block-count math; `TimePeriod::All` walks from genesis. pub fn mining_pools(&self, time_period: TimePeriod) -> Result { let computer = self.computer(); let current_height = self.height(); if computer.pools.pool.len() == 0 { return Ok(PoolsSummary { pools: vec![], block_count: 0, last_estimated_hashrate: 0, last_estimated_hashrate3d: 0, last_estimated_hashrate1w: 0, }); } let start = self.start_height(time_period)?.to_usize(); let lookback = &computer.blocks.lookback; let pools = pools(); let mut pool_data: Vec<(&'static Pool, u64)> = Vec::new(); // Range count = cumulative(end) - cumulative(start - 1). for (pool_id, cumulative) in computer .pools .major .iter() .map(|(id, v)| (id, &v.blocks_mined.cumulative.height)) .chain( computer .pools .minor .iter() .map(|(id, v)| (id, &v.blocks_mined.cumulative.height)), ) { let count_at_end: u64 = *cumulative.collect_one(current_height).data()?; let count_at_start: u64 = if start == 0 { 0 } else { *cumulative.collect_one(Height::from(start - 1)).data()? }; let block_count = count_at_end.saturating_sub(count_at_start); if block_count > 0 { pool_data.push((pools.get(*pool_id), block_count)); } } pool_data.sort_by_key(|p| Reverse(p.1)); let total_blocks: u64 = pool_data.iter().map(|(_, count)| count).sum(); let pool_stats: Vec = pool_data .into_iter() .enumerate() .map(|(idx, (pool, block_count))| { let share = if total_blocks > 0 { block_count as f64 / total_blocks as f64 } else { 0.0 }; PoolStats::new(pool, block_count, (idx + 1) as u32, share) }) .collect(); let last_estimated_hashrate = self.hashrate_at(current_height)?; let last_estimated_hashrate3d = self.hashrate_at(lookback._3d.collect_one(current_height).data()?)?; let last_estimated_hashrate1w = self.hashrate_at(lookback._1w.collect_one(current_height).data()?)?; Ok(PoolsSummary { pools: pool_stats, block_count: total_blocks, last_estimated_hashrate, last_estimated_hashrate3d, last_estimated_hashrate1w, }) } /// All supported pools as `PoolInfo`. Static list, no indexer reads, can't fail. pub fn all_pools(&self) -> Vec { pools().iter().map(PoolInfo::from).collect() } /// Per-pool detail: lifetime block count plus 24h and 1w windowed counts, /// each as a share of network blocks in the same window. The 24h share is /// also used to weight the current 1-day network hashrate into a per-pool /// `estimated_hashrate`. `total_reward` is `Some` only for major pools /// (minor pools don't track per-pool reward sums); under stamp lag on a /// major pool's reward vec this errors rather than silently reporting /// `None`. pub fn pool_detail(&self, slug: PoolSlug) -> Result { let computer = self.computer(); let current_height = self.height(); let end = current_height.to_usize(); let pools_list = pools(); let pool = pools_list.get(slug); let cumulative = computer .pools .major .get(&slug) .map(|v| &v.blocks_mined.cumulative.height) .or_else(|| { computer .pools .minor .get(&slug) .map(|v| &v.blocks_mined.cumulative.height) }) .ok_or_else(|| { Error::Internal( "pool slug present in static list but missing from major/minor maps", ) })?; let total_all: u64 = *cumulative.collect_one(current_height).data()?; let lookback = &computer.blocks.lookback; let start_24h = lookback._24h.collect_one(current_height).data()?.to_usize(); let count_before_24h: u64 = if start_24h == 0 { 0 } else { *cumulative.collect_one(Height::from(start_24h - 1)).data()? }; let total_24h = total_all.saturating_sub(count_before_24h); let start_1w = lookback._1w.collect_one(current_height).data()?.to_usize(); let count_before_1w: u64 = if start_1w == 0 { 0 } else { *cumulative.collect_one(Height::from(start_1w - 1)).data()? }; let total_1w = total_all.saturating_sub(count_before_1w); let network_blocks_all = (end + 1) as u64; let network_blocks_24h = (end - start_24h + 1) as u64; let network_blocks_1w = (end - start_1w + 1) as u64; let share_all = if network_blocks_all > 0 { total_all as f64 / network_blocks_all as f64 } else { 0.0 }; let share_24h = if network_blocks_24h > 0 { total_24h as f64 / network_blocks_24h as f64 } else { 0.0 }; let share_1w = if network_blocks_1w > 0 { total_1w as f64 / network_blocks_1w as f64 } else { 0.0 }; let network_hr = self.hashrate_at(current_height)?; let estimated_hashrate = (share_24h * network_hr as f64) as u128; let total_reward = if let Some(major) = computer.pools.major.get(&slug) { Some( major .rewards .cumulative .sats .height .collect_one(current_height) .data()?, ) } else { None }; Ok(PoolDetail { pool: PoolDetailInfo::from(pool), block_count: PoolBlockCounts { all: total_all, day: total_24h, week: total_1w, }, block_share: PoolBlockShares { all: share_all, day: share_24h, week: share_1w, }, estimated_hashrate, reported_hashrate: None, total_reward, }) } /// Page of blocks mined by `slug`, in descending height order, capped at /// `limit`. `before_height` is the inclusive upper bound to paginate from /// (defaults to tip). Returns an empty `Vec` if the pool has no recorded /// blocks. Heights come from a sorted-ascending per-pool index, so the /// page is computed via `partition_point` then reversed; consecutive /// runs are merged into a single bulk read of `blocks_v1_range`. pub fn pool_blocks( &self, slug: PoolSlug, before_height: Option, limit: usize, ) -> Result> { let computer = self.computer(); let tip = self.height().to_usize(); let upper = before_height.map(|h| h.to_usize()).unwrap_or(tip); let end = upper.min(tip); let heights: Vec = computer .pools .pool_heights .read() .get(&slug) .map(|pool_heights| { let pos = pool_heights.partition_point(|h| h.to_usize() <= end); let start = pos.saturating_sub(limit); pool_heights[start..pos] .iter() .rev() .map(|h| h.to_usize()) .collect() }) .unwrap_or_default(); // Group consecutive descending heights into ranges for batch reads. let mut blocks = Vec::with_capacity(heights.len()); let mut i = 0; while i < heights.len() { let hi = heights[i]; while i + 1 < heights.len() && heights[i + 1] + 1 == heights[i] { i += 1; } let mut v = self.blocks_v1_range(heights[i], hi + 1)?; blocks.append(&mut v); i += 1; } Ok(blocks) } /// Weekly-sampled hashrate series for a single pool over the full chain. /// Each point's hashrate is `network_hashrate(day) * pool_share_over_7d`, /// where the share is the pool's last-7-days block count divided by the /// network's last-7-days block count. pub fn pool_hashrate(&self, slug: PoolSlug) -> Result> { let pool_name = pools().get(slug).name; let shared = self.hashrate_shared_data(0)?; let pool_cum = self.pool_daily_cumulative(slug, shared.start_day, shared.end_day)?; Ok(Self::compute_hashrate_entries( &shared, &pool_cum, pool_name, SAMPLE_WEEKLY, )) } /// Multi-pool weekly-sampled hashrate series over `time_period`. Walks /// the full chain when `time_period` is `None` or `Some(TimePeriod::All)`. /// For each known pool, emits one entry per weekly sample where the /// hashrate is `network_hashrate(day) * pool_share_over_7d`, tagged with /// `pool_name`. Entries from all pools are concatenated; the chart layer /// groups by pool name. pub fn pools_hashrate( &self, time_period: Option, ) -> Result> { let start_height = match time_period { Some(tp) => self.start_height(tp)?.to_usize(), None => 0, }; let shared = self.hashrate_shared_data(start_height)?; let pools_list = pools(); let mut entries = Vec::new(); for pool in pools_list.iter() { let pool_cum = self.pool_daily_cumulative(pool.slug, shared.start_day, shared.end_day)?; entries.extend(Self::compute_hashrate_entries( &shared, &pool_cum, pool.name, SAMPLE_WEEKLY, )); } Ok(entries) } /// Pre-loads the network-wide day1 series (network hashrate, per-day /// first heights) over `[start_day, end_day)`, where `start_day` is the /// day index of `start_height` and `end_day` is the day index of the /// current tip plus one (exclusive). Reused across pools so the network /// series is read only once per request. fn hashrate_shared_data(&self, start_height: usize) -> Result { let computer = self.computer(); let current_height = self.height(); let start_day = computer .indexes .height .day1 .collect_one_at(start_height) .data()? .to_usize(); let end_day = computer .indexes .height .day1 .collect_one(current_height) .data()? .to_usize() + 1; let daily_hashrate = computer .mining .hashrate .rate .base .day1 .collect_range_at(start_day, end_day); let first_heights = computer .indexes .day1 .first_height .collect_range_at(start_day, end_day); Ok(HashrateSharedData { start_day, end_day, daily_hashrate, first_heights, }) } /// Reads the pool's daily-cumulative blocks-mined vec over the half-open /// day range `[start_day, end_day)`. Major pools nest under `.base` /// (additional derived computations), minor pools don't, so the slug is /// looked up in both maps. Errors `Internal` if the slug is in neither /// map: this can only fire on a static-pool-list / indexer-map mismatch /// since both callers guarantee the slug is in the static list, so the /// route layer never reaches a user-driven not-found path here. fn pool_daily_cumulative( &self, slug: PoolSlug, start_day: usize, end_day: usize, ) -> Result>> { let computer = self.computer(); computer .pools .major .get(&slug) .map(|v| { v.base .blocks_mined .cumulative .day1 .collect_range_at(start_day, end_day) }) .or_else(|| { computer.pools.minor.get(&slug).map(|v| { v.blocks_mined .cumulative .day1 .collect_range_at(start_day, end_day) }) }) .ok_or_else(|| { Error::Internal( "pool slug present in static list but missing from major/minor maps", ) }) } /// Per-pool hashrate-share entries from pre-loaded daily cumulative blocks /// plus the shared network series. Walks samples from `LOOKBACK_DAYS` /// onward in `sample_days` strides; for each sample emits one entry with /// pool_blocks = pool_cum[i] - pool_cum[i - LOOKBACK_DAYS] /// total_blocks = first_heights[i] - first_heights[i - LOOKBACK_DAYS] /// share = pool_blocks / total_blocks /// avg_hashrate = daily_hashrate[i] * share /// Skips samples where either cumulative value is `None`, where /// `pool_blocks == 0`, where `total_blocks == 0`, or where the network /// hashrate for that day is unavailable. The iteration is bounded by /// the shortest of `pool_cum`, `shared.first_heights`, and /// `shared.daily_hashrate` so per-vec stamp-lag truncation from /// `collect_range_at` degrades the chart's tail rather than panicking /// on out-of-bounds indexing. `LOOKBACK_DAYS` (rolling window) and /// `sample_days` (point spacing) are independent. fn compute_hashrate_entries( shared: &HashrateSharedData, pool_cum: &[Option], pool_name: &'static str, sample_days: usize, ) -> Vec { let total = pool_cum .len() .min(shared.first_heights.len()) .min(shared.daily_hashrate.len()); if total <= LOOKBACK_DAYS { return vec![]; } let mut entries = Vec::new(); let mut i = LOOKBACK_DAYS; while i < total { if let (Some(cum_now), Some(cum_prev)) = (pool_cum[i], pool_cum[i - LOOKBACK_DAYS]) { let pool_blocks = (*cum_now).saturating_sub(*cum_prev); if pool_blocks > 0 { let h_now = shared.first_heights[i].to_usize(); let h_prev = shared.first_heights[i - LOOKBACK_DAYS].to_usize(); let total_blocks = h_now.saturating_sub(h_prev); if total_blocks > 0 && let Some(hr) = shared.daily_hashrate[i].as_ref() { let network_hr = **hr; let share = pool_blocks as f64 / total_blocks as f64; let day = Day1::from(shared.start_day + i); entries.push(PoolHashrateEntry { timestamp: day.to_timestamp(), avg_hashrate: (network_hr * share) as u128, share, pool_name: Cow::Borrowed(pool_name), }); } } } i += sample_days; } entries } }