From 2b8a0a8cf762870061144d775f60fd9bc19f7ed0 Mon Sep 17 00:00:00 2001 From: nym21 Date: Sat, 2 May 2026 00:42:16 +0200 Subject: [PATCH] global: fixes --- .../src/generators/javascript/api.rs | 342 +++++++++++------- .../src/generators/javascript/client.rs | 61 ++++ .../brk_bindgen/src/generators/python/api.rs | 42 ++- .../src/generators/python/client.rs | 22 ++ crates/brk_bindgen/src/generators/rust/api.rs | 312 +++++++++------- .../brk_bindgen/src/generators/rust/client.rs | 24 ++ crates/brk_bindgen/src/openapi/endpoint.rs | 15 +- crates/brk_bindgen/src/openapi/mod.rs | 29 +- crates/brk_client/src/lib.rs | 70 +++- crates/brk_mempool/src/lib.rs | 21 +- crates/brk_mempool/src/steps/applier.rs | 6 +- .../brk_mempool/src/stores/entry_pool/mod.rs | 7 +- crates/brk_mempool/src/stores/mod.rs | 8 +- .../brk_mempool/src/stores/outpoint_spends.rs | 45 +++ crates/brk_mempool/src/stores/state.rs | 11 +- crates/brk_query/examples/query.rs | 7 +- crates/brk_query/src/impl/addr.rs | 161 +++++---- crates/brk_query/src/impl/mempool.rs | 27 +- crates/brk_query/src/impl/series.rs | 250 ++++++------- crates/brk_query/src/impl/tx.rs | 142 ++++---- crates/brk_query/src/impl/urpd.rs | 17 +- crates/brk_query/src/vecs.rs | 197 +++++----- crates/brk_server/src/api/addrs.rs | 51 ++- crates/brk_server/src/api/series.rs | 2 +- crates/brk_server/src/api/series_legacy.rs | 2 +- crates/brk_server/src/error.rs | 2 + .../src/params/addr_after_txid_param.rs | 14 + .../brk_server/src/params/addr_txids_param.rs | 11 - crates/brk_server/src/params/mod.rs | 4 +- crates/brk_server/src/state.rs | 9 +- crates/brk_types/src/addr_stats.rs | 2 +- crates/brk_types/src/cohort.rs | 48 ++- crates/brk_types/src/lib.rs | 4 +- crates/brk_types/src/outpoint_prefix.rs | 44 +++ crates/brk_types/src/pagination_index.rs | 13 - crates/brk_types/src/pool_slug.rs | 10 + crates/brk_types/src/series_count.rs | 17 - crates/brk_types/src/series_list.rs | 9 +- crates/brk_types/src/series_name.rs | 13 +- crates/brk_types/src/tx_status.rs | 9 + modules/brk-client/index.js | 163 +++++++-- packages/brk_client/brk_client/__init__.py | 95 +++-- .../addresses/test_address_info.py | 127 +++++-- .../addresses/test_address_txs.py | 119 ++++-- .../addresses/test_address_txs_chain.py | 127 +++++-- .../addresses/test_address_txs_mempool.py | 64 ++-- .../addresses/test_address_utxo.py | 81 +++-- .../addresses/test_validate_address.py | 92 +++-- .../tests/mempool_compat/blocks/test_block.py | 44 ++- .../blocks/test_block_header.py | 45 ++- .../blocks/test_block_height.py | 57 ++- .../mempool_compat/blocks/test_block_raw.py | 42 ++- .../blocks/test_block_status.py | 53 ++- .../blocks/test_block_txid_index.py | 68 +++- .../mempool_compat/blocks/test_block_txids.py | 47 ++- .../mempool_compat/blocks/test_block_txs.py | 71 +++- .../blocks/test_block_txs_start.py | 144 +++++--- .../mempool_compat/blocks/test_block_v1.py | 91 +++-- .../blocks/test_blocks_height.py | 73 +++- .../blocks/test_blocks_recent.py | 67 ++-- .../blocks/test_blocks_tip_hash.py | 51 ++- .../blocks/test_blocks_tip_height.py | 39 +- .../blocks/test_blocks_v1_height.py | 102 ++++-- .../blocks/test_blocks_v1_recent.py | 72 +++- .../fees/test_mempool_blocks.py | 50 ++- .../tests/mempool_compat/fees/test_precise.py | 51 ++- .../mempool_compat/fees/test_recommended.py | 44 ++- .../mempool/test_fullrbf_replacements.py | 46 ++- .../mempool_compat/mempool/test_mempool.py | 43 ++- .../mempool_compat/mempool/test_recent.py | 39 +- .../mempool/test_replacements.py | 43 ++- .../mempool_compat/mempool/test_txids.py | 39 +- .../mining/test_blocks_fee_rates.py | 45 ++- .../mempool_compat/mining/test_blocks_fees.py | 40 +- .../mining/test_blocks_rewards.py | 40 +- .../mining/test_blocks_sizes_weights.py | 54 ++- .../mining/test_blocks_timestamp.py | 45 ++- .../mining/test_difficulty_adjustments.py | 47 ++- .../mempool_compat/mining/test_hashrate.py | 46 ++- .../mining/test_hashrate_pools.py | 42 ++- .../tests/mempool_compat/mining/test_pool.py | 68 +++- .../mempool_compat/mining/test_pool_blocks.py | 42 ++- .../mining/test_pool_blocks_height.py | 66 +++- .../mining/test_pool_hashrate.py | 36 +- .../tests/mempool_compat/mining/test_pools.py | 85 +++-- .../mining/test_pools_period.py | 47 ++- .../mining/test_reward_stats.py | 60 ++- .../mempool_compat/transactions/test_cpfp.py | 51 ++- .../transactions/test_post_tx.py | 69 ++-- .../transactions/test_transaction_times.py | 69 ++-- .../mempool_compat/transactions/test_tx.py | 62 +++- .../transactions/test_tx_hex.py | 47 ++- .../transactions/test_tx_merkle_proof.py | 64 +++- .../transactions/test_tx_merkleblock_proof.py | 52 ++- .../transactions/test_tx_outspend.py | 67 +++- .../transactions/test_tx_outspends.py | 53 ++- .../transactions/test_tx_raw.py | 44 ++- .../transactions/test_tx_rbf.py | 78 +++- .../transactions/test_tx_status.py | 45 ++- 99 files changed, 4308 insertions(+), 1525 deletions(-) create mode 100644 crates/brk_mempool/src/stores/outpoint_spends.rs create mode 100644 crates/brk_server/src/params/addr_after_txid_param.rs delete mode 100644 crates/brk_server/src/params/addr_txids_param.rs create mode 100644 crates/brk_types/src/outpoint_prefix.rs delete mode 100644 crates/brk_types/src/pagination_index.rs diff --git a/crates/brk_bindgen/src/generators/javascript/api.rs b/crates/brk_bindgen/src/generators/javascript/api.rs index 4652cbeb1..78d0a2c82 100644 --- a/crates/brk_bindgen/src/generators/javascript/api.rs +++ b/crates/brk_bindgen/src/generators/javascript/api.rs @@ -14,141 +14,235 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) { if !endpoint.should_generate() { continue; } - - let method_name = endpoint_to_method_name(endpoint); - let base_return_type = if endpoint.returns_binary() { - "Uint8Array".to_string() - } else { - jsdoc_normalize(&normalize_return_type( - endpoint.schema_name().unwrap_or("*"), - )) - }; - let return_type = if endpoint.supports_csv { - format!("{} | string", base_return_type) - } else { - 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, " * ", " *"); + match endpoint.method.as_str() { + "GET" => generate_get_method(output, endpoint), + "POST" => generate_post_method(output, endpoint), + _ => continue, } + } +} - // Add endpoint path +fn generate_get_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(); + } + writeln!( + output, + " * @param {{{{ signal?: AbortSignal, onValue?: (value: {}) => void }}}} [options]", + return_type + ) + .unwrap(); + writeln!(output, " * @returns {{Promise<{}>}}", return_type).unwrap(); + writeln!(output, " */").unwrap(); + + let params = build_method_params(endpoint); + let params_with_opts = if params.is_empty() { + "{ signal, onValue } = {}".to_string() + } else { + format!("{}, {{ signal, onValue }} = {{}}", params) + }; + writeln!(output, " async {}({}) {{", method_name, params_with_opts).unwrap(); + + let path = build_path_template(&endpoint.path, &endpoint.path_params); + + let fetch_call: String = if endpoint.returns_binary() { + "this.getBytes(path, { signal, onValue })".to_string() + } else if endpoint.returns_json() { + "this.getJson(path, { signal, onValue })".to_string() + } else if endpoint.response_kind.text_is_numeric() { + "Number(await this.getText(path, { signal, onValue }))".to_string() + } else { + "this.getText(path, { signal, onValue })".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(); - writeln!( - output, - " * Endpoint: `{} {}`", - endpoint.method.to_uppercase(), - endpoint.path - ) - .unwrap(); + write_description(output, desc, " * ", " *"); + } + 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(); - } + 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(); + } +} - 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(); - } +fn write_path_assignment(output: &mut String, endpoint: &Endpoint, path: &str) { + if endpoint.query_params.is_empty() { + writeln!(output, " const path = `{}`;", path).unwrap(); + } else { + writeln!(output, " const params = new URLSearchParams();").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(); + let ident = sanitize_ident(¶m.name); + let is_array = param.param_type.ends_with("[]"); + if is_array { + writeln!( + output, + " for (const _v of {}) params.append('{}', String(_v));", + ident, param.name + ) + .unwrap(); + } else if param.required { + writeln!( + output, + " params.set('{}', String({}));", + param.name, ident + ) + .unwrap(); + } else { + writeln!( + output, + " if ({} !== undefined) params.set('{}', String({}));", + ident, param.name, ident + ) + .unwrap(); + } } - + writeln!(output, " const query = params.toString();").unwrap(); writeln!( output, - " * @param {{{{ signal?: AbortSignal, onValue?: (value: {}) => void }}}} [options]", - return_type + " const path = `{}${{query ? '?' + query : ''}}`;", + path ) .unwrap(); - writeln!(output, " * @returns {{Promise<{}>}}", return_type).unwrap(); - writeln!(output, " */").unwrap(); - - let params = build_method_params(endpoint); - let params_with_opts = if params.is_empty() { - "{ signal, onValue } = {}".to_string() - } else { - format!("{}, {{ signal, onValue }} = {{}}", params) - }; - writeln!(output, " async {}({}) {{", method_name, params_with_opts).unwrap(); - - let path = build_path_template(&endpoint.path, &endpoint.path_params); - - let fetch_call: String = if endpoint.returns_binary() { - "this.getBytes(path, { signal, onValue })".to_string() - } else if endpoint.returns_json() { - "this.getJson(path, { signal, onValue })".to_string() - } else if endpoint.response_kind.text_is_numeric() { - "Number(await this.getText(path, { signal, onValue }))".to_string() - } else { - "this.getText(path, { signal, onValue })".to_string() - }; - - if endpoint.query_params.is_empty() { - writeln!(output, " const path = `{}`;", path).unwrap(); - } else { - writeln!(output, " const params = new URLSearchParams();").unwrap(); - for param in &endpoint.query_params { - let ident = sanitize_ident(¶m.name); - let is_array = param.param_type.ends_with("[]"); - if is_array { - writeln!( - output, - " for (const _v of {}) params.append('{}', String(_v));", - ident, param.name - ) - .unwrap(); - } else if param.required { - writeln!( - output, - " params.set('{}', String({}));", - param.name, ident - ) - .unwrap(); - } else { - writeln!( - output, - " if ({} !== undefined) params.set('{}', String({}));", - ident, param.name, ident - ) - .unwrap(); - } - } - writeln!(output, " const query = params.toString();").unwrap(); - writeln!( - output, - " const path = `{}${{query ? '?' + query : ''}}`;", - path - ) - .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(); } } diff --git a/crates/brk_bindgen/src/generators/javascript/client.rs b/crates/brk_bindgen/src/generators/javascript/client.rs index 8e5b60680..f0c558b72 100644 --- a/crates/brk_bindgen/src/generators/javascript/client.rs +++ b/crates/brk_bindgen/src/generators/javascript/client.rs @@ -569,6 +569,67 @@ class BrkClientBase {{ 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}} + */ + 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}} + */ + 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}} + */ + 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}} + */ + 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) * @template T diff --git a/crates/brk_bindgen/src/generators/python/api.rs b/crates/brk_bindgen/src/generators/python/api.rs index 177cdc1b7..56aee28fc 100644 --- a/crates/brk_bindgen/src/generators/python/api.rs +++ b/crates/brk_bindgen/src/generators/python/api.rs @@ -162,12 +162,20 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) { // Build path let path = build_path_template(&endpoint.path, &endpoint.path_params); - let fetch_method = if endpoint.returns_binary() { - "get" - } else if endpoint.returns_json() { - "get_json" + let is_post = endpoint.method == "POST"; + let fetch_method = match (is_post, &endpoint.response_kind) { + (false, _) if endpoint.returns_binary() => "get", + (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 { - "get_text" + "" }; 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() { writeln!( output, - " return {}self.{}('{}'){}", - wrap_prefix, fetch_method, path, wrap_suffix + " return {}self.{}('{}'{}){}", + wrap_prefix, fetch_method, path, body_arg, wrap_suffix ) .unwrap(); } else { writeln!( output, - " return {}self.{}(f'{}'){}", - wrap_prefix, fetch_method, path, wrap_suffix + " return {}self.{}(f'{}'{}){}", + wrap_prefix, fetch_method, path, body_arg, wrap_suffix ) .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.{}(path){}", - wrap_prefix, fetch_method, wrap_suffix + " return {}self.{}(path{}){}", + wrap_prefix, fetch_method, body_arg, wrap_suffix ) .unwrap(); } else { writeln!( output, - " return {}self.{}(path){}", - wrap_prefix, fetch_method, wrap_suffix + " return {}self.{}(path{}){}", + wrap_prefix, fetch_method, body_arg, wrap_suffix ) .unwrap(); } @@ -279,6 +287,14 @@ fn build_method_params(endpoint: &Endpoint) -> String { 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("") } diff --git a/crates/brk_bindgen/src/generators/python/client.rs b/crates/brk_bindgen/src/generators/python/client.rs index 48562429c..0e4c22f8b 100644 --- a/crates/brk_bindgen/src/generators/python/client.rs +++ b/crates/brk_bindgen/src/generators/python/client.rs @@ -99,6 +99,28 @@ class BrkClientBase: """Make a GET request and return text.""" 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: """Close the HTTP client.""" if self._conn: diff --git a/crates/brk_bindgen/src/generators/rust/api.rs b/crates/brk_bindgen/src/generators/rust/api.rs index 7db36227f..a060938f0 100644 --- a/crates/brk_bindgen/src/generators/rust/api.rs +++ b/crates/brk_bindgen/src/generators/rust/api.rs @@ -87,132 +87,200 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) { if !endpoint.should_generate() { continue; } - - let method_name = endpoint_to_method_name(endpoint); - let base_return_type = if endpoint.returns_binary() { - "Vec".to_string() - } else if endpoint.returns_text() { - // Text bodies arrive as `String`; per-type parsing is left to the caller. - "String".to_string() - } else { - endpoint - .schema_name() - .map(js_type_to_rust) - .unwrap_or_else(|| "String".to_string()) - }; - - let return_type = if endpoint.supports_csv { - format!("FormatResponse<{}>", base_return_type) - } 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, " /// ", " ///"); + match endpoint.method.as_str() { + "GET" => generate_get_method(output, endpoint), + "POST" => generate_post_method(output, endpoint), + _ => continue, } - // Add endpoint path - writeln!(output, " ///").unwrap(); - writeln!( - output, - " /// Endpoint: `{} {}`", - endpoint.method.to_uppercase(), - endpoint.path - ) - .unwrap(); - - let params = build_method_params(endpoint); - writeln!( - output, - " pub fn {}(&self{}) -> Result<{}> {{", - method_name, params, return_type - ) - .unwrap(); - - let (path, index_arg) = build_path_template(endpoint); - let fetch_method = if endpoint.returns_binary() { - "get_bytes" - } else if endpoint.returns_json() { - "get_json" - } else { - "get_text" - }; - - if endpoint.query_params.is_empty() { - writeln!( - output, - " self.base.{}(&format!(\"{}\"{}))", - fetch_method, path, index_arg - ) - .unwrap(); - } else { - writeln!(output, " let mut query = Vec::new();").unwrap(); - for param in &endpoint.query_params { - let ident = sanitize_ident(¶m.name); - let is_array = param.param_type.ends_with("[]"); - if is_array { - writeln!( - output, - " for v in {} {{ query.push(format!(\"{}={{}}\", v)); }}", - ident, param.name - ) - .unwrap(); - } else if param.required { - writeln!( - output, - " query.push(format!(\"{}={{}}\", {}));", - param.name, ident - ) - .unwrap(); - } else { - writeln!( - output, - " if let Some(v) = {} {{ query.push(format!(\"{}={{}}\", v)); }}", - ident, param.name - ) - .unwrap(); - } - } - writeln!(output, " let query_str = if query.is_empty() {{ String::new() }} else {{ format!(\"?{{}}\", query.join(\"&\")) }};").unwrap(); - writeln!( - output, - " let path = format!(\"{}{{}}\"{}, query_str);", - path, index_arg - ) - .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 generate_get_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 params = build_method_params(endpoint); + writeln!( + output, + " pub fn {}(&self{}) -> Result<{}> {{", + method_name, params, return_type + ) + .unwrap(); + + let (path, index_arg) = build_path_template(endpoint); + let fetch_method = if endpoint.returns_binary() { + "get_bytes" + } else if endpoint.returns_json() { + "get_json" + } else { + "get_text" + }; + + if endpoint.query_params.is_empty() { + writeln!( + output, + " self.base.{}(&format!(\"{}\"{}))", + fetch_method, path, index_arg + ) + .unwrap(); + } 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".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(); + for param in &endpoint.query_params { + let ident = sanitize_ident(¶m.name); + let is_array = param.param_type.ends_with("[]"); + if is_array { + writeln!( + output, + " for v in {} {{ query.push(format!(\"{}={{}}\", v)); }}", + ident, param.name + ) + .unwrap(); + } else if param.required { + writeln!( + output, + " query.push(format!(\"{}={{}}\", {}));", + param.name, ident + ) + .unwrap(); + } else { + writeln!( + output, + " if let Some(v) = {} {{ query.push(format!(\"{}={{}}\", v)); }}", + ident, param.name + ) + .unwrap(); + } + } + writeln!(output, " let query_str = if query.is_empty() {{ String::new() }} else {{ format!(\"?{{}}\", query.join(\"&\")) }};").unwrap(); + writeln!( + output, + " let path = format!(\"{}{{}}\"{}, query_str);", + path, index_arg + ) + .unwrap(); +} + fn endpoint_to_method_name(endpoint: &Endpoint) -> String { to_snake_case(&endpoint.operation_name()) } diff --git a/crates/brk_bindgen/src/generators/rust/client.rs b/crates/brk_bindgen/src/generators/rust/client.rs index 65b0b6987..de0b70f7e 100644 --- a/crates/brk_bindgen/src/generators/rust/client.rs +++ b/crates/brk_bindgen/src/generators/rust/client.rs @@ -111,6 +111,30 @@ impl BrkClientBase {{ .and_then(|mut r| r.body_mut().read_to_vec()) .map_err(|e| BrkError {{ message: e.to_string() }}) }} + + /// Make a POST request and deserialize JSON response. + pub fn post_json(&self, path: &str, body: &str) -> Result {{ + 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 {{ + 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> {{ + 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. diff --git a/crates/brk_bindgen/src/openapi/endpoint.rs b/crates/brk_bindgen/src/openapi/endpoint.rs index 7591370ec..bc14ac728 100644 --- a/crates/brk_bindgen/src/openapi/endpoint.rs +++ b/crates/brk_bindgen/src/openapi/endpoint.rs @@ -1,5 +1,14 @@ 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. #[derive(Debug, Clone)] pub struct Endpoint { @@ -17,6 +26,8 @@ pub struct Endpoint { pub path_params: Vec, /// Query parameters pub query_params: Vec, + /// Request body, if any (POST/PUT/PATCH). + pub request_body: Option, /// Body kind for the 200 response. pub response_kind: ResponseKind, /// Whether this endpoint is deprecated @@ -27,9 +38,9 @@ pub struct Endpoint { impl Endpoint { /// 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 { - self.method == "GET" && !self.deprecated + !self.deprecated && (self.method == "GET" || self.method == "POST") } /// Returns true if this endpoint returns JSON. diff --git a/crates/brk_bindgen/src/openapi/mod.rs b/crates/brk_bindgen/src/openapi/mod.rs index fda45b0ed..74cb76178 100644 --- a/crates/brk_bindgen/src/openapi/mod.rs +++ b/crates/brk_bindgen/src/openapi/mod.rs @@ -3,7 +3,7 @@ mod parameter; mod response_kind; mod text_schema; -pub use endpoint::Endpoint; +pub use endpoint::{Endpoint, RequestBody}; pub use parameter::Parameter; pub use response_kind::ResponseKind; pub use text_schema::TextSchema; @@ -129,6 +129,7 @@ fn extract_endpoint( let query_params = extract_parameters(operation, ParameterIn::Query); let response_kind = extract_response_kind(operation, spec); + let request_body = extract_request_body(operation); let supports_csv = check_csv_support(operation); Some(Endpoint { @@ -139,12 +140,38 @@ fn extract_endpoint( description: operation.description.clone(), path_params, query_params, + request_body, response_kind, deprecated: operation.deprecated.unwrap_or(false), supports_csv, }) } +/// Extract the request body shape, if any. +/// Prefers `text/plain` (string) over `application/json` (typed). +fn extract_request_body(operation: &Operation) -> Option { + 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). fn check_csv_support(operation: &Operation) -> bool { let Some(responses) = operation.responses.as_ref() else { diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index c0e43705f..27e2fc146 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -99,6 +99,30 @@ impl BrkClientBase { .and_then(|mut r| r.body_mut().read_to_vec()) .map_err(|e| BrkError { message: e.to_string() }) } + + /// Make a POST request and deserialize JSON response. + pub fn post_json(&self, path: &str, body: &str) -> Result { + 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 { + 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> { + 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. @@ -9002,42 +9026,45 @@ impl BrkClient { /// 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= 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)* /// /// Endpoint: `GET /api/address/{address}/txs` - pub fn get_address_txs(&self, address: Addr, after_txid: Option) -> Result> { - let mut query = Vec::new(); - 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) + pub fn get_address_txs(&self, address: Addr) -> Result> { + self.base.get_json(&format!("/api/address/{address}/txs")) } /// Address confirmed transactions /// - /// Get confirmed transactions for an address, 25 per page. Use ?after_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)* /// /// Endpoint: `GET /api/address/{address}/txs/chain` - pub fn get_address_confirmed_txs(&self, address: Addr, after_txid: Option) -> Result> { - let mut query = Vec::new(); - 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); - self.base.get_json(&path) + pub fn get_address_confirmed_txs(&self, address: Addr) -> Result> { + self.base.get_json(&format!("/api/address/{address}/txs/chain")) + } + + /// 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}` + pub fn get_address_confirmed_txs_after(&self, address: Addr, after_txid: Txid) -> Result> { + self.base.get_json(&format!("/api/address/{address}/txs/chain/{after_txid}")) } /// 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)* /// /// Endpoint: `GET /api/address/{address}/txs/mempool` - pub fn get_address_mempool_txs(&self, address: Addr) -> Result> { + pub fn get_address_mempool_txs(&self, address: Addr) -> Result> { self.base.get_json(&format!("/api/address/{address}/txs/mempool")) } @@ -9408,6 +9435,17 @@ impl BrkClient { 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 { + self.base.post_json(&format!("/api/tx"), body) + } + /// Txid by index /// /// Retrieve the transaction ID (txid) at a given global transaction index. Returns the txid as plain text. diff --git a/crates/brk_mempool/src/lib.rs b/crates/brk_mempool/src/lib.rs index de2edbdf1..5bd963542 100644 --- a/crates/brk_mempool/src/lib.rs +++ b/crates/brk_mempool/src/lib.rs @@ -17,7 +17,7 @@ use std::{sync::Arc, thread, time::Duration}; use brk_error::Result; 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 tracing::error; @@ -75,6 +75,25 @@ impl Mempool { 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> { self.0.state.txs.read() } diff --git a/crates/brk_mempool/src/steps/applier.rs b/crates/brk_mempool/src/steps/applier.rs index fb3a6f680..735e2539d 100644 --- a/crates/brk_mempool/src/steps/applier.rs +++ b/crates/brk_mempool/src/steps/applier.rs @@ -32,7 +32,7 @@ impl Applier { } 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; }; let txid = entry.txid.clone(); @@ -41,6 +41,7 @@ impl Applier { }; s.info.remove(&tx, entry.fee); s.addrs.remove_tx(&tx, &txid); + s.outpoint_spends.remove_spends(&tx, idx); s.graveyard.bury(txid, tx, entry, reason); } @@ -71,7 +72,8 @@ impl Applier { s.info.add(&tx, entry.fee); s.addrs.add_tx(&tx, &entry.txid); let txid = entry.txid.clone(); - s.entries.insert(entry); + let idx = s.entries.insert(entry); + s.outpoint_spends.insert_spends(&tx, idx); (txid, tx) } } diff --git a/crates/brk_mempool/src/stores/entry_pool/mod.rs b/crates/brk_mempool/src/stores/entry_pool/mod.rs index 13fddf83a..cee8d6f51 100644 --- a/crates/brk_mempool/src/stores/entry_pool/mod.rs +++ b/crates/brk_mempool/src/stores/entry_pool/mod.rs @@ -21,10 +21,11 @@ pub struct EntryPool { } impl EntryPool { - pub fn insert(&mut self, entry: TxEntry) { + pub fn insert(&mut self, entry: TxEntry) -> TxIndex { let prefix = entry.txid_prefix(); let idx = self.claim_slot(entry); self.prefix_to_idx.insert(prefix, idx); + idx } fn claim_slot(&mut self, entry: TxEntry) -> TxIndex { @@ -53,11 +54,11 @@ impl EntryPool { self.entries.get(idx.as_usize())?.as_ref() } - pub fn remove(&mut self, prefix: &TxidPrefix) -> Option { + pub fn remove(&mut self, prefix: &TxidPrefix) -> Option<(TxIndex, TxEntry)> { let idx = self.prefix_to_idx.remove(prefix)?; let entry = self.entries.get_mut(idx.as_usize())?.take()?; self.free_slots.push(idx); - Some(entry) + Some((idx, entry)) } pub fn entries(&self) -> &[Option] { diff --git a/crates/brk_mempool/src/stores/mod.rs b/crates/brk_mempool/src/stores/mod.rs index 1c1e77a43..d2a477b2a 100644 --- a/crates/brk_mempool/src/stores/mod.rs +++ b/crates/brk_mempool/src/stores/mod.rs @@ -1,27 +1,31 @@ //! Stateful in-memory holders. Each owns its `RwLock` and exposes a //! 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. //! - [`addr_tracker::AddrTracker`] - per-address mempool stats. //! - [`entry_pool::EntryPool`] - slot-recycled [`TxEntry`](crate::TxEntry) //! 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::TxTombstone`]s, retained for reappearance //! 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. pub mod addr_tracker; pub mod entry_pool; +pub(crate) mod outpoint_spends; pub mod state; pub mod tx_graveyard; pub mod tx_store; pub use addr_tracker::AddrTracker; pub use entry_pool::{EntryPool, TxIndex}; +pub(crate) use outpoint_spends::OutpointSpends; pub(crate) use state::LockedState; pub use state::MempoolState; pub use tx_graveyard::{TxGraveyard, TxTombstone}; diff --git a/crates/brk_mempool/src/stores/outpoint_spends.rs b/crates/brk_mempool/src/stores/outpoint_spends.rs new file mode 100644 index 000000000..9ad32fcf1 --- /dev/null +++ b/crates/brk_mempool/src/stores/outpoint_spends.rs @@ -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); + +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 { + self.0.get(key).copied() + } +} diff --git a/crates/brk_mempool/src/stores/state.rs b/crates/brk_mempool/src/stores/state.rs index e083362d6..2312c478f 100644 --- a/crates/brk_mempool/src/stores/state.rs +++ b/crates/brk_mempool/src/stores/state.rs @@ -1,12 +1,12 @@ use brk_types::MempoolInfo; 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 -/// 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. #[derive(Default)] pub struct MempoolState { @@ -14,11 +14,12 @@ pub struct MempoolState { pub(crate) txs: RwLock, pub(crate) addrs: RwLock, pub(crate) entries: RwLock, + pub(crate) outpoint_spends: RwLock, pub(crate) graveyard: RwLock, } 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. pub(crate) fn write_all(&self) -> LockedState<'_> { LockedState { @@ -26,6 +27,7 @@ impl MempoolState { txs: self.txs.write(), addrs: self.addrs.write(), entries: self.entries.write(), + outpoint_spends: self.outpoint_spends.write(), graveyard: self.graveyard.write(), } } @@ -36,5 +38,6 @@ pub(crate) struct LockedState<'a> { pub txs: RwLockWriteGuard<'a, TxStore>, pub addrs: RwLockWriteGuard<'a, AddrTracker>, pub entries: RwLockWriteGuard<'a, EntryPool>, + pub outpoint_spends: RwLockWriteGuard<'a, OutpointSpends>, pub graveyard: RwLockWriteGuard<'a, TxGraveyard>, } diff --git a/crates/brk_query/examples/query.rs b/crates/brk_query/examples/query.rs index d84ddd47c..fd9cd9b33 100644 --- a/crates/brk_query/examples/query.rs +++ b/crates/brk_query/examples/query.rs @@ -63,9 +63,10 @@ pub fn main() -> Result<()> { 25 )); - let _ = dbg!(query.addr_utxos(Addr::from( - "bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38".to_string() - ))); + let _ = dbg!(query.addr_utxos( + Addr::from("bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38".to_string()), + 1000, + )); // dbg!(query.search_and_format(SeriesSelection { // index: Index::Height, diff --git a/crates/brk_query/src/impl/addr.rs b/crates/brk_query/src/impl/addr.rs index 77a39cfb8..f88bcf478 100644 --- a/crates/brk_query/src/impl/addr.rs +++ b/crates/brk_query/src/impl/addr.rs @@ -4,16 +4,13 @@ use bitcoin::{Network, PublicKey, ScriptBuf}; use brk_error::{Error, OptionData, Result}; use brk_types::{ Addr, AddrBytes, AddrChainStats, AddrHash, AddrIndexOutPoint, AddrIndexTxIndex, AddrStats, - AnyAddrDataIndexEnum, Dollars, Height, OutputType, Transaction, TxIndex, TxStatus, Txid, - TypeIndex, Unit, Utxo, Vout, + AnyAddrDataIndexEnum, Dollars, Height, OutputType, Timestamp, Transaction, TxIndex, TxStatus, + Txid, TxidPrefix, TypeIndex, Unit, Utxo, Vout, }; use vecdb::VecIndex; use crate::Query; -/// Maximum number of mempool txids to return -const MAX_MEMPOOL_TXIDS: usize = 50; - impl Query { pub fn addr(&self, addr: Addr) -> Result { let indexer = self.indexer(); @@ -36,14 +33,12 @@ impl Query { let Ok(bytes) = AddrBytes::try_from((&script, output_type)) else { return Err(Error::InvalidAddr); }; - let addr_type = output_type; 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); }; - let Ok(Some(type_index)) = store.get(&hash).map(|opt| opt.map(|cow| cow.into_owned())) - else { + let Some(type_index) = store.get(&hash)?.map(|cow| cow.into_owned()) else { return Err(Error::UnknownAddr); }; @@ -52,30 +47,32 @@ impl Query { .any_addr_indexes .get_once(output_type, type_index)?; - let addr_data = match any_addr_index.to_enum() { - AnyAddrDataIndexEnum::Funded(index) => computer - .distribution - .addrs_data - .funded - .reader() - .get(usize::from(index)), - AnyAddrDataIndexEnum::Empty(index) => computer - .distribution - .addrs_data - .empty - .reader() - .get(usize::from(index)) - .into(), - }; - - let realized_price = match &any_addr_index.to_enum() { - AnyAddrDataIndexEnum::Funded(_) => addr_data.realized_price().to_dollars(), - AnyAddrDataIndexEnum::Empty(_) => Dollars::default(), + let (addr_data, realized_price) = match any_addr_index.to_enum() { + AnyAddrDataIndexEnum::Funded(index) => { + let data = computer + .distribution + .addrs_data + .funded + .reader() + .get(usize::from(index)); + let price = data.realized_price().to_dollars(); + (data, price) + } + AnyAddrDataIndexEnum::Empty(index) => { + let data = computer + .distribution + .addrs_data + .empty + .reader() + .get(usize::from(index)) + .into(); + (data, Dollars::default()) + } }; Ok(AddrStats { addr, - addr_type, + addr_type: output_type, chain_stats: AddrChainStats { type_index, funded_txo_count: addr_data.funded_txo_count, @@ -85,22 +82,38 @@ impl Query { tx_count: addr_data.tx_count, realized_price, }, - mempool_stats: self.mempool().map(|m| { - m.addrs() - .get(&bytes) - .map(|e| e.stats.clone()) - .unwrap_or_default() - }), + mempool_stats: self + .mempool() + .and_then(|m| m.addrs().get(&bytes).map(|e| e.stats.clone())) + .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( &self, addr: Addr, + mempool_limit: usize, + chain_limit: usize, + ) -> Result> { + 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, limit: usize, ) -> Result> { - let txindices = self.addr_txindices(&addr, after_txid, limit)?; + let txindices = self.addr_txindices(addr, after_txid, limit)?; self.transactions_by_indices(&txindices) } @@ -112,11 +125,10 @@ impl Query { ) -> Result> { let txindices = self.addr_txindices(&addr, after_txid, limit)?; let txid_reader = self.indexer().vecs.transactions.txid.reader(); - let txids = txindices + Ok(txindices .into_iter() .map(|tx_index| txid_reader.get(tx_index.to_usize())) - .collect(); - Ok(txids) + .collect()) } fn addr_txindices( @@ -125,8 +137,7 @@ impl Query { after_txid: Option, limit: usize, ) -> Result> { - let indexer = self.indexer(); - let stores = &indexer.stores; + let stores = &self.indexer().stores; let (output_type, type_index) = self.resolve_addr(addr)?; @@ -137,8 +148,6 @@ impl Query { if let Some(after_txid) = 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 bound = AddrIndexTxIndex::from((type_index, after_tx_index)); Ok(store @@ -148,7 +157,6 @@ impl Query { .map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) .collect()) } else { - // No pagination — scan from end of prefix let prefix = u32::from(type_index).to_be_bytes(); Ok(store .prefix(prefix) @@ -159,7 +167,7 @@ impl Query { } } - pub fn addr_utxos(&self, addr: Addr) -> Result> { + pub fn addr_utxos(&self, addr: Addr, max_utxos: usize) -> Result> { let indexer = self.indexer(); let stores = &indexer.stores; let vecs = &indexer.vecs; @@ -173,14 +181,12 @@ impl Query { 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 .prefix(prefix) .map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout())) - .take(MAX_UTXOS + 1) + .take(max_utxos + 1) .collect(); - if outpoints.len() > MAX_UTXOS { + if outpoints.len() > max_utxos { return Err(Error::TooManyUtxos); } @@ -218,24 +224,38 @@ impl Query { Ok(utxos) } - pub fn addr_mempool_hash(&self, addr: &Addr) -> u64 { - let Some(mempool) = self.mempool() else { - return 0; - }; - let Ok(bytes) = AddrBytes::from_str(addr) else { - return 0; - }; - mempool.addr_state_hash(&bytes) + pub fn addr_mempool_hash(&self, addr: &Addr) -> Option { + let mempool = self.mempool()?; + let bytes = AddrBytes::from_str(addr).ok()?; + Some(mempool.addr_state_hash(&bytes)) } - pub fn addr_mempool_txids(&self, addr: Addr) -> Result> { - let bytes = AddrBytes::from_str(&addr)?; + pub fn addr_mempool_txs(&self, addr: &Addr, limit: usize) -> Result> { + let bytes = AddrBytes::from_str(addr)?; let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; - Ok(mempool - .addrs() - .get(&bytes) - .map(|e| e.txids.iter().take(MAX_MEMPOOL_TXIDS).cloned().collect()) - .unwrap_or_default()) + let addrs = mempool.addrs(); + let Some(entry) = addrs.get(&bytes) else { + return Ok(vec![]); + }; + 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). @@ -253,14 +273,9 @@ impl Query { .next_back() .map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) .ok_or(Error::UnknownAddr)?; - self.computer() - .indexes - .tx_heights - .get_shared(last_tx_index) - .ok_or(Error::UnknownAddr) + self.confirmed_status_height(last_tx_index) } - /// Resolve an address string to its output type and type_index fn resolve_addr(&self, addr: &Addr) -> Result<(OutputType, TypeIndex)> { let stores = &self.indexer().stores; @@ -268,12 +283,12 @@ impl Query { let output_type = OutputType::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 .get(output_type) .data()? - .get(&hash) - .map(|opt| opt.map(|cow| cow.into_owned())) + .get(&hash)? + .map(|cow| cow.into_owned()) else { return Err(Error::UnknownAddr); }; diff --git a/crates/brk_query/src/impl/mempool.rs b/crates/brk_query/src/impl/mempool.rs index e6c29fd4f..cbbebc9df 100644 --- a/crates/brk_query/src/impl/mempool.rs +++ b/crates/brk_query/src/impl/mempool.rs @@ -94,24 +94,25 @@ impl Query { 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 { - let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; let prefix = TxidPrefix::from(txid); - Ok(mempool - .cpfp_info(&prefix) - .unwrap_or_else(|| self.confirmed_cpfp(txid))) + let mempool_cluster = self.mempool().and_then(|m| m.cpfp_info(&prefix)); + Ok(mempool_cluster.unwrap_or_else(|| self.confirmed_cpfp(txid))) } /// CPFP cluster for a confirmed tx: the connected component of - /// same-block parent/child edges, reconstructed by BFS on demand. - /// Walks entirely in `TxIndex` space using direct vec reads (height, - /// weight, fee) - skips full `Transaction` reconstruction and avoids - /// `txid -> tx_index` lookups by reading `OutPoint`'s packed - /// `tx_index` directly. Capped at 25 each side to match Bitcoin - /// Core's default mempool chain limits and mempool.space's own - /// truncation. `effectiveFeePerVsize` is the simple package rate; - /// mempool's `calculateGoodBlockCpfp` chunk-rate algorithm is not - /// ported. + /// same-block parent/child edges, reconstructed by a depth-first + /// walk on demand. Walks entirely in `TxIndex` space using direct + /// vec reads (height, weight, fee) - skips full `Transaction` + /// reconstruction and avoids `txid -> tx_index` lookups by reading + /// `OutPoint`'s packed `tx_index` directly. Capped at 25 each side + /// to match Bitcoin Core's default mempool chain limits and + /// mempool.space's own truncation. `effectiveFeePerVsize` is the + /// simple package rate; mempool's `calculateGoodBlockCpfp` + /// chunk-rate algorithm is not ported. fn confirmed_cpfp(&self, txid: &Txid) -> CpfpInfo { const MAX: usize = 25; let Ok(seed_idx) = self.resolve_tx_index(txid) else { diff --git a/crates/brk_query/src/impl/series.rs b/crates/brk_query/src/impl/series.rs index 9bc5ec97e..f25e1b535 100644 --- a/crates/brk_query/src/impl/series.rs +++ b/crates/brk_query/src/impl/series.rs @@ -1,20 +1,17 @@ -use std::{collections::BTreeMap, sync::LazyLock}; +use std::sync::LazyLock; use brk_error::{Error, Result}; use brk_traversable::TreeNode; use brk_types::{ BlockHashPrefix, CacheClass, Date, DetailedSeriesCount, Epoch, Format, Halving, Height, Index, - IndexInfo, LegacyValue, Limit, Output, OutputLegacy, PaginatedSeries, Pagination, - PaginationIndex, RangeIndex, RangeMap, SearchQuery, SeriesData, SeriesInfo, SeriesName, - SeriesOutput, SeriesOutputLegacy, SeriesSelection, Timestamp, Version, + IndexInfo, LegacyValue, Limit, Output, OutputLegacy, PaginatedSeries, Pagination, RangeIndex, + RangeMap, SearchQuery, SeriesData, SeriesInfo, SeriesName, SeriesOutput, SeriesOutputLegacy, + SeriesSelection, Timestamp, Version, }; use parking_lot::RwLock; use vecdb::{AnyExportableVec, ReadableVec}; -use crate::{ - Query, - vecs::{IndexToVec, SeriesToVec}, -}; +use crate::Query; /// Monotonic block timestamps → height. Lazily extended as new blocks are indexed. static HEIGHT_BY_MONOTONIC_TIMESTAMP: LazyLock>> = @@ -24,14 +21,17 @@ static HEIGHT_BY_MONOTONIC_TIMESTAMP: LazyLock Vec<&'static str> { 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 { - // Check if series exists but with different indexes if let Some(indexes) = self.vecs().series_to_indexes(series) { let supported = indexes .iter() @@ -44,7 +44,6 @@ impl Query { }; } - // Series doesn't exist, suggest alternatives let matches = self .vecs() .matches(series, Limit::DEFAULT) @@ -63,25 +62,8 @@ impl Query { 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 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 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); - } - + let mut csv = String::with_capacity(num_cols * CSV_HEADER_BYTES_PER_COL); for (i, col) in columns.iter().enumerate() { if i > 0 { csv.push(','); @@ -90,6 +72,17 @@ impl Query { } csv.push('\n'); + // Stream a single column without materializing Vec. + 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 .iter() .map(|col| col.create_writer(from, to)) @@ -108,31 +101,31 @@ impl Query { 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. pub fn latest(&self, series: &SeriesName, index: Index) -> Result { - let vec = self - .vecs() - .get(series, index) - .ok_or_else(|| self.series_not_found_error(series))?; - vec.last_json_value().ok_or(Error::NoData) + self.get_vec(series, index)? + .last_json_value() + .ok_or(Error::NoData) } /// Returns the length (total data points) for a single series. pub fn len(&self, series: &SeriesName, index: Index) -> Result { - let vec = self - .vecs() - .get(series, index) - .ok_or_else(|| self.series_not_found_error(series))?; - Ok(vec.len()) + Ok(self.get_vec(series, index)?.len()) } /// Returns the version for a single series. pub fn version(&self, series: &SeriesName, index: Index) -> Result { - let vec = self - .vecs() - .get(series, index) - .ok_or_else(|| self.series_not_found_error(series))?; - Ok(vec.version()) + Ok(self.get_vec(series, index)?.version()) } /// Search for vecs matching the given series and index. @@ -141,14 +134,11 @@ impl Query { if params.series.is_empty() { return Err(Error::NoSeries); } - let mut vecs = Vec::with_capacity(params.series.len()); - for series in params.series.iter() { - match self.vecs().get(series, params.index) { - Some(vec) => vecs.push(vec), - None => return Err(self.series_not_found_error(series)), - } - } - Ok(vecs) + params + .series + .iter() + .map(|s| self.get_vec(s, params.index)) + .collect() } /// 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 index = params.index; + let resolve_bound = |ri: RangeIndex, fallback: usize| -> Result { + let i = self.range_index_to_i64(ri, index)?; + Ok(vecs.iter().map(|v| v.i64_to_usize(i)).min().unwrap_or(fallback)) + }; + let start = match params.start() { - Some(ri) => { - let i = self.range_index_to_i64(ri, index)?; - vecs.iter().map(|v| v.i64_to_usize(i)).min().unwrap_or(0) - } + Some(ri) => resolve_bound(ri, 0)?, None => 0, }; let end = match params.end() { - Some(ri) => { - let i = self.range_index_to_i64(ri, index)?; - vecs.iter() - .map(|v| v.i64_to_usize(i)) - .min() - .unwrap_or(total) - } + Some(ri) => resolve_bound(ri, total)?, None => params .limit() - .map(|l| (start + *l).min(total)) + .map(|l| start.saturating_add(*l).min(total)) .unwrap_or(total), }; @@ -236,33 +222,34 @@ impl Query { CacheClass::Bucket { margin } => Some(total.saturating_sub(margin)), CacheClass::Entity => { let h = Height::from((*tip_height).saturating_sub(6)); - let v = &self.indexer().vecs; - let n = match index { - 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::TxOutIndex => v.outputs.first_txout_index.collect_one(h).map(usize::from), - Index::EmptyOutputIndex => v.scripts.empty.first_index.collect_one(h).map(usize::from), - Index::OpReturnIndex => v.scripts.op_return.first_index.collect_one(h).map(usize::from), - Index::P2MSOutputIndex => v.scripts.p2ms.first_index.collect_one(h).map(usize::from), - Index::UnknownOutputIndex => v.scripts.unknown.first_index.collect_one(h).map(usize::from), - Index::P2AAddrIndex => v.addrs.p2a.first_index.collect_one(h).map(usize::from), - Index::P2PK33AddrIndex => v.addrs.p2pk33.first_index.collect_one(h).map(usize::from), - Index::P2PK65AddrIndex => v.addrs.p2pk65.first_index.collect_one(h).map(usize::from), - Index::P2PKHAddrIndex => v.addrs.p2pkh.first_index.collect_one(h).map(usize::from), - Index::P2SHAddrIndex => v.addrs.p2sh.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::P2WSHAddrIndex => v.addrs.p2wsh.first_index.collect_one(h).map(usize::from), - _ => unreachable!("non-entity index in CacheClass::Entity arm"), - } - .unwrap_or(0) - .min(total); - Some(n) + 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 { + let v = &self.indexer().vecs; + match index { + 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::TxOutIndex => v.outputs.first_txout_index.collect_one(h).map(usize::from), + Index::EmptyOutputIndex => v.scripts.empty.first_index.collect_one(h).map(usize::from), + Index::OpReturnIndex => v.scripts.op_return.first_index.collect_one(h).map(usize::from), + Index::P2MSOutputIndex => v.scripts.p2ms.first_index.collect_one(h).map(usize::from), + Index::UnknownOutputIndex => v.scripts.unknown.first_index.collect_one(h).map(usize::from), + Index::P2AAddrIndex => v.addrs.p2a.first_index.collect_one(h).map(usize::from), + Index::P2PK33AddrIndex => v.addrs.p2pk33.first_index.collect_one(h).map(usize::from), + Index::P2PK65AddrIndex => v.addrs.p2pk65.first_index.collect_one(h).map(usize::from), + Index::P2PKHAddrIndex => v.addrs.p2pkh.first_index.collect_one(h).map(usize::from), + Index::P2SHAddrIndex => v.addrs.p2sh.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::P2WSHAddrIndex => v.addrs.p2wsh.first_index.collect_one(h).map(usize::from), + _ => unreachable!("entity_index_at called for non-Entity Index: {index:?}"), + } + } + /// Format a resolved query (expensive). /// Call after ETag/cache checks to avoid unnecessary work. pub fn format(&self, resolved: ResolvedQuery) -> Result { @@ -281,22 +268,9 @@ impl Query { Format::CSV => Output::CSV(Self::columns_to_csv(&vecs, start, end)?), Format::JSON => { let count = end.saturating_sub(start); - if vecs.len() == 1 { - let mut buf = Vec::with_capacity(count * 12 + 256); - 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) - } + Output::Json(Self::write_json_array(&vecs, count, 256, |v, buf| { + SeriesData::serialize(v, index, start, end, 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). pub fn format_raw(&self, resolved: ResolvedQuery) -> Result { - if resolved.format() == Format::CSV { + if resolved.format == Format::CSV { return self.format(resolved); } @@ -326,8 +301,9 @@ impl Query { } = resolved; let count = end.saturating_sub(start); - let mut buf = Vec::with_capacity(count * 12 + 2); - vecs[0].write_json(Some(start), Some(end), &mut buf)?; + let buf = Self::write_json_array(&vecs, count, 2, |v, buf| { + v.write_json(Some(start), Some(end), buf) + })?; Ok(SeriesOutput { output: Output::Json(buf), @@ -338,12 +314,28 @@ impl Query { }) } - pub fn series_to_index_to_vec(&self) -> &BTreeMap<&str, IndexToVec<'_>> { - &self.vecs().series_to_index_to_vec - } - - pub fn index_to_series_to_vec(&self) -> &BTreeMap> { - &self.vecs().index_to_series_to_vec + fn write_json_array( + vecs: &[&dyn AnyExportableVec], + cell_count: usize, + wrapper_overhead: usize, + mut write_one: impl FnMut(&dyn AnyExportableVec, &mut Vec) -> vecdb::Result<()>, + ) -> Result> { + 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() { + if i > 0 { + buf.push(b','); + } + write_one(*vec, &mut buf)?; + } + if multi { + buf.push(b']'); + } + Ok(buf) } pub fn series_count(&self) -> DetailedSeriesCount { @@ -365,25 +357,8 @@ impl Query { 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 { - let index_to_vec = self - .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> { - self.vecs().series_to_indexes(series) + self.vecs().series_info(series) } /// 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 { - // Direct date-based index conversion (day1, week1, month1, etc.) if let Some(idx) = index.date_to_index(date) { return Ok(idx as i64); } - // Fall through to timestamp-based resolution (height, epoch, halving) self.timestamp_to_i64(Timestamp::from(date), index) } fn timestamp_to_i64(&self, ts: Timestamp, index: Index) -> Result { - // Direct timestamp-based index conversion (minute10, hour1, etc.) if let Some(idx) = index.timestamp_to_index(ts) { return Ok(idx as i64); } - // Height-based indexes: find block height, then convert let height = Height::from(self.height_for_timestamp(ts)); match index { 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. fn height_for_timestamp(&self, ts: Timestamp) -> usize { let current_height: usize = self.height().into(); + let lookup = |map: &RangeMap| { + map.ceil(ts).map(usize::from).unwrap_or(current_height) + }; - // Fast path: read lock, ceil is &self { let map = HEIGHT_BY_MONOTONIC_TIMESTAMP.read(); 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(); if map.len() <= current_height { *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). @@ -520,10 +492,6 @@ pub struct ResolvedQuery { } impl ResolvedQuery { - pub fn format(&self) -> Format { - self.format - } - pub fn csv_filename(&self) -> String { let names: Vec<_> = self.vecs.iter().map(|v| v.name()).collect(); format!("{}-{}.csv", names.join("_"), self.index) diff --git a/crates/brk_query/src/impl/tx.rs b/crates/brk_query/src/impl/tx.rs index ce1b510c5..c09f4e46f 100644 --- a/crates/brk_query/src/impl/tx.rs +++ b/crates/brk_query/src/impl/tx.rs @@ -1,4 +1,7 @@ -use bitcoin::hex::DisplayHex; +use bitcoin::{ + hashes::{Hash, sha256d}, + hex::DisplayHex, +}; use brk_error::{Error, OptionData, Result}; use brk_types::{ BlockHash, Height, MerkleProof, Timestamp, Transaction, TxInIndex, TxIndex, TxOutIndex, @@ -17,17 +20,12 @@ impl Query { self.indexer() .stores .txid_prefix_to_tx_index - .get(&TxidPrefix::from(txid)) - .map_err(|_| Error::UnknownTxid)? + .get(&TxidPrefix::from(txid))? .map(|cow| cow.into_owned()) .ok_or(Error::UnknownTxid) } pub fn txid_by_index(&self, index: TxIndex) -> Result { - 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() .vecs .transactions @@ -55,23 +53,11 @@ impl Query { .data() } - /// Full confirmed TxStatus from a tx_index. - #[inline] - pub(crate) fn confirmed_status(&self, tx_index: TxIndex) -> Result { - let height = self.confirmed_status_height(tx_index)?; - self.confirmed_status_at(height) - } - /// Full confirmed TxStatus from a known height. #[inline] pub(crate) fn confirmed_status_at(&self, height: Height) -> Result { let (block_hash, block_time) = self.block_hash_and_time(height)?; - Ok(TxStatus { - confirmed: true, - block_height: Some(height), - block_hash: Some(block_hash), - block_time: Some(block_time), - }) + Ok(TxStatus::confirmed(height, block_hash, block_time)) } /// Block hash + timestamp for a height (cached vecs, fast). @@ -85,11 +71,15 @@ impl Query { // ── 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(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option { + self.mempool()?.txs().get(txid).map(f) + } + pub fn transaction(&self, txid: &Txid) -> Result { - if let Some(mempool) = self.mempool() - && let Some(tx) = mempool.txs().get(txid) - { - return Ok(tx.clone()); + if let Some(tx) = self.map_mempool_tx(txid, Transaction::clone) { + return Ok(tx); } 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)) { 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> { - if let Some(mempool) = self.mempool() - && let Some(tx) = mempool.txs().get(txid) - { - return Ok(tx.encode_bytes()); + if let Some(bytes) = self.map_mempool_tx(txid, Transaction::encode_bytes) { + return Ok(bytes); } self.transaction_raw_by_index(self.resolve_tx_index(txid)?) } pub fn transaction_hex(&self, txid: &Txid) -> Result { - if let Some(mempool) = self.mempool() - && let Some(tx) = mempool.txs().get(txid) - { - return Ok(tx.encode_bytes().to_lower_hex_string()); + if let Some(hex) = self.map_mempool_tx(txid, |tx| tx.encode_bytes().to_lower_hex_string()) { + return Ok(hex); } 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 { 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)?; if usize::from(vout) >= output_count { 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> { 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)?; - 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. @@ -204,12 +217,7 @@ impl Query { spent: true, txid: Some(spending_txid), vin: Some(vin), - status: Some(TxStatus { - confirmed: true, - block_height: Some(spending_height), - block_hash: Some(block_hash), - block_time: Some(block_time), - }), + status: Some(TxStatus::confirmed(spending_height, block_hash, block_time)), }); } @@ -223,7 +231,7 @@ impl Query { .vecs .inputs .tx_index - .collect_one_at(usize::from(txin_index)) + .collect_one(txin_index) .data()?; let spending_first_txin: TxInIndex = indexer .vecs @@ -236,8 +244,8 @@ impl Query { .vecs .transactions .txid - .reader() - .get(spending_tx_index.to_usize()); + .collect_one(spending_tx_index) + .data()?; let spending_height = self.confirmed_status_height(spending_tx_index)?; let (block_hash, block_time) = self.block_hash_and_time(spending_height)?; @@ -245,12 +253,7 @@ impl Query { spent: true, txid: Some(spending_txid), vin: Some(vin), - status: Some(TxStatus { - confirmed: true, - block_height: Some(spending_height), - block_hash: Some(block_hash), - block_time: Some(block_time), - }), + status: Some(TxStatus::confirmed(spending_height, block_hash, block_time)), }) } @@ -258,26 +261,25 @@ impl Query { fn resolve_tx_outputs(&self, txid: &Txid) -> Result<(TxIndex, TxOutIndex, usize)> { let tx_index = self.resolve_tx_index(txid)?; let indexer = self.indexer(); - let first = indexer - .vecs - .transactions - .first_txout_index - .read_once(tx_index)?; - let next = indexer - .vecs - .transactions - .first_txout_index - .read_once(tx_index.incremented())?; + let first_txout_vec = &indexer.vecs.transactions.first_txout_index; + let first = first_txout_vec.read_once(tx_index)?; + let next_tx = tx_index.incremented(); + let next = if next_tx.to_usize() < first_txout_vec.len() { + first_txout_vec.read_once(next_tx)? + } else { + TxOutIndex::from(indexer.vecs.outputs.value.len()) + }; Ok((tx_index, first, usize::from(next) - usize::from(first))) } // === Helper methods === - pub fn transaction_by_index(&self, tx_index: TxIndex) -> Result { - self.transactions_by_indices(&[tx_index])? + fn transaction_by_index(&self, tx_index: TxIndex) -> Result { + Ok(self + .transactions_by_indices(&[tx_index])? .into_iter() .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> { @@ -328,7 +330,7 @@ impl Query { .transactions .first_tx_index .collect_one(height) - .ok_or(Error::NotFound("Block not found".into()))?; + .data()?; let pos = tx_index.to_usize() - first_tx.to_usize(); let txids = self.block_txids_by_height(height)?; @@ -341,12 +343,10 @@ impl Query { } fn merkle_path(txids: &[Txid], pos: usize) -> Vec { - use bitcoin::hashes::{Hash, sha256d}; - // Txid bytes are in internal order (same layout as bitcoin::Txid) let mut hashes: Vec<[u8; 32]> = txids .iter() - .map(|t| bitcoin::Txid::from(t).to_byte_array()) + .map(|t| <&bitcoin::Txid>::from(t).to_byte_array()) .collect(); let mut proof = Vec::new(); @@ -357,7 +357,7 @@ fn merkle_path(txids: &[Txid], pos: usize) -> Vec { // Display order: reverse bytes for hex output let mut display = hashes[sibling]; display.reverse(); - proof.push(bitcoin::hex::DisplayHex::to_lower_hex_string(&display)); + proof.push(display.to_lower_hex_string()); hashes = hashes .chunks(2) diff --git a/crates/brk_query/src/impl/urpd.rs b/crates/brk_query/src/impl/urpd.rs index f4afbd3f4..c49da1d80 100644 --- a/crates/brk_query/src/impl/urpd.rs +++ b/crates/brk_query/src/impl/urpd.rs @@ -14,20 +14,19 @@ impl Query { let mut cohorts: Vec = fs::read_dir(states_path)? .filter_map(|entry| { let name = entry.ok()?.file_name().into_string().ok()?; - states_path - .join(&name) - .join("urpd") - .exists() - .then(|| Cohort::from(name)) + if !states_path.join(&name).join("urpd").exists() { + return None; + } + Cohort::new(name) }) .collect(); - cohorts.sort_by_key(|a| a.to_string()); + cohorts.sort_unstable(); Ok(cohorts) } - pub(crate) fn urpd_dir(&self, cohort: &str) -> Result { + pub(crate) fn urpd_dir(&self, cohort: &Cohort) -> Result { let dir = self .computer() .distribution @@ -59,7 +58,7 @@ impl Query { .filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok()) .collect(); - dates.sort(); + dates.sort_unstable(); Ok(dates) } @@ -79,7 +78,7 @@ impl Query { /// URPD for a cohort on a specific date. pub fn urpd_at(&self, cohort: &Cohort, date: Date, agg: UrpdAggregation) -> Result { 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 .computer() .prices diff --git a/crates/brk_query/src/vecs.rs b/crates/brk_query/src/vecs.rs index 44d930a79..f90211587 100644 --- a/crates/brk_query/src/vecs.rs +++ b/crates/brk_query/src/vecs.rs @@ -4,13 +4,13 @@ use brk_computer::Computer; use brk_indexer::Indexer; use brk_traversable::{Traversable, TreeNode}; 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 quickmatch::{QuickMatch, QuickMatchConfig}; +use rustc_hash::{FxHashMap, FxHashSet}; use vecdb::{AnyExportableVec, Ro}; -#[derive(Default)] pub struct Vecs<'a> { pub series_to_index_to_vec: BTreeMap<&'a str, IndexToVec<'a>>, pub index_to_series_to_vec: BTreeMap>, @@ -18,10 +18,9 @@ pub struct Vecs<'a> { pub indexes: Vec, pub counts: SeriesCount, pub counts_by_db: BTreeMap, - catalog: Option, - matcher: Option>, + catalog: TreeNode, + matcher: QuickMatch<'a>, series_to_indexes: BTreeMap<&'a str, Vec>, - index_to_series: BTreeMap>, } impl<'a> Vecs<'a> { @@ -49,39 +48,26 @@ impl<'a> Vecs<'a> { computed_vecs: impl Iterator, computed_tree: TreeNode, ) -> Self { - let mut this = Vecs::default(); - - indexed_vecs.for_each(|vec| this.insert(vec, "indexed")); - computed_vecs.for_each(|(db, vec)| this.insert(vec, db)); - - let mut ids = this - .series_to_index_to_vec - .keys() - .copied() - .collect::>(); + let mut builder = Builder::default(); + indexed_vecs.for_each(|vec| builder.insert(vec, "indexed")); + computed_vecs.for_each(|(db, vec)| builder.insert(vec, db)); + builder.counts.distinct_series = builder.series_to_index_to_vec.len(); + let Builder { + series_to_index_to_vec, + index_to_series_to_vec, + counts, + counts_by_db, + .. + } = builder; let sort_ids = |ids: &mut Vec<&str>| { 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::>(); + sort_ids(&mut series); - this.series = ids; - 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::(); - 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 + let indexes = index_to_series_to_vec .keys() .map(|i| IndexInfo { index: *i, @@ -93,60 +79,35 @@ impl<'a> Vecs<'a> { }) .collect(); - this.series_to_indexes = this - .series_to_index_to_vec + let series_to_indexes = series_to_index_to_vec .iter() .map(|(id, index_to_vec)| (*id, index_to_vec.keys().copied().collect::>())) .collect(); - this.index_to_series = this - .index_to_series_to_vec - .iter() - .map(|(index, id_to_vec)| (*index, id_to_vec.keys().copied().collect::>())) - .collect(); - this.index_to_series.values_mut().for_each(sort_ids); - this.catalog.replace( - TreeNode::Branch( - [ - ("indexed".to_string(), indexed_tree), - ("computed".to_string(), computed_tree), - ] - .into_iter() - .collect(), - ) - .merge_branches() - .expect("indexed/computed catalog merge: same series leaf with incompatible schemas"), - ); - this.matcher = Some(QuickMatch::new(&this.series)); - this - } + let catalog = TreeNode::Branch( + [ + ("indexed".to_string(), indexed_tree), + ("computed".to_string(), computed_tree), + ] + .into_iter() + .collect(), + ) + .merge_branches() + .expect("indexed/computed catalog merge: same series leaf with incompatible schemas"); - 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 matcher = QuickMatch::new(&series); - 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); + Self { + series_to_index_to_vec, + index_to_series_to_vec, + series, + indexes, + counts, + counts_by_db, + catalog, + matcher, + series_to_indexes, + } } 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> { - self.series_to_indexes - .get(series.replace("-", "_").as_str()) + self.series_to_indexes.get(series.normalize().as_ref()) } - pub fn index_to_ids( - &self, - PaginationIndex { index, pagination }: PaginationIndex, - ) -> Option<&[&'a str]> { - let vec = self.index_to_series.get(&index)?; - - let len = vec.len(); - let start = pagination.start(len); - let end = pagination.end(len); - - Some(&vec[start..end]) + pub fn series_info(&self, series: &SeriesName) -> Option { + let index_to_vec = self.series_to_index_to_vec.get(series.normalize().as_ref())?; + 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 catalog(&self) -> &TreeNode { - self.catalog.as_ref().expect("catalog not initialized") + &self.catalog } pub fn matches(&self, series: &SeriesName, limit: Limit) -> Vec<&'_ str> { @@ -196,16 +153,13 @@ impl<'a> Vecs<'a> { return Vec::new(); } self.matcher - .as_ref() - .expect("matcher not initialized") .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> { - let series_name = series.replace("-", "_"); 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()) } } @@ -215,3 +169,48 @@ pub struct IndexToVec<'a>(BTreeMap); #[derive(Default, Deref, DerefMut)] 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>, + counts: SeriesCount, + counts_by_db: BTreeMap, + 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; + } + } +} diff --git a/crates/brk_server/src/api/addrs.rs b/crates/brk_server/src/api/addrs.rs index f4b941a65..dd3e3da3a 100644 --- a/crates/brk_server/src/api/addrs.rs +++ b/crates/brk_server/src/api/addrs.rs @@ -1,14 +1,14 @@ use aide::axum::{ApiRouter, routing::get_with}; use axum::{ - extract::{Path, Query, State}, + extract::{Path, State}, http::{HeaderMap, Uri}, }; -use brk_types::{AddrStats, AddrValidation, Transaction, Txid, Utxo, Version}; +use brk_types::{AddrStats, AddrValidation, Transaction, Utxo, Version}; use crate::{ AppState, CacheStrategy, extended::TransformResponseExtended, - params::{AddrParam, AddrTxidsParam, Empty, ValidateAddrParam}, + params::{AddrAfterTxidParam, AddrParam, Empty, ValidateAddrParam}, }; pub trait AddrRoutes { @@ -46,16 +46,16 @@ impl AddrRoutes for ApiRouter { uri: Uri, headers: HeaderMap, Path(path): Path, - Query(params): Query, + _: Empty, State(state): State | { 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 .id("get_address_txs") .addrs_tag() .summary("Address transactions") - .description("Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid= for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*") + .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::>() .not_modified() .bad_request() @@ -69,16 +69,39 @@ impl AddrRoutes for ApiRouter { uri: Uri, headers: HeaderMap, Path(path): Path, - Query(params): Query, + _: Empty, State(state): State | { 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 .id("get_address_confirmed_txs") .addrs_tag() .summary("Address confirmed transactions") - .description("Get confirmed transactions for an address, 25 per page. Use ?after_txid= for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*") + .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::>() + .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, + _: Empty, + State(state): State + | { + 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::>() .not_modified() .bad_request() @@ -95,14 +118,14 @@ impl AddrRoutes for ApiRouter { _: Empty, State(state): State | { - let hash = state.sync(|q| q.addr_mempool_hash(&path.addr)); - state.respond_json(&headers, CacheStrategy::MempoolHash(hash), &uri, move |q| q.addr_mempool_txids(path.addr)).await + 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_txs(&path.addr, 50)).await }, |op| op .id("get_address_mempool_txs") .addrs_tag() .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)*") - .json_response::>() + .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::>() .not_modified() .bad_request() .not_found() @@ -119,7 +142,7 @@ impl AddrRoutes for ApiRouter { State(state): State | { 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 .id("get_address_utxos") .addrs_tag() diff --git a/crates/brk_server/src/api/series.rs b/crates/brk_server/src/api/series.rs index a30b0d37b..1ea6d0e0c 100644 --- a/crates/brk_server/src/api/series.rs +++ b/crates/brk_server/src/api/series.rs @@ -40,7 +40,7 @@ pub(super) async fn serve( let max_weight = state.max_weight; 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 cache_params = CacheParams::series( resolved.version, diff --git a/crates/brk_server/src/api/series_legacy.rs b/crates/brk_server/src/api/series_legacy.rs index deadb7e2f..61c10416c 100644 --- a/crates/brk_server/src/api/series_legacy.rs +++ b/crates/brk_server/src/api/series_legacy.rs @@ -100,7 +100,7 @@ fn cost_basis_formatted( value: CostBasisValue, ) -> BrkResult { 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 .computer() .prices diff --git a/crates/brk_server/src/error.rs b/crates/brk_server/src/error.rs index 8b9e86412..086b8a42b 100644 --- a/crates/brk_server/src/error.rs +++ b/crates/brk_server/src/error.rs @@ -61,6 +61,7 @@ fn error_status(e: &BrkError) -> StatusCode { | BrkError::NotFound(_) | BrkError::NoData | BrkError::OutOfRange(_) + | BrkError::UnindexableDate | BrkError::SeriesNotFound(_) => StatusCode::NOT_FOUND, BrkError::AuthFailed => StatusCode::FORBIDDEN, @@ -85,6 +86,7 @@ fn error_code(e: &BrkError) -> &'static str { BrkError::UnknownTxid => "unknown_txid", BrkError::NotFound(_) => "not_found", BrkError::OutOfRange(_) => "out_of_range", + BrkError::UnindexableDate => "unindexable_date", BrkError::NoData => "no_data", BrkError::SeriesNotFound(_) => "series_not_found", BrkError::MempoolNotAvailable => "mempool_not_available", diff --git a/crates/brk_server/src/params/addr_after_txid_param.rs b/crates/brk_server/src/params/addr_after_txid_param.rs new file mode 100644 index 000000000..e59d9929d --- /dev/null +++ b/crates/brk_server/src/params/addr_after_txid_param.rs @@ -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, +} diff --git a/crates/brk_server/src/params/addr_txids_param.rs b/crates/brk_server/src/params/addr_txids_param.rs deleted file mode 100644 index 2f6457f16..000000000 --- a/crates/brk_server/src/params/addr_txids_param.rs +++ /dev/null @@ -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, -} diff --git a/crates/brk_server/src/params/mod.rs b/crates/brk_server/src/params/mod.rs index 31439c472..2a00f1e1d 100644 --- a/crates/brk_server/src/params/mod.rs +++ b/crates/brk_server/src/params/mod.rs @@ -1,5 +1,5 @@ +mod addr_after_txid_param; mod addr_param; -mod addr_txids_param; mod block_count_param; mod blockhash_param; mod blockhash_start_index; @@ -17,8 +17,8 @@ mod txids_param; mod urpd_params; mod validate_addr_param; +pub use addr_after_txid_param::*; pub use addr_param::*; -pub use addr_txids_param::*; pub use block_count_param::*; pub use blockhash_param::*; pub use blockhash_start_index::*; diff --git a/crates/brk_server/src/state.rs b/crates/brk_server/src/state.rs index 9c9ab88e8..0f545d244 100644 --- a/crates/brk_server/src/state.rs +++ b/crates/brk_server/src/state.rs @@ -75,11 +75,10 @@ impl AppState { /// - Unknown address → `Tip` pub fn addr_strategy(&self, version: Version, addr: &Addr, chain_only: bool) -> CacheStrategy { self.sync(|q| { - if !chain_only { - let mempool_hash = q.addr_mempool_hash(addr); - if mempool_hash != 0 { - return CacheStrategy::MempoolHash(mempool_hash); - } + if !chain_only + && let Some(mempool_hash) = q.addr_mempool_hash(addr) + { + return CacheStrategy::MempoolHash(mempool_hash); } q.addr_last_activity_height(addr) .and_then(|h| { diff --git a/crates/brk_types/src/addr_stats.rs b/crates/brk_types/src/addr_stats.rs index 17355a238..93c4e761e 100644 --- a/crates/brk_types/src/addr_stats.rs +++ b/crates/brk_types/src/addr_stats.rs @@ -19,5 +19,5 @@ pub struct AddrStats { pub chain_stats: AddrChainStats, /// Statistics for unconfirmed transactions in the mempool - pub mempool_stats: Option, + pub mempool_stats: AddrMempoolStats, } diff --git a/crates/brk_types/src/cohort.rs b/crates/brk_types/src/cohort.rs index db6d403c2..8b007140b 100644 --- a/crates/brk_types/src/cohort.rs +++ b/crates/brk_types/src/cohort.rs @@ -1,10 +1,14 @@ -use std::{fmt, ops::Deref}; +use std::{fmt, ops::Deref, path::Path}; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; /// 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" = [ "all", "sth", "lth", "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); -impl fmt::Display for Cohort { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) +impl Cohort { + /// Returns `Some(Cohort)` iff `s` is non-empty ASCII `[a-z0-9_]+`. + pub fn new(s: impl Into) -> Option { + 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> From for Cohort { - fn from(s: T) -> Self { - Self(s.into()) +impl fmt::Display for Cohort { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) } } @@ -34,3 +43,24 @@ impl Deref for Cohort { &self.0 } } + +impl AsRef for Cohort { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl AsRef for Cohort { + fn as_ref(&self) -> &Path { + Path::new(&self.0) + } +} + +impl<'de> Deserialize<'de> for Cohort { + fn deserialize>(d: D) -> Result { + let s = String::deserialize(d)?; + Self::new(s).ok_or_else(|| { + serde::de::Error::custom("invalid cohort: expected non-empty [a-z0-9_]+") + }) + } +} diff --git a/crates/brk_types/src/lib.rs b/crates/brk_types/src/lib.rs index d58bf3188..3d76017f5 100644 --- a/crates/brk_types/src/lib.rs +++ b/crates/brk_types/src/lib.rs @@ -95,6 +95,7 @@ mod op_return_index; mod option_ext; mod oracle_bins; mod outpoint; +mod outpoint_prefix; mod output; mod output_type; mod p2a_addr_index; @@ -115,7 +116,6 @@ mod p2wpkh_bytes; mod p2wsh_addr_index; mod p2wsh_bytes; mod pagination; -mod pagination_index; mod percentile; mod pool; mod pool_detail; @@ -287,6 +287,7 @@ pub use op_return_index::*; pub use option_ext::*; pub use oracle_bins::*; pub use outpoint::*; +pub use outpoint_prefix::*; pub use output::*; pub use output_type::*; pub use p2a_addr_index::*; @@ -307,7 +308,6 @@ pub use p2wpkh_bytes::*; pub use p2wsh_addr_index::*; pub use p2wsh_bytes::*; pub use pagination::*; -pub use pagination_index::*; pub use percentile::*; pub use pool::*; pub use pool_detail::*; diff --git a/crates/brk_types/src/outpoint_prefix.rs b/crates/brk_types/src/outpoint_prefix.rs new file mode 100644 index 000000000..952ebd137 --- /dev/null +++ b/crates/brk_types/src/outpoint_prefix.rs @@ -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) + } +} diff --git a/crates/brk_types/src/pagination_index.rs b/crates/brk_types/src/pagination_index.rs deleted file mode 100644 index e3e67a1bd..000000000 --- a/crates/brk_types/src/pagination_index.rs +++ /dev/null @@ -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, -} diff --git a/crates/brk_types/src/pool_slug.rs b/crates/brk_types/src/pool_slug.rs index e6949fc2e..c7da04314 100644 --- a/crates/brk_types/src/pool_slug.rs +++ b/crates/brk_types/src/pool_slug.rs @@ -30,6 +30,7 @@ pub enum PoolSlug { UltimusPool, TerraPool, Luxor, + #[serde(rename = "1thash")] OneThash, BtcCom, Bitfarms, @@ -38,6 +39,7 @@ pub enum PoolSlug { CanoePool, BtcTop, BitcoinCom, + #[serde(rename = "175btc")] Pool175btc, GbMiners, AXbt, @@ -53,6 +55,7 @@ pub enum PoolSlug { MaxBtc, TripleMining, CoinLab, + #[serde(rename = "50btc")] Pool50btc, GhashIo, StMiningCorp, @@ -84,8 +87,10 @@ pub enum PoolSlug { ExxBw, Bitsolo, BitFury, + #[serde(rename = "21inc")] TwentyOneInc, DigitalBtc, + #[serde(rename = "8baochi")] EightBaochi, MyBtcCoinPool, TbDice, @@ -95,6 +100,7 @@ pub enum PoolSlug { HotPool, OkExPool, BcMonster, + #[serde(rename = "1hash")] OneHash, Bixin, TatmasPool, @@ -105,12 +111,14 @@ pub enum PoolSlug { DcExploration, Dcex, BtPool, + #[serde(rename = "58coin")] FiftyEightCoin, BitcoinIndia, ShawnP0wers, PHashIo, RigPool, HaoZhuZhu, + #[serde(rename = "7pool")] SevenPool, MiningKings, HashBx, @@ -164,6 +172,7 @@ pub enum PoolSlug { EkanemBtc, Canoe, Tiger, + #[serde(rename = "1m1x")] OneM1x, Zulupool, SecPool, @@ -200,6 +209,7 @@ pub enum PoolSlug { RedRockPool, Est3lar, BraiinsSolo, + #[serde(rename = "solopoolcom")] SoloPool, Noderunners, #[serde(skip)] diff --git a/crates/brk_types/src/series_count.rs b/crates/brk_types/src/series_count.rs index 30d9a3443..48a931ade 100644 --- a/crates/brk_types/src/series_count.rs +++ b/crates/brk_types/src/series_count.rs @@ -1,6 +1,5 @@ use std::collections::BTreeMap; -use rustc_hash::FxHashSet; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -19,22 +18,6 @@ pub struct SeriesCount { /// Number of eager (stored on disk) series-index combinations #[schemars(example = 16000)] pub stored_endpoints: usize, - #[serde(skip)] - seen: FxHashSet, -} - -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 diff --git a/crates/brk_types/src/series_list.rs b/crates/brk_types/src/series_list.rs index b353e2803..cd912e11b 100644 --- a/crates/brk_types/src/series_list.rs +++ b/crates/brk_types/src/series_list.rs @@ -30,19 +30,14 @@ impl From for SeriesList { impl From for SeriesList { #[inline] fn from(value: String) -> Self { - Self::from(SeriesName::from(value.replace("-", "_").to_lowercase())) + Self::from(SeriesName::from(value)) } } impl<'a> From> for SeriesList { #[inline] fn from(value: Vec<&'a str>) -> Self { - Self( - value - .iter() - .map(|s| SeriesName::from(s.replace("-", "_").to_lowercase())) - .collect::>(), - ) + Self(value.into_iter().map(SeriesName::from).collect()) } } diff --git a/crates/brk_types/src/series_name.rs b/crates/brk_types/src/series_name.rs index 17ccd7d5b..8cd6b65e5 100644 --- a/crates/brk_types/src/series_name.rs +++ b/crates/brk_types/src/series_name.rs @@ -1,4 +1,4 @@ -use std::fmt::Display; +use std::{borrow::Cow, fmt::Display}; use derive_more::Deref; use schemars::JsonSchema; @@ -15,6 +15,17 @@ use serde::{Deserialize, Serialize}; )] 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 for SeriesName { #[inline] fn from(series: String) -> Self { diff --git a/crates/brk_types/src/tx_status.rs b/crates/brk_types/src/tx_status.rs index 7cd7e02e2..9cd3a936c 100644 --- a/crates/brk_types/src/tx_status.rs +++ b/crates/brk_types/src/tx_status.rs @@ -30,4 +30,13 @@ impl TxStatus { block_height: 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), + } + } } diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index df19d3289..7ef309751 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -8,6 +8,13 @@ * * @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) * @@ -47,11 +54,7 @@ * @property {Addr} address - Bitcoin address string * @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 {(AddrMempoolStats|null)=} mempoolStats - Statistics for unconfirmed transactions in the mempool - */ -/** - * @typedef {Object} AddrTxidsParam - * @property {(Txid|null)=} afterTxid - Txid to paginate from (return transactions before this one) + * @property {AddrMempoolStats} mempoolStats - Statistics for unconfirmed transactions in the mempool */ /** * Address validation result @@ -327,6 +330,10 @@ Matches mempool.space/bitcoin-cli behavior. /** * 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 */ /** @@ -796,7 +803,7 @@ Matches mempool.space/bitcoin-cli behavior. /** * 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 @@ -1871,6 +1878,67 @@ class BrkClientBase { 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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) * @template T @@ -7413,7 +7481,7 @@ class BrkClient extends BrkClientBase { "ultimuspool": "ULTIMUSPOOL", "terrapool": "Terra Pool", "luxor": "Luxor", - "onethash": "1THash", + "1thash": "1THash", "btccom": "BTC.com", "bitfarms": "Bitfarms", "huobipool": "Huobi.pool", @@ -7421,7 +7489,7 @@ class BrkClient extends BrkClientBase { "canoepool": "CanoePool", "btctop": "BTC.TOP", "bitcoincom": "Bitcoin.com", - "pool175btc": "175btc", + "175btc": "175btc", "gbminers": "GBMiners", "axbt": "A-XBT", "asicminer": "ASICMiner", @@ -7436,7 +7504,7 @@ class BrkClient extends BrkClientBase { "maxbtc": "MaxBTC", "triplemining": "TripleMining", "coinlab": "CoinLab", - "pool50btc": "50BTC", + "50btc": "50BTC", "ghashio": "GHash.IO", "stminingcorp": "ST Mining Corp", "bitparking": "Bitparking", @@ -7467,9 +7535,9 @@ class BrkClient extends BrkClientBase { "exxbw": "EXX&BW", "bitsolo": "Bitsolo", "bitfury": "BitFury", - "twentyoneinc": "21 Inc.", + "21inc": "21 Inc.", "digitalbtc": "digitalBTC", - "eightbaochi": "8baochi", + "8baochi": "8baochi", "mybtccoinpool": "myBTCcoin Pool", "tbdice": "TBDice", "hashpool": "HASHPOOL", @@ -7478,7 +7546,7 @@ class BrkClient extends BrkClientBase { "hotpool": "HotPool", "okexpool": "OKExPool", "bcmonster": "BCMonster", - "onehash": "1Hash", + "1hash": "1Hash", "bixin": "Bixin", "tatmaspool": "TATMAS Pool", "viabtc": "ViaBTC", @@ -7488,13 +7556,13 @@ class BrkClient extends BrkClientBase { "dcexploration": "DCExploration", "dcex": "DCEX", "btpool": "BTPOOL", - "fiftyeightcoin": "58COIN", + "58coin": "58COIN", "bitcoinindia": "Bitcoin India", "shawnp0wers": "shawnp0wers", "phashio": "PHash.IO", "rigpool": "RigPool", "haozhuzhu": "HAOZHUZHU", - "sevenpool": "7pool", + "7pool": "7pool", "miningkings": "MiningKings", "hashbx": "HashBX", "dpool": "DPOOL", @@ -7547,7 +7615,7 @@ class BrkClient extends BrkClientBase { "ekanembtc": "EkanemBTC", "canoe": "CANOE", "tiger": "tiger", - "onem1x": "1M1X", + "1m1x": "1M1X", "zulupool": "Zulupool", "secpool": "SECPOOL", "ocean": "OCEAN", @@ -7571,7 +7639,7 @@ class BrkClient extends BrkClientBase { "redrockpool": "RedRock Pool", "est3lar": "Est3lar", "braiinssolo": "Braiins Solo", - "solopool": "SoloPool.com", + "solopoolcom": "SoloPool.com", "noderunners": "Noderunners" }); @@ -10374,59 +10442,70 @@ class BrkClient extends BrkClientBase { /** * 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= 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)* * * Endpoint: `GET /api/address/{address}/txs` * * @param {Addr} address - * @param {Txid=} [after_txid] - Txid to paginate from (return transactions before this one) * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] * @returns {Promise} */ - async getAddressTxs(address, after_txid, { signal, onValue } = {}) { - const params = new URLSearchParams(); - if (after_txid !== undefined) params.set('after_txid', String(after_txid)); - const query = params.toString(); - const path = `/api/address/${address}/txs${query ? '?' + query : ''}`; + async getAddressTxs(address, { signal, onValue } = {}) { + const path = `/api/address/${address}/txs`; return this.getJson(path, { signal, onValue }); } /** * Address confirmed transactions * - * Get confirmed transactions for an address, 25 per page. Use ?after_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)* * * Endpoint: `GET /api/address/{address}/txs/chain` * * @param {Addr} address - * @param {Txid=} [after_txid] - Txid to paginate from (return transactions before this one) * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] * @returns {Promise} */ - async getAddressConfirmedTxs(address, after_txid, { signal, onValue } = {}) { - const params = new URLSearchParams(); - if (after_txid !== undefined) params.set('after_txid', String(after_txid)); - const query = params.toString(); - const path = `/api/address/${address}/txs/chain${query ? '?' + query : ''}`; + async getAddressConfirmedTxs(address, { signal, onValue } = {}) { + const path = `/api/address/${address}/txs/chain`; + return this.getJson(path, { signal, onValue }); + } + + /** + * 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} + */ + async getAddressConfirmedTxsAfter(address, after_txid, { signal, onValue } = {}) { + const path = `/api/address/${address}/txs/chain/${after_txid}`; return this.getJson(path, { signal, onValue }); } /** * 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)* * * Endpoint: `GET /api/address/{address}/txs/mempool` * * @param {Addr} address - * @param {{ signal?: AbortSignal, onValue?: (value: Txid[]) => void }} [options] - * @returns {Promise} + * @param {{ signal?: AbortSignal, onValue?: (value: Transaction[]) => void }} [options] + * @returns {Promise} */ async getAddressMempoolTxs(address, { signal, onValue } = {}) { const path = `/api/address/${address}/txs/mempool`; @@ -11008,6 +11087,24 @@ class BrkClient extends BrkClientBase { 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} + */ + async postTx(body, { signal } = {}) { + const path = `/api/tx`; + return this.postJson(path, body, { signal }); + } + /** * Txid by index * diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index 475941448..5f3640521 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -19,6 +19,8 @@ T = TypeVar('T') # Bitcoin address string Addr = str +# Transaction ID (hash) +Txid = str # US Dollar amount Dollars = float # Amount in satoshis (1 BTC = 100,000,000 sats) @@ -27,8 +29,6 @@ Sats = int TypeIndex = int # Type (P2PKH, P2WPKH, P2SH, P2TR, etc.) 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) AnyAddrIndex = TypeIndex # Unsigned basis points stored as u16. @@ -54,7 +54,7 @@ BasisPointsSigned32 = int # Bitcoin amount as floating point (1 BTC = 100,000,000 satoshis) Bitcoin = float # 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 FeeRate = float # 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 Close = Dollars # 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"] # Coinbase scriptSig tag for pool identification. # @@ -230,6 +234,16 @@ UnknownOutputIndex = TypeIndex Week1 = int Year1 = 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): """ Address statistics on the blockchain (confirmed transactions only) @@ -291,14 +305,7 @@ class AddrStats(TypedDict): address: Addr addr_type: OutputType chain_stats: AddrChainStats - mempool_stats: Union[AddrMempoolStats, None] - -class AddrTxidsParam(TypedDict): - """ - Attributes: - after_txid: Txid to paginate from (return transactions before this one) - """ - after_txid: Union[Txid, None] + mempool_stats: AddrMempoolStats class AddrValidation(TypedDict): """ @@ -1723,6 +1730,28 @@ class BrkClientBase: """Make a GET request and return text.""" 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: """Close the HTTP client.""" if self._conn: @@ -7761,38 +7790,40 @@ class BrkClient(BrkClientBase): Endpoint: `GET /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. - Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid= for pagination. + 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)* Endpoint: `GET /api/address/{address}/txs`""" - params = [] - 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) + return self.get_json(f'/api/address/{address}/txs') - 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. - Get confirmed transactions for an address, 25 per page. Use ?after_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)* Endpoint: `GET /api/address/{address}/txs/chain`""" - params = [] - 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) + return self.get_json(f'/api/address/{address}/txs/chain') - 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. - 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)* @@ -8128,6 +8159,16 @@ class BrkClient(BrkClientBase): Endpoint: `GET /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: """Txid by index. diff --git a/packages/brk_client/tests/mempool_compat/addresses/test_address_info.py b/packages/brk_client/tests/mempool_compat/addresses/test_address_info.py index 85899de81..48bc6f249 100644 --- a/packages/brk_client/tests/mempool_compat/addresses/test_address_info.py +++ b/packages/brk_client/tests/mempool_compat/addresses/test_address_info.py @@ -2,47 +2,130 @@ import pytest +from brk_client import BrkError + from _lib import assert_same_structure, show -@pytest.fixture(params=[ - "12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", # P2PKH — early block reward - "3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", # P2SH -], ids=["p2pkh", "p2sh"]) -def static_addr(request): - """Well-known addresses that always exist.""" - return request.param +KNOWN_ADDR_TYPES = { + "p2pk", "p2pkh", "p2sh", "v0_p2wpkh", "v0_p2wsh", "v1_p2tr", + "multisig", "op_return", "p2a", "empty", "unknown", +} + +# Static fixtures: stable addresses with known shapes. +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): - """Address stats structure must match for well-known addresses.""" - path = f"/api/address/{static_addr}" - b = brk.get_json(path) +def _tx_count_tolerance(m_tx_count: int) -> int: + """Allow drift between brk's distinct-tx and mempool's output-count semantics.""" + import math + 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) show("GET", path, 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): - """Address stats structure must match for each discovered type.""" +def test_address_info_shape_dynamic(brk, mempool, live_addrs): + """Same shape contract over each live-discovered scriptpubkey type.""" + assert live_addrs, "no live addresses discovered" for atype, addr in live_addrs: path = f"/api/address/{addr}" - b = brk.get_json(path) + b = brk.get_address(addr) m = mempool.get_json(path) show("GET", f"{path} [{atype}]", 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): - """Chain stats values must be close for each discovered address.""" +@pytest.mark.parametrize("addr", STATIC_ADDRS) +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: 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"] show("GET", f"{path} [chain_stats, {atype}]", b, m) - assert_same_structure(b, m) - assert abs(b["tx_count"] - m["tx_count"]) <= 5, ( - f"{atype} tx_count: brk={b['tx_count']} vs mempool={m['tx_count']}" + for key in ("funded_txo_count", "funded_txo_sum", "spent_txo_count", "spent_txo_sum"): + assert b[key] == m[key], ( + 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']}" + ) diff --git a/packages/brk_client/tests/mempool_compat/addresses/test_address_txs.py b/packages/brk_client/tests/mempool_compat/addresses/test_address_txs.py index f3d4f534a..e3a4f5451 100644 --- a/packages/brk_client/tests/mempool_compat/addresses/test_address_txs.py +++ b/packages/brk_client/tests/mempool_compat/addresses/test_address_txs.py @@ -2,33 +2,44 @@ import pytest +from brk_client import BrkError + from _lib import assert_same_structure, show -@pytest.fixture(params=[ - "12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", - "3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", -], ids=["p2pkh", "p2sh"]) -def static_addr(request): - return request.param +# Heavy address (recently active) — stresses the 50-cap path; cannot be ordered +# exactly against mempool.space because the two indexers drift at the chain tip. +ACTIVE_ADDR = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + +# Inactive historical addresses — both indexers agree exactly on first-page +# 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): - """Confirmed+mempool tx list structure must match for well-known addresses.""" - path = f"/api/address/{static_addr}/txs" - b = brk.get_json(path) +@pytest.mark.parametrize("addr", STATIC_ADDRS) +def test_address_txs_shape(brk, mempool, addr): + """Typed list response must structurally match mempool; brk's `index` extra is allowed.""" + path = f"/api/address/{addr}/txs" + b = brk.get_address_txs(addr) m = mempool.get_json(path) show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)") assert isinstance(b, list) and isinstance(m, list) if b and m: 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): - """Confirmed+mempool tx list structure must match for each discovered type.""" +def test_address_txs_shape_dynamic(brk, mempool, live_addrs): + """Same shape contract over each live-discovered scriptpubkey type.""" + assert live_addrs, "no live addresses discovered" for atype, addr in live_addrs: path = f"/api/address/{addr}/txs" - b = brk.get_json(path) + b = brk.get_address_txs(addr) m = mempool.get_json(path) show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)") 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]) -def test_address_txs_fields(brk, mempool, live): - """Every tx in the list must carry the core mempool.space fields.""" - path = f"/api/address/{live.sample_address}/txs" - b = brk.get_json(path) - show("GET", path, f"({len(b)} txs)", "—") +@pytest.mark.parametrize("addr", STATIC_ADDRS) +def test_address_txs_ordering(brk, addr): + """All entries must be confirmed and heights monotonically non-increasing.""" + b = brk.get_address_txs(addr) if not b: - pytest.skip("address has no txs in brk") - required = {"txid", "version", "locktime", "vin", "vout", "size", "weight", "fee", "status"} - for tx in b[:5]: - missing = required - set(tx.keys()) - assert not missing, f"tx {tx.get('txid', '?')} missing fields: {missing}" + pytest.skip(f"{addr} has no txs in brk") + for tx in b: + assert tx["status"]["confirmed"] is True, ( + f"{addr} returned unconfirmed tx {tx['txid']} (this endpoint is chain-only on brk)" + ) + 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/addresses/test_address_txs_chain.py b/packages/brk_client/tests/mempool_compat/addresses/test_address_txs_chain.py index 2c3d01e27..699df0da8 100644 --- a/packages/brk_client/tests/mempool_compat/addresses/test_address_txs_chain.py +++ b/packages/brk_client/tests/mempool_compat/addresses/test_address_txs_chain.py @@ -1,34 +1,43 @@ -"""GET /api/address/{address}/txs/chain""" +"""GET /api/address/{address}/txs/chain (and /txs/chain/{after_txid})""" import pytest +from brk_client import BrkError + from _lib import assert_same_structure, show -@pytest.fixture(params=[ - "12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", - "3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", -], ids=["p2pkh", "p2sh"]) -def static_addr(request): - return request.param +# Heavy active address (chain-tip drift expected, no exact-order assertion) +ACTIVE_ADDR = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + +# Inactive historical addresses — both indexers agree exactly on first-page ordering +STABLE_ADDRS = [ + "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): - """Confirmed-only tx list structure must match for well-known addresses.""" - path = f"/api/address/{static_addr}/txs/chain" - b = brk.get_json(path) +@pytest.mark.parametrize("addr", STATIC_ADDRS) +def test_address_txs_chain_shape(brk, mempool, addr): + """Typed list response must structurally match mempool; brk's `index` extra is allowed.""" + path = f"/api/address/{addr}/txs/chain" + b = brk.get_address_confirmed_txs(addr) m = mempool.get_json(path) show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)") assert isinstance(b, list) and isinstance(m, list) if b and m: 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): - """Confirmed-only tx list structure must match for each discovered type.""" +def test_address_txs_chain_shape_dynamic(brk, mempool, live_addrs): + """Same shape contract over each live-discovered scriptpubkey type.""" + assert live_addrs, "no live addresses discovered" for atype, addr in live_addrs: path = f"/api/address/{addr}/txs/chain" - b = brk.get_json(path) + b = brk.get_address_confirmed_txs(addr) m = mempool.get_json(path) show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)") 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]) -def test_address_txs_chain_all_confirmed(brk, live): - """Every tx returned by /txs/chain must have confirmed=True in its status.""" - path = f"/api/address/{live.sample_address}/txs/chain" - b = brk.get_json(path) - show("GET", path, f"({len(b)} txs)", "—") +@pytest.mark.parametrize("addr", STATIC_ADDRS) +def test_address_txs_chain_all_confirmed(brk, addr): + """Every entry must have `status.confirmed == True`.""" + b = brk.get_address_confirmed_txs(addr) if not b: - pytest.skip("address has no confirmed txs in brk") - unconfirmed = [t for t in b if not t.get("status", {}).get("confirmed", False)] + pytest.skip(f"{addr} has no confirmed txs in brk") + unconfirmed = [t for t in b if not t["status"]["confirmed"]] 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}" ) diff --git a/packages/brk_client/tests/mempool_compat/addresses/test_address_txs_mempool.py b/packages/brk_client/tests/mempool_compat/addresses/test_address_txs_mempool.py index 5cdf60167..bf6b70399 100644 --- a/packages/brk_client/tests/mempool_compat/addresses/test_address_txs_mempool.py +++ b/packages/brk_client/tests/mempool_compat/addresses/test_address_txs_mempool.py @@ -1,33 +1,55 @@ """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): - """Mempool tx list must be an array (contents are volatile).""" - path = f"/api/address/{live.sample_address}/txs/mempool" - 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.""" +def test_address_txs_mempool_shape_dynamic(brk, mempool, live_addrs): + """Shape contract over each live-discovered scriptpubkey type.""" + assert live_addrs, "no live addresses discovered" for atype, addr in live_addrs: path = f"/api/address/{addr}/txs/mempool" - b = brk.get_json(path) + b = brk.get_address_mempool_txs(addr) m = mempool.get_json(path) show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)") 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): - """Every tx returned by /txs/mempool must have confirmed=False (if any).""" - path = f"/api/address/{live.sample_address}/txs/mempool" - b = brk.get_json(path) - show("GET", path, f"({len(b)} txs)", "—") - confirmed = [t for t in b if t.get("status", {}).get("confirmed", False)] - assert not confirmed, ( - f"{len(confirmed)} confirmed tx(s) returned by /txs/mempool" +def test_address_txs_mempool_limit(brk, live_addrs): + """Hard cap of 50 mempool txs per call.""" + for _atype, addr in live_addrs: + b = brk.get_address_mempool_txs(addr) + assert len(b) <= 50, f"{addr} returned {len(b)} txs, exceeds 50-cap" + + +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, ( + 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}" ) diff --git a/packages/brk_client/tests/mempool_compat/addresses/test_address_utxo.py b/packages/brk_client/tests/mempool_compat/addresses/test_address_utxo.py index 514b70266..5a91b9c75 100644 --- a/packages/brk_client/tests/mempool_compat/addresses/test_address_utxo.py +++ b/packages/brk_client/tests/mempool_compat/addresses/test_address_utxo.py @@ -2,51 +2,70 @@ import pytest +from brk_client import BrkError + from _lib import assert_same_values, show -@pytest.fixture(params=[ - "12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", - "3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", -], ids=["p2pkh", "p2sh"]) -def static_addr(request): - return request.param +# Inactive historical addresses with stable, comparable UTXO sets. +STABLE_ADDRS = [ + ("p2pkh", "12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S"), + ("p2sh", "3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r"), +] + +# 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): - """UTXO list must match — same txids, values, and statuses.""" - path = f"/api/address/{static_addr}/utxo" - b = brk.get_json(path) +@pytest.mark.parametrize("atype,addr", STABLE_ADDRS, ids=[a for a, _ in STABLE_ADDRS]) +def test_address_utxo_static(brk, mempool, atype, addr): + """Exact UTXO parity (txid+vout+value+status) for stable historical addresses.""" + path = f"/api/address/{addr}/utxo" + b = brk.get_address_utxos(addr) 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) + 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)) def test_address_utxo_discovered(brk, mempool, live_addrs): - """UTXO list must match for each discovered address type — same txids, values, and statuses.""" + """Same exact-parity contract over each live-discovered scriptpubkey type.""" for atype, addr in live_addrs: path = f"/api/address/{addr}/utxo" - b = brk.get_json(path) + b = brk.get_address_utxos(addr) m = mempool.get_json(path) 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.get("txid", ""), u.get("vout", 0)) + key = lambda u: (u["txid"], u["vout"]) assert_same_values(sorted(b, key=key), sorted(m, key=key)) -def test_address_utxo_fields(brk, live): - """Every utxo must carry the core mempool.space fields.""" - path = f"/api/address/{live.sample_address}/utxo" - b = brk.get_json(path) - show("GET", path, f"({len(b)} utxos)", "—") +@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: - pytest.skip("address has no utxos in brk") - required = {"txid", "vout", "value", "status"} - for u in b[:5]: - missing = required - set(u.keys()) - assert not missing, f"utxo {u.get('txid', '?')}:{u.get('vout', '?')} missing fields: {missing}" - assert isinstance(u["value"], int) and u["value"] > 0 + pytest.skip(f"{addr} has no utxos in brk") + unconfirmed = [u for u in b if not u["status"]["confirmed"]] + assert not unconfirmed, ( + f"{addr}: {len(unconfirmed)} unconfirmed UTXO(s) returned: " + f"{[(u['txid'], u['vout']) for u in unconfirmed[:3]]}" + ) + + +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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/addresses/test_validate_address.py b/packages/brk_client/tests/mempool_compat/addresses/test_validate_address.py index 71c44de10..0150e1395 100644 --- a/packages/brk_client/tests/mempool_compat/addresses/test_validate_address.py +++ b/packages/brk_client/tests/mempool_compat/addresses/test_validate_address.py @@ -5,49 +5,79 @@ import pytest from _lib import assert_same_structure, assert_same_values, show -def test_validate_address_discovered(brk, mempool, live_addrs): - """Validation of each discovered address type must match exactly.""" - for atype, addr in live_addrs: - path = f"/api/v1/validate-address/{addr}" - b = brk.get_json(path) - m = mempool.get_json(path) - show("GET", f"{path} [{atype}]", b, m) - assert_same_values(b, m) - assert b["isvalid"] is True +VALID_ADDRS = [ + ("p2pkh-genesis", "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"), + ("p2sh", "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"), + ("p2wpkh", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), + ("p2wsh", "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3"), + ("p2tr", "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"), +] + +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", [ - ("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "p2pkh-genesis"), - ("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", "p2sh"), - ("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", "p2wpkh"), - ("bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0", "p2tr"), -]) -def test_validate_address_static_valid(brk, mempool, addr, kind): +@pytest.mark.parametrize("kind,addr", VALID_ADDRS, ids=[k for k, _ in VALID_ADDRS]) +def test_validate_address_static_valid(brk, mempool, kind, addr): """Well-known addresses across all script types must validate identically.""" path = f"/api/v1/validate-address/{addr}" - b = brk.get_json(path) + b = brk.validate_address(addr) m = mempool.get_json(path) show("GET", f"{path} [{kind}]", b, m) - assert_same_values(b, m) assert b["isvalid"] is True + assert_same_values(b, m) -@pytest.mark.parametrize("addr,kind", [ - ("notanaddress123", "garbage"), - ("", "empty"), - ("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.""" +def test_validate_address_discovered(brk, mempool, live_addrs): + """Validation of each live-discovered scriptpubkey type must match exactly.""" + for atype, addr in live_addrs: + path = f"/api/v1/validate-address/{addr}" + b = brk.validate_address(addr) + m = mempool.get_json(path) + show("GET", f"{path} [{atype}]", b, m) + 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}" - if kind == "empty": - # An empty path segment routes to a different endpoint — skip. - pytest.skip("empty address routes to a different endpoint") - b = brk.get_json(path) + b = brk.validate_address(addr) m = mempool.get_json(path) show("GET", f"{path} [{kind}]", b, m) assert b["isvalid"] is False assert m["isvalid"] is False 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" {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" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_block.py b/packages/brk_client/tests/mempool_compat/blocks/test_block.py index 2a9bd32b4..c478025aa 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_block.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_block.py @@ -1,12 +1,52 @@ """GET /api/block/{hash}""" +import pytest + +from brk_client import BrkError + from _lib import assert_same_values, show 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}" - b = brk.get_json(path) + b = brk.get_block(block.hash) m = mempool.get_json(path) show("GET", path, 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_block_header.py b/packages/brk_client/tests/mempool_compat/blocks/test_block_header.py index 2ba20c5c0..6a3511334 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_block_header.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_block_header.py @@ -1,13 +1,52 @@ """GET /api/block/{hash}/header""" +import re + +import pytest + +from brk_client import BrkError + from _lib import show +HEX_RE = re.compile(r"^[0-9a-f]{160}$") + + 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" - b = brk.get_text(path) + b = brk.get_block_header(block.hash) m = mempool.get_text(path) 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 + + +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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_block_height.py b/packages/brk_client/tests/mempool_compat/blocks/test_block_height.py index 042e3af91..3978ff217 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_block_height.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_block_height.py @@ -1,13 +1,62 @@ """GET /api/block-height/{height}""" +import re + +import pytest + +from brk_client import BrkError + from _lib import show -def test_block_height_to_hash(brk, mempool, block): - """Block hash at a given height must match.""" +HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$") + + +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}" - 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) show("GET", path, 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_block_raw.py b/packages/brk_client/tests/mempool_compat/blocks/test_block_raw.py index 2be8d47d0..8e098947d 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_block_raw.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_block_raw.py @@ -1,13 +1,49 @@ """GET /api/block/{hash}/raw""" +import pytest + +from brk_client import BrkError + from _lib import show 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" - b = brk.get_bytes(path) + b = brk.get_block_raw(block.hash) m = mempool.get_bytes(path) show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>") 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_block_status.py b/packages/brk_client/tests/mempool_compat/blocks/test_block_status.py index d1c96991c..f78d4f3ce 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_block_status.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_block_status.py @@ -1,12 +1,61 @@ """GET /api/block/{hash}/status""" +import pytest + +from brk_client import BrkError + from _lib import assert_same_values, show 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" - b = brk.get_json(path) + b = brk.get_block_status(block.hash) m = mempool.get_json(path) show("GET", path, 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_block_txid_index.py b/packages/brk_client/tests/mempool_compat/blocks/test_block_txid_index.py index c6b92bfc0..035b4b196 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_block_txid_index.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_block_txid_index.py @@ -2,36 +2,70 @@ import pytest +from brk_client import BrkError + from _lib import show -def test_block_txid_at_index_0(brk, mempool, block): - """Txid at position 0 (coinbase) must match.""" +def test_block_txid_coinbase(brk, mempool, block): + """Position 0 is the coinbase txid; must match mempool.space byte-for-byte.""" 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) show("GET", path, b, m) assert b == m -def test_block_txid_at_index_1(brk, mempool, block): - """Txid at position 1 (first non-coinbase) must match.""" +def test_block_txid_positions(brk, mempool, block): + """First, middle, and last positions in the block must all match.""" txids = mempool.get_json(f"/api/block/{block.hash}/txids") - if len(txids) <= 1: - pytest.skip("block has only coinbase") - path = f"/api/block/{block.hash}/txid/1" - b = brk.get_text(path) + n = len(txids) + indices = sorted({0, 1, n // 2, n - 1}) + indices = [i for i in indices if 0 <= i < n] + 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) show("GET", path, b, m) assert b == m + assert b == "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b" -def test_block_txid_at_last_index(brk, mempool, block): - """Txid at last position must match.""" +def test_block_txid_out_of_range(brk, mempool, block): + """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") - last = len(txids) - 1 - path = f"/api/block/{block.hash}/txid/{last}" - b = brk.get_text(path) - m = mempool.get_text(path) - show("GET", path, b, m) - assert b == m + bad_index = len(txids) + 1000 + with pytest.raises(BrkError) as exc_info: + brk.get_block_txid(block.hash, bad_index) + assert exc_info.value.status == 404, ( + f"expected status=404 for out-of-range index, got {exc_info.value.status}" + ) + + +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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_block_txids.py b/packages/brk_client/tests/mempool_compat/blocks/test_block_txids.py index e8c3adc01..ad721a51f 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_block_txids.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_block_txids.py @@ -1,12 +1,55 @@ """GET /api/block/{hash}/txids""" +import re + +import pytest + +from brk_client import BrkError + from _lib import show +HEX_TXID_RE = re.compile(r"^[0-9a-f]{64}$") + + 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" - b = brk.get_json(path) + b = brk.get_block_txids(block.hash) m = mempool.get_json(path) show("GET", path, b[:3], m[:3]) 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_block_txs.py b/packages/brk_client/tests/mempool_compat/blocks/test_block_txs.py index 84df51676..6ad3ad043 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_block_txs.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_block_txs.py @@ -1,14 +1,73 @@ """GET /api/block/{hash}/txs""" +import pytest + +from brk_client import BrkError + from _lib import assert_same_values, show -def test_block_txs_page0(brk, mempool, block): - """First page of block transactions must match.""" +# brk and mempool's sigop counting diverges (different rules for redeemscript/witness). +# 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" - b = brk.get_json(path) + b = brk.get_block_txs(block.hash) 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)}" - if b and m: - assert_same_values(b[0], m[0], exclude={"sigops"}) + assert_same_values(b, m, exclude=SIGOPS_DIFF) + + +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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_block_txs_start.py b/packages/brk_client/tests/mempool_compat/blocks/test_block_txs_start.py index cce10f587..5bc35222e 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_block_txs_start.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_block_txs_start.py @@ -2,67 +2,97 @@ 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): - """Paginated txs from index 25 must match (skip small blocks).""" - txids = mempool.get_json(f"/api/block/{block.hash}/txids") - if len(txids) <= 25: - pytest.skip(f"block has only {len(txids)} txs") - path = f"/api/block/{block.hash}/txs/25" - b = brk.get_json(path) - m = mempool.get_json(path) - show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)") - assert len(b) == len(m) - if b and m: - assert_same_structure(b[0], m[0]) +SIGOPS_DIFF = {"sigops"} +PAGE_SIZE = 25 -def test_block_txs_start_index_zero(brk, mempool, block): - """`/txs/0` must mirror `/txs` (the default page) in length and structure.""" - path0 = f"/api/block/{block.hash}/txs/0" - pathx = f"/api/block/{block.hash}/txs" - b0 = brk.get_json(path0) - bx = brk.get_json(pathx) - show("GET", path0, f"({len(b0)} txs)", f"vs /txs ({len(bx)} txs)") - assert len(b0) == len(bx) - if b0 and bx: - assert b0[0]["txid"] == bx[0]["txid"] +def test_block_txs_start_default(brk, block): + """/txs/0 must equal /txs (the default page).""" + 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_pagination(brk, mempool, block): - """Pages at 0, 25, 50 must each be aligned slices of the full txid list.""" - txids = mempool.get_json(f"/api/block/{block.hash}/txids") - 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 - path = f"/api/block/{block.hash}/txs/{past}" - b_resp = brk.get_raw(path) - m_resp = mempool.get_raw(path) - show("GET", path, f"brk={b_resp.status_code}", f"mempool={m_resp.status_code}") - 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_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) + show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)", max_lines=4) + assert_same_values(b, m, exclude=SIGOPS_DIFF) + + +def test_block_txs_start_genesis(brk, mempool): + """Genesis: /txs/0 returns the 1 coinbase tx; /txs/1 must 404.""" + genesis_hash = mempool.get_text("/api/block-height/0") + page0 = brk.get_block_txs_from_index(genesis_hash, 0) + assert len(page0) == 1 + assert page0[0]["txid"] == "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b" + with pytest.raises(BrkError) as exc_info: + brk.get_block_txs_from_index(genesis_hash, 1) + assert exc_info.value.status == 404, ( + f"expected status=404 for past-end on genesis, got {exc_info.value.status}" + ) + + +def test_block_txs_start_past_end(brk, block): + """Start past the last tx must produce BrkError(status=404).""" + txids = brk.get_block_txids(block.hash) + past = len(txids) + 1000 + with pytest.raises(BrkError) as exc_info: + brk.get_block_txs_from_index(block.hash, past) + assert exc_info.value.status == 404, ( + f"expected status=404 for past-end, got {exc_info.value.status}" + ) + + +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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_block_v1.py b/packages/brk_client/tests/mempool_compat/blocks/test_block_v1.py index 8a7140537..737f81327 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_block_v1.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_block_v1.py @@ -1,37 +1,80 @@ """GET /api/v1/block/{hash}""" +import pytest + +from brk_client import BrkError + from _lib import assert_same_structure, assert_same_values, show -def test_block_v1_extras_all_values(brk, mempool, block): - """Every shared extras field must match - exposes computation differences. +# Fee-distribution fields where mempool uses positional/cut-based percentiles +# 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: - - medianFee, feeRange, feePercentiles: mempool computes each entry with - a different algorithm (1st/99th percentile + first/last 2% of block - order for the feeRange bounds, unweighted positional p10/p25/p50/p75/p90 - for the inner feeRange entries and for feePercentiles, and a vsize- - 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. - - avgFeeRate: mempool returns Bitcoin Core's getblockstats.avgfeerate - (integer sat/vB), brk returns the float version. Same formula, brk - keeps decimal precision. - """ +# avgFeeRate: mempool returns Bitcoin Core's getblockstats.avgfeerate (integer +# sat/vB), brk returns the float version. Same formula, brk preserves precision. +ROUNDING_DIFF = {"avgFeeRate"} + +EXTRAS_EXCLUDE = FEE_ALGO_DIFF | ROUNDING_DIFF + + +def test_block_v1_envelope(brk, mempool, block): + """Top-level v1 envelope: id matches, brk-only `stale` and `extras.price` are present.""" 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"] show("GET", f"{path} [extras]", b, m, max_lines=50) assert_same_structure(b, m) - assert_same_values( - b, m, exclude={"medianFee", "feeRange", "feePercentiles", "avgFeeRate"} + assert_same_values(b, m, exclude=EXTRAS_EXCLUDE) + + +# 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): - """Pool identification structure must match.""" - path = f"/api/v1/block/{block.hash}" - bp = brk.get_json(path)["extras"]["pool"] - mp = mempool.get_json(path)["extras"]["pool"] - show("GET", f"{path} [extras.pool]", bp, mp) - assert_same_structure(bp, mp) +def test_block_v1_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_v1(unknown) + assert exc_info.value.status == 404, ( + f"expected status=404, got {exc_info.value.status}" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_height.py b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_height.py index ba85ebf2c..c830ff5df 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_height.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_height.py @@ -1,14 +1,75 @@ """GET /api/blocks/{height}""" +import pytest + +from brk_client import BrkError + from _lib import assert_same_values, show +PAGE_SIZE = 10 + + 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}" - b = brk.get_json(path) + b = brk.get_blocks_from_height(block.height) m = mempool.get_json(path) - show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") - assert len(b) == len(m) - if b and m: - assert_same_values(b[0], m[0]) + show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=3) + assert len(b) == min(PAGE_SIZE, block.height + 1) + assert_same_values(b, m) + + +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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_recent.py b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_recent.py index 294611233..993c0143e 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_recent.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_recent.py @@ -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): - """Recent blocks list must have the same element structure.""" +HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$") +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" - b = brk.get_json(path) + b = brk.get_blocks() m = mempool.get_json(path) show( "GET", path, - f"({len(b)} blocks, {b[-1]['height']}-{b[0]['height']})" if b else "[]", - f"({len(m)} blocks, {m[-1]['height']}-{m[0]['height']})" if m else "[]", + f"({len(b)} blocks, {b[-1]['height']}-{b[0]['height']})", + 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) -def test_blocks_recent_ordering(brk): - """Returned blocks must be ordered tip-first by strictly decreasing height.""" - b = brk.get_json("/api/blocks") +def test_blocks_recent_chain(brk): + """Tip-first order, no duplicates, and previousblockhash links each block to its successor.""" + b = brk.get_blocks() heights = [blk["height"] for blk in b] - show("GET", "/api/blocks", f"heights={heights[:5]}...", "—") - assert heights == sorted(heights, reverse=True), ( - f"blocks are not strictly tip-first: {heights}" - ) - assert len(set(heights)) == len(heights), "duplicate heights in /api/blocks" + show("GET", "/api/blocks", f"heights={heights}", "-") + assert heights == sorted(heights, reverse=True), f"not 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}" + ) -def test_blocks_recent_count(brk): - """mempool.space returns up to 15 blocks; brk should match that contract.""" - b = brk.get_json("/api/blocks") - show("GET", "/api/blocks", f"({len(b)} blocks)", "—") - assert 1 <= len(b) <= 15, f"unexpected block count: {len(b)}" +def test_blocks_recent_tip(brk): + """The first element of /api/blocks must be the tip.""" + b = brk.get_blocks() + tip_hash = brk.get_block_tip_hash() + 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) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_tip_hash.py b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_tip_hash.py index 47b6092b7..271a64f72 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_tip_hash.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_tip_hash.py @@ -1,37 +1,48 @@ """GET /api/blocks/tip/hash""" +import re + from _lib import show +HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$") + + 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" - b = brk.get_text(path) + b = brk.get_block_tip_hash() m = mempool.get_text(path) show("GET", path, b, m) - assert len(b) == 64 and all(c in "0123456789abcdef" for c in b.lower()) - assert len(m) == 64 and all(c in "0123456789abcdef" for c in m.lower()) + assert HEX_HASH_RE.match(b), f"brk tip hash not 64-char hex: {b!r}" + 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): - """`tip/hash` must equal `block-height/{tip_height}`.""" - h = int(brk.get_text("/api/blocks/tip/height")) - by_height = brk.get_text(f"/api/block-height/{h}") - tip_hash = brk.get_text("/api/blocks/tip/hash") - show("GET", "/api/blocks/tip/hash", tip_hash, by_height) - # Allow a one-block race if a new block landed between the two fetches. - if tip_hash != by_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}" - ) + """tip/hash and tip/height must point to the same block (race-free direction).""" + tip_hash = brk.get_block_tip_hash() + blk = brk.get_block(tip_hash) + tip_height = brk.get_block_tip_height() + show("GET", "/api/blocks/tip/hash", tip_hash, f"block.height={blk['height']} tip/height={tip_height}") + assert tip_height - blk["height"] in (0, 1), ( + f"tip/hash@{blk['height']} not within 1 block of tip/height={tip_height}" + ) def test_blocks_tip_hash_matches_recent(brk): - """`tip/hash` must equal the first hash in `/api/blocks`.""" - tip_hash = brk.get_text("/api/blocks/tip/hash") - blocks = brk.get_json("/api/blocks") + """tip/hash must equal /api/blocks[0].id.""" + tip_hash = brk.get_block_tip_hash() + blocks = brk.get_blocks() show("GET", "/api/blocks/tip/hash", tip_hash, blocks[0]["id"]) - assert blocks and blocks[0]["id"] == tip_hash, ( - f"tip/hash={tip_hash} but /api/blocks[0].id={blocks[0].get('id')}" + assert blocks[0]["id"] == tip_hash, ( + f"tip/hash={tip_hash} but /api/blocks[0].id={blocks[0]['id']}" ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_tip_height.py b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_tip_height.py index fac47bcff..452209844 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_tip_height.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_tip_height.py @@ -1,32 +1,47 @@ """GET /api/blocks/tip/height""" +import re + from _lib import show +HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$") + + 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" - b = int(brk.get_text(path)) + b = brk.get_block_tip_height() m = int(mempool.get_text(path)) 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): - """`tip/height` must resolve to a valid hash via `block-height/{tip}`.""" - h = int(brk.get_text("/api/blocks/tip/height")) - bh = brk.get_text(f"/api/block-height/{h}") + """tip/height must resolve to a 64-char hex hash via /api/block-height/{tip}.""" + h = brk.get_block_tip_height() + bh = brk.get_block_by_height(h) show("GET", "/api/blocks/tip/height", h, bh) - assert len(bh) == 64 and all(c in "0123456789abcdef" for c in bh.lower()), ( - f"block-height/{h} returned non-hash: {bh!r}" + assert HEX_HASH_RE.match(bh), 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): - """`tip/height` must equal the first element's height in `/api/blocks`.""" - h = int(brk.get_text("/api/blocks/tip/height")) - blocks = brk.get_json("/api/blocks") + """tip/height must equal /api/blocks[0].height.""" + h = brk.get_block_tip_height() + blocks = brk.get_blocks() 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']}" ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_v1_height.py b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_v1_height.py index 5cbb961e0..8a67cefd9 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_v1_height.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_v1_height.py @@ -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 -def test_blocks_v1_from_height(brk, mempool, block): - """v1 blocks from a confirmed height - all values must match. +PAGE_SIZE = 15 - Excluded fields: - - medianFee, feeRange, feePercentiles: mempool computes each entry with - a different algorithm (1st/99th percentile + first/last 2% of block - order for the feeRange bounds, unweighted positional p10/p25/p50/p75/p90 - for the inner feeRange entries and for feePercentiles, and a vsize- - 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. - - avgFeeRate: mempool returns Bitcoin Core's getblockstats.avgfeerate - (integer sat/vB), brk returns the float version. Same formula, brk - keeps decimal precision. - """ +# Same fee-algo / rounding divergences as /api/v1/block/{hash} and /api/v1/blocks. +FEE_ALGO_DIFF = {"medianFee", "medianFeeAmt", "feeRange", "feePercentiles"} +ROUNDING_DIFF = {"avgFeeRate"} +EXTRAS_EXCLUDE = FEE_ALGO_DIFF | ROUNDING_DIFF +# Genesis: Bitcoin Core's Satoshi quirk - the genesis coinbase is not in the UTXO set. +GENESIS_EXTRAS_EXCLUDE = EXTRAS_EXCLUDE | {"utxoSetChange"} + + +def test_blocks_v1_from_height(brk, mempool, block): + """Up to 15 v1 blocks descending from `block.height`, full-page value match.""" 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) - show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") - assert len(b) == len(m) - if b and m: - assert_same_values( - b[0], - m[0], - exclude={"medianFee", "feeRange", "feePercentiles", "avgFeeRate"}, + show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=4) + assert len(b) == min(PAGE_SIZE, block.height + 1) + assert len(b) == len(m), f"length mismatch: brk={len(b)} vs mempool={len(m)}" + assert_same_values(b, m, exclude=EXTRAS_EXCLUDE) + + +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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_v1_recent.py b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_v1_recent.py index 764a3f042..01e549564 100644 --- a/packages/brk_client/tests/mempool_compat/blocks/test_blocks_v1_recent.py +++ b/packages/brk_client/tests/mempool_compat/blocks/test_blocks_v1_recent.py @@ -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): - """Recent v1 blocks (with extras) must have the same structure.""" +HEX_HASH_RE = re.compile(r"^[0-9a-f]{64}$") +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" - b = brk.get_json(path) + b = brk.get_blocks_v1() m = mempool.get_json(path) - show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") - assert len(b) > 0 + show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)", max_lines=4) + 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) -def test_blocks_v1_recent_ordering(brk): - """v1 blocks must also be tip-first.""" - b = brk.get_json("/api/v1/blocks") +def test_blocks_v1_recent_chain(brk): + """Tip-first order, no duplicates, valid previousblockhash chain, stale=False, extras.price set.""" + b = brk.get_blocks_v1() heights = [blk["height"] for blk in b] - show("GET", "/api/v1/blocks", f"heights={heights[:5]}...", "—") - assert heights == sorted(heights, reverse=True), ( - f"v1 blocks are not strictly tip-first: {heights}" - ) + show("GET", "/api/v1/blocks", f"heights={heights}", "-") + assert heights == sorted(heights, reverse=True), f"not 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): - """Each v1 block must carry the extras envelope (v1 distinguishes itself from /api/blocks).""" - b = brk.get_json("/api/v1/blocks") - show("GET", "/api/v1/blocks", f"({len(b)} blocks)", "—") - assert b - assert "extras" in b[0], f"v1 blocks element missing 'extras': {list(b[0].keys())}" +def test_blocks_v1_recent_tip(brk): + """The first element must be the tip.""" + b = brk.get_blocks_v1() + tip_hash = brk.get_block_tip_hash() + tip_height = brk.get_block_tip_height() + 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) diff --git a/packages/brk_client/tests/mempool_compat/fees/test_mempool_blocks.py b/packages/brk_client/tests/mempool_compat/fees/test_mempool_blocks.py index d70db0ed0..2ce9756f7 100644 --- a/packages/brk_client/tests/mempool_compat/fees/test_mempool_blocks.py +++ b/packages/brk_client/tests/mempool_compat/fees/test_mempool_blocks.py @@ -3,25 +3,43 @@ from _lib import assert_same_structure, show -def test_fees_mempool_blocks(brk, mempool): - """Projected mempool blocks must have the same element structure.""" +MAX_PROJECTED_BLOCKS = 8 +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" - b = brk.get_json(path) + b = brk.get_mempool_blocks() m = mempool.get_json(path) show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") assert isinstance(b, list) and isinstance(m, list) - assert len(b) > 0 - if b and m: - assert_same_structure(b[0], m[0]) + assert len(b) > 0, "expected non-empty projected blocks" + assert_same_structure(b, m) -def test_fees_mempool_blocks_fee_range(brk, mempool): - """Each projected block must have a 7-element feeRange.""" - path = "/api/v1/fees/mempool-blocks" - for label, client in [("brk", brk), ("mempool", mempool)]: - blocks = client.get_json(path) - for i, block in enumerate(blocks[:3]): - assert "feeRange" in block, f"{label} block {i} missing feeRange" - assert len(block["feeRange"]) == 7, ( - f"{label} block {i} feeRange has {len(block['feeRange'])} items, expected 7" - ) +def test_fees_mempool_blocks_invariants(brk): + """Block counts, sizes, fees, medianFee in feeRange, ordering by descending medianFee.""" + b = brk.get_mempool_blocks() + show("GET", "/api/v1/fees/mempool-blocks", f"({len(b)} blocks)", "-") + assert 1 <= len(b) <= MAX_PROJECTED_BLOCKS, ( + f"projected block count out of range: {len(b)}" + ) + medians = [block["medianFee"] for block in b] + 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]}]" + ) diff --git a/packages/brk_client/tests/mempool_compat/fees/test_precise.py b/packages/brk_client/tests/mempool_compat/fees/test_precise.py index bfa7815da..715810f86 100644 --- a/packages/brk_client/tests/mempool_compat/fees/test_precise.py +++ b/packages/brk_client/tests/mempool_compat/fees/test_precise.py @@ -3,40 +3,37 @@ from _lib import assert_same_structure, show -EXPECTED_FEE_KEYS = [ - "fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee", -] +EXPECTED_FEE_KEYS = ["fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee"] 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" - b = brk.get_json(path) + b = brk.get_precise_fees() m = mempool.get_json(path) show("GET", path, 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: - 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]}" + 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_precise_ordering(brk, mempool): - """Precise fee tiers must be ordered: fastest >= halfHour >= hour >= economy >= minimum.""" - 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"], ( - f"{label}: precise fee ordering violated {d}" - ) - - -def test_fees_precise_numeric(brk): - """Each tier in /precise must be a non-negative number.""" - d = brk.get_json("/api/v1/fees/precise") - show("GET", "/api/v1/fees/precise", d, "—") - for key in EXPECTED_FEE_KEYS: - v = d[key] - assert isinstance(v, (int, float)), f"{key} not numeric: {type(v).__name__}" - assert v >= 0, f"{key} is negative: {v}" +def test_fees_precise_mempool_ordering_sanity(mempool): + """Sanity: mempool itself follows the documented ordering.""" + d = mempool.get_json("/api/v1/fees/precise") + assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"] >= d["economyFee"] >= d["minimumFee"], ( + f"mempool tiers not ordered: {d}" + ) diff --git a/packages/brk_client/tests/mempool_compat/fees/test_recommended.py b/packages/brk_client/tests/mempool_compat/fees/test_recommended.py index 0f08ea17c..560b623ea 100644 --- a/packages/brk_client/tests/mempool_compat/fees/test_recommended.py +++ b/packages/brk_client/tests/mempool_compat/fees/test_recommended.py @@ -3,31 +3,37 @@ from _lib import assert_same_structure, show -EXPECTED_FEE_KEYS = [ - "fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee", -] +EXPECTED_FEE_KEYS = ["fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee"] -def test_fees_recommended(brk, mempool): - """Recommended fees must have the same keys and numeric types.""" +def test_fees_recommended_structure(brk, mempool): + """Recommended fees envelope must match mempool's keys and numeric types.""" path = "/api/v1/fees/recommended" - b = brk.get_json(path) + b = brk.get_recommended_fees() m = mempool.get_json(path) show("GET", path, 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: - assert key in b, f"brk missing '{key}'" - assert isinstance(b[key], (int, float)), f"'{key}' is not numeric: {type(b[key])}" + 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]}" + 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): - """Fee tiers must be ordered: fastest >= halfHour >= hour >= economy >= minimum.""" - path = "/api/v1/fees/recommended" - for label, client in [("brk", brk), ("mempool", mempool)]: - d = client.get_json(path) - 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}" - ) +def test_fees_recommended_mempool_ordering_sanity(mempool): + """Sanity: mempool itself follows the documented ordering (pins our reading of the contract).""" + d = mempool.get_json("/api/v1/fees/recommended") + assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"] >= d["economyFee"] >= d["minimumFee"], ( + f"mempool tiers not ordered: {d}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mempool/test_fullrbf_replacements.py b/packages/brk_client/tests/mempool_compat/mempool/test_fullrbf_replacements.py index e1769b404..05c19b002 100644 --- a/packages/brk_client/tests/mempool_compat/mempool/test_fullrbf_replacements.py +++ b/packages/brk_client/tests/mempool_compat/mempool/test_fullrbf_replacements.py @@ -7,13 +7,49 @@ predecessor was non-signaling (full-RBF). from _lib import assert_same_structure, show -def test_fullrbf_replacements_shape(brk, mempool): - """Full-RBF replacement-tree structure must match for the first element if both lists are non-empty.""" +HEX = set("0123456789abcdef") +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" - b = brk.get_json(path) + b = brk.get_fullrbf_replacements() m = mempool.get_json(path) show("GET", path, b, m) assert isinstance(b, list) and isinstance(m, list) - assert len(b) <= 25 and len(m) <= 25 + assert len(b) <= MAX_REPLACEMENTS and len(m) <= MAX_REPLACEMENTS 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}]") diff --git a/packages/brk_client/tests/mempool_compat/mempool/test_mempool.py b/packages/brk_client/tests/mempool_compat/mempool/test_mempool.py index d7881ce33..5217cc363 100644 --- a/packages/brk_client/tests/mempool_compat/mempool/test_mempool.py +++ b/packages/brk_client/tests/mempool_compat/mempool/test_mempool.py @@ -3,21 +3,40 @@ from _lib import assert_same_structure, show -def test_mempool_info(brk, mempool): - """Mempool stats must have the same keys and types.""" +def test_mempool_info_structure(brk, mempool): + """Mempool stats envelope must match mempool's keys and types.""" path = "/api/mempool" - b = brk.get_json(path) + b = brk.get_mempool() m = mempool.get_json(path) show("GET", path, b, m, max_lines=15) assert_same_structure(b, m) - assert isinstance(b["count"], int) - assert isinstance(b["vsize"], int) -def test_mempool_info_positive(brk, mempool): - """Both servers must report a non-empty mempool.""" - path = "/api/mempool" - for label, client in [("brk", brk), ("mempool", mempool)]: - d = client.get_json(path) - assert d["count"] > 0, f"{label} mempool count is 0" - assert d["vsize"] > 0, f"{label} mempool vsize is 0" +def test_mempool_info_invariants(brk): + """Counts positive, fee histogram descending and accounting-exact (sum bin_vsizes == vsize).""" + b = brk.get_mempool() + show("GET", "/api/mempool", b, "-", max_lines=15) + assert isinstance(b["count"], int) and b["count"] > 0 + assert isinstance(b["vsize"], int) and b["vsize"] > 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']}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mempool/test_recent.py b/packages/brk_client/tests/mempool_compat/mempool/test_recent.py index 2c9184a7a..1608d5519 100644 --- a/packages/brk_client/tests/mempool_compat/mempool/test_recent.py +++ b/packages/brk_client/tests/mempool_compat/mempool/test_recent.py @@ -3,23 +3,34 @@ from _lib import assert_same_structure, show -def test_mempool_recent(brk, mempool): - """Recent mempool txs must have the same element structure.""" +HEX = set("0123456789abcdef") +MAX_RECENT = 10 + + +def test_mempool_recent_structure(brk, mempool): + """Recent mempool txs envelope must match across the full list.""" path = "/api/mempool/recent" - b = brk.get_json(path) + b = brk.get_mempool_recent() m = mempool.get_json(path) show("GET", path, b, m) assert isinstance(b, list) and isinstance(m, list) - assert len(b) > 0 - if b and m: - assert_same_structure(b[0], m[0]) + assert len(b) > 0, "brk recent list is empty" + assert_same_structure(b, m) -def test_mempool_recent_fields(brk, mempool): - """Each recent tx must have txid, fee, vsize, value.""" - path = "/api/mempool/recent" - for label, client in [("brk", brk), ("mempool", mempool)]: - txs = client.get_json(path) - for tx in txs[:3]: - for key in ["txid", "fee", "vsize", "value"]: - assert key in tx, f"{label} recent tx missing '{key}': {tx}" +def test_mempool_recent_invariants(brk): + """Length cap, txid format, positive fee/vsize/value, unique txids.""" + b = brk.get_mempool_recent() + show("GET", "/api/mempool/recent", b, "-") + assert 1 <= len(b) <= MAX_RECENT, f"recent length out of range: {len(b)}" + txids = [] + for i, tx in enumerate(b): + 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}" diff --git a/packages/brk_client/tests/mempool_compat/mempool/test_replacements.py b/packages/brk_client/tests/mempool_compat/mempool/test_replacements.py index d0a7f1960..0e1f5be61 100644 --- a/packages/brk_client/tests/mempool_compat/mempool/test_replacements.py +++ b/packages/brk_client/tests/mempool_compat/mempool/test_replacements.py @@ -8,13 +8,46 @@ load-bearing. from _lib import assert_same_structure, show -def test_replacements_shape(brk, mempool): - """Replacement-tree structure must match for the first element if both lists are non-empty.""" +HEX = set("0123456789abcdef") +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" - b = brk.get_json(path) + b = brk.get_replacements() m = mempool.get_json(path) show("GET", path, b, m) assert isinstance(b, list) and isinstance(m, list) - assert len(b) <= 25 and len(m) <= 25 + assert len(b) <= MAX_REPLACEMENTS and len(m) <= MAX_REPLACEMENTS 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}]") diff --git a/packages/brk_client/tests/mempool_compat/mempool/test_txids.py b/packages/brk_client/tests/mempool_compat/mempool/test_txids.py index 0ff1729cb..8b5799384 100644 --- a/packages/brk_client/tests/mempool_compat/mempool/test_txids.py +++ b/packages/brk_client/tests/mempool_compat/mempool/test_txids.py @@ -6,47 +6,44 @@ from _lib import show HEX = set("0123456789abcdef") -def test_mempool_txids_basic(brk, mempool): - """Txid list must be a non-empty array of strings on both servers.""" +def test_mempool_txids_structure(brk, mempool): + """Txid list must be a non-empty array on both servers.""" path = "/api/mempool/txids" - b = brk.get_json(path) + b = brk.get_mempool_txids() m = mempool.get_json(path) show("GET", path, f"({len(b)} txids)", f"({len(m)} txids)") assert isinstance(b, list) and isinstance(m, list) - assert len(b) > 0, "brk mempool has no txids" - assert isinstance(b[0], str) and len(b[0]) == 64 + assert len(b) > 0, "brk mempool txids list is empty" def test_mempool_txids_format(brk): - """Every txid in brk's mempool list must be a 64-char lowercase hex string.""" - b = brk.get_json("/api/mempool/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)] + """Every txid must be a 64-char strict-lowercase hex string.""" + b = brk.get_mempool_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) <= HEX)] assert not bad, f"{len(bad)} malformed txid(s), e.g. {bad[0] if bad else None!r}" def test_mempool_txids_unique(brk): - """Brk's mempool txid list must not contain duplicates.""" - b = brk.get_json("/api/mempool/txids") - show("GET", "/api/mempool/txids", f"({len(b)} txids)", "—") + """No duplicates.""" + b = brk.get_mempool_txids() + show("GET", "/api/mempool/txids", f"({len(b)} txids)", "-") assert len(b) == len(set(b)), ( f"duplicate txids: {len(b) - len(set(b))} duplicates out of {len(b)}" ) 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 - arrivals / evictions between fetches cause drift. We only assert the - counts are in the same ballpark - exact equality would be flaky. + arrivals / evictions between fetches cause drift. We assert within + max(50, count/100) tolerance to absorb normal churn. """ - txids = brk.get_json("/api/mempool/txids") - summary = brk.get_json("/api/mempool") - show("GET", "/api/mempool/txids", f"len={len(txids)}", f"count={summary.get('count')}") - assert isinstance(summary["count"], int) and summary["count"] > 0 - assert len(txids) > 0 - # 1% tolerance covers normal mempool churn between the two fetches. + txids = brk.get_mempool_txids() + summary = brk.get_mempool() + show("GET", "/api/mempool/txids", f"len={len(txids)}", f"count={summary['count']}") + assert summary["count"] > 0 and len(txids) > 0 drift = abs(len(txids) - summary["count"]) assert drift <= max(50, summary["count"] // 100), ( f"txids={len(txids)} vs /api/mempool.count={summary['count']} (drift={drift})" diff --git a/packages/brk_client/tests/mempool_compat/mining/test_blocks_fee_rates.py b/packages/brk_client/tests/mempool_compat/mining/test_blocks_fee_rates.py index 8bfbac7bf..6944a3d20 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_blocks_fee_rates.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_blocks_fee_rates.py @@ -2,14 +2,51 @@ import pytest +from brk_client import BrkError + from _lib import assert_same_structure, show, summary -@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"]) -def test_mining_blocks_fee_rates(brk, mempool, period): - """Block fee-rate percentiles must have the same element structure.""" +PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"] +PERCENTILES = ["avgFee_0", "avgFee_10", "avgFee_25", "avgFee_50", "avgFee_75", "avgFee_90", "avgFee_100"] + + +@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}" - b = brk.get_json(path) + b = brk.get_block_fee_rates(period) m = mempool.get_json(path) show("GET", path, summary(b), summary(m)) + assert isinstance(b, list) and isinstance(m, list) 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_blocks_fees.py b/packages/brk_client/tests/mempool_compat/mining/test_blocks_fees.py index c7873d4ac..b73629c68 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_blocks_fees.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_blocks_fees.py @@ -2,14 +2,46 @@ import pytest +from brk_client import BrkError + from _lib import assert_same_structure, show, summary -@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"]) -def test_mining_blocks_fees(brk, mempool, period): - """Average block fees must have the same element structure.""" +PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"] + + +@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}" - b = brk.get_json(path) + b = brk.get_block_fees(period) m = mempool.get_json(path) show("GET", path, summary(b), summary(m)) + assert isinstance(b, list) and isinstance(m, list) 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_blocks_rewards.py b/packages/brk_client/tests/mempool_compat/mining/test_blocks_rewards.py index 987e85c37..2805765aa 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_blocks_rewards.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_blocks_rewards.py @@ -2,14 +2,46 @@ import pytest +from brk_client import BrkError + from _lib import assert_same_structure, show, summary -@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"]) -def test_mining_blocks_rewards(brk, mempool, period): - """Average block rewards must have the same element structure.""" +PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"] + + +@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}" - b = brk.get_json(path) + b = brk.get_block_rewards(period) m = mempool.get_json(path) show("GET", path, summary(b), summary(m)) + assert isinstance(b, list) and isinstance(m, list) 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_blocks_sizes_weights.py b/packages/brk_client/tests/mempool_compat/mining/test_blocks_sizes_weights.py index aa588052c..1ba0f3f41 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_blocks_sizes_weights.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_blocks_sizes_weights.py @@ -2,14 +2,60 @@ import pytest +from brk_client import BrkError + from _lib import assert_same_structure, show, summary -@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"]) -def test_mining_blocks_sizes_weights(brk, mempool, period): - """Block sizes and weights must have the same structure.""" +PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"] +MAX_BLOCK_WEIGHT = 4_000_000 + + +@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}" - b = brk.get_json(path) + b = brk.get_block_sizes_weights(period) m = mempool.get_json(path) show("GET", path, summary(b), summary(m)) + assert isinstance(b, dict) and isinstance(m, dict) 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_blocks_timestamp.py b/packages/brk_client/tests/mempool_compat/mining/test_blocks_timestamp.py index 0257da575..651153c39 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_blocks_timestamp.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_blocks_timestamp.py @@ -1,15 +1,52 @@ """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): - """Block lookup by timestamp must have the same structure for various eras.""" +GENESIS_TIMESTAMP = 1231006505 + + +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: info = brk.get_json(f"/api/block/{block.hash}") ts = info["timestamp"] 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) show("GET", path, 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_difficulty_adjustments.py b/packages/brk_client/tests/mempool_compat/mining/test_difficulty_adjustments.py index 4dfe03dc4..1ab47ea98 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_difficulty_adjustments.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_difficulty_adjustments.py @@ -2,14 +2,53 @@ import pytest +from brk_client import BrkError + from _lib import assert_same_structure, show, summary -@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y"]) -def test_mining_difficulty_adjustments(brk, mempool, period): - """Historical difficulty adjustments must have the same structure.""" +PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"] +RETARGET_INTERVAL = 2016 + + +@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}" - b = brk.get_json(path) + b = brk.get_difficulty_adjustments_by_period(period) m = mempool.get_json(path) show("GET", path, summary(b), summary(m)) + assert isinstance(b, list) and isinstance(m, list) 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_hashrate.py b/packages/brk_client/tests/mempool_compat/mining/test_hashrate.py index c40345b83..c10df5cb6 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_hashrate.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_hashrate.py @@ -2,14 +2,52 @@ import pytest +from brk_client import BrkError + from _lib import assert_same_structure, show, summary -@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y"]) -def test_mining_hashrate(brk, mempool, period): - """Network hashrate + difficulty must have the same structure.""" +PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"] + + +@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}" - b = brk.get_json(path) + b = brk.get_hashrate_by_period(period) m = mempool.get_json(path) show("GET", path, summary(b), summary(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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_hashrate_pools.py b/packages/brk_client/tests/mempool_compat/mining/test_hashrate_pools.py index 81548da9d..730e60b67 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_hashrate_pools.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_hashrate_pools.py @@ -2,14 +2,48 @@ import pytest +from brk_client import BrkError + from _lib import assert_same_structure, show, summary -@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "1y"]) -def test_mining_hashrate_pools(brk, mempool, period): - """Per-pool hashrate must have the same structure.""" +PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"] + + +@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}" - b = brk.get_json(path) + b = brk.get_pools_hashrate_by_period(period) m = mempool.get_json(path) show("GET", path, summary(b), summary(m)) + assert isinstance(b, list) and isinstance(m, list) 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_pool.py b/packages/brk_client/tests/mempool_compat/mining/test_pool.py index ea9cf6d68..3a2e1417b 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_pool.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_pool.py @@ -1,13 +1,73 @@ """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): - """Pool detail must have the same structure for top pools.""" +# Tip-race / mempool-only / int-vs-str fields excluded from value equality. +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: path = f"/api/v1/mining/pool/{slug}" - b = brk.get_json(path) + b = brk.get_pool(slug) m = mempool.get_json(path) show("GET", path, summary(b), summary(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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_pool_blocks.py b/packages/brk_client/tests/mempool_compat/mining/test_pool_blocks.py index 81981c0ba..815e0ffc0 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_pool_blocks.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_pool_blocks.py @@ -1,15 +1,47 @@ """GET /api/v1/mining/pool/{slug}/blocks""" +import pytest + +from brk_client import BrkError + from _lib import assert_same_structure, show -def test_mining_pool_blocks(brk, mempool, pool_slugs): - """Recent blocks by pool must have the same element structure.""" +PAGE_SIZE = 100 + + +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: path = f"/api/v1/mining/pool/{slug}/blocks" - b = brk.get_json(path) + b = brk.get_pool_blocks(slug) m = mempool.get_json(path) show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") assert isinstance(b, list) and isinstance(m, list) - if b and m: - assert_same_structure(b[0], m[0]) + assert_same_structure(b, m) + + +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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_pool_blocks_height.py b/packages/brk_client/tests/mempool_compat/mining/test_pool_blocks_height.py index 7e7fbaeda..4a343ab4f 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_pool_blocks_height.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_pool_blocks_height.py @@ -1,15 +1,61 @@ """GET /api/v1/mining/pool/{slug}/blocks/{height}""" +import pytest + +from brk_client import BrkError + from _lib import assert_same_structure, show -def test_mining_pool_blocks_at_height(brk, mempool, pool_slug, live): - """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 - path = f"/api/v1/mining/pool/{pool_slug}/blocks/{block.height}" - b = brk.get_json(path) - m = mempool.get_json(path) - show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") - assert isinstance(b, list) and isinstance(m, list) - if b and m: - assert_same_structure(b[0], m[0]) +PAGE_SIZE = 100 + + +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}" + b = brk.get_pool_blocks_from(pool_slug, block.height) + m = mempool.get_json(path) + show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)") + assert isinstance(b, list) and isinstance(m, list) + assert_same_structure(b, m) + + +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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_pool_hashrate.py b/packages/brk_client/tests/mempool_compat/mining/test_pool_hashrate.py index 5ddf7c464..b366df3b7 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_pool_hashrate.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_pool_hashrate.py @@ -1,13 +1,43 @@ """GET /api/v1/mining/pool/{slug}/hashrate""" +import pytest + +from brk_client import BrkError + from _lib import assert_same_structure, show, summary -def test_mining_pool_hashrate(brk, mempool, pool_slugs): - """Pool hashrate history must have the same structure for top pools.""" +def test_mining_pool_hashrate_structure(brk, mempool, pool_slugs): + """Pool hashrate history element schema must match for top active pools.""" for slug in pool_slugs: path = f"/api/v1/mining/pool/{slug}/hashrate" - b = brk.get_json(path) + b = brk.get_pool_hashrate(slug) m = mempool.get_json(path) show("GET", path, summary(b), summary(m)) + assert isinstance(b, list) and isinstance(m, list) 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_pools.py b/packages/brk_client/tests/mempool_compat/mining/test_pools.py index 0fd34aee0..1e7345aa2 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_pools.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_pools.py @@ -3,43 +3,78 @@ 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): - """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" - b = brk.get_json(path) + b = brk.get_pools() m = mempool.get_json(path) - show( - "GET", path, - b[:3] if isinstance(b, list) else b, - m[:3] if isinstance(m, list) else m, - ) + show("GET", path, f"({len(b)} pools)", f"({len(m)} pools)", max_lines=4) + assert isinstance(b, list) and isinstance(m, list), "both must be flat lists" 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): - """Each pool entry must carry slug and name (period-less endpoint omits stats).""" - b = _pools(brk.get_json("/api/v1/mining/pools")) - show("GET", "/api/v1/mining/pools", f"({len(b)} pools)", "—") - assert b, "no pools in brk's response" - required = {"slug", "name"} - for p in b[:5]: - missing = required - set(p.keys()) - assert not missing, f"pool {p.get('slug', '?')} missing fields: {missing}" - assert isinstance(p["name"], str) and p["name"] + """Every pool entry must carry a non-empty slug + name + non-negative unique_id.""" + b = brk.get_pools() + show("GET", "/api/v1/mining/pools", f"({len(b)} pools)", "-") + assert len(b) >= EXPECTED_MIN_POOLS, f"expected >= {EXPECTED_MIN_POOLS} pools, got {len(b)}" + for p in b: + assert isinstance(p["slug"], str) and p["slug"], f"bad slug: {p!r}" + assert isinstance(p["name"], str) and p["name"], f"bad name: {p!r}" + assert isinstance(p["unique_id"], int) and p["unique_id"] >= 0, ( + f"bad unique_id: {p!r}" + ) def test_mining_pools_slugs_unique(brk): """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] - 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)), ( 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_pools_period.py b/packages/brk_client/tests/mempool_compat/mining/test_pools_period.py index f7df577cb..4cec24411 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_pools_period.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_pools_period.py @@ -2,14 +2,53 @@ import pytest +from brk_client import BrkError + from _lib import assert_same_structure, show, summary -@pytest.mark.parametrize("period", ["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.""" +PERIODS = ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"] + + +@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}" - b = brk.get_json(path) + b = brk.get_pool_stats(period) m = mempool.get_json(path) show("GET", path, summary(b), summary(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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/mining/test_reward_stats.py b/packages/brk_client/tests/mempool_compat/mining/test_reward_stats.py index fbdbc1269..2f71cb2f9 100644 --- a/packages/brk_client/tests/mempool_compat/mining/test_reward_stats.py +++ b/packages/brk_client/tests/mempool_compat/mining/test_reward_stats.py @@ -2,14 +2,62 @@ 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]) -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}" - b = brk.get_json(path) +COUNTS = [1, 10, 100, 500, 1000] + + +@pytest.mark.parametrize("count", COUNTS) +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) show("GET", path, 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}" + ) diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_cpfp.py b/packages/brk_client/tests/mempool_compat/transactions/test_cpfp.py index b5f507924..bf64a758a 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_cpfp.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_cpfp.py @@ -1,12 +1,57 @@ """GET /api/v1/cpfp/{txid}""" +import pytest +from brk_client import BrkError + from _lib import assert_same_structure, show -def test_cpfp(brk, mempool, block): - """CPFP info structure must match for a confirmed tx.""" +def test_cpfp_structure(brk, mempool, block): + """CPFP structure must match for a confirmed regular tx (multi-era).""" path = f"/api/v1/cpfp/{block.txid}" - b = brk.get_json(path) + b = brk.get_cpfp(block.txid) m = mempool.get_json(path) show("GET", path, 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 diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_post_tx.py b/packages/brk_client/tests/mempool_compat/transactions/test_post_tx.py index 05548edf9..0c68235ad 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_post_tx.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_post_tx.py @@ -1,40 +1,55 @@ """POST /api/tx (broadcast) -We can't actually broadcast a real transaction in a test, so we send a -clearly malformed payload and verify both servers reject it with 4xx. The -goal is to confirm the endpoint exists and behaves like a transaction -broadcaster — not to push live transactions. +Live broadcast can't be tested in CI — instead we feed every form of +*invalid* payload and verify both servers reject it identically with 400. """ +import pytest +from brk_client import BrkError + from _lib import show -def test_post_tx_invalid_hex(brk, mempool): - """Both servers must reject an obviously invalid hex payload with 4xx.""" +@pytest.mark.parametrize("label,body", [ + ("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" - bad_hex = "deadbeef" # too short to be a valid serialized transaction - - b = brk.session.post(f"{brk.base_url}{path}", data=bad_hex, timeout=15) + with pytest.raises(BrkError) as ei: + brk.post_tx(body) + assert ei.value.status == 400, label mempool._wait() - m = mempool.session.post(f"{mempool.base_url}{path}", data=bad_hex, timeout=15) - show("POST", path, f"brk={b.status_code}", f"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}" - ) + m = mempool.session.post(f"{mempool.base_url}{path}", data=body, timeout=15) + show("POST", f"{path} ({label})", "brk=400", f"mempool={m.status_code}") + assert m.status_code == 400, f"{label}: mempool={m.status_code}" -def test_post_tx_empty_body(brk, mempool): - """Both servers must reject an empty body with 4xx.""" - path = "/api/tx" - - b = brk.session.post(f"{brk.base_url}{path}", data="", timeout=15) +def test_post_tx_coinbase_rejected(brk, mempool, block): + """Re-broadcasting a coinbase tx is rejected with 400 on both servers (multi-era).""" + coinbase_hex = mempool.get_text(f"/api/tx/{block.coinbase_txid}/hex") + with pytest.raises(BrkError) as ei: + brk.post_tx(coinbase_hex) + assert ei.value.status == 400 mempool._wait() - m = mempool.session.post(f"{mempool.base_url}{path}", data="", timeout=15) - show("POST", path, f"brk={b.status_code}", f"mempool={m.status_code}") + m = mempool.session.post(f"{mempool.base_url}/api/tx", data=coinbase_hex, timeout=15) + 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 diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_transaction_times.py b/packages/brk_client/tests/mempool_compat/transactions/test_transaction_times.py index c506a9c27..307629ceb 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_transaction_times.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_transaction_times.py @@ -1,56 +1,67 @@ """GET /api/v1/transaction-times?txId[]=...""" +import pytest +from brk_client import BrkError + from _lib import show 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]] params = [("txId[]", t) for t in txids] - path = "/api/v1/transaction-times" - b = brk.get_json(path, params=params) - m = mempool.get_json(path, params=params) - show("GET", f"{path}?txId[]={{{len(txids)} txids}}", b, m) + b = brk.get_transaction_times(txids) + m = mempool.get_json("/api/v1/transaction-times", params=params) + show("GET", f"/api/v1/transaction-times?txId[]={{{len(txids)} txids}}", b, m) assert isinstance(b, list) and isinstance(m, list) 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): - """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] params = [("txId[]", t) for t in txids] - path = "/api/v1/transaction-times" - b = brk.get_json(path, params=params) - m = mempool.get_json(path, params=params) - show("GET", f"{path}?txId[]={{{len(txids)} txids}}", f"({len(b)})", f"({len(m)})") + b = brk.get_transaction_times(txids) + m = mempool.get_json("/api/v1/transaction-times", params=params) + show("GET", f"/api/v1/transaction-times?txId[]={{{len(txids)} txids}}", + f"({len(b)})", f"({len(m)})") 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): """A single-element batch must return a 1-element list with the same value.""" txid = live.sample_txid params = [("txId[]", txid)] - path = "/api/v1/transaction-times" - b = brk.get_json(path, params=params) - m = mempool.get_json(path, params=params) - show("GET", f"{path}?txId[]={txid[:16]}...", b, m) + b = brk.get_transaction_times([txid]) + m = mempool.get_json("/api/v1/transaction-times", params=params) + show("GET", f"/api/v1/transaction-times?txId[]={txid[:16]}...", b, m) assert isinstance(b, list) and isinstance(m, list) 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): - """An empty batch must be rejected (any non-2xx) on both servers. +def test_transaction_times_unknown_txid_returns_zero(brk, mempool): + """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 - status parity, only that neither server silently treats it as valid input. - """ - path = "/api/v1/transaction-times" - b_resp = brk.get_raw(path) - 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}" - assert not m_resp.ok, f"mempool accepted empty batch with {m_resp.status_code}" + +def test_transaction_times_empty_batch_rejected(brk): + """Empty batch must produce BrkError(status=400) (mempool returns 500, brk-only check).""" + with pytest.raises(BrkError) as exc_info: + brk.get_transaction_times([]) + assert exc_info.value.status == 400 + + +def test_transaction_times_malformed_short(brk): + """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 diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_tx.py b/packages/brk_client/tests/mempool_compat/transactions/test_tx.py index 302248c48..65f7511aa 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_tx.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_tx.py @@ -1,21 +1,71 @@ """GET /api/tx/{txid}""" +import pytest +from brk_client import BrkError + from _lib import assert_same_values, show -def test_tx_by_id(brk, mempool, block): - """Full transaction data must match for a confirmed tx.""" +def test_tx_by_id_value_parity(brk, mempool, block): + """Full transaction data must match for a confirmed regular tx (multi-era).""" path = f"/api/tx/{block.txid}" - b = brk.get_json(path) + b = brk.get_tx(block.txid) m = mempool.get_json(path) show("GET", path, b, m) assert_same_values(b, m, exclude={"sigops"}) -def test_tx_coinbase(brk, mempool, block): - """Coinbase transaction must match.""" +def test_tx_coinbase_value_parity(brk, mempool, block): + """Coinbase transaction must match (multi-era).""" path = f"/api/tx/{block.coinbase_txid}" - b = brk.get_json(path) + b = brk.get_tx(block.coinbase_txid) m = mempool.get_json(path) show("GET", path, b, m) 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 diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_tx_hex.py b/packages/brk_client/tests/mempool_compat/transactions/test_tx_hex.py index 2b9b75a70..5168f3ea8 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_tx_hex.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_tx_hex.py @@ -1,12 +1,53 @@ """GET /api/tx/{txid}/hex""" +import pytest +from brk_client import BrkError + from _lib import show -def test_tx_hex(brk, mempool, block): - """Raw transaction hex must be identical.""" +HEX = set("0123456789abcdef") + + +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" - b = brk.get_text(path) + b = brk.get_tx_hex(block.txid) m = mempool.get_text(path) show("GET", path, b[:80] + "...", m[:80] + "...") 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 diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_tx_merkle_proof.py b/packages/brk_client/tests/mempool_compat/transactions/test_tx_merkle_proof.py index 8ec9b949c..32bfeabc4 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_tx_merkle_proof.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_tx_merkle_proof.py @@ -1,12 +1,70 @@ """GET /api/tx/{txid}/merkle-proof""" +import pytest +from brk_client import BrkError + from _lib import assert_same_values, show -def test_tx_merkle_proof(brk, mempool, block): - """Merkle inclusion proof must match.""" +HEX = set("0123456789abcdef") + + +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" - b = brk.get_json(path) + b = brk.get_tx_merkle_proof(block.txid) m = mempool.get_json(path) show("GET", path, 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 diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_tx_merkleblock_proof.py b/packages/brk_client/tests/mempool_compat/transactions/test_tx_merkleblock_proof.py index 166e827d4..16f5684c8 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_tx_merkleblock_proof.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_tx_merkleblock_proof.py @@ -1,12 +1,58 @@ """GET /api/tx/{txid}/merkleblock-proof""" +import pytest +from brk_client import BrkError + from _lib import show -def test_tx_merkleblock_proof(brk, mempool, block): - """BIP37 merkleblock proof hex must be identical.""" +HEX = set("0123456789abcdef") +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" - b = brk.get_text(path) + b = brk.get_tx_merkleblock_proof(block.txid) m = mempool.get_text(path) show("GET", path, b[:80] + "...", m[:80] + "...") 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 diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_tx_outspend.py b/packages/brk_client/tests/mempool_compat/transactions/test_tx_outspend.py index 2e967e3ab..4894690ac 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_tx_outspend.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_tx_outspend.py @@ -1,38 +1,79 @@ """GET /api/tx/{txid}/outspend/{vout}""" +import pytest +from brk_client import BrkError + from _lib import assert_same_values, show -def test_tx_outspend_first(brk, mempool, block): - """Spending status of vout 0 must match exactly.""" +HEX = set("0123456789abcdef") + + +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" - b = brk.get_json(path) + b = brk.get_tx_outspend(block.txid, 0) m = mempool.get_json(path) show("GET", path, b, m) assert_same_values(b, m) -def test_tx_outspend_last(brk, mempool, block): - """Spending status of the last vout must also match exactly.""" +def test_tx_outspend_last_value_parity(brk, mempool, block): + """Spending status of the last vout must match (multi-era).""" tx = mempool.get_json(f"/api/tx/{block.txid}") last_vout = len(tx["vout"]) - 1 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) show("GET", path, b, m) assert_same_values(b, m) 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. - - Both servers return `{"spent": false}` rather than 4xx — they don't bound-check - the vout index. The compat property is that they agree. - """ + """Past-the-end vout returns {spent: false} on both servers (no 404).""" tx = mempool.get_json(f"/api/tx/{block.txid}") bad_vout = len(tx["vout"]) + 100 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) 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 diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_tx_outspends.py b/packages/brk_client/tests/mempool_compat/transactions/test_tx_outspends.py index 320538b30..c270397b7 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_tx_outspends.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_tx_outspends.py @@ -1,12 +1,59 @@ """GET /api/tx/{txid}/outspends""" +import pytest +from brk_client import BrkError + from _lib import assert_same_values, show -def test_tx_outspends(brk, mempool, block): - """Spending status of all outputs must match exactly.""" +def test_tx_outspends_value_parity(brk, mempool, block): + """Outspends list must match for a confirmed regular tx (multi-era).""" path = f"/api/tx/{block.txid}/outspends" - b = brk.get_json(path) + b = brk.get_tx_outspends(block.txid) m = mempool.get_json(path) show("GET", path, 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 diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_tx_raw.py b/packages/brk_client/tests/mempool_compat/transactions/test_tx_raw.py index 6c992c2ae..9415ac843 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_tx_raw.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_tx_raw.py @@ -1,12 +1,50 @@ """GET /api/tx/{txid}/raw""" +import pytest +from brk_client import BrkError + from _lib import show -def test_tx_raw(brk, mempool, block): - """Raw transaction bytes must be identical.""" +def test_tx_raw_value_parity(brk, mempool, block): + """Raw tx bytes must be byte-identical for a confirmed regular tx (multi-era).""" path = f"/api/tx/{block.txid}/raw" - b = brk.get_bytes(path) + b = brk.get_tx_raw(block.txid) m = mempool.get_bytes(path) show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>") 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 diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_tx_rbf.py b/packages/brk_client/tests/mempool_compat/transactions/test_tx_rbf.py index 424fbbafc..4f2dc90e0 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_tx_rbf.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_tx_rbf.py @@ -1,16 +1,82 @@ """GET /api/v1/tx/{txid}/rbf -For confirmed transactions both servers return an empty/null replacement -set; the structure is what's load-bearing here. +brk's `tx_graveyard` retains RBF tree data for 1 hour after a tx leaves the +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 -def test_tx_rbf_for_confirmed(brk, mempool, block): - """RBF replacement timeline structure must match for a confirmed tx.""" +NULL_RBF = {"replacements": None, "replaces": None} + + +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" - 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) 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 diff --git a/packages/brk_client/tests/mempool_compat/transactions/test_tx_status.py b/packages/brk_client/tests/mempool_compat/transactions/test_tx_status.py index 8f6f64643..fa78929df 100644 --- a/packages/brk_client/tests/mempool_compat/transactions/test_tx_status.py +++ b/packages/brk_client/tests/mempool_compat/transactions/test_tx_status.py @@ -1,12 +1,51 @@ """GET /api/tx/{txid}/status""" +import pytest +from brk_client import BrkError + from _lib import assert_same_values, show -def test_tx_status(brk, mempool, block): - """Confirmation status must match for a confirmed tx.""" +def test_tx_status_value_parity(brk, mempool, block): + """Status must match for a confirmed regular tx (multi-era).""" path = f"/api/tx/{block.txid}/status" - b = brk.get_json(path) + b = brk.get_tx_status(block.txid) m = mempool.get_json(path) show("GET", path, 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