mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-19 06:14:47 -07:00
global: fixes
This commit is contained in:
38
Cargo.lock
generated
38
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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({}));",
|
||||
|
||||
@@ -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<Uint8Array>}}
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u8>".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"
|
||||
|
||||
@@ -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<Vec<u8>> {{
|
||||
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.
|
||||
|
||||
81
crates/brk_bindgen/src/openapi/endpoint.rs
Normal file
81
crates/brk_bindgen/src/openapi/endpoint.rs
Normal file
@@ -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<String>,
|
||||
/// Short summary
|
||||
pub summary: Option<String>,
|
||||
/// Detailed description
|
||||
pub description: Option<String>,
|
||||
/// Path parameters
|
||||
pub path_params: Vec<Parameter>,
|
||||
/// Query parameters
|
||||
pub query_params: Vec<Parameter>,
|
||||
/// 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<String> = 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("_"))
|
||||
}
|
||||
}
|
||||
@@ -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<String, Value>;
|
||||
|
||||
/// 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<String>,
|
||||
/// Short summary
|
||||
pub summary: Option<String>,
|
||||
/// Detailed description
|
||||
pub description: Option<String>,
|
||||
/// Path parameters
|
||||
pub path_params: Vec<Parameter>,
|
||||
/// Query parameters
|
||||
pub query_params: Vec<Parameter>,
|
||||
/// Response type (simplified)
|
||||
pub response_type: Option<String>,
|
||||
/// 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<String> = 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<String>,
|
||||
}
|
||||
|
||||
/// 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<Endpoint> {
|
||||
|
||||
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<Endpoint> {
|
||||
fn extract_endpoint(
|
||||
path: &str,
|
||||
method: &str,
|
||||
operation: &Operation,
|
||||
spec: &Spec,
|
||||
) -> Option<Endpoint> {
|
||||
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<E
|
||||
description: operation.description.clone(),
|
||||
path_params,
|
||||
query_params,
|
||||
response_type,
|
||||
response_kind,
|
||||
deprecated: operation.deprecated.unwrap_or(false),
|
||||
supports_csv,
|
||||
})
|
||||
@@ -272,28 +210,59 @@ fn extract_parameters(operation: &Operation, location: ParameterIn) -> Vec<Param
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_response_type(operation: &Operation) -> Option<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
8
crates/brk_bindgen/src/openapi/parameter.rs
Normal file
8
crates/brk_bindgen/src/openapi/parameter.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
29
crates/brk_bindgen/src/openapi/response_kind.rs
Normal file
29
crates/brk_bindgen/src/openapi/response_kind.rs
Normal file
@@ -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<TextSchema>),
|
||||
/// `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)
|
||||
}
|
||||
}
|
||||
8
crates/brk_bindgen/src/openapi/text_schema.rs
Normal file
8
crates/brk_bindgen/src/openapi/text_schema.rs
Normal file
@@ -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,
|
||||
}
|
||||
@@ -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<Vec<u8>> {
|
||||
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<String> {
|
||||
self.base.get_text(&format!("/api.json"))
|
||||
pub fn get_api(&self) -> Result<serde_json::Value> {
|
||||
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<String> {
|
||||
self.base.get_text(&format!("/api/block/{hash}/raw"))
|
||||
pub fn get_block_raw(&self, hash: BlockHash) -> Result<Vec<u8>> {
|
||||
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<String> {
|
||||
self.base.get_text(&format!("/api/series/{series}/{}/latest", index.name()))
|
||||
pub fn get_series_latest(&self, series: SeriesName, index: Index) -> Result<serde_json::Value> {
|
||||
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<String> {
|
||||
self.base.get_text(&format!("/api/tx/{txid}/raw"))
|
||||
pub fn get_tx_raw(&self, txid: Txid) -> Result<Vec<u8>> {
|
||||
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<Vec<ReplacementNode>> {
|
||||
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<Vec<ReplacementNode>> {
|
||||
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<Vec<i64>> {
|
||||
self.base.get_json(&format!("/api/v1/transaction-times"))
|
||||
pub fn get_transaction_times(&self, txId: &[Txid]) -> Result<Vec<i64>> {
|
||||
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
|
||||
|
||||
@@ -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<Item = (&Txid, &Txid)> {
|
||||
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<Item = (&Txid, &Txid)> {
|
||||
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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MempoolInfo> {
|
||||
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<Timestamp>,
|
||||
mempool: &Mempool,
|
||||
txs: &TxStore,
|
||||
entries: &EntryPool,
|
||||
graveyard: &TxGraveyard,
|
||||
@@ -348,7 +354,14 @@ impl Query {
|
||||
let replaces: Vec<ReplacementNode> = 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::<u64>());
|
||||
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<Vec<ReplacementNode>> {
|
||||
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<Txid> = 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<ReplacementNode> = 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<Txid> = 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<Vec<u64>> {
|
||||
|
||||
@@ -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<Vec<HistoricalPriceEntry>> {
|
||||
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 {
|
||||
|
||||
@@ -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<Index>> {
|
||||
pub fn series_to_indexes(&self, series: &SeriesName) -> Option<&Vec<Index>> {
|
||||
self.vecs().series_to_indexes(series)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SyncStatus> {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<vecdb::Ro>, computer: &'a Computer<vecdb::Ro>) -> Self {
|
||||
pub fn build(indexer: &'a Indexer<Ro>, computer: &'a Computer<Ro>) -> 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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>()))
|
||||
.map(|(index, id_to_vec)| (*index, id_to_vec.keys().copied().collect::<Vec<_>>()))
|
||||
.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<Index>> {
|
||||
pub fn series_to_indexes(&self, series: &SeriesName) -> Option<&Vec<Index>> {
|
||||
self.series_to_indexes
|
||||
.get(series.replace("-", "_").as_str())
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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<AppState> {
|
||||
.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::<Hex>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
@@ -106,7 +107,7 @@ impl BlockRoutes for ApiRouter<AppState> {
|
||||
.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::<BlockHash>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
@@ -196,7 +197,7 @@ impl BlockRoutes for ApiRouter<AppState> {
|
||||
.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::<Height>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
@@ -213,7 +214,7 @@ impl BlockRoutes for ApiRouter<AppState> {
|
||||
.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::<BlockHash>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
@@ -236,7 +237,7 @@ impl BlockRoutes for ApiRouter<AppState> {
|
||||
.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::<Txid>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
|
||||
@@ -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<AppState> {
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/v1/replacements",
|
||||
get_with(
|
||||
async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State<AppState>| {
|
||||
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::<Vec<ReplacementNode>>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/v1/fullrbf/replacements",
|
||||
get_with(
|
||||
async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State<AppState>| {
|
||||
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::<Vec<ReplacementNode>>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/mempool/price",
|
||||
get_with(
|
||||
|
||||
@@ -34,7 +34,7 @@ impl ServerRoutes for ApiRouter<AppState> {
|
||||
.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<AppState> {
|
||||
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
|
||||
},
|
||||
|
||||
@@ -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<AppState> {
|
||||
.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::<Txid>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
@@ -123,7 +123,7 @@ impl TxRoutes for ApiRouter<AppState> {
|
||||
.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::<Hex>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
@@ -141,7 +141,7 @@ impl TxRoutes for ApiRouter<AppState> {
|
||||
.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::<Hex>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
@@ -281,9 +281,7 @@ impl TxRoutes for ApiRouter<AppState> {
|
||||
.api_route(
|
||||
"/api/v1/transaction-times",
|
||||
get_with(
|
||||
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| -> Result<Response> {
|
||||
let params = TxidsParam::from_query(uri.query().unwrap_or(""))
|
||||
.map_err(Error::bad_request)?;
|
||||
async |uri: Uri, headers: HeaderMap, params: TxidsParam, State(state): State<AppState>| -> Result<Response> {
|
||||
Ok(state.respond_json(&headers, state.mempool_strategy(), &uri, move |q| q.transaction_times(¶ms.txids)).await)
|
||||
},
|
||||
|op| op
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<T>(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<R>, _>(|res| f(res.description("Successful response")))
|
||||
}
|
||||
|
||||
fn text_response(self) -> Self {
|
||||
self.response_with::<200, String, _>(|res| res.description("Successful response"))
|
||||
fn text_response<T>(self) -> Self
|
||||
where
|
||||
T: JsonSchema,
|
||||
{
|
||||
self.response_with::<200, TypedText<T>, _>(|res| res.description("Successful response"))
|
||||
}
|
||||
|
||||
fn binary_response(self) -> Self {
|
||||
|
||||
49
crates/brk_server/src/extended/typed_text.rs
Normal file
49
crates/brk_server/src/extended/typed_text.rs
Normal file
@@ -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<T>(PhantomData<T>);
|
||||
|
||||
impl<T: JsonSchema> OperationOutput for TypedText<T> {
|
||||
type Inner = Self;
|
||||
|
||||
fn operation_response(
|
||||
ctx: &mut aide::generate::GenContext,
|
||||
_operation: &mut Operation,
|
||||
) -> Option<Response> {
|
||||
let json_schema = ctx.schema.subschema_for::<T>();
|
||||
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<StatusCode>, Response)> {
|
||||
Self::operation_response(ctx, operation)
|
||||
.map(|r| vec![(Some(StatusCode::Code(200)), r)])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -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<Txid>,
|
||||
}
|
||||
@@ -41,6 +54,28 @@ impl TxidsParam {
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for TxidsParam
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Error;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
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::<Self>();
|
||||
let params = parameters_from_schema(ctx, schema, ParamLocation::Query);
|
||||
add_parameters(ctx, operation, params);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<bool>,
|
||||
/// `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<bool>,
|
||||
}
|
||||
|
||||
/// 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<u32>,
|
||||
/// `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<bool>,
|
||||
pub replaces: Vec<ReplacementNode>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Uint8Array>}
|
||||
*/
|
||||
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<BlockHash>}
|
||||
*/
|
||||
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<Hex>}
|
||||
*/
|
||||
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<Uint8Array>}
|
||||
*/
|
||||
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<Txid>}
|
||||
*/
|
||||
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<BlockHash>}
|
||||
*/
|
||||
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<Height>}
|
||||
*/
|
||||
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<Txid>}
|
||||
*/
|
||||
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<Hex>}
|
||||
*/
|
||||
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<Hex>}
|
||||
*/
|
||||
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<Uint8Array>}
|
||||
*/
|
||||
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<ReplacementNode[]>}
|
||||
*/
|
||||
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<ReplacementNode[]>}
|
||||
*/
|
||||
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<number[]>}
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] == {}
|
||||
|
||||
@@ -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']}"
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user