diff --git a/Cargo.lock b/Cargo.lock index bc0bfdb7b..a64f78084 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1168,6 +1168,7 @@ dependencies = [ "quick_cache", "schemars 1.0.4", "serde", + "serde_json", "sonic-rs 0.5.5", "tokio", "tower-http", diff --git a/crates/brk_interface/src/index.rs b/crates/brk_interface/src/index.rs index 608092e9e..a739cc706 100644 --- a/crates/brk_interface/src/index.rs +++ b/crates/brk_interface/src/index.rs @@ -1,4 +1,7 @@ -use std::fmt::{self, Debug}; +use std::{ + collections::BTreeMap, + fmt::{self, Debug}, +}; use brk_error::Error; use brk_structs::{ @@ -9,9 +12,19 @@ use brk_structs::{ QuarterIndex, SemesterIndex, TxIndex, UnknownOutputIndex, WeekIndex, YearIndex, }; use schemars::JsonSchema; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema)] +#[derive(Default, Serialize, JsonSchema)] +/// Indexes and their accepted variants +pub struct Indexes(BTreeMap); + +impl Indexes { + pub fn new(tree: BTreeMap) -> Self { + Self(tree) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum Index { #[schemars(description = "Date/day index")] diff --git a/crates/brk_interface/src/lib.rs b/crates/brk_interface/src/lib.rs index 75c91d074..1bdcc6a63 100644 --- a/crates/brk_interface/src/lib.rs +++ b/crates/brk_interface/src/lib.rs @@ -25,10 +25,11 @@ mod params; mod vecs; pub use format::Format; -pub use index::Index; +pub use index::*; pub use output::{Output, Value}; pub use pagination::{PaginatedIndexParam, PaginationParam}; pub use params::{Params, ParamsDeprec, ParamsOpt}; +pub use vecs::PaginatedMetrics; use vecs::Vecs; use crate::vecs::{IndexToVec, MetricToVec}; @@ -236,11 +237,11 @@ impl<'a> Interface<'a> { self.vecs.total_metric_count } - pub fn get_indexes(&self) -> &BTreeMap<&'static str, &'static [&'static str]> { + pub fn get_indexes(&self) -> &Indexes { &self.vecs.indexes } - pub fn get_metrics(&self, pagination: PaginationParam) -> &[&str] { + pub fn get_metrics(&'static self, pagination: PaginationParam) -> PaginatedMetrics { self.vecs.metrics(pagination) } diff --git a/crates/brk_interface/src/pagination.rs b/crates/brk_interface/src/pagination.rs index 164259167..7aa2ecb93 100644 --- a/crates/brk_interface/src/pagination.rs +++ b/crates/brk_interface/src/pagination.rs @@ -1,9 +1,9 @@ use schemars::JsonSchema; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::{Index, deser::de_unquote_usize}; -#[derive(Debug, Default, Deserialize, JsonSchema)] +#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct PaginationParam { #[schemars(description = "Pagination index")] #[serde(default, alias = "p", deserialize_with = "de_unquote_usize")] @@ -11,7 +11,7 @@ pub struct PaginationParam { } impl PaginationParam { - const PER_PAGE: usize = 1_000; + 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) diff --git a/crates/brk_interface/src/vecs.rs b/crates/brk_interface/src/vecs.rs index b3873a1c0..582d99bf8 100644 --- a/crates/brk_interface/src/vecs.rs +++ b/crates/brk_interface/src/vecs.rs @@ -4,9 +4,14 @@ use brk_computer::Computer; use brk_indexer::Indexer; use brk_traversable::{Traversable, TreeNode}; use derive_deref::{Deref, DerefMut}; +use schemars::JsonSchema; +use serde::Serialize; use vecdb::AnyCollectableVec; -use crate::pagination::{PaginatedIndexParam, PaginationParam}; +use crate::{ + index::Indexes, + pagination::{PaginatedIndexParam, PaginationParam}, +}; use super::index::Index; @@ -15,7 +20,7 @@ pub struct Vecs<'a> { pub metric_to_index_to_vec: BTreeMap<&'a str, IndexToVec<'a>>, pub index_to_metric_to_vec: BTreeMap>, pub metrics: Vec<&'a str>, - pub indexes: BTreeMap<&'static str, &'static [&'static str]>, + pub indexes: Indexes, pub distinct_metric_count: usize, pub total_metric_count: usize, pub catalog: Option, @@ -62,11 +67,12 @@ impl<'a> Vecs<'a> { .values() .map(|tree| tree.len()) .sum::(); - this.indexes = this - .index_to_metric_to_vec - .keys() - .map(|i| (i.serialize_long(), i.possible_values())) - .collect::>(); + this.indexes = Indexes::new( + this.index_to_metric_to_vec + .keys() + .map(|i| (*i, i.possible_values())) + .collect::>(), + ); this.metric_to_indexes = this .metric_to_index_to_vec .iter() @@ -129,11 +135,16 @@ impl<'a> Vecs<'a> { } } - pub fn metrics(&self, pagination: PaginationParam) -> &[&'_ str] { + pub fn metrics(&'static self, pagination: PaginationParam) -> PaginatedMetrics { let len = self.metrics.len(); let start = pagination.start(len); let end = pagination.end(len); - &self.metrics[start..end] + + PaginatedMetrics { + current_page: pagination.page.unwrap_or_default(), + total_pages: len / PaginationParam::PER_PAGE, + metrics: &self.metrics[start..end], + } } pub fn metric_to_indexes(&self, metric: String) -> Option<&Vec<&'static str>> { @@ -160,3 +171,17 @@ pub struct IndexToVec<'a>(BTreeMap); #[derive(Default, Deref, DerefMut)] pub struct MetricToVec<'a>(BTreeMap<&'a str, &'a dyn AnyCollectableVec>); + +/// 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)] + current_page: usize, + /// Total number of pages available + #[schemars(example = 21000)] + total_pages: usize, + /// List of metric names (max 1000 per page) + #[schemars(example = ["price_open", "price_close", "realized_price", "..."])] + metrics: &'static [&'static str], +} diff --git a/crates/brk_server/Cargo.toml b/crates/brk_server/Cargo.toml index 6545cd2a3..2ba8c3a2f 100644 --- a/crates/brk_server/Cargo.toml +++ b/crates/brk_server/Cargo.toml @@ -10,7 +10,7 @@ rust-version.workspace = true build = "build.rs" [dependencies] -aide = { workspace = true } +aide = { workspace = true , features = ["axum-json", "axum-query"] } axum = { workspace = true } bitcoin = { workspace = true } bitcoincore-rpc = { workspace = true } @@ -29,7 +29,7 @@ log = { workspace = true } quick_cache = { workspace = true } schemars = { workspace = true } serde = { workspace = true } -# serde_json = { workspace = true } +serde_json = { workspace = true } sonic-rs = { workspace = true } tokio = { workspace = true } tower-http = { version = "0.6.6", features = ["compression-full", "trace"] } diff --git a/crates/brk_server/src/api/chain/addresses.rs b/crates/brk_server/src/api/chain/addresses.rs new file mode 100644 index 000000000..8b1c8ea86 --- /dev/null +++ b/crates/brk_server/src/api/chain/addresses.rs @@ -0,0 +1,229 @@ +use std::str::FromStr; + +use aide::{ + axum::{ApiRouter, routing::get_with}, + transform::TransformOperation, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use bitcoin::{Address as BitcoinAddress, Network, PublicKey, ScriptBuf}; +use brk_structs::{ + AddressBytes, AddressBytesHash, AnyAddressDataIndexEnum, Bitcoin, Dollars, OutputType, Sats, + TypeIndex, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use vecdb::{AnyIterableVec, VecIterator}; + +use super::AppState; + +#[derive(Debug, Serialize, JsonSchema)] +/// Address information +struct AddressInfo { + /// Bitcoin address string + #[schemars(example = &"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f")] + address: String, + + #[schemars(example = OutputType::P2PK65)] + r#type: OutputType, + + #[schemars(example = TypeIndex::new(0))] + type_index: TypeIndex, + + /// Total satoshis ever sent from this address + #[schemars(example = Sats::new(0))] + total_sent: Sats, + + /// Total satoshis ever received by this address + #[schemars(example = Sats::new(5001008380))] + total_received: Sats, + + /// Number of unspent transaction outputs (UTXOs) + #[schemars(example = 10)] + utxo_count: u32, + + /// Current spendable balance in satoshis (total_received - total_sent) + #[schemars(example = Sats::new(5001008380))] + balance: Sats, + + /// Current balance value in USD at current market price + #[schemars(example = Some(Dollars::mint(6_157_891.64)))] + balance_usd: Option, + + /// Estimated total USD value at time of deposit for coins currently in this address (not including coins that were later sent out). Not suitable for tax calculations + #[schemars(example = Some(Dollars::mint(6.2)))] + estimated_total_invested: Option, + + /// Estimated average BTC price at time of deposit for coins currently in this address (USD). Not suitable for tax calculations + #[schemars(example = Some(Dollars::mint(0.12)))] + estimated_avg_entry_price: Option, + // + // Transaction count? + // First/last activity timestamps? + // Realized/unrealized gains? + // Current value (balance × current price)? + // "address": address, + // "type": output_type, + // "index": addri, + // "chain_stats": { + // "funded_txo_count": null, + // "funded_txo_sum": addr_data.received, + // "spent_txo_count": null, + // "spent_txo_sum": addr_data.sent, + // "utxo_count": addr_data.utxos, + // "balance": amount, + // "balance_usd": price.map_or(Value::new(), |p| { + // Value::from(Number::from_f64(*(p * Bitcoin::from(amount))).unwrap()) + // }), + // "realized_value": addr_data.realized_cap, + // "tx_count": null, + // "avg_cost_basis": addr_data.realized_price() + // }, + // "mempool_stats": null +} + +#[derive(Deserialize, JsonSchema)] +struct AddressPath { + /// Bitcoin address string + address: String, +} + +async fn get_address_info( + Path(AddressPath { address }): Path, + state: State, +) -> Result, StatusCode> { + let interface = state.interface; + let indexer = interface.indexer(); + let computer = interface.computer(); + let stores = &indexer.stores; + + let script = if let Ok(address) = BitcoinAddress::from_str(&address) { + if !address.is_valid_for_network(Network::Bitcoin) { + return Err(StatusCode::BAD_REQUEST); + } + let address = address.assume_checked(); + address.script_pubkey() + } else if let Ok(pubkey) = PublicKey::from_str(&address) { + ScriptBuf::new_p2pk(&pubkey) + } else { + return Err(StatusCode::BAD_REQUEST); + }; + + let type_ = OutputType::from(&script); + let Ok(bytes) = AddressBytes::try_from((&script, type_)) else { + return Err(StatusCode::BAD_REQUEST); + }; + let hash = AddressBytesHash::from((&bytes, type_)); + + let Ok(Some(type_index)) = stores + .addressbyteshash_to_typeindex + .get(&hash) + .map(|opt| opt.map(|cow| cow.into_owned())) + else { + return Err(StatusCode::NOT_FOUND); + }; + + let stateful = &computer.stateful; + let price = computer.price.as_ref().map(|v| { + *v.timeindexes_to_price_close + .dateindex + .as_ref() + .unwrap() + .iter() + .last() + .unwrap() + .1 + .into_owned() + }); + + let any_address_index = match type_ { + OutputType::P2PK33 => stateful + .p2pk33addressindex_to_anyaddressindex + .iter() + .unwrap_get_inner(type_index.into()), + OutputType::P2PK65 => stateful + .p2pk65addressindex_to_anyaddressindex + .iter() + .unwrap_get_inner(type_index.into()), + OutputType::P2PKH => stateful + .p2pkhaddressindex_to_anyaddressindex + .iter() + .unwrap_get_inner(type_index.into()), + OutputType::P2SH => stateful + .p2shaddressindex_to_anyaddressindex + .iter() + .unwrap_get_inner(type_index.into()), + OutputType::P2TR => stateful + .p2traddressindex_to_anyaddressindex + .iter() + .unwrap_get_inner(type_index.into()), + OutputType::P2WPKH => stateful + .p2wpkhaddressindex_to_anyaddressindex + .iter() + .unwrap_get_inner(type_index.into()), + OutputType::P2WSH => stateful + .p2wshaddressindex_to_anyaddressindex + .iter() + .unwrap_get_inner(type_index.into()), + OutputType::P2A => stateful + .p2aaddressindex_to_anyaddressindex + .iter() + .unwrap_get_inner(type_index.into()), + _ => return Err(StatusCode::BAD_REQUEST), + }; + + let address_data = match any_address_index.to_enum() { + AnyAddressDataIndexEnum::Loaded(index) => stateful + .loadedaddressindex_to_loadedaddressdata + .iter() + .unwrap_get_inner(index), + AnyAddressDataIndexEnum::Empty(index) => stateful + .emptyaddressindex_to_emptyaddressdata + .iter() + .unwrap_get_inner(index) + .into(), + }; + + let balance = address_data.balance(); + + Ok(Json(AddressInfo { + address: address.to_string(), + r#type: type_, + type_index, + utxo_count: address_data.utxo_count, + total_sent: address_data.sent, + total_received: address_data.received, + balance, + balance_usd: price.map(|p| p * Bitcoin::from(balance)), + estimated_total_invested: price.map(|_| address_data.realized_cap), + estimated_avg_entry_price: price.map(|_| address_data.realized_price()), + })) +} + +fn get_address_info_docs(op: TransformOperation) -> TransformOperation { + op.tag("Chain") + .summary("Address information") + .description("Retrieve comprehensive information about a Bitcoin address including balance, transaction history, UTXOs, and estimated investment metrics. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR, etc.).") + .response_with::<400, (), _>(|res| + res.description("Invalid address format or unsupported address type") + ) + .response_with::<404, (), _>(|res| + res.description("Address not found in the blockchain (no transaction history)") + ) +} + +pub trait AddressesRoutes { + fn add_addresses_routes(self) -> Self; +} + +impl AddressesRoutes for ApiRouter { + fn add_addresses_routes(self) -> Self { + self.api_route( + "/api/chain/address/{address}", + get_with(get_address_info, get_address_info_docs), + ) + } +} diff --git a/crates/brk_server/src/api/chain/block.rs b/crates/brk_server/src/api/chain/block.rs new file mode 100644 index 000000000..e69de29bb diff --git a/crates/brk_server/src/api/chain/mod.rs b/crates/brk_server/src/api/chain/mod.rs index 7e883a217..b47ee82e0 100644 --- a/crates/brk_server/src/api/chain/mod.rs +++ b/crates/brk_server/src/api/chain/mod.rs @@ -1,363 +1,18 @@ -use std::{ - fs::File, - io::{Cursor, Read, Seek, SeekFrom}, - str::FromStr, -}; +use aide::axum::ApiRouter; -use aide::{ - axum::{ApiRouter, IntoApiResponse, routing::get_with}, - transform::TransformOperation, -}; -use axum::{ - Json, - extract::{Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::get, -}; -use bitcoin::{ - Address as BitcoinAddress, Network, Transaction as BitcoinTransaction, consensus::Decodable, -}; -use brk_parser::XORIndex; -use brk_structs::{ - AddressBytesHash, AnyAddressDataIndexEnum, Bitcoin, Dollars, OutputType, Sats, TxIndex, Txid, - TxidPrefix, TypeIndex, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use vecdb::{AnyIterableVec, VecIterator}; +use crate::api::chain::{addresses::AddressesRoutes, transactions::TransactionsRoutes}; use super::AppState; -pub trait ApiExplorerRoutes { - fn add_api_explorer_routes(self) -> Self; +mod addresses; +mod transactions; + +pub trait ChainRoutes { + fn add_chain_routes(self) -> Self; } -#[derive(Debug, Serialize, JsonSchema)] -struct AddressDetails { - /// Bitcoin address string - address: String, - - r#type: OutputType, - - type_index: TypeIndex, - - /// Total satoshis ever sent from this address - total_sent: Sats, - - /// Total satoshis ever received by this address - total_received: Sats, - - /// Number of unspent transaction outputs (UTXOs) - utxo_count: u32, - - /// Current spendable balance in satoshis (total_received - total_sent) - balance: Sats, - - /// Current balance value in USD at current market price - balance_usd: Option, - - /// Estimated total USD value at time of deposit for coins currently in this address (not including coins that were later sent out). Not suitable for tax calculations - estimated_total_invested: Option, - - /// Estimated average BTC price at time of deposit for coins currently in this address (USD). Not suitable for tax calculations - estimated_avg_entry_price: Option, - // - // Transaction count? - // First/last activity timestamps? - // Realized/unrealized gains? - // Current value (balance × current price)? - // "address": address, - // "type": output_type, - // "index": addri, - // "chain_stats": { - // "funded_txo_count": null, - // "funded_txo_sum": addr_data.received, - // "spent_txo_count": null, - // "spent_txo_sum": addr_data.sent, - // "utxo_count": addr_data.utxos, - // "balance": amount, - // "balance_usd": price.map_or(Value::new(), |p| { - // Value::from(Number::from_f64(*(p * Bitcoin::from(amount))).unwrap()) - // }), - // "realized_value": addr_data.realized_cap, - // "tx_count": null, - // "avg_cost_basis": addr_data.realized_price() - // }, - // "mempool_stats": null -} - -#[derive(Serialize)] -struct TxResponse { - txid: Txid, - index: TxIndex, - tx: BitcoinTransaction, -} - -#[derive(Deserialize, JsonSchema)] -struct AddressPath { - /// Bitcoin address string - address: String, -} - -#[derive(Deserialize, JsonSchema)] -struct TypeIndexPath { - /// Index of type - index: TypeIndex, -} - -async fn get_address_details_from_address( - Path(AddressPath { address }): Path, - state: State, -) -> impl IntoApiResponse { - let Ok(address) = BitcoinAddress::from_str(&address) else { - return StatusCode::BAD_REQUEST.into_response(); - }; - if !address.is_valid_for_network(Network::Bitcoin) { - return StatusCode::BAD_REQUEST.into_response(); - } - let address = address.assume_checked(); - let interface = state.interface; - let indexer = interface.indexer(); - let computer = interface.computer(); - let stores = &indexer.stores; - let hash = AddressBytesHash::from(&address); - - let r#type = OutputType::from(&address); - - let Ok(Some(type_index)) = stores - .addressbyteshash_to_typeindex - .get(&hash) - .map(|opt| opt.map(|cow| cow.into_owned())) - else { - return StatusCode::NOT_FOUND.into_response(); - }; - - let stateful = &computer.stateful; - let price = computer.price.as_ref().map(|v| { - *v.timeindexes_to_price_close - .dateindex - .as_ref() - .unwrap() - .iter() - .last() - .unwrap() - .1 - .into_owned() - }); - - let any_address_index = match r#type { - OutputType::P2PK33 => stateful - .p2pk33addressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(type_index.into()), - OutputType::P2PK65 => stateful - .p2pk65addressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(type_index.into()), - OutputType::P2PKH => stateful - .p2pkhaddressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(type_index.into()), - OutputType::P2SH => stateful - .p2shaddressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(type_index.into()), - OutputType::P2TR => stateful - .p2traddressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(type_index.into()), - OutputType::P2WPKH => stateful - .p2wpkhaddressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(type_index.into()), - OutputType::P2WSH => stateful - .p2wshaddressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(type_index.into()), - OutputType::P2A => stateful - .p2aaddressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(type_index.into()), - _ => unreachable!(), - }; - - let address_data = match any_address_index.to_enum() { - AnyAddressDataIndexEnum::Loaded(index) => stateful - .loadedaddressindex_to_loadedaddressdata - .iter() - .unwrap_get_inner(index), - AnyAddressDataIndexEnum::Empty(index) => stateful - .emptyaddressindex_to_emptyaddressdata - .iter() - .unwrap_get_inner(index) - .into(), - }; - - let balance = address_data.balance(); - - Json(AddressDetails { - address: address.to_string(), - r#type, - type_index, - utxo_count: address_data.utxo_count, - total_sent: address_data.sent, - total_received: address_data.received, - balance, - balance_usd: price.map(|p| p * Bitcoin::from(balance)), - estimated_total_invested: price.map(|_| address_data.realized_cap), - estimated_avg_entry_price: price.map(|_| address_data.realized_price()), - }) - .into_response() -} - -fn get_address_docs(op: TransformOperation) -> TransformOperation { - op.tag("Chain") - .summary("Get address information") - .description("Get Bitcoin address details") - .response_with::<200, Json, _>(|res| { - res.example(AddressDetails { - address: "bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38" - .to_string(), - r#type: OutputType::P2WSH, - type_index: TypeIndex::new(26158889), - total_sent: Sats::new(5498948012620), - total_received: Sats::new(5557954331207), - utxo_count: 195, - balance: Sats::new(59006318587), - balance_usd: Some(Dollars::mint(73757839.22)), - estimated_total_invested: Some(Dollars::mint(71943052.66)), - estimated_avg_entry_price: Some(Dollars::mint(121900.0)), - }) - }) - .response_with::<400, (), _>(|res| res.description("The address provided was invalid")) - .response_with::<404, (), _>(|res| res.description("The address provided was not found")) -} - -impl ApiExplorerRoutes for ApiRouter { - fn add_api_explorer_routes(self) -> Self { - self.api_route( - "/api/chain/address/{address}", - get_with(get_address_details_from_address, get_address_docs), - ) - .api_route( - "/api/chain/address/p2pk65/{index}", - get_with(get_address_details_from_address, get_address_docs), - ) - .api_route( - "/api/chain/address/p2pk33/{index}", - get_with(get_address_details_from_address, get_address_docs), - ) - .api_route( - "/api/chain/address/p2pkh/{index}", - get_with(get_address_details_from_address, get_address_docs), - ) - .api_route( - "/api/chain/address/p2sh/{index}", - get_with(get_address_details_from_address, get_address_docs), - ) - .api_route( - "/api/chain/address/p2wpkh/{index}", - get_with(get_address_details_from_address, get_address_docs), - ) - .api_route( - "/api/chain/address/p2wsh/{index}", - get_with(get_address_details_from_address, get_address_docs), - ) - .api_route( - "/api/chain/address/p2tr/{index}", - get_with(get_address_details_from_address, get_address_docs), - ) - .api_route( - "/api/chain/address/p2a/{index}", - get_with(get_address_details_from_address, get_address_docs), - ) - .route( - "/api/chain/tx/{txid}", - get( - async |Path(txid): Path, state: State| -> Response { - let Ok(txid) = bitcoin::Txid::from_str(&txid) else { - return "Invalid txid".into_response(); - }; - - let txid = Txid::from(txid); - let prefix = TxidPrefix::from(&txid); - let interface = state.interface; - let indexer = interface.indexer(); - let Ok(Some(txindex)) = indexer - .stores - .txidprefix_to_txindex - .get(&prefix) - .map(|opt| opt.map(|cow| cow.into_owned())) - else { - return "Unknown transaction".into_response(); - }; - - let txid = indexer - .vecs - .txindex_to_txid - .iter() - .unwrap_get_inner(txindex); - - let parser = interface.parser(); - let computer = interface.computer(); - - let position = computer - .blks - .txindex_to_position - .iter() - .unwrap_get_inner(txindex); - let len = indexer - .vecs - .txindex_to_total_size - .iter() - .unwrap_get_inner(txindex); - - let blk_index_to_blk_path = parser.blk_index_to_blk_path(); - - let Some(blk_path) = blk_index_to_blk_path.get(&position.blk_index()) else { - return "Unknown blk index".into_response(); - }; - - let mut xori = XORIndex::default(); - xori.add_assign(position.offset() as usize); - - let Ok(mut file) = File::open(blk_path) else { - return "Error opening blk file".into_response(); - }; - - if file - .seek(SeekFrom::Start(position.offset() as u64)) - .is_err() - { - return "Error seeking position in blk file".into_response(); - } - - let mut buffer = vec![0u8; *len as usize]; - if file.read_exact(&mut buffer).is_err() { - return "File fail read exact".into_response(); - } - xori.bytes(&mut buffer, parser.xor_bytes()); - - let mut reader = Cursor::new(buffer); - let Ok(tx) = BitcoinTransaction::consensus_decode(&mut reader) else { - return "Error decoding transaction".into_response(); - }; - - let response = TxResponse { - txid, - index: txindex, - tx, - }; - - let bytes = sonic_rs::to_vec(&response).unwrap(); - - Response::builder() - .header("content-type", "application/json") - .body(bytes.into()) - .unwrap() - }, - ), - ) +impl ChainRoutes for ApiRouter { + fn add_chain_routes(self) -> Self { + self.add_addresses_routes().add_transactions_routes() } } diff --git a/crates/brk_server/src/api/chain/transactions.rs b/crates/brk_server/src/api/chain/transactions.rs new file mode 100644 index 000000000..40f7f6416 --- /dev/null +++ b/crates/brk_server/src/api/chain/transactions.rs @@ -0,0 +1,158 @@ +use std::{ + fs::File, + io::{Cursor, Read, Seek, SeekFrom}, + str::FromStr, +}; + +use aide::{ + axum::{ApiRouter, routing::get_with}, + transform::TransformOperation, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::Response, +}; +use bitcoin::{Transaction as BitcoinTransaction, consensus::Decodable}; +use brk_parser::XORIndex; +use brk_structs::{TxIndex, Txid, TxidPrefix}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use vecdb::VecIterator; + +use super::AppState; + +#[derive(Serialize, JsonSchema)] +/// Transaction Information +struct TransactionInfo { + #[schemars( + with = "String", + example = "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b" + )] + txid: Txid, + #[schemars(example = TxIndex::new(0))] + index: TxIndex, + #[serde(flatten)] + #[schemars(with = "serde_json::Value")] + tx: BitcoinTransaction, +} + +#[derive(Deserialize, JsonSchema)] +struct TxidPath { + /// Bitcoin transaction id + txid: String, +} + +async fn get_transaction_info( + Path(TxidPath { txid }): Path, + state: State, +) -> Result { + let Ok(txid) = bitcoin::Txid::from_str(&txid) else { + return Err(StatusCode::BAD_REQUEST); + }; + + let txid = Txid::from(txid); + let prefix = TxidPrefix::from(&txid); + let interface = state.interface; + let indexer = interface.indexer(); + let Ok(Some(index)) = indexer + .stores + .txidprefix_to_txindex + .get(&prefix) + .map(|opt| opt.map(|cow| cow.into_owned())) + else { + return Err(StatusCode::NOT_FOUND); + }; + + let txid = indexer.vecs.txindex_to_txid.iter().unwrap_get_inner(index); + + let parser = interface.parser(); + let computer = interface.computer(); + + let position = computer + .blks + .txindex_to_position + .iter() + .unwrap_get_inner(index); + let len = indexer + .vecs + .txindex_to_total_size + .iter() + .unwrap_get_inner(index); + + let blk_index_to_blk_path = parser.blk_index_to_blk_path(); + + let Some(blk_path) = blk_index_to_blk_path.get(&position.blk_index()) else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + let mut xori = XORIndex::default(); + xori.add_assign(position.offset() as usize); + + let Ok(mut file) = File::open(blk_path) else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + if file + .seek(SeekFrom::Start(position.offset() as u64)) + .is_err() + { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + let mut buffer = vec![0u8; *len as usize]; + if file.read_exact(&mut buffer).is_err() { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + xori.bytes(&mut buffer, parser.xor_bytes()); + + let mut reader = Cursor::new(buffer); + let Ok(tx) = BitcoinTransaction::consensus_decode(&mut reader) else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + let tx_info = TransactionInfo { txid, index, tx }; + + let bytes = sonic_rs::to_vec(&tx_info).unwrap(); + + Ok(Response::builder() + .header("content-type", "application/json") + .body(bytes.into()) + .unwrap()) +} + +fn get_transaction_info_docs(op: TransformOperation) -> TransformOperation { + op.tag("Chain") + .summary("Transaction information") + .description( + "Retrieve complete transaction data by transaction ID (txid). Returns the full transaction details including inputs, outputs, and metadata. The transaction data is read directly from the blockchain data files.", + ) + .response_with::<200, Json, _>(|res| res) + .response_with::<400, (), _>(|res| { + res.description( + "Invalid transaction ID format (must be a valid 64-character hex string)", + ) + }) + .response_with::<404, (), _>(|res| { + res.description("Transaction not found in the blockchain") + }) + .response_with::<500, (), _>(|res| { + res.description( + "Internal server error while reading transaction data from blockchain files", + ) + }) +} + +pub trait TransactionsRoutes { + fn add_transactions_routes(self) -> Self; +} + +impl TransactionsRoutes for ApiRouter { + fn add_transactions_routes(self) -> Self { + self.api_route( + "/api/chain/tx/{txid}", + get_with(get_transaction_info, get_transaction_info_docs), + ) + } +} diff --git a/crates/brk_server/src/api/metrics/mod.rs b/crates/brk_server/src/api/metrics/mod.rs index 97e2a4d3c..d49971bd6 100644 --- a/crates/brk_server/src/api/metrics/mod.rs +++ b/crates/brk_server/src/api/metrics/mod.rs @@ -1,4 +1,4 @@ -use aide::axum::ApiRouter; +use aide::axum::{ApiRouter, routing::get_with}; use axum::{ Json, extract::{Path, Query, State}, @@ -6,7 +6,11 @@ use axum::{ response::{IntoResponse, Response}, routing::get, }; -use brk_interface::{Index, PaginationParam, Params, ParamsDeprec, ParamsOpt}; +use brk_interface::{ + Index, Indexes, PaginatedMetrics, PaginationParam, Params, ParamsDeprec, ParamsOpt, +}; +use schemars::JsonSchema; +use serde::Serialize; use crate::{ VERSION, @@ -23,23 +27,62 @@ pub trait ApiMetricsRoutes { const TO_SEPARATOR: &str = "_to_"; +#[derive(Debug, Serialize, JsonSchema)] +/// Metric count statistics - distinct metrics and total metric-index combinations +struct MetricCount { + #[schemars(example = 3141)] + /// Number of unique metrics available (e.g., realized_price, market_cap) + distinct_metrics: usize, + #[schemars(example = 21000)] + /// Total number of metric-index combinations across all timeframes + total_endpoints: usize, +} + impl ApiMetricsRoutes for ApiRouter { fn add_api_metrics_routes(self) -> Self { - self.route( + self.api_route( "/api/metrics/count", - get(async |State(app_state): State| -> Response { - Json(sonic_rs::json!({ - "distinct": app_state.interface.distinct_metric_count(), - "total": app_state.interface.total_metric_count(), - })) - .into_response() - }), + get_with( + async |State(app_state): State| -> Json { + Json(MetricCount { + distinct_metrics: app_state.interface.distinct_metric_count(), + total_endpoints: app_state.interface.total_metric_count(), + }) + }, + |op| { + op.tag("Metrics") + .summary("Metric count") + .description("Current metric count") + }, + ), ) - .route( + .api_route( "/api/metrics/indexes", - get(async |State(app_state): State| -> Response { - Json(app_state.interface.get_indexes()).into_response() - }), + get_with( + async |State(app_state): State| -> Json<&Indexes> { + Json(app_state.interface.get_indexes()) + }, + |op| { + op.tag("Metrics") + .summary("Metric indexes") + .description("Available metric indexes and their accepted variants") + }, + ), + ) + .api_route( + "/api/metrics/list", + get_with( + async |State(app_state): State, + Query(pagination): Query| + -> Json { + Json(app_state.interface.get_metrics(pagination)) + }, + |op| { + op.tag("Metrics") + .summary("Metrics list") + .description("Paginated list of available metrics") + }, + ), ) .route( "/api/metrics/catalog", @@ -65,16 +108,6 @@ impl ApiMetricsRoutes for ApiRouter { }, ), ) - .route( - "/api/metrics/list", - get( - async |State(app_state): State, - Query(pagination): Query| - -> Response { - Json(app_state.interface.get_metrics(pagination)).into_response() - }, - ), - ) // TODO: // .route( // "/api/metrics/search", diff --git a/crates/brk_server/src/api/mod.rs b/crates/brk_server/src/api/mod.rs index 8f6ae6424..70ae0e2e3 100644 --- a/crates/brk_server/src/api/mod.rs +++ b/crates/brk_server/src/api/mod.rs @@ -1,7 +1,17 @@ -use aide::axum::ApiRouter; -use axum::{response::Html, routing::get}; +use std::sync::Arc; -use crate::api::{chain::ApiExplorerRoutes, metrics::ApiMetricsRoutes}; +use aide::{ + axum::{ApiRouter, routing::get_with}, + openapi::{Info, OpenApi, Tag}, +}; +use axum::{Extension, Json, response::Html, routing::get}; +use schemars::JsonSchema; +use serde::Serialize; + +use crate::{ + VERSION, + api::{chain::ChainRoutes, metrics::ApiMetricsRoutes}, +}; use super::AppState; @@ -12,10 +22,99 @@ pub trait ApiRoutes { fn add_api_routes(self) -> Self; } +#[derive(Debug, Serialize, JsonSchema)] +/// Server health status +struct Health { + status: String, + service: String, + timestamp: String, +} + impl ApiRoutes for ApiRouter { fn add_api_routes(self) -> Self { - self.add_api_explorer_routes() + self.add_chain_routes() .add_api_metrics_routes() + .api_route( + "/version", + get_with( + async || -> Json<&'static str> { Json(VERSION) }, + |op| { + op.tag("Server") + .summary("API version") + .description("Returns the current version of the API server") + }, + ), + ) + .api_route( + "/health", + get_with( + async || -> Json { + Json(Health { + status: "healthy".to_string(), + service: "brk-server".to_string(), + timestamp: jiff::Timestamp::now().to_string(), + }) + }, + |op| { + op.tag("Server") + .summary("Health check") + .description("Returns the health status of the API server") + }, + ), + ) + .route( + "/api.json", + get( + async |Extension(api): Extension>| -> Json> { + Json(api) + }, + ), + ) .route("/api", get(Html::from(include_str!("./scalar.html")))) } } + +pub fn create_openapi() -> OpenApi { + let tags = vec![ + Tag { + name: "Chain".to_string(), + description: Some( + "Explore Bitcoin blockchain data: addresses, transactions, blocks, balances, and UTXOs." + .to_string() + ), + ..Default::default() + }, + 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." + .to_string() + ), + ..Default::default() + }, + Tag { + name: "Server".to_string(), + description: Some( + "Metadata and utility endpoints for API status, health checks, and system information." + .to_string() + ), + ..Default::default() + }, + ]; + + OpenApi { + info: Info { + title: "Bitcoin Research Kit API".to_string(), + description: Some( + "API for querying Bitcoin blockchain data including addresses, transactions, and chain statistics. This API provides low-level access to indexed blockchain data with advanced analytics capabilities.\n\n\ + ⚠️ **Early Development**: This API is in very early stages of development. Breaking changes may occur without notice. For a more stable experience, self-host or use the hosting service." + .to_string(), + ), + version: format!("v{VERSION}"), + ..Info::default() + }, + tags, + ..OpenApi::default() + } +} diff --git a/crates/brk_server/src/api/scalar.html b/crates/brk_server/src/api/scalar.html index c0537c123..ae66caf68 100644 --- a/crates/brk_server/src/api/scalar.html +++ b/crates/brk_server/src/api/scalar.html @@ -1,7 +1,11 @@ - Scalar API Reference + BRK API + @@ -15,11 +19,9 @@ diff --git a/crates/brk_server/src/lib.rs b/crates/brk_server/src/lib.rs index 93a278d74..12432e220 100644 --- a/crates/brk_server/src/lib.rs +++ b/crates/brk_server/src/lib.rs @@ -5,13 +5,10 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; -use aide::{ - axum::{ApiRouter, IntoApiResponse}, - openapi::{Info, OpenApi}, -}; +use aide::axum::ApiRouter; use api::ApiRoutes; use axum::{ - Extension, Json, + Extension, body::{Body, Bytes}, http::{Request, Response, StatusCode, Uri}, middleware::Next, @@ -36,6 +33,8 @@ mod files; use extended::*; +use crate::api::create_openapi; + #[derive(Clone)] pub struct AppState { interface: &'static Interface<'static>, @@ -102,15 +101,6 @@ impl Server { .add_api_routes() .add_files_routes(state.path.as_ref()) .add_mcp_routes(state.interface, mcp) - .api_route("/version", aide::axum::routing::get(version)) - .route( - "/health", - get(Json(sonic_rs::json!({ - "status": "healthy", - "service": "brk-server", - "timestamp": jiff::Timestamp::now().to_string() - }))), - ) .route( "/discord", get(Redirect::temporary("https://discord.gg/WACpShCB7M")), @@ -130,7 +120,6 @@ impl Server { get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk?tab=readme-ov-file#hosting-as-a-service")), ) .route("/nostr", get(Redirect::temporary("https://primal.net/p/npub1jagmm3x39lmwfnrtvxcs9ac7g300y3dusv9lgzhk2e4x5frpxlrqa73v44"))) - .route("/api.json", get(serve_api)) .with_state(state) .layer(compression_layer) .layer(response_uri_layer) @@ -147,24 +136,16 @@ impl Server { port += 1; } - let mut api = OpenApi { - info: Info { - title: "Bitcoin Research Kit API".to_string(), - description: Some("A documentation for using BRK's API".to_string()), - ..Info::default() - }, - ..OpenApi::default() - }; - info!("Starting server on port {port}..."); let listener = listener.unwrap(); + let mut openapi = create_openapi(); serve( listener, router - .finish_api(&mut api) - .layer(Extension(Arc::new(api))) + .finish_api(&mut openapi) + .layer(Extension(Arc::new(openapi))) .into_make_service(), ) .await?; @@ -172,11 +153,3 @@ impl Server { Ok(()) } } - -async fn serve_api(Extension(api): Extension>) -> impl IntoApiResponse { - Json(api) -} - -async fn version() -> impl IntoApiResponse { - Json(VERSION) -} diff --git a/crates/brk_structs/src/structs/addressbytes.rs b/crates/brk_structs/src/structs/addressbytes.rs index 153018fe3..1247eda26 100644 --- a/crates/brk_structs/src/structs/addressbytes.rs +++ b/crates/brk_structs/src/structs/addressbytes.rs @@ -55,12 +55,6 @@ impl AddressBytes { } } -impl From<&Address> for AddressBytes { - fn from(value: &Address) -> Self { - Self::try_from((&value.script_pubkey(), OutputType::from(value))).unwrap() - } -} - impl TryFrom<(&ScriptBuf, OutputType)> for AddressBytes { type Error = Error; fn try_from(tuple: (&ScriptBuf, OutputType)) -> Result { diff --git a/crates/brk_structs/src/structs/addressbyteshash.rs b/crates/brk_structs/src/structs/addressbyteshash.rs index 8ce488d5d..c6b453fb1 100644 --- a/crates/brk_structs/src/structs/addressbyteshash.rs +++ b/crates/brk_structs/src/structs/addressbyteshash.rs @@ -1,4 +1,3 @@ -use bitcoin::Address; use byteview::ByteView; use derive_deref::Deref; use zerocopy::{FromBytes, IntoBytes}; @@ -22,12 +21,6 @@ use super::{AddressBytes, OutputType}; )] pub struct AddressBytesHash([u8; 8]); -impl From<&Address> for AddressBytesHash { - fn from(value: &Address) -> Self { - Self::from((&AddressBytes::from(value), OutputType::from(value))) - } -} - impl From<(&AddressBytes, OutputType)> for AddressBytesHash { fn from((address_bytes, outputtype): (&AddressBytes, OutputType)) -> Self { let mut slice = rapidhash::v3::rapidhash_v3(address_bytes.as_slice()).to_le_bytes(); diff --git a/crates/brk_structs/src/structs/outputtype.rs b/crates/brk_structs/src/structs/outputtype.rs index 244f60461..8ac7b5dac 100644 --- a/crates/brk_structs/src/structs/outputtype.rs +++ b/crates/brk_structs/src/structs/outputtype.rs @@ -1,7 +1,7 @@ -use bitcoin::{Address, AddressType, ScriptBuf, opcodes::all::OP_PUSHBYTES_2}; +use bitcoin::{AddressType, ScriptBuf, opcodes::all::OP_PUSHBYTES_2}; use brk_error::Error; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use strum::Display; use zerocopy_derive::{FromBytes, Immutable, IntoBytes, KnownLayout}; @@ -632,12 +632,6 @@ impl From<&ScriptBuf> for OutputType { } } -impl From<&Address> for OutputType { - fn from(value: &Address) -> Self { - Self::from(&value.script_pubkey()) - } -} - impl From for OutputType { fn from(value: AddressType) -> Self { match value { diff --git a/crates/brk_structs/src/structs/txid.rs b/crates/brk_structs/src/structs/txid.rs index 70bf22df9..61026203b 100644 --- a/crates/brk_structs/src/structs/txid.rs +++ b/crates/brk_structs/src/structs/txid.rs @@ -2,10 +2,13 @@ use std::{fmt, mem}; use bitcoin::hashes::Hash; use derive_deref::Deref; +use schemars::JsonSchema; use serde::{Serialize, Serializer}; use zerocopy_derive::{FromBytes, Immutable, IntoBytes, KnownLayout}; -#[derive(Debug, Deref, Clone, PartialEq, Eq, Immutable, IntoBytes, KnownLayout, FromBytes)] +#[derive( + Debug, Deref, Clone, PartialEq, Eq, Immutable, IntoBytes, KnownLayout, FromBytes, JsonSchema, +)] pub struct Txid([u8; 32]); impl From for Txid { diff --git a/crates/brk_structs/src/structs/txindex.rs b/crates/brk_structs/src/structs/txindex.rs index 7ffa7995f..8c62ad02a 100644 --- a/crates/brk_structs/src/structs/txindex.rs +++ b/crates/brk_structs/src/structs/txindex.rs @@ -3,6 +3,7 @@ use std::ops::{Add, AddAssign}; use allocative::Allocative; use byteview::ByteView; use derive_deref::{Deref, DerefMut}; +use schemars::JsonSchema; use serde::Serialize; use vecdb::{CheckedSub, PrintableIndex, StoredCompressed}; use zerocopy_derive::{FromBytes, Immutable, IntoBytes, KnownLayout}; @@ -29,6 +30,7 @@ use super::StoredU32; Serialize, StoredCompressed, Allocative, + JsonSchema, )] pub struct TxIndex(u32);