diff --git a/crates/brk_bindgen/src/generators/python/tree.rs b/crates/brk_bindgen/src/generators/python/tree.rs index 2e64be0f5..0249e5b94 100644 --- a/crates/brk_bindgen/src/generators/python/tree.rs +++ b/crates/brk_bindgen/src/generators/python/tree.rs @@ -7,7 +7,7 @@ use brk_types::TreeNode; use crate::{ ClientMetadata, GenericSyntax, PatternField, PythonSyntax, build_child_path, - generate_leaf_field, prepare_tree_node, to_snake_case, + escape_python_keyword, generate_leaf_field, prepare_tree_node, to_snake_case, }; /// Generate tree classes @@ -70,7 +70,7 @@ fn generate_tree_class( let syntax = PythonSyntax; for child in &ctx.children { - let field_name_py = to_snake_case(child.name); + let field_name_py = escape_python_keyword(&to_snake_case(child.name)); if child.is_leaf { if let TreeNode::Leaf(leaf) = child.node { diff --git a/crates/brk_bindgen/src/openapi.rs b/crates/brk_bindgen/src/openapi.rs index c0874e80d..4c81073ec 100644 --- a/crates/brk_bindgen/src/openapi.rs +++ b/crates/brk_bindgen/src/openapi.rs @@ -306,18 +306,52 @@ fn schema_type_from_schema(schema: &Schema) -> Option { } fn schema_to_type_name(schema: &ObjectSchema) -> Option { - let schema_type = schema.schema_type.as_ref()?; + if let Some(schema_type) = schema.schema_type.as_ref() { + return match schema_type { + SchemaTypeSet::Single(t) => single_type_to_name(t, schema), + SchemaTypeSet::Multiple(types) => { + // For nullable types like ["integer", "null"], return the non-null type + types + .iter() + .find(|t| !matches!(t, SchemaType::Null)) + .and_then(|t| single_type_to_name(t, schema)) + .or(Some("*".to_string())) + } + }; + } - match schema_type { - SchemaTypeSet::Single(t) => single_type_to_name(t, schema), - SchemaTypeSet::Multiple(types) => { - // For nullable types like ["integer", "null"], return the non-null type - types - .iter() - .find(|t| !matches!(t, SchemaType::Null)) - .and_then(|t| single_type_to_name(t, schema)) - .or(Some("*".to_string())) - } + // Handle anyOf/oneOf unions (e.g., Option → anyOf: [$ref, null]) + let variants = if !schema.any_of.is_empty() { + &schema.any_of + } else if !schema.one_of.is_empty() { + &schema.one_of + } else { + return None; + }; + + let types: Vec = variants + .iter() + .filter_map(|v| match v { + ObjectOrReference::Ref { ref_path, .. } => { + ref_to_type_name(ref_path).map(|s| s.to_string()) + } + ObjectOrReference::Object(obj) => { + // Skip null variants + if matches!( + obj.schema_type.as_ref(), + Some(SchemaTypeSet::Single(SchemaType::Null)) + ) { + return None; + } + schema_to_type_name(obj) + } + }) + .collect(); + + match types.len() { + 0 => None, + 1 => Some(types.into_iter().next().unwrap()), + _ => Some(types.join(" | ")), } } diff --git a/crates/brk_client/examples/basic.rs b/crates/brk_client/examples/basic.rs index 9a7691da3..343b34828 100644 --- a/crates/brk_client/examples/basic.rs +++ b/crates/brk_client/examples/basic.rs @@ -1,7 +1,7 @@ //! Basic example of using the BRK client. use brk_client::{BrkClient, BrkClientOptions}; -use brk_types::{FormatResponse, Index, Metric}; +use brk_types::{FormatResponse, Index, Metric, RangeIndex}; fn main() -> brk_client::Result<()> { // Create client with default options @@ -79,7 +79,7 @@ fn main() -> brk_client::Result<()> { let metricdata = client.get_metric( Metric::from("price_close"), Index::Day1, - Some(-3), + Some(RangeIndex::Int(-3)), None, None, None, diff --git a/crates/brk_client/examples/tree.rs b/crates/brk_client/examples/tree.rs index e4a4d36b0..2de441f94 100644 --- a/crates/brk_client/examples/tree.rs +++ b/crates/brk_client/examples/tree.rs @@ -4,7 +4,7 @@ //! and fetch data from each endpoint. Run with: cargo run --example tree use brk_client::BrkClient; -use brk_types::{Index, TreeNode}; +use brk_types::{Index, RangeIndex, TreeNode}; use std::collections::BTreeSet; /// A collected metric with its path and available indexes. @@ -62,7 +62,7 @@ fn main() -> brk_client::Result<()> { metric.name.as_str().into(), *index, None, - Some(0), + Some(RangeIndex::Int(0)), None, None, ) { diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index c65962ad9..8baa5a540 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -7860,17 +7860,16 @@ impl BrkClient { self.base.get_json(&format!("/api/address/{address}")) } - /// Address transaction IDs + /// Address transactions /// - /// Get transaction IDs for an address, newest first. Use after_txid for pagination. + /// Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid= for pagination. /// /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* /// /// Endpoint: `GET /api/address/{address}/txs` - pub fn get_address_txs(&self, address: Address, after_txid: Option<&str>, limit: Option) -> Result> { + pub fn get_address_txs(&self, address: Address, after_txid: Option) -> Result> { let mut query = Vec::new(); if let Some(v) = after_txid { query.push(format!("after_txid={}", v)); } - if let Some(v) = limit { query.push(format!("limit={}", v)); } let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; let path = format!("/api/address/{address}/txs{}", query_str); self.base.get_json(&path) @@ -7878,15 +7877,14 @@ impl BrkClient { /// Address confirmed transactions /// - /// Get confirmed transaction IDs for an address, 25 per page. Use ?after_txid= for pagination. + /// Get confirmed transactions for an address, 25 per page. Use ?after_txid= for pagination. /// /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* /// /// Endpoint: `GET /api/address/{address}/txs/chain` - pub fn get_address_confirmed_txs(&self, address: Address, after_txid: Option<&str>, limit: Option) -> Result> { + pub fn get_address_confirmed_txs(&self, address: Address, after_txid: Option) -> Result> { let mut query = Vec::new(); if let Some(v) = after_txid { query.push(format!("after_txid={}", v)); } - if let Some(v) = limit { query.push(format!("limit={}", v)); } let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; let path = format!("/api/address/{address}/txs/chain{}", query_str); self.base.get_json(&path) @@ -8058,7 +8056,7 @@ impl BrkClient { /// Fetch data for a specific metric at the given index. Use query parameters to filter by date range and format (json/csv). /// /// Endpoint: `GET /api/metric/{metric}/{index}` - pub fn get_metric(&self, metric: Metric, index: Index, start: Option<&str>, end: Option<&str>, limit: Option<&str>, format: Option) -> Result> { + pub fn get_metric(&self, metric: Metric, index: Index, start: Option, end: Option, limit: Option, format: Option) -> Result> { let mut query = Vec::new(); if let Some(v) = start { query.push(format!("start={}", v)); } if let Some(v) = end { query.push(format!("end={}", v)); } @@ -8078,7 +8076,7 @@ impl BrkClient { /// Returns just the data array without the MetricData wrapper. Supports the same range and format parameters as the standard endpoint. /// /// Endpoint: `GET /api/metric/{metric}/{index}/data` - pub fn get_metric_data(&self, metric: Metric, index: Index, start: Option<&str>, end: Option<&str>, limit: Option<&str>, format: Option) -> Result>> { + pub fn get_metric_data(&self, metric: Metric, index: Index, start: Option, end: Option, limit: Option, format: Option) -> Result>> { let mut query = Vec::new(); if let Some(v) = start { query.push(format!("start={}", v)); } if let Some(v) = end { query.push(format!("end={}", v)); } @@ -8134,7 +8132,7 @@ impl BrkClient { /// Fetch multiple metrics in a single request. Supports filtering by index and date range. Returns an array of MetricData objects. For a single metric, use `get_metric` instead. /// /// Endpoint: `GET /api/metrics/bulk` - pub fn get_metrics(&self, metrics: Metrics, index: Index, start: Option<&str>, end: Option<&str>, limit: Option<&str>, format: Option) -> Result>> { + pub fn get_metrics(&self, metrics: Metrics, index: Index, start: Option, end: Option, limit: Option, format: Option) -> Result>> { let mut query = Vec::new(); query.push(format!("metrics={}", metrics)); query.push(format!("index={}", index)); @@ -8210,9 +8208,10 @@ impl BrkClient { /// Paginated flat list of all available metric names. Use `page` query param for pagination. /// /// Endpoint: `GET /api/metrics/list` - pub fn list_metrics(&self, page: Option) -> Result { + pub fn list_metrics(&self, page: Option, per_page: Option) -> Result { let mut query = Vec::new(); if let Some(v) = page { query.push(format!("page={}", v)); } + if let Some(v) = per_page { query.push(format!("per_page={}", v)); } let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; let path = format!("/api/metrics/list{}", query_str); self.base.get_json(&path) diff --git a/crates/brk_computer/src/distribution/metrics/activity/core.rs b/crates/brk_computer/src/distribution/metrics/activity/core.rs index 9d30fd4b3..69b5e5bfd 100644 --- a/crates/brk_computer/src/distribution/metrics/activity/core.rs +++ b/crates/brk_computer/src/distribution/metrics/activity/core.rs @@ -96,9 +96,7 @@ impl ActivityCore { sum_others!(self, starting_indexes, others, exit; coindays_destroyed.raw.height); sum_others!(self, starting_indexes, others, exit; sent_in_profit.raw.sats.height); - sum_others!(self, starting_indexes, others, exit; sent_in_profit.raw.cents.height); sum_others!(self, starting_indexes, others, exit; sent_in_loss.raw.sats.height); - sum_others!(self, starting_indexes, others, exit; sent_in_loss.raw.cents.height); Ok(()) } diff --git a/crates/brk_error/src/lib.rs b/crates/brk_error/src/lib.rs index ea9c1e801..56e899c88 100644 --- a/crates/brk_error/src/lib.rs +++ b/crates/brk_error/src/lib.rs @@ -235,9 +235,13 @@ pub struct MetricNotFound { } impl MetricNotFound { - pub fn new(metric: String, all_matches: Vec) -> Self { + pub fn new(mut metric: String, all_matches: Vec) -> Self { let total_matches = all_matches.len(); let suggestions = all_matches.into_iter().take(3).collect(); + if metric.len() > 100 { + metric.truncate(100); + metric.push_str("..."); + } Self { metric, suggestions, @@ -261,7 +265,7 @@ impl fmt::Display for MetricNotFound { if remaining > 0 { write!( f, - " ({remaining} more — /api/metrics/search/{} for all)", + " ({remaining} more — /api/metrics/search?q={} for all)", self.metric )?; } diff --git a/crates/brk_indexer/src/vecs/addresses.rs b/crates/brk_indexer/src/vecs/addresses.rs index 7c0ca24ea..ca4c9775d 100644 --- a/crates/brk_indexer/src/vecs/addresses.rs +++ b/crates/brk_indexer/src/vecs/addresses.rs @@ -9,6 +9,7 @@ use brk_types::{ use rayon::prelude::*; use schemars::JsonSchema; use serde::Serialize; +use bitcoin::ScriptBuf; use vecdb::{ AnyStoredVec, BytesVec, BytesVecValue, Database, Formattable, ImportableVec, PcoVec, PcoVecValue, ReadableVec, Rw, Stamp, StorageMode, VecIndex, WritableVec, @@ -257,3 +258,20 @@ impl AddressesVecs { } } } + +impl AddressesVecs { + pub fn script_pubkey(&self, outputtype: OutputType, typeindex: TypeIndex) -> ScriptBuf { + let bytes: Option = match outputtype { + OutputType::P2PK65 => self.p2pk65.bytes.collect_one(typeindex.into()).map(Into::into), + OutputType::P2PK33 => self.p2pk33.bytes.collect_one(typeindex.into()).map(Into::into), + OutputType::P2PKH => self.p2pkh.bytes.collect_one(typeindex.into()).map(Into::into), + OutputType::P2SH => self.p2sh.bytes.collect_one(typeindex.into()).map(Into::into), + OutputType::P2WPKH => self.p2wpkh.bytes.collect_one(typeindex.into()).map(Into::into), + OutputType::P2WSH => self.p2wsh.bytes.collect_one(typeindex.into()).map(Into::into), + OutputType::P2TR => self.p2tr.bytes.collect_one(typeindex.into()).map(Into::into), + OutputType::P2A => self.p2a.bytes.collect_one(typeindex.into()).map(Into::into), + _ => None, + }; + bytes.map(|b| b.to_script_pubkey()).unwrap_or_default() + } +} diff --git a/crates/brk_query/src/impl/address.rs b/crates/brk_query/src/impl/address.rs index 3b2d9b219..765d9c1b8 100644 --- a/crates/brk_query/src/impl/address.rs +++ b/crates/brk_query/src/impl/address.rs @@ -4,8 +4,8 @@ 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, + AddressIndexTxIndex, AddressStats, AnyAddressDataIndexEnum, OutputType, Sats, Transaction, + TxIndex, TxStatus, Txid, TypeIndex, Unit, Utxo, Vout, }; use vecdb::{ReadableVec, VecIndex}; @@ -32,16 +32,12 @@ impl Query { return Err(Error::InvalidAddress); }; - dbg!(&script); - let outputtype = OutputType::from(&script); - dbg!(outputtype); let Ok(bytes) = AddressBytes::try_from((&script, outputtype)) else { return Err(Error::InvalidAddress); }; let addresstype = outputtype; let hash = AddressHash::from(&bytes); - dbg!(hash); let Ok(Some(type_index)) = stores .addresstype_to_addresshash_to_addressindex @@ -94,16 +90,44 @@ impl Query { }) } + pub fn address_txs( + &self, + address: Address, + after_txid: Option, + limit: usize, + ) -> Result> { + let txindices = self.address_txindices(&address, after_txid, limit)?; + txindices + .into_iter() + .map(|txindex| self.transaction_by_index(txindex)) + .collect() + } + pub fn address_txids( &self, address: Address, after_txid: Option, limit: usize, ) -> Result> { + let txindices = self.address_txindices(&address, after_txid, limit)?; + let txid_reader = self.indexer().vecs.transactions.txid.reader(); + let txids = txindices + .into_iter() + .map(|txindex| txid_reader.get(txindex.to_usize())) + .collect(); + Ok(txids) + } + + fn address_txindices( + &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 (outputtype, type_index) = self.resolve_address(address)?; let store = stores .addresstype_to_addressindex_and_txindex @@ -124,7 +148,7 @@ impl Query { None }; - let txindices: Vec = store + Ok(store .prefix(prefix) .rev() .filter(|(key, _): &(AddressIndexTxIndex, Unit)| { @@ -136,15 +160,7 @@ impl Query { }) .take(limit) .map(|(key, _)| key.txindex()) - .collect(); - - let txid_reader = indexer.vecs.transactions.txid.reader(); - let txids: Vec = txindices - .into_iter() - .map(|txindex| txid_reader.get(txindex.to_usize())) - .collect(); - - Ok(txids) + .collect()) } pub fn address_utxos(&self, address: Address) -> Result> { diff --git a/crates/brk_query/src/impl/metrics.rs b/crates/brk_query/src/impl/metrics.rs index 26c5634f1..e23351c54 100644 --- a/crates/brk_query/src/impl/metrics.rs +++ b/crates/brk_query/src/impl/metrics.rs @@ -35,7 +35,7 @@ impl Query { if let Some(indexes) = self.vecs().metric_to_indexes(metric.clone()) { let supported = indexes .iter() - .map(|i| format!("/api/metric/{metric}/{i}")) + .map(|i| format!("/api/metric/{metric}/{}", i.name())) .collect::>() .join(", "); return Error::MetricUnsupportedIndex { @@ -253,7 +253,12 @@ impl Query { } /// Format a resolved query as raw data (just the JSON array, no MetricData wrapper). + /// CSV output is identical to `format` (no wrapper distinction for CSV). pub fn format_raw(&self, resolved: ResolvedQuery) -> Result { + if resolved.format() == Format::CSV { + return self.format(resolved); + } + let ResolvedQuery { vecs, version, total, start, end, .. } = resolved; diff --git a/crates/brk_query/src/impl/transaction.rs b/crates/brk_query/src/impl/transaction.rs index bb89004fc..ca15e6d6f 100644 --- a/crates/brk_query/src/impl/transaction.rs +++ b/crates/brk_query/src/impl/transaction.rs @@ -3,8 +3,8 @@ use std::io::Cursor; use bitcoin::{consensus::Decodable, hex::DisplayHex}; use brk_error::{Error, Result}; use brk_types::{ - Sats, Transaction, TxIn, TxInIndex, TxIndex, TxOut, TxOutspend, TxStatus, Txid, TxidParam, - TxidPrefix, Vin, Vout, Weight, + OutputType, Sats, Transaction, TxIn, TxInIndex, TxIndex, TxOut, TxOutspend, TxStatus, Txid, + TxidParam, TxidPrefix, Vin, Vout, Weight, }; use vecdb::{ReadableVec, VecIndex}; @@ -242,6 +242,8 @@ impl Query { let txid_reader = indexer.vecs.transactions.txid.reader(); let first_txoutindex_reader = indexer.vecs.transactions.first_txoutindex.reader(); let value_reader = indexer.vecs.outputs.value.reader(); + let outputtype_reader = indexer.vecs.outputs.outputtype.reader(); + let typeindex_reader = indexer.vecs.outputs.typeindex.reader(); // Batch-read outpoints for all inputs (avoids per-input PcoVec page decompression) let outpoints: Vec<_> = indexer.vecs.inputs.outpoint.collect_range_at( @@ -272,13 +274,16 @@ impl Query { first_txoutindex_reader.get(prev_txindex.to_usize()); let prev_txoutindex = prev_first_txoutindex + prev_vout; - // Get the value of the prevout let prev_value = value_reader.get(usize::from(prev_txoutindex)); + let prev_outputtype: OutputType = + outputtype_reader.get(usize::from(prev_txoutindex)); + let prev_typeindex = typeindex_reader.get(usize::from(prev_txoutindex)); + let script_pubkey = indexer + .vecs + .addresses + .script_pubkey(prev_outputtype, prev_typeindex); - let prevout = Some(TxOut::from(( - bitcoin::ScriptBuf::new(), // Placeholder - would need to reconstruct - prev_value, - ))); + let prevout = Some(TxOut::from((script_pubkey, prev_value))); (prev_txid, prev_vout, prevout) }; diff --git a/crates/brk_query/src/vecs.rs b/crates/brk_query/src/vecs.rs index f8108ac37..d70dec2b8 100644 --- a/crates/brk_query/src/vecs.rs +++ b/crates/brk_query/src/vecs.rs @@ -147,12 +147,17 @@ impl<'a> Vecs<'a> { pub fn metrics(&'static self, pagination: Pagination) -> PaginatedMetrics { let len = self.metrics.len(); + let per_page = pagination.per_page(); let start = pagination.start(len); let end = pagination.end(len); + let max_page = len.div_ceil(per_page).saturating_sub(1); PaginatedMetrics { current_page: pagination.page(), - max_page: len.div_ceil(Pagination::PER_PAGE).saturating_sub(1), + max_page, + total_count: len, + per_page, + has_more: pagination.page() < max_page, metrics: self.metrics[start..end] .iter() .map(|&s| Cow::Borrowed(s)) @@ -183,6 +188,9 @@ impl<'a> Vecs<'a> { } pub fn matches(&self, metric: &Metric, limit: Limit) -> Vec<&'_ str> { + if limit.is_zero() { + return Vec::new(); + } self.matcher .as_ref() .expect("matcher not initialized") diff --git a/crates/brk_server/src/api/addresses/mod.rs b/crates/brk_server/src/api/addresses/mod.rs index 306d70e91..c1bc8e8d0 100644 --- a/crates/brk_server/src/api/addresses/mod.rs +++ b/crates/brk_server/src/api/addresses/mod.rs @@ -6,7 +6,7 @@ use axum::{ routing::get, }; use brk_types::{ - AddressParam, AddressStats, AddressTxidsParam, AddressValidation, Txid, Utxo, + AddressParam, AddressStats, AddressTxidsParam, AddressValidation, Transaction, Txid, Utxo, ValidateAddressParam, }; @@ -53,13 +53,13 @@ impl AddressRoutes for ApiRouter { Query(params): Query, State(state): State | { - state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_txids(path.address, params.after_txid, params.limit)).await + state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_txs(path.address, params.after_txid, 25)).await }, |op| op .id("get_address_txs") .addresses_tag() - .summary("Address transaction IDs") - .description("Get transaction IDs for an address, newest first. Use after_txid for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*") - .ok_response::>() + .summary("Address transactions") + .description("Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid= for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*") + .ok_response::>() .not_modified() .bad_request() .not_found() @@ -67,20 +67,21 @@ impl AddressRoutes for ApiRouter { ), ) .api_route( - "/api/address/{address}/utxo", + "/api/address/{address}/txs/chain", get_with(async | uri: Uri, headers: HeaderMap, Path(path): Path, + Query(params): Query, State(state): State | { - state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_utxos(path.address)).await + state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_txs(path.address, params.after_txid, 25)).await }, |op| op - .id("get_address_utxos") + .id("get_address_confirmed_txs") .addresses_tag() - .summary("Address UTXOs") - .description("Get unspent transaction outputs (UTXOs) for an address. Returns txid, vout, value, and confirmation status for each UTXO.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-utxo)*") - .ok_response::>() + .summary("Address confirmed transactions") + .description("Get confirmed transactions for an address, 25 per page. Use ?after_txid= for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*") + .ok_response::>() .not_modified() .bad_request() .not_found() @@ -109,21 +110,20 @@ impl AddressRoutes for ApiRouter { ), ) .api_route( - "/api/address/{address}/txs/chain", + "/api/address/{address}/utxo", get_with(async | uri: Uri, headers: HeaderMap, Path(path): Path, - Query(params): Query, State(state): State | { - state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_txids(path.address, params.after_txid, 25)).await + state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_utxos(path.address)).await }, |op| op - .id("get_address_confirmed_txs") + .id("get_address_utxos") .addresses_tag() - .summary("Address confirmed transactions") - .description("Get confirmed transaction IDs for an address, 25 per page. Use ?after_txid= for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*") - .ok_response::>() + .summary("Address UTXOs") + .description("Get unspent transaction outputs (UTXOs) for an address. Returns txid, vout, value, and confirmation status for each UTXO.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-utxo)*") + .ok_response::>() .not_modified() .bad_request() .not_found() diff --git a/crates/brk_server/src/api/blocks/mod.rs b/crates/brk_server/src/api/blocks/mod.rs index 1c357cf6f..4742dd2c9 100644 --- a/crates/brk_server/src/api/blocks/mod.rs +++ b/crates/brk_server/src/api/blocks/mod.rs @@ -38,6 +38,53 @@ impl BlockRoutes for ApiRouter { }, ), ) + .api_route( + "/api/blocks/{height}", + get_with( + async |uri: Uri, + headers: HeaderMap, + Path(path): Path, + State(state): State| { + state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.blocks(Some(path.height))).await + }, + |op| { + op.id("get_blocks_from_height") + .blocks_tag() + .summary("Blocks from height") + .description( + "Retrieve up to 10 blocks going backwards from the given height. For example, height=100 returns blocks 100, 99, 98, ..., 91. Height=0 returns only block 0.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)*", + ) + .ok_response::>() + .not_modified() + .bad_request() + .server_error() + }, + ), + ) + .api_route( + "/api/block-height/{height}", + get_with( + async |uri: Uri, + headers: HeaderMap, + Path(path): Path, + State(state): State| { + state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_by_height(path.height)).await + }, + |op| { + op.id("get_block_by_height") + .blocks_tag() + .summary("Block by height") + .description( + "Retrieve block information by block height. Returns block metadata including hash, timestamp, difficulty, size, weight, and transaction count.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)*", + ) + .ok_response::() + .not_modified() + .bad_request() + .not_found() + .server_error() + }, + ), + ) .api_route( "/api/block/{hash}", get_with( @@ -86,53 +133,6 @@ impl BlockRoutes for ApiRouter { }, ), ) - .api_route( - "/api/block-height/{height}", - get_with( - async |uri: Uri, - headers: HeaderMap, - Path(path): Path, - State(state): State| { - state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_by_height(path.height)).await - }, - |op| { - op.id("get_block_by_height") - .blocks_tag() - .summary("Block by height") - .description( - "Retrieve block information by block height. Returns block metadata including hash, timestamp, difficulty, size, weight, and transaction count.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)*", - ) - .ok_response::() - .not_modified() - .bad_request() - .not_found() - .server_error() - }, - ), - ) - .api_route( - "/api/blocks/{height}", - get_with( - async |uri: Uri, - headers: HeaderMap, - Path(path): Path, - State(state): State| { - state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.blocks(Some(path.height))).await - }, - |op| { - op.id("get_blocks_from_height") - .blocks_tag() - .summary("Blocks from height") - .description( - "Retrieve up to 10 blocks going backwards from the given height. For example, height=100 returns blocks 100, 99, 98, ..., 91. Height=0 returns only block 0.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)*", - ) - .ok_response::>() - .not_modified() - .bad_request() - .server_error() - }, - ), - ) .api_route( "/api/block/{hash}/txids", get_with( @@ -206,28 +206,6 @@ impl BlockRoutes for ApiRouter { }, ), ) - .api_route( - "/api/v1/mining/blocks/timestamp/{timestamp}", - get_with( - async |uri: Uri, - headers: HeaderMap, - Path(path): Path, - State(state): State| { - state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_by_timestamp(path.timestamp)).await - }, - |op| { - op.id("get_block_by_timestamp") - .blocks_tag() - .summary("Block by timestamp") - .description("Find the block closest to a given UNIX timestamp.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-timestamp)*") - .ok_response::() - .not_modified() - .bad_request() - .not_found() - .server_error() - }, - ), - ) .api_route( "/api/block/{hash}/raw", get_with( @@ -252,5 +230,27 @@ impl BlockRoutes for ApiRouter { }, ), ) + .api_route( + "/api/v1/mining/blocks/timestamp/{timestamp}", + get_with( + async |uri: Uri, + headers: HeaderMap, + Path(path): Path, + State(state): State| { + state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_by_timestamp(path.timestamp)).await + }, + |op| { + op.id("get_block_by_timestamp") + .blocks_tag() + .summary("Block by timestamp") + .description("Find the block closest to a given UNIX timestamp.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-timestamp)*") + .ok_response::() + .not_modified() + .bad_request() + .not_found() + .server_error() + }, + ), + ) } } diff --git a/crates/brk_server/src/api/mempool/mod.rs b/crates/brk_server/src/api/mempool/mod.rs index c1ac5a7b6..0ab4a7707 100644 --- a/crates/brk_server/src/api/mempool/mod.rs +++ b/crates/brk_server/src/api/mempool/mod.rs @@ -51,22 +51,6 @@ impl MempoolRoutes for ApiRouter { }, ), ) - .api_route( - "/api/v1/fees/recommended", - get_with( - async |uri: Uri, headers: HeaderMap, State(state): State| { - state.cached_json(&headers, state.mempool_cache(), &uri, |q| q.recommended_fees()).await - }, - |op| { - op.id("get_recommended_fees") - .mempool_tag() - .summary("Recommended fees") - .description("Get recommended fee rates for different confirmation targets based on current mempool state.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)*") - .ok_response::() - .server_error() - }, - ), - ) .api_route( "/api/mempool/price", get_with( @@ -87,6 +71,22 @@ impl MempoolRoutes for ApiRouter { }, ), ) + .api_route( + "/api/v1/fees/recommended", + get_with( + async |uri: Uri, headers: HeaderMap, State(state): State| { + state.cached_json(&headers, state.mempool_cache(), &uri, |q| q.recommended_fees()).await + }, + |op| { + op.id("get_recommended_fees") + .mempool_tag() + .summary("Recommended fees") + .description("Get recommended fee rates for different confirmation targets based on current mempool state.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)*") + .ok_response::() + .server_error() + }, + ), + ) .api_route( "/api/v1/fees/mempool-blocks", get_with( diff --git a/crates/brk_server/src/api/metrics/mod.rs b/crates/brk_server/src/api/metrics/mod.rs index bff5b8bf6..a34639b33 100644 --- a/crates/brk_server/src/api/metrics/mod.rs +++ b/crates/brk_server/src/api/metrics/mod.rs @@ -171,6 +171,74 @@ impl ApiMetricsRoutes for ApiRouter { .server_error(), ), ) + .api_route( + "/api/metric/{metric}/{index}", + get_with( + async |uri: Uri, + headers: HeaderMap, + addr: Extension, + state: State, + Path(path): Path, + Query(range): Query| + -> Response { + data::handler( + uri, + headers, + addr, + Query(MetricSelection::from((path.index, path.metric, range))), + state, + ) + .await + .into_response() + }, + |op| op + .id("get_metric") + .metrics_tag() + .summary("Get metric data") + .description( + "Fetch data for a specific metric at the given index. \ + Use query parameters to filter by date range and format (json/csv)." + ) + .ok_response::() + .csv_response() + .not_modified() + .not_found(), + ), + ) + .api_route( + "/api/metric/{metric}/{index}/data", + get_with( + async |uri: Uri, + headers: HeaderMap, + addr: Extension, + state: State, + Path(path): Path, + Query(range): Query| + -> Response { + data::raw_handler( + uri, + headers, + addr, + Query(MetricSelection::from((path.index, path.metric, range))), + state, + ) + .await + .into_response() + }, + |op| op + .id("get_metric_data") + .metrics_tag() + .summary("Get raw metric data") + .description( + "Returns just the data array without the MetricData wrapper. \ + Supports the same range and format parameters as the standard endpoint." + ) + .ok_response::>() + .csv_response() + .not_modified() + .not_found(), + ), + ) .api_route( "/api/metric/{metric}/{index}/latest", get_with( @@ -239,74 +307,6 @@ impl ApiMetricsRoutes for ApiRouter { .not_found(), ), ) - .api_route( - "/api/metric/{metric}/{index}/data", - get_with( - async |uri: Uri, - headers: HeaderMap, - addr: Extension, - state: State, - Path(path): Path, - Query(range): Query| - -> Response { - data::raw_handler( - uri, - headers, - addr, - Query(MetricSelection::from((path.index, path.metric, range))), - state, - ) - .await - .into_response() - }, - |op| op - .id("get_metric_data") - .metrics_tag() - .summary("Get raw metric data") - .description( - "Returns just the data array without the MetricData wrapper. \ - Supports the same range and format parameters as the standard endpoint." - ) - .ok_response::>() - .csv_response() - .not_modified() - .not_found(), - ), - ) - .api_route( - "/api/metric/{metric}/{index}", - get_with( - async |uri: Uri, - headers: HeaderMap, - addr: Extension, - state: State, - Path(path): Path, - Query(range): Query| - -> Response { - data::handler( - uri, - headers, - addr, - Query(MetricSelection::from((path.index, path.metric, range))), - state, - ) - .await - .into_response() - }, - |op| op - .id("get_metric") - .metrics_tag() - .summary("Get metric data") - .description( - "Fetch data for a specific metric at the given index. \ - Use query parameters to filter by date range and format (json/csv)." - ) - .ok_response::() - .csv_response() - .not_modified() - .not_found(), - ), - ) .api_route( "/api/metrics/bulk", get_with( @@ -326,77 +326,6 @@ impl ApiMetricsRoutes for ApiRouter { .not_modified(), ), ) - .api_route( - "/api/vecs/{variant}", - get_with( - async |uri: Uri, - headers: HeaderMap, - addr: Extension, - Path(variant): Path, - Query(range): Query, - state: State| - -> Response { - let separator = "_to_"; - let variant = variant.replace("-", "_"); - let mut split = variant.split(separator); - - let ser_index = split.next().unwrap(); - let Ok(index) = Index::try_from(ser_index) else { - return Error::not_found( - format!("Index '{ser_index}' doesn't exist") - ).into_response(); - }; - - let params = MetricSelection::from(( - index, - Metrics::from(split.collect::>().join(separator)), - range, - )); - legacy::handler(uri, headers, addr, Query(params), state) - .await - .into_response() - }, - |op| op - .metrics_tag() - .summary("Legacy variant endpoint") - .description( - "**DEPRECATED** - Use `/api/metric/{metric}/{index}` instead.\n\n\ - Sunset date: 2027-01-01. May be removed earlier in case of abuse.\n\n\ - Legacy endpoint for querying metrics by variant path (e.g., `day1_to_price`). \ - Returns raw data without the MetricData wrapper." - ) - .deprecated() - .ok_response::() - .not_modified(), - ), - ) - .api_route( - "/api/vecs/query", - get_with( - async |uri: Uri, - headers: HeaderMap, - addr: Extension, - Query(params): Query, - state: State| - -> Response { - let params: MetricSelection = params.into(); - legacy::handler(uri, headers, addr, Query(params), state) - .await - .into_response() - }, - |op| op - .metrics_tag() - .summary("Legacy query endpoint") - .description( - "**DEPRECATED** - Use `/api/metric/{metric}/{index}` or `/api/metrics/bulk` instead.\n\n\ - Sunset date: 2027-01-01. May be removed earlier in case of abuse.\n\n\ - Legacy endpoint for querying metrics. Returns raw data without the MetricData wrapper." - ) - .deprecated() - .ok_response::() - .not_modified(), - ), - ) // Cost basis distribution endpoints .api_route( "/api/metrics/cost-basis", @@ -475,5 +404,77 @@ impl ApiMetricsRoutes for ApiRouter { }, ), ) + // Deprecated endpoints + .api_route( + "/api/vecs/{variant}", + get_with( + async |uri: Uri, + headers: HeaderMap, + addr: Extension, + Path(variant): Path, + Query(range): Query, + state: State| + -> Response { + let separator = "_to_"; + let variant = variant.replace("-", "_"); + let mut split = variant.split(separator); + + let ser_index = split.next().unwrap(); + let Ok(index) = Index::try_from(ser_index) else { + return Error::not_found( + format!("Index '{ser_index}' doesn't exist") + ).into_response(); + }; + + let params = MetricSelection::from(( + index, + Metrics::from(split.collect::>().join(separator)), + range, + )); + legacy::handler(uri, headers, addr, Query(params), state) + .await + .into_response() + }, + |op| op + .metrics_tag() + .summary("Legacy variant endpoint") + .description( + "**DEPRECATED** - Use `/api/metric/{metric}/{index}` instead.\n\n\ + Sunset date: 2027-01-01. May be removed earlier in case of abuse.\n\n\ + Legacy endpoint for querying metrics by variant path (e.g., `day1_to_price`). \ + Returns raw data without the MetricData wrapper." + ) + .deprecated() + .ok_response::() + .not_modified(), + ), + ) + .api_route( + "/api/vecs/query", + get_with( + async |uri: Uri, + headers: HeaderMap, + addr: Extension, + Query(params): Query, + state: State| + -> Response { + let params: MetricSelection = params.into(); + legacy::handler(uri, headers, addr, Query(params), state) + .await + .into_response() + }, + |op| op + .metrics_tag() + .summary("Legacy query endpoint") + .description( + "**DEPRECATED** - Use `/api/metric/{metric}/{index}` or `/api/metrics/bulk` instead.\n\n\ + Sunset date: 2027-01-01. May be removed earlier in case of abuse.\n\n\ + Legacy endpoint for querying metrics. Returns raw data without the MetricData wrapper." + ) + .deprecated() + .ok_response::() + .not_modified(), + ), + ) } } diff --git a/crates/brk_server/src/api/mining/mod.rs b/crates/brk_server/src/api/mining/mod.rs index 33157ef7c..003502488 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, Uri}, - response::Redirect, + response::{IntoResponse, Redirect, Response}, routing::get, }; use brk_types::{ @@ -11,7 +11,7 @@ use brk_types::{ RewardStats, TimePeriodParam, }; -use crate::{CacheStrategy, extended::TransformResponseExtended}; +use crate::{CacheStrategy, Error, extended::TransformResponseExtended}; use super::AppState; @@ -200,18 +200,15 @@ impl MiningRoutes for ApiRouter { .api_route( "/api/v1/mining/blocks/fee-rates/{time_period}", get_with( - async |Path(_path): Path| { - axum::Json(serde_json::json!({ - "status": "wip", - "message": "This endpoint is work in progress. Percentile fields are not yet available." - })) + async |Path(_path): Path| -> Response { + Error::not_implemented("Fee rate percentiles are not yet available").into_response() }, |op| { op.id("get_block_fee_rates") .mining_tag() .summary("Block fee rates (WIP)") .description("**Work in progress.** Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: 24h, 3d, 1w, 1m, 3m, 6m, 1y, 2y, 3y\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)*") - .ok_response::() + .server_error() }, ), ) diff --git a/crates/brk_server/src/api/mod.rs b/crates/brk_server/src/api/mod.rs index de217a099..b5a8ed854 100644 --- a/crates/brk_server/src/api/mod.rs +++ b/crates/brk_server/src/api/mod.rs @@ -12,6 +12,7 @@ use axum::{ }; use crate::{ + Error, api::{ addresses::AddressRoutes, blocks::BlockRoutes, mempool::MempoolRoutes, metrics::ApiMetricsRoutes, mining::MiningRoutes, server::ServerRoutes, @@ -39,13 +40,13 @@ pub trait ApiRoutes { impl ApiRoutes for ApiRouter { fn add_api_routes(self) -> Self { - self.add_addresses_routes() + self.add_server_routes() + .add_metrics_routes() .add_block_routes() + .add_tx_routes() + .add_addresses_routes() .add_mempool_routes() .add_mining_routes() - .add_tx_routes() - .add_metrics_routes() - .add_server_routes() .route("/api/server", get(Redirect::temporary("/api#tag/server"))) .api_route( "/openapi.json", @@ -99,7 +100,9 @@ impl ApiRoutes for ApiRouter { ) .route( "/api/{*path}", - get(|| async { Redirect::permanent("/api") }), + get(|| async { + Error::not_found("Unknown API endpoint. See /api for documentation.") + }), ) } } diff --git a/crates/brk_server/src/api/openapi/mod.rs b/crates/brk_server/src/api/openapi/mod.rs index fca92f043..9a7b1932b 100644 --- a/crates/brk_server/src/api/openapi/mod.rs +++ b/crates/brk_server/src/api/openapi/mod.rs @@ -14,7 +14,7 @@ mod compact; pub use compact::ApiJson; -use aide::openapi::{Contact, Info, License, OpenApi, Server, ServerVariable, Tag}; +use aide::openapi::{Contact, Info, License, OpenApi, Tag}; use crate::VERSION; @@ -159,38 +159,9 @@ All errors return structured JSON with a consistent format: }, ]; - let servers = vec![Server { - url: "{scheme}://{host}".into(), - description: Some("BRK server".into()), - variables: [ - ( - "scheme".into(), - ServerVariable { - enumeration: vec!["https".into(), "http".into()], - default: "https".into(), - description: Some("Protocol".into()), - ..Default::default() - }, - ), - ( - "host".into(), - ServerVariable { - default: "bitview.space".into(), - description: Some( - "Server address (e.g. bitview.space or localhost:3110)".into(), - ), - ..Default::default() - }, - ), - ] - .into(), - ..Default::default() - }]; - OpenApi { info, tags, - servers, ..OpenApi::default() } } diff --git a/crates/brk_server/src/api/server/mod.rs b/crates/brk_server/src/api/server/mod.rs index efa8f0195..6a2e58a67 100644 --- a/crates/brk_server/src/api/server/mod.rs +++ b/crates/brk_server/src/api/server/mod.rs @@ -18,6 +18,55 @@ pub trait ServerRoutes { impl ServerRoutes for ApiRouter { fn add_server_routes(self) -> Self { self.api_route( + "/health", + get_with( + async |State(state): State| -> axum::Json { + let uptime = state.started_instant.elapsed(); + let tip_height = state.client.get_last_height(); + let sync = state.sync(|q| { + let tip_height = tip_height.unwrap_or(q.indexed_height()); + q.sync_status(tip_height) + }); + axum::Json(Health { + status: Cow::Borrowed("healthy"), + service: Cow::Borrowed("brk"), + version: Cow::Borrowed(VERSION), + timestamp: jiff::Timestamp::now().to_string(), + started_at: state.started_at.to_string(), + uptime_seconds: uptime.as_secs(), + sync, + }) + }, + |op| { + op.id("get_health") + .server_tag() + .summary("Health check") + .description("Returns the health status of the API server, including uptime information.") + .ok_response::() + }, + ), + ) + .api_route( + "/version", + get_with( + async |uri: Uri, headers: HeaderMap, State(state): State| { + state + .cached_json(&headers, CacheStrategy::Static, &uri, |_| { + Ok(env!("CARGO_PKG_VERSION")) + }) + .await + }, + |op| { + op.id("get_version") + .server_tag() + .summary("API version") + .description("Returns the current version of the API server") + .ok_response::() + .not_modified() + }, + ), + ) + .api_route( "/api/server/sync", get_with( async |uri: Uri, headers: HeaderMap, State(state): State| { @@ -67,55 +116,6 @@ impl ServerRoutes for ApiRouter { }, ), ) - .api_route( - "/health", - get_with( - async |State(state): State| -> axum::Json { - let uptime = state.started_instant.elapsed(); - let tip_height = state.client.get_last_height(); - let sync = state.sync(|q| { - let tip_height = tip_height.unwrap_or(q.indexed_height()); - q.sync_status(tip_height) - }); - axum::Json(Health { - status: Cow::Borrowed("healthy"), - service: Cow::Borrowed("brk"), - version: Cow::Borrowed(VERSION), - timestamp: jiff::Timestamp::now().to_string(), - started_at: state.started_at.to_string(), - uptime_seconds: uptime.as_secs(), - sync, - }) - }, - |op| { - op.id("get_health") - .server_tag() - .summary("Health check") - .description("Returns the health status of the API server, including uptime information.") - .ok_response::() - }, - ), - ) - .api_route( - "/version", - get_with( - async |uri: Uri, headers: HeaderMap, State(state): State| { - state - .cached_json(&headers, CacheStrategy::Static, &uri, |_| { - Ok(env!("CARGO_PKG_VERSION")) - }) - .await - }, - |op| { - op.id("get_version") - .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/error.rs b/crates/brk_server/src/error.rs index 00d64c477..03c71e800 100644 --- a/crates/brk_server/src/error.rs +++ b/crates/brk_server/src/error.rs @@ -58,6 +58,7 @@ fn error_status(e: &BrkError) -> StatusCode { | BrkError::UnknownTxid | BrkError::NotFound(_) | BrkError::NoData + | BrkError::OutOfRange(_) | BrkError::MetricNotFound(_) => StatusCode::NOT_FOUND, BrkError::AuthFailed => StatusCode::FORBIDDEN, @@ -80,6 +81,7 @@ fn error_code(e: &BrkError) -> &'static str { BrkError::UnknownAddress => "unknown_address", BrkError::UnknownTxid => "unknown_txid", BrkError::NotFound(_) => "not_found", + BrkError::OutOfRange(_) => "out_of_range", BrkError::NoData => "no_data", BrkError::MetricNotFound(_) => "metric_not_found", BrkError::MempoolNotAvailable => "mempool_not_available", @@ -128,6 +130,10 @@ impl Error { Self::new(StatusCode::NOT_FOUND, "not_found", msg) } + pub fn not_implemented(msg: impl Into) -> Self { + Self::new(StatusCode::NOT_IMPLEMENTED, "not_implemented", msg) + } + pub fn internal(msg: impl Into) -> Self { Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", msg) } @@ -156,7 +162,7 @@ impl IntoResponse for Error { let body = build_error_body(self.status, self.code, self.message); ( self.status, - [(header::CONTENT_TYPE, "application/json")], + [(header::CONTENT_TYPE, "application/problem+json")], body, ) .into_response() diff --git a/crates/brk_server/src/extended/encoding.rs b/crates/brk_server/src/extended/encoding.rs index 080ba00ae..f269c6d43 100644 --- a/crates/brk_server/src/extended/encoding.rs +++ b/crates/brk_server/src/extended/encoding.rs @@ -1,6 +1,6 @@ use axum::{ body::Bytes, - http::{header, HeaderMap, HeaderValue}, + http::{HeaderMap, HeaderValue, header}, }; /// HTTP content encoding for pre-compressed caching. @@ -61,9 +61,9 @@ impl ContentEncoding { encoder.write_all(&bytes).expect("gzip compression failed"); Bytes::from(encoder.finish().expect("gzip finish failed")) } - Self::Zstd => Bytes::from( - zstd::encode_all(bytes.as_ref(), 3).expect("zstd compression failed"), - ), + Self::Zstd => { + Bytes::from(zstd::encode_all(bytes.as_ref(), 3).expect("zstd compression failed")) + } } } diff --git a/crates/brk_server/src/extended/header_map.rs b/crates/brk_server/src/extended/header_map.rs index fc477a581..69d6cc0e8 100644 --- a/crates/brk_server/src/extended/header_map.rs +++ b/crates/brk_server/src/extended/header_map.rs @@ -24,12 +24,19 @@ pub trait HeaderMapExtended { impl HeaderMapExtended for HeaderMap { fn has_etag(&self, etag: &str) -> bool { - self.get(IF_NONE_MATCH) - .is_some_and(|prev_etag| etag == prev_etag) + self.get(IF_NONE_MATCH).is_some_and(|v| { + let s = v.as_bytes(); + // Match both quoted and unquoted: "etag" or etag + s == etag.as_bytes() + || (s.len() == etag.len() + 2 + && s[0] == b'"' + && s[s.len() - 1] == b'"' + && &s[1..s.len() - 1] == etag.as_bytes()) + }) } fn insert_etag(&mut self, etag: &str) { - self.insert(header::ETAG, etag.parse().unwrap()); + self.insert(header::ETAG, format!("\"{etag}\"").parse().unwrap()); } fn insert_cache_control(&mut self, value: &str) { @@ -43,7 +50,9 @@ impl HeaderMapExtended for HeaderMap { fn insert_content_disposition_attachment(&mut self, filename: &str) { self.insert( header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{filename}\"").parse().unwrap(), + format!("attachment; filename=\"{filename}\"") + .parse() + .unwrap(), ); } diff --git a/crates/brk_server/src/lib.rs b/crates/brk_server/src/lib.rs index 55d6c9b67..060e67825 100644 --- a/crates/brk_server/src/lib.rs +++ b/crates/brk_server/src/lib.rs @@ -1,6 +1,7 @@ #![doc = include_str!("../README.md")] use std::{ + any::Any, net::SocketAddr, path::PathBuf, sync::Arc, @@ -9,11 +10,14 @@ use std::{ use aide::axum::ApiRouter; use axum::{ - Extension, + Extension, ServiceExt, body::Body, - http::{Request, Response, StatusCode, Uri}, + http::{ + Request, Response, StatusCode, Uri, + header::{CONTENT_TYPE, VARY}, + }, middleware::Next, - response::Redirect, + response::{IntoResponse, Redirect}, routing::get, serve, }; @@ -25,6 +29,7 @@ use tower_http::{ compression::CompressionLayer, cors::CorsLayer, normalize_path::NormalizePathLayer, timeout::TimeoutLayer, trace::TraceLayer, }; +use tower_layer::Layer; use tracing::{error, info}; mod api; @@ -101,32 +106,57 @@ impl Server { }, ); - // Wrap non-JSON client errors (e.g. axum extraction rejections) in structured JSON + // Wrap non-JSON error responses in structured JSON let json_error_layer = axum::middleware::from_fn( async |request: Request, next: Next| -> Response { - use axum::http::header::CONTENT_TYPE; - use axum::response::IntoResponse; - let response = next.run(request).await; - if !response.status().is_client_error() - || response - .headers() - .get(CONTENT_TYPE) - .is_some_and(|v| v.as_bytes().starts_with(b"application/json")) + let status = response.status(); + if status.is_success() + || status.is_redirection() + || status.is_informational() + || response.headers().get(CONTENT_TYPE).is_some_and(|v| { + let b = v.as_bytes(); + b.starts_with(b"application/") && b.ends_with(b"json") + }) { return response; } let (parts, body) = response.into_parts(); - let bytes = axum::body::to_bytes(body, 4096) - .await - .unwrap_or_default(); - let msg = String::from_utf8_lossy(&bytes).to_string(); - let code = if parts.status == StatusCode::NOT_FOUND { - "not_found" - } else { - "bad_request" + let bytes = axum::body::to_bytes(body, 4096).await.unwrap_or_default(); + let msg = String::from_utf8_lossy(&bytes); + let (code, msg) = match parts.status { + StatusCode::NOT_FOUND => ( + "not_found", + if msg.is_empty() { + "Not found".into() + } else { + msg + }, + ), + StatusCode::METHOD_NOT_ALLOWED => ( + "method_not_allowed", + "Only GET requests are supported".into(), + ), + StatusCode::GATEWAY_TIMEOUT => ("timeout", "Request timed out".into()), + s if s.is_client_error() => ( + "bad_request", + if msg.is_empty() { + "Bad request".into() + } else { + msg + }, + ), + _ => ( + "internal_error", + if msg.is_empty() { + "Internal server error".into() + } else { + msg + }, + ), }; + let msg = msg.into_owned(); let mut response = Error::new(parts.status, code, msg).into_response(); response.extensions_mut().extend(parts.extensions); response @@ -165,18 +195,41 @@ impl Server { let router = router .with_state(state) .merge(website_router) - .layer(json_error_layer) .layer(compression_layer) .layer(response_time_layer) .layer(trace_layer) - .layer(CatchPanicLayer::new()) + .layer(CatchPanicLayer::custom(|panic: Box| { + let msg = panic + .downcast_ref::() + .map(|s| s.as_str()) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or("Unknown panic"); + Error::internal(msg).into_response() + })) .layer(TimeoutLayer::with_status_code( StatusCode::GATEWAY_TIMEOUT, Duration::from_secs(5), )) + .layer(json_error_layer) .layer(CorsLayer::permissive()) - .layer(connect_info_layer) - .layer(NormalizePathLayer::trim_trailing_slash()); + .layer(axum::middleware::from_fn( + async |request: Request, next: Next| -> Response { + let mut response = next.run(request).await; + // Consolidate multiple Vary headers into one + let vary: Vec<&str> = response + .headers() + .get_all(VARY) + .iter() + .filter_map(|v| v.to_str().ok()) + .collect(); + if vary.len() > 1 { + let merged = vary.join(", "); + response.headers_mut().insert(VARY, merged.parse().unwrap()); + } + response + }, + )) + .layer(connect_info_layer); let (listener, port) = match port { Some(port) => { @@ -235,9 +288,12 @@ impl Server { .layer(Extension(Arc::new(openapi))) .layer(Extension(api_json)); + // NormalizePath must wrap the router (not be a layer) to run before route matching + let app = NormalizePathLayer::trim_trailing_slash().layer(router); + serve( listener, - router.into_make_service_with_connect_info::(), + ServiceExt::>::into_make_service_with_connect_info::(app), ) .await?; diff --git a/crates/brk_server/src/state.rs b/crates/brk_server/src/state.rs index 719eba263..72cf4738e 100644 --- a/crates/brk_server/src/state.rs +++ b/crates/brk_server/src/state.rs @@ -60,9 +60,10 @@ impl AppState { let full_key = format!("{}-{}-{}", uri, params.etag_str(), encoding.as_str()); let result = self - .get_or_insert(&full_key, async move { - self.run(move |q| f(q, encoding)).await - }) + .get_or_insert( + &full_key, + async move { self.run(move |q| f(q, encoding)).await }, + ) .await; match result { diff --git a/crates/brk_types/src/address.rs b/crates/brk_types/src/address.rs index 2efe06a1d..c0d53893b 100644 --- a/crates/brk_types/src/address.rs +++ b/crates/brk_types/src/address.rs @@ -43,10 +43,18 @@ impl TryFrom<&ScriptBuf> for Address { impl TryFrom<(&ScriptBuf, OutputType)> for Address { type Error = Error; fn try_from((script, outputtype): (&ScriptBuf, OutputType)) -> Result { - if outputtype.is_address() { - Ok(Self(script.to_hex_string())) - } else { - Err(Error::InvalidAddress) + match outputtype { + OutputType::P2PK65 | OutputType::P2PK33 => { + // P2PK has no standard address encoding, use raw pubkey hex + let bytes = AddressBytes::try_from((script, outputtype))?; + Ok(Self(bytes_to_hex(bytes.as_slice()))) + } + _ if outputtype.is_address() => { + let addr = bitcoin::Address::from_script(script, bitcoin::Network::Bitcoin) + .map_err(|_| Error::InvalidAddress)?; + Ok(Self(addr.to_string())) + } + _ => Err(Error::InvalidAddress), } } } @@ -54,14 +62,7 @@ impl TryFrom<(&ScriptBuf, OutputType)> for Address { impl TryFrom<&AddressBytes> for Address { type Error = Error; fn try_from(bytes: &AddressBytes) -> Result { - // P2PK addresses are represented as raw pubkey hex, not as a script - let address = match bytes { - AddressBytes::P2PK65(_) | AddressBytes::P2PK33(_) => { - Self::from(bytes_to_hex(bytes.as_slice())) - } - _ => Self::try_from(&bytes.to_script_pubkey())?, - }; - Ok(address) + Self::try_from(&bytes.to_script_pubkey()) } } diff --git a/crates/brk_types/src/addressindexoutpoint.rs b/crates/brk_types/src/addressindexoutpoint.rs index 0ff4c910f..c6d439cc4 100644 --- a/crates/brk_types/src/addressindexoutpoint.rs +++ b/crates/brk_types/src/addressindexoutpoint.rs @@ -50,8 +50,8 @@ impl From for AddressIndexOutPoint { #[inline] fn from(value: ByteView) -> Self { Self { - addressindextxindex: AddressIndexTxIndex::from_bytes(&value[0..8]).unwrap(), - vout: Vout::from_bytes(&value[8..]).unwrap(), + addressindextxindex: AddressIndexTxIndex::from(ByteView::new(&value[..8])), + vout: Vout::from(u16::from_be_bytes([value[8], value[9]])), } } } diff --git a/crates/brk_types/src/addresstxidsparam.rs b/crates/brk_types/src/addresstxidsparam.rs index 5d985090d..e50b220dc 100644 --- a/crates/brk_types/src/addresstxidsparam.rs +++ b/crates/brk_types/src/addresstxidsparam.rs @@ -7,11 +7,4 @@ use crate::Txid; pub struct AddressTxidsParam { /// Txid to paginate from (return transactions before this one) pub after_txid: Option, - /// Maximum number of results to return. Defaults to 25 if not specified. - #[serde(default = "default_limit")] - pub limit: usize, -} - -fn default_limit() -> usize { - 25 } diff --git a/crates/brk_types/src/basis_points_signed_32.rs b/crates/brk_types/src/basis_points_signed_32.rs index 0926addeb..56ae1fe78 100644 --- a/crates/brk_types/src/basis_points_signed_32.rs +++ b/crates/brk_types/src/basis_points_signed_32.rs @@ -83,7 +83,9 @@ impl From for i32 { impl From for BasisPointsSigned32 { #[inline] fn from(value: f64) -> Self { - let scaled = (value * 10000.0).round().clamp(i32::MIN as f64, i32::MAX as f64); + let scaled = (value * 10000.0) + .round() + .clamp(i32::MIN as f64, i32::MAX as f64); Self(scaled as i32) } } diff --git a/crates/brk_types/src/bitcoin.rs b/crates/brk_types/src/bitcoin.rs index d77454327..6f04f65e2 100644 --- a/crates/brk_types/src/bitcoin.rs +++ b/crates/brk_types/src/bitcoin.rs @@ -144,7 +144,18 @@ impl std::fmt::Display for Bitcoin { impl Formattable for Bitcoin { #[inline(always)] fn write_to(&self, buf: &mut Vec) { - let mut b = ryu::Buffer::new(); - buf.extend_from_slice(b.format(self.0).as_bytes()); + if !self.0.is_nan() { + let mut b = ryu::Buffer::new(); + buf.extend_from_slice(b.format(self.0).as_bytes()); + } + } + + #[inline(always)] + fn fmt_json(&self, buf: &mut Vec) { + if self.0.is_nan() { + buf.extend_from_slice(b"null"); + } else { + self.write_to(buf); + } } } diff --git a/crates/brk_types/src/dollars.rs b/crates/brk_types/src/dollars.rs index 2a79a28a6..e8ea0d8cd 100644 --- a/crates/brk_types/src/dollars.rs +++ b/crates/brk_types/src/dollars.rs @@ -435,7 +435,18 @@ impl std::fmt::Display for Dollars { impl Formattable for Dollars { #[inline(always)] fn write_to(&self, buf: &mut Vec) { - let mut b = ryu::Buffer::new(); - buf.extend_from_slice(b.format(self.0).as_bytes()); + if !self.0.is_nan() { + let mut b = ryu::Buffer::new(); + buf.extend_from_slice(b.format(self.0).as_bytes()); + } + } + + #[inline(always)] + fn fmt_json(&self, buf: &mut Vec) { + if self.0.is_nan() { + buf.extend_from_slice(b"null"); + } else { + self.write_to(buf); + } } } diff --git a/crates/brk_types/src/hour1.rs b/crates/brk_types/src/hour1.rs index fff8287d4..dde14527e 100644 --- a/crates/brk_types/src/hour1.rs +++ b/crates/brk_types/src/hour1.rs @@ -67,7 +67,7 @@ impl PrintableIndex for Hour1 { } fn to_possible_strings() -> &'static [&'static str] { - &["1h", "h", "hour", "hourly", "hour1"] + &["1h", "hour", "hourly", "hour1"] } } diff --git a/crates/brk_types/src/index.rs b/crates/brk_types/src/index.rs index 1c8545cf6..075f3d620 100644 --- a/crates/brk_types/src/index.rs +++ b/crates/brk_types/src/index.rs @@ -8,11 +8,11 @@ use vecdb::PrintableIndex; use crate::PairOutputIndex; use super::{ - Date, Day1, Day3, Epoch, EmptyAddressIndex, EmptyOutputIndex, FundedAddressIndex, - Halving, Height, Hour1, Hour4, Hour12, Minute10, Minute30, Month1, Month3, Month6, - OpReturnIndex, P2AAddressIndex, P2MSOutputIndex, P2PK33AddressIndex, P2PK65AddressIndex, - P2PKHAddressIndex, P2SHAddressIndex, P2TRAddressIndex, P2WPKHAddressIndex, P2WSHAddressIndex, - Timestamp, TxInIndex, TxIndex, TxOutIndex, UnknownOutputIndex, Week1, Year1, Year10, + Date, Day1, Day3, EmptyAddressIndex, EmptyOutputIndex, Epoch, FundedAddressIndex, Halving, + Height, Hour1, Hour4, Hour12, Minute10, Minute30, Month1, Month3, Month6, OpReturnIndex, + P2AAddressIndex, P2MSOutputIndex, P2PK33AddressIndex, P2PK65AddressIndex, P2PKHAddressIndex, + P2SHAddressIndex, P2TRAddressIndex, P2WPKHAddressIndex, P2WSHAddressIndex, Timestamp, + TxInIndex, TxIndex, TxOutIndex, UnknownOutputIndex, Week1, Year1, Year10, hour1::HOUR1_INTERVAL, hour4::HOUR4_INTERVAL, hour12::HOUR12_INTERVAL, minute10::MINUTE10_INTERVAL, minute30::MINUTE30_INTERVAL, timestamp::INDEX_EPOCH, }; @@ -184,7 +184,6 @@ impl Index { } } - /// Returns true if this index type is date-based. pub const fn is_date_based(&self) -> bool { matches!( diff --git a/crates/brk_types/src/lib.rs b/crates/brk_types/src/lib.rs index d47d3c75a..0388d86c8 100644 --- a/crates/brk_types/src/lib.rs +++ b/crates/brk_types/src/lib.rs @@ -57,12 +57,12 @@ mod deser; mod difficultyadjustment; mod difficultyadjustmententry; mod difficultyentry; -mod epoch; mod diskusage; mod dollars; mod emptyaddressdata; mod emptyaddressindex; mod emptyoutputindex; +mod epoch; mod etag; mod feerate; mod feeratepercentiles; @@ -149,8 +149,8 @@ mod recommendedfees; mod rewardstats; mod sats; mod sats_signed; -mod searchquery; mod satsfract; +mod searchquery; mod stored_bool; mod stored_f32; mod stored_f64; @@ -253,12 +253,12 @@ pub use deser::*; pub use difficultyadjustment::*; pub use difficultyadjustmententry::*; pub use difficultyentry::*; -pub use epoch::*; pub use diskusage::*; pub use dollars::*; pub use emptyaddressdata::*; pub use emptyaddressindex::*; pub use emptyoutputindex::*; +pub use epoch::*; pub use etag::*; pub use feerate::*; pub use feeratepercentiles::*; diff --git a/crates/brk_types/src/limit.rs b/crates/brk_types/src/limit.rs index 60b04f8f6..99e3fe1ef 100644 --- a/crates/brk_types/src/limit.rs +++ b/crates/brk_types/src/limit.rs @@ -14,6 +14,10 @@ pub struct Limit(usize); impl Limit { pub const MIN: Self = Self(1); pub const DEFAULT: Self = Self(100); + + pub fn is_zero(&self) -> bool { + self.0 == 0 + } } impl Default for Limit { diff --git a/crates/brk_types/src/metricspaginated.rs b/crates/brk_types/src/metricspaginated.rs index 93609c426..f4ec20926 100644 --- a/crates/brk_types/src/metricspaginated.rs +++ b/crates/brk_types/src/metricspaginated.rs @@ -12,6 +12,12 @@ pub struct PaginatedMetrics { /// Maximum valid page index (0-indexed) #[schemars(example = 21)] pub max_page: usize, - /// List of metric names (max 1000 per page) + /// Total number of metrics + pub total_count: usize, + /// Results per page + pub per_page: usize, + /// Whether more pages are available after the current one + pub has_more: bool, + /// List of metric names pub metrics: Vec>, } diff --git a/crates/brk_types/src/ohlc.rs b/crates/brk_types/src/ohlc.rs index 54ba7fef2..6d2fe52b8 100644 --- a/crates/brk_types/src/ohlc.rs +++ b/crates/brk_types/src/ohlc.rs @@ -129,10 +129,15 @@ impl Display for OHLCCents { impl Formattable for OHLCCents { fn write_to(&self, buf: &mut Vec) { - use std::fmt::Write; - let mut s = String::new(); - write!(s, "{}", self).unwrap(); - buf.extend_from_slice(s.as_bytes()); + buf.push(b'['); + self.open.write_to(buf); + buf.push(b','); + self.high.write_to(buf); + buf.push(b','); + self.low.write_to(buf); + buf.push(b','); + self.close.write_to(buf); + buf.push(b']'); } fn fmt_csv(&self, f: &mut String) -> std::fmt::Result { @@ -144,12 +149,6 @@ impl Formattable for OHLCCents { } Ok(()) } - - fn fmt_json(&self, buf: &mut Vec) { - buf.push(b'"'); - self.write_to(buf); - buf.push(b'"'); - } } impl Bytes for OHLCCents { @@ -272,10 +271,15 @@ impl Display for OHLCDollars { impl Formattable for OHLCDollars { fn write_to(&self, buf: &mut Vec) { - use std::fmt::Write; - let mut s = String::new(); - write!(s, "{}", self).unwrap(); - buf.extend_from_slice(s.as_bytes()); + buf.push(b'['); + self.open.write_to(buf); + buf.push(b','); + self.high.write_to(buf); + buf.push(b','); + self.low.write_to(buf); + buf.push(b','); + self.close.write_to(buf); + buf.push(b']'); } fn fmt_csv(&self, f: &mut String) -> std::fmt::Result { @@ -287,12 +291,6 @@ impl Formattable for OHLCDollars { } Ok(()) } - - fn fmt_json(&self, buf: &mut Vec) { - buf.push(b'"'); - self.write_to(buf); - buf.push(b'"'); - } } impl Bytes for OHLCDollars { @@ -384,10 +382,15 @@ impl Display for OHLCSats { impl Formattable for OHLCSats { fn write_to(&self, buf: &mut Vec) { - use std::fmt::Write; - let mut s = String::new(); - write!(s, "{}", self).unwrap(); - buf.extend_from_slice(s.as_bytes()); + buf.push(b'['); + self.open.write_to(buf); + buf.push(b','); + self.high.write_to(buf); + buf.push(b','); + self.low.write_to(buf); + buf.push(b','); + self.close.write_to(buf); + buf.push(b']'); } fn fmt_csv(&self, f: &mut String) -> std::fmt::Result { @@ -399,12 +402,6 @@ impl Formattable for OHLCSats { } Ok(()) } - - fn fmt_json(&self, buf: &mut Vec) { - buf.push(b'"'); - self.write_to(buf); - buf.push(b'"'); - } } impl Bytes for OHLCSats { diff --git a/crates/brk_types/src/pagination.rs b/crates/brk_types/src/pagination.rs index e4bfca15e..6213b883f 100644 --- a/crates/brk_types/src/pagination.rs +++ b/crates/brk_types/src/pagination.rs @@ -8,20 +8,31 @@ pub struct Pagination { #[serde(default, alias = "p")] #[schemars(example = 0, example = 1, example = 2)] pub page: Option, + /// Results per page (default: 1000, max: 1000) + #[serde(default)] + #[schemars(example = 100, example = 1000)] + pub per_page: Option, } impl Pagination { - pub const PER_PAGE: usize = 1_000; + pub const DEFAULT_PER_PAGE: usize = 1_000; + pub const MAX_PER_PAGE: usize = 1_000; pub fn page(&self) -> usize { self.page.unwrap_or_default() } + pub fn per_page(&self) -> usize { + self.per_page + .unwrap_or(Self::DEFAULT_PER_PAGE) + .min(Self::MAX_PER_PAGE) + } + pub fn start(&self, len: usize) -> usize { - (self.page() * Self::PER_PAGE).clamp(0, len) + (self.page() * self.per_page()).clamp(0, len) } pub fn end(&self, len: usize) -> usize { - ((self.page() + 1) * Self::PER_PAGE).clamp(0, len) + ((self.page() + 1) * self.per_page()).clamp(0, len) } } diff --git a/crates/brk_types/src/poolslug.rs b/crates/brk_types/src/poolslug.rs index 4f5aadc39..da5793485 100644 --- a/crates/brk_types/src/poolslug.rs +++ b/crates/brk_types/src/poolslug.rs @@ -169,22 +169,22 @@ pub enum PoolSlug { Ocean, WhitePool, Wiz, - #[schemars(skip)] + #[serde(skip)] Dummy145, - #[schemars(skip)] + #[serde(skip)] Dummy146, Wk057, FutureBitApolloSolo, - #[schemars(skip)] + #[serde(skip)] Dummy149, - #[schemars(skip)] + #[serde(skip)] Dummy150, CarbonNegative, PortlandHodl, Phoenix, Neopool, MaxiPool, - #[schemars(skip)] + #[serde(skip)] Dummy156, BitFuFuPool, GDPool, @@ -192,187 +192,187 @@ pub enum PoolSlug { PublicPool, MiningSquared, InnopolisTech, - #[schemars(skip)] + #[serde(skip)] Dummy163, BtcLab, Parasite, RedRockPool, Est3lar, - #[schemars(skip)] + #[serde(skip)] Dummy168, - #[schemars(skip)] + #[serde(skip)] Dummy169, - #[schemars(skip)] + #[serde(skip)] Dummy170, - #[schemars(skip)] + #[serde(skip)] Dummy171, - #[schemars(skip)] + #[serde(skip)] Dummy172, - #[schemars(skip)] + #[serde(skip)] Dummy173, - #[schemars(skip)] + #[serde(skip)] Dummy174, - #[schemars(skip)] + #[serde(skip)] Dummy175, - #[schemars(skip)] + #[serde(skip)] Dummy176, - #[schemars(skip)] + #[serde(skip)] Dummy177, - #[schemars(skip)] + #[serde(skip)] Dummy178, - #[schemars(skip)] + #[serde(skip)] Dummy179, - #[schemars(skip)] + #[serde(skip)] Dummy180, - #[schemars(skip)] + #[serde(skip)] Dummy181, - #[schemars(skip)] + #[serde(skip)] Dummy182, - #[schemars(skip)] + #[serde(skip)] Dummy183, - #[schemars(skip)] + #[serde(skip)] Dummy184, - #[schemars(skip)] + #[serde(skip)] Dummy185, - #[schemars(skip)] + #[serde(skip)] Dummy186, - #[schemars(skip)] + #[serde(skip)] Dummy187, - #[schemars(skip)] + #[serde(skip)] Dummy188, - #[schemars(skip)] + #[serde(skip)] Dummy189, - #[schemars(skip)] + #[serde(skip)] Dummy190, - #[schemars(skip)] + #[serde(skip)] Dummy191, - #[schemars(skip)] + #[serde(skip)] Dummy192, - #[schemars(skip)] + #[serde(skip)] Dummy193, - #[schemars(skip)] + #[serde(skip)] Dummy194, - #[schemars(skip)] + #[serde(skip)] Dummy195, - #[schemars(skip)] + #[serde(skip)] Dummy196, - #[schemars(skip)] + #[serde(skip)] Dummy197, - #[schemars(skip)] + #[serde(skip)] Dummy198, - #[schemars(skip)] + #[serde(skip)] Dummy199, - #[schemars(skip)] + #[serde(skip)] Dummy200, - #[schemars(skip)] + #[serde(skip)] Dummy201, - #[schemars(skip)] + #[serde(skip)] Dummy202, - #[schemars(skip)] + #[serde(skip)] Dummy203, - #[schemars(skip)] + #[serde(skip)] Dummy204, - #[schemars(skip)] + #[serde(skip)] Dummy205, - #[schemars(skip)] + #[serde(skip)] Dummy206, - #[schemars(skip)] + #[serde(skip)] Dummy207, - #[schemars(skip)] + #[serde(skip)] Dummy208, - #[schemars(skip)] + #[serde(skip)] Dummy209, - #[schemars(skip)] + #[serde(skip)] Dummy210, - #[schemars(skip)] + #[serde(skip)] Dummy211, - #[schemars(skip)] + #[serde(skip)] Dummy212, - #[schemars(skip)] + #[serde(skip)] Dummy213, - #[schemars(skip)] + #[serde(skip)] Dummy214, - #[schemars(skip)] + #[serde(skip)] Dummy215, - #[schemars(skip)] + #[serde(skip)] Dummy216, - #[schemars(skip)] + #[serde(skip)] Dummy217, - #[schemars(skip)] + #[serde(skip)] Dummy218, - #[schemars(skip)] + #[serde(skip)] Dummy219, - #[schemars(skip)] + #[serde(skip)] Dummy220, - #[schemars(skip)] + #[serde(skip)] Dummy221, - #[schemars(skip)] + #[serde(skip)] Dummy222, - #[schemars(skip)] + #[serde(skip)] Dummy223, - #[schemars(skip)] + #[serde(skip)] Dummy224, - #[schemars(skip)] + #[serde(skip)] Dummy225, - #[schemars(skip)] + #[serde(skip)] Dummy226, - #[schemars(skip)] + #[serde(skip)] Dummy227, - #[schemars(skip)] + #[serde(skip)] Dummy228, - #[schemars(skip)] + #[serde(skip)] Dummy229, - #[schemars(skip)] + #[serde(skip)] Dummy230, - #[schemars(skip)] + #[serde(skip)] Dummy231, - #[schemars(skip)] + #[serde(skip)] Dummy232, - #[schemars(skip)] + #[serde(skip)] Dummy233, - #[schemars(skip)] + #[serde(skip)] Dummy234, - #[schemars(skip)] + #[serde(skip)] Dummy235, - #[schemars(skip)] + #[serde(skip)] Dummy236, - #[schemars(skip)] + #[serde(skip)] Dummy237, - #[schemars(skip)] + #[serde(skip)] Dummy238, - #[schemars(skip)] + #[serde(skip)] Dummy239, - #[schemars(skip)] + #[serde(skip)] Dummy240, - #[schemars(skip)] + #[serde(skip)] Dummy241, - #[schemars(skip)] + #[serde(skip)] Dummy242, - #[schemars(skip)] + #[serde(skip)] Dummy243, - #[schemars(skip)] + #[serde(skip)] Dummy244, - #[schemars(skip)] + #[serde(skip)] Dummy245, - #[schemars(skip)] + #[serde(skip)] Dummy246, - #[schemars(skip)] + #[serde(skip)] Dummy247, - #[schemars(skip)] + #[serde(skip)] Dummy248, - #[schemars(skip)] + #[serde(skip)] Dummy249, - #[schemars(skip)] + #[serde(skip)] Dummy250, - #[schemars(skip)] + #[serde(skip)] Dummy251, - #[schemars(skip)] + #[serde(skip)] Dummy252, - #[schemars(skip)] + #[serde(skip)] Dummy253, - #[schemars(skip)] + #[serde(skip)] Dummy254, - #[schemars(skip)] + #[serde(skip)] Dummy255, } diff --git a/crates/brk_types/src/range_map.rs b/crates/brk_types/src/range_map.rs index 3d69a8267..9c049222f 100644 --- a/crates/brk_types/src/range_map.rs +++ b/crates/brk_types/src/range_map.rs @@ -51,6 +51,7 @@ impl Default for RangeMap { impl, V: From + Copy + Default> RangeMap { /// Number of ranges stored. + #[allow(clippy::len_without_is_empty)] pub fn len(&self) -> usize { self.first_indexes.len() } @@ -102,7 +103,12 @@ impl, V: From + Copy + Default> Ran if pos > 0 { let value = V::from(pos - 1); if pos < self.first_indexes.len() { - self.cache[slot] = (self.first_indexes[pos - 1], self.first_indexes[pos], value, true); + self.cache[slot] = ( + self.first_indexes[pos - 1], + self.first_indexes[pos], + value, + true, + ); } Some(value) } else { diff --git a/crates/brk_types/src/rangeindex.rs b/crates/brk_types/src/rangeindex.rs index 58d7cf388..d8865fe1b 100644 --- a/crates/brk_types/src/rangeindex.rs +++ b/crates/brk_types/src/rangeindex.rs @@ -1,3 +1,5 @@ +use std::fmt; + use schemars::JsonSchema; use serde::{Deserialize, Deserializer}; @@ -12,6 +14,34 @@ pub enum RangeIndex { Timestamp(Timestamp), } +impl From for RangeIndex { + fn from(i: i64) -> Self { + Self::Int(i) + } +} + +impl From for RangeIndex { + fn from(d: Date) -> Self { + Self::Date(d) + } +} + +impl From for RangeIndex { + fn from(t: Timestamp) -> Self { + Self::Timestamp(t) + } +} + +impl fmt::Display for RangeIndex { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Int(i) => write!(f, "{i}"), + Self::Date(d) => write!(f, "{d}"), + Self::Timestamp(t) => write!(f, "{t}"), + } + } +} + impl<'de> Deserialize<'de> for RangeIndex { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; diff --git a/crates/brk_types/src/satsfract.rs b/crates/brk_types/src/satsfract.rs index 8e8420aac..72d49723a 100644 --- a/crates/brk_types/src/satsfract.rs +++ b/crates/brk_types/src/satsfract.rs @@ -189,8 +189,19 @@ impl std::fmt::Display for SatsFract { impl Formattable for SatsFract { #[inline(always)] fn write_to(&self, buf: &mut Vec) { - let mut b = ryu::Buffer::new(); - buf.extend_from_slice(b.format(self.0).as_bytes()); + if !self.0.is_nan() { + let mut b = ryu::Buffer::new(); + buf.extend_from_slice(b.format(self.0).as_bytes()); + } + } + + #[inline(always)] + fn fmt_json(&self, buf: &mut Vec) { + if self.0.is_nan() { + buf.extend_from_slice(b"null"); + } else { + self.write_to(buf); + } } } diff --git a/crates/brk_types/src/stored_f32.rs b/crates/brk_types/src/stored_f32.rs index c7987e398..d62275cad 100644 --- a/crates/brk_types/src/stored_f32.rs +++ b/crates/brk_types/src/stored_f32.rs @@ -274,7 +274,18 @@ impl std::fmt::Display for StoredF32 { impl Formattable for StoredF32 { #[inline(always)] fn write_to(&self, buf: &mut Vec) { - let mut b = ryu::Buffer::new(); - buf.extend_from_slice(b.format(self.0).as_bytes()); + if !self.0.is_nan() { + let mut b = ryu::Buffer::new(); + buf.extend_from_slice(b.format(self.0).as_bytes()); + } + } + + #[inline(always)] + fn fmt_json(&self, buf: &mut Vec) { + if self.0.is_nan() { + buf.extend_from_slice(b"null"); + } else { + self.write_to(buf); + } } } diff --git a/crates/brk_types/src/stored_f64.rs b/crates/brk_types/src/stored_f64.rs index 2c66347bc..a42ca9238 100644 --- a/crates/brk_types/src/stored_f64.rs +++ b/crates/brk_types/src/stored_f64.rs @@ -247,7 +247,18 @@ impl std::fmt::Display for StoredF64 { impl Formattable for StoredF64 { #[inline(always)] fn write_to(&self, buf: &mut Vec) { - let mut b = ryu::Buffer::new(); - buf.extend_from_slice(b.format(self.0).as_bytes()); + if !self.0.is_nan() { + let mut b = ryu::Buffer::new(); + buf.extend_from_slice(b.format(self.0).as_bytes()); + } + } + + #[inline(always)] + fn fmt_json(&self, buf: &mut Vec) { + if self.0.is_nan() { + buf.extend_from_slice(b"null"); + } else { + self.write_to(buf); + } } } diff --git a/crates/brk_types/src/treenode.rs b/crates/brk_types/src/treenode.rs index d74473480..8383bfdfe 100644 --- a/crates/brk_types/src/treenode.rs +++ b/crates/brk_types/src/treenode.rs @@ -487,10 +487,7 @@ mod tests { "epoch", branch(vec![ ("sum", leaf("metric_sum", Index::Epoch)), - ( - "cumulative", - leaf("metric_cumulative", Index::Epoch), - ), + ("cumulative", leaf("metric_cumulative", Index::Epoch)), ]), ), ]); diff --git a/crates/brk_types/src/txin.rs b/crates/brk_types/src/txin.rs index 254c23324..4c1e120f0 100644 --- a/crates/brk_types/src/txin.rs +++ b/crates/brk_types/src/txin.rs @@ -1,5 +1,5 @@ use crate::{TxOut, Txid, Vout}; -use bitcoin::{Script, ScriptBuf}; +use bitcoin::ScriptBuf; use schemars::JsonSchema; use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct}; @@ -60,8 +60,11 @@ impl TxIn { self.script_sig.to_asm_string() } - pub fn redeem_script(&self) -> Option<&Script> { - self.script_sig.redeem_script() + pub fn inner_redeemscript_asm(&self) -> String { + self.script_sig + .redeem_script() + .map(|s| s.to_asm_string()) + .unwrap_or_default() } } @@ -79,7 +82,7 @@ impl Serialize for TxIn { state.serialize_field("scriptsig_asm", &self.script_sig_asm())?; state.serialize_field("is_coinbase", &self.is_coinbase)?; state.serialize_field("sequence", &self.sequence)?; - state.serialize_field("inner_redeemscript_asm", &self.redeem_script())?; + state.serialize_field("inner_redeemscript_asm", &self.inner_redeemscript_asm())?; state.end() } diff --git a/crates/brk_types/src/vout.rs b/crates/brk_types/src/vout.rs index ffba19f48..c90ae2f87 100644 --- a/crates/brk_types/src/vout.rs +++ b/crates/brk_types/src/vout.rs @@ -40,6 +40,13 @@ impl Vout { } } +impl From for Vout { + #[inline] + fn from(value: u16) -> Self { + Self(value) + } +} + const U16_MAX_AS_U32: u32 = u16::MAX as u32; impl From for Vout { #[inline] diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 87fb457ed..d87f85e3b 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -48,7 +48,6 @@ /** * @typedef {Object} AddressTxidsParam * @property {(Txid|null)=} afterTxid - Txid to paginate from (return transactions before this one) - * @property {number=} limit - Maximum number of results to return. Defaults to 25 if not specified. */ /** * Address validation result @@ -603,13 +602,17 @@ * @typedef {Object} PaginatedMetrics * @property {number} currentPage - Current page number (0-indexed) * @property {number} maxPage - Maximum valid page index (0-indexed) - * @property {string[]} metrics - List of metric names (max 1000 per page) + * @property {number} totalCount - Total number of metrics + * @property {number} perPage - Results per page + * @property {boolean} hasMore - Whether more pages are available after the current one + * @property {string[]} metrics - List of metric names */ /** * Pagination parameters for paginated API endpoints * * @typedef {Object} Pagination * @property {?number=} page - Pagination index + * @property {?number=} perPage - Results per page (default: 1000, max: 1000) */ /** * Block counts for different time periods @@ -8732,23 +8735,21 @@ class BrkClient extends BrkClientBase { } /** - * Address transaction IDs + * Address transactions * - * Get transaction IDs for an address, newest first. Use after_txid for pagination. + * Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid= for pagination. * * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* * * Endpoint: `GET /api/address/{address}/txs` * * @param {Address} address - * @param {string=} [after_txid] - Txid to paginate from (return transactions before this one) - * @param {number=} [limit] - Maximum number of results to return. Defaults to 25 if not specified. - * @returns {Promise} + * @param {Txid=} [after_txid] - Txid to paginate from (return transactions before this one) + * @returns {Promise} */ - async getAddressTxs(address, after_txid, limit) { + async getAddressTxs(address, after_txid) { const params = new URLSearchParams(); if (after_txid !== undefined) params.set('after_txid', String(after_txid)); - if (limit !== undefined) params.set('limit', String(limit)); const query = params.toString(); const path = `/api/address/${address}/txs${query ? '?' + query : ''}`; return this.getJson(path); @@ -8757,21 +8758,19 @@ class BrkClient extends BrkClientBase { /** * Address confirmed transactions * - * Get confirmed transaction IDs for an address, 25 per page. Use ?after_txid= for pagination. + * Get confirmed transactions for an address, 25 per page. Use ?after_txid= for pagination. * * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* * * Endpoint: `GET /api/address/{address}/txs/chain` * * @param {Address} address - * @param {string=} [after_txid] - Txid to paginate from (return transactions before this one) - * @param {number=} [limit] - Maximum number of results to return. Defaults to 25 if not specified. - * @returns {Promise} + * @param {Txid=} [after_txid] - Txid to paginate from (return transactions before this one) + * @returns {Promise} */ - async getAddressConfirmedTxs(address, after_txid, limit) { + async getAddressConfirmedTxs(address, after_txid) { const params = new URLSearchParams(); if (after_txid !== undefined) params.set('after_txid', String(after_txid)); - if (limit !== undefined) params.set('limit', String(limit)); const query = params.toString(); const path = `/api/address/${address}/txs/chain${query ? '?' + query : ''}`; return this.getJson(path); @@ -9016,9 +9015,9 @@ class BrkClient extends BrkClientBase { * * @param {Metric} metric - Metric name * @param {Index} index - Aggregation index - * @param {string=} [start] - Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` - * @param {string=} [end] - Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` - * @param {string=} [limit] - Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` + * @param {RangeIndex=} [start] - Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` + * @param {RangeIndex=} [end] - Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` + * @param {Limit=} [limit] - Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` * @param {Format=} [format] - Format of the output * @returns {Promise} */ @@ -9045,9 +9044,9 @@ class BrkClient extends BrkClientBase { * * @param {Metric} metric - Metric name * @param {Index} index - Aggregation index - * @param {string=} [start] - Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` - * @param {string=} [end] - Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` - * @param {string=} [limit] - Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` + * @param {RangeIndex=} [start] - Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` + * @param {RangeIndex=} [end] - Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` + * @param {Limit=} [limit] - Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` * @param {Format=} [format] - Format of the output * @returns {Promise} */ @@ -9131,9 +9130,9 @@ class BrkClient extends BrkClientBase { * * @param {Metrics} [metrics] - Requested metrics * @param {Index} [index] - Index to query - * @param {string=} [start] - Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` - * @param {string=} [end] - Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` - * @param {string=} [limit] - Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` + * @param {RangeIndex=} [start] - Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` + * @param {RangeIndex=} [end] - Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` + * @param {Limit=} [limit] - Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` * @param {Format=} [format] - Format of the output * @returns {Promise} */ @@ -9237,11 +9236,13 @@ class BrkClient extends BrkClientBase { * Endpoint: `GET /api/metrics/list` * * @param {number=} [page] - Pagination index + * @param {number=} [per_page] - Results per page (default: 1000, max: 1000) * @returns {Promise} */ - async listMetrics(page) { + async listMetrics(page, per_page) { const params = new URLSearchParams(); if (page !== undefined) params.set('page', String(page)); + if (per_page !== undefined) params.set('per_page', String(per_page)); const query = params.toString(); const path = `/api/metrics/list${query ? '?' + query : ''}`; return this.getJson(path); diff --git a/modules/brk-client/tests/metric_data.js b/modules/brk-client/tests/metric_data.js index cb44041a8..8bf935eec 100644 --- a/modules/brk-client/tests/metric_data.js +++ b/modules/brk-client/tests/metric_data.js @@ -68,7 +68,9 @@ console.log("\n7. dateEntries():"); const dateEntries = price.dateEntries(); if (!(dateEntries[0][0] instanceof Date)) throw new Error("Expected Date entry key"); -console.log(` First: [${dateEntries[0][0].toISOString()}, ${dateEntries[0][1]}]`); +console.log( + ` First: [${dateEntries[0][0].toISOString()}, ${dateEntries[0][1]}]`, +); // Test toMap() - returns Map console.log("\n8. toMap():"); @@ -95,7 +97,7 @@ if (count !== 5) throw new Error("Expected 5 iterations"); // Test with non-date-based index (height) console.log("\n11. Testing height-based metric:"); -const heightMetric = await client.metrics.prices.price.usd.by.height.last(3); +const heightMetric = await client.metrics.prices.spot.usd.by.height.last(3); console.log( ` Total: ${heightMetric.total}, Start: ${heightMetric.start}, End: ${heightMetric.end}`, ); diff --git a/modules/quickmatch-js/0.3.1/src/index.js b/modules/quickmatch-js/0.3.1/src/index.js index 91bbbdde2..15d02dff7 100644 --- a/modules/quickmatch-js/0.3.1/src/index.js +++ b/modules/quickmatch-js/0.3.1/src/index.js @@ -1,6 +1,7 @@ const DEFAULT_SEPARATORS = "_- ,:"; const DEFAULT_TRIGRAM_BUDGET = 6; const DEFAULT_LIMIT = 100; +const DEFAULT_MIN_SCORE = 2; /** * Configuration for QuickMatch. @@ -15,6 +16,9 @@ export class QuickMatchConfig { /** @type {number} Number of trigram lookups for fuzzy matching (0-20) */ trigramBudget = DEFAULT_TRIGRAM_BUDGET; + /** @type {number} Minimum trigram score required for fuzzy matches */ + minScore = DEFAULT_MIN_SCORE; + /** * Set maximum number of results. * @param {number} n @@ -42,6 +46,16 @@ export class QuickMatchConfig { this.separators = s; return this; } + + /** + * Set minimum trigram score for fuzzy matches. + * Higher values require more trigram overlap, reducing noise. + * @param {number} n - Minimum score (default: 2, min: 1) + */ + withMinScore(n) { + this.minScore = Math.max(1, n); + return this; + } } /** @@ -179,7 +193,7 @@ export class QuickMatch { minItemLength, }); - const minScoreToInclude = Math.max(1, Math.ceil(hitCount / 2)); + const minScoreToInclude = Math.max(config.minScore, Math.ceil(hitCount / 2)); return this.rankedResults(scores, minScoreToInclude, limit); } diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index 7ecbca56d..c621ffed7 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -264,10 +264,8 @@ class AddressTxidsParam(TypedDict): """ Attributes: after_txid: Txid to paginate from (return transactions before this one) - limit: Maximum number of results to return. Defaults to 25 if not specified. """ after_txid: Union[Txid, None] - limit: int class AddressValidation(TypedDict): """ @@ -767,10 +765,16 @@ class PaginatedMetrics(TypedDict): Attributes: current_page: Current page number (0-indexed) max_page: Maximum valid page index (0-indexed) - metrics: List of metric names (max 1000 per page) + total_count: Total number of metrics + per_page: Results per page + has_more: Whether more pages are available after the current one + metrics: List of metric names """ current_page: int max_page: int + total_count: int + per_page: int + has_more: bool metrics: List[str] class Pagination(TypedDict): @@ -779,8 +783,10 @@ class Pagination(TypedDict): Attributes: page: Pagination index + per_page: Results per page (default: 1000, max: 1000) """ page: Optional[int] + per_page: Optional[int] class PoolBlockCounts(TypedDict): """ @@ -4481,7 +4487,7 @@ class MetricsTree_Market_Dca: def __init__(self, client: BrkClientBase, base_path: str = ''): self.sats_per_day: MetricPattern18[Sats] = MetricPattern18(client, 'dca_sats_per_day') self.period: MetricsTree_Market_Dca_Period = MetricsTree_Market_Dca_Period(client) - self.class: MetricsTree_Market_Dca_Class = MetricsTree_Market_Dca_Class(client) + self.class_: MetricsTree_Market_Dca_Class = MetricsTree_Market_Dca_Class(client) class MetricsTree_Market_Technical_Rsi_1w: """Metrics tree node.""" @@ -5303,7 +5309,7 @@ class MetricsTree_Distribution_Cohorts_Utxo: self.under_age: MetricsTree_Distribution_Cohorts_Utxo_UnderAge = MetricsTree_Distribution_Cohorts_Utxo_UnderAge(client) self.over_age: MetricsTree_Distribution_Cohorts_Utxo_OverAge = MetricsTree_Distribution_Cohorts_Utxo_OverAge(client) self.epoch: MetricsTree_Distribution_Cohorts_Utxo_Epoch = MetricsTree_Distribution_Cohorts_Utxo_Epoch(client) - self.class: MetricsTree_Distribution_Cohorts_Utxo_Class = MetricsTree_Distribution_Cohorts_Utxo_Class(client) + self.class_: MetricsTree_Distribution_Cohorts_Utxo_Class = MetricsTree_Distribution_Cohorts_Utxo_Class(client) self.over_amount: MetricsTree_Distribution_Cohorts_Utxo_OverAmount = MetricsTree_Distribution_Cohorts_Utxo_OverAmount(client) self.amount_range: MetricsTree_Distribution_Cohorts_Utxo_AmountRange = MetricsTree_Distribution_Cohorts_Utxo_AmountRange(client) self.under_amount: MetricsTree_Distribution_Cohorts_Utxo_UnderAmount = MetricsTree_Distribution_Cohorts_Utxo_UnderAmount(client) @@ -6392,32 +6398,30 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/address/{address}`""" return self.get_json(f'/api/address/{address}') - def get_address_txs(self, address: Address, after_txid: Optional[str] = None, limit: Optional[float] = None) -> List[Txid]: - """Address transaction IDs. + def get_address_txs(self, address: Address, after_txid: Optional[Txid] = None) -> List[Transaction]: + """Address transactions. - Get transaction IDs for an address, newest first. Use after_txid for pagination. + Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid= for pagination. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* Endpoint: `GET /api/address/{address}/txs`""" params = [] if after_txid is not None: params.append(f'after_txid={after_txid}') - if limit is not None: params.append(f'limit={limit}') query = '&'.join(params) path = f'/api/address/{address}/txs{"?" + query if query else ""}' return self.get_json(path) - def get_address_confirmed_txs(self, address: Address, after_txid: Optional[str] = None, limit: Optional[float] = None) -> List[Txid]: + def get_address_confirmed_txs(self, address: Address, after_txid: Optional[Txid] = None) -> List[Transaction]: """Address confirmed transactions. - Get confirmed transaction IDs for an address, 25 per page. Use ?after_txid= for pagination. + Get confirmed transactions for an address, 25 per page. Use ?after_txid= for pagination. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* Endpoint: `GET /api/address/{address}/txs/chain`""" params = [] if after_txid is not None: params.append(f'after_txid={after_txid}') - if limit is not None: params.append(f'limit={limit}') query = '&'.join(params) path = f'/api/address/{address}/txs/chain{"?" + query if query else ""}' return self.get_json(path) @@ -6568,7 +6572,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/metric/{metric}`""" return self.get_json(f'/api/metric/{metric}') - def get_metric(self, metric: Metric, index: Index, start: Optional[str] = None, end: Optional[str] = None, limit: Optional[str] = None, format: Optional[Format] = None) -> Union[AnyMetricData, str]: + def get_metric(self, metric: Metric, index: Index, start: Optional[RangeIndex] = None, end: Optional[RangeIndex] = None, limit: Optional[Limit] = None, format: Optional[Format] = None) -> Union[AnyMetricData, str]: """Get metric data. Fetch data for a specific metric at the given index. Use query parameters to filter by date range and format (json/csv). @@ -6585,7 +6589,7 @@ class BrkClient(BrkClientBase): return self.get_text(path) return self.get_json(path) - def get_metric_data(self, metric: Metric, index: Index, start: Optional[str] = None, end: Optional[str] = None, limit: Optional[str] = None, format: Optional[Format] = None) -> Union[List[bool], str]: + def get_metric_data(self, metric: Metric, index: Index, start: Optional[RangeIndex] = None, end: Optional[RangeIndex] = None, limit: Optional[Limit] = None, format: Optional[Format] = None) -> Union[List[bool], str]: """Get raw metric data. Returns just the data array without the MetricData wrapper. Supports the same range and format parameters as the standard endpoint. @@ -6634,7 +6638,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/metrics`""" return self.get_json('/api/metrics') - def get_metrics(self, metrics: Metrics, index: Index, start: Optional[str] = None, end: Optional[str] = None, limit: Optional[str] = None, format: Optional[Format] = None) -> Union[List[AnyMetricData], str]: + def get_metrics(self, metrics: Metrics, index: Index, start: Optional[RangeIndex] = None, end: Optional[RangeIndex] = None, limit: Optional[Limit] = None, format: Optional[Format] = None) -> Union[List[AnyMetricData], str]: """Bulk metric data. Fetch multiple metrics in a single request. Supports filtering by index and date range. Returns an array of MetricData objects. For a single metric, use `get_metric` instead. @@ -6702,7 +6706,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/metrics/indexes`""" return self.get_json('/api/metrics/indexes') - def list_metrics(self, page: Optional[float] = None) -> PaginatedMetrics: + def list_metrics(self, page: Optional[float] = None, per_page: Optional[float] = None) -> PaginatedMetrics: """Metrics list. Paginated flat list of all available metric names. Use `page` query param for pagination. @@ -6710,6 +6714,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/metrics/list`""" params = [] if page is not None: params.append(f'page={page}') + if per_page is not None: params.append(f'per_page={per_page}') query = '&'.join(params) path = f'/api/metrics/list{"?" + query if query else ""}' return self.get_json(path)