From 6f879a5551c4114f700469c101475c7f6ff7516b Mon Sep 17 00:00:00 2001 From: nym21 Date: Fri, 1 May 2026 19:14:15 +0200 Subject: [PATCH] global: fixes --- Cargo.lock | 38 ++-- .../src/generators/javascript/api.rs | 30 ++- .../src/generators/javascript/client.rs | 11 ++ .../brk_bindgen/src/generators/python/api.rs | 65 +++++-- crates/brk_bindgen/src/generators/rust/api.rs | 20 +- .../brk_bindgen/src/generators/rust/client.rs | 8 + crates/brk_bindgen/src/openapi/endpoint.rs | 81 +++++++++ .../src/{openapi.rs => openapi/mod.rs} | 171 +++++++----------- crates/brk_bindgen/src/openapi/parameter.rs | 8 + .../brk_bindgen/src/openapi/response_kind.rs | 29 +++ crates/brk_bindgen/src/openapi/text_schema.rs | 8 + crates/brk_client/src/lib.rs | 54 +++++- .../src/stores/tx_graveyard/mod.rs | 18 +- crates/brk_query/src/async.rs | 6 +- crates/brk_query/src/impl/mempool.rs | 105 +++++++---- crates/brk_query/src/impl/price.rs | 7 +- crates/brk_query/src/impl/series.rs | 4 +- crates/brk_query/src/lib.rs | 13 +- crates/brk_query/src/vecs.rs | 31 +--- crates/brk_server/examples/bindgen.rs | 3 +- crates/brk_server/src/api/blocks.rs | 13 +- crates/brk_server/src/api/mempool.rs | 44 ++++- crates/brk_server/src/api/server.rs | 4 +- crates/brk_server/src/api/transactions.rs | 12 +- crates/brk_server/src/extended/mod.rs | 2 + .../src/extended/transform_operation.rs | 15 +- crates/brk_server/src/extended/typed_text.rs | 49 +++++ crates/brk_server/src/params/txids_param.rs | 35 ++++ crates/brk_types/src/pagination.rs | 1 + crates/brk_types/src/rbf.rs | 9 + modules/brk-client/index.js | 125 ++++++++++--- packages/brk_client/brk_client/__init__.py | 87 +++++++-- .../general/test_historical_price.py | 98 +++++++--- .../mempool_compat/general/test_prices.py | 43 +++-- .../mempool/test_fullrbf_replacements.py | 19 ++ .../mempool/test_replacements.py | 20 ++ 36 files changed, 949 insertions(+), 337 deletions(-) create mode 100644 crates/brk_bindgen/src/openapi/endpoint.rs rename crates/brk_bindgen/src/{openapi.rs => openapi/mod.rs} (71%) create mode 100644 crates/brk_bindgen/src/openapi/parameter.rs create mode 100644 crates/brk_bindgen/src/openapi/response_kind.rs create mode 100644 crates/brk_bindgen/src/openapi/text_schema.rs create mode 100644 crates/brk_server/src/extended/typed_text.rs create mode 100644 packages/brk_client/tests/mempool_compat/mempool/test_fullrbf_replacements.py create mode 100644 packages/brk_client/tests/mempool_compat/mempool/test_replacements.py diff --git a/Cargo.lock b/Cargo.lock index 9ec41c934..695bd367e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1714,9 +1714,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1851,10 +1851,12 @@ checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -2543,9 +2545,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", @@ -3348,9 +3350,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", @@ -3361,9 +3363,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3371,9 +3373,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ "bumpalo", "proc-macro2", @@ -3384,9 +3386,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] @@ -3427,9 +3429,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" dependencies = [ "js-sys", "wasm-bindgen", @@ -3745,9 +3747,9 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yeslogic-fontconfig-sys" -version = "6.0.0" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" dependencies = [ "dlib", "once_cell", diff --git a/crates/brk_bindgen/src/generators/javascript/api.rs b/crates/brk_bindgen/src/generators/javascript/api.rs index 0e96b0726..4652cbeb1 100644 --- a/crates/brk_bindgen/src/generators/javascript/api.rs +++ b/crates/brk_bindgen/src/generators/javascript/api.rs @@ -16,9 +16,13 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) { } let method_name = endpoint_to_method_name(endpoint); - let base_return_type = jsdoc_normalize(&normalize_return_type( - endpoint.response_type.as_deref().unwrap_or("*"), - )); + let base_return_type = if endpoint.returns_binary() { + "Uint8Array".to_string() + } else { + jsdoc_normalize(&normalize_return_type( + endpoint.schema_name().unwrap_or("*"), + )) + }; let return_type = if endpoint.supports_csv { format!("{} | string", base_return_type) } else { @@ -86,10 +90,14 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) { let path = build_path_template(&endpoint.path, &endpoint.path_params); - let fetch_call = if endpoint.returns_json() { - "this.getJson(path, { signal, onValue })" + let fetch_call: String = if endpoint.returns_binary() { + "this.getBytes(path, { signal, onValue })".to_string() + } else if endpoint.returns_json() { + "this.getJson(path, { signal, onValue })".to_string() + } else if endpoint.response_kind.text_is_numeric() { + "Number(await this.getText(path, { signal, onValue }))".to_string() } else { - "this.getText(path, { signal, onValue })" + "this.getText(path, { signal, onValue })".to_string() }; if endpoint.query_params.is_empty() { @@ -98,7 +106,15 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) { writeln!(output, " const params = new URLSearchParams();").unwrap(); for param in &endpoint.query_params { let ident = sanitize_ident(¶m.name); - if param.required { + let is_array = param.param_type.ends_with("[]"); + if is_array { + writeln!( + output, + " for (const _v of {}) params.append('{}', String(_v));", + ident, param.name + ) + .unwrap(); + } else if param.required { writeln!( output, " params.set('{}', String({}));", diff --git a/crates/brk_bindgen/src/generators/javascript/client.rs b/crates/brk_bindgen/src/generators/javascript/client.rs index 728cf9b17..8e5b60680 100644 --- a/crates/brk_bindgen/src/generators/javascript/client.rs +++ b/crates/brk_bindgen/src/generators/javascript/client.rs @@ -558,6 +558,17 @@ class BrkClientBase {{ return this._getCached(path, (res) => res.text(), options); }} + /** + * Make a GET request expecting binary data (application/octet-stream). + * Cached and supports `onValue`, same as `getJson`. + * @param {{string}} path + * @param {{{{ onValue?: (value: Uint8Array) => void, signal?: AbortSignal }}}} [options] + * @returns {{Promise}} + */ + getBytes(path, options) {{ + return this._getCached(path, async (res) => new Uint8Array(await res.arrayBuffer()), options); + }} + /** * Fetch series data and wrap with helper methods (internal) * @template T diff --git a/crates/brk_bindgen/src/generators/python/api.rs b/crates/brk_bindgen/src/generators/python/api.rs index 04a82fb21..177cdc1b7 100644 --- a/crates/brk_bindgen/src/generators/python/api.rs +++ b/crates/brk_bindgen/src/generators/python/api.rs @@ -96,13 +96,16 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) { } let method_name = endpoint_to_method_name(endpoint); - let base_return_type = normalize_return_type( - &endpoint - .response_type - .as_deref() - .map(js_type_to_python) - .unwrap_or_else(|| "str".to_string()), - ); + let base_return_type = if endpoint.returns_binary() { + "bytes".to_string() + } else { + normalize_return_type( + &endpoint + .schema_name() + .map(js_type_to_python) + .unwrap_or_else(|| "str".to_string()), + ) + }; let return_type = if endpoint.supports_csv { format!("Union[{}, str]", base_return_type) @@ -159,24 +162,50 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) { // Build path let path = build_path_template(&endpoint.path, &endpoint.path_params); - let fetch_method = if endpoint.returns_json() { + let fetch_method = if endpoint.returns_binary() { + "get" + } else if endpoint.returns_json() { "get_json" } else { "get_text" }; + let (wrap_prefix, wrap_suffix) = if endpoint.response_kind.text_is_numeric() { + ("int(", ")") + } else { + ("", "") + }; + if endpoint.query_params.is_empty() { if endpoint.path_params.is_empty() { - writeln!(output, " return self.{}('{}')", fetch_method, path).unwrap(); + writeln!( + output, + " return {}self.{}('{}'){}", + wrap_prefix, fetch_method, path, wrap_suffix + ) + .unwrap(); } else { - writeln!(output, " return self.{}(f'{}')", fetch_method, path).unwrap(); + writeln!( + output, + " return {}self.{}(f'{}'){}", + wrap_prefix, fetch_method, path, wrap_suffix + ) + .unwrap(); } } else { writeln!(output, " params = []").unwrap(); for param in &endpoint.query_params { // Use safe name for Python variable, original name for API query parameter let safe_name = escape_python_keyword(¶m.name); - if param.required { + let is_array = param.param_type.ends_with("[]"); + if is_array { + writeln!( + output, + " for _v in {}: params.append(f'{}={{_v}}')", + safe_name, param.name + ) + .unwrap(); + } else if param.required { writeln!( output, " params.append(f'{}={{{}}}')", @@ -203,9 +232,19 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) { if endpoint.supports_csv { writeln!(output, " if format == 'csv':").unwrap(); writeln!(output, " return self.get_text(path)").unwrap(); - writeln!(output, " return self.{}(path)", fetch_method).unwrap(); + writeln!( + output, + " return {}self.{}(path){}", + wrap_prefix, fetch_method, wrap_suffix + ) + .unwrap(); } else { - writeln!(output, " return self.{}(path)", fetch_method).unwrap(); + writeln!( + output, + " return {}self.{}(path){}", + wrap_prefix, fetch_method, wrap_suffix + ) + .unwrap(); } } diff --git a/crates/brk_bindgen/src/generators/rust/api.rs b/crates/brk_bindgen/src/generators/rust/api.rs index 1ff4e4842..7db36227f 100644 --- a/crates/brk_bindgen/src/generators/rust/api.rs +++ b/crates/brk_bindgen/src/generators/rust/api.rs @@ -89,11 +89,17 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) { } let method_name = endpoint_to_method_name(endpoint); - let base_return_type = endpoint - .response_type - .as_deref() - .map(js_type_to_rust) - .unwrap_or_else(|| "String".to_string()); + let base_return_type = if endpoint.returns_binary() { + "Vec".to_string() + } else if endpoint.returns_text() { + // Text bodies arrive as `String`; per-type parsing is left to the caller. + "String".to_string() + } else { + endpoint + .schema_name() + .map(js_type_to_rust) + .unwrap_or_else(|| "String".to_string()) + }; let return_type = if endpoint.supports_csv { format!("FormatResponse<{}>", base_return_type) @@ -132,7 +138,9 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) { .unwrap(); let (path, index_arg) = build_path_template(endpoint); - let fetch_method = if endpoint.returns_json() { + let fetch_method = if endpoint.returns_binary() { + "get_bytes" + } else if endpoint.returns_json() { "get_json" } else { "get_text" diff --git a/crates/brk_bindgen/src/generators/rust/client.rs b/crates/brk_bindgen/src/generators/rust/client.rs index f4b6cf542..65b0b6987 100644 --- a/crates/brk_bindgen/src/generators/rust/client.rs +++ b/crates/brk_bindgen/src/generators/rust/client.rs @@ -103,6 +103,14 @@ impl BrkClientBase {{ .and_then(|mut r| r.body_mut().read_to_string()) .map_err(|e| BrkError {{ message: e.to_string() }}) }} + + /// Make a GET request and return raw bytes response. + pub fn get_bytes(&self, path: &str) -> Result> {{ + self.agent.get(&self.url(path)) + .call() + .and_then(|mut r| r.body_mut().read_to_vec()) + .map_err(|e| BrkError {{ message: e.to_string() }}) + }} }} /// Build series name with suffix. diff --git a/crates/brk_bindgen/src/openapi/endpoint.rs b/crates/brk_bindgen/src/openapi/endpoint.rs new file mode 100644 index 000000000..7591370ec --- /dev/null +++ b/crates/brk_bindgen/src/openapi/endpoint.rs @@ -0,0 +1,81 @@ +use crate::openapi::{Parameter, ResponseKind}; + +/// Endpoint information extracted from OpenAPI spec. +#[derive(Debug, Clone)] +pub struct Endpoint { + /// HTTP method (GET, POST, etc.) + pub method: String, + /// Path template (e.g., "/blocks/{hash}") + pub path: String, + /// Operation ID (e.g., "getBlockByHash") + pub operation_id: Option, + /// Short summary + pub summary: Option, + /// Detailed description + pub description: Option, + /// Path parameters + pub path_params: Vec, + /// Query parameters + pub query_params: Vec, + /// Body kind for the 200 response. + pub response_kind: ResponseKind, + /// Whether this endpoint is deprecated + pub deprecated: bool, + /// Whether this endpoint supports CSV format (text/csv content type) + pub supports_csv: bool, +} + +impl Endpoint { + /// Returns true if this endpoint should be included in client generation. + /// Only non-deprecated GET endpoints are included. + pub fn should_generate(&self) -> bool { + self.method == "GET" && !self.deprecated + } + + /// Returns true if this endpoint returns JSON. + pub fn returns_json(&self) -> bool { + matches!(self.response_kind, ResponseKind::Json(_)) + } + + /// Returns true if this endpoint returns binary data (application/octet-stream). + pub fn returns_binary(&self) -> bool { + matches!(self.response_kind, ResponseKind::Binary) + } + + /// Returns true if this endpoint returns plain text (typed or opaque). + pub fn returns_text(&self) -> bool { + matches!(self.response_kind, ResponseKind::Text(_)) + } + + /// Schema name attached to the response, if any. + pub fn schema_name(&self) -> Option<&str> { + self.response_kind.schema_name() + } + + /// Returns the operation ID or generates one from the path. + /// The returned string uses the raw case from the spec (typically camelCase). + pub fn operation_name(&self) -> String { + if let Some(op_id) = &self.operation_id { + return op_id.clone(); + } + let mut parts: Vec = Vec::new(); + let mut prev_segment = ""; + + for segment in self.path.split('/').filter(|s| !s.is_empty()) { + if segment == "api" { + continue; + } + if let Some(param) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) { + let prev_normalized = prev_segment.replace('-', "_"); + if !prev_normalized.ends_with(param) { + parts.push(format!("by_{}", param)); + } + } else { + let normalized = segment.replace('-', "_"); + parts.push(normalized); + prev_segment = segment; + } + } + format!("get_{}", parts.join("_")) + } +} diff --git a/crates/brk_bindgen/src/openapi.rs b/crates/brk_bindgen/src/openapi/mod.rs similarity index 71% rename from crates/brk_bindgen/src/openapi.rs rename to crates/brk_bindgen/src/openapi/mod.rs index 921564e9d..fda45b0ed 100644 --- a/crates/brk_bindgen/src/openapi.rs +++ b/crates/brk_bindgen/src/openapi/mod.rs @@ -1,3 +1,13 @@ +mod endpoint; +mod parameter; +mod response_kind; +mod text_schema; + +pub use endpoint::Endpoint; +pub use parameter::Parameter; +pub use response_kind::ResponseKind; +pub use text_schema::TextSchema; + use std::{collections::BTreeMap, io}; use crate::ref_to_type_name; @@ -11,83 +21,6 @@ use serde_json::Value; /// Type schema extracted from OpenAPI components pub type TypeSchemas = BTreeMap; -/// Endpoint information extracted from OpenAPI spec -#[derive(Debug, Clone)] -pub struct Endpoint { - /// HTTP method (GET, POST, etc.) - pub method: String, - /// Path template (e.g., "/blocks/{hash}") - pub path: String, - /// Operation ID (e.g., "getBlockByHash") - pub operation_id: Option, - /// Short summary - pub summary: Option, - /// Detailed description - pub description: Option, - /// Path parameters - pub path_params: Vec, - /// Query parameters - pub query_params: Vec, - /// Response type (simplified) - pub response_type: Option, - /// Whether this endpoint is deprecated - pub deprecated: bool, - /// Whether this endpoint supports CSV format (text/csv content type) - pub supports_csv: bool, -} - -impl Endpoint { - /// Returns true if this endpoint should be included in client generation. - /// Only non-deprecated GET endpoints are included. - pub fn should_generate(&self) -> bool { - self.method == "GET" && !self.deprecated - } - - /// Returns true if this endpoint returns JSON (has a response_type extracted from application/json). - pub fn returns_json(&self) -> bool { - self.response_type.is_some() - } - - /// Returns the operation ID or generates one from the path. - /// The returned string uses the raw case from the spec (typically camelCase). - pub fn operation_name(&self) -> String { - if let Some(op_id) = &self.operation_id { - return op_id.clone(); - } - // Generate from path: /api/block/{hash} -> "get_block" - // Skip "api" prefix, convert hyphens to underscores, avoid redundant param names - let mut parts: Vec = Vec::new(); - let mut prev_segment = ""; - - for segment in self.path.split('/').filter(|s| !s.is_empty()) { - if segment == "api" { - continue; - } - if let Some(param) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) { - // Only add "by_{param}" if the previous segment doesn't already contain the param name - let prev_normalized = prev_segment.replace('-', "_"); - if !prev_normalized.ends_with(param) { - parts.push(format!("by_{}", param)); - } - } else { - let normalized = segment.replace('-', "_"); - parts.push(normalized); - prev_segment = segment; - } - } - format!("get_{}", parts.join("_")) - } -} - -/// Parameter information -#[derive(Debug, Clone)] -pub struct Parameter { - pub name: String, - pub required: bool, - pub param_type: String, - pub description: Option, -} - /// Parse OpenAPI spec from JSON string /// /// Pre-processes the JSON to handle oas3 limitations: @@ -164,7 +97,7 @@ pub fn extract_endpoints(spec: &Spec) -> Vec { for (path, path_item) in paths { for (method, operation) in get_operations(path_item) { - if let Some(endpoint) = extract_endpoint(path, method, operation) { + if let Some(endpoint) = extract_endpoint(path, method, operation, spec) { endpoints.push(endpoint); } } @@ -186,11 +119,16 @@ fn get_operations(path_item: &PathItem) -> Vec<(&'static str, &Operation)> { .collect() } -fn extract_endpoint(path: &str, method: &str, operation: &Operation) -> Option { +fn extract_endpoint( + path: &str, + method: &str, + operation: &Operation, + spec: &Spec, +) -> Option { let path_params = extract_path_parameters(path, operation); let query_params = extract_parameters(operation, ParameterIn::Query); - let response_type = extract_response_type(operation); + let response_kind = extract_response_kind(operation, spec); let supports_csv = check_csv_support(operation); Some(Endpoint { @@ -201,7 +139,7 @@ fn extract_endpoint(path: &str, method: &str, operation: &Operation) -> Option Vec Option { - let responses = operation.responses.as_ref()?; +fn extract_response_kind(operation: &Operation, spec: &Spec) -> ResponseKind { + let response = operation + .responses + .as_ref() + .and_then(|r| r.get("200")) + .and_then(|r| match r { + ObjectOrReference::Object(o) => Some(o), + ObjectOrReference::Ref { .. } => None, + }); + let Some(response) = response else { + return ResponseKind::Text(None); + }; - // Look for 200 OK response - let response = responses.get("200")?; - - match response { - ObjectOrReference::Object(response) => { - // Look for JSON content - let content = response.content.get("application/json")?; - - match &content.schema { - Some(ObjectOrReference::Ref { ref_path, .. }) => { - // Extract type name from reference like "#/components/schemas/Block" - Some(ref_to_type_name(ref_path)?.to_string()) - } - Some(ObjectOrReference::Object(schema)) => schema_to_type_name(schema), - None => None, - } - } - ObjectOrReference::Ref { .. } => None, + if response.content.contains_key("application/octet-stream") { + return ResponseKind::Binary; } + if let Some(content) = response.content.get("application/json") { + return ResponseKind::Json( + schema_name_from_content(content).unwrap_or_else(|| "*".to_string()), + ); + } + if let Some(content) = response.content.get("text/plain; charset=utf-8") { + let schema = schema_name_from_content(content).map(|name| { + let is_numeric = is_numeric_schema(spec, &name); + TextSchema { name, is_numeric } + }); + return ResponseKind::Text(schema); + } + ResponseKind::Text(None) +} + +fn schema_name_from_content(content: &oas3::spec::MediaType) -> Option { + match content.schema.as_ref()? { + ObjectOrReference::Ref { ref_path, .. } => { + Some(ref_to_type_name(ref_path)?.to_string()) + } + ObjectOrReference::Object(schema) => schema_to_type_name(schema), + } +} + +/// Resolves `name` against `components.schemas` and reports whether the +/// underlying primitive is `integer` or `number`. +fn is_numeric_schema(spec: &Spec, name: &str) -> bool { + let Some(components) = spec.components.as_ref() else { + return false; + }; + let Some(ObjectOrReference::Object(schema)) = components.schemas.get(name) else { + return false; + }; + matches!( + schema.schema_type.as_ref(), + Some(SchemaTypeSet::Single(SchemaType::Integer | SchemaType::Number)) + ) } fn schema_type_from_schema(schema: &Schema) -> Option { diff --git a/crates/brk_bindgen/src/openapi/parameter.rs b/crates/brk_bindgen/src/openapi/parameter.rs new file mode 100644 index 000000000..e2718b821 --- /dev/null +++ b/crates/brk_bindgen/src/openapi/parameter.rs @@ -0,0 +1,8 @@ +/// Parameter information. +#[derive(Debug, Clone)] +pub struct Parameter { + pub name: String, + pub required: bool, + pub param_type: String, + pub description: Option, +} diff --git a/crates/brk_bindgen/src/openapi/response_kind.rs b/crates/brk_bindgen/src/openapi/response_kind.rs new file mode 100644 index 000000000..24fbf51ac --- /dev/null +++ b/crates/brk_bindgen/src/openapi/response_kind.rs @@ -0,0 +1,29 @@ +use crate::openapi::TextSchema; + +/// 200-response body shape. +#[derive(Debug, Clone)] +pub enum ResponseKind { + /// JSON body, schema named (e.g. "Block"). + Json(String), + /// `text/plain` body. `Some(schema)` carries a typed shape (e.g. "Height", "Hex"); + /// `None` is the escape hatch for opaque text. + Text(Option), + /// `application/octet-stream`. + Binary, +} + +impl ResponseKind { + /// Schema name, if the body is named (Json or typed Text). + pub fn schema_name(&self) -> Option<&str> { + match self { + Self::Json(s) => Some(s.as_str()), + Self::Text(Some(t)) => Some(t.name.as_str()), + _ => None, + } + } + + /// True when a typed text body needs numeric parsing (`int(...)` etc.). + pub fn text_is_numeric(&self) -> bool { + matches!(self, Self::Text(Some(t)) if t.is_numeric) + } +} diff --git a/crates/brk_bindgen/src/openapi/text_schema.rs b/crates/brk_bindgen/src/openapi/text_schema.rs new file mode 100644 index 000000000..1ca604d2c --- /dev/null +++ b/crates/brk_bindgen/src/openapi/text_schema.rs @@ -0,0 +1,8 @@ +/// Schema metadata for a typed `text/plain` response. +#[derive(Debug, Clone)] +pub struct TextSchema { + /// Schema name, e.g. "Height", "Hex". + pub name: String, + /// True when the underlying primitive is `integer`/`number` (body needs numeric parsing). + pub is_numeric: bool, +} diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index 77e465d50..c0e43705f 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -91,6 +91,14 @@ impl BrkClientBase { .and_then(|mut r| r.body_mut().read_to_string()) .map_err(|e| BrkError { message: e.to_string() }) } + + /// Make a GET request and return raw bytes response. + pub fn get_bytes(&self, path: &str) -> Result> { + self.agent.get(&self.url(path)) + .call() + .and_then(|mut r| r.body_mut().read_to_vec()) + .map_err(|e| BrkError { message: e.to_string() }) + } } /// Build series name with suffix. @@ -8977,8 +8985,8 @@ impl BrkClient { /// Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`. /// /// Endpoint: `GET /api.json` - pub fn get_api(&self) -> Result { - self.base.get_text(&format!("/api.json")) + pub fn get_api(&self) -> Result { + self.base.get_json(&format!("/api.json")) } /// Address information @@ -9084,8 +9092,8 @@ impl BrkClient { /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)* /// /// Endpoint: `GET /api/block/{hash}/raw` - pub fn get_block_raw(&self, hash: BlockHash) -> Result { - self.base.get_text(&format!("/api/block/{hash}/raw")) + pub fn get_block_raw(&self, hash: BlockHash) -> Result> { + self.base.get_bytes(&format!("/api/block/{hash}/raw")) } /// Block status @@ -9360,8 +9368,8 @@ impl BrkClient { /// Returns the single most recent value for a series, unwrapped (not inside a SeriesData object). /// /// Endpoint: `GET /api/series/{series}/{index}/latest` - pub fn get_series_latest(&self, series: SeriesName, index: Index) -> Result { - self.base.get_text(&format!("/api/series/{series}/{}/latest", index.name())) + pub fn get_series_latest(&self, series: SeriesName, index: Index) -> Result { + self.base.get_json(&format!("/api/series/{series}/{}/latest", index.name())) } /// Get series data length @@ -9482,8 +9490,8 @@ impl BrkClient { /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-raw)* /// /// Endpoint: `GET /api/tx/{txid}/raw` - pub fn get_tx_raw(&self, txid: Txid) -> Result { - self.base.get_text(&format!("/api/tx/{txid}/raw")) + pub fn get_tx_raw(&self, txid: Txid) -> Result> { + self.base.get_bytes(&format!("/api/tx/{txid}/raw")) } /// Transaction status @@ -9633,6 +9641,17 @@ impl BrkClient { self.base.get_json(&format!("/api/v1/fees/recommended")) } + /// Recent full-RBF replacements + /// + /// Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF). + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)* + /// + /// Endpoint: `GET /api/v1/fullrbf/replacements` + pub fn get_fullrbf_replacements(&self) -> Result> { + self.base.get_json(&format!("/api/v1/fullrbf/replacements")) + } + /// Historical price /// /// Get historical BTC/USD price. Optionally specify a UNIX timestamp to get the price at that time. @@ -9857,6 +9876,17 @@ impl BrkClient { self.base.get_json(&format!("/api/v1/prices")) } + /// Recent RBF replacements + /// + /// Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`. + /// + /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)* + /// + /// Endpoint: `GET /api/v1/replacements` + pub fn get_replacements(&self) -> Result> { + self.base.get_json(&format!("/api/v1/replacements")) + } + /// Transaction first-seen times /// /// Returns timestamps when transactions were first seen in the mempool. Returns 0 for mined or unknown transactions. @@ -9864,8 +9894,12 @@ impl BrkClient { /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-times)* /// /// Endpoint: `GET /api/v1/transaction-times` - pub fn get_transaction_times(&self) -> Result> { - self.base.get_json(&format!("/api/v1/transaction-times")) + pub fn get_transaction_times(&self, txId: &[Txid]) -> Result> { + let mut query = Vec::new(); + for v in txId { query.push(format!("txId[]={}", v)); } + let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; + let path = format!("/api/v1/transaction-times{}", query_str); + self.base.get_json(&path) } /// RBF replacement history diff --git a/crates/brk_mempool/src/stores/tx_graveyard/mod.rs b/crates/brk_mempool/src/stores/tx_graveyard/mod.rs index b7e6cd253..4db8229e9 100644 --- a/crates/brk_mempool/src/stores/tx_graveyard/mod.rs +++ b/crates/brk_mempool/src/stores/tx_graveyard/mod.rs @@ -44,12 +44,20 @@ impl TxGraveyard { } /// Every `Replaced` tombstone, yielded as (predecessor_txid, - /// replacer_txid). Caller walks the replacer chain forward to find + /// replacer_txid) in reverse bury order (most recent replacement + /// event first). Caller walks the replacer chain forward to find /// each tree's terminal replacer. - pub fn replaced_iter(&self) -> impl Iterator { - self.tombstones - .iter() - .filter_map(|(txid, ts)| ts.replaced_by().map(|by| (txid, by))) + /// + /// `order` may carry stale entries (re-buries, prior exhumes); the + /// `removed_at == t` check skips those. + pub fn replaced_iter_recent_first(&self) -> impl Iterator { + self.order.iter().rev().filter_map(|(t, txid)| { + let ts = self.tombstones.get(txid)?; + if ts.removed_at() != *t { + return None; + } + Some((txid, ts.replaced_by()?)) + }) } pub fn bury(&mut self, txid: Txid, tx: Transaction, entry: TxEntry, removal: TxRemoval) { diff --git a/crates/brk_query/src/async.rs b/crates/brk_query/src/async.rs index 04ff04e04..6ed76e68c 100644 --- a/crates/brk_query/src/async.rs +++ b/crates/brk_query/src/async.rs @@ -3,7 +3,6 @@ use brk_error::Result; use brk_indexer::Indexer; use brk_mempool::Mempool; use brk_reader::Reader; -use brk_rpc::Client; use tokio::task::spawn_blocking; use crate::Query; @@ -51,11 +50,8 @@ impl AsyncQuery { f(&self.0) } + #[inline] pub fn inner(&self) -> &Query { &self.0 } - - pub fn client(&self) -> &Client { - self.0.client() - } } diff --git a/crates/brk_query/src/impl/mempool.rs b/crates/brk_query/src/impl/mempool.rs index b135af917..e6c29fd4f 100644 --- a/crates/brk_query/src/impl/mempool.rs +++ b/crates/brk_query/src/impl/mempool.rs @@ -1,5 +1,5 @@ use brk_error::{Error, OptionData, Result}; -use brk_mempool::{EntryPool, TxEntry, TxGraveyard, TxRemoval, TxStore, TxTombstone}; +use brk_mempool::{EntryPool, Mempool, TxEntry, TxGraveyard, TxRemoval, TxStore, TxTombstone}; use brk_types::{ CheckedSub, CpfpEntry, CpfpInfo, FeeRate, MempoolBlock, MempoolInfo, MempoolRecentTx, OutputType, RbfResponse, RbfTx, RecommendedFees, ReplacementNode, Sats, Timestamp, Transaction, @@ -10,6 +10,8 @@ use vecdb::{AnyVec, ReadableVec, VecIndex}; use crate::Query; +const RECENT_REPLACEMENTS_LIMIT: usize = 25; + impl Query { pub fn mempool_info(&self) -> Result { let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; @@ -305,11 +307,7 @@ impl Query { let replaces = (!replaces_vec.is_empty()).then_some(replaces_vec); let replacements = - Self::build_rbf_node(&root_txid, None, &txs, &entries, &graveyard).map(|mut node| { - node.tx.full_rbf = Some(node.full_rbf); - node.interval = None; - node - }); + self.build_rbf_node(&root_txid, None, mempool, &txs, &entries, &graveyard); Ok(RbfResponse { replacements, @@ -336,9 +334,17 @@ impl Query { /// Predecessors are always in the graveyard (that's where /// `Removal::Replaced` lives), so the recursion only needs the /// graveyard; the live pool is consulted for the root. + /// + /// `rate` matches mempool.space's `tx.effectiveFeePerVsize`: live + /// txs get the live CPFP-cluster effective rate; mined txs get the + /// computer's stored same-block-cluster effective rate; never-mined + /// replaced predecessors have no recorded effective rate, so we + /// fall back to the simple `fee/vsize` snapshotted at burial. fn build_rbf_node( + &self, txid: &Txid, successor_time: Option, + mempool: &Mempool, txs: &TxStore, entries: &EntryPool, graveyard: &TxGraveyard, @@ -348,7 +354,14 @@ impl Query { let replaces: Vec = graveyard .predecessors_of(txid) .filter_map(|(pred_txid, _)| { - Self::build_rbf_node(pred_txid, Some(entry.first_seen), txs, entries, graveyard) + self.build_rbf_node( + pred_txid, + Some(entry.first_seen), + mempool, + txs, + entries, + graveyard, + ) }) .collect(); @@ -359,6 +372,24 @@ impl Query { .map(|d| usize::from(d) as u32); let value = Sats::from(tx.output.iter().map(|o| u64::from(o.value)).sum::()); + let tx_index = self.resolve_tx_index(txid).ok(); + let mined = tx_index.map(|_| true); + let rate = if txs.contains(txid) { + mempool + .cpfp_info(&TxidPrefix::from(txid)) + .and_then(|info| info.effective_fee_per_vsize) + .unwrap_or_else(|| entry.fee_rate()) + } else if let Some(idx) = tx_index { + self.computer() + .transactions + .fees + .effective_fee_rate + .tx_index + .collect_one(idx) + .unwrap_or_else(|| entry.fee_rate()) + } else { + entry.fee_rate() + }; Some(ReplacementNode { tx: RbfTx { @@ -366,14 +397,16 @@ impl Query { fee: entry.fee, vsize: entry.vsize, value, - rate: entry.fee_rate(), + rate, time: entry.first_seen, rbf: entry.rbf, - full_rbf: None, + full_rbf: Some(full_rbf), + mined, }, time: entry.first_seen, full_rbf, interval, + mined, replaces, }) } @@ -381,45 +414,39 @@ impl Query { /// Recent RBF replacements across the whole mempool, matching /// mempool.space's `GET /api/v1/replacements` and /// `GET /api/v1/fullrbf/replacements`. Each entry is a complete - /// replacement tree rooted at the latest replacer; same shape as - /// `tx_rbf().replacements`. Sorted most-recent-first by root - /// `time`. When `full_rbf_only` is true, only trees with at least - /// one non-signaling predecessor are returned. + /// replacement tree rooted at the terminal replacer; same shape as + /// `tx_rbf().replacements`. Ordered by most-recent replacement + /// event first (matches mempool.space's reversed-`replacedBy` + /// iteration) and capped at 25 entries. When `full_rbf_only` is + /// true, only trees with at least one non-signaling predecessor + /// are returned. pub fn recent_replacements(&self, full_rbf_only: bool) -> Result> { let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; let txs = mempool.txs(); let entries = mempool.entries(); let graveyard = mempool.graveyard(); - // Collect every distinct tree-root replacer. A predecessor's - // `by` may itself have been replaced; walk forward through - // chained Replaced tombstones until reaching a tx that's no - // longer flagged as replaced (live, Vanished, or unknown). - let mut roots: FxHashSet = FxHashSet::default(); - for (_, by) in graveyard.replaced_iter() { - let mut root = by.clone(); - while let Some(TxRemoval::Replaced { by: next }) = - graveyard.get(&root).map(TxTombstone::reason) - { - root = next.clone(); - } - roots.insert(root); - } - - let mut trees: Vec = roots - .iter() + // A predecessor's `by` may itself be replaced; walk the chain + // forward to the terminal replacer for each tree, dedup so each + // tree is emitted once at its first (most recent) sighting. + let mut seen: FxHashSet = FxHashSet::default(); + Ok(graveyard + .replaced_iter_recent_first() + .filter_map(|(_, by)| { + let mut root = by.clone(); + while let Some(TxRemoval::Replaced { by: next }) = + graveyard.get(&root).map(TxTombstone::reason) + { + root = next.clone(); + } + seen.insert(root.clone()).then_some(root) + }) .filter_map(|root| { - Self::build_rbf_node(root, None, &txs, &entries, &graveyard).map(|mut node| { - node.tx.full_rbf = Some(node.full_rbf); - node.interval = None; - node - }) + self.build_rbf_node(&root, None, mempool, &txs, &entries, &graveyard) }) .filter(|node| !full_rbf_only || node.full_rbf) - .collect(); - - trees.sort_by(|a, b| b.time.cmp(&a.time)); - Ok(trees) + .take(RECENT_REPLACEMENTS_LIMIT) + .collect()) } pub fn transaction_times(&self, txids: &[Txid]) -> Result> { diff --git a/crates/brk_query/src/impl/price.rs b/crates/brk_query/src/impl/price.rs index fe3e8e9a2..c0340b289 100644 --- a/crates/brk_query/src/impl/price.rs +++ b/crates/brk_query/src/impl/price.rs @@ -1,5 +1,7 @@ use brk_error::Result; -use brk_types::{Dollars, ExchangeRates, HistoricalPrice, HistoricalPriceEntry, Hour4, Timestamp}; +use brk_types::{ + Dollars, ExchangeRates, HistoricalPrice, HistoricalPriceEntry, Hour4, INDEX_EPOCH, Timestamp, +}; use vecdb::ReadableVec; use crate::Query; @@ -32,6 +34,9 @@ impl Query { } fn price_at(&self, target: Timestamp) -> Result> { + if *target < INDEX_EPOCH { + return Ok(vec![]); + } let h4 = Hour4::from_timestamp(target); let cents = self.computer().prices.spot.cents.hour4.collect_one(h4); Ok(vec![HistoricalPriceEntry { diff --git a/crates/brk_query/src/impl/series.rs b/crates/brk_query/src/impl/series.rs index 96039dde2..9bc5ec97e 100644 --- a/crates/brk_query/src/impl/series.rs +++ b/crates/brk_query/src/impl/series.rs @@ -32,7 +32,7 @@ impl Query { pub fn series_not_found_error(&self, series: &SeriesName) -> Error { // Check if series exists but with different indexes - if let Some(indexes) = self.vecs().series_to_indexes(series.clone()) { + if let Some(indexes) = self.vecs().series_to_indexes(series) { let supported = indexes .iter() .map(|i| format!("/api/series/{series}/{}", i.name())) @@ -382,7 +382,7 @@ impl Query { }) } - pub fn series_to_indexes(&self, series: SeriesName) -> Option<&Vec> { + pub fn series_to_indexes(&self, series: &SeriesName) -> Option<&Vec> { self.vecs().series_to_indexes(series) } diff --git a/crates/brk_query/src/lib.rs b/crates/brk_query/src/lib.rs index 60e84b769..0f944ce53 100644 --- a/crates/brk_query/src/lib.rs +++ b/crates/brk_query/src/lib.rs @@ -1,9 +1,10 @@ #![doc = include_str!("../README.md")] #![allow(clippy::module_inception)] -use std::sync::Arc; +use std::{path::Path, sync::Arc}; use brk_computer::Computer; +use brk_error::{OptionData, Result}; use brk_indexer::Indexer; use brk_mempool::Mempool; use brk_reader::Reader; @@ -84,7 +85,7 @@ impl Query { } /// Build sync status with the given tip height - pub fn sync_status(&self, tip_height: Height) -> SyncStatus { + pub fn sync_status(&self, tip_height: Height) -> Result { let indexed_height = self.indexed_height(); let computed_height = self.computed_height(); let blocks_behind = Height::from(tip_height.saturating_sub(*indexed_height)); @@ -94,16 +95,16 @@ impl Query { .blocks .timestamp .collect_one(indexed_height) - .unwrap(); + .data()?; - SyncStatus { + Ok(SyncStatus { indexed_height, computed_height, tip_height, blocks_behind, last_indexed_at: last_indexed_at_unix.to_iso8601(), last_indexed_at_unix, - } + }) } #[inline] @@ -117,7 +118,7 @@ impl Query { } #[inline] - pub fn blocks_dir(&self) -> &std::path::Path { + pub fn blocks_dir(&self) -> &Path { self.0.reader.blocks_dir() } diff --git a/crates/brk_query/src/vecs.rs b/crates/brk_query/src/vecs.rs index 1d35a1907..44d930a79 100644 --- a/crates/brk_query/src/vecs.rs +++ b/crates/brk_query/src/vecs.rs @@ -8,7 +8,7 @@ use brk_types::{ }; use derive_more::{Deref, DerefMut}; use quickmatch::{QuickMatch, QuickMatchConfig}; -use vecdb::AnyExportableVec; +use vecdb::{AnyExportableVec, Ro}; #[derive(Default)] pub struct Vecs<'a> { @@ -25,7 +25,7 @@ pub struct Vecs<'a> { } impl<'a> Vecs<'a> { - pub fn build(indexer: &'a Indexer, computer: &'a Computer) -> Self { + pub fn build(indexer: &'a Indexer, computer: &'a Computer) -> Self { Self::build_from( indexer.vecs.iter_any_visible(), indexer.vecs.to_tree_node(), @@ -57,24 +57,17 @@ impl<'a> Vecs<'a> { let mut ids = this .series_to_index_to_vec .keys() - .cloned() + .copied() .collect::>(); let sort_ids = |ids: &mut Vec<&str>| { - ids.sort_unstable_by(|a, b| { - let len_cmp = a.len().cmp(&b.len()); - if len_cmp == std::cmp::Ordering::Equal { - a.cmp(b) - } else { - len_cmp - } - }) + ids.sort_unstable_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b))) }; sort_ids(&mut ids); this.series = ids; - this.counts.distinct_series = this.series_to_index_to_vec.keys().count(); + this.counts.distinct_series = this.series_to_index_to_vec.len(); this.counts.total_endpoints = this .index_to_series_to_vec .values() @@ -108,7 +101,7 @@ impl<'a> Vecs<'a> { this.index_to_series = this .index_to_series_to_vec .iter() - .map(|(index, id_to_vec)| (*index, id_to_vec.keys().cloned().collect::>())) + .map(|(index, id_to_vec)| (*index, id_to_vec.keys().copied().collect::>())) .collect(); this.index_to_series.values_mut().for_each(sort_ids); this.catalog.replace( @@ -121,7 +114,7 @@ impl<'a> Vecs<'a> { .collect(), ) .merge_branches() - .unwrap(), + .expect("indexed/computed catalog merge: same series leaf with incompatible schemas"), ); this.matcher = Some(QuickMatch::new(&this.series)); @@ -144,17 +137,11 @@ impl<'a> Vecs<'a> { "Duplicate series: {name} for index {index:?}" ); - let prev = self - .index_to_series_to_vec + self.index_to_series_to_vec .entry(index) .or_default() .insert(name, vec); - assert!( - prev.is_none(), - "Duplicate series: {name} for index {index:?}" - ); - // Track per-db counts let is_lazy = vec.region_names().is_empty(); self.counts_by_db .entry(db.to_string()) @@ -182,7 +169,7 @@ impl<'a> Vecs<'a> { } } - pub fn series_to_indexes(&self, series: SeriesName) -> Option<&Vec> { + pub fn series_to_indexes(&self, series: &SeriesName) -> Option<&Vec> { self.series_to_indexes .get(series.replace("-", "_").as_str()) } diff --git a/crates/brk_server/examples/bindgen.rs b/crates/brk_server/examples/bindgen.rs index 286b208ca..59ae46a58 100644 --- a/crates/brk_server/examples/bindgen.rs +++ b/crates/brk_server/examples/bindgen.rs @@ -26,7 +26,8 @@ pub fn main() -> color_eyre::Result<()> { let output_paths = brk_bindgen::ClientOutputPaths::new() .rust(workspace_root.join("crates/brk_client/src/lib.rs")) - .javascript(workspace_root.join("website/scripts/modules/brk-client/index.js")); + .javascript(workspace_root.join("website/scripts/modules/brk-client/index.js")) + .python(workspace_root.join("packages/brk_client/brk_client/__init__.py")); generate_bindings(&vecs, &openapi, &output_paths)?; diff --git a/crates/brk_server/src/api/blocks.rs b/crates/brk_server/src/api/blocks.rs index 965359218..004a0c150 100644 --- a/crates/brk_server/src/api/blocks.rs +++ b/crates/brk_server/src/api/blocks.rs @@ -5,7 +5,8 @@ use axum::{ }; use brk_query::BLOCK_TXS_PAGE_SIZE; use brk_types::{ - BlockInfo, BlockInfoV1, BlockStatus, BlockTimestamp, Transaction, TxIndex, Txid, Version, + BlockHash, BlockInfo, BlockInfoV1, BlockStatus, BlockTimestamp, Height, Hex, Transaction, + TxIndex, Txid, Version, }; use crate::{ @@ -82,7 +83,7 @@ impl BlockRoutes for ApiRouter { .blocks_tag() .summary("Block header") .description("Returns the hex-encoded 80-byte block header.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-header)*") - .text_response() + .text_response::() .not_modified() .bad_request() .not_found() @@ -106,7 +107,7 @@ impl BlockRoutes for ApiRouter { .description( "Retrieve the block hash at a given height. Returns the hash as plain text.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)*", ) - .text_response() + .text_response::() .not_modified() .bad_request() .not_found() @@ -196,7 +197,7 @@ impl BlockRoutes for ApiRouter { .blocks_tag() .summary("Block tip height") .description("Returns the height of the last block.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-height)*") - .text_response() + .text_response::() .not_modified() .server_error() }, @@ -213,7 +214,7 @@ impl BlockRoutes for ApiRouter { .blocks_tag() .summary("Block tip hash") .description("Returns the hash of the last block.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-hash)*") - .text_response() + .text_response::() .not_modified() .server_error() }, @@ -236,7 +237,7 @@ impl BlockRoutes for ApiRouter { .description( "Retrieve a single transaction ID at a specific index within a block. Returns plain text txid.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-id)*", ) - .text_response() + .text_response::() .not_modified() .bad_request() .not_found() diff --git a/crates/brk_server/src/api/mempool.rs b/crates/brk_server/src/api/mempool.rs index 38a255a2d..04573d2a1 100644 --- a/crates/brk_server/src/api/mempool.rs +++ b/crates/brk_server/src/api/mempool.rs @@ -3,7 +3,7 @@ use axum::{ extract::State, http::{HeaderMap, Uri}, }; -use brk_types::{Dollars, MempoolInfo, MempoolRecentTx, Txid}; +use brk_types::{Dollars, MempoolInfo, MempoolRecentTx, ReplacementNode, Txid}; use crate::{AppState, extended::TransformResponseExtended, params::Empty}; @@ -70,6 +70,48 @@ impl MempoolRoutes for ApiRouter { }, ), ) + .api_route( + "/api/v1/replacements", + get_with( + async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State| { + state + .respond_json(&headers, state.mempool_strategy(), &uri, |q| { + q.recent_replacements(false) + }) + .await + }, + |op| { + op.id("get_replacements") + .mempool_tag() + .summary("Recent RBF replacements") + .description("Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)*") + .json_response::>() + .not_modified() + .server_error() + }, + ), + ) + .api_route( + "/api/v1/fullrbf/replacements", + get_with( + async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State| { + state + .respond_json(&headers, state.mempool_strategy(), &uri, |q| { + q.recent_replacements(true) + }) + .await + }, + |op| { + op.id("get_fullrbf_replacements") + .mempool_tag() + .summary("Recent full-RBF replacements") + .description("Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)*") + .json_response::>() + .not_modified() + .server_error() + }, + ), + ) .api_route( "/api/mempool/price", get_with( diff --git a/crates/brk_server/src/api/server.rs b/crates/brk_server/src/api/server.rs index 0a3928ffe..9ddf50586 100644 --- a/crates/brk_server/src/api/server.rs +++ b/crates/brk_server/src/api/server.rs @@ -34,7 +34,7 @@ impl ServerRoutes for ApiRouter { .client() .get_last_height() .unwrap_or(q.indexed_height()); - Ok(q.sync_status(tip_height)) + q.sync_status(tip_height) }) .await .expect("health sync task panicked"); @@ -89,7 +89,7 @@ impl ServerRoutes for ApiRouter { state .respond_json(&headers, CacheStrategy::Tip, &uri, move |q| { let tip_height = q.client().get_last_height()?; - Ok(q.sync_status(tip_height)) + q.sync_status(tip_height) }) .await }, diff --git a/crates/brk_server/src/api/transactions.rs b/crates/brk_server/src/api/transactions.rs index ce48f7970..06aea0a47 100644 --- a/crates/brk_server/src/api/transactions.rs +++ b/crates/brk_server/src/api/transactions.rs @@ -8,7 +8,7 @@ use axum::{ response::Response, }; use brk_types::{ - CpfpInfo, MerkleProof, RbfResponse, Transaction, TxOutspend, TxStatus, Txid, Version, + CpfpInfo, Hex, MerkleProof, RbfResponse, Transaction, TxOutspend, TxStatus, Txid, Version, }; use crate::{ @@ -35,7 +35,7 @@ impl TxRoutes for ApiRouter { .transactions_tag() .summary("Txid by index") .description("Retrieve the transaction ID (txid) at a given global transaction index. Returns the txid as plain text.") - .text_response() + .text_response::() .not_modified() .bad_request() .not_found() @@ -123,7 +123,7 @@ impl TxRoutes for ApiRouter { .description( "Retrieve the raw transaction as a hex-encoded string. Returns the serialized transaction in hexadecimal format.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-hex)*", ) - .text_response() + .text_response::() .not_modified() .bad_request() .not_found() @@ -141,7 +141,7 @@ impl TxRoutes for ApiRouter { .transactions_tag() .summary("Transaction merkleblock proof") .description("Get the merkleblock proof for a transaction (BIP37 format, hex encoded).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-merkleblock-proof)*") - .text_response() + .text_response::() .not_modified() .bad_request() .not_found() @@ -281,9 +281,7 @@ impl TxRoutes for ApiRouter { .api_route( "/api/v1/transaction-times", get_with( - async |uri: Uri, headers: HeaderMap, State(state): State| -> Result { - let params = TxidsParam::from_query(uri.query().unwrap_or("")) - .map_err(Error::bad_request)?; + async |uri: Uri, headers: HeaderMap, params: TxidsParam, State(state): State| -> Result { Ok(state.respond_json(&headers, state.mempool_strategy(), &uri, move |q| q.transaction_times(¶ms.txids)).await) }, |op| op diff --git a/crates/brk_server/src/extended/mod.rs b/crates/brk_server/src/extended/mod.rs index a66e7f101..46ea7fe5f 100644 --- a/crates/brk_server/src/extended/mod.rs +++ b/crates/brk_server/src/extended/mod.rs @@ -1,7 +1,9 @@ mod header_map; mod response; mod transform_operation; +mod typed_text; pub use header_map::*; pub use response::*; pub use transform_operation::*; +pub use typed_text::*; diff --git a/crates/brk_server/src/extended/transform_operation.rs b/crates/brk_server/src/extended/transform_operation.rs index 01c4536df..59a129574 100644 --- a/crates/brk_server/src/extended/transform_operation.rs +++ b/crates/brk_server/src/extended/transform_operation.rs @@ -3,7 +3,7 @@ use aide::transform::{TransformOperation, TransformResponse}; use axum::Json; use schemars::JsonSchema; -use crate::error::ErrorBody; +use crate::{error::ErrorBody, extended::TypedText}; pub trait TransformResponseExtended<'t> { fn general_tag(self) -> Self; @@ -30,8 +30,10 @@ pub trait TransformResponseExtended<'t> { where R: JsonSchema, F: FnOnce(TransformResponse<'_, R>) -> TransformResponse<'_, R>; - /// 200 with text/plain content type - fn text_response(self) -> Self; + /// 200 with text/plain content type whose body parses as `T` + fn text_response(self) -> Self + where + T: JsonSchema; /// 200 with application/octet-stream content type fn binary_response(self) -> Self; /// 200 with text/csv content type (adds CSV as alternative response format) @@ -111,8 +113,11 @@ impl<'t> TransformResponseExtended<'t> for TransformOperation<'t> { self.response_with::<200, Json, _>(|res| f(res.description("Successful response"))) } - fn text_response(self) -> Self { - self.response_with::<200, String, _>(|res| res.description("Successful response")) + fn text_response(self) -> Self + where + T: JsonSchema, + { + self.response_with::<200, TypedText, _>(|res| res.description("Successful response")) } fn binary_response(self) -> Self { diff --git a/crates/brk_server/src/extended/typed_text.rs b/crates/brk_server/src/extended/typed_text.rs new file mode 100644 index 000000000..c9af7e281 --- /dev/null +++ b/crates/brk_server/src/extended/typed_text.rs @@ -0,0 +1,49 @@ +use std::marker::PhantomData; + +use aide::{ + OperationOutput, + openapi::{MediaType, Operation, Response, SchemaObject, StatusCode}, +}; +use schemars::JsonSchema; + +/// `text/plain` response whose body parses as `T`. +/// +/// Used purely for OpenAPI metadata: handlers still return `String`, +/// but the schema advertises `T`'s shape so generated SDKs can decode. +pub struct TypedText(PhantomData); + +impl OperationOutput for TypedText { + type Inner = Self; + + fn operation_response( + ctx: &mut aide::generate::GenContext, + _operation: &mut Operation, + ) -> Option { + let json_schema = ctx.schema.subschema_for::(); + Some(Response { + description: "plain text".into(), + content: [( + "text/plain; charset=utf-8".into(), + MediaType { + schema: Some(SchemaObject { + json_schema, + example: None, + external_docs: None, + }), + ..Default::default() + }, + )] + .into(), + ..Default::default() + }) + } + + fn inferred_responses( + ctx: &mut aide::generate::GenContext, + operation: &mut Operation, + ) -> Vec<(Option, Response)> { + Self::operation_response(ctx, operation) + .map(|r| vec![(Some(StatusCode::Code(200)), r)]) + .unwrap_or_default() + } +} diff --git a/crates/brk_server/src/params/txids_param.rs b/crates/brk_server/src/params/txids_param.rs index 1294711df..c51532ce2 100644 --- a/crates/brk_server/src/params/txids_param.rs +++ b/crates/brk_server/src/params/txids_param.rs @@ -1,14 +1,27 @@ use std::str::FromStr; +use aide::{ + OperationInput, + operation::{ParamLocation, add_parameters, parameters_from_schema}, +}; +use axum::{extract::FromRequestParts, http::request::Parts}; use schemars::JsonSchema; use brk_types::Txid; +use crate::Error; + const MAX_TXIDS: usize = 250; /// Query parameter for transaction-times endpoint. +/// +/// Extracted manually because `serde_urlencoded` (and serde derive in general) +/// doesn't support repeated keys like `txId[]=a&txId[]=b`. The schema is still +/// declared via `JsonSchema` so the OpenAPI spec lists the parameter and the +/// generated client SDKs see `txids: List[Txid]`. #[derive(JsonSchema)] pub struct TxidsParam { + /// Transaction IDs to look up (max 250 per request). #[serde(rename = "txId[]")] pub txids: Vec, } @@ -41,6 +54,28 @@ impl TxidsParam { } } +impl FromRequestParts for TxidsParam +where + S: Send + Sync, +{ + type Rejection = Error; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + Self::from_query(parts.uri.query().unwrap_or("")).map_err(Error::bad_request) + } +} + +impl OperationInput for TxidsParam { + fn operation_input( + ctx: &mut aide::generate::GenContext, + operation: &mut aide::openapi::Operation, + ) { + let schema = ctx.schema.subschema_for::(); + let params = parameters_from_schema(ctx, schema, ParamLocation::Query); + add_parameters(ctx, operation, params); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/brk_types/src/pagination.rs b/crates/brk_types/src/pagination.rs index 24a95f3fb..dec7abed5 100644 --- a/crates/brk_types/src/pagination.rs +++ b/crates/brk_types/src/pagination.rs @@ -25,6 +25,7 @@ impl Pagination { pub fn per_page(&self) -> usize { self.per_page + .filter(|&n| n > 0) .unwrap_or(Self::DEFAULT_PER_PAGE) .min(Self::MAX_PER_PAGE) } diff --git a/crates/brk_types/src/rbf.rs b/crates/brk_types/src/rbf.rs index 2523a214e..aa36171f5 100644 --- a/crates/brk_types/src/rbf.rs +++ b/crates/brk_types/src/rbf.rs @@ -21,6 +21,11 @@ pub struct RbfTx { /// this tx displaced at least one non-signaling predecessor. #[serde(rename = "fullRbf", skip_serializing_if = "Option::is_none", default)] pub full_rbf: Option, + /// `Some(true)` iff the tx is currently confirmed in the indexed + /// chain. Absent on serialization when the tx is still pending or + /// has been evicted without confirming. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub mined: Option, } /// One node in an RBF replacement tree. The node's `tx` replaced each @@ -38,6 +43,10 @@ pub struct ReplacementNode { /// replaced it. Omitted on the root of an RBF response. #[serde(skip_serializing_if = "Option::is_none")] pub interval: Option, + /// `Some(true)` iff this node's tx is currently confirmed. Absent + /// on serialization otherwise. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub mined: Option, pub replaces: Vec, } diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 3b5d7aaf5..df19d3289 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -550,6 +550,13 @@ Matches mempool.space/bitcoin-cli behavior. * @typedef {Object} HeightParam * @property {Height} height */ +/** + * Hex-encoded string. Transparent wrapper over `String`: serializes + * as a plain JSON string and derefs to `str`, so anywhere `&str` or + * `AsRef<[u8]>` is expected the `Hex` "just works". + * + * @typedef {string} Hex + */ /** * Highest price value for a time period * @@ -869,6 +876,9 @@ Matches mempool.space/bitcoin-cli behavior. * @property {boolean} rbf - BIP-125 signaling: at least one input has sequence < 0xffffffff-1. * @property {?boolean=} fullRbf - Only populated on the root `tx` of an RBF response. `true` iff this tx displaced at least one non-signaling predecessor. + * @property {?boolean=} mined - `Some(true)` iff the tx is currently confirmed in the indexed +chain. Absent on serialization when the tx is still pending or +has been evicted without confirming. */ /** * Recommended fee rates in sat/vB @@ -891,6 +901,8 @@ on-the-wire shape. * @property {boolean} fullRbf - Any predecessor in this subtree was non-signaling. * @property {?number=} interval - Seconds between this node's `time` and the successor that replaced it. Omitted on the root of an RBF response. + * @property {?boolean=} mined - `Some(true)` iff this node's tx is currently confirmed. Absent +on serialization otherwise. * @property {ReplacementNode[]} replaces */ /** @@ -1172,6 +1184,17 @@ replaced it. Omitted on the root of an RBF response. * @property {Txid} txid - Transaction ID * @property {Vout} vout - Output index */ +/** + * Query parameter for transaction-times endpoint. + * + * Extracted manually because `serde_urlencoded` (and serde derive in general) + * doesn't support repeated keys like `txId[]=a&txId[]=b`. The schema is still + * declared via `JsonSchema` so the OpenAPI spec lists the parameter and the + * generated client SDKs see `txids: List[Txid]`. + * + * @typedef {Object} TxidsParam + * @property {Txid[]} txId[] - Transaction IDs to look up (max 250 per request). + */ /** * Index within its type (e.g., 0 for first P2WPKH address) * @@ -1837,6 +1860,17 @@ class BrkClientBase { return this._getCached(path, (res) => res.text(), options); } + /** + * Make a GET request expecting binary data (application/octet-stream). + * Cached and supports `onValue`, same as `getJson`. + * @param {string} path + * @param {{ onValue?: (value: Uint8Array) => void, signal?: AbortSignal }} [options] + * @returns {Promise} + */ + getBytes(path, options) { + return this._getCached(path, async (res) => new Uint8Array(await res.arrayBuffer()), options); + } + /** * Fetch series data and wrap with helper methods (internal) * @template T @@ -10316,7 +10350,7 @@ class BrkClient extends BrkClientBase { */ async getApi({ signal, onValue } = {}) { const path = `/api.json`; - return this.getText(path, { signal, onValue }); + return this.getJson(path, { signal, onValue }); } /** @@ -10427,8 +10461,8 @@ class BrkClient extends BrkClientBase { * Endpoint: `GET /api/block-height/{height}` * * @param {Height} height - * @param {{ signal?: AbortSignal, onValue?: (value: *) => void }} [options] - * @returns {Promise<*>} + * @param {{ signal?: AbortSignal, onValue?: (value: BlockHash) => void }} [options] + * @returns {Promise} */ async getBlockByHeight(height, { signal, onValue } = {}) { const path = `/api/block-height/${height}`; @@ -10463,8 +10497,8 @@ class BrkClient extends BrkClientBase { * Endpoint: `GET /api/block/{hash}/header` * * @param {BlockHash} hash - * @param {{ signal?: AbortSignal, onValue?: (value: *) => void }} [options] - * @returns {Promise<*>} + * @param {{ signal?: AbortSignal, onValue?: (value: Hex) => void }} [options] + * @returns {Promise} */ async getBlockHeader(hash, { signal, onValue } = {}) { const path = `/api/block/${hash}/header`; @@ -10481,12 +10515,12 @@ class BrkClient extends BrkClientBase { * Endpoint: `GET /api/block/{hash}/raw` * * @param {BlockHash} hash - * @param {{ signal?: AbortSignal, onValue?: (value: *) => void }} [options] - * @returns {Promise<*>} + * @param {{ signal?: AbortSignal, onValue?: (value: Uint8Array) => void }} [options] + * @returns {Promise} */ async getBlockRaw(hash, { signal, onValue } = {}) { const path = `/api/block/${hash}/raw`; - return this.getText(path, { signal, onValue }); + return this.getBytes(path, { signal, onValue }); } /** @@ -10518,8 +10552,8 @@ class BrkClient extends BrkClientBase { * * @param {BlockHash} hash - Bitcoin block hash * @param {TxIndex} index - Transaction index within the block (0-based) - * @param {{ signal?: AbortSignal, onValue?: (value: *) => void }} [options] - * @returns {Promise<*>} + * @param {{ signal?: AbortSignal, onValue?: (value: Txid) => void }} [options] + * @returns {Promise} */ async getBlockTxid(hash, index, { signal, onValue } = {}) { const path = `/api/block/${hash}/txid/${index}`; @@ -10605,8 +10639,8 @@ class BrkClient extends BrkClientBase { * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-hash)* * * Endpoint: `GET /api/blocks/tip/hash` - * @param {{ signal?: AbortSignal, onValue?: (value: *) => void }} [options] - * @returns {Promise<*>} + * @param {{ signal?: AbortSignal, onValue?: (value: BlockHash) => void }} [options] + * @returns {Promise} */ async getBlockTipHash({ signal, onValue } = {}) { const path = `/api/blocks/tip/hash`; @@ -10621,12 +10655,12 @@ class BrkClient extends BrkClientBase { * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-height)* * * Endpoint: `GET /api/blocks/tip/height` - * @param {{ signal?: AbortSignal, onValue?: (value: *) => void }} [options] - * @returns {Promise<*>} + * @param {{ signal?: AbortSignal, onValue?: (value: Height) => void }} [options] + * @returns {Promise} */ async getBlockTipHeight({ signal, onValue } = {}) { const path = `/api/blocks/tip/height`; - return this.getText(path, { signal, onValue }); + return Number(await this.getText(path, { signal, onValue })); } /** @@ -10909,7 +10943,7 @@ class BrkClient extends BrkClientBase { */ async getSeriesLatest(series, index, { signal, onValue } = {}) { const path = `/api/series/${series}/${index}/latest`; - return this.getText(path, { signal, onValue }); + return this.getJson(path, { signal, onValue }); } /** @@ -10982,8 +11016,8 @@ class BrkClient extends BrkClientBase { * Endpoint: `GET /api/tx-index/{index}` * * @param {TxIndex} index - * @param {{ signal?: AbortSignal, onValue?: (value: *) => void }} [options] - * @returns {Promise<*>} + * @param {{ signal?: AbortSignal, onValue?: (value: Txid) => void }} [options] + * @returns {Promise} */ async getTxByIndex(index, { signal, onValue } = {}) { const path = `/api/tx-index/${index}`; @@ -11018,8 +11052,8 @@ class BrkClient extends BrkClientBase { * Endpoint: `GET /api/tx/{txid}/hex` * * @param {Txid} txid - * @param {{ signal?: AbortSignal, onValue?: (value: *) => void }} [options] - * @returns {Promise<*>} + * @param {{ signal?: AbortSignal, onValue?: (value: Hex) => void }} [options] + * @returns {Promise} */ async getTxHex(txid, { signal, onValue } = {}) { const path = `/api/tx/${txid}/hex`; @@ -11054,8 +11088,8 @@ class BrkClient extends BrkClientBase { * Endpoint: `GET /api/tx/{txid}/merkleblock-proof` * * @param {Txid} txid - * @param {{ signal?: AbortSignal, onValue?: (value: *) => void }} [options] - * @returns {Promise<*>} + * @param {{ signal?: AbortSignal, onValue?: (value: Hex) => void }} [options] + * @returns {Promise} */ async getTxMerkleblockProof(txid, { signal, onValue } = {}) { const path = `/api/tx/${txid}/merkleblock-proof`; @@ -11109,12 +11143,12 @@ class BrkClient extends BrkClientBase { * Endpoint: `GET /api/tx/{txid}/raw` * * @param {Txid} txid - * @param {{ signal?: AbortSignal, onValue?: (value: *) => void }} [options] - * @returns {Promise<*>} + * @param {{ signal?: AbortSignal, onValue?: (value: Uint8Array) => void }} [options] + * @returns {Promise} */ async getTxRaw(txid, { signal, onValue } = {}) { const path = `/api/tx/${txid}/raw`; - return this.getText(path, { signal, onValue }); + return this.getBytes(path, { signal, onValue }); } /** @@ -11344,6 +11378,22 @@ class BrkClient extends BrkClientBase { return this.getJson(path, { signal, onValue }); } + /** + * Recent full-RBF replacements + * + * Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF). + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)* + * + * Endpoint: `GET /api/v1/fullrbf/replacements` + * @param {{ signal?: AbortSignal, onValue?: (value: ReplacementNode[]) => void }} [options] + * @returns {Promise} + */ + async getFullrbfReplacements({ signal, onValue } = {}) { + const path = `/api/v1/fullrbf/replacements`; + return this.getJson(path, { signal, onValue }); + } + /** * Historical price * @@ -11698,6 +11748,22 @@ class BrkClient extends BrkClientBase { return this.getJson(path, { signal, onValue }); } + /** + * Recent RBF replacements + * + * Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`. + * + * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)* + * + * Endpoint: `GET /api/v1/replacements` + * @param {{ signal?: AbortSignal, onValue?: (value: ReplacementNode[]) => void }} [options] + * @returns {Promise} + */ + async getReplacements({ signal, onValue } = {}) { + const path = `/api/v1/replacements`; + return this.getJson(path, { signal, onValue }); + } + /** * Transaction first-seen times * @@ -11706,11 +11772,16 @@ class BrkClient extends BrkClientBase { * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-times)* * * Endpoint: `GET /api/v1/transaction-times` + * + * @param {Txid[]} [txId[]] - Transaction IDs to look up (max 250 per request). * @param {{ signal?: AbortSignal, onValue?: (value: number[]) => void }} [options] * @returns {Promise} */ - async getTransactionTimes({ signal, onValue } = {}) { - const path = `/api/v1/transaction-times`; + async getTransactionTimes(txId, { signal, onValue } = {}) { + const params = new URLSearchParams(); + for (const _v of txId) params.append('txId[]', String(_v)); + const query = params.toString(); + const path = `/api/v1/transaction-times${query ? '?' + query : ''}`; return this.getJson(path, { signal, onValue }); } diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index 2eec30a6e..475941448 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -117,6 +117,10 @@ Epoch = int ExchangeRates = dict FundedAddrIndex = TypeIndex Halving = int +# Hex-encoded string. Transparent wrapper over `String`: serializes +# as a plain JSON string and derefs to `str`, so anywhere `&str` or +# `AsRef<[u8]>` is expected the `Hex` "just works". +Hex = str # Highest price value for a time period High = Dollars Hour1 = int @@ -1252,6 +1256,9 @@ class RbfTx(TypedDict): rbf: BIP-125 signaling: at least one input has sequence < 0xffffffff-1. fullRbf: Only populated on the root `tx` of an RBF response. `true` iff this tx displaced at least one non-signaling predecessor. + mined: `Some(true)` iff the tx is currently confirmed in the indexed +chain. Absent on serialization when the tx is still pending or +has been evicted without confirming. """ txid: Txid fee: Sats @@ -1261,6 +1268,7 @@ this tx displaced at least one non-signaling predecessor. time: Timestamp rbf: bool fullRbf: Optional[bool] + mined: Optional[bool] class ReplacementNode(TypedDict): """ @@ -1273,11 +1281,14 @@ on-the-wire shape. fullRbf: Any predecessor in this subtree was non-signaling. interval: Seconds between this node's `time` and the successor that replaced it. Omitted on the root of an RBF response. + mined: `Some(true)` iff this node's tx is currently confirmed. Absent +on serialization otherwise. """ tx: RbfTx time: Timestamp fullRbf: bool interval: Optional[int] + mined: Optional[bool] replaces: List["ReplacementNode"] class RbfResponse(TypedDict): @@ -1568,6 +1579,20 @@ class TxidVout(TypedDict): txid: Txid vout: Vout +class TxidsParam(TypedDict): + """ + Query parameter for transaction-times endpoint. + + Extracted manually because `serde_urlencoded` (and serde derive in general) + doesn't support repeated keys like `txId[]=a&txId[]=b`. The schema is still + declared via `JsonSchema` so the OpenAPI spec lists the parameter and the + generated client SDKs see `txids: List[Txid]`. + + Attributes: + txId: Transaction IDs to look up (max 250 per request). + """ + txId: List[Txid] + class UrpdBucket(TypedDict): """ A single bucket in a URPD snapshot. @@ -7718,13 +7743,13 @@ class BrkClient(BrkClientBase): """Convert a date/datetime to an index value for date-based indexes.""" return _date_to_index(index, d) - def get_api(self) -> str: + def get_api(self) -> Any: """Compact OpenAPI specification. Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`. Endpoint: `GET /api.json`""" - return self.get_text('/api.json') + return self.get_json('/api.json') def get_address(self, address: Addr) -> AddrStats: """Address information. @@ -7784,7 +7809,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/address/{address}/utxo`""" return self.get_json(f'/api/address/{address}/utxo') - def get_block_by_height(self, height: Height) -> str: + def get_block_by_height(self, height: Height) -> BlockHash: """Block hash by height. Retrieve the block hash at a given height. Returns the hash as plain text. @@ -7804,7 +7829,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/block/{hash}`""" return self.get_json(f'/api/block/{hash}') - def get_block_header(self, hash: BlockHash) -> str: + def get_block_header(self, hash: BlockHash) -> Hex: """Block header. Returns the hex-encoded 80-byte block header. @@ -7814,7 +7839,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/block/{hash}/header`""" return self.get_text(f'/api/block/{hash}/header') - def get_block_raw(self, hash: BlockHash) -> str: + def get_block_raw(self, hash: BlockHash) -> bytes: """Raw block. Returns the raw block data in binary format. @@ -7822,7 +7847,7 @@ class BrkClient(BrkClientBase): *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)* Endpoint: `GET /api/block/{hash}/raw`""" - return self.get_text(f'/api/block/{hash}/raw') + return self.get(f'/api/block/{hash}/raw') def get_block_status(self, hash: BlockHash) -> BlockStatus: """Block status. @@ -7834,7 +7859,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/block/{hash}/status`""" return self.get_json(f'/api/block/{hash}/status') - def get_block_txid(self, hash: BlockHash, index: TxIndex) -> str: + def get_block_txid(self, hash: BlockHash, index: TxIndex) -> Txid: """Transaction ID at index. Retrieve a single transaction ID at a specific index within a block. Returns plain text txid. @@ -7884,7 +7909,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/blocks`""" return self.get_json('/api/blocks') - def get_block_tip_hash(self) -> str: + def get_block_tip_hash(self) -> BlockHash: """Block tip hash. Returns the hash of the last block. @@ -7894,7 +7919,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/blocks/tip/hash`""" return self.get_text('/api/blocks/tip/hash') - def get_block_tip_height(self) -> str: + def get_block_tip_height(self) -> Height: """Block tip height. Returns the height of the last block. @@ -7902,7 +7927,7 @@ class BrkClient(BrkClientBase): *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-height)* Endpoint: `GET /api/blocks/tip/height`""" - return self.get_text('/api/blocks/tip/height') + return int(self.get_text('/api/blocks/tip/height')) def get_blocks_from_height(self, height: Height) -> List[BlockInfo]: """Blocks from height. @@ -8063,13 +8088,13 @@ class BrkClient(BrkClientBase): return self.get_text(path) return self.get_json(path) - def get_series_latest(self, series: SeriesName, index: Index) -> str: + def get_series_latest(self, series: SeriesName, index: Index) -> Any: """Get latest series value. Returns the single most recent value for a series, unwrapped (not inside a SeriesData object). Endpoint: `GET /api/series/{series}/{index}/latest`""" - return self.get_text(f'/api/series/{series}/{index}/latest') + return self.get_json(f'/api/series/{series}/{index}/latest') def get_series_len(self, series: SeriesName, index: Index) -> int: """Get series data length. @@ -8103,7 +8128,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/server/sync`""" return self.get_json('/api/server/sync') - def get_tx_by_index(self, index: TxIndex) -> str: + def get_tx_by_index(self, index: TxIndex) -> Txid: """Txid by index. Retrieve the transaction ID (txid) at a given global transaction index. Returns the txid as plain text. @@ -8121,7 +8146,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/tx/{txid}`""" return self.get_json(f'/api/tx/{txid}') - def get_tx_hex(self, txid: Txid) -> str: + def get_tx_hex(self, txid: Txid) -> Hex: """Transaction hex. Retrieve the raw transaction as a hex-encoded string. Returns the serialized transaction in hexadecimal format. @@ -8141,7 +8166,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/tx/{txid}/merkle-proof`""" return self.get_json(f'/api/tx/{txid}/merkle-proof') - def get_tx_merkleblock_proof(self, txid: Txid) -> str: + def get_tx_merkleblock_proof(self, txid: Txid) -> Hex: """Transaction merkleblock proof. Get the merkleblock proof for a transaction (BIP37 format, hex encoded). @@ -8171,7 +8196,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/tx/{txid}/outspends`""" return self.get_json(f'/api/tx/{txid}/outspends') - def get_tx_raw(self, txid: Txid) -> str: + def get_tx_raw(self, txid: Txid) -> bytes: """Transaction raw. Returns a transaction as binary data. @@ -8179,7 +8204,7 @@ class BrkClient(BrkClientBase): *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-raw)* Endpoint: `GET /api/tx/{txid}/raw`""" - return self.get_text(f'/api/tx/{txid}/raw') + return self.get(f'/api/tx/{txid}/raw') def get_tx_status(self, txid: Txid) -> TxStatus: """Transaction status. @@ -8315,6 +8340,16 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/v1/fees/recommended`""" return self.get_json('/api/v1/fees/recommended') + def get_fullrbf_replacements(self) -> List[ReplacementNode]: + """Recent full-RBF replacements. + + Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF). + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)* + + Endpoint: `GET /api/v1/fullrbf/replacements`""" + return self.get_json('/api/v1/fullrbf/replacements') + def get_historical_price(self, timestamp: Optional[Timestamp] = None) -> HistoricalPrice: """Historical price. @@ -8519,7 +8554,17 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/v1/prices`""" return self.get_json('/api/v1/prices') - def get_transaction_times(self) -> List[int]: + def get_replacements(self) -> List[ReplacementNode]: + """Recent RBF replacements. + + Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`. + + *[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)* + + Endpoint: `GET /api/v1/replacements`""" + return self.get_json('/api/v1/replacements') + + def get_transaction_times(self, txId: List[Txid]) -> List[int]: """Transaction first-seen times. Returns timestamps when transactions were first seen in the mempool. Returns 0 for mined or unknown transactions. @@ -8527,7 +8572,11 @@ class BrkClient(BrkClientBase): *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-times)* Endpoint: `GET /api/v1/transaction-times`""" - return self.get_json('/api/v1/transaction-times') + params = [] + for _v in txId: params.append(f'txId[]={_v}') + query = '&'.join(params) + path = f'/api/v1/transaction-times{"?" + query if query else ""}' + return self.get_json(path) def get_tx_rbf(self, txid: Txid) -> RbfResponse: """RBF replacement history. diff --git a/packages/brk_client/tests/mempool_compat/general/test_historical_price.py b/packages/brk_client/tests/mempool_compat/general/test_historical_price.py index 4e5652288..6845199ae 100644 --- a/packages/brk_client/tests/mempool_compat/general/test_historical_price.py +++ b/packages/brk_client/tests/mempool_compat/general/test_historical_price.py @@ -1,11 +1,15 @@ """GET /api/v1/historical-price (with and without timestamp)""" +import time + import pytest from _lib import assert_same_structure, show -# Well-known timestamps from different eras +HOUR4 = 14400 # brk's bucket size for the price series + +# Well-known timestamps from different eras. HISTORICAL_TIMESTAMPS = [ 1231006505, # genesis block (2009-01-03) 1354116278, # block 210000 — first halving (2012-11-28) @@ -15,40 +19,88 @@ HISTORICAL_TIMESTAMPS = [ ] -def test_historical_price(brk, mempool): - """Historical price must have the same structure.""" +def test_historical_price_bulk_shape(brk, mempool): + """Bulk response must structurally match mempool.space and have a non-empty `prices` list.""" path = "/api/v1/historical-price" - b = brk.get_json(path) + b = brk.get_historical_price() m = mempool.get_json(path) - show("GET", path, b, m, max_lines=15) + show("GET", path, b, m, max_lines=10) assert_same_structure(b, m) - assert "prices" in b - assert isinstance(b["prices"], list) + assert isinstance(b["prices"], list) and b["prices"], "brk returned no prices" + assert b["exchangeRates"] == {}, "brk must not emit fiat exchange rates" -def test_historical_price_at_block_timestamps(brk, mempool, live): - """Historical price at each discovered block's timestamp must match structure.""" - for block in live.blocks: - info = brk.get_json(f"/api/block/{block.hash}") - ts = info["timestamp"] - path = f"/api/v1/historical-price?timestamp={ts}" - b = brk.get_json(path) - m = mempool.get_json(path) - show("GET", path, b, m) - assert_same_structure(b, m) - assert "prices" in b - assert len(b["prices"]) > 0 +def test_historical_price_bulk_ordering(brk): + """Brk's bulk series must be strictly ascending in `time`, span pre-2010 to within ~7 days of now.""" + d = brk.get_historical_price() + times = [p["time"] for p in d["prices"]] + assert times == sorted(times), "bulk prices must be ascending" + assert len(set(times)) == len(times), "bulk prices must have unique timestamps" + assert times[0] < 1262304000, f"first entry must be pre-2010, got {times[0]}" + now = int(time.time()) + assert times[-1] > now - 7 * 86400, f"latest entry stale: {times[-1]} vs now {now}" + + +def test_historical_price_bulk_usd_sane(brk): + """No negative or null USD values; the latest entry sits in the protocol-realistic spot band.""" + d = brk.get_historical_price() + usds = [p["USD"] for p in d["prices"]] + assert all(isinstance(u, (int, float)) for u in usds), "USD must be numeric" + assert all(u >= 0 for u in usds), "USD must be non-negative" + assert 1_000 < usds[-1] < 10_000_000, f"latest USD={usds[-1]} outside sane spot bounds" @pytest.mark.parametrize("ts", HISTORICAL_TIMESTAMPS, ids=[ "genesis", "halving1", "halving2", "halving3", "halving4", ]) def test_historical_price_at_era(brk, mempool, ts): - """Historical price at well-known timestamps must match structure.""" + """Single-entry response with bucket-aligned `time`, USD within 25% of mempool when both have data.""" path = f"/api/v1/historical-price?timestamp={ts}" - b = brk.get_json(path) + b = brk.get_historical_price(timestamp=ts) m = mempool.get_json(path) show("GET", path, b, m) assert_same_structure(b, m) - assert "prices" in b - assert len(b["prices"]) > 0 + assert len(b["prices"]) == 1, f"expected 1 price entry, got {len(b['prices'])}" + + entry = b["prices"][0] + bucket_start = (ts // HOUR4) * HOUR4 + assert entry["time"] == bucket_start, ( + f"bucket misaligned: entry time={entry['time']} vs expected {bucket_start} for ts={ts}" + ) + + if m["prices"] and entry["USD"] > 0 and m["prices"][0]["USD"] > 0: + m_usd = m["prices"][0]["USD"] + drift = abs(entry["USD"] - m_usd) / m_usd + assert drift < 0.25, ( + f"USD diverges from mempool by {drift:.1%} at ts={ts}: " + f"brk={entry['USD']} vs mempool={m_usd}" + ) + + +def test_historical_price_at_block(brk, live): + """At each fixture-block timestamp brk returns one bucket-aligned entry.""" + for block in live.blocks: + info = brk.get_block(block.hash) + ts = info["timestamp"] + b = brk.get_historical_price(timestamp=ts) + assert len(b["prices"]) == 1, f"height {block.height}: expected 1 entry, got {len(b['prices'])}" + bucket_start = (ts // HOUR4) * HOUR4 + assert b["prices"][0]["time"] == bucket_start, ( + f"height {block.height}: bucket misaligned: " + f"got {b['prices'][0]['time']} vs expected {bucket_start} for ts={ts}" + ) + + +def test_historical_price_future(brk): + """A future timestamp must not crash; brk emits a single entry whose USD is numeric.""" + ts = int(time.time()) + 86400 + b = brk.get_historical_price(timestamp=ts) + assert len(b["prices"]) == 1 + assert isinstance(b["prices"][0]["USD"], (int, float)) + + +def test_historical_price_pre_genesis(brk): + """Pre-INDEX_EPOCH (2009-01-01) timestamps must return an empty list, not panic.""" + b = brk.get_historical_price(timestamp=0) + assert b["prices"] == [], f"expected empty list for pre-EPOCH timestamp, got {b['prices']}" + assert b["exchangeRates"] == {} diff --git a/packages/brk_client/tests/mempool_compat/general/test_prices.py b/packages/brk_client/tests/mempool_compat/general/test_prices.py index 54953524a..adb5888b2 100644 --- a/packages/brk_client/tests/mempool_compat/general/test_prices.py +++ b/packages/brk_client/tests/mempool_compat/general/test_prices.py @@ -1,22 +1,43 @@ """GET /api/v1/prices""" +import time + from _lib import assert_same_structure, show -def test_prices(brk, mempool): - """Current price must have the same structure.""" +def test_prices_shape(brk, mempool): + """Brk's typed response must carry every key mempool.space returns (modulo intentional fiat skips).""" path = "/api/v1/prices" - b = brk.get_json(path) + b = brk.get_prices() m = mempool.get_json(path) show("GET", path, b, m) assert_same_structure(b, m) - assert "USD" in b - assert "time" in b + assert "USD" in b and "time" in b -def test_prices_positive(brk, mempool): - """USD price must be a positive number on both servers.""" - path = "/api/v1/prices" - for label, client in [("brk", brk), ("mempool", mempool)]: - d = client.get_json(path) - assert d["USD"] > 0, f"{label} USD price is not positive: {d['USD']}" +def test_prices_invariants(brk): + """`time` is a recent unix-seconds value and `USD` is a sane spot price.""" + d = brk.get_prices() + now = int(time.time()) + + assert isinstance(d["time"], int), f"time must be int, got {type(d['time']).__name__}" + assert 1_500_000_000 < d["time"] < now + 10, ( + f"time={d['time']} not within sane bounds (post-2017 and not in the future)" + ) + + assert isinstance(d["USD"], (int, float)), ( + f"USD must be numeric, got {type(d['USD']).__name__}" + ) + assert 1_000 < d["USD"] < 10_000_000, ( + f"USD={d['USD']} outside protocol-realistic spot bounds" + ) + + +def test_prices_close_to_mempool(brk, mempool): + """Brk's USD must track mempool.space's within 10% (covers feed divergence, not market drift).""" + b = brk.get_prices() + m = mempool.get_json("/api/v1/prices") + drift = abs(b["USD"] - m["USD"]) / m["USD"] + assert drift < 0.10, ( + f"USD diverges from mempool.space by {drift:.1%}: brk={b['USD']} vs mempool={m['USD']}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mempool/test_fullrbf_replacements.py b/packages/brk_client/tests/mempool_compat/mempool/test_fullrbf_replacements.py new file mode 100644 index 000000000..e1769b404 --- /dev/null +++ b/packages/brk_client/tests/mempool_compat/mempool/test_fullrbf_replacements.py @@ -0,0 +1,19 @@ +"""GET /api/v1/fullrbf/replacements + +Like `/api/v1/replacements`, but limited to trees where at least one +predecessor was non-signaling (full-RBF). +""" + +from _lib import assert_same_structure, show + + +def test_fullrbf_replacements_shape(brk, mempool): + """Full-RBF replacement-tree structure must match for the first element if both lists are non-empty.""" + path = "/api/v1/fullrbf/replacements" + b = brk.get_json(path) + m = mempool.get_json(path) + show("GET", path, b, m) + assert isinstance(b, list) and isinstance(m, list) + assert len(b) <= 25 and len(m) <= 25 + if b and m: + assert_same_structure(b[0], m[0]) diff --git a/packages/brk_client/tests/mempool_compat/mempool/test_replacements.py b/packages/brk_client/tests/mempool_compat/mempool/test_replacements.py new file mode 100644 index 000000000..d0a7f1960 --- /dev/null +++ b/packages/brk_client/tests/mempool_compat/mempool/test_replacements.py @@ -0,0 +1,20 @@ +"""GET /api/v1/replacements + +Returns up to 25 most-recent RBF replacement trees. Both servers may +report an empty list at any moment; the element structure is what's +load-bearing. +""" + +from _lib import assert_same_structure, show + + +def test_replacements_shape(brk, mempool): + """Replacement-tree structure must match for the first element if both lists are non-empty.""" + path = "/api/v1/replacements" + b = brk.get_json(path) + m = mempool.get_json(path) + show("GET", path, b, m) + assert isinstance(b, list) and isinstance(m, list) + assert len(b) <= 25 and len(m) <= 25 + if b and m: + assert_same_structure(b[0], m[0])