global: fixes

This commit is contained in:
nym21
2026-05-02 00:42:16 +02:00
parent 6f879a5551
commit 2b8a0a8cf7
99 changed files with 4308 additions and 1525 deletions

View File

@@ -14,46 +14,19 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
if !endpoint.should_generate() { if !endpoint.should_generate() {
continue; continue;
} }
match endpoint.method.as_str() {
"GET" => generate_get_method(output, endpoint),
"POST" => generate_post_method(output, endpoint),
_ => continue,
}
}
}
fn generate_get_method(output: &mut String, endpoint: &Endpoint) {
let method_name = endpoint_to_method_name(endpoint); let method_name = endpoint_to_method_name(endpoint);
let base_return_type = if endpoint.returns_binary() { let return_type = build_return_type(endpoint);
"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 {
base_return_type
};
writeln!(output, " /**").unwrap();
if let Some(summary) = &endpoint.summary {
writeln!(output, " * {}", summary).unwrap();
}
if let Some(desc) = &endpoint.description
&& endpoint.summary.as_ref() != Some(desc)
{
writeln!(output, " *").unwrap();
write_description(output, desc, " * ", " *");
}
// Add endpoint path
writeln!(output, " *").unwrap();
writeln!(
output,
" * Endpoint: `{} {}`",
endpoint.method.to_uppercase(),
endpoint.path
)
.unwrap();
if !endpoint.path_params.is_empty() || !endpoint.query_params.is_empty() {
writeln!(output, " *").unwrap();
}
write_method_doc(output, endpoint);
for param in &endpoint.path_params { for param in &endpoint.path_params {
let desc = format_param_desc(param.description.as_deref()); let desc = format_param_desc(param.description.as_deref());
let ty = jsdoc_normalize(&param.param_type); let ty = jsdoc_normalize(&param.param_type);
@@ -70,7 +43,6 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
) )
.unwrap(); .unwrap();
} }
writeln!( writeln!(
output, output,
" * @param {{{{ signal?: AbortSignal, onValue?: (value: {}) => void }}}} [options]", " * @param {{{{ signal?: AbortSignal, onValue?: (value: {}) => void }}}} [options]",
@@ -100,6 +72,140 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
"this.getText(path, { signal, onValue })".to_string() "this.getText(path, { signal, onValue })".to_string()
}; };
write_path_assignment(output, endpoint, &path);
if endpoint.supports_csv {
writeln!(
output,
" if (format === 'csv') return this.getText(path, {{ signal, onValue }});"
)
.unwrap();
}
writeln!(output, " return {};", fetch_call).unwrap();
writeln!(output, " }}\n").unwrap();
}
fn generate_post_method(output: &mut String, endpoint: &Endpoint) {
let method_name = endpoint_to_method_name(endpoint);
let return_type = build_return_type(endpoint);
write_method_doc(output, endpoint);
for param in &endpoint.path_params {
let desc = format_param_desc(param.description.as_deref());
let ty = jsdoc_normalize(&param.param_type);
writeln!(output, " * @param {{{}}} {}{}", ty, param.name, desc).unwrap();
}
for param in &endpoint.query_params {
let optional = if param.required { "" } else { "=" };
let desc = format_param_desc(param.description.as_deref());
let ty = jsdoc_normalize(&param.param_type);
writeln!(
output,
" * @param {{{}{}}} [{}]{}",
ty, optional, param.name, desc
)
.unwrap();
}
if let Some(body) = &endpoint.request_body {
let optional = if body.required { "" } else { "=" };
let ty = jsdoc_normalize(&body.body_type);
writeln!(
output,
" * @param {{{}{}}} body - Request body",
ty, optional
)
.unwrap();
}
writeln!(
output,
" * @param {{{{ signal?: AbortSignal }}}} [options]"
)
.unwrap();
writeln!(output, " * @returns {{Promise<{}>}}", return_type).unwrap();
writeln!(output, " */").unwrap();
let mut params = build_method_params(endpoint);
if endpoint.request_body.is_some() {
if !params.is_empty() {
params.push_str(", ");
}
params.push_str("body");
}
let params_with_opts = if params.is_empty() {
"{ signal } = {}".to_string()
} else {
format!("{}, {{ signal }} = {{}}", params)
};
writeln!(output, " async {}({}) {{", method_name, params_with_opts).unwrap();
let path = build_path_template(&endpoint.path, &endpoint.path_params);
let body_arg = if endpoint.request_body.is_some() {
"body"
} else {
"''"
};
let fetch_call: String = if endpoint.returns_binary() {
format!("this.postBytes(path, {}, {{ signal }})", body_arg)
} else if endpoint.returns_json() {
format!("this.postJson(path, {}, {{ signal }})", body_arg)
} else if endpoint.response_kind.text_is_numeric() {
format!(
"Number(await this.postText(path, {}, {{ signal }}))",
body_arg
)
} else {
format!("this.postText(path, {}, {{ signal }})", body_arg)
};
write_path_assignment(output, endpoint, &path);
writeln!(output, " return {};", fetch_call).unwrap();
writeln!(output, " }}\n").unwrap();
}
fn build_return_type(endpoint: &Endpoint) -> String {
let base = if endpoint.returns_binary() {
"Uint8Array".to_string()
} else {
jsdoc_normalize(&normalize_return_type(
endpoint.schema_name().unwrap_or("*"),
))
};
if endpoint.supports_csv {
format!("{} | string", base)
} else {
base
}
}
fn write_method_doc(output: &mut String, endpoint: &Endpoint) {
writeln!(output, " /**").unwrap();
if let Some(summary) = &endpoint.summary {
writeln!(output, " * {}", summary).unwrap();
}
if let Some(desc) = &endpoint.description
&& endpoint.summary.as_ref() != Some(desc)
{
writeln!(output, " *").unwrap();
write_description(output, desc, " * ", " *");
}
writeln!(output, " *").unwrap();
writeln!(
output,
" * Endpoint: `{} {}`",
endpoint.method.to_uppercase(),
endpoint.path
)
.unwrap();
let has_body_param = endpoint.method == "POST" && endpoint.request_body.is_some();
if !endpoint.path_params.is_empty() || !endpoint.query_params.is_empty() || has_body_param {
writeln!(output, " *").unwrap();
}
}
fn write_path_assignment(output: &mut String, endpoint: &Endpoint, path: &str) {
if endpoint.query_params.is_empty() { if endpoint.query_params.is_empty() {
writeln!(output, " const path = `{}`;", path).unwrap(); writeln!(output, " const path = `{}`;", path).unwrap();
} else { } else {
@@ -138,18 +244,6 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
) )
.unwrap(); .unwrap();
} }
if endpoint.supports_csv {
writeln!(
output,
" if (format === 'csv') return this.getText(path, {{ signal, onValue }});"
)
.unwrap();
}
writeln!(output, " return {};", fetch_call).unwrap();
writeln!(output, " }}\n").unwrap();
}
} }
fn endpoint_to_method_name(endpoint: &Endpoint) -> String { fn endpoint_to_method_name(endpoint: &Endpoint) -> String {

View File

@@ -569,6 +569,67 @@ class BrkClientBase {{
return this._getCached(path, async (res) => new Uint8Array(await res.arrayBuffer()), options); return this._getCached(path, async (res) => new Uint8Array(await res.arrayBuffer()), options);
}} }}
/**
* Make a POST request with a string body.
*
* POST responses are uncached and never invoke `onValue` — every call hits
* the network with the same body and returns the upstream response.
*
* @param {{string}} path
* @param {{string}} body
* @param {{{{ signal?: AbortSignal }}}} [options]
* @returns {{Promise<Response>}}
*/
async post(path, body, {{ signal }} = {{}}) {{
const url = `${{this.baseUrl}}${{path}}`;
const signals = [AbortSignal.timeout(this.timeout)];
if (signal) signals.push(signal);
const res = await fetch(url, {{
method: 'POST',
body,
signal: AbortSignal.any(signals),
}});
if (!res.ok) throw new BrkError(`HTTP ${{res.status}}: ${{url}}`, res.status);
return res;
}}
/**
* Make a POST request expecting a JSON response.
* @template T
* @param {{string}} path
* @param {{string}} body
* @param {{{{ signal?: AbortSignal }}}} [options]
* @returns {{Promise<T>}}
*/
async postJson(path, body, options) {{
const res = await this.post(path, body, options);
return _addCamelGetters(await res.json());
}}
/**
* Make a POST request expecting a text response.
* @param {{string}} path
* @param {{string}} body
* @param {{{{ signal?: AbortSignal }}}} [options]
* @returns {{Promise<string>}}
*/
async postText(path, body, options) {{
const res = await this.post(path, body, options);
return res.text();
}}
/**
* Make a POST request expecting binary data (application/octet-stream).
* @param {{string}} path
* @param {{string}} body
* @param {{{{ signal?: AbortSignal }}}} [options]
* @returns {{Promise<Uint8Array>}}
*/
async postBytes(path, body, options) {{
const res = await this.post(path, body, options);
return new Uint8Array(await res.arrayBuffer());
}}
/** /**
* Fetch series data and wrap with helper methods (internal) * Fetch series data and wrap with helper methods (internal)
* @template T * @template T

View File

@@ -162,12 +162,20 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
// Build path // Build path
let path = build_path_template(&endpoint.path, &endpoint.path_params); let path = build_path_template(&endpoint.path, &endpoint.path_params);
let fetch_method = if endpoint.returns_binary() { let is_post = endpoint.method == "POST";
"get" let fetch_method = match (is_post, &endpoint.response_kind) {
} else if endpoint.returns_json() { (false, _) if endpoint.returns_binary() => "get",
"get_json" (false, _) if endpoint.returns_json() => "get_json",
(false, _) => "get_text",
(true, _) if endpoint.returns_binary() => "post",
(true, _) if endpoint.returns_json() => "post_json",
(true, _) => "post_text",
};
let body_arg = if is_post && endpoint.request_body.is_some() {
", body"
} else { } else {
"get_text" ""
}; };
let (wrap_prefix, wrap_suffix) = if endpoint.response_kind.text_is_numeric() { let (wrap_prefix, wrap_suffix) = if endpoint.response_kind.text_is_numeric() {
@@ -180,15 +188,15 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
if endpoint.path_params.is_empty() { if endpoint.path_params.is_empty() {
writeln!( writeln!(
output, output,
" return {}self.{}('{}'){}", " return {}self.{}('{}'{}){}",
wrap_prefix, fetch_method, path, wrap_suffix wrap_prefix, fetch_method, path, body_arg, wrap_suffix
) )
.unwrap(); .unwrap();
} else { } else {
writeln!( writeln!(
output, output,
" return {}self.{}(f'{}'){}", " return {}self.{}(f'{}'{}){}",
wrap_prefix, fetch_method, path, wrap_suffix wrap_prefix, fetch_method, path, body_arg, wrap_suffix
) )
.unwrap(); .unwrap();
} }
@@ -234,15 +242,15 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
writeln!(output, " return self.get_text(path)").unwrap(); writeln!(output, " return self.get_text(path)").unwrap();
writeln!( writeln!(
output, output,
" return {}self.{}(path){}", " return {}self.{}(path{}){}",
wrap_prefix, fetch_method, wrap_suffix wrap_prefix, fetch_method, body_arg, wrap_suffix
) )
.unwrap(); .unwrap();
} else { } else {
writeln!( writeln!(
output, output,
" return {}self.{}(path){}", " return {}self.{}(path{}){}",
wrap_prefix, fetch_method, wrap_suffix wrap_prefix, fetch_method, body_arg, wrap_suffix
) )
.unwrap(); .unwrap();
} }
@@ -279,6 +287,14 @@ fn build_method_params(endpoint: &Endpoint) -> String {
params.push(format!(", {}: Optional[{}] = None", safe_name, py_type)); params.push(format!(", {}: Optional[{}] = None", safe_name, py_type));
} }
} }
if let Some(body) = &endpoint.request_body {
let py_type = js_type_to_python(&body.body_type);
if body.required {
params.push(format!(", body: {}", py_type));
} else {
params.push(format!(", body: Optional[{}] = None", py_type));
}
}
params.join("") params.join("")
} }

View File

@@ -99,6 +99,28 @@ class BrkClientBase:
"""Make a GET request and return text.""" """Make a GET request and return text."""
return self.get(path).decode() return self.get(path).decode()
def post(self, path: str, body: str) -> bytes:
"""Make a POST request with a string body and return raw bytes."""
try:
conn = self._connect()
conn.request("POST", path, body=body)
res = conn.getresponse()
data = res.read()
if res.status >= 400:
raise BrkError(f"HTTP error: {{res.status}}", res.status)
return data
except (ConnectionError, OSError, TimeoutError) as e:
self._conn = None
raise BrkError(str(e))
def post_json(self, path: str, body: str) -> Any:
"""Make a POST request and return JSON."""
return json.loads(self.post(path, body))
def post_text(self, path: str, body: str) -> str:
"""Make a POST request and return text."""
return self.post(path, body).decode()
def close(self) -> None: def close(self) -> None:
"""Close the HTTP client.""" """Close the HTTP client."""
if self._conn: if self._conn:

View File

@@ -87,47 +87,19 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
if !endpoint.should_generate() { if !endpoint.should_generate() {
continue; continue;
} }
match endpoint.method.as_str() {
let method_name = endpoint_to_method_name(endpoint); "GET" => generate_get_method(output, endpoint),
let base_return_type = if endpoint.returns_binary() { "POST" => generate_post_method(output, endpoint),
"Vec<u8>".to_string() _ => continue,
} 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)
} else {
base_return_type.clone()
};
writeln!(
output,
" /// {}",
endpoint.summary.as_deref().unwrap_or(&method_name)
)
.unwrap();
if let Some(desc) = &endpoint.description
&& endpoint.summary.as_ref() != Some(desc)
{
writeln!(output, " ///").unwrap();
write_description(output, desc, " /// ", " ///");
} }
// Add endpoint path }
writeln!(output, " ///").unwrap(); }
writeln!(
output, fn generate_get_method(output: &mut String, endpoint: &Endpoint) {
" /// Endpoint: `{} {}`", let method_name = endpoint_to_method_name(endpoint);
endpoint.method.to_uppercase(), let return_type = build_return_type(endpoint);
endpoint.path
) write_method_doc(output, endpoint);
.unwrap();
let params = build_method_params(endpoint); let params = build_method_params(endpoint);
writeln!( writeln!(
@@ -154,6 +126,125 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
) )
.unwrap(); .unwrap();
} else { } else {
write_query_assembly(output, endpoint, &path, &index_arg);
if endpoint.supports_csv {
writeln!(output, " if format == Some(Format::CSV) {{").unwrap();
writeln!(
output,
" self.base.get_text(&path).map(FormatResponse::Csv)"
)
.unwrap();
writeln!(output, " }} else {{").unwrap();
writeln!(
output,
" self.base.{}(&path).map(FormatResponse::Json)",
fetch_method
)
.unwrap();
writeln!(output, " }}").unwrap();
} else {
writeln!(output, " self.base.{}(&path)", fetch_method).unwrap();
}
}
writeln!(output, " }}\n").unwrap();
}
fn generate_post_method(output: &mut String, endpoint: &Endpoint) {
let method_name = endpoint_to_method_name(endpoint);
let return_type = build_return_type(endpoint);
write_method_doc(output, endpoint);
let mut params = build_method_params(endpoint);
if endpoint.request_body.is_some() {
params.push_str(", body: &str");
}
writeln!(
output,
" pub fn {}(&self{}) -> Result<{}> {{",
method_name, params, return_type
)
.unwrap();
let (path, index_arg) = build_path_template(endpoint);
let body_arg = if endpoint.request_body.is_some() {
"body"
} else {
"\"\""
};
let fetch_method = if endpoint.returns_binary() {
"post_bytes"
} else if endpoint.returns_json() {
"post_json"
} else {
"post_text"
};
if endpoint.query_params.is_empty() {
writeln!(
output,
" self.base.{}(&format!(\"{}\"{}), {})",
fetch_method, path, index_arg, body_arg
)
.unwrap();
} else {
write_query_assembly(output, endpoint, &path, &index_arg);
writeln!(
output,
" self.base.{}(&path, {})",
fetch_method, body_arg
)
.unwrap();
}
writeln!(output, " }}\n").unwrap();
}
fn build_return_type(endpoint: &Endpoint) -> String {
let base = if endpoint.returns_binary() {
"Vec<u8>".to_string()
} else if endpoint.returns_text() {
"String".to_string()
} else {
endpoint
.schema_name()
.map(js_type_to_rust)
.unwrap_or_else(|| "String".to_string())
};
if endpoint.supports_csv {
format!("FormatResponse<{}>", base)
} else {
base
}
}
fn write_method_doc(output: &mut String, endpoint: &Endpoint) {
let method_name = endpoint_to_method_name(endpoint);
writeln!(
output,
" /// {}",
endpoint.summary.as_deref().unwrap_or(&method_name)
)
.unwrap();
if let Some(desc) = &endpoint.description
&& endpoint.summary.as_ref() != Some(desc)
{
writeln!(output, " ///").unwrap();
write_description(output, desc, " /// ", " ///");
}
writeln!(output, " ///").unwrap();
writeln!(
output,
" /// Endpoint: `{} {}`",
endpoint.method.to_uppercase(),
endpoint.path
)
.unwrap();
}
fn write_query_assembly(output: &mut String, endpoint: &Endpoint, path: &str, index_arg: &str) {
writeln!(output, " let mut query = Vec::new();").unwrap(); writeln!(output, " let mut query = Vec::new();").unwrap();
for param in &endpoint.query_params { for param in &endpoint.query_params {
let ident = sanitize_ident(&param.name); let ident = sanitize_ident(&param.name);
@@ -188,29 +279,6 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
path, index_arg path, index_arg
) )
.unwrap(); .unwrap();
if endpoint.supports_csv {
writeln!(output, " if format == Some(Format::CSV) {{").unwrap();
writeln!(
output,
" self.base.get_text(&path).map(FormatResponse::Csv)"
)
.unwrap();
writeln!(output, " }} else {{").unwrap();
writeln!(
output,
" self.base.{}(&path).map(FormatResponse::Json)",
fetch_method
)
.unwrap();
writeln!(output, " }}").unwrap();
} else {
writeln!(output, " self.base.{}(&path)", fetch_method).unwrap();
}
}
writeln!(output, " }}\n").unwrap();
}
} }
fn endpoint_to_method_name(endpoint: &Endpoint) -> String { fn endpoint_to_method_name(endpoint: &Endpoint) -> String {

View File

@@ -111,6 +111,30 @@ impl BrkClientBase {{
.and_then(|mut r| r.body_mut().read_to_vec()) .and_then(|mut r| r.body_mut().read_to_vec())
.map_err(|e| BrkError {{ message: e.to_string() }}) .map_err(|e| BrkError {{ message: e.to_string() }})
}} }}
/// Make a POST request and deserialize JSON response.
pub fn post_json<T: DeserializeOwned>(&self, path: &str, body: &str) -> Result<T> {{
self.agent.post(&self.url(path))
.send(body)
.and_then(|mut r| r.body_mut().read_json())
.map_err(|e| BrkError {{ message: e.to_string() }})
}}
/// Make a POST request and return raw text response.
pub fn post_text(&self, path: &str, body: &str) -> Result<String> {{
self.agent.post(&self.url(path))
.send(body)
.and_then(|mut r| r.body_mut().read_to_string())
.map_err(|e| BrkError {{ message: e.to_string() }})
}}
/// Make a POST request and return raw bytes response.
pub fn post_bytes(&self, path: &str, body: &str) -> Result<Vec<u8>> {{
self.agent.post(&self.url(path))
.send(body)
.and_then(|mut r| r.body_mut().read_to_vec())
.map_err(|e| BrkError {{ message: e.to_string() }})
}}
}} }}
/// Build series name with suffix. /// Build series name with suffix.

View File

@@ -1,5 +1,14 @@
use crate::openapi::{Parameter, ResponseKind}; use crate::openapi::{Parameter, ResponseKind};
/// Request body shape for POST/PUT/PATCH endpoints.
#[derive(Debug, Clone)]
pub struct RequestBody {
/// Body content type as a name (e.g. "string" for text/plain, "Foo" for an `application/json` $ref).
pub body_type: String,
/// Whether the body is required.
pub required: bool,
}
/// Endpoint information extracted from OpenAPI spec. /// Endpoint information extracted from OpenAPI spec.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Endpoint { pub struct Endpoint {
@@ -17,6 +26,8 @@ pub struct Endpoint {
pub path_params: Vec<Parameter>, pub path_params: Vec<Parameter>,
/// Query parameters /// Query parameters
pub query_params: Vec<Parameter>, pub query_params: Vec<Parameter>,
/// Request body, if any (POST/PUT/PATCH).
pub request_body: Option<RequestBody>,
/// Body kind for the 200 response. /// Body kind for the 200 response.
pub response_kind: ResponseKind, pub response_kind: ResponseKind,
/// Whether this endpoint is deprecated /// Whether this endpoint is deprecated
@@ -27,9 +38,9 @@ pub struct Endpoint {
impl Endpoint { impl Endpoint {
/// Returns true if this endpoint should be included in client generation. /// Returns true if this endpoint should be included in client generation.
/// Only non-deprecated GET endpoints are included. /// Non-deprecated GET and POST endpoints are included.
pub fn should_generate(&self) -> bool { pub fn should_generate(&self) -> bool {
self.method == "GET" && !self.deprecated !self.deprecated && (self.method == "GET" || self.method == "POST")
} }
/// Returns true if this endpoint returns JSON. /// Returns true if this endpoint returns JSON.

View File

@@ -3,7 +3,7 @@ mod parameter;
mod response_kind; mod response_kind;
mod text_schema; mod text_schema;
pub use endpoint::Endpoint; pub use endpoint::{Endpoint, RequestBody};
pub use parameter::Parameter; pub use parameter::Parameter;
pub use response_kind::ResponseKind; pub use response_kind::ResponseKind;
pub use text_schema::TextSchema; pub use text_schema::TextSchema;
@@ -129,6 +129,7 @@ fn extract_endpoint(
let query_params = extract_parameters(operation, ParameterIn::Query); let query_params = extract_parameters(operation, ParameterIn::Query);
let response_kind = extract_response_kind(operation, spec); let response_kind = extract_response_kind(operation, spec);
let request_body = extract_request_body(operation);
let supports_csv = check_csv_support(operation); let supports_csv = check_csv_support(operation);
Some(Endpoint { Some(Endpoint {
@@ -139,12 +140,38 @@ fn extract_endpoint(
description: operation.description.clone(), description: operation.description.clone(),
path_params, path_params,
query_params, query_params,
request_body,
response_kind, response_kind,
deprecated: operation.deprecated.unwrap_or(false), deprecated: operation.deprecated.unwrap_or(false),
supports_csv, supports_csv,
}) })
} }
/// Extract the request body shape, if any.
/// Prefers `text/plain` (string) over `application/json` (typed).
fn extract_request_body(operation: &Operation) -> Option<RequestBody> {
let req = operation.request_body.as_ref()?;
let req = match req {
ObjectOrReference::Object(rb) => rb,
ObjectOrReference::Ref { .. } => return None,
};
let body_type = if req.content.contains_key("text/plain; charset=utf-8")
|| req.content.contains_key("text/plain")
{
"string".to_string()
} else if let Some(content) = req.content.get("application/json") {
schema_name_from_content(content).unwrap_or_else(|| "Object".to_string())
} else {
"string".to_string()
};
Some(RequestBody {
body_type,
required: req.required.unwrap_or(false),
})
}
/// Check if the endpoint supports CSV format (has text/csv in 200 response content types). /// Check if the endpoint supports CSV format (has text/csv in 200 response content types).
fn check_csv_support(operation: &Operation) -> bool { fn check_csv_support(operation: &Operation) -> bool {
let Some(responses) = operation.responses.as_ref() else { let Some(responses) = operation.responses.as_ref() else {

View File

@@ -99,6 +99,30 @@ impl BrkClientBase {
.and_then(|mut r| r.body_mut().read_to_vec()) .and_then(|mut r| r.body_mut().read_to_vec())
.map_err(|e| BrkError { message: e.to_string() }) .map_err(|e| BrkError { message: e.to_string() })
} }
/// Make a POST request and deserialize JSON response.
pub fn post_json<T: DeserializeOwned>(&self, path: &str, body: &str) -> Result<T> {
self.agent.post(&self.url(path))
.send(body)
.and_then(|mut r| r.body_mut().read_json())
.map_err(|e| BrkError { message: e.to_string() })
}
/// Make a POST request and return raw text response.
pub fn post_text(&self, path: &str, body: &str) -> Result<String> {
self.agent.post(&self.url(path))
.send(body)
.and_then(|mut r| r.body_mut().read_to_string())
.map_err(|e| BrkError { message: e.to_string() })
}
/// Make a POST request and return raw bytes response.
pub fn post_bytes(&self, path: &str, body: &str) -> Result<Vec<u8>> {
self.agent.post(&self.url(path))
.send(body)
.and_then(|mut r| r.body_mut().read_to_vec())
.map_err(|e| BrkError { message: e.to_string() })
}
} }
/// Build series name with suffix. /// Build series name with suffix.
@@ -9002,42 +9026,45 @@ impl BrkClient {
/// Address transactions /// Address transactions
/// ///
/// Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid=<txid> for pagination. /// Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`.
/// ///
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
/// ///
/// Endpoint: `GET /api/address/{address}/txs` /// Endpoint: `GET /api/address/{address}/txs`
pub fn get_address_txs(&self, address: Addr, after_txid: Option<Txid>) -> Result<Vec<Transaction>> { pub fn get_address_txs(&self, address: Addr) -> Result<Vec<Transaction>> {
let mut query = Vec::new(); self.base.get_json(&format!("/api/address/{address}/txs"))
if let Some(v) = after_txid { query.push(format!("after_txid={}", v)); }
let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) };
let path = format!("/api/address/{address}/txs{}", query_str);
self.base.get_json(&path)
} }
/// Address confirmed transactions /// Address confirmed transactions
/// ///
/// Get confirmed transactions for an address, 25 per page. Use ?after_txid=<txid> for pagination. /// Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`.
/// ///
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
/// ///
/// Endpoint: `GET /api/address/{address}/txs/chain` /// Endpoint: `GET /api/address/{address}/txs/chain`
pub fn get_address_confirmed_txs(&self, address: Addr, after_txid: Option<Txid>) -> Result<Vec<Transaction>> { pub fn get_address_confirmed_txs(&self, address: Addr) -> Result<Vec<Transaction>> {
let mut query = Vec::new(); self.base.get_json(&format!("/api/address/{address}/txs/chain"))
if let Some(v) = after_txid { query.push(format!("after_txid={}", v)); } }
let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) };
let path = format!("/api/address/{address}/txs/chain{}", query_str); /// Address confirmed transactions (paginated)
self.base.get_json(&path) ///
/// Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space).
///
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
///
/// Endpoint: `GET /api/address/{address}/txs/chain/{after_txid}`
pub fn get_address_confirmed_txs_after(&self, address: Addr, after_txid: Txid) -> Result<Vec<Transaction>> {
self.base.get_json(&format!("/api/address/{address}/txs/chain/{after_txid}"))
} }
/// Address mempool transactions /// Address mempool transactions
/// ///
/// Get unconfirmed transaction IDs for an address from the mempool (up to 50). /// Get unconfirmed transactions for an address from the mempool, newest first (up to 50).
/// ///
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)* /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)*
/// ///
/// Endpoint: `GET /api/address/{address}/txs/mempool` /// Endpoint: `GET /api/address/{address}/txs/mempool`
pub fn get_address_mempool_txs(&self, address: Addr) -> Result<Vec<Txid>> { pub fn get_address_mempool_txs(&self, address: Addr) -> Result<Vec<Transaction>> {
self.base.get_json(&format!("/api/address/{address}/txs/mempool")) self.base.get_json(&format!("/api/address/{address}/txs/mempool"))
} }
@@ -9408,6 +9435,17 @@ impl BrkClient {
self.base.get_json(&format!("/api/server/sync")) self.base.get_json(&format!("/api/server/sync"))
} }
/// Broadcast transaction
///
/// Broadcast a raw transaction to the network. The transaction should be provided as hex in the request body. The txid will be returned on success.
///
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#post-transaction)*
///
/// Endpoint: `POST /api/tx`
pub fn post_tx(&self, body: &str) -> Result<Txid> {
self.base.post_json(&format!("/api/tx"), body)
}
/// Txid by index /// Txid by index
/// ///
/// Retrieve the transaction ID (txid) at a given global transaction index. Returns the txid as plain text. /// Retrieve the transaction ID (txid) at a given global transaction index. Returns the txid as plain text.

View File

@@ -17,7 +17,7 @@ use std::{sync::Arc, thread, time::Duration};
use brk_error::Result; use brk_error::Result;
use brk_rpc::Client; use brk_rpc::Client;
use brk_types::{AddrBytes, MempoolInfo, TxOut, Txid, Vout}; use brk_types::{AddrBytes, MempoolInfo, OutpointPrefix, TxOut, Txid, TxidPrefix, Vin, Vout};
use parking_lot::RwLockReadGuard; use parking_lot::RwLockReadGuard;
use tracing::error; use tracing::error;
@@ -75,6 +75,25 @@ impl Mempool {
self.0.state.addrs.read().stats_hash(addr) self.0.state.addrs.read().stats_hash(addr)
} }
/// Look up the mempool tx that spends `(txid, vout)`. Returns
/// `(spender_txid, vin)` if the outpoint is spent in the mempool,
/// `None` otherwise. The spender's input list is walked to rule
/// out a `TxidPrefix` collision before returning a match.
pub fn lookup_spender(&self, txid: &Txid, vout: Vout) -> Option<(Txid, Vin)> {
let key = OutpointPrefix::new(TxidPrefix::from(txid), vout);
let txs = self.0.state.txs.read();
let entries = self.0.state.entries.read();
let outpoint_spends = self.0.state.outpoint_spends.read();
let idx = outpoint_spends.get(&key)?;
let spender_txid = entries.slot(idx)?.txid.clone();
let spender_tx = txs.get(&spender_txid)?;
let vin_pos = spender_tx
.input
.iter()
.position(|inp| inp.txid == *txid && inp.vout == vout)?;
Some((spender_txid, Vin::from(vin_pos)))
}
pub fn txs(&self) -> RwLockReadGuard<'_, TxStore> { pub fn txs(&self) -> RwLockReadGuard<'_, TxStore> {
self.0.state.txs.read() self.0.state.txs.read()
} }

View File

@@ -32,7 +32,7 @@ impl Applier {
} }
fn bury_one(s: &mut LockedState, prefix: &TxidPrefix, reason: TxRemoval) { fn bury_one(s: &mut LockedState, prefix: &TxidPrefix, reason: TxRemoval) {
let Some(entry) = s.entries.remove(prefix) else { let Some((idx, entry)) = s.entries.remove(prefix) else {
return; return;
}; };
let txid = entry.txid.clone(); let txid = entry.txid.clone();
@@ -41,6 +41,7 @@ impl Applier {
}; };
s.info.remove(&tx, entry.fee); s.info.remove(&tx, entry.fee);
s.addrs.remove_tx(&tx, &txid); s.addrs.remove_tx(&tx, &txid);
s.outpoint_spends.remove_spends(&tx, idx);
s.graveyard.bury(txid, tx, entry, reason); s.graveyard.bury(txid, tx, entry, reason);
} }
@@ -71,7 +72,8 @@ impl Applier {
s.info.add(&tx, entry.fee); s.info.add(&tx, entry.fee);
s.addrs.add_tx(&tx, &entry.txid); s.addrs.add_tx(&tx, &entry.txid);
let txid = entry.txid.clone(); let txid = entry.txid.clone();
s.entries.insert(entry); let idx = s.entries.insert(entry);
s.outpoint_spends.insert_spends(&tx, idx);
(txid, tx) (txid, tx)
} }
} }

View File

@@ -21,10 +21,11 @@ pub struct EntryPool {
} }
impl EntryPool { impl EntryPool {
pub fn insert(&mut self, entry: TxEntry) { pub fn insert(&mut self, entry: TxEntry) -> TxIndex {
let prefix = entry.txid_prefix(); let prefix = entry.txid_prefix();
let idx = self.claim_slot(entry); let idx = self.claim_slot(entry);
self.prefix_to_idx.insert(prefix, idx); self.prefix_to_idx.insert(prefix, idx);
idx
} }
fn claim_slot(&mut self, entry: TxEntry) -> TxIndex { fn claim_slot(&mut self, entry: TxEntry) -> TxIndex {
@@ -53,11 +54,11 @@ impl EntryPool {
self.entries.get(idx.as_usize())?.as_ref() self.entries.get(idx.as_usize())?.as_ref()
} }
pub fn remove(&mut self, prefix: &TxidPrefix) -> Option<TxEntry> { pub fn remove(&mut self, prefix: &TxidPrefix) -> Option<(TxIndex, TxEntry)> {
let idx = self.prefix_to_idx.remove(prefix)?; let idx = self.prefix_to_idx.remove(prefix)?;
let entry = self.entries.get_mut(idx.as_usize())?.take()?; let entry = self.entries.get_mut(idx.as_usize())?.take()?;
self.free_slots.push(idx); self.free_slots.push(idx);
Some(entry) Some((idx, entry))
} }
pub fn entries(&self) -> &[Option<TxEntry>] { pub fn entries(&self) -> &[Option<TxEntry>] {

View File

@@ -1,27 +1,31 @@
//! Stateful in-memory holders. Each owns its `RwLock` and exposes a //! Stateful in-memory holders. Each owns its `RwLock` and exposes a
//! behaviour-shaped API (insert, remove, evict, query). //! behaviour-shaped API (insert, remove, evict, query).
//! //!
//! [`state::MempoolState`] aggregates four locked buckets: //! [`state::MempoolState`] aggregates five locked buckets:
//! //!
//! - [`tx_store::TxStore`] - full `Transaction` data for live txs. //! - [`tx_store::TxStore`] - full `Transaction` data for live txs.
//! - [`addr_tracker::AddrTracker`] - per-address mempool stats. //! - [`addr_tracker::AddrTracker`] - per-address mempool stats.
//! - [`entry_pool::EntryPool`] - slot-recycled [`TxEntry`](crate::TxEntry) //! - [`entry_pool::EntryPool`] - slot-recycled [`TxEntry`](crate::TxEntry)
//! storage indexed by [`entry_pool::TxIndex`]. //! storage indexed by [`entry_pool::TxIndex`].
//! - [`outpoint_spends::OutpointSpends`] - outpoint → spending mempool
//! tx index, used to answer mempool-to-mempool outspend queries.
//! - [`tx_graveyard::TxGraveyard`] - recently-dropped txs as //! - [`tx_graveyard::TxGraveyard`] - recently-dropped txs as
//! [`tx_graveyard::TxTombstone`]s, retained for reappearance //! [`tx_graveyard::TxTombstone`]s, retained for reappearance
//! detection and post-mine analytics. //! detection and post-mine analytics.
//! //!
//! A fifth bucket, `info`, holds a `MempoolInfo` from `brk_types`, //! A sixth bucket, `info`, holds a `MempoolInfo` from `brk_types`,
//! so it has no file here. //! so it has no file here.
pub mod addr_tracker; pub mod addr_tracker;
pub mod entry_pool; pub mod entry_pool;
pub(crate) mod outpoint_spends;
pub mod state; pub mod state;
pub mod tx_graveyard; pub mod tx_graveyard;
pub mod tx_store; pub mod tx_store;
pub use addr_tracker::AddrTracker; pub use addr_tracker::AddrTracker;
pub use entry_pool::{EntryPool, TxIndex}; pub use entry_pool::{EntryPool, TxIndex};
pub(crate) use outpoint_spends::OutpointSpends;
pub(crate) use state::LockedState; pub(crate) use state::LockedState;
pub use state::MempoolState; pub use state::MempoolState;
pub use tx_graveyard::{TxGraveyard, TxTombstone}; pub use tx_graveyard::{TxGraveyard, TxTombstone};

View File

@@ -0,0 +1,45 @@
use brk_types::{OutpointPrefix, Transaction, TxidPrefix};
use derive_more::Deref;
use rustc_hash::FxHashMap;
use super::TxIndex;
/// Mempool index from spent outpoint to spending mempool tx.
///
/// Keys are `OutpointPrefix` (8 bytes txid + 2 bytes vout); prefix
/// collisions are possible, so callers must verify the candidate
/// spender's input list. Values are slot indices into `EntryPool`,
/// stable for the lifetime of an entry.
#[derive(Default, Deref)]
pub struct OutpointSpends(FxHashMap<OutpointPrefix, TxIndex>);
impl OutpointSpends {
pub fn insert_spends(&mut self, tx: &Transaction, idx: TxIndex) {
for input in &tx.input {
if input.is_coinbase {
continue;
}
let key = OutpointPrefix::new(TxidPrefix::from(&input.txid), input.vout);
self.0.insert(key, idx);
}
}
/// Only removes entries whose stored `TxIndex` still matches `idx`,
/// so a slot already recycled by a later insert is left alone.
pub fn remove_spends(&mut self, tx: &Transaction, idx: TxIndex) {
for input in &tx.input {
if input.is_coinbase {
continue;
}
let key = OutpointPrefix::new(TxidPrefix::from(&input.txid), input.vout);
if self.0.get(&key) == Some(&idx) {
self.0.remove(&key);
}
}
}
#[inline]
pub fn get(&self, key: &OutpointPrefix) -> Option<TxIndex> {
self.0.get(key).copied()
}
}

View File

@@ -1,12 +1,12 @@
use brk_types::MempoolInfo; use brk_types::MempoolInfo;
use parking_lot::{RwLock, RwLockWriteGuard}; use parking_lot::{RwLock, RwLockWriteGuard};
use super::{AddrTracker, EntryPool, TxGraveyard, TxStore}; use super::{AddrTracker, EntryPool, OutpointSpends, TxGraveyard, TxStore};
/// The five buckets making up live mempool state. /// The six buckets making up live mempool state.
/// ///
/// Each bucket has its own `RwLock` so readers of different buckets /// Each bucket has its own `RwLock` so readers of different buckets
/// don't contend with each other. The Applier takes all five write /// don't contend with each other. The Applier takes all six write
/// locks in a fixed order for a brief window once per cycle. /// locks in a fixed order for a brief window once per cycle.
#[derive(Default)] #[derive(Default)]
pub struct MempoolState { pub struct MempoolState {
@@ -14,11 +14,12 @@ pub struct MempoolState {
pub(crate) txs: RwLock<TxStore>, pub(crate) txs: RwLock<TxStore>,
pub(crate) addrs: RwLock<AddrTracker>, pub(crate) addrs: RwLock<AddrTracker>,
pub(crate) entries: RwLock<EntryPool>, pub(crate) entries: RwLock<EntryPool>,
pub(crate) outpoint_spends: RwLock<OutpointSpends>,
pub(crate) graveyard: RwLock<TxGraveyard>, pub(crate) graveyard: RwLock<TxGraveyard>,
} }
impl MempoolState { impl MempoolState {
/// All five write guards in the canonical lock order. Used by the /// All six write guards in the canonical lock order. Used by the
/// Applier to apply a sync diff atomically. /// Applier to apply a sync diff atomically.
pub(crate) fn write_all(&self) -> LockedState<'_> { pub(crate) fn write_all(&self) -> LockedState<'_> {
LockedState { LockedState {
@@ -26,6 +27,7 @@ impl MempoolState {
txs: self.txs.write(), txs: self.txs.write(),
addrs: self.addrs.write(), addrs: self.addrs.write(),
entries: self.entries.write(), entries: self.entries.write(),
outpoint_spends: self.outpoint_spends.write(),
graveyard: self.graveyard.write(), graveyard: self.graveyard.write(),
} }
} }
@@ -36,5 +38,6 @@ pub(crate) struct LockedState<'a> {
pub txs: RwLockWriteGuard<'a, TxStore>, pub txs: RwLockWriteGuard<'a, TxStore>,
pub addrs: RwLockWriteGuard<'a, AddrTracker>, pub addrs: RwLockWriteGuard<'a, AddrTracker>,
pub entries: RwLockWriteGuard<'a, EntryPool>, pub entries: RwLockWriteGuard<'a, EntryPool>,
pub outpoint_spends: RwLockWriteGuard<'a, OutpointSpends>,
pub graveyard: RwLockWriteGuard<'a, TxGraveyard>, pub graveyard: RwLockWriteGuard<'a, TxGraveyard>,
} }

View File

@@ -63,9 +63,10 @@ pub fn main() -> Result<()> {
25 25
)); ));
let _ = dbg!(query.addr_utxos(Addr::from( let _ = dbg!(query.addr_utxos(
"bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38".to_string() Addr::from("bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38".to_string()),
))); 1000,
));
// dbg!(query.search_and_format(SeriesSelection { // dbg!(query.search_and_format(SeriesSelection {
// index: Index::Height, // index: Index::Height,

View File

@@ -4,16 +4,13 @@ use bitcoin::{Network, PublicKey, ScriptBuf};
use brk_error::{Error, OptionData, Result}; use brk_error::{Error, OptionData, Result};
use brk_types::{ use brk_types::{
Addr, AddrBytes, AddrChainStats, AddrHash, AddrIndexOutPoint, AddrIndexTxIndex, AddrStats, Addr, AddrBytes, AddrChainStats, AddrHash, AddrIndexOutPoint, AddrIndexTxIndex, AddrStats,
AnyAddrDataIndexEnum, Dollars, Height, OutputType, Transaction, TxIndex, TxStatus, Txid, AnyAddrDataIndexEnum, Dollars, Height, OutputType, Timestamp, Transaction, TxIndex, TxStatus,
TypeIndex, Unit, Utxo, Vout, Txid, TxidPrefix, TypeIndex, Unit, Utxo, Vout,
}; };
use vecdb::VecIndex; use vecdb::VecIndex;
use crate::Query; use crate::Query;
/// Maximum number of mempool txids to return
const MAX_MEMPOOL_TXIDS: usize = 50;
impl Query { impl Query {
pub fn addr(&self, addr: Addr) -> Result<AddrStats> { pub fn addr(&self, addr: Addr) -> Result<AddrStats> {
let indexer = self.indexer(); let indexer = self.indexer();
@@ -36,14 +33,12 @@ impl Query {
let Ok(bytes) = AddrBytes::try_from((&script, output_type)) else { let Ok(bytes) = AddrBytes::try_from((&script, output_type)) else {
return Err(Error::InvalidAddr); return Err(Error::InvalidAddr);
}; };
let addr_type = output_type;
let hash = AddrHash::from(&bytes); let hash = AddrHash::from(&bytes);
let Some(store) = stores.addr_type_to_addr_hash_to_addr_index.get(addr_type) else { let Some(store) = stores.addr_type_to_addr_hash_to_addr_index.get(output_type) else {
return Err(Error::InvalidAddr); return Err(Error::InvalidAddr);
}; };
let Ok(Some(type_index)) = store.get(&hash).map(|opt| opt.map(|cow| cow.into_owned())) let Some(type_index) = store.get(&hash)?.map(|cow| cow.into_owned()) else {
else {
return Err(Error::UnknownAddr); return Err(Error::UnknownAddr);
}; };
@@ -52,30 +47,32 @@ impl Query {
.any_addr_indexes .any_addr_indexes
.get_once(output_type, type_index)?; .get_once(output_type, type_index)?;
let addr_data = match any_addr_index.to_enum() { let (addr_data, realized_price) = match any_addr_index.to_enum() {
AnyAddrDataIndexEnum::Funded(index) => computer AnyAddrDataIndexEnum::Funded(index) => {
let data = computer
.distribution .distribution
.addrs_data .addrs_data
.funded .funded
.reader() .reader()
.get(usize::from(index)), .get(usize::from(index));
AnyAddrDataIndexEnum::Empty(index) => computer let price = data.realized_price().to_dollars();
(data, price)
}
AnyAddrDataIndexEnum::Empty(index) => {
let data = computer
.distribution .distribution
.addrs_data .addrs_data
.empty .empty
.reader() .reader()
.get(usize::from(index)) .get(usize::from(index))
.into(), .into();
}; (data, Dollars::default())
}
let realized_price = match &any_addr_index.to_enum() {
AnyAddrDataIndexEnum::Funded(_) => addr_data.realized_price().to_dollars(),
AnyAddrDataIndexEnum::Empty(_) => Dollars::default(),
}; };
Ok(AddrStats { Ok(AddrStats {
addr, addr,
addr_type, addr_type: output_type,
chain_stats: AddrChainStats { chain_stats: AddrChainStats {
type_index, type_index,
funded_txo_count: addr_data.funded_txo_count, funded_txo_count: addr_data.funded_txo_count,
@@ -85,22 +82,38 @@ impl Query {
tx_count: addr_data.tx_count, tx_count: addr_data.tx_count,
realized_price, realized_price,
}, },
mempool_stats: self.mempool().map(|m| { mempool_stats: self
m.addrs() .mempool()
.get(&bytes) .and_then(|m| m.addrs().get(&bytes).map(|e| e.stats.clone()))
.map(|e| e.stats.clone()) .unwrap_or_default(),
.unwrap_or_default()
}),
}) })
} }
/// Esplora `/address/:address/txs` first page: up to `mempool_limit`
/// mempool (newest first) followed by the first `chain_limit`
/// confirmed. Pagination is path-style via `/txs/chain/:after_txid`.
pub fn addr_txs( pub fn addr_txs(
&self, &self,
addr: Addr, addr: Addr,
mempool_limit: usize,
chain_limit: usize,
) -> Result<Vec<Transaction>> {
let mut out = if self.mempool().is_some() {
self.addr_mempool_txs(&addr, mempool_limit)?
} else {
Vec::new()
};
out.extend(self.addr_txs_chain(&addr, None, chain_limit)?);
Ok(out)
}
pub fn addr_txs_chain(
&self,
addr: &Addr,
after_txid: Option<Txid>, after_txid: Option<Txid>,
limit: usize, limit: usize,
) -> Result<Vec<Transaction>> { ) -> Result<Vec<Transaction>> {
let txindices = self.addr_txindices(&addr, after_txid, limit)?; let txindices = self.addr_txindices(addr, after_txid, limit)?;
self.transactions_by_indices(&txindices) self.transactions_by_indices(&txindices)
} }
@@ -112,11 +125,10 @@ impl Query {
) -> Result<Vec<Txid>> { ) -> Result<Vec<Txid>> {
let txindices = self.addr_txindices(&addr, after_txid, limit)?; let txindices = self.addr_txindices(&addr, after_txid, limit)?;
let txid_reader = self.indexer().vecs.transactions.txid.reader(); let txid_reader = self.indexer().vecs.transactions.txid.reader();
let txids = txindices Ok(txindices
.into_iter() .into_iter()
.map(|tx_index| txid_reader.get(tx_index.to_usize())) .map(|tx_index| txid_reader.get(tx_index.to_usize()))
.collect(); .collect())
Ok(txids)
} }
fn addr_txindices( fn addr_txindices(
@@ -125,8 +137,7 @@ impl Query {
after_txid: Option<Txid>, after_txid: Option<Txid>,
limit: usize, limit: usize,
) -> Result<Vec<TxIndex>> { ) -> Result<Vec<TxIndex>> {
let indexer = self.indexer(); let stores = &self.indexer().stores;
let stores = &indexer.stores;
let (output_type, type_index) = self.resolve_addr(addr)?; let (output_type, type_index) = self.resolve_addr(addr)?;
@@ -137,8 +148,6 @@ impl Query {
if let Some(after_txid) = after_txid { if let Some(after_txid) = after_txid {
let after_tx_index = self.resolve_tx_index(&after_txid)?; let after_tx_index = self.resolve_tx_index(&after_txid)?;
// Seek directly to after_tx_index and iterate backward — O(limit)
let min = AddrIndexTxIndex::min_for_addr(type_index); let min = AddrIndexTxIndex::min_for_addr(type_index);
let bound = AddrIndexTxIndex::from((type_index, after_tx_index)); let bound = AddrIndexTxIndex::from((type_index, after_tx_index));
Ok(store Ok(store
@@ -148,7 +157,6 @@ impl Query {
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) .map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.collect()) .collect())
} else { } else {
// No pagination — scan from end of prefix
let prefix = u32::from(type_index).to_be_bytes(); let prefix = u32::from(type_index).to_be_bytes();
Ok(store Ok(store
.prefix(prefix) .prefix(prefix)
@@ -159,7 +167,7 @@ impl Query {
} }
} }
pub fn addr_utxos(&self, addr: Addr) -> Result<Vec<Utxo>> { pub fn addr_utxos(&self, addr: Addr, max_utxos: usize) -> Result<Vec<Utxo>> {
let indexer = self.indexer(); let indexer = self.indexer();
let stores = &indexer.stores; let stores = &indexer.stores;
let vecs = &indexer.vecs; let vecs = &indexer.vecs;
@@ -173,14 +181,12 @@ impl Query {
let prefix = u32::from(type_index).to_be_bytes(); let prefix = u32::from(type_index).to_be_bytes();
// Bounds worst-case work and response size, prevents heavy-address DDoS.
const MAX_UTXOS: usize = 1000;
let outpoints: Vec<(TxIndex, Vout)> = store let outpoints: Vec<(TxIndex, Vout)> = store
.prefix(prefix) .prefix(prefix)
.map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout())) .map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout()))
.take(MAX_UTXOS + 1) .take(max_utxos + 1)
.collect(); .collect();
if outpoints.len() > MAX_UTXOS { if outpoints.len() > max_utxos {
return Err(Error::TooManyUtxos); return Err(Error::TooManyUtxos);
} }
@@ -218,24 +224,38 @@ impl Query {
Ok(utxos) Ok(utxos)
} }
pub fn addr_mempool_hash(&self, addr: &Addr) -> u64 { pub fn addr_mempool_hash(&self, addr: &Addr) -> Option<u64> {
let Some(mempool) = self.mempool() else { let mempool = self.mempool()?;
return 0; let bytes = AddrBytes::from_str(addr).ok()?;
}; Some(mempool.addr_state_hash(&bytes))
let Ok(bytes) = AddrBytes::from_str(addr) else {
return 0;
};
mempool.addr_state_hash(&bytes)
} }
pub fn addr_mempool_txids(&self, addr: Addr) -> Result<Vec<Txid>> { pub fn addr_mempool_txs(&self, addr: &Addr, limit: usize) -> Result<Vec<Transaction>> {
let bytes = AddrBytes::from_str(&addr)?; let bytes = AddrBytes::from_str(addr)?;
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
Ok(mempool let addrs = mempool.addrs();
.addrs() let Some(entry) = addrs.get(&bytes) else {
.get(&bytes) return Ok(vec![]);
.map(|e| e.txids.iter().take(MAX_MEMPOOL_TXIDS).cloned().collect()) };
.unwrap_or_default()) let entries = mempool.entries();
let mut ordered: Vec<(Timestamp, &Txid)> = entry
.txids
.iter()
.map(|txid| {
let first_seen = entries
.get(&TxidPrefix::from(txid))
.map(|e| e.first_seen)
.unwrap_or_default();
(first_seen, txid)
})
.collect();
ordered.sort_unstable_by(|a, b| b.0.cmp(&a.0));
let txs = mempool.txs();
Ok(ordered
.into_iter()
.filter_map(|(_, txid)| txs.get(txid).cloned())
.take(limit)
.collect())
} }
/// Height of the last on-chain activity for an address (last tx_index → height). /// Height of the last on-chain activity for an address (last tx_index → height).
@@ -253,14 +273,9 @@ impl Query {
.next_back() .next_back()
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) .map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.ok_or(Error::UnknownAddr)?; .ok_or(Error::UnknownAddr)?;
self.computer() self.confirmed_status_height(last_tx_index)
.indexes
.tx_heights
.get_shared(last_tx_index)
.ok_or(Error::UnknownAddr)
} }
/// Resolve an address string to its output type and type_index
fn resolve_addr(&self, addr: &Addr) -> Result<(OutputType, TypeIndex)> { fn resolve_addr(&self, addr: &Addr) -> Result<(OutputType, TypeIndex)> {
let stores = &self.indexer().stores; let stores = &self.indexer().stores;
@@ -268,12 +283,12 @@ impl Query {
let output_type = OutputType::from(&bytes); let output_type = OutputType::from(&bytes);
let hash = AddrHash::from(&bytes); let hash = AddrHash::from(&bytes);
let Ok(Some(type_index)) = stores let Some(type_index) = stores
.addr_type_to_addr_hash_to_addr_index .addr_type_to_addr_hash_to_addr_index
.get(output_type) .get(output_type)
.data()? .data()?
.get(&hash) .get(&hash)?
.map(|opt| opt.map(|cow| cow.into_owned())) .map(|cow| cow.into_owned())
else { else {
return Err(Error::UnknownAddr); return Err(Error::UnknownAddr);
}; };

View File

@@ -94,24 +94,25 @@ impl Query {
Ok(mempool.txs().recent().to_vec()) Ok(mempool.txs().recent().to_vec())
} }
/// CPFP cluster for `txid`. Returns the mempool cluster when the txid is
/// unconfirmed; otherwise reconstructs the confirmed same-block cluster
/// from indexer state. Works even when the mempool feature is off.
pub fn cpfp(&self, txid: &Txid) -> Result<CpfpInfo> { pub fn cpfp(&self, txid: &Txid) -> Result<CpfpInfo> {
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
let prefix = TxidPrefix::from(txid); let prefix = TxidPrefix::from(txid);
Ok(mempool let mempool_cluster = self.mempool().and_then(|m| m.cpfp_info(&prefix));
.cpfp_info(&prefix) Ok(mempool_cluster.unwrap_or_else(|| self.confirmed_cpfp(txid)))
.unwrap_or_else(|| self.confirmed_cpfp(txid)))
} }
/// CPFP cluster for a confirmed tx: the connected component of /// CPFP cluster for a confirmed tx: the connected component of
/// same-block parent/child edges, reconstructed by BFS on demand. /// same-block parent/child edges, reconstructed by a depth-first
/// Walks entirely in `TxIndex` space using direct vec reads (height, /// walk on demand. Walks entirely in `TxIndex` space using direct
/// weight, fee) - skips full `Transaction` reconstruction and avoids /// vec reads (height, weight, fee) - skips full `Transaction`
/// `txid -> tx_index` lookups by reading `OutPoint`'s packed /// reconstruction and avoids `txid -> tx_index` lookups by reading
/// `tx_index` directly. Capped at 25 each side to match Bitcoin /// `OutPoint`'s packed `tx_index` directly. Capped at 25 each side
/// Core's default mempool chain limits and mempool.space's own /// to match Bitcoin Core's default mempool chain limits and
/// truncation. `effectiveFeePerVsize` is the simple package rate; /// mempool.space's own truncation. `effectiveFeePerVsize` is the
/// mempool's `calculateGoodBlockCpfp` chunk-rate algorithm is not /// simple package rate; mempool's `calculateGoodBlockCpfp`
/// ported. /// chunk-rate algorithm is not ported.
fn confirmed_cpfp(&self, txid: &Txid) -> CpfpInfo { fn confirmed_cpfp(&self, txid: &Txid) -> CpfpInfo {
const MAX: usize = 25; const MAX: usize = 25;
let Ok(seed_idx) = self.resolve_tx_index(txid) else { let Ok(seed_idx) = self.resolve_tx_index(txid) else {

View File

@@ -1,20 +1,17 @@
use std::{collections::BTreeMap, sync::LazyLock}; use std::sync::LazyLock;
use brk_error::{Error, Result}; use brk_error::{Error, Result};
use brk_traversable::TreeNode; use brk_traversable::TreeNode;
use brk_types::{ use brk_types::{
BlockHashPrefix, CacheClass, Date, DetailedSeriesCount, Epoch, Format, Halving, Height, Index, BlockHashPrefix, CacheClass, Date, DetailedSeriesCount, Epoch, Format, Halving, Height, Index,
IndexInfo, LegacyValue, Limit, Output, OutputLegacy, PaginatedSeries, Pagination, IndexInfo, LegacyValue, Limit, Output, OutputLegacy, PaginatedSeries, Pagination, RangeIndex,
PaginationIndex, RangeIndex, RangeMap, SearchQuery, SeriesData, SeriesInfo, SeriesName, RangeMap, SearchQuery, SeriesData, SeriesInfo, SeriesName, SeriesOutput, SeriesOutputLegacy,
SeriesOutput, SeriesOutputLegacy, SeriesSelection, Timestamp, Version, SeriesSelection, Timestamp, Version,
}; };
use parking_lot::RwLock; use parking_lot::RwLock;
use vecdb::{AnyExportableVec, ReadableVec}; use vecdb::{AnyExportableVec, ReadableVec};
use crate::{ use crate::Query;
Query,
vecs::{IndexToVec, SeriesToVec},
};
/// Monotonic block timestamps → height. Lazily extended as new blocks are indexed. /// Monotonic block timestamps → height. Lazily extended as new blocks are indexed.
static HEIGHT_BY_MONOTONIC_TIMESTAMP: LazyLock<RwLock<RangeMap<Timestamp, Height>>> = static HEIGHT_BY_MONOTONIC_TIMESTAMP: LazyLock<RwLock<RangeMap<Timestamp, Height>>> =
@@ -24,14 +21,17 @@ static HEIGHT_BY_MONOTONIC_TIMESTAMP: LazyLock<RwLock<RangeMap<Timestamp, Height
const CSV_HEADER_BYTES_PER_COL: usize = 10; const CSV_HEADER_BYTES_PER_COL: usize = 10;
/// Estimated bytes per cell value /// Estimated bytes per cell value
const CSV_CELL_BYTES: usize = 15; const CSV_CELL_BYTES: usize = 15;
/// Estimated bytes per JSON cell value
const JSON_CELL_BYTES: usize = 12;
impl Query { impl Query {
pub fn search_series(&self, query: &SearchQuery) -> Vec<&'static str> { pub fn search_series(&self, query: &SearchQuery) -> Vec<&'static str> {
self.vecs().matches(&query.q, query.limit) self.vecs().matches(&query.q, query.limit)
} }
/// Returns the error for a missing series: `SeriesUnsupportedIndex` if the name
/// exists at other indexes, else `SeriesNotFound` with fuzzy-match suggestions.
pub fn series_not_found_error(&self, series: &SeriesName) -> Error { 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) { if let Some(indexes) = self.vecs().series_to_indexes(series) {
let supported = indexes let supported = indexes
.iter() .iter()
@@ -44,7 +44,6 @@ impl Query {
}; };
} }
// Series doesn't exist, suggest alternatives
let matches = self let matches = self
.vecs() .vecs()
.matches(series, Limit::DEFAULT) .matches(series, Limit::DEFAULT)
@@ -63,25 +62,8 @@ impl Query {
return Ok(String::new()); return Ok(String::new());
} }
let from = Some(start as i64);
let to = Some(end as i64);
let num_rows = columns[0].range_count(from, to);
let num_cols = columns.len(); let num_cols = columns.len();
let mut csv = String::with_capacity(num_cols * CSV_HEADER_BYTES_PER_COL);
let estimated_size =
num_cols * CSV_HEADER_BYTES_PER_COL + num_rows * num_cols * CSV_CELL_BYTES;
let mut csv = String::with_capacity(estimated_size);
// Single-column fast path: stream directly, no Vec<T> materialization
if num_cols == 1 {
let col = columns[0];
csv.push_str(col.name());
csv.push('\n');
col.write_csv_column(Some(start), Some(end), &mut csv)?;
return Ok(csv);
}
for (i, col) in columns.iter().enumerate() { for (i, col) in columns.iter().enumerate() {
if i > 0 { if i > 0 {
csv.push(','); csv.push(',');
@@ -90,6 +72,17 @@ impl Query {
} }
csv.push('\n'); csv.push('\n');
// Stream a single column without materializing Vec<T>.
if num_cols == 1 {
columns[0].write_csv_column(Some(start), Some(end), &mut csv)?;
return Ok(csv);
}
let from = Some(start as i64);
let to = Some(end as i64);
let num_rows = columns[0].range_count(from, to);
csv.reserve(num_rows * num_cols * CSV_CELL_BYTES);
let mut writers: Vec<_> = columns let mut writers: Vec<_> = columns
.iter() .iter()
.map(|col| col.create_writer(from, to)) .map(|col| col.create_writer(from, to))
@@ -108,31 +101,31 @@ impl Query {
Ok(csv) Ok(csv)
} }
fn get_vec(
&self,
series: &SeriesName,
index: Index,
) -> Result<&'static dyn AnyExportableVec> {
self.vecs()
.get(series, index)
.ok_or_else(|| self.series_not_found_error(series))
}
/// Returns the latest value for a single series as a JSON value. /// Returns the latest value for a single series as a JSON value.
pub fn latest(&self, series: &SeriesName, index: Index) -> Result<serde_json::Value> { pub fn latest(&self, series: &SeriesName, index: Index) -> Result<serde_json::Value> {
let vec = self self.get_vec(series, index)?
.vecs() .last_json_value()
.get(series, index) .ok_or(Error::NoData)
.ok_or_else(|| self.series_not_found_error(series))?;
vec.last_json_value().ok_or(Error::NoData)
} }
/// Returns the length (total data points) for a single series. /// Returns the length (total data points) for a single series.
pub fn len(&self, series: &SeriesName, index: Index) -> Result<usize> { pub fn len(&self, series: &SeriesName, index: Index) -> Result<usize> {
let vec = self Ok(self.get_vec(series, index)?.len())
.vecs()
.get(series, index)
.ok_or_else(|| self.series_not_found_error(series))?;
Ok(vec.len())
} }
/// Returns the version for a single series. /// Returns the version for a single series.
pub fn version(&self, series: &SeriesName, index: Index) -> Result<Version> { pub fn version(&self, series: &SeriesName, index: Index) -> Result<Version> {
let vec = self Ok(self.get_vec(series, index)?.version())
.vecs()
.get(series, index)
.ok_or_else(|| self.series_not_found_error(series))?;
Ok(vec.version())
} }
/// Search for vecs matching the given series and index. /// Search for vecs matching the given series and index.
@@ -141,14 +134,11 @@ impl Query {
if params.series.is_empty() { if params.series.is_empty() {
return Err(Error::NoSeries); return Err(Error::NoSeries);
} }
let mut vecs = Vec::with_capacity(params.series.len()); params
for series in params.series.iter() { .series
match self.vecs().get(series, params.index) { .iter()
Some(vec) => vecs.push(vec), .map(|s| self.get_vec(s, params.index))
None => return Err(self.series_not_found_error(series)), .collect()
}
}
Ok(vecs)
} }
/// Calculate total weight of the vecs for the given range. /// Calculate total weight of the vecs for the given range.
@@ -165,25 +155,21 @@ impl Query {
let version: Version = vecs.iter().map(|v| v.version()).sum(); let version: Version = vecs.iter().map(|v| v.version()).sum();
let index = params.index; let index = params.index;
let start = match params.start() { let resolve_bound = |ri: RangeIndex, fallback: usize| -> Result<usize> {
Some(ri) => {
let i = self.range_index_to_i64(ri, index)?; let i = self.range_index_to_i64(ri, index)?;
vecs.iter().map(|v| v.i64_to_usize(i)).min().unwrap_or(0) Ok(vecs.iter().map(|v| v.i64_to_usize(i)).min().unwrap_or(fallback))
} };
let start = match params.start() {
Some(ri) => resolve_bound(ri, 0)?,
None => 0, None => 0,
}; };
let end = match params.end() { let end = match params.end() {
Some(ri) => { Some(ri) => resolve_bound(ri, total)?,
let i = self.range_index_to_i64(ri, index)?;
vecs.iter()
.map(|v| v.i64_to_usize(i))
.min()
.unwrap_or(total)
}
None => params None => params
.limit() .limit()
.map(|l| (start + *l).min(total)) .map(|l| start.saturating_add(*l).min(total))
.unwrap_or(total), .unwrap_or(total),
}; };
@@ -236,8 +222,15 @@ impl Query {
CacheClass::Bucket { margin } => Some(total.saturating_sub(margin)), CacheClass::Bucket { margin } => Some(total.saturating_sub(margin)),
CacheClass::Entity => { CacheClass::Entity => {
let h = Height::from((*tip_height).saturating_sub(6)); let h = Height::from((*tip_height).saturating_sub(6));
Some(self.entity_index_at(index, h).unwrap_or(0).min(total))
}
CacheClass::Mutable => None,
}
}
fn entity_index_at(&self, index: Index, h: Height) -> Option<usize> {
let v = &self.indexer().vecs; let v = &self.indexer().vecs;
let n = match index { match index {
Index::TxIndex => v.transactions.first_tx_index.collect_one(h).map(usize::from), Index::TxIndex => v.transactions.first_tx_index.collect_one(h).map(usize::from),
Index::TxInIndex => v.inputs.first_txin_index.collect_one(h).map(usize::from), Index::TxInIndex => v.inputs.first_txin_index.collect_one(h).map(usize::from),
Index::TxOutIndex => v.outputs.first_txout_index.collect_one(h).map(usize::from), Index::TxOutIndex => v.outputs.first_txout_index.collect_one(h).map(usize::from),
@@ -253,13 +246,7 @@ impl Query {
Index::P2TRAddrIndex => v.addrs.p2tr.first_index.collect_one(h).map(usize::from), Index::P2TRAddrIndex => v.addrs.p2tr.first_index.collect_one(h).map(usize::from),
Index::P2WPKHAddrIndex => v.addrs.p2wpkh.first_index.collect_one(h).map(usize::from), Index::P2WPKHAddrIndex => v.addrs.p2wpkh.first_index.collect_one(h).map(usize::from),
Index::P2WSHAddrIndex => v.addrs.p2wsh.first_index.collect_one(h).map(usize::from), Index::P2WSHAddrIndex => v.addrs.p2wsh.first_index.collect_one(h).map(usize::from),
_ => unreachable!("non-entity index in CacheClass::Entity arm"), _ => unreachable!("entity_index_at called for non-Entity Index: {index:?}"),
}
.unwrap_or(0)
.min(total);
Some(n)
}
CacheClass::Mutable => None,
} }
} }
@@ -281,22 +268,9 @@ impl Query {
Format::CSV => Output::CSV(Self::columns_to_csv(&vecs, start, end)?), Format::CSV => Output::CSV(Self::columns_to_csv(&vecs, start, end)?),
Format::JSON => { Format::JSON => {
let count = end.saturating_sub(start); let count = end.saturating_sub(start);
if vecs.len() == 1 { Output::Json(Self::write_json_array(&vecs, count, 256, |v, buf| {
let mut buf = Vec::with_capacity(count * 12 + 256); SeriesData::serialize(v, index, start, end, buf)
SeriesData::serialize(vecs[0], index, start, end, &mut buf)?; })?)
Output::Json(buf)
} else {
let mut buf = Vec::with_capacity(count * 12 * vecs.len() + 256);
buf.push(b'[');
for (i, vec) in vecs.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
SeriesData::serialize(*vec, index, start, end, &mut buf)?;
}
buf.push(b']');
Output::Json(buf)
}
} }
}; };
@@ -309,10 +283,11 @@ impl Query {
}) })
} }
/// Format a resolved query as raw data (just the JSON array, no SeriesData wrapper). /// Format a resolved query as raw data (just the JSON values, no SeriesData wrapper).
/// Single vec → `[v1,v2,...]`. Multi-vec → `[[v1,v2],[v3,v4],...]`.
/// CSV output is identical to `format` (no wrapper distinction for CSV). /// CSV output is identical to `format` (no wrapper distinction for CSV).
pub fn format_raw(&self, resolved: ResolvedQuery) -> Result<SeriesOutput> { pub fn format_raw(&self, resolved: ResolvedQuery) -> Result<SeriesOutput> {
if resolved.format() == Format::CSV { if resolved.format == Format::CSV {
return self.format(resolved); return self.format(resolved);
} }
@@ -326,8 +301,9 @@ impl Query {
} = resolved; } = resolved;
let count = end.saturating_sub(start); let count = end.saturating_sub(start);
let mut buf = Vec::with_capacity(count * 12 + 2); let buf = Self::write_json_array(&vecs, count, 2, |v, buf| {
vecs[0].write_json(Some(start), Some(end), &mut buf)?; v.write_json(Some(start), Some(end), buf)
})?;
Ok(SeriesOutput { Ok(SeriesOutput {
output: Output::Json(buf), output: Output::Json(buf),
@@ -338,12 +314,28 @@ impl Query {
}) })
} }
pub fn series_to_index_to_vec(&self) -> &BTreeMap<&str, IndexToVec<'_>> { fn write_json_array(
&self.vecs().series_to_index_to_vec vecs: &[&dyn AnyExportableVec],
cell_count: usize,
wrapper_overhead: usize,
mut write_one: impl FnMut(&dyn AnyExportableVec, &mut Vec<u8>) -> vecdb::Result<()>,
) -> Result<Vec<u8>> {
let multi = vecs.len() > 1;
let mut buf =
Vec::with_capacity(cell_count * JSON_CELL_BYTES * vecs.len() + wrapper_overhead);
if multi {
buf.push(b'[');
} }
for (i, vec) in vecs.iter().enumerate() {
pub fn index_to_series_to_vec(&self) -> &BTreeMap<Index, SeriesToVec<'_>> { if i > 0 {
&self.vecs().index_to_series_to_vec buf.push(b',');
}
write_one(*vec, &mut buf)?;
}
if multi {
buf.push(b']');
}
Ok(buf)
} }
pub fn series_count(&self) -> DetailedSeriesCount { pub fn series_count(&self) -> DetailedSeriesCount {
@@ -365,25 +357,8 @@ impl Query {
self.vecs().catalog() self.vecs().catalog()
} }
pub fn index_to_vecids(&self, paginated_index: PaginationIndex) -> Option<&[&str]> {
self.vecs().index_to_ids(paginated_index)
}
pub fn series_info(&self, series: &SeriesName) -> Option<SeriesInfo> { pub fn series_info(&self, series: &SeriesName) -> Option<SeriesInfo> {
let index_to_vec = self self.vecs().series_info(series)
.vecs()
.series_to_index_to_vec
.get(series.replace("-", "_").as_str())?;
let value_type = index_to_vec.values().next()?.value_type_to_string();
let indexes = index_to_vec.keys().copied().collect();
Some(SeriesInfo {
indexes,
value_type: value_type.into(),
})
}
pub fn series_to_indexes(&self, series: &SeriesName) -> Option<&Vec<Index>> {
self.vecs().series_to_indexes(series)
} }
/// Resolve a RangeIndex to an i64 offset for the given index type. /// Resolve a RangeIndex to an i64 offset for the given index type.
@@ -396,20 +371,16 @@ impl Query {
} }
fn date_to_i64(&self, date: Date, index: Index) -> Result<i64> { fn date_to_i64(&self, date: Date, index: Index) -> Result<i64> {
// Direct date-based index conversion (day1, week1, month1, etc.)
if let Some(idx) = index.date_to_index(date) { if let Some(idx) = index.date_to_index(date) {
return Ok(idx as i64); return Ok(idx as i64);
} }
// Fall through to timestamp-based resolution (height, epoch, halving)
self.timestamp_to_i64(Timestamp::from(date), index) self.timestamp_to_i64(Timestamp::from(date), index)
} }
fn timestamp_to_i64(&self, ts: Timestamp, index: Index) -> Result<i64> { fn timestamp_to_i64(&self, ts: Timestamp, index: Index) -> Result<i64> {
// Direct timestamp-based index conversion (minute10, hour1, etc.)
if let Some(idx) = index.timestamp_to_index(ts) { if let Some(idx) = index.timestamp_to_index(ts) {
return Ok(idx as i64); return Ok(idx as i64);
} }
// Height-based indexes: find block height, then convert
let height = Height::from(self.height_for_timestamp(ts)); let height = Height::from(self.height_for_timestamp(ts));
match index { match index {
Index::Height => Ok(usize::from(height) as i64), Index::Height => Ok(usize::from(height) as i64),
@@ -425,21 +396,22 @@ impl Query {
/// O(log n) binary search. Lazily rebuilt as new blocks arrive. /// O(log n) binary search. Lazily rebuilt as new blocks arrive.
fn height_for_timestamp(&self, ts: Timestamp) -> usize { fn height_for_timestamp(&self, ts: Timestamp) -> usize {
let current_height: usize = self.height().into(); let current_height: usize = self.height().into();
let lookup = |map: &RangeMap<Timestamp, Height>| {
map.ceil(ts).map(usize::from).unwrap_or(current_height)
};
// Fast path: read lock, ceil is &self
{ {
let map = HEIGHT_BY_MONOTONIC_TIMESTAMP.read(); let map = HEIGHT_BY_MONOTONIC_TIMESTAMP.read();
if map.len() > current_height { if map.len() > current_height {
return map.ceil(ts).map(usize::from).unwrap_or(current_height); return lookup(&map);
} }
} }
// Slow path: rebuild from computer's precomputed monotonic timestamps
let mut map = HEIGHT_BY_MONOTONIC_TIMESTAMP.write(); let mut map = HEIGHT_BY_MONOTONIC_TIMESTAMP.write();
if map.len() <= current_height { if map.len() <= current_height {
*map = RangeMap::from(self.computer().indexes.timestamp.monotonic.collect()); *map = RangeMap::from(self.computer().indexes.timestamp.monotonic.collect());
} }
map.ceil(ts).map(usize::from).unwrap_or(current_height) lookup(&map)
} }
/// Deprecated - format a resolved query as legacy output (expensive). /// Deprecated - format a resolved query as legacy output (expensive).
@@ -520,10 +492,6 @@ pub struct ResolvedQuery {
} }
impl ResolvedQuery { impl ResolvedQuery {
pub fn format(&self) -> Format {
self.format
}
pub fn csv_filename(&self) -> String { pub fn csv_filename(&self) -> String {
let names: Vec<_> = self.vecs.iter().map(|v| v.name()).collect(); let names: Vec<_> = self.vecs.iter().map(|v| v.name()).collect();
format!("{}-{}.csv", names.join("_"), self.index) format!("{}-{}.csv", names.join("_"), self.index)

View File

@@ -1,4 +1,7 @@
use bitcoin::hex::DisplayHex; use bitcoin::{
hashes::{Hash, sha256d},
hex::DisplayHex,
};
use brk_error::{Error, OptionData, Result}; use brk_error::{Error, OptionData, Result};
use brk_types::{ use brk_types::{
BlockHash, Height, MerkleProof, Timestamp, Transaction, TxInIndex, TxIndex, TxOutIndex, BlockHash, Height, MerkleProof, Timestamp, Transaction, TxInIndex, TxIndex, TxOutIndex,
@@ -17,17 +20,12 @@ impl Query {
self.indexer() self.indexer()
.stores .stores
.txid_prefix_to_tx_index .txid_prefix_to_tx_index
.get(&TxidPrefix::from(txid)) .get(&TxidPrefix::from(txid))?
.map_err(|_| Error::UnknownTxid)?
.map(|cow| cow.into_owned()) .map(|cow| cow.into_owned())
.ok_or(Error::UnknownTxid) .ok_or(Error::UnknownTxid)
} }
pub fn txid_by_index(&self, index: TxIndex) -> Result<Txid> { pub fn txid_by_index(&self, index: TxIndex) -> Result<Txid> {
let len = self.indexer().vecs.transactions.txid.len();
if index.to_usize() >= len {
return Err(Error::OutOfRange("Transaction index out of range".into()));
}
self.indexer() self.indexer()
.vecs .vecs
.transactions .transactions
@@ -55,23 +53,11 @@ impl Query {
.data() .data()
} }
/// Full confirmed TxStatus from a tx_index.
#[inline]
pub(crate) fn confirmed_status(&self, tx_index: TxIndex) -> Result<TxStatus> {
let height = self.confirmed_status_height(tx_index)?;
self.confirmed_status_at(height)
}
/// Full confirmed TxStatus from a known height. /// Full confirmed TxStatus from a known height.
#[inline] #[inline]
pub(crate) fn confirmed_status_at(&self, height: Height) -> Result<TxStatus> { pub(crate) fn confirmed_status_at(&self, height: Height) -> Result<TxStatus> {
let (block_hash, block_time) = self.block_hash_and_time(height)?; let (block_hash, block_time) = self.block_hash_and_time(height)?;
Ok(TxStatus { Ok(TxStatus::confirmed(height, block_hash, block_time))
confirmed: true,
block_height: Some(height),
block_hash: Some(block_hash),
block_time: Some(block_time),
})
} }
/// Block hash + timestamp for a height (cached vecs, fast). /// Block hash + timestamp for a height (cached vecs, fast).
@@ -85,11 +71,15 @@ impl Query {
// ── Transaction queries ──────────────────────────────────────── // ── Transaction queries ────────────────────────────────────────
/// Map a mempool transaction by txid through `f`, returning `None`
/// if no mempool is attached or the txid is not in mempool.
fn map_mempool_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> {
self.mempool()?.txs().get(txid).map(f)
}
pub fn transaction(&self, txid: &Txid) -> Result<Transaction> { pub fn transaction(&self, txid: &Txid) -> Result<Transaction> {
if let Some(mempool) = self.mempool() if let Some(tx) = self.map_mempool_tx(txid, Transaction::clone) {
&& let Some(tx) = mempool.txs().get(txid) return Ok(tx);
{
return Ok(tx.clone());
} }
self.transaction_by_index(self.resolve_tx_index(txid)?) self.transaction_by_index(self.resolve_tx_index(txid)?)
} }
@@ -98,23 +88,20 @@ impl Query {
if self.mempool().is_some_and(|m| m.txs().contains_key(txid)) { if self.mempool().is_some_and(|m| m.txs().contains_key(txid)) {
return Ok(TxStatus::UNCONFIRMED); return Ok(TxStatus::UNCONFIRMED);
} }
self.confirmed_status(self.resolve_tx_index(txid)?) let (_, height) = self.resolve_tx(txid)?;
self.confirmed_status_at(height)
} }
pub fn transaction_raw(&self, txid: &Txid) -> Result<Vec<u8>> { pub fn transaction_raw(&self, txid: &Txid) -> Result<Vec<u8>> {
if let Some(mempool) = self.mempool() if let Some(bytes) = self.map_mempool_tx(txid, Transaction::encode_bytes) {
&& let Some(tx) = mempool.txs().get(txid) return Ok(bytes);
{
return Ok(tx.encode_bytes());
} }
self.transaction_raw_by_index(self.resolve_tx_index(txid)?) self.transaction_raw_by_index(self.resolve_tx_index(txid)?)
} }
pub fn transaction_hex(&self, txid: &Txid) -> Result<String> { pub fn transaction_hex(&self, txid: &Txid) -> Result<String> {
if let Some(mempool) = self.mempool() if let Some(hex) = self.map_mempool_tx(txid, |tx| tx.encode_bytes().to_lower_hex_string()) {
&& let Some(tx) = mempool.txs().get(txid) return Ok(hex);
{
return Ok(tx.encode_bytes().to_lower_hex_string());
} }
self.transaction_hex_by_index(self.resolve_tx_index(txid)?) self.transaction_hex_by_index(self.resolve_tx_index(txid)?)
} }
@@ -123,23 +110,49 @@ impl Query {
pub fn outspend(&self, txid: &Txid, vout: Vout) -> Result<TxOutspend> { pub fn outspend(&self, txid: &Txid, vout: Vout) -> Result<TxOutspend> {
if self.mempool().is_some_and(|m| m.txs().contains_key(txid)) { if self.mempool().is_some_and(|m| m.txs().contains_key(txid)) {
return Ok(TxOutspend::UNSPENT); return Ok(self.mempool_outspend(txid, vout));
} }
let (_, first_txout, output_count) = self.resolve_tx_outputs(txid)?; let (_, first_txout, output_count) = self.resolve_tx_outputs(txid)?;
if usize::from(vout) >= output_count { if usize::from(vout) >= output_count {
return Ok(TxOutspend::UNSPENT); return Ok(TxOutspend::UNSPENT);
} }
self.resolve_outspend(first_txout + vout) let confirmed = self.resolve_outspend(first_txout + vout)?;
if confirmed.spent {
return Ok(confirmed);
}
Ok(self.mempool_outspend(txid, vout))
} }
pub fn outspends(&self, txid: &Txid) -> Result<Vec<TxOutspend>> { pub fn outspends(&self, txid: &Txid) -> Result<Vec<TxOutspend>> {
if let Some(mempool) = self.mempool() if let Some(mempool) = self.mempool()
&& let Some(tx) = mempool.txs().get(txid) && let Some(output_count) = mempool.txs().get(txid).map(|tx| tx.output.len())
{ {
return Ok(vec![TxOutspend::UNSPENT; tx.output.len()]); return Ok((0..output_count)
.map(|i| self.mempool_outspend(txid, Vout::from(i)))
.collect());
} }
let (_, first_txout, output_count) = self.resolve_tx_outputs(txid)?; let (_, first_txout, output_count) = self.resolve_tx_outputs(txid)?;
self.resolve_outspends(first_txout, output_count) let mut spends = self.resolve_outspends(first_txout, output_count)?;
for (i, spend) in spends.iter_mut().enumerate() {
if !spend.spent {
*spend = self.mempool_outspend(txid, Vout::from(i));
}
}
Ok(spends)
}
fn mempool_outspend(&self, txid: &Txid, vout: Vout) -> TxOutspend {
let Some((spender_txid, vin)) =
self.mempool().and_then(|m| m.lookup_spender(txid, vout))
else {
return TxOutspend::UNSPENT;
};
TxOutspend {
spent: true,
txid: Some(spender_txid),
vin: Some(vin),
status: Some(TxStatus::UNCONFIRMED),
}
} }
/// Resolve spend status for a single output. Minimal reads. /// Resolve spend status for a single output. Minimal reads.
@@ -204,12 +217,7 @@ impl Query {
spent: true, spent: true,
txid: Some(spending_txid), txid: Some(spending_txid),
vin: Some(vin), vin: Some(vin),
status: Some(TxStatus { status: Some(TxStatus::confirmed(spending_height, block_hash, block_time)),
confirmed: true,
block_height: Some(spending_height),
block_hash: Some(block_hash),
block_time: Some(block_time),
}),
}); });
} }
@@ -223,7 +231,7 @@ impl Query {
.vecs .vecs
.inputs .inputs
.tx_index .tx_index
.collect_one_at(usize::from(txin_index)) .collect_one(txin_index)
.data()?; .data()?;
let spending_first_txin: TxInIndex = indexer let spending_first_txin: TxInIndex = indexer
.vecs .vecs
@@ -236,8 +244,8 @@ impl Query {
.vecs .vecs
.transactions .transactions
.txid .txid
.reader() .collect_one(spending_tx_index)
.get(spending_tx_index.to_usize()); .data()?;
let spending_height = self.confirmed_status_height(spending_tx_index)?; let spending_height = self.confirmed_status_height(spending_tx_index)?;
let (block_hash, block_time) = self.block_hash_and_time(spending_height)?; let (block_hash, block_time) = self.block_hash_and_time(spending_height)?;
@@ -245,12 +253,7 @@ impl Query {
spent: true, spent: true,
txid: Some(spending_txid), txid: Some(spending_txid),
vin: Some(vin), vin: Some(vin),
status: Some(TxStatus { status: Some(TxStatus::confirmed(spending_height, block_hash, block_time)),
confirmed: true,
block_height: Some(spending_height),
block_hash: Some(block_hash),
block_time: Some(block_time),
}),
}) })
} }
@@ -258,26 +261,25 @@ impl Query {
fn resolve_tx_outputs(&self, txid: &Txid) -> Result<(TxIndex, TxOutIndex, usize)> { fn resolve_tx_outputs(&self, txid: &Txid) -> Result<(TxIndex, TxOutIndex, usize)> {
let tx_index = self.resolve_tx_index(txid)?; let tx_index = self.resolve_tx_index(txid)?;
let indexer = self.indexer(); let indexer = self.indexer();
let first = indexer let first_txout_vec = &indexer.vecs.transactions.first_txout_index;
.vecs let first = first_txout_vec.read_once(tx_index)?;
.transactions let next_tx = tx_index.incremented();
.first_txout_index let next = if next_tx.to_usize() < first_txout_vec.len() {
.read_once(tx_index)?; first_txout_vec.read_once(next_tx)?
let next = indexer } else {
.vecs TxOutIndex::from(indexer.vecs.outputs.value.len())
.transactions };
.first_txout_index
.read_once(tx_index.incremented())?;
Ok((tx_index, first, usize::from(next) - usize::from(first))) Ok((tx_index, first, usize::from(next) - usize::from(first)))
} }
// === Helper methods === // === Helper methods ===
pub fn transaction_by_index(&self, tx_index: TxIndex) -> Result<Transaction> { fn transaction_by_index(&self, tx_index: TxIndex) -> Result<Transaction> {
self.transactions_by_indices(&[tx_index])? Ok(self
.transactions_by_indices(&[tx_index])?
.into_iter() .into_iter()
.next() .next()
.ok_or(Error::NotFound("Transaction not found".into())) .expect("transactions_by_indices returns one tx per input index"))
} }
fn transaction_raw_by_index(&self, tx_index: TxIndex) -> Result<Vec<u8>> { fn transaction_raw_by_index(&self, tx_index: TxIndex) -> Result<Vec<u8>> {
@@ -328,7 +330,7 @@ impl Query {
.transactions .transactions
.first_tx_index .first_tx_index
.collect_one(height) .collect_one(height)
.ok_or(Error::NotFound("Block not found".into()))?; .data()?;
let pos = tx_index.to_usize() - first_tx.to_usize(); let pos = tx_index.to_usize() - first_tx.to_usize();
let txids = self.block_txids_by_height(height)?; let txids = self.block_txids_by_height(height)?;
@@ -341,12 +343,10 @@ impl Query {
} }
fn merkle_path(txids: &[Txid], pos: usize) -> Vec<String> { fn merkle_path(txids: &[Txid], pos: usize) -> Vec<String> {
use bitcoin::hashes::{Hash, sha256d};
// Txid bytes are in internal order (same layout as bitcoin::Txid) // Txid bytes are in internal order (same layout as bitcoin::Txid)
let mut hashes: Vec<[u8; 32]> = txids let mut hashes: Vec<[u8; 32]> = txids
.iter() .iter()
.map(|t| bitcoin::Txid::from(t).to_byte_array()) .map(|t| <&bitcoin::Txid>::from(t).to_byte_array())
.collect(); .collect();
let mut proof = Vec::new(); let mut proof = Vec::new();
@@ -357,7 +357,7 @@ fn merkle_path(txids: &[Txid], pos: usize) -> Vec<String> {
// Display order: reverse bytes for hex output // Display order: reverse bytes for hex output
let mut display = hashes[sibling]; let mut display = hashes[sibling];
display.reverse(); display.reverse();
proof.push(bitcoin::hex::DisplayHex::to_lower_hex_string(&display)); proof.push(display.to_lower_hex_string());
hashes = hashes hashes = hashes
.chunks(2) .chunks(2)

View File

@@ -14,20 +14,19 @@ impl Query {
let mut cohorts: Vec<Cohort> = fs::read_dir(states_path)? let mut cohorts: Vec<Cohort> = fs::read_dir(states_path)?
.filter_map(|entry| { .filter_map(|entry| {
let name = entry.ok()?.file_name().into_string().ok()?; let name = entry.ok()?.file_name().into_string().ok()?;
states_path if !states_path.join(&name).join("urpd").exists() {
.join(&name) return None;
.join("urpd") }
.exists() Cohort::new(name)
.then(|| Cohort::from(name))
}) })
.collect(); .collect();
cohorts.sort_by_key(|a| a.to_string()); cohorts.sort_unstable();
Ok(cohorts) Ok(cohorts)
} }
pub(crate) fn urpd_dir(&self, cohort: &str) -> Result<PathBuf> { pub(crate) fn urpd_dir(&self, cohort: &Cohort) -> Result<PathBuf> {
let dir = self let dir = self
.computer() .computer()
.distribution .distribution
@@ -59,7 +58,7 @@ impl Query {
.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok()) .filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok())
.collect(); .collect();
dates.sort(); dates.sort_unstable();
Ok(dates) Ok(dates)
} }
@@ -79,7 +78,7 @@ impl Query {
/// URPD for a cohort on a specific date. /// URPD for a cohort on a specific date.
pub fn urpd_at(&self, cohort: &Cohort, date: Date, agg: UrpdAggregation) -> Result<Urpd> { pub fn urpd_at(&self, cohort: &Cohort, date: Date, agg: UrpdAggregation) -> Result<Urpd> {
let raw = self.urpd_raw(cohort, date)?; let raw = self.urpd_raw(cohort, date)?;
let day1 = Day1::try_from(date).map_err(|e| Error::Parse(e.to_string()))?; let day1 = Day1::try_from(date)?;
let close = self let close = self
.computer() .computer()
.prices .prices

View File

@@ -4,13 +4,13 @@ use brk_computer::Computer;
use brk_indexer::Indexer; use brk_indexer::Indexer;
use brk_traversable::{Traversable, TreeNode}; use brk_traversable::{Traversable, TreeNode};
use brk_types::{ use brk_types::{
Index, IndexInfo, Limit, PaginatedSeries, Pagination, PaginationIndex, SeriesCount, SeriesName, Index, IndexInfo, Limit, PaginatedSeries, Pagination, SeriesCount, SeriesInfo, SeriesName,
}; };
use derive_more::{Deref, DerefMut}; use derive_more::{Deref, DerefMut};
use quickmatch::{QuickMatch, QuickMatchConfig}; use quickmatch::{QuickMatch, QuickMatchConfig};
use rustc_hash::{FxHashMap, FxHashSet};
use vecdb::{AnyExportableVec, Ro}; use vecdb::{AnyExportableVec, Ro};
#[derive(Default)]
pub struct Vecs<'a> { pub struct Vecs<'a> {
pub series_to_index_to_vec: BTreeMap<&'a str, IndexToVec<'a>>, pub series_to_index_to_vec: BTreeMap<&'a str, IndexToVec<'a>>,
pub index_to_series_to_vec: BTreeMap<Index, SeriesToVec<'a>>, pub index_to_series_to_vec: BTreeMap<Index, SeriesToVec<'a>>,
@@ -18,10 +18,9 @@ pub struct Vecs<'a> {
pub indexes: Vec<IndexInfo>, pub indexes: Vec<IndexInfo>,
pub counts: SeriesCount, pub counts: SeriesCount,
pub counts_by_db: BTreeMap<String, SeriesCount>, pub counts_by_db: BTreeMap<String, SeriesCount>,
catalog: Option<TreeNode>, catalog: TreeNode,
matcher: Option<QuickMatch<'a>>, matcher: QuickMatch<'a>,
series_to_indexes: BTreeMap<&'a str, Vec<Index>>, series_to_indexes: BTreeMap<&'a str, Vec<Index>>,
index_to_series: BTreeMap<Index, Vec<&'a str>>,
} }
impl<'a> Vecs<'a> { impl<'a> Vecs<'a> {
@@ -49,39 +48,26 @@ impl<'a> Vecs<'a> {
computed_vecs: impl Iterator<Item = (&'static str, &'a dyn AnyExportableVec)>, computed_vecs: impl Iterator<Item = (&'static str, &'a dyn AnyExportableVec)>,
computed_tree: TreeNode, computed_tree: TreeNode,
) -> Self { ) -> Self {
let mut this = Vecs::default(); let mut builder = Builder::default();
indexed_vecs.for_each(|vec| builder.insert(vec, "indexed"));
indexed_vecs.for_each(|vec| this.insert(vec, "indexed")); computed_vecs.for_each(|(db, vec)| builder.insert(vec, db));
computed_vecs.for_each(|(db, vec)| this.insert(vec, db)); builder.counts.distinct_series = builder.series_to_index_to_vec.len();
let Builder {
let mut ids = this series_to_index_to_vec,
.series_to_index_to_vec index_to_series_to_vec,
.keys() counts,
.copied() counts_by_db,
.collect::<Vec<_>>(); ..
} = builder;
let sort_ids = |ids: &mut Vec<&str>| { let sort_ids = |ids: &mut Vec<&str>| {
ids.sort_unstable_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b))) ids.sort_unstable_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)))
}; };
sort_ids(&mut ids); let mut series = series_to_index_to_vec.keys().copied().collect::<Vec<_>>();
sort_ids(&mut series);
this.series = ids; let indexes = index_to_series_to_vec
this.counts.distinct_series = this.series_to_index_to_vec.len();
this.counts.total_endpoints = this
.index_to_series_to_vec
.values()
.map(|tree| tree.len())
.sum::<usize>();
this.counts.lazy_endpoints = this
.index_to_series_to_vec
.values()
.flat_map(|tree| tree.values())
.filter(|vec| vec.region_names().is_empty())
.count();
this.counts.stored_endpoints = this.counts.total_endpoints - this.counts.lazy_endpoints;
this.indexes = this
.index_to_series_to_vec
.keys() .keys()
.map(|i| IndexInfo { .map(|i| IndexInfo {
index: *i, index: *i,
@@ -93,19 +79,12 @@ impl<'a> Vecs<'a> {
}) })
.collect(); .collect();
this.series_to_indexes = this let series_to_indexes = series_to_index_to_vec
.series_to_index_to_vec
.iter() .iter()
.map(|(id, index_to_vec)| (*id, index_to_vec.keys().copied().collect::<Vec<_>>())) .map(|(id, index_to_vec)| (*id, index_to_vec.keys().copied().collect::<Vec<_>>()))
.collect(); .collect();
this.index_to_series = this
.index_to_series_to_vec let catalog = TreeNode::Branch(
.iter()
.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(
TreeNode::Branch(
[ [
("indexed".to_string(), indexed_tree), ("indexed".to_string(), indexed_tree),
("computed".to_string(), computed_tree), ("computed".to_string(), computed_tree),
@@ -114,39 +93,21 @@ impl<'a> Vecs<'a> {
.collect(), .collect(),
) )
.merge_branches() .merge_branches()
.expect("indexed/computed catalog merge: same series leaf with incompatible schemas"), .expect("indexed/computed catalog merge: same series leaf with incompatible schemas");
);
this.matcher = Some(QuickMatch::new(&this.series));
this let matcher = QuickMatch::new(&series);
Self {
series_to_index_to_vec,
index_to_series_to_vec,
series,
indexes,
counts,
counts_by_db,
catalog,
matcher,
series_to_indexes,
} }
fn insert(&mut self, vec: &'a dyn AnyExportableVec, db: &str) {
let name = vec.name();
let serialized_index = vec.index_type_to_string();
let index = Index::try_from(serialized_index)
.unwrap_or_else(|_| panic!("Unknown index type: {serialized_index}"));
let prev = self
.series_to_index_to_vec
.entry(name)
.or_default()
.insert(index, vec);
assert!(
prev.is_none(),
"Duplicate series: {name} for index {index:?}"
);
self.index_to_series_to_vec
.entry(index)
.or_default()
.insert(name, vec);
let is_lazy = vec.region_names().is_empty();
self.counts_by_db
.entry(db.to_string())
.or_default()
.add_endpoint(name, is_lazy);
} }
pub fn series(&'static self, pagination: Pagination) -> PaginatedSeries { pub fn series(&'static self, pagination: Pagination) -> PaginatedSeries {
@@ -170,25 +131,21 @@ 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 self.series_to_indexes.get(series.normalize().as_ref())
.get(series.replace("-", "_").as_str())
} }
pub fn index_to_ids( pub fn series_info(&self, series: &SeriesName) -> Option<SeriesInfo> {
&self, let index_to_vec = self.series_to_index_to_vec.get(series.normalize().as_ref())?;
PaginationIndex { index, pagination }: PaginationIndex, let value_type = index_to_vec.values().next()?.value_type_to_string();
) -> Option<&[&'a str]> { let indexes = index_to_vec.keys().copied().collect();
let vec = self.index_to_series.get(&index)?; Some(SeriesInfo {
indexes,
let len = vec.len(); value_type: value_type.into(),
let start = pagination.start(len); })
let end = pagination.end(len);
Some(&vec[start..end])
} }
pub fn catalog(&self) -> &TreeNode { pub fn catalog(&self) -> &TreeNode {
self.catalog.as_ref().expect("catalog not initialized") &self.catalog
} }
pub fn matches(&self, series: &SeriesName, limit: Limit) -> Vec<&'_ str> { pub fn matches(&self, series: &SeriesName, limit: Limit) -> Vec<&'_ str> {
@@ -196,16 +153,13 @@ impl<'a> Vecs<'a> {
return Vec::new(); return Vec::new();
} }
self.matcher self.matcher
.as_ref()
.expect("matcher not initialized")
.matches_with(series, &QuickMatchConfig::new().with_limit(*limit)) .matches_with(series, &QuickMatchConfig::new().with_limit(*limit))
} }
/// Look up a vec by series name and index /// Look up a vec by series name and index. `series` is normalized (`-` → `_`, lowercased).
pub fn get(&self, series: &SeriesName, index: Index) -> Option<&'a dyn AnyExportableVec> { pub fn get(&self, series: &SeriesName, index: Index) -> Option<&'a dyn AnyExportableVec> {
let series_name = series.replace("-", "_");
self.series_to_index_to_vec self.series_to_index_to_vec
.get(series_name.as_str()) .get(series.normalize().as_ref())
.and_then(|index_to_vec| index_to_vec.get(&index).copied()) .and_then(|index_to_vec| index_to_vec.get(&index).copied())
} }
} }
@@ -215,3 +169,48 @@ pub struct IndexToVec<'a>(BTreeMap<Index, &'a dyn AnyExportableVec>);
#[derive(Default, Deref, DerefMut)] #[derive(Default, Deref, DerefMut)]
pub struct SeriesToVec<'a>(BTreeMap<&'a str, &'a dyn AnyExportableVec>); pub struct SeriesToVec<'a>(BTreeMap<&'a str, &'a dyn AnyExportableVec>);
#[derive(Default)]
struct Builder<'a> {
series_to_index_to_vec: BTreeMap<&'a str, IndexToVec<'a>>,
index_to_series_to_vec: BTreeMap<Index, SeriesToVec<'a>>,
counts: SeriesCount,
counts_by_db: BTreeMap<String, SeriesCount>,
seen_by_db: FxHashMap<&'a str, FxHashSet<&'a str>>,
}
impl<'a> Builder<'a> {
fn insert(&mut self, vec: &'a dyn AnyExportableVec, db: &'a str) {
let name = vec.name();
let serialized_index = vec.index_type_to_string();
let index = Index::try_from(serialized_index)
.unwrap_or_else(|_| panic!("Unknown index type: {serialized_index}"));
let prev = self
.series_to_index_to_vec
.entry(name)
.or_default()
.insert(index, vec);
assert!(prev.is_none(), "Duplicate series: {name} for index {index:?}");
self.index_to_series_to_vec
.entry(index)
.or_default()
.insert(name, vec);
let is_lazy = vec.region_names().is_empty();
let by_db = self.counts_by_db.entry(db.to_string()).or_default();
self.counts.total_endpoints += 1;
by_db.total_endpoints += 1;
if is_lazy {
self.counts.lazy_endpoints += 1;
by_db.lazy_endpoints += 1;
} else {
self.counts.stored_endpoints += 1;
by_db.stored_endpoints += 1;
}
if self.seen_by_db.entry(db).or_default().insert(name) {
by_db.distinct_series += 1;
}
}
}

View File

@@ -1,14 +1,14 @@
use aide::axum::{ApiRouter, routing::get_with}; use aide::axum::{ApiRouter, routing::get_with};
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, State},
http::{HeaderMap, Uri}, http::{HeaderMap, Uri},
}; };
use brk_types::{AddrStats, AddrValidation, Transaction, Txid, Utxo, Version}; use brk_types::{AddrStats, AddrValidation, Transaction, Utxo, Version};
use crate::{ use crate::{
AppState, CacheStrategy, AppState, CacheStrategy,
extended::TransformResponseExtended, extended::TransformResponseExtended,
params::{AddrParam, AddrTxidsParam, Empty, ValidateAddrParam}, params::{AddrAfterTxidParam, AddrParam, Empty, ValidateAddrParam},
}; };
pub trait AddrRoutes { pub trait AddrRoutes {
@@ -46,16 +46,16 @@ impl AddrRoutes for ApiRouter<AppState> {
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(path): Path<AddrParam>, Path(path): Path<AddrParam>,
Query(params): Query<AddrTxidsParam>, _: Empty,
State(state): State<AppState> State(state): State<AppState>
| { | {
let strategy = state.addr_strategy(Version::ONE, &path.addr, false); let strategy = state.addr_strategy(Version::ONE, &path.addr, false);
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs(path.addr, params.after_txid, 50)).await state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs(path.addr, 50, 25)).await
}, |op| op }, |op| op
.id("get_address_txs") .id("get_address_txs")
.addrs_tag() .addrs_tag()
.summary("Address transactions") .summary("Address transactions")
.description("Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid=<txid> for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*") .description("Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*")
.json_response::<Vec<Transaction>>() .json_response::<Vec<Transaction>>()
.not_modified() .not_modified()
.bad_request() .bad_request()
@@ -69,16 +69,39 @@ impl AddrRoutes for ApiRouter<AppState> {
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(path): Path<AddrParam>, Path(path): Path<AddrParam>,
Query(params): Query<AddrTxidsParam>, _: Empty,
State(state): State<AppState> State(state): State<AppState>
| { | {
let strategy = state.addr_strategy(Version::ONE, &path.addr, true); let strategy = state.addr_strategy(Version::ONE, &path.addr, true);
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs(path.addr, params.after_txid, 25)).await state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs_chain(&path.addr, None, 25)).await
}, |op| op }, |op| op
.id("get_address_confirmed_txs") .id("get_address_confirmed_txs")
.addrs_tag() .addrs_tag()
.summary("Address confirmed transactions") .summary("Address confirmed transactions")
.description("Get confirmed transactions for an address, 25 per page. Use ?after_txid=<txid> for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*") .description("Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*")
.json_response::<Vec<Transaction>>()
.not_modified()
.bad_request()
.not_found()
.server_error()
),
)
.api_route(
"/api/address/{address}/txs/chain/{after_txid}",
get_with(async |
uri: Uri,
headers: HeaderMap,
Path(path): Path<AddrAfterTxidParam>,
_: Empty,
State(state): State<AppState>
| {
let strategy = state.addr_strategy(Version::ONE, &path.addr, true);
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs_chain(&path.addr, Some(path.after_txid), 25)).await
}, |op| op
.id("get_address_confirmed_txs_after")
.addrs_tag()
.summary("Address confirmed transactions (paginated)")
.description("Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*")
.json_response::<Vec<Transaction>>() .json_response::<Vec<Transaction>>()
.not_modified() .not_modified()
.bad_request() .bad_request()
@@ -95,14 +118,14 @@ impl AddrRoutes for ApiRouter<AppState> {
_: Empty, _: Empty,
State(state): State<AppState> State(state): State<AppState>
| { | {
let hash = state.sync(|q| q.addr_mempool_hash(&path.addr)); let hash = state.sync(|q| q.addr_mempool_hash(&path.addr)).unwrap_or(0);
state.respond_json(&headers, CacheStrategy::MempoolHash(hash), &uri, move |q| q.addr_mempool_txids(path.addr)).await state.respond_json(&headers, CacheStrategy::MempoolHash(hash), &uri, move |q| q.addr_mempool_txs(&path.addr, 50)).await
}, |op| op }, |op| op
.id("get_address_mempool_txs") .id("get_address_mempool_txs")
.addrs_tag() .addrs_tag()
.summary("Address mempool transactions") .summary("Address mempool transactions")
.description("Get unconfirmed transaction IDs for an address from the mempool (up to 50).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)*") .description("Get unconfirmed transactions for an address from the mempool, newest first (up to 50).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)*")
.json_response::<Vec<Txid>>() .json_response::<Vec<Transaction>>()
.not_modified() .not_modified()
.bad_request() .bad_request()
.not_found() .not_found()
@@ -119,7 +142,7 @@ impl AddrRoutes for ApiRouter<AppState> {
State(state): State<AppState> State(state): State<AppState>
| { | {
let strategy = state.addr_strategy(Version::ONE, &path.addr, false); let strategy = state.addr_strategy(Version::ONE, &path.addr, false);
state.respond_json(&headers, strategy, &uri, move |q| q.addr_utxos(path.addr)).await state.respond_json(&headers, strategy, &uri, move |q| q.addr_utxos(path.addr, 1000)).await
}, |op| op }, |op| op
.id("get_address_utxos") .id("get_address_utxos")
.addrs_tag() .addrs_tag()

View File

@@ -40,7 +40,7 @@ pub(super) async fn serve(
let max_weight = state.max_weight; let max_weight = state.max_weight;
let resolved = state.run(move |q| q.resolve(params, max_weight)).await?; let resolved = state.run(move |q| q.resolve(params, max_weight)).await?;
let format = resolved.format(); let format = resolved.format;
let csv_filename = resolved.csv_filename(); let csv_filename = resolved.csv_filename();
let cache_params = CacheParams::series( let cache_params = CacheParams::series(
resolved.version, resolved.version,

View File

@@ -100,7 +100,7 @@ fn cost_basis_formatted(
value: CostBasisValue, value: CostBasisValue,
) -> BrkResult<CostBasisFormatted> { ) -> BrkResult<CostBasisFormatted> {
let raw = q.urpd_raw(cohort, date)?; let raw = q.urpd_raw(cohort, date)?;
let day1 = Day1::try_from(date).map_err(|e| Error::Parse(e.to_string()))?; let day1 = Day1::try_from(date)?;
let spot_cents = q let spot_cents = q
.computer() .computer()
.prices .prices

View File

@@ -61,6 +61,7 @@ fn error_status(e: &BrkError) -> StatusCode {
| BrkError::NotFound(_) | BrkError::NotFound(_)
| BrkError::NoData | BrkError::NoData
| BrkError::OutOfRange(_) | BrkError::OutOfRange(_)
| BrkError::UnindexableDate
| BrkError::SeriesNotFound(_) => StatusCode::NOT_FOUND, | BrkError::SeriesNotFound(_) => StatusCode::NOT_FOUND,
BrkError::AuthFailed => StatusCode::FORBIDDEN, BrkError::AuthFailed => StatusCode::FORBIDDEN,
@@ -85,6 +86,7 @@ fn error_code(e: &BrkError) -> &'static str {
BrkError::UnknownTxid => "unknown_txid", BrkError::UnknownTxid => "unknown_txid",
BrkError::NotFound(_) => "not_found", BrkError::NotFound(_) => "not_found",
BrkError::OutOfRange(_) => "out_of_range", BrkError::OutOfRange(_) => "out_of_range",
BrkError::UnindexableDate => "unindexable_date",
BrkError::NoData => "no_data", BrkError::NoData => "no_data",
BrkError::SeriesNotFound(_) => "series_not_found", BrkError::SeriesNotFound(_) => "series_not_found",
BrkError::MempoolNotAvailable => "mempool_not_available", BrkError::MempoolNotAvailable => "mempool_not_available",

View File

@@ -0,0 +1,14 @@
use schemars::JsonSchema;
use serde::Deserialize;
use brk_types::{Addr, Txid};
/// Bitcoin address + last-seen txid path parameters (Esplora-style pagination)
#[derive(Deserialize, JsonSchema)]
pub struct AddrAfterTxidParam {
#[serde(rename = "address")]
pub addr: Addr,
/// Last txid from the previous page (return transactions strictly older than this)
pub after_txid: Txid,
}

View File

@@ -1,11 +0,0 @@
use schemars::JsonSchema;
use serde::Deserialize;
use brk_types::Txid;
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AddrTxidsParam {
/// Txid to paginate from (return transactions before this one)
pub after_txid: Option<Txid>,
}

View File

@@ -1,5 +1,5 @@
mod addr_after_txid_param;
mod addr_param; mod addr_param;
mod addr_txids_param;
mod block_count_param; mod block_count_param;
mod blockhash_param; mod blockhash_param;
mod blockhash_start_index; mod blockhash_start_index;
@@ -17,8 +17,8 @@ mod txids_param;
mod urpd_params; mod urpd_params;
mod validate_addr_param; mod validate_addr_param;
pub use addr_after_txid_param::*;
pub use addr_param::*; pub use addr_param::*;
pub use addr_txids_param::*;
pub use block_count_param::*; pub use block_count_param::*;
pub use blockhash_param::*; pub use blockhash_param::*;
pub use blockhash_start_index::*; pub use blockhash_start_index::*;

View File

@@ -75,12 +75,11 @@ impl AppState {
/// - Unknown address → `Tip` /// - Unknown address → `Tip`
pub fn addr_strategy(&self, version: Version, addr: &Addr, chain_only: bool) -> CacheStrategy { pub fn addr_strategy(&self, version: Version, addr: &Addr, chain_only: bool) -> CacheStrategy {
self.sync(|q| { self.sync(|q| {
if !chain_only { if !chain_only
let mempool_hash = q.addr_mempool_hash(addr); && let Some(mempool_hash) = q.addr_mempool_hash(addr)
if mempool_hash != 0 { {
return CacheStrategy::MempoolHash(mempool_hash); return CacheStrategy::MempoolHash(mempool_hash);
} }
}
q.addr_last_activity_height(addr) q.addr_last_activity_height(addr)
.and_then(|h| { .and_then(|h| {
let block_hash = q.block_hash_by_height(h)?; let block_hash = q.block_hash_by_height(h)?;

View File

@@ -19,5 +19,5 @@ pub struct AddrStats {
pub chain_stats: AddrChainStats, pub chain_stats: AddrChainStats,
/// Statistics for unconfirmed transactions in the mempool /// Statistics for unconfirmed transactions in the mempool
pub mempool_stats: Option<AddrMempoolStats>, pub mempool_stats: AddrMempoolStats,
} }

View File

@@ -1,10 +1,14 @@
use std::{fmt, ops::Deref}; use std::{fmt, ops::Deref, path::Path};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
/// URPD cohort identifier. Use `GET /api/urpd` to list available cohorts. /// URPD cohort identifier. Use `GET /api/urpd` to list available cohorts.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] ///
/// Validated at construction: non-empty, ASCII `[a-z0-9_]+`. Matches the
/// schemars enum value set; the type therefore proves "this is a valid
/// cohort name" wherever a `Cohort` is held.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, JsonSchema)]
#[schemars(extend("enum" = [ #[schemars(extend("enum" = [
"all", "sth", "lth", "all", "sth", "lth",
"utxos_under_1h_old", "utxos_1h_to_1d_old", "utxos_1d_to_1w_old", "utxos_1w_to_1m_old", "utxos_under_1h_old", "utxos_1h_to_1d_old", "utxos_1d_to_1w_old", "utxos_1w_to_1m_old",
@@ -16,15 +20,20 @@ use serde::{Deserialize, Serialize};
]))] ]))]
pub struct Cohort(String); pub struct Cohort(String);
impl fmt::Display for Cohort { impl Cohort {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { /// Returns `Some(Cohort)` iff `s` is non-empty ASCII `[a-z0-9_]+`.
f.write_str(&self.0) pub fn new(s: impl Into<String>) -> Option<Self> {
let s = s.into();
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') {
return None;
}
Some(Self(s))
} }
} }
impl<T: Into<String>> From<T> for Cohort { impl fmt::Display for Cohort {
fn from(s: T) -> Self { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
Self(s.into()) f.write_str(&self.0)
} }
} }
@@ -34,3 +43,24 @@ impl Deref for Cohort {
&self.0 &self.0
} }
} }
impl AsRef<str> for Cohort {
fn as_ref(&self) -> &str {
&self.0
}
}
impl AsRef<Path> for Cohort {
fn as_ref(&self) -> &Path {
Path::new(&self.0)
}
}
impl<'de> Deserialize<'de> for Cohort {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::new(s).ok_or_else(|| {
serde::de::Error::custom("invalid cohort: expected non-empty [a-z0-9_]+")
})
}
}

View File

@@ -95,6 +95,7 @@ mod op_return_index;
mod option_ext; mod option_ext;
mod oracle_bins; mod oracle_bins;
mod outpoint; mod outpoint;
mod outpoint_prefix;
mod output; mod output;
mod output_type; mod output_type;
mod p2a_addr_index; mod p2a_addr_index;
@@ -115,7 +116,6 @@ mod p2wpkh_bytes;
mod p2wsh_addr_index; mod p2wsh_addr_index;
mod p2wsh_bytes; mod p2wsh_bytes;
mod pagination; mod pagination;
mod pagination_index;
mod percentile; mod percentile;
mod pool; mod pool;
mod pool_detail; mod pool_detail;
@@ -287,6 +287,7 @@ pub use op_return_index::*;
pub use option_ext::*; pub use option_ext::*;
pub use oracle_bins::*; pub use oracle_bins::*;
pub use outpoint::*; pub use outpoint::*;
pub use outpoint_prefix::*;
pub use output::*; pub use output::*;
pub use output_type::*; pub use output_type::*;
pub use p2a_addr_index::*; pub use p2a_addr_index::*;
@@ -307,7 +308,6 @@ pub use p2wpkh_bytes::*;
pub use p2wsh_addr_index::*; pub use p2wsh_addr_index::*;
pub use p2wsh_bytes::*; pub use p2wsh_bytes::*;
pub use pagination::*; pub use pagination::*;
pub use pagination_index::*;
pub use percentile::*; pub use percentile::*;
pub use pool::*; pub use pool::*;
pub use pool_detail::*; pub use pool_detail::*;

View File

@@ -0,0 +1,44 @@
use crate::{Txid, TxidPrefix, Vout};
/// Compact `(TxidPrefix, Vout)` outpoint identifier. Prefix collisions
/// are possible and must be verified by the caller.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OutpointPrefix(TxidPrefix, Vout);
impl OutpointPrefix {
#[inline]
pub fn new(txid_prefix: TxidPrefix, vout: Vout) -> Self {
Self(txid_prefix, vout)
}
#[inline]
pub fn txid_prefix(self) -> TxidPrefix {
self.0
}
#[inline]
pub fn vout(self) -> Vout {
self.1
}
}
impl From<(TxidPrefix, Vout)> for OutpointPrefix {
#[inline]
fn from((txid_prefix, vout): (TxidPrefix, Vout)) -> Self {
Self(txid_prefix, vout)
}
}
impl From<(&Txid, Vout)> for OutpointPrefix {
#[inline]
fn from((txid, vout): (&Txid, Vout)) -> Self {
Self(TxidPrefix::from(txid), vout)
}
}
impl From<(Txid, Vout)> for OutpointPrefix {
#[inline]
fn from((txid, vout): (Txid, Vout)) -> Self {
Self(TxidPrefix::from(&txid), vout)
}
}

View File

@@ -1,13 +0,0 @@
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{Index, Pagination};
/// Pagination parameters with an index filter
#[derive(Debug, Deserialize, JsonSchema)]
pub struct PaginationIndex {
/// The index to filter by
pub index: Index,
#[serde(flatten)]
pub pagination: Pagination,
}

View File

@@ -30,6 +30,7 @@ pub enum PoolSlug {
UltimusPool, UltimusPool,
TerraPool, TerraPool,
Luxor, Luxor,
#[serde(rename = "1thash")]
OneThash, OneThash,
BtcCom, BtcCom,
Bitfarms, Bitfarms,
@@ -38,6 +39,7 @@ pub enum PoolSlug {
CanoePool, CanoePool,
BtcTop, BtcTop,
BitcoinCom, BitcoinCom,
#[serde(rename = "175btc")]
Pool175btc, Pool175btc,
GbMiners, GbMiners,
AXbt, AXbt,
@@ -53,6 +55,7 @@ pub enum PoolSlug {
MaxBtc, MaxBtc,
TripleMining, TripleMining,
CoinLab, CoinLab,
#[serde(rename = "50btc")]
Pool50btc, Pool50btc,
GhashIo, GhashIo,
StMiningCorp, StMiningCorp,
@@ -84,8 +87,10 @@ pub enum PoolSlug {
ExxBw, ExxBw,
Bitsolo, Bitsolo,
BitFury, BitFury,
#[serde(rename = "21inc")]
TwentyOneInc, TwentyOneInc,
DigitalBtc, DigitalBtc,
#[serde(rename = "8baochi")]
EightBaochi, EightBaochi,
MyBtcCoinPool, MyBtcCoinPool,
TbDice, TbDice,
@@ -95,6 +100,7 @@ pub enum PoolSlug {
HotPool, HotPool,
OkExPool, OkExPool,
BcMonster, BcMonster,
#[serde(rename = "1hash")]
OneHash, OneHash,
Bixin, Bixin,
TatmasPool, TatmasPool,
@@ -105,12 +111,14 @@ pub enum PoolSlug {
DcExploration, DcExploration,
Dcex, Dcex,
BtPool, BtPool,
#[serde(rename = "58coin")]
FiftyEightCoin, FiftyEightCoin,
BitcoinIndia, BitcoinIndia,
ShawnP0wers, ShawnP0wers,
PHashIo, PHashIo,
RigPool, RigPool,
HaoZhuZhu, HaoZhuZhu,
#[serde(rename = "7pool")]
SevenPool, SevenPool,
MiningKings, MiningKings,
HashBx, HashBx,
@@ -164,6 +172,7 @@ pub enum PoolSlug {
EkanemBtc, EkanemBtc,
Canoe, Canoe,
Tiger, Tiger,
#[serde(rename = "1m1x")]
OneM1x, OneM1x,
Zulupool, Zulupool,
SecPool, SecPool,
@@ -200,6 +209,7 @@ pub enum PoolSlug {
RedRockPool, RedRockPool,
Est3lar, Est3lar,
BraiinsSolo, BraiinsSolo,
#[serde(rename = "solopoolcom")]
SoloPool, SoloPool,
Noderunners, Noderunners,
#[serde(skip)] #[serde(skip)]

View File

@@ -1,6 +1,5 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use rustc_hash::FxHashSet;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -19,22 +18,6 @@ pub struct SeriesCount {
/// Number of eager (stored on disk) series-index combinations /// Number of eager (stored on disk) series-index combinations
#[schemars(example = 16000)] #[schemars(example = 16000)]
pub stored_endpoints: usize, pub stored_endpoints: usize,
#[serde(skip)]
seen: FxHashSet<String>,
}
impl SeriesCount {
pub fn add_endpoint(&mut self, name: &str, is_lazy: bool) {
self.total_endpoints += 1;
if is_lazy {
self.lazy_endpoints += 1;
} else {
self.stored_endpoints += 1;
}
if self.seen.insert(name.to_string()) {
self.distinct_series += 1;
}
}
} }
/// Detailed series count with per-database breakdown /// Detailed series count with per-database breakdown

View File

@@ -30,19 +30,14 @@ impl From<SeriesName> for SeriesList {
impl From<String> for SeriesList { impl From<String> for SeriesList {
#[inline] #[inline]
fn from(value: String) -> Self { fn from(value: String) -> Self {
Self::from(SeriesName::from(value.replace("-", "_").to_lowercase())) Self::from(SeriesName::from(value))
} }
} }
impl<'a> From<Vec<&'a str>> for SeriesList { impl<'a> From<Vec<&'a str>> for SeriesList {
#[inline] #[inline]
fn from(value: Vec<&'a str>) -> Self { fn from(value: Vec<&'a str>) -> Self {
Self( Self(value.into_iter().map(SeriesName::from).collect())
value
.iter()
.map(|s| SeriesName::from(s.replace("-", "_").to_lowercase()))
.collect::<Vec<_>>(),
)
} }
} }

View File

@@ -1,4 +1,4 @@
use std::fmt::Display; use std::{borrow::Cow, fmt::Display};
use derive_more::Deref; use derive_more::Deref;
use schemars::JsonSchema; use schemars::JsonSchema;
@@ -15,6 +15,17 @@ use serde::{Deserialize, Serialize};
)] )]
pub struct SeriesName(String); pub struct SeriesName(String);
impl SeriesName {
/// Lookup key: `-` → `_`, lowercased. Borrows when already normalized.
pub fn normalize(&self) -> Cow<'_, str> {
if self.0.bytes().any(|b| b == b'-' || b.is_ascii_uppercase()) {
Cow::Owned(self.0.replace('-', "_").to_lowercase())
} else {
Cow::Borrowed(&self.0)
}
}
}
impl From<String> for SeriesName { impl From<String> for SeriesName {
#[inline] #[inline]
fn from(series: String) -> Self { fn from(series: String) -> Self {

View File

@@ -30,4 +30,13 @@ impl TxStatus {
block_height: None, block_height: None,
block_time: None, block_time: None,
}; };
pub fn confirmed(height: Height, block_hash: BlockHash, block_time: Timestamp) -> Self {
Self {
confirmed: true,
block_height: Some(height),
block_hash: Some(block_hash),
block_time: Some(block_time),
}
}
} }

View File

@@ -8,6 +8,13 @@
* *
* @typedef {string} Addr * @typedef {string} Addr
*/ */
/**
* Bitcoin address + last-seen txid path parameters (Esplora-style pagination)
*
* @typedef {Object} AddrAfterTxidParam
* @property {Addr} address
* @property {Txid} afterTxid - Last txid from the previous page (return transactions strictly older than this)
*/
/** /**
* Address statistics on the blockchain (confirmed transactions only) * Address statistics on the blockchain (confirmed transactions only)
* *
@@ -47,11 +54,7 @@
* @property {Addr} address - Bitcoin address string * @property {Addr} address - Bitcoin address string
* @property {OutputType} addrType - Address type (p2pkh, p2sh, v0_p2wpkh, v0_p2wsh, v1_p2tr, etc.) * @property {OutputType} addrType - Address type (p2pkh, p2sh, v0_p2wpkh, v0_p2wsh, v1_p2tr, etc.)
* @property {AddrChainStats} chainStats - Statistics for confirmed transactions on the blockchain * @property {AddrChainStats} chainStats - Statistics for confirmed transactions on the blockchain
* @property {(AddrMempoolStats|null)=} mempoolStats - Statistics for unconfirmed transactions in the mempool * @property {AddrMempoolStats} mempoolStats - Statistics for unconfirmed transactions in the mempool
*/
/**
* @typedef {Object} AddrTxidsParam
* @property {(Txid|null)=} afterTxid - Txid to paginate from (return transactions before this one)
*/ */
/** /**
* Address validation result * Address validation result
@@ -327,6 +330,10 @@ Matches mempool.space/bitcoin-cli behavior.
/** /**
* URPD cohort identifier. Use `GET /api/urpd` to list available cohorts. * URPD cohort identifier. Use `GET /api/urpd` to list available cohorts.
* *
* Validated at construction: non-empty, ASCII `[a-z0-9_]+`. Matches the
* schemars enum value set; the type therefore proves "this is a valid
* cohort name" wherever a `Cohort` is held.
*
* @typedef {("all"|"sth"|"lth"|"utxos_under_1h_old"|"utxos_1h_to_1d_old"|"utxos_1d_to_1w_old"|"utxos_1w_to_1m_old"|"utxos_1m_to_2m_old"|"utxos_2m_to_3m_old"|"utxos_3m_to_4m_old"|"utxos_4m_to_5m_old"|"utxos_5m_to_6m_old"|"utxos_6m_to_1y_old"|"utxos_1y_to_2y_old"|"utxos_2y_to_3y_old"|"utxos_3y_to_4y_old"|"utxos_4y_to_5y_old"|"utxos_5y_to_6y_old"|"utxos_6y_to_7y_old"|"utxos_7y_to_8y_old"|"utxos_8y_to_10y_old"|"utxos_10y_to_12y_old"|"utxos_12y_to_15y_old"|"utxos_over_15y_old")} Cohort * @typedef {("all"|"sth"|"lth"|"utxos_under_1h_old"|"utxos_1h_to_1d_old"|"utxos_1d_to_1w_old"|"utxos_1w_to_1m_old"|"utxos_1m_to_2m_old"|"utxos_2m_to_3m_old"|"utxos_3m_to_4m_old"|"utxos_4m_to_5m_old"|"utxos_5m_to_6m_old"|"utxos_6m_to_1y_old"|"utxos_1y_to_2y_old"|"utxos_2y_to_3y_old"|"utxos_3y_to_4y_old"|"utxos_4y_to_5y_old"|"utxos_5y_to_6y_old"|"utxos_6y_to_7y_old"|"utxos_7y_to_8y_old"|"utxos_8y_to_10y_old"|"utxos_10y_to_12y_old"|"utxos_12y_to_15y_old"|"utxos_over_15y_old")} Cohort
*/ */
/** /**
@@ -796,7 +803,7 @@ Matches mempool.space/bitcoin-cli behavior.
/** /**
* URL-friendly mining pool identifier * URL-friendly mining pool identifier
* *
* @typedef {("unknown"|"blockfills"|"ultimuspool"|"terrapool"|"luxor"|"onethash"|"btccom"|"bitfarms"|"huobipool"|"wayicn"|"canoepool"|"btctop"|"bitcoincom"|"pool175btc"|"gbminers"|"axbt"|"asicminer"|"bitminter"|"bitcoinrussia"|"btcserv"|"simplecoinus"|"btcguild"|"eligius"|"ozcoin"|"eclipsemc"|"maxbtc"|"triplemining"|"coinlab"|"pool50btc"|"ghashio"|"stminingcorp"|"bitparking"|"mmpool"|"polmine"|"kncminer"|"bitalo"|"f2pool"|"hhtt"|"megabigpower"|"mtred"|"nmcbit"|"yourbtcnet"|"givemecoins"|"braiinspool"|"antpool"|"multicoinco"|"bcpoolio"|"cointerra"|"kanopool"|"solock"|"ckpool"|"nicehash"|"bitclub"|"bitcoinaffiliatenetwork"|"btcc"|"bwpool"|"exxbw"|"bitsolo"|"bitfury"|"twentyoneinc"|"digitalbtc"|"eightbaochi"|"mybtccoinpool"|"tbdice"|"hashpool"|"nexious"|"bravomining"|"hotpool"|"okexpool"|"bcmonster"|"onehash"|"bixin"|"tatmaspool"|"viabtc"|"connectbtc"|"batpool"|"waterhole"|"dcexploration"|"dcex"|"btpool"|"fiftyeightcoin"|"bitcoinindia"|"shawnp0wers"|"phashio"|"rigpool"|"haozhuzhu"|"sevenpool"|"miningkings"|"hashbx"|"dpool"|"rawpool"|"haominer"|"helix"|"bitcoinukraine"|"poolin"|"secretsuperstar"|"tigerpoolnet"|"sigmapoolcom"|"okpooltop"|"hummerpool"|"tangpool"|"bytepool"|"spiderpool"|"novablock"|"miningcity"|"binancepool"|"minerium"|"lubiancom"|"okkong"|"aaopool"|"emcdpool"|"foundryusa"|"sbicrypto"|"arkpool"|"purebtccom"|"marapool"|"kucoinpool"|"entrustcharitypool"|"okminer"|"titan"|"pegapool"|"btcnuggets"|"cloudhashing"|"digitalxmintsy"|"telco214"|"btcpoolparty"|"multipool"|"transactioncoinmining"|"btcdig"|"trickysbtcpool"|"btcmp"|"eobot"|"unomp"|"patels"|"gogreenlight"|"bitcoinindiapool"|"ekanembtc"|"canoe"|"tiger"|"onem1x"|"zulupool"|"secpool"|"ocean"|"whitepool"|"wiz"|"wk057"|"futurebitapollosolo"|"carbonnegative"|"portlandhodl"|"phoenix"|"neopool"|"maxipool"|"bitfufupool"|"gdpool"|"miningdutch"|"publicpool"|"miningsquared"|"innopolistech"|"btclab"|"parasite"|"redrockpool"|"est3lar"|"braiinssolo"|"solopool"|"noderunners")} PoolSlug * @typedef {("unknown"|"blockfills"|"ultimuspool"|"terrapool"|"luxor"|"1thash"|"btccom"|"bitfarms"|"huobipool"|"wayicn"|"canoepool"|"btctop"|"bitcoincom"|"175btc"|"gbminers"|"axbt"|"asicminer"|"bitminter"|"bitcoinrussia"|"btcserv"|"simplecoinus"|"btcguild"|"eligius"|"ozcoin"|"eclipsemc"|"maxbtc"|"triplemining"|"coinlab"|"50btc"|"ghashio"|"stminingcorp"|"bitparking"|"mmpool"|"polmine"|"kncminer"|"bitalo"|"f2pool"|"hhtt"|"megabigpower"|"mtred"|"nmcbit"|"yourbtcnet"|"givemecoins"|"braiinspool"|"antpool"|"multicoinco"|"bcpoolio"|"cointerra"|"kanopool"|"solock"|"ckpool"|"nicehash"|"bitclub"|"bitcoinaffiliatenetwork"|"btcc"|"bwpool"|"exxbw"|"bitsolo"|"bitfury"|"21inc"|"digitalbtc"|"8baochi"|"mybtccoinpool"|"tbdice"|"hashpool"|"nexious"|"bravomining"|"hotpool"|"okexpool"|"bcmonster"|"1hash"|"bixin"|"tatmaspool"|"viabtc"|"connectbtc"|"batpool"|"waterhole"|"dcexploration"|"dcex"|"btpool"|"58coin"|"bitcoinindia"|"shawnp0wers"|"phashio"|"rigpool"|"haozhuzhu"|"7pool"|"miningkings"|"hashbx"|"dpool"|"rawpool"|"haominer"|"helix"|"bitcoinukraine"|"poolin"|"secretsuperstar"|"tigerpoolnet"|"sigmapoolcom"|"okpooltop"|"hummerpool"|"tangpool"|"bytepool"|"spiderpool"|"novablock"|"miningcity"|"binancepool"|"minerium"|"lubiancom"|"okkong"|"aaopool"|"emcdpool"|"foundryusa"|"sbicrypto"|"arkpool"|"purebtccom"|"marapool"|"kucoinpool"|"entrustcharitypool"|"okminer"|"titan"|"pegapool"|"btcnuggets"|"cloudhashing"|"digitalxmintsy"|"telco214"|"btcpoolparty"|"multipool"|"transactioncoinmining"|"btcdig"|"trickysbtcpool"|"btcmp"|"eobot"|"unomp"|"patels"|"gogreenlight"|"bitcoinindiapool"|"ekanembtc"|"canoe"|"tiger"|"1m1x"|"zulupool"|"secpool"|"ocean"|"whitepool"|"wiz"|"wk057"|"futurebitapollosolo"|"carbonnegative"|"portlandhodl"|"phoenix"|"neopool"|"maxipool"|"bitfufupool"|"gdpool"|"miningdutch"|"publicpool"|"miningsquared"|"innopolistech"|"btclab"|"parasite"|"redrockpool"|"est3lar"|"braiinssolo"|"solopoolcom"|"noderunners")} PoolSlug
*/ */
/** /**
* Mining pool slug + block height path parameters * Mining pool slug + block height path parameters
@@ -1871,6 +1878,67 @@ class BrkClientBase {
return this._getCached(path, async (res) => new Uint8Array(await res.arrayBuffer()), options); return this._getCached(path, async (res) => new Uint8Array(await res.arrayBuffer()), options);
} }
/**
* Make a POST request with a string body.
*
* POST responses are uncached and never invoke `onValue` — every call hits
* the network with the same body and returns the upstream response.
*
* @param {string} path
* @param {string} body
* @param {{ signal?: AbortSignal }} [options]
* @returns {Promise<Response>}
*/
async post(path, body, { signal } = {}) {
const url = `${this.baseUrl}${path}`;
const signals = [AbortSignal.timeout(this.timeout)];
if (signal) signals.push(signal);
const res = await fetch(url, {
method: 'POST',
body,
signal: AbortSignal.any(signals),
});
if (!res.ok) throw new BrkError(`HTTP ${res.status}: ${url}`, res.status);
return res;
}
/**
* Make a POST request expecting a JSON response.
* @template T
* @param {string} path
* @param {string} body
* @param {{ signal?: AbortSignal }} [options]
* @returns {Promise<T>}
*/
async postJson(path, body, options) {
const res = await this.post(path, body, options);
return _addCamelGetters(await res.json());
}
/**
* Make a POST request expecting a text response.
* @param {string} path
* @param {string} body
* @param {{ signal?: AbortSignal }} [options]
* @returns {Promise<string>}
*/
async postText(path, body, options) {
const res = await this.post(path, body, options);
return res.text();
}
/**
* Make a POST request expecting binary data (application/octet-stream).
* @param {string} path
* @param {string} body
* @param {{ signal?: AbortSignal }} [options]
* @returns {Promise<Uint8Array>}
*/
async postBytes(path, body, options) {
const res = await this.post(path, body, options);
return new Uint8Array(await res.arrayBuffer());
}
/** /**
* Fetch series data and wrap with helper methods (internal) * Fetch series data and wrap with helper methods (internal)
* @template T * @template T
@@ -7413,7 +7481,7 @@ class BrkClient extends BrkClientBase {
"ultimuspool": "ULTIMUSPOOL", "ultimuspool": "ULTIMUSPOOL",
"terrapool": "Terra Pool", "terrapool": "Terra Pool",
"luxor": "Luxor", "luxor": "Luxor",
"onethash": "1THash", "1thash": "1THash",
"btccom": "BTC.com", "btccom": "BTC.com",
"bitfarms": "Bitfarms", "bitfarms": "Bitfarms",
"huobipool": "Huobi.pool", "huobipool": "Huobi.pool",
@@ -7421,7 +7489,7 @@ class BrkClient extends BrkClientBase {
"canoepool": "CanoePool", "canoepool": "CanoePool",
"btctop": "BTC.TOP", "btctop": "BTC.TOP",
"bitcoincom": "Bitcoin.com", "bitcoincom": "Bitcoin.com",
"pool175btc": "175btc", "175btc": "175btc",
"gbminers": "GBMiners", "gbminers": "GBMiners",
"axbt": "A-XBT", "axbt": "A-XBT",
"asicminer": "ASICMiner", "asicminer": "ASICMiner",
@@ -7436,7 +7504,7 @@ class BrkClient extends BrkClientBase {
"maxbtc": "MaxBTC", "maxbtc": "MaxBTC",
"triplemining": "TripleMining", "triplemining": "TripleMining",
"coinlab": "CoinLab", "coinlab": "CoinLab",
"pool50btc": "50BTC", "50btc": "50BTC",
"ghashio": "GHash.IO", "ghashio": "GHash.IO",
"stminingcorp": "ST Mining Corp", "stminingcorp": "ST Mining Corp",
"bitparking": "Bitparking", "bitparking": "Bitparking",
@@ -7467,9 +7535,9 @@ class BrkClient extends BrkClientBase {
"exxbw": "EXX&BW", "exxbw": "EXX&BW",
"bitsolo": "Bitsolo", "bitsolo": "Bitsolo",
"bitfury": "BitFury", "bitfury": "BitFury",
"twentyoneinc": "21 Inc.", "21inc": "21 Inc.",
"digitalbtc": "digitalBTC", "digitalbtc": "digitalBTC",
"eightbaochi": "8baochi", "8baochi": "8baochi",
"mybtccoinpool": "myBTCcoin Pool", "mybtccoinpool": "myBTCcoin Pool",
"tbdice": "TBDice", "tbdice": "TBDice",
"hashpool": "HASHPOOL", "hashpool": "HASHPOOL",
@@ -7478,7 +7546,7 @@ class BrkClient extends BrkClientBase {
"hotpool": "HotPool", "hotpool": "HotPool",
"okexpool": "OKExPool", "okexpool": "OKExPool",
"bcmonster": "BCMonster", "bcmonster": "BCMonster",
"onehash": "1Hash", "1hash": "1Hash",
"bixin": "Bixin", "bixin": "Bixin",
"tatmaspool": "TATMAS Pool", "tatmaspool": "TATMAS Pool",
"viabtc": "ViaBTC", "viabtc": "ViaBTC",
@@ -7488,13 +7556,13 @@ class BrkClient extends BrkClientBase {
"dcexploration": "DCExploration", "dcexploration": "DCExploration",
"dcex": "DCEX", "dcex": "DCEX",
"btpool": "BTPOOL", "btpool": "BTPOOL",
"fiftyeightcoin": "58COIN", "58coin": "58COIN",
"bitcoinindia": "Bitcoin India", "bitcoinindia": "Bitcoin India",
"shawnp0wers": "shawnp0wers", "shawnp0wers": "shawnp0wers",
"phashio": "PHash.IO", "phashio": "PHash.IO",
"rigpool": "RigPool", "rigpool": "RigPool",
"haozhuzhu": "HAOZHUZHU", "haozhuzhu": "HAOZHUZHU",
"sevenpool": "7pool", "7pool": "7pool",
"miningkings": "MiningKings", "miningkings": "MiningKings",
"hashbx": "HashBX", "hashbx": "HashBX",
"dpool": "DPOOL", "dpool": "DPOOL",
@@ -7547,7 +7615,7 @@ class BrkClient extends BrkClientBase {
"ekanembtc": "EkanemBTC", "ekanembtc": "EkanemBTC",
"canoe": "CANOE", "canoe": "CANOE",
"tiger": "tiger", "tiger": "tiger",
"onem1x": "1M1X", "1m1x": "1M1X",
"zulupool": "Zulupool", "zulupool": "Zulupool",
"secpool": "SECPOOL", "secpool": "SECPOOL",
"ocean": "OCEAN", "ocean": "OCEAN",
@@ -7571,7 +7639,7 @@ class BrkClient extends BrkClientBase {
"redrockpool": "RedRock Pool", "redrockpool": "RedRock Pool",
"est3lar": "Est3lar", "est3lar": "Est3lar",
"braiinssolo": "Braiins Solo", "braiinssolo": "Braiins Solo",
"solopool": "SoloPool.com", "solopoolcom": "SoloPool.com",
"noderunners": "Noderunners" "noderunners": "Noderunners"
}); });
@@ -10374,59 +10442,70 @@ class BrkClient extends BrkClientBase {
/** /**
* Address transactions * Address transactions
* *
* Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid=<txid> for pagination. * Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`.
* *
* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
* *
* Endpoint: `GET /api/address/{address}/txs` * Endpoint: `GET /api/address/{address}/txs`
* *
* @param {Addr} address * @param {Addr} address
* @param {Txid=} [after_txid] - Txid to paginate from (return transactions before this one)
* @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options]
* @returns {Promise<Transaction[]>} * @returns {Promise<Transaction[]>}
*/ */
async getAddressTxs(address, after_txid, { signal, onValue } = {}) { async getAddressTxs(address, { signal, onValue } = {}) {
const params = new URLSearchParams(); const path = `/api/address/${address}/txs`;
if (after_txid !== undefined) params.set('after_txid', String(after_txid));
const query = params.toString();
const path = `/api/address/${address}/txs${query ? '?' + query : ''}`;
return this.getJson(path, { signal, onValue }); return this.getJson(path, { signal, onValue });
} }
/** /**
* Address confirmed transactions * Address confirmed transactions
* *
* Get confirmed transactions for an address, 25 per page. Use ?after_txid=<txid> for pagination. * Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`.
* *
* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
* *
* Endpoint: `GET /api/address/{address}/txs/chain` * Endpoint: `GET /api/address/{address}/txs/chain`
* *
* @param {Addr} address * @param {Addr} address
* @param {Txid=} [after_txid] - Txid to paginate from (return transactions before this one)
* @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options]
* @returns {Promise<Transaction[]>} * @returns {Promise<Transaction[]>}
*/ */
async getAddressConfirmedTxs(address, after_txid, { signal, onValue } = {}) { async getAddressConfirmedTxs(address, { signal, onValue } = {}) {
const params = new URLSearchParams(); const path = `/api/address/${address}/txs/chain`;
if (after_txid !== undefined) params.set('after_txid', String(after_txid)); return this.getJson(path, { signal, onValue });
const query = params.toString(); }
const path = `/api/address/${address}/txs/chain${query ? '?' + query : ''}`;
/**
* Address confirmed transactions (paginated)
*
* Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space).
*
* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
*
* Endpoint: `GET /api/address/{address}/txs/chain/{after_txid}`
*
* @param {Addr} address
* @param {Txid} after_txid - Last txid from the previous page (return transactions strictly older than this)
* @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options]
* @returns {Promise<Transaction[]>}
*/
async getAddressConfirmedTxsAfter(address, after_txid, { signal, onValue } = {}) {
const path = `/api/address/${address}/txs/chain/${after_txid}`;
return this.getJson(path, { signal, onValue }); return this.getJson(path, { signal, onValue });
} }
/** /**
* Address mempool transactions * Address mempool transactions
* *
* Get unconfirmed transaction IDs for an address from the mempool (up to 50). * Get unconfirmed transactions for an address from the mempool, newest first (up to 50).
* *
* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)* * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)*
* *
* Endpoint: `GET /api/address/{address}/txs/mempool` * Endpoint: `GET /api/address/{address}/txs/mempool`
* *
* @param {Addr} address * @param {Addr} address
* @param {{ signal?: AbortSignal, onValue?: (value: Txid[]) => void }} [options] * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options]
* @returns {Promise<Txid[]>} * @returns {Promise<Transaction[]>}
*/ */
async getAddressMempoolTxs(address, { signal, onValue } = {}) { async getAddressMempoolTxs(address, { signal, onValue } = {}) {
const path = `/api/address/${address}/txs/mempool`; const path = `/api/address/${address}/txs/mempool`;
@@ -11008,6 +11087,24 @@ class BrkClient extends BrkClientBase {
return this.getJson(path, { signal, onValue }); return this.getJson(path, { signal, onValue });
} }
/**
* Broadcast transaction
*
* Broadcast a raw transaction to the network. The transaction should be provided as hex in the request body. The txid will be returned on success.
*
* *[Mempool.space docs](https://mempool.space/docs/api/rest#post-transaction)*
*
* Endpoint: `POST /api/tx`
*
* @param {string} body - Request body
* @param {{ signal?: AbortSignal }} [options]
* @returns {Promise<Txid>}
*/
async postTx(body, { signal } = {}) {
const path = `/api/tx`;
return this.postJson(path, body, { signal });
}
/** /**
* Txid by index * Txid by index
* *

View File

@@ -19,6 +19,8 @@ T = TypeVar('T')
# Bitcoin address string # Bitcoin address string
Addr = str Addr = str
# Transaction ID (hash)
Txid = str
# US Dollar amount # US Dollar amount
Dollars = float Dollars = float
# Amount in satoshis (1 BTC = 100,000,000 sats) # Amount in satoshis (1 BTC = 100,000,000 sats)
@@ -27,8 +29,6 @@ Sats = int
TypeIndex = int TypeIndex = int
# Type (P2PKH, P2WPKH, P2SH, P2TR, etc.) # Type (P2PKH, P2WPKH, P2SH, P2TR, etc.)
OutputType = Literal["p2pk", "p2pk", "p2pkh", "multisig", "p2sh", "op_return", "v0_p2wpkh", "v0_p2wsh", "v1_p2tr", "p2a", "empty", "unknown"] OutputType = Literal["p2pk", "p2pk", "p2pkh", "multisig", "p2sh", "op_return", "v0_p2wpkh", "v0_p2wsh", "v1_p2tr", "p2a", "empty", "unknown"]
# Transaction ID (hash)
Txid = str
# Unified index for any address type (funded or empty) # Unified index for any address type (funded or empty)
AnyAddrIndex = TypeIndex AnyAddrIndex = TypeIndex
# Unsigned basis points stored as u16. # Unsigned basis points stored as u16.
@@ -54,7 +54,7 @@ BasisPointsSigned32 = int
# Bitcoin amount as floating point (1 BTC = 100,000,000 satoshis) # Bitcoin amount as floating point (1 BTC = 100,000,000 satoshis)
Bitcoin = float Bitcoin = float
# URL-friendly mining pool identifier # URL-friendly mining pool identifier
PoolSlug = Literal["unknown", "blockfills", "ultimuspool", "terrapool", "luxor", "onethash", "btccom", "bitfarms", "huobipool", "wayicn", "canoepool", "btctop", "bitcoincom", "pool175btc", "gbminers", "axbt", "asicminer", "bitminter", "bitcoinrussia", "btcserv", "simplecoinus", "btcguild", "eligius", "ozcoin", "eclipsemc", "maxbtc", "triplemining", "coinlab", "pool50btc", "ghashio", "stminingcorp", "bitparking", "mmpool", "polmine", "kncminer", "bitalo", "f2pool", "hhtt", "megabigpower", "mtred", "nmcbit", "yourbtcnet", "givemecoins", "braiinspool", "antpool", "multicoinco", "bcpoolio", "cointerra", "kanopool", "solock", "ckpool", "nicehash", "bitclub", "bitcoinaffiliatenetwork", "btcc", "bwpool", "exxbw", "bitsolo", "bitfury", "twentyoneinc", "digitalbtc", "eightbaochi", "mybtccoinpool", "tbdice", "hashpool", "nexious", "bravomining", "hotpool", "okexpool", "bcmonster", "onehash", "bixin", "tatmaspool", "viabtc", "connectbtc", "batpool", "waterhole", "dcexploration", "dcex", "btpool", "fiftyeightcoin", "bitcoinindia", "shawnp0wers", "phashio", "rigpool", "haozhuzhu", "sevenpool", "miningkings", "hashbx", "dpool", "rawpool", "haominer", "helix", "bitcoinukraine", "poolin", "secretsuperstar", "tigerpoolnet", "sigmapoolcom", "okpooltop", "hummerpool", "tangpool", "bytepool", "spiderpool", "novablock", "miningcity", "binancepool", "minerium", "lubiancom", "okkong", "aaopool", "emcdpool", "foundryusa", "sbicrypto", "arkpool", "purebtccom", "marapool", "kucoinpool", "entrustcharitypool", "okminer", "titan", "pegapool", "btcnuggets", "cloudhashing", "digitalxmintsy", "telco214", "btcpoolparty", "multipool", "transactioncoinmining", "btcdig", "trickysbtcpool", "btcmp", "eobot", "unomp", "patels", "gogreenlight", "bitcoinindiapool", "ekanembtc", "canoe", "tiger", "onem1x", "zulupool", "secpool", "ocean", "whitepool", "wiz", "wk057", "futurebitapollosolo", "carbonnegative", "portlandhodl", "phoenix", "neopool", "maxipool", "bitfufupool", "gdpool", "miningdutch", "publicpool", "miningsquared", "innopolistech", "btclab", "parasite", "redrockpool", "est3lar", "braiinssolo", "solopool", "noderunners"] PoolSlug = Literal["unknown", "blockfills", "ultimuspool", "terrapool", "luxor", "1thash", "btccom", "bitfarms", "huobipool", "wayicn", "canoepool", "btctop", "bitcoincom", "175btc", "gbminers", "axbt", "asicminer", "bitminter", "bitcoinrussia", "btcserv", "simplecoinus", "btcguild", "eligius", "ozcoin", "eclipsemc", "maxbtc", "triplemining", "coinlab", "50btc", "ghashio", "stminingcorp", "bitparking", "mmpool", "polmine", "kncminer", "bitalo", "f2pool", "hhtt", "megabigpower", "mtred", "nmcbit", "yourbtcnet", "givemecoins", "braiinspool", "antpool", "multicoinco", "bcpoolio", "cointerra", "kanopool", "solock", "ckpool", "nicehash", "bitclub", "bitcoinaffiliatenetwork", "btcc", "bwpool", "exxbw", "bitsolo", "bitfury", "21inc", "digitalbtc", "8baochi", "mybtccoinpool", "tbdice", "hashpool", "nexious", "bravomining", "hotpool", "okexpool", "bcmonster", "1hash", "bixin", "tatmaspool", "viabtc", "connectbtc", "batpool", "waterhole", "dcexploration", "dcex", "btpool", "58coin", "bitcoinindia", "shawnp0wers", "phashio", "rigpool", "haozhuzhu", "7pool", "miningkings", "hashbx", "dpool", "rawpool", "haominer", "helix", "bitcoinukraine", "poolin", "secretsuperstar", "tigerpoolnet", "sigmapoolcom", "okpooltop", "hummerpool", "tangpool", "bytepool", "spiderpool", "novablock", "miningcity", "binancepool", "minerium", "lubiancom", "okkong", "aaopool", "emcdpool", "foundryusa", "sbicrypto", "arkpool", "purebtccom", "marapool", "kucoinpool", "entrustcharitypool", "okminer", "titan", "pegapool", "btcnuggets", "cloudhashing", "digitalxmintsy", "telco214", "btcpoolparty", "multipool", "transactioncoinmining", "btcdig", "trickysbtcpool", "btcmp", "eobot", "unomp", "patels", "gogreenlight", "bitcoinindiapool", "ekanembtc", "canoe", "tiger", "1m1x", "zulupool", "secpool", "ocean", "whitepool", "wiz", "wk057", "futurebitapollosolo", "carbonnegative", "portlandhodl", "phoenix", "neopool", "maxipool", "bitfufupool", "gdpool", "miningdutch", "publicpool", "miningsquared", "innopolistech", "btclab", "parasite", "redrockpool", "est3lar", "braiinssolo", "solopoolcom", "noderunners"]
# Fee rate in sat/vB # Fee rate in sat/vB
FeeRate = float FeeRate = float
# Weight in weight units (WU). Max block weight is 4,000,000 WU. # Weight in weight units (WU). Max block weight is 4,000,000 WU.
@@ -83,6 +83,10 @@ CentsSquaredSats = int
# Closing price value for a time period # Closing price value for a time period
Close = Dollars Close = Dollars
# URPD cohort identifier. Use `GET /api/urpd` to list available cohorts. # URPD cohort identifier. Use `GET /api/urpd` to list available cohorts.
#
# Validated at construction: non-empty, ASCII `[a-z0-9_]+`. Matches the
# schemars enum value set; the type therefore proves "this is a valid
# cohort name" wherever a `Cohort` is held.
Cohort = Literal["all", "sth", "lth", "utxos_under_1h_old", "utxos_1h_to_1d_old", "utxos_1d_to_1w_old", "utxos_1w_to_1m_old", "utxos_1m_to_2m_old", "utxos_2m_to_3m_old", "utxos_3m_to_4m_old", "utxos_4m_to_5m_old", "utxos_5m_to_6m_old", "utxos_6m_to_1y_old", "utxos_1y_to_2y_old", "utxos_2y_to_3y_old", "utxos_3y_to_4y_old", "utxos_4y_to_5y_old", "utxos_5y_to_6y_old", "utxos_6y_to_7y_old", "utxos_7y_to_8y_old", "utxos_8y_to_10y_old", "utxos_10y_to_12y_old", "utxos_12y_to_15y_old", "utxos_over_15y_old"] Cohort = Literal["all", "sth", "lth", "utxos_under_1h_old", "utxos_1h_to_1d_old", "utxos_1d_to_1w_old", "utxos_1w_to_1m_old", "utxos_1m_to_2m_old", "utxos_2m_to_3m_old", "utxos_3m_to_4m_old", "utxos_4m_to_5m_old", "utxos_5m_to_6m_old", "utxos_6m_to_1y_old", "utxos_1y_to_2y_old", "utxos_2y_to_3y_old", "utxos_3y_to_4y_old", "utxos_4y_to_5y_old", "utxos_5y_to_6y_old", "utxos_6y_to_7y_old", "utxos_7y_to_8y_old", "utxos_8y_to_10y_old", "utxos_10y_to_12y_old", "utxos_12y_to_15y_old", "utxos_over_15y_old"]
# Coinbase scriptSig tag for pool identification. # Coinbase scriptSig tag for pool identification.
# #
@@ -230,6 +234,16 @@ UnknownOutputIndex = TypeIndex
Week1 = int Week1 = int
Year1 = int Year1 = int
Year10 = int Year10 = int
class AddrAfterTxidParam(TypedDict):
"""
Bitcoin address + last-seen txid path parameters (Esplora-style pagination)
Attributes:
after_txid: Last txid from the previous page (return transactions strictly older than this)
"""
address: Addr
after_txid: Txid
class AddrChainStats(TypedDict): class AddrChainStats(TypedDict):
""" """
Address statistics on the blockchain (confirmed transactions only) Address statistics on the blockchain (confirmed transactions only)
@@ -291,14 +305,7 @@ class AddrStats(TypedDict):
address: Addr address: Addr
addr_type: OutputType addr_type: OutputType
chain_stats: AddrChainStats chain_stats: AddrChainStats
mempool_stats: Union[AddrMempoolStats, None] mempool_stats: AddrMempoolStats
class AddrTxidsParam(TypedDict):
"""
Attributes:
after_txid: Txid to paginate from (return transactions before this one)
"""
after_txid: Union[Txid, None]
class AddrValidation(TypedDict): class AddrValidation(TypedDict):
""" """
@@ -1723,6 +1730,28 @@ class BrkClientBase:
"""Make a GET request and return text.""" """Make a GET request and return text."""
return self.get(path).decode() return self.get(path).decode()
def post(self, path: str, body: str) -> bytes:
"""Make a POST request with a string body and return raw bytes."""
try:
conn = self._connect()
conn.request("POST", path, body=body)
res = conn.getresponse()
data = res.read()
if res.status >= 400:
raise BrkError(f"HTTP error: {res.status}", res.status)
return data
except (ConnectionError, OSError, TimeoutError) as e:
self._conn = None
raise BrkError(str(e))
def post_json(self, path: str, body: str) -> Any:
"""Make a POST request and return JSON."""
return json.loads(self.post(path, body))
def post_text(self, path: str, body: str) -> str:
"""Make a POST request and return text."""
return self.post(path, body).decode()
def close(self) -> None: def close(self) -> None:
"""Close the HTTP client.""" """Close the HTTP client."""
if self._conn: if self._conn:
@@ -7761,38 +7790,40 @@ class BrkClient(BrkClientBase):
Endpoint: `GET /api/address/{address}`""" Endpoint: `GET /api/address/{address}`"""
return self.get_json(f'/api/address/{address}') return self.get_json(f'/api/address/{address}')
def get_address_txs(self, address: Addr, after_txid: Optional[Txid] = None) -> List[Transaction]: def get_address_txs(self, address: Addr) -> List[Transaction]:
"""Address transactions. """Address transactions.
Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid=<txid> for pagination. Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`.
*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
Endpoint: `GET /api/address/{address}/txs`""" Endpoint: `GET /api/address/{address}/txs`"""
params = [] return self.get_json(f'/api/address/{address}/txs')
if after_txid is not None: params.append(f'after_txid={after_txid}')
query = '&'.join(params)
path = f'/api/address/{address}/txs{"?" + query if query else ""}'
return self.get_json(path)
def get_address_confirmed_txs(self, address: Addr, after_txid: Optional[Txid] = None) -> List[Transaction]: def get_address_confirmed_txs(self, address: Addr) -> List[Transaction]:
"""Address confirmed transactions. """Address confirmed transactions.
Get confirmed transactions for an address, 25 per page. Use ?after_txid=<txid> for pagination. Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`.
*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
Endpoint: `GET /api/address/{address}/txs/chain`""" Endpoint: `GET /api/address/{address}/txs/chain`"""
params = [] return self.get_json(f'/api/address/{address}/txs/chain')
if after_txid is not None: params.append(f'after_txid={after_txid}')
query = '&'.join(params)
path = f'/api/address/{address}/txs/chain{"?" + query if query else ""}'
return self.get_json(path)
def get_address_mempool_txs(self, address: Addr) -> List[Txid]: def get_address_confirmed_txs_after(self, address: Addr, after_txid: Txid) -> List[Transaction]:
"""Address confirmed transactions (paginated).
Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space).
*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
Endpoint: `GET /api/address/{address}/txs/chain/{after_txid}`"""
return self.get_json(f'/api/address/{address}/txs/chain/{after_txid}')
def get_address_mempool_txs(self, address: Addr) -> List[Transaction]:
"""Address mempool transactions. """Address mempool transactions.
Get unconfirmed transaction IDs for an address from the mempool (up to 50). Get unconfirmed transactions for an address from the mempool, newest first (up to 50).
*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)*
@@ -8128,6 +8159,16 @@ class BrkClient(BrkClientBase):
Endpoint: `GET /api/server/sync`""" Endpoint: `GET /api/server/sync`"""
return self.get_json('/api/server/sync') return self.get_json('/api/server/sync')
def post_tx(self, body: str) -> Txid:
"""Broadcast transaction.
Broadcast a raw transaction to the network. The transaction should be provided as hex in the request body. The txid will be returned on success.
*[Mempool.space docs](https://mempool.space/docs/api/rest#post-transaction)*
Endpoint: `POST /api/tx`"""
return self.post_json('/api/tx', body)
def get_tx_by_index(self, index: TxIndex) -> Txid: def get_tx_by_index(self, index: TxIndex) -> Txid:
"""Txid by index. """Txid by index.

View File

@@ -2,47 +2,130 @@
import pytest import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
@pytest.fixture(params=[ KNOWN_ADDR_TYPES = {
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", # P2PKH — early block reward "p2pk", "p2pkh", "p2sh", "v0_p2wpkh", "v0_p2wsh", "v1_p2tr",
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", # P2SH "multisig", "op_return", "p2a", "empty", "unknown",
], ids=["p2pkh", "p2sh"]) }
def static_addr(request):
"""Well-known addresses that always exist.""" # Static fixtures: stable addresses with known shapes.
return request.param STATIC_ADDRS = [
"1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", # genesis coinbase, p2pkh — heavy path
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", # p2pkh — exercises tx_count divergence
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", # p2sh
]
# Satoshi's genesis pubkey (uncompressed). Brk-only: mempool returns 400.
SATOSHI_GENESIS_PUBKEY = (
"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f"
)
def test_address_info_static(brk, mempool, static_addr): def _tx_count_tolerance(m_tx_count: int) -> int:
"""Address stats structure must match for well-known addresses.""" """Allow drift between brk's distinct-tx and mempool's output-count semantics."""
path = f"/api/address/{static_addr}" import math
b = brk.get_json(path) return max(5, math.ceil(0.05 * m_tx_count))
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_info_shape(brk, mempool, addr):
"""Typed brk response must structurally match mempool and echo the input address."""
path = f"/api/address/{addr}"
b = brk.get_address(addr)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_structure(b, m) assert_same_structure(b, m)
assert b["address"] == m["address"] assert b["address"] == addr
assert "addr_type" in b
assert "type_index" in b["chain_stats"]
assert "realized_price" in b["chain_stats"]
def test_address_info_discovered(brk, mempool, live_addrs): def test_address_info_shape_dynamic(brk, mempool, live_addrs):
"""Address stats structure must match for each discovered type.""" """Same shape contract over each live-discovered scriptpubkey type."""
assert live_addrs, "no live addresses discovered"
for atype, addr in live_addrs: for atype, addr in live_addrs:
path = f"/api/address/{addr}" path = f"/api/address/{addr}"
b = brk.get_json(path) b = brk.get_address(addr)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", b, m) show("GET", f"{path} [{atype}]", b, m)
assert_same_structure(b, m) assert_same_structure(b, m)
assert b["address"] == m["address"] assert b["address"] == addr
def test_address_chain_stats_close(brk, mempool, live_addrs): @pytest.mark.parametrize("addr", STATIC_ADDRS)
"""Chain stats values must be close for each discovered address.""" def test_address_chain_stats_match(brk, mempool, addr):
"""Funded/spent counts and sums must match exactly; tx_count tolerated within 5% (min 5)."""
path = f"/api/address/{addr}"
b = brk.get_address(addr)["chain_stats"]
m = mempool.get_json(path)["chain_stats"]
show("GET", f"{path} [chain_stats]", b, m)
for key in ("funded_txo_count", "funded_txo_sum", "spent_txo_count", "spent_txo_sum"):
assert b[key] == m[key], (
f"{addr} {key}: brk={b[key]} vs mempool={m[key]}"
)
tol = _tx_count_tolerance(m["tx_count"])
assert abs(b["tx_count"] - m["tx_count"]) <= tol, (
f"{addr} tx_count drift {abs(b['tx_count'] - m['tx_count'])} > tol {tol}: "
f"brk={b['tx_count']} vs mempool={m['tx_count']}"
)
def test_address_chain_stats_match_dynamic(brk, mempool, live_addrs):
"""Same equality/tolerance contract on dynamically discovered addresses."""
assert live_addrs, "no live addresses discovered"
for atype, addr in live_addrs: for atype, addr in live_addrs:
path = f"/api/address/{addr}" path = f"/api/address/{addr}"
b = brk.get_json(path)["chain_stats"] b = brk.get_address(addr)["chain_stats"]
m = mempool.get_json(path)["chain_stats"] m = mempool.get_json(path)["chain_stats"]
show("GET", f"{path} [chain_stats, {atype}]", b, m) show("GET", f"{path} [chain_stats, {atype}]", b, m)
assert_same_structure(b, m) for key in ("funded_txo_count", "funded_txo_sum", "spent_txo_count", "spent_txo_sum"):
assert abs(b["tx_count"] - m["tx_count"]) <= 5, ( assert b[key] == m[key], (
f"{atype} tx_count: brk={b['tx_count']} vs mempool={m['tx_count']}" f"{atype} {addr} {key}: brk={b[key]} vs mempool={m[key]}"
)
tol = _tx_count_tolerance(m["tx_count"])
assert abs(b["tx_count"] - m["tx_count"]) <= tol, (
f"{atype} {addr} tx_count drift {abs(b['tx_count'] - m['tx_count'])} > tol {tol}: "
f"brk={b['tx_count']} vs mempool={m['tx_count']}"
)
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_brk_extras(brk, addr):
"""Brk-only extras must be coherent: known addr_type, non-negative type_index/realized_price."""
b = brk.get_address(addr)
assert b["addr_type"] in KNOWN_ADDR_TYPES, (
f"unknown addr_type {b['addr_type']!r} for {addr}"
)
cs = b["chain_stats"]
assert cs["type_index"] >= 0, f"negative type_index for {addr}: {cs['type_index']}"
assert cs["realized_price"] >= 0, (
f"negative realized_price for {addr}: {cs['realized_price']}"
)
if cs["tx_count"] == 0:
assert cs["realized_price"] == 0, (
f"unfunded address {addr} must have realized_price=0, got {cs['realized_price']}"
)
def test_address_invalid(brk):
"""Garbage input must produce a BrkError carrying HTTP 400."""
with pytest.raises(BrkError) as exc_info:
brk.get_address("abc")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_address_pubkey_as_address(brk):
"""Brk-only: hex-encoded pubkey is accepted as a P2PK address."""
b = brk.get_address(SATOSHI_GENESIS_PUBKEY)
assert b["addr_type"] == "p2pk", f"expected p2pk, got {b['addr_type']!r}"
assert b["chain_stats"]["funded_txo_count"] >= 1, (
f"genesis pubkey must have at least one funded output, got "
f"{b['chain_stats']['funded_txo_count']}"
) )

View File

@@ -2,33 +2,44 @@
import pytest import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
@pytest.fixture(params=[ # Heavy address (recently active) — stresses the 50-cap path; cannot be ordered
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", # exactly against mempool.space because the two indexers drift at the chain tip.
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", ACTIVE_ADDR = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
], ids=["p2pkh", "p2sh"])
def static_addr(request): # Inactive historical addresses — both indexers agree exactly on first-page
return request.param # ordering and on pagination.
STABLE_ADDRS = [
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", # p2pkh, ~125 txs
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", # p2sh, ~5700 txs (heavy pagination)
]
STATIC_ADDRS = [ACTIVE_ADDR] + STABLE_ADDRS
def test_address_txs_static(brk, mempool, static_addr): @pytest.mark.parametrize("addr", STATIC_ADDRS)
"""Confirmed+mempool tx list structure must match for well-known addresses.""" def test_address_txs_shape(brk, mempool, addr):
path = f"/api/address/{static_addr}/txs" """Typed list response must structurally match mempool; brk's `index` extra is allowed."""
b = brk.get_json(path) path = f"/api/address/{addr}/txs"
b = brk.get_address_txs(addr)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)") show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
if b and m: if b and m:
assert_same_structure(b[0], m[0]) assert_same_structure(b[0], m[0])
assert "index" in b[0], "brk-only `index` field missing"
def test_address_txs_discovered(brk, mempool, live_addrs): def test_address_txs_shape_dynamic(brk, mempool, live_addrs):
"""Confirmed+mempool tx list structure must match for each discovered type.""" """Same shape contract over each live-discovered scriptpubkey type."""
assert live_addrs, "no live addresses discovered"
for atype, addr in live_addrs: for atype, addr in live_addrs:
path = f"/api/address/{addr}/txs" path = f"/api/address/{addr}/txs"
b = brk.get_json(path) b = brk.get_address_txs(addr)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)") show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)")
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
@@ -36,14 +47,76 @@ def test_address_txs_discovered(brk, mempool, live_addrs):
assert_same_structure(b[0], m[0]) assert_same_structure(b[0], m[0])
def test_address_txs_fields(brk, mempool, live): @pytest.mark.parametrize("addr", STATIC_ADDRS)
"""Every tx in the list must carry the core mempool.space fields.""" def test_address_txs_ordering(brk, addr):
path = f"/api/address/{live.sample_address}/txs" """All entries must be confirmed and heights monotonically non-increasing."""
b = brk.get_json(path) b = brk.get_address_txs(addr)
show("GET", path, f"({len(b)} txs)", "")
if not b: if not b:
pytest.skip("address has no txs in brk") pytest.skip(f"{addr} has no txs in brk")
required = {"txid", "version", "locktime", "vin", "vout", "size", "weight", "fee", "status"} for tx in b:
for tx in b[:5]: assert tx["status"]["confirmed"] is True, (
missing = required - set(tx.keys()) f"{addr} returned unconfirmed tx {tx['txid']} (this endpoint is chain-only on brk)"
assert not missing, f"tx {tx.get('txid', '?')} missing fields: {missing}" )
heights = [tx["status"]["block_height"] for tx in b]
assert heights == sorted(heights, reverse=True), (
f"{addr} not newest-first by height: {heights[:5]}..."
)
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_txs_limit(brk, addr):
"""Hard cap of 50 confirmed txs per call."""
b = brk.get_address_txs(addr)
assert len(b) <= 50, f"{addr} returned {len(b)} txs, exceeds 50-cap"
@pytest.mark.parametrize("addr", STABLE_ADDRS)
def test_address_txs_top_match_stable(brk, mempool, addr):
"""For inactive historical addresses, brk and mempool agree on first-page order."""
b_txids = [t["txid"] for t in brk.get_address_txs(addr)]
m_txids = [t["txid"] for t in mempool.get_json(f"/api/address/{addr}/txs")]
assert b_txids == m_txids, (
f"{addr} first-page txid order diverges:\n"
f" brk: {b_txids[:5]}...\n"
f" mempool: {m_txids[:5]}..."
)
def test_address_txs_pagination(brk, mempool):
"""`after_txid` returns a fresh, strictly-older page; matches mempool.space."""
addr = "3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r"
first = brk.get_address_txs(addr)
assert len(first) == 50, f"expected full first page, got {len(first)}"
last_txid = first[-1]["txid"]
last_height = first[-1]["status"]["block_height"]
second = brk.get_address_txs(addr, after_txid=last_txid)
assert second, "second page must be non-empty for a 5700-tx address"
first_txids = {t["txid"] for t in first}
second_txids = {t["txid"] for t in second}
assert not (first_txids & second_txids), "pagination must not return overlapping txs"
for tx in second:
assert tx["status"]["block_height"] <= last_height, (
f"page 2 tx {tx['txid']} at height {tx['status']['block_height']} "
f"exceeds page-1 tail height {last_height}"
)
m_second = mempool.get_json(f"/api/address/{addr}/txs?after_txid={last_txid}")
b_ids = [t["txid"] for t in second]
m_ids = [t["txid"] for t in m_second]
assert b_ids == m_ids, (
f"page-2 order diverges from mempool:\n"
f" brk: {b_ids[:5]}...\n"
f" mempool: {m_ids[:5]}..."
)
def test_address_txs_invalid(brk):
"""Garbage input must produce a BrkError carrying HTTP 400."""
with pytest.raises(BrkError) as exc_info:
brk.get_address_txs("abc")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)

View File

@@ -1,34 +1,43 @@
"""GET /api/address/{address}/txs/chain""" """GET /api/address/{address}/txs/chain (and /txs/chain/{after_txid})"""
import pytest import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
@pytest.fixture(params=[ # Heavy active address (chain-tip drift expected, no exact-order assertion)
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", ACTIVE_ADDR = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r",
], ids=["p2pkh", "p2sh"]) # Inactive historical addresses — both indexers agree exactly on first-page ordering
def static_addr(request): STABLE_ADDRS = [
return request.param "12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", # p2pkh, ~125 txs
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", # p2sh, ~5700 txs (heavy pagination)
]
STATIC_ADDRS = [ACTIVE_ADDR] + STABLE_ADDRS
def test_address_txs_chain_static(brk, mempool, static_addr): @pytest.mark.parametrize("addr", STATIC_ADDRS)
"""Confirmed-only tx list structure must match for well-known addresses.""" def test_address_txs_chain_shape(brk, mempool, addr):
path = f"/api/address/{static_addr}/txs/chain" """Typed list response must structurally match mempool; brk's `index` extra is allowed."""
b = brk.get_json(path) path = f"/api/address/{addr}/txs/chain"
b = brk.get_address_confirmed_txs(addr)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)") show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
if b and m: if b and m:
assert_same_structure(b[0], m[0]) assert_same_structure(b[0], m[0])
assert "index" in b[0], "brk-only `index` field missing"
def test_address_txs_chain_discovered(brk, mempool, live_addrs): def test_address_txs_chain_shape_dynamic(brk, mempool, live_addrs):
"""Confirmed-only tx list structure must match for each discovered type.""" """Same shape contract over each live-discovered scriptpubkey type."""
assert live_addrs, "no live addresses discovered"
for atype, addr in live_addrs: for atype, addr in live_addrs:
path = f"/api/address/{addr}/txs/chain" path = f"/api/address/{addr}/txs/chain"
b = brk.get_json(path) b = brk.get_address_confirmed_txs(addr)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)") show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)")
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
@@ -36,14 +45,88 @@ def test_address_txs_chain_discovered(brk, mempool, live_addrs):
assert_same_structure(b[0], m[0]) assert_same_structure(b[0], m[0])
def test_address_txs_chain_all_confirmed(brk, live): @pytest.mark.parametrize("addr", STATIC_ADDRS)
"""Every tx returned by /txs/chain must have confirmed=True in its status.""" def test_address_txs_chain_all_confirmed(brk, addr):
path = f"/api/address/{live.sample_address}/txs/chain" """Every entry must have `status.confirmed == True`."""
b = brk.get_json(path) b = brk.get_address_confirmed_txs(addr)
show("GET", path, f"({len(b)} txs)", "")
if not b: if not b:
pytest.skip("address has no confirmed txs in brk") pytest.skip(f"{addr} has no confirmed txs in brk")
unconfirmed = [t for t in b if not t.get("status", {}).get("confirmed", False)] unconfirmed = [t for t in b if not t["status"]["confirmed"]]
assert not unconfirmed, ( assert not unconfirmed, (
f"{len(unconfirmed)} unconfirmed tx(s) returned by /txs/chain" f"{addr}: {len(unconfirmed)} unconfirmed tx(s) returned: "
f"{[t['txid'] for t in unconfirmed[:3]]}"
)
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_txs_chain_ordering(brk, addr):
"""Heights must be monotonically non-increasing (newest first)."""
b = brk.get_address_confirmed_txs(addr)
if not b:
pytest.skip(f"{addr} has no confirmed txs in brk")
heights = [t["status"]["block_height"] for t in b]
assert heights == sorted(heights, reverse=True), (
f"{addr} not newest-first by height: {heights[:5]}..."
)
@pytest.mark.parametrize("addr", STATIC_ADDRS)
def test_address_txs_chain_limit(brk, addr):
"""Hard cap of 25 confirmed txs per call."""
b = brk.get_address_confirmed_txs(addr)
assert len(b) <= 25, f"{addr} returned {len(b)} txs, exceeds 25-cap"
@pytest.mark.parametrize("addr", STABLE_ADDRS)
def test_address_txs_chain_top_match_stable(brk, mempool, addr):
"""For inactive historical addresses, brk and mempool agree on first-page order."""
b_txids = [t["txid"] for t in brk.get_address_confirmed_txs(addr)]
m_txids = [t["txid"] for t in mempool.get_json(f"/api/address/{addr}/txs/chain")]
assert b_txids == m_txids, (
f"{addr} first-page txid order diverges:\n"
f" brk: {b_txids[:5]}...\n"
f" mempool: {m_txids[:5]}..."
)
def test_address_txs_chain_pagination(brk, mempool):
"""Path-style pagination must match mempool.space's Esplora-canonical form exactly."""
addr = "3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r"
first = brk.get_address_confirmed_txs(addr)
assert len(first) == 25, f"expected full first page (25), got {len(first)}"
last_txid = first[-1]["txid"]
last_height = first[-1]["status"]["block_height"]
second = brk.get_address_confirmed_txs_after(addr, last_txid)
assert second, "second page must be non-empty for a 5700-tx address"
assert len(second) <= 25, f"page 2 exceeds 25-cap: {len(second)}"
first_txids = {t["txid"] for t in first}
second_txids = {t["txid"] for t in second}
assert not (first_txids & second_txids), "pagination must not return overlapping txs"
for tx in second:
assert tx["status"]["confirmed"] is True, f"page 2 has unconfirmed tx {tx['txid']}"
assert tx["status"]["block_height"] <= last_height, (
f"page 2 tx {tx['txid']} at height {tx['status']['block_height']} "
f"exceeds page-1 tail height {last_height}"
)
# Cross-check against mempool.space's path-style form.
m_second = mempool.get_json(f"/api/address/{addr}/txs/chain/{last_txid}")
b_ids = [t["txid"] for t in second]
m_ids = [t["txid"] for t in m_second]
assert b_ids == m_ids, (
f"page-2 order diverges from mempool path-style:\n"
f" brk: {b_ids[:5]}...\n"
f" mempool: {m_ids[:5]}..."
)
def test_address_txs_chain_invalid(brk):
"""Garbage input must produce a BrkError carrying HTTP 400."""
with pytest.raises(BrkError) as exc_info:
brk.get_address_confirmed_txs("abc")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
) )

View File

@@ -1,33 +1,55 @@
"""GET /api/address/{address}/txs/mempool""" """GET /api/address/{address}/txs/mempool"""
from _lib import show import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show
def test_address_txs_mempool_sample(brk, mempool, live): def test_address_txs_mempool_shape_dynamic(brk, mempool, live_addrs):
"""Mempool tx list must be an array (contents are volatile).""" """Shape contract over each live-discovered scriptpubkey type."""
path = f"/api/address/{live.sample_address}/txs/mempool" assert live_addrs, "no live addresses discovered"
b = brk.get_json(path)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
assert isinstance(b, list) and isinstance(m, list)
def test_address_txs_mempool_discovered(brk, mempool, live_addrs):
"""Mempool tx list must be a (possibly empty) array for each discovered type."""
for atype, addr in live_addrs: for atype, addr in live_addrs:
path = f"/api/address/{addr}/txs/mempool" path = f"/api/address/{addr}/txs/mempool"
b = brk.get_json(path) b = brk.get_address_mempool_txs(addr)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)") show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)")
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
if b and m:
assert_same_structure(b[0], m[0])
def test_address_txs_mempool_all_unconfirmed(brk, live): def test_address_txs_mempool_limit(brk, live_addrs):
"""Every tx returned by /txs/mempool must have confirmed=False (if any).""" """Hard cap of 50 mempool txs per call."""
path = f"/api/address/{live.sample_address}/txs/mempool" for _atype, addr in live_addrs:
b = brk.get_json(path) b = brk.get_address_mempool_txs(addr)
show("GET", path, f"({len(b)} txs)", "") assert len(b) <= 50, f"{addr} returned {len(b)} txs, exceeds 50-cap"
confirmed = [t for t in b if t.get("status", {}).get("confirmed", False)]
def test_address_txs_mempool_all_unconfirmed(brk, live_addrs):
"""Every entry must have status.confirmed == False."""
for _atype, addr in live_addrs:
b = brk.get_address_mempool_txs(addr)
confirmed = [t for t in b if t["status"]["confirmed"]]
assert not confirmed, ( assert not confirmed, (
f"{len(confirmed)} confirmed tx(s) returned by /txs/mempool" f"{addr}: {len(confirmed)} confirmed tx(s) returned by /txs/mempool: "
f"{[t['txid'] for t in confirmed[:3]]}"
)
def test_address_txs_mempool_unique_txids(brk, live_addrs):
"""No duplicate txids within a single response."""
for _atype, addr in live_addrs:
b = brk.get_address_mempool_txs(addr)
txids = [t["txid"] for t in b]
assert len(txids) == len(set(txids)), f"{addr}: duplicate txids in response"
def test_address_txs_mempool_invalid(brk):
"""Garbage input must produce a BrkError carrying HTTP 400."""
with pytest.raises(BrkError) as exc_info:
brk.get_address_mempool_txs("abc")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
) )

View File

@@ -2,51 +2,70 @@
import pytest import pytest
from brk_client import BrkError
from _lib import assert_same_values, show from _lib import assert_same_values, show
@pytest.fixture(params=[ # Inactive historical addresses with stable, comparable UTXO sets.
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", STABLE_ADDRS = [
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", ("p2pkh", "12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S"),
], ids=["p2pkh", "p2sh"]) ("p2sh", "3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r"),
def static_addr(request): ]
return request.param
# Genesis pubkey-hash address: tens of thousands of dust UTXOs — exceeds both
# brk's 1000-cap and mempool.space's 500-cap, so both indexers must 400.
HEAVY_ADDR = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
def test_address_utxo_static(brk, mempool, static_addr): @pytest.mark.parametrize("atype,addr", STABLE_ADDRS, ids=[a for a, _ in STABLE_ADDRS])
"""UTXO list must match — same txids, values, and statuses.""" def test_address_utxo_static(brk, mempool, atype, addr):
path = f"/api/address/{static_addr}/utxo" """Exact UTXO parity (txid+vout+value+status) for stable historical addresses."""
b = brk.get_json(path)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} utxos)", f"({len(m)} utxos)")
assert isinstance(b, list) and isinstance(m, list)
key = lambda u: (u.get("txid", ""), u.get("vout", 0))
b_sorted = sorted(b, key=key)
m_sorted = sorted(m, key=key)
assert_same_values(b_sorted, m_sorted)
def test_address_utxo_discovered(brk, mempool, live_addrs):
"""UTXO list must match for each discovered address type — same txids, values, and statuses."""
for atype, addr in live_addrs:
path = f"/api/address/{addr}/utxo" path = f"/api/address/{addr}/utxo"
b = brk.get_json(path) b = brk.get_address_utxos(addr)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", f"({len(b)} utxos)", f"({len(m)} utxos)") show("GET", f"{path} [{atype}]", f"({len(b)} utxos)", f"({len(m)} utxos)")
assert isinstance(b, list) and isinstance(m, list) key = lambda u: (u["txid"], u["vout"])
key = lambda u: (u.get("txid", ""), u.get("vout", 0))
assert_same_values(sorted(b, key=key), sorted(m, key=key)) assert_same_values(sorted(b, key=key), sorted(m, key=key))
def test_address_utxo_fields(brk, live): def test_address_utxo_discovered(brk, mempool, live_addrs):
"""Every utxo must carry the core mempool.space fields.""" """Same exact-parity contract over each live-discovered scriptpubkey type."""
path = f"/api/address/{live.sample_address}/utxo" for atype, addr in live_addrs:
b = brk.get_json(path) path = f"/api/address/{addr}/utxo"
show("GET", path, f"({len(b)} utxos)", "") b = brk.get_address_utxos(addr)
m = mempool.get_json(path)
show("GET", f"{path} [{atype}]", f"({len(b)} utxos)", f"({len(m)} utxos)")
key = lambda u: (u["txid"], u["vout"])
assert_same_values(sorted(b, key=key), sorted(m, key=key))
@pytest.mark.parametrize("atype,addr", STABLE_ADDRS, ids=[a for a, _ in STABLE_ADDRS])
def test_address_utxo_all_confirmed(brk, atype, addr):
"""brk's /utxo only returns confirmed UTXOs (mempool-funded ones are excluded by design)."""
b = brk.get_address_utxos(addr)
if not b: if not b:
pytest.skip("address has no utxos in brk") pytest.skip(f"{addr} has no utxos in brk")
required = {"txid", "vout", "value", "status"} unconfirmed = [u for u in b if not u["status"]["confirmed"]]
for u in b[:5]: assert not unconfirmed, (
missing = required - set(u.keys()) f"{addr}: {len(unconfirmed)} unconfirmed UTXO(s) returned: "
assert not missing, f"utxo {u.get('txid', '?')}:{u.get('vout', '?')} missing fields: {missing}" f"{[(u['txid'], u['vout']) for u in unconfirmed[:3]]}"
assert isinstance(u["value"], int) and u["value"] > 0 )
def test_address_utxo_too_many(brk):
"""Heavy address (>1000 UTXOs) must produce BrkError(status=400, code=too_many_utxos)."""
with pytest.raises(BrkError) as exc_info:
brk.get_address_utxos(HEAVY_ADDR)
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_address_utxo_invalid(brk):
"""Garbage input must produce a BrkError carrying HTTP 400."""
with pytest.raises(BrkError) as exc_info:
brk.get_address_utxos("abc")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)

View File

@@ -5,49 +5,79 @@ import pytest
from _lib import assert_same_structure, assert_same_values, show from _lib import assert_same_structure, assert_same_values, show
def test_validate_address_discovered(brk, mempool, live_addrs): VALID_ADDRS = [
"""Validation of each discovered address type must match exactly.""" ("p2pkh-genesis", "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"),
for atype, addr in live_addrs: ("p2sh", "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"),
path = f"/api/v1/validate-address/{addr}" ("p2wpkh", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"),
b = brk.get_json(path) ("p2wsh", "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3"),
m = mempool.get_json(path) ("p2tr", "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"),
show("GET", f"{path} [{atype}]", b, m) ]
assert_same_values(b, m)
assert b["isvalid"] is True INVALID_ADDRS = [
("garbage", "notanaddress123"),
("bad-checksum-p2pkh", "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNb"),
("bad-checksum-p2sh", "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLz"),
("bad-checksum-p2wpkh", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5"),
("wrong-network-bech32", "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"),
("mixed-case-bech32", "bc1QRP33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3"),
]
# Satoshi's genesis-coinbase pubkey: brk validates this as p2pk; mempool.space
# rejects all raw-pubkey-hex inputs. Documents the intentional brk superset.
GENESIS_PUBKEY = (
"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb6"
"49f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f"
)
@pytest.mark.parametrize("addr,kind", [ @pytest.mark.parametrize("kind,addr", VALID_ADDRS, ids=[k for k, _ in VALID_ADDRS])
("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "p2pkh-genesis"), def test_validate_address_static_valid(brk, mempool, kind, addr):
("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", "p2sh"),
("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", "p2wpkh"),
("bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0", "p2tr"),
])
def test_validate_address_static_valid(brk, mempool, addr, kind):
"""Well-known addresses across all script types must validate identically.""" """Well-known addresses across all script types must validate identically."""
path = f"/api/v1/validate-address/{addr}" path = f"/api/v1/validate-address/{addr}"
b = brk.get_json(path) b = brk.validate_address(addr)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", f"{path} [{kind}]", b, m) show("GET", f"{path} [{kind}]", b, m)
assert_same_values(b, m)
assert b["isvalid"] is True assert b["isvalid"] is True
assert_same_values(b, m)
@pytest.mark.parametrize("addr,kind", [ def test_validate_address_discovered(brk, mempool, live_addrs):
("notanaddress123", "garbage"), """Validation of each live-discovered scriptpubkey type must match exactly."""
("", "empty"), for atype, addr in live_addrs:
("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNb", "bad-checksum-p2pkh"),
("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5", "bad-checksum-p2wpkh"),
("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLz", "bad-checksum-p2sh"),
])
def test_validate_address_invalid(brk, mempool, addr, kind):
"""Invalid addresses must produce the same rejection structure."""
path = f"/api/v1/validate-address/{addr}" path = f"/api/v1/validate-address/{addr}"
if kind == "empty": b = brk.validate_address(addr)
# An empty path segment routes to a different endpoint — skip. m = mempool.get_json(path)
pytest.skip("empty address routes to a different endpoint") show("GET", f"{path} [{atype}]", b, m)
b = brk.get_json(path) assert b["isvalid"] is True
assert_same_values(b, m)
@pytest.mark.parametrize("kind,addr", INVALID_ADDRS, ids=[k for k, _ in INVALID_ADDRS])
def test_validate_address_invalid(brk, mempool, kind, addr):
"""Invalid addresses produce isvalid=false; structure must match (error strings differ by impl)."""
path = f"/api/v1/validate-address/{addr}"
b = brk.validate_address(addr)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", f"{path} [{kind}]", b, m) show("GET", f"{path} [{kind}]", b, m)
assert b["isvalid"] is False assert b["isvalid"] is False
assert m["isvalid"] is False assert m["isvalid"] is False
assert_same_structure(b, m) assert_same_structure(b, m)
def test_validate_address_pubkey_hex_brk_only(brk, mempool):
"""Raw pubkey hex: brk accepts as p2pk (superset); mempool.space rejects (non-2xx or no isvalid:true)."""
path = f"/api/v1/validate-address/{GENESIS_PUBKEY}"
b = brk.validate_address(GENESIS_PUBKEY)
m_resp = mempool.get_raw(path)
show("GET", path, b, f"<HTTP {m_resp.status_code}> {m_resp.text[:200]}")
assert b["isvalid"] is True, "brk must accept raw pubkey hex as p2pk"
assert b.get("isscript") is False
assert b.get("iswitness") is False
if 200 <= m_resp.status_code < 300:
try:
m = m_resp.json()
except ValueError:
m = None
assert not (isinstance(m, dict) and m.get("isvalid") is True), (
"mempool.space must not validate raw pubkey hex as a real address"
)

View File

@@ -1,12 +1,52 @@
"""GET /api/block/{hash}""" """GET /api/block/{hash}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show from _lib import assert_same_values, show
def test_block_by_hash(brk, mempool, block): def test_block_by_hash(brk, mempool, block):
"""Confirmed block info must be identical.""" """Confirmed block info must be byte-identical for every height in the fixture."""
path = f"/api/block/{block.hash}" path = f"/api/block/{block.hash}"
b = brk.get_json(path) b = brk.get_block(block.hash)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_values(b, m) assert_same_values(b, m)
def test_block_genesis(brk, mempool):
"""Genesis (h=0): all fields match except previousblockhash.
Known divergence: brk returns the all-zero hash, mempool.space returns null.
Excluded from value comparison so this test surfaces if any *other* genesis
field drifts, without blocking on the known nullability gap.
"""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/block/{genesis_hash}"
b = brk.get_block(genesis_hash)
m = mempool.get_json(path)
show("GET", path, b, m)
assert b["height"] == 0
assert b["id"] == genesis_hash
assert_same_values(b, m, exclude={"previousblockhash"})
def test_block_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,13 +1,52 @@
"""GET /api/block/{hash}/header""" """GET /api/block/{hash}/header"""
import re
import pytest
from brk_client import BrkError
from _lib import show from _lib import show
HEX_RE = re.compile(r"^[0-9a-f]{160}$")
def test_block_header(brk, mempool, block): def test_block_header(brk, mempool, block):
"""80-byte hex block header must be identical.""" """80-byte hex block header must be identical for every height in the fixture."""
path = f"/api/block/{block.hash}/header" path = f"/api/block/{block.hash}/header"
b = brk.get_text(path) b = brk.get_block_header(block.hash)
m = mempool.get_text(path) m = mempool.get_text(path)
show("GET", path, b, m) show("GET", path, b, m)
assert len(b) == 160, f"Expected 160 hex chars (80 bytes), got {len(b)}" assert HEX_RE.match(b), f"brk header is not 160 lowercase hex chars: {b!r}"
assert b == m assert b == m
def test_block_header_genesis(brk, mempool):
"""Genesis header is byte-deterministic — must match mempool.space exactly."""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/block/{genesis_hash}/header"
b = brk.get_block_header(genesis_hash)
m = mempool.get_text(path)
show("GET", path, b, m)
assert HEX_RE.match(b)
assert b == m
def test_block_header_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_header("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_header_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_header(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,13 +1,62 @@
"""GET /api/block-height/{height}""" """GET /api/block-height/{height}"""
import re
import pytest
from brk_client import BrkError
from _lib import show from _lib import show
def test_block_height_to_hash(brk, mempool, block): HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
"""Block hash at a given height must match."""
def test_block_height(brk, mempool, block):
"""Hash at the given height must match mempool.space and round-trip via /block/{hash}."""
path = f"/api/block-height/{block.height}" path = f"/api/block-height/{block.height}"
b = brk.get_text(path) b = brk.get_block_by_height(block.height)
m = mempool.get_text(path)
show("GET", path, b, m)
assert HEX_HASH_RE.match(b), f"hash is not 64 lowercase hex chars: {b!r}"
assert b == m
assert b == block.hash
assert brk.get_block(b)["height"] == block.height, "round-trip /block/{hash}.height must match"
def test_block_height_genesis(brk, mempool):
"""Height 0 returns the deterministic genesis hash."""
path = "/api/block-height/0"
b = brk.get_block_by_height(0)
m = mempool.get_text(path) m = mempool.get_text(path)
show("GET", path, b, m) show("GET", path, b, m)
assert b == m assert b == m
assert b == block.hash assert b == "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
def test_block_height_tip(brk, mempool):
"""Height = tip/height returns tip/hash."""
tip_height = int(mempool.get_text("/api/blocks/tip/height"))
tip_hash = mempool.get_text("/api/blocks/tip/hash")
b = brk.get_block_by_height(tip_height)
show("GET", f"/api/block-height/{tip_height}", b, tip_hash)
assert b == tip_hash, f"tip mismatch: brk={b!r} mempool={tip_hash!r}"
def test_block_height_out_of_range(brk):
"""Height past the tip must produce BrkError(status=404)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_by_height(99_999_999)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)
@pytest.mark.parametrize("bad", ["-1", "abc"])
def test_block_height_malformed(brk, bad):
"""Negative or non-numeric height must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/block-height/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,13 +1,49 @@
"""GET /api/block/{hash}/raw""" """GET /api/block/{hash}/raw"""
import pytest
from brk_client import BrkError
from _lib import show from _lib import show
def test_block_raw(brk, mempool, block): def test_block_raw(brk, mempool, block):
"""Raw block bytes must be identical and start with the 80-byte header.""" """Raw block bytes must be byte-identical to mempool.space and start with the /header bytes."""
path = f"/api/block/{block.hash}/raw" path = f"/api/block/{block.hash}/raw"
b = brk.get_bytes(path) b = brk.get_block_raw(block.hash)
m = mempool.get_bytes(path) m = mempool.get_bytes(path)
show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>") show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>")
assert b == m assert b == m
assert len(b) >= 80 assert b[:80].hex() == brk.get_block_header(block.hash), (
"first 80 bytes of /raw must match /header response"
)
def test_block_raw_genesis(brk, mempool):
"""Genesis raw block is byte-deterministic — must match exactly."""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/block/{genesis_hash}/raw"
b = brk.get_block_raw(genesis_hash)
m = mempool.get_bytes(path)
show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>")
assert b == m
assert b[:80].hex() == brk.get_block_header(genesis_hash)
def test_block_raw_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_raw("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_raw_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_raw(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,12 +1,61 @@
"""GET /api/block/{hash}/status""" """GET /api/block/{hash}/status"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show from _lib import assert_same_values, show
def test_block_status(brk, mempool, block): def test_block_status(brk, mempool, block):
"""Block status must be identical for a confirmed block.""" """Block status must be identical for every height in the fixture."""
path = f"/api/block/{block.hash}/status" path = f"/api/block/{block.hash}/status"
b = brk.get_json(path) b = brk.get_block_status(block.hash)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_values(b, m) assert_same_values(b, m)
def test_block_status_genesis(brk, mempool):
"""Genesis: in_best_chain=true, height=0, next_best is block 1."""
genesis_hash = mempool.get_text("/api/block-height/0")
h1_hash = mempool.get_text("/api/block-height/1")
path = f"/api/block/{genesis_hash}/status"
b = brk.get_block_status(genesis_hash)
m = mempool.get_json(path)
show("GET", path, b, m)
assert b["in_best_chain"] is True
assert b["height"] == 0
assert b["next_best"] == h1_hash
assert_same_values(b, m)
def test_block_status_tip(brk, mempool):
"""Tip: next_best must be null (only block with no successor)."""
tip_hash = mempool.get_text("/api/blocks/tip/hash")
path = f"/api/block/{tip_hash}/status"
b = brk.get_block_status(tip_hash)
m = mempool.get_json(path)
show("GET", path, b, m)
assert b["in_best_chain"] is True
assert b["next_best"] is None, f"tip next_best must be null, got {b['next_best']!r}"
assert_same_values(b, m)
def test_block_status_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_status("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_status_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_status(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -2,36 +2,70 @@
import pytest import pytest
from brk_client import BrkError
from _lib import show from _lib import show
def test_block_txid_at_index_0(brk, mempool, block): def test_block_txid_coinbase(brk, mempool, block):
"""Txid at position 0 (coinbase) must match.""" """Position 0 is the coinbase txid; must match mempool.space byte-for-byte."""
path = f"/api/block/{block.hash}/txid/0" path = f"/api/block/{block.hash}/txid/0"
b = brk.get_text(path) b = brk.get_block_txid(block.hash, 0)
m = mempool.get_text(path) m = mempool.get_text(path)
show("GET", path, b, m) show("GET", path, b, m)
assert b == m assert b == m
def test_block_txid_at_index_1(brk, mempool, block): def test_block_txid_positions(brk, mempool, block):
"""Txid at position 1 (first non-coinbase) must match.""" """First, middle, and last positions in the block must all match."""
txids = mempool.get_json(f"/api/block/{block.hash}/txids") txids = mempool.get_json(f"/api/block/{block.hash}/txids")
if len(txids) <= 1: n = len(txids)
pytest.skip("block has only coinbase") indices = sorted({0, 1, n // 2, n - 1})
path = f"/api/block/{block.hash}/txid/1" indices = [i for i in indices if 0 <= i < n]
b = brk.get_text(path) for i in indices:
path = f"/api/block/{block.hash}/txid/{i}"
b = brk.get_block_txid(block.hash, i)
m = mempool.get_text(path)
show("GET", path, b, m)
assert b == m, f"index {i} differs: brk={b!r} mempool={m!r}"
def test_block_txid_genesis(brk, mempool):
"""Genesis: only one tx (coinbase) at index 0, byte-deterministic."""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/block/{genesis_hash}/txid/0"
b = brk.get_block_txid(genesis_hash, 0)
m = mempool.get_text(path) m = mempool.get_text(path)
show("GET", path, b, m) show("GET", path, b, m)
assert b == m assert b == m
assert b == "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"
def test_block_txid_at_last_index(brk, mempool, block): def test_block_txid_out_of_range(brk, mempool, block):
"""Txid at last position must match.""" """Index past the last tx in the block must produce BrkError(status=404) on both."""
txids = mempool.get_json(f"/api/block/{block.hash}/txids") txids = mempool.get_json(f"/api/block/{block.hash}/txids")
last = len(txids) - 1 bad_index = len(txids) + 1000
path = f"/api/block/{block.hash}/txid/{last}" with pytest.raises(BrkError) as exc_info:
b = brk.get_text(path) brk.get_block_txid(block.hash, bad_index)
m = mempool.get_text(path) assert exc_info.value.status == 404, (
show("GET", path, b, m) f"expected status=404 for out-of-range index, got {exc_info.value.status}"
assert b == m )
def test_block_txid_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_txid("notavalidhash", 0)
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_txid_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_txid(unknown, 0)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,12 +1,55 @@
"""GET /api/block/{hash}/txids""" """GET /api/block/{hash}/txids"""
import re
import pytest
from brk_client import BrkError
from _lib import show from _lib import show
HEX_TXID_RE = re.compile(r"^[0-9a-f]{64}$")
def test_block_txids(brk, mempool, block): def test_block_txids(brk, mempool, block):
"""Ordered txid list must be identical.""" """Ordered txid list must match mempool.space byte-for-byte."""
path = f"/api/block/{block.hash}/txids" path = f"/api/block/{block.hash}/txids"
b = brk.get_json(path) b = brk.get_block_txids(block.hash)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b[:3], m[:3]) show("GET", path, b[:3], m[:3])
assert b == m assert b == m
assert all(HEX_TXID_RE.match(t) for t in b), "every txid must be 64 lowercase hex chars"
assert b[0] == brk.get_block_txid(block.hash, 0), (
"txids[0] must equal /txid/0 (split-brain check)"
)
def test_block_txids_genesis(brk, mempool):
"""Genesis: single-element list with the deterministic coinbase txid."""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/block/{genesis_hash}/txids"
b = brk.get_block_txids(genesis_hash)
m = mempool.get_json(path)
show("GET", path, b, m)
assert b == m
assert b == ["4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"]
def test_block_txids_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_txids("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_txids_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_txids(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,14 +1,73 @@
"""GET /api/block/{hash}/txs""" """GET /api/block/{hash}/txs"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show from _lib import assert_same_values, show
def test_block_txs_page0(brk, mempool, block): # brk and mempool's sigop counting diverges (different rules for redeemscript/witness).
"""First page of block transactions must match.""" # Documented divergence — same source data, different aggregation.
SIGOPS_DIFF = {"sigops"}
PAGE_SIZE = 25
def test_block_txs(brk, mempool, block):
"""First page (up to 25 txs) must match mempool.space tx-for-tx, in order."""
path = f"/api/block/{block.hash}/txs" path = f"/api/block/{block.hash}/txs"
b = brk.get_json(path) b = brk.get_block_txs(block.hash)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)") show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)", max_lines=4)
assert len(b) == len(m), f"Page size: brk={len(b)} vs mempool={len(m)}" assert len(b) == len(m), f"Page size: brk={len(b)} vs mempool={len(m)}"
if b and m: assert_same_values(b, m, exclude=SIGOPS_DIFF)
assert_same_values(b[0], m[0], exclude={"sigops"})
def test_block_txs_page_size(brk, block):
"""Page size invariant: 25 if block has ≥25 txs, else exactly tx_count."""
txids = brk.get_block_txids(block.hash)
b = brk.get_block_txs(block.hash)
expected = min(PAGE_SIZE, len(txids))
assert len(b) == expected, (
f"page size: got {len(b)}, expected min({PAGE_SIZE}, {len(txids)})={expected}"
)
def test_block_txs_order_and_coinbase(brk, block):
"""Page order matches /txids and tx[0] is the coinbase."""
txids = brk.get_block_txids(block.hash)
b = brk.get_block_txs(block.hash)
assert [t["txid"] for t in b] == txids[: len(b)], "order must match /txids"
assert b[0]["vin"][0]["is_coinbase"] is True, "tx[0] must be coinbase"
def test_block_txs_genesis(brk, mempool):
"""Genesis: single coinbase tx with the well-known scriptsig."""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/block/{genesis_hash}/txs"
b = brk.get_block_txs(genesis_hash)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)", max_lines=4)
assert len(b) == 1
assert_same_values(b, m, exclude=SIGOPS_DIFF)
assert b[0]["txid"] == "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"
def test_block_txs_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_txs("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_txs_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_txs(unknown)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -2,67 +2,97 @@
import pytest import pytest
from _lib import assert_same_structure, show from brk_client import BrkError
from _lib import assert_same_values, show
def test_block_txs_start_index_25(brk, mempool, block): SIGOPS_DIFF = {"sigops"}
"""Paginated txs from index 25 must match (skip small blocks).""" PAGE_SIZE = 25
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
if len(txids) <= 25:
pytest.skip(f"block has only {len(txids)} txs") def test_block_txs_start_default(brk, block):
path = f"/api/block/{block.hash}/txs/25" """/txs/0 must equal /txs (the default page)."""
b = brk.get_json(path) b0 = brk.get_block_txs_from_index(block.hash, 0)
bx = brk.get_block_txs(block.hash)
show("GET", f"/api/block/{block.hash}/txs/0", f"({len(b0)} txs)", f"vs /txs ({len(bx)} txs)")
assert b0 == bx
def test_block_txs_start_aligned(brk, block):
"""Every aligned page is the matching slice of /txids; no overlap, no gaps."""
txids = brk.get_block_txids(block.hash)
n = len(txids)
for start in range(0, n, PAGE_SIZE):
page = brk.get_block_txs_from_index(block.hash, start)
end = min(start + PAGE_SIZE, n)
assert [t["txid"] for t in page] == txids[start:end], (
f"page at start={start} txids do not match /txids[{start}:{end}]"
)
def test_block_txs_start_last_partial_page(brk, block):
"""The final page returns exactly the trailing remainder."""
txids = brk.get_block_txids(block.hash)
n = len(txids)
last_start = ((n - 1) // PAGE_SIZE) * PAGE_SIZE
expected = n - last_start
page = brk.get_block_txs_from_index(block.hash, last_start)
assert len(page) == expected, (
f"last page from start={last_start}: got {len(page)}, expected {expected}"
)
def test_block_txs_start_against_mempool(brk, mempool, block):
"""Mid-block page: full body must match mempool tx-for-tx."""
txids = brk.get_block_txids(block.hash)
if len(txids) <= PAGE_SIZE:
pytest.skip(f"block has only {len(txids)} txs (<= page size)")
path = f"/api/block/{block.hash}/txs/{PAGE_SIZE}"
b = brk.get_block_txs_from_index(block.hash, PAGE_SIZE)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)") show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)", max_lines=4)
assert len(b) == len(m) assert_same_values(b, m, exclude=SIGOPS_DIFF)
if b and m:
assert_same_structure(b[0], m[0])
def test_block_txs_start_index_zero(brk, mempool, block): def test_block_txs_start_genesis(brk, mempool):
"""`/txs/0` must mirror `/txs` (the default page) in length and structure.""" """Genesis: /txs/0 returns the 1 coinbase tx; /txs/1 must 404."""
path0 = f"/api/block/{block.hash}/txs/0" genesis_hash = mempool.get_text("/api/block-height/0")
pathx = f"/api/block/{block.hash}/txs" page0 = brk.get_block_txs_from_index(genesis_hash, 0)
b0 = brk.get_json(path0) assert len(page0) == 1
bx = brk.get_json(pathx) assert page0[0]["txid"] == "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"
show("GET", path0, f"({len(b0)} txs)", f"vs /txs ({len(bx)} txs)") with pytest.raises(BrkError) as exc_info:
assert len(b0) == len(bx) brk.get_block_txs_from_index(genesis_hash, 1)
if b0 and bx: assert exc_info.value.status == 404, (
assert b0[0]["txid"] == bx[0]["txid"] f"expected status=404 for past-end on genesis, got {exc_info.value.status}"
)
def test_block_txs_start_aligned_pagination(brk, mempool, block): def test_block_txs_start_past_end(brk, block):
"""Pages at 0, 25, 50 must each be aligned slices of the full txid list.""" """Start past the last tx must produce BrkError(status=404)."""
txids = mempool.get_json(f"/api/block/{block.hash}/txids") txids = brk.get_block_txids(block.hash)
if len(txids) <= 50:
pytest.skip(f"block has only {len(txids)} txs")
# mempool.space orders txids tip-first inside the block payload, but
# /txids returns them in block order (coinbase-first). Paged /txs follows
# the same coinbase-first order — so page N starts at offset N.
page0 = brk.get_json(f"/api/block/{block.hash}/txs/0")
page25 = brk.get_json(f"/api/block/{block.hash}/txs/25")
page50 = brk.get_json(f"/api/block/{block.hash}/txs/50")
show("GET", f"/api/block/{block.hash}/txs/{{0,25,50}}",
f"page0={len(page0)} page25={len(page25)} page50={len(page50)}", "")
# The paging origin is what mempool.space does; verify against the live
# /txids list rather than re-deriving the order ourselves.
assert page0 and page0[0]["txid"] == txids[0]
assert page25 and page25[0]["txid"] == txids[25]
assert page50 and page50[0]["txid"] == txids[50]
def test_block_txs_start_past_end(brk, mempool, block):
"""A start index past the last tx must produce the same response on both servers."""
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
past = len(txids) + 1000 past = len(txids) + 1000
path = f"/api/block/{block.hash}/txs/{past}" with pytest.raises(BrkError) as exc_info:
b_resp = brk.get_raw(path) brk.get_block_txs_from_index(block.hash, past)
m_resp = mempool.get_raw(path) assert exc_info.value.status == 404, (
show("GET", path, f"brk={b_resp.status_code}", f"mempool={m_resp.status_code}") f"expected status=404 for past-end, got {exc_info.value.status}"
assert b_resp.status_code == m_resp.status_code, (
f"past-end status differs: brk={b_resp.status_code} vs mempool={m_resp.status_code}"
) )
if b_resp.status_code == 200:
assert b_resp.json() == m_resp.json(), (
f"past-end body differs: brk={b_resp.json()} vs mempool={m_resp.json()}" def test_block_txs_start_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_txs_from_index("notavalidhash", 0)
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
)
def test_block_txs_start_unknown_hash(brk):
"""Syntactically valid but unknown hash must produce BrkError(status=404)."""
unknown = "0000000000000000000000000000000000000000000000000000000000000001"
with pytest.raises(BrkError) as exc_info:
brk.get_block_txs_from_index(unknown, 0)
assert exc_info.value.status == 404, (
f"expected status=404, got {exc_info.value.status}"
) )

View File

@@ -1,37 +1,80 @@
"""GET /api/v1/block/{hash}""" """GET /api/v1/block/{hash}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, assert_same_values, show from _lib import assert_same_structure, assert_same_values, show
def test_block_v1_extras_all_values(brk, mempool, block): # Fee-distribution fields where mempool uses positional/cut-based percentiles
"""Every shared extras field must match - exposes computation differences. # and brk uses a single vsize-weighted percentile distribution. Same source
# data, different aggregation — diverges anywhere tx sizes vary.
FEE_ALGO_DIFF = {"medianFee", "medianFeeAmt", "feeRange", "feePercentiles"}
Excluded fields: # avgFeeRate: mempool returns Bitcoin Core's getblockstats.avgfeerate (integer
- medianFee, feeRange, feePercentiles: mempool computes each entry with # sat/vB), brk returns the float version. Same formula, brk preserves precision.
a different algorithm (1st/99th percentile + first/last 2% of block ROUNDING_DIFF = {"avgFeeRate"}
order for the feeRange bounds, unweighted positional p10/p25/p50/p75/p90
for the inner feeRange entries and for feePercentiles, and a vsize- EXTRAS_EXCLUDE = FEE_ALGO_DIFF | ROUNDING_DIFF
weighted middle-0.25%-of-block-weight slice for medianFee). brk
computes them all from a single vsize-weighted percentile distribution,
so they diverge anywhere tx sizes vary widely. def test_block_v1_envelope(brk, mempool, block):
- avgFeeRate: mempool returns Bitcoin Core's getblockstats.avgfeerate """Top-level v1 envelope: id matches, brk-only `stale` and `extras.price` are present."""
(integer sat/vB), brk returns the float version. Same formula, brk
keeps decimal precision.
"""
path = f"/api/v1/block/{block.hash}" path = f"/api/v1/block/{block.hash}"
b = brk.get_json(path)["extras"] b = brk.get_block_v1(block.hash)
m = mempool.get_json(path)
show("GET", path, b, m, max_lines=30)
assert b["id"] == block.hash
assert b["stale"] is False, f"confirmed block must have stale=False, got {b['stale']!r}"
assert isinstance(b["extras"]["price"], (int, float))
assert b["extras"]["price"] >= 0
def test_block_v1_extras(brk, mempool, block):
"""Every shared extras field must match (excluding documented algorithm divergences)."""
path = f"/api/v1/block/{block.hash}"
b = brk.get_block_v1(block.hash)["extras"]
m = mempool.get_json(path)["extras"] m = mempool.get_json(path)["extras"]
show("GET", f"{path} [extras]", b, m, max_lines=50) show("GET", f"{path} [extras]", b, m, max_lines=50)
assert_same_structure(b, m) assert_same_structure(b, m)
assert_same_values( assert_same_values(b, m, exclude=EXTRAS_EXCLUDE)
b, m, exclude={"medianFee", "feeRange", "feePercentiles", "avgFeeRate"}
# Genesis-only divergence: Bitcoin Core treats the genesis coinbase output as
# unspendable and excludes it from the UTXO set (Satoshi quirk). brk counts
# it like any other output, so genesis utxoSetChange is 1 on brk vs 0 on
# mempool.space. Documented test-only exclude.
GENESIS_EXTRAS_EXCLUDE = EXTRAS_EXCLUDE | {"utxoSetChange"}
def test_block_v1_genesis(brk, mempool):
"""Genesis: extras must match (excluding fee-algo divergence and the genesis utxoSetChange quirk)."""
genesis_hash = mempool.get_text("/api/block-height/0")
path = f"/api/v1/block/{genesis_hash}"
b = brk.get_block_v1(genesis_hash)
m = mempool.get_json(path)
show("GET", path, b, m, max_lines=30)
assert b["height"] == 0
assert b["stale"] is False
assert_same_structure(b["extras"], m["extras"])
assert_same_values(b["extras"], m["extras"], exclude=GENESIS_EXTRAS_EXCLUDE)
def test_block_v1_invalid_hash(brk):
"""Non-hex / wrong-length hash must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_block_v1("notavalidhash")
assert exc_info.value.status == 400, (
f"expected status=400, got {exc_info.value.status}"
) )
def test_block_v1_extras_pool(brk, mempool, block): def test_block_v1_unknown_hash(brk):
"""Pool identification structure must match.""" """Syntactically valid but unknown hash must produce BrkError(status=404)."""
path = f"/api/v1/block/{block.hash}" unknown = "0000000000000000000000000000000000000000000000000000000000000001"
bp = brk.get_json(path)["extras"]["pool"] with pytest.raises(BrkError) as exc_info:
mp = mempool.get_json(path)["extras"]["pool"] brk.get_block_v1(unknown)
show("GET", f"{path} [extras.pool]", bp, mp) assert exc_info.value.status == 404, (
assert_same_structure(bp, mp) f"expected status=404, got {exc_info.value.status}"
)

View File

@@ -1,14 +1,75 @@
"""GET /api/blocks/{height}""" """GET /api/blocks/{height}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show from _lib import assert_same_values, show
PAGE_SIZE = 10
def test_blocks_from_height(brk, mempool, block): def test_blocks_from_height(brk, mempool, block):
"""Confirmed blocks from a fixed height must match exactly.""" """Up to 10 blocks descending from `block.height` must match mempool tx-for-tx."""
path = f"/api/blocks/{block.height}" path = f"/api/blocks/{block.height}"
b = brk.get_json(path) b = brk.get_blocks_from_height(block.height)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=3)
assert len(b) == len(m) assert len(b) == min(PAGE_SIZE, block.height + 1)
if b and m: assert_same_values(b, m)
assert_same_values(b[0], m[0])
def test_blocks_from_height_chain(brk, block):
"""Heights strictly descending; previousblockhash links the page."""
b = brk.get_blocks_from_height(block.height)
heights = [blk["height"] for blk in b]
assert heights == list(range(block.height, block.height - len(b), -1)), (
f"page is not contiguous descending: {heights}"
)
for i in range(len(b) - 1):
assert b[i]["previousblockhash"] == b[i + 1]["id"], (
f"chain break at index {i}"
)
def test_blocks_from_height_genesis(brk, mempool):
"""height=0 returns exactly the genesis block."""
path = "/api/blocks/0"
b = brk.get_blocks_from_height(0)
m = mempool.get_json(path)
show("GET", path, b, m, max_lines=4)
assert len(b) == 1
assert b[0]["id"] == "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
assert_same_values(b, m)
def test_blocks_from_height_small(brk, mempool):
"""height=5 returns 6 blocks (5,4,3,2,1,0), byte-deterministic against mempool."""
path = "/api/blocks/5"
b = brk.get_blocks_from_height(5)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=3)
assert len(b) == 6
assert [blk["height"] for blk in b] == [5, 4, 3, 2, 1, 0]
assert_same_values(b, m)
def test_blocks_from_height_clamp_to_tip(brk):
"""height past the tip clamps to 10 tip blocks."""
b = brk.get_blocks_from_height(99_999_999)
show("GET", "/api/blocks/99999999", f"({len(b)} blocks)", "-")
assert len(b) == PAGE_SIZE
assert b[0]["id"] == brk.get_block_tip_hash(), (
"head of clamped page must equal /api/blocks/tip/hash"
)
@pytest.mark.parametrize("bad", ["-1", "abc"])
def test_blocks_from_height_malformed(brk, bad):
"""Negative or non-numeric height must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/blocks/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,35 +1,60 @@
"""GET /api/blocks (most recent confirmed blocks, no height)""" """GET /api/blocks (most recent confirmed blocks)"""
from _lib import assert_same_structure, show import re
from _lib import assert_same_structure, assert_same_values, show
def test_blocks_recent_structure(brk, mempool): HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
"""Recent blocks list must have the same element structure.""" EXPECTED_COUNT = 10
def test_blocks_recent_shape(brk, mempool):
"""Recent blocks list must have the same length and element structure as mempool.space."""
path = "/api/blocks" path = "/api/blocks"
b = brk.get_json(path) b = brk.get_blocks()
m = mempool.get_json(path) m = mempool.get_json(path)
show( show(
"GET", path, "GET", path,
f"({len(b)} blocks, {b[-1]['height']}-{b[0]['height']})" if b else "[]", f"({len(b)} blocks, {b[-1]['height']}-{b[0]['height']})",
f"({len(m)} blocks, {m[-1]['height']}-{m[0]['height']})" if m else "[]", f"({len(m)} blocks, {m[-1]['height']}-{m[0]['height']})",
) )
assert len(b) > 0 assert len(b) == EXPECTED_COUNT, f"expected {EXPECTED_COUNT}, got {len(b)}"
assert len(b) == len(m), f"length mismatch: brk={len(b)} vs mempool={len(m)}"
assert_same_structure(b, m) assert_same_structure(b, m)
def test_blocks_recent_ordering(brk): def test_blocks_recent_chain(brk):
"""Returned blocks must be ordered tip-first by strictly decreasing height.""" """Tip-first order, no duplicates, and previousblockhash links each block to its successor."""
b = brk.get_json("/api/blocks") b = brk.get_blocks()
heights = [blk["height"] for blk in b] heights = [blk["height"] for blk in b]
show("GET", "/api/blocks", f"heights={heights[:5]}...", "") show("GET", "/api/blocks", f"heights={heights}", "-")
assert heights == sorted(heights, reverse=True), ( assert heights == sorted(heights, reverse=True), f"not tip-first: {heights}"
f"blocks are not strictly tip-first: {heights}" assert len(set(heights)) == len(heights), "duplicate heights"
for blk in b:
assert HEX_HASH_RE.match(blk["id"]), f"id is not 64 lowercase hex: {blk['id']!r}"
for i in range(len(b) - 1):
assert b[i]["previousblockhash"] == b[i + 1]["id"], (
f"chain break at index {i}: prev={b[i]['previousblockhash']!r} "
f"vs next.id={b[i + 1]['id']!r}"
) )
assert len(set(heights)) == len(heights), "duplicate heights in /api/blocks"
def test_blocks_recent_count(brk): def test_blocks_recent_tip(brk):
"""mempool.space returns up to 15 blocks; brk should match that contract.""" """The first element of /api/blocks must be the tip."""
b = brk.get_json("/api/blocks") b = brk.get_blocks()
show("GET", "/api/blocks", f"({len(b)} blocks)", "") tip_hash = brk.get_block_tip_hash()
assert 1 <= len(b) <= 15, f"unexpected block count: {len(b)}" tip_height = brk.get_block_tip_height()
show("GET", "/api/blocks[0]", b[0], f"tip={tip_hash} h={tip_height}")
assert b[0]["id"] == tip_hash, f"head mismatch: {b[0]['id']!r} vs tip={tip_hash!r}"
assert b[0]["height"] == tip_height
def test_blocks_recent_canonical(brk, mempool):
"""The floor block (least likely to race vs mempool's tip) must value-match mempool."""
b = brk.get_blocks()
floor = b[-1]
path = f"/api/block/{floor['id']}"
m = mempool.get_json(path)
show("GET", path, floor, m, max_lines=20)
assert_same_values(floor, m)

View File

@@ -1,37 +1,48 @@
"""GET /api/blocks/tip/hash""" """GET /api/blocks/tip/hash"""
import re
from _lib import show from _lib import show
HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
def test_blocks_tip_hash_format(brk, mempool): def test_blocks_tip_hash_format(brk, mempool):
"""Tip hash must be a valid 64-char hex string on both servers.""" """Tip hash on both servers must be a 64-char lowercase hex string."""
path = "/api/blocks/tip/hash" path = "/api/blocks/tip/hash"
b = brk.get_text(path) b = brk.get_block_tip_hash()
m = mempool.get_text(path) m = mempool.get_text(path)
show("GET", path, b, m) show("GET", path, b, m)
assert len(b) == 64 and all(c in "0123456789abcdef" for c in b.lower()) assert HEX_HASH_RE.match(b), f"brk tip hash not 64-char hex: {b!r}"
assert len(m) == 64 and all(c in "0123456789abcdef" for c in m.lower()) assert HEX_HASH_RE.match(m), f"mempool tip hash not 64-char hex: {m!r}"
def test_blocks_tip_hash_resolves(brk):
"""tip/hash must resolve to a real block whose .id matches."""
tip_hash = brk.get_block_tip_hash()
blk = brk.get_block(tip_hash)
show("GET", "/api/blocks/tip/hash", tip_hash, f"block.id={blk['id']} h={blk['height']}")
assert blk["id"] == tip_hash, f"round-trip mismatch: {blk['id']!r} vs {tip_hash!r}"
assert blk["height"] >= 0
def test_blocks_tip_hash_matches_height(brk): def test_blocks_tip_hash_matches_height(brk):
"""`tip/hash` must equal `block-height/{tip_height}`.""" """tip/hash and tip/height must point to the same block (race-free direction)."""
h = int(brk.get_text("/api/blocks/tip/height")) tip_hash = brk.get_block_tip_hash()
by_height = brk.get_text(f"/api/block-height/{h}") blk = brk.get_block(tip_hash)
tip_hash = brk.get_text("/api/blocks/tip/hash") tip_height = brk.get_block_tip_height()
show("GET", "/api/blocks/tip/hash", tip_hash, by_height) show("GET", "/api/blocks/tip/hash", tip_hash, f"block.height={blk['height']} tip/height={tip_height}")
# Allow a one-block race if a new block landed between the two fetches. assert tip_height - blk["height"] in (0, 1), (
if tip_hash != by_height: f"tip/hash@{blk['height']} not within 1 block of tip/height={tip_height}"
h2 = int(brk.get_text("/api/blocks/tip/height"))
assert h2 != h or tip_hash == by_height, (
f"tip/hash={tip_hash} but block-height/{h}={by_height}"
) )
def test_blocks_tip_hash_matches_recent(brk): def test_blocks_tip_hash_matches_recent(brk):
"""`tip/hash` must equal the first hash in `/api/blocks`.""" """tip/hash must equal /api/blocks[0].id."""
tip_hash = brk.get_text("/api/blocks/tip/hash") tip_hash = brk.get_block_tip_hash()
blocks = brk.get_json("/api/blocks") blocks = brk.get_blocks()
show("GET", "/api/blocks/tip/hash", tip_hash, blocks[0]["id"]) show("GET", "/api/blocks/tip/hash", tip_hash, blocks[0]["id"])
assert blocks and blocks[0]["id"] == tip_hash, ( assert blocks[0]["id"] == tip_hash, (
f"tip/hash={tip_hash} but /api/blocks[0].id={blocks[0].get('id')}" f"tip/hash={tip_hash} but /api/blocks[0].id={blocks[0]['id']}"
) )

View File

@@ -1,32 +1,47 @@
"""GET /api/blocks/tip/height""" """GET /api/blocks/tip/height"""
import re
from _lib import show from _lib import show
HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
def test_blocks_tip_height_close(brk, mempool): def test_blocks_tip_height_close(brk, mempool):
"""Tip heights must be within a few blocks of each other.""" """brk and mempool tips must be within 3 blocks (live-race tolerance)."""
path = "/api/blocks/tip/height" path = "/api/blocks/tip/height"
b = int(brk.get_text(path)) b = brk.get_block_tip_height()
m = int(mempool.get_text(path)) m = int(mempool.get_text(path))
show("GET", path, b, m) show("GET", path, b, m)
assert abs(b - m) <= 3, f"Tip heights differ by {abs(b - m)}: brk={b}, mempool={m}" assert isinstance(b, int) and b >= 0, f"tip height not a non-negative int: {b!r}"
assert abs(b - m) <= 3, f"tip heights differ by {abs(b - m)}: brk={b} mempool={m}"
def test_blocks_tip_height_resolves_to_hash(brk): def test_blocks_tip_height_resolves_to_hash(brk):
"""`tip/height` must resolve to a valid hash via `block-height/{tip}`.""" """tip/height must resolve to a 64-char hex hash via /api/block-height/{tip}."""
h = int(brk.get_text("/api/blocks/tip/height")) h = brk.get_block_tip_height()
bh = brk.get_text(f"/api/block-height/{h}") bh = brk.get_block_by_height(h)
show("GET", "/api/blocks/tip/height", h, bh) show("GET", "/api/blocks/tip/height", h, bh)
assert len(bh) == 64 and all(c in "0123456789abcdef" for c in bh.lower()), ( assert HEX_HASH_RE.match(bh), f"block-height/{h} returned non-hash: {bh!r}"
f"block-height/{h} returned non-hash: {bh!r}"
def test_blocks_tip_height_matches_tip_hash(brk):
"""tip/height and tip/hash must point to the same block."""
h = brk.get_block_tip_height()
tip_hash = brk.get_block_tip_hash()
blk = brk.get_block(tip_hash)
show("GET", "/api/blocks/tip/height", h, f"tip_hash={tip_hash} block.height={blk['height']}")
assert blk["height"] == h, (
f"tip/height={h} but /block/{tip_hash}.height={blk['height']}"
) )
def test_blocks_tip_height_matches_recent(brk): def test_blocks_tip_height_matches_recent(brk):
"""`tip/height` must equal the first element's height in `/api/blocks`.""" """tip/height must equal /api/blocks[0].height."""
h = int(brk.get_text("/api/blocks/tip/height")) h = brk.get_block_tip_height()
blocks = brk.get_json("/api/blocks") blocks = brk.get_blocks()
show("GET", "/api/blocks/tip/height", h, blocks[0]["height"]) show("GET", "/api/blocks/tip/height", h, blocks[0]["height"])
assert blocks and blocks[0]["height"] == h, ( assert blocks[0]["height"] == h, (
f"tip/height={h} but /api/blocks[0].height={blocks[0]['height']}" f"tip/height={h} but /api/blocks[0].height={blocks[0]['height']}"
) )

View File

@@ -1,31 +1,87 @@
"""GET /api/v1/blocks/{height}""" """GET /api/v1/blocks/{height} (paginated descending v1 blocks with extras)"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show from _lib import assert_same_values, show
def test_blocks_v1_from_height(brk, mempool, block): PAGE_SIZE = 15
"""v1 blocks from a confirmed height - all values must match.
Excluded fields: # Same fee-algo / rounding divergences as /api/v1/block/{hash} and /api/v1/blocks.
- medianFee, feeRange, feePercentiles: mempool computes each entry with FEE_ALGO_DIFF = {"medianFee", "medianFeeAmt", "feeRange", "feePercentiles"}
a different algorithm (1st/99th percentile + first/last 2% of block ROUNDING_DIFF = {"avgFeeRate"}
order for the feeRange bounds, unweighted positional p10/p25/p50/p75/p90 EXTRAS_EXCLUDE = FEE_ALGO_DIFF | ROUNDING_DIFF
for the inner feeRange entries and for feePercentiles, and a vsize- # Genesis: Bitcoin Core's Satoshi quirk - the genesis coinbase is not in the UTXO set.
weighted middle-0.25%-of-block-weight slice for medianFee). brk GENESIS_EXTRAS_EXCLUDE = EXTRAS_EXCLUDE | {"utxoSetChange"}
computes them all from a single vsize-weighted percentile distribution,
so they diverge anywhere tx sizes vary widely.
- avgFeeRate: mempool returns Bitcoin Core's getblockstats.avgfeerate def test_blocks_v1_from_height(brk, mempool, block):
(integer sat/vB), brk returns the float version. Same formula, brk """Up to 15 v1 blocks descending from `block.height`, full-page value match."""
keeps decimal precision.
"""
path = f"/api/v1/blocks/{block.height}" path = f"/api/v1/blocks/{block.height}"
b = brk.get_json(path) b = brk.get_blocks_v1_from_height(block.height)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=4)
assert len(b) == len(m) assert len(b) == min(PAGE_SIZE, block.height + 1)
if b and m: assert len(b) == len(m), f"length mismatch: brk={len(b)} vs mempool={len(m)}"
assert_same_values( assert_same_values(b, m, exclude=EXTRAS_EXCLUDE)
b[0],
m[0],
exclude={"medianFee", "feeRange", "feePercentiles", "avgFeeRate"}, def test_blocks_v1_from_height_chain(brk, block):
"""Heights strictly descending; prev-hash chain; stale=False; extras.price set."""
b = brk.get_blocks_v1_from_height(block.height)
heights = [blk["height"] for blk in b]
assert heights == list(range(block.height, block.height - len(b), -1)), (
f"page is not contiguous descending: {heights}"
)
for blk in b:
assert blk["stale"] is False, f"confirmed block stale=True: {blk['id']}"
assert isinstance(blk["extras"]["price"], (int, float))
assert blk["extras"]["price"] >= 0
for i in range(len(b) - 1):
assert b[i]["previousblockhash"] == b[i + 1]["id"], (
f"chain break at index {i}"
)
def test_blocks_v1_from_height_genesis(brk, mempool):
"""height=0 returns exactly the genesis block (with utxoSetChange divergence)."""
path = "/api/v1/blocks/0"
b = brk.get_blocks_v1_from_height(0)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=4)
assert len(b) == 1
assert b[0]["id"] == "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
assert_same_values(b, m, exclude=GENESIS_EXTRAS_EXCLUDE)
def test_blocks_v1_from_height_small(brk, mempool):
"""height=5 returns 6 blocks (5,4,3,2,1,0) with full-page value match."""
path = "/api/v1/blocks/5"
b = brk.get_blocks_v1_from_height(5)
m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=3)
assert len(b) == 6
assert [blk["height"] for blk in b] == [5, 4, 3, 2, 1, 0]
assert_same_values(b, m, exclude=GENESIS_EXTRAS_EXCLUDE)
def test_blocks_v1_from_height_clamp_to_tip(brk):
"""Height past the tip clamps to a 15-block tip page."""
b = brk.get_blocks_v1_from_height(99_999_999)
show("GET", "/api/v1/blocks/99999999", f"({len(b)} blocks)", "-")
assert len(b) == PAGE_SIZE
assert b[0]["id"] == brk.get_block_tip_hash(), (
"head of clamped page must equal /api/blocks/tip/hash"
)
@pytest.mark.parametrize("bad", ["-1", "abc"])
def test_blocks_v1_from_height_malformed(brk, bad):
"""Negative or non-numeric height must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/blocks/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
) )

View File

@@ -1,31 +1,63 @@
"""GET /api/v1/blocks (with extras, no height)""" """GET /api/v1/blocks (most recent confirmed blocks with extras)"""
from _lib import assert_same_structure, show import re
from _lib import assert_same_structure, assert_same_values, show
def test_blocks_v1_recent_structure(brk, mempool): HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
"""Recent v1 blocks (with extras) must have the same structure.""" EXPECTED_COUNT = 15
# Same fee-algo / rounding divergences as /api/v1/block/{hash}.
FEE_ALGO_DIFF = {"medianFee", "medianFeeAmt", "feeRange", "feePercentiles"}
ROUNDING_DIFF = {"avgFeeRate"}
EXTRAS_EXCLUDE = FEE_ALGO_DIFF | ROUNDING_DIFF
def test_blocks_v1_recent_shape(brk, mempool):
"""v1 list must have the same length and element structure as mempool.space."""
path = "/api/v1/blocks" path = "/api/v1/blocks"
b = brk.get_json(path) b = brk.get_blocks_v1()
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=4)
assert len(b) > 0 assert len(b) == EXPECTED_COUNT, f"expected {EXPECTED_COUNT}, got {len(b)}"
assert len(b) == len(m), f"length mismatch: brk={len(b)} vs mempool={len(m)}"
assert_same_structure(b, m) assert_same_structure(b, m)
def test_blocks_v1_recent_ordering(brk): def test_blocks_v1_recent_chain(brk):
"""v1 blocks must also be tip-first.""" """Tip-first order, no duplicates, valid previousblockhash chain, stale=False, extras.price set."""
b = brk.get_json("/api/v1/blocks") b = brk.get_blocks_v1()
heights = [blk["height"] for blk in b] heights = [blk["height"] for blk in b]
show("GET", "/api/v1/blocks", f"heights={heights[:5]}...", "") show("GET", "/api/v1/blocks", f"heights={heights}", "-")
assert heights == sorted(heights, reverse=True), ( assert heights == sorted(heights, reverse=True), f"not tip-first: {heights}"
f"v1 blocks are not strictly tip-first: {heights}" assert len(set(heights)) == len(heights), "duplicate heights"
for blk in b:
assert HEX_HASH_RE.match(blk["id"]), f"id is not 64 lowercase hex: {blk['id']!r}"
assert blk["stale"] is False, f"confirmed block stale=True: {blk['id']}"
assert isinstance(blk["extras"]["price"], (int, float))
assert blk["extras"]["price"] >= 0
for i in range(len(b) - 1):
assert b[i]["previousblockhash"] == b[i + 1]["id"], (
f"chain break at index {i}"
) )
def test_blocks_v1_recent_has_extras(brk): def test_blocks_v1_recent_tip(brk):
"""Each v1 block must carry the extras envelope (v1 distinguishes itself from /api/blocks).""" """The first element must be the tip."""
b = brk.get_json("/api/v1/blocks") b = brk.get_blocks_v1()
show("GET", "/api/v1/blocks", f"({len(b)} blocks)", "") tip_hash = brk.get_block_tip_hash()
assert b tip_height = brk.get_block_tip_height()
assert "extras" in b[0], f"v1 blocks element missing 'extras': {list(b[0].keys())}" show("GET", "/api/v1/blocks[0]", b[0]["id"], f"tip={tip_hash} h={tip_height}")
assert b[0]["id"] == tip_hash
assert b[0]["height"] == tip_height
def test_blocks_v1_recent_canonical(brk, mempool):
"""The floor block must value-match mempool (modulo fee-algo + rounding divergences)."""
b = brk.get_blocks_v1()
floor = b[-1]
path = f"/api/v1/block/{floor['id']}"
m = mempool.get_json(path)
show("GET", path, floor["extras"], m["extras"], max_lines=15)
assert_same_values(floor, m, exclude=EXTRAS_EXCLUDE)

View File

@@ -3,25 +3,43 @@
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
def test_fees_mempool_blocks(brk, mempool): MAX_PROJECTED_BLOCKS = 8
"""Projected mempool blocks must have the same element structure.""" BRK_FEE_RANGE_LEN = 7
def test_fees_mempool_blocks_structure(brk, mempool):
"""Projected mempool blocks envelope must match across the full list."""
path = "/api/v1/fees/mempool-blocks" path = "/api/v1/fees/mempool-blocks"
b = brk.get_json(path) b = brk.get_mempool_blocks()
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
assert len(b) > 0 assert len(b) > 0, "expected non-empty projected blocks"
if b and m: assert_same_structure(b, m)
assert_same_structure(b[0], m[0])
def test_fees_mempool_blocks_fee_range(brk, mempool): def test_fees_mempool_blocks_invariants(brk):
"""Each projected block must have a 7-element feeRange.""" """Block counts, sizes, fees, medianFee in feeRange, ordering by descending medianFee."""
path = "/api/v1/fees/mempool-blocks" b = brk.get_mempool_blocks()
for label, client in [("brk", brk), ("mempool", mempool)]: show("GET", "/api/v1/fees/mempool-blocks", f"({len(b)} blocks)", "-")
blocks = client.get_json(path) assert 1 <= len(b) <= MAX_PROJECTED_BLOCKS, (
for i, block in enumerate(blocks[:3]): f"projected block count out of range: {len(b)}"
assert "feeRange" in block, f"{label} block {i} missing feeRange" )
assert len(block["feeRange"]) == 7, ( medians = [block["medianFee"] for block in b]
f"{label} block {i} feeRange has {len(block['feeRange'])} items, expected 7" assert medians == sorted(medians, reverse=True), (
f"blocks not ordered by descending medianFee: {medians}"
)
for i, block in enumerate(b):
assert block["blockSize"] > 0, f"block {i} has non-positive blockSize"
assert block["blockVSize"] > 0, f"block {i} has non-positive blockVSize"
assert block["nTx"] > 0, f"block {i} has non-positive nTx"
assert block["totalFees"] >= 0, f"block {i} has negative totalFees"
assert block["medianFee"] > 0, f"block {i} has non-positive medianFee"
fr = block["feeRange"]
assert len(fr) == BRK_FEE_RANGE_LEN, (
f"block {i} feeRange has {len(fr)} items, expected {BRK_FEE_RANGE_LEN}"
)
assert fr == sorted(fr), f"block {i} feeRange not ascending: {fr}"
assert fr[0] <= block["medianFee"] <= fr[-1], (
f"block {i} medianFee {block['medianFee']} outside feeRange [{fr[0]}, {fr[-1]}]"
) )

View File

@@ -3,40 +3,37 @@
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
EXPECTED_FEE_KEYS = [ EXPECTED_FEE_KEYS = ["fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee"]
"fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee",
]
def test_fees_precise_structure(brk, mempool): def test_fees_precise_structure(brk, mempool):
"""Precise fees must have the same structure as recommended.""" """Precise fees envelope must match mempool's keys and numeric types."""
path = "/api/v1/fees/precise" path = "/api/v1/fees/precise"
b = brk.get_json(path) b = brk.get_precise_fees()
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_structure(b, m) assert_same_structure(b, m)
def test_fees_precise_invariants(brk):
"""All tiers numeric, positive, and monotonically non-increasing."""
b = brk.get_precise_fees()
show("GET", "/api/v1/fees/precise", b, "-")
for key in EXPECTED_FEE_KEYS: for key in EXPECTED_FEE_KEYS:
assert key in b assert key in b, f"missing '{key}'"
assert isinstance(b[key], (int, float)), f"'{key}' not numeric: {type(b[key])}"
assert b[key] > 0, f"'{key}' must be positive, got {b[key]}"
def test_fees_precise_ordering(brk, mempool): assert b["fastestFee"] >= b["halfHourFee"] >= b["hourFee"], (
"""Precise fee tiers must be ordered: fastest >= halfHour >= hour >= economy >= minimum.""" f"fast tiers not ordered: {b}"
path = "/api/v1/fees/precise"
for label, client in [("brk", brk), ("mempool", mempool)]:
d = client.get_json(path)
assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"], (
f"{label}: precise fee ordering violated {d}"
) )
assert d["hourFee"] >= d["economyFee"] >= d["minimumFee"], ( assert b["hourFee"] >= b["economyFee"] >= b["minimumFee"], (
f"{label}: precise fee ordering violated {d}" f"slow tiers not ordered: {b}"
) )
def test_fees_precise_numeric(brk): def test_fees_precise_mempool_ordering_sanity(mempool):
"""Each tier in /precise must be a non-negative number.""" """Sanity: mempool itself follows the documented ordering."""
d = brk.get_json("/api/v1/fees/precise") d = mempool.get_json("/api/v1/fees/precise")
show("GET", "/api/v1/fees/precise", d, "") assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"] >= d["economyFee"] >= d["minimumFee"], (
for key in EXPECTED_FEE_KEYS: f"mempool tiers not ordered: {d}"
v = d[key] )
assert isinstance(v, (int, float)), f"{key} not numeric: {type(v).__name__}"
assert v >= 0, f"{key} is negative: {v}"

View File

@@ -3,31 +3,37 @@
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
EXPECTED_FEE_KEYS = [ EXPECTED_FEE_KEYS = ["fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee"]
"fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee",
]
def test_fees_recommended(brk, mempool): def test_fees_recommended_structure(brk, mempool):
"""Recommended fees must have the same keys and numeric types.""" """Recommended fees envelope must match mempool's keys and numeric types."""
path = "/api/v1/fees/recommended" path = "/api/v1/fees/recommended"
b = brk.get_json(path) b = brk.get_recommended_fees()
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_structure(b, m) assert_same_structure(b, m)
def test_fees_recommended_invariants(brk):
"""All tiers numeric, positive, and monotonically non-increasing."""
b = brk.get_recommended_fees()
show("GET", "/api/v1/fees/recommended", b, "-")
for key in EXPECTED_FEE_KEYS: for key in EXPECTED_FEE_KEYS:
assert key in b, f"brk missing '{key}'" assert key in b, f"missing '{key}'"
assert isinstance(b[key], (int, float)), f"'{key}' is not numeric: {type(b[key])}" assert isinstance(b[key], (int, float)), f"'{key}' not numeric: {type(b[key])}"
assert b[key] > 0, f"'{key}' must be positive, got {b[key]}"
assert b["fastestFee"] >= b["halfHourFee"] >= b["hourFee"], (
f"fast tiers not ordered: {b}"
)
assert b["hourFee"] >= b["economyFee"] >= b["minimumFee"], (
f"slow tiers not ordered: {b}"
)
def test_fees_recommended_ordering(brk, mempool): def test_fees_recommended_mempool_ordering_sanity(mempool):
"""Fee tiers must be ordered: fastest >= halfHour >= hour >= economy >= minimum.""" """Sanity: mempool itself follows the documented ordering (pins our reading of the contract)."""
path = "/api/v1/fees/recommended" d = mempool.get_json("/api/v1/fees/recommended")
for label, client in [("brk", brk), ("mempool", mempool)]: assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"] >= d["economyFee"] >= d["minimumFee"], (
d = client.get_json(path) f"mempool tiers not ordered: {d}"
assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"], (
f"{label}: fee ordering violated {d}"
)
assert d["hourFee"] >= d["economyFee"] >= d["minimumFee"], (
f"{label}: fee ordering violated {d}"
) )

View File

@@ -7,13 +7,49 @@ predecessor was non-signaling (full-RBF).
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
def test_fullrbf_replacements_shape(brk, mempool): HEX = set("0123456789abcdef")
"""Full-RBF replacement-tree structure must match for the first element if both lists are non-empty.""" MAX_REPLACEMENTS = 25
def _validate_node(node, path):
"""Recursively validate a ReplacementNode and its replaces children."""
assert "tx" in node and "replaces" in node, f"{path}: missing tx/replaces"
assert node["time"] > 0, f"{path}: non-positive time {node['time']}"
tx = node["tx"]
txid = tx["txid"]
assert isinstance(txid, str) and len(txid) == 64 and set(txid) <= HEX, (
f"{path}.tx.txid malformed: {txid!r}"
)
assert int(tx["fee"]) >= 0, f"{path}.tx.fee negative: {tx['fee']}"
assert int(tx["vsize"]) > 0, f"{path}.tx.vsize non-positive: {tx['vsize']}"
assert int(tx["value"]) >= 0, f"{path}.tx.value negative: {tx['value']}"
assert tx["rate"] >= 0, f"{path}.tx.rate negative: {tx['rate']}"
assert tx["time"] > 0, f"{path}.tx.time non-positive: {tx['time']}"
replaces = node["replaces"]
assert isinstance(replaces, list), f"{path}.replaces not a list"
for i, child in enumerate(replaces):
_validate_node(child, f"{path}.replaces[{i}]")
def test_fullrbf_replacements_structure(brk, mempool):
"""Full-RBF replacement-tree envelope must match across the full list."""
path = "/api/v1/fullrbf/replacements" path = "/api/v1/fullrbf/replacements"
b = brk.get_json(path) b = brk.get_fullrbf_replacements()
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
assert len(b) <= 25 and len(m) <= 25 assert len(b) <= MAX_REPLACEMENTS and len(m) <= MAX_REPLACEMENTS
if b and m: if b and m:
assert_same_structure(b[0], m[0]) assert_same_structure(b, m)
def test_fullrbf_replacements_invariants(brk):
"""Length cap, recursive node validation, every root must be full-RBF."""
b = brk.get_fullrbf_replacements()
show("GET", "/api/v1/fullrbf/replacements", f"({len(b)} trees)", "-")
assert 0 <= len(b) <= MAX_REPLACEMENTS, f"unexpected length: {len(b)}"
for i, root in enumerate(b):
assert root["fullRbf"] is True, (
f"root[{i}] is not fullRbf - endpoint contract violated: {root['tx']['txid']}"
)
_validate_node(root, f"root[{i}]")

View File

@@ -3,21 +3,40 @@
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
def test_mempool_info(brk, mempool): def test_mempool_info_structure(brk, mempool):
"""Mempool stats must have the same keys and types.""" """Mempool stats envelope must match mempool's keys and types."""
path = "/api/mempool" path = "/api/mempool"
b = brk.get_json(path) b = brk.get_mempool()
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m, max_lines=15) show("GET", path, b, m, max_lines=15)
assert_same_structure(b, m) assert_same_structure(b, m)
assert isinstance(b["count"], int)
assert isinstance(b["vsize"], int)
def test_mempool_info_positive(brk, mempool): def test_mempool_info_invariants(brk):
"""Both servers must report a non-empty mempool.""" """Counts positive, fee histogram descending and accounting-exact (sum bin_vsizes == vsize)."""
path = "/api/mempool" b = brk.get_mempool()
for label, client in [("brk", brk), ("mempool", mempool)]: show("GET", "/api/mempool", b, "-", max_lines=15)
d = client.get_json(path) assert isinstance(b["count"], int) and b["count"] > 0
assert d["count"] > 0, f"{label} mempool count is 0" assert isinstance(b["vsize"], int) and b["vsize"] > 0
assert d["vsize"] > 0, f"{label} mempool vsize is 0" assert b["total_fee"] >= 0, f"negative total_fee: {b['total_fee']}"
fh = b["fee_histogram"]
assert isinstance(fh, list) and len(fh) > 0, "fee_histogram must be non-empty list"
rates = []
bin_vsize_sum = 0
for i, entry in enumerate(fh):
assert isinstance(entry, list) and len(entry) == 2, (
f"histogram entry {i} not a 2-element list: {entry}"
)
rate, bvs = entry
assert isinstance(rate, (int, float)) and rate > 0, (
f"non-positive rate at bin {i}: {rate}"
)
assert isinstance(bvs, int) and bvs > 0, f"non-positive vsize at bin {i}: {bvs}"
rates.append(rate)
bin_vsize_sum += bvs
assert rates == sorted(rates, reverse=True), (
f"fee_histogram not descending by rate: {rates[:5]}..."
)
assert bin_vsize_sum == b["vsize"], (
f"sum(bin_vsizes)={bin_vsize_sum} != vsize={b['vsize']}"
)

View File

@@ -3,23 +3,34 @@
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
def test_mempool_recent(brk, mempool): HEX = set("0123456789abcdef")
"""Recent mempool txs must have the same element structure.""" MAX_RECENT = 10
def test_mempool_recent_structure(brk, mempool):
"""Recent mempool txs envelope must match across the full list."""
path = "/api/mempool/recent" path = "/api/mempool/recent"
b = brk.get_json(path) b = brk.get_mempool_recent()
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
assert len(b) > 0 assert len(b) > 0, "brk recent list is empty"
if b and m: assert_same_structure(b, m)
assert_same_structure(b[0], m[0])
def test_mempool_recent_fields(brk, mempool): def test_mempool_recent_invariants(brk):
"""Each recent tx must have txid, fee, vsize, value.""" """Length cap, txid format, positive fee/vsize/value, unique txids."""
path = "/api/mempool/recent" b = brk.get_mempool_recent()
for label, client in [("brk", brk), ("mempool", mempool)]: show("GET", "/api/mempool/recent", b, "-")
txs = client.get_json(path) assert 1 <= len(b) <= MAX_RECENT, f"recent length out of range: {len(b)}"
for tx in txs[:3]: txids = []
for key in ["txid", "fee", "vsize", "value"]: for i, tx in enumerate(b):
assert key in tx, f"{label} recent tx missing '{key}': {tx}" txid = tx["txid"]
assert isinstance(txid, str) and len(txid) == 64 and set(txid) <= HEX, (
f"entry {i} txid malformed: {txid!r}"
)
assert int(tx["fee"]) >= 0, f"entry {i} negative fee: {tx['fee']}"
assert int(tx["vsize"]) > 0, f"entry {i} non-positive vsize: {tx['vsize']}"
assert int(tx["value"]) > 0, f"entry {i} non-positive value: {tx['value']}"
txids.append(txid)
assert len(txids) == len(set(txids)), f"duplicate txids in recent: {txids}"

View File

@@ -8,13 +8,46 @@ load-bearing.
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
def test_replacements_shape(brk, mempool): HEX = set("0123456789abcdef")
"""Replacement-tree structure must match for the first element if both lists are non-empty.""" MAX_REPLACEMENTS = 25
def _validate_node(node, path):
"""Recursively validate a ReplacementNode and its replaces children."""
assert "tx" in node and "replaces" in node, f"{path}: missing tx/replaces"
assert node["time"] > 0, f"{path}: non-positive time {node['time']}"
tx = node["tx"]
txid = tx["txid"]
assert isinstance(txid, str) and len(txid) == 64 and set(txid) <= HEX, (
f"{path}.tx.txid malformed: {txid!r}"
)
assert int(tx["fee"]) >= 0, f"{path}.tx.fee negative: {tx['fee']}"
assert int(tx["vsize"]) > 0, f"{path}.tx.vsize non-positive: {tx['vsize']}"
assert int(tx["value"]) >= 0, f"{path}.tx.value negative: {tx['value']}"
assert tx["rate"] >= 0, f"{path}.tx.rate negative: {tx['rate']}"
assert tx["time"] > 0, f"{path}.tx.time non-positive: {tx['time']}"
replaces = node["replaces"]
assert isinstance(replaces, list), f"{path}.replaces not a list"
for i, child in enumerate(replaces):
_validate_node(child, f"{path}.replaces[{i}]")
def test_replacements_structure(brk, mempool):
"""Replacement-tree envelope must match across the full list."""
path = "/api/v1/replacements" path = "/api/v1/replacements"
b = brk.get_json(path) b = brk.get_replacements()
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
assert len(b) <= 25 and len(m) <= 25 assert len(b) <= MAX_REPLACEMENTS and len(m) <= MAX_REPLACEMENTS
if b and m: if b and m:
assert_same_structure(b[0], m[0]) assert_same_structure(b, m)
def test_replacements_invariants(brk):
"""Length cap, recursive node validation."""
b = brk.get_replacements()
show("GET", "/api/v1/replacements", f"({len(b)} trees)", "-")
assert 0 <= len(b) <= MAX_REPLACEMENTS, f"unexpected length: {len(b)}"
for i, root in enumerate(b):
_validate_node(root, f"root[{i}]")

View File

@@ -6,47 +6,44 @@ from _lib import show
HEX = set("0123456789abcdef") HEX = set("0123456789abcdef")
def test_mempool_txids_basic(brk, mempool): def test_mempool_txids_structure(brk, mempool):
"""Txid list must be a non-empty array of strings on both servers.""" """Txid list must be a non-empty array on both servers."""
path = "/api/mempool/txids" path = "/api/mempool/txids"
b = brk.get_json(path) b = brk.get_mempool_txids()
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, f"({len(b)} txids)", f"({len(m)} txids)") show("GET", path, f"({len(b)} txids)", f"({len(m)} txids)")
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
assert len(b) > 0, "brk mempool has no txids" assert len(b) > 0, "brk mempool txids list is empty"
assert isinstance(b[0], str) and len(b[0]) == 64
def test_mempool_txids_format(brk): def test_mempool_txids_format(brk):
"""Every txid in brk's mempool list must be a 64-char lowercase hex string.""" """Every txid must be a 64-char strict-lowercase hex string."""
b = brk.get_json("/api/mempool/txids") b = brk.get_mempool_txids()
show("GET", "/api/mempool/txids", f"({len(b)} txids)", "") show("GET", "/api/mempool/txids", f"({len(b)} txids)", "-")
bad = [t for t in b if not (isinstance(t, str) and len(t) == 64 and set(t.lower()) <= HEX)] bad = [t for t in b if not (isinstance(t, str) and len(t) == 64 and set(t) <= HEX)]
assert not bad, f"{len(bad)} malformed txid(s), e.g. {bad[0] if bad else None!r}" assert not bad, f"{len(bad)} malformed txid(s), e.g. {bad[0] if bad else None!r}"
def test_mempool_txids_unique(brk): def test_mempool_txids_unique(brk):
"""Brk's mempool txid list must not contain duplicates.""" """No duplicates."""
b = brk.get_json("/api/mempool/txids") b = brk.get_mempool_txids()
show("GET", "/api/mempool/txids", f"({len(b)} txids)", "") show("GET", "/api/mempool/txids", f"({len(b)} txids)", "-")
assert len(b) == len(set(b)), ( assert len(b) == len(set(b)), (
f"duplicate txids: {len(b) - len(set(b))} duplicates out of {len(b)}" f"duplicate txids: {len(b) - len(set(b))} duplicates out of {len(b)}"
) )
def test_mempool_txids_count_matches_summary(brk): def test_mempool_txids_count_matches_summary(brk):
"""`/api/mempool/txids` length must roughly track `/api/mempool`'s `count`. """`/api/mempool/txids` length must roughly track `/api/mempool`.count.
The two endpoints are independent reads against a live mempool, so The two endpoints are independent reads against a live mempool, so
arrivals / evictions between fetches cause drift. We only assert the arrivals / evictions between fetches cause drift. We assert within
counts are in the same ballpark - exact equality would be flaky. max(50, count/100) tolerance to absorb normal churn.
""" """
txids = brk.get_json("/api/mempool/txids") txids = brk.get_mempool_txids()
summary = brk.get_json("/api/mempool") summary = brk.get_mempool()
show("GET", "/api/mempool/txids", f"len={len(txids)}", f"count={summary.get('count')}") show("GET", "/api/mempool/txids", f"len={len(txids)}", f"count={summary['count']}")
assert isinstance(summary["count"], int) and summary["count"] > 0 assert summary["count"] > 0 and len(txids) > 0
assert len(txids) > 0
# 1% tolerance covers normal mempool churn between the two fetches.
drift = abs(len(txids) - summary["count"]) drift = abs(len(txids) - summary["count"])
assert drift <= max(50, summary["count"] // 100), ( assert drift <= max(50, summary["count"] // 100), (
f"txids={len(txids)} vs /api/mempool.count={summary['count']} (drift={drift})" f"txids={len(txids)} vs /api/mempool.count={summary['count']} (drift={drift})"

View File

@@ -2,14 +2,51 @@
import pytest import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"]) PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
def test_mining_blocks_fee_rates(brk, mempool, period): PERCENTILES = ["avgFee_0", "avgFee_10", "avgFee_25", "avgFee_50", "avgFee_75", "avgFee_90", "avgFee_100"]
"""Block fee-rate percentiles must have the same element structure."""
@pytest.mark.parametrize("period", PERIODS)
def test_mining_blocks_fee_rates_structure(brk, mempool, period):
"""Block fee-rate percentiles envelope must match across all periods."""
path = f"/api/v1/mining/blocks/fee-rates/{period}" path = f"/api/v1/mining/blocks/fee-rates/{period}"
b = brk.get_json(path) b = brk.get_block_fee_rates(period)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, summary(b), summary(m)) show("GET", path, summary(b), summary(m))
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m) assert_same_structure(b, m)
def test_mining_blocks_fee_rates_invariants(brk):
"""Series ordering, percentile monotonicity, non-negative rates (period=1m)."""
period = "1m"
b = brk.get_block_fee_rates(period)
show("GET", f"/api/v1/mining/blocks/fee-rates/{period}", summary(b), "-")
assert len(b) > 0, "expected non-empty fee-rates series for 1m"
heights = [entry["avgHeight"] for entry in b]
timestamps = [entry["timestamp"] for entry in b]
assert heights == sorted(heights), "avgHeight not ascending"
assert timestamps == sorted(timestamps), "timestamps not ascending"
assert len(set(heights)) == len(heights), "duplicate avgHeight in series"
for entry in b:
values = [entry[k] for k in PERCENTILES]
assert values == sorted(values), (
f"percentiles not monotonically non-decreasing at height {entry['avgHeight']}: {values}"
)
for k in PERCENTILES:
assert entry[k] >= 0, f"negative fee rate {k}={entry[k]} at {entry['avgHeight']}"
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_blocks_fee_rates_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/blocks/fee-rates/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,46 @@
import pytest import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"]) PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
def test_mining_blocks_fees(brk, mempool, period):
"""Average block fees must have the same element structure."""
@pytest.mark.parametrize("period", PERIODS)
def test_mining_blocks_fees_structure(brk, mempool, period):
"""Average block fees envelope must match across all periods."""
path = f"/api/v1/mining/blocks/fees/{period}" path = f"/api/v1/mining/blocks/fees/{period}"
b = brk.get_json(path) b = brk.get_block_fees(period)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, summary(b), summary(m)) show("GET", path, summary(b), summary(m))
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m) assert_same_structure(b, m)
def test_mining_blocks_fees_invariants(brk):
"""Series ascending by height and timestamp, fees and USD non-negative (period=1m)."""
period = "1m"
b = brk.get_block_fees(period)
show("GET", f"/api/v1/mining/blocks/fees/{period}", summary(b), "-")
assert len(b) > 0, "expected non-empty fees series for 1m"
heights = [entry["avgHeight"] for entry in b]
timestamps = [entry["timestamp"] for entry in b]
assert heights == sorted(heights), "avgHeight not ascending"
assert timestamps == sorted(timestamps), "timestamps not ascending"
assert len(set(heights)) == len(heights), "duplicate avgHeight in series"
for entry in b:
assert entry["avgFees"] >= 0, f"negative avgFees: {entry}"
assert entry["USD"] >= 0, f"negative USD: {entry}"
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_blocks_fees_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/blocks/fees/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,46 @@
import pytest import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"]) PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
def test_mining_blocks_rewards(brk, mempool, period):
"""Average block rewards must have the same element structure."""
@pytest.mark.parametrize("period", PERIODS)
def test_mining_blocks_rewards_structure(brk, mempool, period):
"""Average block rewards envelope must match across all periods."""
path = f"/api/v1/mining/blocks/rewards/{period}" path = f"/api/v1/mining/blocks/rewards/{period}"
b = brk.get_json(path) b = brk.get_block_rewards(period)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, summary(b), summary(m)) show("GET", path, summary(b), summary(m))
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m) assert_same_structure(b, m)
def test_mining_blocks_rewards_invariants(brk):
"""Series ascending by height and timestamp, rewards positive, USD non-negative (period=1m)."""
period = "1m"
b = brk.get_block_rewards(period)
show("GET", f"/api/v1/mining/blocks/rewards/{period}", summary(b), "-")
assert len(b) > 0, "expected non-empty rewards series for 1m"
heights = [entry["avgHeight"] for entry in b]
timestamps = [entry["timestamp"] for entry in b]
assert heights == sorted(heights), "avgHeight not ascending"
assert timestamps == sorted(timestamps), "timestamps not ascending"
assert len(set(heights)) == len(heights), "duplicate avgHeight in series"
for entry in b:
assert entry["avgRewards"] > 0, f"non-positive avgRewards: {entry}"
assert entry["USD"] >= 0, f"negative USD: {entry}"
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_blocks_rewards_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/blocks/rewards/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,60 @@
import pytest import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"]) PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
def test_mining_blocks_sizes_weights(brk, mempool, period): MAX_BLOCK_WEIGHT = 4_000_000
"""Block sizes and weights must have the same structure."""
@pytest.mark.parametrize("period", PERIODS)
def test_mining_blocks_sizes_weights_structure(brk, mempool, period):
"""Combined sizes/weights envelope must match across all periods."""
path = f"/api/v1/mining/blocks/sizes-weights/{period}" path = f"/api/v1/mining/blocks/sizes-weights/{period}"
b = brk.get_json(path) b = brk.get_block_sizes_weights(period)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, summary(b), summary(m)) show("GET", path, summary(b), summary(m))
assert isinstance(b, dict) and isinstance(m, dict)
assert_same_structure(b, m) assert_same_structure(b, m)
def test_mining_blocks_sizes_weights_invariants(brk):
"""Parallel arrays, ascending order, positive size, weight in (0, 4M] (period=1m)."""
period = "1m"
b = brk.get_block_sizes_weights(period)
sizes = b["sizes"]
weights = b["weights"]
show("GET", f"/api/v1/mining/blocks/sizes-weights/{period}", summary(b), "-")
assert len(sizes) > 0, "expected non-empty sizes series for 1m"
assert len(sizes) == len(weights), (
f"sizes/weights array lengths diverge: {len(sizes)} vs {len(weights)}"
)
size_heights = [e["avgHeight"] for e in sizes]
size_ts = [e["timestamp"] for e in sizes]
assert size_heights == sorted(size_heights), "size avgHeights not ascending"
assert size_ts == sorted(size_ts), "size timestamps not ascending"
assert len(set(size_heights)) == len(size_heights), "duplicate avgHeight in sizes"
for s, w in zip(sizes, weights):
assert s["avgHeight"] == w["avgHeight"], (
f"size/weight height misalignment: {s['avgHeight']} vs {w['avgHeight']}"
)
assert s["timestamp"] == w["timestamp"], (
f"size/weight timestamp misalignment at height {s['avgHeight']}"
)
assert s["avgSize"] > 0, f"non-positive avgSize at {s['avgHeight']}: {s['avgSize']}"
assert 0 < w["avgWeight"] <= MAX_BLOCK_WEIGHT, (
f"avgWeight out of range at {w['avgHeight']}: {w['avgWeight']}"
)
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_blocks_sizes_weights_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/blocks/sizes-weights/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,15 +1,52 @@
"""GET /api/v1/mining/blocks/timestamp/{timestamp}""" """GET /api/v1/mining/blocks/timestamp/{timestamp}"""
from _lib import assert_same_structure, show import pytest
from brk_client import BrkError
from _lib import assert_same_structure, assert_same_values, show
def test_mining_blocks_timestamp(brk, mempool, live): GENESIS_TIMESTAMP = 1231006505
"""Block lookup by timestamp must have the same structure for various eras."""
def test_mining_blocks_timestamp_structure_and_parity(brk, mempool, live):
"""For each live era, brk and mempool must resolve the same block."""
for block in live.blocks: for block in live.blocks:
info = brk.get_json(f"/api/block/{block.hash}") info = brk.get_json(f"/api/block/{block.hash}")
ts = info["timestamp"] ts = info["timestamp"]
path = f"/api/v1/mining/blocks/timestamp/{ts}" path = f"/api/v1/mining/blocks/timestamp/{ts}"
b = brk.get_json(path) b = brk.get_block_by_timestamp(ts)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_structure(b, m) assert_same_structure(b, m)
assert_same_values(b, m)
def test_mining_blocks_timestamp_round_trip(brk, live):
"""Looking up a block's own timestamp must return that block (or an earlier one with same ts)."""
for block in live.blocks:
info = brk.get_json(f"/api/block/{block.hash}")
ts = info["timestamp"]
b = brk.get_block_by_timestamp(ts)
show("GET", f"/api/v1/mining/blocks/timestamp/{ts}", b, "-")
assert b["height"] <= block.height, (
f"resolved height {b['height']} > requested block height {block.height}"
)
def test_mining_blocks_timestamp_genesis(brk):
"""Genesis Unix timestamp must resolve to genesis (height 0)."""
b = brk.get_block_by_timestamp(GENESIS_TIMESTAMP)
show("GET", f"/api/v1/mining/blocks/timestamp/{GENESIS_TIMESTAMP}", b, "-")
assert b["height"] == 0, f"genesis ts must resolve to height 0, got {b['height']}"
@pytest.mark.parametrize("bad", ["abc", "-1"])
def test_mining_blocks_timestamp_malformed(brk, bad):
"""Non-numeric or negative timestamp must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/blocks/timestamp/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,53 @@
import pytest import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y"]) PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
def test_mining_difficulty_adjustments(brk, mempool, period): RETARGET_INTERVAL = 2016
"""Historical difficulty adjustments must have the same structure."""
@pytest.mark.parametrize("period", PERIODS)
def test_mining_difficulty_adjustments_structure(brk, mempool, period):
"""Historical difficulty adjustments envelope must match across all periods."""
path = f"/api/v1/mining/difficulty-adjustments/{period}" path = f"/api/v1/mining/difficulty-adjustments/{period}"
b = brk.get_json(path) b = brk.get_difficulty_adjustments_by_period(period)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, summary(b), summary(m)) show("GET", path, summary(b), summary(m))
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m) assert_same_structure(b, m)
def test_mining_difficulty_adjustments_invariants(brk):
"""Tip-first ordering, retarget-aligned heights, genesis sentinel (period=all)."""
period = "all"
b = brk.get_difficulty_adjustments_by_period(period)
show("GET", f"/api/v1/mining/difficulty-adjustments/{period}", summary(b), "-")
assert len(b) > 0, "expected non-empty difficulty adjustments for period=all"
heights = [entry[1] for entry in b]
assert heights == sorted(heights, reverse=True), "entries not descending by height"
assert len(set(heights)) == len(heights), "duplicate heights in series"
assert heights[-1] == 0, f"last entry must be genesis (height 0), got {heights[-1]}"
assert heights.count(0) == 1, "expected exactly one genesis entry"
for entry in b[:-1]:
timestamp, height, difficulty, change_ratio = entry
assert height % RETARGET_INTERVAL == 0, (
f"non-genesis height {height} not on retarget boundary"
)
assert difficulty > 0, f"non-positive difficulty: {difficulty} at height {height}"
assert change_ratio > 0, f"non-positive change ratio: {change_ratio} at height {height}"
genesis = b[-1]
assert genesis[2] == 1.0, f"genesis difficulty must be 1.0, got {genesis[2]}"
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_difficulty_adjustments_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/difficulty-adjustments/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,52 @@
import pytest import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y"]) PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
def test_mining_hashrate(brk, mempool, period):
"""Network hashrate + difficulty must have the same structure."""
@pytest.mark.parametrize("period", PERIODS)
def test_mining_hashrate_structure(brk, mempool, period):
"""Network hashrate envelope must match across all periods."""
path = f"/api/v1/mining/hashrate/{period}" path = f"/api/v1/mining/hashrate/{period}"
b = brk.get_json(path) b = brk.get_hashrate_by_period(period)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, summary(b), summary(m)) show("GET", path, summary(b), summary(m))
assert_same_structure(b, m) assert_same_structure(b, m)
def test_mining_hashrate_invariants(brk):
"""Series ascending, values positive, current* fields populated (period=1m)."""
period = "1m"
b = brk.get_hashrate_by_period(period)
show("GET", f"/api/v1/mining/hashrate/{period}", summary(b), "-")
assert isinstance(b["currentHashrate"], int) and b["currentHashrate"] > 0
assert isinstance(b["currentDifficulty"], (int, float)) and b["currentDifficulty"] > 0
hashrates = b["hashrates"]
assert len(hashrates) > 0, "expected non-empty hashrates list for 1m"
timestamps = [h["timestamp"] for h in hashrates]
assert timestamps == sorted(timestamps), "hashrate timestamps not ascending"
assert len(set(timestamps)) == len(timestamps), "duplicate hashrate timestamps"
for h in hashrates:
assert isinstance(h["avgHashrate"], int) and h["avgHashrate"] > 0
difficulty = b["difficulty"]
times = [d["time"] for d in difficulty]
heights = [d["height"] for d in difficulty]
assert times == sorted(times), "difficulty entries not ascending by time"
assert heights == sorted(heights), "difficulty entries not ascending by height"
for d in difficulty:
assert d["difficulty"] > 0, f"non-positive difficulty: {d}"
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_hashrate_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/hashrate/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,48 @@
import pytest import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "1y"]) PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
def test_mining_hashrate_pools(brk, mempool, period):
"""Per-pool hashrate must have the same structure."""
@pytest.mark.parametrize("period", PERIODS)
def test_mining_hashrate_pools_structure(brk, mempool, period):
"""Per-pool hashrate snapshot envelope must match across all periods."""
path = f"/api/v1/mining/hashrate/pools/{period}" path = f"/api/v1/mining/hashrate/pools/{period}"
b = brk.get_json(path) b = brk.get_pools_hashrate_by_period(period)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, summary(b), summary(m)) show("GET", path, summary(b), summary(m))
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m) assert_same_structure(b, m)
def test_mining_hashrate_pools_invariants(brk):
"""Snapshot has single timestamp, valid shares summing to <=1, unique pool names (period=1w)."""
period = "1w"
b = brk.get_pools_hashrate_by_period(period)
show("GET", f"/api/v1/mining/hashrate/pools/{period}", summary(b), "-")
assert len(b) > 0, "expected non-empty per-pool hashrate snapshot for 1w"
timestamps = {entry["timestamp"] for entry in b}
assert len(timestamps) == 1, f"expected single snapshot timestamp, got {timestamps}"
pool_names = [entry["poolName"] for entry in b]
assert len(set(pool_names)) == len(pool_names), "duplicate poolName in snapshot"
for entry in b:
assert entry["poolName"], "empty poolName"
assert isinstance(entry["avgHashrate"], int) and entry["avgHashrate"] >= 0
assert isinstance(entry["share"], (int, float)) and 0.0 <= entry["share"] <= 1.0
total_share = sum(entry["share"] for entry in b)
assert total_share <= 1.0001, f"share sum > 1: {total_share}"
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_hashrate_pools_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/hashrate/pools/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,13 +1,73 @@
"""GET /api/v1/mining/pool/{slug}""" """GET /api/v1/mining/pool/{slug}"""
from _lib import assert_same_structure, show, summary import pytest
from brk_client import BrkError
from _lib import assert_same_structure, assert_same_values, show, summary
def test_mining_pool_detail(brk, mempool, pool_slugs): # Tip-race / mempool-only / int-vs-str fields excluded from value equality.
"""Pool detail must have the same structure for top pools.""" VOLATILE = {
"blockCount", "blockShare", "estimatedHashrate", "reportedHashrate",
"totalReward", "avgBlockHealth", "avgMatchRate", "avgFeeDelta",
}
# Digit/punctuation slugs that previously diverged between brk and mempool.
# Pinning them here lets the slug rename fixes regress loudly if reverted.
SLUG_RENAME_REGRESSION_GUARD = ["1thash", "175btc", "21inc", "1hash", "58coin", "7pool"]
def test_mining_pool_detail_structure(brk, mempool, pool_slugs):
"""Pool detail envelope must match mempool for the top active pools."""
for slug in pool_slugs: for slug in pool_slugs:
path = f"/api/v1/mining/pool/{slug}" path = f"/api/v1/mining/pool/{slug}"
b = brk.get_json(path) b = brk.get_pool(slug)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, summary(b), summary(m)) show("GET", path, summary(b), summary(m))
assert_same_structure(b, m) assert_same_structure(b, m)
def test_mining_pool_detail_static_fields(brk, mempool, pool_slug):
"""The pool registry fields (id, name, link, slug, unique_id) must value-match."""
path = f"/api/v1/mining/pool/{pool_slug}"
b = brk.get_pool(pool_slug)
m = mempool.get_json(path)
show("GET", path, b["pool"], m["pool"])
assert_same_values(b["pool"], m["pool"], path=f"{path}.pool")
def test_mining_pool_detail_invariants(brk, pool_slug):
"""blockCount monotonic by window; blockShare in [0,1]; pool.slug round-trips."""
b = brk.get_pool(pool_slug)
show("GET", f"/api/v1/mining/pool/{pool_slug}", summary(b), "-")
assert b["pool"]["slug"] == pool_slug, (
f"response.pool.slug={b['pool']['slug']!r} vs URL slug={pool_slug!r}"
)
bc = b["blockCount"]
assert bc["all"] >= bc["1w"] >= bc["24h"] >= 0, f"blockCount not monotonic: {bc}"
bs = b["blockShare"]
for window, value in bs.items():
assert 0.0 <= value <= 1.0, f"blockShare[{window}]={value} out of [0,1]"
assert isinstance(b["estimatedHashrate"], int) and b["estimatedHashrate"] >= 0
@pytest.mark.parametrize("slug", SLUG_RENAME_REGRESSION_GUARD)
def test_mining_pool_detail_slug_renames(brk, mempool, slug):
"""Pools whose slugs were renamed to match mempool must remain reachable."""
path = f"/api/v1/mining/pool/{slug}"
b = brk.get_pool(slug)
m = mempool.get_json(path)
show("GET", path, b["pool"], m["pool"])
assert b["pool"]["slug"] == slug
assert_same_values(b["pool"], m["pool"], path=f"{path}.pool")
@pytest.mark.parametrize("bad", ["notapool", "FoundryUSA", ""])
def test_mining_pool_detail_malformed(brk, bad):
"""Unknown slug must produce BrkError(status=400 or 404)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/pool/{bad}")
assert exc_info.value.status in (400, 404), (
f"expected 400 or 404 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,15 +1,47 @@
"""GET /api/v1/mining/pool/{slug}/blocks""" """GET /api/v1/mining/pool/{slug}/blocks"""
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
def test_mining_pool_blocks(brk, mempool, pool_slugs): PAGE_SIZE = 100
"""Recent blocks by pool must have the same element structure."""
def test_mining_pool_blocks_structure(brk, mempool, pool_slugs):
"""Per-pool block list element schema must match for top active pools."""
for slug in pool_slugs: for slug in pool_slugs:
path = f"/api/v1/mining/pool/{slug}/blocks" path = f"/api/v1/mining/pool/{slug}/blocks"
b = brk.get_json(path) b = brk.get_pool_blocks(slug)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
if b and m: assert_same_structure(b, m)
assert_same_structure(b[0], m[0])
def test_mining_pool_blocks_invariants(brk, pool_slug):
"""Page is descending, capped at 100, all blocks attributed to the requested pool."""
b = brk.get_pool_blocks(pool_slug)
show("GET", f"/api/v1/mining/pool/{pool_slug}/blocks", f"({len(b)} blocks)", "-")
assert 0 < len(b) <= PAGE_SIZE, f"unexpected length: {len(b)}"
heights = [blk["height"] for blk in b]
assert heights == sorted(heights, reverse=True), f"not tip-first: {heights[:5]}..."
assert len(set(heights)) == len(heights), "duplicate heights in page"
for blk in b:
assert blk["stale"] is False, f"stale block in page: {blk['id']}"
assert blk["extras"]["pool"]["slug"] == pool_slug, (
f"block {blk['id']} attributed to {blk['extras']['pool']['slug']}, "
f"expected {pool_slug}"
)
@pytest.mark.parametrize("bad", ["notapool", "FoundryUSA"])
def test_mining_pool_blocks_malformed(brk, bad):
"""Unknown slug must produce BrkError(status=400 or 404)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/pool/{bad}/blocks")
assert exc_info.value.status in (400, 404), (
f"expected 400 or 404 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,15 +1,61 @@
"""GET /api/v1/mining/pool/{slug}/blocks/{height}""" """GET /api/v1/mining/pool/{slug}/blocks/{height}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
def test_mining_pool_blocks_at_height(brk, mempool, pool_slug, live): PAGE_SIZE = 100
"""Pool blocks before various heights must have the same element structure."""
for block in live.blocks[::2]: # every other block, to keep run-time bounded
def test_mining_pool_blocks_from_height_structure(brk, mempool, pool_slug, block):
"""Per-pool block list before a height must match mempool's element schema."""
path = f"/api/v1/mining/pool/{pool_slug}/blocks/{block.height}" path = f"/api/v1/mining/pool/{pool_slug}/blocks/{block.height}"
b = brk.get_json(path) b = brk.get_pool_blocks_from(pool_slug, block.height)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
if b and m: assert_same_structure(b, m)
assert_same_structure(b[0], m[0])
def test_mining_pool_blocks_from_height_invariants(brk, pool_slug, block):
"""Page is descending, capped at 100, height-bounded, attributed to the pool."""
b = brk.get_pool_blocks_from(pool_slug, block.height)
show("GET", f"/api/v1/mining/pool/{pool_slug}/blocks/{block.height}", f"({len(b)} blocks)", "-")
assert 0 <= len(b) <= PAGE_SIZE, f"unexpected length: {len(b)}"
if not b:
return
heights = [blk["height"] for blk in b]
assert heights == sorted(heights, reverse=True), f"not descending: {heights[:5]}..."
assert max(heights) <= block.height, (
f"page contains height > requested {block.height}: max={max(heights)}"
)
assert len(set(heights)) == len(heights), "duplicate heights in page"
for blk in b:
assert blk["stale"] is False, f"stale block in page: {blk['id']}"
assert blk["extras"]["pool"]["slug"] == pool_slug, (
f"block {blk['id']} attributed to {blk['extras']['pool']['slug']}, "
f"expected {pool_slug}"
)
@pytest.mark.parametrize("bad_slug", ["notapool", "FoundryUSA"])
def test_mining_pool_blocks_from_height_malformed_slug(brk, bad_slug):
"""Unknown slug must produce BrkError(status=400 or 404)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/pool/{bad_slug}/blocks/100000")
assert exc_info.value.status in (400, 404), (
f"expected 400 or 404 for slug {bad_slug!r}, got {exc_info.value.status}"
)
@pytest.mark.parametrize("bad_height", ["-1", "abc"])
def test_mining_pool_blocks_from_height_malformed_height(brk, pool_slug, bad_height):
"""Negative or non-numeric height must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/pool/{pool_slug}/blocks/{bad_height}")
assert exc_info.value.status == 400, (
f"expected 400 for height {bad_height!r}, got {exc_info.value.status}"
)

View File

@@ -1,13 +1,43 @@
"""GET /api/v1/mining/pool/{slug}/hashrate""" """GET /api/v1/mining/pool/{slug}/hashrate"""
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary from _lib import assert_same_structure, show, summary
def test_mining_pool_hashrate(brk, mempool, pool_slugs): def test_mining_pool_hashrate_structure(brk, mempool, pool_slugs):
"""Pool hashrate history must have the same structure for top pools.""" """Pool hashrate history element schema must match for top active pools."""
for slug in pool_slugs: for slug in pool_slugs:
path = f"/api/v1/mining/pool/{slug}/hashrate" path = f"/api/v1/mining/pool/{slug}/hashrate"
b = brk.get_json(path) b = brk.get_pool_hashrate(slug)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, summary(b), summary(m)) show("GET", path, summary(b), summary(m))
assert isinstance(b, list) and isinstance(m, list)
assert_same_structure(b, m) assert_same_structure(b, m)
def test_mining_pool_hashrate_invariants(brk, pool_slug):
"""Series must be non-empty, ascending in time, with valid hashrate/share/poolName."""
b = brk.get_pool_hashrate(pool_slug)
show("GET", f"/api/v1/mining/pool/{pool_slug}/hashrate", summary(b), "-")
assert len(b) > 0, f"empty hashrate history for {pool_slug}"
timestamps = [entry["timestamp"] for entry in b]
assert timestamps == sorted(timestamps), "timestamps not ascending"
assert len(set(timestamps)) == len(timestamps), "duplicate timestamps"
pool_names = {entry["poolName"] for entry in b}
assert len(pool_names) == 1, f"poolName not consistent across series: {pool_names}"
for entry in b:
assert isinstance(entry["avgHashrate"], int) and entry["avgHashrate"] >= 0
assert isinstance(entry["share"], (int, float)) and 0.0 <= entry["share"] <= 1.0
@pytest.mark.parametrize("bad", ["notapool", "FoundryUSA"])
def test_mining_pool_hashrate_malformed(brk, bad):
"""Unknown slug must produce BrkError(status=400 or 404)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/pool/{bad}/hashrate")
assert exc_info.value.status in (400, 404), (
f"expected 400 or 404 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -3,43 +3,78 @@
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
# Slugs present in brk's vendored pools-v2.json but reported under a different
# slug (or missing) by mempool.space. Currently the duplicate-pool collision
# case where brk preserves both `bitcoinindia` (variant 80) and
# `bitcoinindiapool` (variant 134), while mempool emits both as `bitcoinindia`.
KNOWN_BRK_ONLY_SLUGS = {"bitcoinindiapool"}
# Pools added upstream after brk's vendored pools-v2.json snapshot. Refresh
# the vendored file (and update this set) when bumping the snapshot.
KNOWN_MEMPOOL_ONLY_SLUGS = {
"drdetroit", "emzy", "knorrium", "mononaut", "nymkappa", "rijndael",
}
EXPECTED_MIN_POOLS = 165
def test_mining_pools_list_structure(brk, mempool): def test_mining_pools_list_structure(brk, mempool):
"""Pool list must have the same element structure.""" """Pool list element schema must match (flat list, {name, slug, unique_id})."""
path = "/api/v1/mining/pools" path = "/api/v1/mining/pools"
b = brk.get_json(path) b = brk.get_pools()
m = mempool.get_json(path) m = mempool.get_json(path)
show( show("GET", path, f"({len(b)} pools)", f"({len(m)} pools)", max_lines=4)
"GET", path, assert isinstance(b, list) and isinstance(m, list), "both must be flat lists"
b[:3] if isinstance(b, list) else b,
m[:3] if isinstance(m, list) else m,
)
assert_same_structure(b, m) assert_same_structure(b, m)
def _pools(data):
"""`pools` may live at the root or inside an envelope across versions."""
if isinstance(data, list):
return data
return data.get("pools", []) if isinstance(data, dict) else []
def test_mining_pools_list_fields(brk): def test_mining_pools_list_fields(brk):
"""Each pool entry must carry slug and name (period-less endpoint omits stats).""" """Every pool entry must carry a non-empty slug + name + non-negative unique_id."""
b = _pools(brk.get_json("/api/v1/mining/pools")) b = brk.get_pools()
show("GET", "/api/v1/mining/pools", f"({len(b)} pools)", "") show("GET", "/api/v1/mining/pools", f"({len(b)} pools)", "-")
assert b, "no pools in brk's response" assert len(b) >= EXPECTED_MIN_POOLS, f"expected >= {EXPECTED_MIN_POOLS} pools, got {len(b)}"
required = {"slug", "name"} for p in b:
for p in b[:5]: assert isinstance(p["slug"], str) and p["slug"], f"bad slug: {p!r}"
missing = required - set(p.keys()) assert isinstance(p["name"], str) and p["name"], f"bad name: {p!r}"
assert not missing, f"pool {p.get('slug', '?')} missing fields: {missing}" assert isinstance(p["unique_id"], int) and p["unique_id"] >= 0, (
assert isinstance(p["name"], str) and p["name"] f"bad unique_id: {p!r}"
)
def test_mining_pools_slugs_unique(brk): def test_mining_pools_slugs_unique(brk):
"""Pool slugs must be unique across the response.""" """Pool slugs must be unique across the response."""
b = _pools(brk.get_json("/api/v1/mining/pools")) b = brk.get_pools()
slugs = [p["slug"] for p in b] slugs = [p["slug"] for p in b]
show("GET", "/api/v1/mining/pools", f"({len(slugs)} slugs)", "") show("GET", "/api/v1/mining/pools", f"({len(slugs)} slugs)", "-")
assert len(slugs) == len(set(slugs)), ( assert len(slugs) == len(set(slugs)), (
f"duplicate slugs: {len(slugs) - len(set(slugs))}" f"duplicate slugs: {len(slugs) - len(set(slugs))}"
) )
def test_mining_pools_unique_ids_unique(brk):
"""Pool unique_ids must be unique across the response."""
b = brk.get_pools()
ids = [p["unique_id"] for p in b]
show("GET", "/api/v1/mining/pools", f"({len(ids)} unique_ids)", "-")
assert len(ids) == len(set(ids)), (
f"duplicate unique_ids: {len(ids) - len(set(ids))}"
)
def test_mining_pools_slugs_match_mempool(brk, mempool):
"""brk's slug set must equal mempool's, modulo documented exceptions."""
b_slugs = {p["slug"] for p in brk.get_pools()}
m_slugs = {p["slug"] for p in mempool.get_json("/api/v1/mining/pools")}
show(
"GET", "/api/v1/mining/pools",
f"brk-only={sorted(b_slugs - m_slugs)}",
f"mempool-only={sorted(m_slugs - b_slugs)}",
)
unexpected_brk_only = (b_slugs - m_slugs) - KNOWN_BRK_ONLY_SLUGS
unexpected_mempool_only = (m_slugs - b_slugs) - KNOWN_MEMPOOL_ONLY_SLUGS
assert not unexpected_brk_only, (
f"undocumented brk-only slugs (likely format divergence): {unexpected_brk_only}"
)
assert not unexpected_mempool_only, (
f"undocumented mempool-only slugs (refresh pools-v2.json?): {unexpected_mempool_only}"
)

View File

@@ -2,14 +2,53 @@
import pytest import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show, summary from _lib import assert_same_structure, show, summary
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]) PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"]
def test_mining_pools_by_period(brk, mempool, period):
"""Pool stats for a time period must have the same structure."""
@pytest.mark.parametrize("period", PERIODS)
def test_mining_pools_by_period_structure(brk, mempool, period):
"""Pool stats envelope must structurally match mempool across all periods."""
path = f"/api/v1/mining/pools/{period}" path = f"/api/v1/mining/pools/{period}"
b = brk.get_json(path) b = brk.get_pool_stats(period)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, summary(b), summary(m)) show("GET", path, summary(b), summary(m))
assert_same_structure(b, m) assert_same_structure(b, m)
def test_mining_pools_by_period_invariants(brk):
"""A single deep-period sanity pass on `1w`."""
period = "1w"
b = brk.get_pool_stats(period)
show("GET", f"/api/v1/mining/pools/{period}", summary(b), "-")
assert isinstance(b["blockCount"], int) and b["blockCount"] > 0
assert isinstance(b["lastEstimatedHashrate"], int) and b["lastEstimatedHashrate"] > 0
pools = b["pools"]
assert pools, "expected non-empty pools list for 1w"
slugs = [p["slug"] for p in pools]
assert len(slugs) == len(set(slugs)), "duplicate slugs in pools list"
ranks = [p["rank"] for p in pools]
assert ranks == list(range(1, len(pools) + 1)), f"ranks not 1..N: {ranks}"
block_total = 0
for p in pools:
assert isinstance(p["blockCount"], int) and p["blockCount"] >= 0
assert isinstance(p["emptyBlocks"], int) and p["emptyBlocks"] >= 0
assert 0.0 <= p["share"] <= 1.0, f"share out of range for {p['slug']}: {p['share']}"
block_total += p["blockCount"]
assert block_total <= b["blockCount"], (
f"sum(pool.blockCount)={block_total} exceeds envelope.blockCount={b['blockCount']}"
)
@pytest.mark.parametrize("bad", ["9000y", "abc", "1d"])
def test_mining_pools_by_period_malformed(brk, bad):
"""Unknown time period must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/pools/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -2,14 +2,62 @@
import pytest import pytest
from _lib import assert_same_structure, show from brk_client import BrkError
from _lib import assert_same_structure, assert_same_values, show
@pytest.mark.parametrize("block_count", [10, 100, 500]) COUNTS = [1, 10, 100, 500, 1000]
def test_mining_reward_stats(brk, mempool, block_count):
"""Reward stats must have the same structure."""
path = f"/api/v1/mining/reward-stats/{block_count}" @pytest.mark.parametrize("count", COUNTS)
b = brk.get_json(path) def test_mining_reward_stats_structure(brk, mempool, count):
"""Reward stats envelope must match across counts."""
path = f"/api/v1/mining/reward-stats/{count}"
b = brk.get_reward_stats(count)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_structure(b, m) assert_same_structure(b, m)
@pytest.mark.parametrize("count", [100, 1000])
def test_mining_reward_stats_values_match(brk, mempool, count):
"""brk and mempool must agree exactly on aggregated stats."""
path = f"/api/v1/mining/reward-stats/{count}"
b = brk.get_reward_stats(count)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_mining_reward_stats_invariants(brk):
"""Range alignment, reward >= fee, totalTx >= block count (count=1000)."""
count = 1000
b = brk.get_reward_stats(count)
show("GET", f"/api/v1/mining/reward-stats/{count}", b, "-")
start = int(b["startBlock"])
end = int(b["endBlock"])
total_reward = int(b["totalReward"])
total_fee = int(b["totalFee"])
total_tx = int(b["totalTx"])
assert start <= end, f"startBlock {start} > endBlock {end}"
assert end - start + 1 == count, (
f"range mismatch: {end} - {start} + 1 = {end - start + 1}, expected {count}"
)
assert total_fee >= 0, f"negative totalFee: {total_fee}"
assert total_reward >= total_fee, (
f"totalReward {total_reward} < totalFee {total_fee} (subsidy must be non-negative)"
)
assert total_tx >= count, (
f"totalTx {total_tx} < block_count {count} (each block has >=1 coinbase tx)"
)
@pytest.mark.parametrize("bad", ["abc", "-1"])
def test_mining_reward_stats_malformed(brk, bad):
"""Non-numeric or negative block_count must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/mining/reward-stats/{bad}")
assert exc_info.value.status == 400, (
f"expected status=400 for {bad!r}, got {exc_info.value.status}"
)

View File

@@ -1,12 +1,57 @@
"""GET /api/v1/cpfp/{txid}""" """GET /api/v1/cpfp/{txid}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
def test_cpfp(brk, mempool, block): def test_cpfp_structure(brk, mempool, block):
"""CPFP info structure must match for a confirmed tx.""" """CPFP structure must match for a confirmed regular tx (multi-era)."""
path = f"/api/v1/cpfp/{block.txid}" path = f"/api/v1/cpfp/{block.txid}"
b = brk.get_json(path) b = brk.get_cpfp(block.txid)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_structure(b, m) assert_same_structure(b, m)
def test_cpfp_coinbase_structure(brk, mempool, block):
"""CPFP structure must match for a coinbase tx (multi-era)."""
path = f"/api/v1/cpfp/{block.coinbase_txid}"
b = brk.get_cpfp(block.coinbase_txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_structure(b, m)
def test_cpfp_invariants(brk, live):
"""Recent confirmed tx: ancestors empty, any brk-computed extras non-negative."""
sample = live.blocks[-1]
c = brk.get_cpfp(sample.txid)
show("GET", f"/api/v1/cpfp/{sample.txid}", c, "-")
assert c["ancestors"] == [], "confirmed tx must have empty ancestors"
if "fee" in c:
assert int(c["fee"]) >= 0
if "effectiveFeePerVsize" in c:
assert c["effectiveFeePerVsize"] >= 0
if "adjustedVsize" in c:
assert int(c["adjustedVsize"]) > 0
def test_cpfp_unknown_tx_returns_empty(brk, mempool):
"""Both servers return {ancestors: []} for any 64-char hex (no 404)."""
bad = "0" * 64
path = f"/api/v1/cpfp/{bad}"
b = brk.get_cpfp(bad)
m = mempool.get_json(path)
show("GET", path, b, m)
assert b.get("ancestors") == []
assert m.get("ancestors") == []
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_cpfp_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400) on brk (mempool returns 501)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/cpfp/{bad}")
assert exc_info.value.status == 400

View File

@@ -1,40 +1,55 @@
"""POST /api/tx (broadcast) """POST /api/tx (broadcast)
We can't actually broadcast a real transaction in a test, so we send a Live broadcast can't be tested in CI — instead we feed every form of
clearly malformed payload and verify both servers reject it with 4xx. The *invalid* payload and verify both servers reject it identically with 400.
goal is to confirm the endpoint exists and behaves like a transaction
broadcaster — not to push live transactions.
""" """
import pytest
from brk_client import BrkError
from _lib import show from _lib import show
def test_post_tx_invalid_hex(brk, mempool): @pytest.mark.parametrize("label,body", [
"""Both servers must reject an obviously invalid hex payload with 4xx.""" ("empty", ""),
("whitespace", " "),
("padded_garbage", " deadbeef "),
("garbage_short", "deadbeef"),
("non_hex", "not-hex-zzzz"),
("single_byte", "00"),
])
def test_post_tx_invalid_body_rejected(brk, mempool, label, body):
"""Invalid body must be rejected with 400 on both servers."""
path = "/api/tx" path = "/api/tx"
bad_hex = "deadbeef" # too short to be a valid serialized transaction with pytest.raises(BrkError) as ei:
brk.post_tx(body)
b = brk.session.post(f"{brk.base_url}{path}", data=bad_hex, timeout=15) assert ei.value.status == 400, label
mempool._wait() mempool._wait()
m = mempool.session.post(f"{mempool.base_url}{path}", data=bad_hex, timeout=15) m = mempool.session.post(f"{mempool.base_url}{path}", data=body, timeout=15)
show("POST", path, f"brk={b.status_code}", f"mempool={m.status_code}") show("POST", f"{path} ({label})", "brk=400", f"mempool={m.status_code}")
assert m.status_code == 400, f"{label}: mempool={m.status_code}"
assert 400 <= b.status_code < 500, (
f"brk POST /api/tx with garbage should 4xx, got {b.status_code}: {b.text!r}"
)
assert 400 <= m.status_code < 500, (
f"mempool POST /api/tx with garbage should 4xx, got {m.status_code}: {m.text!r}"
)
def test_post_tx_empty_body(brk, mempool): def test_post_tx_coinbase_rejected(brk, mempool, block):
"""Both servers must reject an empty body with 4xx.""" """Re-broadcasting a coinbase tx is rejected with 400 on both servers (multi-era)."""
path = "/api/tx" coinbase_hex = mempool.get_text(f"/api/tx/{block.coinbase_txid}/hex")
with pytest.raises(BrkError) as ei:
b = brk.session.post(f"{brk.base_url}{path}", data="", timeout=15) brk.post_tx(coinbase_hex)
assert ei.value.status == 400
mempool._wait() mempool._wait()
m = mempool.session.post(f"{mempool.base_url}{path}", data="", timeout=15) m = mempool.session.post(f"{mempool.base_url}/api/tx", data=coinbase_hex, timeout=15)
show("POST", path, f"brk={b.status_code}", f"mempool={m.status_code}") show("POST", f"/api/tx (coinbase h={block.height})", "brk=400", f"mempool={m.status_code}")
assert m.status_code == 400
assert 400 <= b.status_code < 500
assert 400 <= m.status_code < 500 def test_post_tx_already_confirmed_rejected(brk, mempool, live):
"""Re-broadcasting an already-confirmed regular tx is rejected with 400 on both."""
sample = live.blocks[-1]
tx_hex = mempool.get_text(f"/api/tx/{sample.txid}/hex")
with pytest.raises(BrkError) as ei:
brk.post_tx(tx_hex)
assert ei.value.status == 400
mempool._wait()
m = mempool.session.post(f"{mempool.base_url}/api/tx", data=tx_hex, timeout=15)
show("POST", f"/api/tx (confirmed h={sample.height})", "brk=400", f"mempool={m.status_code}")
assert m.status_code == 400

View File

@@ -1,56 +1,67 @@
"""GET /api/v1/transaction-times?txId[]=...""" """GET /api/v1/transaction-times?txId[]=..."""
import pytest
from brk_client import BrkError
from _lib import show from _lib import show
def test_transaction_times_few(brk, mempool, live): def test_transaction_times_few(brk, mempool, live):
"""First-seen timestamps must match for a few txids.""" """First-seen timestamps must match for a few txids (confirmed → all 0)."""
txids = [b.txid for b in live.blocks[:3]] txids = [b.txid for b in live.blocks[:3]]
params = [("txId[]", t) for t in txids] params = [("txId[]", t) for t in txids]
path = "/api/v1/transaction-times" b = brk.get_transaction_times(txids)
b = brk.get_json(path, params=params) m = mempool.get_json("/api/v1/transaction-times", params=params)
m = mempool.get_json(path, params=params) show("GET", f"/api/v1/transaction-times?txId[]={{{len(txids)} txids}}", b, m)
show("GET", f"{path}?txId[]={{{len(txids)} txids}}", b, m)
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
assert len(b) == len(m) == len(txids) assert len(b) == len(m) == len(txids)
assert b == m, f"timestamps differ: brk={b} vs mempool={m}" assert b == m
def test_transaction_times_many(brk, mempool, live): def test_transaction_times_many(brk, mempool, live):
"""A larger batch (covering all sample blocks + coinbases) must match exactly.""" """A larger batch (all sample blocks + coinbases) must match exactly."""
txids = [b.txid for b in live.blocks] + [b.coinbase_txid for b in live.blocks] txids = [b.txid for b in live.blocks] + [b.coinbase_txid for b in live.blocks]
params = [("txId[]", t) for t in txids] params = [("txId[]", t) for t in txids]
path = "/api/v1/transaction-times" b = brk.get_transaction_times(txids)
b = brk.get_json(path, params=params) m = mempool.get_json("/api/v1/transaction-times", params=params)
m = mempool.get_json(path, params=params) show("GET", f"/api/v1/transaction-times?txId[]={{{len(txids)} txids}}",
show("GET", f"{path}?txId[]={{{len(txids)} txids}}", f"({len(b)})", f"({len(m)})") f"({len(b)})", f"({len(m)})")
assert len(b) == len(m) == len(txids) assert len(b) == len(m) == len(txids)
assert b == m, f"timestamps differ: brk={b} vs mempool={m}" assert b == m
def test_transaction_times_single(brk, mempool, live): def test_transaction_times_single(brk, mempool, live):
"""A single-element batch must return a 1-element list with the same value.""" """A single-element batch must return a 1-element list with the same value."""
txid = live.sample_txid txid = live.sample_txid
params = [("txId[]", txid)] params = [("txId[]", txid)]
path = "/api/v1/transaction-times" b = brk.get_transaction_times([txid])
b = brk.get_json(path, params=params) m = mempool.get_json("/api/v1/transaction-times", params=params)
m = mempool.get_json(path, params=params) show("GET", f"/api/v1/transaction-times?txId[]={txid[:16]}...", b, m)
show("GET", f"{path}?txId[]={txid[:16]}...", b, m)
assert isinstance(b, list) and isinstance(m, list) assert isinstance(b, list) and isinstance(m, list)
assert len(b) == len(m) == 1 assert len(b) == len(m) == 1
assert b == m, f"single timestamp differs: brk={b} vs mempool={m}" assert b == m
def test_transaction_times_empty(brk, mempool): def test_transaction_times_unknown_txid_returns_zero(brk, mempool):
"""An empty batch must be rejected (any non-2xx) on both servers. """Unknown 64-char hex must return [0] on both servers."""
bad = "0" * 64
params = [("txId[]", bad)]
b = brk.get_transaction_times([bad])
m = mempool.get_json("/api/v1/transaction-times", params=params)
show("GET", f"/api/v1/transaction-times?txId[]={bad[:16]}...", b, m)
assert b == [0]
assert m == [0]
mempool.space returns 500 — technically a server-side bug (it should be a
4xx since the request itself is malformed) — so we don't insist on exact def test_transaction_times_empty_batch_rejected(brk):
status parity, only that neither server silently treats it as valid input. """Empty batch must produce BrkError(status=400) (mempool returns 500, brk-only check)."""
""" with pytest.raises(BrkError) as exc_info:
path = "/api/v1/transaction-times" brk.get_transaction_times([])
b_resp = brk.get_raw(path) assert exc_info.value.status == 400
m_resp = mempool.get_raw(path)
show("GET", path, f"brk={b_resp.status_code}", f"mempool={m_resp.status_code}")
assert not b_resp.ok, f"brk accepted empty batch with {b_resp.status_code}: {b_resp.text!r}" def test_transaction_times_malformed_short(brk):
assert not m_resp.ok, f"mempool accepted empty batch with {m_resp.status_code}" """Short txid in batch must produce BrkError(status=400) (mempool silently returns [])."""
with pytest.raises(BrkError) as exc_info:
brk.get_transaction_times(["abc"])
assert exc_info.value.status == 400

View File

@@ -1,21 +1,71 @@
"""GET /api/tx/{txid}""" """GET /api/tx/{txid}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show from _lib import assert_same_values, show
def test_tx_by_id(brk, mempool, block): def test_tx_by_id_value_parity(brk, mempool, block):
"""Full transaction data must match for a confirmed tx.""" """Full transaction data must match for a confirmed regular tx (multi-era)."""
path = f"/api/tx/{block.txid}" path = f"/api/tx/{block.txid}"
b = brk.get_json(path) b = brk.get_tx(block.txid)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_values(b, m, exclude={"sigops"}) assert_same_values(b, m, exclude={"sigops"})
def test_tx_coinbase(brk, mempool, block): def test_tx_coinbase_value_parity(brk, mempool, block):
"""Coinbase transaction must match.""" """Coinbase transaction must match (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}" path = f"/api/tx/{block.coinbase_txid}"
b = brk.get_json(path) b = brk.get_tx(block.coinbase_txid)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_values(b, m, exclude={"sigops"}) assert_same_values(b, m, exclude={"sigops"})
def test_tx_invariants_regular(brk, live):
"""Recent regular tx: fee accounting, weight <= 4*size, status confirmed."""
sample = live.blocks[-1]
if sample.txid == sample.coinbase_txid:
pytest.skip("recent block has only coinbase")
tx = brk.get_tx(sample.txid)
show("GET", f"/api/tx/{sample.txid}", tx, "-")
assert tx["txid"] == sample.txid
assert len(tx["vin"]) > 0 and len(tx["vout"]) > 0
assert int(tx["size"]) > 0
assert 0 < int(tx["weight"]) <= 4 * int(tx["size"])
sum_in = sum(int(v["prevout"]["value"]) for v in tx["vin"])
sum_out = sum(int(o["value"]) for o in tx["vout"])
assert sum_in - sum_out == int(tx["fee"])
assert tx["status"]["confirmed"] is True
def test_tx_invariants_coinbase(brk, live):
"""Recent coinbase: single vin, is_coinbase, no prevout, status confirmed."""
sample = live.blocks[-1]
tx = brk.get_tx(sample.coinbase_txid)
show("GET", f"/api/tx/{sample.coinbase_txid}", tx, "-")
assert tx["txid"] == sample.coinbase_txid
assert len(tx["vin"]) == 1
cbin = tx["vin"][0]
assert cbin["is_coinbase"] is True
assert cbin["prevout"] is None
assert int(tx["fee"]) == 0
assert tx["status"]["confirmed"] is True
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}")
assert exc_info.value.status == 400
def test_tx_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}")
assert exc_info.value.status == 404

View File

@@ -1,12 +1,53 @@
"""GET /api/tx/{txid}/hex""" """GET /api/tx/{txid}/hex"""
import pytest
from brk_client import BrkError
from _lib import show from _lib import show
def test_tx_hex(brk, mempool, block): HEX = set("0123456789abcdef")
"""Raw transaction hex must be identical."""
def test_tx_hex_value_parity(brk, mempool, block):
"""Raw tx hex must be byte-identical for a confirmed regular tx (multi-era)."""
path = f"/api/tx/{block.txid}/hex" path = f"/api/tx/{block.txid}/hex"
b = brk.get_text(path) b = brk.get_tx_hex(block.txid)
m = mempool.get_text(path) m = mempool.get_text(path)
show("GET", path, b[:80] + "...", m[:80] + "...") show("GET", path, b[:80] + "...", m[:80] + "...")
assert b == m assert b == m
def test_tx_hex_coinbase_value_parity(brk, mempool, block):
"""Coinbase tx hex must be byte-identical (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/hex"
b = brk.get_tx_hex(block.coinbase_txid)
m = mempool.get_text(path)
show("GET", path, b[:80] + "...", m[:80] + "...")
assert b == m
def test_tx_hex_invariants(brk, live):
"""Recent tx hex: non-empty, even length, strict lowercase hex."""
sample = live.blocks[-1]
h = brk.get_tx_hex(sample.txid)
show("GET", f"/api/tx/{sample.txid}/hex", f"({len(h)} chars)", "-")
assert isinstance(h, str) and len(h) > 0
assert len(h) % 2 == 0, f"odd hex length: {len(h)}"
assert set(h) <= HEX, f"non-hex chars present: {set(h) - HEX}"
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_hex_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/hex")
assert exc_info.value.status == 400
def test_tx_hex_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/hex")
assert exc_info.value.status == 404

View File

@@ -1,12 +1,70 @@
"""GET /api/tx/{txid}/merkle-proof""" """GET /api/tx/{txid}/merkle-proof"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show from _lib import assert_same_values, show
def test_tx_merkle_proof(brk, mempool, block): HEX = set("0123456789abcdef")
"""Merkle inclusion proof must match."""
def test_tx_merkle_proof_value_parity(brk, mempool, block):
"""Merkle proof must match for a confirmed regular tx (multi-era)."""
path = f"/api/tx/{block.txid}/merkle-proof" path = f"/api/tx/{block.txid}/merkle-proof"
b = brk.get_json(path) b = brk.get_tx_merkle_proof(block.txid)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_values(b, m) assert_same_values(b, m)
def test_tx_merkle_proof_coinbase_value_parity(brk, mempool, block):
"""Merkle proof must match for a coinbase tx (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/merkle-proof"
b = brk.get_tx_merkle_proof(block.coinbase_txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_merkle_proof_invariants_regular(brk, live):
"""Recent regular tx: block_height matches, pos > 0, all siblings 64-char hex."""
sample = live.blocks[-1]
if sample.txid == sample.coinbase_txid:
pytest.skip("recent block has only coinbase")
p = brk.get_tx_merkle_proof(sample.txid)
show("GET", f"/api/tx/{sample.txid}/merkle-proof", p, "-")
assert int(p["block_height"]) == sample.height
assert int(p["pos"]) > 0, "regular tx pos must be > 0 (coinbase is at 0)"
assert isinstance(p["merkle"], list)
for i, sib in enumerate(p["merkle"]):
assert isinstance(sib, str) and len(sib) == 64 and set(sib) <= HEX, (
f"merkle[{i}] malformed: {sib!r}"
)
def test_tx_merkle_proof_invariants_coinbase(brk, live):
"""Recent coinbase: pos == 0, block_height matches."""
sample = live.blocks[-1]
p = brk.get_tx_merkle_proof(sample.coinbase_txid)
show("GET", f"/api/tx/{sample.coinbase_txid}/merkle-proof", p, "-")
assert int(p["block_height"]) == sample.height
assert int(p["pos"]) == 0
for sib in p["merkle"]:
assert isinstance(sib, str) and len(sib) == 64 and set(sib) <= HEX
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_merkle_proof_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/merkle-proof")
assert exc_info.value.status == 400
def test_tx_merkle_proof_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/merkle-proof")
assert exc_info.value.status == 404

View File

@@ -1,12 +1,58 @@
"""GET /api/tx/{txid}/merkleblock-proof""" """GET /api/tx/{txid}/merkleblock-proof"""
import pytest
from brk_client import BrkError
from _lib import show from _lib import show
def test_tx_merkleblock_proof(brk, mempool, block): HEX = set("0123456789abcdef")
"""BIP37 merkleblock proof hex must be identical.""" HEADER_HEX_LEN = 160 # 80-byte BIP37 block header prefix
def test_tx_merkleblock_proof_value_parity(brk, mempool, block):
"""Merkleblock proof hex must be byte-identical for a regular tx (multi-era)."""
path = f"/api/tx/{block.txid}/merkleblock-proof" path = f"/api/tx/{block.txid}/merkleblock-proof"
b = brk.get_text(path) b = brk.get_tx_merkleblock_proof(block.txid)
m = mempool.get_text(path) m = mempool.get_text(path)
show("GET", path, b[:80] + "...", m[:80] + "...") show("GET", path, b[:80] + "...", m[:80] + "...")
assert b == m assert b == m
def test_tx_merkleblock_proof_coinbase_value_parity(brk, mempool, block):
"""Merkleblock proof hex must be byte-identical for a coinbase tx (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/merkleblock-proof"
b = brk.get_tx_merkleblock_proof(block.coinbase_txid)
m = mempool.get_text(path)
show("GET", path, b[:80] + "...", m[:80] + "...")
assert b == m
def test_tx_merkleblock_proof_invariants(brk, live):
"""Recent tx: even hex, lowercase, header prefix matches /block/{hash}/header."""
sample = live.blocks[-1]
proof = brk.get_tx_merkleblock_proof(sample.txid)
show("GET", f"/api/tx/{sample.txid}/merkleblock-proof", f"({len(proof)} chars)", "-")
assert isinstance(proof, str) and len(proof) > HEADER_HEX_LEN
assert len(proof) % 2 == 0, f"odd hex length: {len(proof)}"
assert set(proof) <= HEX, f"non-hex chars: {set(proof) - HEX}"
header = brk.get_block_header(sample.hash)
assert proof[:HEADER_HEX_LEN] == header, (
"merkleblock-proof header prefix must match /block/{hash}/header"
)
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_merkleblock_proof_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/merkleblock-proof")
assert exc_info.value.status == 400
def test_tx_merkleblock_proof_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/merkleblock-proof")
assert exc_info.value.status == 404

View File

@@ -1,38 +1,79 @@
"""GET /api/tx/{txid}/outspend/{vout}""" """GET /api/tx/{txid}/outspend/{vout}"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show from _lib import assert_same_values, show
def test_tx_outspend_first(brk, mempool, block): HEX = set("0123456789abcdef")
"""Spending status of vout 0 must match exactly."""
def test_tx_outspend_first_value_parity(brk, mempool, block):
"""Spending status of vout 0 must match (multi-era)."""
path = f"/api/tx/{block.txid}/outspend/0" path = f"/api/tx/{block.txid}/outspend/0"
b = brk.get_json(path) b = brk.get_tx_outspend(block.txid, 0)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_values(b, m) assert_same_values(b, m)
def test_tx_outspend_last(brk, mempool, block): def test_tx_outspend_last_value_parity(brk, mempool, block):
"""Spending status of the last vout must also match exactly.""" """Spending status of the last vout must match (multi-era)."""
tx = mempool.get_json(f"/api/tx/{block.txid}") tx = mempool.get_json(f"/api/tx/{block.txid}")
last_vout = len(tx["vout"]) - 1 last_vout = len(tx["vout"]) - 1
path = f"/api/tx/{block.txid}/outspend/{last_vout}" path = f"/api/tx/{block.txid}/outspend/{last_vout}"
b = brk.get_json(path) b = brk.get_tx_outspend(block.txid, last_vout)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_outspend_coinbase_value_parity(brk, mempool, block):
"""Coinbase vout 0 spending status must match (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/outspend/0"
b = brk.get_tx_outspend(block.coinbase_txid, 0)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_values(b, m) assert_same_values(b, m)
def test_tx_outspend_out_of_range(brk, mempool, block): def test_tx_outspend_out_of_range(brk, mempool, block):
"""A vout index past the last output must produce the same response on both servers. """Past-the-end vout returns {spent: false} on both servers (no 404)."""
Both servers return `{"spent": false}` rather than 4xx — they don't bound-check
the vout index. The compat property is that they agree.
"""
tx = mempool.get_json(f"/api/tx/{block.txid}") tx = mempool.get_json(f"/api/tx/{block.txid}")
bad_vout = len(tx["vout"]) + 100 bad_vout = len(tx["vout"]) + 100
path = f"/api/tx/{block.txid}/outspend/{bad_vout}" path = f"/api/tx/{block.txid}/outspend/{bad_vout}"
b = brk.get_json(path) b = brk.get_tx_outspend(block.txid, bad_vout)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert b == m, f"out-of-range outspend disagrees: brk={b} vs mempool={m}" assert b == m
def test_tx_outspend_invariants_spent(brk, live):
"""An old (h100) tx output: if spent, validate the spending-tx envelope."""
h100 = next((b for b in live.blocks if b.height == 100), None)
if h100 is None:
pytest.skip("h100 not discovered")
o = brk.get_tx_outspend(h100.txid, 0)
show("GET", f"/api/tx/{h100.txid}/outspend/0", o, "-")
if o["spent"] is True:
assert isinstance(o["txid"], str) and len(o["txid"]) == 64 and set(o["txid"]) <= HEX
assert int(o["vin"]) >= 0
assert o["status"]["confirmed"] is True
assert int(o["status"]["block_height"]) >= h100.height
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_outspend_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/outspend/0")
assert exc_info.value.status == 400
def test_tx_outspend_malformed_unknown_tx(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/outspend/0")
assert exc_info.value.status == 404

View File

@@ -1,12 +1,59 @@
"""GET /api/tx/{txid}/outspends""" """GET /api/tx/{txid}/outspends"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show from _lib import assert_same_values, show
def test_tx_outspends(brk, mempool, block): def test_tx_outspends_value_parity(brk, mempool, block):
"""Spending status of all outputs must match exactly.""" """Outspends list must match for a confirmed regular tx (multi-era)."""
path = f"/api/tx/{block.txid}/outspends" path = f"/api/tx/{block.txid}/outspends"
b = brk.get_json(path) b = brk.get_tx_outspends(block.txid)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_values(b, m) assert_same_values(b, m)
def test_tx_outspends_coinbase_value_parity(brk, mempool, block):
"""Outspends list must match for a coinbase tx (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/outspends"
b = brk.get_tx_outspends(block.coinbase_txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_outspends_length_matches_vout(brk, live):
"""Recent tx: len(outspends) must equal len(tx.vout)."""
sample = live.blocks[-1]
tx = brk.get_tx(sample.txid)
o = brk.get_tx_outspends(sample.txid)
show("GET", f"/api/tx/{sample.txid}/outspends", f"({len(o)} entries)", "-")
assert len(o) == len(tx["vout"]), f"outspends={len(o)} vs vout={len(tx['vout'])}"
def test_tx_outspends_matches_per_vout(brk, live):
"""Recent tx: each outspends[i] equals /outspend/{i}."""
sample = live.blocks[-1]
o = brk.get_tx_outspends(sample.txid)
show("GET", f"/api/tx/{sample.txid}/outspends", f"({len(o)} entries)", "-")
for i, expected in enumerate(o):
single = brk.get_tx_outspend(sample.txid, i)
assert single == expected, f"outspends[{i}] != /outspend/{i}"
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_outspends_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/outspends")
assert exc_info.value.status == 400
def test_tx_outspends_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/outspends")
assert exc_info.value.status == 404

View File

@@ -1,12 +1,50 @@
"""GET /api/tx/{txid}/raw""" """GET /api/tx/{txid}/raw"""
import pytest
from brk_client import BrkError
from _lib import show from _lib import show
def test_tx_raw(brk, mempool, block): def test_tx_raw_value_parity(brk, mempool, block):
"""Raw transaction bytes must be identical.""" """Raw tx bytes must be byte-identical for a confirmed regular tx (multi-era)."""
path = f"/api/tx/{block.txid}/raw" path = f"/api/tx/{block.txid}/raw"
b = brk.get_bytes(path) b = brk.get_tx_raw(block.txid)
m = mempool.get_bytes(path) m = mempool.get_bytes(path)
show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>") show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>")
assert b == m assert b == m
def test_tx_raw_coinbase_value_parity(brk, mempool, block):
"""Coinbase tx bytes must be byte-identical (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/raw"
b = brk.get_tx_raw(block.coinbase_txid)
m = mempool.get_bytes(path)
show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>")
assert b == m
def test_tx_raw_matches_hex(brk, live):
"""Recent tx: raw bytes' hex must equal /hex endpoint output exactly."""
sample = live.blocks[-1]
raw = brk.get_tx_raw(sample.txid)
hex_str = brk.get_tx_hex(sample.txid)
show("GET", f"/api/tx/{sample.txid}/raw", f"<{len(raw)} bytes>", "-")
assert isinstance(raw, bytes) and len(raw) > 0
assert raw.hex() == hex_str, "raw.hex() != /hex"
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_raw_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get(f"/api/tx/{bad}/raw")
assert exc_info.value.status == 400
def test_tx_raw_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get(f"/api/tx/{bad}/raw")
assert exc_info.value.status == 404

View File

@@ -1,16 +1,82 @@
"""GET /api/v1/tx/{txid}/rbf """GET /api/v1/tx/{txid}/rbf
For confirmed transactions both servers return an empty/null replacement brk's `tx_graveyard` retains RBF tree data for 1 hour after a tx leaves the
set; the structure is what's load-bearing here. live mempool (whether mined or replaced). Past that window, brk returns
`{replacements: null, replaces: null}`. mempool.space retains RBF history
for longer, so the cross-server response can diverge for txs older than
brk's window but newer than mempool's. The tests verify:
- brk's contract (always-null) holds for txs deeply past its retention window;
- value parity holds when mempool also reports null (steady state);
- within retention, brk and mempool agree structurally on the tree shape.
""" """
import pytest
from brk_client import BrkError
from _lib import assert_same_structure, show from _lib import assert_same_structure, show
def test_tx_rbf_for_confirmed(brk, mempool, block): NULL_RBF = {"replacements": None, "replaces": None}
"""RBF replacement timeline structure must match for a confirmed tx."""
def test_tx_rbf_brk_null_for_confirmed(brk, block):
"""brk contract: confirmed regular tx always has null replacements/replaces."""
r = brk.get_tx_rbf(block.txid)
show("GET", f"/api/v1/tx/{block.txid}/rbf", r, "-")
assert r == NULL_RBF
def test_tx_rbf_brk_null_for_coinbase(brk, block):
"""brk contract: coinbase tx always has null replacements/replaces."""
r = brk.get_tx_rbf(block.coinbase_txid)
show("GET", f"/api/v1/tx/{block.coinbase_txid}/rbf", r, "-")
assert r == NULL_RBF
def test_tx_rbf_value_parity_when_mempool_null(brk, mempool, block):
"""When mempool also reports null, brk and mempool must agree exactly."""
path = f"/api/v1/tx/{block.txid}/rbf" path = f"/api/v1/tx/{block.txid}/rbf"
b = brk.get_json(path) m = mempool.get_json(path)
if m != NULL_RBF:
pytest.skip("mempool retained RBF history (recently-confirmed); brk doesn't")
b = brk.get_tx_rbf(block.txid)
show("GET", path, b, m)
assert b == m
def test_tx_rbf_within_retention_window(brk, mempool):
"""A root from brk's /replacements list is within the 1h retention window;
brk must return its tree, and mempool (longer retention) must agree on shape."""
trees = brk.get_replacements()
if not trees:
pytest.skip("no recent RBF replacements observed by brk")
root_txid = trees[0]["tx"]["txid"]
path = f"/api/v1/tx/{root_txid}/rbf"
b = brk.get_tx_rbf(root_txid)
show("GET", path, b, "-")
assert b["replacements"] is not None, (
"brk evicted RBF tree it just listed in /replacements"
)
m = mempool.get_json(path)
if m["replacements"] is None:
pytest.skip("mempool has no RBF history for brk's recent root")
assert_same_structure(b, m)
def test_tx_rbf_unknown_tx_returns_null(brk, mempool):
"""Both servers return null replacements/replaces for any 64-char hex (no 404)."""
bad = "0" * 64
path = f"/api/v1/tx/{bad}/rbf"
b = brk.get_tx_rbf(bad)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_structure(b, m) assert b == NULL_RBF
assert m == NULL_RBF
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_rbf_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400) on brk (mempool returns 501)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/v1/tx/{bad}/rbf")
assert exc_info.value.status == 400

View File

@@ -1,12 +1,51 @@
"""GET /api/tx/{txid}/status""" """GET /api/tx/{txid}/status"""
import pytest
from brk_client import BrkError
from _lib import assert_same_values, show from _lib import assert_same_values, show
def test_tx_status(brk, mempool, block): def test_tx_status_value_parity(brk, mempool, block):
"""Confirmation status must match for a confirmed tx.""" """Status must match for a confirmed regular tx (multi-era)."""
path = f"/api/tx/{block.txid}/status" path = f"/api/tx/{block.txid}/status"
b = brk.get_json(path) b = brk.get_tx_status(block.txid)
m = mempool.get_json(path) m = mempool.get_json(path)
show("GET", path, b, m) show("GET", path, b, m)
assert_same_values(b, m) assert_same_values(b, m)
def test_tx_status_coinbase_value_parity(brk, mempool, block):
"""Status must match for a coinbase tx (multi-era)."""
path = f"/api/tx/{block.coinbase_txid}/status"
b = brk.get_tx_status(block.coinbase_txid)
m = mempool.get_json(path)
show("GET", path, b, m)
assert_same_values(b, m)
def test_tx_status_invariants(brk, live):
"""Recent confirmed tx: confirmed=True, height/hash/time match the block."""
sample = live.blocks[-1]
s = brk.get_tx_status(sample.txid)
show("GET", f"/api/tx/{sample.txid}/status", s, "-")
assert s["confirmed"] is True
assert int(s["block_height"]) == sample.height
assert s["block_hash"] == sample.hash
assert int(s["block_time"]) > 0
@pytest.mark.parametrize("bad", ["abc", "deadbeef"])
def test_tx_status_malformed_short(brk, bad):
"""Short txid must produce BrkError(status=400)."""
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/status")
assert exc_info.value.status == 400
def test_tx_status_malformed_unknown(brk):
"""Valid 64-char hex with no matching tx must produce BrkError(status=404)."""
bad = "0" * 64
with pytest.raises(BrkError) as exc_info:
brk.get_text(f"/api/tx/{bad}/status")
assert exc_info.value.status == 404