global: fixes

This commit is contained in:
nym21
2026-05-01 19:14:15 +02:00
parent 1068ad4e8f
commit 6f879a5551
36 changed files with 949 additions and 337 deletions

View File

@@ -16,9 +16,13 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
}
let method_name = endpoint_to_method_name(endpoint);
let base_return_type = jsdoc_normalize(&normalize_return_type(
endpoint.response_type.as_deref().unwrap_or("*"),
));
let base_return_type = if endpoint.returns_binary() {
"Uint8Array".to_string()
} else {
jsdoc_normalize(&normalize_return_type(
endpoint.schema_name().unwrap_or("*"),
))
};
let return_type = if endpoint.supports_csv {
format!("{} | string", base_return_type)
} else {
@@ -86,10 +90,14 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
let path = build_path_template(&endpoint.path, &endpoint.path_params);
let fetch_call = if endpoint.returns_json() {
"this.getJson(path, { signal, onValue })"
let fetch_call: String = if endpoint.returns_binary() {
"this.getBytes(path, { signal, onValue })".to_string()
} else if endpoint.returns_json() {
"this.getJson(path, { signal, onValue })".to_string()
} else if endpoint.response_kind.text_is_numeric() {
"Number(await this.getText(path, { signal, onValue }))".to_string()
} else {
"this.getText(path, { signal, onValue })"
"this.getText(path, { signal, onValue })".to_string()
};
if endpoint.query_params.is_empty() {
@@ -98,7 +106,15 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
writeln!(output, " const params = new URLSearchParams();").unwrap();
for param in &endpoint.query_params {
let ident = sanitize_ident(&param.name);
if param.required {
let is_array = param.param_type.ends_with("[]");
if is_array {
writeln!(
output,
" for (const _v of {}) params.append('{}', String(_v));",
ident, param.name
)
.unwrap();
} else if param.required {
writeln!(
output,
" params.set('{}', String({}));",

View File

@@ -558,6 +558,17 @@ class BrkClientBase {{
return this._getCached(path, (res) => res.text(), options);
}}
/**
* Make a GET request expecting binary data (application/octet-stream).
* Cached and supports `onValue`, same as `getJson`.
* @param {{string}} path
* @param {{{{ onValue?: (value: Uint8Array) => void, signal?: AbortSignal }}}} [options]
* @returns {{Promise<Uint8Array>}}
*/
getBytes(path, options) {{
return this._getCached(path, async (res) => new Uint8Array(await res.arrayBuffer()), options);
}}
/**
* Fetch series data and wrap with helper methods (internal)
* @template T

View File

@@ -96,13 +96,16 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
}
let method_name = endpoint_to_method_name(endpoint);
let base_return_type = normalize_return_type(
&endpoint
.response_type
.as_deref()
.map(js_type_to_python)
.unwrap_or_else(|| "str".to_string()),
);
let base_return_type = if endpoint.returns_binary() {
"bytes".to_string()
} else {
normalize_return_type(
&endpoint
.schema_name()
.map(js_type_to_python)
.unwrap_or_else(|| "str".to_string()),
)
};
let return_type = if endpoint.supports_csv {
format!("Union[{}, str]", base_return_type)
@@ -159,24 +162,50 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
// Build path
let path = build_path_template(&endpoint.path, &endpoint.path_params);
let fetch_method = if endpoint.returns_json() {
let fetch_method = if endpoint.returns_binary() {
"get"
} else if endpoint.returns_json() {
"get_json"
} else {
"get_text"
};
let (wrap_prefix, wrap_suffix) = if endpoint.response_kind.text_is_numeric() {
("int(", ")")
} else {
("", "")
};
if endpoint.query_params.is_empty() {
if endpoint.path_params.is_empty() {
writeln!(output, " return self.{}('{}')", fetch_method, path).unwrap();
writeln!(
output,
" return {}self.{}('{}'){}",
wrap_prefix, fetch_method, path, wrap_suffix
)
.unwrap();
} else {
writeln!(output, " return self.{}(f'{}')", fetch_method, path).unwrap();
writeln!(
output,
" return {}self.{}(f'{}'){}",
wrap_prefix, fetch_method, path, wrap_suffix
)
.unwrap();
}
} else {
writeln!(output, " params = []").unwrap();
for param in &endpoint.query_params {
// Use safe name for Python variable, original name for API query parameter
let safe_name = escape_python_keyword(&param.name);
if param.required {
let is_array = param.param_type.ends_with("[]");
if is_array {
writeln!(
output,
" for _v in {}: params.append(f'{}={{_v}}')",
safe_name, param.name
)
.unwrap();
} else if param.required {
writeln!(
output,
" params.append(f'{}={{{}}}')",
@@ -203,9 +232,19 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
if endpoint.supports_csv {
writeln!(output, " if format == 'csv':").unwrap();
writeln!(output, " return self.get_text(path)").unwrap();
writeln!(output, " return self.{}(path)", fetch_method).unwrap();
writeln!(
output,
" return {}self.{}(path){}",
wrap_prefix, fetch_method, wrap_suffix
)
.unwrap();
} else {
writeln!(output, " return self.{}(path)", fetch_method).unwrap();
writeln!(
output,
" return {}self.{}(path){}",
wrap_prefix, fetch_method, wrap_suffix
)
.unwrap();
}
}

View File

@@ -89,11 +89,17 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
}
let method_name = endpoint_to_method_name(endpoint);
let base_return_type = endpoint
.response_type
.as_deref()
.map(js_type_to_rust)
.unwrap_or_else(|| "String".to_string());
let base_return_type = if endpoint.returns_binary() {
"Vec<u8>".to_string()
} else if endpoint.returns_text() {
// Text bodies arrive as `String`; per-type parsing is left to the caller.
"String".to_string()
} else {
endpoint
.schema_name()
.map(js_type_to_rust)
.unwrap_or_else(|| "String".to_string())
};
let return_type = if endpoint.supports_csv {
format!("FormatResponse<{}>", base_return_type)
@@ -132,7 +138,9 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
.unwrap();
let (path, index_arg) = build_path_template(endpoint);
let fetch_method = if endpoint.returns_json() {
let fetch_method = if endpoint.returns_binary() {
"get_bytes"
} else if endpoint.returns_json() {
"get_json"
} else {
"get_text"

View File

@@ -103,6 +103,14 @@ impl BrkClientBase {{
.and_then(|mut r| r.body_mut().read_to_string())
.map_err(|e| BrkError {{ message: e.to_string() }})
}}
/// Make a GET request and return raw bytes response.
pub fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {{
self.agent.get(&self.url(path))
.call()
.and_then(|mut r| r.body_mut().read_to_vec())
.map_err(|e| BrkError {{ message: e.to_string() }})
}}
}}
/// Build series name with suffix.

View File

@@ -0,0 +1,81 @@
use crate::openapi::{Parameter, ResponseKind};
/// Endpoint information extracted from OpenAPI spec.
#[derive(Debug, Clone)]
pub struct Endpoint {
/// HTTP method (GET, POST, etc.)
pub method: String,
/// Path template (e.g., "/blocks/{hash}")
pub path: String,
/// Operation ID (e.g., "getBlockByHash")
pub operation_id: Option<String>,
/// Short summary
pub summary: Option<String>,
/// Detailed description
pub description: Option<String>,
/// Path parameters
pub path_params: Vec<Parameter>,
/// Query parameters
pub query_params: Vec<Parameter>,
/// Body kind for the 200 response.
pub response_kind: ResponseKind,
/// Whether this endpoint is deprecated
pub deprecated: bool,
/// Whether this endpoint supports CSV format (text/csv content type)
pub supports_csv: bool,
}
impl Endpoint {
/// Returns true if this endpoint should be included in client generation.
/// Only non-deprecated GET endpoints are included.
pub fn should_generate(&self) -> bool {
self.method == "GET" && !self.deprecated
}
/// Returns true if this endpoint returns JSON.
pub fn returns_json(&self) -> bool {
matches!(self.response_kind, ResponseKind::Json(_))
}
/// Returns true if this endpoint returns binary data (application/octet-stream).
pub fn returns_binary(&self) -> bool {
matches!(self.response_kind, ResponseKind::Binary)
}
/// Returns true if this endpoint returns plain text (typed or opaque).
pub fn returns_text(&self) -> bool {
matches!(self.response_kind, ResponseKind::Text(_))
}
/// Schema name attached to the response, if any.
pub fn schema_name(&self) -> Option<&str> {
self.response_kind.schema_name()
}
/// Returns the operation ID or generates one from the path.
/// The returned string uses the raw case from the spec (typically camelCase).
pub fn operation_name(&self) -> String {
if let Some(op_id) = &self.operation_id {
return op_id.clone();
}
let mut parts: Vec<String> = Vec::new();
let mut prev_segment = "";
for segment in self.path.split('/').filter(|s| !s.is_empty()) {
if segment == "api" {
continue;
}
if let Some(param) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
let prev_normalized = prev_segment.replace('-', "_");
if !prev_normalized.ends_with(param) {
parts.push(format!("by_{}", param));
}
} else {
let normalized = segment.replace('-', "_");
parts.push(normalized);
prev_segment = segment;
}
}
format!("get_{}", parts.join("_"))
}
}

View File

@@ -1,3 +1,13 @@
mod endpoint;
mod parameter;
mod response_kind;
mod text_schema;
pub use endpoint::Endpoint;
pub use parameter::Parameter;
pub use response_kind::ResponseKind;
pub use text_schema::TextSchema;
use std::{collections::BTreeMap, io};
use crate::ref_to_type_name;
@@ -11,83 +21,6 @@ use serde_json::Value;
/// Type schema extracted from OpenAPI components
pub type TypeSchemas = BTreeMap<String, Value>;
/// Endpoint information extracted from OpenAPI spec
#[derive(Debug, Clone)]
pub struct Endpoint {
/// HTTP method (GET, POST, etc.)
pub method: String,
/// Path template (e.g., "/blocks/{hash}")
pub path: String,
/// Operation ID (e.g., "getBlockByHash")
pub operation_id: Option<String>,
/// Short summary
pub summary: Option<String>,
/// Detailed description
pub description: Option<String>,
/// Path parameters
pub path_params: Vec<Parameter>,
/// Query parameters
pub query_params: Vec<Parameter>,
/// Response type (simplified)
pub response_type: Option<String>,
/// Whether this endpoint is deprecated
pub deprecated: bool,
/// Whether this endpoint supports CSV format (text/csv content type)
pub supports_csv: bool,
}
impl Endpoint {
/// Returns true if this endpoint should be included in client generation.
/// Only non-deprecated GET endpoints are included.
pub fn should_generate(&self) -> bool {
self.method == "GET" && !self.deprecated
}
/// Returns true if this endpoint returns JSON (has a response_type extracted from application/json).
pub fn returns_json(&self) -> bool {
self.response_type.is_some()
}
/// Returns the operation ID or generates one from the path.
/// The returned string uses the raw case from the spec (typically camelCase).
pub fn operation_name(&self) -> String {
if let Some(op_id) = &self.operation_id {
return op_id.clone();
}
// Generate from path: /api/block/{hash} -> "get_block"
// Skip "api" prefix, convert hyphens to underscores, avoid redundant param names
let mut parts: Vec<String> = Vec::new();
let mut prev_segment = "";
for segment in self.path.split('/').filter(|s| !s.is_empty()) {
if segment == "api" {
continue;
}
if let Some(param) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
// Only add "by_{param}" if the previous segment doesn't already contain the param name
let prev_normalized = prev_segment.replace('-', "_");
if !prev_normalized.ends_with(param) {
parts.push(format!("by_{}", param));
}
} else {
let normalized = segment.replace('-', "_");
parts.push(normalized);
prev_segment = segment;
}
}
format!("get_{}", parts.join("_"))
}
}
/// Parameter information
#[derive(Debug, Clone)]
pub struct Parameter {
pub name: String,
pub required: bool,
pub param_type: String,
pub description: Option<String>,
}
/// Parse OpenAPI spec from JSON string
///
/// Pre-processes the JSON to handle oas3 limitations:
@@ -164,7 +97,7 @@ pub fn extract_endpoints(spec: &Spec) -> Vec<Endpoint> {
for (path, path_item) in paths {
for (method, operation) in get_operations(path_item) {
if let Some(endpoint) = extract_endpoint(path, method, operation) {
if let Some(endpoint) = extract_endpoint(path, method, operation, spec) {
endpoints.push(endpoint);
}
}
@@ -186,11 +119,16 @@ fn get_operations(path_item: &PathItem) -> Vec<(&'static str, &Operation)> {
.collect()
}
fn extract_endpoint(path: &str, method: &str, operation: &Operation) -> Option<Endpoint> {
fn extract_endpoint(
path: &str,
method: &str,
operation: &Operation,
spec: &Spec,
) -> Option<Endpoint> {
let path_params = extract_path_parameters(path, operation);
let query_params = extract_parameters(operation, ParameterIn::Query);
let response_type = extract_response_type(operation);
let response_kind = extract_response_kind(operation, spec);
let supports_csv = check_csv_support(operation);
Some(Endpoint {
@@ -201,7 +139,7 @@ fn extract_endpoint(path: &str, method: &str, operation: &Operation) -> Option<E
description: operation.description.clone(),
path_params,
query_params,
response_type,
response_kind,
deprecated: operation.deprecated.unwrap_or(false),
supports_csv,
})
@@ -272,28 +210,59 @@ fn extract_parameters(operation: &Operation, location: ParameterIn) -> Vec<Param
.collect()
}
fn extract_response_type(operation: &Operation) -> Option<String> {
let responses = operation.responses.as_ref()?;
fn extract_response_kind(operation: &Operation, spec: &Spec) -> ResponseKind {
let response = operation
.responses
.as_ref()
.and_then(|r| r.get("200"))
.and_then(|r| match r {
ObjectOrReference::Object(o) => Some(o),
ObjectOrReference::Ref { .. } => None,
});
let Some(response) = response else {
return ResponseKind::Text(None);
};
// Look for 200 OK response
let response = responses.get("200")?;
match response {
ObjectOrReference::Object(response) => {
// Look for JSON content
let content = response.content.get("application/json")?;
match &content.schema {
Some(ObjectOrReference::Ref { ref_path, .. }) => {
// Extract type name from reference like "#/components/schemas/Block"
Some(ref_to_type_name(ref_path)?.to_string())
}
Some(ObjectOrReference::Object(schema)) => schema_to_type_name(schema),
None => None,
}
}
ObjectOrReference::Ref { .. } => None,
if response.content.contains_key("application/octet-stream") {
return ResponseKind::Binary;
}
if let Some(content) = response.content.get("application/json") {
return ResponseKind::Json(
schema_name_from_content(content).unwrap_or_else(|| "*".to_string()),
);
}
if let Some(content) = response.content.get("text/plain; charset=utf-8") {
let schema = schema_name_from_content(content).map(|name| {
let is_numeric = is_numeric_schema(spec, &name);
TextSchema { name, is_numeric }
});
return ResponseKind::Text(schema);
}
ResponseKind::Text(None)
}
fn schema_name_from_content(content: &oas3::spec::MediaType) -> Option<String> {
match content.schema.as_ref()? {
ObjectOrReference::Ref { ref_path, .. } => {
Some(ref_to_type_name(ref_path)?.to_string())
}
ObjectOrReference::Object(schema) => schema_to_type_name(schema),
}
}
/// Resolves `name` against `components.schemas` and reports whether the
/// underlying primitive is `integer` or `number`.
fn is_numeric_schema(spec: &Spec, name: &str) -> bool {
let Some(components) = spec.components.as_ref() else {
return false;
};
let Some(ObjectOrReference::Object(schema)) = components.schemas.get(name) else {
return false;
};
matches!(
schema.schema_type.as_ref(),
Some(SchemaTypeSet::Single(SchemaType::Integer | SchemaType::Number))
)
}
fn schema_type_from_schema(schema: &Schema) -> Option<String> {

View File

@@ -0,0 +1,8 @@
/// Parameter information.
#[derive(Debug, Clone)]
pub struct Parameter {
pub name: String,
pub required: bool,
pub param_type: String,
pub description: Option<String>,
}

View File

@@ -0,0 +1,29 @@
use crate::openapi::TextSchema;
/// 200-response body shape.
#[derive(Debug, Clone)]
pub enum ResponseKind {
/// JSON body, schema named (e.g. "Block").
Json(String),
/// `text/plain` body. `Some(schema)` carries a typed shape (e.g. "Height", "Hex");
/// `None` is the escape hatch for opaque text.
Text(Option<TextSchema>),
/// `application/octet-stream`.
Binary,
}
impl ResponseKind {
/// Schema name, if the body is named (Json or typed Text).
pub fn schema_name(&self) -> Option<&str> {
match self {
Self::Json(s) => Some(s.as_str()),
Self::Text(Some(t)) => Some(t.name.as_str()),
_ => None,
}
}
/// True when a typed text body needs numeric parsing (`int(...)` etc.).
pub fn text_is_numeric(&self) -> bool {
matches!(self, Self::Text(Some(t)) if t.is_numeric)
}
}

View File

@@ -0,0 +1,8 @@
/// Schema metadata for a typed `text/plain` response.
#[derive(Debug, Clone)]
pub struct TextSchema {
/// Schema name, e.g. "Height", "Hex".
pub name: String,
/// True when the underlying primitive is `integer`/`number` (body needs numeric parsing).
pub is_numeric: bool,
}

View File

@@ -91,6 +91,14 @@ impl BrkClientBase {
.and_then(|mut r| r.body_mut().read_to_string())
.map_err(|e| BrkError { message: e.to_string() })
}
/// Make a GET request and return raw bytes response.
pub fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
self.agent.get(&self.url(path))
.call()
.and_then(|mut r| r.body_mut().read_to_vec())
.map_err(|e| BrkError { message: e.to_string() })
}
}
/// Build series name with suffix.
@@ -8977,8 +8985,8 @@ impl BrkClient {
/// Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`.
///
/// Endpoint: `GET /api.json`
pub fn get_api(&self) -> Result<String> {
self.base.get_text(&format!("/api.json"))
pub fn get_api(&self) -> Result<serde_json::Value> {
self.base.get_json(&format!("/api.json"))
}
/// Address information
@@ -9084,8 +9092,8 @@ impl BrkClient {
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)*
///
/// Endpoint: `GET /api/block/{hash}/raw`
pub fn get_block_raw(&self, hash: BlockHash) -> Result<String> {
self.base.get_text(&format!("/api/block/{hash}/raw"))
pub fn get_block_raw(&self, hash: BlockHash) -> Result<Vec<u8>> {
self.base.get_bytes(&format!("/api/block/{hash}/raw"))
}
/// Block status
@@ -9360,8 +9368,8 @@ impl BrkClient {
/// Returns the single most recent value for a series, unwrapped (not inside a SeriesData object).
///
/// Endpoint: `GET /api/series/{series}/{index}/latest`
pub fn get_series_latest(&self, series: SeriesName, index: Index) -> Result<String> {
self.base.get_text(&format!("/api/series/{series}/{}/latest", index.name()))
pub fn get_series_latest(&self, series: SeriesName, index: Index) -> Result<serde_json::Value> {
self.base.get_json(&format!("/api/series/{series}/{}/latest", index.name()))
}
/// Get series data length
@@ -9482,8 +9490,8 @@ impl BrkClient {
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-raw)*
///
/// Endpoint: `GET /api/tx/{txid}/raw`
pub fn get_tx_raw(&self, txid: Txid) -> Result<String> {
self.base.get_text(&format!("/api/tx/{txid}/raw"))
pub fn get_tx_raw(&self, txid: Txid) -> Result<Vec<u8>> {
self.base.get_bytes(&format!("/api/tx/{txid}/raw"))
}
/// Transaction status
@@ -9633,6 +9641,17 @@ impl BrkClient {
self.base.get_json(&format!("/api/v1/fees/recommended"))
}
/// Recent full-RBF replacements
///
/// Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF).
///
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)*
///
/// Endpoint: `GET /api/v1/fullrbf/replacements`
pub fn get_fullrbf_replacements(&self) -> Result<Vec<ReplacementNode>> {
self.base.get_json(&format!("/api/v1/fullrbf/replacements"))
}
/// Historical price
///
/// Get historical BTC/USD price. Optionally specify a UNIX timestamp to get the price at that time.
@@ -9857,6 +9876,17 @@ impl BrkClient {
self.base.get_json(&format!("/api/v1/prices"))
}
/// Recent RBF replacements
///
/// Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`.
///
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)*
///
/// Endpoint: `GET /api/v1/replacements`
pub fn get_replacements(&self) -> Result<Vec<ReplacementNode>> {
self.base.get_json(&format!("/api/v1/replacements"))
}
/// Transaction first-seen times
///
/// Returns timestamps when transactions were first seen in the mempool. Returns 0 for mined or unknown transactions.
@@ -9864,8 +9894,12 @@ impl BrkClient {
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-times)*
///
/// Endpoint: `GET /api/v1/transaction-times`
pub fn get_transaction_times(&self) -> Result<Vec<i64>> {
self.base.get_json(&format!("/api/v1/transaction-times"))
pub fn get_transaction_times(&self, txId: &[Txid]) -> Result<Vec<i64>> {
let mut query = Vec::new();
for v in txId { query.push(format!("txId[]={}", v)); }
let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) };
let path = format!("/api/v1/transaction-times{}", query_str);
self.base.get_json(&path)
}
/// RBF replacement history

View File

@@ -44,12 +44,20 @@ impl TxGraveyard {
}
/// Every `Replaced` tombstone, yielded as (predecessor_txid,
/// replacer_txid). Caller walks the replacer chain forward to find
/// replacer_txid) in reverse bury order (most recent replacement
/// event first). Caller walks the replacer chain forward to find
/// each tree's terminal replacer.
pub fn replaced_iter(&self) -> impl Iterator<Item = (&Txid, &Txid)> {
self.tombstones
.iter()
.filter_map(|(txid, ts)| ts.replaced_by().map(|by| (txid, by)))
///
/// `order` may carry stale entries (re-buries, prior exhumes); the
/// `removed_at == t` check skips those.
pub fn replaced_iter_recent_first(&self) -> impl Iterator<Item = (&Txid, &Txid)> {
self.order.iter().rev().filter_map(|(t, txid)| {
let ts = self.tombstones.get(txid)?;
if ts.removed_at() != *t {
return None;
}
Some((txid, ts.replaced_by()?))
})
}
pub fn bury(&mut self, txid: Txid, tx: Transaction, entry: TxEntry, removal: TxRemoval) {

View File

@@ -3,7 +3,6 @@ use brk_error::Result;
use brk_indexer::Indexer;
use brk_mempool::Mempool;
use brk_reader::Reader;
use brk_rpc::Client;
use tokio::task::spawn_blocking;
use crate::Query;
@@ -51,11 +50,8 @@ impl AsyncQuery {
f(&self.0)
}
#[inline]
pub fn inner(&self) -> &Query {
&self.0
}
pub fn client(&self) -> &Client {
self.0.client()
}
}

View File

@@ -1,5 +1,5 @@
use brk_error::{Error, OptionData, Result};
use brk_mempool::{EntryPool, TxEntry, TxGraveyard, TxRemoval, TxStore, TxTombstone};
use brk_mempool::{EntryPool, Mempool, TxEntry, TxGraveyard, TxRemoval, TxStore, TxTombstone};
use brk_types::{
CheckedSub, CpfpEntry, CpfpInfo, FeeRate, MempoolBlock, MempoolInfo, MempoolRecentTx,
OutputType, RbfResponse, RbfTx, RecommendedFees, ReplacementNode, Sats, Timestamp, Transaction,
@@ -10,6 +10,8 @@ use vecdb::{AnyVec, ReadableVec, VecIndex};
use crate::Query;
const RECENT_REPLACEMENTS_LIMIT: usize = 25;
impl Query {
pub fn mempool_info(&self) -> Result<MempoolInfo> {
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
@@ -305,11 +307,7 @@ impl Query {
let replaces = (!replaces_vec.is_empty()).then_some(replaces_vec);
let replacements =
Self::build_rbf_node(&root_txid, None, &txs, &entries, &graveyard).map(|mut node| {
node.tx.full_rbf = Some(node.full_rbf);
node.interval = None;
node
});
self.build_rbf_node(&root_txid, None, mempool, &txs, &entries, &graveyard);
Ok(RbfResponse {
replacements,
@@ -336,9 +334,17 @@ impl Query {
/// Predecessors are always in the graveyard (that's where
/// `Removal::Replaced` lives), so the recursion only needs the
/// graveyard; the live pool is consulted for the root.
///
/// `rate` matches mempool.space's `tx.effectiveFeePerVsize`: live
/// txs get the live CPFP-cluster effective rate; mined txs get the
/// computer's stored same-block-cluster effective rate; never-mined
/// replaced predecessors have no recorded effective rate, so we
/// fall back to the simple `fee/vsize` snapshotted at burial.
fn build_rbf_node(
&self,
txid: &Txid,
successor_time: Option<Timestamp>,
mempool: &Mempool,
txs: &TxStore,
entries: &EntryPool,
graveyard: &TxGraveyard,
@@ -348,7 +354,14 @@ impl Query {
let replaces: Vec<ReplacementNode> = graveyard
.predecessors_of(txid)
.filter_map(|(pred_txid, _)| {
Self::build_rbf_node(pred_txid, Some(entry.first_seen), txs, entries, graveyard)
self.build_rbf_node(
pred_txid,
Some(entry.first_seen),
mempool,
txs,
entries,
graveyard,
)
})
.collect();
@@ -359,6 +372,24 @@ impl Query {
.map(|d| usize::from(d) as u32);
let value = Sats::from(tx.output.iter().map(|o| u64::from(o.value)).sum::<u64>());
let tx_index = self.resolve_tx_index(txid).ok();
let mined = tx_index.map(|_| true);
let rate = if txs.contains(txid) {
mempool
.cpfp_info(&TxidPrefix::from(txid))
.and_then(|info| info.effective_fee_per_vsize)
.unwrap_or_else(|| entry.fee_rate())
} else if let Some(idx) = tx_index {
self.computer()
.transactions
.fees
.effective_fee_rate
.tx_index
.collect_one(idx)
.unwrap_or_else(|| entry.fee_rate())
} else {
entry.fee_rate()
};
Some(ReplacementNode {
tx: RbfTx {
@@ -366,14 +397,16 @@ impl Query {
fee: entry.fee,
vsize: entry.vsize,
value,
rate: entry.fee_rate(),
rate,
time: entry.first_seen,
rbf: entry.rbf,
full_rbf: None,
full_rbf: Some(full_rbf),
mined,
},
time: entry.first_seen,
full_rbf,
interval,
mined,
replaces,
})
}
@@ -381,45 +414,39 @@ impl Query {
/// Recent RBF replacements across the whole mempool, matching
/// mempool.space's `GET /api/v1/replacements` and
/// `GET /api/v1/fullrbf/replacements`. Each entry is a complete
/// replacement tree rooted at the latest replacer; same shape as
/// `tx_rbf().replacements`. Sorted most-recent-first by root
/// `time`. When `full_rbf_only` is true, only trees with at least
/// one non-signaling predecessor are returned.
/// replacement tree rooted at the terminal replacer; same shape as
/// `tx_rbf().replacements`. Ordered by most-recent replacement
/// event first (matches mempool.space's reversed-`replacedBy`
/// iteration) and capped at 25 entries. When `full_rbf_only` is
/// true, only trees with at least one non-signaling predecessor
/// are returned.
pub fn recent_replacements(&self, full_rbf_only: bool) -> Result<Vec<ReplacementNode>> {
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
let txs = mempool.txs();
let entries = mempool.entries();
let graveyard = mempool.graveyard();
// Collect every distinct tree-root replacer. A predecessor's
// `by` may itself have been replaced; walk forward through
// chained Replaced tombstones until reaching a tx that's no
// longer flagged as replaced (live, Vanished, or unknown).
let mut roots: FxHashSet<Txid> = FxHashSet::default();
for (_, by) in graveyard.replaced_iter() {
let mut root = by.clone();
while let Some(TxRemoval::Replaced { by: next }) =
graveyard.get(&root).map(TxTombstone::reason)
{
root = next.clone();
}
roots.insert(root);
}
let mut trees: Vec<ReplacementNode> = roots
.iter()
// A predecessor's `by` may itself be replaced; walk the chain
// forward to the terminal replacer for each tree, dedup so each
// tree is emitted once at its first (most recent) sighting.
let mut seen: FxHashSet<Txid> = FxHashSet::default();
Ok(graveyard
.replaced_iter_recent_first()
.filter_map(|(_, by)| {
let mut root = by.clone();
while let Some(TxRemoval::Replaced { by: next }) =
graveyard.get(&root).map(TxTombstone::reason)
{
root = next.clone();
}
seen.insert(root.clone()).then_some(root)
})
.filter_map(|root| {
Self::build_rbf_node(root, None, &txs, &entries, &graveyard).map(|mut node| {
node.tx.full_rbf = Some(node.full_rbf);
node.interval = None;
node
})
self.build_rbf_node(&root, None, mempool, &txs, &entries, &graveyard)
})
.filter(|node| !full_rbf_only || node.full_rbf)
.collect();
trees.sort_by(|a, b| b.time.cmp(&a.time));
Ok(trees)
.take(RECENT_REPLACEMENTS_LIMIT)
.collect())
}
pub fn transaction_times(&self, txids: &[Txid]) -> Result<Vec<u64>> {

View File

@@ -1,5 +1,7 @@
use brk_error::Result;
use brk_types::{Dollars, ExchangeRates, HistoricalPrice, HistoricalPriceEntry, Hour4, Timestamp};
use brk_types::{
Dollars, ExchangeRates, HistoricalPrice, HistoricalPriceEntry, Hour4, INDEX_EPOCH, Timestamp,
};
use vecdb::ReadableVec;
use crate::Query;
@@ -32,6 +34,9 @@ impl Query {
}
fn price_at(&self, target: Timestamp) -> Result<Vec<HistoricalPriceEntry>> {
if *target < INDEX_EPOCH {
return Ok(vec![]);
}
let h4 = Hour4::from_timestamp(target);
let cents = self.computer().prices.spot.cents.hour4.collect_one(h4);
Ok(vec![HistoricalPriceEntry {

View File

@@ -32,7 +32,7 @@ impl Query {
pub fn series_not_found_error(&self, series: &SeriesName) -> Error {
// Check if series exists but with different indexes
if let Some(indexes) = self.vecs().series_to_indexes(series.clone()) {
if let Some(indexes) = self.vecs().series_to_indexes(series) {
let supported = indexes
.iter()
.map(|i| format!("/api/series/{series}/{}", i.name()))
@@ -382,7 +382,7 @@ impl Query {
})
}
pub fn series_to_indexes(&self, series: SeriesName) -> Option<&Vec<Index>> {
pub fn series_to_indexes(&self, series: &SeriesName) -> Option<&Vec<Index>> {
self.vecs().series_to_indexes(series)
}

View File

@@ -1,9 +1,10 @@
#![doc = include_str!("../README.md")]
#![allow(clippy::module_inception)]
use std::sync::Arc;
use std::{path::Path, sync::Arc};
use brk_computer::Computer;
use brk_error::{OptionData, Result};
use brk_indexer::Indexer;
use brk_mempool::Mempool;
use brk_reader::Reader;
@@ -84,7 +85,7 @@ impl Query {
}
/// Build sync status with the given tip height
pub fn sync_status(&self, tip_height: Height) -> SyncStatus {
pub fn sync_status(&self, tip_height: Height) -> Result<SyncStatus> {
let indexed_height = self.indexed_height();
let computed_height = self.computed_height();
let blocks_behind = Height::from(tip_height.saturating_sub(*indexed_height));
@@ -94,16 +95,16 @@ impl Query {
.blocks
.timestamp
.collect_one(indexed_height)
.unwrap();
.data()?;
SyncStatus {
Ok(SyncStatus {
indexed_height,
computed_height,
tip_height,
blocks_behind,
last_indexed_at: last_indexed_at_unix.to_iso8601(),
last_indexed_at_unix,
}
})
}
#[inline]
@@ -117,7 +118,7 @@ impl Query {
}
#[inline]
pub fn blocks_dir(&self) -> &std::path::Path {
pub fn blocks_dir(&self) -> &Path {
self.0.reader.blocks_dir()
}

View File

@@ -8,7 +8,7 @@ use brk_types::{
};
use derive_more::{Deref, DerefMut};
use quickmatch::{QuickMatch, QuickMatchConfig};
use vecdb::AnyExportableVec;
use vecdb::{AnyExportableVec, Ro};
#[derive(Default)]
pub struct Vecs<'a> {
@@ -25,7 +25,7 @@ pub struct Vecs<'a> {
}
impl<'a> Vecs<'a> {
pub fn build(indexer: &'a Indexer<vecdb::Ro>, computer: &'a Computer<vecdb::Ro>) -> Self {
pub fn build(indexer: &'a Indexer<Ro>, computer: &'a Computer<Ro>) -> Self {
Self::build_from(
indexer.vecs.iter_any_visible(),
indexer.vecs.to_tree_node(),
@@ -57,24 +57,17 @@ impl<'a> Vecs<'a> {
let mut ids = this
.series_to_index_to_vec
.keys()
.cloned()
.copied()
.collect::<Vec<_>>();
let sort_ids = |ids: &mut Vec<&str>| {
ids.sort_unstable_by(|a, b| {
let len_cmp = a.len().cmp(&b.len());
if len_cmp == std::cmp::Ordering::Equal {
a.cmp(b)
} else {
len_cmp
}
})
ids.sort_unstable_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)))
};
sort_ids(&mut ids);
this.series = ids;
this.counts.distinct_series = this.series_to_index_to_vec.keys().count();
this.counts.distinct_series = this.series_to_index_to_vec.len();
this.counts.total_endpoints = this
.index_to_series_to_vec
.values()
@@ -108,7 +101,7 @@ impl<'a> Vecs<'a> {
this.index_to_series = this
.index_to_series_to_vec
.iter()
.map(|(index, id_to_vec)| (*index, id_to_vec.keys().cloned().collect::<Vec<_>>()))
.map(|(index, id_to_vec)| (*index, id_to_vec.keys().copied().collect::<Vec<_>>()))
.collect();
this.index_to_series.values_mut().for_each(sort_ids);
this.catalog.replace(
@@ -121,7 +114,7 @@ impl<'a> Vecs<'a> {
.collect(),
)
.merge_branches()
.unwrap(),
.expect("indexed/computed catalog merge: same series leaf with incompatible schemas"),
);
this.matcher = Some(QuickMatch::new(&this.series));
@@ -144,17 +137,11 @@ impl<'a> Vecs<'a> {
"Duplicate series: {name} for index {index:?}"
);
let prev = self
.index_to_series_to_vec
self.index_to_series_to_vec
.entry(index)
.or_default()
.insert(name, vec);
assert!(
prev.is_none(),
"Duplicate series: {name} for index {index:?}"
);
// Track per-db counts
let is_lazy = vec.region_names().is_empty();
self.counts_by_db
.entry(db.to_string())
@@ -182,7 +169,7 @@ impl<'a> Vecs<'a> {
}
}
pub fn series_to_indexes(&self, series: SeriesName) -> Option<&Vec<Index>> {
pub fn series_to_indexes(&self, series: &SeriesName) -> Option<&Vec<Index>> {
self.series_to_indexes
.get(series.replace("-", "_").as_str())
}

View File

@@ -26,7 +26,8 @@ pub fn main() -> color_eyre::Result<()> {
let output_paths = brk_bindgen::ClientOutputPaths::new()
.rust(workspace_root.join("crates/brk_client/src/lib.rs"))
.javascript(workspace_root.join("website/scripts/modules/brk-client/index.js"));
.javascript(workspace_root.join("website/scripts/modules/brk-client/index.js"))
.python(workspace_root.join("packages/brk_client/brk_client/__init__.py"));
generate_bindings(&vecs, &openapi, &output_paths)?;

View File

@@ -5,7 +5,8 @@ use axum::{
};
use brk_query::BLOCK_TXS_PAGE_SIZE;
use brk_types::{
BlockInfo, BlockInfoV1, BlockStatus, BlockTimestamp, Transaction, TxIndex, Txid, Version,
BlockHash, BlockInfo, BlockInfoV1, BlockStatus, BlockTimestamp, Height, Hex, Transaction,
TxIndex, Txid, Version,
};
use crate::{
@@ -82,7 +83,7 @@ impl BlockRoutes for ApiRouter<AppState> {
.blocks_tag()
.summary("Block header")
.description("Returns the hex-encoded 80-byte block header.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-header)*")
.text_response()
.text_response::<Hex>()
.not_modified()
.bad_request()
.not_found()
@@ -106,7 +107,7 @@ impl BlockRoutes for ApiRouter<AppState> {
.description(
"Retrieve the block hash at a given height. Returns the hash as plain text.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)*",
)
.text_response()
.text_response::<BlockHash>()
.not_modified()
.bad_request()
.not_found()
@@ -196,7 +197,7 @@ impl BlockRoutes for ApiRouter<AppState> {
.blocks_tag()
.summary("Block tip height")
.description("Returns the height of the last block.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-height)*")
.text_response()
.text_response::<Height>()
.not_modified()
.server_error()
},
@@ -213,7 +214,7 @@ impl BlockRoutes for ApiRouter<AppState> {
.blocks_tag()
.summary("Block tip hash")
.description("Returns the hash of the last block.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-hash)*")
.text_response()
.text_response::<BlockHash>()
.not_modified()
.server_error()
},
@@ -236,7 +237,7 @@ impl BlockRoutes for ApiRouter<AppState> {
.description(
"Retrieve a single transaction ID at a specific index within a block. Returns plain text txid.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-id)*",
)
.text_response()
.text_response::<Txid>()
.not_modified()
.bad_request()
.not_found()

View File

@@ -3,7 +3,7 @@ use axum::{
extract::State,
http::{HeaderMap, Uri},
};
use brk_types::{Dollars, MempoolInfo, MempoolRecentTx, Txid};
use brk_types::{Dollars, MempoolInfo, MempoolRecentTx, ReplacementNode, Txid};
use crate::{AppState, extended::TransformResponseExtended, params::Empty};
@@ -70,6 +70,48 @@ impl MempoolRoutes for ApiRouter<AppState> {
},
),
)
.api_route(
"/api/v1/replacements",
get_with(
async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State<AppState>| {
state
.respond_json(&headers, state.mempool_strategy(), &uri, |q| {
q.recent_replacements(false)
})
.await
},
|op| {
op.id("get_replacements")
.mempool_tag()
.summary("Recent RBF replacements")
.description("Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)*")
.json_response::<Vec<ReplacementNode>>()
.not_modified()
.server_error()
},
),
)
.api_route(
"/api/v1/fullrbf/replacements",
get_with(
async |uri: Uri, headers: HeaderMap, _: Empty, State(state): State<AppState>| {
state
.respond_json(&headers, state.mempool_strategy(), &uri, |q| {
q.recent_replacements(true)
})
.await
},
|op| {
op.id("get_fullrbf_replacements")
.mempool_tag()
.summary("Recent full-RBF replacements")
.description("Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)*")
.json_response::<Vec<ReplacementNode>>()
.not_modified()
.server_error()
},
),
)
.api_route(
"/api/mempool/price",
get_with(

View File

@@ -34,7 +34,7 @@ impl ServerRoutes for ApiRouter<AppState> {
.client()
.get_last_height()
.unwrap_or(q.indexed_height());
Ok(q.sync_status(tip_height))
q.sync_status(tip_height)
})
.await
.expect("health sync task panicked");
@@ -89,7 +89,7 @@ impl ServerRoutes for ApiRouter<AppState> {
state
.respond_json(&headers, CacheStrategy::Tip, &uri, move |q| {
let tip_height = q.client().get_last_height()?;
Ok(q.sync_status(tip_height))
q.sync_status(tip_height)
})
.await
},

View File

@@ -8,7 +8,7 @@ use axum::{
response::Response,
};
use brk_types::{
CpfpInfo, MerkleProof, RbfResponse, Transaction, TxOutspend, TxStatus, Txid, Version,
CpfpInfo, Hex, MerkleProof, RbfResponse, Transaction, TxOutspend, TxStatus, Txid, Version,
};
use crate::{
@@ -35,7 +35,7 @@ impl TxRoutes for ApiRouter<AppState> {
.transactions_tag()
.summary("Txid by index")
.description("Retrieve the transaction ID (txid) at a given global transaction index. Returns the txid as plain text.")
.text_response()
.text_response::<Txid>()
.not_modified()
.bad_request()
.not_found()
@@ -123,7 +123,7 @@ impl TxRoutes for ApiRouter<AppState> {
.description(
"Retrieve the raw transaction as a hex-encoded string. Returns the serialized transaction in hexadecimal format.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-hex)*",
)
.text_response()
.text_response::<Hex>()
.not_modified()
.bad_request()
.not_found()
@@ -141,7 +141,7 @@ impl TxRoutes for ApiRouter<AppState> {
.transactions_tag()
.summary("Transaction merkleblock proof")
.description("Get the merkleblock proof for a transaction (BIP37 format, hex encoded).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-merkleblock-proof)*")
.text_response()
.text_response::<Hex>()
.not_modified()
.bad_request()
.not_found()
@@ -281,9 +281,7 @@ impl TxRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/transaction-times",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| -> Result<Response> {
let params = TxidsParam::from_query(uri.query().unwrap_or(""))
.map_err(Error::bad_request)?;
async |uri: Uri, headers: HeaderMap, params: TxidsParam, State(state): State<AppState>| -> Result<Response> {
Ok(state.respond_json(&headers, state.mempool_strategy(), &uri, move |q| q.transaction_times(&params.txids)).await)
},
|op| op

View File

@@ -1,7 +1,9 @@
mod header_map;
mod response;
mod transform_operation;
mod typed_text;
pub use header_map::*;
pub use response::*;
pub use transform_operation::*;
pub use typed_text::*;

View File

@@ -3,7 +3,7 @@ use aide::transform::{TransformOperation, TransformResponse};
use axum::Json;
use schemars::JsonSchema;
use crate::error::ErrorBody;
use crate::{error::ErrorBody, extended::TypedText};
pub trait TransformResponseExtended<'t> {
fn general_tag(self) -> Self;
@@ -30,8 +30,10 @@ pub trait TransformResponseExtended<'t> {
where
R: JsonSchema,
F: FnOnce(TransformResponse<'_, R>) -> TransformResponse<'_, R>;
/// 200 with text/plain content type
fn text_response(self) -> Self;
/// 200 with text/plain content type whose body parses as `T`
fn text_response<T>(self) -> Self
where
T: JsonSchema;
/// 200 with application/octet-stream content type
fn binary_response(self) -> Self;
/// 200 with text/csv content type (adds CSV as alternative response format)
@@ -111,8 +113,11 @@ impl<'t> TransformResponseExtended<'t> for TransformOperation<'t> {
self.response_with::<200, Json<R>, _>(|res| f(res.description("Successful response")))
}
fn text_response(self) -> Self {
self.response_with::<200, String, _>(|res| res.description("Successful response"))
fn text_response<T>(self) -> Self
where
T: JsonSchema,
{
self.response_with::<200, TypedText<T>, _>(|res| res.description("Successful response"))
}
fn binary_response(self) -> Self {

View File

@@ -0,0 +1,49 @@
use std::marker::PhantomData;
use aide::{
OperationOutput,
openapi::{MediaType, Operation, Response, SchemaObject, StatusCode},
};
use schemars::JsonSchema;
/// `text/plain` response whose body parses as `T`.
///
/// Used purely for OpenAPI metadata: handlers still return `String`,
/// but the schema advertises `T`'s shape so generated SDKs can decode.
pub struct TypedText<T>(PhantomData<T>);
impl<T: JsonSchema> OperationOutput for TypedText<T> {
type Inner = Self;
fn operation_response(
ctx: &mut aide::generate::GenContext,
_operation: &mut Operation,
) -> Option<Response> {
let json_schema = ctx.schema.subschema_for::<T>();
Some(Response {
description: "plain text".into(),
content: [(
"text/plain; charset=utf-8".into(),
MediaType {
schema: Some(SchemaObject {
json_schema,
example: None,
external_docs: None,
}),
..Default::default()
},
)]
.into(),
..Default::default()
})
}
fn inferred_responses(
ctx: &mut aide::generate::GenContext,
operation: &mut Operation,
) -> Vec<(Option<StatusCode>, Response)> {
Self::operation_response(ctx, operation)
.map(|r| vec![(Some(StatusCode::Code(200)), r)])
.unwrap_or_default()
}
}

View File

@@ -1,14 +1,27 @@
use std::str::FromStr;
use aide::{
OperationInput,
operation::{ParamLocation, add_parameters, parameters_from_schema},
};
use axum::{extract::FromRequestParts, http::request::Parts};
use schemars::JsonSchema;
use brk_types::Txid;
use crate::Error;
const MAX_TXIDS: usize = 250;
/// Query parameter for transaction-times endpoint.
///
/// Extracted manually because `serde_urlencoded` (and serde derive in general)
/// doesn't support repeated keys like `txId[]=a&txId[]=b`. The schema is still
/// declared via `JsonSchema` so the OpenAPI spec lists the parameter and the
/// generated client SDKs see `txids: List[Txid]`.
#[derive(JsonSchema)]
pub struct TxidsParam {
/// Transaction IDs to look up (max 250 per request).
#[serde(rename = "txId[]")]
pub txids: Vec<Txid>,
}
@@ -41,6 +54,28 @@ impl TxidsParam {
}
}
impl<S> FromRequestParts<S> for TxidsParam
where
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Self::from_query(parts.uri.query().unwrap_or("")).map_err(Error::bad_request)
}
}
impl OperationInput for TxidsParam {
fn operation_input(
ctx: &mut aide::generate::GenContext,
operation: &mut aide::openapi::Operation,
) {
let schema = ctx.schema.subschema_for::<Self>();
let params = parameters_from_schema(ctx, schema, ParamLocation::Query);
add_parameters(ctx, operation, params);
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -25,6 +25,7 @@ impl Pagination {
pub fn per_page(&self) -> usize {
self.per_page
.filter(|&n| n > 0)
.unwrap_or(Self::DEFAULT_PER_PAGE)
.min(Self::MAX_PER_PAGE)
}

View File

@@ -21,6 +21,11 @@ pub struct RbfTx {
/// this tx displaced at least one non-signaling predecessor.
#[serde(rename = "fullRbf", skip_serializing_if = "Option::is_none", default)]
pub full_rbf: Option<bool>,
/// `Some(true)` iff the tx is currently confirmed in the indexed
/// chain. Absent on serialization when the tx is still pending or
/// has been evicted without confirming.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub mined: Option<bool>,
}
/// One node in an RBF replacement tree. The node's `tx` replaced each
@@ -38,6 +43,10 @@ pub struct ReplacementNode {
/// replaced it. Omitted on the root of an RBF response.
#[serde(skip_serializing_if = "Option::is_none")]
pub interval: Option<u32>,
/// `Some(true)` iff this node's tx is currently confirmed. Absent
/// on serialization otherwise.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub mined: Option<bool>,
pub replaces: Vec<ReplacementNode>,
}