diff --git a/Cargo.lock b/Cargo.lock index ba2c494e4..34ad68265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1200,9 +1200,9 @@ dependencies = [ "brk_error", "brk_fetcher", "brk_indexer", - "brk_iterator", "brk_logger", "brk_mcp", + "brk_mempool", "brk_query", "brk_reader", "brk_rpc", diff --git a/crates/brk_computer/src/pools/vecs.rs b/crates/brk_computer/src/pools/vecs.rs index 9e512a8b2..6de16fcd4 100644 --- a/crates/brk_computer/src/pools/vecs.rs +++ b/crates/brk_computer/src/pools/vecs.rs @@ -42,7 +42,7 @@ impl Vecs { indexes: &indexes::Vecs, price: Option<&price::Vecs>, ) -> Result { - let suffix = |s: &str| format!("{}_{s}", slug.to_string()); + let suffix = |s: &str| format!("{}_{s}", slug); let compute_dollars = price.is_some(); let version = parent_version + Version::ZERO; diff --git a/crates/brk_mcp/src/lib.rs b/crates/brk_mcp/src/lib.rs index d9bfb9746..1cffb5d85 100644 --- a/crates/brk_mcp/src/lib.rs +++ b/crates/brk_mcp/src/lib.rs @@ -1,6 +1,6 @@ #![doc = include_str!("../README.md")] -use brk_query::{AsyncQuery, PaginatedIndexParam, PaginationParam, Params}; +use brk_query::{AsyncQuery, MetricSelection, Pagination, PaginationIndex}; use brk_rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, @@ -36,7 +36,7 @@ Get the count of unique metrics. async fn get_metric_count(&self) -> Result { info!("mcp: distinct_metric_count"); Ok(CallToolResult::success(vec![ - Content::json(self.query.distinct_metric_count().await).unwrap(), + Content::json(self.query.sync(|q| q.distinct_metric_count())).unwrap(), ])) } @@ -46,7 +46,7 @@ Get the count of all metrics. (distinct metrics multiplied by the number of inde async fn get_vec_count(&self) -> Result { info!("mcp: total_metric_count"); Ok(CallToolResult::success(vec![ - Content::json(self.query.total_metric_count().await).unwrap(), + Content::json(self.query.sync(|q| q.total_metric_count())).unwrap(), ])) } @@ -56,7 +56,7 @@ Get the list of all existing indexes and their accepted variants. async fn get_indexes(&self) -> Result { info!("mcp: get_indexes"); Ok(CallToolResult::success(vec![ - Content::json(self.query.get_indexes().await).unwrap(), + Content::json(self.query.inner().get_indexes()).unwrap(), ])) } @@ -67,11 +67,11 @@ If the `page` param is omitted, it will default to the first page. ")] async fn get_vecids( &self, - Parameters(pagination): Parameters, + Parameters(pagination): Parameters, ) -> Result { info!("mcp: get_metrics"); Ok(CallToolResult::success(vec![ - Content::json(self.query.get_metrics(pagination).await).unwrap(), + Content::json(self.query.sync(|q| q.get_metrics(pagination))).unwrap(), ])) } @@ -82,11 +82,16 @@ If the `page` param is omitted, it will default to the first page. ")] async fn get_index_to_vecids( &self, - Parameters(paginated_index): Parameters, + Parameters(paginated_index): Parameters, ) -> Result { info!("mcp: get_index_to_vecids"); + let result = self + .query + .inner() + .get_index_to_vecids(paginated_index) + .unwrap_or_default(); Ok(CallToolResult::success(vec![ - Content::json(self.query.get_index_to_vecids(paginated_index).await).unwrap(), + Content::json(result).unwrap(), ])) } @@ -100,7 +105,7 @@ The list will be empty if the vec id isn't correct. ) -> Result { info!("mcp: get_vecid_to_indexes"); Ok(CallToolResult::success(vec![ - Content::json(self.query.metric_to_indexes(metric).await).unwrap(), + Content::json(self.query.inner().metric_to_indexes(metric)).unwrap(), ])) } @@ -113,11 +118,11 @@ The response's format will depend on the given parameters, it will be: ")] async fn get_vecs( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> Result { info!("mcp: get_vecs"); Ok(CallToolResult::success(vec![Content::text( - match self.query.search_and_format(params).await { + match self.query.run(move |q| q.search_and_format(params)).await { Ok(output) => output.to_string(), Err(e) => format!("Error:\n{e}"), }, diff --git a/crates/brk_query/examples/main.rs b/crates/brk_query/examples/main.rs index ffdf86f3f..d8fbead1b 100644 --- a/crates/brk_query/examples/main.rs +++ b/crates/brk_query/examples/main.rs @@ -3,10 +3,10 @@ use std::{env, fs, path::Path}; use brk_computer::Computer; use brk_error::Result; use brk_indexer::Indexer; -use brk_query::{Params, ParamsOpt, Query}; +use brk_query::Query; use brk_reader::Reader; use brk_rpc::{Auth, Client}; -use brk_types::Index; +use brk_types::{DataRangeFormat, Index, MetricSelection}; use vecdb::Exit; pub fn main() -> Result<()> { @@ -38,15 +38,15 @@ pub fn main() -> Result<()> { let query = Query::build(&reader, &indexer, &computer, None); - dbg!(query.search_and_format(Params { + dbg!(query.search_and_format(MetricSelection { index: Index::Height, metrics: vec!["date"].into(), - rest: ParamsOpt::default().set_from(-1), + range: DataRangeFormat::default().set_from(-1), })?); - dbg!(query.search_and_format(Params { + dbg!(query.search_and_format(MetricSelection { index: Index::Height, metrics: vec!["date", "timestamp"].into(), - rest: ParamsOpt::default().set_from(-10).set_count(5), + range: DataRangeFormat::default().set_from(-10).set_count(5), })?); Ok(()) diff --git a/crates/brk_query/src/async.rs b/crates/brk_query/src/async.rs index 20cb202ae..4a6a31a9d 100644 --- a/crates/brk_query/src/async.rs +++ b/crates/brk_query/src/async.rs @@ -1,22 +1,11 @@ -use std::collections::BTreeMap; - use brk_computer::Computer; use brk_error::Result; use brk_indexer::Indexer; use brk_mempool::Mempool; use brk_reader::Reader; -use brk_types::{ - Address, AddressStats, BlockInfo, BlockStatus, BlockTimestamp, DifficultyAdjustment, - HashrateSummary, Height, Index, IndexInfo, Limit, MempoolBlock, MempoolInfo, Metric, - MetricCount, PoolDetail, PoolInfo, PoolSlug, PoolsSummary, RecommendedFees, TimePeriod, - Timestamp, Transaction, TreeNode, TxOutspend, TxStatus, Txid, TxidPath, Utxo, Vout, -}; use tokio::task::spawn_blocking; -use crate::{ - Output, PaginatedIndexParam, PaginatedMetrics, PaginationParam, Params, Query, - vecs::{IndexToVec, MetricToVec, Vecs}, -}; +use crate::Query; #[derive(Clone)] pub struct AsyncQuery(Query); @@ -31,282 +20,37 @@ impl AsyncQuery { Self(Query::build(reader, indexer, computer, mempool)) } + /// Run a blocking query operation on a spawn_blocking thread. + /// Use this for I/O-heavy or CPU-intensive operations. + /// + /// # Example + /// ```ignore + /// let address_stats = query.run(move |q| q.address(address)).await?; + /// ``` + pub async fn run(&self, f: F) -> Result + where + F: FnOnce(&Query) -> Result + Send + 'static, + T: Send + 'static, + { + let query = self.0.clone(); + spawn_blocking(move || f(&query)).await? + } + + /// Run a cheap sync operation directly without spawn_blocking. + /// Use this for simple accessors that don't do I/O. + /// + /// # Example + /// ```ignore + /// let height = query.sync(|q| q.height()); + /// ``` + pub fn sync(&self, f: F) -> T + where + F: FnOnce(&Query) -> T, + { + f(&self.0) + } + pub fn inner(&self) -> &Query { &self.0 } - - pub async fn get_height(&self) -> Height { - self.0.get_height() - } - - pub async fn get_address(&self, address: Address) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_address(address)).await? - } - - pub async fn get_address_txids( - &self, - address: Address, - after_txid: Option, - limit: usize, - ) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || query.get_address_txids(address, after_txid, limit)).await? - } - - pub async fn get_address_utxos(&self, address: Address) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || query.get_address_utxos(address)).await? - } - - pub async fn get_address_mempool_txids(&self, address: Address) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || query.get_address_mempool_txids(address)).await? - } - - pub async fn get_transaction(&self, txid: TxidPath) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_transaction(txid)).await? - } - - pub async fn get_transaction_status(&self, txid: TxidPath) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_transaction_status(txid)).await? - } - - pub async fn get_transaction_hex(&self, txid: TxidPath) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_transaction_hex(txid)).await? - } - - pub async fn get_tx_outspend(&self, txid: TxidPath, vout: Vout) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_tx_outspend(txid, vout)).await? - } - - pub async fn get_tx_outspends(&self, txid: TxidPath) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || query.get_tx_outspends(txid)).await? - } - - pub async fn get_block(&self, hash: String) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_block(&hash)).await? - } - - pub async fn get_block_by_height(&self, height: Height) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_block_by_height(height)).await? - } - - pub async fn get_block_by_timestamp(&self, timestamp: Timestamp) -> Result { - self.0.get_block_by_timestamp(timestamp) - } - - pub async fn get_block_status(&self, hash: String) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_block_status(&hash)).await? - } - - pub async fn get_blocks(&self, start_height: Option) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || query.get_blocks(start_height)).await? - } - - pub async fn get_block_txids(&self, hash: String) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || query.get_block_txids(&hash)).await? - } - - pub async fn get_block_txs( - &self, - hash: String, - start_index: usize, - ) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || query.get_block_txs(&hash, start_index)).await? - } - - pub async fn get_block_txid_at_index(&self, hash: String, index: usize) -> Result { - self.0.get_block_txid_at_index(&hash, index) - } - - pub async fn get_block_raw(&self, hash: String) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || query.get_block_raw(&hash)).await? - } - - pub async fn get_mempool_info(&self) -> Result { - self.0.get_mempool_info() - } - - pub async fn get_mempool_txids(&self) -> Result> { - self.0.get_mempool_txids() - } - - pub async fn get_recommended_fees(&self) -> Result { - self.0.get_recommended_fees() - } - - pub async fn get_mempool_blocks(&self) -> Result> { - self.0.get_mempool_blocks() - } - - pub async fn get_difficulty_adjustment(&self) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_difficulty_adjustment()).await? - } - - pub async fn get_mining_pools(&self, time_period: TimePeriod) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_mining_pools(time_period)).await? - } - - pub async fn get_all_pools(&self) -> Result> { - Ok(self.0.get_all_pools()) - } - - pub async fn get_pool_detail(&self, slug: PoolSlug) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_pool_detail(slug)).await? - } - - pub async fn get_hashrate(&self, time_period: Option) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_hashrate(time_period)).await? - } - - pub async fn get_difficulty_adjustments( - &self, - time_period: Option, - ) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || query.get_difficulty_adjustments(time_period)).await? - } - - pub async fn get_block_fees( - &self, - time_period: TimePeriod, - ) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || query.get_block_fees(time_period)).await? - } - - pub async fn get_block_rewards( - &self, - time_period: TimePeriod, - ) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || query.get_block_rewards(time_period)).await? - } - - pub async fn get_block_fee_rates( - &self, - time_period: TimePeriod, - ) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || query.get_block_fee_rates(time_period)).await? - } - - pub async fn get_block_sizes_weights( - &self, - time_period: TimePeriod, - ) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_block_sizes_weights(time_period)).await? - } - - pub async fn get_reward_stats(&self, block_count: usize) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.get_reward_stats(block_count)).await? - } - - pub async fn match_metric(&self, metric: Metric, limit: Limit) -> Result> { - let query = self.0.clone(); - spawn_blocking(move || Ok(query.match_metric(&metric, limit))).await? - } - - // pub async fn search_metric_with_index( - // &self, - // metric: &str, - // index: Index, - // // params: &Params, - // ) -> Result> { - // let query = self.0.clone(); - // spawn_blocking(move || query.search_metric_with_index(metric, index)).await? - // } - - // pub async fn format( - // &self, - // metrics: Vec<(String, &&dyn AnyExportableVec)>, - // params: &ParamsOpt, - // ) -> Result { - // let query = self.0.clone(); - // spawn_blocking(move || query.format(metrics, params)).await? - // } - - pub async fn search_and_format(&self, params: Params) -> Result { - let query = self.0.clone(); - spawn_blocking(move || query.search_and_format(params)).await? - } - - pub async fn metric_to_index_to_vec(&self) -> &BTreeMap<&str, IndexToVec<'_>> { - self.0.metric_to_index_to_vec() - } - - pub async fn index_to_metric_to_vec(&self) -> &BTreeMap> { - self.0.index_to_metric_to_vec() - } - - pub async fn metric_count(&self) -> MetricCount { - self.0.metric_count() - } - - pub async fn distinct_metric_count(&self) -> usize { - self.0.distinct_metric_count() - } - - pub async fn total_metric_count(&self) -> usize { - self.0.total_metric_count() - } - - pub async fn get_indexes(&self) -> &[IndexInfo] { - self.0.get_indexes() - } - - pub async fn get_metrics(&self, pagination: PaginationParam) -> PaginatedMetrics { - self.0.get_metrics(pagination) - } - - pub async fn get_metrics_catalog(&self) -> &TreeNode { - self.0.get_metrics_catalog() - } - - pub async fn get_index_to_vecids(&self, paginated_index: PaginatedIndexParam) -> Vec<&str> { - self.0.get_index_to_vecids(paginated_index) - } - - pub async fn metric_to_indexes(&self, metric: Metric) -> Option<&Vec> { - self.0.metric_to_indexes(metric) - } - - #[inline] - pub async fn reader(&self) -> &Reader { - self.0.reader() - } - - #[inline] - pub async fn indexer(&self) -> &Indexer { - self.0.indexer() - } - - #[inline] - pub async fn computer(&self) -> &Computer { - self.0.computer() - } - - #[inline] - pub async fn vecs(&self) -> &'static Vecs<'static> { - self.0.vecs() - } } diff --git a/crates/brk_query/src/chain/addr/addr.rs b/crates/brk_query/src/chain/addr/addr.rs deleted file mode 100644 index 8f3c4c990..000000000 --- a/crates/brk_query/src/chain/addr/addr.rs +++ /dev/null @@ -1,87 +0,0 @@ -use std::str::FromStr; - -use bitcoin::{Network, PublicKey, ScriptBuf}; -use brk_error::{Error, Result}; -use brk_types::{ - Address, AddressBytes, AddressChainStats, AddressHash, AddressStats, AnyAddressDataIndexEnum, - OutputType, -}; -use vecdb::TypedVecIterator; - -use crate::Query; - -pub fn get_address(Address { address }: Address, query: &Query) -> Result { - let indexer = query.indexer(); - let computer = query.computer(); - let stores = &indexer.stores; - - let script = if let Ok(address) = bitcoin::Address::from_str(&address) { - if !address.is_valid_for_network(Network::Bitcoin) { - return Err(Error::InvalidNetwork); - } - let address = address.assume_checked(); - address.script_pubkey() - } else if let Ok(pubkey) = PublicKey::from_str(&address) { - ScriptBuf::new_p2pk(&pubkey) - } else { - return Err(Error::InvalidAddress); - }; - - let outputtype = OutputType::from(&script); - let Ok(bytes) = AddressBytes::try_from((&script, outputtype)) else { - return Err(Error::Str("Failed to convert the address to bytes")); - }; - let addresstype = outputtype; - let hash = AddressHash::from(&bytes); - - let Ok(Some(type_index)) = stores - .addresstype_to_addresshash_to_addressindex - .get(addresstype) - .unwrap() - .get(&hash) - .map(|opt| opt.map(|cow| cow.into_owned())) - else { - return Err(Error::UnknownAddress); - }; - - let any_address_index = computer - .stateful - .any_address_indexes - .get_anyaddressindex_once(outputtype, type_index)?; - - let address_data = match any_address_index.to_enum() { - AnyAddressDataIndexEnum::Loaded(index) => computer - .stateful - .addresses_data - .loaded - .iter()? - .get_unwrap(index), - AnyAddressDataIndexEnum::Empty(index) => computer - .stateful - .addresses_data - .empty - .iter()? - .get_unwrap(index) - .into(), - }; - - Ok(AddressStats { - address: address.into(), - chain_stats: AddressChainStats { - type_index, - funded_txo_count: address_data.funded_txo_count, - funded_txo_sum: address_data.received, - spent_txo_count: address_data.spent_txo_count, - spent_txo_sum: address_data.sent, - tx_count: address_data.tx_count, - }, - mempool_stats: query.mempool().map(|mempool| { - mempool - .get_addresses() - .get(&bytes) - .map(|(stats, _)| stats) - .cloned() - .unwrap_or_default() - }), - }) -} diff --git a/crates/brk_query/src/chain/addr/mempool_txids.rs b/crates/brk_query/src/chain/addr/mempool_txids.rs deleted file mode 100644 index 4a5015b3b..000000000 --- a/crates/brk_query/src/chain/addr/mempool_txids.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::str::FromStr; - -use brk_error::{Error, Result}; -use brk_types::{Address, AddressBytes, Txid}; - -use crate::Query; - -/// Maximum number of mempool txids to return -const MAX_MEMPOOL_TXIDS: usize = 50; - -/// Get mempool transaction IDs for an address -pub fn get_address_mempool_txids(address: Address, query: &Query) -> Result> { - let mempool = query.mempool().ok_or(Error::Str("Mempool not available"))?; - - let bytes = AddressBytes::from_str(&address.address)?; - let addresses = mempool.get_addresses(); - - let txids: Vec = addresses - .get(&bytes) - .map(|(_, txids)| txids.iter().take(MAX_MEMPOOL_TXIDS).cloned().collect()) - .unwrap_or_default(); - - Ok(txids) -} diff --git a/crates/brk_query/src/chain/addr/mod.rs b/crates/brk_query/src/chain/addr/mod.rs deleted file mode 100644 index fbbbc124d..000000000 --- a/crates/brk_query/src/chain/addr/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -mod addr; -mod mempool_txids; -mod resolve; -mod txids; -mod utxos; -mod validate; - -pub use addr::*; -pub use mempool_txids::*; -pub use txids::*; -pub use utxos::*; -pub use validate::*; diff --git a/crates/brk_query/src/chain/addr/resolve.rs b/crates/brk_query/src/chain/addr/resolve.rs deleted file mode 100644 index c6750a5d1..000000000 --- a/crates/brk_query/src/chain/addr/resolve.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::str::FromStr; - -use brk_error::{Error, Result}; -use brk_types::{Address, AddressBytes, AddressHash, OutputType, TypeIndex}; - -use crate::Query; - -/// Resolve an address string to its output type and type_index -pub fn resolve_address(address: &Address, query: &Query) -> Result<(OutputType, TypeIndex)> { - let stores = &query.indexer().stores; - - let bytes = AddressBytes::from_str(&address.address)?; - let outputtype = OutputType::from(&bytes); - let hash = AddressHash::from(&bytes); - - let Ok(Some(type_index)) = stores - .addresstype_to_addresshash_to_addressindex - .get(outputtype) - .unwrap() - .get(&hash) - .map(|opt| opt.map(|cow| cow.into_owned())) - else { - return Err(Error::UnknownAddress); - }; - - Ok((outputtype, type_index)) -} diff --git a/crates/brk_query/src/chain/addr/txids.rs b/crates/brk_query/src/chain/addr/txids.rs deleted file mode 100644 index 61efc29a5..000000000 --- a/crates/brk_query/src/chain/addr/txids.rs +++ /dev/null @@ -1,60 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::{Address, AddressIndexTxIndex, TxIndex, Txid, Unit}; -use vecdb::TypedVecIterator; - -use super::resolve::resolve_address; -use crate::Query; - -/// Get transaction IDs for an address, newest first -pub fn get_address_txids( - address: Address, - after_txid: Option, - limit: usize, - query: &Query, -) -> Result> { - let indexer = query.indexer(); - let stores = &indexer.stores; - - let (outputtype, type_index) = resolve_address(&address, query)?; - - let store = stores - .addresstype_to_addressindex_and_txindex - .get(outputtype) - .unwrap(); - - let prefix = u32::from(type_index).to_be_bytes(); - - let after_txindex = if let Some(after_txid) = after_txid { - let txindex = stores - .txidprefix_to_txindex - .get(&after_txid.into()) - .map_err(|_| Error::Str("Failed to look up after_txid"))? - .ok_or(Error::Str("after_txid not found"))? - .into_owned(); - Some(txindex) - } else { - None - }; - - let txindices: Vec = store - .prefix(prefix) - .rev() - .filter(|(key, _): &(AddressIndexTxIndex, Unit)| { - if let Some(after) = after_txindex { - key.txindex() < after - } else { - true - } - }) - .take(limit) - .map(|(key, _)| key.txindex()) - .collect(); - - let mut txindex_to_txid_iter = indexer.vecs.tx.txindex_to_txid.iter()?; - let txids: Vec = txindices - .into_iter() - .map(|txindex| txindex_to_txid_iter.get_unwrap(txindex)) - .collect(); - - Ok(txids) -} diff --git a/crates/brk_query/src/chain/addr/utxos.rs b/crates/brk_query/src/chain/addr/utxos.rs deleted file mode 100644 index 6b368b2c9..000000000 --- a/crates/brk_query/src/chain/addr/utxos.rs +++ /dev/null @@ -1,65 +0,0 @@ -use brk_error::Result; -use brk_types::{ - Address, AddressIndexOutPoint, Sats, TxIndex, TxStatus, Txid, Unit, Utxo, Vout, -}; -use vecdb::TypedVecIterator; - -use super::resolve::resolve_address; -use crate::Query; - -/// Get UTXOs for an address -pub fn get_address_utxos(address: Address, query: &Query) -> Result> { - let indexer = query.indexer(); - let stores = &indexer.stores; - let vecs = &indexer.vecs; - - let (outputtype, type_index) = resolve_address(&address, query)?; - - let store = stores - .addresstype_to_addressindex_and_unspentoutpoint - .get(outputtype) - .unwrap(); - - let prefix = u32::from(type_index).to_be_bytes(); - - // Collect outpoints (txindex, vout) - let outpoints: Vec<(TxIndex, Vout)> = store - .prefix(prefix) - .map(|(key, _): (AddressIndexOutPoint, Unit)| (key.txindex(), key.vout())) - .collect(); - - // Create iterators for looking up tx data - let mut txindex_to_txid_iter = vecs.tx.txindex_to_txid.iter()?; - let mut txindex_to_height_iter = vecs.tx.txindex_to_height.iter()?; - let mut txindex_to_first_txoutindex_iter = vecs.tx.txindex_to_first_txoutindex.iter()?; - let mut txoutindex_to_value_iter = vecs.txout.txoutindex_to_value.iter()?; - let mut height_to_blockhash_iter = vecs.block.height_to_blockhash.iter()?; - let mut height_to_timestamp_iter = vecs.block.height_to_timestamp.iter()?; - - let utxos: Vec = outpoints - .into_iter() - .map(|(txindex, vout)| { - let txid: Txid = txindex_to_txid_iter.get_unwrap(txindex); - let height = txindex_to_height_iter.get_unwrap(txindex); - let first_txoutindex = txindex_to_first_txoutindex_iter.get_unwrap(txindex); - let txoutindex = first_txoutindex + vout; - let value: Sats = txoutindex_to_value_iter.get_unwrap(txoutindex); - let block_hash = height_to_blockhash_iter.get_unwrap(height); - let block_time = height_to_timestamp_iter.get_unwrap(height); - - Utxo { - txid, - vout, - status: TxStatus { - confirmed: true, - block_height: Some(height), - block_hash: Some(block_hash), - block_time: Some(block_time), - }, - value, - } - }) - .collect(); - - Ok(utxos) -} diff --git a/crates/brk_query/src/chain/addr/validate.rs b/crates/brk_query/src/chain/addr/validate.rs deleted file mode 100644 index 948d2db16..000000000 --- a/crates/brk_query/src/chain/addr/validate.rs +++ /dev/null @@ -1,41 +0,0 @@ -use bitcoin::hex::DisplayHex; -use brk_types::{AddressBytes, AddressValidation, OutputType}; - -/// Validate a Bitcoin address and return details -pub fn validate_address(address: &str) -> AddressValidation { - let Ok(script) = AddressBytes::address_to_script(address) else { - return AddressValidation::invalid(); - }; - - let output_type = OutputType::from(&script); - let script_hex = script.as_bytes().to_lower_hex_string(); - - let is_script = matches!(output_type, OutputType::P2SH); - let is_witness = matches!( - output_type, - OutputType::P2WPKH | OutputType::P2WSH | OutputType::P2TR | OutputType::P2A - ); - - let (witness_version, witness_program) = if is_witness { - let version = script.witness_version().map(|v| v.to_num()); - // Witness program is after the version byte and push opcode - let program = if script.len() > 2 { - Some(script.as_bytes()[2..].to_lower_hex_string()) - } else { - None - }; - (version, program) - } else { - (None, None) - }; - - AddressValidation { - isvalid: true, - address: Some(address.to_string()), - script_pub_key: Some(script_hex), - isscript: Some(is_script), - iswitness: Some(is_witness), - witness_version, - witness_program, - } -} diff --git a/crates/brk_query/src/chain/block/by_timestamp.rs b/crates/brk_query/src/chain/block/by_timestamp.rs deleted file mode 100644 index 95db42309..000000000 --- a/crates/brk_query/src/chain/block/by_timestamp.rs +++ /dev/null @@ -1,80 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::{BlockTimestamp, Date, DateIndex, Height, Timestamp}; -use jiff::Timestamp as JiffTimestamp; -use vecdb::{GenericStoredVec, TypedVecIterator}; - -use crate::Query; - -/// Get the block closest to a given timestamp using dateindex for fast lookup -pub fn get_block_by_timestamp(timestamp: Timestamp, query: &Query) -> Result { - let indexer = query.indexer(); - let computer = query.computer(); - - let max_height = query.get_height(); - let max_height_usize: usize = max_height.into(); - - if max_height_usize == 0 { - return Err(Error::Str("No blocks indexed")); - } - - let target = timestamp; - let date = Date::from(target); - let dateindex = DateIndex::try_from(date).unwrap_or_default(); - - // Get first height of the target date - let first_height_of_day = computer - .indexes - .dateindex_to_first_height - .read_once(dateindex) - .unwrap_or(Height::from(0usize)); - - let start: usize = usize::from(first_height_of_day).min(max_height_usize); - - // Use iterator for efficient sequential access - let mut timestamp_iter = indexer.vecs.block.height_to_timestamp.iter()?; - - // Search forward from start to find the last block <= target timestamp - let mut best_height = start; - let mut best_ts = timestamp_iter.get_unwrap(Height::from(start)); - - for h in (start + 1)..=max_height_usize { - let height = Height::from(h); - let block_ts = timestamp_iter.get_unwrap(height); - if block_ts <= target { - best_height = h; - best_ts = block_ts; - } else { - break; - } - } - - // Check one block before start in case we need to go backward - if start > 0 && best_ts > target { - let prev_height = Height::from(start - 1); - let prev_ts = timestamp_iter.get_unwrap(prev_height); - if prev_ts <= target { - best_height = start - 1; - best_ts = prev_ts; - } - } - - let height = Height::from(best_height); - let blockhash = indexer - .vecs - .block - .height_to_blockhash - .iter()? - .get_unwrap(height); - - // Convert timestamp to ISO 8601 format - let ts_secs: i64 = (*best_ts).into(); - let iso_timestamp = JiffTimestamp::from_second(ts_secs) - .map(|t| t.to_string()) - .unwrap_or_else(|_| best_ts.to_string()); - - Ok(BlockTimestamp { - height, - hash: blockhash, - timestamp: iso_timestamp, - }) -} diff --git a/crates/brk_query/src/chain/block/height_by_hash.rs b/crates/brk_query/src/chain/block/height_by_hash.rs deleted file mode 100644 index 6470b522f..000000000 --- a/crates/brk_query/src/chain/block/height_by_hash.rs +++ /dev/null @@ -1,19 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::{BlockHash, BlockHashPrefix, Height}; - -use crate::Query; - -/// Resolve a block hash to height -pub fn get_height_by_hash(hash: &str, query: &Query) -> Result { - let indexer = query.indexer(); - - let blockhash: BlockHash = hash.parse().map_err(|_| Error::Str("Invalid block hash"))?; - let prefix = BlockHashPrefix::from(&blockhash); - - indexer - .stores - .blockhashprefix_to_height - .get(&prefix)? - .map(|h| *h) - .ok_or(Error::Str("Block not found")) -} diff --git a/crates/brk_query/src/chain/block/info.rs b/crates/brk_query/src/chain/block/info.rs deleted file mode 100644 index 7ef3c291a..000000000 --- a/crates/brk_query/src/chain/block/info.rs +++ /dev/null @@ -1,62 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::{BlockInfo, Height, TxIndex}; -use vecdb::{AnyVec, GenericStoredVec, VecIndex}; - -use crate::Query; - -/// Get block info by height -pub fn get_block_by_height(height: Height, query: &Query) -> Result { - let indexer = query.indexer(); - - let max_height = max_height(query); - if height > max_height { - return Err(Error::Str("Block height out of range")); - } - - let blockhash = indexer.vecs.block.height_to_blockhash.read_once(height)?; - let difficulty = indexer.vecs.block.height_to_difficulty.read_once(height)?; - let timestamp = indexer.vecs.block.height_to_timestamp.read_once(height)?; - let size = indexer.vecs.block.height_to_total_size.read_once(height)?; - let weight = indexer.vecs.block.height_to_weight.read_once(height)?; - let tx_count = tx_count_at_height(height, max_height, query)?; - - Ok(BlockInfo { - id: blockhash, - height, - tx_count, - size: *size, - weight, - timestamp, - difficulty: *difficulty, - }) -} - -fn max_height(query: &Query) -> Height { - Height::from( - query - .indexer() - .vecs - .block - .height_to_blockhash - .len() - .saturating_sub(1), - ) -} - -fn tx_count_at_height(height: Height, max_height: Height, query: &Query) -> Result { - let indexer = query.indexer(); - let computer = query.computer(); - - let first_txindex = indexer.vecs.tx.height_to_first_txindex.read_once(height)?; - let next_first_txindex = if height < max_height { - indexer - .vecs - .tx - .height_to_first_txindex - .read_once(height.incremented())? - } else { - TxIndex::from(computer.indexes.txindex_to_txindex.len()) - }; - - Ok((next_first_txindex.to_usize() - first_txindex.to_usize()) as u32) -} diff --git a/crates/brk_query/src/chain/block/list.rs b/crates/brk_query/src/chain/block/list.rs deleted file mode 100644 index c92fbd8ac..000000000 --- a/crates/brk_query/src/chain/block/list.rs +++ /dev/null @@ -1,27 +0,0 @@ -use brk_error::Result; -use brk_types::{BlockInfo, Height}; - -use crate::Query; - -use super::info::get_block_by_height; - -const DEFAULT_BLOCK_COUNT: u32 = 10; - -/// Get a list of blocks, optionally starting from a specific height -pub fn get_blocks(start_height: Option, query: &Query) -> Result> { - let max_height = query.get_height(); - - let start = start_height.unwrap_or(max_height); - let start = start.min(max_height); - - let start_u32: u32 = start.into(); - let count = DEFAULT_BLOCK_COUNT.min(start_u32 + 1); - - let mut blocks = Vec::with_capacity(count as usize); - for i in 0..count { - let height = Height::from(start_u32 - i); - blocks.push(get_block_by_height(height, query)?); - } - - Ok(blocks) -} diff --git a/crates/brk_query/src/chain/block/mod.rs b/crates/brk_query/src/chain/block/mod.rs deleted file mode 100644 index ef6def8d3..000000000 --- a/crates/brk_query/src/chain/block/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -mod by_timestamp; -mod height_by_hash; -mod info; -mod list; -mod raw; -mod status; -mod txid_at_index; -mod txids; -mod txs; - -pub use by_timestamp::*; -pub use height_by_hash::*; -pub use info::*; -pub use list::*; -pub use raw::*; -pub use status::*; -pub use txid_at_index::*; -pub use txids::*; -pub use txs::*; diff --git a/crates/brk_query/src/chain/block/raw.rs b/crates/brk_query/src/chain/block/raw.rs deleted file mode 100644 index 62c6be7c6..000000000 --- a/crates/brk_query/src/chain/block/raw.rs +++ /dev/null @@ -1,29 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::Height; -use vecdb::{AnyVec, GenericStoredVec}; - -use crate::Query; - -/// Get raw block bytes by height -pub fn get_block_raw(height: Height, query: &Query) -> Result> { - let indexer = query.indexer(); - let computer = query.computer(); - let reader = query.reader(); - - let max_height = Height::from( - indexer - .vecs - .block - .height_to_blockhash - .len() - .saturating_sub(1), - ); - if height > max_height { - return Err(Error::Str("Block height out of range")); - } - - let position = computer.blks.height_to_position.read_once(height)?; - let size = indexer.vecs.block.height_to_total_size.read_once(height)?; - - reader.read_raw_bytes(position, *size as usize) -} diff --git a/crates/brk_query/src/chain/block/status.rs b/crates/brk_query/src/chain/block/status.rs deleted file mode 100644 index a47dbb062..000000000 --- a/crates/brk_query/src/chain/block/status.rs +++ /dev/null @@ -1,37 +0,0 @@ -use brk_error::Result; -use brk_types::{BlockStatus, Height}; -use vecdb::{AnyVec, GenericStoredVec}; - -use crate::Query; - -/// Get block status by height -pub fn get_block_status_by_height(height: Height, query: &Query) -> Result { - let indexer = query.indexer(); - - let max_height = Height::from( - indexer - .vecs - .block - .height_to_blockhash - .len() - .saturating_sub(1), - ); - - if height > max_height { - return Ok(BlockStatus::not_in_best_chain()); - } - - let next_best = if height < max_height { - Some( - indexer - .vecs - .block - .height_to_blockhash - .read_once(height.incremented())?, - ) - } else { - None - }; - - Ok(BlockStatus::in_best_chain(height, next_best)) -} diff --git a/crates/brk_query/src/chain/block/txid_at_index.rs b/crates/brk_query/src/chain/block/txid_at_index.rs deleted file mode 100644 index 355785df8..000000000 --- a/crates/brk_query/src/chain/block/txid_at_index.rs +++ /dev/null @@ -1,36 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::{Height, TxIndex, Txid}; -use vecdb::{AnyVec, GenericStoredVec, TypedVecIterator}; - -use crate::Query; - -/// Get a single txid at a specific index within a block -pub fn get_block_txid_at_index(height: Height, index: usize, query: &Query) -> Result { - let indexer = query.indexer(); - - let max_height = query.get_height(); - if height > max_height { - return Err(Error::Str("Block height out of range")); - } - - let first_txindex = indexer.vecs.tx.height_to_first_txindex.read_once(height)?; - let next_first_txindex = indexer - .vecs - .tx - .height_to_first_txindex - .read_once(height.incremented()) - .unwrap_or_else(|_| TxIndex::from(indexer.vecs.tx.txindex_to_txid.len())); - - let first: usize = first_txindex.into(); - let next: usize = next_first_txindex.into(); - let tx_count = next - first; - - if index >= tx_count { - return Err(Error::Str("Transaction index out of range")); - } - - let txindex = TxIndex::from(first + index); - let txid = indexer.vecs.tx.txindex_to_txid.iter()?.get_unwrap(txindex); - - Ok(txid) -} diff --git a/crates/brk_query/src/chain/block/txids.rs b/crates/brk_query/src/chain/block/txids.rs deleted file mode 100644 index a556ab837..000000000 --- a/crates/brk_query/src/chain/block/txids.rs +++ /dev/null @@ -1,38 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::{Height, TxIndex, Txid}; -use vecdb::{AnyVec, GenericStoredVec}; - -use crate::Query; - -/// Get all txids in a block by height -pub fn get_block_txids(height: Height, query: &Query) -> Result> { - let indexer = query.indexer(); - - let max_height = query.get_height(); - if height > max_height { - return Err(Error::Str("Block height out of range")); - } - - let first_txindex = indexer.vecs.tx.height_to_first_txindex.read_once(height)?; - let next_first_txindex = indexer - .vecs - .tx - .height_to_first_txindex - .read_once(height.incremented()) - .unwrap_or_else(|_| TxIndex::from(indexer.vecs.tx.txindex_to_txid.len())); - - let first: usize = first_txindex.into(); - let next: usize = next_first_txindex.into(); - let count = next - first; - - let txids: Vec = indexer - .vecs - .tx - .txindex_to_txid - .iter()? - .skip(first) - .take(count) - .collect(); - - Ok(txids) -} diff --git a/crates/brk_query/src/chain/block/txs.rs b/crates/brk_query/src/chain/block/txs.rs deleted file mode 100644 index 790f5c77b..000000000 --- a/crates/brk_query/src/chain/block/txs.rs +++ /dev/null @@ -1,45 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::{Height, Transaction, TxIndex}; -use vecdb::{AnyVec, GenericStoredVec}; - -use crate::{Query, chain::tx::get_transaction_by_index}; - -pub const BLOCK_TXS_PAGE_SIZE: usize = 25; - -/// Get paginated transactions in a block by height -pub fn get_block_txs(height: Height, start_index: usize, query: &Query) -> Result> { - let indexer = query.indexer(); - - let max_height = query.get_height(); - if height > max_height { - return Err(Error::Str("Block height out of range")); - } - - let first_txindex = indexer.vecs.tx.height_to_first_txindex.read_once(height)?; - let next_first_txindex = indexer - .vecs - .tx - .height_to_first_txindex - .read_once(height.incremented()) - .unwrap_or_else(|_| TxIndex::from(indexer.vecs.tx.txindex_to_txid.len())); - - let first: usize = first_txindex.into(); - let next: usize = next_first_txindex.into(); - let tx_count = next - first; - - if start_index >= tx_count { - return Ok(Vec::new()); - } - - let end_index = (start_index + BLOCK_TXS_PAGE_SIZE).min(tx_count); - let count = end_index - start_index; - - let mut txs = Vec::with_capacity(count); - for i in start_index..end_index { - let txindex = TxIndex::from(first + i); - let tx = get_transaction_by_index(txindex, query)?; - txs.push(tx); - } - - Ok(txs) -} diff --git a/crates/brk_query/src/chain/mempool/blocks.rs b/crates/brk_query/src/chain/mempool/blocks.rs deleted file mode 100644 index 6f0dc4186..000000000 --- a/crates/brk_query/src/chain/mempool/blocks.rs +++ /dev/null @@ -1,20 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::MempoolBlock; - -use crate::Query; - -/// Get projected mempool blocks for fee estimation -pub fn get_mempool_blocks(query: &Query) -> Result> { - let mempool = query.mempool().ok_or(Error::Str("Mempool not available"))?; - - let block_stats = mempool.get_block_stats(); - - let blocks = block_stats - .into_iter() - .map(|stats| { - MempoolBlock::new(stats.tx_count, stats.total_vsize, stats.total_fee, stats.fee_range) - }) - .collect(); - - Ok(blocks) -} diff --git a/crates/brk_query/src/chain/mempool/fees.rs b/crates/brk_query/src/chain/mempool/fees.rs deleted file mode 100644 index 9b84b99ef..000000000 --- a/crates/brk_query/src/chain/mempool/fees.rs +++ /dev/null @@ -1,11 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::RecommendedFees; - -use crate::Query; - -pub fn get_recommended_fees(query: &Query) -> Result { - query - .mempool() - .map(|mempool| mempool.get_fees()) - .ok_or(Error::MempoolNotAvailable) -} diff --git a/crates/brk_query/src/chain/mempool/info.rs b/crates/brk_query/src/chain/mempool/info.rs deleted file mode 100644 index dc6da171e..000000000 --- a/crates/brk_query/src/chain/mempool/info.rs +++ /dev/null @@ -1,10 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::MempoolInfo; - -use crate::Query; - -/// Get mempool statistics -pub fn get_mempool_info(query: &Query) -> Result { - let mempool = query.mempool().ok_or(Error::Str("Mempool not available"))?; - Ok(mempool.get_info()) -} diff --git a/crates/brk_query/src/chain/mempool/mod.rs b/crates/brk_query/src/chain/mempool/mod.rs deleted file mode 100644 index 03ee1d197..000000000 --- a/crates/brk_query/src/chain/mempool/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod blocks; -mod fees; -mod info; -mod txids; - -pub use blocks::*; -pub use fees::*; -pub use info::*; -pub use txids::*; diff --git a/crates/brk_query/src/chain/mempool/txids.rs b/crates/brk_query/src/chain/mempool/txids.rs deleted file mode 100644 index ba62ca3cf..000000000 --- a/crates/brk_query/src/chain/mempool/txids.rs +++ /dev/null @@ -1,11 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::Txid; - -use crate::Query; - -/// Get all mempool transaction IDs -pub fn get_mempool_txids(query: &Query) -> Result> { - let mempool = query.mempool().ok_or(Error::Str("Mempool not available"))?; - let txs = mempool.get_txs(); - Ok(txs.keys().cloned().collect()) -} diff --git a/crates/brk_query/src/chain/mining/block_fee_rates.rs b/crates/brk_query/src/chain/mining/block_fee_rates.rs deleted file mode 100644 index 5a500e3b7..000000000 --- a/crates/brk_query/src/chain/mining/block_fee_rates.rs +++ /dev/null @@ -1,44 +0,0 @@ -use brk_error::Result; -use brk_types::{BlockFeeRatesEntry, FeeRatePercentiles, TimePeriod}; -use vecdb::{IterableVec, VecIndex}; - -use super::dateindex_iter::DateIndexIter; -use crate::Query; - -pub fn get_block_fee_rates( - time_period: TimePeriod, - query: &Query, -) -> Result> { - let computer = query.computer(); - let current_height = query.get_height(); - let start = current_height - .to_usize() - .saturating_sub(time_period.block_count()); - - let iter = DateIndexIter::new(computer, start, current_height.to_usize()); - - let vecs = &computer.chain.indexes_to_fee_rate.dateindex; - 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.into(), - timestamp: *ts as u32, - 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(), - ), - }) - })) -} diff --git a/crates/brk_query/src/chain/mining/block_fees.rs b/crates/brk_query/src/chain/mining/block_fees.rs deleted file mode 100644 index 45077ffb0..000000000 --- a/crates/brk_query/src/chain/mining/block_fees.rs +++ /dev/null @@ -1,32 +0,0 @@ -use brk_error::Result; -use brk_types::{BlockFeesEntry, TimePeriod}; -use vecdb::{IterableVec, VecIndex}; - -use super::dateindex_iter::DateIndexIter; -use crate::Query; - -pub fn get_block_fees(time_period: TimePeriod, query: &Query) -> Result> { - let computer = query.computer(); - let current_height = query.get_height(); - let start = current_height - .to_usize() - .saturating_sub(time_period.block_count()); - - let iter = DateIndexIter::new(computer, start, current_height.to_usize()); - - let mut fees = computer - .chain - .indexes_to_fee - .sats - .dateindex - .unwrap_average() - .iter(); - - Ok(iter.collect(|di, ts, h| { - fees.get(di).map(|fee| BlockFeesEntry { - avg_height: h.into(), - timestamp: *ts as u32, - avg_fees: u64::from(*fee), - }) - })) -} diff --git a/crates/brk_query/src/chain/mining/block_rewards.rs b/crates/brk_query/src/chain/mining/block_rewards.rs deleted file mode 100644 index 6ae1024fb..000000000 --- a/crates/brk_query/src/chain/mining/block_rewards.rs +++ /dev/null @@ -1,33 +0,0 @@ -use brk_error::Result; -use brk_types::{BlockRewardsEntry, TimePeriod}; -use vecdb::{IterableVec, VecIndex}; - -use super::dateindex_iter::DateIndexIter; -use crate::Query; - -pub fn get_block_rewards(time_period: TimePeriod, query: &Query) -> Result> { - let computer = query.computer(); - let current_height = query.get_height(); - let start = current_height - .to_usize() - .saturating_sub(time_period.block_count()); - - let iter = DateIndexIter::new(computer, start, current_height.to_usize()); - - // coinbase = subsidy + fees - let mut rewards = computer - .chain - .indexes_to_coinbase - .sats - .dateindex - .unwrap_average() - .iter(); - - Ok(iter.collect(|di, ts, h| { - rewards.get(di).map(|reward| BlockRewardsEntry { - avg_height: h.into(), - timestamp: *ts as u32, - avg_rewards: u64::from(*reward), - }) - })) -} diff --git a/crates/brk_query/src/chain/mining/block_sizes_weights.rs b/crates/brk_query/src/chain/mining/block_sizes_weights.rs deleted file mode 100644 index 518c9e76b..000000000 --- a/crates/brk_query/src/chain/mining/block_sizes_weights.rs +++ /dev/null @@ -1,62 +0,0 @@ -use brk_error::Result; -use brk_types::{BlockSizeEntry, BlockSizesWeights, BlockWeightEntry, TimePeriod}; -use vecdb::{IterableVec, VecIndex}; - -use super::dateindex_iter::DateIndexIter; -use crate::Query; - -pub fn get_block_sizes_weights( - time_period: TimePeriod, - query: &Query, -) -> Result { - let computer = query.computer(); - let current_height = query.get_height(); - let start = current_height - .to_usize() - .saturating_sub(time_period.block_count()); - - let iter = DateIndexIter::new(computer, start, current_height.to_usize()); - - let mut sizes_vec = computer - .chain - .indexes_to_block_size - .dateindex - .unwrap_average() - .iter(); - let mut weights_vec = computer - .chain - .indexes_to_block_weight - .dateindex - .unwrap_average() - .iter(); - - let entries: Vec<_> = iter.collect(|di, ts, h| { - let size = sizes_vec.get(di).map(|s| u64::from(*s)); - let weight = weights_vec.get(di).map(|w| u64::from(*w)); - Some((h.into(), *ts as u32, size, weight)) - }); - - let sizes = entries - .iter() - .filter_map(|(h, ts, size, _)| { - size.map(|s| BlockSizeEntry { - avg_height: *h, - timestamp: *ts, - avg_size: s, - }) - }) - .collect(); - - let weights = entries - .iter() - .filter_map(|(h, ts, _, weight)| { - weight.map(|w| BlockWeightEntry { - avg_height: *h, - timestamp: *ts, - avg_weight: w, - }) - }) - .collect(); - - Ok(BlockSizesWeights { sizes, weights }) -} diff --git a/crates/brk_query/src/chain/mining/difficulty.rs b/crates/brk_query/src/chain/mining/difficulty.rs deleted file mode 100644 index 12795bb43..000000000 --- a/crates/brk_query/src/chain/mining/difficulty.rs +++ /dev/null @@ -1,120 +0,0 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use brk_error::Result; -use brk_types::{DifficultyAdjustment, DifficultyEpoch, Height}; -use vecdb::GenericStoredVec; - -use crate::Query; - -/// Blocks per difficulty epoch (2 weeks target) -const BLOCKS_PER_EPOCH: u32 = 2016; - -/// Target block time in seconds (10 minutes) -const TARGET_BLOCK_TIME: u64 = 600; - -/// Get difficulty adjustment information -pub fn get_difficulty_adjustment(query: &Query) -> Result { - let indexer = query.indexer(); - let computer = query.computer(); - let current_height = query.get_height(); - let current_height_u32: u32 = current_height.into(); - - // Get current epoch - let current_epoch = computer - .indexes - .height_to_difficultyepoch - .read_once(current_height)?; - let current_epoch_usize: usize = current_epoch.into(); - - // Get epoch start height - let epoch_start_height = computer - .indexes - .difficultyepoch_to_first_height - .read_once(current_epoch)?; - let epoch_start_u32: u32 = epoch_start_height.into(); - - // Calculate epoch progress - let next_retarget_height = epoch_start_u32 + BLOCKS_PER_EPOCH; - let blocks_into_epoch = current_height_u32 - epoch_start_u32; - let remaining_blocks = next_retarget_height - current_height_u32; - let progress_percent = (blocks_into_epoch as f64 / BLOCKS_PER_EPOCH as f64) * 100.0; - - // Get timestamps using difficultyepoch_to_timestamp for epoch start - let epoch_start_timestamp = computer - .chain - .difficultyepoch_to_timestamp - .read_once(current_epoch)?; - let current_timestamp = indexer - .vecs - .block - .height_to_timestamp - .read_once(current_height)?; - - // Calculate average block time in current epoch - let elapsed_time = (*current_timestamp - *epoch_start_timestamp) as u64; - let time_avg = if blocks_into_epoch > 0 { - elapsed_time / blocks_into_epoch as u64 - } else { - TARGET_BLOCK_TIME - }; - - // Estimate remaining time and retarget date - let remaining_time = remaining_blocks as u64 * time_avg; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(*current_timestamp as u64); - let estimated_retarget_date = now + remaining_time; - - // Calculate expected vs actual time for difficulty change estimate - let expected_time = blocks_into_epoch as u64 * TARGET_BLOCK_TIME; - let difficulty_change = if elapsed_time > 0 && blocks_into_epoch > 0 { - ((expected_time as f64 / elapsed_time as f64) - 1.0) * 100.0 - } else { - 0.0 - }; - - // Time offset from expected schedule - let time_offset = expected_time as i64 - elapsed_time as i64; - - // Calculate previous retarget using stored difficulty values - let previous_retarget = if current_epoch_usize > 0 { - let prev_epoch = DifficultyEpoch::from(current_epoch_usize - 1); - let prev_epoch_start = computer - .indexes - .difficultyepoch_to_first_height - .read_once(prev_epoch)?; - - let prev_difficulty = indexer - .vecs - .block - .height_to_difficulty - .read_once(prev_epoch_start)?; - let curr_difficulty = indexer - .vecs - .block - .height_to_difficulty - .read_once(epoch_start_height)?; - - if *prev_difficulty > 0.0 { - ((*curr_difficulty / *prev_difficulty) - 1.0) * 100.0 - } else { - 0.0 - } - } else { - 0.0 - }; - - Ok(DifficultyAdjustment { - progress_percent, - difficulty_change, - estimated_retarget_date, - remaining_blocks, - remaining_time, - previous_retarget, - next_retarget_height: Height::from(next_retarget_height), - time_avg, - adjusted_time_avg: time_avg, - time_offset, - }) -} diff --git a/crates/brk_query/src/chain/mining/difficulty_adjustments.rs b/crates/brk_query/src/chain/mining/difficulty_adjustments.rs deleted file mode 100644 index d35a3f13a..000000000 --- a/crates/brk_query/src/chain/mining/difficulty_adjustments.rs +++ /dev/null @@ -1,26 +0,0 @@ -use brk_error::Result; -use brk_types::{DifficultyAdjustmentEntry, TimePeriod}; -use vecdb::VecIndex; - -use crate::Query; - -use super::epochs::iter_difficulty_epochs; - -/// Get historical difficulty adjustments. -pub fn get_difficulty_adjustments( - time_period: Option, - query: &Query, -) -> Result> { - let current_height = query.get_height(); - let end = current_height.to_usize(); - let start = match time_period { - Some(tp) => end.saturating_sub(tp.block_count()), - None => 0, - }; - - let mut entries = iter_difficulty_epochs(query.computer(), start, end); - - // Return in reverse chronological order (newest first) - entries.reverse(); - Ok(entries) -} diff --git a/crates/brk_query/src/chain/mining/hashrate.rs b/crates/brk_query/src/chain/mining/hashrate.rs deleted file mode 100644 index cd3787300..000000000 --- a/crates/brk_query/src/chain/mining/hashrate.rs +++ /dev/null @@ -1,99 +0,0 @@ -use brk_error::Result; -use brk_types::{DateIndex, DifficultyEntry, HashrateEntry, HashrateSummary, Height, TimePeriod}; -use vecdb::{GenericStoredVec, IterableVec, VecIndex}; - -use super::epochs::iter_difficulty_epochs; -use crate::Query; - -/// Get hashrate and difficulty data for a time period. -pub fn get_hashrate(time_period: Option, query: &Query) -> Result { - let indexer = query.indexer(); - let computer = query.computer(); - let current_height = query.get_height(); - - // Get current difficulty - let current_difficulty = *indexer - .vecs - .block - .height_to_difficulty - .read_once(current_height)?; - - // Get current hashrate - let current_dateindex = computer - .indexes - .height_to_dateindex - .read_once(current_height)?; - let current_hashrate = *computer - .chain - .indexes_to_hash_rate - .dateindex - .unwrap_last() - .read_once(current_dateindex)? as u128; - - // Calculate start height based on time period - let end = current_height.to_usize(); - let start = match time_period { - Some(tp) => end.saturating_sub(tp.block_count()), - None => 0, - }; - - // Get hashrate entries using iterators for efficiency - let start_dateindex = computer - .indexes - .height_to_dateindex - .read_once(Height::from(start))?; - let end_dateindex = current_dateindex; - - // Sample at regular intervals to avoid too many data points - let total_days = end_dateindex - .to_usize() - .saturating_sub(start_dateindex.to_usize()) - + 1; - let step = (total_days / 200).max(1); // Max ~200 data points - - // Create iterators for the loop - let mut hashrate_iter = computer - .chain - .indexes_to_hash_rate - .dateindex - .unwrap_last() - .iter(); - let mut timestamp_iter = computer - .chain - .timeindexes_to_timestamp - .dateindex_extra - .unwrap_first() - .iter(); - - let mut hashrates = Vec::with_capacity(total_days / step + 1); - let mut di = start_dateindex.to_usize(); - while di <= end_dateindex.to_usize() { - let dateindex = DateIndex::from(di); - if let (Some(hr), Some(timestamp)) = - (hashrate_iter.get(dateindex), timestamp_iter.get(dateindex)) - { - hashrates.push(HashrateEntry { - timestamp, - avg_hashrate: (*hr) as u128, - }); - } - di += step; - } - - // Get difficulty adjustments within the period - let difficulty: Vec = iter_difficulty_epochs(computer, start, end) - .into_iter() - .map(|e| DifficultyEntry { - timestamp: e.timestamp, - difficulty: e.difficulty, - height: e.height, - }) - .collect(); - - Ok(HashrateSummary { - hashrates, - difficulty, - current_hashrate, - current_difficulty, - }) -} diff --git a/crates/brk_query/src/chain/mining/mod.rs b/crates/brk_query/src/chain/mining/mod.rs deleted file mode 100644 index dd1412bf1..000000000 --- a/crates/brk_query/src/chain/mining/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -mod block_fee_rates; -mod block_fees; -mod block_rewards; -mod block_sizes_weights; -mod dateindex_iter; -mod difficulty; -mod difficulty_adjustments; -mod epochs; -mod hashrate; -mod pools; -mod reward_stats; - -pub use block_fee_rates::*; -pub use block_fees::*; -pub use block_rewards::*; -pub use block_sizes_weights::*; -pub use difficulty::*; -pub use difficulty_adjustments::*; -pub use hashrate::*; -pub use pools::*; -pub use reward_stats::*; diff --git a/crates/brk_query/src/chain/mining/pools.rs b/crates/brk_query/src/chain/mining/pools.rs deleted file mode 100644 index c3331dd78..000000000 --- a/crates/brk_query/src/chain/mining/pools.rs +++ /dev/null @@ -1,172 +0,0 @@ -use brk_error::{Error, Result}; -use brk_types::{ - Height, PoolBlockCounts, PoolBlockShares, PoolDetail, PoolDetailInfo, PoolInfo, PoolSlug, - PoolStats, PoolsSummary, TimePeriod, pools, -}; -use vecdb::{AnyVec, GenericStoredVec, IterableVec, VecIndex}; - -use crate::Query; - -/// Get mining pool statistics for a time period using pre-computed cumulative counts. -pub fn get_mining_pools(time_period: TimePeriod, query: &Query) -> Result { - let computer = query.computer(); - let current_height = query.get_height(); - let end = current_height.to_usize(); - - // No blocks indexed yet - if computer.pools.height_to_pool.len() == 0 { - return Ok(PoolsSummary { - pools: vec![], - block_count: 0, - last_estimated_hashrate: 0, - }); - } - - // Calculate start height based on time period - let start = end.saturating_sub(time_period.block_count()); - - let pools = pools(); - let mut pool_data: Vec<(&'static brk_types::Pool, u32)> = Vec::new(); - - // For each pool, get cumulative count at end and start, subtract to get range count - for (pool_id, pool_vecs) in &computer.pools.vecs { - let mut cumulative = pool_vecs - .indexes_to_blocks_mined - .height_extra - .unwrap_cumulative() - .iter(); - - let count_at_end: u32 = *cumulative.get(current_height).unwrap_or_default(); - - let count_at_start: u32 = if start == 0 { - 0 - } else { - *cumulative.get(Height::from(start - 1)).unwrap_or_default() - }; - - let block_count = count_at_end.saturating_sub(count_at_start); - - // Only include pools that mined at least one block in the period - if block_count > 0 { - pool_data.push((pools.get(*pool_id), block_count)); - } - } - - // Sort by block count descending - pool_data.sort_by(|a, b| b.1.cmp(&a.1)); - - let total_blocks: u32 = pool_data.iter().map(|(_, count)| count).sum(); - - // Build stats with ranks - 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(); - - // TODO: Calculate actual hashrate from difficulty - let last_estimated_hashrate = 0u128; - - Ok(PoolsSummary { - pools: pool_stats, - block_count: total_blocks, - last_estimated_hashrate, - }) -} - -/// Get list of all known mining pools (no statistics). -pub fn get_all_pools() -> Vec { - pools().iter().map(PoolInfo::from).collect() -} - -/// Get detailed information about a specific pool by slug. -pub fn get_pool_detail(slug: PoolSlug, query: &Query) -> Result { - let computer = query.computer(); - let current_height = query.get_height(); - let end = current_height.to_usize(); - - let pools_list = pools(); - let pool = pools_list.get(slug); - - // Get pool vecs for this specific pool - let pool_vecs = computer - .pools - .vecs - .get(&slug) - .ok_or_else(|| Error::Str("Pool data not found"))?; - - let mut cumulative = pool_vecs - .indexes_to_blocks_mined - .height_extra - .unwrap_cumulative() - .iter(); - - // Get total blocks (all time) - let total_all: u32 = *cumulative.get(current_height).unwrap_or_default(); - - // Get blocks for 24h (144 blocks) - let start_24h = end.saturating_sub(144); - let count_before_24h: u32 = if start_24h == 0 { - 0 - } else { - *cumulative - .get(Height::from(start_24h - 1)) - .unwrap_or_default() - }; - let total_24h = total_all.saturating_sub(count_before_24h); - - // Get blocks for 1w (1008 blocks) - let start_1w = end.saturating_sub(1008); - let count_before_1w: u32 = if start_1w == 0 { - 0 - } else { - *cumulative - .get(Height::from(start_1w - 1)) - .unwrap_or_default() - }; - let total_1w = total_all.saturating_sub(count_before_1w); - - // Calculate total network blocks for share calculation - let network_blocks_all = (end + 1) as u32; - let network_blocks_24h = (end - start_24h + 1) as u32; - let network_blocks_1w = (end - start_1w + 1) as u32; - - 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 - }; - - 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: 0, // TODO: Calculate from share and network hashrate - reported_hashrate: None, - }) -} diff --git a/crates/brk_query/src/chain/mining/reward_stats.rs b/crates/brk_query/src/chain/mining/reward_stats.rs deleted file mode 100644 index 55a079362..000000000 --- a/crates/brk_query/src/chain/mining/reward_stats.rs +++ /dev/null @@ -1,58 +0,0 @@ -use brk_error::Result; -use brk_types::{Height, RewardStats, Sats}; -use vecdb::{IterableVec, VecIndex}; - -use crate::Query; - -pub fn get_reward_stats(block_count: usize, query: &Query) -> Result { - let computer = query.computer(); - let current_height = query.get_height(); - - let end_block = current_height; - let start_block = Height::from(current_height.to_usize().saturating_sub(block_count - 1)); - - let mut coinbase_iter = computer - .chain - .indexes_to_coinbase - .sats - .height - .as_ref() - .unwrap() - .iter(); - let mut fee_iter = computer.chain.indexes_to_fee.sats.height.unwrap_sum().iter(); - let mut tx_count_iter = computer - .chain - .indexes_to_tx_count - .height - .as_ref() - .unwrap() - .iter(); - - let mut total_reward = Sats::ZERO; - let mut total_fee = Sats::ZERO; - let mut total_tx: u64 = 0; - - for height in start_block.to_usize()..=end_block.to_usize() { - let h = Height::from(height); - - if let Some(coinbase) = coinbase_iter.get(h) { - total_reward += Sats::from(u64::from(*coinbase)); - } - - if let Some(fee) = fee_iter.get(h) { - total_fee += Sats::from(u64::from(*fee)); - } - - if let Some(tx_count) = tx_count_iter.get(h) { - total_tx += u64::from(*tx_count); - } - } - - Ok(RewardStats { - start_block, - end_block, - total_reward, - total_fee, - total_tx, - }) -} diff --git a/crates/brk_query/src/chain/mod.rs b/crates/brk_query/src/chain/mod.rs deleted file mode 100644 index 5a7d8e98d..000000000 --- a/crates/brk_query/src/chain/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -mod addr; -mod block; -mod mempool; -mod mining; -mod tx; - -pub use addr::*; -pub use block::*; -pub use mempool::*; -pub use mining::*; -pub use tx::*; diff --git a/crates/brk_query/src/chain/tx/hex.rs b/crates/brk_query/src/chain/tx/hex.rs deleted file mode 100644 index 328ed5c6d..000000000 --- a/crates/brk_query/src/chain/tx/hex.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::str::FromStr; - -use bitcoin::hex::DisplayHex; -use brk_error::{Error, Result}; -use brk_types::{TxIndex, Txid, TxidPath, TxidPrefix}; -use vecdb::GenericStoredVec; - -use crate::Query; - -pub fn get_transaction_hex(TxidPath { txid }: TxidPath, query: &Query) -> Result { - let Ok(txid) = bitcoin::Txid::from_str(&txid) else { - return Err(Error::InvalidTxid); - }; - - let txid = Txid::from(txid); - - // First check mempool for unconfirmed transactions - if let Some(mempool) = query.mempool() - && let Some(tx_with_hex) = mempool.get_txs().get(&txid) - { - return Ok(tx_with_hex.hex().to_string()); - } - - // Look up confirmed transaction by txid prefix - let prefix = TxidPrefix::from(&txid); - let indexer = query.indexer(); - let Ok(Some(txindex)) = indexer - .stores - .txidprefix_to_txindex - .get(&prefix) - .map(|opt| opt.map(|cow| cow.into_owned())) - else { - return Err(Error::UnknownTxid); - }; - - get_transaction_hex_by_index(txindex, query) -} - -pub fn get_transaction_hex_by_index(txindex: TxIndex, query: &Query) -> Result { - let indexer = query.indexer(); - let reader = query.reader(); - let computer = query.computer(); - - let total_size = indexer.vecs.tx.txindex_to_total_size.read_once(txindex)?; - let position = computer.blks.txindex_to_position.read_once(txindex)?; - - let buffer = reader.read_raw_bytes(position, *total_size as usize)?; - - Ok(buffer.to_lower_hex_string()) -} diff --git a/crates/brk_query/src/chain/tx/mod.rs b/crates/brk_query/src/chain/tx/mod.rs deleted file mode 100644 index 0c9b721db..000000000 --- a/crates/brk_query/src/chain/tx/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod hex; -mod outspend; -mod status; -mod tx; - -pub use hex::*; -pub use outspend::*; -pub use status::*; -pub use tx::*; diff --git a/crates/brk_query/src/chain/tx/outspend.rs b/crates/brk_query/src/chain/tx/outspend.rs deleted file mode 100644 index da753244a..000000000 --- a/crates/brk_query/src/chain/tx/outspend.rs +++ /dev/null @@ -1,174 +0,0 @@ -use std::str::FromStr; - -use brk_error::{Error, Result}; -use brk_types::{TxInIndex, TxOutspend, TxStatus, Txid, TxidPath, TxidPrefix, Vin, Vout}; -use vecdb::{GenericStoredVec, TypedVecIterator}; - -use crate::Query; - -/// Get the spend status of a specific output -pub fn get_tx_outspend( - TxidPath { txid }: TxidPath, - vout: Vout, - query: &Query, -) -> Result { - let Ok(txid) = bitcoin::Txid::from_str(&txid) else { - return Err(Error::InvalidTxid); - }; - - let txid = Txid::from(txid); - - // Mempool outputs are unspent in on-chain terms - if let Some(mempool) = query.mempool() - && mempool.get_txs().contains_key(&txid) - { - return Ok(TxOutspend::UNSPENT); - } - - // Look up confirmed transaction - let prefix = TxidPrefix::from(&txid); - let indexer = query.indexer(); - let Ok(Some(txindex)) = indexer - .stores - .txidprefix_to_txindex - .get(&prefix) - .map(|opt| opt.map(|cow| cow.into_owned())) - else { - return Err(Error::UnknownTxid); - }; - - // Calculate txoutindex - let first_txoutindex = indexer - .vecs - .tx - .txindex_to_first_txoutindex - .read_once(txindex)?; - let txoutindex = first_txoutindex + vout; - - // Look up spend status - let computer = query.computer(); - let txinindex = computer - .stateful - .txoutindex_to_txinindex - .read_once(txoutindex)?; - - if txinindex == TxInIndex::UNSPENT { - return Ok(TxOutspend::UNSPENT); - } - - get_outspend_details(txinindex, query) -} - -/// Get the spend status of all outputs in a transaction -pub fn get_tx_outspends(TxidPath { txid }: TxidPath, query: &Query) -> Result> { - let Ok(txid) = bitcoin::Txid::from_str(&txid) else { - return Err(Error::InvalidTxid); - }; - - let txid = Txid::from(txid); - - // Mempool outputs are unspent in on-chain terms - if let Some(mempool) = query.mempool() - && let Some(tx_with_hex) = mempool.get_txs().get(&txid) - { - let output_count = tx_with_hex.tx().output.len(); - return Ok(vec![TxOutspend::UNSPENT; output_count]); - } - - // Look up confirmed transaction - let prefix = TxidPrefix::from(&txid); - let indexer = query.indexer(); - let Ok(Some(txindex)) = indexer - .stores - .txidprefix_to_txindex - .get(&prefix) - .map(|opt| opt.map(|cow| cow.into_owned())) - else { - return Err(Error::UnknownTxid); - }; - - // Get output range - let first_txoutindex = indexer - .vecs - .tx - .txindex_to_first_txoutindex - .read_once(txindex)?; - let next_first_txoutindex = indexer - .vecs - .tx - .txindex_to_first_txoutindex - .read_once(txindex.incremented())?; - let output_count = usize::from(next_first_txoutindex) - usize::from(first_txoutindex); - - // Get spend status for each output - let computer = query.computer(); - let mut txoutindex_to_txinindex_iter = computer.stateful.txoutindex_to_txinindex.iter()?; - - let mut outspends = Vec::with_capacity(output_count); - for i in 0..output_count { - let txoutindex = first_txoutindex + Vout::from(i); - let txinindex = txoutindex_to_txinindex_iter.get_unwrap(txoutindex); - - if txinindex == TxInIndex::UNSPENT { - outspends.push(TxOutspend::UNSPENT); - } else { - outspends.push(get_outspend_details(txinindex, query)?); - } - } - - Ok(outspends) -} - -/// Get spending transaction details from a txinindex -fn get_outspend_details(txinindex: TxInIndex, query: &Query) -> Result { - let indexer = query.indexer(); - - // Look up spending txindex directly - let spending_txindex = indexer - .vecs - .txin - .txinindex_to_txindex - .read_once(txinindex)?; - - // Calculate vin - let spending_first_txinindex = indexer - .vecs - .tx - .txindex_to_first_txinindex - .read_once(spending_txindex)?; - let vin = Vin::from(usize::from(txinindex) - usize::from(spending_first_txinindex)); - - // Get spending tx details - let spending_txid = indexer - .vecs - .tx - .txindex_to_txid - .read_once(spending_txindex)?; - let spending_height = indexer - .vecs - .tx - .txindex_to_height - .read_once(spending_txindex)?; - let block_hash = indexer - .vecs - .block - .height_to_blockhash - .read_once(spending_height)?; - let block_time = indexer - .vecs - .block - .height_to_timestamp - .read_once(spending_height)?; - - Ok(TxOutspend { - spent: true, - txid: Some(spending_txid), - vin: Some(vin), - status: Some(TxStatus { - confirmed: true, - block_height: Some(spending_height), - block_hash: Some(block_hash), - block_time: Some(block_time), - }), - }) -} diff --git a/crates/brk_query/src/chain/tx/status.rs b/crates/brk_query/src/chain/tx/status.rs deleted file mode 100644 index ce17773cd..000000000 --- a/crates/brk_query/src/chain/tx/status.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::str::FromStr; - -use brk_error::{Error, Result}; -use brk_types::{TxStatus, Txid, TxidPath, TxidPrefix}; -use vecdb::GenericStoredVec; - -use crate::Query; - -pub fn get_transaction_status(TxidPath { txid }: TxidPath, query: &Query) -> Result { - let Ok(txid) = bitcoin::Txid::from_str(&txid) else { - return Err(Error::InvalidTxid); - }; - - let txid = Txid::from(txid); - - // First check mempool for unconfirmed transactions - if let Some(mempool) = query.mempool() - && mempool.get_txs().contains_key(&txid) - { - return Ok(TxStatus::UNCONFIRMED); - } - - // Look up confirmed transaction by txid prefix - let prefix = TxidPrefix::from(&txid); - let indexer = query.indexer(); - let Ok(Some(txindex)) = indexer - .stores - .txidprefix_to_txindex - .get(&prefix) - .map(|opt| opt.map(|cow| cow.into_owned())) - else { - return Err(Error::UnknownTxid); - }; - - // Get block info for status - let height = indexer.vecs.tx.txindex_to_height.read_once(txindex)?; - let block_hash = indexer.vecs.block.height_to_blockhash.read_once(height)?; - let block_time = indexer.vecs.block.height_to_timestamp.read_once(height)?; - - Ok(TxStatus { - confirmed: true, - block_height: Some(height), - block_hash: Some(block_hash), - block_time: Some(block_time), - }) -} diff --git a/crates/brk_query/src/chain/tx/tx.rs b/crates/brk_query/src/chain/tx/tx.rs deleted file mode 100644 index 4e5511393..000000000 --- a/crates/brk_query/src/chain/tx/tx.rs +++ /dev/null @@ -1,166 +0,0 @@ -use std::{io::Cursor, str::FromStr}; - -use bitcoin::consensus::Decodable; -use brk_error::{Error, Result}; -use brk_types::{ - Sats, Transaction, TxIn, TxIndex, TxOut, TxStatus, Txid, TxidPath, TxidPrefix, Vout, Weight, -}; -use vecdb::{GenericStoredVec, TypedVecIterator}; - -use crate::Query; - -pub fn get_transaction(TxidPath { txid }: TxidPath, query: &Query) -> Result { - let Ok(txid) = bitcoin::Txid::from_str(&txid) else { - return Err(Error::InvalidTxid); - }; - - let txid = Txid::from(txid); - - // First check mempool for unconfirmed transactions - if let Some(mempool) = query.mempool() - && let Some(tx_with_hex) = mempool.get_txs().get(&txid) - { - return Ok(tx_with_hex.tx().clone()); - } - - // Look up confirmed transaction by txid prefix - let prefix = TxidPrefix::from(&txid); - let indexer = query.indexer(); - let Ok(Some(txindex)) = indexer - .stores - .txidprefix_to_txindex - .get(&prefix) - .map(|opt| opt.map(|cow| cow.into_owned())) - else { - return Err(Error::UnknownTxid); - }; - - get_transaction_by_index(txindex, query) -} - -pub fn get_transaction_by_index(txindex: TxIndex, query: &Query) -> Result { - let indexer = query.indexer(); - let reader = query.reader(); - let computer = query.computer(); - - // Get tx metadata using read_once for single lookups - let txid = indexer.vecs.tx.txindex_to_txid.read_once(txindex)?; - let height = indexer.vecs.tx.txindex_to_height.read_once(txindex)?; - let version = indexer.vecs.tx.txindex_to_txversion.read_once(txindex)?; - let lock_time = indexer.vecs.tx.txindex_to_rawlocktime.read_once(txindex)?; - let total_size = indexer.vecs.tx.txindex_to_total_size.read_once(txindex)?; - let first_txinindex = indexer - .vecs - .tx - .txindex_to_first_txinindex - .read_once(txindex)?; - let position = computer.blks.txindex_to_position.read_once(txindex)?; - - // Get block info for status - let block_hash = indexer.vecs.block.height_to_blockhash.read_once(height)?; - let block_time = indexer.vecs.block.height_to_timestamp.read_once(height)?; - - // Read and decode the raw transaction from blk file - let buffer = reader.read_raw_bytes(position, *total_size as usize)?; - let mut cursor = Cursor::new(buffer); - let tx = bitcoin::Transaction::consensus_decode(&mut cursor) - .map_err(|_| Error::Str("Failed to decode transaction"))?; - - // For iterating through inputs, we need iterators (multiple lookups) - let mut txindex_to_txid_iter = indexer.vecs.tx.txindex_to_txid.iter()?; - let mut txindex_to_first_txoutindex_iter = - indexer.vecs.tx.txindex_to_first_txoutindex.iter()?; - let mut txinindex_to_outpoint_iter = indexer.vecs.txin.txinindex_to_outpoint.iter()?; - let mut txoutindex_to_value_iter = indexer.vecs.txout.txoutindex_to_value.iter()?; - - // Build inputs with prevout information - let input: Vec = tx - .input - .iter() - .enumerate() - .map(|(i, txin)| { - let txinindex = first_txinindex + i; - let outpoint = txinindex_to_outpoint_iter.get_unwrap(txinindex); - - let is_coinbase = outpoint.is_coinbase(); - - // Get prevout info if not coinbase - let (prev_txid, prev_vout, prevout) = if is_coinbase { - (Txid::COINBASE, Vout::MAX, None) - } else { - let prev_txindex = outpoint.txindex(); - let prev_vout = outpoint.vout(); - let prev_txid = txindex_to_txid_iter.get_unwrap(prev_txindex); - - // Calculate the txoutindex for the prevout - let prev_first_txoutindex = - txindex_to_first_txoutindex_iter.get_unwrap(prev_txindex); - let prev_txoutindex = prev_first_txoutindex + prev_vout; - - // Get the value of the prevout - let prev_value = txoutindex_to_value_iter.get_unwrap(prev_txoutindex); - - // We don't have the script_pubkey stored directly, so we need to reconstruct - // For now, we'll get it from the decoded transaction's witness/scriptsig - // which can reveal the prevout script type, but the actual script needs - // to be fetched from the spending tx or reconstructed from address bytes - let prevout = Some(TxOut::from(( - bitcoin::ScriptBuf::new(), // Placeholder - would need to reconstruct - prev_value, - ))); - - (prev_txid, prev_vout, prevout) - }; - - TxIn { - txid: prev_txid, - vout: prev_vout, - prevout, - script_sig: txin.script_sig.clone(), - script_sig_asm: (), - is_coinbase, - sequence: txin.sequence.0, - inner_redeem_script_asm: (), - } - }) - .collect(); - - // Calculate weight before consuming tx.output - let weight = Weight::from(tx.weight()); - - // Calculate sigop cost - // Note: Using |_| None means P2SH and SegWit sigops won't be counted accurately - // since we don't provide the prevout scripts. This matches mempool tx behavior. - // For accurate counting, we'd need to reconstruct prevout scripts from indexed data. - let total_sigop_cost = tx.total_sigop_cost(|_| None); - - // Build outputs - let output: Vec = tx.output.into_iter().map(TxOut::from).collect(); - - // Build status - let status = TxStatus { - confirmed: true, - block_height: Some(height), - block_hash: Some(block_hash), - block_time: Some(block_time), - }; - - let mut transaction = Transaction { - index: Some(txindex), - txid, - version, - lock_time, - total_size: *total_size as usize, - weight, - total_sigop_cost, - fee: Sats::ZERO, // Will be computed below - input, - output, - status, - }; - - // Compute fee from inputs - outputs - transaction.compute_fee(); - - Ok(transaction) -} diff --git a/crates/brk_query/src/deser.rs b/crates/brk_query/src/deser.rs deleted file mode 100644 index 5c6d807a2..000000000 --- a/crates/brk_query/src/deser.rs +++ /dev/null @@ -1,52 +0,0 @@ -use serde::{Deserialize, Deserializer}; -use serde_json::Value; - -pub fn de_unquote_i64<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let value: Option = Option::deserialize(deserializer)?; - - if value.is_none() { - return Ok(None); - } - - let value = value.unwrap(); - - if let Some(mut s) = value.as_str().map(|s| s.to_string()) { - if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 { - s = s[1..s.len() - 1].to_string(); - } - s.parse::().map(Some).map_err(serde::de::Error::custom) - } else if let Some(n) = value.as_i64() { - Ok(Some(n)) - } else { - Err(serde::de::Error::custom("expected a string or number")) - } -} - -pub fn de_unquote_usize<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let value: Option = Option::deserialize(deserializer)?; - - if value.is_none() { - return Ok(None); - } - - let value = value.unwrap(); - - if let Some(mut s) = value.as_str().map(|s| s.to_string()) { - if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 { - s = s[1..s.len() - 1].to_string(); - } - s.parse::() - .map(Some) - .map_err(serde::de::Error::custom) - } else if let Some(n) = value.as_u64() { - Ok(Some(n as usize)) - } else { - Err(serde::de::Error::custom("expected a string or number")) - } -} diff --git a/crates/brk_query/src/impl/address.rs b/crates/brk_query/src/impl/address.rs new file mode 100644 index 000000000..d4ae2f8fd --- /dev/null +++ b/crates/brk_query/src/impl/address.rs @@ -0,0 +1,235 @@ +use std::str::FromStr; + +use bitcoin::{Network, PublicKey, ScriptBuf}; +use brk_error::{Error, Result}; +use brk_types::{ + Address, AddressBytes, AddressChainStats, AddressHash, AddressIndexOutPoint, + AddressIndexTxIndex, AddressStats, AnyAddressDataIndexEnum, OutputType, Sats, TxIndex, + TxStatus, Txid, TypeIndex, Unit, Utxo, Vout, +}; +use vecdb::TypedVecIterator; + +use crate::Query; + +/// Maximum number of mempool txids to return +const MAX_MEMPOOL_TXIDS: usize = 50; + +impl Query { + pub fn address(&self, Address { address }: Address) -> Result { + let indexer = self.indexer(); + let computer = self.computer(); + let stores = &indexer.stores; + + let script = if let Ok(address) = bitcoin::Address::from_str(&address) { + if !address.is_valid_for_network(Network::Bitcoin) { + return Err(Error::InvalidNetwork); + } + let address = address.assume_checked(); + address.script_pubkey() + } else if let Ok(pubkey) = PublicKey::from_str(&address) { + ScriptBuf::new_p2pk(&pubkey) + } else { + return Err(Error::InvalidAddress); + }; + + let outputtype = OutputType::from(&script); + let Ok(bytes) = AddressBytes::try_from((&script, outputtype)) else { + return Err(Error::Str("Failed to convert the address to bytes")); + }; + let addresstype = outputtype; + let hash = AddressHash::from(&bytes); + + let Ok(Some(type_index)) = stores + .addresstype_to_addresshash_to_addressindex + .get(addresstype) + .unwrap() + .get(&hash) + .map(|opt| opt.map(|cow| cow.into_owned())) + else { + return Err(Error::UnknownAddress); + }; + + let any_address_index = computer + .stateful + .any_address_indexes + .get_anyaddressindex_once(outputtype, type_index)?; + + let address_data = match any_address_index.to_enum() { + AnyAddressDataIndexEnum::Loaded(index) => computer + .stateful + .addresses_data + .loaded + .iter()? + .get_unwrap(index), + AnyAddressDataIndexEnum::Empty(index) => computer + .stateful + .addresses_data + .empty + .iter()? + .get_unwrap(index) + .into(), + }; + + Ok(AddressStats { + address: address.into(), + chain_stats: AddressChainStats { + type_index, + funded_txo_count: address_data.funded_txo_count, + funded_txo_sum: address_data.received, + spent_txo_count: address_data.spent_txo_count, + spent_txo_sum: address_data.sent, + tx_count: address_data.tx_count, + }, + mempool_stats: self.mempool().map(|mempool| { + mempool + .get_addresses() + .get(&bytes) + .map(|(stats, _)| stats) + .cloned() + .unwrap_or_default() + }), + }) + } + + pub fn address_txids( + &self, + address: Address, + after_txid: Option, + limit: usize, + ) -> Result> { + let indexer = self.indexer(); + let stores = &indexer.stores; + + let (outputtype, type_index) = self.resolve_address(&address)?; + + let store = stores + .addresstype_to_addressindex_and_txindex + .get(outputtype) + .unwrap(); + + let prefix = u32::from(type_index).to_be_bytes(); + + let after_txindex = if let Some(after_txid) = after_txid { + let txindex = stores + .txidprefix_to_txindex + .get(&after_txid.into()) + .map_err(|_| Error::Str("Failed to look up after_txid"))? + .ok_or(Error::Str("after_txid not found"))? + .into_owned(); + Some(txindex) + } else { + None + }; + + let txindices: Vec = store + .prefix(prefix) + .rev() + .filter(|(key, _): &(AddressIndexTxIndex, Unit)| { + if let Some(after) = after_txindex { + key.txindex() < after + } else { + true + } + }) + .take(limit) + .map(|(key, _)| key.txindex()) + .collect(); + + let mut txindex_to_txid_iter = indexer.vecs.tx.txindex_to_txid.iter()?; + let txids: Vec = txindices + .into_iter() + .map(|txindex| txindex_to_txid_iter.get_unwrap(txindex)) + .collect(); + + Ok(txids) + } + + pub fn address_utxos(&self, address: Address) -> Result> { + let indexer = self.indexer(); + let stores = &indexer.stores; + let vecs = &indexer.vecs; + + let (outputtype, type_index) = self.resolve_address(&address)?; + + let store = stores + .addresstype_to_addressindex_and_unspentoutpoint + .get(outputtype) + .unwrap(); + + let prefix = u32::from(type_index).to_be_bytes(); + + let outpoints: Vec<(TxIndex, Vout)> = store + .prefix(prefix) + .map(|(key, _): (AddressIndexOutPoint, Unit)| (key.txindex(), key.vout())) + .collect(); + + let mut txindex_to_txid_iter = vecs.tx.txindex_to_txid.iter()?; + let mut txindex_to_height_iter = vecs.tx.txindex_to_height.iter()?; + let mut txindex_to_first_txoutindex_iter = vecs.tx.txindex_to_first_txoutindex.iter()?; + let mut txoutindex_to_value_iter = vecs.txout.txoutindex_to_value.iter()?; + let mut height_to_blockhash_iter = vecs.block.height_to_blockhash.iter()?; + let mut height_to_timestamp_iter = vecs.block.height_to_timestamp.iter()?; + + let utxos: Vec = outpoints + .into_iter() + .map(|(txindex, vout)| { + let txid: Txid = txindex_to_txid_iter.get_unwrap(txindex); + let height = txindex_to_height_iter.get_unwrap(txindex); + let first_txoutindex = txindex_to_first_txoutindex_iter.get_unwrap(txindex); + let txoutindex = first_txoutindex + vout; + let value: Sats = txoutindex_to_value_iter.get_unwrap(txoutindex); + let block_hash = height_to_blockhash_iter.get_unwrap(height); + let block_time = height_to_timestamp_iter.get_unwrap(height); + + Utxo { + txid, + vout, + status: TxStatus { + confirmed: true, + block_height: Some(height), + block_hash: Some(block_hash), + block_time: Some(block_time), + }, + value, + } + }) + .collect(); + + Ok(utxos) + } + + pub fn address_mempool_txids(&self, address: Address) -> Result> { + let mempool = self.mempool().ok_or(Error::Str("Mempool not available"))?; + + let bytes = AddressBytes::from_str(&address.address)?; + let addresses = mempool.get_addresses(); + + let txids: Vec = addresses + .get(&bytes) + .map(|(_, txids)| txids.iter().take(MAX_MEMPOOL_TXIDS).cloned().collect()) + .unwrap_or_default(); + + Ok(txids) + } + + /// Resolve an address string to its output type and type_index + fn resolve_address(&self, address: &Address) -> Result<(OutputType, TypeIndex)> { + let stores = &self.indexer().stores; + + let bytes = AddressBytes::from_str(&address.address)?; + let outputtype = OutputType::from(&bytes); + let hash = AddressHash::from(&bytes); + + let Ok(Some(type_index)) = stores + .addresstype_to_addresshash_to_addressindex + .get(outputtype) + .unwrap() + .get(&hash) + .map(|opt| opt.map(|cow| cow.into_owned())) + else { + return Err(Error::UnknownAddress); + }; + + Ok((outputtype, type_index)) + } +} diff --git a/crates/brk_query/src/impl/block/info.rs b/crates/brk_query/src/impl/block/info.rs new file mode 100644 index 000000000..c474807d0 --- /dev/null +++ b/crates/brk_query/src/impl/block/info.rs @@ -0,0 +1,103 @@ +use brk_error::{Error, Result}; +use brk_types::{BlockHash, BlockHashPrefix, BlockInfo, Height, TxIndex}; +use vecdb::{AnyVec, GenericStoredVec, VecIndex}; + +use crate::Query; + +const DEFAULT_BLOCK_COUNT: u32 = 10; + +impl Query { + pub fn block(&self, hash: &str) -> Result { + let height = self.height_by_hash(hash)?; + self.block_by_height(height) + } + + pub fn block_by_height(&self, height: Height) -> Result { + let indexer = self.indexer(); + + let max_height = self.max_height(); + if height > max_height { + return Err(Error::Str("Block height out of range")); + } + + let blockhash = indexer.vecs.block.height_to_blockhash.read_once(height)?; + let difficulty = indexer.vecs.block.height_to_difficulty.read_once(height)?; + let timestamp = indexer.vecs.block.height_to_timestamp.read_once(height)?; + let size = indexer.vecs.block.height_to_total_size.read_once(height)?; + let weight = indexer.vecs.block.height_to_weight.read_once(height)?; + let tx_count = self.tx_count_at_height(height, max_height)?; + + Ok(BlockInfo { + id: blockhash, + height, + tx_count, + size: *size, + weight, + timestamp, + difficulty: *difficulty, + }) + } + + pub fn blocks(&self, start_height: Option) -> Result> { + let max_height = self.height(); + + let start = start_height.unwrap_or(max_height); + let start = start.min(max_height); + + let start_u32: u32 = start.into(); + let count = DEFAULT_BLOCK_COUNT.min(start_u32 + 1); + + let mut blocks = Vec::with_capacity(count as usize); + for i in 0..count { + let height = Height::from(start_u32 - i); + blocks.push(self.block_by_height(height)?); + } + + Ok(blocks) + } + + // === Helper methods === + + pub fn height_by_hash(&self, hash: &str) -> Result { + let indexer = self.indexer(); + + let blockhash: BlockHash = hash.parse().map_err(|_| Error::Str("Invalid block hash"))?; + let prefix = BlockHashPrefix::from(&blockhash); + + indexer + .stores + .blockhashprefix_to_height + .get(&prefix)? + .map(|h| *h) + .ok_or(Error::Str("Block not found")) + } + + fn max_height(&self) -> Height { + Height::from( + self.indexer() + .vecs + .block + .height_to_blockhash + .len() + .saturating_sub(1), + ) + } + + fn tx_count_at_height(&self, height: Height, max_height: Height) -> Result { + let indexer = self.indexer(); + let computer = self.computer(); + + let first_txindex = indexer.vecs.tx.height_to_first_txindex.read_once(height)?; + let next_first_txindex = if height < max_height { + indexer + .vecs + .tx + .height_to_first_txindex + .read_once(height.incremented())? + } else { + TxIndex::from(computer.indexes.txindex_to_txindex.len()) + }; + + Ok((next_first_txindex.to_usize() - first_txindex.to_usize()) as u32) + } +} diff --git a/crates/brk_query/src/impl/block/mod.rs b/crates/brk_query/src/impl/block/mod.rs new file mode 100644 index 000000000..999d42dc8 --- /dev/null +++ b/crates/brk_query/src/impl/block/mod.rs @@ -0,0 +1,7 @@ +mod info; +mod raw; +mod status; +mod timestamp; +mod txs; + +pub const BLOCK_TXS_PAGE_SIZE: usize = 25; diff --git a/crates/brk_query/src/impl/block/raw.rs b/crates/brk_query/src/impl/block/raw.rs new file mode 100644 index 000000000..d5aa62114 --- /dev/null +++ b/crates/brk_query/src/impl/block/raw.rs @@ -0,0 +1,35 @@ +use brk_error::{Error, Result}; +use brk_types::Height; +use vecdb::{AnyVec, GenericStoredVec}; + +use crate::Query; + +impl Query { + pub fn block_raw(&self, hash: &str) -> Result> { + let height = self.height_by_hash(hash)?; + self.block_raw_by_height(height) + } + + fn block_raw_by_height(&self, height: Height) -> Result> { + let indexer = self.indexer(); + let computer = self.computer(); + let reader = self.reader(); + + let max_height = Height::from( + indexer + .vecs + .block + .height_to_blockhash + .len() + .saturating_sub(1), + ); + if height > max_height { + return Err(Error::Str("Block height out of range")); + } + + let position = computer.blks.height_to_position.read_once(height)?; + let size = indexer.vecs.block.height_to_total_size.read_once(height)?; + + reader.read_raw_bytes(position, *size as usize) + } +} diff --git a/crates/brk_query/src/impl/block/status.rs b/crates/brk_query/src/impl/block/status.rs new file mode 100644 index 000000000..a7bf631af --- /dev/null +++ b/crates/brk_query/src/impl/block/status.rs @@ -0,0 +1,43 @@ +use brk_error::Result; +use brk_types::{BlockStatus, Height}; +use vecdb::{AnyVec, GenericStoredVec}; + +use crate::Query; + +impl Query { + pub fn block_status(&self, hash: &str) -> Result { + let height = self.height_by_hash(hash)?; + self.block_status_by_height(height) + } + + fn block_status_by_height(&self, height: Height) -> Result { + let indexer = self.indexer(); + + let max_height = Height::from( + indexer + .vecs + .block + .height_to_blockhash + .len() + .saturating_sub(1), + ); + + if height > max_height { + return Ok(BlockStatus::not_in_best_chain()); + } + + let next_best = if height < max_height { + Some( + indexer + .vecs + .block + .height_to_blockhash + .read_once(height.incremented())?, + ) + } else { + None + }; + + Ok(BlockStatus::in_best_chain(height, next_best)) + } +} diff --git a/crates/brk_query/src/impl/block/timestamp.rs b/crates/brk_query/src/impl/block/timestamp.rs new file mode 100644 index 000000000..b281ad6b7 --- /dev/null +++ b/crates/brk_query/src/impl/block/timestamp.rs @@ -0,0 +1,81 @@ +use brk_error::{Error, Result}; +use brk_types::{BlockTimestamp, Date, DateIndex, Height, Timestamp}; +use jiff::Timestamp as JiffTimestamp; +use vecdb::{GenericStoredVec, TypedVecIterator}; + +use crate::Query; + +impl Query { + pub fn block_by_timestamp(&self, timestamp: Timestamp) -> Result { + let indexer = self.indexer(); + let computer = self.computer(); + + let max_height = self.height(); + let max_height_usize: usize = max_height.into(); + + if max_height_usize == 0 { + return Err(Error::Str("No blocks indexed")); + } + + let target = timestamp; + let date = Date::from(target); + let dateindex = DateIndex::try_from(date).unwrap_or_default(); + + // Get first height of the target date + let first_height_of_day = computer + .indexes + .dateindex_to_first_height + .read_once(dateindex) + .unwrap_or(Height::from(0usize)); + + let start: usize = usize::from(first_height_of_day).min(max_height_usize); + + // Use iterator for efficient sequential access + let mut timestamp_iter = indexer.vecs.block.height_to_timestamp.iter()?; + + // Search forward from start to find the last block <= target timestamp + let mut best_height = start; + let mut best_ts = timestamp_iter.get_unwrap(Height::from(start)); + + for h in (start + 1)..=max_height_usize { + let height = Height::from(h); + let block_ts = timestamp_iter.get_unwrap(height); + if block_ts <= target { + best_height = h; + best_ts = block_ts; + } else { + break; + } + } + + // Check one block before start in case we need to go backward + if start > 0 && best_ts > target { + let prev_height = Height::from(start - 1); + let prev_ts = timestamp_iter.get_unwrap(prev_height); + if prev_ts <= target { + best_height = start - 1; + best_ts = prev_ts; + } + } + + let height = Height::from(best_height); + let blockhash = indexer + .vecs + .block + .height_to_blockhash + .iter()? + .get_unwrap(height); + + // Convert timestamp to ISO 8601 format + let ts_secs: i64 = (*best_ts).into(); + let iso_timestamp = JiffTimestamp::from_second(ts_secs) + .map(|t| t.to_string()) + .unwrap_or_else(|_| best_ts.to_string()); + + Ok(BlockTimestamp { + height, + hash: blockhash, + timestamp: iso_timestamp, + }) + } +} diff --git a/crates/brk_query/src/impl/block/txs.rs b/crates/brk_query/src/impl/block/txs.rs new file mode 100644 index 000000000..5591c73d3 --- /dev/null +++ b/crates/brk_query/src/impl/block/txs.rs @@ -0,0 +1,128 @@ +use brk_error::{Error, Result}; +use brk_types::{Height, Transaction, TxIndex, Txid}; +use vecdb::{AnyVec, GenericStoredVec, TypedVecIterator}; + +use super::BLOCK_TXS_PAGE_SIZE; +use crate::Query; + +impl Query { + pub fn block_txids(&self, hash: &str) -> Result> { + let height = self.height_by_hash(hash)?; + self.block_txids_by_height(height) + } + + pub fn block_txs(&self, hash: &str, start_index: usize) -> Result> { + let height = self.height_by_hash(hash)?; + self.block_txs_by_height(height, start_index) + } + + pub fn block_txid_at_index(&self, hash: &str, index: usize) -> Result { + let height = self.height_by_hash(hash)?; + self.block_txid_at_index_by_height(height, index) + } + + // === Helper methods === + + fn block_txids_by_height(&self, height: Height) -> Result> { + let indexer = self.indexer(); + + let max_height = self.height(); + if height > max_height { + return Err(Error::Str("Block height out of range")); + } + + let first_txindex = indexer.vecs.tx.height_to_first_txindex.read_once(height)?; + let next_first_txindex = indexer + .vecs + .tx + .height_to_first_txindex + .read_once(height.incremented()) + .unwrap_or_else(|_| TxIndex::from(indexer.vecs.tx.txindex_to_txid.len())); + + let first: usize = first_txindex.into(); + let next: usize = next_first_txindex.into(); + let count = next - first; + + let txids: Vec = indexer + .vecs + .tx + .txindex_to_txid + .iter()? + .skip(first) + .take(count) + .collect(); + + Ok(txids) + } + + fn block_txs_by_height( + &self, + height: Height, + start_index: usize, + ) -> Result> { + let indexer = self.indexer(); + + let max_height = self.height(); + if height > max_height { + return Err(Error::Str("Block height out of range")); + } + + let first_txindex = indexer.vecs.tx.height_to_first_txindex.read_once(height)?; + let next_first_txindex = indexer + .vecs + .tx + .height_to_first_txindex + .read_once(height.incremented()) + .unwrap_or_else(|_| TxIndex::from(indexer.vecs.tx.txindex_to_txid.len())); + + let first: usize = first_txindex.into(); + let next: usize = next_first_txindex.into(); + let tx_count = next - first; + + if start_index >= tx_count { + return Ok(Vec::new()); + } + + let end_index = (start_index + BLOCK_TXS_PAGE_SIZE).min(tx_count); + let count = end_index - start_index; + + let mut txs = Vec::with_capacity(count); + for i in start_index..end_index { + let txindex = TxIndex::from(first + i); + let tx = self.transaction_by_index(txindex)?; + txs.push(tx); + } + + Ok(txs) + } + + fn block_txid_at_index_by_height(&self, height: Height, index: usize) -> Result { + let indexer = self.indexer(); + + let max_height = self.height(); + if height > max_height { + return Err(Error::Str("Block height out of range")); + } + + let first_txindex = indexer.vecs.tx.height_to_first_txindex.read_once(height)?; + let next_first_txindex = indexer + .vecs + .tx + .height_to_first_txindex + .read_once(height.incremented()) + .unwrap_or_else(|_| TxIndex::from(indexer.vecs.tx.txindex_to_txid.len())); + + let first: usize = first_txindex.into(); + let next: usize = next_first_txindex.into(); + let tx_count = next - first; + + if index >= tx_count { + return Err(Error::Str("Transaction index out of range")); + } + + let txindex = TxIndex::from(first + index); + let txid = indexer.vecs.tx.txindex_to_txid.iter()?.get_unwrap(txindex); + + Ok(txid) + } +} diff --git a/crates/brk_query/src/impl/mempool.rs b/crates/brk_query/src/impl/mempool.rs new file mode 100644 index 000000000..1c2f51cf8 --- /dev/null +++ b/crates/brk_query/src/impl/mempool.rs @@ -0,0 +1,38 @@ +use brk_error::{Error, Result}; +use brk_types::{MempoolBlock, MempoolInfo, RecommendedFees, Txid}; + +use crate::Query; + +impl Query { + pub fn mempool_info(&self) -> Result { + let mempool = self.mempool().ok_or(Error::Str("Mempool not available"))?; + Ok(mempool.get_info()) + } + + pub fn mempool_txids(&self) -> Result> { + let mempool = self.mempool().ok_or(Error::Str("Mempool not available"))?; + let txs = mempool.get_txs(); + Ok(txs.keys().cloned().collect()) + } + + pub fn recommended_fees(&self) -> Result { + self.mempool() + .map(|mempool| mempool.get_fees()) + .ok_or(Error::MempoolNotAvailable) + } + + pub fn mempool_blocks(&self) -> Result> { + let mempool = self.mempool().ok_or(Error::Str("Mempool not available"))?; + + let block_stats = mempool.get_block_stats(); + + let blocks = block_stats + .into_iter() + .map(|stats| { + MempoolBlock::new(stats.tx_count, stats.total_vsize, stats.total_fee, stats.fee_range) + }) + .collect(); + + Ok(blocks) + } +} diff --git a/crates/brk_query/src/impl/mining/block_fee_rates.rs b/crates/brk_query/src/impl/mining/block_fee_rates.rs new file mode 100644 index 000000000..80799fb0b --- /dev/null +++ b/crates/brk_query/src/impl/mining/block_fee_rates.rs @@ -0,0 +1,43 @@ +use brk_error::Result; +use brk_types::{BlockFeeRatesEntry, FeeRatePercentiles, TimePeriod}; +use vecdb::{IterableVec, VecIndex}; + +use super::dateindex_iter::DateIndexIter; +use crate::Query; + +impl Query { + pub fn block_fee_rates(&self, time_period: TimePeriod) -> Result> { + let computer = self.computer(); + let current_height = self.height(); + let start = current_height + .to_usize() + .saturating_sub(time_period.block_count()); + + let iter = DateIndexIter::new(computer, start, current_height.to_usize()); + + let vecs = &computer.chain.indexes_to_fee_rate.dateindex; + 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(), + ), + }) + })) + } +} diff --git a/crates/brk_query/src/impl/mining/block_fees.rs b/crates/brk_query/src/impl/mining/block_fees.rs new file mode 100644 index 000000000..f38cf9788 --- /dev/null +++ b/crates/brk_query/src/impl/mining/block_fees.rs @@ -0,0 +1,34 @@ +use brk_error::Result; +use brk_types::{BlockFeesEntry, TimePeriod}; +use vecdb::{IterableVec, VecIndex}; + +use super::dateindex_iter::DateIndexIter; +use crate::Query; + +impl Query { + pub fn block_fees(&self, time_period: TimePeriod) -> Result> { + let computer = self.computer(); + let current_height = self.height(); + let start = current_height + .to_usize() + .saturating_sub(time_period.block_count()); + + let iter = DateIndexIter::new(computer, start, current_height.to_usize()); + + let mut fees = computer + .chain + .indexes_to_fee + .sats + .dateindex + .unwrap_average() + .iter(); + + Ok(iter.collect(|di, ts, h| { + fees.get(di).map(|fee| BlockFeesEntry { + avg_height: h, + timestamp: ts, + avg_fees: fee, + }) + })) + } +} diff --git a/crates/brk_query/src/impl/mining/block_rewards.rs b/crates/brk_query/src/impl/mining/block_rewards.rs new file mode 100644 index 000000000..226868129 --- /dev/null +++ b/crates/brk_query/src/impl/mining/block_rewards.rs @@ -0,0 +1,35 @@ +use brk_error::Result; +use brk_types::{BlockRewardsEntry, TimePeriod}; +use vecdb::{IterableVec, VecIndex}; + +use super::dateindex_iter::DateIndexIter; +use crate::Query; + +impl Query { + pub fn block_rewards(&self, time_period: TimePeriod) -> Result> { + let computer = self.computer(); + let current_height = self.height(); + let start = current_height + .to_usize() + .saturating_sub(time_period.block_count()); + + let iter = DateIndexIter::new(computer, start, current_height.to_usize()); + + // coinbase = subsidy + fees + let mut rewards = computer + .chain + .indexes_to_coinbase + .sats + .dateindex + .unwrap_average() + .iter(); + + Ok(iter.collect(|di, ts, h| { + rewards.get(di).map(|reward| BlockRewardsEntry { + avg_height: h.into(), + timestamp: *ts, + avg_rewards: *reward, + }) + })) + } +} diff --git a/crates/brk_query/src/impl/mining/block_sizes.rs b/crates/brk_query/src/impl/mining/block_sizes.rs new file mode 100644 index 000000000..fed7e2b8a --- /dev/null +++ b/crates/brk_query/src/impl/mining/block_sizes.rs @@ -0,0 +1,61 @@ +use brk_error::Result; +use brk_types::{BlockSizeEntry, BlockSizesWeights, BlockWeightEntry, TimePeriod}; +use vecdb::{IterableVec, VecIndex}; + +use super::dateindex_iter::DateIndexIter; +use crate::Query; + +impl Query { + pub fn block_sizes_weights(&self, time_period: TimePeriod) -> Result { + let computer = self.computer(); + let current_height = self.height(); + let start = current_height + .to_usize() + .saturating_sub(time_period.block_count()); + + let iter = DateIndexIter::new(computer, start, current_height.to_usize()); + + let mut sizes_vec = computer + .chain + .indexes_to_block_size + .dateindex + .unwrap_average() + .iter(); + let mut weights_vec = computer + .chain + .indexes_to_block_weight + .dateindex + .unwrap_average() + .iter(); + + let entries: Vec<_> = iter.collect(|di, ts, h| { + let size = sizes_vec.get(di).map(|s| *s); + let weight = weights_vec.get(di).map(|w| *w); + Some((h.into(), (*ts), size, weight)) + }); + + let sizes = entries + .iter() + .filter_map(|(h, ts, size, _)| { + size.map(|s| BlockSizeEntry { + avg_height: *h, + timestamp: *ts, + avg_size: s, + }) + }) + .collect(); + + let weights = entries + .iter() + .filter_map(|(h, ts, _, weight)| { + weight.map(|w| BlockWeightEntry { + avg_height: *h, + timestamp: *ts, + avg_weight: w, + }) + }) + .collect(); + + Ok(BlockSizesWeights { sizes, weights }) + } +} diff --git a/crates/brk_query/src/chain/mining/dateindex_iter.rs b/crates/brk_query/src/impl/mining/dateindex_iter.rs similarity index 87% rename from crates/brk_query/src/chain/mining/dateindex_iter.rs rename to crates/brk_query/src/impl/mining/dateindex_iter.rs index ba7155aed..18d49b28a 100644 --- a/crates/brk_query/src/chain/mining/dateindex_iter.rs +++ b/crates/brk_query/src/impl/mining/dateindex_iter.rs @@ -39,7 +39,11 @@ impl<'a> DateIndexIter<'a> { where F: FnMut(DateIndex, Timestamp, Height) -> Option, { - let total = self.end_di.to_usize().saturating_sub(self.start_di.to_usize()) + 1; + let total = self + .end_di + .to_usize() + .saturating_sub(self.start_di.to_usize()) + + 1; let mut timestamps = self .computer .chain @@ -54,10 +58,10 @@ impl<'a> DateIndexIter<'a> { while i <= self.end_di.to_usize() { let di = DateIndex::from(i); - if let (Some(ts), Some(h)) = (timestamps.get(di), heights.get(di)) { - if let Some(entry) = transform(di, ts, h) { - entries.push(entry); - } + if let (Some(ts), Some(h)) = (timestamps.get(di), heights.get(di)) + && let Some(entry) = transform(di, ts, h) + { + entries.push(entry); } i += self.step; } diff --git a/crates/brk_query/src/impl/mining/difficulty.rs b/crates/brk_query/src/impl/mining/difficulty.rs new file mode 100644 index 000000000..af5c959ae --- /dev/null +++ b/crates/brk_query/src/impl/mining/difficulty.rs @@ -0,0 +1,121 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use brk_error::Result; +use brk_types::{DifficultyAdjustment, DifficultyEpoch, Height}; +use vecdb::GenericStoredVec; + +use crate::Query; + +/// Blocks per difficulty epoch (2 weeks target) +const BLOCKS_PER_EPOCH: u32 = 2016; + +/// Target block time in seconds (10 minutes) +const TARGET_BLOCK_TIME: u64 = 600; + +impl Query { + pub fn difficulty_adjustment(&self) -> Result { + let indexer = self.indexer(); + let computer = self.computer(); + let current_height = self.height(); + let current_height_u32: u32 = current_height.into(); + + // Get current epoch + let current_epoch = computer + .indexes + .height_to_difficultyepoch + .read_once(current_height)?; + let current_epoch_usize: usize = current_epoch.into(); + + // Get epoch start height + let epoch_start_height = computer + .indexes + .difficultyepoch_to_first_height + .read_once(current_epoch)?; + let epoch_start_u32: u32 = epoch_start_height.into(); + + // Calculate epoch progress + let next_retarget_height = epoch_start_u32 + BLOCKS_PER_EPOCH; + let blocks_into_epoch = current_height_u32 - epoch_start_u32; + let remaining_blocks = next_retarget_height - current_height_u32; + let progress_percent = (blocks_into_epoch as f64 / BLOCKS_PER_EPOCH as f64) * 100.0; + + // Get timestamps using difficultyepoch_to_timestamp for epoch start + let epoch_start_timestamp = computer + .chain + .difficultyepoch_to_timestamp + .read_once(current_epoch)?; + let current_timestamp = indexer + .vecs + .block + .height_to_timestamp + .read_once(current_height)?; + + // Calculate average block time in current epoch + let elapsed_time = (*current_timestamp - *epoch_start_timestamp) as u64; + let time_avg = if blocks_into_epoch > 0 { + elapsed_time / blocks_into_epoch as u64 + } else { + TARGET_BLOCK_TIME + }; + + // Estimate remaining time and retarget date + let remaining_time = remaining_blocks as u64 * time_avg; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(*current_timestamp as u64); + let estimated_retarget_date = now + remaining_time; + + // Calculate expected vs actual time for difficulty change estimate + let expected_time = blocks_into_epoch as u64 * TARGET_BLOCK_TIME; + let difficulty_change = if elapsed_time > 0 && blocks_into_epoch > 0 { + ((expected_time as f64 / elapsed_time as f64) - 1.0) * 100.0 + } else { + 0.0 + }; + + // Time offset from expected schedule + let time_offset = expected_time as i64 - elapsed_time as i64; + + // Calculate previous retarget using stored difficulty values + let previous_retarget = if current_epoch_usize > 0 { + let prev_epoch = DifficultyEpoch::from(current_epoch_usize - 1); + let prev_epoch_start = computer + .indexes + .difficultyepoch_to_first_height + .read_once(prev_epoch)?; + + let prev_difficulty = indexer + .vecs + .block + .height_to_difficulty + .read_once(prev_epoch_start)?; + let curr_difficulty = indexer + .vecs + .block + .height_to_difficulty + .read_once(epoch_start_height)?; + + if *prev_difficulty > 0.0 { + ((*curr_difficulty / *prev_difficulty) - 1.0) * 100.0 + } else { + 0.0 + } + } else { + 0.0 + }; + + Ok(DifficultyAdjustment { + progress_percent, + difficulty_change, + estimated_retarget_date, + remaining_blocks, + remaining_time, + previous_retarget, + next_retarget_height: Height::from(next_retarget_height), + time_avg, + adjusted_time_avg: time_avg, + time_offset, + }) + } +} diff --git a/crates/brk_query/src/impl/mining/difficulty_adjustments.rs b/crates/brk_query/src/impl/mining/difficulty_adjustments.rs new file mode 100644 index 000000000..69b6a585b --- /dev/null +++ b/crates/brk_query/src/impl/mining/difficulty_adjustments.rs @@ -0,0 +1,26 @@ +use brk_error::Result; +use brk_types::{DifficultyAdjustmentEntry, TimePeriod}; +use vecdb::VecIndex; + +use super::epochs::iter_difficulty_epochs; +use crate::Query; + +impl Query { + pub fn difficulty_adjustments( + &self, + time_period: Option, + ) -> Result> { + let current_height = self.height(); + let end = current_height.to_usize(); + let start = match time_period { + Some(tp) => end.saturating_sub(tp.block_count()), + None => 0, + }; + + let mut entries = iter_difficulty_epochs(self.computer(), start, end); + + // Return in reverse chronological order (newest first) + entries.reverse(); + Ok(entries) + } +} diff --git a/crates/brk_query/src/chain/mining/epochs.rs b/crates/brk_query/src/impl/mining/epochs.rs similarity index 100% rename from crates/brk_query/src/chain/mining/epochs.rs rename to crates/brk_query/src/impl/mining/epochs.rs diff --git a/crates/brk_query/src/impl/mining/hashrate.rs b/crates/brk_query/src/impl/mining/hashrate.rs new file mode 100644 index 000000000..8b5c57038 --- /dev/null +++ b/crates/brk_query/src/impl/mining/hashrate.rs @@ -0,0 +1,100 @@ +use brk_error::Result; +use brk_types::{DateIndex, DifficultyEntry, HashrateEntry, HashrateSummary, Height, TimePeriod}; +use vecdb::{GenericStoredVec, IterableVec, VecIndex}; + +use super::epochs::iter_difficulty_epochs; +use crate::Query; + +impl Query { + pub fn hashrate(&self, time_period: Option) -> Result { + let indexer = self.indexer(); + let computer = self.computer(); + let current_height = self.height(); + + // Get current difficulty + let current_difficulty = *indexer + .vecs + .block + .height_to_difficulty + .read_once(current_height)?; + + // Get current hashrate + let current_dateindex = computer + .indexes + .height_to_dateindex + .read_once(current_height)?; + let current_hashrate = *computer + .chain + .indexes_to_hash_rate + .dateindex + .unwrap_last() + .read_once(current_dateindex)? as u128; + + // Calculate start height based on time period + let end = current_height.to_usize(); + let start = match time_period { + Some(tp) => end.saturating_sub(tp.block_count()), + None => 0, + }; + + // Get hashrate entries using iterators for efficiency + let start_dateindex = computer + .indexes + .height_to_dateindex + .read_once(Height::from(start))?; + let end_dateindex = current_dateindex; + + // Sample at regular intervals to avoid too many data points + let total_days = end_dateindex + .to_usize() + .saturating_sub(start_dateindex.to_usize()) + + 1; + let step = (total_days / 200).max(1); // Max ~200 data points + + // Create iterators for the loop + let mut hashrate_iter = computer + .chain + .indexes_to_hash_rate + .dateindex + .unwrap_last() + .iter(); + let mut timestamp_iter = computer + .chain + .timeindexes_to_timestamp + .dateindex_extra + .unwrap_first() + .iter(); + + let mut hashrates = Vec::with_capacity(total_days / step + 1); + let mut di = start_dateindex.to_usize(); + while di <= end_dateindex.to_usize() { + let dateindex = DateIndex::from(di); + if let (Some(hr), Some(timestamp)) = + (hashrate_iter.get(dateindex), timestamp_iter.get(dateindex)) + { + hashrates.push(HashrateEntry { + timestamp, + avg_hashrate: (*hr) as u128, + }); + } + di += step; + } + + // Get difficulty adjustments within the period + let difficulty: Vec = iter_difficulty_epochs(computer, start, end) + .into_iter() + .map(|e| DifficultyEntry { + timestamp: e.timestamp, + difficulty: e.difficulty, + height: e.height, + }) + .collect(); + + Ok(HashrateSummary { + hashrates, + difficulty, + current_hashrate, + current_difficulty, + }) + } +} diff --git a/crates/brk_query/src/impl/mining/mod.rs b/crates/brk_query/src/impl/mining/mod.rs new file mode 100644 index 000000000..ee08665f8 --- /dev/null +++ b/crates/brk_query/src/impl/mining/mod.rs @@ -0,0 +1,11 @@ +mod block_fee_rates; +mod block_fees; +mod block_rewards; +mod block_sizes; +mod dateindex_iter; +mod difficulty; +mod difficulty_adjustments; +mod epochs; +mod hashrate; +mod pools; +mod reward_stats; diff --git a/crates/brk_query/src/impl/mining/pools.rs b/crates/brk_query/src/impl/mining/pools.rs new file mode 100644 index 000000000..49e6a212b --- /dev/null +++ b/crates/brk_query/src/impl/mining/pools.rs @@ -0,0 +1,171 @@ +use brk_error::{Error, Result}; +use brk_types::{ + Height, PoolBlockCounts, PoolBlockShares, PoolDetail, PoolDetailInfo, PoolInfo, PoolSlug, + PoolStats, PoolsSummary, TimePeriod, pools, +}; +use vecdb::{AnyVec, IterableVec, VecIndex}; + +use crate::Query; + +impl Query { + pub fn mining_pools(&self, time_period: TimePeriod) -> Result { + let computer = self.computer(); + let current_height = self.height(); + let end = current_height.to_usize(); + + // No blocks indexed yet + if computer.pools.height_to_pool.len() == 0 { + return Ok(PoolsSummary { + pools: vec![], + block_count: 0, + last_estimated_hashrate: 0, + }); + } + + // Calculate start height based on time period + let start = end.saturating_sub(time_period.block_count()); + + let pools = pools(); + let mut pool_data: Vec<(&'static brk_types::Pool, u32)> = Vec::new(); + + // For each pool, get cumulative count at end and start, subtract to get range count + for (pool_id, pool_vecs) in &computer.pools.vecs { + let mut cumulative = pool_vecs + .indexes_to_blocks_mined + .height_extra + .unwrap_cumulative() + .iter(); + + let count_at_end: u32 = *cumulative.get(current_height).unwrap_or_default(); + + let count_at_start: u32 = if start == 0 { + 0 + } else { + *cumulative.get(Height::from(start - 1)).unwrap_or_default() + }; + + let block_count = count_at_end.saturating_sub(count_at_start); + + // Only include pools that mined at least one block in the period + if block_count > 0 { + pool_data.push((pools.get(*pool_id), block_count)); + } + } + + // Sort by block count descending + pool_data.sort_by(|a, b| b.1.cmp(&a.1)); + + let total_blocks: u32 = pool_data.iter().map(|(_, count)| count).sum(); + + // Build stats with ranks + 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(); + + // TODO: Calculate actual hashrate from difficulty + let last_estimated_hashrate = 0u128; + + Ok(PoolsSummary { + pools: pool_stats, + block_count: total_blocks, + last_estimated_hashrate, + }) + } + + pub fn all_pools(&self) -> Vec { + pools().iter().map(PoolInfo::from).collect() + } + + 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); + + // Get pool vecs for this specific pool + let pool_vecs = computer + .pools + .vecs + .get(&slug) + .ok_or_else(|| Error::Str("Pool data not found"))?; + + let mut cumulative = pool_vecs + .indexes_to_blocks_mined + .height_extra + .unwrap_cumulative() + .iter(); + + // Get total blocks (all time) + let total_all: u32 = *cumulative.get(current_height).unwrap_or_default(); + + // Get blocks for 24h (144 blocks) + let start_24h = end.saturating_sub(144); + let count_before_24h: u32 = if start_24h == 0 { + 0 + } else { + *cumulative + .get(Height::from(start_24h - 1)) + .unwrap_or_default() + }; + let total_24h = total_all.saturating_sub(count_before_24h); + + // Get blocks for 1w (1008 blocks) + let start_1w = end.saturating_sub(1008); + let count_before_1w: u32 = if start_1w == 0 { + 0 + } else { + *cumulative + .get(Height::from(start_1w - 1)) + .unwrap_or_default() + }; + let total_1w = total_all.saturating_sub(count_before_1w); + + // Calculate total network blocks for share calculation + let network_blocks_all = (end + 1) as u32; + let network_blocks_24h = (end - start_24h + 1) as u32; + let network_blocks_1w = (end - start_1w + 1) as u32; + + 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 + }; + + 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: 0, // TODO: Calculate from share and network hashrate + reported_hashrate: None, + }) + } +} diff --git a/crates/brk_query/src/impl/mining/reward_stats.rs b/crates/brk_query/src/impl/mining/reward_stats.rs new file mode 100644 index 000000000..1fae3bb52 --- /dev/null +++ b/crates/brk_query/src/impl/mining/reward_stats.rs @@ -0,0 +1,66 @@ +use brk_error::Result; +use brk_types::{Height, RewardStats, Sats}; +use vecdb::{IterableVec, VecIndex}; + +use crate::Query; + +impl Query { + pub fn reward_stats(&self, block_count: usize) -> Result { + let computer = self.computer(); + let current_height = self.height(); + + let end_block = current_height; + let start_block = Height::from(current_height.to_usize().saturating_sub(block_count - 1)); + + let mut coinbase_iter = computer + .chain + .indexes_to_coinbase + .sats + .height + .as_ref() + .unwrap() + .iter(); + let mut fee_iter = computer + .chain + .indexes_to_fee + .sats + .height + .unwrap_sum() + .iter(); + let mut tx_count_iter = computer + .chain + .indexes_to_tx_count + .height + .as_ref() + .unwrap() + .iter(); + + let mut total_reward = Sats::ZERO; + let mut total_fee = Sats::ZERO; + let mut total_tx: u64 = 0; + + for height in start_block.to_usize()..=end_block.to_usize() { + let h = Height::from(height); + + if let Some(coinbase) = coinbase_iter.get(h) { + total_reward += coinbase; + } + + if let Some(fee) = fee_iter.get(h) { + total_fee += fee; + } + + if let Some(tx_count) = tx_count_iter.get(h) { + total_tx += *tx_count; + } + } + + Ok(RewardStats { + start_block, + end_block, + total_reward, + total_fee, + total_tx, + }) + } +} diff --git a/crates/brk_query/src/impl/mod.rs b/crates/brk_query/src/impl/mod.rs new file mode 100644 index 000000000..9228f7850 --- /dev/null +++ b/crates/brk_query/src/impl/mod.rs @@ -0,0 +1,11 @@ +//! Query implementation modules. +//! +//! Each module extends `Query` with domain-specific methods using `impl Query` blocks. + +mod address; +mod block; +mod mempool; +mod mining; +mod transaction; + +pub use block::BLOCK_TXS_PAGE_SIZE; diff --git a/crates/brk_query/src/impl/transaction.rs b/crates/brk_query/src/impl/transaction.rs new file mode 100644 index 000000000..c2a9e24da --- /dev/null +++ b/crates/brk_query/src/impl/transaction.rs @@ -0,0 +1,405 @@ +use std::{io::Cursor, str::FromStr}; + +use bitcoin::{consensus::Decodable, hex::DisplayHex}; +use brk_error::{Error, Result}; +use brk_types::{ + Sats, Transaction, TxIn, TxInIndex, TxIndex, TxOut, TxOutspend, TxStatus, Txid, TxidPath, + TxidPrefix, Vin, Vout, Weight, +}; +use vecdb::{GenericStoredVec, TypedVecIterator}; + +use crate::Query; + +impl Query { + pub fn transaction(&self, TxidPath { txid }: TxidPath) -> Result { + let Ok(txid) = bitcoin::Txid::from_str(&txid) else { + return Err(Error::InvalidTxid); + }; + + let txid = Txid::from(txid); + + // First check mempool for unconfirmed transactions + if let Some(mempool) = self.mempool() + && let Some(tx_with_hex) = mempool.get_txs().get(&txid) + { + return Ok(tx_with_hex.tx().clone()); + } + + // Look up confirmed transaction by txid prefix + let prefix = TxidPrefix::from(&txid); + let indexer = self.indexer(); + let Ok(Some(txindex)) = indexer + .stores + .txidprefix_to_txindex + .get(&prefix) + .map(|opt| opt.map(|cow| cow.into_owned())) + else { + return Err(Error::UnknownTxid); + }; + + self.transaction_by_index(txindex) + } + + pub fn transaction_status(&self, TxidPath { txid }: TxidPath) -> Result { + let Ok(txid) = bitcoin::Txid::from_str(&txid) else { + return Err(Error::InvalidTxid); + }; + + let txid = Txid::from(txid); + + // First check mempool for unconfirmed transactions + if let Some(mempool) = self.mempool() + && mempool.get_txs().contains_key(&txid) + { + return Ok(TxStatus::UNCONFIRMED); + } + + // Look up confirmed transaction by txid prefix + let prefix = TxidPrefix::from(&txid); + let indexer = self.indexer(); + let Ok(Some(txindex)) = indexer + .stores + .txidprefix_to_txindex + .get(&prefix) + .map(|opt| opt.map(|cow| cow.into_owned())) + else { + return Err(Error::UnknownTxid); + }; + + // Get block info for status + let height = indexer.vecs.tx.txindex_to_height.read_once(txindex)?; + let block_hash = indexer.vecs.block.height_to_blockhash.read_once(height)?; + let block_time = indexer.vecs.block.height_to_timestamp.read_once(height)?; + + Ok(TxStatus { + confirmed: true, + block_height: Some(height), + block_hash: Some(block_hash), + block_time: Some(block_time), + }) + } + + pub fn transaction_hex(&self, TxidPath { txid }: TxidPath) -> Result { + let Ok(txid) = bitcoin::Txid::from_str(&txid) else { + return Err(Error::InvalidTxid); + }; + + let txid = Txid::from(txid); + + // First check mempool for unconfirmed transactions + if let Some(mempool) = self.mempool() + && let Some(tx_with_hex) = mempool.get_txs().get(&txid) + { + return Ok(tx_with_hex.hex().to_string()); + } + + // Look up confirmed transaction by txid prefix + let prefix = TxidPrefix::from(&txid); + let indexer = self.indexer(); + let Ok(Some(txindex)) = indexer + .stores + .txidprefix_to_txindex + .get(&prefix) + .map(|opt| opt.map(|cow| cow.into_owned())) + else { + return Err(Error::UnknownTxid); + }; + + self.transaction_hex_by_index(txindex) + } + + pub fn outspend(&self, TxidPath { txid }: TxidPath, vout: Vout) -> Result { + let Ok(txid) = bitcoin::Txid::from_str(&txid) else { + return Err(Error::InvalidTxid); + }; + + let txid = Txid::from(txid); + + // Mempool outputs are unspent in on-chain terms + if let Some(mempool) = self.mempool() + && mempool.get_txs().contains_key(&txid) + { + return Ok(TxOutspend::UNSPENT); + } + + // Look up confirmed transaction + let prefix = TxidPrefix::from(&txid); + let indexer = self.indexer(); + let Ok(Some(txindex)) = indexer + .stores + .txidprefix_to_txindex + .get(&prefix) + .map(|opt| opt.map(|cow| cow.into_owned())) + else { + return Err(Error::UnknownTxid); + }; + + // Calculate txoutindex + let first_txoutindex = indexer + .vecs + .tx + .txindex_to_first_txoutindex + .read_once(txindex)?; + let txoutindex = first_txoutindex + vout; + + // Look up spend status + let computer = self.computer(); + let txinindex = computer + .stateful + .txoutindex_to_txinindex + .read_once(txoutindex)?; + + if txinindex == TxInIndex::UNSPENT { + return Ok(TxOutspend::UNSPENT); + } + + self.outspend_details(txinindex) + } + + pub fn outspends(&self, TxidPath { txid }: TxidPath) -> Result> { + let Ok(txid) = bitcoin::Txid::from_str(&txid) else { + return Err(Error::InvalidTxid); + }; + + let txid = Txid::from(txid); + + // Mempool outputs are unspent in on-chain terms + if let Some(mempool) = self.mempool() + && let Some(tx_with_hex) = mempool.get_txs().get(&txid) + { + let output_count = tx_with_hex.tx().output.len(); + return Ok(vec![TxOutspend::UNSPENT; output_count]); + } + + // Look up confirmed transaction + let prefix = TxidPrefix::from(&txid); + let indexer = self.indexer(); + let Ok(Some(txindex)) = indexer + .stores + .txidprefix_to_txindex + .get(&prefix) + .map(|opt| opt.map(|cow| cow.into_owned())) + else { + return Err(Error::UnknownTxid); + }; + + // Get output range + let first_txoutindex = indexer + .vecs + .tx + .txindex_to_first_txoutindex + .read_once(txindex)?; + let next_first_txoutindex = indexer + .vecs + .tx + .txindex_to_first_txoutindex + .read_once(txindex.incremented())?; + let output_count = usize::from(next_first_txoutindex) - usize::from(first_txoutindex); + + // Get spend status for each output + let computer = self.computer(); + let mut txoutindex_to_txinindex_iter = computer.stateful.txoutindex_to_txinindex.iter()?; + + let mut outspends = Vec::with_capacity(output_count); + for i in 0..output_count { + let txoutindex = first_txoutindex + Vout::from(i); + let txinindex = txoutindex_to_txinindex_iter.get_unwrap(txoutindex); + + if txinindex == TxInIndex::UNSPENT { + outspends.push(TxOutspend::UNSPENT); + } else { + outspends.push(self.outspend_details(txinindex)?); + } + } + + Ok(outspends) + } + + // === Helper methods === + + pub fn transaction_by_index(&self, txindex: TxIndex) -> Result { + let indexer = self.indexer(); + let reader = self.reader(); + let computer = self.computer(); + + // Get tx metadata using read_once for single lookups + let txid = indexer.vecs.tx.txindex_to_txid.read_once(txindex)?; + let height = indexer.vecs.tx.txindex_to_height.read_once(txindex)?; + let version = indexer.vecs.tx.txindex_to_txversion.read_once(txindex)?; + let lock_time = indexer.vecs.tx.txindex_to_rawlocktime.read_once(txindex)?; + let total_size = indexer.vecs.tx.txindex_to_total_size.read_once(txindex)?; + let first_txinindex = indexer + .vecs + .tx + .txindex_to_first_txinindex + .read_once(txindex)?; + let position = computer.blks.txindex_to_position.read_once(txindex)?; + + // Get block info for status + let block_hash = indexer.vecs.block.height_to_blockhash.read_once(height)?; + let block_time = indexer.vecs.block.height_to_timestamp.read_once(height)?; + + // Read and decode the raw transaction from blk file + let buffer = reader.read_raw_bytes(position, *total_size as usize)?; + let mut cursor = Cursor::new(buffer); + let tx = bitcoin::Transaction::consensus_decode(&mut cursor) + .map_err(|_| Error::Str("Failed to decode transaction"))?; + + // For iterating through inputs, we need iterators (multiple lookups) + let mut txindex_to_txid_iter = indexer.vecs.tx.txindex_to_txid.iter()?; + let mut txindex_to_first_txoutindex_iter = + indexer.vecs.tx.txindex_to_first_txoutindex.iter()?; + let mut txinindex_to_outpoint_iter = indexer.vecs.txin.txinindex_to_outpoint.iter()?; + let mut txoutindex_to_value_iter = indexer.vecs.txout.txoutindex_to_value.iter()?; + + // Build inputs with prevout information + let input: Vec = tx + .input + .iter() + .enumerate() + .map(|(i, txin)| { + let txinindex = first_txinindex + i; + let outpoint = txinindex_to_outpoint_iter.get_unwrap(txinindex); + + let is_coinbase = outpoint.is_coinbase(); + + // Get prevout info if not coinbase + let (prev_txid, prev_vout, prevout) = if is_coinbase { + (Txid::COINBASE, Vout::MAX, None) + } else { + let prev_txindex = outpoint.txindex(); + let prev_vout = outpoint.vout(); + let prev_txid = txindex_to_txid_iter.get_unwrap(prev_txindex); + + // Calculate the txoutindex for the prevout + let prev_first_txoutindex = + txindex_to_first_txoutindex_iter.get_unwrap(prev_txindex); + let prev_txoutindex = prev_first_txoutindex + prev_vout; + + // Get the value of the prevout + let prev_value = txoutindex_to_value_iter.get_unwrap(prev_txoutindex); + + let prevout = Some(TxOut::from(( + bitcoin::ScriptBuf::new(), // Placeholder - would need to reconstruct + prev_value, + ))); + + (prev_txid, prev_vout, prevout) + }; + + TxIn { + txid: prev_txid, + vout: prev_vout, + prevout, + script_sig: txin.script_sig.clone(), + script_sig_asm: (), + is_coinbase, + sequence: txin.sequence.0, + inner_redeem_script_asm: (), + } + }) + .collect(); + + // Calculate weight before consuming tx.output + let weight = Weight::from(tx.weight()); + + // Calculate sigop cost + let total_sigop_cost = tx.total_sigop_cost(|_| None); + + // Build outputs + let output: Vec = tx.output.into_iter().map(TxOut::from).collect(); + + // Build status + let status = TxStatus { + confirmed: true, + block_height: Some(height), + block_hash: Some(block_hash), + block_time: Some(block_time), + }; + + let mut transaction = Transaction { + index: Some(txindex), + txid, + version, + lock_time, + total_size: *total_size as usize, + weight, + total_sigop_cost, + fee: Sats::ZERO, // Will be computed below + input, + output, + status, + }; + + // Compute fee from inputs - outputs + transaction.compute_fee(); + + Ok(transaction) + } + + fn transaction_hex_by_index(&self, txindex: TxIndex) -> Result { + let indexer = self.indexer(); + let reader = self.reader(); + let computer = self.computer(); + + let total_size = indexer.vecs.tx.txindex_to_total_size.read_once(txindex)?; + let position = computer.blks.txindex_to_position.read_once(txindex)?; + + let buffer = reader.read_raw_bytes(position, *total_size as usize)?; + + Ok(buffer.to_lower_hex_string()) + } + + fn outspend_details(&self, txinindex: TxInIndex) -> Result { + let indexer = self.indexer(); + + // Look up spending txindex directly + let spending_txindex = indexer + .vecs + .txin + .txinindex_to_txindex + .read_once(txinindex)?; + + // Calculate vin + let spending_first_txinindex = indexer + .vecs + .tx + .txindex_to_first_txinindex + .read_once(spending_txindex)?; + let vin = Vin::from(usize::from(txinindex) - usize::from(spending_first_txinindex)); + + // Get spending tx details + let spending_txid = indexer + .vecs + .tx + .txindex_to_txid + .read_once(spending_txindex)?; + let spending_height = indexer + .vecs + .tx + .txindex_to_height + .read_once(spending_txindex)?; + let block_hash = indexer + .vecs + .block + .height_to_blockhash + .read_once(spending_height)?; + let block_time = indexer + .vecs + .block + .height_to_timestamp + .read_once(spending_height)?; + + Ok(TxOutspend { + spent: true, + txid: Some(spending_txid), + vin: Some(vin), + status: Some(TxStatus { + confirmed: true, + block_height: Some(spending_height), + block_hash: Some(block_hash), + block_time: Some(block_time), + }), + }) + } +} diff --git a/crates/brk_query/src/lib.rs b/crates/brk_query/src/lib.rs index f07865878..b217febc3 100644 --- a/crates/brk_query/src/lib.rs +++ b/crates/brk_query/src/lib.rs @@ -9,44 +9,30 @@ use brk_indexer::Indexer; use brk_mempool::Mempool; use brk_reader::Reader; use brk_traversable::TreeNode; -use brk_types::{ - Address, AddressStats, BlockInfo, BlockStatus, BlockTimestamp, Format, HashrateSummary, - Height, Index, IndexInfo, Limit, MempoolInfo, Metric, MetricCount, PoolDetail, PoolInfo, - PoolSlug, PoolsSummary, RecommendedFees, TimePeriod, Timestamp, Transaction, TxOutspend, - TxStatus, Txid, TxidPath, Utxo, Vout, -}; +use brk_types::{Format, Height, Index, IndexInfo, Limit, Metric, MetricCount}; use vecdb::{AnyExportableVec, AnyStoredVec}; +// Infrastructure modules #[cfg(feature = "tokio")] mod r#async; -mod chain; -mod deser; mod output; -mod pagination; -mod params; mod vecs; +// Query impl blocks (extend Query with domain methods) +mod r#impl; + +// Re-exports #[cfg(feature = "tokio")] pub use r#async::*; -pub use output::{Output, Value}; -pub use pagination::{PaginatedIndexParam, PaginatedMetrics, PaginationParam}; -pub use params::{Params, ParamsDeprec, ParamsOpt}; -use vecs::Vecs; - -pub use crate::chain::BLOCK_TXS_PAGE_SIZE; -pub use crate::chain::validate_address; -use crate::{ - chain::{ - get_address, get_address_mempool_txids, get_address_txids, get_address_utxos, - get_all_pools, get_block_by_height, get_block_by_timestamp, get_block_raw, - get_block_status_by_height, get_block_txid_at_index, get_block_txids, get_block_txs, - get_blocks, get_difficulty_adjustment, get_hashrate, get_height_by_hash, - get_mempool_blocks, get_mempool_info, get_mempool_txids, get_mining_pools, get_pool_detail, - get_recommended_fees, get_transaction, get_transaction_hex, get_transaction_status, - get_tx_outspend, get_tx_outspends, - }, - vecs::{IndexToVec, MetricToVec}, +pub use brk_types::{ + DataRange, DataRangeFormat, MetricSelection, MetricSelectionLegacy, PaginatedMetrics, + Pagination, PaginationIndex, }; +pub use r#impl::BLOCK_TXS_PAGE_SIZE; +pub use output::{Output, Value}; + +use crate::vecs::{IndexToVec, MetricToVec}; +use vecs::Vecs; #[derive(Clone)] pub struct Query(Arc>); @@ -79,217 +65,17 @@ impl Query { })) } - pub fn get_height(&self) -> Height { + /// Current indexed height + pub fn height(&self) -> Height { Height::from(self.indexer().vecs.block.height_to_blockhash.stamp()) } - pub fn get_address(&self, address: Address) -> Result { - get_address(address, self) - } - - pub fn get_address_txids( - &self, - address: Address, - after_txid: Option, - limit: usize, - ) -> Result> { - get_address_txids(address, after_txid, limit, self) - } - - pub fn get_address_utxos(&self, address: Address) -> Result> { - get_address_utxos(address, self) - } - - pub fn get_address_mempool_txids(&self, address: Address) -> Result> { - get_address_mempool_txids(address, self) - } - - pub fn get_transaction(&self, txid: TxidPath) -> Result { - get_transaction(txid, self) - } - - pub fn get_transaction_status(&self, txid: TxidPath) -> Result { - get_transaction_status(txid, self) - } - - pub fn get_transaction_hex(&self, txid: TxidPath) -> Result { - get_transaction_hex(txid, self) - } - - pub fn get_tx_outspend(&self, txid: TxidPath, vout: Vout) -> Result { - get_tx_outspend(txid, vout, self) - } - - pub fn get_tx_outspends(&self, txid: TxidPath) -> Result> { - get_tx_outspends(txid, self) - } - - pub fn get_block(&self, hash: &str) -> Result { - let height = get_height_by_hash(hash, self)?; - get_block_by_height(height, self) - } - - pub fn get_block_by_height(&self, height: Height) -> Result { - get_block_by_height(height, self) - } - - pub fn get_block_by_timestamp(&self, timestamp: Timestamp) -> Result { - get_block_by_timestamp(timestamp, self) - } - - pub fn get_block_status(&self, hash: &str) -> Result { - let height = get_height_by_hash(hash, self)?; - get_block_status_by_height(height, self) - } - - pub fn get_blocks(&self, start_height: Option) -> Result> { - get_blocks(start_height, self) - } - - pub fn get_block_txids(&self, hash: &str) -> Result> { - let height = get_height_by_hash(hash, self)?; - get_block_txids(height, self) - } - - pub fn get_block_txs(&self, hash: &str, start_index: usize) -> Result> { - let height = get_height_by_hash(hash, self)?; - get_block_txs(height, start_index, self) - } - - pub fn get_block_txid_at_index(&self, hash: &str, index: usize) -> Result { - let height = get_height_by_hash(hash, self)?; - get_block_txid_at_index(height, index, self) - } - - pub fn get_block_raw(&self, hash: &str) -> Result> { - let height = get_height_by_hash(hash, self)?; - get_block_raw(height, self) - } - - pub fn get_mempool_info(&self) -> Result { - get_mempool_info(self) - } - - pub fn get_mempool_txids(&self) -> Result> { - get_mempool_txids(self) - } - - pub fn get_recommended_fees(&self) -> Result { - get_recommended_fees(self) - } - - pub fn get_mempool_blocks(&self) -> Result> { - get_mempool_blocks(self) - } - - pub fn get_difficulty_adjustment(&self) -> Result { - get_difficulty_adjustment(self) - } - - pub fn get_mining_pools(&self, time_period: TimePeriod) -> Result { - get_mining_pools(time_period, self) - } - - pub fn get_all_pools(&self) -> Vec { - get_all_pools() - } - - pub fn get_pool_detail(&self, slug: PoolSlug) -> Result { - get_pool_detail(slug, self) - } - - pub fn get_hashrate(&self, time_period: Option) -> Result { - get_hashrate(time_period, self) - } - - pub fn get_difficulty_adjustments( - &self, - time_period: Option, - ) -> Result> { - chain::get_difficulty_adjustments(time_period, self) - } - - pub fn get_block_fees(&self, time_period: TimePeriod) -> Result> { - chain::get_block_fees(time_period, self) - } - - pub fn get_block_rewards( - &self, - time_period: TimePeriod, - ) -> Result> { - chain::get_block_rewards(time_period, self) - } - - pub fn get_block_fee_rates( - &self, - time_period: TimePeriod, - ) -> Result> { - chain::get_block_fee_rates(time_period, self) - } - - pub fn get_block_sizes_weights( - &self, - time_period: TimePeriod, - ) -> Result { - chain::get_block_sizes_weights(time_period, self) - } - - pub fn get_reward_stats(&self, block_count: usize) -> Result { - chain::get_reward_stats(block_count, self) - } + // === Metrics methods === pub fn match_metric(&self, metric: &Metric, limit: Limit) -> Vec<&'static str> { self.vecs().matches(metric, limit) } - pub fn search_metric_with_index( - &self, - metric: &str, - index: Index, - // params: &Params, - ) -> Result> { - todo!(); - - // let all_metrics = &self.vecs.metrics; - // let metrics = ¶ms.metrics; - // let index = params.index; - - // let ids_to_vec = self - // .vecs - // .index_to_metric_to_vec - // .get(&index) - // .ok_or(Error::String(format!( - // "Index \"{}\" isn't a valid index", - // index - // )))?; - - // metrics - // .iter() - // .map(|metric| { - // let vec = ids_to_vec.get(metric.as_str()).ok_or_else(|| { - // let matches: Vec<&str> = MATCHER.with(|matcher| { - // let matcher = matcher.borrow(); - // let mut scored: Vec<(&str, i64)> = all_metrics - // .iter() - // .filter_map(|m| matcher.fuzzy_match(m, metric).map(|s| (*m, s))) - // .collect(); - - // scored.sort_unstable_by_key(|&(_, s)| std::cmp::Reverse(s)); - // scored.into_iter().take(5).map(|(m, _)| m).collect() - // }); - - // let mut message = format!("No vec \"{metric}\" for index \"{index}\".\n"); - // if !matches.is_empty() { - // message += &format!("\nDid you mean: {matches:?}\n"); - // } - - // Error::String(message) - // }); - // vec.map(|vec| (metric.clone(), vec)) - // }) - // .collect::>>() - } - fn columns_to_csv( columns: &[&&dyn AnyExportableVec], from: Option, @@ -336,7 +122,7 @@ impl Query { pub fn format( &self, metrics: Vec<&&dyn AnyExportableVec>, - params: &ParamsOpt, + params: &DataRangeFormat, ) -> Result { let from = params.from().map(|from| { metrics @@ -381,9 +167,50 @@ impl Query { }) } - pub fn search_and_format(&self, params: Params) -> Result { - todo!() - // self.format(self.search(¶ms)?, ¶ms.rest) + /// Search for vecs matching the given metrics and index + pub fn search(&self, params: &MetricSelection) -> Vec<&'static dyn AnyExportableVec> { + params + .metrics + .iter() + .filter_map(|metric| self.vecs().get(metric, params.index)) + .collect() + } + + /// Calculate total weight of the vecs for the given range + pub fn weight(vecs: &[&dyn AnyExportableVec], from: Option, to: Option) -> usize { + vecs.iter().map(|v| v.range_weight(from, to)).sum() + } + + pub fn search_and_format(&self, params: MetricSelection) -> Result { + let vecs = self.search(¶ms); + + if vecs.is_empty() { + return Ok(Output::default(params.range.format())); + } + + self.format(vecs.iter().collect(), ¶ms.range) + } + + /// Search and format with weight limit (for DDoS prevention) + pub fn search_and_format_checked( + &self, + params: MetricSelection, + max_weight: usize, + ) -> Result { + let vecs = self.search(¶ms); + + if vecs.is_empty() { + return Ok(Output::default(params.range.format())); + } + + let weight = Self::weight(&vecs, params.from(), params.to()); + if weight > max_weight { + return Err(Error::String(format!( + "Request too heavy: {weight} bytes exceeds limit of {max_weight} bytes" + ))); + } + + self.format(vecs.iter().collect(), ¶ms.range) } pub fn metric_to_index_to_vec(&self) -> &BTreeMap<&str, IndexToVec<'_>> { @@ -413,7 +240,7 @@ impl Query { &self.vecs().indexes } - pub fn get_metrics(&self, pagination: PaginationParam) -> PaginatedMetrics { + pub fn get_metrics(&self, pagination: Pagination) -> PaginatedMetrics { self.vecs().metrics(pagination) } @@ -421,7 +248,7 @@ impl Query { self.vecs().catalog() } - pub fn get_index_to_vecids(&self, paginated_index: PaginatedIndexParam) -> Vec<&str> { + pub fn get_index_to_vecids(&self, paginated_index: PaginationIndex) -> Option<&[&str]> { self.vecs().index_to_ids(paginated_index) } @@ -429,6 +256,8 @@ impl Query { self.vecs().metric_to_indexes(metric) } + // === Core accessors === + #[inline] pub fn reader(&self) -> &Reader { &self.0.reader diff --git a/crates/brk_query/src/pagination.rs b/crates/brk_query/src/pagination.rs deleted file mode 100644 index 0c1550128..000000000 --- a/crates/brk_query/src/pagination.rs +++ /dev/null @@ -1,45 +0,0 @@ -use brk_types::Index; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -use crate::deser::de_unquote_usize; - -#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] -pub struct PaginationParam { - #[schemars(description = "Pagination index")] - #[serde(default, alias = "p", deserialize_with = "de_unquote_usize")] - pub page: Option, -} - -impl PaginationParam { - pub const PER_PAGE: usize = 1_000; - - pub fn start(&self, len: usize) -> usize { - (self.page.unwrap_or_default() * Self::PER_PAGE).clamp(0, len) - } - - pub fn end(&self, len: usize) -> usize { - ((self.page.unwrap_or_default() + 1) * Self::PER_PAGE).clamp(0, len) - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct PaginatedIndexParam { - pub index: Index, - #[serde(flatten)] - pub pagination: PaginationParam, -} - -/// A paginated list of available metric names (1000 per page) -#[derive(Debug, Serialize, JsonSchema)] -pub struct PaginatedMetrics { - /// Current page number (0-indexed) - #[schemars(example = 0)] - pub current_page: usize, - /// Maximum valid page index (0-indexed) - #[schemars(example = 21000)] - pub max_page: usize, - /// List of metric names (max 1000 per page) - #[schemars(example = ["price_open", "price_close", "realized_price", "..."])] - pub metrics: &'static [&'static str], -} diff --git a/crates/brk_query/src/params.rs b/crates/brk_query/src/params.rs deleted file mode 100644 index d1a967750..000000000 --- a/crates/brk_query/src/params.rs +++ /dev/null @@ -1,130 +0,0 @@ -use std::ops::Deref; - -use brk_types::{Format, Index, Metric, Metrics}; -use schemars::JsonSchema; -use serde::Deserialize; - -use crate::deser::{de_unquote_i64, de_unquote_usize}; - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct Params { - /// Requested metrics - #[serde(alias = "m")] - pub metrics: Metrics, - - #[serde(alias = "i")] - pub index: Index, - - #[serde(flatten)] - pub rest: ParamsOpt, -} - -impl Deref for Params { - type Target = ParamsOpt; - fn deref(&self) -> &Self::Target { - &self.rest - } -} - -impl From<(Index, Metric, ParamsOpt)> for Params { - #[inline] - fn from((index, metric, rest): (Index, Metric, ParamsOpt)) -> Self { - Self { - index, - metrics: Metrics::from(metric), - rest, - } - } -} - -impl From<(Index, Metrics, ParamsOpt)> for Params { - #[inline] - fn from((index, metrics, rest): (Index, Metrics, ParamsOpt)) -> Self { - Self { - index, - metrics, - rest, - } - } -} - -#[derive(Default, Debug, Deserialize, JsonSchema)] -pub struct ParamsOpt { - /// Inclusive starting index, if negative will be from the end - #[serde(default, alias = "f", deserialize_with = "de_unquote_i64")] - from: Option, - - /// Exclusive ending index, if negative will be from the end, overrides 'count' - #[serde(default, alias = "t", deserialize_with = "de_unquote_i64")] - to: Option, - - /// Number of values requested - #[serde(default, alias = "c", deserialize_with = "de_unquote_usize")] - count: Option, - - /// Format of the output - #[serde(default)] - format: Format, -} - -impl ParamsOpt { - pub fn set_from(mut self, from: i64) -> Self { - self.from.replace(from); - self - } - - pub fn set_to(mut self, to: i64) -> Self { - self.to.replace(to); - self - } - - pub fn set_count(mut self, count: usize) -> Self { - self.count.replace(count); - self - } - - pub fn from(&self) -> Option { - self.from - } - - pub fn to(&self) -> Option { - if self.to.is_none() - && let Some(c) = self.count - { - let c = c as i64; - if let Some(f) = self.from { - if f >= 0 || f.abs() > c { - return Some(f + c); - } - } else { - return Some(c); - } - } - self.to - } - - pub fn format(&self) -> Format { - self.format - } -} - -#[derive(Debug, Deserialize)] -pub struct ParamsDeprec { - #[serde(alias = "i")] - pub index: Index, - #[serde(alias = "v")] - pub ids: Metrics, - #[serde(flatten)] - pub rest: ParamsOpt, -} - -impl From for Params { - #[inline] - fn from(value: ParamsDeprec) -> Self { - Params { - index: value.index, - metrics: value.ids, - rest: value.rest, - } - } -} diff --git a/crates/brk_query/src/vecs.rs b/crates/brk_query/src/vecs.rs index 2fde2bd82..02814dadb 100644 --- a/crates/brk_query/src/vecs.rs +++ b/crates/brk_query/src/vecs.rs @@ -3,13 +3,11 @@ use std::collections::BTreeMap; use brk_computer::Computer; use brk_indexer::Indexer; use brk_traversable::{Traversable, TreeNode}; -use brk_types::{Index, IndexInfo, Limit, Metric}; +use brk_types::{Index, IndexInfo, Limit, Metric, PaginatedMetrics, Pagination, PaginationIndex}; use derive_deref::{Deref, DerefMut}; use quickmatch::{QuickMatch, QuickMatchConfig}; use vecdb::AnyExportableVec; -use crate::pagination::{PaginatedIndexParam, PaginatedMetrics, PaginationParam}; - #[derive(Default)] pub struct Vecs<'a> { pub metric_to_index_to_vec: BTreeMap<&'a str, IndexToVec<'a>>, @@ -18,7 +16,6 @@ pub struct Vecs<'a> { pub indexes: Vec, pub distinct_metric_count: usize, pub total_metric_count: usize, - pub longest_metric_len: usize, catalog: Option, matcher: Option>, metric_to_indexes: BTreeMap<&'a str, Vec>, @@ -58,12 +55,6 @@ impl<'a> Vecs<'a> { sort_ids(&mut ids); this.metrics = ids; - this.longest_metric_len = this - .metrics - .iter() - .map(|s| s.len()) - .max() - .unwrap_or_default(); this.distinct_metric_count = this.metric_to_index_to_vec.keys().count(); this.total_metric_count = this .index_to_metric_to_vec @@ -107,44 +98,35 @@ impl<'a> Vecs<'a> { this } - // Not the most performant or type safe but only built once so that's okay fn insert(&mut self, vec: &'a dyn AnyExportableVec) { let name = vec.name(); - // dbg!(vec.region_name()); let serialized_index = vec.index_type_to_string(); let index = Index::try_from(serialized_index) - .inspect_err(|_| { - dbg!(&serialized_index); - }) - .unwrap(); + .unwrap_or_else(|_| panic!("Unknown index type: {serialized_index}")); + let prev = self .metric_to_index_to_vec .entry(name) .or_default() .insert(index, vec); - if prev.is_some() { - dbg!(serialized_index, name); - panic!() - } + assert!(prev.is_none(), "Duplicate metric: {name} for index {index:?}"); + let prev = self .index_to_metric_to_vec .entry(index) .or_default() .insert(name, vec); - if prev.is_some() { - dbg!(serialized_index, name); - panic!() - } + assert!(prev.is_none(), "Duplicate metric: {name} for index {index:?}"); } - pub fn metrics(&'static self, pagination: PaginationParam) -> PaginatedMetrics { + pub fn metrics(&'static self, pagination: Pagination) -> PaginatedMetrics { let len = self.metrics.len(); let start = pagination.start(len); let end = pagination.end(len); PaginatedMetrics { - current_page: pagination.page.unwrap_or_default(), - max_page: len.div_ceil(PaginationParam::PER_PAGE).saturating_sub(1), + current_page: pagination.page(), + max_page: len.div_ceil(Pagination::PER_PAGE).saturating_sub(1), metrics: &self.metrics[start..end], } } @@ -156,28 +138,34 @@ impl<'a> Vecs<'a> { pub fn index_to_ids( &self, - PaginatedIndexParam { index, pagination }: PaginatedIndexParam, - ) -> Vec<&'a str> { - let vec = self.index_to_metrics.get(&index).unwrap(); + PaginationIndex { index, pagination }: PaginationIndex, + ) -> Option<&[&'a str]> { + let vec = self.index_to_metrics.get(&index)?; let len = vec.len(); let start = pagination.start(len); let end = pagination.end(len); - vec.iter().skip(start).take(end).cloned().collect() + Some(&vec[start..end]) } pub fn catalog(&self) -> &TreeNode { - self.catalog.as_ref().unwrap() + self.catalog.as_ref().expect("catalog not initialized") } pub fn matches(&self, metric: &Metric, limit: Limit) -> Vec<&'_ str> { - self.matcher() + self.matcher + .as_ref() + .expect("matcher not initialized") .matches_with(metric, &QuickMatchConfig::new().with_limit(*limit)) } - fn matcher(&self) -> &QuickMatch<'_> { - self.matcher.as_ref().unwrap() + /// Look up a vec by metric name and index + pub fn get(&self, metric: &Metric, index: Index) -> Option<&'a dyn AnyExportableVec> { + let metric_name = metric.replace("-", "_"); + self.metric_to_index_to_vec + .get(metric_name.as_str()) + .and_then(|index_to_vec| index_to_vec.get(&index).copied()) } } diff --git a/crates/brk_server/Cargo.toml b/crates/brk_server/Cargo.toml index 6fe1875de..0a8777fa4 100644 --- a/crates/brk_server/Cargo.toml +++ b/crates/brk_server/Cargo.toml @@ -15,7 +15,6 @@ brk_computer = { workspace = true } brk_error = { workspace = true } brk_fetcher = { workspace = true } brk_indexer = { workspace = true } -brk_iterator = { workspace = true } brk_logger = { workspace = true } brk_mcp = { workspace = true } brk_query = { workspace = true } @@ -33,3 +32,6 @@ serde_json = { workspace = true } tokio = { workspace = true } tower-http = { version = "0.6.8", features = ["compression-full", "trace"] } tracing = "0.1.43" + +[dev-dependencies] +brk_mempool = { workspace = true } diff --git a/crates/brk_server/README.md b/crates/brk_server/README.md index 5a738a337..adedc2be8 100644 --- a/crates/brk_server/README.md +++ b/crates/brk_server/README.md @@ -1,238 +1,39 @@ # brk_server -HTTP server providing REST API access to Bitcoin analytics data +HTTP server for the Bitcoin Research Kit. [![Crates.io](https://img.shields.io/crates/v/brk_server.svg)](https://crates.io/crates/brk_server) [![Documentation](https://docs.rs/brk_server/badge.svg)](https://docs.rs/brk_server) ## Overview -This crate provides a high-performance HTTP server built on `axum` that exposes Bitcoin blockchain analytics data through a comprehensive REST API. It integrates with the entire BRK ecosystem, serving data from indexers, computers, and parsers with intelligent caching, compression, and multiple output formats. +This crate provides an HTTP server that exposes BRK's blockchain data through a REST API. It serves as the web interface layer for the Bitcoin Research Kit, making data accessible to applications, dashboards, and research tools. -**Key Features:** +Built on `axum` with automatic OpenAPI documentation via Scalar. -- RESTful API for blockchain data queries with flexible filtering -- Multiple output formats: JSON, CSV -- Intelligent caching system with ETags and conditional requests -- HTTP compression (Gzip, Brotli, Deflate, Zstd) for bandwidth efficiency -- Static file serving for web interfaces and documentation -- Bitcoin address and transaction lookup endpoints -- Vector database query interface with pagination -- Health monitoring and status endpoints - -**Target Use Cases:** - -- Bitcoin data APIs for applications and research -- Web-based blockchain explorers and analytics dashboards -- Research data export and analysis tools -- Integration with external systems requiring Bitcoin data - -## Installation - -```bash -cargo add brk_server -``` - -## Quick Start +## Usage ```rust use brk_server::Server; -use brk_query::Interface; -use std::path::PathBuf; +use brk_query::AsyncQuery; -// Initialize interface with your data sources -let interface = Interface::new(/* your config */); +let query = AsyncQuery::build(&reader, &indexer, &computer, Some(mempool)); +let server = Server::new(&query, None); -// Optional static file serving directory -let files_path = Some(PathBuf::from("./web")); - -// Create and start server -let server = Server::new(interface, files_path); - -// Start server with optional MCP (Model Context Protocol) support +// Starts on port 3110 (or next available) server.serve(true).await?; ``` -## API Overview +Once running: +- **API Documentation**: `http://localhost:3110/api` +- **OpenAPI Spec**: `http://localhost:3110/api.json` -### Core Endpoints +## Features -**Blockchain Queries:** - -- `GET /api/address/{address}` - Address information, balance, transaction counts -- `GET /api/tx/{txid}` - Transaction details including version, locktime -- `GET /api/vecs/{variant}` - Vector database queries with filtering - -**System Information:** - -- `GET /api/vecs/index-count` - Total number of indexes available -- `GET /api/vecs/id-count` - Vector ID statistics -- `GET /api/vecs/indexes` - List of available data indexes -- `GET /health` - Service health status -- `GET /version` - Server version information - -### Vector Database API - -**Query Interface:** - -- `GET /api/vecs/query` - Generic vector query with parameters -- `GET /api/vecs/{variant}?from={start}&to={end}&format={format}` - Range queries - -**Supported Parameters:** - -- `from` / `to`: Range filtering (height, timestamp, date-based) -- `format`: Output format (json, csv) -- Pagination parameters for large datasets - -### Address API Response Format - -```json -{ - "address": "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", - "type": "p2pkh", - "index": 12345, - "chain_stats": { - "funded_txo_sum": 500000000, - "spent_txo_sum": 400000000, - "utxo_count": 5, - "balance": 100000000, - "balance_usd": 4200.5, - "realized_value": 450000000, - "avg_cost_basis": 45000.0 - } -} -``` - -## Examples - -### Basic Server Setup - -```rust -use brk_server::Server; -use brk_query::Interface; - -// Initialize with BRK interface -let interface = Interface::builder() - .with_indexer_path("./data/indexer") - .with_computer_path("./data/computer") - .build()?; - -let server = Server::new(interface, None); - -// Server automatically finds available port starting from 3110 -server.serve(false).await?; -``` - -### Address Balance Lookup - -```bash -# Get address information -curl http://localhost:3110/api/address/1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2 - -# Response includes balance, transaction counts, USD value -{ - "address": "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", - "type": "p2pkh", - "chain_stats": { - "balance": 100000000, - "balance_usd": 4200.50, - "utxo_count": 5 - } -} -``` - -### Data Export Queries - -```bash -# Export height-to-price data as CSV -curl "http://localhost:3110/api/vecs/height-to-price?from=800000&to=800100&format=csv" \ - -H "Accept-Encoding: gzip" - -# Query with caching - subsequent requests return 304 Not Modified -curl "http://localhost:3110/api/vecs/dateindex-to-price-ohlc?from=0&to=1000" \ - -H "If-None-Match: \"etag-hash\"" -``` - -### Transaction Details - -```bash -# Get transaction information -curl http://localhost:3110/api/tx/abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 - -# Response includes version, locktime, and internal indexing -{ - "txid": "abcdef...", - "index": 98765, - "version": 2, - "locktime": 0 -} -``` - -## Architecture - -### Server Stack - -- **HTTP Framework**: `axum` with async/await for high concurrency -- **Compression**: Multi-algorithm support (Gzip, Brotli, Deflate, Zstd) -- **Caching**: `quick_cache` with LRU eviction and ETag validation -- **Tracing**: Request/response logging with latency tracking -- **Static Files**: Optional web interface serving - -### Caching Strategy - -The server implements intelligent caching: - -- **ETags**: Generated from data version and query parameters -- **Conditional Requests**: 304 Not Modified responses for unchanged data -- **Memory Cache**: LRU cache with configurable capacity (5,000 entries) -- **Cache Control**: `must-revalidate` headers for data consistency - -### Request Processing - -1. **Route Matching**: Path-based routing to appropriate handlers -2. **Parameter Validation**: Query parameter parsing and validation -3. **Data Retrieval**: Interface calls to indexer/computer components -4. **Caching Logic**: ETag generation and cache lookup -5. **Format Conversion**: JSON/CSV output formatting -6. **Compression**: Response compression based on Accept-Encoding -7. **Response**: HTTP response with appropriate headers - -### Static File Serving - -Optional static file serving supports: - -- Web interface hosting for blockchain explorers -- Documentation and API reference serving -- Asset serving (CSS, JS, images) with proper MIME types -- Directory browsing with index.html fallback - -## Configuration - -### Environment Variables - -The server automatically configures itself but respects: - -- Port selection: Starts at 3110, increments if unavailable -- Compression: Enabled by default for all supported algorithms -- CORS: Permissive headers for cross-origin requests - -### Memory Management - -- Cache size: 5,000 entries by default -- Request weight limits: 65MB maximum per query -- Timeout handling: 50ms cache guard timeout -- Compression: Adaptive based on content type and size - -## Code Analysis Summary - -**Main Components**: `Server` struct with `AppState` containing interface, cache, and file paths \ -**HTTP Framework**: Built on `axum` with middleware for compression, tracing, and CORS \ -**API Routes**: Address lookup, transaction details, vector queries, and system information \ -**Caching Layer**: `quick_cache` integration with ETag-based conditional requests \ -**Data Integration**: Direct interface to BRK indexer, computer, parser, and fetcher components \ -**Static Serving**: Optional file serving for web interfaces and documentation \ -**Architecture**: Async HTTP server with intelligent caching and multi-format data export capabilities - ---- - -_This README was generated by Claude Code_ +- **REST API** for addresses, blocks, transactions, mempool, mining stats, and metrics +- **OpenAPI documentation** with interactive Scalar UI +- **Multiple formats**: JSON and CSV output +- **HTTP caching**: ETag-based conditional requests +- **Compression**: Gzip, Brotli, Deflate, Zstd +- **MCP support**: Model Context Protocol for AI integrations +- **Static file serving**: Optional web interface hosting diff --git a/crates/brk_server/examples/main.rs b/crates/brk_server/examples/server.rs similarity index 53% rename from crates/brk_server/examples/main.rs rename to crates/brk_server/examples/server.rs index 220f3a110..9d8c05222 100644 --- a/crates/brk_server/examples/main.rs +++ b/crates/brk_server/examples/server.rs @@ -1,15 +1,10 @@ -use std::{ - path::Path, - thread::{self, sleep}, - time::Duration, -}; +use std::{path::Path, thread}; use brk_computer::Computer; - use brk_error::Result; use brk_fetcher::Fetcher; use brk_indexer::Indexer; -use brk_iterator::Blocks; +use brk_mempool::Mempool; use brk_query::AsyncQuery; use brk_reader::Reader; use brk_rpc::{Auth, Client}; @@ -30,10 +25,7 @@ fn run() -> Result<()> { brk_logger::init(Some(Path::new(".log")))?; let bitcoin_dir = Client::default_bitcoin_path(); - // let bitcoin_dir = Path::new("/Volumes/WD_BLACK1/bitcoin"); - let outputs_dir = Path::new(&std::env::var("HOME").unwrap()).join(".brk"); - // let outputs_dir = Path::new("../../_outputs"); let client = Client::new( Client::default_url(), @@ -41,51 +33,45 @@ fn run() -> Result<()> { )?; let reader = Reader::new(bitcoin_dir.join("blocks"), &client); - - let blocks = Blocks::new(&client, &reader); - - let mut indexer = Indexer::forced_import(&outputs_dir)?; - + let indexer = Indexer::forced_import(&outputs_dir)?; let fetcher = Some(Fetcher::import(true, None)?); + let computer = Computer::forced_import(&outputs_dir, &indexer, fetcher)?; - let mut computer = Computer::forced_import(&outputs_dir, &indexer, fetcher)?; + let mempool = Mempool::new(&client); + let mempool_clone = mempool.clone(); + thread::spawn(move || { + mempool_clone.start(); + }); let exit = Exit::new(); exit.set_ctrlc_handler(); - let query = AsyncQuery::build(&reader, &indexer, &computer, None); - - let future = async move { - let server = Server::new(&query, None); - - tokio::spawn(async move { - server.serve(true).await.unwrap(); - }); - - Ok(()) as Result<()> - }; + let query = AsyncQuery::build(&reader, &indexer, &computer, Some(mempool)); let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; - let _handle = runtime.spawn(future); + // Option 1: block_on to run and properly propagate errors + runtime.block_on(async move { + let server = Server::new(&query, None); - loop { - client.wait_for_synced_node()?; + let handle = tokio::spawn(async move { server.serve(true).await }); - let last_height = client.get_last_height()?; - - info!("{} blocks found.", u32::from(last_height) + 1); - - let starting_indexes = indexer.checked_index(&blocks, &client, &exit)?; - - computer.compute(&indexer, starting_indexes, &reader, &exit)?; - - info!("Waiting for new blocks..."); - - while last_height == client.get_last_height()? { - sleep(Duration::from_secs(1)) + // Await the handle to catch both panics and errors + match handle.await { + Ok(Ok(())) => info!("Server shut down cleanly"), + Ok(Err(e)) => log::error!("Server error: {e:?}"), + Err(e) => { + // JoinError - either panic or cancellation + if e.is_panic() { + log::error!("Server panicked: {:?}", e.into_panic()); + } else { + log::error!("Server task cancelled"); + } + } } - } + + Ok(()) as Result<()> + }) } diff --git a/crates/brk_server/src/api/addresses/mod.rs b/crates/brk_server/src/api/addresses/mod.rs index 19f83eb7a..be68c8ba9 100644 --- a/crates/brk_server/src/api/addresses/mod.rs +++ b/crates/brk_server/src/api/addresses/mod.rs @@ -2,16 +2,12 @@ use aide::axum::{ApiRouter, routing::get_with}; use axum::{ extract::{Path, Query, State}, http::HeaderMap, - response::{Redirect, Response}, + response::Redirect, routing::get, }; -use brk_query::validate_address; use brk_types::{Address, AddressStats, AddressTxidsParam, AddressValidation, Txid, Utxo}; -use crate::{ - VERSION, - extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended}, -}; +use crate::{CacheStrategy, extended::TransformResponseExtended}; use super::AppState; @@ -31,11 +27,7 @@ impl AddressRoutes for ApiRouter { Path(address): Path
, State(state): State | { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_address(address).await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.address(address)).await }, |op| op .addresses_tag() .summary("Address information") @@ -55,11 +47,7 @@ impl AddressRoutes for ApiRouter { Query(params): Query, State(state): State | { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_address_txids(address, params.after_txid, params.limit).await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.address_txids(address, params.after_txid, params.limit)).await }, |op| op .addresses_tag() .summary("Address transaction IDs") @@ -78,11 +66,7 @@ impl AddressRoutes for ApiRouter { Path(address): Path
, State(state): State | { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_address_utxos(address).await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.address_utxos(address)).await }, |op| op .addresses_tag() .summary("Address UTXOs") @@ -101,17 +85,13 @@ impl AddressRoutes for ApiRouter { Path(address): Path
, State(state): State | { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_address_mempool_txids(address).await.to_json_response(&etag) + // Mempool txs for an address - use MaxAge since it's volatile + state.cached_json(&headers, CacheStrategy::MaxAge(5), move |q| q.address_mempool_txids(address)).await }, |op| op .addresses_tag() .summary("Address mempool transactions") .description("Get unconfirmed transaction IDs for an address from the mempool (up to 50).") .ok_response::>() - .not_modified() .bad_request() .not_found() .server_error() @@ -125,11 +105,7 @@ impl AddressRoutes for ApiRouter { Query(params): Query, State(state): State | { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_address_txids(address, params.after_txid, 25).await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.address_txids(address, params.after_txid, 25)).await }, |op| op .addresses_tag() .summary("Address confirmed transactions") @@ -148,11 +124,7 @@ impl AddressRoutes for ApiRouter { Path(address): Path, State(state): State | { - let etag = VERSION; - if headers.has_etag(etag) { - return Response::new_not_modified(); - } - Response::new_json(validate_address(&address), etag) + state.cached_json(&headers, CacheStrategy::Static, move |_q| Ok(AddressValidation::from_address(&address))).await }, |op| op .addresses_tag() .summary("Validate address") diff --git a/crates/brk_server/src/api/blocks/mod.rs b/crates/brk_server/src/api/blocks/mod.rs index f32025b39..fc8cf368d 100644 --- a/crates/brk_server/src/api/blocks/mod.rs +++ b/crates/brk_server/src/api/blocks/mod.rs @@ -2,7 +2,7 @@ use aide::axum::{ApiRouter, routing::get_with}; use axum::{ extract::{Path, State}, http::HeaderMap, - response::{Redirect, Response}, + response::Redirect, routing::get, }; use brk_query::BLOCK_TXS_PAGE_SIZE; @@ -11,10 +11,7 @@ use brk_types::{ BlockTimestamp, Height, HeightPath, StartHeightPath, TimestampPath, Transaction, Txid, }; -use crate::{ - VERSION, - extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended}, -}; +use crate::{CacheStrategy, extended::TransformResponseExtended}; use super::AppState; @@ -35,11 +32,7 @@ impl BlockRoutes for ApiRouter { async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_block(path.hash).await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.block(&path.hash)).await }, |op| { op.blocks_tag() @@ -61,14 +54,7 @@ impl BlockRoutes for ApiRouter { async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_block_status(path.hash) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_status(&path.hash)).await }, |op| { op.blocks_tag() @@ -90,14 +76,8 @@ impl BlockRoutes for ApiRouter { async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_block_by_height(Height::from(path.height)) - .await - .to_json_response(&etag) + let height = Height::from(path.height); + state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_by_height(height)).await }, |op| { op.blocks_tag() @@ -119,12 +99,8 @@ impl BlockRoutes for ApiRouter { async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } let start_height = path.start_height.map(Height::from); - state.get_blocks(start_height).await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.blocks(start_height)).await }, |op| { op.blocks_tag() @@ -145,11 +121,7 @@ impl BlockRoutes for ApiRouter { async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_block_txids(path.hash).await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_txids(&path.hash)).await }, |op| { op.blocks_tag() @@ -171,11 +143,7 @@ impl BlockRoutes for ApiRouter { async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_block_txs(path.hash, path.start_index).await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_txs(&path.hash, path.start_index)).await }, |op| { op.blocks_tag() @@ -198,11 +166,7 @@ impl BlockRoutes for ApiRouter { async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_block_txid_at_index(path.hash, path.index).await.to_display_response(&etag) + state.cached_text(&headers, CacheStrategy::Height, move |q| q.block_txid_at_index(&path.hash, path.index).map(|t| t.to_string())).await }, |op| { op.blocks_tag() @@ -224,14 +188,7 @@ impl BlockRoutes for ApiRouter { async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_block_by_timestamp(path.timestamp) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_by_timestamp(path.timestamp)).await }, |op| { op.blocks_tag() @@ -251,11 +208,7 @@ impl BlockRoutes for ApiRouter { async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_block_raw(path.hash).await.to_bytes_response(&etag) + state.cached_bytes(&headers, CacheStrategy::Height, move |q| q.block_raw(&path.hash)).await }, |op| { op.blocks_tag() diff --git a/crates/brk_server/src/api/mempool/mod.rs b/crates/brk_server/src/api/mempool/mod.rs index bdfcf0677..0d0a5c001 100644 --- a/crates/brk_server/src/api/mempool/mod.rs +++ b/crates/brk_server/src/api/mempool/mod.rs @@ -2,15 +2,12 @@ use aide::axum::{ApiRouter, routing::get_with}; use axum::{ extract::State, http::HeaderMap, - response::{Redirect, Response}, + response::Redirect, routing::get, }; use brk_types::{MempoolBlock, MempoolInfo, RecommendedFees, Txid}; -use crate::{ - VERSION, - extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended}, -}; +use crate::{CacheStrategy, extended::TransformResponseExtended}; use super::AppState; @@ -26,18 +23,13 @@ impl MempoolRoutes for ApiRouter { "/api/mempool/info", get_with( async |headers: HeaderMap, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_mempool_info().await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_info()).await }, |op| { op.mempool_tag() .summary("Mempool statistics") .description("Get current mempool statistics including transaction count, total vsize, and total fees.") .ok_response::() - .not_modified() .server_error() }, ), @@ -46,18 +38,13 @@ impl MempoolRoutes for ApiRouter { "/api/mempool/txids", get_with( async |headers: HeaderMap, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_mempool_txids().await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_txids()).await }, |op| { op.mempool_tag() .summary("Mempool transaction IDs") .description("Get all transaction IDs currently in the mempool.") .ok_response::>() - .not_modified() .server_error() }, ), @@ -66,18 +53,13 @@ impl MempoolRoutes for ApiRouter { "/api/v1/fees/recommended", get_with( async |headers: HeaderMap, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_recommended_fees().await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::MaxAge(3), |q| q.recommended_fees()).await }, |op| { op.mempool_tag() .summary("Recommended fees") .description("Get recommended fee rates for different confirmation targets based on current mempool state.") .ok_response::() - .not_modified() .server_error() }, ), @@ -86,18 +68,13 @@ impl MempoolRoutes for ApiRouter { "/api/v1/fees/mempool-blocks", get_with( async |headers: HeaderMap, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_mempool_blocks().await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_blocks()).await }, |op| { op.mempool_tag() .summary("Projected mempool blocks") .description("Get projected blocks from the mempool for fee estimation. Each block contains statistics about transactions that would be included if a block were mined now.") .ok_response::>() - .not_modified() .server_error() }, ), diff --git a/crates/brk_server/src/api/metrics/data.rs b/crates/brk_server/src/api/metrics/data.rs index 39d323fb6..97f9cfd27 100644 --- a/crates/brk_server/src/api/metrics/data.rs +++ b/crates/brk_server/src/api/metrics/data.rs @@ -1,31 +1,29 @@ use std::time::Duration; use axum::{ - Json, body::Body, extract::{Query, State}, http::{HeaderMap, StatusCode, Uri}, response::{IntoResponse, Response}, }; -use brk_error::{Error, Result}; -use brk_query::{Output, Params}; +use brk_query::{MetricSelection, Output}; use brk_types::Format; use quick_cache::sync::GuardResult; -use vecdb::Stamp; -use crate::{HeaderMapExtended, ResponseExtended}; +use crate::{CacheStrategy, cache::CacheParams, extended::HeaderMapExtended}; use super::AppState; +/// Maximum allowed request weight in bytes (650KB) const MAX_WEIGHT: usize = 65 * 10_000; pub async fn handler( uri: Uri, headers: HeaderMap, - query: Query, + query: Query, State(state): State, ) -> Response { - match req_to_response_res(uri, headers, query, state) { + match req_to_response_res(uri, headers, query, state).await { Ok(response) => response, Err(error) => { let mut response = @@ -36,91 +34,64 @@ pub async fn handler( } } -fn req_to_response_res( +async fn req_to_response_res( uri: Uri, headers: HeaderMap, - Query(params): Query, - AppState { - query: interface, - cache, - .. - }: AppState, -) -> Result { - todo!(); + Query(params): Query, + AppState { query, cache, .. }: AppState, +) -> brk_error::Result { + let format = params.format(); + let height = query.sync(|q| q.height()); + let to = params.to(); - // let vecs = interface.search(¶ms)?; + let cache_params = CacheParams::resolve(&CacheStrategy::height_with(format!("{to:?}")), || height.into()); - // if vecs.is_empty() { - // return Ok(Json(vec![] as Vec).into_response()); - // } + if cache_params.matches_etag(&headers) { + let mut response = (StatusCode::NOT_MODIFIED, "").into_response(); + response.headers_mut().insert_cors(); + return Ok(response); + } - // let from = params.from(); - // let to = params.to(); - // let format = params.format(); + let cache_key = format!("{}{}{}", uri.path(), uri.query().unwrap_or(""), cache_params.etag_str()); + let guard_res = cache.get_value_or_guard(&cache_key, Some(Duration::from_millis(50))); - // // TODO: From and to should be capped here + let mut response = if let GuardResult::Value(v) = guard_res { + Response::new(Body::from(v)) + } else { + match query + .run(move |q| q.search_and_format_checked(params, MAX_WEIGHT)) + .await? + { + Output::CSV(s) => { + if let GuardResult::Guard(g) = guard_res { + let _ = g.insert(s.clone().into()); + } + s.into_response() + } + Output::Json(v) => { + let json = v.to_vec(); + if let GuardResult::Guard(g) = guard_res { + let _ = g.insert(json.clone().into()); + } + json.into_response() + } + } + }; - // let weight = vecs - // .iter() - // .map(|(_, v)| v.range_weight(from, to)) - // .sum::(); + let headers = response.headers_mut(); + headers.insert_cors(); + if let Some(etag) = &cache_params.etag { + headers.insert_etag(etag); + } + headers.insert_cache_control(&cache_params.cache_control); - // if weight > MAX_WEIGHT { - // return Err(Error::String(format!( - // "Request is too heavy, max weight is {MAX_WEIGHT} bytes" - // ))); - // } + match format { + Format::CSV => { + headers.insert_content_disposition_attachment(); + headers.insert_content_type_text_csv() + } + Format::JSON => headers.insert_content_type_application_json(), + } - // // TODO: height should be from vec, but good enough for now - // let etag = vecs - // .first() - // .unwrap() - // .1 - // .etag(Stamp::from(interface.get_height()), to); - - // if headers.has_etag(etag) { - // return Ok(Response::new_not_modified()); - // } - - // let guard_res = cache.get_value_or_guard( - // &format!("{}{}{etag}", uri.path(), uri.query().unwrap_or("")), - // Some(Duration::from_millis(50)), - // ); - - // let mut response = if let GuardResult::Value(v) = guard_res { - // Response::new(Body::from(v)) - // } else { - // match interface.format(vecs, ¶ms.rest)? { - // Output::CSV(s) => { - // if let GuardResult::Guard(g) = guard_res { - // let _ = g.insert(s.clone().into()); - // } - // s.into_response() - // } - // Output::Json(v) => { - // let json = v.to_vec(); - // if let GuardResult::Guard(g) = guard_res { - // let _ = g.insert(json.clone().into()); - // } - // json.into_response() - // } - // } - // }; - - // let headers = response.headers_mut(); - - // headers.insert_cors(); - - // headers.insert_etag(&etag); - // headers.insert_cache_control_must_revalidate(); - - // match format { - // Format::CSV => { - // headers.insert_content_disposition_attachment(); - // headers.insert_content_type_text_csv() - // } - // Format::JSON => headers.insert_content_type_application_json(), - // } - - // Ok(response) + Ok(response) } diff --git a/crates/brk_server/src/api/metrics/mod.rs b/crates/brk_server/src/api/metrics/mod.rs index 0e376a99f..19c748d52 100644 --- a/crates/brk_server/src/api/metrics/mod.rs +++ b/crates/brk_server/src/api/metrics/mod.rs @@ -1,18 +1,15 @@ use aide::axum::{ApiRouter, routing::get_with}; use axum::{ extract::{Path, Query, State}, - http::{HeaderMap, StatusCode, Uri}, + http::{HeaderMap, Uri}, response::{IntoResponse, Redirect, Response}, routing::get, }; -use brk_query::{PaginatedMetrics, PaginationParam, Params, ParamsDeprec, ParamsOpt}; +use brk_query::{DataRangeFormat, MetricSelection, MetricSelectionLegacy, PaginatedMetrics, Pagination}; use brk_traversable::TreeNode; use brk_types::{Index, IndexInfo, Limit, Metric, MetricCount, Metrics}; -use crate::{ - VERSION, - extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended}, -}; +use crate::{CacheStrategy, extended::TransformResponseExtended}; use super::AppState; @@ -34,11 +31,7 @@ impl ApiMetricsRoutes for ApiRouter { headers: HeaderMap, State(state): State | { - let etag = VERSION; - if headers.has_etag(etag) { - return Response::new_not_modified(); - } - Response::new_json(state.metric_count().await, etag) + state.cached_json(&headers, CacheStrategy::Static, |q| Ok(q.metric_count())).await }, |op| op .metrics_tag() @@ -55,11 +48,7 @@ impl ApiMetricsRoutes for ApiRouter { headers: HeaderMap, State(state): State | { - let etag = VERSION; - if headers.has_etag(etag) { - return Response::new_not_modified(); - } - Response::new_json( state.get_indexes().await, etag) + state.cached_json(&headers, CacheStrategy::Static, |q| Ok(q.get_indexes().to_vec())).await }, |op| op .metrics_tag() @@ -77,13 +66,9 @@ impl ApiMetricsRoutes for ApiRouter { async | headers: HeaderMap, State(state): State, - Query(pagination): Query + Query(pagination): Query | { - let etag = VERSION; - if headers.has_etag(etag) { - return Response::new_not_modified(); - } - Response::new_json(state.get_metrics(pagination).await, etag) + state.cached_json(&headers, CacheStrategy::Static, move |q| Ok(q.get_metrics(pagination))).await }, |op| op .metrics_tag() @@ -96,12 +81,8 @@ impl ApiMetricsRoutes for ApiRouter { .api_route( "/api/metrics/catalog", get_with( - async |headers: HeaderMap, State(state): State| -> Response { - let etag = VERSION; - if headers.has_etag(etag) { - return Response::new_not_modified(); - } - Response::new_json(state.get_metrics_catalog().await, etag) + async |headers: HeaderMap, State(state): State| { + state.cached_json(&headers, CacheStrategy::Static, |q| Ok(q.get_metrics_catalog().clone())).await }, |op| op .metrics_tag() @@ -122,11 +103,7 @@ impl ApiMetricsRoutes for ApiRouter { Path(metric): Path, Query(limit): Query | { - let etag = VERSION; - if headers.has_etag(etag) { - return Response::new_not_modified(); - } - state.match_metric(metric, limit).await.to_json_response(etag) + state.cached_json(&headers, CacheStrategy::Static, move |q| Ok(q.match_metric(&metric, limit))).await }, |op| op .metrics_tag() @@ -144,20 +121,18 @@ impl ApiMetricsRoutes for ApiRouter { State(state): State, Path(metric): Path | { - let etag = VERSION; - if headers.has_etag(etag) { - return Response::new_not_modified(); - } - if let Some(indexes) = state.metric_to_indexes(metric.clone()).await { - return Response::new_json(indexes, etag) - } - // REMOVE UNWRAP !! - let value = if let Some(first) = state.match_metric(metric.clone(), Limit::MIN).await.unwrap().first() { - format!("Could not find '{metric}', did you mean '{first}' ?") - } else { - format!("Could not find '{metric}'.") - }; - Response::new_json_with(StatusCode::NOT_FOUND, value, etag) + state.cached_json(&headers, CacheStrategy::Static, move |q| { + if let Some(indexes) = q.metric_to_indexes(metric.clone()) { + return Ok(indexes.clone()) + } + Err(brk_error::Error::String( + if let Some(first) = q.match_metric(&metric, Limit::MIN).first() { + format!("Could not find '{metric}', did you mean '{first}' ?") + } else { + format!("Could not find '{metric}'.") + } + )) + }).await }, |op| op .metrics_tag() @@ -173,7 +148,6 @@ impl ApiMetricsRoutes for ApiRouter { ) // WIP .route("/api/metrics/bulk", get(data::handler)) - // WIP .route( "/api/metric/{metric}/{index}", get( @@ -181,16 +155,15 @@ impl ApiMetricsRoutes for ApiRouter { headers: HeaderMap, state: State, Path((metric, index)): Path<(Metric, Index)>, - Query(params_opt): Query| + Query(range): Query| -> Response { - todo!(); - // data::handler( - // uri, - // headers, - // Query(Params::from(((index, metric), params_opt))), - // state, - // ) - // .await + data::handler( + uri, + headers, + Query(MetricSelection::from((index, metric, range))), + state, + ) + .await }, ), ) @@ -202,7 +175,7 @@ impl ApiMetricsRoutes for ApiRouter { get( async |uri: Uri, headers: HeaderMap, - Query(params): Query, + Query(params): Query, state: State| -> Response { data::handler(uri, headers, Query(params.into()), state).await @@ -218,7 +191,7 @@ impl ApiMetricsRoutes for ApiRouter { async |uri: Uri, headers: HeaderMap, Path(variant): Path, - Query(params_opt): Query, + Query(range): Query, state: State| -> Response { let separator = "_to_"; @@ -230,10 +203,10 @@ impl ApiMetricsRoutes for ApiRouter { return format!("Index {ser_index} doesn't exist").into_response(); }; - let params = Params::from(( + let params = MetricSelection::from(( index, Metrics::from(split.collect::>().join(separator)), - params_opt, + range, )); data::handler(uri, headers, Query(params), state).await }, diff --git a/crates/brk_server/src/api/mining/mod.rs b/crates/brk_server/src/api/mining/mod.rs index 0e23dcb5a..622b5762f 100644 --- a/crates/brk_server/src/api/mining/mod.rs +++ b/crates/brk_server/src/api/mining/mod.rs @@ -2,7 +2,7 @@ use aide::axum::{ApiRouter, routing::get_with}; use axum::{ extract::{Path, State}, http::HeaderMap, - response::{Redirect, Response}, + response::Redirect, routing::get, }; use brk_types::{ @@ -11,10 +11,7 @@ use brk_types::{ PoolSlugPath, PoolsSummary, RewardStats, TimePeriodPath, }; -use crate::{ - VERSION, - extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended}, -}; +use crate::{CacheStrategy, extended::TransformResponseExtended}; use super::AppState; @@ -32,14 +29,7 @@ impl MiningRoutes for ApiRouter { "/api/v1/difficulty-adjustment", get_with( async |headers: HeaderMap, State(state): State| { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_difficulty_adjustment() - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, |q| q.difficulty_adjustment()).await }, |op| { op.mining_tag() @@ -55,11 +45,8 @@ impl MiningRoutes for ApiRouter { "/api/v1/mining/pools", get_with( async |headers: HeaderMap, State(state): State| { - let etag = format!("{VERSION}-pools"); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_all_pools().await.to_json_response(&etag) + // Pool list is static, only changes on code update + state.cached_json(&headers, CacheStrategy::Static, |q| Ok(q.all_pools())).await }, |op| { op.mining_tag() @@ -72,17 +59,10 @@ impl MiningRoutes for ApiRouter { ), ) .api_route( - "/api/v1/mining/pools/:time_period", + "/api/v1/mining/pools/{time_period}", get_with( async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-{}-{:?}", state.get_height().await, path.time_period); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_mining_pools(path.time_period) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::height_with(format!("{:?}", path.time_period)), move |q| q.mining_pools(path.time_period)).await }, |op| { op.mining_tag() @@ -95,17 +75,10 @@ impl MiningRoutes for ApiRouter { ), ) .api_route( - "/api/v1/mining/pool/:slug", + "/api/v1/mining/pool/{slug}", get_with( async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-{}-{:?}", state.get_height().await, path.slug); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_pool_detail(path.slug) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::height_with(path.slug), move |q| q.pool_detail(path.slug)).await }, |op| { op.mining_tag() @@ -122,14 +95,7 @@ impl MiningRoutes for ApiRouter { "/api/v1/mining/hashrate", get_with( async |headers: HeaderMap, State(state): State| { - let etag = format!("{VERSION}-hashrate-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_hashrate(None) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::height_with("hashrate"), |q| q.hashrate(None)).await }, |op| { op.mining_tag() @@ -142,17 +108,10 @@ impl MiningRoutes for ApiRouter { ), ) .api_route( - "/api/v1/mining/hashrate/:time_period", + "/api/v1/mining/hashrate/{time_period}", get_with( async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-hashrate-{}-{:?}", state.get_height().await, path.time_period); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_hashrate(Some(path.time_period)) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::height_with(format!("hashrate-{:?}", path.time_period)), move |q| q.hashrate(Some(path.time_period))).await }, |op| { op.mining_tag() @@ -168,14 +127,7 @@ impl MiningRoutes for ApiRouter { "/api/v1/mining/difficulty-adjustments", get_with( async |headers: HeaderMap, State(state): State| { - let etag = format!("{VERSION}-diff-adj-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_difficulty_adjustments(None) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::height_with("diff-adj"), |q| q.difficulty_adjustments(None)).await }, |op| { op.mining_tag() @@ -188,17 +140,10 @@ impl MiningRoutes for ApiRouter { ), ) .api_route( - "/api/v1/mining/difficulty-adjustments/:time_period", + "/api/v1/mining/difficulty-adjustments/{time_period}", get_with( async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-diff-adj-{}-{:?}", state.get_height().await, path.time_period); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_difficulty_adjustments(Some(path.time_period)) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::height_with(format!("diff-adj-{:?}", path.time_period)), move |q| q.difficulty_adjustments(Some(path.time_period))).await }, |op| { op.mining_tag() @@ -211,17 +156,10 @@ impl MiningRoutes for ApiRouter { ), ) .api_route( - "/api/v1/mining/blocks/fees/:time_period", + "/api/v1/mining/blocks/fees/{time_period}", get_with( async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-fees-{}-{:?}", state.get_height().await, path.time_period); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_block_fees(path.time_period) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::height_with(format!("fees-{:?}", path.time_period)), move |q| q.block_fees(path.time_period)).await }, |op| { op.mining_tag() @@ -234,17 +172,10 @@ impl MiningRoutes for ApiRouter { ), ) .api_route( - "/api/v1/mining/blocks/rewards/:time_period", + "/api/v1/mining/blocks/rewards/{time_period}", get_with( async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-rewards-{}-{:?}", state.get_height().await, path.time_period); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_block_rewards(path.time_period) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::height_with(format!("rewards-{:?}", path.time_period)), move |q| q.block_rewards(path.time_period)).await }, |op| { op.mining_tag() @@ -257,17 +188,10 @@ impl MiningRoutes for ApiRouter { ), ) .api_route( - "/api/v1/mining/blocks/fee-rates/:time_period", + "/api/v1/mining/blocks/fee-rates/{time_period}", get_with( async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-feerates-{}-{:?}", state.get_height().await, path.time_period); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_block_fee_rates(path.time_period) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::height_with(format!("feerates-{:?}", path.time_period)), move |q| q.block_fee_rates(path.time_period)).await }, |op| { op.mining_tag() @@ -280,17 +204,10 @@ impl MiningRoutes for ApiRouter { ), ) .api_route( - "/api/v1/mining/blocks/sizes-weights/:time_period", + "/api/v1/mining/blocks/sizes-weights/{time_period}", get_with( async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-sizes-{}-{:?}", state.get_height().await, path.time_period); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_block_sizes_weights(path.time_period) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::height_with(format!("sizes-{:?}", path.time_period)), move |q| q.block_sizes_weights(path.time_period)).await }, |op| { op.mining_tag() @@ -303,17 +220,10 @@ impl MiningRoutes for ApiRouter { ), ) .api_route( - "/api/v1/mining/reward-stats/:block_count", + "/api/v1/mining/reward-stats/{block_count}", get_with( async |headers: HeaderMap, Path(path): Path, State(state): State| { - let etag = format!("{VERSION}-reward-stats-{}-{}", state.get_height().await, path.block_count); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state - .get_reward_stats(path.block_count) - .await - .to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::height_with(format!("reward-stats-{}", path.block_count)), move |q| q.reward_stats(path.block_count)).await }, |op| { op.mining_tag() diff --git a/crates/brk_server/src/api/mod.rs b/crates/brk_server/src/api/mod.rs index 82c58c52b..06c5613f2 100644 --- a/crates/brk_server/src/api/mod.rs +++ b/crates/brk_server/src/api/mod.rs @@ -6,6 +6,7 @@ use aide::{ }; use axum::{ Extension, Json, + extract::State, http::HeaderMap, response::{Html, Redirect, Response}, routing::get, @@ -13,7 +14,7 @@ use axum::{ use brk_types::Health; use crate::{ - VERSION, + CacheStrategy, VERSION, api::{ addresses::AddressRoutes, blocks::BlockRoutes, mempool::MempoolRoutes, metrics::ApiMetricsRoutes, mining::MiningRoutes, transactions::TxRoutes, @@ -49,12 +50,15 @@ impl ApiRoutes for ApiRouter { .api_route( "/version", get_with( - async || -> Json<&'static str> { Json(VERSION) }, + async |headers: HeaderMap, State(state): State| { + state.cached_json(&headers, CacheStrategy::Static, |_| Ok(env!("CARGO_PKG_VERSION"))).await + }, |op| { op.server_tag() .summary("API version") .description("Returns the current version of the API server") .ok_response::() + .not_modified() }, ), ) diff --git a/crates/brk_server/src/api/openapi.rs b/crates/brk_server/src/api/openapi.rs index 3af65cc1b..190da48a7 100644 --- a/crates/brk_server/src/api/openapi.rs +++ b/crates/brk_server/src/api/openapi.rs @@ -29,7 +29,8 @@ pub fn create_openapi() -> OpenApi { Tag { name: "Addresses".to_string(), description: Some( - "Explore Bitcoin addresses." + "Query Bitcoin address data including balances, transaction history, and UTXOs. \ + Supports all address types: P2PKH, P2SH, P2WPKH, P2WSH, and P2TR." .to_string() ), ..Default::default() @@ -37,7 +38,17 @@ pub fn create_openapi() -> OpenApi { Tag { name: "Blocks".to_string(), description: Some( - "Explore Bitcoin blocks." + "Retrieve block data by hash or height. Access block headers, transaction lists, \ + and raw block bytes." + .to_string() + ), + ..Default::default() + }, + Tag { + name: "Mempool".to_string(), + description: Some( + "Monitor unconfirmed transactions and fee estimates. Get mempool statistics, \ + transaction IDs, and recommended fee rates for different confirmation targets." .to_string() ), ..Default::default() @@ -45,8 +56,8 @@ pub fn create_openapi() -> OpenApi { Tag { name: "Metrics".to_string(), description: Some( - "Access Bitcoin network metrics and time-series data. Query historical and real-time \ - statistics across various blockchain dimensions and aggregation levels." + "Access Bitcoin network metrics and time-series data. Query historical statistics \ + across various indexes (date, week, month, year, halving epoch) with JSON or CSV output." .to_string() ), ..Default::default() @@ -54,7 +65,8 @@ pub fn create_openapi() -> OpenApi { Tag { name: "Mining".to_string(), description: Some( - "Explore mining related endpoints." + "Mining statistics including pool distribution, hashrate, difficulty adjustments, \ + block rewards, and fee rates across configurable time periods." .to_string() ), ..Default::default() @@ -62,15 +74,16 @@ pub fn create_openapi() -> OpenApi { Tag { name: "Server".to_string(), description: Some( - "Metadata and utility endpoints for API status, health checks, and system information." + "API metadata and health monitoring. Version information and service status." .to_string() ), - ..Default::default() + ..Default::default() }, Tag { name: "Transactions".to_string(), description: Some( - "Explore Bitcoin transactions." + "Retrieve transaction data by txid. Access full transaction details, confirmation \ + status, raw hex, and output spend information." .to_string() ), ..Default::default() diff --git a/crates/brk_server/src/api/transactions/mod.rs b/crates/brk_server/src/api/transactions/mod.rs index d22e68ec8..42fc36f4b 100644 --- a/crates/brk_server/src/api/transactions/mod.rs +++ b/crates/brk_server/src/api/transactions/mod.rs @@ -2,15 +2,12 @@ use aide::axum::{ApiRouter, routing::get_with}; use axum::{ extract::{Path, State}, http::HeaderMap, - response::{Redirect, Response}, + response::Redirect, routing::get, }; use brk_types::{Transaction, TxOutspend, TxStatus, TxidPath, TxidVoutPath}; -use crate::{ - VERSION, - extended::{HeaderMapExtended, ResponseExtended, ResultExtended, TransformResponseExtended}, -}; +use crate::{CacheStrategy, extended::TransformResponseExtended}; use super::AppState; @@ -31,11 +28,7 @@ impl TxRoutes for ApiRouter { Path(txid): Path, State(state): State | { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_transaction(txid).await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.transaction(txid)).await }, |op| op .transactions_tag() @@ -58,11 +51,7 @@ impl TxRoutes for ApiRouter { Path(txid): Path, State(state): State | { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_transaction_status(txid).await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.transaction_status(txid)).await }, |op| op .transactions_tag() @@ -85,11 +74,7 @@ impl TxRoutes for ApiRouter { Path(txid): Path, State(state): State | { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_transaction_hex(txid).await.to_text_response(&etag) + state.cached_text(&headers, CacheStrategy::Height, move |q| q.transaction_hex(txid)).await }, |op| op .transactions_tag() @@ -112,12 +97,8 @@ impl TxRoutes for ApiRouter { Path(path): Path, State(state): State | { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } let txid = TxidPath { txid: path.txid }; - state.get_tx_outspend(txid, path.vout).await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.outspend(txid, path.vout)).await }, |op| op .transactions_tag() @@ -140,11 +121,7 @@ impl TxRoutes for ApiRouter { Path(txid): Path, State(state): State | { - let etag = format!("{VERSION}-{}", state.get_height().await); - if headers.has_etag(&etag) { - return Response::new_not_modified(); - } - state.get_tx_outspends(txid).await.to_json_response(&etag) + state.cached_json(&headers, CacheStrategy::Height, move |q| q.outspends(txid)).await }, |op| op .transactions_tag() diff --git a/crates/brk_server/src/cache.rs b/crates/brk_server/src/cache.rs new file mode 100644 index 000000000..4ea01dfe7 --- /dev/null +++ b/crates/brk_server/src/cache.rs @@ -0,0 +1,108 @@ +use axum::http::HeaderMap; + +use crate::{VERSION, extended::HeaderMapExtended}; + +/// Minimum confirmations before data is considered immutable +pub const MIN_CONFIRMATIONS: u32 = 6; + +/// Cache strategy for HTTP responses. +/// +/// # Future optimization: Immutable caching for blocks/txs +/// +/// The `Immutable` variant supports caching deeply-confirmed blocks/txs forever +/// (1 year, `immutable` directive). To use it, you need the confirmation count: +/// +/// ```ignore +/// // Example: cache block by hash as immutable if deeply confirmed +/// let confirmations = current_height - block_height + 1; +/// let prefix = *BlockHashPrefix::from(&hash); +/// state.cached_json(&headers, CacheStrategy::immutable(prefix, confirmations), |q| q.block(&hash)).await +/// ``` +/// +/// Currently all block/tx handlers use `Height` for simplicity since determining +/// confirmations requires knowing the block height upfront (an extra lookup). +/// This could be optimized by either: +/// 1. Including confirmation count in the response type +/// 2. Doing a lightweight height lookup before the main query +pub enum CacheStrategy { + /// Immutable data (blocks by hash with 6+ confirmations) + /// Etag = VERSION-{prefix:x}, Cache-Control: immutable, 1yr + /// Falls back to Height if < 6 confirmations + Immutable { prefix: u64, confirmations: u32 }, + + /// Data that changes with each new block (addresses, block-by-height) + /// Etag = VERSION-{height}, Cache-Control: must-revalidate + Height, + + /// Data that changes with height + depends on parameter + /// Etag = VERSION-{height}-{suffix}, Cache-Control: must-revalidate + HeightWith(String), + + /// Static data (validate-address, metrics catalog) + /// Etag = VERSION only, Cache-Control: 1hr + Static, + + /// Volatile data (mempool) - no etag, just max-age + /// Cache-Control: max-age={seconds} + MaxAge(u64), +} + +impl CacheStrategy { + /// Create Immutable strategy - pass *prefix (deref BlockHashPrefix/TxidPrefix to u64) + pub fn immutable(prefix: u64, confirmations: u32) -> Self { + Self::Immutable { + prefix, + confirmations, + } + } + + /// Create HeightWith from any Display type + pub fn height_with(suffix: impl std::fmt::Display) -> Self { + Self::HeightWith(suffix.to_string()) + } +} + +/// Resolved cache parameters +pub struct CacheParams { + pub etag: Option, + pub cache_control: String, +} + +impl CacheParams { + pub fn etag_str(&self) -> &str { + self.etag.as_deref().unwrap_or("") + } + + pub fn matches_etag(&self, headers: &HeaderMap) -> bool { + self.etag.as_ref().is_some_and(|etag| headers.has_etag(etag)) + } + + pub fn resolve(strategy: &CacheStrategy, height: impl FnOnce() -> u32) -> Self { + use CacheStrategy::*; + match strategy { + Immutable { + prefix, + confirmations, + } if *confirmations >= MIN_CONFIRMATIONS => Self { + etag: Some(format!("{VERSION}-{prefix:x}")), + cache_control: "public, max-age=31536000, immutable".into(), + }, + Immutable { .. } | Height => Self { + etag: Some(format!("{VERSION}-{}", height())), + cache_control: "public, max-age=1, must-revalidate".into(), + }, + HeightWith(suffix) => Self { + etag: Some(format!("{VERSION}-{}-{suffix}", height())), + cache_control: "public, max-age=1, must-revalidate".into(), + }, + Static => Self { + etag: Some(VERSION.to_string()), + cache_control: "public, max-age=1, must-revalidate".into(), + }, + MaxAge(secs) => Self { + etag: None, + cache_control: format!("public, max-age={secs}"), + }, + } + } +} diff --git a/crates/brk_server/src/extended/header_map.rs b/crates/brk_server/src/extended/header_map.rs index a5569b049..8c821da6e 100644 --- a/crates/brk_server/src/extended/header_map.rs +++ b/crates/brk_server/src/extended/header_map.rs @@ -27,6 +27,7 @@ pub trait HeaderMapExtended { fn check_if_modified_since(&self, path: &Path) -> Result<(ModifiedState, DateTime)>; fn check_if_modified_since_(&self, duration: Duration) -> Result<(ModifiedState, DateTime)>; + fn insert_cache_control(&mut self, value: &str); fn insert_cache_control_must_revalidate(&mut self); fn insert_cache_control_immutable(&mut self); fn insert_etag(&mut self, etag: &str); @@ -56,18 +57,16 @@ impl HeaderMapExtended for HeaderMap { self.insert(header::ACCESS_CONTROL_ALLOW_HEADERS, "*".parse().unwrap()); } + fn insert_cache_control(&mut self, value: &str) { + self.insert(header::CACHE_CONTROL, value.parse().unwrap()); + } + fn insert_cache_control_must_revalidate(&mut self) { - self.insert( - header::CACHE_CONTROL, - "public, max-age=1, must-revalidate".parse().unwrap(), - ); + self.insert_cache_control("public, max-age=1, must-revalidate"); } fn insert_cache_control_immutable(&mut self) { - self.insert( - header::CACHE_CONTROL, - "public, max-age=31536000, immutable".parse().unwrap(), - ); + self.insert_cache_control("public, max-age=31536000, immutable"); } fn insert_content_disposition_attachment(&mut self) { diff --git a/crates/brk_server/src/extended/response.rs b/crates/brk_server/src/extended/response.rs index 973ce52a0..ae85d0a1d 100644 --- a/crates/brk_server/src/extended/response.rs +++ b/crates/brk_server/src/extended/response.rs @@ -6,6 +6,7 @@ use axum::{ use serde::Serialize; use super::header_map::HeaderMapExtended; +use crate::cache::CacheParams; pub trait ResponseExtended where @@ -16,12 +17,17 @@ where where T: Serialize; fn new_json_with(status: StatusCode, value: T, etag: &str) -> Self + where + T: Serialize; + fn new_json_cached(value: T, params: &CacheParams) -> Self where T: Serialize; fn new_text(value: &str, etag: &str) -> Self; fn new_text_with(status: StatusCode, value: &str, etag: &str) -> Self; + fn new_text_cached(value: &str, params: &CacheParams) -> Self; fn new_bytes(value: Vec, etag: &str) -> Self; fn new_bytes_with(status: StatusCode, value: Vec, etag: &str) -> Self; + fn new_bytes_cached(value: Vec, params: &CacheParams) -> Self; } impl ResponseExtended for Response { @@ -85,4 +91,46 @@ impl ResponseExtended for Response { headers.insert_etag(etag); response } + + fn new_json_cached(value: T, params: &CacheParams) -> Self + where + T: Serialize, + { + let bytes = serde_json::to_vec(&value).unwrap(); + let mut response = Response::builder().body(bytes.into()).unwrap(); + let headers = response.headers_mut(); + headers.insert_cors(); + headers.insert_content_type_application_json(); + headers.insert_cache_control(¶ms.cache_control); + if let Some(etag) = ¶ms.etag { + headers.insert_etag(etag); + } + response + } + + fn new_text_cached(value: &str, params: &CacheParams) -> Self { + let mut response = Response::builder() + .body(value.to_string().into()) + .unwrap(); + let headers = response.headers_mut(); + headers.insert_cors(); + headers.insert_content_type_text_plain(); + headers.insert_cache_control(¶ms.cache_control); + if let Some(etag) = ¶ms.etag { + headers.insert_etag(etag); + } + response + } + + fn new_bytes_cached(value: Vec, params: &CacheParams) -> Self { + let mut response = Response::builder().body(value.into()).unwrap(); + let headers = response.headers_mut(); + headers.insert_cors(); + headers.insert_content_type_octet_stream(); + headers.insert_cache_control(¶ms.cache_control); + if let Some(etag) = ¶ms.etag { + headers.insert_etag(etag); + } + response + } } diff --git a/crates/brk_server/src/extended/result.rs b/crates/brk_server/src/extended/result.rs index b09a0ec66..0406c9e34 100644 --- a/crates/brk_server/src/extended/result.rs +++ b/crates/brk_server/src/extended/result.rs @@ -1,5 +1,3 @@ -use std::fmt::Display; - use axum::{http::StatusCode, response::Response}; use brk_error::{Error, Result}; use serde::Serialize; @@ -14,9 +12,6 @@ pub trait ResultExtended { fn to_text_response(self, etag: &str) -> Response where T: AsRef; - fn to_display_response(self, etag: &str) -> Response - where - T: Display; fn to_bytes_response(self, etag: &str) -> Response where T: Into>; @@ -59,16 +54,6 @@ impl ResultExtended for Result { } } - fn to_display_response(self, etag: &str) -> Response - where - T: Display, - { - match self.with_status() { - Ok(value) => Response::new_text(&value.to_string(), etag), - Err((status, message)) => Response::new_text_with(status, &message, etag), - } - } - fn to_bytes_response(self, etag: &str) -> Response where T: Into>, diff --git a/crates/brk_server/src/files/mod.rs b/crates/brk_server/src/files/mod.rs index 45a087322..56b3f217d 100644 --- a/crates/brk_server/src/files/mod.rs +++ b/crates/brk_server/src/files/mod.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use aide::axum::ApiRouter; -use axum::routing::get; +use axum::{response::Redirect, routing::get}; use super::AppState; @@ -19,7 +19,7 @@ impl FilesRoutes for ApiRouter { self.route("/{*path}", get(file_handler)) .route("/", get(index_handler)) } else { - self + self.route("/", get(Redirect::temporary("/api"))) } } } diff --git a/crates/brk_server/src/lib.rs b/crates/brk_server/src/lib.rs index 6a424506c..5d973f15a 100644 --- a/crates/brk_server/src/lib.rs +++ b/crates/brk_server/src/lib.rs @@ -1,6 +1,6 @@ #![doc = include_str!("../README.md")] #![doc = "\n## Example\n\n```rust"] -#![doc = include_str!("../examples/main.rs")] +#![doc = include_str!("../examples/server.rs")] #![doc = "```"] use std::{ops::Deref, path::PathBuf, sync::Arc, time::Duration}; @@ -28,10 +28,12 @@ use tower_http::{compression::CompressionLayer, trace::TraceLayer}; use tracing::Span; mod api; +pub mod cache; mod extended; mod files; use api::*; +pub use cache::{CacheParams, CacheStrategy}; use extended::*; #[derive(Clone)] @@ -48,6 +50,71 @@ impl Deref for AppState { } } +impl AppState { + /// JSON response with caching + pub async fn cached_json( + &self, + headers: &axum::http::HeaderMap, + strategy: CacheStrategy, + f: F, + ) -> axum::http::Response + where + T: serde::Serialize + Send + 'static, + F: FnOnce(&brk_query::Query) -> brk_error::Result + Send + 'static, + { + let params = CacheParams::resolve(&strategy, || self.sync(|q| q.height().into())); + if params.matches_etag(headers) { + return ResponseExtended::new_not_modified(); + } + match self.run(f).await { + Ok(value) => ResponseExtended::new_json_cached(&value, ¶ms), + Err(e) => ResultExtended::::to_json_response(Err(e), params.etag_str()), + } + } + + /// Text response with caching + pub async fn cached_text( + &self, + headers: &axum::http::HeaderMap, + strategy: CacheStrategy, + f: F, + ) -> axum::http::Response + where + T: AsRef + Send + 'static, + F: FnOnce(&brk_query::Query) -> brk_error::Result + Send + 'static, + { + let params = CacheParams::resolve(&strategy, || self.sync(|q| q.height().into())); + if params.matches_etag(headers) { + return ResponseExtended::new_not_modified(); + } + match self.run(f).await { + Ok(value) => ResponseExtended::new_text_cached(value.as_ref(), ¶ms), + Err(e) => ResultExtended::::to_text_response(Err(e), params.etag_str()), + } + } + + /// Binary response with caching + pub async fn cached_bytes( + &self, + headers: &axum::http::HeaderMap, + strategy: CacheStrategy, + f: F, + ) -> axum::http::Response + where + T: Into> + Send + 'static, + F: FnOnce(&brk_query::Query) -> brk_error::Result + Send + 'static, + { + let params = CacheParams::resolve(&strategy, || self.sync(|q| q.height().into())); + if params.matches_etag(headers) { + return ResponseExtended::new_not_modified(); + } + match self.run(f).await { + Ok(value) => ResponseExtended::new_bytes_cached(value.into(), ¶ms), + Err(e) => ResultExtended::::to_bytes_response(Err(e), params.etag_str()), + } + } +} + pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub struct Server(AppState); @@ -132,21 +199,20 @@ impl Server { .layer(response_uri_layer) .layer(trace_layer); - let mut port = 3110; + const BASE_PORT: u16 = 3110; + const MAX_PORT: u16 = BASE_PORT + 100; - let mut listener; - loop { - listener = TcpListener::bind(format!("0.0.0.0:{port}")).await; - if listener.is_ok() { - break; + let mut port = BASE_PORT; + let listener = loop { + match TcpListener::bind(format!("0.0.0.0:{port}")).await { + Ok(l) => break l, + Err(_) if port < MAX_PORT => port += 1, + Err(e) => return Err(e.into()), } - port += 1; - } + }; info!("Starting server on port {port}..."); - let listener = listener.unwrap(); - let mut openapi = create_openapi(); serve( listener, diff --git a/crates/brk_types/src/addressvalidation.rs b/crates/brk_types/src/addressvalidation.rs index d5b8f5191..4aecb84f9 100644 --- a/crates/brk_types/src/addressvalidation.rs +++ b/crates/brk_types/src/addressvalidation.rs @@ -1,6 +1,9 @@ +use bitcoin::hex::DisplayHex; use schemars::JsonSchema; use serde::Serialize; +use crate::{AddressBytes, OutputType}; + /// Address validation result #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct AddressValidation { @@ -34,6 +37,7 @@ pub struct AddressValidation { } impl AddressValidation { + /// Returns an invalid validation result pub fn invalid() -> Self { Self { isvalid: false, @@ -45,4 +49,42 @@ impl AddressValidation { witness_program: None, } } + + /// Validate a Bitcoin address string and return details + pub fn from_address(address: &str) -> Self { + let Ok(script) = AddressBytes::address_to_script(address) else { + return Self::invalid(); + }; + + let output_type = OutputType::from(&script); + let script_hex = script.as_bytes().to_lower_hex_string(); + + let is_script = matches!(output_type, OutputType::P2SH); + let is_witness = matches!( + output_type, + OutputType::P2WPKH | OutputType::P2WSH | OutputType::P2TR | OutputType::P2A + ); + + let (witness_version, witness_program) = if is_witness { + let version = script.witness_version().map(|v| v.to_num()); + let program = if script.len() > 2 { + Some(script.as_bytes()[2..].to_lower_hex_string()) + } else { + None + }; + (version, program) + } else { + (None, None) + }; + + Self { + isvalid: true, + address: Some(address.to_string()), + script_pub_key: Some(script_hex), + isscript: Some(is_script), + iswitness: Some(is_witness), + witness_version, + witness_program, + } + } } diff --git a/crates/brk_types/src/blockfeesentry.rs b/crates/brk_types/src/blockfeesentry.rs index 9123bb1e9..9f2323e19 100644 --- a/crates/brk_types/src/blockfeesentry.rs +++ b/crates/brk_types/src/blockfeesentry.rs @@ -1,11 +1,13 @@ use schemars::JsonSchema; use serde::Serialize; +use crate::{Height, Sats, Timestamp}; + /// A single block fees data point. #[derive(Debug, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct BlockFeesEntry { - pub avg_height: u32, - pub timestamp: u32, - pub avg_fees: u64, + pub avg_height: Height, + pub timestamp: Timestamp, + pub avg_fees: Sats, } diff --git a/crates/brk_types/src/blockferatesentry.rs b/crates/brk_types/src/blockferatesentry.rs index e72683178..cb4f94e27 100644 --- a/crates/brk_types/src/blockferatesentry.rs +++ b/crates/brk_types/src/blockferatesentry.rs @@ -1,14 +1,16 @@ use schemars::JsonSchema; use serde::Serialize; +use crate::{Height, Timestamp}; + use super::FeeRatePercentiles; /// A single block fee rates data point with percentiles. #[derive(Debug, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct BlockFeeRatesEntry { - pub avg_height: u32, - pub timestamp: u32, + pub avg_height: Height, + pub timestamp: Timestamp, #[serde(flatten)] pub percentiles: FeeRatePercentiles, } diff --git a/crates/brk_types/src/datarange.rs b/crates/brk_types/src/datarange.rs new file mode 100644 index 000000000..d4b69ed7e --- /dev/null +++ b/crates/brk_types/src/datarange.rs @@ -0,0 +1,55 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +/// Range parameters for slicing data +#[derive(Default, Debug, Deserialize, JsonSchema)] +pub struct DataRange { + /// Inclusive starting index, if negative will be from the end + #[serde(default, alias = "f")] + from: Option, + + /// Exclusive ending index, if negative will be from the end, overrides 'count' + #[serde(default, alias = "t")] + to: Option, + + /// Number of values requested + #[serde(default, alias = "c")] + count: Option, +} + +impl DataRange { + pub fn set_from(mut self, from: i64) -> Self { + self.from.replace(from); + self + } + + pub fn set_to(mut self, to: i64) -> Self { + self.to.replace(to); + self + } + + pub fn set_count(mut self, count: usize) -> Self { + self.count.replace(count); + self + } + + pub fn from(&self) -> Option { + self.from + } + + pub fn to(&self) -> Option { + if self.to.is_none() + && let Some(c) = self.count + { + let c = c as i64; + if let Some(f) = self.from { + if f >= 0 || f.abs() > c { + return Some(f + c); + } + } else { + return Some(c); + } + } + self.to + } +} diff --git a/crates/brk_types/src/datarangeformat.rs b/crates/brk_types/src/datarangeformat.rs new file mode 100644 index 000000000..068bd0091 --- /dev/null +++ b/crates/brk_types/src/datarangeformat.rs @@ -0,0 +1,45 @@ +use std::ops::Deref; + +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{DataRange, Format}; + +/// Data range with output format for API query parameters +#[derive(Default, Debug, Deserialize, JsonSchema)] +pub struct DataRangeFormat { + #[serde(flatten)] + pub range: DataRange, + + /// Format of the output + #[serde(default)] + format: Format, +} + +impl DataRangeFormat { + pub fn format(&self) -> Format { + self.format + } + + pub fn set_from(mut self, from: i64) -> Self { + self.range = self.range.set_from(from); + self + } + + pub fn set_to(mut self, to: i64) -> Self { + self.range = self.range.set_to(to); + self + } + + pub fn set_count(mut self, count: usize) -> Self { + self.range = self.range.set_count(count); + self + } +} + +impl Deref for DataRangeFormat { + type Target = DataRange; + fn deref(&self) -> &Self::Target { + &self.range + } +} diff --git a/crates/brk_types/src/indexinfo.rs b/crates/brk_types/src/indexinfo.rs index e0a8369ea..4c202f799 100644 --- a/crates/brk_types/src/indexinfo.rs +++ b/crates/brk_types/src/indexinfo.rs @@ -3,7 +3,7 @@ use serde::Serialize; use super::Index; -#[derive(Serialize, JsonSchema)] +#[derive(Clone, Copy, Serialize, JsonSchema)] /// Information about an available index and its query aliases pub struct IndexInfo { /// The canonical index name diff --git a/crates/brk_types/src/lib.rs b/crates/brk_types/src/lib.rs index 3fb0927a2..c460f0313 100644 --- a/crates/brk_types/src/lib.rs +++ b/crates/brk_types/src/lib.rs @@ -18,20 +18,20 @@ mod blkmetadata; mod blkposition; mod block; mod blockcountpath; +mod blockfeesentry; +mod blockferatesentry; mod blockhash; mod blockhashpath; mod blockhashprefix; mod blockhashstartindexpath; mod blockhashtxindexpath; -mod blockfeesentry; -mod blockferatesentry; mod blockinfo; mod blockrewardsentry; mod blocksizeentry; mod blocksizesweights; mod blockstatus; -mod blockweightentry; mod blocktimestamp; +mod blockweightentry; mod bytes; mod cents; mod date; @@ -62,9 +62,14 @@ mod loadedaddressindex; mod mempoolblock; mod mempoolentryinfo; mod mempoolinfo; +mod datarange; +mod datarangeformat; mod metric; mod metriccount; mod metrics; +mod metricselection; +mod metricselectionlegacy; +mod metricspaginated; mod monthindex; mod ohlc; mod opreturnindex; @@ -87,6 +92,8 @@ mod p2wpkhaddressindex; mod p2wpkhbytes; mod p2wshaddressindex; mod p2wshbytes; +mod pagination; +mod paginationindex; mod pool; mod pooldetail; mod poolinfo; @@ -157,20 +164,20 @@ pub use blkmetadata::*; pub use blkposition::*; pub use block::*; pub use blockcountpath::*; +pub use blockfeesentry::*; +pub use blockferatesentry::*; pub use blockhash::*; pub use blockhashpath::*; pub use blockhashprefix::*; pub use blockhashstartindexpath::*; pub use blockhashtxindexpath::*; -pub use blockfeesentry::*; -pub use blockferatesentry::*; pub use blockinfo::*; pub use blockrewardsentry::*; pub use blocksizeentry::*; pub use blocksizesweights::*; pub use blockstatus::*; -pub use blockweightentry::*; pub use blocktimestamp::*; +pub use blockweightentry::*; pub use bytes::*; pub use cents::*; pub use date::*; @@ -201,9 +208,14 @@ pub use loadedaddressindex::*; pub use mempoolblock::*; pub use mempoolentryinfo::*; pub use mempoolinfo::*; +pub use datarange::*; +pub use datarangeformat::*; pub use metric::*; pub use metriccount::*; pub use metrics::*; +pub use metricselection::*; +pub use metricselectionlegacy::*; +pub use metricspaginated::*; pub use monthindex::*; pub use ohlc::*; pub use opreturnindex::*; @@ -226,6 +238,8 @@ pub use p2wpkhaddressindex::*; pub use p2wpkhbytes::*; pub use p2wshaddressindex::*; pub use p2wshbytes::*; +pub use pagination::*; +pub use paginationindex::*; pub use pool::*; pub use pooldetail::*; pub use poolinfo::*; diff --git a/crates/brk_types/src/metricselection.rs b/crates/brk_types/src/metricselection.rs new file mode 100644 index 000000000..53c5a781c --- /dev/null +++ b/crates/brk_types/src/metricselection.rs @@ -0,0 +1,50 @@ +use std::ops::Deref; + +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{DataRangeFormat, Index, Metric, Metrics}; + +/// Selection of metrics to query +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MetricSelection { + /// Requested metrics + #[serde(alias = "m")] + pub metrics: Metrics, + + /// Index to query + #[serde(alias = "i")] + pub index: Index, + + #[serde(flatten)] + pub range: DataRangeFormat, +} + +impl Deref for MetricSelection { + type Target = DataRangeFormat; + fn deref(&self) -> &Self::Target { + &self.range + } +} + +impl From<(Index, Metric, DataRangeFormat)> for MetricSelection { + #[inline] + fn from((index, metric, range): (Index, Metric, DataRangeFormat)) -> Self { + Self { + index, + metrics: Metrics::from(metric), + range, + } + } +} + +impl From<(Index, Metrics, DataRangeFormat)> for MetricSelection { + #[inline] + fn from((index, metrics, range): (Index, Metrics, DataRangeFormat)) -> Self { + Self { + index, + metrics, + range, + } + } +} diff --git a/crates/brk_types/src/metricselectionlegacy.rs b/crates/brk_types/src/metricselectionlegacy.rs new file mode 100644 index 000000000..b895133ab --- /dev/null +++ b/crates/brk_types/src/metricselectionlegacy.rs @@ -0,0 +1,26 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{DataRangeFormat, Index, MetricSelection, Metrics}; + +/// Legacy metric selection parameters (deprecated) +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MetricSelectionLegacy { + #[serde(alias = "i")] + pub index: Index, + #[serde(alias = "v")] + pub ids: Metrics, + #[serde(flatten)] + pub range: DataRangeFormat, +} + +impl From for MetricSelection { + #[inline] + fn from(value: MetricSelectionLegacy) -> Self { + MetricSelection { + index: value.index, + metrics: value.ids, + range: value.range, + } + } +} diff --git a/crates/brk_types/src/metricspaginated.rs b/crates/brk_types/src/metricspaginated.rs new file mode 100644 index 000000000..4836a8986 --- /dev/null +++ b/crates/brk_types/src/metricspaginated.rs @@ -0,0 +1,15 @@ +use schemars::JsonSchema; +use serde::Serialize; + +/// A paginated list of available metric names (1000 per page) +#[derive(Debug, Serialize, JsonSchema)] +pub struct PaginatedMetrics { + /// Current page number (0-indexed) + #[schemars(example = 0)] + pub current_page: usize, + /// Maximum valid page index (0-indexed) + #[schemars(example = 21)] + pub max_page: usize, + /// List of metric names (max 1000 per page) + pub metrics: &'static [&'static str], +} diff --git a/crates/brk_types/src/pagination.rs b/crates/brk_types/src/pagination.rs new file mode 100644 index 000000000..b10e32986 --- /dev/null +++ b/crates/brk_types/src/pagination.rs @@ -0,0 +1,25 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] +pub struct Pagination { + /// Pagination index + #[serde(default, alias = "p")] + pub page: Option, +} + +impl Pagination { + pub const PER_PAGE: usize = 1_000; + + pub fn page(&self) -> usize { + self.page.unwrap_or_default() + } + + pub fn start(&self, len: usize) -> usize { + (self.page() * Self::PER_PAGE).clamp(0, len) + } + + pub fn end(&self, len: usize) -> usize { + ((self.page() + 1) * Self::PER_PAGE).clamp(0, len) + } +} diff --git a/crates/brk_types/src/paginationindex.rs b/crates/brk_types/src/paginationindex.rs new file mode 100644 index 000000000..e3e67a1bd --- /dev/null +++ b/crates/brk_types/src/paginationindex.rs @@ -0,0 +1,13 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{Index, Pagination}; + +/// Pagination parameters with an index filter +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PaginationIndex { + /// The index to filter by + pub index: Index, + #[serde(flatten)] + pub pagination: Pagination, +}