mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-19 14:24:47 -07:00
global: fixes
This commit is contained in:
@@ -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(¶m.param_type);
|
let ty = jsdoc_normalize(¶m.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(¶m.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(¶m.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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(¶m.name);
|
let ident = sanitize_ident(¶m.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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>] {
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
45
crates/brk_mempool/src/stores/outpoint_spends.rs
Normal file
45
crates/brk_mempool/src/stores/outpoint_spends.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
14
crates/brk_server/src/params/addr_after_txid_param.rs
Normal file
14
crates/brk_server/src/params/addr_after_txid_param.rs
Normal 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,
|
||||||
|
}
|
||||||
@@ -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>,
|
|
||||||
}
|
|
||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_]+")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
44
crates/brk_types/src/outpoint_prefix.rs
Normal file
44
crates/brk_types/src/outpoint_prefix.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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<_>>(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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']}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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']}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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']}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]}]"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}]")
|
||||||
|
|||||||
@@ -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']}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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}]")
|
||||||
|
|||||||
@@ -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})"
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user